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. /// The policy for discovery of "system" Python interpreters.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SystemPython { 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] #[default]
Explicit,
/// Do not allow a system Python
Disallowed, Disallowed,
/// Allow a system Python to be used if no virtual environment is active. /// Allow a system Python to be used if no virtual environment is active.
Allowed, Allowed,
@ -125,8 +127,9 @@ pub enum InterpreterSource {
PyLauncher, PyLauncher,
/// The interpreter was found in the uv toolchain directory /// The interpreter was found in the uv toolchain directory
ManagedToolchain, 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 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)] #[derive(Error, Debug)]
@ -158,6 +161,7 @@ pub enum Error {
/// ///
/// In order, we look in: /// In order, we look in:
/// ///
/// - The spawning interpreter
/// - The active environment /// - The active environment
/// - A discovered environment (e.g. `.venv`) /// - A discovered environment (e.g. `.venv`)
/// - Installed managed toolchains /// - Installed managed toolchains
@ -179,14 +183,22 @@ fn python_executables<'a>(
) -> impl Iterator<Item = Result<(InterpreterSource, PathBuf), Error>> + 'a { ) -> impl Iterator<Item = Result<(InterpreterSource, PathBuf), Error>> + 'a {
// Note we are careful to ensure the iterator chain is lazy to avoid unnecessary work // Note we are careful to ensure the iterator chain is lazy to avoid unnecessary work
// (1) The active environment // (1) The parent interpreter
sources.contains(InterpreterSource::ActiveEnvironment).then(|| sources.contains(InterpreterSource::ParentInterpreter).then(||
virtualenv_from_env() std::env::var_os("UV_INTERNAL__PARENT_INTERPRETER")
.into_iter() .into_iter()
.map(virtualenv_python_executable) .map(|path| Ok((InterpreterSource::ParentInterpreter, PathBuf::from(path))))
.map(|path| Ok((InterpreterSource::ActiveEnvironment, path)))
).into_iter().flatten() ).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( .chain(
sources.contains(InterpreterSource::DiscoveredEnvironment).then(|| sources.contains(InterpreterSource::DiscoveredEnvironment).then(||
std::iter::once( std::iter::once(
@ -201,7 +213,7 @@ fn python_executables<'a>(
).flatten_ok() ).flatten_ok()
).into_iter().flatten() ).into_iter().flatten()
) )
// (3) Managed toolchains // (4) Managed toolchains
.chain( .chain(
sources.contains(InterpreterSource::ManagedToolchain).then(move || sources.contains(InterpreterSource::ManagedToolchain).then(move ||
std::iter::once( std::iter::once(
@ -219,14 +231,14 @@ fn python_executables<'a>(
).flatten_ok() ).flatten_ok()
).into_iter().flatten() ).into_iter().flatten()
) )
// (4) The search path // (5) The search path
.chain( .chain(
sources.contains(InterpreterSource::SearchPath).then(move || sources.contains(InterpreterSource::SearchPath).then(move ||
python_executables_from_search_path(version, implementation) python_executables_from_search_path(version, implementation)
.map(|path| Ok((InterpreterSource::SearchPath, path))), .map(|path| Ok((InterpreterSource::SearchPath, path))),
).into_iter().flatten() ).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. // TODO(konstin): Implement <https://peps.python.org/pep-0514/> to read python installations from the registry instead.
.chain( .chain(
(sources.contains(InterpreterSource::PyLauncher) && cfg!(windows)).then(|| (sources.contains(InterpreterSource::PyLauncher) && cfg!(windows)).then(||
@ -344,8 +356,27 @@ fn python_interpreters<'a>(
}) })
.filter(move |result| match result { .filter(move |result| match result {
// Filter the returned interpreters to conform to the system request // 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::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) => { (SystemPython::Disallowed, false) => {
debug!( debug!(
"Ignoring Python interpreter at `{}`: system intepreter not allowed", "Ignoring Python interpreter at `{}`: system intepreter not allowed",
@ -1073,31 +1104,22 @@ impl SourceSelector {
]) ])
} else { } else {
match system { match system {
SystemPython::Allowed => Self::All, SystemPython::Allowed | SystemPython::Explicit => Self::All,
SystemPython::Required => { SystemPython::Required => Self::from_sources([
debug!("Excluding virtual environment Python due to system flag"); InterpreterSource::ProvidedPath,
Self::from_sources([ InterpreterSource::SearchPath,
InterpreterSource::ProvidedPath, #[cfg(windows)]
InterpreterSource::SearchPath, InterpreterSource::PyLauncher,
#[cfg(windows)] InterpreterSource::ManagedToolchain,
InterpreterSource::PyLauncher, InterpreterSource::ParentInterpreter,
InterpreterSource::ManagedToolchain, ]),
]) SystemPython::Disallowed => Self::from_sources([
} InterpreterSource::DiscoveredEnvironment,
SystemPython::Disallowed => { InterpreterSource::ActiveEnvironment,
debug!("Only considering virtual environment Python interpreters"); ]),
Self::virtualenvs()
}
} }
} }
} }
pub fn virtualenvs() -> Self {
Self::from_sources([
InterpreterSource::DiscoveredEnvironment,
InterpreterSource::ActiveEnvironment,
])
}
} }
impl SystemPython { impl SystemPython {
@ -1138,6 +1160,7 @@ impl fmt::Display for InterpreterSource {
Self::SearchPath => f.write_str("search path"), Self::SearchPath => f.write_str("search path"),
Self::PyLauncher => f.write_str("`py` launcher output"), Self::PyLauncher => f.write_str("`py` launcher output"),
Self::ManagedToolchain => f.write_str("managed toolchains"), 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::discovery::{InterpreterRequest, SourceSelector, SystemPython, VersionRequest};
use crate::virtualenv::{virtualenv_python_executable, PyVenvConfiguration}; 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. /// A Python environment, consisting of a Python [`Interpreter`] and its associated paths.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -31,6 +33,14 @@ impl PythonEnvironment {
} else if system.is_preferred() { } else if system.is_preferred() {
Self::from_default_python(cache) Self::from_default_python(cache)
} else { } 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) { match Self::from_virtualenv(cache) {
Ok(venv) => Ok(venv), Ok(venv) => Ok(venv),
Err(Error::NotFound(_)) if system.is_allowed() => Self::from_default_python(cache), 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. /// Create a [`PythonEnvironment`] for an existing virtual environment.
pub fn from_virtualenv(cache: &Cache) -> Result<Self, Error> { 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 request = InterpreterRequest::Version(VersionRequest::Default);
let found = find_interpreter(&request, SystemPython::Disallowed, &sources, cache)??; let found = find_interpreter(&request, SystemPython::Disallowed, &sources, cache)??;
debug_assert!( debug_assert!(
found.interpreter().base_prefix() == found.interpreter().base_exec_prefix(), found.interpreter().is_virtualenv(),
"Not a virtualenv (source: {}, prefix: {})", "Not a virtualenv (source: {}, prefix: {})",
found.source(), found.source(),
found.interpreter().base_prefix().display() 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. /// Create a [`PythonEnvironment`] from the virtual environment at the given root.
pub fn from_root(root: &Path, cache: &Cache) -> Result<Self, Error> { pub fn from_root(root: &Path, cache: &Cache) -> Result<Self, Error> {
let venv = match fs_err::canonicalize(root) { let venv = match fs_err::canonicalize(root) {

View file

@ -1360,6 +1360,174 @@ mod tests {
Ok(()) 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] #[test]
fn find_environment_active_environment_skipped_if_system_required() -> Result<()> { fn find_environment_active_environment_skipped_if_system_required() -> Result<()> {
let tempdir = TempDir::new()?; let tempdir = TempDir::new()?;
@ -1604,6 +1772,43 @@ mod tests {
Ok(()) 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] #[test]
fn find_environment_does_not_allow_file_path_with_system_disallowed() -> Result<()> { fn find_environment_does_not_allow_file_path_with_system_disallowed() -> Result<()> {
let tempdir = TempDir::new()?; let tempdir = TempDir::new()?;

View file

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

View file

@ -31,6 +31,9 @@ def _run() -> None:
if venv: if venv:
env.setdefault("VIRTUAL_ENV", 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": if sys.platform == "win32":
import subprocess import subprocess