From f557ea382378dba716471b3021611abe8b0a3bbe Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 5 May 2025 10:57:46 -0400 Subject: [PATCH] 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. --- crates/uv/src/commands/project/add.rs | 3 + crates/uv/src/commands/project/export.rs | 2 + crates/uv/src/commands/project/lock.rs | 2 + crates/uv/src/commands/project/mod.rs | 131 +++++++++++++++-------- crates/uv/src/commands/project/remove.rs | 3 + crates/uv/src/commands/project/run.rs | 4 + crates/uv/src/commands/project/sync.rs | 2 + crates/uv/src/commands/project/tree.rs | 2 + crates/uv/src/commands/python/find.rs | 1 + crates/uv/tests/it/lock.rs | 2 +- crates/uv/tests/it/run.rs | 51 +++++++++ 11 files changed, 159 insertions(+), 44 deletions(-) diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 111972bb2..8ed8cbcd4 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -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, diff --git a/crates/uv/src/commands/project/export.rs b/crates/uv/src/commands/project/export.rs index 3363354cc..2918cf442 100644 --- a/crates/uv/src/commands/project/export.rs +++ b/crates/uv/src/commands/project/export.rs @@ -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, diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index c29ea4725..b87799bc7 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -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, diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index b72ab1302..4c290d5d2 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -645,6 +645,7 @@ impl ScriptInterpreter { python_preference: PythonPreference, python_downloads: PythonDownloads, install_mirrors: &PythonInstallMirrors, + keep_incompatible: bool, no_config: bool, active: Option, 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, 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, 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, cache: &Cache, @@ -1385,6 +1429,7 @@ impl ScriptEnvironment { python_preference, python_downloads, install_mirrors, + no_sync, no_config, active, cache, diff --git a/crates/uv/src/commands/project/remove.rs b/crates/uv/src/commands/project/remove.rs index f77f16220..75ef315ef 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -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, diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 97243a64d..8fe2081c8 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -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, diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 8221e2dec..6c03f7bcb 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -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, diff --git a/crates/uv/src/commands/project/tree.rs b/crates/uv/src/commands/project/tree.rs index 86ad6d61b..6ad2dfb19 100644 --- a/crates/uv/src/commands/project/tree.rs +++ b/crates/uv/src/commands/project/tree.rs @@ -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, diff --git a/crates/uv/src/commands/python/find.rs b/crates/uv/src/commands/python/find.rs index 0ad4a6a0b..67590b9df 100644 --- a/crates/uv/src/commands/python/find.rs +++ b/crates/uv/src/commands/python/find.rs @@ -124,6 +124,7 @@ pub(crate) async fn find_script( python_preference, python_downloads, &PythonInstallMirrors::default(), + false, no_config, Some(false), cache, diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 4ea71ff67..c159cdbd1 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -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 diff --git a/crates/uv/tests/it/run.rs b/crates/uv/tests/it/run.rs index 26ed860b2..6688db2b6 100644 --- a/crates/uv/tests/it/run.rs +++ b/crates/uv/tests/it/run.rs @@ -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(()) +}