Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
113 changes: 112 additions & 1 deletion crates/uv-python/src/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: This can be a method on PythonSource

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.
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 1 addition & 2 deletions crates/uv-python/src/environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)? {
Expand Down
23 changes: 18 additions & 5 deletions crates/uv-python/src/interpreter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/uv-python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
8 changes: 8 additions & 0 deletions crates/uv-static/src/env_vars.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
26 changes: 24 additions & 2 deletions crates/uv/src/commands/project/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -664,6 +664,7 @@ impl ScriptInterpreter {
&venv,
EnvironmentKind::Script,
python_request.as_ref(),
python_preference,
requires_python
.as_ref()
.map(|(requires_python, _)| requires_python),
Expand Down Expand Up @@ -794,13 +795,17 @@ 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.
fn environment_is_usable(
environment: &PythonEnvironment,
kind: EnvironmentKind,
python_request: Option<&PythonRequest>,
python_preference: PythonPreference,
requires_python: Option<&RequiresPython>,
cache: &Cache,
) -> Result<(), EnvironmentIncompatibilityError> {
Expand Down Expand Up @@ -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(())
}

Expand Down Expand Up @@ -889,6 +910,7 @@ impl ProjectInterpreter {
&venv,
EnvironmentKind::Project,
python_request.as_ref(),
python_preference,
requires_python.as_ref(),
cache,
) {
Expand Down
21 changes: 21 additions & 0 deletions crates/uv/tests/it/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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();
Expand Down
55 changes: 55 additions & 0 deletions crates/uv/tests/it/pip_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
");
}
Loading
Loading