diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34093171c1fe..c5f5c4bc1dda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,6 +144,7 @@ jobs: - name: "Cargo test" run: | + export UV_BOOTSTRAP_DIR="$(pwd)/bin" cargo nextest run \ --workspace \ --status-level skip --failure-output immediate-final --no-fail-fast -j 12 --final-status-level slow diff --git a/Cargo.lock b/Cargo.lock index 3262a58385b4..d3f6b71dbfb3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3821,6 +3821,15 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f227968ec00f0e5322f9b8173c7a0cbcff6181a0a5b28e9892491c286277231" +[[package]] +name = "temp-env" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96374855068f47402c3121c6eed88d29cb1de8f3ab27090e273e420bdabcf050" +dependencies = [ + "parking_lot 0.12.2", +] + [[package]] name = "tempfile" version = "3.10.1" @@ -4889,12 +4898,12 @@ name = "uv-interpreter" version = "0.0.1" dependencies = [ "anyhow", + "assert_fs", "cache-key", "configparser", "fs-err", "futures", "indoc", - "insta", "install-wheel-rs", "itertools 0.13.0", "once_cell", @@ -4910,7 +4919,9 @@ dependencies = [ "schemars", "serde", "serde_json", + "temp-env", "tempfile", + "test-log", "thiserror", "tokio-util", "tracing", diff --git a/crates/uv-dev/src/fetch_python.rs b/crates/uv-dev/src/fetch_python.rs index a993b6b9bfcb..3869a53160c8 100644 --- a/crates/uv-dev/src/fetch_python.rs +++ b/crates/uv-dev/src/fetch_python.rs @@ -25,11 +25,13 @@ pub(crate) struct FetchPythonArgs { pub(crate) async fn fetch_python(args: FetchPythonArgs) -> Result<()> { let start = Instant::now(); - let bootstrap_dir = TOOLCHAIN_DIRECTORY - .as_ref() - .expect("The toolchain directory must exist for bootstrapping"); + let bootstrap_dir = TOOLCHAIN_DIRECTORY.clone().unwrap_or_else(|| { + std::env::current_dir() + .expect("Use `UV_BOOTSTRAP_DIR` if the current directory is not usable.") + .join("bin") + }); - fs_err::create_dir_all(bootstrap_dir)?; + fs_err::create_dir_all(&bootstrap_dir)?; let versions = if args.versions.is_empty() { info!("Reading versions from file..."); @@ -59,7 +61,7 @@ pub(crate) async fn fetch_python(args: FetchPythonArgs) -> Result<()> { let mut tasks = futures::stream::iter(downloads.iter()) .map(|download| { async { - let result = download.fetch(&client, bootstrap_dir).await; + let result = download.fetch(&client, &bootstrap_dir).await; (download.python_version(), result) } .instrument(info_span!("download", key = %download)) @@ -130,6 +132,10 @@ pub(crate) async fn fetch_python(args: FetchPythonArgs) -> Result<()> { }; info!("Installed {} versions", requests.len()); + info!( + r#"To enable discovery: export UV_BOOTSTRAP_DIR="{}""#, + bootstrap_dir.display() + ); Ok(()) } diff --git a/crates/uv-interpreter/Cargo.toml b/crates/uv-interpreter/Cargo.toml index 81f22fef3d69..98c9859fb8da 100644 --- a/crates/uv-interpreter/Cargo.toml +++ b/crates/uv-interpreter/Cargo.toml @@ -51,7 +51,9 @@ winapi = { workspace = true } [dev-dependencies] anyhow = { version = "1.0.80" } +assert_fs = { version = "1.1.1" } indoc = { version = "2.0.4" } -insta = { version = "1.36.1", features = ["filters"] } itertools = { version = "0.13.0" } +temp-env = { version = "0.3.6" } tempfile = { version = "3.9.0" } +test-log = { version = "0.2.15", features = ["trace"], default-features = false } diff --git a/crates/uv-interpreter/src/discovery.rs b/crates/uv-interpreter/src/discovery.rs new file mode 100644 index 000000000000..b0dfc15cea95 --- /dev/null +++ b/crates/uv-interpreter/src/discovery.rs @@ -0,0 +1,1226 @@ +use itertools::Itertools; +use thiserror::Error; +use tracing::{debug, instrument, trace}; +use uv_cache::Cache; +use uv_fs::Simplified; +use uv_warnings::warn_user_once; +use which::which; + +use crate::implementation::ImplementationName; +use crate::interpreter::Error as InterpreterError; +use crate::managed::toolchains_for_current_platform; +use crate::py_launcher::py_list_paths; +use crate::virtualenv::{ + virtualenv_from_env, virtualenv_from_working_dir, virtualenv_python_executable, +}; +use crate::{Interpreter, PythonVersion}; +use std::borrow::Cow; + +use std::collections::HashSet; +use std::fmt::{self, Formatter}; +use std::num::ParseIntError; +use std::{env, io}; +use std::{path::Path, path::PathBuf, str::FromStr}; + +/// A request to find a Python interpreter. +/// +/// See [`InterpreterRequest::from_str`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InterpreterRequest { + /// A Python version without an implementation name e.g. `3.10` + Version(VersionRequest), + /// A path to a directory containing a Python installation, e.g. `.venv` + Directory(PathBuf), + /// A path to a Python executable e.g. `~/bin/python` + File(PathBuf), + /// The name of a Python executable (i.e. for lookup in the PATH) e.g. `foopython3` + ExecutableName(String), + /// A Python implementation without a version e.g. `pypy` + Implementation(ImplementationName), + /// A Python implementation name and version e.g. `pypy3.8` + ImplementationVersion(ImplementationName, VersionRequest), +} + +/// The sources to consider when finding a Python interpreter. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum SourceSelector { + #[default] + All, + Some(HashSet), +} + +/// A Python interpreter version request. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum VersionRequest { + #[default] + Default, + Major(u8), + MajorMinor(u8, u8), + MajorMinorPatch(u8, u8, u8), +} + +/// The policy for discovery of "system" Python interpreters. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SystemPython { + /// Do not allow a system Python + #[default] + Disallowed, + /// Allow a system Python to be used if no virtual environment is active. + Allowed, + /// Ignore virtual environments and require a system Python. + Required, +} + +/// The result of an interpreter search. +/// +/// Returned by [`find_interpreter`]. +type InterpreterResult = Result; + +/// The result of failed interpreter discovery. +/// +/// See [`InterpreterResult`]. +#[derive(Clone, Debug, Error)] +pub enum InterpreterNotFound { + /// No Python installations were found. + NoPythonInstallation(SourceSelector, Option), + /// No Python installations with the requested version were found. + NoMatchingVersion(SourceSelector, VersionRequest), + /// No Python installations with the requested implementation name were found. + NoMatchingImplementation(SourceSelector, ImplementationName), + /// No Python installations with the requested implementation name and version were found. + NoMatchingImplementationVersion(SourceSelector, ImplementationName, VersionRequest), + /// The requested file path does not exist. + FileNotFound(PathBuf), + /// The requested directory path does not exist. + DirectoryNotFound(PathBuf), + /// No Python executables could be found in the requested directory. + ExecutableNotFoundInDirectory(PathBuf, PathBuf), + /// The Python executable name could not be found in the search path (i.e. PATH). + ExecutableNotFoundInSearchPath(String), + /// A Python executable was found but is not executable. + FileNotExecutable(PathBuf), +} + +/// The result of successful interpreter discovery. +/// +/// See [`InterpreterResult`]. +#[derive(Clone, Debug)] +pub struct DiscoveredInterpreter { + pub(crate) source: InterpreterSource, + pub(crate) interpreter: Interpreter, +} + +/// The source of a discovered Python interpreter. +#[derive(Debug, Clone, PartialEq, Eq, Copy, Hash, PartialOrd, Ord)] +pub enum InterpreterSource { + /// The interpreter path was provided directly + ProvidedPath, + /// An environment was active e.g. via `VIRTUAL_ENV` + ActiveEnvironment, + /// An environment was discovered e.g. via `.venv` + DiscoveredEnvironment, + /// An executable was found in the search path i.e. `PATH` + SearchPath, + /// An executable was found via the `py` launcher + PyLauncher, + /// The interpreter was found in the uv toolchain directory + ManagedToolchain, + // 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)] +pub enum Error { + #[error(transparent)] + IO(#[from] io::Error), + + /// An error was encountering when retrieving interpreter information. + #[error(transparent)] + Query(#[from] crate::interpreter::Error), + + /// An error was encountered when interacting with a managed toolchain. + #[error(transparent)] + ManagedToolchain(#[from] crate::managed::Error), + + /// An error was encountered when inspecting a virtual environment. + #[error(transparent)] + VirtualEnv(#[from] crate::virtualenv::Error), + + /// An error was encountered when using the `py` launcher on Windows. + #[error(transparent)] + PyLauncher(#[from] crate::py_launcher::Error), + + #[error("Interpreter discovery for `{0}` requires `{1}` but it is not selected")] + SourceNotSelected(InterpreterRequest, InterpreterSource), +} + +/// Lazily iterate over all discoverable Python executables. +/// +/// In order, we look in: +/// +/// - The active environment +/// - A discovered environment (e.g. `.venv`) +/// - Installed managed toolchains +/// - The search path (i.e. PATH) +/// - `py` launcher output +/// +/// Each location is only queried if the previous location is exhausted. +/// Locations may be omitted using `sources`, sources that are not selected will not be queried. +/// +/// If a [`VersionRequest`] is provided, we will skip executables that we know do not satisfy the request +/// and (as discussed in [`python_executables_from_search_path`]) additional version specific executables may +/// be included. However, the caller MUST query the returned executables to ensure they satisfy the request; +/// this function does not guarantee that the executables provide any particular version. See +/// [`find_interpreter`] instead. +fn python_executables<'a>( + version: Option<&'a VersionRequest>, + sources: &SourceSelector, +) -> 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() + .into_iter() + .map(virtualenv_python_executable) + .map(|path| Ok((InterpreterSource::ActiveEnvironment, path))) + ).into_iter().flatten() + // (2) A discovered environment + .chain( + sources.contains(InterpreterSource::DiscoveredEnvironment).then(|| + std::iter::once( + virtualenv_from_working_dir() + .map(|path| + path + .map(virtualenv_python_executable) + .map(|path| (InterpreterSource::DiscoveredEnvironment, path)) + .into_iter() + ) + .map_err(Error::from) + ).flatten_ok() + ).into_iter().flatten() + ) + // (3) Managed toolchains + .chain( + sources.contains(InterpreterSource::ManagedToolchain).then(move || + std::iter::once( + toolchains_for_current_platform() + .map(|toolchains| + // Check that the toolchain version satisfies the request to avoid unnecessary interpreter queries later + toolchains.filter(move |toolchain| + version.is_none() || version.is_some_and(|version| + version.matches_version(toolchain.python_version()) + ) + ) + .map(|toolchain| (InterpreterSource::ManagedToolchain, toolchain.executable())) + ) + .map_err(Error::from) + ).flatten_ok() + ).into_iter().flatten() + ) + // (4) The search path + .chain( + sources.contains(InterpreterSource::SearchPath).then(move || + python_executables_from_search_path(version) + .map(|path| Ok((InterpreterSource::SearchPath, path))), + ).into_iter().flatten() + ) + // (5) The `py` launcher (windows only) + // TODO(konstin): Implement to read python installations from the registry instead. + .chain( + (sources.contains(InterpreterSource::PyLauncher) && cfg!(windows)).then(|| + std::iter::once( + py_list_paths() + .map(|entries| + // We can avoid querying the interpreter using versions from the py launcher output unless a patch is requested + entries.into_iter().filter(move |entry| + version.is_none() || version.is_some_and(|version| + version.has_patch() || version.matches_major_minor(entry.major, entry.minor) + ) + ) + .map(|entry| (InterpreterSource::PyLauncher, entry.executable_path)) + ) + .map_err(Error::from) + ).flatten_ok() + ).into_iter().flatten() + ) +} + +/// Lazily iterate over Python executables in the `PATH`. +/// +/// The [`VersionRequest`] is used to determine the possible Python interpreter names, e.g. +/// if looking for Python 3.9 we will look for `python3.9` in addition to the default names. +/// +/// Executables are returned in the search path order, then by specificity of the name, e.g. +/// `python3.9` is preferred over `python3`. +/// +/// If a `version` is not provided, we will only look for default executable names e.g. +/// `python3` and `python` — `python3.9` and similar will not be included. +fn python_executables_from_search_path( + version: Option<&VersionRequest>, +) -> impl Iterator + '_ { + // `UV_TEST_PYTHON_PATH` can be used to override `PATH` to limit Python executable availability in the test suite + let search_path = + env::var_os("UV_TEST_PYTHON_PATH").unwrap_or(env::var_os("PATH").unwrap_or_default()); + let possible_names = version.unwrap_or(&VersionRequest::Default).possible_names(); + + // Split and iterate over the paths instead of using `which_all` so we can + // check multiple names per directory while respecting the search path order + let search_dirs: Vec<_> = env::split_paths(&search_path).collect(); + search_dirs + .into_iter() + .filter(|dir| dir.is_dir()) + .flat_map(move |dir| { + // Clone the directory for second closure + let dir_clone = dir.clone(); + trace!( + "Checking `PATH` directory for interpreters: {}", + dir.display() + ); + possible_names + .clone() + .into_iter() + .flatten() + .flat_map(move |name| { + // Since we're just working with a single directory at a time, we collect to simplify ownership + which::which_in_global(&*name, Some(&dir)) + .into_iter() + .flatten() + .collect::>() + }) + .filter(|path| !is_windows_store_shim(path)) + .inspect(|path| trace!("Found possible Python executable: {}", path.display())) + .chain( + // TODO(zanieb): Consider moving `python.bat` into `possible_names` to avoid a chain + cfg!(windows) + .then(move || { + which::which_in_global("python.bat", Some(&dir_clone)) + .into_iter() + .flatten() + .collect::>() + }) + .into_iter() + .flatten(), + ) + }) +} + +/// Lazily iterate over all discoverable Python interpreters. +/// +///See [`python_executables`] for more information on discovery. +fn python_interpreters<'a>( + version: Option<&'a VersionRequest>, + sources: &SourceSelector, + cache: &'a Cache, +) -> impl Iterator> + 'a { + python_executables(version, sources).map(|result| match result { + Ok((source, path)) => Interpreter::query(&path, cache) + .map(|interpreter| (source, interpreter)) + .inspect(|(source, interpreter)| { + trace!( + "Found Python interpreter {} {} at {} from {source}", + interpreter.implementation_name(), + interpreter.python_full_version(), + path.display() + ); + }) + .map_err(Error::from) + .inspect_err(|err| trace!("{err}")), + Err(err) => Err(err), + }) +} + +/// Check if an encountered error should stop discovery. +/// +/// Returns false when an error could be due to a faulty intepreter and we should continue searching for a working one. +fn should_stop_discovery(err: &Error) -> bool { + match err { + // When querying the interpreter fails, we will only raise errors that demonstrate that something is broken + // If the interpreter returned a bad response, we'll continue searching for one that works + Error::Query(err) => match err { + InterpreterError::Encode(_) + | InterpreterError::Io(_) + | InterpreterError::SpawnFailed { .. } => true, + InterpreterError::QueryScript { path, .. } + | InterpreterError::UnexpectedResponse { path, .. } + | InterpreterError::StatusCode { path, .. } => { + trace!("Skipping bad interpreter at {}", path.display()); + false + } + }, + _ => true, + } +} + +/// Find an interpreter that satisfies the given request. +/// +/// If an error is encountered while locating or inspecting a candidate interpreter, +/// the error will raised instead of attempting further candidates. +pub fn find_interpreter( + request: &InterpreterRequest, + sources: &SourceSelector, + cache: &Cache, +) -> Result { + let result = match request { + InterpreterRequest::File(path) => { + if !sources.contains(InterpreterSource::ProvidedPath) { + return Err(Error::SourceNotSelected( + request.clone(), + InterpreterSource::ProvidedPath, + )); + } + if !path.try_exists()? { + return Ok(InterpreterResult::Err(InterpreterNotFound::FileNotFound( + path.clone(), + ))); + } + DiscoveredInterpreter { + source: InterpreterSource::ProvidedPath, + interpreter: Interpreter::query(path, cache)?, + } + } + InterpreterRequest::Directory(path) => { + if !sources.contains(InterpreterSource::ProvidedPath) { + return Err(Error::SourceNotSelected( + request.clone(), + InterpreterSource::ProvidedPath, + )); + } + if !path.try_exists()? { + return Ok(InterpreterResult::Err(InterpreterNotFound::FileNotFound( + path.clone(), + ))); + } + let executable = virtualenv_python_executable(path); + if !executable.try_exists()? { + return Ok(InterpreterResult::Err( + InterpreterNotFound::ExecutableNotFoundInDirectory(path.clone(), executable), + )); + } + DiscoveredInterpreter { + source: InterpreterSource::ProvidedPath, + interpreter: Interpreter::query(executable, cache)?, + } + } + InterpreterRequest::ExecutableName(name) => { + if !sources.contains(InterpreterSource::SearchPath) { + return Err(Error::SourceNotSelected( + request.clone(), + InterpreterSource::SearchPath, + )); + } + let Some(executable) = which(name).ok() else { + return Ok(InterpreterResult::Err( + InterpreterNotFound::ExecutableNotFoundInSearchPath(name.clone()), + )); + }; + DiscoveredInterpreter { + source: InterpreterSource::SearchPath, + interpreter: Interpreter::query(executable, cache)?, + } + } + InterpreterRequest::Implementation(implementation) => { + let Some((source, interpreter)) = python_interpreters(None, sources, cache) + .find(|result| { + match result { + // Return the first critical error or matching interpreter + Err(err) => should_stop_discovery(err), + Ok((_source, interpreter)) => { + interpreter.implementation_name() == implementation.as_str() + } + } + }) + .transpose()? + else { + return Ok(InterpreterResult::Err( + InterpreterNotFound::NoMatchingImplementation(sources.clone(), *implementation), + )); + }; + DiscoveredInterpreter { + source, + interpreter, + } + } + InterpreterRequest::ImplementationVersion(implementation, version) => { + let Some((source, interpreter)) = python_interpreters(Some(version), sources, cache) + .find(|result| { + match result { + // Return the first critical error or matching interpreter + Err(err) => should_stop_discovery(err), + Ok((_source, interpreter)) => { + version.matches_interpreter(interpreter) + && interpreter.implementation_name() == implementation.as_str() + } + } + }) + .transpose()? + else { + // TODO(zanieb): Peek if there are any interpreters with the requested implementation + // to improve the error message e.g. using `NoMatchingImplementation` instead + return Ok(InterpreterResult::Err( + InterpreterNotFound::NoMatchingImplementationVersion( + sources.clone(), + *implementation, + *version, + ), + )); + }; + DiscoveredInterpreter { + source, + interpreter, + } + } + InterpreterRequest::Version(version) => { + let Some((source, interpreter)) = python_interpreters(Some(version), sources, cache) + .find(|result| { + match result { + // Return the first critical error or matching interpreter + Err(err) => should_stop_discovery(err), + Ok((_source, interpreter)) => version.matches_interpreter(interpreter), + } + }) + .transpose()? + else { + let err = if matches!(version, VersionRequest::Default) { + InterpreterNotFound::NoPythonInstallation(sources.clone(), Some(*version)) + } else { + InterpreterNotFound::NoMatchingVersion(sources.clone(), *version) + }; + return Ok(InterpreterResult::Err(err)); + }; + DiscoveredInterpreter { + source, + interpreter, + } + } + }; + + Ok(InterpreterResult::Ok(result)) +} + +/// Find the default Python interpreter on the system. +/// +/// Virtual environments are not included in discovery. +/// +/// See [`find_interpreter`] for more details on interpreter discovery. +pub fn find_default_interpreter(cache: &Cache) -> Result { + let request = InterpreterRequest::Version(VersionRequest::Default); + let sources = SourceSelector::from_sources([ + InterpreterSource::SearchPath, + InterpreterSource::PyLauncher, + ]); + + let result = find_interpreter(&request, &sources, cache)?; + if let Ok(ref found) = result { + warn_on_unsupported_python(found.interpreter()); + } + + Ok(result) +} + +/// Find the best-matching Python interpreter. +/// +/// If no Python version is provided, we will use the first available interpreter. +/// +/// If a Python version is provided, we will first try to find an exact match. If +/// that cannot be found and a patch version was requested, we will look for a match +/// without comparing the patch version number. If that cannot be found, we fall back to +/// the first available version. +/// +/// See [`find_interpreter`] for more details on interpreter discovery. +#[instrument(skip_all, fields(?request))] +pub fn find_best_interpreter( + request: &InterpreterRequest, + system: SystemPython, + cache: &Cache, +) -> Result { + debug!("Starting interpreter discovery for {}", request); + + // Determine if we should be allowed to look outside of virtual environments. + let sources = SourceSelector::from_env(system); + + // First, check for an exact match (or the first available version if no Python versfion was provided) + debug!("Looking for exact match for request {request}"); + let result = find_interpreter(request, &sources, cache)?; + if let Ok(ref found) = result { + warn_on_unsupported_python(found.interpreter()); + return Ok(result); + } + + // If that fails, and a specific patch version was requested try again allowing a + // different patch version + if let Some(request) = match request { + InterpreterRequest::Version(version) => { + if version.has_patch() { + Some(InterpreterRequest::Version((*version).without_patch())) + } else { + None + } + } + InterpreterRequest::ImplementationVersion(implementation, version) => Some( + InterpreterRequest::ImplementationVersion(*implementation, (*version).without_patch()), + ), + _ => None, + } { + debug!("Looking for relaxed patch version {request}"); + let result = find_interpreter(&request, &sources, cache)?; + if let Ok(ref found) = result { + warn_on_unsupported_python(found.interpreter()); + return Ok(result); + } + } + + // If a Python version was requested but cannot be fulfilled, just take any version + debug!("Looking for Python interpreter with any version"); + let request = InterpreterRequest::Version(VersionRequest::Default); + Ok(find_interpreter( + // TODO(zanieb): Add a dedicated `Default` variant to `InterpreterRequest` + &request, &sources, cache, + )? + .map_err(|err| { + // Use a more general error in this case since we looked for multiple versions + if matches!(err, InterpreterNotFound::NoMatchingVersion(..)) { + InterpreterNotFound::NoPythonInstallation(sources.clone(), None) + } else { + err + } + })) +} + +/// Display a warning if the Python version of the [`Interpreter`] is unsupported by uv. +fn warn_on_unsupported_python(interpreter: &Interpreter) { + // Warn on usage with an unsupported Python version + if interpreter.python_tuple() < (3, 8) { + warn_user_once!( + "uv is only compatible with Python 3.8+, found Python {}.", + interpreter.python_version() + ); + } +} + +/// On Windows we might encounter the Windows Store proxy shim (enabled in: +/// Settings/Apps/Advanced app settings/App execution aliases). When Python is _not_ installed +/// via the Windows Store, but the proxy shim is enabled, then executing `python.exe` or +/// `python3.exe` will redirect to the Windows Store installer. +/// +/// We need to detect that these `python.exe` and `python3.exe` files are _not_ Python +/// executables. +/// +/// This method is taken from Rye: +/// +/// > This is a pretty dumb way. We know how to parse this reparse point, but Microsoft +/// > does not want us to do this as the format is unstable. So this is a best effort way. +/// > we just hope that the reparse point has the python redirector in it, when it's not +/// > pointing to a valid Python. +/// +/// See: +#[cfg(windows)] +pub(crate) fn is_windows_store_shim(path: &Path) -> bool { + use std::os::windows::fs::MetadataExt; + use std::os::windows::prelude::OsStrExt; + use winapi::um::fileapi::{CreateFileW, OPEN_EXISTING}; + use winapi::um::handleapi::{CloseHandle, INVALID_HANDLE_VALUE}; + use winapi::um::ioapiset::DeviceIoControl; + use winapi::um::winbase::{FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT}; + use winapi::um::winioctl::FSCTL_GET_REPARSE_POINT; + use winapi::um::winnt::{FILE_ATTRIBUTE_REPARSE_POINT, MAXIMUM_REPARSE_DATA_BUFFER_SIZE}; + + // The path must be absolute. + if !path.is_absolute() { + return false; + } + + // The path must point to something like: + // `C:\Users\crmar\AppData\Local\Microsoft\WindowsApps\python3.exe` + let mut components = path.components().rev(); + + // Ex) `python.exe` or `python3.exe` + if !components + .next() + .and_then(|component| component.as_os_str().to_str()) + .is_some_and(|component| component == "python.exe" || component == "python3.exe") + { + return false; + } + + // Ex) `WindowsApps` + if !components + .next() + .is_some_and(|component| component.as_os_str() == "WindowsApps") + { + return false; + } + + // Ex) `Microsoft` + if !components + .next() + .is_some_and(|component| component.as_os_str() == "Microsoft") + { + return false; + } + + // The file is only relevant if it's a reparse point. + let Ok(md) = fs_err::symlink_metadata(path) else { + return false; + }; + if md.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0 { + return false; + } + + let mut path_encoded = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + + // SAFETY: The path is null-terminated. + #[allow(unsafe_code)] + let reparse_handle = unsafe { + CreateFileW( + path_encoded.as_mut_ptr(), + 0, + 0, + std::ptr::null_mut(), + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, + std::ptr::null_mut(), + ) + }; + + if reparse_handle == INVALID_HANDLE_VALUE { + return false; + } + + let mut buf = [0u16; MAXIMUM_REPARSE_DATA_BUFFER_SIZE as usize]; + let mut bytes_returned = 0; + + // SAFETY: The buffer is large enough to hold the reparse point. + #[allow(unsafe_code, clippy::cast_possible_truncation)] + let success = unsafe { + DeviceIoControl( + reparse_handle, + FSCTL_GET_REPARSE_POINT, + std::ptr::null_mut(), + 0, + buf.as_mut_ptr().cast(), + buf.len() as u32 * 2, + &mut bytes_returned, + std::ptr::null_mut(), + ) != 0 + }; + + // SAFETY: The handle is valid. + #[allow(unsafe_code)] + unsafe { + CloseHandle(reparse_handle); + } + + // If the operation failed, assume it's not a reparse point. + if !success { + return false; + } + + let reparse_point = String::from_utf16_lossy(&buf[..bytes_returned as usize]); + reparse_point.contains("\\AppInstallerPythonRedirector.exe") +} + +/// On Unix, we do not need to deal with Windows store shims. +/// +/// See the Windows implementation for details. +#[cfg(not(windows))] +fn is_windows_store_shim(_path: &Path) -> bool { + false +} + +impl InterpreterRequest { + /// Create a request from a string. + /// + /// This cannot fail, which means weird inputs will be parsed as [`InterpreterRequest::File`] or [`InterpreterRequest::ExecutableName`]. + pub fn parse(value: &str) -> Self { + // e.g. `3.12.1` + if let Ok(version) = VersionRequest::from_str(value) { + return Self::Version(version); + } + // e.g. `python3.12.1` + if let Some(remainder) = value.strip_prefix("python") { + if let Ok(version) = VersionRequest::from_str(remainder) { + return Self::Version(version); + } + } + // e.g. `pypy@3.12` + if let Some((first, second)) = value.split_once('@') { + if let Ok(implementation) = ImplementationName::from_str(first) { + if let Ok(version) = VersionRequest::from_str(second) { + return Self::ImplementationVersion(implementation, version); + } + } + } + for implementation in ImplementationName::iter() { + if let Some(remainder) = value + .to_ascii_lowercase() + .strip_prefix(implementation.as_str()) + { + // e.g. `pypy` + if remainder.is_empty() { + return Self::Implementation(*implementation); + } + // e.g. `pypy3.12` + if let Ok(version) = VersionRequest::from_str(remainder) { + return Self::ImplementationVersion(*implementation, version); + } + } + } + let value_as_path = PathBuf::from(value); + // e.g. /path/to/.venv + if value_as_path.is_dir() { + return Self::Directory(value_as_path); + } + // e.g. /path/to/python + if value_as_path.is_file() { + return Self::File(value_as_path); + } + // During unit testing, we cannot change the working directory used by std + // so we perform a check relative to the mock working directory. Ideally we'd + // remove this code and use tests at the CLI level so we can change the real + // directory. + #[cfg(test)] + if value_as_path.is_relative() { + if let Ok(current_dir) = crate::current_dir() { + let relative = current_dir.join(&value_as_path); + if relative.is_dir() { + return Self::Directory(relative); + } + if relative.is_file() { + return Self::File(relative); + } + } + } + // e.g. .\path\to\python3.exe or ./path/to/python3 + // If it contains a path separator, we'll treat it as a full path even if it does not exist + if value.contains(std::path::MAIN_SEPARATOR) { + return Self::File(value_as_path); + } + // e.g. ./path/to/python3.exe + // On Windows, Unix path separators are often valid + if cfg!(windows) && value.contains('/') { + return Self::File(value_as_path); + } + // Finally, we'll treat it as the name of an executable (i.e. in the search PATH) + // e.g. foo.exe + Self::ExecutableName(value.to_string()) + } +} + +impl VersionRequest { + pub(crate) fn possible_names(self) -> [Option>; 4] { + let (python, python3, extension) = if cfg!(windows) { + ( + Cow::Borrowed("python.exe"), + Cow::Borrowed("python3.exe"), + ".exe", + ) + } else { + (Cow::Borrowed("python"), Cow::Borrowed("python3"), "") + }; + + match self { + Self::Default => [Some(python3), Some(python), None, None], + Self::Major(major) => [ + Some(Cow::Owned(format!("python{major}{extension}"))), + Some(python), + None, + None, + ], + Self::MajorMinor(major, minor) => [ + Some(Cow::Owned(format!("python{major}.{minor}{extension}"))), + Some(Cow::Owned(format!("python{major}{extension}"))), + Some(python), + None, + ], + Self::MajorMinorPatch(major, minor, patch) => [ + Some(Cow::Owned(format!( + "python{major}.{minor}.{patch}{extension}", + ))), + Some(Cow::Owned(format!("python{major}.{minor}{extension}"))), + Some(Cow::Owned(format!("python{major}{extension}"))), + Some(python), + ], + } + } + + /// Check if a interpreter matches the requested Python version. + fn matches_interpreter(self, interpreter: &Interpreter) -> bool { + match self { + Self::Default => true, + Self::Major(major) => interpreter.python_major() == major, + Self::MajorMinor(major, minor) => { + (interpreter.python_major(), interpreter.python_minor()) == (major, minor) + } + Self::MajorMinorPatch(major, minor, patch) => { + ( + interpreter.python_major(), + interpreter.python_minor(), + interpreter.python_patch(), + ) == (major, minor, patch) + } + } + } + + fn matches_version(self, version: &PythonVersion) -> bool { + match self { + Self::Default => true, + Self::Major(major) => version.major() == major, + Self::MajorMinor(major, minor) => (version.major(), version.minor()) == (major, minor), + Self::MajorMinorPatch(major, minor, patch) => { + (version.major(), version.minor(), version.patch()) == (major, minor, Some(patch)) + } + } + } + + fn matches_major_minor(self, major: u8, minor: u8) -> bool { + match self { + Self::Default => true, + Self::Major(self_major) => self_major == major, + Self::MajorMinor(self_major, self_minor) => (self_major, self_minor) == (major, minor), + Self::MajorMinorPatch(self_major, self_minor, _) => { + (self_major, self_minor) == (major, minor) + } + } + } + + /// Return true if a patch version is present in the request. + fn has_patch(self) -> bool { + match self { + Self::Default => false, + Self::Major(..) => false, + Self::MajorMinor(..) => false, + Self::MajorMinorPatch(..) => true, + } + } + + /// Return a new `VersionRequest` without the patch version. + #[must_use] + fn without_patch(self) -> Self { + match self { + Self::Default => Self::Default, + Self::Major(major) => Self::Major(major), + Self::MajorMinor(major, minor) => Self::MajorMinor(major, minor), + Self::MajorMinorPatch(major, minor, _) => Self::MajorMinor(major, minor), + } + } +} + +impl FromStr for VersionRequest { + type Err = ParseIntError; + + fn from_str(s: &str) -> Result { + let versions = s + .splitn(3, '.') + .map(str::parse::) + .collect::, _>>()?; + + let selector = match versions.as_slice() { + // e.g. `3` + [major] => VersionRequest::Major(*major), + // e.g. `3.10` + [major, minor] => VersionRequest::MajorMinor(*major, *minor), + // e.g. `3.10.4` + [major, minor, patch] => VersionRequest::MajorMinorPatch(*major, *minor, *patch), + _ => unreachable!(), + }; + + Ok(selector) + } +} + +impl From<&PythonVersion> for VersionRequest { + fn from(version: &PythonVersion) -> Self { + Self::from_str(&version.string) + .expect("Valid `PythonVersion`s should be valid `VersionRequest`s") + } +} + +impl fmt::Display for VersionRequest { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Default => f.write_str("default"), + Self::Major(major) => write!(f, "{major}"), + Self::MajorMinor(major, minor) => write!(f, "{major}.{minor}"), + Self::MajorMinorPatch(major, minor, patch) => { + write!(f, "{major}.{minor}.{patch}") + } + } + } +} + +impl SourceSelector { + /// Create a new [`SourceSelector::Some`] from an iterator. + pub(crate) fn from_sources(iter: impl IntoIterator) -> Self { + let inner = HashSet::from_iter(iter); + assert!(!inner.is_empty(), "Source selectors cannot be empty"); + Self::Some(inner) + } + + /// Return true if this selector includes the given [`InterpreterSource`]. + fn contains(&self, source: InterpreterSource) -> bool { + match self { + Self::All => true, + Self::Some(sources) => sources.contains(&source), + } + } + + /// Return the default [`SourceSelector`] based on environment variables. + pub fn from_env(system: SystemPython) -> Self { + if env::var_os("UV_FORCE_MANAGED_PYTHON").is_some() { + debug!("Only considering managed toolchains due to `UV_FORCE_MANAGED_PYTHON`"); + Self::from_sources([InterpreterSource::ManagedToolchain]) + } else if env::var_os("UV_TEST_PYTHON_PATH").is_some() { + debug!( + "Only considering search path and active environments due to `UV_TEST_PYTHON_PATH`" + ); + Self::from_sources([ + InterpreterSource::ActiveEnvironment, + InterpreterSource::SearchPath, + ]) + } 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, + InterpreterSource::PyLauncher, + InterpreterSource::ManagedToolchain, + ]) + } + SystemPython::Disallowed => { + debug!("Only considering virtual environment Python interpreters"); + Self::virtualenvs() + } + } + } + } + + pub fn virtualenvs() -> Self { + Self::from_sources([ + InterpreterSource::DiscoveredEnvironment, + InterpreterSource::ActiveEnvironment, + ]) + } +} + +impl SystemPython { + /// Returns true if a system Python is allowed. + pub fn is_allowed(&self) -> bool { + matches!(self, SystemPython::Allowed | SystemPython::Required) + } + + /// Returns true if a system Python is preferred. + pub fn is_preferred(&self) -> bool { + matches!(self, SystemPython::Required) + } +} + +impl fmt::Display for InterpreterRequest { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Version(version) => write!(f, "python@{version}"), + Self::Directory(path) => write!(f, "directory {}", path.user_display()), + Self::File(path) => write!(f, "file {}", path.user_display()), + Self::ExecutableName(name) => write!(f, "executable `{name}`"), + Self::Implementation(implementation) => { + write!(f, "{implementation}") + } + Self::ImplementationVersion(implementation, version) => { + write!(f, "{implementation}@{version}") + } + } + } +} + +impl fmt::Display for InterpreterSource { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::ProvidedPath => f.write_str("provided path"), + Self::ActiveEnvironment => f.write_str("active virtual environment"), + Self::DiscoveredEnvironment => f.write_str("virtual environment"), + Self::SearchPath => f.write_str("search path"), + Self::PyLauncher => f.write_str("`py` launcher output"), + Self::ManagedToolchain => f.write_str("managed toolchains"), + } + } +} + +impl fmt::Display for InterpreterNotFound { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::NoPythonInstallation(sources, Some(version)) => { + write!(f, "No Python {version} installation found in {sources}") + } + Self::NoPythonInstallation(sources, None) => { + write!(f, "No Python installation found in {sources}") + } + Self::NoMatchingVersion(sources, VersionRequest::Default) => { + write!(f, "No Python interpreter found in {sources}") + } + Self::NoMatchingVersion(sources, version) => { + write!(f, "No interpreter found for Python {version} in {sources}") + } + Self::NoMatchingImplementation(sources, implementation) => { + write!(f, "No interpreter found for {implementation} in {sources}") + } + Self::NoMatchingImplementationVersion(sources, implementation, version) => { + write!( + f, + "No interpreter found for {implementation} {version} in {sources}" + ) + } + Self::FileNotFound(path) => write!( + f, + "Requested interpreter path `{}` does not exist", + path.user_display() + ), + Self::DirectoryNotFound(path) => write!( + f, + "Requested interpreter directory `{}` does not exist", + path.user_display() + ), + Self::ExecutableNotFoundInDirectory(directory, executable) => { + let executable = if let Ok(relative_executable) = executable.strip_prefix(directory) + { + relative_executable.display() + } else { + executable.user_display() + }; + write!( + f, + "Interpreter directory `{}` does not contain Python executable at `{}`", + directory.user_display(), + executable + ) + } + Self::ExecutableNotFoundInSearchPath(name) => { + write!(f, "Requested Python executable `{name}` not found in PATH") + } + Self::FileNotExecutable(path) => { + write!( + f, + "Python interpreter at `{}` is not executable", + path.user_display() + ) + } + } + } +} + +impl fmt::Display for SourceSelector { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::All => f.write_str("all sources"), + Self::Some(sources) => { + let sources: Vec<_> = sources + .iter() + .sorted() + .map(InterpreterSource::to_string) + .collect(); + match sources[..] { + [] => unreachable!("Source selectors must contain at least one source"), + [ref one] => f.write_str(one), + [ref first, ref second] => write!(f, "{first} or {second}"), + [ref first @ .., ref last] => write!(f, "{}, or {last}", first.join(", ")), + } + } + } + } +} + +impl DiscoveredInterpreter { + #[allow(dead_code)] + pub fn source(&self) -> &InterpreterSource { + &self.source + } + + pub fn interpreter(&self) -> &Interpreter { + &self.interpreter + } + + pub fn into_interpreter(self) -> Interpreter { + self.interpreter + } +} + +#[cfg(test)] +mod tests { + + use std::{path::PathBuf, str::FromStr}; + + use test_log::test; + + use assert_fs::{prelude::*, TempDir}; + + use crate::{ + discovery::{InterpreterRequest, VersionRequest}, + implementation::ImplementationName, + }; + + #[test] + fn interpreter_request_from_str() { + assert_eq!( + InterpreterRequest::parse("3.12"), + InterpreterRequest::Version(VersionRequest::from_str("3.12").unwrap()) + ); + assert_eq!( + InterpreterRequest::parse("foo"), + InterpreterRequest::ExecutableName("foo".to_string()) + ); + assert_eq!( + InterpreterRequest::parse("cpython"), + InterpreterRequest::Implementation(ImplementationName::Cpython) + ); + assert_eq!( + InterpreterRequest::parse("cpython3.12.2"), + InterpreterRequest::ImplementationVersion( + ImplementationName::Cpython, + VersionRequest::from_str("3.12.2").unwrap() + ) + ); + + let tempdir = TempDir::new().unwrap(); + assert_eq!( + InterpreterRequest::parse(tempdir.path().to_str().unwrap()), + InterpreterRequest::Directory(tempdir.path().to_path_buf()), + "An existing directory is treated as a directory" + ); + assert_eq!( + InterpreterRequest::parse(tempdir.child("foo").path().to_str().unwrap()), + InterpreterRequest::File(tempdir.child("foo").path().to_path_buf()), + "A path that does not exist is treated as a file" + ); + tempdir.child("bar").touch().unwrap(); + assert_eq!( + InterpreterRequest::parse(tempdir.child("bar").path().to_str().unwrap()), + InterpreterRequest::File(tempdir.child("bar").path().to_path_buf()), + "An existing file is treated as a file" + ); + assert_eq!( + InterpreterRequest::parse("./foo"), + InterpreterRequest::File(PathBuf::from_str("./foo").unwrap()), + "A string with a file system separator is treated as a file" + ); + } + + #[test] + fn version_request_from_str() { + assert_eq!(VersionRequest::from_str("3"), Ok(VersionRequest::Major(3))); + assert_eq!( + VersionRequest::from_str("3.12"), + Ok(VersionRequest::MajorMinor(3, 12)) + ); + assert_eq!( + VersionRequest::from_str("3.12.1"), + Ok(VersionRequest::MajorMinorPatch(3, 12, 1)) + ); + assert!(VersionRequest::from_str("1.foo.1").is_err()); + } +} diff --git a/crates/uv-interpreter/src/environment.rs b/crates/uv-interpreter/src/environment.rs index 69df98e39154..b56edf8dc35c 100644 --- a/crates/uv-interpreter/src/environment.rs +++ b/crates/uv-interpreter/src/environment.rs @@ -8,8 +8,9 @@ use same_file::is_same_file; use uv_cache::Cache; use uv_fs::{LockedFile, Simplified}; -use crate::virtualenv::{detect_virtualenv, virtualenv_python_executable, PyVenvConfiguration}; -use crate::{find_default_python, find_requested_python, Error, Interpreter, Target}; +use crate::discovery::{InterpreterRequest, SourceSelector, SystemPython, VersionRequest}; +use crate::virtualenv::{virtualenv_python_executable, PyVenvConfiguration}; +use crate::{find_default_interpreter, find_interpreter, Error, Interpreter, Target}; /// A Python environment, consisting of a Python [`Interpreter`] and its associated paths. #[derive(Debug, Clone)] @@ -22,13 +23,39 @@ struct PythonEnvironmentShared { } impl PythonEnvironment { - /// Create a [`PythonEnvironment`] for an existing virtual environment, detected from the - /// environment variables and filesystem. + /// Create a [`PythonEnvironment`] from a user request. + pub fn find(python: Option<&str>, system: SystemPython, cache: &Cache) -> Result { + // Detect the current Python interpreter. + if let Some(python) = python { + Self::from_requested_python(python, system, cache) + } else if system.is_preferred() { + Self::from_default_python(cache) + } else { + match Self::from_virtualenv(cache) { + Ok(venv) => Ok(venv), + Err(Error::NotFound(_)) if system.is_allowed() => Self::from_default_python(cache), + Err(err) => Err(err), + } + } + } + + /// Create a [`PythonEnvironment`] for an existing virtual environment. pub fn from_virtualenv(cache: &Cache) -> Result { - let Some(venv) = detect_virtualenv()? else { - return Err(Error::VenvNotFound); - }; - Self::from_root(&venv, cache) + let sources = SourceSelector::virtualenvs(); + let request = InterpreterRequest::Version(VersionRequest::Default); + let found = find_interpreter(&request, &sources, cache)??; + + debug_assert!( + found.interpreter().base_prefix() == found.interpreter().base_exec_prefix(), + "Not a virtualenv (source: {}, prefix: {})", + found.source(), + found.interpreter().base_prefix().display() + ); + + 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. @@ -36,31 +63,30 @@ impl PythonEnvironment { let venv = match fs_err::canonicalize(root) { Ok(venv) => venv, Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - return Err(Error::VenvDoesNotExist(root.to_path_buf())); + return Err(Error::NotFound( + crate::InterpreterNotFound::DirectoryNotFound(root.to_path_buf()), + )); } - Err(err) => return Err(err.into()), + Err(err) => return Err(Error::Discovery(err.into())), }; - let executable = virtualenv_python_executable(&venv); - let interpreter = Interpreter::query(&executable, cache)?; - - debug_assert!( - interpreter.base_prefix() == interpreter.base_exec_prefix(), - "Not a virtualenv (Python: {}, prefix: {})", - executable.display(), - interpreter.base_prefix().display() - ); + let executable = virtualenv_python_executable(venv); + let interpreter = Interpreter::query(executable, cache)?; Ok(Self(Arc::new(PythonEnvironmentShared { - root: venv, + root: interpreter.prefix().to_path_buf(), interpreter, }))) } /// Create a [`PythonEnvironment`] for a Python interpreter specifier (e.g., a path or a binary name). - pub fn from_requested_python(python: &str, cache: &Cache) -> Result { - let Some(interpreter) = find_requested_python(python, cache)? else { - return Err(Error::RequestedPythonNotFound(python.to_string())); - }; + pub fn from_requested_python( + request: &str, + system: SystemPython, + cache: &Cache, + ) -> Result { + let sources = SourceSelector::from_env(system); + let request = InterpreterRequest::parse(request); + let interpreter = find_interpreter(&request, &sources, cache)??.into_interpreter(); Ok(Self(Arc::new(PythonEnvironmentShared { root: interpreter.prefix().to_path_buf(), interpreter, @@ -69,14 +95,14 @@ impl PythonEnvironment { /// Create a [`PythonEnvironment`] for the default Python interpreter. pub fn from_default_python(cache: &Cache) -> Result { - let interpreter = find_default_python(cache)?; + let interpreter = find_default_interpreter(cache)??.into_interpreter(); Ok(Self(Arc::new(PythonEnvironmentShared { root: interpreter.prefix().to_path_buf(), interpreter, }))) } - /// Create a [`PythonEnvironment`] from an existing [`Interpreter`] and root directory. + /// Create a [`PythonEnvironment`] from an existing [`Interpreter`]. pub fn from_interpreter(interpreter: Interpreter) -> Self { Self(Arc::new(PythonEnvironmentShared { root: interpreter.prefix().to_path_buf(), @@ -100,11 +126,13 @@ impl PythonEnvironment { } /// Return the [`Interpreter`] for this virtual environment. + /// + /// See also [`PythonEnvironment::into_interpreter`]. pub fn interpreter(&self) -> &Interpreter { &self.0.interpreter } - /// Return the [`PyVenvConfiguration`] for this virtual environment, as extracted from the + /// Return the [`PyVenvConfiguration`] for this environment, as extracted from the /// `pyvenv.cfg` file. pub fn cfg(&self) -> Result { Ok(PyVenvConfiguration::parse(self.0.root.join("pyvenv.cfg"))?) @@ -115,7 +143,7 @@ impl PythonEnvironment { self.0.interpreter.sys_executable() } - /// Returns an iterator over the `site-packages` directories inside a virtual environment. + /// Returns an iterator over the `site-packages` directories inside the environment. /// /// In most cases, `purelib` and `platlib` will be the same, and so the iterator will contain /// a single element; however, in some distributions, they may be different. @@ -138,12 +166,12 @@ impl PythonEnvironment { } } - /// Returns the path to the `bin` directory inside a virtual environment. + /// Returns the path to the `bin` directory inside this environment. pub fn scripts(&self) -> &Path { self.0.interpreter.scripts() } - /// Grab a file lock for the virtual environment to prevent concurrent writes across processes. + /// Grab a file lock for the environment to prevent concurrent writes across processes. pub fn lock(&self) -> Result { if let Some(target) = self.0.interpreter.target() { // If we're installing into a `--target`, use a target-specific lock file. @@ -163,7 +191,9 @@ impl PythonEnvironment { } } - /// Return the [`Interpreter`] for this virtual environment. + /// Return the [`Interpreter`] for this environment. + /// + /// See also [`PythonEnvironment::interpreter`]. pub fn into_interpreter(self) -> Interpreter { Arc::unwrap_or_clone(self.0).interpreter } diff --git a/crates/uv-interpreter/src/find_python.rs b/crates/uv-interpreter/src/find_python.rs deleted file mode 100644 index be1f9e252325..000000000000 --- a/crates/uv-interpreter/src/find_python.rs +++ /dev/null @@ -1,800 +0,0 @@ -use std::borrow::Cow; -use std::env; -use std::ffi::{OsStr, OsString}; -use std::path::PathBuf; - -use tracing::{debug, instrument}; - -use uv_cache::Cache; -use uv_warnings::warn_user_once; - -use crate::interpreter::InterpreterInfoError; -use crate::py_launcher::{py_list_paths, Error as PyLauncherError, PyListPath}; -use crate::virtualenv::{detect_virtualenv, virtualenv_python_executable}; -use crate::PythonVersion; -use crate::{Error, Interpreter}; - -/// Find a Python of a specific version, a binary with a name or a path to a binary. -/// -/// Supported formats: -/// * `-p 3.10` searches for an installed Python 3.10 (`py --list-paths` on Windows, `python3.10` on -/// Linux/Mac). Specifying a patch version is not supported. -/// * `-p python3.10` or `-p python.exe` looks for a binary in `PATH`. -/// * `-p /home/ferris/.local/bin/python3.10` uses this exact Python. -/// -/// When the user passes a patch version (e.g. 3.12.1), we currently search for a matching minor -/// version (e.g. `python3.12` on unix) and error when the version mismatches, as a binary with the -/// patch version (e.g. `python3.12.1`) is often not in `PATH` and we make the simplifying -/// assumption that the user has only this one patch version installed. -#[instrument(skip_all, fields(%request))] -pub fn find_requested_python(request: &str, cache: &Cache) -> Result, Error> { - debug!("Starting interpreter discovery for Python @ `{request}`"); - let versions = request - .splitn(3, '.') - .map(str::parse::) - .collect::, _>>(); - if let Ok(versions) = versions { - // `-p 3.10` or `-p 3.10.1` - let selector = match versions.as_slice() { - [requested_major] => PythonVersionSelector::Major(*requested_major), - [major, minor] => PythonVersionSelector::MajorMinor(*major, *minor), - [major, minor, requested_patch] => { - PythonVersionSelector::MajorMinorPatch(*major, *minor, *requested_patch) - } - // SAFETY: Guaranteed by the Ok(versions) guard - _ => unreachable!(), - }; - let interpreter = find_python(selector, cache)?; - interpreter - .as_ref() - .inspect(|inner| warn_on_unsupported_python(inner)); - Ok(interpreter) - } else { - match fs_err::metadata(request) { - Ok(metadata) => { - // Map from user-provided path to an executable. - let path = uv_fs::absolutize_path(request.as_ref())?; - let executable = if metadata.is_dir() { - // If the user provided a directory, assume it's a virtual environment. - // `-p /home/ferris/.venv` - if cfg!(windows) { - Cow::Owned(path.join("Scripts/python.exe")) - } else { - Cow::Owned(path.join("bin/python")) - } - } else { - // Otherwise, assume it's a Python executable. - // `-p /home/ferris/.local/bin/python3.10` - path - }; - Interpreter::query(executable, cache) - .inspect(warn_on_unsupported_python) - .map(Some) - } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - // `-p python3.10`; Generally not used on windows because all Python are `python.exe`. - let Some(executable) = find_executable(request)? else { - return Ok(None); - }; - Interpreter::query(executable, cache) - .inspect(warn_on_unsupported_python) - .map(Some) - } - Err(err) => return Err(err.into()), - } - } -} - -/// Pick a sensible default for the Python a user wants when they didn't specify a version. -/// -/// We prefer the test overwrite `UV_TEST_PYTHON_PATH` if it is set, otherwise `python3`/`python` or -/// `python.exe` respectively. -#[instrument(skip_all)] -pub fn find_default_python(cache: &Cache) -> Result { - debug!("Starting interpreter discovery for default Python"); - try_find_default_python(cache)? - .ok_or(if cfg!(windows) { - Error::NoPythonInstalledWindows - } else if cfg!(unix) { - Error::NoPythonInstalledUnix - } else { - unreachable!("Only Unix and Windows are supported") - }) - .inspect(warn_on_unsupported_python) -} - -/// Same as [`find_default_python`] but returns `None` if no python is found instead of returning an `Err`. -pub(crate) fn try_find_default_python(cache: &Cache) -> Result, Error> { - find_python(PythonVersionSelector::Default, cache) -} - -/// Find a Python version matching `selector`. -/// -/// It searches for an existing installation in the following order: -/// * Search for the python binary in `PATH` (or `UV_TEST_PYTHON_PATH` if set). Visits each path and for each path resolves the -/// files in the following order: -/// * Major.Minor.Patch: `pythonx.y.z`, `pythonx.y`, `python.x`, `python` -/// * Major.Minor: `pythonx.y`, `pythonx`, `python` -/// * Major: `pythonx`, `python` -/// * Default: `python3`, `python` -/// * (windows): For each of the above, test for the existence of `python.bat` shim (pyenv-windows) last. -/// * (windows): Discover installations using `py --list-paths` (PEP514). Continue if `py` is not installed. -/// -/// (Windows): Filter out the Windows store shim (Enabled in Settings/Apps/Advanced app settings/App execution aliases). -fn find_python( - selector: PythonVersionSelector, - cache: &Cache, -) -> Result, Error> { - #[allow(non_snake_case)] - let UV_TEST_PYTHON_PATH = env::var_os("UV_TEST_PYTHON_PATH"); - - let use_override = UV_TEST_PYTHON_PATH.is_some(); - let possible_names = selector.possible_names(); - - #[allow(non_snake_case)] - let PATH = UV_TEST_PYTHON_PATH - .or(env::var_os("PATH")) - .unwrap_or_default(); - - // We use `which` here instead of joining the paths ourselves because `which` checks for us if the python - // binary is executable and exists. It also has some extra logic that handles inconsistent casing on Windows - // and expands `~`. - for path in env::split_paths(&PATH) { - for name in possible_names.iter().flatten() { - if let Ok(paths) = which::which_in_global(&**name, Some(&path)) { - for path in paths { - #[cfg(windows)] - if windows::is_windows_store_shim(&path) { - continue; - } - - let interpreter = match Interpreter::query(&path, cache) { - Ok(interpreter) => interpreter, - - // If the Python version is < 3.4, the `-I` flag is not supported, so - // we can't run the script at all, and need to sniff it from the output. - Err(Error::PythonSubcommandOutput { stderr, .. }) - if stderr.contains("Unknown option: -I") => - { - // If the user _requested_ a version prior to 3.4, raise an error, as - // 3.4 is the minimum supported version for invoking the interpreter - // query script at all. - match selector { - PythonVersionSelector::Major(major) if major < 3 => { - return Err(Error::UnsupportedPython(major.to_string())); - } - PythonVersionSelector::MajorMinor(major, minor) - if (major, minor) < (3, 4) => - { - return Err(Error::UnsupportedPython(format!( - "{major}.{minor}" - ))); - } - PythonVersionSelector::MajorMinorPatch(major, minor, patch) - if (major, minor) < (3, 4) => - { - return Err(Error::UnsupportedPython(format!( - "{major}.{minor}.{patch}" - ))); - } - _ => {} - } - - debug!( - "Found a Python installation that isn't supported by uv, skipping." - ); - continue; - } - - Err(Error::QueryScript { - err: InterpreterInfoError::UnsupportedPythonVersion { .. }, - .. - }) => { - // If the user _requested_ a version prior to 3.7, raise an error, as - // 3.7 is the minimum supported version for running the interpreter - // query script. - match selector { - PythonVersionSelector::Major(major) if major < 3 => { - return Err(Error::UnsupportedPython(major.to_string())); - } - PythonVersionSelector::MajorMinor(major, minor) - if (major, minor) < (3, 7) => - { - return Err(Error::UnsupportedPython(format!( - "{major}.{minor}" - ))); - } - PythonVersionSelector::MajorMinorPatch(major, minor, patch) - if (major, minor) < (3, 7) => - { - return Err(Error::UnsupportedPython(format!( - "{major}.{minor}.{patch}" - ))); - } - _ => {} - } - - debug!( - "Found a Python installation that isn't supported by uv, skipping." - ); - continue; - } - - Err(error) => return Err(error), - }; - - let installation = PythonInstallation::Interpreter(interpreter); - - if let Some(interpreter) = installation.select(selector, cache)? { - return Ok(Some(interpreter)); - } - } - } - } - - // Python's `venv` model doesn't have this case because they use the `sys.executable` by default - // which is sufficient to support pyenv-windows. Unfortunately, we can't rely on the executing Python version. - // That's why we explicitly search for a Python shim as last resort. - if cfg!(windows) { - if let Ok(shims) = which::which_in_global("python.bat", Some(&path)) { - for shim in shims { - let interpreter = match Interpreter::query(&shim, cache) { - Ok(interpreter) => interpreter, - Err(error) => { - // Don't fail when querying the shim failed. E.g it's possible that no python version is selected - // in the shim in which case pyenv prints to stdout. - tracing::warn!("Failed to query python shim: {error}"); - continue; - } - }; - - if let Some(interpreter) = - PythonInstallation::Interpreter(interpreter).select(selector, cache)? - { - return Ok(Some(interpreter)); - } - } - } - } - } - - if cfg!(windows) && !use_override { - // Use `py` to find the python installation on the system. - match py_list_paths() { - Ok(paths) => { - for entry in paths { - let installation = PythonInstallation::PyListPath(entry); - if let Some(interpreter) = installation.select(selector, cache)? { - return Ok(Some(interpreter)); - } - } - } - // Do not error when `py` is not available - Err(PyLauncherError::NotFound) => debug!("`py` is not installed"), - Err(error) => return Err(Error::PyLauncher(error)), - } - } - - Ok(None) -} - -/// Find the Python interpreter in `PATH` matching the given name (e.g., `python3`), respecting -/// `UV_PYTHON_PATH`. -/// -/// Returns `Ok(None)` if not found. -fn find_executable + Into + Copy>( - requested: R, -) -> Result, Error> { - #[allow(non_snake_case)] - let UV_TEST_PYTHON_PATH = env::var_os("UV_TEST_PYTHON_PATH"); - - let use_override = UV_TEST_PYTHON_PATH.is_some(); - - #[allow(non_snake_case)] - let PATH = UV_TEST_PYTHON_PATH - .or(env::var_os("PATH")) - .unwrap_or_default(); - - // We use `which` here instead of joining the paths ourselves because `which` checks for us if the python - // binary is executable and exists. It also has some extra logic that handles inconsistent casing on Windows - // and expands `~`. - for path in env::split_paths(&PATH) { - let paths = match which::which_in_global(requested, Some(&path)) { - Ok(paths) => paths, - Err(which::Error::CannotFindBinaryPath) => continue, - Err(err) => return Err(Error::WhichError(requested.into(), err)), - }; - - #[allow(clippy::never_loop)] - for path in paths { - #[cfg(windows)] - if windows::is_windows_store_shim(&path) { - continue; - } - - return Ok(Some(path)); - } - } - - if cfg!(windows) && !use_override { - // Use `py` to find the python installation on the system. - match py_list_paths() { - Ok(paths) => { - for entry in paths { - // Ex) `--python python3.12.exe` - if entry.executable_path.file_name() == Some(requested.as_ref()) { - return Ok(Some(entry.executable_path)); - } - - // Ex) `--python python3.12` - if entry - .executable_path - .file_stem() - .is_some_and(|stem| stem == requested.as_ref()) - { - return Ok(Some(entry.executable_path)); - } - } - } - // Do not error when `py` is not available - Err(PyLauncherError::NotFound) => debug!("`py` is not installed"), - Err(error) => return Err(Error::PyLauncher(error)), - } - } - - Ok(None) -} - -#[derive(Debug, Clone)] -enum PythonInstallation { - PyListPath(PyListPath), - Interpreter(Interpreter), -} - -impl PythonInstallation { - fn major(&self) -> u8 { - match self { - Self::PyListPath(PyListPath { major, .. }) => *major, - Self::Interpreter(interpreter) => interpreter.python_major(), - } - } - - fn minor(&self) -> u8 { - match self { - Self::PyListPath(PyListPath { minor, .. }) => *minor, - Self::Interpreter(interpreter) => interpreter.python_minor(), - } - } - - /// Selects the interpreter if it matches the selector (version specification). - fn select( - self, - selector: PythonVersionSelector, - cache: &Cache, - ) -> Result, Error> { - let selected = match selector { - PythonVersionSelector::Default => true, - - PythonVersionSelector::Major(major) => self.major() == major, - - PythonVersionSelector::MajorMinor(major, minor) => { - self.major() == major && self.minor() == minor - } - - PythonVersionSelector::MajorMinorPatch(major, minor, requested_patch) => { - let interpreter = self.into_interpreter(cache)?; - return Ok( - if major == interpreter.python_major() - && minor == interpreter.python_minor() - && requested_patch == interpreter.python_patch() - { - Some(interpreter) - } else { - None - }, - ); - } - }; - - if selected { - self.into_interpreter(cache).map(Some) - } else { - Ok(None) - } - } - - pub(super) fn into_interpreter(self, cache: &Cache) -> Result { - match self { - Self::PyListPath(PyListPath { - executable_path, .. - }) => Interpreter::query(executable_path, cache), - Self::Interpreter(interpreter) => Ok(interpreter), - } - } -} - -#[derive(Copy, Clone, Debug)] -enum PythonVersionSelector { - Default, - Major(u8), - MajorMinor(u8, u8), - MajorMinorPatch(u8, u8, u8), -} - -impl PythonVersionSelector { - fn possible_names(self) -> [Option>; 4] { - let (python, python3, extension) = if cfg!(windows) { - ( - Cow::Borrowed("python.exe"), - Cow::Borrowed("python3.exe"), - ".exe", - ) - } else { - (Cow::Borrowed("python"), Cow::Borrowed("python3"), "") - }; - - match self { - Self::Default => [Some(python3), Some(python), None, None], - Self::Major(major) => [ - Some(Cow::Owned(format!("python{major}{extension}"))), - Some(python), - None, - None, - ], - Self::MajorMinor(major, minor) => [ - Some(Cow::Owned(format!("python{major}.{minor}{extension}"))), - Some(Cow::Owned(format!("python{major}{extension}"))), - Some(python), - None, - ], - Self::MajorMinorPatch(major, minor, patch) => [ - Some(Cow::Owned(format!( - "python{major}.{minor}.{patch}{extension}", - ))), - Some(Cow::Owned(format!("python{major}.{minor}{extension}"))), - Some(Cow::Owned(format!("python{major}{extension}"))), - Some(python), - ], - } - } -} - -fn warn_on_unsupported_python(interpreter: &Interpreter) { - // Warn on usage with an unsupported Python version - if interpreter.python_tuple() < (3, 8) { - warn_user_once!( - "uv is only compatible with Python 3.8+, found Python {}.", - interpreter.python_version() - ); - } -} - -/// Find a matching Python or any fallback Python. -/// -/// If no Python version is provided, we will use the first available interpreter. -/// -/// If a Python version is provided, we will first try to find an exact match. If -/// that cannot be found and a patch version was requested, we will look for a match -/// without comparing the patch version number. If that cannot be found, we fall back to -/// the first available version. -/// -/// See [`Self::find_version`] for details on the precedence of Python lookup locations. -#[instrument(skip_all, fields(?python_version))] -pub fn find_best_python( - python_version: Option<&PythonVersion>, - system: bool, - cache: &Cache, -) -> Result { - if let Some(python_version) = python_version { - debug!( - "Starting interpreter discovery for Python {}", - python_version - ); - } else { - debug!("Starting interpreter discovery for active Python"); - } - - // First, check for an exact match (or the first available version if no Python version was provided) - if let Some(interpreter) = find_version(python_version, system, cache)? { - warn_on_unsupported_python(&interpreter); - return Ok(interpreter); - } - - if let Some(python_version) = python_version { - // If that fails, and a specific patch version was requested try again allowing a - // different patch version - if python_version.patch().is_some() { - if let Some(interpreter) = - find_version(Some(&python_version.without_patch()), system, cache)? - { - warn_on_unsupported_python(&interpreter); - return Ok(interpreter); - } - } - } - - // If a Python version was requested but cannot be fulfilled, just take any version - if let Some(interpreter) = find_version(None, system, cache)? { - return Ok(interpreter); - } - - Err(Error::PythonNotFound) -} - -/// Find a Python interpreter. -/// -/// We check, in order, the following locations: -/// -/// - `UV_DEFAULT_PYTHON`, which is set to the python interpreter when using `python -m uv`. -/// - `VIRTUAL_ENV` and `CONDA_PREFIX` -/// - A `.venv` folder -/// - If a python version is given: Search `PATH` and `py --list-paths`, see `find_python` -/// - `python3` (unix) or `python.exe` (windows) -/// -/// If `UV_TEST_PYTHON_PATH` is set, we will not check for Python versions in the -/// global PATH, instead we will search using the provided path. Virtual environments -/// will still be respected. -/// -/// If a version is provided and an interpreter cannot be found with the given version, -/// we will return [`None`]. -fn find_version( - python_version: Option<&PythonVersion>, - system: bool, - cache: &Cache, -) -> Result, Error> { - let version_matches = |interpreter: &Interpreter| -> bool { - if let Some(python_version) = python_version { - // If a patch version was provided, check for an exact match - interpreter.satisfies(python_version) - } else { - // The version always matches if one was not provided - true - } - }; - - // Check if the venv Python matches. - if !system { - if let Some(venv) = detect_virtualenv()? { - let executable = virtualenv_python_executable(venv); - let interpreter = Interpreter::query(executable, cache)?; - - if version_matches(&interpreter) { - return Ok(Some(interpreter)); - } - }; - } - - // Look for the requested version with by search for `python{major}.{minor}` in `PATH` on - // Unix and `py --list-paths` on Windows. - let interpreter = if let Some(python_version) = python_version { - find_requested_python(&python_version.string, cache)? - } else { - try_find_default_python(cache)? - }; - - if let Some(interpreter) = interpreter { - debug_assert!(version_matches(&interpreter)); - Ok(Some(interpreter)) - } else { - Ok(None) - } -} - -mod windows { - /// On Windows we might encounter the Windows Store proxy shim (enabled in: - /// Settings/Apps/Advanced app settings/App execution aliases). When Python is _not_ installed - /// via the Windows Store, but the proxy shim is enabled, then executing `python.exe` or - /// `python3.exe` will redirect to the Windows Store installer. - /// - /// We need to detect that these `python.exe` and `python3.exe` files are _not_ Python - /// executables. - /// - /// This method is taken from Rye: - /// - /// > This is a pretty dumb way. We know how to parse this reparse point, but Microsoft - /// > does not want us to do this as the format is unstable. So this is a best effort way. - /// > we just hope that the reparse point has the python redirector in it, when it's not - /// > pointing to a valid Python. - /// - /// See: - #[cfg(windows)] - pub(super) fn is_windows_store_shim(path: &std::path::Path) -> bool { - use std::os::windows::fs::MetadataExt; - use std::os::windows::prelude::OsStrExt; - use winapi::um::fileapi::{CreateFileW, OPEN_EXISTING}; - use winapi::um::handleapi::{CloseHandle, INVALID_HANDLE_VALUE}; - use winapi::um::ioapiset::DeviceIoControl; - use winapi::um::winbase::{FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT}; - use winapi::um::winioctl::FSCTL_GET_REPARSE_POINT; - use winapi::um::winnt::{FILE_ATTRIBUTE_REPARSE_POINT, MAXIMUM_REPARSE_DATA_BUFFER_SIZE}; - - // The path must be absolute. - if !path.is_absolute() { - return false; - } - - // The path must point to something like: - // `C:\Users\crmar\AppData\Local\Microsoft\WindowsApps\python3.exe` - let mut components = path.components().rev(); - - // Ex) `python.exe` or `python3.exe` - if !components - .next() - .and_then(|component| component.as_os_str().to_str()) - .is_some_and(|component| component == "python.exe" || component == "python3.exe") - { - return false; - } - - // Ex) `WindowsApps` - if !components - .next() - .is_some_and(|component| component.as_os_str() == "WindowsApps") - { - return false; - } - - // Ex) `Microsoft` - if !components - .next() - .is_some_and(|component| component.as_os_str() == "Microsoft") - { - return false; - } - - // The file is only relevant if it's a reparse point. - let Ok(md) = fs_err::symlink_metadata(path) else { - return false; - }; - if md.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0 { - return false; - } - - let mut path_encoded = path - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect::>(); - - // SAFETY: The path is null-terminated. - #[allow(unsafe_code)] - let reparse_handle = unsafe { - CreateFileW( - path_encoded.as_mut_ptr(), - 0, - 0, - std::ptr::null_mut(), - OPEN_EXISTING, - FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, - std::ptr::null_mut(), - ) - }; - - if reparse_handle == INVALID_HANDLE_VALUE { - return false; - } - - let mut buf = [0u16; MAXIMUM_REPARSE_DATA_BUFFER_SIZE as usize]; - let mut bytes_returned = 0; - - // SAFETY: The buffer is large enough to hold the reparse point. - #[allow(unsafe_code, clippy::cast_possible_truncation)] - let success = unsafe { - DeviceIoControl( - reparse_handle, - FSCTL_GET_REPARSE_POINT, - std::ptr::null_mut(), - 0, - buf.as_mut_ptr().cast(), - buf.len() as u32 * 2, - &mut bytes_returned, - std::ptr::null_mut(), - ) != 0 - }; - - // SAFETY: The handle is valid. - #[allow(unsafe_code)] - unsafe { - CloseHandle(reparse_handle); - } - - // If the operation failed, assume it's not a reparse point. - if !success { - return false; - } - - let reparse_point = String::from_utf16_lossy(&buf[..bytes_returned as usize]); - reparse_point.contains("\\AppInstallerPythonRedirector.exe") - } - - #[cfg(test)] - mod tests { - use std::fmt::Debug; - - use insta::assert_snapshot; - use itertools::Itertools; - - use uv_cache::Cache; - - use crate::{find_requested_python, Error}; - - fn format_err(err: Result) -> String { - anyhow::Error::new(err.unwrap_err()) - .chain() - .join("\n Caused by: ") - } - - #[test] - #[cfg_attr(not(windows), ignore)] - fn no_such_python_path() { - let cache = Cache::temp().unwrap().init().unwrap(); - let result = find_requested_python(r"C:\does\not\exists\python3.12", &cache) - .unwrap() - .ok_or(Error::RequestedPythonNotFound( - r"C:\does\not\exists\python3.12".to_string(), - )); - assert_snapshot!( - format_err(result), - @"Failed to locate Python interpreter at: `C:\\does\\not\\exists\\python3.12`" - ); - } - } -} - -#[cfg(test)] -mod tests { - use insta::assert_snapshot; - use itertools::Itertools; - - use uv_cache::Cache; - - use crate::find_python::find_requested_python; - use crate::Error; - - fn format_err(err: Result) -> String { - anyhow::Error::new(err.unwrap_err()) - .chain() - .join("\n Caused by: ") - } - - #[test] - #[cfg_attr(not(unix), ignore)] - fn no_such_python_version() { - let cache = Cache::temp().unwrap().init().unwrap(); - let request = "3.1000"; - let result = find_requested_python(request, &cache) - .unwrap() - .ok_or(Error::NoSuchPython(request.to_string())); - assert_snapshot!( - format_err(result), - @"No Python 3.1000 in `PATH`. Is Python 3.1000 installed?" - ); - } - - #[test] - #[cfg_attr(not(unix), ignore)] - fn no_such_python_binary() { - let cache = Cache::temp().unwrap().init().unwrap(); - let request = "python3.1000"; - let result = find_requested_python(request, &cache) - .unwrap() - .ok_or(Error::NoSuchPython(request.to_string())); - assert_snapshot!( - format_err(result), - @"No Python python3.1000 in `PATH`. Is Python python3.1000 installed?" - ); - } - - #[test] - #[cfg_attr(not(unix), ignore)] - fn no_such_python_path() { - let cache = Cache::temp().unwrap().init().unwrap(); - let result = find_requested_python("/does/not/exists/python3.12", &cache) - .unwrap() - .ok_or(Error::RequestedPythonNotFound( - "/does/not/exists/python3.12".to_string(), - )); - assert_snapshot!( - format_err(result), @"Failed to locate Python interpreter at: `/does/not/exists/python3.12`"); - } -} diff --git a/crates/uv-interpreter/src/implementation.rs b/crates/uv-interpreter/src/implementation.rs index 9fdab3e813c7..fee3bb8f9287 100644 --- a/crates/uv-interpreter/src/implementation.rs +++ b/crates/uv-interpreter/src/implementation.rs @@ -16,7 +16,6 @@ pub enum ImplementationName { } impl ImplementationName { - #[allow(dead_code)] pub(crate) fn iter() -> impl Iterator { static NAMES: &[ImplementationName] = &[ImplementationName::Cpython]; NAMES.iter() diff --git a/crates/uv-interpreter/src/interpreter.rs b/crates/uv-interpreter/src/interpreter.rs index 2d52b10fb297..ec56f7b6d896 100644 --- a/crates/uv-interpreter/src/interpreter.rs +++ b/crates/uv-interpreter/src/interpreter.rs @@ -1,11 +1,13 @@ +use std::io; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, ExitStatus}; use configparser::ini::Ini; use fs_err as fs; use once_cell::sync::OnceCell; use serde::{Deserialize, Serialize}; -use tracing::{debug, warn}; +use thiserror::Error; +use tracing::{trace, warn}; use cache_key::digest; use install_wheel_rs::Layout; @@ -18,7 +20,7 @@ use uv_cache::{Cache, CacheBucket, CachedByTimestamp, Freshness, Timestamp}; use uv_fs::{write_atomic_sync, PythonExt, Simplified}; use crate::pointer_size::PointerSize; -use crate::{Error, PythonVersion, Target, VirtualEnvironment}; +use crate::{PythonVersion, Target, VirtualEnvironment}; /// A Python executable and its associated platform markers. #[derive(Debug, Clone)] @@ -407,6 +409,41 @@ impl ExternallyManaged { } } +#[derive(Debug, Error)] +pub enum Error { + #[error(transparent)] + Io(#[from] io::Error), + #[error("Failed to query Python interpreter at `{path}`")] + SpawnFailed { + path: PathBuf, + #[source] + err: io::Error, + }, + #[error("Querying Python at `{}` did not return the expected data\n{err}\n--- stdout:\n{stdout}\n--- stderr:\n{stderr}\n---", path.display())] + UnexpectedResponse { + err: serde_json::Error, + stdout: String, + stderr: String, + path: PathBuf, + }, + + #[error("Querying Python at `{}` failed with exit status {code}\n--- stdout:\n{stdout}\n--- stderr:\n{stderr}\n---", path.display())] + StatusCode { + code: ExitStatus, + stdout: String, + stderr: String, + path: PathBuf, + }, + #[error("Can't use Python at `{path}`")] + QueryScript { + #[source] + err: InterpreterInfoError, + path: PathBuf, + }, + #[error("Failed to write to cache")] + Encode(#[from] rmp_serde::encode::Error), +} + #[derive(Debug, Deserialize, Serialize)] #[serde(tag = "result", rename_all = "lowercase")] enum InterpreterInfoResult { @@ -423,6 +460,8 @@ pub enum InterpreterInfoError { UnknownOperatingSystem { operating_system: String }, #[error("Python {python_version} is not supported. Please use Python 3.8 or newer.")] UnsupportedPythonVersion { python_version: String }, + #[error("Python executable does not support `-I` flag. Please use Python 3.8 or newer.")] + UnsupportedPython, } #[derive(Debug, Deserialize, Serialize, Clone)] @@ -460,41 +499,54 @@ impl InterpreterInfo { .arg("-c") .arg(script) .output() - .map_err(|err| Error::PythonSubcommandLaunch { - interpreter: interpreter.to_path_buf(), + .map_err(|err| Error::SpawnFailed { + path: interpreter.to_path_buf(), err, })?; if !output.status.success() { - return Err(Error::PythonSubcommandOutput { - message: format!( - "Querying Python at `{}` failed with status {}", - interpreter.display(), - output.status, - ), - exit_code: output.status, + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + + // If the Python version is too old, we may not even be able to invoke the query script + if stderr.contains("Unknown option: -I") { + return Err(Error::QueryScript { + err: InterpreterInfoError::UnsupportedPython, + path: interpreter.to_path_buf(), + }); + } + + return Err(Error::StatusCode { + code: output.status, + stderr, stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(), - stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), + path: interpreter.to_path_buf(), }); } let result: InterpreterInfoResult = serde_json::from_slice(&output.stdout).map_err(|err| { - Error::PythonSubcommandOutput { - message: format!( - "Querying Python at `{}` did not return the expected data: {err}", - interpreter.display(), - ), - exit_code: output.status, - stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(), - stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + + // If the Python version is too old, we may not even be able to invoke the query script + if stderr.contains("Unknown option: -I") { + Error::QueryScript { + err: InterpreterInfoError::UnsupportedPython, + path: interpreter.to_path_buf(), + } + } else { + Error::UnexpectedResponse { + err, + stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(), + stderr, + path: interpreter.to_path_buf(), + } } })?; match result { InterpreterInfoResult::Error(err) => Err(Error::QueryScript { err, - interpreter: interpreter.to_path_buf(), + path: interpreter.to_path_buf(), }), InterpreterInfoResult::Success(data) => Ok(*data), } @@ -557,7 +609,7 @@ impl InterpreterInfo { match rmp_serde::from_slice::>(&data) { Ok(cached) => { if cached.timestamp == modified { - debug!( + trace!( "Cached interpreter info for Python {}, skipping probing: {}", cached.data.markers.python_full_version(), executable.user_display() @@ -565,14 +617,14 @@ impl InterpreterInfo { return Ok(cached.data); } - debug!( - "Ignoring stale cached markers for: {}", + trace!( + "Ignoring stale interpreter markers for: {}", executable.user_display() ); } Err(err) => { warn!( - "Broken cache entry at {}, removing: {err}", + "Broken interpreter cache entry at {}, removing: {err}", cache_entry.path().user_display() ); let _ = fs_err::remove_file(cache_entry.path()); @@ -582,10 +634,13 @@ impl InterpreterInfo { } // Otherwise, run the Python script. - debug!("Probing interpreter info for: {}", executable.display()); + trace!( + "Querying interpreter executable at {}", + executable.display() + ); let info = Self::query(executable, cache)?; - debug!( - "Found Python {} for: {}", + trace!( + "Found Python {} at {}", info.markers.python_full_version(), executable.display() ); @@ -687,6 +742,7 @@ mod tests { "##}, ) .unwrap(); + fs::set_permissions( &mocked_interpreter, std::os::unix::fs::PermissionsExt::from_mode(0o770), diff --git a/crates/uv-interpreter/src/lib.rs b/crates/uv-interpreter/src/lib.rs index e0f8e54ad654..cdb232ae8954 100644 --- a/crates/uv-interpreter/src/lib.rs +++ b/crates/uv-interpreter/src/lib.rs @@ -1,32 +1,20 @@ -//! Find matching Python interpreter and query information about python interpreter. -//! -//! * The `venv` subcommand uses [`find_requested_python`] if `-p`/`--python` is used and -//! `find_default_python` otherwise. -//! * The `compile` subcommand uses [`find_best_python`]. -//! * The `sync`, `install`, `uninstall`, `freeze`, `list` and `show` subcommands use -//! [`find_default_python`] when `--python` is used, [`find_default_python`] when `--system` is used -//! and the current venv by default. - -use std::ffi::OsString; -use std::io; -use std::path::PathBuf; -use std::process::ExitStatus; - +//! Find requested Python interpreters and query interpreters for information. use thiserror::Error; -use uv_fs::Simplified; - +pub use crate::discovery::{ + find_best_interpreter, find_default_interpreter, find_interpreter, Error as DiscoveryError, + InterpreterNotFound, InterpreterRequest, InterpreterSource, SourceSelector, SystemPython, + VersionRequest, +}; pub use crate::environment::PythonEnvironment; -pub use crate::find_python::{find_best_python, find_default_python, find_requested_python}; pub use crate::interpreter::Interpreter; -use crate::interpreter::InterpreterInfoError; pub use crate::pointer_size::PointerSize; pub use crate::python_version::PythonVersion; pub use crate::target::Target; -pub use crate::virtualenv::{PyVenvConfiguration, VirtualEnvironment}; +pub use crate::virtualenv::{Error as VirtualEnvError, PyVenvConfiguration, VirtualEnvironment}; +mod discovery; mod environment; -mod find_python; mod implementation; mod interpreter; pub mod managed; @@ -37,61 +25,1429 @@ mod python_version; mod target; mod virtualenv; +#[cfg(not(test))] +pub(crate) fn current_dir() -> Result { + std::env::current_dir() +} + +#[cfg(test)] +pub(crate) fn current_dir() -> Result { + std::env::var_os("PWD") + .map(std::path::PathBuf::from) + .map(Result::Ok) + .unwrap_or(std::env::current_dir()) +} + #[derive(Debug, Error)] pub enum Error { - #[error("Expected `{}` to be a virtualenv, but `pyvenv.cfg` is missing", _0.user_display())] - MissingPyVenvCfg(PathBuf), - #[error("No versions of Python could be found. Is Python installed?")] - PythonNotFound, - #[error("Failed to locate a virtualenv or Conda environment (checked: `VIRTUAL_ENV`, `CONDA_PREFIX`, and `.venv`). Run `uv venv` to create a virtualenv.")] - VenvNotFound, - #[error("Virtualenv does not exist at: `{}`", _0.user_display())] - VenvDoesNotExist(PathBuf), - #[error("Failed to locate Python interpreter at: `{0}`")] - RequestedPythonNotFound(String), #[error(transparent)] - Io(#[from] io::Error), - #[error("Failed to query Python interpreter at `{interpreter}`")] - PythonSubcommandLaunch { - interpreter: PathBuf, - #[source] - err: io::Error, - }, + VirtualEnv(#[from] virtualenv::Error), + + #[error(transparent)] + Query(#[from] interpreter::Error), + + #[error(transparent)] + Discovery(#[from] discovery::Error), + #[error(transparent)] PyLauncher(#[from] py_launcher::Error), - #[cfg(windows)] - #[error( - "No Python {0} found through `py --list-paths` or in `PATH`. Is Python {0} installed?" - )] - NoSuchPython(String), - #[cfg(unix)] - #[error("No Python {0} in `PATH`. Is Python {0} installed?")] - NoSuchPython(String), - #[error("Neither `python` nor `python3` are in `PATH`. Is Python installed?")] - NoPythonInstalledUnix, - #[error( - "Could not find `python.exe` through `py --list-paths` or in 'PATH'. Is Python installed?" - )] - NoPythonInstalledWindows, - #[error("{message} with {exit_code}\n--- stdout:\n{stdout}\n--- stderr:\n{stderr}\n---")] - PythonSubcommandOutput { - message: String, - exit_code: ExitStatus, - stdout: String, - stderr: String, - }, - #[error("Requested Python version ({0}) is unsupported")] - UnsupportedPython(String), - #[error("Failed to write to cache")] - Encode(#[from] rmp_serde::encode::Error), - #[error("Broken virtualenv: Failed to parse pyvenv.cfg")] - Cfg(#[from] virtualenv::Error), - #[error("Error finding `{}` in PATH", _0.to_string_lossy())] - WhichError(OsString, #[source] which::Error), - #[error("Can't use Python at `{interpreter}`")] - QueryScript { - #[source] - err: InterpreterInfoError, - interpreter: PathBuf, - }, + + #[error(transparent)] + NotFound(#[from] discovery::InterpreterNotFound), +} + +// The mock interpreters are not valid on Windows so we don't have unit test coverage there +// TODO(zanieb): We should write a mock interpreter script that works on Windows +#[cfg(all(test, unix))] +mod tests { + use anyhow::Result; + use indoc::{formatdoc, indoc}; + use std::{ + env, + ffi::OsString, + path::{Path, PathBuf}, + str::FromStr, + }; + use temp_env::with_vars; + use test_log::test; + + use assert_fs::{prelude::*, TempDir}; + use uv_cache::Cache; + + use crate::{ + discovery::{self, DiscoveredInterpreter, InterpreterRequest, VersionRequest}, + find_best_interpreter, find_default_interpreter, find_interpreter, + virtualenv::virtualenv_python_executable, + Error, InterpreterNotFound, InterpreterSource, PythonEnvironment, PythonVersion, + SourceSelector, + }; + + /// Create a fake Python interpreter executable which returns fixed metadata mocking our interpreter + /// query script output. + fn create_mock_interpreter(path: &Path, version: &PythonVersion) -> Result<()> { + let json = indoc! {r##" + { + "result": "success", + "platform": { + "os": { + "name": "manylinux", + "major": 2, + "minor": 38 + }, + "arch": "x86_64" + }, + "markers": { + "implementation_name": "cpython", + "implementation_version": "{FULL_VERSION}", + "os_name": "posix", + "platform_machine": "x86_64", + "platform_python_implementation": "CPython", + "platform_release": "6.5.0-13-generic", + "platform_system": "Linux", + "platform_version": "#13-Ubuntu SMP PREEMPT_DYNAMIC Fri Nov 3 12:16:05 UTC 2023", + "python_full_version": "{FULL_VERSION}", + "python_version": "{VERSION}", + "sys_platform": "linux" + }, + "base_exec_prefix": "/home/ferris/.pyenv/versions/{FULL_VERSION}", + "base_prefix": "/home/ferris/.pyenv/versions/{FULL_VERSION}", + "prefix": "/home/ferris/projects/uv/.venv", + "sys_executable": "{PATH}", + "sys_path": [ + "/home/ferris/.pyenv/versions/{FULL_VERSION}/lib/python{VERSION}/lib/python{VERSION}", + "/home/ferris/.pyenv/versions/{FULL_VERSION}/lib/python{VERSION}/site-packages" + ], + "stdlib": "/home/ferris/.pyenv/versions/{FULL_VERSION}/lib/python{VERSION}", + "scheme": { + "data": "/home/ferris/.pyenv/versions/{FULL_VERSION}", + "include": "/home/ferris/.pyenv/versions/{FULL_VERSION}/include", + "platlib": "/home/ferris/.pyenv/versions/{FULL_VERSION}/lib/python{VERSION}/site-packages", + "purelib": "/home/ferris/.pyenv/versions/{FULL_VERSION}/lib/python{VERSION}/site-packages", + "scripts": "/home/ferris/.pyenv/versions/{FULL_VERSION}/bin" + }, + "virtualenv": { + "data": "", + "include": "include", + "platlib": "lib/python{VERSION}/site-packages", + "purelib": "lib/python{VERSION}/site-packages", + "scripts": "bin" + }, + "pointer_size": "64", + "gil_disabled": true + } + "##} + .replace("{PATH}", path.to_str().expect("Path can be represented as string")) + .replace("{FULL_VERSION}", &version.to_string()) + .replace("{VERSION}", &version.without_patch().to_string()); + + fs_err::write( + path, + formatdoc! {r##" + #!/bin/bash + echo '{json}' + "##}, + )?; + + fs_err::set_permissions(path, std::os::unix::fs::PermissionsExt::from_mode(0o770))?; + + Ok(()) + } + + /// Create a mock Python 2 interpreter executable which returns a fixed error message mocking + /// invocation of Python 2 with the `-I` flag as done by our query script. + fn create_mock_python2_interpreter(path: &Path) -> Result<()> { + let output = indoc! { r" + Unknown option: -I + usage: /usr/bin/python [option] ... [-c cmd | -m mod | file | -] [arg] ... + Try `python -h` for more information. + "}; + + fs_err::write( + path, + formatdoc! {r##" + #!/bin/bash + echo '{output}' 1>&2 + "##}, + )?; + + fs_err::set_permissions(path, std::os::unix::fs::PermissionsExt::from_mode(0o770))?; + + Ok(()) + } + + /// Create child directories in a temporary directory. + fn create_children>(tempdir: &TempDir, names: &[P]) -> Result> { + let paths: Vec = names + .iter() + .map(|name| tempdir.child(name).to_path_buf()) + .collect(); + for path in &paths { + fs_err::create_dir_all(path)?; + } + Ok(paths) + } + + /// Create fake Python interpreters the given Python versions. + /// + /// Returns a search path for the mock interpreters. + fn mock_interpreters(tempdir: &TempDir, versions: &[&'static str]) -> Result { + let names: Vec = (0..versions.len()) + .map(|i| OsString::from(i.to_string())) + .collect(); + let paths = create_children(tempdir, names.as_slice())?; + for (path, version) in itertools::zip_eq(&paths, versions) { + let python = format!("python{}", std::env::consts::EXE_SUFFIX); + create_mock_interpreter( + &path.join(python), + &PythonVersion::from_str(version).unwrap(), + )?; + } + Ok(env::join_paths(paths)?) + } + + /// Create a mock virtual environment in the given directory. + /// + /// Returns the path to the virtual environment. + fn mock_venv(tempdir: &TempDir, version: &'static str) -> Result { + let venv = tempdir.child(".venv"); + let executable = virtualenv_python_executable(&venv); + fs_err::create_dir_all( + executable + .parent() + .expect("A Python executable path should always have a parent"), + )?; + create_mock_interpreter( + &executable, + &PythonVersion::from_str(version).expect("A valid Python version is used for tests"), + )?; + venv.child("pyvenv.cfg").touch()?; + Ok(venv.to_path_buf()) + } + + #[test] + fn find_default_interpreter_empty_path() -> Result<()> { + let cache = Cache::temp()?; + + with_vars( + [ + ("UV_TEST_PYTHON_PATH", None), + ("UV_BOOTSTRAP_DIR", None), + ("PATH", Some("")), + ], + || { + let result = find_default_interpreter(&cache); + assert!( + matches!( + result, + Ok(Err(InterpreterNotFound::NoPythonInstallation(..))) + ), + "With an empty path, no Python installation should be detected got {result:?}" + ); + }, + ); + + with_vars( + [ + ("UV_TEST_PYTHON_PATH", None), + ("UV_BOOTSTRAP_DIR", None), + ("PATH", None::), + ], + || { + let result = find_default_interpreter(&cache); + assert!( + matches!( + result, + Ok(Err(InterpreterNotFound::NoPythonInstallation(..))) + ), + "With an unset path, no Python installation should be detected; got {result:?}" + ); + }, + ); + + Ok(()) + } + + #[test] + fn find_default_interpreter_invalid_executable() -> Result<()> { + let cache = Cache::temp()?; + let tempdir = TempDir::new()?; + let python = tempdir.child(format!("python{}", std::env::consts::EXE_SUFFIX)); + python.touch()?; + + with_vars( + [ + ("UV_TEST_PYTHON_PATH", None), + ("UV_BOOTSTRAP_DIR", None), + ("PATH", Some(tempdir.path().as_os_str())), + ], + || { + let result = find_default_interpreter(&cache); + assert!( + matches!( + result, + Ok(Err(InterpreterNotFound::NoPythonInstallation(..))) + ), + "With an invalid Python executable, no Python installation should be detected; got {result:?}" + ); + }, + ); + + Ok(()) + } + + #[test] + fn find_default_interpreter_valid_executable() -> Result<()> { + let tempdir = TempDir::new()?; + let cache = Cache::temp()?; + let python = tempdir.child(format!("python{}", std::env::consts::EXE_SUFFIX)); + create_mock_interpreter(&python, &PythonVersion::from_str("3.12.1").unwrap())?; + + with_vars( + [ + ("UV_TEST_PYTHON_PATH", None), + ("UV_BOOTSTRAP_DIR", None), + ("PATH", Some(tempdir.path().as_os_str())), + ], + || { + let result = find_default_interpreter(&cache); + assert!( + matches!( + result, + Ok(Ok(DiscoveredInterpreter { + source: InterpreterSource::SearchPath, + interpreter: _ + })) + ), + "We should find the valid executable; got {result:?}" + ); + }, + ); + + Ok(()) + } + + #[test] + fn find_default_interpreter_valid_executable_after_invalid() -> Result<()> { + let tempdir = TempDir::new()?; + let cache = Cache::temp()?; + let children = create_children( + &tempdir, + &["query-parse-error", "not-executable", "good", "empty"], + )?; + + // Just an empty file + tempdir + .child("not-executable") + .child(format!("python{}", std::env::consts::EXE_SUFFIX)) + .touch()?; + + // An executable file with a bad response + #[cfg(unix)] + fs_err::write( + tempdir + .child("query-parse-error") + .child(format!("python{}", std::env::consts::EXE_SUFFIX)), + formatdoc! {r##" + #!/bin/bash + echo 'foo' + "##}, + )?; + fs_err::set_permissions( + tempdir + .child("query-parse-error") + .child(format!("python{}", std::env::consts::EXE_SUFFIX)) + .path(), + std::os::unix::fs::PermissionsExt::from_mode(0o770), + )?; + + // An interpreter + let python = tempdir + .child("good") + .child(format!("python{}", std::env::consts::EXE_SUFFIX)); + create_mock_interpreter(&python, &PythonVersion::from_str("3.12.1").unwrap())?; + + with_vars( + [ + ("UV_TEST_PYTHON_PATH", None), + ("UV_BOOTSTRAP_DIR", None), + ( + "PATH", + Some(env::join_paths( + [tempdir.child("missing").as_os_str()] + .into_iter() + .chain(children.iter().map(|child| child.as_os_str())), + )?), + ), + ("PWD", Some(tempdir.path().into())), + ], + || { + let result = find_default_interpreter(&cache); + assert!( + matches!( + result, + Ok(Ok(DiscoveredInterpreter { + source: InterpreterSource::SearchPath, + interpreter: _ + })) + ), + "We should skip the bad executable in favor of the good one; got {result:?}" + ); + assert_eq!( + result.unwrap().unwrap().interpreter().sys_executable(), + python.path() + ); + }, + ); + + Ok(()) + } + + #[test] + fn find_default_interpreter_only_python2_executable() -> Result<()> { + let tempdir = TempDir::new()?; + let pwd = tempdir.child("pwd"); + pwd.create_dir_all()?; + let cache = Cache::temp()?; + let python = tempdir.child(format!("python{}", std::env::consts::EXE_SUFFIX)); + create_mock_python2_interpreter(&python)?; + + with_vars( + [ + ("UV_TEST_PYTHON_PATH", None), + ("UV_BOOTSTRAP_DIR", None), + ("PATH", Some(tempdir.path().as_os_str())), + ("PWD", Some(pwd.path().as_os_str())), + ], + || { + let result = find_default_interpreter(&cache); + assert!( + matches!( + result, + Ok(Err(InterpreterNotFound::NoPythonInstallation(..))) + ), + // TODO(zanieb): We could improve the error handling to hint this to the user + "If only Python 2 is available, we should not find an interpreter; got {result:?}" + ); + }, + ); + + Ok(()) + } + + #[test] + fn find_default_interpreter_skip_python2_executable() -> Result<()> { + let tempdir = TempDir::new()?; + let cache = Cache::temp()?; + tempdir.child("bad").create_dir_all()?; + tempdir.child("good").create_dir_all()?; + let python2 = tempdir + .child("bad") + .child(format!("python{}", std::env::consts::EXE_SUFFIX)); + create_mock_python2_interpreter(&python2)?; + let pwd = tempdir.child("pwd"); + pwd.create_dir_all()?; + + let python3 = tempdir + .child("good") + .child(format!("python{}", std::env::consts::EXE_SUFFIX)); + create_mock_interpreter(&python3, &PythonVersion::from_str("3.12.1").unwrap())?; + + with_vars( + [ + ("UV_TEST_PYTHON_PATH", None), + ("UV_BOOTSTRAP_DIR", None), + ( + "PATH", + Some(env::join_paths([ + tempdir.child("bad").as_os_str(), + tempdir.child("good").as_os_str(), + ])?), + ), + ("PWD", Some(pwd.path().into())), + ], + || { + let result = find_default_interpreter(&cache); + assert!( + matches!( + result, + Ok(Ok(DiscoveredInterpreter { + source: InterpreterSource::SearchPath, + interpreter: _ + })) + ), + "We should skip the Python 2 installation and find the Python 3 interpreter; got {result:?}" + ); + assert_eq!( + result.unwrap().unwrap().interpreter().sys_executable(), + python3.path() + ); + }, + ); + + Ok(()) + } + + #[test] + fn find_interpreter_version_minor() -> Result<()> { + let tempdir = TempDir::new()?; + let cache = Cache::temp()?; + let sources = SourceSelector::from_sources([ + InterpreterSource::ProvidedPath, + InterpreterSource::ActiveEnvironment, + InterpreterSource::DiscoveredEnvironment, + InterpreterSource::SearchPath, + ]); + + with_vars( + [ + ("UV_TEST_PYTHON_PATH", None), + ("UV_BOOTSTRAP_DIR", None), + ( + "PATH", + Some(mock_interpreters( + &tempdir, + &["3.10.1", "3.11.2", "3.12.3"], + )?), + ), + ("PWD", Some(tempdir.path().into())), + ], + || { + let result = find_interpreter(&InterpreterRequest::parse("3.11"), &sources, &cache); + assert!( + matches!( + result, + Ok(Ok(DiscoveredInterpreter { + source: InterpreterSource::SearchPath, + interpreter: _ + })) + ), + "We should find an interpreter; got {result:?}" + ); + assert_eq!( + &result + .unwrap() + .unwrap() + .interpreter() + .python_full_version() + .to_string(), + "3.11.2", + "We should find the correct interpreter for the request" + ); + }, + ); + + Ok(()) + } + + #[test] + fn find_interpreter_version_patch() -> Result<()> { + let tempdir = TempDir::new()?; + let cache = Cache::temp()?; + let sources = SourceSelector::from_sources([ + InterpreterSource::ProvidedPath, + InterpreterSource::ActiveEnvironment, + InterpreterSource::DiscoveredEnvironment, + InterpreterSource::SearchPath, + ]); + + with_vars( + [ + ("UV_TEST_PYTHON_PATH", None), + ("UV_BOOTSTRAP_DIR", None), + ( + "PATH", + Some(mock_interpreters( + &tempdir, + &["3.10.1", "3.11.2", "3.12.3"], + )?), + ), + ("PWD", Some(tempdir.path().into())), + ], + || { + let result = + find_interpreter(&InterpreterRequest::parse("3.11.2"), &sources, &cache); + assert!( + matches!( + result, + Ok(Ok(DiscoveredInterpreter { + source: InterpreterSource::SearchPath, + interpreter: _ + })) + ), + "We should find an interpreter; got {result:?}" + ); + assert_eq!( + result + .unwrap() + .unwrap() + .interpreter() + .python_full_version() + .to_string(), + "3.11.2", + "We should find the correct interpreter for the request" + ); + }, + ); + + Ok(()) + } + + #[test] + fn find_interpreter_version_minor_no_match() -> Result<()> { + let tempdir = TempDir::new()?; + let cache = Cache::temp()?; + let sources = SourceSelector::from_sources([ + InterpreterSource::ProvidedPath, + InterpreterSource::ActiveEnvironment, + InterpreterSource::DiscoveredEnvironment, + InterpreterSource::SearchPath, + ]); + + with_vars( + [ + ("UV_TEST_PYTHON_PATH", None), + ("UV_BOOTSTRAP_DIR", None), + ( + "PATH", + Some(mock_interpreters( + &tempdir, + &["3.10.1", "3.11.2", "3.12.3"], + )?), + ), + ("PWD", Some(tempdir.path().into())), + ], + || { + let result = find_interpreter(&InterpreterRequest::parse("3.9"), &sources, &cache); + assert!( + matches!( + result, + Ok(Err(InterpreterNotFound::NoMatchingVersion( + _, + VersionRequest::MajorMinor(3, 9) + ))) + ), + "We should not find an interpreter; got {result:?}" + ); + }, + ); + + Ok(()) + } + + #[test] + fn find_interpreter_version_patch_no_match() -> Result<()> { + let tempdir = TempDir::new()?; + let cache = Cache::temp()?; + let sources = SourceSelector::from_sources([ + InterpreterSource::ProvidedPath, + InterpreterSource::ActiveEnvironment, + InterpreterSource::DiscoveredEnvironment, + InterpreterSource::SearchPath, + ]); + + with_vars( + [ + ("UV_TEST_PYTHON_PATH", None), + ("UV_BOOTSTRAP_DIR", None), + ( + "PATH", + Some(mock_interpreters( + &tempdir, + &["3.10.1", "3.11.2", "3.12.3"], + )?), + ), + ("PWD", Some(tempdir.path().into())), + ], + || { + let result = + find_interpreter(&InterpreterRequest::parse("3.11.9"), &sources, &cache); + assert!( + matches!( + result, + Ok(Err(InterpreterNotFound::NoMatchingVersion( + _, + VersionRequest::MajorMinorPatch(3, 11, 9) + ))) + ), + "We should not find an interpreter; got {result:?}" + ); + }, + ); + + Ok(()) + } + + #[test] + fn find_best_interpreter_version_patch_exact() -> Result<()> { + let tempdir = TempDir::new()?; + let cache = Cache::temp()?; + + with_vars( + [ + ("UV_TEST_PYTHON_PATH", None), + ("UV_BOOTSTRAP_DIR", None), + ( + "PATH", + Some(mock_interpreters( + &tempdir, + &["3.10.1", "3.11.2", "3.11.9"], + )?), + ), + ("PWD", Some(tempdir.path().into())), + ], + || { + let result = find_best_interpreter( + &InterpreterRequest::parse("3.11.9"), + crate::SystemPython::Allowed, + &cache, + ); + assert!( + matches!( + result, + Ok(Ok(DiscoveredInterpreter { + source: InterpreterSource::SearchPath, + interpreter: _ + })) + ), + "We should find an interpreter; got {result:?}" + ); + assert_eq!( + result + .unwrap() + .unwrap() + .interpreter() + .python_full_version() + .to_string(), + "3.11.9", + "We should prefer the exact match" + ); + }, + ); + + Ok(()) + } + + #[test] + fn find_best_interpreter_version_patch_fallback() -> Result<()> { + let tempdir = TempDir::new()?; + let cache = Cache::temp()?; + + with_vars( + [ + ("UV_TEST_PYTHON_PATH", None), + ("UV_BOOTSTRAP_DIR", None), + ( + "PATH", + Some(mock_interpreters( + &tempdir, + &["3.10.1", "3.11.2", "3.12.3"], + )?), + ), + ("PWD", Some(tempdir.path().into())), + ], + || { + let result = find_best_interpreter( + &InterpreterRequest::parse("3.11.9"), + crate::SystemPython::Allowed, + &cache, + ); + assert!( + matches!( + result, + Ok(Ok(DiscoveredInterpreter { + source: InterpreterSource::SearchPath, + interpreter: _ + })) + ), + "We should find an interpreter; got {result:?}" + ); + assert_eq!( + result + .unwrap() + .unwrap() + .interpreter() + .python_full_version() + .to_string(), + "3.11.2", + "We should fallback to the matching minor version" + ); + }, + ); + + with_vars( + [ + ("UV_TEST_PYTHON_PATH", None), + ("UV_BOOTSTRAP_DIR", None), + ( + "PATH", + Some(mock_interpreters( + &tempdir, + &["3.10.1", "3.11.2", "3.11.8", "3.12.3"], + )?), + ), + ("PWD", Some(tempdir.path().into())), + ], + || { + let result = find_best_interpreter( + &InterpreterRequest::parse("3.11.9"), + crate::SystemPython::Allowed, + &cache, + ); + assert!( + matches!( + result, + Ok(Ok(DiscoveredInterpreter { + source: InterpreterSource::SearchPath, + interpreter: _ + })) + ), + "We should find an interpreter; got {result:?}" + ); + assert_eq!( + result + .unwrap() + .unwrap() + .interpreter() + .python_full_version() + .to_string(), + "3.11.2", + "We fallback to the first matching minor version, not the closest patch" + ); + }, + ); + + Ok(()) + } + + #[test] + fn find_best_interpreter_skips_broken_active_environment() -> Result<()> { + let tempdir = TempDir::new()?; + let cache = Cache::temp()?; + + let venv = mock_venv(&tempdir, "3.12.0")?; + // Delete the `pyvenv.cfg` to "break" the environment + fs_err::remove_file(venv.join("pyvenv.cfg"))?; + + with_vars( + [ + ("UV_TEST_PYTHON_PATH", None), + ("UV_BOOTSTRAP_DIR", None), + ( + "PATH", + Some(mock_interpreters(&tempdir, &["3.11.1", "3.12.3"])?), + ), + ("VIRTUAL_ENV", Some(venv.into())), + ("PWD", Some(tempdir.path().into())), + ], + || { + let result = find_best_interpreter( + // TODO(zanieb): Consider moving this test to `PythonEnvironment::find` instead + &InterpreterRequest::parse("3.12"), + crate::SystemPython::Allowed, + &cache, + ); + assert!( + matches!( + result, + Ok(Ok(DiscoveredInterpreter { + source: InterpreterSource::ActiveEnvironment, + interpreter: _ + })) + ), + "We should find an interpreter; got {result:?}" + ); + assert_eq!( + result + .unwrap() + .unwrap() + .interpreter() + .python_full_version() + .to_string(), + "3.12.0", + "We should prefer the active environment" + ); + }, + ); + + Ok(()) + } + + #[test] + fn find_best_interpreter_skips_source_without_match() -> Result<()> { + let tempdir = TempDir::new()?; + let cache = Cache::temp()?; + + let venv = mock_venv(&tempdir, "3.12.0")?; + + with_vars( + [ + ("UV_TEST_PYTHON_PATH", None), + ("UV_BOOTSTRAP_DIR", None), + ("PATH", Some(mock_interpreters(&tempdir, &["3.10.1"])?)), + ("VIRTUAL_ENV", Some(venv.into())), + ("PWD", Some(tempdir.path().into())), + ], + || { + let result = find_best_interpreter( + // TODO(zanieb): Consider moving this test to `PythonEnvironment::find` instead + &InterpreterRequest::parse("3.10"), + crate::SystemPython::Allowed, + &cache, + ); + assert!( + matches!( + result, + Ok(Ok(DiscoveredInterpreter { + source: InterpreterSource::SearchPath, + interpreter: _ + })) + ), + "We should skip the active environment in favor of the requested version; got {result:?}" + ); + assert_eq!( + result + .unwrap() + .unwrap() + .interpreter() + .python_full_version() + .to_string(), + "3.10.1", + "We should prefer the active environment" + ); + }, + ); + + Ok(()) + } + + #[test] + fn find_best_interpreter_returns_to_earlier_source_on_fallback() -> Result<()> { + let tempdir = TempDir::new()?; + let cache = Cache::temp()?; + + let venv = mock_venv(&tempdir, "3.10.0")?; + + with_vars( + [ + ("UV_TEST_PYTHON_PATH", None), + ("UV_BOOTSTRAP_DIR", None), + ("PATH", Some(mock_interpreters(&tempdir, &["3.10.3"])?)), + ("VIRTUAL_ENV", Some(venv.into())), + ("PWD", Some(tempdir.path().into())), + ], + || { + let result = find_best_interpreter( + // TODO(zanieb): Consider moving this test to `PythonEnvironment::find` instead + &InterpreterRequest::parse("3.10.2"), + crate::SystemPython::Allowed, + &cache, + ); + assert!( + matches!( + result, + Ok(Ok(DiscoveredInterpreter { + source: InterpreterSource::ActiveEnvironment, + interpreter: _ + })) + ), + "We should prefer to the active environment after relaxing; got {result:?}" + ); + assert_eq!( + result + .unwrap() + .unwrap() + .interpreter() + .python_full_version() + .to_string(), + "3.10.0", + "We should prefer the active environment" + ); + }, + ); + + Ok(()) + } + + #[test] + fn find_best_interpreter_virtualenv_used_if_system_not_allowed() -> Result<()> { + let tempdir = TempDir::new()?; + let cache = Cache::temp()?; + + let venv = mock_venv(&tempdir, "3.11.1")?; + + // Matching minor version + with_vars( + [ + ("UV_TEST_PYTHON_PATH", None), + ("UV_BOOTSTRAP_DIR", None), + ("PATH", Some(mock_interpreters(&tempdir, &["3.11.2"])?)), + ("VIRTUAL_ENV", Some(venv.clone().into())), + ("PWD", Some(tempdir.path().into())), + ], + || { + let result = find_best_interpreter( + // Request the search path Python with a matching minor + &InterpreterRequest::parse("3.11.2"), + crate::SystemPython::Disallowed, + &cache, + ); + assert!( + matches!( + result, + Ok(Ok(DiscoveredInterpreter { + source: InterpreterSource::ActiveEnvironment, + interpreter: _ + })) + ), + "We should find an interpreter; got {result:?}" + ); + assert_eq!( + result + .unwrap() + .unwrap() + .interpreter() + .python_full_version() + .to_string(), + "3.11.1", + "We should use the active environment" + ); + }, + ); + + // Matching major version + with_vars( + [ + ("UV_TEST_PYTHON_PATH", None), + ("UV_BOOTSTRAP_DIR", None), + ( + "PATH", + Some(mock_interpreters(&tempdir, &["3.11.2", "3.10.0"])?), + ), + ("VIRTUAL_ENV", Some(venv.into())), + ("PWD", Some(tempdir.path().into())), + ], + || { + let result = find_best_interpreter( + // Request the search path Python with a matching minor + &InterpreterRequest::parse("3.10.2"), + crate::SystemPython::Disallowed, + &cache, + ); + assert!( + matches!( + result, + Ok(Ok(DiscoveredInterpreter { + source: InterpreterSource::ActiveEnvironment, + interpreter: _ + })) + ), + "We should find an interpreter; got {result:?}" + ); + assert_eq!( + result + .unwrap() + .unwrap() + .interpreter() + .python_full_version() + .to_string(), + "3.11.1", + "We should use the active environment" + ); + }, + ); + + Ok(()) + } + + #[test] + fn find_environment_from_active_environment() -> Result<()> { + let tempdir = TempDir::new()?; + let cache = Cache::temp()?; + + let venv = mock_venv(&tempdir, "3.12.0")?; + + with_vars( + [ + ("UV_TEST_PYTHON_PATH", None), + ("UV_BOOTSTRAP_DIR", None), + ( + "PATH", + Some(mock_interpreters(&tempdir, &["3.10.1", "3.11.2"])?), + ), + ("VIRTUAL_ENV", Some(venv.into())), + ("PWD", Some(tempdir.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.0", + "We should prefer the active environment" + ); + }, + ); + + Ok(()) + } + + #[test] + fn find_environment_from_discovered_environment() -> Result<()> { + let tempdir = TempDir::new()?; + let cache = Cache::temp()?; + + let _venv = mock_venv(&tempdir, "3.12.0")?; + + with_vars( + [ + ("UV_TEST_PYTHON_PATH", None), + ("UV_BOOTSTRAP_DIR", None), + ( + "PATH", + Some(mock_interpreters(&tempdir, &["3.10.1", "3.11.2"])?), + ), + ("PWD", Some(tempdir.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.0", + "We should prefer the discovered environment" + ); + }, + ); + + Ok(()) + } + + #[test] + fn find_environment_active_environment_skipped_if_system_required() -> Result<()> { + let tempdir = TempDir::new()?; + let cache = Cache::temp()?; + + let venv = mock_venv(&tempdir, "3.12.0")?; + + // Without a request + with_vars( + [ + ("UV_TEST_PYTHON_PATH", None), + ("UV_BOOTSTRAP_DIR", None), + ( + "PATH", + Some(mock_interpreters(&tempdir, &["3.10.1", "3.11.2"])?), + ), + ("VIRTUAL_ENV", Some(venv.clone().into())), + ], + || { + let environment = + PythonEnvironment::find(None, crate::SystemPython::Required, &cache) + .expect("Environment should be found"); + assert_eq!( + environment.interpreter().python_full_version().to_string(), + "3.10.1", + "We should skip the active environment" + ); + }, + ); + + // With a requested version + with_vars( + [ + ("UV_TEST_PYTHON_PATH", None), + ("UV_BOOTSTRAP_DIR", None), + ( + "PATH", + Some(mock_interpreters(&tempdir, &["3.10.1", "3.12.2"])?), + ), + ("VIRTUAL_ENV", Some(venv.clone().into())), + ], + || { + let environment = + PythonEnvironment::find(Some("3.12"), crate::SystemPython::Required, &cache) + .expect("Environment should be found"); + assert_eq!( + environment.interpreter().python_full_version().to_string(), + "3.12.2", + "We should skip the active environment" + ); + }, + ); + + // Request a patch version that cannot be found + with_vars( + [ + ("UV_TEST_PYTHON_PATH", None), + ("UV_BOOTSTRAP_DIR", None), + ( + "PATH", + Some(mock_interpreters(&tempdir, &["3.10.1", "3.12.2"])?), + ), + ("VIRTUAL_ENV", Some(venv.clone().into())), + ("PWD", Some(tempdir.path().into())), + ], + || { + let result = + PythonEnvironment::find(Some("3.12.3"), crate::SystemPython::Required, &cache); + assert!( + result.is_err(), + "We should not find an environment; got {result:?}" + ); + }, + ); + + Ok(()) + } + + #[test] + fn find_environment_fails_if_no_virtualenv_and_system_not_allowed() -> Result<()> { + let tempdir = TempDir::new()?; + let cache = Cache::temp()?; + + with_vars( + [ + ("UV_TEST_PYTHON_PATH", None), + ("UV_BOOTSTRAP_DIR", None), + ( + "PATH", + Some(mock_interpreters(&tempdir, &["3.10.1", "3.11.2"])?), + ), + ("PWD", Some(tempdir.path().into())), + ], + || { + let result = PythonEnvironment::find(None, crate::SystemPython::Disallowed, &cache); + assert!( + result.is_err(), + "We should not find an environment; got {result:?}" + ); + }, + ); + + Ok(()) + } + + #[test] + fn find_environment_allows_name_in_working_directory() -> Result<()> { + let tempdir = TempDir::new()?; + let cache = Cache::temp()?; + let python = tempdir.join("foobar"); + create_mock_interpreter(&python, &PythonVersion::from_str("3.10.0").unwrap())?; + + with_vars( + [ + ("UV_TEST_PYTHON_PATH", None::), + ("PATH", None), + ("PWD", Some(tempdir.path().into())), + ], + || { + let environment = + PythonEnvironment::find(Some("foobar"), crate::SystemPython::Allowed, &cache) + .expect("Environment should be found"); + assert_eq!( + environment.interpreter().python_full_version().to_string(), + "3.10.0", + "We should find the `foobar` executable" + ); + }, + ); + + Ok(()) + } + + #[test] + fn find_environment_allows_relative_file_path() -> 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())?; + + with_vars( + [ + ("UV_TEST_PYTHON_PATH", None::), + ("PATH", None), + ("PWD", Some(tempdir.path().into())), + ], + || { + let environment = PythonEnvironment::find( + Some("./foo/bar"), + crate::SystemPython::Allowed, + &cache, + ) + .expect("Environment should be found"); + assert_eq!( + environment.interpreter().python_full_version().to_string(), + "3.10.0", + "We should find the `bar` interpreter" + ); + }, + ); + + Ok(()) + } + + #[test] + fn find_environment_allows_absolute_file_path() -> 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())?; + + 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::Allowed, + &cache, + ) + .expect("Environment should be found"); + assert_eq!( + environment.interpreter().python_full_version().to_string(), + "3.10.0", + "We should find the `bar` interpreter" + ); + }, + ); + + Ok(()) + } + + #[test] + fn find_environment_allows_venv_directory_path() -> Result<()> { + let tempdir = TempDir::new()?; + // Create a separate pwd to avoid ancestor discovery of the venv + let pwd = TempDir::new()?; + let cache = Cache::temp()?; + let venv = mock_venv(&tempdir, "3.10.0")?; + + with_vars( + [ + ("UV_TEST_PYTHON_PATH", None::), + ("PATH", None), + ("PWD", Some(pwd.path().into())), + ], + || { + let environment = PythonEnvironment::find( + Some(venv.to_str().expect("Test path is valid unicode")), + crate::SystemPython::Allowed, + &cache, + ) + .expect("Environment should be found"); + assert_eq!( + environment.interpreter().python_full_version().to_string(), + "3.10.0", + "We should find the venv interpreter" + ); + }, + ); + + Ok(()) + } + + #[test] + fn find_environment_does_not_allow_file_path_with_system_disallowed() -> 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())?; + + with_vars( + [ + ("UV_TEST_PYTHON_PATH", None::), + ("PATH", None), + ("PWD", Some(tempdir.path().into())), + ], + || { + let result = PythonEnvironment::find( + Some(python.to_str().expect("Test path is valid unicode")), + crate::SystemPython::Disallowed, + &cache, + ); + assert!( + matches!( + result, + Err(Error::Discovery(discovery::Error::SourceNotSelected( + _, + InterpreterSource::ProvidedPath + ))) + ), + "We should complain that provided paths are not allowed; got {result:?}" + ); + }, + ); + + Ok(()) + } + + #[test] + fn find_environment_treats_missing_file_path_as_file() -> Result<()> { + let tempdir = TempDir::new()?; + let cache = Cache::temp()?; + tempdir.child("foo").create_dir_all()?; + + with_vars( + [ + ("UV_TEST_PYTHON_PATH", None::), + ("PATH", None), + ("PWD", Some(tempdir.path().into())), + ], + || { + let result: std::prelude::v1::Result = + PythonEnvironment::find( + Some("./foo/bar"), + crate::SystemPython::Allowed, + &cache, + ); + assert!( + matches!( + result, + Err(Error::NotFound(InterpreterNotFound::FileNotFound(_))) + ), + "We should not find the file; got {result:?}" + ); + }, + ); + + Ok(()) + } + + #[test] + fn find_environment_executable_name_in_search_path() -> Result<()> { + let tempdir = TempDir::new()?; + let pwd = tempdir.child("pwd"); + pwd.create_dir_all()?; + let cache = Cache::temp()?; + let python = tempdir.join("foobar"); + create_mock_interpreter(&python, &PythonVersion::from_str("3.10.0").unwrap())?; + + with_vars( + [ + ("UV_TEST_PYTHON_PATH", None::), + ("PATH", Some(tempdir.path().into())), + ("PWD", Some(pwd.path().into())), + ], + || { + let environment = + PythonEnvironment::find(Some("foobar"), crate::SystemPython::Required, &cache) + .expect("Environment should be found"); + assert_eq!( + environment.interpreter().python_full_version().to_string(), + "3.10.0", + "We should find the `foobar` executable" + ); + }, + ); + + Ok(()) + } } diff --git a/crates/uv-interpreter/src/managed/downloads.rs b/crates/uv-interpreter/src/managed/downloads.rs index 1b4c9b106439..c78ec2434e1b 100644 --- a/crates/uv-interpreter/src/managed/downloads.rs +++ b/crates/uv-interpreter/src/managed/downloads.rs @@ -46,6 +46,8 @@ pub enum Error { #[source] err: io::Error, }, + #[error("failed to parse toolchain directory name: {0}")] + NameError(String), } #[derive(Debug, PartialEq)] diff --git a/crates/uv-interpreter/src/managed/find.rs b/crates/uv-interpreter/src/managed/find.rs index 54b6eca1182a..d6191ab21e41 100644 --- a/crates/uv-interpreter/src/managed/find.rs +++ b/crates/uv-interpreter/src/managed/find.rs @@ -1,38 +1,77 @@ use std::collections::BTreeSet; use std::ffi::OsStr; -use std::path::{Path, PathBuf}; - -use crate::managed::downloads::Error; -use crate::{ - platform::{Arch, Libc, Os}, - python_version::PythonVersion, -}; +use std::path::PathBuf; +use std::str::FromStr; use once_cell::sync::Lazy; +use tracing::debug; + +use uv_fs::Simplified; + +use crate::managed::downloads::Error; +use crate::platform::{Arch, Libc, Os}; +use crate::python_version::PythonVersion; /// The directory where Python toolchains we install are stored. -pub static TOOLCHAIN_DIRECTORY: Lazy> = Lazy::new(|| { - std::env::var_os("UV_BOOTSTRAP_DIR").map_or( - std::env::var_os("CARGO_MANIFEST_DIR").map(|manifest_dir| { - Path::new(&manifest_dir) - .parent() - .expect("CARGO_MANIFEST_DIR should be nested in workspace") - .parent() - .expect("CARGO_MANIFEST_DIR should be doubly nested in workspace") - .join("bin") - }), - |bootstrap_dir| Some(PathBuf::from(bootstrap_dir)), - ) -}); +pub static TOOLCHAIN_DIRECTORY: Lazy> = + Lazy::new(|| std::env::var_os("UV_BOOTSTRAP_DIR").map(PathBuf::from)); + +pub fn toolchains_for_current_platform() -> Result, Error> { + let platform_key = platform_key_from_env()?; + let iter = toolchain_directories()? + .into_iter() + // Sort "newer" versions of Python first + .rev() + .filter_map(move |path| { + if path + .file_name() + .map(OsStr::to_string_lossy) + .is_some_and(|filename| filename.ends_with(&platform_key)) + { + Toolchain::new(path.clone()) + .inspect_err(|err| { + debug!( + "Ignoring invalid toolchain directory {}: {err}", + path.user_display() + ); + }) + .ok() + } else { + None + } + }); + + Ok(iter) +} /// An installed Python toolchain. #[derive(Debug, Clone)] pub struct Toolchain { /// The path to the top-level directory of the installed toolchain. path: PathBuf, + python_version: PythonVersion, } impl Toolchain { + pub fn new(path: PathBuf) -> Result { + let python_version = PythonVersion::from_str( + path.file_name() + .ok_or(Error::NameError("No directory name".to_string()))? + .to_str() + .ok_or(Error::NameError("Name not a valid string".to_string()))? + .split('-') + .nth(1) + .ok_or(Error::NameError( + "Not enough `-` separarated values".to_string(), + ))?, + ) + .map_err(|err| Error::NameError(format!("Name has invalid Python version: {err}")))?; + + Ok(Self { + path, + python_version, + }) + } pub fn executable(&self) -> PathBuf { if cfg!(windows) { self.path.join("install").join("python.exe") @@ -42,8 +81,18 @@ impl Toolchain { unimplemented!("Only Windows and Unix systems are supported.") } } + + pub fn python_version(&self) -> &PythonVersion { + &self.python_version + } } +/// Return the directories in the toolchain directory. +/// +/// Toolchain directories are sorted descending by name, such that we get deterministic +/// ordering across platforms. This also results in newer Python versions coming first, +/// but should not be relied on — instead the toolchains should be sorted later by +/// the parsed Python version. fn toolchain_directories() -> Result, Error> { let Some(toolchain_dir) = TOOLCHAIN_DIRECTORY.as_ref() else { return Ok(BTreeSet::default()); @@ -101,7 +150,14 @@ pub fn toolchains_for_version(version: &PythonVersion) -> Result, && filename.ends_with(&platform_key) }) { - Some(Toolchain { path }) + Toolchain::new(path.clone()) + .inspect_err(|err| { + debug!( + "Ignoring invalid toolchain directory {}: {err}", + path.user_display() + ); + }) + .ok() } else { None } diff --git a/crates/uv-interpreter/src/managed/mod.rs b/crates/uv-interpreter/src/managed/mod.rs index 1d596703018c..83cf680cc8e6 100644 --- a/crates/uv-interpreter/src/managed/mod.rs +++ b/crates/uv-interpreter/src/managed/mod.rs @@ -1,5 +1,7 @@ pub use crate::managed::downloads::{DownloadResult, Error, PythonDownload, PythonDownloadRequest}; -pub use crate::managed::find::{toolchains_for_version, Toolchain, TOOLCHAIN_DIRECTORY}; +pub use crate::managed::find::{ + toolchains_for_current_platform, toolchains_for_version, Toolchain, TOOLCHAIN_DIRECTORY, +}; mod downloads; mod find; diff --git a/crates/uv-interpreter/src/py_launcher.rs b/crates/uv-interpreter/src/py_launcher.rs index 483f29f4ea07..c49627939f81 100644 --- a/crates/uv-interpreter/src/py_launcher.rs +++ b/crates/uv-interpreter/src/py_launcher.rs @@ -14,6 +14,7 @@ pub(crate) struct PyListPath { pub(crate) executable_path: PathBuf, } +/// An error was encountered when using the `py` launcher on Windows. #[derive(Error, Debug)] pub enum Error { #[error("{message} with {exit_code}\n--- stdout:\n{stdout}\n--- stderr:\n{stderr}\n---")] @@ -91,36 +92,3 @@ pub(crate) fn py_list_paths() -> Result, Error> { }) .collect()) } - -#[cfg(test)] -mod tests { - use std::fmt::Debug; - - use insta::assert_snapshot; - use itertools::Itertools; - - use uv_cache::Cache; - - use crate::{find_requested_python, Error}; - - fn format_err(err: Result) -> String { - anyhow::Error::new(err.unwrap_err()) - .chain() - .join("\n Caused by: ") - } - - #[test] - #[cfg_attr(not(windows), ignore)] - fn no_such_python_path() { - let cache = Cache::temp().unwrap().init().unwrap(); - let result = find_requested_python(r"C:\does\not\exists\python3.12", &cache) - .unwrap() - .ok_or(Error::RequestedPythonNotFound( - r"C:\does\not\exists\python3.12".to_string(), - )); - assert_snapshot!( - format_err(result), - @"Failed to locate Python interpreter at: `C:\\does\\not\\exists\\python3.12`" - ); - } -} diff --git a/crates/uv-interpreter/src/virtualenv.rs b/crates/uv-interpreter/src/virtualenv.rs index 01ccb590f131..f34bb0b09259 100644 --- a/crates/uv-interpreter/src/virtualenv.rs +++ b/crates/uv-interpreter/src/virtualenv.rs @@ -37,15 +37,8 @@ pub enum Error { MissingPyVenvCfg(PathBuf), #[error("Broken virtualenv `{0}`: `pyvenv.cfg` could not be parsed")] ParsePyVenvCfg(PathBuf, #[source] io::Error), -} - -/// Locate the current virtual environment. -pub(crate) fn detect_virtualenv() -> Result, Error> { - let from_env = virtualenv_from_env(); - if from_env.is_some() { - return Ok(from_env); - } - virtualenv_from_working_dir() + #[error(transparent)] + IO(#[from] io::Error), } /// Locate an active virtual environment by inspecting environment variables. @@ -54,7 +47,7 @@ pub(crate) fn detect_virtualenv() -> Result, Error> { pub(crate) fn virtualenv_from_env() -> Option { if let Some(dir) = env::var_os("VIRTUAL_ENV").filter(|value| !value.is_empty()) { info!( - "Found a virtualenv through VIRTUAL_ENV at: {}", + "Found active virtual environment (via VIRTUAL_ENV) at: {}", Path::new(&dir).display() ); return Some(PathBuf::from(dir)); @@ -62,7 +55,7 @@ pub(crate) fn virtualenv_from_env() -> Option { if let Some(dir) = env::var_os("CONDA_PREFIX").filter(|value| !value.is_empty()) { info!( - "Found a virtualenv through CONDA_PREFIX at: {}", + "Found active virtual environment (via CONDA_PREFIX) at: {}", Path::new(&dir).display() ); return Some(PathBuf::from(dir)); @@ -77,12 +70,12 @@ pub(crate) fn virtualenv_from_env() -> Option { /// directory is itself a virtual environment (or a subdirectory of a virtual environment), the /// containing virtual environment is returned. pub(crate) fn virtualenv_from_working_dir() -> Result, Error> { - let current_dir = env::current_dir().expect("Failed to detect current directory"); + let current_dir = crate::current_dir()?; for dir in current_dir.ancestors() { // If we're _within_ a virtualenv, return it. if dir.join("pyvenv.cfg").is_file() { - debug!("Found a virtualenv at: {}", dir.display()); + debug!("Found a virtual environment at: {}", dir.display()); return Ok(Some(dir.to_path_buf())); } @@ -92,7 +85,7 @@ pub(crate) fn virtualenv_from_working_dir() -> Result, Error> { if !dot_venv.join("pyvenv.cfg").is_file() { return Err(Error::MissingPyVenvCfg(dot_venv)); } - debug!("Found a virtualenv named .venv at: {}", dot_venv.display()); + debug!("Found a virtual environment at: {}", dot_venv.display()); return Ok(Some(dot_venv)); } } @@ -105,9 +98,9 @@ pub(crate) fn virtualenv_python_executable(venv: impl AsRef) -> PathBuf { let venv = venv.as_ref(); if cfg!(windows) { // Search for `python.exe` in the `Scripts` directory. - let executable = venv.join("Scripts").join("python.exe"); - if executable.exists() { - return executable; + let default_executable = venv.join("Scripts").join("python.exe"); + if default_executable.exists() { + return default_executable; } // Apparently, Python installed via msys2 on Windows _might_ produce a POSIX-like layout. @@ -118,7 +111,13 @@ pub(crate) fn virtualenv_python_executable(venv: impl AsRef) -> PathBuf { } // Fallback for Conda environments. - venv.join("python.exe") + let executable = venv.join("python.exe"); + if executable.exists() { + return executable; + } + + // If none of these exist, return the standard location + default_executable } else { // Search for `python` in the `bin` directory. venv.join("bin").join("python") diff --git a/crates/uv-resolver/tests/resolver.rs b/crates/uv-resolver/tests/resolver.rs index 874400fdd75a..196150c86027 100644 --- a/crates/uv-resolver/tests/resolver.rs +++ b/crates/uv-resolver/tests/resolver.rs @@ -19,7 +19,7 @@ use uv_configuration::{ BuildKind, Concurrency, Constraints, NoBinary, NoBuild, Overrides, SetupPyStrategy, }; use uv_distribution::DistributionDatabase; -use uv_interpreter::{find_default_python, Interpreter, PythonEnvironment}; +use uv_interpreter::{find_default_interpreter, Interpreter, PythonEnvironment}; use uv_resolver::{ DisplayResolutionGraph, ExcludeNewer, Exclusions, FlatIndex, InMemoryIndex, Manifest, Options, OptionsBuilder, PreReleaseMode, Preference, PythonRequirement, ResolutionGraph, ResolutionMode, @@ -124,10 +124,13 @@ async fn resolve( tags: &Tags, ) -> Result { let cache = Cache::temp().unwrap().init().unwrap(); - let real_interpreter = find_default_python(&cache).expect("Expected a python to be installed"); let client = RegistryClientBuilder::new(cache).build(); let flat_index = FlatIndex::default(); let index = InMemoryIndex::default(); + let real_interpreter = find_default_interpreter(&Cache::temp().unwrap()) + .unwrap() + .expect("Python should be installed") + .into_interpreter(); let interpreter = Interpreter::artificial(real_interpreter.platform().clone(), markers.clone()); let python_requirement = PythonRequirement::from_marker_environment(&interpreter, markers); let cache = Cache::temp().unwrap().init().unwrap(); diff --git a/crates/uv-virtualenv/src/lib.rs b/crates/uv-virtualenv/src/lib.rs index ff6981a23f0f..4be9ea90a19f 100644 --- a/crates/uv-virtualenv/src/lib.rs +++ b/crates/uv-virtualenv/src/lib.rs @@ -14,8 +14,10 @@ mod bare; pub enum Error { #[error(transparent)] IO(#[from] io::Error), - #[error("Failed to determine python interpreter to use")] - InterpreterError(#[from] uv_interpreter::Error), + #[error("Failed to determine Python interpreter to use")] + Discovery(#[from] uv_interpreter::DiscoveryError), + #[error("Failed to determine Python interpreter to use")] + InterpreterNotFound(#[from] uv_interpreter::InterpreterNotFound), #[error(transparent)] Platform(#[from] PlatformError), #[error("Could not find a suitable Python executable for the virtual environment based on the interpreter: {0}")] diff --git a/crates/uv/src/commands/pip/check.rs b/crates/uv/src/commands/pip/check.rs index 93363c0a4a65..9f125b616451 100644 --- a/crates/uv/src/commands/pip/check.rs +++ b/crates/uv/src/commands/pip/check.rs @@ -9,7 +9,7 @@ use tracing::debug; use uv_cache::Cache; use uv_fs::Simplified; use uv_installer::{Diagnostic, SitePackages}; -use uv_interpreter::PythonEnvironment; +use uv_interpreter::{PythonEnvironment, SystemPython}; use crate::commands::{elapsed, ExitStatus}; use crate::printer::Printer; @@ -24,19 +24,12 @@ pub(crate) fn pip_check( let start = Instant::now(); // Detect the current Python interpreter. - let venv = if let Some(python) = python { - PythonEnvironment::from_requested_python(python, cache)? - } else if system { - PythonEnvironment::from_default_python(cache)? + let system = if system { + SystemPython::Required } else { - match PythonEnvironment::from_virtualenv(cache) { - Ok(venv) => venv, - Err(uv_interpreter::Error::VenvNotFound) => { - PythonEnvironment::from_default_python(cache)? - } - Err(err) => return Err(err.into()), - } + SystemPython::Allowed }; + let venv = PythonEnvironment::find(python, system, cache)?; debug!( "Using Python {} environment at {}", diff --git a/crates/uv/src/commands/pip/compile.rs b/crates/uv/src/commands/pip/compile.rs index 17724065782c..36ba4379c415 100644 --- a/crates/uv/src/commands/pip/compile.rs +++ b/crates/uv/src/commands/pip/compile.rs @@ -36,8 +36,11 @@ use uv_dispatch::BuildDispatch; use uv_distribution::DistributionDatabase; use uv_fs::Simplified; use uv_installer::Downloader; -use uv_interpreter::PythonVersion; -use uv_interpreter::{find_best_python, find_requested_python, PythonEnvironment}; +use uv_interpreter::{ + find_best_interpreter, find_interpreter, InterpreterRequest, PythonEnvironment, SystemPython, + VersionRequest, +}; +use uv_interpreter::{PythonVersion, SourceSelector}; use uv_normalize::{ExtraName, PackageName}; use uv_requirements::{ upgrade::read_lockfile, ExtrasSpecification, LookaheadResolver, NamedRequirementsResolver, @@ -161,12 +164,26 @@ pub(crate) async fn pip_compile( } // Find an interpreter to use for building distributions - let interpreter = if let Some(python) = python.as_ref() { - find_requested_python(python, &cache)? - .ok_or_else(|| uv_interpreter::Error::RequestedPythonNotFound(python.to_string()))? + let system = if system { + SystemPython::Required } else { - find_best_python(python_version.as_ref(), system, &cache)? + SystemPython::Allowed }; + let interpreter = if let Some(python) = python.as_ref() { + let request = InterpreterRequest::parse(python); + let sources = SourceSelector::from_env(system); + find_interpreter(&request, &sources, &cache)?? + } else { + let request = if let Some(version) = python_version.as_ref() { + // TODO(zanieb): We should consolidate `VersionRequest` and `PythonVersion` + InterpreterRequest::Version(VersionRequest::from(version)) + } else { + InterpreterRequest::Version(VersionRequest::Default) + }; + find_best_interpreter(&request, system, &cache)?? + } + .into_interpreter(); + debug!( "Using Python {} interpreter at {} for builds", interpreter.python_version(), diff --git a/crates/uv/src/commands/pip/freeze.rs b/crates/uv/src/commands/pip/freeze.rs index 0cf862702105..ff59ed8d9184 100644 --- a/crates/uv/src/commands/pip/freeze.rs +++ b/crates/uv/src/commands/pip/freeze.rs @@ -9,7 +9,7 @@ use distribution_types::{InstalledDist, Name}; use uv_cache::Cache; use uv_fs::Simplified; use uv_installer::SitePackages; -use uv_interpreter::PythonEnvironment; +use uv_interpreter::{PythonEnvironment, SystemPython}; use crate::commands::ExitStatus; use crate::printer::Printer; @@ -24,19 +24,12 @@ pub(crate) fn pip_freeze( printer: Printer, ) -> Result { // Detect the current Python interpreter. - let venv = if let Some(python) = python { - PythonEnvironment::from_requested_python(python, cache)? - } else if system { - PythonEnvironment::from_default_python(cache)? + let system = if system { + SystemPython::Required } else { - match PythonEnvironment::from_virtualenv(cache) { - Ok(venv) => venv, - Err(uv_interpreter::Error::VenvNotFound) => { - PythonEnvironment::from_default_python(cache)? - } - Err(err) => return Err(err.into()), - } + SystemPython::Allowed }; + let venv = PythonEnvironment::find(python, system, cache)?; debug!( "Using Python {} environment at {}", diff --git a/crates/uv/src/commands/pip/install.rs b/crates/uv/src/commands/pip/install.rs index ae1d540a20e5..6f2eb38233e4 100644 --- a/crates/uv/src/commands/pip/install.rs +++ b/crates/uv/src/commands/pip/install.rs @@ -33,7 +33,7 @@ use uv_dispatch::BuildDispatch; use uv_distribution::DistributionDatabase; use uv_fs::Simplified; use uv_installer::{Downloader, Plan, Planner, ResolvedEditable, SatisfiesResult, SitePackages}; -use uv_interpreter::{Interpreter, PythonEnvironment, PythonVersion, Target}; +use uv_interpreter::{Interpreter, PythonEnvironment, PythonVersion, SystemPython, Target}; use uv_normalize::PackageName; use uv_requirements::{ ExtrasSpecification, LookaheadResolver, NamedRequirementsResolver, RequirementsSource, @@ -126,13 +126,13 @@ pub(crate) async fn pip_install( .await?; // Detect the current Python interpreter. - let venv = if let Some(python) = python.as_ref() { - PythonEnvironment::from_requested_python(python, &cache)? - } else if system { - PythonEnvironment::from_default_python(&cache)? + let system = if system { + SystemPython::Required } else { - PythonEnvironment::from_virtualenv(&cache)? + SystemPython::Disallowed }; + let venv = PythonEnvironment::find(python.as_deref(), system, &cache)?; + debug!( "Using Python {} environment at {}", venv.interpreter().python_version(), diff --git a/crates/uv/src/commands/pip/list.rs b/crates/uv/src/commands/pip/list.rs index 5b91881479bd..d7d35f957995 100644 --- a/crates/uv/src/commands/pip/list.rs +++ b/crates/uv/src/commands/pip/list.rs @@ -12,7 +12,7 @@ use distribution_types::{InstalledDist, Name}; use uv_cache::Cache; use uv_fs::Simplified; use uv_installer::SitePackages; -use uv_interpreter::PythonEnvironment; +use uv_interpreter::{PythonEnvironment, SystemPython}; use uv_normalize::PackageName; use crate::commands::ExitStatus; @@ -33,19 +33,12 @@ pub(crate) fn pip_list( printer: Printer, ) -> Result { // Detect the current Python interpreter. - let venv = if let Some(python) = python { - PythonEnvironment::from_requested_python(python, cache)? - } else if system { - PythonEnvironment::from_default_python(cache)? + let system = if system { + SystemPython::Required } else { - match PythonEnvironment::from_virtualenv(cache) { - Ok(venv) => venv, - Err(uv_interpreter::Error::VenvNotFound) => { - PythonEnvironment::from_default_python(cache)? - } - Err(err) => return Err(err.into()), - } + SystemPython::Allowed }; + let venv = PythonEnvironment::find(python, system, cache)?; debug!( "Using Python {} environment at {}", diff --git a/crates/uv/src/commands/pip/show.rs b/crates/uv/src/commands/pip/show.rs index 710f351ed00c..c98da09d65f9 100644 --- a/crates/uv/src/commands/pip/show.rs +++ b/crates/uv/src/commands/pip/show.rs @@ -10,7 +10,7 @@ use distribution_types::Name; use uv_cache::Cache; use uv_fs::Simplified; use uv_installer::SitePackages; -use uv_interpreter::PythonEnvironment; +use uv_interpreter::{PythonEnvironment, SystemPython}; use uv_normalize::PackageName; use crate::commands::ExitStatus; @@ -39,19 +39,12 @@ pub(crate) fn pip_show( } // Detect the current Python interpreter. - let venv = if let Some(python) = python { - PythonEnvironment::from_requested_python(python, cache)? - } else if system { - PythonEnvironment::from_default_python(cache)? + let system = if system { + SystemPython::Required } else { - match PythonEnvironment::from_virtualenv(cache) { - Ok(venv) => venv, - Err(uv_interpreter::Error::VenvNotFound) => { - PythonEnvironment::from_default_python(cache)? - } - Err(err) => return Err(err.into()), - } + SystemPython::Allowed }; + let venv = PythonEnvironment::find(python, system, cache)?; debug!( "Using Python {} environment at {}", diff --git a/crates/uv/src/commands/pip/sync.rs b/crates/uv/src/commands/pip/sync.rs index c2370928078f..45ab56976ecb 100644 --- a/crates/uv/src/commands/pip/sync.rs +++ b/crates/uv/src/commands/pip/sync.rs @@ -23,7 +23,7 @@ use uv_dispatch::BuildDispatch; use uv_distribution::DistributionDatabase; use uv_fs::Simplified; use uv_installer::{Downloader, Plan, Planner, SitePackages}; -use uv_interpreter::{PythonEnvironment, PythonVersion, Target}; +use uv_interpreter::{PythonEnvironment, PythonVersion, SystemPython, Target}; use uv_requirements::{ ExtrasSpecification, NamedRequirementsResolver, RequirementsSource, RequirementsSpecification, SourceTreeResolver, @@ -101,13 +101,13 @@ pub(crate) async fn pip_sync( } // Detect the current Python interpreter. - let venv = if let Some(python) = python.as_ref() { - PythonEnvironment::from_requested_python(python, &cache)? - } else if system { - PythonEnvironment::from_default_python(&cache)? + let system = if system { + SystemPython::Required } else { - PythonEnvironment::from_virtualenv(&cache)? + SystemPython::Disallowed }; + let venv = PythonEnvironment::find(python.as_deref(), system, &cache)?; + debug!( "Using Python {} environment at {}", venv.interpreter().python_version(), diff --git a/crates/uv/src/commands/pip/uninstall.rs b/crates/uv/src/commands/pip/uninstall.rs index 4648374f7588..2ec6573caa31 100644 --- a/crates/uv/src/commands/pip/uninstall.rs +++ b/crates/uv/src/commands/pip/uninstall.rs @@ -11,7 +11,7 @@ use uv_cache::Cache; use uv_client::{BaseClientBuilder, Connectivity}; use uv_configuration::{KeyringProviderType, PreviewMode}; use uv_fs::Simplified; -use uv_interpreter::{PythonEnvironment, Target}; +use uv_interpreter::{PythonEnvironment, SystemPython, Target}; use uv_requirements::{RequirementsSource, RequirementsSpecification}; use crate::commands::{elapsed, ExitStatus}; @@ -43,13 +43,13 @@ pub(crate) async fn pip_uninstall( RequirementsSpecification::from_simple_sources(sources, &client_builder, preview).await?; // Detect the current Python interpreter. - let venv = if let Some(python) = python.as_ref() { - PythonEnvironment::from_requested_python(python, &cache)? - } else if system { - PythonEnvironment::from_default_python(&cache)? + let system = if system { + SystemPython::Required } else { - PythonEnvironment::from_virtualenv(&cache)? + SystemPython::Disallowed }; + let venv = PythonEnvironment::find(python.as_deref(), system, &cache)?; + debug!( "Using Python {} environment at {}", venv.interpreter().python_version(), diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index d85b054451a0..d4956e0281f4 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -20,7 +20,7 @@ use uv_dispatch::BuildDispatch; use uv_distribution::DistributionDatabase; use uv_fs::Simplified; use uv_installer::{Downloader, Plan, Planner, SatisfiesResult, SitePackages}; -use uv_interpreter::{find_default_python, Interpreter, PythonEnvironment}; +use uv_interpreter::{find_default_interpreter, Interpreter, PythonEnvironment}; use uv_requirements::{ ExtrasSpecification, LookaheadResolver, NamedRequirementsResolver, RequirementsSource, RequirementsSpecification, SourceTreeResolver, @@ -86,9 +86,12 @@ pub(crate) fn init( // TODO(charlie): If the environment isn't compatible with `--python`, recreate it. match PythonEnvironment::from_root(&venv, cache) { Ok(venv) => Ok(venv), - Err(uv_interpreter::Error::VenvDoesNotExist(_)) => { + Err(uv_interpreter::Error::NotFound(_)) => { // TODO(charlie): Respect `--python`; if unset, respect `Requires-Python`. - let interpreter = find_default_python(cache)?; + let interpreter = find_default_interpreter(cache) + .map_err(uv_interpreter::Error::from)? + .map_err(uv_interpreter::Error::from)? + .into_interpreter(); writeln!( printer.stderr(), diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 3d54b10a28ee..e9d046d23329 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -9,7 +9,7 @@ use tracing::debug; use uv_cache::Cache; use uv_configuration::PreviewMode; -use uv_interpreter::PythonEnvironment; +use uv_interpreter::{PythonEnvironment, SystemPython}; use uv_requirements::RequirementsSource; use uv_warnings::warn_user; @@ -81,7 +81,8 @@ pub(crate) async fn run( let interpreter = if let Some(project_env) = &project_env { project_env.interpreter().clone() } else if let Some(python) = python.as_ref() { - PythonEnvironment::from_requested_python(python, cache)?.into_interpreter() + PythonEnvironment::from_requested_python(python, SystemPython::Allowed, cache)? + .into_interpreter() } else { PythonEnvironment::from_default_python(cache)?.into_interpreter() }; diff --git a/crates/uv/src/commands/venv.rs b/crates/uv/src/commands/venv.rs index 99d5d24375ef..b8ec5270652f 100644 --- a/crates/uv/src/commands/venv.rs +++ b/crates/uv/src/commands/venv.rs @@ -19,7 +19,9 @@ use uv_configuration::{Concurrency, KeyringProviderType}; use uv_configuration::{ConfigSettings, IndexStrategy, NoBinary, NoBuild, SetupPyStrategy}; use uv_dispatch::BuildDispatch; use uv_fs::Simplified; -use uv_interpreter::{find_default_python, find_requested_python, Error}; +use uv_interpreter::{ + find_default_interpreter, find_interpreter, InterpreterRequest, SourceSelector, +}; use uv_resolver::{ExcludeNewer, FlatIndex, InMemoryIndex, OptionsBuilder}; use uv_types::{BuildContext, BuildIsolation, HashStrategy, InFlight}; @@ -116,14 +118,16 @@ async fn venv_impl( printer: Printer, ) -> miette::Result { // Locate the Python interpreter. - let interpreter = if let Some(python_request) = python_request { - find_requested_python(python_request, cache) - .into_diagnostic()? - .ok_or(Error::NoSuchPython(python_request.to_string())) - .into_diagnostic()? + let interpreter = if let Some(python) = python_request.as_ref() { + let request = InterpreterRequest::parse(python); + let sources = SourceSelector::from_env(uv_interpreter::SystemPython::Allowed); + find_interpreter(&request, &sources, cache) } else { - find_default_python(cache).into_diagnostic()? - }; + find_default_interpreter(cache) + } + .into_diagnostic()? + .into_diagnostic()? + .into_interpreter(); // Add all authenticated sources to the cache. for url in index_locations.urls() { diff --git a/crates/uv/tests/common/mod.rs b/crates/uv/tests/common/mod.rs index d6bf4f066691..b175b978e10d 100644 --- a/crates/uv/tests/common/mod.rs +++ b/crates/uv/tests/common/mod.rs @@ -4,7 +4,6 @@ use assert_cmd::assert::{Assert, OutputAssertExt}; use assert_cmd::Command; use assert_fs::assert::PathAssert; - use assert_fs::fixture::PathChild; use regex::Regex; use std::borrow::BorrowMut; @@ -13,10 +12,13 @@ use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::process::Output; use std::str::FromStr; + use uv_cache::Cache; use uv_fs::Simplified; use uv_interpreter::managed::toolchains_for_version; -use uv_interpreter::{find_requested_python, PythonVersion}; +use uv_interpreter::{ + find_interpreter, InterpreterRequest, PythonVersion, SourceSelector, VersionRequest, +}; // Exclude any packages uploaded after this date. pub static EXCLUDE_NEWER: &str = "2024-03-25T00:00:00Z"; @@ -394,8 +396,14 @@ pub fn python_path_with_versions( .collect::>(); if inner.is_empty() { // Fallback to a system lookup if we failed to find one in the toolchain directory - if let Some(interpreter) = find_requested_python(python_version, &cache).unwrap() { - vec![interpreter + let request = InterpreterRequest::Version( + VersionRequest::from_str(python_version) + .expect("The test version request must be valid"), + ); + let sources = SourceSelector::All; + if let Ok(found) = find_interpreter(&request, &sources, &cache).unwrap() { + vec![found + .into_interpreter() .sys_executable() .parent() .expect("Python executable should always be in a directory") @@ -409,6 +417,11 @@ pub fn python_path_with_versions( }) .collect::>(); + assert!( + python_versions.is_empty() || !selected_pythons.is_empty(), + "Failed to fulfill requested test Python versions: {selected_pythons:?}" + ); + Ok(env::join_paths(selected_pythons)?) } diff --git a/crates/uv/tests/pip_compile.rs b/crates/uv/tests/pip_compile.rs index 744a02d88c21..b4004dc50f2a 100644 --- a/crates/uv/tests/pip_compile.rs +++ b/crates/uv/tests/pip_compile.rs @@ -8,7 +8,6 @@ use std::process::Command; use anyhow::{bail, Context, Result}; use assert_fs::prelude::*; -use assert_fs::TempDir; use indoc::indoc; use url::Url; @@ -127,29 +126,42 @@ fn missing_requirements_in() { #[test] fn missing_venv() -> Result<()> { - let temp_dir = TempDir::new()?; - let cache_dir = TempDir::new()?; - let venv = temp_dir.child(".venv"); - - uv_snapshot!(Command::new(get_bin()) - .arg("pip") - .arg("compile") - .arg("requirements.in") - .arg("--cache-dir") - .arg(cache_dir.path()) - .env("VIRTUAL_ENV", venv.as_os_str()) - .current_dir(&temp_dir), @r###" - success: false - exit_code: 2 - ----- stdout ----- - - ----- stderr ----- - error: failed to read from file `requirements.in` - Caused by: No such file or directory (os error 2) - "### - ); + let context = TestContext::new("3.12"); + context.temp_dir.child("requirements.in").touch()?; + fs_err::remove_dir_all(context.temp_dir.child(".venv").path())?; + + if cfg!(windows) { + uv_snapshot!(context.filters(), context.compile() + .arg("requirements.in"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: Requirements file requirements.in does not contain any dependencies + error: failed to canonicalize path `[VENV]/Scripts/python.exe` + Caused by: The system cannot find the path specified. (os error 3) + "### + ); + } else { + uv_snapshot!(context.filters(), context.compile() + .arg("requirements.in"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: Requirements file requirements.in does not contain any dependencies + error: failed to canonicalize path `[VENV]/bin/python` + Caused by: No such file or directory (os error 2) + "### + ); + } - venv.assert(predicates::path::missing()); + context + .temp_dir + .child(".venv") + .assert(predicates::path::missing()); Ok(()) } diff --git a/crates/uv/tests/pip_sync.rs b/crates/uv/tests/pip_sync.rs index 6b7a5920a7e4..41ebecd6279f 100644 --- a/crates/uv/tests/pip_sync.rs +++ b/crates/uv/tests/pip_sync.rs @@ -102,14 +102,27 @@ fn missing_venv() -> Result<()> { requirements.write_str("anyio")?; fs::remove_dir_all(&context.venv)?; - uv_snapshot!(context.filters(), command(&context).arg("requirements.txt"), @r###" - success: false - exit_code: 2 - ----- stdout ----- - - ----- stderr ----- - error: Virtualenv does not exist at: `[VENV]/` - "###); + if cfg!(windows) { + uv_snapshot!(context.filters(), command(&context).arg("requirements.txt"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: failed to canonicalize path `[VENV]/Scripts/python.exe` + Caused by: The system cannot find the path specified. (os error 3) + "###); + } else { + uv_snapshot!(context.filters(), command(&context).arg("requirements.txt"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: failed to canonicalize path `[VENV]/bin/python` + Caused by: No such file or directory (os error 2) + "###); + } assert!(predicates::path::missing().eval(&context.venv)); diff --git a/crates/uv/tests/venv.rs b/crates/uv/tests/venv.rs index dd6dc8b23d97..7d4ea665b627 100644 --- a/crates/uv/tests/venv.rs +++ b/crates/uv/tests/venv.rs @@ -220,27 +220,18 @@ fn create_venv_unknown_python_minor() { .arg(context.venv.as_os_str()) .arg("--python") .arg("3.15"); - if cfg!(windows) { - uv_snapshot!(&mut command, @r###" - success: false - exit_code: 1 - ----- stdout ----- - - ----- stderr ----- - × No Python 3.15 found through `py --list-paths` or in `PATH`. Is Python 3.15 installed? - "### - ); - } else { - uv_snapshot!(&mut command, @r###" - success: false - exit_code: 1 - ----- stdout ----- - - ----- stderr ----- - × No Python 3.15 in `PATH`. Is Python 3.15 installed? - "### - ); - } + + // Note the `py` launcher is not included in the search in Windows due to + // `UV_TEST_PYTHON_PATH` being set + uv_snapshot!(&mut command, @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × No interpreter found for Python 3.15 in active virtual environment or search path + "### + ); context.venv.assert(predicates::path::missing()); } @@ -249,17 +240,7 @@ fn create_venv_unknown_python_minor() { fn create_venv_unknown_python_patch() { let context = VenvTestContext::new(&["3.12"]); - let filters = &[ - ( - r"Using Python 3\.\d+\.\d+ interpreter at: .+", - "Using Python [VERSION] interpreter at: [PATH]", - ), - ( - r"No Python 3\.8\.0 found through `py --list-paths` or in `PATH`\. Is Python 3\.8\.0 installed\?", - "No Python 3.8.0 in `PATH`. Is Python 3.8.0 installed?", - ), - ]; - uv_snapshot!(filters, context.venv_command() + uv_snapshot!(context.filters(), context.venv_command() .arg(context.venv.as_os_str()) .arg("--python") .arg("3.8.0"), @r###" @@ -268,7 +249,7 @@ fn create_venv_unknown_python_patch() { ----- stdout ----- ----- stderr ----- - × No Python 3.8.0 in `PATH`. Is Python 3.8.0 installed? + × No interpreter found for Python 3.8.0 in active virtual environment or search path "### );