diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index c067082dde4d7..f10b480e282a5 100644 --- a/crates/uv-python/src/discovery.rs +++ b/crates/uv-python/src/discovery.rs @@ -446,7 +446,16 @@ fn python_executables_from_installed<'a>( .flatten(); match preference { - PythonPreference::OnlyManaged => Box::new(from_managed_installations), + PythonPreference::OnlyManaged => { + // TODO(zanieb): Ideally, we'd create "fake" managed installation directories for tests, + // but for now... we'll just include the test interpreters which are always on the + // search path. + if std::env::var(uv_static::EnvVars::UV_INTERNAL__TEST_PYTHON_MANAGED).is_ok() { + Box::new(from_managed_installations.chain(from_search_path)) + } else { + Box::new(from_managed_installations) + } + } PythonPreference::Managed => Box::new( from_managed_installations .chain(from_search_path) @@ -730,6 +739,9 @@ fn python_interpreters<'a>( false } }) + .filter_ok(move |(source, interpreter)| { + satisfies_python_preference(*source, interpreter, preference) + }) } /// Lazily convert Python executables into interpreters. @@ -857,6 +869,93 @@ fn source_satisfies_environment_preference( } } +/// Returns true if a Python interpreter matches the [`PythonPreference`]. +pub fn satisfies_python_preference( + source: PythonSource, + interpreter: &Interpreter, + preference: PythonPreference, +) -> bool { + // If the source is "explicit", we will not apply the Python preference, e.g., if the user has + // activated a virtual environment, we should always allow it. We may want to invalidate the + // environment in some cases, like in projects, but we can't distinguish between explicit + // requests for a different Python preference or a persistent preference in a configuration file + // which would result in overly aggressive invalidation. + let is_explicit = match source { + PythonSource::ProvidedPath + | PythonSource::ParentInterpreter + | PythonSource::ActiveEnvironment + | PythonSource::CondaPrefix => true, + PythonSource::Managed + | PythonSource::DiscoveredEnvironment + | PythonSource::SearchPath + | PythonSource::SearchPathFirst + | PythonSource::Registry + | PythonSource::MicrosoftStore + | PythonSource::BaseCondaPrefix => false, + }; + + match preference { + PythonPreference::OnlyManaged => { + // Perform a fast check using the source before querying the interpreter + if matches!(source, PythonSource::Managed) || interpreter.is_managed() { + true + } else { + if is_explicit { + debug!( + "Allowing unmanaged Python interpreter at `{}` (in conflict with the `python-preference`) since it is from source: {source}", + interpreter.sys_executable().display() + ); + true + } else { + debug!( + "Ignoring Python interpreter at `{}`: only managed interpreters allowed", + interpreter.sys_executable().display() + ); + false + } + } + } + // If not "only" a kind, any interpreter is okay + PythonPreference::Managed | PythonPreference::System => true, + PythonPreference::OnlySystem => { + let is_system = match source { + // A managed interpreter is never a system interpreter + PythonSource::Managed => false, + // We can't be sure if this is a system interpreter without checking + PythonSource::ProvidedPath + | PythonSource::ParentInterpreter + | PythonSource::ActiveEnvironment + | PythonSource::CondaPrefix + | PythonSource::DiscoveredEnvironment + | PythonSource::SearchPath + | PythonSource::SearchPathFirst + | PythonSource::Registry + | PythonSource::BaseCondaPrefix => !interpreter.is_managed(), + // Managed interpreters should never be found in the store + PythonSource::MicrosoftStore => true, + }; + + if is_system { + true + } else { + if is_explicit { + debug!( + "Allowing managed Python interpreter at `{}` (in conflict with the `python-preference`) since it is from source: {source}", + interpreter.sys_executable().display() + ); + true + } else { + debug!( + "Ignoring Python interpreter at `{}`: only system interpreters allowed", + interpreter.sys_executable().display() + ); + false + } + } + } + } +} + /// Check if an encountered error is critical and should stop discovery. /// /// Returns false when an error could be due to a faulty Python installation and we should continue searching for a working one. @@ -2812,6 +2911,18 @@ impl PythonPreference { } } } + + /// Return the canonical name. + // TODO(zanieb): This should be a `Display` impl and we should have a different view for + // the sources + pub fn canonical_name(&self) -> &'static str { + match self { + Self::OnlyManaged => "only managed", + Self::Managed => "prefer managed", + Self::System => "prefer system", + Self::OnlySystem => "only system", + } + } } impl fmt::Display for PythonPreference { diff --git a/crates/uv-python/src/environment.rs b/crates/uv-python/src/environment.rs index 07f3ddb545a7d..10cec16ad106c 100644 --- a/crates/uv-python/src/environment.rs +++ b/crates/uv-python/src/environment.rs @@ -158,8 +158,7 @@ impl PythonEnvironment { let installation = match find_python_installation( request, preference, - // Ignore managed installations when looking for environments - PythonPreference::OnlySystem, + PythonPreference::default(), cache, preview, )? { diff --git a/crates/uv-python/src/interpreter.rs b/crates/uv-python/src/interpreter.rs index fc5adb833a412..dd9dd1cb4ecf0 100644 --- a/crates/uv-python/src/interpreter.rs +++ b/crates/uv-python/src/interpreter.rs @@ -271,15 +271,28 @@ impl Interpreter { /// /// Returns `false` if we cannot determine the path of the uv managed Python interpreters. pub fn is_managed(&self) -> bool { + if let Ok(test_managed) = + std::env::var(uv_static::EnvVars::UV_INTERNAL__TEST_PYTHON_MANAGED) + { + // During testing, we collect interpreters into an artificial search path and need to + // be able to mock whether an interpreter is managed or not. + return test_managed.split_ascii_whitespace().any(|item| { + let version = ::from_str(item).expect( + "`UV_INTERNAL__TEST_PYTHON_MANAGED` items should be valid Python versions", + ); + if version.patch().is_some() { + version.version() == self.python_version() + } else { + (version.major(), version.minor()) == self.python_tuple() + } + }); + } + let Ok(installations) = ManagedPythonInstallations::from_settings(None) else { return false; }; - installations - .find_all() - .into_iter() - .flatten() - .any(|install| install.path() == self.sys_base_prefix) + self.sys_base_prefix.starts_with(installations.root()) } /// Returns `Some` if the environment is externally managed, optionally including an error diff --git a/crates/uv-python/src/lib.rs b/crates/uv-python/src/lib.rs index ea6f0db619837..2461f90068069 100644 --- a/crates/uv-python/src/lib.rs +++ b/crates/uv-python/src/lib.rs @@ -8,7 +8,7 @@ use uv_static::EnvVars; pub use crate::discovery::{ EnvironmentPreference, Error as DiscoveryError, PythonDownloads, PythonNotFound, PythonPreference, PythonRequest, PythonSource, PythonVariant, VersionRequest, - find_python_installations, + find_python_installations, satisfies_python_preference, }; pub use crate::downloads::PlatformRequest; pub use crate::environment::{InvalidEnvironmentKind, PythonEnvironment}; diff --git a/crates/uv-static/src/env_vars.rs b/crates/uv-static/src/env_vars.rs index 2809626181eea..d791691f82352 100644 --- a/crates/uv-static/src/env_vars.rs +++ b/crates/uv-static/src/env_vars.rs @@ -367,6 +367,14 @@ impl EnvVars { #[attr_hidden] pub const UV_INTERNAL__SHOW_DERIVATION_TREE: &'static str = "UV_INTERNAL__SHOW_DERIVATION_TREE"; + /// Used to set a temporary directory for some tests. + #[attr_hidden] + pub const UV_INTERNAL__TEST_DIR: &'static str = "UV_INTERNAL__TEST_DIR"; + + /// Used to force treating an interpreter as "managed" during tests. + #[attr_hidden] + pub const UV_INTERNAL__TEST_PYTHON_MANAGED: &'static str = "UV_INTERNAL__TEST_PYTHON_MANAGED"; + /// Path to system-level configuration directory on Unix systems. pub const XDG_CONFIG_DIRS: &'static str = "XDG_CONFIG_DIRS"; diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 23655c1ca212d..cce02a70bb3ef 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -30,8 +30,8 @@ use uv_pep508::MarkerTreeContents; use uv_pypi_types::{ConflictPackage, ConflictSet, Conflicts}; use uv_python::{ EnvironmentPreference, Interpreter, InvalidEnvironmentKind, PythonDownloads, PythonEnvironment, - PythonInstallation, PythonPreference, PythonRequest, PythonVariant, PythonVersionFile, - VersionFileDiscoveryOptions, VersionRequest, + PythonInstallation, PythonPreference, PythonRequest, PythonSource, PythonVariant, + PythonVersionFile, VersionFileDiscoveryOptions, VersionRequest, satisfies_python_preference, }; use uv_requirements::upgrade::{LockedRequirements, read_lock_requirements}; use uv_requirements::{NamedRequirementsResolver, RequirementsSpecification}; @@ -664,6 +664,7 @@ impl ScriptInterpreter { &venv, EnvironmentKind::Script, python_request.as_ref(), + python_preference, requires_python .as_ref() .map(|(requires_python, _)| requires_python), @@ -794,6 +795,9 @@ pub(crate) enum EnvironmentIncompatibilityError { "The interpreter in the {0} environment has a different version ({1}) than it was created with ({2})" )] PyenvVersionConflict(EnvironmentKind, Version, Version), + + #[error("The {0} environment's Python interpreter does not meet the Python preference: `{1}`")] + PythonPreference(EnvironmentKind, PythonPreference), } /// Whether an environment is usable for a project or script, i.e., if it matches the requirements. @@ -801,6 +805,7 @@ fn environment_is_usable( environment: &PythonEnvironment, kind: EnvironmentKind, python_request: Option<&PythonRequest>, + python_preference: PythonPreference, requires_python: Option<&RequiresPython>, cache: &Cache, ) -> Result<(), EnvironmentIncompatibilityError> { @@ -836,6 +841,22 @@ fn environment_is_usable( } } + if satisfies_python_preference( + PythonSource::DiscoveredEnvironment, + environment.interpreter(), + python_preference, + ) { + trace!( + "The virtual environment's Python interpreter meets the Python preference: `{}`", + python_preference + ); + } else { + return Err(EnvironmentIncompatibilityError::PythonPreference( + kind, + python_preference, + )); + } + Ok(()) } @@ -889,6 +910,7 @@ impl ProjectInterpreter { &venv, EnvironmentKind::Project, python_request.as_ref(), + python_preference, requires_python.as_ref(), cache, ) { diff --git a/crates/uv/tests/it/common/mod.rs b/crates/uv/tests/it/common/mod.rs index 9d3c1428fd591..08eeec3aa2a15 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -187,6 +187,18 @@ impl TestContext { "virtual environments, managed installations, search path, or registry".to_string(), "[PYTHON SOURCES]".to_string(), )); + self.filters.push(( + "virtual environments, search path, or registry".to_string(), + "[PYTHON SOURCES]".to_string(), + )); + self.filters.push(( + "virtual environments, registry, or search path".to_string(), + "[PYTHON SOURCES]".to_string(), + )); + self.filters.push(( + "virtual environments or search path".to_string(), + "[PYTHON SOURCES]".to_string(), + )); self.filters.push(( "managed installations or search path".to_string(), "[PYTHON SOURCES]".to_string(), @@ -415,6 +427,15 @@ impl TestContext { self } + pub fn with_versions_as_managed(mut self, versions: &[&str]) -> Self { + self.extra_env.push(( + EnvVars::UV_INTERNAL__TEST_PYTHON_MANAGED.into(), + versions.iter().join(" ").into(), + )); + + self + } + /// Clear filters on `TestContext`. pub fn clear_filters(mut self) -> Self { self.filters.clear(); diff --git a/crates/uv/tests/it/pip_install.rs b/crates/uv/tests/it/pip_install.rs index 9cd394bbd6467..2a7b0f4049cc1 100644 --- a/crates/uv/tests/it/pip_install.rs +++ b/crates/uv/tests/it/pip_install.rs @@ -11684,3 +11684,58 @@ fn strip_shebang_arguments() -> Result<()> { Ok(()) } + +#[test] +fn install_python_preference() { + let context = + TestContext::new_with_versions(&["3.12", "3.11"]).with_versions_as_managed(&["3.12"]); + + // Create a managed interpreter environment + uv_snapshot!(context.filters(), context.venv(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: .venv + Activate with: source .venv/[BIN]/activate + "); + + // Install a package, requesting managed Python + uv_snapshot!(context.filters(), context.pip_install().arg("anyio").arg("--managed-python"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + Prepared 3 packages in [TIME] + Installed 3 packages in [TIME] + + anyio==4.3.0 + + idna==3.6 + + sniffio==1.3.1 + "); + + // Install a package, requesting unmanaged Python + // This is allowed, because the virtual environment already exists + uv_snapshot!(context.filters(), context.pip_install().arg("anyio").arg("--no-managed-python"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Audited 1 package in [TIME] + "); + + // This also works with `VIRTUAL_ENV` unset + uv_snapshot!(context.filters(), context.pip_install() + .arg("anyio").arg("--no-managed-python").env_remove("VIRTUAL_ENV"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Audited 1 package in [TIME] + "); +} diff --git a/crates/uv/tests/it/python_find.rs b/crates/uv/tests/it/python_find.rs index 49e60c068f3bf..41eceeb922542 100644 --- a/crates/uv/tests/it/python_find.rs +++ b/crates/uv/tests/it/python_find.rs @@ -728,6 +728,57 @@ fn python_find_venv_invalid() { "###); } +#[test] +fn python_find_managed() { + let context: TestContext = TestContext::new_with_versions(&["3.11", "3.12"]) + .with_filtered_python_sources() + .with_versions_as_managed(&["3.12"]); + + // We find the managed interpreter + uv_snapshot!(context.filters(), context.python_find().arg("--managed-python"), @r" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-3.12] + + ----- stderr ----- + "); + + // Request an interpreter that cannot be satisfied + uv_snapshot!(context.filters(), context.python_find().arg("--managed-python").arg("3.11"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No interpreter found for Python 3.11 in virtual environments or managed installations + "); + + let context: TestContext = TestContext::new_with_versions(&["3.11", "3.12"]) + .with_filtered_python_sources() + .with_versions_as_managed(&["3.11"]); + + // We find the unmanaged interpreter + uv_snapshot!(context.filters(), context.python_find().arg("--no-managed-python"), @r" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-3.12] + + ----- stderr ----- + "); + + // Request an interpreter that cannot be satisfied + uv_snapshot!(context.filters(), context.python_find().arg("--no-managed-python").arg("3.11"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No interpreter found for Python 3.11 in [PYTHON SOURCES] + "); +} + /// See: /// /// This test will not succeed on macOS if using a Homebrew provided interpreter. The interpreter diff --git a/crates/uv/tests/it/run.rs b/crates/uv/tests/it/run.rs index 6a1eb60934764..ad8672788df36 100644 --- a/crates/uv/tests/it/run.rs +++ b/crates/uv/tests/it/run.rs @@ -5500,3 +5500,49 @@ fn run_no_sync_incompatible_python() -> Result<()> { Ok(()) } + +#[test] +fn run_python_preference_no_project() { + let context = + TestContext::new_with_versions(&["3.12", "3.11"]).with_versions_as_managed(&["3.12"]); + + context.venv().assert().success(); + + uv_snapshot!(context.filters(), context.run().arg("python").arg("--version"), @r" + success: true + exit_code: 0 + ----- stdout ----- + Python 3.12.[X] + + ----- stderr ----- + "); + + uv_snapshot!(context.filters(), context.run().arg("--managed-python").arg("python").arg("--version"), @r" + success: true + exit_code: 0 + ----- stdout ----- + Python 3.12.[X] + + ----- stderr ----- + "); + + // `VIRTUAL_ENV` is set here, so we'll ignore the flag + uv_snapshot!(context.filters(), context.run().arg("--no-managed-python").arg("python").arg("--version"), @r" + success: true + exit_code: 0 + ----- stdout ----- + Python 3.12.[X] + + ----- stderr ----- + "); + + // If we remove the `VIRTUAL_ENV` variable, we should get the unmanaged Python + uv_snapshot!(context.filters(), context.run().arg("--no-managed-python").arg("python").arg("--version").env_remove("VIRTUAL_ENV"), @r" + success: true + exit_code: 0 + ----- stdout ----- + Python 3.11.[X] + + ----- stderr ----- + "); +} diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 35a06ea57b07d..3544f1961cc08 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -10804,3 +10804,144 @@ fn undeclared_editable() -> Result<()> { Ok(()) } + +#[test] +fn sync_python_preference() -> Result<()> { + let context = TestContext::new_with_versions(&["3.12", "3.11"]); + + 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 = [] + "#, + )?; + + // Run an initial sync, with 3.12 as an "unmanaged" interpreter + context.sync().assert().success(); + + // Mark 3.12 as a managed interpreter for the rest of the tests + let context = context.with_versions_as_managed(&["3.12"]); + uv_snapshot!(context.filters(), context.sync(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Audited in [TIME] + "); + + // We should invalidate the environment and switch to 3.11 + uv_snapshot!(context.filters(), context.sync().arg("--no-managed-python"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.11.[X] interpreter at: [PYTHON-3.11] + Removed virtual environment at: .venv + Creating virtual environment at: .venv + Resolved 1 package in [TIME] + Audited in [TIME] + "); + + // We will use the environment if it exists + uv_snapshot!(context.filters(), context.sync(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Audited in [TIME] + "); + + // Unless the user requests a Python preference that is incompatible + uv_snapshot!(context.filters(), context.sync().arg("--managed-python"), @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] + "); + + // If a interpreter cannot be found, we'll fail + uv_snapshot!(context.filters(), context.sync().arg("--managed-python").arg("-p").arg("3.11"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No interpreter found for Python 3.11 in managed installations + + hint: A managed Python download is available for Python 3.11, but Python downloads are set to 'never' + "); + + 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 = [] + + [tool.uv] + python-preference = "only-system" + "#, + )?; + + // We'll respect a `python-preference` in the `pyproject.toml` file + uv_snapshot!(context.filters(), context.sync(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.11.[X] interpreter at: [PYTHON-3.11] + Removed virtual environment at: .venv + Creating virtual environment at: .venv + Resolved 1 package in [TIME] + Audited in [TIME] + "); + + // But it can be overridden via the CLI + uv_snapshot!(context.filters(), context.sync().arg("--managed-python"), @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] + "); + + // `uv run` will invalidate the environment too + uv_snapshot!(context.filters(), context.run().arg("python").arg("--version"), @r" + success: true + exit_code: 0 + ----- stdout ----- + Python 3.11.[X] + + ----- stderr ----- + Using CPython 3.11.[X] interpreter at: [PYTHON-3.11] + Removed virtual environment at: .venv + Creating virtual environment at: .venv + Resolved 1 package in [TIME] + Audited in [TIME] + "); + + Ok(()) +} diff --git a/crates/uv/tests/it/venv.rs b/crates/uv/tests/it/venv.rs index 2430e607d7abf..120d7def2dfd0 100644 --- a/crates/uv/tests/it/venv.rs +++ b/crates/uv/tests/it/venv.rs @@ -1322,3 +1322,69 @@ fn create_venv_apostrophe() { let stdout = String::from_utf8_lossy(&output.stdout); assert_eq!(stdout.trim(), venv_dir.to_string_lossy()); } + +#[test] +fn venv_python_preference() { + let context = + TestContext::new_with_versions(&["3.12", "3.11"]).with_versions_as_managed(&["3.12"]); + + // Create a managed interpreter environment + uv_snapshot!(context.filters(), context.venv(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: .venv + Activate with: source .venv/[BIN]/activate + "); + + uv_snapshot!(context.filters(), context.venv().arg("--no-managed-python"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.11.[X] interpreter at: [PYTHON-3.11] + Creating virtual environment at: .venv + warning: A virtual environment already exists at `.venv`. In the future, uv will require `--clear` to replace it + Activate with: source .venv/[BIN]/activate + "); + + uv_snapshot!(context.filters(), context.venv().arg("--no-managed-python"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.11.[X] interpreter at: [PYTHON-3.11] + Creating virtual environment at: .venv + warning: A virtual environment already exists at `.venv`. In the future, uv will require `--clear` to replace it + Activate with: source .venv/[BIN]/activate + "); + + uv_snapshot!(context.filters(), context.venv(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: .venv + warning: A virtual environment already exists at `.venv`. In the future, uv will require `--clear` to replace it + Activate with: source .venv/[BIN]/activate + "); + + uv_snapshot!(context.filters(), context.venv().arg("--managed-python"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: .venv + warning: A virtual environment already exists at `.venv`. In the future, uv will require `--clear` to replace it + Activate with: source .venv/[BIN]/activate + "); +}