diff --git a/Cargo.lock b/Cargo.lock index 3e0837f9..a7b9842d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "ahash" diff --git a/crates/pet-conda/tests/unix/conda_env_without_manager_but_found_in_history/env_python_3/conda-meta/history b/crates/pet-conda/tests/unix/conda_env_without_manager_but_found_in_history/env_python_3/conda-meta/history index 198ee3a3..fc09724d 100644 --- a/crates/pet-conda/tests/unix/conda_env_without_manager_but_found_in_history/env_python_3/conda-meta/history +++ b/crates/pet-conda/tests/unix/conda_env_without_manager_but_found_in_history/env_python_3/conda-meta/history @@ -1,8 +1,8 @@ ==> 2024-02-28 23:05:07 <== -# cmd: /Users/donjayamanne/Development/vsc/python-environment-tools/crates/pet-conda/tests/unix/conda_env_without_manager_but_found_in_history/some_other_location/conda_install/bin/conda create -n conda1 +# cmd: /home/runner/work/python-environment-tools/python-environment-tools/crates/pet-conda/tests/unix/conda_env_without_manager_but_found_in_history/some_other_location/conda_install/bin/conda create -n conda1 # conda version: 23.11.0 ==> 2024-02-28 23:08:59 <== -# cmd: /Users/donjayamanne/Development/vsc/python-environment-tools/crates/pet-conda/tests/unix/conda_env_without_manager_but_found_in_history/some_other_location/conda_install/bin/conda install -c conda-forge --name conda1 ipykernel -y +# cmd: /home/runner/work/python-environment-tools/python-environment-tools/crates/pet-conda/tests/unix/conda_env_without_manager_but_found_in_history/some_other_location/conda_install/bin/conda install -c conda-forge --name conda1 ipykernel -y # conda version: 23.11.0 +conda-forge/noarch::appnope-0.1.4-pyhd8ed1ab_0 +conda-forge/noarch::asttokens-2.4.1-pyhd8ed1ab_0 diff --git a/crates/pet-core/src/python_environment.rs b/crates/pet-core/src/python_environment.rs index a611ced8..1d98e198 100644 --- a/crates/pet-core/src/python_environment.rs +++ b/crates/pet-core/src/python_environment.rs @@ -24,6 +24,7 @@ pub enum PythonEnvironmentKind { LinuxGlobal, MacXCode, Venv, + VenvUv, VirtualEnv, VirtualEnvWrapper, WindowsStore, diff --git a/crates/pet-core/src/pyvenv_cfg.rs b/crates/pet-core/src/pyvenv_cfg.rs index 1dd454a5..32aac73b 100644 --- a/crates/pet-core/src/pyvenv_cfg.rs +++ b/crates/pet-core/src/pyvenv_cfg.rs @@ -23,6 +23,7 @@ pub struct PyVenvCfg { pub version_major: u64, pub version_minor: u64, pub prompt: Option, + pub uv_version: Option, } impl PyVenvCfg { @@ -31,14 +32,19 @@ impl PyVenvCfg { version_major: u64, version_minor: u64, prompt: Option, + uv_version: Option, ) -> Self { Self { version, version_major, version_minor, prompt, + uv_version, } } + pub fn is_uv(&self) -> bool { + self.uv_version.is_some() + } pub fn find(path: &Path) -> Option { if let Some(ref file) = find(path) { parse(file) @@ -99,6 +105,7 @@ fn parse(file: &Path) -> Option { let mut version_major: Option = None; let mut version_minor: Option = None; let mut prompt: Option = None; + let mut uv_version: Option = None; for line in contents.lines() { if version.is_none() { @@ -120,13 +127,20 @@ fn parse(file: &Path) -> Option { prompt = Some(p); } } - if version.is_some() && prompt.is_some() { + if uv_version.is_none() { + if let Some(uv_ver) = parse_uv_version(line) { + uv_version = Some(uv_ver); + } + } + if version.is_some() && prompt.is_some() && uv_version.is_some() { break; } } match (version, version_major, version_minor) { - (Some(ver), Some(major), Some(minor)) => Some(PyVenvCfg::new(ver, major, minor, prompt)), + (Some(ver), Some(major), Some(minor)) => { + Some(PyVenvCfg::new(ver, major, minor, prompt, uv_version)) + } _ => None, } } @@ -177,3 +191,78 @@ fn parse_prompt(line: &str) -> Option { } None } + +fn parse_uv_version(line: &str) -> Option { + let trimmed = line.trim(); + if trimmed.starts_with("uv") { + if let Some(eq_idx) = trimmed.find('=') { + let mut version = trimmed[eq_idx + 1..].trim().to_string(); + // Strip any leading or trailing single or double quotes + if version.starts_with('"') { + version = version.trim_start_matches('"').to_string(); + } + if version.ends_with('"') { + version = version.trim_end_matches('"').to_string(); + } + if version.starts_with('\'') { + version = version.trim_start_matches('\'').to_string(); + } + if version.ends_with('\'') { + version = version.trim_end_matches('\'').to_string(); + } + if !version.is_empty() { + return Some(version); + } + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{fs, path::PathBuf}; + + #[test] + fn test_parse_uv_version() { + assert_eq!(parse_uv_version("uv = 0.8.14"), Some("0.8.14".to_string())); + assert_eq!(parse_uv_version("uv=0.8.14"), Some("0.8.14".to_string())); + assert_eq!( + parse_uv_version("uv = \"0.8.14\""), + Some("0.8.14".to_string()) + ); + assert_eq!( + parse_uv_version("uv = '0.8.14'"), + Some("0.8.14".to_string()) + ); + assert_eq!(parse_uv_version("version = 3.12.11"), None); + assert_eq!(parse_uv_version("prompt = test-env"), None); + } + + #[test] + fn test_pyvenv_cfg_detects_uv() { + let temp_file = "/tmp/test_pyvenv_uv.cfg"; + let contents = "home = /usr/bin/python3.12\nimplementation = CPython\nuv = 0.8.14\nversion_info = 3.12.11\ninclude-system-site-packages = false\nprompt = test-uv-env\n"; + fs::write(temp_file, contents).unwrap(); + + let cfg = parse(&PathBuf::from(temp_file)).unwrap(); + assert!(cfg.is_uv()); + assert_eq!(cfg.uv_version, Some("0.8.14".to_string())); + assert_eq!(cfg.prompt, Some("test-uv-env".to_string())); + + fs::remove_file(temp_file).ok(); + } + + #[test] + fn test_pyvenv_cfg_regular_venv() { + let temp_file = "/tmp/test_pyvenv_regular.cfg"; + let contents = "home = /usr/bin/python3.12\ninclude-system-site-packages = false\nversion = 3.13.5\nexecutable = /usr/bin/python3.12\ncommand = python -m venv /path/to/env\n"; + fs::write(temp_file, contents).unwrap(); + + let cfg = parse(&PathBuf::from(temp_file)).unwrap(); + assert!(!cfg.is_uv()); + assert_eq!(cfg.uv_version, None); + + fs::remove_file(temp_file).ok(); + } +} diff --git a/crates/pet-venv/src/lib.rs b/crates/pet-venv/src/lib.rs index 5ca5575c..f3c76468 100644 --- a/crates/pet-venv/src/lib.rs +++ b/crates/pet-venv/src/lib.rs @@ -26,6 +26,26 @@ pub fn is_venv(env: &PythonEnv) -> bool { pub fn is_venv_dir(path: &Path) -> bool { PyVenvCfg::find(path).is_some() } + +pub fn is_venv_uv(env: &PythonEnv) -> bool { + if let Some(cfg) = PyVenvCfg::find(env.executable.parent().unwrap_or(Path::new(""))) { + return cfg.is_uv(); + } + if let Some(ref prefix) = env.prefix { + if let Some(cfg) = PyVenvCfg::find(prefix) { + return cfg.is_uv(); + } + } + false +} + +pub fn is_venv_uv_dir(path: &Path) -> bool { + if let Some(cfg) = PyVenvCfg::find(path) { + cfg.is_uv() + } else { + false + } +} pub struct Venv {} impl Venv { @@ -43,7 +63,7 @@ impl Locator for Venv { LocatorKind::Venv } fn supported_categories(&self) -> Vec { - vec![PythonEnvironmentKind::Venv] + vec![PythonEnvironmentKind::Venv, PythonEnvironmentKind::VenvUv] } fn try_from(&self, env: &PythonEnv) -> Option { @@ -67,10 +87,17 @@ impl Locator for Venv { // Get the name from the prefix if it exists. let cfg = PyVenvCfg::find(env.executable.parent()?) .or_else(|| PyVenvCfg::find(&env.prefix.clone()?)); - let name = cfg.and_then(|cfg| cfg.prompt); + let name = cfg.as_ref().and_then(|cfg| cfg.prompt.clone()); + + // Determine the environment kind based on whether it was created with UV + let kind = if cfg.as_ref().is_some_and(|c| c.is_uv()) { + PythonEnvironmentKind::VenvUv + } else { + PythonEnvironmentKind::Venv + }; Some( - PythonEnvironmentBuilder::new(Some(PythonEnvironmentKind::Venv)) + PythonEnvironmentBuilder::new(Some(kind)) .name(name) .executable(Some(env.executable.clone())) .version(version) @@ -88,3 +115,107 @@ impl Locator for Venv { // We expect the user of this class to call `is_compatible` } } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn test_is_venv_uv_dir_detects_uv_environment() { + use std::fs; + let test_dir = PathBuf::from("/tmp/test_uv_env_venv"); + fs::create_dir_all(&test_dir).unwrap(); + let pyvenv_cfg = test_dir.join("pyvenv.cfg"); + let contents = "home = /usr/bin/python3.12\nimplementation = CPython\nuv = 0.8.14\nversion_info = 3.12.11\ninclude-system-site-packages = false\nprompt = test-uv-env\n"; + fs::write(&pyvenv_cfg, contents).unwrap(); + + assert!(is_venv_uv_dir(&test_dir), "Should detect UV environment"); + + fs::remove_dir_all(&test_dir).ok(); + } + + #[test] + fn test_is_venv_uv_dir_does_not_detect_regular_environment() { + use std::fs; + let test_dir = PathBuf::from("/tmp/test_regular_env_venv"); + fs::create_dir_all(&test_dir).unwrap(); + let pyvenv_cfg = test_dir.join("pyvenv.cfg"); + let contents = "home = /usr/bin/python3.12\ninclude-system-site-packages = false\nversion = 3.13.5\nexecutable = /usr/bin/python3.12\ncommand = python -m venv /path/to/env\n"; + fs::write(&pyvenv_cfg, contents).unwrap(); + + assert!( + !is_venv_uv_dir(&test_dir), + "Should not detect regular venv as UV environment" + ); + + fs::remove_dir_all(&test_dir).ok(); + } + + #[test] + fn test_is_venv_uv_dir_handles_nonexistent_environment() { + let nonexistent_path = PathBuf::from("/tmp/nonexistent_env"); + assert!( + !is_venv_uv_dir(&nonexistent_path), + "Should not detect non-existent environment as UV" + ); + } + + #[test] + fn test_venv_locator_detects_uv_kind() { + use pet_core::env::PythonEnv; + use std::fs; + + // Create a test UV environment + let test_dir = PathBuf::from("/tmp/test_locator_uv"); + let bin_dir = test_dir.join("bin"); + fs::create_dir_all(&bin_dir).unwrap(); + + let pyvenv_cfg = test_dir.join("pyvenv.cfg"); + let contents = "home = /usr/bin/python3.12\nimplementation = CPython\nuv = 0.8.14\nversion_info = 3.12.11\ninclude-system-site-packages = false\nprompt = test-uv-env\n"; + fs::write(&pyvenv_cfg, contents).unwrap(); + + let python_exe = bin_dir.join("python"); + fs::write(&python_exe, "").unwrap(); // Create dummy python executable + + let env = PythonEnv::new(python_exe.clone(), Some(test_dir.clone()), Some("3.12.11".to_string())); + let locator = Venv::new(); + + if let Some(result) = locator.try_from(&env) { + assert_eq!(result.kind, Some(PythonEnvironmentKind::VenvUv), "UV environment should be detected as VenvUv"); + } else { + panic!("Locator should detect UV environment"); + } + + fs::remove_dir_all(&test_dir).ok(); + } + + #[test] + fn test_venv_locator_detects_regular_venv_kind() { + use pet_core::env::PythonEnv; + use std::fs; + + // Create a test regular venv environment + let test_dir = PathBuf::from("/tmp/test_locator_regular"); + let bin_dir = test_dir.join("bin"); + fs::create_dir_all(&bin_dir).unwrap(); + + let pyvenv_cfg = test_dir.join("pyvenv.cfg"); + let contents = "home = /usr/bin/python3.12\ninclude-system-site-packages = false\nversion = 3.13.5\nexecutable = /usr/bin/python3.12\ncommand = python -m venv /path/to/env\n"; + fs::write(&pyvenv_cfg, contents).unwrap(); + + let python_exe = bin_dir.join("python"); + fs::write(&python_exe, "").unwrap(); // Create dummy python executable + + let env = PythonEnv::new(python_exe.clone(), Some(test_dir.clone()), Some("3.13.5".to_string())); + let locator = Venv::new(); + + if let Some(result) = locator.try_from(&env) { + assert_eq!(result.kind, Some(PythonEnvironmentKind::Venv), "Regular venv should be detected as Venv"); + } else { + panic!("Locator should detect regular venv environment"); + } + + fs::remove_dir_all(&test_dir).ok(); + } +}