Skip to content

Commit a0bf90c

Browse files
fix: identify uv venvs without a prompt as Uv (Fixes #432) (#480)
## Summary `uv venv` only writes a `prompt` line to `pyvenv.cfg` when `--prompt` is passed, but `UvVenv::maybe_from_file` treated `prompt` as mandatory. As a result, ordinary uv-created venvs were misidentified as generic `Venv` environments. The generic `Venv` locator already treats `prompt` as optional, so the same `.venv` could be reported with a different identity by different code paths — the inconsistent identity drives the consuming extension to repeatedly re-resolve the same environment (the CPU spin / flickering kernel selector reported in #432 and microsoft/vscode-jupyter#17377). ## Root cause In `crates/pet-uv/src/lib.rs`, `maybe_from_file` returned `None` unless `uv`, `version_info`, **and** `prompt` were all present (`prompt: prompt?`). The `uv` marker is what actually identifies a uv-managed venv; `prompt` is optional metadata. ## Changes - `UvVenv.prompt` is now `Option<String>`; `maybe_from_file` only requires the `uv` marker and `version_info`. - Pass the optional prompt straight through to `.name(...)` in all four builder sites (matches the generic `Venv` locator). - Trim trailing whitespace on `version_info` for consistency with the other fields. - Updated `test_uv_venv_parse_missing_prompt` (now expects identification to succeed) and added `test_uv_venv_parse_real_uv_venv_cfg_without_prompt` using a real `uv venv` cfg. ## Verification - `cargo test -p pet-uv` — 42 passing. - `cargo fmt --all` and `cargo clippy -p pet-uv --all-targets -- -D warnings` clean. - Manual repro: a real `uv venv` now resolves as `"kind": "Uv"` (previously `"Venv"`). > Note: `cargo clippy --all` surfaces pre-existing `needless_borrows_for_generic_args` errors in `pet-hatch` tests (newer local clippy than CI pins); unrelated to this change and present on `main`. Fixes #432 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 89bf1c0 commit a0bf90c

1 file changed

Lines changed: 41 additions & 11 deletions

File tree

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

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ pub struct Uv {
2929
struct UvVenv {
3030
uv_version: String,
3131
python_version: String,
32-
prompt: String,
32+
prompt: Option<String>,
3333
}
3434

3535
impl UvVenv {
@@ -43,7 +43,7 @@ impl UvVenv {
4343
uv_version = Some(uv_version_value.trim_end().to_string())
4444
}
4545
if let Some(version_info) = line.trim_start().strip_prefix("version_info = ") {
46-
python_version = Some(version_info.to_string());
46+
python_version = Some(version_info.trim_end().to_string());
4747
}
4848
if let Some(prompt_value) = line.trim_start().strip_prefix("prompt = ") {
4949
prompt = Some(prompt_value.trim_end().to_string());
@@ -53,10 +53,14 @@ impl UvVenv {
5353
break;
5454
}
5555
}
56+
// The `uv` marker identifies the venv as uv-managed; `version_info` gives
57+
// the Python version. `prompt` is optional metadata: `uv venv` only writes
58+
// it when `--prompt` is passed, so requiring it would misidentify ordinary
59+
// uv venvs as generic virtual environments.
5660
Some(Self {
5761
uv_version: uv_version?,
5862
python_version: python_version?,
59-
prompt: prompt?,
63+
prompt,
6064
})
6165
}
6266
}
@@ -151,7 +155,7 @@ impl Locator for Uv {
151155

152156
Some(
153157
PythonEnvironmentBuilder::new(Some(kind))
154-
.name(Some(uv_venv.prompt))
158+
.name(uv_venv.prompt)
155159
.executable(Some(env.executable.clone()))
156160
.version(Some(uv_venv.python_version))
157161
.symlinks(prefix.as_ref().map(find_executables))
@@ -443,7 +447,7 @@ fn build_workspace_env(workspace_root: &Path) -> Option<PythonEnvironment> {
443447
if let Some(uv_venv) = UvVenv::maybe_from_file(&pyvenv_cfg) {
444448
Some(
445449
PythonEnvironmentBuilder::new(Some(PythonEnvironmentKind::UvWorkspace))
446-
.name(Some(uv_venv.prompt))
450+
.name(uv_venv.prompt)
447451
.executable(executable)
448452
.version(Some(uv_venv.python_version))
449453
.symlinks(Some(find_executables(&prefix)))
@@ -486,7 +490,7 @@ fn list_envs_in_directory(path: &Path) -> Vec<PythonEnvironment> {
486490
if let Some(uv_venv) = UvVenv::maybe_from_file(&pyvenv_cfg) {
487491
trace!("uv-managed venv found for workspace in {}", path.display());
488492
let env = PythonEnvironmentBuilder::new(Some(PythonEnvironmentKind::UvWorkspace))
489-
.name(Some(uv_venv.prompt))
493+
.name(uv_venv.prompt)
490494
.symlinks(Some(find_executables(&prefix)))
491495
.prefix(Some(prefix))
492496
.executable(executable)
@@ -504,7 +508,7 @@ fn list_envs_in_directory(path: &Path) -> Vec<PythonEnvironment> {
504508
if let Some(uv_venv) = UvVenv::maybe_from_file(&pyvenv_cfg) {
505509
trace!("uv-managed venv found for project in {}", path.display());
506510
let env = PythonEnvironmentBuilder::new(Some(PythonEnvironmentKind::Uv))
507-
.name(Some(uv_venv.prompt))
511+
.name(uv_venv.prompt)
508512
.symlinks(Some(find_executables(&prefix)))
509513
.prefix(Some(prefix))
510514
.version(Some(uv_venv.python_version))
@@ -657,7 +661,7 @@ prompt = test-env"#;
657661
let uv_venv = uv_venv.unwrap();
658662
assert_eq!(uv_venv.uv_version, "0.1.0");
659663
assert_eq!(uv_venv.python_version, "3.11.0");
660-
assert_eq!(uv_venv.prompt, "test-env");
664+
assert_eq!(uv_venv.prompt, Some("test-env".to_string()));
661665
}
662666

663667
#[test]
@@ -709,9 +713,35 @@ version_info = 3.11.0"#;
709713

710714
let uv_venv = UvVenv::maybe_from_file(&cfg_path);
711715
assert!(
712-
uv_venv.is_none(),
713-
"Should return None when 'prompt' field is missing"
716+
uv_venv.is_some(),
717+
"Should identify a uv venv even when the optional 'prompt' field is missing"
714718
);
719+
720+
let uv_venv = uv_venv.unwrap();
721+
assert_eq!(uv_venv.uv_version, "0.1.0");
722+
assert_eq!(uv_venv.python_version, "3.11.0");
723+
assert_eq!(uv_venv.prompt, None);
724+
}
725+
726+
#[test]
727+
fn test_uv_venv_parse_real_uv_venv_cfg_without_prompt() {
728+
// `uv venv` (without `--prompt`) produces a pyvenv.cfg with no `prompt`
729+
// line. Such an environment must still be identified as uv-managed.
730+
let temp_dir = TempDir::new().unwrap();
731+
let cfg_path = temp_dir.path().join("pyvenv.cfg");
732+
733+
let contents = r#"home = /home/user/.local/share/uv/python/cpython-3.13.0-linux-x86_64-gnu
734+
implementation = CPython
735+
uv = 0.5.4
736+
version_info = 3.13.0
737+
include-system-site-packages = false"#;
738+
739+
std::fs::write(&cfg_path, contents).unwrap();
740+
741+
let uv_venv = UvVenv::maybe_from_file(&cfg_path).expect("uv venv should be identified");
742+
assert_eq!(uv_venv.uv_version, "0.5.4");
743+
assert_eq!(uv_venv.python_version, "3.13.0");
744+
assert_eq!(uv_venv.prompt, None);
715745
}
716746

717747
#[test]
@@ -731,7 +761,7 @@ version_info = 3.11.0"#;
731761
let uv_venv = uv_venv.unwrap();
732762
assert_eq!(uv_venv.uv_version, "0.2.5");
733763
assert_eq!(uv_venv.python_version, "3.12.1");
734-
assert_eq!(uv_venv.prompt, "my-project");
764+
assert_eq!(uv_venv.prompt, Some("my-project".to_string()));
735765
}
736766

737767
#[test]

0 commit comments

Comments
 (0)