diff --git a/crates/pet-conda/tests/ci_test.rs b/crates/pet-conda/tests/ci_test.rs index 03cb4d33..eb4174c5 100644 --- a/crates/pet-conda/tests/ci_test.rs +++ b/crates/pet-conda/tests/ci_test.rs @@ -269,9 +269,7 @@ fn detect_new_conda_env_created_with_p_flag_without_python() { let env = environments .iter() .find(|x| x.prefix == Some(prefix.clone())) - .unwrap_or_else(|| { - panic!("New Environment ({prefix:?}) not created, detected envs {environments:?}") - }); + .unwrap_or_else(|| panic!("New Environment ({prefix:?}) not created, detected envs {environments:?}")); assert_eq!(env.prefix, prefix.clone().into()); assert_eq!(env.name, None); @@ -387,7 +385,7 @@ fn create_conda_env(mode: &CondaCreateEnvNameOrPath, python_version: Option String { +fn get_version(value: &str) -> String { // Regex to extract just the d.d.d version from the full version string let re = regex::Regex::new(r"\d+\.\d+\.\d+").unwrap(); let captures = re.captures(value).unwrap(); diff --git a/crates/pet-conda/tests/common.rs b/crates/pet-conda/tests/common.rs index 1a282083..fdc752c2 100644 --- a/crates/pet-conda/tests/common.rs +++ b/crates/pet-conda/tests/common.rs @@ -46,6 +46,22 @@ pub struct TestEnvironment { root: Option, globals_locations: Vec, } + +impl Environment for TestEnvironment { + fn get_env_var(&self, key: String) -> Option { + self.vars.get(&key).cloned() + } + fn get_root(&self) -> Option { + self.root.clone() + } + fn get_user_home(&self) -> Option { + self.home.clone() + } + fn get_know_global_search_locations(&self) -> Vec { + self.globals_locations.clone() + } +} + #[allow(dead_code)] pub fn create_test_environment( vars: HashMap, @@ -53,20 +69,6 @@ pub fn create_test_environment( globals_locations: Vec, root: Option, ) -> TestEnvironment { - impl Environment for TestEnvironment { - fn get_env_var(&self, key: String) -> Option { - self.vars.get(&key).cloned() - } - fn get_root(&self) -> Option { - self.root.clone() - } - fn get_user_home(&self) -> Option { - self.home.clone() - } - fn get_know_global_search_locations(&self) -> Vec { - self.globals_locations.clone() - } - } TestEnvironment { vars, home, diff --git a/crates/pet-core/src/python_environment.rs b/crates/pet-core/src/python_environment.rs index 9531a70a..d52d9baf 100644 --- a/crates/pet-core/src/python_environment.rs +++ b/crates/pet-core/src/python_environment.rs @@ -416,6 +416,7 @@ pub fn get_environment_key(env: &PythonEnvironment) -> Option { #[cfg(test)] mod tests { + #[cfg(windows)] use super::*; #[test] diff --git a/crates/pet-pipenv/src/lib.rs b/crates/pet-pipenv/src/lib.rs index b727a11d..e5e6f353 100644 --- a/crates/pet-pipenv/src/lib.rs +++ b/crates/pet-pipenv/src/lib.rs @@ -20,22 +20,58 @@ mod env_variables; fn get_pipenv_project(env: &PythonEnv) -> Option { if let Some(prefix) = &env.prefix { - get_pipenv_project_from_prefix(prefix) - } else { - // If the parent is bin or script, then get the parent. - let bin = env.executable.parent()?; - if bin.file_name().unwrap_or_default() == Path::new("bin") + if let Some(project) = get_pipenv_project_from_prefix(prefix) { + return Some(project); + } + // If there's no .project file, but the venv lives inside the project folder + // (e.g., /.venv or /venv), then the project is the parent + // directory of the venv. Detect that by checking for a Pipfile next to the venv. + if let Some(parent) = prefix.parent() { + let project_folder = parent; + if project_folder.join("Pipfile").exists() { + return Some(project_folder.to_path_buf()); + } + } + } + + // We can also have a venv in the workspace that has pipenv installed in it. + // In such cases, the project is the workspace folder containing the venv. + // Derive the project folder from the executable path when prefix isn't available. + // Typical layout: /.venv/{bin|Scripts}/python + // So walk up to {bin|Scripts} -> venv dir -> project dir and check for Pipfile. + if let Some(bin) = env.executable.parent() { + let venv_dir = if bin.file_name().unwrap_or_default() == Path::new("bin") || bin.file_name().unwrap_or_default() == Path::new("Scripts") { - get_pipenv_project_from_prefix(env.executable.parent()?.parent()?) + bin.parent() } else { - get_pipenv_project_from_prefix(env.executable.parent()?) + Some(bin) + }; + if let Some(venv_dir) = venv_dir { + if let Some(project_dir) = venv_dir.parent() { + if project_dir.join("Pipfile").exists() { + return Some(project_dir.to_path_buf()); + } + } } } + + // If the parent is bin or script, then get the parent. + let bin = env.executable.parent()?; + if bin.file_name().unwrap_or_default() == Path::new("bin") + || bin.file_name().unwrap_or_default() == Path::new("Scripts") + { + get_pipenv_project_from_prefix(env.executable.parent()?.parent()?) + } else { + get_pipenv_project_from_prefix(env.executable.parent()?) + } } fn get_pipenv_project_from_prefix(prefix: &Path) -> Option { let project_file = prefix.join(".project"); + if !project_file.exists() { + return None; + } let contents = fs::read_to_string(project_file).ok()?; let project_folder = norm_case(PathBuf::from(contents.trim().to_string())); if project_folder.exists() { @@ -45,12 +81,44 @@ fn get_pipenv_project_from_prefix(prefix: &Path) -> Option { } } +fn is_pipenv_from_project(env: &PythonEnv) -> bool { + // If the env prefix is inside a project folder, check that folder for a Pipfile. + if let Some(prefix) = &env.prefix { + if let Some(project_dir) = prefix.parent() { + if project_dir.join("Pipfile").exists() { + return true; + } + } + } + // Derive from the executable path as a fallback. + if let Some(bin) = env.executable.parent() { + let venv_dir = if bin.file_name().unwrap_or_default() == Path::new("bin") + || bin.file_name().unwrap_or_default() == Path::new("Scripts") + { + bin.parent() + } else { + Some(bin) + }; + if let Some(venv_dir) = venv_dir { + if let Some(project_dir) = venv_dir.parent() { + if project_dir.join("Pipfile").exists() { + return true; + } + } + } + } + false +} + fn is_pipenv(env: &PythonEnv, env_vars: &EnvVariables) -> bool { if let Some(project_path) = get_pipenv_project(env) { if project_path.join(env_vars.pipenv_pipfile.clone()).exists() { return true; } } + if is_pipenv_from_project(env) { + return true; + } // If we have a Pipfile, then this is a pipenv environment. // Else likely a virtualenvwrapper or the like. if let Some(project_path) = get_pipenv_project(env) { @@ -119,3 +187,70 @@ impl Locator for PipEnv { // } } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn unique_temp_dir() -> PathBuf { + let mut dir = std::env::temp_dir(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + dir.push(format!("pet_pipenv_test_{}", nanos)); + dir + } + + #[test] + fn infer_project_for_venv_in_project() { + let project_dir = unique_temp_dir(); + let venv_dir = project_dir.join(".venv"); + let bin_dir = if cfg!(windows) { + venv_dir.join("Scripts") + } else { + venv_dir.join("bin") + }; + let python_exe = if cfg!(windows) { + bin_dir.join("python.exe") + } else { + bin_dir.join("python") + }; + + // Create directories and files + std::fs::create_dir_all(&bin_dir).unwrap(); + std::fs::write(project_dir.join("Pipfile"), b"[[source]]\n").unwrap(); + // Touch python exe file + std::fs::write(&python_exe, b"").unwrap(); + // Touch pyvenv.cfg in venv root so PythonEnv::new logic would normally detect prefix + std::fs::write(venv_dir.join("pyvenv.cfg"), b"version = 3.12.0\n").unwrap(); + + // Construct PythonEnv directly + let env = PythonEnv { + executable: norm_case(python_exe.clone()), + prefix: Some(norm_case(venv_dir.clone())), + version: None, + symlinks: None, + }; + + // Validate helper infers project + let inferred = get_pipenv_project(&env).expect("expected project path"); + assert_eq!(inferred, norm_case(project_dir.clone())); + + // Validate locator populates project + let locator = PipEnv { + env_vars: EnvVariables { + pipenv_max_depth: 3, + pipenv_pipfile: "Pipfile".to_string(), + }, + }; + let result = locator + .try_from(&env) + .expect("expected locator to return environment"); + assert_eq!(result.project, Some(norm_case(project_dir.clone()))); + + // Cleanup + std::fs::remove_dir_all(&project_dir).ok(); + } +} diff --git a/crates/pet-poetry/tests/common.rs b/crates/pet-poetry/tests/common.rs index 4432561b..85676686 100644 --- a/crates/pet-poetry/tests/common.rs +++ b/crates/pet-poetry/tests/common.rs @@ -35,25 +35,27 @@ pub struct TestEnvironment { home: Option, root: Option, } + +impl Environment for TestEnvironment { + fn get_env_var(&self, key: String) -> Option { + self.vars.get(&key).cloned() + } + fn get_root(&self) -> Option { + self.root.clone() + } + fn get_user_home(&self) -> Option { + self.home.clone() + } + fn get_know_global_search_locations(&self) -> Vec { + vec![] + } +} + #[allow(dead_code)] pub fn create_test_environment( vars: HashMap, home: Option, root: Option, ) -> TestEnvironment { - impl Environment for TestEnvironment { - fn get_env_var(&self, key: String) -> Option { - self.vars.get(&key).cloned() - } - fn get_root(&self) -> Option { - self.root.clone() - } - fn get_user_home(&self) -> Option { - self.home.clone() - } - fn get_know_global_search_locations(&self) -> Vec { - vec![] - } - } TestEnvironment { vars, home, root } } diff --git a/crates/pet-poetry/tests/config_test.rs b/crates/pet-poetry/tests/config_test.rs index 92172b6c..c1d203cd 100644 --- a/crates/pet-poetry/tests/config_test.rs +++ b/crates/pet-poetry/tests/config_test.rs @@ -4,7 +4,7 @@ mod common; #[cfg(unix)] -#[cfg_attr(any(feature = "ci",), test)] +#[test] #[allow(dead_code)] fn global_config_with_defaults() { use common::create_env_variables; @@ -37,7 +37,7 @@ fn global_config_with_defaults() { } #[cfg(unix)] -#[cfg_attr(any(feature = "ci",), test)] +#[test] #[allow(dead_code)] fn global_config_with_specific_values() { use std::path::PathBuf; @@ -81,9 +81,8 @@ fn global_config_with_specific_values() { } #[cfg(unix)] -#[cfg_attr(any(feature = "ci",), test)] +#[test] #[allow(dead_code)] - fn local_config_with_specific_values() { use std::path::PathBuf; diff --git a/crates/pet-pyenv/tests/common.rs b/crates/pet-pyenv/tests/common.rs index a6ab9d79..69ed9883 100644 --- a/crates/pet-pyenv/tests/common.rs +++ b/crates/pet-pyenv/tests/common.rs @@ -34,6 +34,22 @@ pub struct TestEnvironment { root: Option, globals_locations: Vec, } + +impl Environment for TestEnvironment { + fn get_env_var(&self, key: String) -> Option { + self.vars.get(&key).cloned() + } + fn get_root(&self) -> Option { + self.root.clone() + } + fn get_user_home(&self) -> Option { + self.home.clone() + } + fn get_know_global_search_locations(&self) -> Vec { + self.globals_locations.clone() + } +} + #[allow(dead_code)] pub fn create_test_environment( vars: HashMap, @@ -41,20 +57,6 @@ pub fn create_test_environment( globals_locations: Vec, root: Option, ) -> TestEnvironment { - impl Environment for TestEnvironment { - fn get_env_var(&self, key: String) -> Option { - self.vars.get(&key).cloned() - } - fn get_root(&self) -> Option { - self.root.clone() - } - fn get_user_home(&self) -> Option { - self.home.clone() - } - fn get_know_global_search_locations(&self) -> Vec { - self.globals_locations.clone() - } - } TestEnvironment { vars, home, diff --git a/crates/pet-pyenv/tests/pyenv_test.rs b/crates/pet-pyenv/tests/pyenv_test.rs index 210d42e3..33b461c7 100644 --- a/crates/pet-pyenv/tests/pyenv_test.rs +++ b/crates/pet-pyenv/tests/pyenv_test.rs @@ -164,7 +164,6 @@ fn find_pyenv_envs() { home.to_str().unwrap(), ".pyenv/versions/3.9.9/bin/python", ])]), - ..Default::default() }; let expected_virtual_env = PythonEnvironment { display_name: None, @@ -186,7 +185,6 @@ fn find_pyenv_envs() { home.to_str().unwrap(), ".pyenv/versions/my-virtual-env/bin/python", ])]), - ..Default::default() }; let expected_3_12_1 = PythonEnvironment { display_name: None, @@ -208,7 +206,6 @@ fn find_pyenv_envs() { home.to_str().unwrap(), ".pyenv/versions/3.12.1/bin/python", ])]), - ..Default::default() }; let expected_3_13_dev = PythonEnvironment { display_name: None, @@ -230,7 +227,6 @@ fn find_pyenv_envs() { home.to_str().unwrap(), ".pyenv/versions/3.13-dev/bin/python", ])]), - ..Default::default() }; let expected_3_12_1a3 = PythonEnvironment { display_name: None, @@ -252,7 +248,6 @@ fn find_pyenv_envs() { home.to_str().unwrap(), ".pyenv/versions/3.12.1a3/bin/python", ])]), - ..Default::default() }; let expected_no_gil = PythonEnvironment { display_name: None, @@ -274,7 +269,6 @@ fn find_pyenv_envs() { home.to_str().unwrap(), ".pyenv/versions/nogil-3.9.10-1/bin/python", ])]), - ..Default::default() }; let expected_pypy = PythonEnvironment { display_name: None, @@ -296,7 +290,6 @@ fn find_pyenv_envs() { home.to_str().unwrap(), ".pyenv/versions/pypy3.9-7.3.15/bin/python", ])]), - ..Default::default() }; let expected_conda_root = PythonEnvironment { @@ -310,7 +303,6 @@ fn find_pyenv_envs() { manager: Some(expected_conda_manager.clone()), arch: Some(Architecture::X64), symlinks: Some(vec![conda_dir.join("bin").join("python")]), - ..Default::default() }; let expected_conda_one = PythonEnvironment { display_name: None, @@ -323,7 +315,6 @@ fn find_pyenv_envs() { manager: Some(expected_conda_manager.clone()), arch: None, symlinks: Some(vec![conda_dir.join("envs").join("one").join("python")]), - ..Default::default() }; let expected_conda_two = PythonEnvironment { display_name: None, @@ -336,7 +327,6 @@ fn find_pyenv_envs() { manager: Some(expected_conda_manager.clone()), symlinks: Some(vec![conda_dir.join("envs").join("two").join("python")]), arch: None, - ..Default::default() }; let mut expected_envs = vec![ @@ -406,7 +396,6 @@ fn resolve_pyenv_environment() { manager: Some(expected_manager.clone()), arch: None, symlinks: Some(vec![executable]), - ..Default::default() }; let expected_virtual_env = PythonEnvironment { display_name: None, @@ -428,7 +417,6 @@ fn resolve_pyenv_environment() { home.to_str().unwrap(), ".pyenv/versions/my-virtual-env/bin/python", ])]), - ..Default::default() }; // Resolve regular Python installs in Pyenv diff --git a/crates/pet/tests/ci_poetry.rs b/crates/pet/tests/ci_poetry.rs index 8c14d3b0..277c07a0 100644 --- a/crates/pet/tests/ci_poetry.rs +++ b/crates/pet/tests/ci_poetry.rs @@ -40,8 +40,10 @@ fn verify_ci_poetry_global() { let environment = EnvironmentApi::new(); let conda_locator = Arc::new(Conda::from(&environment)); let poetry_locator = Arc::new(Poetry::from(&environment)); - let mut config = Configuration::default(); - config.workspace_directories = Some(vec![workspace_dir.clone()]); + let config = Configuration { + workspace_directories: Some(vec![workspace_dir.clone()]), + ..Default::default() + }; let locators = create_locators(conda_locator.clone(), poetry_locator.clone(), &environment); for locator in locators.iter() { locator.configure(&config); @@ -110,8 +112,10 @@ fn verify_ci_poetry_project() { let environment = EnvironmentApi::new(); let conda_locator = Arc::new(Conda::from(&environment)); let poetry_locator = Arc::new(Poetry::from(&environment)); - let mut config = Configuration::default(); - config.workspace_directories = Some(vec![workspace_dir.clone()]); + let config = Configuration { + workspace_directories: Some(vec![workspace_dir.clone()]), + ..Default::default() + }; let locators = create_locators(conda_locator.clone(), poetry_locator.clone(), &environment); for locator in locators.iter() { locator.configure(&config); diff --git a/crates/pet/tests/ci_test.rs b/crates/pet/tests/ci_test.rs index 7c315efa..7c2a27fa 100644 --- a/crates/pet/tests/ci_test.rs +++ b/crates/pet/tests/ci_test.rs @@ -75,8 +75,10 @@ fn verify_validity_of_discovered_envs() { let environment = EnvironmentApi::new(); let conda_locator = Arc::new(Conda::from(&environment)); let poetry_locator = Arc::new(Poetry::from(&environment)); - let mut config = Configuration::default(); - config.workspace_directories = Some(vec![workspace_dir.clone()]); + let config = Configuration { + workspace_directories: Some(vec![workspace_dir.clone()]), + ..Default::default() + }; let locators = create_locators(conda_locator.clone(), poetry_locator.clone(), &environment); for locator in locators.iter() { locator.configure(&config); @@ -283,19 +285,16 @@ fn verify_validity_of_interpreter_info(environment: PythonEnvironment) { } } if let Some(prefix) = environment.clone().prefix { - if interpreter_info.clone().executable == "/usr/local/python/current/bin/python" + if (interpreter_info.clone().executable == "/usr/local/python/current/bin/python" && (prefix.to_str().unwrap() == "/usr/local/python/current" && interpreter_info.clone().sys_prefix == "/usr/local/python/3.10.13") || (prefix.to_str().unwrap() == "/usr/local/python/3.10.13" - && interpreter_info.clone().sys_prefix == "/usr/local/python/current") - { - // known issue https://github.com/microsoft/python-environment-tools/issues/64 - } else if interpreter_info.clone().executable - == "/home/codespace/.python/current/bin/python" - && (prefix.to_str().unwrap() == "/home/codespace/.python/current" - && interpreter_info.clone().sys_prefix == "/usr/local/python/3.10.13") - || (prefix.to_str().unwrap() == "/usr/local/python/3.10.13" - && interpreter_info.clone().sys_prefix == "/home/codespace/.python/current") + && interpreter_info.clone().sys_prefix == "/usr/local/python/current")) + || (interpreter_info.clone().executable == "/home/codespace/.python/current/bin/python" + && (prefix.to_str().unwrap() == "/home/codespace/.python/current" + && interpreter_info.clone().sys_prefix == "/usr/local/python/3.10.13") + || (prefix.to_str().unwrap() == "/usr/local/python/3.10.13" + && interpreter_info.clone().sys_prefix == "/home/codespace/.python/current")) { // known issue https://github.com/microsoft/python-environment-tools/issues/64 } else { @@ -487,20 +486,19 @@ fn compare_environments(actual: PythonEnvironment, expected: PythonEnvironment, actual.version = expected.clone().version; if let Some(prefix) = expected.clone().prefix { - if actual.clone().executable == Some(PathBuf::from("/usr/local/python/current/bin/python")) + if (actual.clone().executable + == Some(PathBuf::from("/usr/local/python/current/bin/python")) && (prefix.to_str().unwrap() == "/usr/local/python/current" && actual.clone().prefix == Some(PathBuf::from("/usr/local/python/3.10.13"))) || (prefix.to_str().unwrap() == "/usr/local/python/3.10.13" - && actual.clone().prefix == Some(PathBuf::from("/usr/local/python/current"))) - { - // known issue https://github.com/microsoft/python-environment-tools/issues/64 - actual.prefix = expected.clone().prefix; - } else if actual.clone().executable - == Some(PathBuf::from("/home/codespace/.python/current/bin/python")) - && (prefix.to_str().unwrap() == "/home/codespace/.python/current" - && actual.clone().prefix == Some(PathBuf::from("/usr/local/python/3.10.13"))) - || (prefix.to_str().unwrap() == "/usr/local/python/3.10.13" - && actual.clone().prefix == Some(PathBuf::from("/home/codespace/.python/current"))) + && actual.clone().prefix == Some(PathBuf::from("/usr/local/python/current")))) + || (actual.clone().executable + == Some(PathBuf::from("/home/codespace/.python/current/bin/python")) + && (prefix.to_str().unwrap() == "/home/codespace/.python/current" + && actual.clone().prefix == Some(PathBuf::from("/usr/local/python/3.10.13"))) + || (prefix.to_str().unwrap() == "/usr/local/python/3.10.13" + && actual.clone().prefix + == Some(PathBuf::from("/home/codespace/.python/current")))) { // known issue https://github.com/microsoft/python-environment-tools/issues/64 actual.prefix = expected.clone().prefix; @@ -591,8 +589,10 @@ fn verify_we_can_get_same_env_info_using_resolve_with_exe( let os_environment = EnvironmentApi::new(); let conda_locator = Arc::new(Conda::from(&os_environment)); let poetry_locator = Arc::new(Poetry::from(&os_environment)); - let mut config = Configuration::default(); - config.workspace_directories = Some(vec![workspace_dir.clone()]); + let config = Configuration { + workspace_directories: Some(vec![workspace_dir.clone()]), + ..Default::default() + }; let locators = create_locators( conda_locator.clone(), poetry_locator.clone(), @@ -720,13 +720,13 @@ fn get_python_run_command(env: &PythonEnvironment) -> Vec { "python".to_string(), ] } else if let Some(prefix) = env.prefix.clone() { - return vec![ + vec![ conda_exe, "run".to_string(), "-p".to_string(), prefix.to_str().unwrap_or_default().to_string(), "python".to_string(), - ]; + ] } else { panic!("Conda environment without name or prefix") } @@ -741,8 +741,8 @@ fn get_python_run_command(env: &PythonEnvironment) -> Vec { } } -fn get_python_interpreter_info(cli: &Vec) -> InterpreterInfo { - let mut cli = cli.clone(); +fn get_python_interpreter_info(cli: &[String]) -> InterpreterInfo { + let mut cli = cli.to_owned(); cli.push( resolve_test_path(&["interpreterInfo.py"]) .to_str() diff --git a/crates/pet/tests/common.rs b/crates/pet/tests/common.rs index 634fc59d..07d15da9 100644 --- a/crates/pet/tests/common.rs +++ b/crates/pet/tests/common.rs @@ -22,12 +22,12 @@ pub fn resolve_test_path(paths: &[&str]) -> PathBuf { } #[allow(dead_code)] -pub fn does_version_match(version: &String, expected_version: &String) -> bool { +pub fn does_version_match(version: &str, expected_version: &str) -> bool { let version = get_version(version); expected_version.starts_with(&version) } -fn get_version(value: &String) -> String { +fn get_version(value: &str) -> String { // Regex to extract just the d.d.d version from the full version string let captures = PYTHON_VERSION.captures(value).unwrap(); let version = captures.get(1).unwrap().as_str().to_string(); @@ -39,6 +39,6 @@ fn get_version(value: &String) -> String { } #[allow(dead_code)] -pub fn is_valid_version(value: &String) -> bool { +pub fn is_valid_version(value: &str) -> bool { PYTHON_FULLVERSION.is_match(value) }