diff --git a/Cargo.lock b/Cargo.lock index 3e0837f9..f1200f1e 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" @@ -90,6 +90,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + [[package]] name = "block-buffer" version = "0.10.4" @@ -214,6 +220,22 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "generic-array" version = "0.14.7" @@ -224,6 +246,18 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -234,13 +268,19 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + [[package]] name = "hashlink" version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" dependencies = [ - "hashbrown", + "hashbrown 0.14.5", ] [[package]] @@ -263,12 +303,12 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" [[package]] name = "indexmap" -version = "2.2.6" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.0", ] [[package]] @@ -302,9 +342,15 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.155" +version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "log" @@ -360,6 +406,7 @@ dependencies = [ "pet-python-utils", "pet-reporter", "pet-telemetry", + "pet-uv", "pet-venv", "pet-virtualenv", "pet-virtualenvwrapper", @@ -554,7 +601,7 @@ dependencies = [ "serde", "serde_json", "sha2", - "toml", + "toml 0.8.14", ] [[package]] @@ -617,6 +664,18 @@ dependencies = [ "regex", ] +[[package]] +name = "pet-uv" +version = "0.1.0" +dependencies = [ + "log", + "pet-core", + "pet-python-utils", + "serde", + "tempfile", + "toml 0.9.7", +] + [[package]] name = "pet-venv" version = "0.1.0" @@ -685,9 +744,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.86" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ "unicode-ident", ] @@ -701,6 +760,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "regex" version = "1.10.5" @@ -730,6 +795,19 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + [[package]] name = "ryu" version = "1.0.18" @@ -738,18 +816,28 @@ checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] name = "serde" -version = "1.0.203" +version = "1.0.226" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094" +checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.203" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" +checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33" dependencies = [ "proc-macro2", "quote", @@ -776,6 +864,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5417783452c2be558477e104686f7de5dae53dba813c28435e0e70f82d9b04ee" +dependencies = [ + "serde_core", +] + [[package]] name = "sha2" version = "0.10.8" @@ -795,15 +892,28 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.67" +version = "2.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff8655ed1d86f3af4ee3fd3263786bc14245ad17c4c7e85ba7187fb3ae028c90" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -820,11 +930,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f49eb2ab21d2f26bd6db7bf383edc527a7ebaee412d17af4d40fdccd442f335" dependencies = [ "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.6", + "toml_datetime 0.6.6", "toml_edit", ] +[[package]] +name = "toml" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00e5e5d9bf2475ac9d4f0d9edab68cc573dc2fd644b0dba36b0c30a92dd9eaa0" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.0.2", + "toml_datetime 0.7.2", + "toml_parser", + "toml_writer", + "winnow 0.7.13", +] + [[package]] name = "toml_datetime" version = "0.6.6" @@ -834,6 +959,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f1085dec27c2b6632b04c80b3bb1b4300d6495d1e129693bdda7d91e72eec1" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.14" @@ -842,11 +976,26 @@ checksum = "f21c7aaf97f1bd9ca9d4f9e73b0a6c74bd5afef56f2bc931943a6e1c37e04e38" dependencies = [ "indexmap", "serde", - "serde_spanned", - "toml_datetime", - "winnow", + "serde_spanned 0.6.6", + "toml_datetime 0.6.6", + "winnow 0.6.13", +] + +[[package]] +name = "toml_parser" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf893c33be71572e0e9aa6dd15e6677937abd686b066eac3f8cd3531688a627" +dependencies = [ + "winnow 0.7.13", ] +[[package]] +name = "toml_writer" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d163a63c116ce562a22cda521fcc4d79152e7aba014456fb5eb442f6d6a10109" + [[package]] name = "typenum" version = "1.17.0" @@ -871,6 +1020,15 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "winapi-util" version = "0.1.8" @@ -971,6 +1129,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" + [[package]] name = "winreg" version = "0.55.0" @@ -981,6 +1145,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + [[package]] name = "yaml-rust2" version = "0.8.1" diff --git a/crates/pet-core/src/lib.rs b/crates/pet-core/src/lib.rs index 8d123469..1768cae6 100644 --- a/crates/pet-core/src/lib.rs +++ b/crates/pet-core/src/lib.rs @@ -50,6 +50,7 @@ pub enum LocatorKind { Pixi, Poetry, PyEnv, + Uv, Venv, VirtualEnv, VirtualEnvWrapper, diff --git a/crates/pet-core/src/python_environment.rs b/crates/pet-core/src/python_environment.rs index a611ced8..361ec66b 100644 --- a/crates/pet-core/src/python_environment.rs +++ b/crates/pet-core/src/python_environment.rs @@ -23,6 +23,8 @@ pub enum PythonEnvironmentKind { MacCommandLineTools, LinuxGlobal, MacXCode, + Uv, + UvWorkspace, Venv, VirtualEnv, VirtualEnvWrapper, diff --git a/crates/pet-core/src/pyvenv_cfg.rs b/crates/pet-core/src/pyvenv_cfg.rs index 1dd454a5..28dbff54 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 file_path: PathBuf, } impl PyVenvCfg { @@ -31,12 +32,14 @@ impl PyVenvCfg { version_major: u64, version_minor: u64, prompt: Option, + file_path: PathBuf, ) -> Self { Self { version, version_major, version_minor, prompt, + file_path, } } pub fn find(path: &Path) -> Option { @@ -126,7 +129,13 @@ fn parse(file: &Path) -> Option { } 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, + file.to_path_buf(), + )), _ => None, } } diff --git a/crates/pet-uv/Cargo.toml b/crates/pet-uv/Cargo.toml new file mode 100644 index 00000000..78fce599 --- /dev/null +++ b/crates/pet-uv/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "pet-uv" +version = "0.1.0" +edition = "2021" +license.workspace = true + +[dependencies] +pet-core = { path = "../pet-core" } +pet-python-utils = { path = "../pet-python-utils" } +serde = {version = "1.0.226", features = ["derive"]} +toml = "0.9.7" +log = "0.4.21" + +[dev-dependencies] +tempfile = "3.13" diff --git a/crates/pet-uv/src/lib.rs b/crates/pet-uv/src/lib.rs new file mode 100644 index 00000000..353e9da2 --- /dev/null +++ b/crates/pet-uv/src/lib.rs @@ -0,0 +1,565 @@ +use std::{ + fs, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, +}; + +use log::trace; +use pet_core::{ + env::PythonEnv, + python_environment::{PythonEnvironment, PythonEnvironmentBuilder, PythonEnvironmentKind}, + pyvenv_cfg::PyVenvCfg, + reporter::Reporter, + Configuration, Locator, LocatorKind, +}; +use pet_python_utils::executable::find_executables; +use serde::Deserialize; +pub struct Uv { + pub workspace_directories: Arc>>, +} + +/// Represents information stored in a `pyvenv.cfg` generated by uv +struct UvVenv { + uv_version: String, + python_version: String, + prompt: String, +} + +impl UvVenv { + fn maybe_from_file(file: &Path) -> Option { + let contents = fs::read_to_string(file).ok()?; + let mut uv_version = None; + let mut python_version = None; + let mut prompt = None; + for line in contents.lines() { + if let Some(uv_version_value) = line.trim_start().strip_prefix("uv = ") { + uv_version = Some(uv_version_value.trim_end().to_string()) + } + if let Some(version_info) = line.trim_start().strip_prefix("version_info = ") { + python_version = Some(version_info.to_string()); + } + if let Some(prompt_value) = line.trim_start().strip_prefix("prompt = ") { + prompt = Some(prompt_value.trim_end().to_string()); + } + if uv_version.is_some() && python_version.is_some() && prompt.is_some() { + // we've found all the values we need, stop parsing + break; + } + } + Some(Self { + uv_version: uv_version?, + python_version: python_version?, + prompt: prompt?, + }) + } +} + +impl Default for Uv { + fn default() -> Self { + Self::new() + } +} + +impl Uv { + pub fn new() -> Self { + Self { + workspace_directories: Arc::new(Mutex::new(Vec::new())), + } + } +} + +impl Locator for Uv { + fn get_kind(&self) -> LocatorKind { + LocatorKind::Uv + } + + fn supported_categories(&self) -> Vec { + vec![ + PythonEnvironmentKind::Uv, + PythonEnvironmentKind::UvWorkspace, + ] + } + + fn configure(&self, config: &Configuration) { + if let Some(workspace_directories) = config.workspace_directories.as_ref() { + let mut ws = self.workspace_directories.lock().unwrap(); + ws.clear(); + ws.extend(workspace_directories.iter().cloned()); + } + } + + fn try_from(&self, env: &PythonEnv) -> Option { + let cfg = env + .executable + .parent() + .and_then(PyVenvCfg::find) + .or_else(|| { + env.prefix + .as_ref() + .and_then(|prefix| PyVenvCfg::find(prefix)) + })?; + let uv_venv = UvVenv::maybe_from_file(&cfg.file_path)?; + trace!( + "uv-managed venv found in {}, made by uv {}", + env.executable.display(), + uv_venv.uv_version + ); + let prefix = env.prefix.clone().or_else(|| { + env.executable + .parent() + .and_then(|p| p.parent().map(|pp| pp.to_path_buf())) + }); + let pyproject = prefix + .as_ref() + .and_then(|prefix| prefix.parent()) + .and_then(parse_pyproject_toml_in); + let kind = if pyproject + .and_then(|pyproject| pyproject.tool) + .and_then(|t| t.uv) + .and_then(|uv| uv.workspace) + .is_some() + { + PythonEnvironmentKind::UvWorkspace + } else { + PythonEnvironmentKind::Uv + }; + + Some( + PythonEnvironmentBuilder::new(Some(kind)) + .name(Some(uv_venv.prompt)) + .executable(Some(env.executable.clone())) + .version(Some(uv_venv.python_version)) + .symlinks(prefix.as_ref().map(find_executables)) + .prefix(prefix) + .build(), + ) + } + + fn find(&self, reporter: &dyn Reporter) { + // look through workspace directories for uv-managed projects and any of their workspaces + let workspaces = self.workspace_directories.lock().unwrap().clone(); + for workspace in workspaces { + // TODO: maybe check for workspace in parent folders? + for env in list_envs_in_directory(&workspace) { + reporter.report_environment(&env); + } + } + } +} + +fn find_workspace(path: &Path) -> Option { + for candidate in path.ancestors() { + let pyproject = parse_pyproject_toml_in(candidate); + if pyproject + .as_ref() + .and_then(|pp| pp.tool.as_ref()) + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.workspace.as_ref()) + .is_none() + { + continue; + } + // TODO: check for workspace members/excludes + trace!("Found workspace at {:?}", candidate); + let prefix = candidate.join(".venv"); + let pyvenv_cfg = prefix.join("pyvenv.cfg"); + if !pyvenv_cfg.exists() { + trace!( + "Workspace at {} does not have a virtual environment", + candidate.display() + ); + return None; + } + let unix_executable = prefix.join("bin/python"); + let windows_executable = prefix.join("Scripts/python.exe"); + let executable = if unix_executable.exists() { + Some(unix_executable) + } else if windows_executable.exists() { + Some(windows_executable) + } else { + None + }; + if let Some(uv_venv) = UvVenv::maybe_from_file(&pyvenv_cfg) { + return Some( + PythonEnvironmentBuilder::new(Some(PythonEnvironmentKind::UvWorkspace)) + .name(Some(uv_venv.prompt)) + .executable(executable) + .version(Some(uv_venv.python_version)) + .symlinks(Some(find_executables(&prefix))) + .prefix(Some(prefix)) + .build(), + ); + } else { + trace!( + "Workspace at {} does not have a uv-managed virtual environment", + candidate.display() + ); + } + return None; + } + None +} + +fn list_envs_in_directory(path: &Path) -> Vec { + let mut envs = Vec::new(); + let pyproject = parse_pyproject_toml_in(path); + let Some(pyproject) = pyproject else { + return envs; + }; + let pyvenv_cfg = path.join(".venv/pyvenv.cfg"); + let prefix = path.join(".venv"); + let unix_executable = prefix.join("bin/python"); + let windows_executable = prefix.join("Scripts/python.exe"); + let executable = if unix_executable.exists() { + Some(unix_executable) + } else if windows_executable.exists() { + Some(windows_executable) + } else { + None + }; + if pyproject + .tool + .and_then(|t| t.uv) + .and_then(|uv| uv.workspace) + .is_some() + { + trace!("Workspace found in {}", path.display()); + if let Some(uv_venv) = UvVenv::maybe_from_file(&pyvenv_cfg) { + trace!("uv-managed venv found for workspace in {}", path.display()); + let env = PythonEnvironmentBuilder::new(Some(PythonEnvironmentKind::UvWorkspace)) + .name(Some(uv_venv.prompt)) + .symlinks(Some(find_executables(&prefix))) + .prefix(Some(prefix)) + .executable(executable) + .version(Some(uv_venv.python_version)) + .build(); + envs.push(env); + } else { + trace!( + "No uv-managed venv found for workspace in {}", + path.display() + ); + } + // prioritize the workspace over the project if it's the same venv + } else if let Some(project) = pyproject.project { + if let Some(uv_venv) = UvVenv::maybe_from_file(&pyvenv_cfg) { + trace!("uv-managed venv found for project in {}", path.display()); + let env = PythonEnvironmentBuilder::new(Some(PythonEnvironmentKind::Uv)) + .name(Some(uv_venv.prompt)) + .symlinks(Some(find_executables(&prefix))) + .prefix(Some(prefix)) + .version(Some(uv_venv.python_version)) + .display_name(project.name) + .executable(executable) + .build(); + envs.push(env); + } else { + trace!("No uv-managed venv found in {}", path.display()); + } + if let Some(workspace) = path.parent().and_then(find_workspace) { + envs.push(workspace); + } + } + + envs +} + +fn parse_pyproject_toml_in(path: &Path) -> Option { + let contents = fs::read_to_string(path.join("pyproject.toml")).ok()?; + toml::from_str(&contents).ok() +} + +#[derive(Deserialize, Debug)] +struct PyProjectToml { + project: Option, + tool: Option, +} + +#[derive(Deserialize, Debug)] +struct Project { + name: Option, +} + +#[derive(Deserialize, Debug)] +struct Tool { + uv: Option, +} + +#[derive(Deserialize, Debug)] +struct ToolUv { + workspace: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_uv_venv_parse_valid_pyvenv_cfg() { + let temp_dir = TempDir::new().unwrap(); + let cfg_path = temp_dir.path().join("pyvenv.cfg"); + + let contents = r#"home = /usr/bin +include-system-site-packages = false +version = 3.11.0 +executable = /usr/bin/python3.11 +uv = 0.1.0 +version_info = 3.11.0 +prompt = test-env"#; + + std::fs::write(&cfg_path, contents).unwrap(); + + let uv_venv = UvVenv::maybe_from_file(&cfg_path); + assert!(uv_venv.is_some()); + + let uv_venv = uv_venv.unwrap(); + assert_eq!(uv_venv.uv_version, "0.1.0"); + assert_eq!(uv_venv.python_version, "3.11.0"); + assert_eq!(uv_venv.prompt, "test-env"); + } + + #[test] + fn test_uv_venv_parse_missing_uv_field() { + let temp_dir = TempDir::new().unwrap(); + let cfg_path = temp_dir.path().join("pyvenv.cfg"); + + let contents = r#"home = /usr/bin +version_info = 3.11.0 +prompt = test-env"#; + + std::fs::write(&cfg_path, contents).unwrap(); + + let uv_venv = UvVenv::maybe_from_file(&cfg_path); + assert!( + uv_venv.is_none(), + "Should return None when 'uv' field is missing" + ); + } + + #[test] + fn test_uv_venv_parse_missing_version_info() { + let temp_dir = TempDir::new().unwrap(); + let cfg_path = temp_dir.path().join("pyvenv.cfg"); + + let contents = r#"home = /usr/bin +uv = 0.1.0 +prompt = test-env"#; + + std::fs::write(&cfg_path, contents).unwrap(); + + let uv_venv = UvVenv::maybe_from_file(&cfg_path); + assert!( + uv_venv.is_none(), + "Should return None when 'version_info' field is missing" + ); + } + + #[test] + fn test_uv_venv_parse_missing_prompt() { + let temp_dir = TempDir::new().unwrap(); + let cfg_path = temp_dir.path().join("pyvenv.cfg"); + + let contents = r#"home = /usr/bin +uv = 0.1.0 +version_info = 3.11.0"#; + + std::fs::write(&cfg_path, contents).unwrap(); + + let uv_venv = UvVenv::maybe_from_file(&cfg_path); + assert!( + uv_venv.is_none(), + "Should return None when 'prompt' field is missing" + ); + } + + #[test] + fn test_uv_venv_parse_with_whitespace() { + let temp_dir = TempDir::new().unwrap(); + let cfg_path = temp_dir.path().join("pyvenv.cfg"); + + let contents = r#" uv = 0.2.5 + version_info = 3.12.1 + prompt = my-project "#; + + std::fs::write(&cfg_path, contents).unwrap(); + + let uv_venv = UvVenv::maybe_from_file(&cfg_path); + assert!(uv_venv.is_some()); + + let uv_venv = uv_venv.unwrap(); + assert_eq!(uv_venv.uv_version, "0.2.5"); + assert_eq!(uv_venv.python_version, "3.12.1"); + assert_eq!(uv_venv.prompt, "my-project"); + } + + #[test] + fn test_uv_venv_parse_nonexistent_file() { + let uv_venv = UvVenv::maybe_from_file(Path::new("/nonexistent/path/pyvenv.cfg")); + assert!(uv_venv.is_none()); + } + + #[test] + fn test_parse_pyproject_toml_with_workspace() { + let temp_dir = TempDir::new().unwrap(); + let pyproject_path = temp_dir.path().join("pyproject.toml"); + + let contents = r#"[project] +name = "my-workspace" + +[tool.uv.workspace] +members = ["packages/*"]"#; + + std::fs::write(&pyproject_path, contents).unwrap(); + + let pyproject = parse_pyproject_toml_in(temp_dir.path()); + assert!(pyproject.is_some()); + + let pyproject = pyproject.unwrap(); + assert!(pyproject.project.is_some()); + assert_eq!( + pyproject.project.unwrap().name, + Some("my-workspace".to_string()) + ); + assert!(pyproject.tool.is_some()); + assert!(pyproject.tool.unwrap().uv.is_some()); + } + + #[test] + fn test_parse_pyproject_toml_without_workspace() { + let temp_dir = TempDir::new().unwrap(); + let pyproject_path = temp_dir.path().join("pyproject.toml"); + + let contents = r#"[project] +name = "my-project" + +[tool.uv] +dev-dependencies = ["pytest"]"#; + + std::fs::write(&pyproject_path, contents).unwrap(); + + let pyproject = parse_pyproject_toml_in(temp_dir.path()); + assert!(pyproject.is_some()); + + let pyproject = pyproject.unwrap(); + assert!(pyproject.project.is_some()); + assert_eq!( + pyproject.project.unwrap().name, + Some("my-project".to_string()) + ); + } + + #[test] + fn test_parse_pyproject_toml_missing_file() { + let temp_dir = TempDir::new().unwrap(); + let pyproject = parse_pyproject_toml_in(temp_dir.path()); + assert!(pyproject.is_none()); + } + + #[test] + fn test_parse_pyproject_toml_invalid_toml() { + let temp_dir = TempDir::new().unwrap(); + let pyproject_path = temp_dir.path().join("pyproject.toml"); + + let contents = r#"[project +name = "invalid"#; + + std::fs::write(&pyproject_path, contents).unwrap(); + + let pyproject = parse_pyproject_toml_in(temp_dir.path()); + assert!(pyproject.is_none()); + } + + #[test] + fn test_list_envs_in_directory_with_workspace() { + let temp_dir = TempDir::new().unwrap(); + let project_path = temp_dir.path(); + + // Create pyproject.toml with workspace + let pyproject_path = project_path.join("pyproject.toml"); + let pyproject_contents = r#"[tool.uv.workspace] +members = ["packages/*"]"#; + std::fs::write(&pyproject_path, pyproject_contents).unwrap(); + + // Create .venv directory + let venv_path = project_path.join(".venv"); + std::fs::create_dir_all(&venv_path).unwrap(); + + // Create pyvenv.cfg + let pyvenv_cfg_path = venv_path.join("pyvenv.cfg"); + let pyvenv_contents = r#"uv = 0.1.0 +version_info = 3.11.0 +prompt = workspace-env"#; + std::fs::write(&pyvenv_cfg_path, pyvenv_contents).unwrap(); + + // Create executables directory (Unix style for testing) + let bin_path = venv_path.join("bin"); + std::fs::create_dir_all(&bin_path).unwrap(); + let python_path = bin_path.join("python"); + std::fs::File::create(&python_path).unwrap(); + + let envs = list_envs_in_directory(project_path); + assert_eq!(envs.len(), 1); + assert_eq!(envs[0].kind, Some(PythonEnvironmentKind::UvWorkspace)); + assert_eq!(envs[0].name, Some("workspace-env".to_string())); + } + + #[test] + fn test_list_envs_in_directory_with_project() { + let temp_dir = TempDir::new().unwrap(); + let project_path = temp_dir.path(); + + // Create pyproject.toml with project (no workspace) + let pyproject_path = project_path.join("pyproject.toml"); + let pyproject_contents = r#"[project] +name = "my-project" + +[tool.uv] +dev-dependencies = []"#; + std::fs::write(&pyproject_path, pyproject_contents).unwrap(); + + // Create .venv directory + let venv_path = project_path.join(".venv"); + std::fs::create_dir_all(&venv_path).unwrap(); + + // Create pyvenv.cfg + let pyvenv_cfg_path = venv_path.join("pyvenv.cfg"); + let pyvenv_contents = r#"uv = 0.1.0 +version_info = 3.11.0 +prompt = my-project"#; + std::fs::write(&pyvenv_cfg_path, pyvenv_contents).unwrap(); + + // Create executables directory + let bin_path = venv_path.join("bin"); + std::fs::create_dir_all(&bin_path).unwrap(); + let python_path = bin_path.join("python"); + std::fs::File::create(&python_path).unwrap(); + + let envs = list_envs_in_directory(project_path); + assert_eq!(envs.len(), 1); + assert_eq!(envs[0].kind, Some(PythonEnvironmentKind::Uv)); + assert_eq!(envs[0].display_name, Some("my-project".to_string())); + } + + #[test] + fn test_list_envs_in_directory_no_pyproject() { + let temp_dir = TempDir::new().unwrap(); + let envs = list_envs_in_directory(temp_dir.path()); + assert_eq!(envs.len(), 0); + } + + #[test] + fn test_list_envs_in_directory_no_venv() { + let temp_dir = TempDir::new().unwrap(); + let project_path = temp_dir.path(); + + // Create pyproject.toml but no .venv + let pyproject_path = project_path.join("pyproject.toml"); + let pyproject_contents = r#"[project] +name = "my-project""#; + std::fs::write(&pyproject_path, pyproject_contents).unwrap(); + + let envs = list_envs_in_directory(project_path); + assert_eq!(envs.len(), 0); + } +} diff --git a/crates/pet/Cargo.toml b/crates/pet/Cargo.toml index ee2efdf7..99f7b23c 100644 --- a/crates/pet/Cargo.toml +++ b/crates/pet/Cargo.toml @@ -35,6 +35,7 @@ pet-virtualenv = { path = "../pet-virtualenv" } pet-pipenv = { path = "../pet-pipenv" } pet-telemetry = { path = "../pet-telemetry" } pet-global-virtualenvs = { path = "../pet-global-virtualenvs" } +pet-uv = { path = "../pet-uv" } log = "0.4.21" clap = { version = "4.5.4", features = ["derive", "cargo"] } serde = { version = "1.0.152", features = ["derive"] } diff --git a/crates/pet/src/locators.rs b/crates/pet/src/locators.rs index 5b6b3e81..c51302d2 100644 --- a/crates/pet/src/locators.rs +++ b/crates/pet/src/locators.rs @@ -19,6 +19,7 @@ use pet_pixi::Pixi; use pet_poetry::Poetry; use pet_pyenv::PyEnv; use pet_python_utils::env::ResolvedPythonEnv; +use pet_uv::Uv; use pet_venv::Venv; use pet_virtualenv::VirtualEnv; use pet_virtualenvwrapper::VirtualEnvWrapper; @@ -58,6 +59,7 @@ pub fn create_locators( // 6. Support for Virtual Envs // The order of these matter. // Basically PipEnv is a superset of VirtualEnvWrapper, which is a superset of Venv, which is a superset of VirtualEnv. + locators.push(Arc::new(Uv::new())); locators.push(poetry_locator); locators.push(Arc::new(PipEnv::from(environment))); locators.push(Arc::new(VirtualEnvWrapper::from(environment)));