Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions crates/uv-python/src/environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
25 changes: 25 additions & 0 deletions crates/uv-python/src/virtualenv.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use std::borrow::Cow;
use std::str::FromStr;
use std::{
env, io,
path::{Path, PathBuf},
Expand All @@ -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 {
Expand Down Expand Up @@ -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<PythonVersion>,
}

#[derive(Debug, Error)]
Expand Down Expand Up @@ -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
Expand All @@ -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))?,
);
}
_ => {}
}
}
Expand All @@ -229,6 +241,7 @@ impl PyVenvConfiguration {
relocatable,
seed,
include_system_site_packages,
version,
})
}

Expand Down Expand Up @@ -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::<Vec<_>>();
Expand Down
37 changes: 21 additions & 16 deletions crates/uv/src/commands/project/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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| {
Expand Down Expand Up @@ -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));
Expand Down
111 changes: 111 additions & 0 deletions crates/uv/tests/it/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9168,3 +9168,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::<Vec<_>>()
.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::<Vec<_>>()
.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(())
}
Loading