Skip to content

Commit 118cfba

Browse files
committed
Validate that discovered interpreters meet the Python preference (#7934)
Closes #5144 e.g. ``` ❯ cargo run -q -- sync --python-preference only-system Using CPython 3.12.6 interpreter at: /opt/homebrew/opt/[email protected]/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 ```
1 parent ddafdb3 commit 118cfba

File tree

12 files changed

+544
-11
lines changed

12 files changed

+544
-11
lines changed

crates/uv-python/src/discovery.rs

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,7 +446,16 @@ fn python_executables_from_installed<'a>(
446446
.flatten();
447447

448448
match preference {
449-
PythonPreference::OnlyManaged => Box::new(from_managed_installations),
449+
PythonPreference::OnlyManaged => {
450+
// TODO(zanieb): Ideally, we'd create "fake" managed installation directories for tests,
451+
// but for now... we'll just include the test interpreters which are always on the
452+
// search path.
453+
if std::env::var(uv_static::EnvVars::UV_INTERNAL__TEST_PYTHON_MANAGED).is_ok() {
454+
Box::new(from_managed_installations.chain(from_search_path))
455+
} else {
456+
Box::new(from_managed_installations)
457+
}
458+
}
450459
PythonPreference::Managed => Box::new(
451460
from_managed_installations
452461
.chain(from_search_path)
@@ -730,6 +739,9 @@ fn python_interpreters<'a>(
730739
false
731740
}
732741
})
742+
.filter_ok(move |(source, interpreter)| {
743+
satisfies_python_preference(*source, interpreter, preference)
744+
})
733745
}
734746

735747
/// Lazily convert Python executables into interpreters.
@@ -857,6 +869,93 @@ fn source_satisfies_environment_preference(
857869
}
858870
}
859871

872+
/// Returns true if a Python interpreter matches the [`PythonPreference`].
873+
pub fn satisfies_python_preference(
874+
source: PythonSource,
875+
interpreter: &Interpreter,
876+
preference: PythonPreference,
877+
) -> bool {
878+
// If the source is "explicit", we will not apply the Python preference, e.g., if the user has
879+
// activated a virtual environment, we should always allow it. We may want to invalidate the
880+
// environment in some cases, like in projects, but we can't distinguish between explicit
881+
// requests for a different Python preference or a persistent preference in a configuration file
882+
// which would result in overly aggressive invalidation.
883+
let is_explicit = match source {
884+
PythonSource::ProvidedPath
885+
| PythonSource::ParentInterpreter
886+
| PythonSource::ActiveEnvironment
887+
| PythonSource::CondaPrefix => true,
888+
PythonSource::Managed
889+
| PythonSource::DiscoveredEnvironment
890+
| PythonSource::SearchPath
891+
| PythonSource::SearchPathFirst
892+
| PythonSource::Registry
893+
| PythonSource::MicrosoftStore
894+
| PythonSource::BaseCondaPrefix => false,
895+
};
896+
897+
match preference {
898+
PythonPreference::OnlyManaged => {
899+
// Perform a fast check using the source before querying the interpreter
900+
if matches!(source, PythonSource::Managed) || interpreter.is_managed() {
901+
true
902+
} else {
903+
if is_explicit {
904+
debug!(
905+
"Allowing unmanaged Python interpreter at `{}` (in conflict with the `python-preference`) since it is from source: {source}",
906+
interpreter.sys_executable().display()
907+
);
908+
true
909+
} else {
910+
debug!(
911+
"Ignoring Python interpreter at `{}`: only managed interpreters allowed",
912+
interpreter.sys_executable().display()
913+
);
914+
false
915+
}
916+
}
917+
}
918+
// If not "only" a kind, any interpreter is okay
919+
PythonPreference::Managed | PythonPreference::System => true,
920+
PythonPreference::OnlySystem => {
921+
let is_system = match source {
922+
// A managed interpreter is never a system interpreter
923+
PythonSource::Managed => false,
924+
// We can't be sure if this is a system interpreter without checking
925+
PythonSource::ProvidedPath
926+
| PythonSource::ParentInterpreter
927+
| PythonSource::ActiveEnvironment
928+
| PythonSource::CondaPrefix
929+
| PythonSource::DiscoveredEnvironment
930+
| PythonSource::SearchPath
931+
| PythonSource::SearchPathFirst
932+
| PythonSource::Registry
933+
| PythonSource::BaseCondaPrefix => !interpreter.is_managed(),
934+
// Managed interpreters should never be found in the store
935+
PythonSource::MicrosoftStore => true,
936+
};
937+
938+
if is_system {
939+
true
940+
} else {
941+
if is_explicit {
942+
debug!(
943+
"Allowing managed Python interpreter at `{}` (in conflict with the `python-preference`) since it is from source: {source}",
944+
interpreter.sys_executable().display()
945+
);
946+
true
947+
} else {
948+
debug!(
949+
"Ignoring Python interpreter at `{}`: only system interpreters allowed",
950+
interpreter.sys_executable().display()
951+
);
952+
false
953+
}
954+
}
955+
}
956+
}
957+
}
958+
860959
/// Check if an encountered error is critical and should stop discovery.
861960
///
862961
/// 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 {
28122911
}
28132912
}
28142913
}
2914+
2915+
/// Return the canonical name.
2916+
// TODO(zanieb): This should be a `Display` impl and we should have a different view for
2917+
// the sources
2918+
pub fn canonical_name(&self) -> &'static str {
2919+
match self {
2920+
Self::OnlyManaged => "only managed",
2921+
Self::Managed => "prefer managed",
2922+
Self::System => "prefer system",
2923+
Self::OnlySystem => "only system",
2924+
}
2925+
}
28152926
}
28162927

