Avoid re-creating virtual environment with --no-sync (#13287)

## Summary

We now show a user-visible warning if we're using a "stale" virtual
environment due to `--no-sync`. I'd also be fine erroring here.

Closes https://github.com/astral-sh/uv/issues/13235.
This commit is contained in:
Charlie Marsh
2025-05-05 10:57:46 -04:00
committed by GitHub
parent 5386701cc1
commit f557ea3823
11 changed files with 159 additions and 44 deletions
+3
View File
@@ -168,6 +168,7 @@ pub(crate) async fn add(
python_preference,
python_downloads,
&install_mirrors,
false,
no_config,
active,
cache,
@@ -225,6 +226,7 @@ pub(crate) async fn add(
python_preference,
python_downloads,
&install_mirrors,
false,
no_config,
active,
cache,
@@ -243,6 +245,7 @@ pub(crate) async fn add(
&network_settings,
python_preference,
python_downloads,
no_sync,
no_config,
active,
cache,
+2
View File
@@ -138,6 +138,7 @@ pub(crate) async fn export(
python_downloads,
&install_mirrors,
no_config,
false,
Some(false),
cache,
printer,
@@ -152,6 +153,7 @@ pub(crate) async fn export(
python_preference,
python_downloads,
&install_mirrors,
false,
no_config,
Some(false),
cache,
+2
View File
@@ -146,6 +146,7 @@ pub(crate) async fn lock(
python_preference,
python_downloads,
&install_mirrors,
false,
no_config,
Some(false),
cache,
@@ -160,6 +161,7 @@ pub(crate) async fn lock(
python_preference,
python_downloads,
&install_mirrors,
false,
no_config,
Some(false),
cache,
+88 -43
View File
@@ -645,6 +645,7 @@ impl ScriptInterpreter {
python_preference: PythonPreference,
python_downloads: PythonDownloads,
install_mirrors: &PythonInstallMirrors,
keep_incompatible: bool,
no_config: bool,
active: Option<bool>,
cache: &Cache,
@@ -662,31 +663,26 @@ impl ScriptInterpreter {
let root = Self::root(script, active, cache);
match PythonEnvironment::from_root(&root, cache) {
Ok(venv) => {
if python_request.as_ref().is_none_or(|request| {
if request.satisfied(venv.interpreter(), cache) {
debug!(
"The script environment's Python version satisfies `{}`",
request.to_canonical_string()
match environment_is_usable(
&venv,
EnvironmentKind::Script,
python_request.as_ref(),
requires_python
.as_ref()
.map(|(requires_python, _)| requires_python),
cache,
) {
Ok(()) => return Ok(Self::Environment(venv)),
Err(err) if keep_incompatible => {
warn_user!(
"Using incompatible environment (`{}`) due to `--no-sync` ({err})",
root.user_display().cyan(),
);
true
} else {
debug!(
"The script 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));
}
debug!(
"The script environment's Python version does not meet the script's Python requirement: `{requires_python}`"
);
} else {
return Ok(Self::Environment(venv));
}
Err(err) => {
debug!("{err}");
}
}
}
Err(uv_python::Error::MissingEnvironment(_)) => {}
@@ -766,41 +762,74 @@ impl ScriptInterpreter {
}
}
/// Whether an environment is usable for the project, i.e., if it matches the requirements.
#[derive(Debug)]
pub(crate) enum EnvironmentKind {
Script,
Project,
}
impl std::fmt::Display for EnvironmentKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Script => write!(f, "script"),
Self::Project => write!(f, "project"),
}
}
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum EnvironmentIncompatibilityError {
#[error("The {0} environment's Python version does not satisfy the request: `{1}`")]
PythonRequest(EnvironmentKind, PythonRequest),
#[error("The {0} environment's Python version does not meet the Python requirement: `{1}`")]
RequiresPython(EnvironmentKind, RequiresPython),
#[error("The interpreter in the {0} environment has different version ({1}) than it was created with ({2})")]
PyenvVersionConflict(EnvironmentKind, Version, Version),
}
/// Whether an environment is usable for a project or script, i.e., if it matches the requirements.
fn environment_is_usable(
environment: &PythonEnvironment,
kind: EnvironmentKind,
python_request: Option<&PythonRequest>,
requires_python: Option<&RequiresPython>,
cache: &Cache,
) -> bool {
) -> Result<(), EnvironmentIncompatibilityError> {
if let Some((cfg_version, int_version)) = environment.get_pyvenv_version_conflict() {
debug!("The interpreter in the virtual environment has different version ({int_version}) than it was created with ({cfg_version})");
return false;
return Err(EnvironmentIncompatibilityError::PyenvVersionConflict(
kind,
cfg_version,
int_version,
));
}
if let Some(request) = python_request {
if request.satisfied(environment.interpreter(), cache) {
debug!("The virtual environment's Python version satisfies the request: `{request}`");
debug!("The {kind} environment's Python version satisfies the request: `{request}`");
} else {
debug!("The virtual environment's Python version does not satisfy the request: `{request}`");
return false;
return Err(EnvironmentIncompatibilityError::PythonRequest(
kind,
request.clone(),
));
}
}
if let Some(requires_python) = requires_python.as_ref() {
if let Some(requires_python) = requires_python {
if requires_python.contains(environment.interpreter().python_version()) {
trace!(
"The virtual environment's Python version meets the Python requirement: `{requires_python}`"
"The {kind} environment's Python version meets the Python requirement: `{requires_python}`"
);
} else {
debug!(
"The virtual environment's Python version does not meet the Python requirement: `{requires_python}`"
);
return false;
return Err(EnvironmentIncompatibilityError::RequiresPython(
kind,
requires_python.clone(),
));
}
}
true
Ok(())
}
/// An interpreter suitable for the project.
@@ -823,6 +852,7 @@ impl ProjectInterpreter {
python_preference: PythonPreference,
python_downloads: PythonDownloads,
install_mirrors: &PythonInstallMirrors,
keep_incompatible: bool,
no_config: bool,
active: Option<bool>,
cache: &Cache,
@@ -837,16 +867,27 @@ impl ProjectInterpreter {
.await?;
// Read from the virtual environment first.
let venv = workspace.venv(active);
match PythonEnvironment::from_root(&venv, cache) {
let root = workspace.venv(active);
match PythonEnvironment::from_root(&root, cache) {
Ok(venv) => {
if environment_is_usable(
match environment_is_usable(
&venv,
EnvironmentKind::Project,
python_request.as_ref(),
requires_python.as_ref(),
cache,
) {
return Ok(Self::Environment(venv));
Ok(()) => return Ok(Self::Environment(venv)),
Err(err) if keep_incompatible => {
warn_user!(
"Using incompatible environment (`{}`) due to `--no-sync` ({err})",
root.user_display().cyan(),
);
return Ok(Self::Environment(venv));
}
Err(err) => {
debug!("{err}");
}
}
}
Err(uv_python::Error::MissingEnvironment(_)) => {}
@@ -856,14 +897,14 @@ impl ProjectInterpreter {
match inner.kind {
InvalidEnvironmentKind::NotDirectory => {
return Err(ProjectError::InvalidProjectEnvironmentDir(
venv,
root,
inner.kind.to_string(),
))
}
InvalidEnvironmentKind::MissingExecutable(_) => {
if fs_err::read_dir(&venv).is_ok_and(|mut dir| dir.next().is_some()) {
if fs_err::read_dir(&root).is_ok_and(|mut dir| dir.next().is_some()) {
return Err(ProjectError::InvalidProjectEnvironmentDir(
venv,
root,
"it is not a valid Python environment (no Python executable was found)"
.to_string(),
));
@@ -1167,6 +1208,7 @@ impl ProjectEnvironment {
network_settings: &NetworkSettings,
python_preference: PythonPreference,
python_downloads: PythonDownloads,
no_sync: bool,
no_config: bool,
active: Option<bool>,
cache: &Cache,
@@ -1184,6 +1226,7 @@ impl ProjectEnvironment {
python_preference,
python_downloads,
install_mirrors,
no_sync,
no_config,
active,
cache,
@@ -1369,6 +1412,7 @@ impl ScriptEnvironment {
python_preference: PythonPreference,
python_downloads: PythonDownloads,
install_mirrors: &PythonInstallMirrors,
no_sync: bool,
no_config: bool,
active: Option<bool>,
cache: &Cache,
@@ -1385,6 +1429,7 @@ impl ScriptEnvironment {
python_preference,
python_downloads,
install_mirrors,
no_sync,
no_config,
active,
cache,
+3
View File
@@ -215,6 +215,7 @@ pub(crate) async fn remove(
python_preference,
python_downloads,
&install_mirrors,
false,
no_config,
active,
cache,
@@ -233,6 +234,7 @@ pub(crate) async fn remove(
&network_settings,
python_preference,
python_downloads,
no_sync,
no_config,
active,
cache,
@@ -253,6 +255,7 @@ pub(crate) async fn remove(
python_preference,
python_downloads,
&install_mirrors,
no_sync,
no_config,
active,
cache,
+4
View File
@@ -228,6 +228,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl
python_preference,
python_downloads,
&install_mirrors,
no_sync,
no_config,
active.map_or(Some(false), Some),
cache,
@@ -351,6 +352,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl
python_preference,
python_downloads,
&install_mirrors,
no_sync,
no_config,
active.map_or(Some(false), Some),
cache,
@@ -425,6 +427,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl
python_preference,
python_downloads,
&install_mirrors,
no_sync,
no_config,
active.map_or(Some(false), Some),
cache,
@@ -648,6 +651,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl
&network_settings,
python_preference,
python_downloads,
no_sync,
no_config,
active,
cache,
+2
View File
@@ -136,6 +136,7 @@ pub(crate) async fn sync(
&network_settings,
python_preference,
python_downloads,
false,
no_config,
active,
cache,
@@ -152,6 +153,7 @@ pub(crate) async fn sync(
python_preference,
python_downloads,
&install_mirrors,
false,
no_config,
active,
cache,
+2
View File
@@ -91,6 +91,7 @@ pub(crate) async fn tree(
python_preference,
python_downloads,
&install_mirrors,
false,
no_config,
Some(false),
cache,
@@ -106,6 +107,7 @@ pub(crate) async fn tree(
python_preference,
python_downloads,
&install_mirrors,
false,
no_config,
Some(false),
cache,
+1
View File
@@ -124,6 +124,7 @@ pub(crate) async fn find_script(
python_preference,
python_downloads,
&PythonInstallMirrors::default(),
false,
no_config,
Some(false),
cache,
+1 -1
View File
@@ -15748,7 +15748,7 @@ fn lock_explicit_default_index() -> Result<()> {
DEBUG No Python version file found in workspace: [TEMP_DIR]/
DEBUG Using Python request `>=3.12` from `requires-python` metadata
DEBUG Checking for Python environment at `.venv`
DEBUG The virtual environment's Python version satisfies the request: `Python >=3.12`
DEBUG The project environment's Python version satisfies the request: `Python >=3.12`
DEBUG Using request timeout of [TIME]
DEBUG Found static `pyproject.toml` for: project @ file://[TEMP_DIR]/
DEBUG No workspace root found, using project root
+51
View File
@@ -5134,3 +5134,54 @@ fn run_pep723_script_with_constraints() -> Result<()> {
Ok(())
}
#[test]
fn run_no_sync_incompatible_python() -> Result<()> {
let context = TestContext::new_with_versions(&["3.12", "3.11", "3.8"]);
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! { r#"
[project]
name = "foo"
version = "1.0.0"
requires-python = ">=3.12"
dependencies = [
"iniconfig"
]
"#
})?;
let test_script = context.temp_dir.child("main.py");
test_script.write_str(indoc! { r#"
import iniconfig
print("Hello, world!")
"#
})?;
uv_snapshot!(context.filters(), context.run().arg("main.py"), @r"
success: true
exit_code: 0
----- stdout -----
Hello, world!
----- stderr -----
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
Creating virtual environment at: .venv
Resolved 2 packages in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ iniconfig==2.0.0
");
uv_snapshot!(context.filters(), context.run().arg("--no-sync").arg("--python").arg("3.8").arg("main.py"), @r"
success: true
exit_code: 0
----- stdout -----
Hello, world!
----- stderr -----
warning: Using incompatible environment (`.venv`) due to `--no-sync` (The project environment's Python version does not satisfy the request: `Python 3.8`)
");
Ok(())
}