Skip to content

Commit 66d9591

Browse files
Copilotkarthiknadig
andcommitted
Implement venv-uv environment type for UV-created virtual environments
Co-authored-by: karthiknadig <3840081+karthiknadig@users.noreply.github.com>
1 parent 5ee7517 commit 66d9591

File tree

9 files changed

+246
-6
lines changed

9 files changed

+246
-6
lines changed

Cargo.lock

Lines changed: 12 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/pet-conda/tests/unix/conda_env_without_manager_but_found_in_history/env_python_3/conda-meta/history

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
==> 2024-02-28 23:05:07 <==
2-
# 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
2+
# 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
33
# conda version: 23.11.0
44
==> 2024-02-28 23:08:59 <==
5-
# 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
5+
# 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
66
# conda version: 23.11.0
77
+conda-forge/noarch::appnope-0.1.4-pyhd8ed1ab_0
88
+conda-forge/noarch::asttokens-2.4.1-pyhd8ed1ab_0

crates/pet-core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ pub enum LocatorKind {
5151
Poetry,
5252
PyEnv,
5353
Venv,
54+
VenvUv,
5455
VirtualEnv,
5556
VirtualEnvWrapper,
5657
WindowsRegistry,

crates/pet-core/src/python_environment.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ pub enum PythonEnvironmentKind {
2424
LinuxGlobal,
2525
MacXCode,
2626
Venv,
27+
VenvUv,
2728
VirtualEnv,
2829
VirtualEnvWrapper,
2930
WindowsStore,

crates/pet-core/src/pyvenv_cfg.rs

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ pub struct PyVenvCfg {
2323
pub version_major: u64,
2424
pub version_minor: u64,
2525
pub prompt: Option<String>,
26+
pub uv_version: Option<String>,
2627
}
2728

2829
impl PyVenvCfg {
@@ -31,14 +32,19 @@ impl PyVenvCfg {
3132
version_major: u64,
3233
version_minor: u64,
3334
prompt: Option<String>,
35+
uv_version: Option<String>,
3436
) -> Self {
3537
Self {
3638
version,
3739
version_major,
3840
version_minor,
3941
prompt,
42+
uv_version,
4043
}
4144
}
45+
pub fn is_uv(&self) -> bool {
46+
self.uv_version.is_some()
47+
}
4248
pub fn find(path: &Path) -> Option<Self> {
4349
if let Some(ref file) = find(path) {
4450
parse(file)
@@ -99,6 +105,7 @@ fn parse(file: &Path) -> Option<PyVenvCfg> {
99105
let mut version_major: Option<u64> = None;
100106
let mut version_minor: Option<u64> = None;
101107
let mut prompt: Option<String> = None;
108+
let mut uv_version: Option<String> = None;
102109

103110
for line in contents.lines() {
104111
if version.is_none() {
@@ -120,13 +127,18 @@ fn parse(file: &Path) -> Option<PyVenvCfg> {
120127
prompt = Some(p);
121128
}
122129
}
123-
if version.is_some() && prompt.is_some() {
130+
if uv_version.is_none() {
131+
if let Some(uv_ver) = parse_uv_version(line) {
132+
uv_version = Some(uv_ver);
133+
}
134+
}
135+
if version.is_some() && prompt.is_some() && uv_version.is_some() {
124136
break;
125137
}
126138
}
127139

128140
match (version, version_major, version_minor) {
129-
(Some(ver), Some(major), Some(minor)) => Some(PyVenvCfg::new(ver, major, minor, prompt)),
141+
(Some(ver), Some(major), Some(minor)) => Some(PyVenvCfg::new(ver, major, minor, prompt, uv_version)),
130142
_ => None,
131143
}
132144
}
@@ -177,3 +189,72 @@ fn parse_prompt(line: &str) -> Option<String> {
177189
}
178190
None
179191
}
192+
193+
fn parse_uv_version(line: &str) -> Option<String> {
194+
let trimmed = line.trim();
195+
if trimmed.starts_with("uv") {
196+
if let Some(eq_idx) = trimmed.find('=') {
197+
let mut version = trimmed[eq_idx + 1..].trim().to_string();
198+
// Strip any leading or trailing single or double quotes
199+
if version.starts_with('"') {
200+
version = version.trim_start_matches('"').to_string();
201+
}
202+
if version.ends_with('"') {
203+
version = version.trim_end_matches('"').to_string();
204+
}
205+
if version.starts_with('\'') {
206+
version = version.trim_start_matches('\'').to_string();
207+
}
208+
if version.ends_with('\'') {
209+
version = version.trim_end_matches('\'').to_string();
210+
}
211+
if !version.is_empty() {
212+
return Some(version);
213+
}
214+
}
215+
}
216+
None
217+
}
218+
219+
#[cfg(test)]
220+
mod tests {
221+
use super::*;
222+
use std::{path::PathBuf, fs};
223+
224+
#[test]
225+
fn test_parse_uv_version() {
226+
assert_eq!(parse_uv_version("uv = 0.8.14"), Some("0.8.14".to_string()));
227+
assert_eq!(parse_uv_version("uv=0.8.14"), Some("0.8.14".to_string()));
228+
assert_eq!(parse_uv_version("uv = \"0.8.14\""), Some("0.8.14".to_string()));
229+
assert_eq!(parse_uv_version("uv = '0.8.14'"), Some("0.8.14".to_string()));
230+
assert_eq!(parse_uv_version("version = 3.12.11"), None);
231+
assert_eq!(parse_uv_version("prompt = test-env"), None);
232+
}
233+
234+
#[test]
235+
fn test_pyvenv_cfg_detects_uv() {
236+
let temp_file = "/tmp/test_pyvenv_uv.cfg";
237+
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";
238+
fs::write(temp_file, contents).unwrap();
239+
240+
let cfg = parse(&PathBuf::from(temp_file)).unwrap();
241+
assert!(cfg.is_uv());
242+
assert_eq!(cfg.uv_version, Some("0.8.14".to_string()));
243+
assert_eq!(cfg.prompt, Some("test-uv-env".to_string()));
244+
245+
fs::remove_file(temp_file).ok();
246+
}
247+
248+
#[test]
249+
fn test_pyvenv_cfg_regular_venv() {
250+
let temp_file = "/tmp/test_pyvenv_regular.cfg";
251+
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";
252+
fs::write(temp_file, contents).unwrap();
253+
254+
let cfg = parse(&PathBuf::from(temp_file)).unwrap();
255+
assert!(!cfg.is_uv());
256+
assert_eq!(cfg.uv_version, None);
257+
258+
fs::remove_file(temp_file).ok();
259+
}
260+
}

crates/pet-venv-uv/Cargo.toml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[package]
2+
name = "pet-venv-uv"
3+
version = "0.1.0"
4+
edition = "2021"
5+
license = "MIT"
6+
7+
[target.'cfg(target_os = "windows")'.dependencies]
8+
msvc_spectre_libs = { version = "0.1.1", features = ["error"] }
9+
10+
[dependencies]
11+
pet-core = { path = "../pet-core" }
12+
pet-python-utils = { path = "../pet-python-utils" }
13+
log = "0.4.21"

crates/pet-venv-uv/src/lib.rs

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
use std::path::Path;
5+
6+
use pet_core::{
7+
env::PythonEnv,
8+
python_environment::{PythonEnvironment, PythonEnvironmentBuilder, PythonEnvironmentKind},
9+
pyvenv_cfg::PyVenvCfg,
10+
reporter::Reporter,
11+
Locator, LocatorKind,
12+
};
13+
use pet_python_utils::executable::find_executables;
14+
use pet_python_utils::version;
15+
16+
fn is_venv_uv_internal(env: &PythonEnv) -> Option<bool> {
17+
// Check if there's a pyvenv.cfg file with uv entry
18+
if let Some(cfg) = PyVenvCfg::find(env.executable.parent()?) {
19+
return Some(cfg.is_uv());
20+
}
21+
if let Some(cfg) = PyVenvCfg::find(&env.prefix.clone()?) {
22+
return Some(cfg.is_uv());
23+
}
24+
Some(false)
25+
}
26+
27+
pub fn is_venv_uv(env: &PythonEnv) -> bool {
28+
is_venv_uv_internal(env).unwrap_or_default()
29+
}
30+
31+
pub fn is_venv_uv_dir(path: &Path) -> bool {
32+
if let Some(cfg) = PyVenvCfg::find(path) {
33+
cfg.is_uv()
34+
} else {
35+
false
36+
}
37+
}
38+
39+
pub struct VenvUv {}
40+
41+
impl VenvUv {
42+
pub fn new() -> VenvUv {
43+
VenvUv {}
44+
}
45+
}
46+
47+
impl Default for VenvUv {
48+
fn default() -> Self {
49+
Self::new()
50+
}
51+
}
52+
53+
impl Locator for VenvUv {
54+
fn get_kind(&self) -> LocatorKind {
55+
LocatorKind::VenvUv
56+
}
57+
58+
fn supported_categories(&self) -> Vec<PythonEnvironmentKind> {
59+
vec![PythonEnvironmentKind::VenvUv]
60+
}
61+
62+
fn try_from(&self, env: &PythonEnv) -> Option<PythonEnvironment> {
63+
if is_venv_uv(env) {
64+
let mut prefix = env.prefix.clone();
65+
if prefix.is_none() {
66+
prefix = Some(env.executable.parent()?.parent()?.to_path_buf());
67+
}
68+
let version = match env.version {
69+
Some(ref v) => Some(v.clone()),
70+
None => match &prefix {
71+
Some(prefix) => version::from_creator_for_virtual_env(prefix),
72+
None => None,
73+
},
74+
};
75+
let mut symlinks = vec![];
76+
if let Some(ref prefix) = prefix {
77+
symlinks.append(&mut find_executables(prefix));
78+
}
79+
80+
// Get the name from the prefix if it exists.
81+
let cfg = PyVenvCfg::find(env.executable.parent()?)
82+
.or_else(|| PyVenvCfg::find(&env.prefix.clone()?));
83+
let name = cfg.and_then(|cfg| cfg.prompt);
84+
85+
Some(
86+
PythonEnvironmentBuilder::new(Some(PythonEnvironmentKind::VenvUv))
87+
.name(name)
88+
.executable(Some(env.executable.clone()))
89+
.version(version)
90+
.prefix(prefix)
91+
.symlinks(Some(symlinks))
92+
.build(),
93+
)
94+
} else {
95+
None
96+
}
97+
}
98+
99+
fn find(&self, _reporter: &dyn Reporter) {
100+
// There are no common global locations for uv virtual environments.
101+
// We expect the user of this class to call `is_compatible`
102+
}
103+
}
104+
105+
#[cfg(test)]
106+
mod tests {
107+
use super::*;
108+
use std::path::PathBuf;
109+
110+
#[test]
111+
fn test_is_venv_uv_dir_detects_uv_environment() {
112+
// This test checks if we can detect a UV environment from pyvenv.cfg
113+
let uv_env_path = PathBuf::from("/tmp/test_uv_env");
114+
assert!(is_venv_uv_dir(&uv_env_path), "Should detect UV environment");
115+
}
116+
117+
#[test]
118+
fn test_is_venv_uv_dir_does_not_detect_regular_environment() {
119+
// This test checks if we can properly ignore regular venv environments
120+
let regular_env_path = PathBuf::from("/tmp/test_regular_env");
121+
assert!(!is_venv_uv_dir(&regular_env_path), "Should not detect regular venv as UV environment");
122+
}
123+
124+
#[test]
125+
fn test_is_venv_uv_dir_handles_nonexistent_environment() {
126+
// This test checks if we handle non-existent environments gracefully
127+
let nonexistent_path = PathBuf::from("/tmp/nonexistent_env");
128+
assert!(!is_venv_uv_dir(&nonexistent_path), "Should not detect non-existent environment as UV");
129+
}
130+
}

crates/pet/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ pet-linux-global-python = { path = "../pet-linux-global-python" }
3131
pet-mac-xcode = { path = "../pet-mac-xcode" }
3232
pet-mac-python-org = { path = "../pet-mac-python-org" }
3333
pet-venv = { path = "../pet-venv" }
34+
pet-venv-uv = { path = "../pet-venv-uv" }
3435
pet-virtualenv = { path = "../pet-virtualenv" }
3536
pet-pipenv = { path = "../pet-pipenv" }
3637
pet-telemetry = { path = "../pet-telemetry" }

crates/pet/src/locators.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ use pet_poetry::Poetry;
2020
use pet_pyenv::PyEnv;
2121
use pet_python_utils::env::ResolvedPythonEnv;
2222
use pet_venv::Venv;
23+
use pet_venv_uv::VenvUv;
2324
use pet_virtualenv::VirtualEnv;
2425
use pet_virtualenvwrapper::VirtualEnvWrapper;
2526
use std::path::PathBuf;
@@ -57,10 +58,11 @@ pub fn create_locators(
5758

5859
// 6. Support for Virtual Envs
5960
// The order of these matter.
60-
// Basically PipEnv is a superset of VirtualEnvWrapper, which is a superset of Venv, which is a superset of VirtualEnv.
61+
// Basically PipEnv is a superset of VirtualEnvWrapper, which is a superset of VenvUv, which is a superset of Venv, which is a superset of VirtualEnv.
6162
locators.push(poetry_locator);
6263
locators.push(Arc::new(PipEnv::from(environment)));
6364
locators.push(Arc::new(VirtualEnvWrapper::from(environment)));
65+
locators.push(Arc::new(VenvUv::new()));
6466
locators.push(Arc::new(Venv::new()));
6567
// VirtualEnv is the most generic, hence should be the last.
6668
locators.push(Arc::new(VirtualEnv::new()));

0 commit comments

Comments
 (0)