From 78d207d1ffbbf0a30ccc9ca0d13099d91fc16dea Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Wed, 1 May 2024 11:51:44 -0500 Subject: [PATCH] Rewrite Python interpreter discovery # Conflicts: # crates/uv-interpreter/src/environment/python_environment.rs # crates/uv-interpreter/src/find_python.rs # crates/uv-interpreter/src/interpreter.rs # crates/uv-virtualenv/src/bare.rs # Conflicts: # crates/uv-virtualenv/src/bare.rs --- crates/uv-interpreter/src/discovery.rs | 675 ++++++++++++++++++ .../python_environment.rs => environment.rs} | 65 +- crates/uv-interpreter/src/environment/cfg.rs | 58 -- crates/uv-interpreter/src/environment/mod.rs | 3 - .../src/environment/virtualenv.rs | 17 - crates/uv-interpreter/src/find_python.rs | 662 ++--------------- crates/uv-interpreter/src/implementation.rs | 46 ++ crates/uv-interpreter/src/interpreter.rs | 35 +- crates/uv-interpreter/src/lib.rs | 52 +- .../uv-interpreter/src/managed/downloads.rs | 7 +- crates/uv-interpreter/src/managed/find.rs | 87 ++- crates/uv-interpreter/src/managed/mod.rs | 4 +- .../src/{selectors.rs => platform.rs} | 56 +- crates/uv-interpreter/src/py_launcher.rs | 118 +++ crates/uv-interpreter/src/virtualenv.rs | 156 ++++ crates/uv-virtualenv/src/bare.rs | 6 +- crates/uv-virtualenv/src/lib.rs | 2 + 17 files changed, 1179 insertions(+), 870 deletions(-) create mode 100644 crates/uv-interpreter/src/discovery.rs rename crates/uv-interpreter/src/{environment/python_environment.rs => environment.rs} (70%) delete mode 100644 crates/uv-interpreter/src/environment/cfg.rs delete mode 100644 crates/uv-interpreter/src/environment/mod.rs delete mode 100644 crates/uv-interpreter/src/environment/virtualenv.rs create mode 100644 crates/uv-interpreter/src/implementation.rs rename crates/uv-interpreter/src/{selectors.rs => platform.rs} (73%) create mode 100644 crates/uv-interpreter/src/py_launcher.rs create mode 100644 crates/uv-interpreter/src/virtualenv.rs diff --git a/crates/uv-interpreter/src/discovery.rs b/crates/uv-interpreter/src/discovery.rs new file mode 100644 index 0000000000000..e733aea6f44ff --- /dev/null +++ b/crates/uv-interpreter/src/discovery.rs @@ -0,0 +1,675 @@ +use thiserror::Error; +use uv_cache::Cache; +use which::which; + +use crate::implementation::ImplementationName; +use crate::managed::toolchains_for_current_platform; +use crate::py_launcher::py_list_paths; +use crate::virtualenv::virtualenv_python_executable; +use crate::virtualenv::{virtualenv_from_env, virtualenv_from_working_dir}; +use crate::{Interpreter, PythonVersion}; +use std::borrow::Cow; + +use std::collections::HashSet; +use std::num::ParseIntError; +use std::{env, io}; +use std::{path::Path, path::PathBuf, str::FromStr}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct InterpreterFinder { + request: InterpreterRequest, + sources: SourceSelector, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum InterpreterRequest { + /// A Python version without an implementation name + Version(VersionRequest), + /// A path to a directory containing a Python installation + Directory(PathBuf), + /// A path to a Python executable + File(PathBuf), + /// The name of a Python executable + ExecutableName(String), + /// A Python implementation without a version + Implementation(ImplementationName), + /// A Python implementation name and version + ImplementationVersion(ImplementationName, VersionRequest), +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(crate) enum SourceSelector { + #[default] + All, + Some(HashSet), +} + +impl SourceSelector { + fn contains(&self, source: InterpreterSource) -> bool { + match self { + Self::All => true, + Self::Some(sources) => sources.contains(&source), + } + } +} + +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub(crate) enum VersionRequest { + #[default] + Default, + Major(u8), + MajorMinor(u8, u8), + MajorMinorPatch(u8, u8, u8), +} + +#[derive(Clone, Debug)] +pub(crate) struct FindResult { + source: InterpreterSource, + interpreter: Interpreter, +} + +#[derive(Debug, Clone, PartialEq, Eq, Copy, Hash)] +pub(crate) 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 `PATH` + SearchPath, + /// An executable was found via `py --list-paths` + 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), + #[error(transparent)] + Query(#[from] crate::interpreter::Error), + #[error(transparent)] + ManagedToolchain(#[from] crate::managed::Error), + #[error(transparent)] + VirtualEnv(#[from] crate::virtualenv::Error), + #[error(transparent)] + PyLauncher(#[from] crate::py_launcher::Error), +} + +/// Lazily iterate over all discoverable Python executables. +/// +/// A [`VersionRequest`] may be provided, adjusting the possible executables. +/// The [`SourceSelector`] is used to filter the sources of the executables, sources that +/// are not selected will not be queried. +fn python_executables<'a>( + version: Option<&'a VersionRequest>, + sources: &'a SourceSelector, +) -> impl Iterator + 'a { + // Note we are careful to ensure the iterator chain is lazy to avoid unnecessary work + let iter = + // (1) The active environment + sources.contains(InterpreterSource::ActiveEnvironment).then(|| + virtualenv_from_env() + .into_iter() + .map(virtualenv_python_executable) + .map(|path| (InterpreterSource::ActiveEnvironment, path)) + ).into_iter().flatten() + // (2) A discovered environment + .chain( + sources.contains(InterpreterSource::DiscoveredEnvironment).then(|| + virtualenv_from_working_dir() + .into_iter().flatten() + .map(virtualenv_python_executable) + .map(|path| (InterpreterSource::DiscoveredEnvironment, path)) + ).into_iter().flatten() + ) + // (3) Managed toolchains + .chain( + sources.contains(InterpreterSource::ManagedToolchain).then(move || + toolchains_for_current_platform() + .into_iter().flatten() + // Check that the toolchain version satisfies the request to avoid unnecessary interpreter queries later + .filter(move |toolchain| version.is_none() || version.is_some_and(|version| version.matches_version(&toolchain.python_version()))) + .map(|toolchain| (InterpreterSource::ManagedToolchain, toolchain.executable())) + ).into_iter().flatten() + + ) + // (4) The search path + .chain( + sources.contains(InterpreterSource::SearchPath).then(move || + python_executables_from_search_path(version) + .map(|path| (InterpreterSource::SearchPath, path)), + ).into_iter().flatten() + ) + // (5) `py --list-paths` (windows only) + // TODO(konstin): Implement to read python installations from the registry instead. + .chain( + (sources.contains(InterpreterSource::PyLauncher) && cfg!(windows)).then(|| + py_list_paths().ok().into_iter().flatten() + // We can avoid querying the interpreter using versions from the py launcher output unless a patch is requested + .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)) + ).into_iter().flatten() + ); + + iter +} + +fn python_executables_from_search_path( + version: Option<&VersionRequest>, +) -> impl Iterator + '_ { + let search_path = + env::var_os("UV_TEST_PYTHON_PATH").unwrap_or(env::var_os("PATH").unwrap_or_default()); + let search_dirs: Vec<_> = env::split_paths(&search_path).collect(); + let possible_names = version.unwrap_or(&VersionRequest::Default).possible_names(); + + search_dirs.into_iter().flat_map(move |dir| { + // Clone the directory for second closure + let dir_clone = dir.clone(); + possible_names + .clone() + .into_iter() + .flatten() + .flat_map(move |name| { + which::which_in_global(&*name, Some(&dir)) + .into_iter() + .flatten() + .collect::>() + }) + .filter(|path| !is_windows_store_shim(path)) + .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. +/// +/// A [`VersionRequest`] may be provided, expanding the executable name search. +fn python_interpreters<'a>( + version: Option<&'a VersionRequest>, + sources: &'a SourceSelector, + cache: &'a Cache, +) -> impl Iterator + 'a { + let iter = python_executables(version, sources).filter_map(|(source, path)| { + if let Ok(interpreter) = Interpreter::query(path, cache) { + Some((source, interpreter)) + } else { + None + } + }); + + iter +} + +pub(crate) fn find_interpreter( + request: &InterpreterRequest, + sources: &SourceSelector, + cache: &Cache, +) -> Result, Error> { + let result = match request { + InterpreterRequest::File(path) => { + if !sources.contains(InterpreterSource::ProvidedPath) { + // TODO(zanieb): Consider an error case when it's not possible to find + return Ok(None); + } + if !path.try_exists()? { + return Ok(None); + } + FindResult { + source: InterpreterSource::ProvidedPath, + interpreter: Interpreter::query(path, cache)?, + } + } + InterpreterRequest::Directory(path) => { + if !sources.contains(InterpreterSource::ProvidedPath) { + // TODO(zanieb): Consider an error case when it's not possible to find + return Ok(None); + } + if !path.try_exists()? { + return Ok(None); + } + let executable = if cfg!(windows) { + // On Windows, we'll check for both `bin/python.exe` and `Scripts/python.exe` + // the latter is standard, but the first can exist when using GNU-compatible tools + let executable = path.join("bin/python.exe"); + if executable.exists() { + executable + } else { + path.join("Scripts/python.exe") + } + } else { + path.join("bin/python") + }; + FindResult { + source: InterpreterSource::ProvidedPath, + interpreter: Interpreter::query(executable, cache)?, + } + } + InterpreterRequest::ExecutableName(name) => { + if !sources.contains(InterpreterSource::SearchPath) { + // TODO(zanieb): Consider an error case when it's not possible to find + return Ok(None); + } + let Some(executable) = which(name).ok() else { + return Ok(None); + }; + FindResult { + source: InterpreterSource::SearchPath, + interpreter: Interpreter::query(executable, cache)?, + } + } + InterpreterRequest::Implementation(implementation) => { + let Some((source, interpreter)) = + python_interpreters(None, sources, cache).find(|(_source, interpreter)| { + interpreter.implementation_name() == implementation.as_str() + }) + else { + return Ok(None); + }; + FindResult { + source, + interpreter, + } + } + InterpreterRequest::ImplementationVersion(implementation, version) => { + let Some((source, interpreter)) = python_interpreters(Some(version), sources, cache) + .find(|(_source, interpreter)| { + version.matches_interpreter(interpreter) + && interpreter.implementation_name() == implementation.as_str() + }) + else { + return Ok(None); + }; + FindResult { + source, + interpreter, + } + } + InterpreterRequest::Version(version) => { + let Some((source, interpreter)) = python_interpreters(Some(version), sources, cache) + .find(|(_source, interpreter)| version.matches_interpreter(interpreter)) + else { + return Ok(None); + }; + FindResult { + source, + interpreter, + } + } + }; + Ok(Some(result)) +} + +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(crate) 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/python3.exe + // 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); + } + // 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) + } + } + } + + fn has_patch(self) -> bool { + match self { + Self::Default => false, + Self::Major(..) => false, + Self::MajorMinor(..) => false, + Self::MajorMinorPatch(..) => true, + } + } +} + +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) + } +} + +/// 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 FindResult { + #[allow(dead_code)] + pub(crate) fn source(&self) -> &InterpreterSource { + &self.source + } + + #[allow(dead_code)] + pub(crate) fn interpreteter(&self) -> &Interpreter { + &self.interpreter + } + + pub(crate) fn into_interpreteter(self) -> Interpreter { + self.interpreter + } +} +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use crate::{ + discovery::{InterpreterRequest, VersionRequest}, + implementation::ImplementationName, + }; + + #[test] + fn python_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() + ) + ); + } + + #[test] + fn python_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/python_environment.rs b/crates/uv-interpreter/src/environment.rs similarity index 70% rename from crates/uv-interpreter/src/environment/python_environment.rs rename to crates/uv-interpreter/src/environment.rs index 2518c5b3e8ba0..33b785ce37708 100644 --- a/crates/uv-interpreter/src/environment/python_environment.rs +++ b/crates/uv-interpreter/src/environment.rs @@ -3,12 +3,11 @@ use std::env; use std::path::{Path, PathBuf}; use same_file::is_same_file; -use tracing::{debug, info}; use uv_cache::Cache; use uv_fs::{LockedFile, Simplified}; -use crate::environment::cfg::PyVenvConfiguration; +use crate::virtualenv::{detect_virtualenv, virtualenv_python_executable, PyVenvConfiguration}; use crate::{find_default_python, find_requested_python, Error, Interpreter, Target}; /// A Python environment, consisting of a Python [`Interpreter`] and its associated paths. @@ -21,11 +20,11 @@ pub struct PythonEnvironment { impl PythonEnvironment { /// Create a [`PythonEnvironment`] for an existing virtual environment. pub fn from_virtualenv(cache: &Cache) -> Result { - let Some(venv) = detect_virtual_env()? else { + let Some(venv) = detect_virtualenv()? else { return Err(Error::VenvNotFound); }; let venv = fs_err::canonicalize(venv)?; - let executable = detect_python_executable(&venv); + let executable = virtualenv_python_executable(&venv); let interpreter = Interpreter::query(&executable, cache)?; debug_assert!( @@ -152,61 +151,3 @@ impl PythonEnvironment { self.interpreter } } - -/// Locate the current virtual environment. -pub(crate) fn detect_virtual_env() -> Result, Error> { - if let Some(dir) = env::var_os("VIRTUAL_ENV").filter(|value| !value.is_empty()) { - info!( - "Found a virtualenv through VIRTUAL_ENV at: {}", - Path::new(&dir).display() - ); - return Ok(Some(PathBuf::from(dir))); - } - if let Some(dir) = env::var_os("CONDA_PREFIX").filter(|value| !value.is_empty()) { - info!( - "Found a virtualenv through CONDA_PREFIX at: {}", - Path::new(&dir).display() - ); - return Ok(Some(PathBuf::from(dir))); - } - - // Search for a `.venv` directory in the current or any parent directory. - let current_dir = env::current_dir().expect("Failed to detect current directory"); - for dir in current_dir.ancestors() { - let dot_venv = dir.join(".venv"); - if dot_venv.is_dir() { - if !dot_venv.join("pyvenv.cfg").is_file() { - return Err(Error::MissingPyVenvCfg(dot_venv)); - } - debug!("Found a virtualenv named .venv at: {}", dot_venv.display()); - return Ok(Some(dot_venv)); - } - } - - Ok(None) -} - -/// Returns the path to the `python` executable inside a virtual environment. -pub(crate) fn detect_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; - } - - // Apparently, Python installed via msys2 on Windows _might_ produce a POSIX-like layout. - // See: https://github.com/PyO3/maturin/issues/1108 - let executable = venv.join("bin").join("python.exe"); - if executable.exists() { - return executable; - } - - // Fallback for Conda environments. - venv.join("python.exe") - } else { - // Search for `python` in the `bin` directory. - venv.join("bin").join("python") - } -} diff --git a/crates/uv-interpreter/src/environment/cfg.rs b/crates/uv-interpreter/src/environment/cfg.rs deleted file mode 100644 index 0cf15a96e379b..0000000000000 --- a/crates/uv-interpreter/src/environment/cfg.rs +++ /dev/null @@ -1,58 +0,0 @@ -use std::path::Path; - -use fs_err as fs; -use thiserror::Error; - -/// A parsed `pyvenv.cfg` -#[derive(Debug, Clone)] -pub struct PyVenvConfiguration { - /// The version of the `virtualenv` package used to create the virtual environment, if any. - pub(crate) virtualenv: bool, - /// The version of the `uv` package used to create the virtual environment, if any. - pub(crate) uv: bool, -} - -impl PyVenvConfiguration { - /// Parse a `pyvenv.cfg` file into a [`PyVenvConfiguration`]. - pub fn parse(cfg: impl AsRef) -> Result { - let mut virtualenv = false; - let mut uv = false; - - // Per https://snarky.ca/how-virtual-environments-work/, the `pyvenv.cfg` file is not a - // valid INI file, and is instead expected to be parsed by partitioning each line on the - // first equals sign. - let content = fs::read_to_string(&cfg)?; - for line in content.lines() { - let Some((key, _value)) = line.split_once('=') else { - continue; - }; - match key.trim() { - "virtualenv" => { - virtualenv = true; - } - "uv" => { - uv = true; - } - _ => {} - } - } - - Ok(Self { virtualenv, uv }) - } - - /// Returns true if the virtual environment was created with the `virtualenv` package. - pub fn is_virtualenv(&self) -> bool { - self.virtualenv - } - - /// Returns true if the virtual environment was created with the `uv` package. - pub fn is_uv(&self) -> bool { - self.uv - } -} - -#[derive(Debug, Error)] -pub enum Error { - #[error(transparent)] - Io(#[from] std::io::Error), -} diff --git a/crates/uv-interpreter/src/environment/mod.rs b/crates/uv-interpreter/src/environment/mod.rs deleted file mode 100644 index 004e682691edd..0000000000000 --- a/crates/uv-interpreter/src/environment/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub(crate) mod cfg; -pub(crate) mod python_environment; -pub(crate) mod virtualenv; diff --git a/crates/uv-interpreter/src/environment/virtualenv.rs b/crates/uv-interpreter/src/environment/virtualenv.rs deleted file mode 100644 index 64f74003dd414..0000000000000 --- a/crates/uv-interpreter/src/environment/virtualenv.rs +++ /dev/null @@ -1,17 +0,0 @@ -use std::path::PathBuf; - -use pypi_types::Scheme; - -/// The layout of a virtual environment. -#[derive(Debug)] -pub struct Virtualenv { - /// The absolute path to the root of the virtualenv, e.g., `/path/to/.venv`. - pub root: PathBuf, - - /// The path to the Python interpreter inside the virtualenv, e.g., `.venv/bin/python` - /// (Unix, Python 3.11). - pub executable: PathBuf, - - /// The [`Scheme`] paths for the virtualenv, as returned by (e.g.) `sysconfig.get_paths()`. - pub scheme: Scheme, -} diff --git a/crates/uv-interpreter/src/find_python.rs b/crates/uv-interpreter/src/find_python.rs index d2090a5c71ffe..f98e73d2414cf 100644 --- a/crates/uv-interpreter/src/find_python.rs +++ b/crates/uv-interpreter/src/find_python.rs @@ -1,18 +1,32 @@ -use std::borrow::Cow; +use std::collections::HashSet; use std::env; -use std::ffi::{OsStr, OsString}; -use std::path::PathBuf; -use tracing::{debug, instrument}; +use tracing::{debug, instrument, warn}; use uv_cache::Cache; use uv_warnings::warn_user_once; -use crate::environment::python_environment::{detect_python_executable, detect_virtual_env}; -use crate::interpreter::InterpreterInfoError; +use crate::discovery::{ + find_interpreter, Error as DiscoveryError, FindResult, InterpreterRequest, InterpreterSource, + SourceSelector, VersionRequest, +}; +use crate::virtualenv::detect_virtualenv; +use crate::virtualenv::virtualenv_python_executable; use crate::PythonVersion; use crate::{Error, Interpreter}; +fn interpreter_sources_from_env() -> SourceSelector { + if env::var_os("UV_FORCE_MANAGED_PYTHON").is_some() { + debug!("Only considering managed toolchains due to `UV_FORCE_MANAGED_PYTHON`"); + SourceSelector::Some(HashSet::from_iter([InterpreterSource::ManagedToolchain])) + } else if env::var_os("UV_TEST_PYTHON_PATH").is_some() { + debug!("Only considering search path due to `UV_TEST_PYTHON_PATH`"); + SourceSelector::Some(HashSet::from_iter([InterpreterSource::SearchPath])) + } else { + SourceSelector::All + } +} + /// Find a Python of a specific version, a binary with a name or a path to a binary. /// /// Supported formats: @@ -26,62 +40,16 @@ use crate::{Error, Interpreter}; /// 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> { +pub fn find_requested_python( + request: &str, + cache: &Cache, +) -> Result, DiscoveryError> { 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()), - } - } + let request = InterpreterRequest::parse(request); + let sources = interpreter_sources_from_env(); + Ok(find_interpreter(&request, &sources, cache)? + .map(FindResult::into_interpreteter) + .inspect(warn_on_unsupported_python)) } /// Pick a sensible default for the Python a user wants when they didn't specify a version. @@ -91,336 +59,22 @@ pub fn find_requested_python(request: &str, cache: &Cache) -> Result 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) + 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") + }) } /// 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, - Err( - err @ Error::QueryScript { - err: InterpreterInfoError::UnsupportedPythonVersion, - .. - }, - ) => { - if selector.major() <= Some(2) { - return Err(err); - } - // Skip over Python 2 or older installation when querying for a recent python installation. - debug!("Found a Python 2 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 windows::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)); - } - } - } - Err(Error::PyList(error)) => { - if error.kind() == std::io::ErrorKind::NotFound { - debug!("`py` is not installed"); - } - } - Err(error) => return Err(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 windows::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)); - } - } - } - Err(Error::PyList(error)) => { - if error.kind() == std::io::ErrorKind::NotFound { - debug!("`py` is not installed"); - } - } - Err(error) => return Err(error), - } - } - - Ok(None) -} - -#[derive(Debug, Clone)] -struct PyListPath { - major: u8, - minor: u8, - executable_path: PathBuf, -} - -#[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 major(self) -> Option { - match self { - Self::Default => None, - Self::Major(major) => Some(major), - Self::MajorMinor(major, _) => Some(major), - Self::MajorMinorPatch(major, _, _) => Some(major), - } - } + let request = InterpreterRequest::Version(VersionRequest::Default); + let sources = interpreter_sources_from_env(); + Ok(find_interpreter(&request, &sources, cache)? + .map(FindResult::into_interpreteter) + .inspect(warn_on_unsupported_python)) } fn warn_on_unsupported_python(interpreter: &Interpreter) { @@ -518,8 +172,8 @@ fn find_version( // Check if the venv Python matches. if !system { - if let Some(venv) = detect_virtual_env()? { - let executable = detect_python_executable(venv); + if let Some(venv) = detect_virtualenv()? { + let executable = virtualenv_python_executable(venv); let interpreter = Interpreter::query(executable, cache)?; if version_matches(&interpreter) { @@ -544,236 +198,6 @@ fn find_version( } } -mod windows { - use std::path::PathBuf; - use std::process::Command; - - use once_cell::sync::Lazy; - use regex::Regex; - use tracing::info_span; - - use crate::find_python::PyListPath; - use crate::Error; - - /// ```text - /// -V:3.12 C:\Users\Ferris\AppData\Local\Programs\Python\Python312\python.exe - /// -V:3.8 C:\Users\Ferris\AppData\Local\Programs\Python\Python38\python.exe - /// ``` - static PY_LIST_PATHS: Lazy = Lazy::new(|| { - // Without the `R` flag, paths have trailing \r - Regex::new(r"(?mR)^ -(?:V:)?(\d).(\d+)-?(?:arm)?\d*\s*\*?\s*(.*)$").unwrap() - }); - - /// Run `py --list-paths` to find the installed pythons. - /// - /// The command takes 8ms on my machine. - /// TODO(konstin): Implement to read python installations from the registry instead. - pub(super) fn py_list_paths() -> Result, Error> { - let output = info_span!("py_list_paths") - .in_scope(|| Command::new("py").arg("--list-paths").output()) - .map_err(Error::PyList)?; - - // `py` sometimes prints "Installed Pythons found by py Launcher for Windows" to stderr which we ignore. - if !output.status.success() { - return Err(Error::PythonSubcommandOutput { - message: format!( - "Running `py --list-paths` failed with status {}", - output.status - ), - exit_code: output.status, - stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(), - stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), - }); - } - - // Find the first python of the version we want in the list - let stdout = - String::from_utf8(output.stdout).map_err(|err| Error::PythonSubcommandOutput { - message: format!("The stdout of `py --list-paths` isn't UTF-8 encoded: {err}"), - exit_code: output.status, - stdout: String::from_utf8_lossy(err.as_bytes()).trim().to_string(), - stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), - })?; - - Ok(PY_LIST_PATHS - .captures_iter(&stdout) - .filter_map(|captures| { - let (_, [major, minor, path]) = captures.extract(); - if let (Some(major), Some(minor)) = - (major.parse::().ok(), minor.parse::().ok()) - { - Some(PyListPath { - major, - minor, - executable_path: PathBuf::from(path), - }) - } else { - None - } - }) - .collect()) - } - - /// 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 result = - find_requested_python(r"C:\does\not\exists\python3.12", &Cache::temp().unwrap()) - .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; diff --git a/crates/uv-interpreter/src/implementation.rs b/crates/uv-interpreter/src/implementation.rs new file mode 100644 index 0000000000000..fee3bb8f92877 --- /dev/null +++ b/crates/uv-interpreter/src/implementation.rs @@ -0,0 +1,46 @@ +use std::{ + fmt::{self, Display}, + str::FromStr, +}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum Error { + #[error("Unknown Python implementation `{0}`")] + UnknownImplementation(String), +} + +#[derive(Debug, Eq, PartialEq, Clone, Copy)] +pub enum ImplementationName { + Cpython, +} + +impl ImplementationName { + pub(crate) fn iter() -> impl Iterator { + static NAMES: &[ImplementationName] = &[ImplementationName::Cpython]; + NAMES.iter() + } + + pub fn as_str(&self) -> &str { + match self { + Self::Cpython => "cpython", + } + } +} + +impl FromStr for ImplementationName { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s.to_ascii_lowercase().as_str() { + "cpython" => Ok(Self::Cpython), + _ => Err(Error::UnknownImplementation(s.to_string())), + } + } +} + +impl Display for ImplementationName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} diff --git a/crates/uv-interpreter/src/interpreter.rs b/crates/uv-interpreter/src/interpreter.rs index 0270424af1e1f..4aed7ccf06162 100644 --- a/crates/uv-interpreter/src/interpreter.rs +++ b/crates/uv-interpreter/src/interpreter.rs @@ -1,10 +1,12 @@ +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 thiserror::Error; use tracing::{debug, warn}; use cache_key::digest; @@ -17,7 +19,7 @@ use pypi_types::Scheme; use uv_cache::{Cache, CacheBucket, CachedByTimestamp, Freshness, Timestamp}; use uv_fs::{write_atomic_sync, PythonExt, Simplified}; -use crate::{Error, PythonVersion, Target, Virtualenv}; +use crate::{PythonVersion, Target, VirtualEnvironment}; /// A Python executable and its associated platform markers. #[derive(Debug, Clone)] @@ -98,7 +100,7 @@ impl Interpreter { /// Return a new [`Interpreter`] with the given virtual environment root. #[must_use] - pub fn with_virtualenv(self, virtualenv: Virtualenv) -> Self { + pub fn with_virtualenv(self, virtualenv: VirtualEnvironment) -> Self { Self { scheme: virtualenv.scheme, sys_executable: virtualenv.executable, @@ -389,6 +391,33 @@ impl ExternallyManaged { } } +#[derive(Debug, Error)] +pub enum Error { + #[error(transparent)] + Io(#[from] io::Error), + #[error("Failed to query Python interpreter at `{interpreter}`")] + PythonSubcommandLaunch { + interpreter: PathBuf, + #[source] + err: io::Error, + }, + #[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("Can't use Python at `{interpreter}`")] + QueryScript { + #[source] + err: InterpreterInfoError, + interpreter: 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 { diff --git a/crates/uv-interpreter/src/lib.rs b/crates/uv-interpreter/src/lib.rs index 4c0fb9fb45d2f..557fce94643d8 100644 --- a/crates/uv-interpreter/src/lib.rs +++ b/crates/uv-interpreter/src/lib.rs @@ -9,32 +9,43 @@ use std::ffi::OsString; use std::io; -use std::path::PathBuf; -use std::process::ExitStatus; use thiserror::Error; -pub use crate::environment::cfg::PyVenvConfiguration; -pub use crate::environment::python_environment::PythonEnvironment; -pub use crate::environment::virtualenv::Virtualenv; +pub use crate::discovery::Error as DiscoveryError; +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::python_version::PythonVersion; pub use crate::target::Target; +pub use crate::virtualenv::{Error as VirtualEnvError, PyVenvConfiguration, VirtualEnvironment}; +mod discovery; mod environment; mod find_python; +mod implementation; mod interpreter; pub mod managed; +pub mod platform; +mod py_launcher; mod python_version; -pub mod selectors; mod target; +mod virtualenv; #[derive(Debug, Error)] pub enum Error { - #[error("Expected `{0}` to be a virtualenv, but `pyvenv.cfg` is missing")] - MissingPyVenvCfg(PathBuf), + #[error(transparent)] + VirtualEnv(#[from] virtualenv::Error), + + #[error(transparent)] + Query(#[from] interpreter::Error), + + #[error(transparent)] + Request(#[from] discovery::Error), + + #[error(transparent)] + PyLauncher(#[from] py_launcher::Error), + #[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.")] @@ -43,14 +54,6 @@ pub enum Error { RequestedPythonNotFound(String), #[error(transparent)] Io(#[from] io::Error), - #[error("Failed to query Python interpreter at `{interpreter}`")] - PythonSubcommandLaunch { - interpreter: PathBuf, - #[source] - err: io::Error, - }, - #[error("Failed to run `py --list-paths` to find Python installations. Is Python installed?")] - PyList(#[source] io::Error), #[cfg(windows)] #[error( "No Python {0} found through `py --list-paths` or in `PATH`. Is Python {0} installed?" @@ -65,23 +68,8 @@ pub enum 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("Failed to write to cache")] Encode(#[from] rmp_serde::encode::Error), - #[error("Broken virtualenv: Failed to parse pyvenv.cfg")] - Cfg(#[from] environment::cfg::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, - }, } diff --git a/crates/uv-interpreter/src/managed/downloads.rs b/crates/uv-interpreter/src/managed/downloads.rs index 22792e7f5689d..7f210aec73145 100644 --- a/crates/uv-interpreter/src/managed/downloads.rs +++ b/crates/uv-interpreter/src/managed/downloads.rs @@ -3,7 +3,8 @@ use std::io; use std::path::{Path, PathBuf}; use std::str::FromStr; -use crate::selectors::{Arch, ImplementationName, Libc, Os, PythonSelectorError}; +use crate::implementation::{Error as ImplementationError, ImplementationName}; +use crate::platform::{Arch, Error as PlatformError, Libc, Os}; use crate::PythonVersion; use thiserror::Error; use uv_client::BetterReqwestError; @@ -18,7 +19,9 @@ use uv_fs::Simplified; #[derive(Error, Debug)] pub enum Error { #[error(transparent)] - SelectorError(#[from] PythonSelectorError), + PlatformError(#[from] PlatformError), + #[error(transparent)] + ImplementationError(#[from] ImplementationError), #[error("invalid python version: {0}")] InvalidPythonVersion(String), #[error("download failed")] diff --git a/crates/uv-interpreter/src/managed/find.rs b/crates/uv-interpreter/src/managed/find.rs index ddeb02b19ab0d..ad114bb50a09e 100644 --- a/crates/uv-interpreter/src/managed/find.rs +++ b/crates/uv-interpreter/src/managed/find.rs @@ -1,10 +1,11 @@ use std::collections::BTreeSet; use std::ffi::OsStr; use std::path::{Path, PathBuf}; +use std::str::FromStr; use crate::managed::downloads::Error; +use crate::platform::{Arch, Libc, Os}; use crate::python_version::PythonVersion; -use crate::selectors::{Arch, Libc, Os}; use once_cell::sync::Lazy; @@ -21,6 +22,27 @@ pub static TOOLCHAIN_DIRECTORY: Lazy = Lazy::new(|| { ) }); +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)) + { + Some(Toolchain { path }) + } else { + None + } + }); + + Ok(iter) +} + /// An installed Python toolchain. #[derive(Debug, Clone)] pub struct Toolchain { @@ -38,21 +60,25 @@ impl Toolchain { unimplemented!("Only Windows and Unix systems are supported.") } } -} -/// Return the toolchains that satisfy the given Python version on this platform. -/// -/// ## Errors -/// -/// - The platform metadata cannot be read -/// - A directory in the toolchain directory cannot be read -pub fn toolchains_for_version(version: &PythonVersion) -> Result, Error> { - let platform_key = platform_key_from_env()?; - - // TODO(zanieb): Consider returning an iterator instead of a `Vec` - // Note we need to collect paths regardless for sorting by version. + pub fn python_version(&self) -> PythonVersion { + PythonVersion::from_str( + self.path + .file_name() + .expect("Toolchains must have a directory name") + .to_str() + .expect("Toolchains have valid names").split('-') + .nth(1) + .expect( + "Toolchain directory names must have the proper number of `-` separated components", + ), + ) + .expect("Toolchain directory names must have a valid Python version") + } +} - let toolchain_dirs = match fs_err::read_dir(TOOLCHAIN_DIRECTORY.to_path_buf()) { +fn toolchain_directories() -> Result, Error> { + match fs_err::read_dir(TOOLCHAIN_DIRECTORY.to_path_buf()) { Ok(toolchain_dirs) => { // Collect sorted directory paths; `read_dir` is not stable across platforms let directories: BTreeSet<_> = toolchain_dirs @@ -68,18 +94,29 @@ pub fn toolchains_for_version(version: &PythonVersion) -> Result, dir: TOOLCHAIN_DIRECTORY.to_path_buf(), err, })?; - directories + Ok(directories) } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - return Ok(Vec::new()); - } - Err(err) => { - return Err(Error::ReadError { - dir: TOOLCHAIN_DIRECTORY.to_path_buf(), - err, - }) - } - }; + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(BTreeSet::default()), + Err(err) => Err(Error::ReadError { + dir: TOOLCHAIN_DIRECTORY.to_path_buf(), + err, + }), + } +} + +/// Return the toolchains that satisfy the given Python version on this platform. +/// +/// ## Errors +/// +/// - The platform metadata cannot be read +/// - A directory in the toolchain directory cannot be read +pub fn toolchains_for_version(version: &PythonVersion) -> Result, Error> { + let platform_key = platform_key_from_env()?; + + // TODO(zanieb): Consider returning an iterator instead of a `Vec` + // Note we need to collect paths regardless for sorting by version. + + let toolchain_dirs = toolchain_directories()?; Ok(toolchain_dirs .into_iter() diff --git a/crates/uv-interpreter/src/managed/mod.rs b/crates/uv-interpreter/src/managed/mod.rs index 1d596703018cb..83cf680cc8e61 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/selectors.rs b/crates/uv-interpreter/src/platform.rs similarity index 73% rename from crates/uv-interpreter/src/selectors.rs rename to crates/uv-interpreter/src/platform.rs index f790763f0dce6..f96614d45affc 100644 --- a/crates/uv-interpreter/src/selectors.rs +++ b/crates/uv-interpreter/src/platform.rs @@ -1,14 +1,9 @@ use std::{ - fmt::{self, Display}, + fmt::{self}, str::FromStr, }; use thiserror::Error; -#[derive(Debug, Eq, PartialEq, Clone, Copy)] -pub enum ImplementationName { - Cpython, -} - #[derive(Debug, PartialEq, Clone)] pub struct Platform { os: Os, @@ -51,49 +46,20 @@ pub enum Libc { } #[derive(Error, Debug)] -pub enum PythonSelectorError { +pub enum Error { #[error("Operating system not supported: {0}")] OsNotSupported(String), #[error("Architecture not supported: {0}")] ArchNotSupported(String), #[error("Libc type could not be detected")] LibcNotDetected(), - #[error("Implementation not supported: {0}")] - ImplementationNotSupported(String), -} - -impl ImplementationName { - pub fn as_str(&self) -> &str { - match self { - Self::Cpython => "cpython", - } - } -} - -impl FromStr for ImplementationName { - type Err = PythonSelectorError; - - fn from_str(s: &str) -> Result { - match s { - "cpython" => Ok(Self::Cpython), - _ => Err(PythonSelectorError::ImplementationNotSupported( - s.to_string(), - )), - } - } -} - -impl Display for ImplementationName { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.as_str()) - } } impl Platform { pub fn new(os: Os, arch: Arch, libc: Libc) -> Self { Self { os, arch, libc } } - pub fn from_env() -> Result { + pub fn from_env() -> Result { Ok(Self::new( Os::from_env()?, Arch::from_env()?, @@ -119,13 +85,13 @@ impl fmt::Display for Os { } impl Os { - pub(crate) fn from_env() -> Result { + pub(crate) fn from_env() -> Result { Self::from_str(std::env::consts::OS) } } impl FromStr for Os { - type Err = PythonSelectorError; + type Err = Error; fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { @@ -138,7 +104,7 @@ impl FromStr for Os { "dragonfly" => Ok(Self::Dragonfly), "illumos" => Ok(Self::Illumos), "haiku" => Ok(Self::Haiku), - _ => Err(PythonSelectorError::OsNotSupported(s.to_string())), + _ => Err(Error::OsNotSupported(s.to_string())), } } } @@ -159,7 +125,7 @@ impl fmt::Display for Arch { } impl FromStr for Arch { - type Err = PythonSelectorError; + type Err = Error; fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { @@ -171,24 +137,24 @@ impl FromStr for Arch { "x86" | "i686" | "i386" => Ok(Self::X86), "x86_64" | "amd64" => Ok(Self::X86_64), "s390x" => Ok(Self::S390X), - _ => Err(PythonSelectorError::ArchNotSupported(s.to_string())), + _ => Err(Error::ArchNotSupported(s.to_string())), } } } impl Arch { - pub(crate) fn from_env() -> Result { + pub(crate) fn from_env() -> Result { Self::from_str(std::env::consts::ARCH) } } impl Libc { - pub(crate) fn from_env() -> Result { + pub(crate) fn from_env() -> Result { // TODO(zanieb): Perform this lookup match std::env::consts::OS { "linux" => Ok(Libc::Gnu), "windows" | "macos" => Ok(Libc::None), - _ => Err(PythonSelectorError::LibcNotDetected()), + _ => Err(Error::LibcNotDetected()), } } } diff --git a/crates/uv-interpreter/src/py_launcher.rs b/crates/uv-interpreter/src/py_launcher.rs new file mode 100644 index 0000000000000..9097b3ad60f9e --- /dev/null +++ b/crates/uv-interpreter/src/py_launcher.rs @@ -0,0 +1,118 @@ +use std::io; +use std::path::PathBuf; +use std::process::{Command, ExitStatus}; + +use once_cell::sync::Lazy; +use regex::Regex; +use thiserror::Error; +use tracing::info_span; + +#[derive(Debug, Clone)] +pub(crate) struct PyListPath { + pub(crate) major: u8, + pub(crate) minor: u8, + pub(crate) executable_path: PathBuf, +} + +#[derive(Error, Debug)] +pub enum Error { + #[error("{message} with {exit_code}\n--- stdout:\n{stdout}\n--- stderr:\n{stderr}\n---")] + StatusCode { + message: String, + exit_code: ExitStatus, + stdout: String, + stderr: String, + }, + #[error("Failed to run `py --list-paths` to find Python installations. Is Python installed?")] + Spawn(#[source] io::Error), +} + +/// ```text +/// -V:3.12 C:\Users\Ferris\AppData\Local\Programs\Python\Python312\python.exe +/// -V:3.8 C:\Users\Ferris\AppData\Local\Programs\Python\Python38\python.exe +/// ``` +static PY_LIST_PATHS: Lazy = Lazy::new(|| { + // Without the `R` flag, paths have trailing \r + Regex::new(r"(?mR)^ -(?:V:)?(\d).(\d+)-?(?:arm)?\d*\s*\*?\s*(.*)$").unwrap() +}); + +/// Use the `py` launcher to find installed Python versions. +/// +/// Calls `py --list-paths`. +pub(crate) fn py_list_paths() -> Result, Error> { + // konstin: The command takes 8ms on my machine. + let output = info_span!("py_list_paths") + .in_scope(|| Command::new("py").arg("--list-paths").output()) + .map_err(Error::Spawn)?; + + // `py` sometimes prints "Installed Pythons found by py Launcher for Windows" to stderr which we ignore. + if !output.status.success() { + return Err(Error::StatusCode { + message: format!( + "Running `py --list-paths` failed with status {}", + output.status + ), + exit_code: output.status, + stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(), + stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), + }); + } + + // Find the first python of the version we want in the list + let stdout = String::from_utf8(output.stdout).map_err(|err| Error::StatusCode { + message: format!("The stdout of `py --list-paths` isn't UTF-8 encoded: {err}"), + exit_code: output.status, + stdout: String::from_utf8_lossy(err.as_bytes()).trim().to_string(), + stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), + })?; + + Ok(PY_LIST_PATHS + .captures_iter(&stdout) + .filter_map(|captures| { + let (_, [major, minor, path]) = captures.extract(); + if let (Some(major), Some(minor)) = (major.parse::().ok(), minor.parse::().ok()) + { + Some(PyListPath { + major, + minor, + executable_path: PathBuf::from(path), + }) + } else { + None + } + }) + .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 result = + find_requested_python(r"C:\does\not\exists\python3.12", &Cache::temp().unwrap()) + .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 new file mode 100644 index 0000000000000..e87d248b4cfcc --- /dev/null +++ b/crates/uv-interpreter/src/virtualenv.rs @@ -0,0 +1,156 @@ +use std::{ + env, io, + path::{Path, PathBuf}, +}; + +use fs_err as fs; +use pypi_types::Scheme; +use thiserror::Error; +use tracing::{debug, info}; + +/// The layout of a virtual environment. +#[derive(Debug)] +pub struct VirtualEnvironment { + /// The absolute path to the root of the virtualenv, e.g., `/path/to/.venv`. + pub root: PathBuf, + + /// The path to the Python interpreter inside the virtualenv, e.g., `.venv/bin/python` + /// (Unix, Python 3.11). + pub executable: PathBuf, + + /// The [`Scheme`] paths for the virtualenv, as returned by (e.g.) `sysconfig.get_paths()`. + pub scheme: Scheme, +} + +/// A parsed `pyvenv.cfg` +#[derive(Debug, Clone)] +pub struct PyVenvConfiguration { + /// If the `virtualenv` package was used to create the virtual environment. + pub(crate) virtualenv: bool, + /// If the `uv` package was used to create the virtual environment. + pub(crate) uv: bool, +} + +#[derive(Debug, Error)] +pub enum Error { + #[error("Broken virtualenv `{0}`: `pyvenv.cfg` is missing")] + 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() +} + +/// Locate an active virtual environment by inspecting environment variables. +/// +/// Supports `VIRTUAL_ENV` and `CONDA_PREFIX`. +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: {}", + Path::new(&dir).display() + ); + return Some(PathBuf::from(dir)); + } + + if let Some(dir) = env::var_os("CONDA_PREFIX").filter(|value| !value.is_empty()) { + info!( + "Found a virtualenv through CONDA_PREFIX at: {}", + Path::new(&dir).display() + ); + return Some(PathBuf::from(dir)); + } + + None +} + +/// Locate a virtual environment by searching the file system. +/// +/// Finds a `.venv` directory in the current or any parent directory. +pub(crate) fn virtualenv_from_working_dir() -> Result, Error> { + let current_dir = env::current_dir().expect("Failed to detect current directory"); + for dir in current_dir.ancestors() { + let dot_venv = dir.join(".venv"); + if dot_venv.is_dir() { + if !dot_venv.join("pyvenv.cfg").is_file() { + return Err(Error::MissingPyVenvCfg(dot_venv)); + } + debug!("Found a virtualenv named .venv at: {}", dot_venv.display()); + return Ok(Some(dot_venv)); + } + } + + Ok(None) +} + +/// Returns the path to the `python` executable inside a virtual environment. +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; + } + + // Apparently, Python installed via msys2 on Windows _might_ produce a POSIX-like layout. + // See: https://github.com/PyO3/maturin/issues/1108 + let executable = venv.join("bin").join("python.exe"); + if executable.exists() { + return executable; + } + + // Fallback for Conda environments. + venv.join("python.exe") + } else { + // Search for `python` in the `bin` directory. + venv.join("bin").join("python") + } +} + +impl PyVenvConfiguration { + /// Parse a `pyvenv.cfg` file into a [`PyVenvConfiguration`]. + pub fn parse(cfg: impl AsRef) -> Result { + let mut virtualenv = false; + let mut uv = false; + + // Per https://snarky.ca/how-virtual-environments-work/, the `pyvenv.cfg` file is not a + // valid INI file, and is instead expected to be parsed by partitioning each line on the + // first equals sign. + let content = fs::read_to_string(&cfg) + .map_err(|err| Error::ParsePyVenvCfg(cfg.as_ref().to_path_buf(), err))?; + for line in content.lines() { + let Some((key, _value)) = line.split_once('=') else { + continue; + }; + match key.trim() { + "virtualenv" => { + virtualenv = true; + } + "uv" => { + uv = true; + } + _ => {} + } + } + + Ok(Self { virtualenv, uv }) + } + + /// Returns true if the virtual environment was created with the `virtualenv` package. + pub fn is_virtualenv(&self) -> bool { + self.virtualenv + } + + /// Returns true if the virtual environment was created with the `uv` package. + pub fn is_uv(&self) -> bool { + self.uv + } +} diff --git a/crates/uv-virtualenv/src/bare.rs b/crates/uv-virtualenv/src/bare.rs index 429a62de56a50..1e912c87b4b7e 100644 --- a/crates/uv-virtualenv/src/bare.rs +++ b/crates/uv-virtualenv/src/bare.rs @@ -13,7 +13,7 @@ use tracing::info; use crate::{Error, Prompt}; use uv_fs::{cachedir, Simplified}; -use uv_interpreter::{Interpreter, Virtualenv}; +use uv_interpreter::{Interpreter, VirtualEnvironment}; use uv_version::version; /// The bash activate scripts with the venv dependent paths patches out @@ -48,7 +48,7 @@ pub fn create_bare_venv( prompt: Prompt, system_site_packages: bool, force: bool, -) -> Result { +) -> Result { // Determine the base Python executable; that is, the Python executable that should be // considered the "base" for the virtual environment. This is typically the Python executable // from the [`Interpreter`]; however, if the interpreter is a virtual environment itself, then @@ -258,7 +258,7 @@ pub fn create_bare_venv( fs::write(site_packages.join("_virtualenv.py"), VIRTUALENV_PATCH)?; fs::write(site_packages.join("_virtualenv.pth"), "import _virtualenv")?; - Ok(Virtualenv { + Ok(VirtualEnvironment { scheme: Scheme { purelib: location.join(&interpreter.virtualenv().purelib), platlib: location.join(&interpreter.virtualenv().platlib), diff --git a/crates/uv-virtualenv/src/lib.rs b/crates/uv-virtualenv/src/lib.rs index 1ea705f5eb295..abd9c111d963c 100644 --- a/crates/uv-virtualenv/src/lib.rs +++ b/crates/uv-virtualenv/src/lib.rs @@ -15,6 +15,8 @@ pub enum Error { #[error(transparent)] IO(#[from] io::Error), #[error("Failed to determine python interpreter to use")] + DiscoveryError(#[from] uv_interpreter::DiscoveryError), + #[error("Failed to determine python interpreter to use")] InterpreterError(#[from] uv_interpreter::Error), #[error(transparent)] Platform(#[from] PlatformError),