diff --git a/crates/uv-interpreter/src/discovery.rs b/crates/uv-interpreter/src/discovery.rs index 69da5a4c8..868adeb51 100644 --- a/crates/uv-interpreter/src/discovery.rs +++ b/crates/uv-interpreter/src/discovery.rs @@ -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> + '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 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"), } } } diff --git a/crates/uv-interpreter/src/environment.rs b/crates/uv-interpreter/src/environment.rs index 8c7fc21a8..9484e4ead 100644 --- a/crates/uv-interpreter/src/environment.rs +++ b/crates/uv-interpreter/src/environment.rs @@ -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 { - 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 { + 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 { let venv = match fs_err::canonicalize(root) { diff --git a/crates/uv-interpreter/src/lib.rs b/crates/uv-interpreter/src/lib.rs index e448d2fdc..ee6501546 100644 --- a/crates/uv-interpreter/src/lib.rs +++ b/crates/uv-interpreter/src/lib.rs @@ -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::), + ("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()?; diff --git a/crates/uv/src/commands/pip/install.rs b/crates/uv/src/commands/pip/install.rs index 397a6a0a6..255062ef7 100644 --- a/crates/uv/src/commands/pip/install.rs +++ b/crates/uv/src/commands/pip/install.rs @@ -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)?; diff --git a/crates/uv/src/commands/pip/sync.rs b/crates/uv/src/commands/pip/sync.rs index dbdaa8296..d88101e67 100644 --- a/crates/uv/src/commands/pip/sync.rs +++ b/crates/uv/src/commands/pip/sync.rs @@ -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)?; diff --git a/crates/uv/src/commands/pip/uninstall.rs b/crates/uv/src/commands/pip/uninstall.rs index 2ec6573ca..c83a66bcb 100644 --- a/crates/uv/src/commands/pip/uninstall.rs +++ b/crates/uv/src/commands/pip/uninstall.rs @@ -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)?; diff --git a/python/uv/__main__.py b/python/uv/__main__.py index 1524c19fe..d8731c7ec 100644 --- a/python/uv/__main__.py +++ b/python/uv/__main__.py @@ -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