Skip to content

Commit d3a1a5d

Browse files
authored
Fix to detect venv with pipenv installed in it as pipenv environment (#244)
1 parent 363e6a5 commit d3a1a5d

File tree

2 files changed

+143
-8
lines changed

2 files changed

+143
-8
lines changed

.github/workflows/pr-check.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ jobs:
134134
if: startsWith( matrix.os, 'ubuntu') || startsWith( matrix.os, 'macos')
135135
run: |
136136
pyenv install --list
137-
pyenv install 3.13:latest 3.12:latest 3.8:latest
137+
pyenv install 3.13:latest 3.12:latest 3.9:latest
138138
shell: bash
139139

140140
# pyenv-win install list has not updated for a while

crates/pet-pipenv/src/lib.rs

Lines changed: 142 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,22 +20,58 @@ mod env_variables;
2020

2121
fn get_pipenv_project(env: &PythonEnv) -> Option<PathBuf> {
2222
if let Some(prefix) = &env.prefix {
23-
get_pipenv_project_from_prefix(prefix)
24-
} else {
25-
// If the parent is bin or script, then get the parent.
26-
let bin = env.executable.parent()?;
27-
if bin.file_name().unwrap_or_default() == Path::new("bin")
23+
if let Some(project) = get_pipenv_project_from_prefix(prefix) {
24+
return Some(project);
25+
}
26+
// If there's no .project file, but the venv lives inside the project folder
27+
// (e.g., <project>/.venv or <project>/venv), then the project is the parent
28+
// directory of the venv. Detect that by checking for a Pipfile next to the venv.
29+
if let Some(parent) = prefix.parent() {
30+
let project_folder = parent;
31+
if project_folder.join("Pipfile").exists() {
32+
return Some(project_folder.to_path_buf());
33+
}
34+
}
35+
}
36+
37+
// We can also have a venv in the workspace that has pipenv installed in it.
38+
// In such cases, the project is the workspace folder containing the venv.
39+
// Derive the project folder from the executable path when prefix isn't available.
40+
// Typical layout: <project>/.venv/{bin|Scripts}/python
41+
// So walk up to {bin|Scripts} -> venv dir -> project dir and check for Pipfile.
42+
if let Some(bin) = env.executable.parent() {
43+
let venv_dir = if bin.file_name().unwrap_or_default() == Path::new("bin")
2844
|| bin.file_name().unwrap_or_default() == Path::new("Scripts")
2945
{
30-
get_pipenv_project_from_prefix(env.executable.parent()?.parent()?)
46+
bin.parent()
3147
} else {
32-
get_pipenv_project_from_prefix(env.executable.parent()?)
48+
Some(bin)
49+
};
50+
if let Some(venv_dir) = venv_dir {
51+
if let Some(project_dir) = venv_dir.parent() {
52+
if project_dir.join("Pipfile").exists() {
53+
return Some(project_dir.to_path_buf());
54+
}
55+
}
3356
}
3457
}
58+
59+
// If the parent is bin or script, then get the parent.
60+
let bin = env.executable.parent()?;
61+
if bin.file_name().unwrap_or_default() == Path::new("bin")
62+
|| bin.file_name().unwrap_or_default() == Path::new("Scripts")
63+
{
64+
get_pipenv_project_from_prefix(env.executable.parent()?.parent()?)
65+
} else {
66+
get_pipenv_project_from_prefix(env.executable.parent()?)
67+
}
3568
}
3669

3770
fn get_pipenv_project_from_prefix(prefix: &Path) -> Option<PathBuf> {
3871
let project_file = prefix.join(".project");
72+
if !project_file.exists() {
73+
return None;
74+
}
3975
let contents = fs::read_to_string(project_file).ok()?;
4076
let project_folder = norm_case(PathBuf::from(contents.trim().to_string()));
4177
if project_folder.exists() {
@@ -45,12 +81,44 @@ fn get_pipenv_project_from_prefix(prefix: &Path) -> Option<PathBuf> {
4581
}
4682
}
4783

84+
fn is_pipenv_from_project(env: &PythonEnv) -> bool {
85+
// If the env prefix is inside a project folder, check that folder for a Pipfile.
86+
if let Some(prefix) = &env.prefix {
87+
if let Some(project_dir) = prefix.parent() {
88+
if project_dir.join("Pipfile").exists() {
89+
return true;
90+
}
91+
}
92+
}
93+
// Derive from the executable path as a fallback.
94+
if let Some(bin) = env.executable.parent() {
95+
let venv_dir = if bin.file_name().unwrap_or_default() == Path::new("bin")
96+
|| bin.file_name().unwrap_or_default() == Path::new("Scripts")
97+
{
98+
bin.parent()
99+
} else {
100+
Some(bin)
101+
};
102+
if let Some(venv_dir) = venv_dir {
103+
if let Some(project_dir) = venv_dir.parent() {
104+
if project_dir.join("Pipfile").exists() {
105+
return true;
106+
}
107+
}
108+
}
109+
}
110+
false
111+
}
112+
48113
fn is_pipenv(env: &PythonEnv, env_vars: &EnvVariables) -> bool {
49114
if let Some(project_path) = get_pipenv_project(env) {
50115
if project_path.join(env_vars.pipenv_pipfile.clone()).exists() {
51116
return true;
52117
}
53118
}
119+
if is_pipenv_from_project(env) {
120+
return true;
121+
}
54122
// If we have a Pipfile, then this is a pipenv environment.
55123
// Else likely a virtualenvwrapper or the like.
56124
if let Some(project_path) = get_pipenv_project(env) {
@@ -119,3 +187,70 @@ impl Locator for PipEnv {
119187
//
120188
}
121189
}
190+
191+
#[cfg(test)]
192+
mod tests {
193+
use super::*;
194+
use std::time::{SystemTime, UNIX_EPOCH};
195+
196+
fn unique_temp_dir() -> PathBuf {
197+
let mut dir = std::env::temp_dir();
198+
let nanos = SystemTime::now()
199+
.duration_since(UNIX_EPOCH)
200+
.unwrap()
201+
.as_nanos();
202+
dir.push(format!("pet_pipenv_test_{}", nanos));
203+
dir
204+
}
205+
206+
#[test]
207+
fn infer_project_for_venv_in_project() {
208+
let project_dir = unique_temp_dir();
209+
let venv_dir = project_dir.join(".venv");
210+
let bin_dir = if cfg!(windows) {
211+
venv_dir.join("Scripts")
212+
} else {
213+
venv_dir.join("bin")
214+
};
215+
let python_exe = if cfg!(windows) {
216+
bin_dir.join("python.exe")
217+
} else {
218+
bin_dir.join("python")
219+
};
220+
221+
// Create directories and files
222+
std::fs::create_dir_all(&bin_dir).unwrap();
223+
std::fs::write(project_dir.join("Pipfile"), b"[[source]]\n").unwrap();
224+
// Touch python exe file
225+
std::fs::write(&python_exe, b"").unwrap();
226+
// Touch pyvenv.cfg in venv root so PythonEnv::new logic would normally detect prefix
227+
std::fs::write(venv_dir.join("pyvenv.cfg"), b"version = 3.12.0\n").unwrap();
228+
229+
// Construct PythonEnv directly
230+
let env = PythonEnv {
231+
executable: norm_case(python_exe.clone()),
232+
prefix: Some(norm_case(venv_dir.clone())),
233+
version: None,
234+
symlinks: None,
235+
};
236+
237+
// Validate helper infers project
238+
let inferred = get_pipenv_project(&env).expect("expected project path");
239+
assert_eq!(inferred, norm_case(project_dir.clone()));
240+
241+
// Validate locator populates project
242+
let locator = PipEnv {
243+
env_vars: EnvVariables {
244+
pipenv_max_depth: 3,
245+
pipenv_pipfile: "Pipfile".to_string(),
246+
},
247+
};
248+
let result = locator
249+
.try_from(&env)
250+
.expect("expected locator to return environment");
251+
assert_eq!(result.project, Some(norm_case(project_dir.clone())));
252+
253+
// Cleanup
254+
std::fs::remove_dir_all(&project_dir).ok();
255+
}
256+
}

0 commit comments

Comments
 (0)