From 9d206ac4ebf421d58f0a1f8b4c0a47041dc02d3b Mon Sep 17 00:00:00 2001 From: John Mumm Date: Mon, 14 Apr 2025 19:54:56 +0200 Subject: [PATCH 1/6] Ensure virtual environment is compatible with interpreter on sync --- crates/uv-python/src/environment.rs | 8 ++ crates/uv-python/src/virtualenv.rs | 25 +++++++ crates/uv/src/commands/project/mod.rs | 33 +++++---- crates/uv/tests/it/sync.rs | 102 ++++++++++++++++++++++++++ 4 files changed, 152 insertions(+), 16 deletions(-) diff --git a/crates/uv-python/src/environment.rs b/crates/uv-python/src/environment.rs index d4e72f20671f7..d3402f0177e52 100644 --- a/crates/uv-python/src/environment.rs +++ b/crates/uv-python/src/environment.rs @@ -355,4 +355,12 @@ impl PythonEnvironment { .unwrap_or(false) } } + + /// Returns true if the virtual environment has the same `pyvenv.cfg` version + /// as the interpreter Python version. Also returns true if there is no + /// `pyvenv.cfg` file. + pub fn matches_interpreter(&self, interpreter: &Interpreter) -> bool { + let Ok(cfg) = self.cfg() else { return true }; + cfg.matches_interpreter(interpreter) + } } diff --git a/crates/uv-python/src/virtualenv.rs b/crates/uv-python/src/virtualenv.rs index d95ee1c55a251..a930b0513af24 100644 --- a/crates/uv-python/src/virtualenv.rs +++ b/crates/uv-python/src/virtualenv.rs @@ -1,4 +1,5 @@ use std::borrow::Cow; +use std::str::FromStr; use std::{ env, io, path::{Path, PathBuf}, @@ -10,6 +11,8 @@ use thiserror::Error; use uv_pypi_types::Scheme; use uv_static::EnvVars; +use crate::{Interpreter, PythonVersion}; + /// The layout of a virtual environment. #[derive(Debug)] pub struct VirtualEnvironment { @@ -41,6 +44,8 @@ pub struct PyVenvConfiguration { pub(crate) seed: bool, /// Should the virtual environment include system site packages? pub(crate) include_system_site_packages: bool, + /// The Python version the virtual environment was created with + pub(crate) version: Option, } #[derive(Debug, Error)] @@ -193,6 +198,7 @@ impl PyVenvConfiguration { let mut relocatable = false; let mut seed = false; let mut include_system_site_packages = true; + let mut version = None; // Per https://snarky.ca/how-virtual-environments-work/, the `pyvenv.cfg` file is not a // valid INI file, and is instead expected to be parsed by partitioning each line on the @@ -219,6 +225,12 @@ impl PyVenvConfiguration { "include-system-site-packages" => { include_system_site_packages = value.trim().to_lowercase() == "true"; } + "version" | "version_info" => { + version = Some( + PythonVersion::from_str(value.trim()) + .map_err(|e| io::Error::new(std::io::ErrorKind::InvalidData, e))?, + ); + } _ => {} } } @@ -229,6 +241,7 @@ impl PyVenvConfiguration { relocatable, seed, include_system_site_packages, + version, }) } @@ -257,6 +270,18 @@ impl PyVenvConfiguration { self.include_system_site_packages } + /// Returns true if the virtual environment has the same `pyvenv.cfg` version + /// as the interpreter Python version. Also returns true if there is no version. + pub fn matches_interpreter(&self, interpreter: &Interpreter) -> bool { + self.version.as_ref().is_none_or(|version| { + interpreter.python_major() == version.major() + && interpreter.python_minor() == version.minor() + && version + .patch() + .is_none_or(|patch| patch == interpreter.python_patch()) + }) + } + /// Set the key-value pair in the `pyvenv.cfg` file. pub fn set(content: &str, key: &str, value: &str) -> String { let mut lines = content.lines().map(Cow::Borrowed).collect::>(); diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index ee9266a12d007..1633e41b54414 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -661,7 +661,6 @@ impl ScriptInterpreter { } = ScriptPython::from_request(python_request, workspace, script, no_config).await?; let root = Self::root(script, active, cache); - match PythonEnvironment::from_root(&root, cache) { Ok(venv) => { if python_request.as_ref().is_none_or(|request| { @@ -804,21 +803,23 @@ impl ProjectInterpreter { let venv = workspace.venv(active); match PythonEnvironment::from_root(&venv, cache) { Ok(venv) => { - if python_request.as_ref().is_none_or(|request| { - if request.satisfied(venv.interpreter(), cache) { - debug!( - "The virtual environment's Python version satisfies `{}`", - request.to_canonical_string() - ); - true - } else { - debug!( - "The virtual environment's Python version does not satisfy `{}`", - request.to_canonical_string() - ); - false - } - }) { + if venv.matches_interpreter(venv.interpreter()) + && python_request.as_ref().is_none_or(|request| { + if request.satisfied(venv.interpreter(), cache) { + debug!( + "The virtual environment's Python version satisfies `{}`", + request.to_canonical_string() + ); + true + } else { + debug!( + "The virtual environment's Python version does not satisfy `{}`", + request.to_canonical_string() + ); + false + } + }) + { if let Some(requires_python) = requires_python.as_ref() { if requires_python.contains(venv.interpreter().python_version()) { return Ok(Self::Environment(venv)); diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 7de30d035f878..6cac3bb3e57d0 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -9168,3 +9168,105 @@ fn sync_build_constraints() -> Result<()> { Ok(()) } + +// Test that we recreate a virtual environment when `pyvenv.cfg` version +// is incompatible with the interpreter version. +#[test] +fn sync_when_virtual_environment_incompatible_with_interpreter() -> Result<()> { + let context = TestContext::new("3.12"); + + context.init().assert().success(); + + // Create a virtual environment at `.venv`. + context + .venv() + .arg(context.venv.as_os_str()) + .arg("--python") + .arg("3.12") + .assert() + .success(); + + // Simulate an incompatible `pyvenv.cfg:version` value created + // by the venv module. + let pyvenv_cfg = context.venv.child("pyvenv.cfg"); + let contents = fs_err::read_to_string(&pyvenv_cfg) + .unwrap() + .lines() + .map(|line| { + if line.trim_start().starts_with("version") { + "version = 3.11.0".to_string() + } else { + line.to_string() + } + }) + .collect::>() + .join("\n"); + fs_err::write(&pyvenv_cfg, contents)?; + + // We should also be able to read from the lockfile. + uv_snapshot!(context.filters(), context.sync(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Removed virtual environment at: .venv + Creating virtual environment at: .venv + Resolved 1 package in [TIME] + Audited in [TIME] + "); + + insta::with_settings!({ + filters => context.filters(), + }, { + let contents = fs_err::read_to_string(&pyvenv_cfg).unwrap(); + let lines: Vec<&str> = contents.split('\n').collect(); + assert_snapshot!(lines[3], @r###" + version_info = 3.12.[X] + "###); + }); + + // Simulate an incompatible `pyvenv.cfg:version_info` value created + // by uv or virtualenv. + let pyvenv_cfg = context.venv.child("pyvenv.cfg"); + let contents = fs_err::read_to_string(&pyvenv_cfg) + .unwrap() + .lines() + .map(|line| { + if line.trim_start().starts_with("version") { + "version_info = 3.11.0".to_string() + } else { + line.to_string() + } + }) + .collect::>() + .join("\n"); + fs_err::write(&pyvenv_cfg, contents)?; + + // We should also be able to read from the lockfile. + uv_snapshot!(context.filters(), context.sync(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Removed virtual environment at: .venv + Creating virtual environment at: .venv + Resolved 1 package in [TIME] + Audited in [TIME] + "); + + insta::with_settings!({ + filters => context.filters(), + }, { + let contents = fs_err::read_to_string(&pyvenv_cfg).unwrap(); + let lines: Vec<&str> = contents.split('\n').collect(); + assert_snapshot!(lines[3], @r###" + version_info = 3.12.[X] + "###); + }); + + Ok(()) +} From 2925455d970fc7be8e11585423be092c2e6961c2 Mon Sep 17 00:00:00 2001 From: John Mumm Date: Tue, 15 Apr 2025 09:39:07 +0200 Subject: [PATCH 2/6] .. --- crates/uv-python/src/environment.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/uv-python/src/environment.rs b/crates/uv-python/src/environment.rs index d3402f0177e52..d71d28b48b47d 100644 --- a/crates/uv-python/src/environment.rs +++ b/crates/uv-python/src/environment.rs @@ -356,9 +356,9 @@ impl PythonEnvironment { } } - /// Returns true if the virtual environment has the same `pyvenv.cfg` version - /// as the interpreter Python version. Also returns true if there is no - /// `pyvenv.cfg` file. + /// Returns true if the virtual environment has the same `pyvenv.cfg` + /// version as the interpreter Python version. Also returns true if + /// there is no `pyvenv.cfg` file. pub fn matches_interpreter(&self, interpreter: &Interpreter) -> bool { let Ok(cfg) = self.cfg() else { return true }; cfg.matches_interpreter(interpreter) From c947f01ba4b1084857ba6881d857445176f0858b Mon Sep 17 00:00:00 2001 From: John Mumm Date: Tue, 15 Apr 2025 10:41:39 +0200 Subject: [PATCH 3/6] .. --- crates/uv/tests/it/sync.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 6cac3bb3e57d0..4955c1dda03a3 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -9172,6 +9172,7 @@ fn sync_build_constraints() -> Result<()> { // Test that we recreate a virtual environment when `pyvenv.cfg` version // is incompatible with the interpreter version. #[test] +#[cfg(feature = "git")] fn sync_when_virtual_environment_incompatible_with_interpreter() -> Result<()> { let context = TestContext::new("3.12"); From 85d5bb605d49512e345b5a298be31f4f08c15071 Mon Sep 17 00:00:00 2001 From: John Mumm Date: Tue, 15 Apr 2025 10:45:03 +0200 Subject: [PATCH 4/6] .. --- crates/uv/tests/it/sync.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 4955c1dda03a3..15c1fbb5e42c3 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -9172,11 +9172,19 @@ fn sync_build_constraints() -> Result<()> { // Test that we recreate a virtual environment when `pyvenv.cfg` version // is incompatible with the interpreter version. #[test] -#[cfg(feature = "git")] fn sync_when_virtual_environment_incompatible_with_interpreter() -> Result<()> { let context = TestContext::new("3.12"); - context.init().assert().success(); + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.11" + dependencies = [] + "#, + )?; // Create a virtual environment at `.venv`. context From 42e8cc0b2b055df4e68955bc6cc27a8a8693936a Mon Sep 17 00:00:00 2001 From: John Mumm Date: Tue, 15 Apr 2025 11:24:02 +0200 Subject: [PATCH 5/6] .. --- crates/uv/src/commands/project/mod.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 1633e41b54414..cdf15c9bff5d9 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -803,7 +803,11 @@ impl ProjectInterpreter { let venv = workspace.venv(active); match PythonEnvironment::from_root(&venv, cache) { Ok(venv) => { - if venv.matches_interpreter(venv.interpreter()) + let venv_matches_interpreter = venv.matches_interpreter(venv.interpreter()); + if !venv_matches_interpreter { + debug!("The virtual environment's interpreter version does not match the version it was created from."); + } + if venv_matches_interpreter && python_request.as_ref().is_none_or(|request| { if request.satisfied(venv.interpreter(), cache) { debug!( From 5c6c5af969c061c3ae9dc5aa9f98d383138cfec6 Mon Sep 17 00:00:00 2001 From: John Mumm Date: Tue, 15 Apr 2025 11:28:57 +0200 Subject: [PATCH 6/6] .. --- crates/uv-python/src/environment.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/uv-python/src/environment.rs b/crates/uv-python/src/environment.rs index d71d28b48b47d..dfc65359a949b 100644 --- a/crates/uv-python/src/environment.rs +++ b/crates/uv-python/src/environment.rs @@ -356,9 +356,10 @@ impl PythonEnvironment { } } - /// Returns true if the virtual environment has the same `pyvenv.cfg` - /// version as the interpreter Python version. Also returns true if - /// there is no `pyvenv.cfg` file. + /// If this is a virtual environment (indicated by the presence of + /// a `pyvenv.cfg` file), this returns true if the `pyvenv.cfg` version + /// is the same as the interpreter Python version. Also returns true + /// if this is not a virtual environment. pub fn matches_interpreter(&self, interpreter: &Interpreter) -> bool { let Ok(cfg) = self.cfg() else { return true }; cfg.matches_interpreter(interpreter)