Validate that discovered interpreters meet the Python preference (#7934)

Closes https://github.com/astral-sh/uv/issues/5144

e.g.

```
❯ cargo run -q -- sync --python-preference only-system
Using CPython 3.12.6 interpreter at: /opt/homebrew/opt/python@3.12/bin/python3.12
Removed virtual environment at: .venv
Creating virtual environment at: .venv
Resolved 9 packages in 14ms
Installed 8 packages in 9ms
 + anyio==4.6.0
 + certifi==2024.8.30
 + h11==0.14.0
 + httpcore==1.0.5
 + httpx==0.27.2
 + idna==3.10
 + ruff==0.6.7
 + sniffio==1.3.1

❯ cargo run -q -- sync --python-preference only-managed
Using CPython 3.12.1
Removed virtual environment at: .venv
Creating virtual environment at: .venv
Resolved 9 packages in 14ms
Installed 8 packages in 11ms
 + anyio==4.6.0
 + certifi==2024.8.30
 + h11==0.14.0
 + httpcore==1.0.5
 + httpx==0.27.2
 + idna==3.10
 + ruff==0.6.7
 + sniffio==1.3.1
```
This commit is contained in:
Zanie Blue 2025-07-16 15:31:47 -05:00
parent 2df06ebfbc
commit b98ac8c224
12 changed files with 544 additions and 11 deletions

View file

@ -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 {

View file

@ -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,
)? {

View file

@ -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 = <PythonVersion as std::str::FromStr>::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

View file

@ -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};