28172928
impl fmt::Display for PythonPreference {

crates/uv-python/src/environment.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,7 @@ impl PythonEnvironment {
158158
let installation = match find_python_installation(
159159
request,
160160
preference,
161-
// Ignore managed installations when looking for environments
162-
PythonPreference::OnlySystem,
161+
PythonPreference::default(),
163162
cache,
164163
preview,
165164
)? {

crates/uv-python/src/interpreter.rs

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -271,15 +271,28 @@ impl Interpreter {
271271
///
272272
/// Returns `false` if we cannot determine the path of the uv managed Python interpreters.
273273
pub fn is_managed(&self) -> bool {
274+
if let Ok(test_managed) =
275+
std::env::var(uv_static::EnvVars::UV_INTERNAL__TEST_PYTHON_MANAGED)
276+
{
277+
// During testing, we collect interpreters into an artificial search path and need to
278+
// be able to mock whether an interpreter is managed or not.
279+
return test_managed.split_ascii_whitespace().any(|item| {
280+
let version = <PythonVersion as std::str::FromStr>::from_str(item).expect(
281+
"`UV_INTERNAL__TEST_PYTHON_MANAGED` items should be valid Python versions",
282+
);
283+
if version.patch().is_some() {
284+
version.version() == self.python_version()
285+
} else {
286+
(version.major(), version.minor()) == self.python_tuple()
287+
}
288+
});
289+
}
290+
274291
let Ok(installations) = ManagedPythonInstallations::from_settings(None) else {
275292
return false;
276293
};
277294

278-
installations
279-
.find_all()
280-
.into_iter()
281-
.flatten()
282-
.any(|install| install.path() == self.sys_base_prefix)
295+
self.sys_base_prefix.starts_with(installations.root())
283296
}
284297

285298
/// Returns `Some` if the environment is externally managed, optionally including an error

crates/uv-python/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use uv_static::EnvVars;
88
pub use crate::discovery::{
99
EnvironmentPreference, Error as DiscoveryError, PythonDownloads, PythonNotFound,
1010
PythonPreference, PythonRequest, PythonSource, PythonVariant, VersionRequest,
11-
find_python_installations,
11+
find_python_installations, satisfies_python_preference,
1212
};
1313
pub use crate::downloads::PlatformRequest;
1414
pub use crate::environment::{InvalidEnvironmentKind, PythonEnvironment};

crates/uv-static/src/env_vars.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,14 @@ impl EnvVars {
376376
#[attr_hidden]
377377
pub const UV_INTERNAL__SHOW_DERIVATION_TREE: &'static str = "UV_INTERNAL__SHOW_DERIVATION_TREE";
378378

379+
/// Used to set a temporary directory for some tests.
380+
#[attr_hidden]
381+
pub const UV_INTERNAL__TEST_DIR: &'static str = "UV_INTERNAL__TEST_DIR";
382+
383+
/// Used to force treating an interpreter as "managed" during tests.
384+
#[attr_hidden]
385+
pub const UV_INTERNAL__TEST_PYTHON_MANAGED: &'static str = "UV_INTERNAL__TEST_PYTHON_MANAGED";
386+
379387
/// Path to system-level configuration directory on Unix systems.
380388
pub const XDG_CONFIG_DIRS: &'static str = "XDG_CONFIG_DIRS";
381389

crates/uv/src/commands/project/mod.rs

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ use uv_pep508::MarkerTreeContents;
3030
use uv_pypi_types::{ConflictPackage, ConflictSet, Conflicts};
3131
use uv_python::{
3232
EnvironmentPreference, Interpreter, InvalidEnvironmentKind, PythonDownloads, PythonEnvironment,
33-
PythonInstallation, PythonPreference, PythonRequest, PythonVariant, PythonVersionFile,
34-
VersionFileDiscoveryOptions, VersionRequest,
33+
PythonInstallation, PythonPreference, PythonRequest, PythonSource, PythonVariant,
34+
PythonVersionFile, VersionFileDiscoveryOptions, VersionRequest, satisfies_python_preference,
3535
};
3636
use uv_requirements::upgrade::{LockedRequirements, read_lock_requirements};
3737
use uv_requirements::{NamedRequirementsResolver, RequirementsSpecification};
@@ -664,6 +664,7 @@ impl ScriptInterpreter {
664664
&venv,
665665
EnvironmentKind::Script,
666666
python_request.as_ref(),
667+
python_preference,
667668
requires_python
668669
.as_ref()
669670
.map(|(requires_python, _)| requires_python),
@@ -794,13 +795,17 @@ pub(crate) enum EnvironmentIncompatibilityError {
794795
"The interpreter in the {0} environment has a different version ({1}) than it was created with ({2})"
795796
)]
796797
PyenvVersionConflict(EnvironmentKind, Version, Version),
798+
799+
#[error("The {0} environment's Python interpreter does not meet the Python preference: `{1}`")]
800+
PythonPreference(EnvironmentKind, PythonPreference),
797801
}
798802

799803
/// Whether an environment is usable for a project or script, i.e., if it matches the requirements.
800804
fn environment_is_usable(
801805
environment: &PythonEnvironment,
802806
kind: EnvironmentKind,
803807
python_request: Option<&PythonRequest>,
808+
python_preference: PythonPreference,
804809
requires_python: Option<&RequiresPython>,
805810
cache: &Cache,
806811
) -> Result<(), EnvironmentIncompatibilityError> {
@@ -836,6 +841,22 @@ fn environment_is_usable(
836841
}
837842
}
838843

844+
if satisfies_python_preference(
845+
PythonSource::DiscoveredEnvironment,
846+
environment.interpreter(),
847+
python_preference,
848+
) {
849+
trace!(
850+
"The virtual environment's Python interpreter meets the Python preference: `{}`",
851+
python_preference
852+
);
853+
} else {
854+
return Err(EnvironmentIncompatibilityError::PythonPreference(
855+
kind,
856+
python_preference,
857+
));
858+
}
859+
839860
Ok(())
840861
}
841862

@@ -889,6 +910,7 @@ impl ProjectInterpreter {
889910
&venv,
890911
EnvironmentKind::Project,
891912
python_request.as_ref(),
913+
python_preference,
892914
requires_python.as_ref(),
893915
cache,
894916
) {

crates/uv/tests/it/common/mod.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,18 @@ impl TestContext {
187187
"virtual environments, managed installations, search path, or registry".to_string(),
188188
"[PYTHON SOURCES]".to_string(),
189189
));
190+
self.filters.push((
191+
"virtual environments, search path, or registry".to_string(),
192+
"[PYTHON SOURCES]".to_string(),
193+
));
194+
self.filters.push((
195+
"virtual environments, registry, or search path".to_string(),
196+
"[PYTHON SOURCES]".to_string(),
197+
));
198+
self.filters.push((
199+
"virtual environments or search path".to_string(),
200+
"[PYTHON SOURCES]".to_string(),
201+
));
190202
self.filters.push((
191203
"managed installations or search path".to_string(),
192204
"[PYTHON SOURCES]".to_string(),
@@ -415,6 +427,15 @@ impl TestContext {
415427
self
416428
}
417429

430+
pub fn with_versions_as_managed(mut self, versions: &[&str]) -> Self {
431+
self.extra_env.push((
432+
EnvVars::UV_INTERNAL__TEST_PYTHON_MANAGED.into(),
433+
versions.iter().join(" ").into(),
434+
));
435+
436+
self
437+
}
438+
418439
/// Clear filters on `TestContext`.
419440
pub fn clear_filters(mut self) -> Self {
420441
self.filters.clear();

crates/uv/tests/it/pip_install.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11684,3 +11684,58 @@ fn strip_shebang_arguments() -> Result<()> {
1168411684

1168511685
Ok(())
1168611686
}
11687+
11688+
#[test]
11689+
fn install_python_preference() {
11690+
let context =
11691+
TestContext::new_with_versions(&["3.12", "3.11"]).with_versions_as_managed(&["3.12"]);
11692+
11693+
// Create a managed interpreter environment
11694+
uv_snapshot!(context.filters(), context.venv(), @r"
11695+
success: true
11696+
exit_code: 0
11697+
----- stdout -----
11698+
11699+
----- stderr -----
11700+
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
11701+
Creating virtual environment at: .venv
11702+
Activate with: source .venv/[BIN]/activate
11703+
");
11704+
11705+
// Install a package, requesting managed Python
11706+
uv_snapshot!(context.filters(), context.pip_install().arg("anyio").arg("--managed-python"), @r"
11707+
success: true
11708+
exit_code: 0
11709+
----- stdout -----
11710+
11711+
----- stderr -----
11712+
Resolved 3 packages in [TIME]
11713+
Prepared 3 packages in [TIME]
11714+
Installed 3 packages in [TIME]
11715+
+ anyio==4.3.0
11716+
+ idna==3.6
11717+
+ sniffio==1.3.1
11718+
");
11719+
11720+
// Install a package, requesting unmanaged Python
11721+
// This is allowed, because the virtual environment already exists
11722+
uv_snapshot!(context.filters(), context.pip_install().arg("anyio").arg("--no-managed-python"), @r"
11723+
success: true
11724+
exit_code: 0
11725+
----- stdout -----
11726+
11727+
----- stderr -----
11728+
Audited 1 package in [TIME]
11729+
");
11730+
11731+
// This also works with `VIRTUAL_ENV` unset
11732+
uv_snapshot!(context.filters(), context.pip_install()
11733+
.arg("anyio").arg("--no-managed-python").env_remove("VIRTUAL_ENV"), @r"
11734+
success: true
11735+
exit_code: 0
11736+
----- stdout -----
11737+
11738+
----- stderr -----
11739+
Audited 1 package in [TIME]
11740+
");
11741+
}

0 commit comments

Comments
 (0)