Discover and prefer the parent interpreter when invoked with python -m uv (#3736)

Closes #2222
Closes https://github.com/astral-sh/uv/issues/2058
Replaces https://github.com/astral-sh/uv/pull/2338
See also https://github.com/astral-sh/uv/issues/2649

We use an environment variable (`UV_INTERNAL__PARENT_INTERPRETER`) to
track the invoking interpreter when `python -m uv` is used. The parent
interpreter is preferred over all other sources (though it will be
skipped if it does not meet a `--python` request or if `--system` is
used and it belongs to a virtual environment). We warn if `--system` is
not provided and this interpreter would mutate system packages, but
allow it.
This commit is contained in:
Zanie Blue 2024-05-22 12:34:24 -04:00 committed by GitHub
parent b92321bd2d
commit 5fe891082d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 296 additions and 40 deletions

View file

@ -62,8 +62,10 @@ pub enum VersionRequest {
/// The policy for discovery of "system" Python interpreters.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SystemPython {
/// Do not allow a system Python
/// Only allow a system Python if passed directly i.e. via [`InterpreterSource::ProvidedPath`] or [`InterpreterSource::ParentInterpreter`]
#[default]
Explicit,
/// Do not allow a system Python
Disallowed,
/// Allow a system Python to be used if no virtual environment is active.
Allowed,
@ -125,8 +127,9 @@ pub enum InterpreterSource {
PyLauncher,
/// The interpreter was found in the uv toolchain directory
ManagedToolchain,
/// The interpreter invoked uv i.e. via `python -m uv ...`
ParentInterpreter,
// TODO(zanieb): Add support for fetching the interpreter from a remote source
// TODO(zanieb): Add variant for: The interpreter path was inherited from the parent process
}
#[derive(Error, Debug)]
@ -158,6 +161,7 @@ pub enum Error {
///
/// In order, we look in:
///
/// - The spawning interpreter
/// - The active environment
/// - A discovered environment (e.g. `.venv`)
/// - Installed managed toolchains
@ -179,14 +183,22 @@ fn python_executables<'a>(
) -> impl Iterator<Item = Result<(InterpreterSource, PathBuf), Error>> + 'a {
// Note we are careful to ensure the iterator chain is lazy to avoid unnecessary work
// (1) The active environment
sources.contains(InterpreterSource::ActiveEnvironment).then(||
virtualenv_from_env()
// (1) The parent interpreter
sources.contains(InterpreterSource::ParentInterpreter).then(||
std::env::var_os("UV_INTERNAL__PARENT_INTERPRETER")
.into_iter()
.map(virtualenv_python_executable)
.map(|path| Ok((InterpreterSource::ActiveEnvironment, path)))
.map(|path| Ok((InterpreterSource::ParentInterpreter, PathBuf::from(path))))
).into_iter().flatten()
// (2) A discovered environment
// (2) The active environment
.chain(
sources.contains(InterpreterSource::ActiveEnvironment).then(||
virtualenv_from_env()
.into_iter()
.map(virtualenv_python_executable)
.map(|path| Ok((InterpreterSource::ActiveEnvironment, path)))
).into_iter().flatten()
)
// (3) A discovered environment
.chain(
sources.contains(InterpreterSource::DiscoveredEnvironment).then(||
std::iter::once(
@ -201,7 +213,7 @@ fn python_executables<'a>(
).flatten_ok()
).into_iter().flatten()
)
// (3) Managed toolchains
// (4) Managed toolchains
.chain(
sources.contains(InterpreterSource::ManagedToolchain).then(move ||
std::iter::once(
@ -219,14 +231,14 @@ fn python_executables<'a>(
).flatten_ok()
).into_iter().flatten()
)
// (4) The search path
// (5) The search path
.chain(
sources.contains(InterpreterSource::SearchPath).then(move ||
python_executables_from_search_path(version, implementation)
.map(|path| Ok((InterpreterSource::SearchPath, path))),
).into_iter().flatten()
)
// (5) The `py` launcher (windows only)
// (6) The `py` launcher (windows only)
// TODO(konstin): Implement <https://peps.python.org/pep-0514/> to read python installations from the registry instead.
.chain(
(sources.contains(InterpreterSource::PyLauncher) && cfg!(windows)).then(||
@ -344,8 +356,27 @@ fn python_interpreters<'a>(
})
.filter(move |result| match result {
// Filter the returned interpreters to conform to the system request
Ok((_, interpreter)) => match (system, interpreter.is_virtualenv()) {
Ok((source, interpreter)) => match (system, interpreter.is_virtualenv()) {
(SystemPython::Allowed, _) => true,
(SystemPython::Explicit, false) => {
if matches!(
source,
InterpreterSource::ProvidedPath | InterpreterSource::ParentInterpreter
) {
debug!(
"Allowing system Python interpreter at `{}`",
interpreter.sys_executable().display()
);
true
} else {
debug!(
"Ignoring Python interpreter at `{}`: system intepreter not explicit",
interpreter.sys_executable().display()
);
false
}
}
(SystemPython::Explicit, true) => true,
(SystemPython::Disallowed, false) => {
debug!(
"Ignoring Python interpreter at `{}`: system intepreter not allowed",
@ -1073,31 +1104,22 @@ impl SourceSelector {
])
} else {
match system {
SystemPython::Allowed => Self::All,
SystemPython::Required => {
debug!("Excluding virtual environment Python due to system flag");
Self::from_sources([
InterpreterSource::ProvidedPath,
InterpreterSource::SearchPath,
#[cfg(windows)]
InterpreterSource::PyLauncher,
InterpreterSource::ManagedToolchain,
])
}
SystemPython::Disallowed => {
debug!("Only considering virtual environment Python interpreters");
Self::virtualenvs()
}
SystemPython::Allowed | SystemPython::Explicit => Self::All,
SystemPython::Required => Self::from_sources([
InterpreterSource::ProvidedPath,
InterpreterSource::SearchPath,
#[cfg(windows)]
InterpreterSource::PyLauncher,
InterpreterSource::ManagedToolchain,
InterpreterSource::ParentInterpreter,
]),
SystemPython::Disallowed => Self::from_sources([
InterpreterSource::DiscoveredEnvironment,
InterpreterSource::ActiveEnvironment,
]),
}
}
}
pub fn virtualenvs() -> Self {
Self::from_sources([
InterpreterSource::DiscoveredEnvironment,
InterpreterSource::ActiveEnvironment,
])
}
}
impl SystemPython {
@ -1138,6 +1160,7 @@ impl fmt::Display for InterpreterSource {
Self::SearchPath => f.write_str("search path"),
Self::PyLauncher => f.write_str("`py` launcher output"),
Self::ManagedToolchain => f.write_str("managed toolchains"),
Self::ParentInterpreter => f.write_str("parent interpreter"),
}
}
}

View file

@ -10,7 +10,9 @@ use uv_fs::{LockedFile, Simplified};
use crate::discovery::{InterpreterRequest, SourceSelector, SystemPython, VersionRequest};
use crate::virtualenv::{virtualenv_python_executable, PyVenvConfiguration};
use crate::{find_default_interpreter, find_interpreter, Error, Interpreter, Target};
use crate::{
find_default_interpreter, find_interpreter, Error, Interpreter, InterpreterSource, Target,
};
/// A Python environment, consisting of a Python [`Interpreter`] and its associated paths.
#[derive(Debug, Clone)]
@ -31,6 +33,14 @@ impl PythonEnvironment {
} else if system.is_preferred() {
Self::from_default_python(cache)
} else {
// First check for a parent intepreter
match Self::from_parent_interpreter(system, cache) {
Ok(env) => return Ok(env),
Err(Error::NotFound(_)) => {}
Err(err) => return Err(err),
}
// Then a virtual environment
match Self::from_virtualenv(cache) {
Ok(venv) => Ok(venv),
Err(Error::NotFound(_)) if system.is_allowed() => Self::from_default_python(cache),
@ -41,12 +51,15 @@ impl PythonEnvironment {
/// Create a [`PythonEnvironment`] for an existing virtual environment.
pub fn from_virtualenv(cache: &Cache) -> Result<Self, Error> {
let sources = SourceSelector::virtualenvs();
let sources = SourceSelector::from_sources([
InterpreterSource::DiscoveredEnvironment,
InterpreterSource::ActiveEnvironment,
]);
let request = InterpreterRequest::Version(VersionRequest::Default);
let found = find_interpreter(&request, SystemPython::Disallowed, &sources, cache)??;
debug_assert!(
found.interpreter().base_prefix() == found.interpreter().base_exec_prefix(),
found.interpreter().is_virtualenv(),
"Not a virtualenv (source: {}, prefix: {})",
found.source(),
found.interpreter().base_prefix().display()
@ -58,6 +71,18 @@ impl PythonEnvironment {
})))
}
/// Create a [`PythonEnvironment`] for the parent interpreter i.e. the executable in `python -m uv ...`
pub fn from_parent_interpreter(system: SystemPython, cache: &Cache) -> Result<Self, Error> {
let sources = SourceSelector::from_sources([InterpreterSource::ParentInterpreter]);
let request = InterpreterRequest::Version(VersionRequest::Default);
let found = find_interpreter(&request, system, &sources, cache)??;
Ok(Self(Arc::new(PythonEnvironmentShared {
root: found.interpreter().prefix().to_path_buf(),
interpreter: found.into_interpreter(),
})))
}
/// Create a [`PythonEnvironment`] from the virtual environment at the given root.
pub fn from_root(root: &Path, cache: &Cache) -> Result<Self, Error> {
let venv = match fs_err::canonicalize(root) {

View file

@ -1360,6 +1360,174 @@ mod tests {
Ok(())
}
#[test]
fn find_environment_from_parent_interpreter() -> Result<()> {
let tempdir = TempDir::new()?;
let cache = Cache::temp()?;
let pwd = tempdir.child("pwd");
pwd.create_dir_all()?;
let venv = mock_venv(&tempdir, "3.12.0")?;
let python = tempdir.child("python").to_path_buf();
create_mock_interpreter(
&python,
&PythonVersion::from_str("3.12.1").unwrap(),
ImplementationName::CPython,
true,
)?;
with_vars(
[
("UV_TEST_PYTHON_PATH", None),
("UV_BOOTSTRAP_DIR", None),
(
"PATH",
Some(simple_mock_interpreters(&tempdir, &["3.12.2", "3.12.3"])?),
),
("UV_INTERNAL__PARENT_INTERPRETER", Some(python.into())),
("VIRTUAL_ENV", Some(venv.into())),
("PWD", Some(pwd.path().into())),
],
|| {
let environment =
PythonEnvironment::find(None, crate::SystemPython::Allowed, &cache)
.expect("An environment is found");
assert_eq!(
environment.interpreter().python_full_version().to_string(),
"3.12.1",
"We should prefer parent interpreter"
);
},
);
Ok(())
}
#[test]
fn find_environment_from_parent_interpreter_system_explicit() -> Result<()> {
let tempdir = TempDir::new()?;
let cache = Cache::temp()?;
let pwd = tempdir.child("pwd");
pwd.create_dir_all()?;
let venv = mock_venv(&tempdir, "3.12.0")?;
let python = tempdir.child("python").to_path_buf();
create_mock_interpreter(
&python,
&PythonVersion::from_str("3.12.1").unwrap(),
ImplementationName::CPython,
true,
)?;
with_vars(
[
("UV_TEST_PYTHON_PATH", None),
("UV_BOOTSTRAP_DIR", None),
(
"PATH",
Some(simple_mock_interpreters(&tempdir, &["3.12.2", "3.12.3"])?),
),
("UV_INTERNAL__PARENT_INTERPRETER", Some(python.into())),
("VIRTUAL_ENV", Some(venv.into())),
("PWD", Some(pwd.path().into())),
],
|| {
let environment =
PythonEnvironment::find(None, crate::SystemPython::Explicit, &cache)
.expect("An environment is found");
assert_eq!(
environment.interpreter().python_full_version().to_string(),
"3.12.1",
"We prefer the parent interpreter even though it is system"
);
},
);
Ok(())
}
#[test]
fn find_environment_from_parent_interpreter_system_disallowed() -> Result<()> {
let tempdir = TempDir::new()?;
let cache = Cache::temp()?;
let pwd = tempdir.child("pwd");
pwd.create_dir_all()?;
let venv = mock_venv(&tempdir, "3.12.0")?;
let python = tempdir.child("python").to_path_buf();
create_mock_interpreter(
&python,
&PythonVersion::from_str("3.12.1").unwrap(),
ImplementationName::CPython,
true,
)?;
with_vars(
[
("UV_TEST_PYTHON_PATH", None),
("UV_BOOTSTRAP_DIR", None),
(
"PATH",
Some(simple_mock_interpreters(&tempdir, &["3.12.2", "3.12.3"])?),
),
("UV_INTERNAL__PARENT_INTERPRETER", Some(python.into())),
("VIRTUAL_ENV", Some(venv.into())),
("PWD", Some(pwd.path().into())),
],
|| {
let environment =
PythonEnvironment::find(None, crate::SystemPython::Disallowed, &cache)
.expect("An environment is found");
assert_eq!(
environment.interpreter().python_full_version().to_string(),
"3.12.0",
"We find the virtual environment Python because the system is explicitly not allowed"
);
},
);
Ok(())
}
#[test]
fn find_environment_from_parent_interpreter_system_required() -> Result<()> {
let tempdir = TempDir::new()?;
let cache = Cache::temp()?;
let pwd = tempdir.child("pwd");
pwd.create_dir_all()?;
let venv = mock_venv(&tempdir, "3.12.0")?;
let python = tempdir.child("python").to_path_buf();
create_mock_interpreter(
&python,
&PythonVersion::from_str("3.12.1").unwrap(),
ImplementationName::CPython,
false,
)?;
with_vars(
[
("UV_TEST_PYTHON_PATH", None),
("UV_BOOTSTRAP_DIR", None),
(
"PATH",
Some(simple_mock_interpreters(&tempdir, &["3.12.2", "3.12.3"])?),
),
("UV_INTERNAL__PARENT_INTERPRETER", Some(python.into())),
("VIRTUAL_ENV", Some(venv.into())),
("PWD", Some(pwd.path().into())),
],
|| {
let environment =
PythonEnvironment::find(None, crate::SystemPython::Required, &cache)
.expect("An environment is found");
assert_eq!(
environment.interpreter().python_full_version().to_string(),
"3.12.2",
"We should skip the parent interpreter since its in a virtual environment"
);
},
);
Ok(())
}
#[test]
fn find_environment_active_environment_skipped_if_system_required() -> Result<()> {
let tempdir = TempDir::new()?;
@ -1604,6 +1772,43 @@ mod tests {
Ok(())
}
#[test]
fn find_environment_allows_file_path_with_system_explicit() -> Result<()> {
let tempdir = TempDir::new()?;
let cache = Cache::temp()?;
tempdir.child("foo").create_dir_all()?;
let python = tempdir.child("foo").join("bar");
create_mock_interpreter(
&python,
&PythonVersion::from_str("3.10.0").unwrap(),
ImplementationName::default(),
true,
)?;
with_vars(
[
("UV_TEST_PYTHON_PATH", None::<OsString>),
("PATH", None),
("PWD", Some(tempdir.path().into())),
],
|| {
let environment = PythonEnvironment::find(
Some(python.to_str().expect("Test path is valid unicode")),
crate::SystemPython::Explicit,
&cache,
)
.expect("Environment should be found");
assert_eq!(
environment.interpreter().python_full_version().to_string(),
"3.10.0",
"We should find the interpreter"
);
},
);
Ok(())
}
#[test]
fn find_environment_does_not_allow_file_path_with_system_disallowed() -> Result<()> {
let tempdir = TempDir::new()?;

View file

@ -117,7 +117,7 @@ pub(crate) async fn pip_install(
let system = if system {
SystemPython::Required
} else {
SystemPython::Disallowed
SystemPython::Explicit
};
let venv = PythonEnvironment::find(python.as_deref(), system, &cache)?;

View file

@ -121,7 +121,7 @@ pub(crate) async fn pip_sync(
let system = if system {
SystemPython::Required
} else {
SystemPython::Disallowed
SystemPython::Explicit
};
let venv = PythonEnvironment::find(python.as_deref(), system, &cache)?;

View file

@ -46,7 +46,7 @@ pub(crate) async fn pip_uninstall(
let system = if system {
SystemPython::Required
} else {
SystemPython::Disallowed
SystemPython::Explicit
};
let venv = PythonEnvironment::find(python.as_deref(), system, &cache)?;

View file

@ -31,6 +31,9 @@ def _run() -> None:
if venv:
env.setdefault("VIRTUAL_ENV", venv)
# Let `uv` know that it was spawned by this Python interpreter
env["UV_INTERNAL__PARENT_INTERPRETER"] = sys.executable
if sys.platform == "win32":
import subprocess