diff --git a/crates/uv-python/src/environment.rs b/crates/uv-python/src/environment.rs index d4e72f206..dfc65359a 100644 --- a/crates/uv-python/src/environment.rs +++ b/crates/uv-python/src/environment.rs @@ -355,4 +355,13 @@ impl PythonEnvironment { .unwrap_or(false) } } + + /// 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) + } } diff --git a/crates/uv-python/src/virtualenv.rs b/crates/uv-python/src/virtualenv.rs index d95ee1c55..a930b0513 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 dc9b93a54..7253fb8bc 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,27 @@ 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 - } - }) { + 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!( + "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 6a246c8bc..239d35610 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -9268,3 +9268,114 @@ 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"); + + 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 + .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(()) +}