diff --git a/crates/uv-interpreter/src/interpreter.rs b/crates/uv-interpreter/src/interpreter.rs index 74ca950f9..e1d2bc683 100644 --- a/crates/uv-interpreter/src/interpreter.rs +++ b/crates/uv-interpreter/src/interpreter.rs @@ -1,4 +1,5 @@ use std::ffi::{OsStr, OsString}; +use std::io::Write; use std::path::{Path, PathBuf}; use std::process::Command; @@ -16,6 +17,7 @@ use uv_cache::{Cache, CacheBucket, CachedByTimestamp, Freshness, Timestamp}; use uv_fs::write_atomic_sync; use crate::python_platform::PythonPlatform; +use crate::python_query::try_find_default_python; use crate::virtual_env::detect_virtual_env; use crate::{find_requested_python, Error, PythonVersion}; @@ -35,12 +37,7 @@ impl Interpreter { /// Detect the interpreter info for the given Python executable. pub fn query(executable: &Path, platform: &Platform, cache: &Cache) -> Result { let info = InterpreterQueryResult::query_cached(executable, cache)?; - debug_assert!( - info.base_prefix == info.base_exec_prefix, - "Not a virtualenv (Python: {}, prefix: {})", - executable.display(), - info.base_prefix.display() - ); + debug_assert!( info.sys_executable.is_absolute(), "`sys.executable` is not an absolute Python; Python installation is broken: {}", @@ -170,38 +167,18 @@ impl Interpreter { // Look for the requested version with by search for `python{major}.{minor}` in `PATH` on // Unix and `py --list-paths` on Windows. - if let Some(python_version) = python_version { - if let Some(interpreter) = - find_requested_python(&python_version.string, platform, cache)? - { - if version_matches(&interpreter) { - return Ok(Some(interpreter)); - } - } - } - - // Python discovery failed to find the requested version, maybe the default Python in PATH - // matches? - if cfg!(unix) { - if let Some(executable) = Interpreter::find_executable("python3")? { - debug!("Resolved python3 to {}", executable.display()); - let interpreter = Interpreter::query(&executable, &python_platform.0, cache)?; - if version_matches(&interpreter) { - return Ok(Some(interpreter)); - } - } - } else if cfg!(windows) { - if let Some(executable) = Interpreter::find_executable("python.exe")? { - let interpreter = Interpreter::query(&executable, &python_platform.0, cache)?; - if version_matches(&interpreter) { - return Ok(Some(interpreter)); - } - } + let interpreter = if let Some(python_version) = python_version { + find_requested_python(&python_version.string, platform, cache)? } else { - unimplemented!("Only Windows and Unix are supported"); - } + try_find_default_python(platform, cache)? + }; - Ok(None) + if let Some(interpreter) = interpreter { + debug_assert!(version_matches(&interpreter)); + Ok(Some(interpreter)) + } else { + Ok(None) + } } /// Find the Python interpreter in `PATH`, respecting `UV_PYTHON_PATH`. @@ -324,13 +301,50 @@ pub(crate) struct InterpreterQueryResult { impl InterpreterQueryResult { /// Return the resolved [`InterpreterQueryResult`] for the given Python executable. pub(crate) fn query(interpreter: &Path) -> Result { - let output = Command::new(interpreter) - .args(["-c", include_str!("get_interpreter_info.py")]) - .output() - .map_err(|err| Error::PythonSubcommandLaunch { - interpreter: interpreter.to_path_buf(), - err, - })?; + let script = include_str!("get_interpreter_info.py"); + let output = if cfg!(windows) + && interpreter + .extension() + .is_some_and(|extension| extension == "bat") + { + // Multiline arguments aren't well-supported in batch files and `pyenv-win`, for example, trips over it. + // We work around this batch limitation by passing the script via stdin instead. + // This is somewhat more expensive because we have to spawn a new thread to write the + // stdin to avoid deadlocks in case the child process waits for the parent to read stdout. + // The performance overhead is the reason why we only applies this to batch files. + // https://github.com/pyenv-win/pyenv-win/issues/589 + let mut child = Command::new(interpreter) + .arg("-") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .spawn() + .map_err(|err| Error::PythonSubcommandLaunch { + interpreter: interpreter.to_path_buf(), + err, + })?; + + let mut stdin = child.stdin.take().unwrap(); + + // From the Rust documentation: + // If the child process fills its stdout buffer, it may end up + // waiting until the parent reads the stdout, and not be able to + // read stdin in the meantime, causing a deadlock. + // Writing from another thread ensures that stdout is being read + // at the same time, avoiding the problem. + std::thread::spawn(move || { + stdin + .write_all(script.as_bytes()) + .expect("failed to write to stdin"); + }); + + child.wait_with_output() + } else { + Command::new(interpreter).arg("-c").arg(script).output() + } + .map_err(|err| Error::PythonSubcommandLaunch { + interpreter: interpreter.to_path_buf(), + err, + })?; // stderr isn't technically a criterion for success, but i don't know of any cases where there // should be stderr output and if there is, we want to know diff --git a/crates/uv-interpreter/src/lib.rs b/crates/uv-interpreter/src/lib.rs index 684c7f164..9a2d7f29e 100644 --- a/crates/uv-interpreter/src/lib.rs +++ b/crates/uv-interpreter/src/lib.rs @@ -2,11 +2,8 @@ use std::ffi::OsString; use std::io; use std::path::PathBuf; -use pep440_rs::Version; use thiserror::Error; -use uv_fs::Normalized; - pub use crate::cfg::Configuration; pub use crate::interpreter::Interpreter; pub use crate::python_query::{find_default_python, find_requested_python}; @@ -43,14 +40,18 @@ pub enum 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`. Is Python {0} installed?")] + #[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` in PATH and `py --list-paths` did not list any Python versions. Is Python installed?")] + #[error( + "Could not find `python.exe` through `py --list-paths` or in 'PATH'. Is Python installed?" + )] NoPythonInstalledWindows, #[error("{message}:\n--- stdout:\n{stdout}\n--- stderr:\n{stderr}\n---")] PythonSubcommandOutput { @@ -64,6 +65,4 @@ pub enum Error { Cfg(#[from] cfg::Error), #[error("Error finding `{}` in PATH", _0.to_string_lossy())] WhichError(OsString, #[source] which::Error), - #[error("Interpreter at `{}` has the wrong patch version. Expected: {}, actual: {}", _0.normalized_display(), _1, _2)] - PatchVersionMismatch(PathBuf, String, Version), } diff --git a/crates/uv-interpreter/src/python_query.rs b/crates/uv-interpreter/src/python_query.rs index 85f11fd8b..2a6f34bea 100644 --- a/crates/uv-interpreter/src/python_query.rs +++ b/crates/uv-interpreter/src/python_query.rs @@ -1,26 +1,16 @@ //! Find a user requested python version/interpreter. +use std::borrow::Cow; use std::env; use std::path::PathBuf; -use std::process::Command; -use once_cell::sync::Lazy; +use tracing::instrument; + use platform_host::Platform; -use regex::Regex; -use tracing::{info_span, instrument}; use uv_cache::Cache; use crate::{Error, Interpreter}; -/// ```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() -}); - /// Find a python version/interpreter of a specific version. /// /// Supported formats: @@ -33,7 +23,6 @@ static PY_LIST_PATHS: Lazy = Lazy::new(|| { /// 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] pub fn find_requested_python( request: &str, platform: &Platform, @@ -43,231 +32,441 @@ pub fn find_requested_python( .splitn(3, '.') .map(str::parse::) .collect::, _>>(); - Ok(Some(if let Ok(versions) = versions { + if let Ok(versions) = versions { // `-p 3.10` or `-p 3.10.1` - if cfg!(unix) { - if let [_major, _minor, requested_patch] = versions.as_slice() { - let formatted = PathBuf::from(format!("python{}.{}", versions[0], versions[1])); - let Some(executable) = Interpreter::find_executable(&formatted)? else { - return Ok(None); - }; - let interpreter = Interpreter::query(&executable, platform, cache)?; - if interpreter.python_patch() != *requested_patch { - return Err(Error::PatchVersionMismatch( - executable, - request.to_string(), - interpreter.python_version().clone(), - )); - } - interpreter - } else { - let formatted = PathBuf::from(format!("python{request}")); - let Some(executable) = Interpreter::find_executable(&formatted)? else { - return Ok(None); - }; - Interpreter::query(&executable, platform, cache)? - } - } else if cfg!(windows) { - if let Some(python_overwrite) = env::var_os("UV_TEST_PYTHON_PATH") { - let executable_dir = env::split_paths(&python_overwrite).find(|path| { - path.as_os_str() - .to_str() - // Good enough since we control the bootstrap directory - .is_some_and(|path| path.contains(&format!("@{request}"))) - }); - return if let Some(path) = executable_dir { - let executable = path.join(if cfg!(unix) { - "python3" - } else if cfg!(windows) { - "python.exe" - } else { - unimplemented!("Only Windows and Unix are supported") - }); - Ok(Some(Interpreter::query(&executable, platform, cache)?)) - } else { - Ok(None) - }; - } - - match versions.as_slice() { - [major] => { - let Some(executable) = installed_pythons_windows()? - .into_iter() - .find(|(major_, _minor, _path)| major_ == major) - .map(|(_, _, path)| path) - else { - return Ok(None); - }; - Interpreter::query(&executable, platform, cache)? - } - [major, minor] => { - let Some(executable) = find_python_windows(*major, *minor)? else { - return Ok(None); - }; - Interpreter::query(&executable, platform, cache)? - } - [major, minor, requested_patch] => { - let Some(executable) = find_python_windows(*major, *minor)? else { - return Ok(None); - }; - let interpreter = Interpreter::query(&executable, platform, cache)?; - if interpreter.python_patch() != *requested_patch { - return Err(Error::PatchVersionMismatch( - executable, - request.to_string(), - interpreter.python_version().clone(), - )); - } - interpreter - } - _ => unreachable!(), - } - } else { - unimplemented!("Only Windows and Unix are supported") + match versions.as_slice() { + [requested_major] => find_python( + PythonVersionSelector::Major(*requested_major), + platform, + cache, + ), + [major, minor] => find_python( + PythonVersionSelector::MajorMinor(*major, *minor), + platform, + cache, + ), + [major, minor, requested_patch] => find_python( + PythonVersionSelector::MajorMinorPatch(*major, *minor, *requested_patch), + platform, + cache, + ), + // SAFETY: Guaranteed by the Ok(versions) guard + _ => unreachable!(), } } else if !request.contains(std::path::MAIN_SEPARATOR) { // `-p python3.10`; Generally not used on windows because all Python are `python.exe`. let Some(executable) = Interpreter::find_executable(request)? else { return Ok(None); }; - Interpreter::query(&executable, platform, cache)? + Interpreter::query(&executable, platform, cache).map(Some) } else { // `-p /home/ferris/.local/bin/python3.10` let executable = fs_err::canonicalize(request)?; - Interpreter::query(&executable, platform, cache)? - })) + Interpreter::query(&executable, platform, cache).map(Some) + } } /// 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] pub fn find_default_python(platform: &Platform, cache: &Cache) -> Result { - let current_dir = env::current_dir()?; - let python = if cfg!(unix) { - which::which_in("python3", env::var_os("UV_TEST_PYTHON_PATH"), current_dir) - .or_else(|_| which::which("python3")) - .or_else(|_| which::which("python")) - .map_err(|_| Error::NoPythonInstalledUnix)? - } else if cfg!(windows) { - // TODO(konstin): Is that the right order, or should we look for `py --list-paths` first? With the current way - // it works even if the python launcher is not installed. - if let Ok(python) = which::which_in( - "python.exe", - env::var_os("UV_TEST_PYTHON_PATH"), - current_dir, - ) - .or_else(|_| which::which("python.exe")) - { - python - } else { - installed_pythons_windows()? - .into_iter() - .next() - .ok_or(Error::NoPythonInstalledWindows)? - .2 - } + try_find_default_python(platform, cache)?.ok_or(if cfg!(windows) { + Error::NoPythonInstalledWindows + } else if cfg!(unix) { + Error::NoPythonInstalledUnix } else { - unimplemented!("Only Windows and Unix are supported") - }; - let base_python = fs_err::canonicalize(python)?; - let interpreter = Interpreter::query(&base_python, platform, cache)?; - return Ok(interpreter); + unreachable!("Only Unix and Windows are supported") + }) } -/// Run `py --list-paths` to find the installed pythons. +/// 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( + platform: &Platform, + cache: &Cache, +) -> Result, Error> { + find_python(PythonVersionSelector::Default, platform, cache) +} + +/// Finds a python version matching `selector`. +/// It searches for an existing installation in the following order: +/// * (windows): Discover installations using `py --list-paths` (PEP514). Continue if `py` is not installed. +/// * 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. /// -/// The command takes 8ms on my machine. TODO(konstin): Implement to read python -/// installations from the registry instead. -fn installed_pythons_windows() -> Result, Error> { - // TODO(konstin): We're not checking UV_TEST_PYTHON_PATH here, no test currently depends on it. +/// (Windows): Filter out the windows store shim (Enabled in Settings/Apps/Advanced app settings/App execution aliases). +#[instrument(skip_all, fields(? selector))] +fn find_python( + selector: PythonVersionSelector, + platform: &Platform, + cache: &Cache, +) -> Result, Error> { + #[allow(non_snake_case)] + let UV_TEST_PYTHON_PATH = env::var_os("UV_TEST_PYTHON_PATH"); - // TODO(konstin): Special case the not found error - let output = info_span!("py_list_paths") - .in_scope(|| Command::new("py").arg("--list-paths").output()) - .map_err(Error::PyList)?; - - // There shouldn't be any output on stderr. - if !output.status.success() || !output.stderr.is_empty() { - return Err(Error::PythonSubcommandOutput { - message: format!( - "Running `py --list-paths` failed with status {}", - output.status - ), - stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(), - stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), - }); + if cfg!(windows) && UV_TEST_PYTHON_PATH.is_none() { + // Use `py` to find the python installation on the system. + match windows::py_list_paths(selector, platform, cache) { + Ok(Some(interpreter)) => return Ok(Some(interpreter)), + Ok(None) => { + // No matching Python version found, continue searching PATH + } + Err(Error::PyList(error)) => { + if error.kind() == std::io::ErrorKind::NotFound { + tracing::debug!( + "`py` is not installed. Falling back to searching Python on the path" + ); + // Continue searching for python installations on the path. + } + } + Err(error) => return Err(error), + } } - // Find the first python of the version we want in the list - let stdout = - String::from_utf8(output.stdout.clone()).map_err(|err| Error::PythonSubcommandOutput { - message: format!("The stdout of `py --list-paths` isn't UTF-8 encoded: {err}"), - stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(), - stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), - })?; - let pythons = PY_LIST_PATHS - .captures_iter(&stdout) - .filter_map(|captures| { + 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 { + if cfg!(windows) && windows::is_windows_store_shim(&path) { + continue; + } + + let installation = PythonInstallation::Interpreter(Interpreter::query( + &path, platform, cache, + )?); + + if let Some(interpreter) = installation.select(selector, platform, 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, platform, 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, platform, cache)? + { + return Ok(Some(interpreter)); + } + } + } + } + } + + Ok(None) +} + +#[derive(Debug, Clone)] +enum PythonInstallation { + PyListPath { + major: u8, + minor: u8, + executable_path: PathBuf, + }, + Interpreter(Interpreter), +} + +impl PythonInstallation { + fn major(&self) -> u8 { + match self { + PythonInstallation::PyListPath { major, .. } => *major, + PythonInstallation::Interpreter(interpreter) => interpreter.python_major(), + } + } + + fn minor(&self) -> u8 { + match self { + PythonInstallation::PyListPath { minor, .. } => *minor, + PythonInstallation::Interpreter(interpreter) => interpreter.python_minor(), + } + } + + /// Selects the interpreter if it matches the selector (version specification). + fn select( + self, + selector: PythonVersionSelector, + platform: &Platform, + 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(platform, 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(platform, cache).map(Some) + } else { + Ok(None) + } + } + + pub(super) fn into_interpreter( + self, + platform: &Platform, + cache: &Cache, + ) -> Result { + match self { + PythonInstallation::PyListPath { + executable_path, .. + } => Interpreter::query(&executable_path, platform, cache), + PythonInstallation::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 { + PythonVersionSelector::Default => [Some(python3), Some(python), None, None], + PythonVersionSelector::Major(major) => [ + Some(Cow::Owned(format!("python{major}{extension}"))), + Some(python), + None, + None, + ], + PythonVersionSelector::MajorMinor(major, minor) => [ + Some(Cow::Owned(format!("python{major}.{minor}{extension}"))), + Some(Cow::Owned(format!("python{major}{extension}"))), + Some(python), + None, + ], + PythonVersionSelector::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), + ], + } + } +} + +mod windows { + use std::path::PathBuf; + use std::process::Command; + + use once_cell::sync::Lazy; + use regex::Regex; + use tracing::info_span; + + use platform_host::Platform; + use uv_cache::Cache; + + use crate::python_query::{PythonInstallation, PythonVersionSelector}; + use crate::{Error, Interpreter}; + + /// ```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( + selector: PythonVersionSelector, + platform: &Platform, + cache: &Cache, + ) -> Result, Error> { + let output = info_span!("py_list_paths") + .in_scope(|| Command::new("py").arg("--list-paths").output()) + .map_err(Error::PyList)?; + + // There shouldn't be any output on stderr. + if !output.status.success() || !output.stderr.is_empty() { + return Err(Error::PythonSubcommandOutput { + message: format!( + "Running `py --list-paths` failed with status {}", + 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}"), + stdout: String::from_utf8_lossy(err.as_bytes()).trim().to_string(), + stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), + })?; + + for captures in PY_LIST_PATHS.captures_iter(&stdout) { let (_, [major, minor, path]) = captures.extract(); - Some(( - major.parse::().ok()?, - minor.parse::().ok()?, - PathBuf::from(path), - )) - }) - .collect(); - Ok(pythons) -} -pub(crate) fn find_python_windows(major: u8, minor: u8) -> Result, Error> { - if let Some(python_overwrite) = env::var_os("UV_TEST_PYTHON_PATH") { - let executable_dir = env::split_paths(&python_overwrite).find(|path| { - path.as_os_str() - .to_str() - // Good enough since we control the bootstrap directory - .is_some_and(|path| path.contains(&format!("@{major}.{minor}"))) - }); - return Ok(executable_dir.map(|path| { - path.join(if cfg!(unix) { - "python3" - } else if cfg!(windows) { - "python.exe" - } else { - unimplemented!("Only Windows and Unix are supported") - }) - })); + if let (Some(major), Some(minor)) = (major.parse::().ok(), minor.parse::().ok()) + { + let installation = PythonInstallation::PyListPath { + major, + minor, + executable_path: PathBuf::from(path), + }; + + if let Some(interpreter) = installation.select(selector, platform, cache)? { + return Ok(Some(interpreter)); + } + } + } + + Ok(None) } - Ok(installed_pythons_windows()? - .into_iter() - .find(|(major_, minor_, _path)| *major_ == major && *minor_ == minor) - .map(|(_, _, path)| path)) + /// On Windows we might encounter the windows store proxy shim (Enabled in Settings/Apps/Advanced app settings/App execution aliases). + /// This requires quite a bit of custom logic to figure out what this thing does. + /// + /// 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. + pub(super) fn is_windows_store_shim(path: &std::path::Path) -> bool { + // Rye uses a more sophisticated test to identify the windows store shim. + // Unfortunately, it only works with the `python.exe` shim but not `python3.exe`. + // What we do here is a very naive implementation but probably sufficient for all we need. + // There's the risk of false positives but I consider it rare, considering how specific + // the path is. + // Rye Shim detection: https://github.com/mitsuhiko/rye/blob/78bf4d010d5e2e88ebce1ba636c7acec97fd454d/rye/src/cli/shim.rs#L100-L172 + path.to_str().map_or(false, |path| { + path.ends_with("Local\\Microsoft\\WindowsApps\\python.exe") + || path.ends_with("Local\\Microsoft\\WindowsApps\\python3.exe") + }) + } + + #[cfg(test)] + #[cfg(windows)] + mod tests { + use std::fmt::Debug; + + use insta::assert_display_snapshot; + use itertools::Itertools; + + use platform_host::Platform; + 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] + fn no_such_python_path() { + let result = find_requested_python( + r"C:\does\not\exists\python3.12", + &Platform::current().unwrap(), + &Cache::temp().unwrap(), + ); + insta::with_settings!({ + filters => vec![ + // The exact message is host language dependent + (r"Caused by: .* \(os error 3\)", "Caused by: The system cannot find the path specified. (os error 3)") + ] + }, { + assert_display_snapshot!( + format_err(result), @r###" + failed to canonicalize path `C:\does\not\exists\python3.12` + Caused by: The system cannot find the path specified. (os error 3) + "###); + }); + } + } } +#[cfg(unix)] #[cfg(test)] mod tests { - use std::fmt::Debug; - use insta::assert_display_snapshot; #[cfg(unix)] use insta::assert_snapshot; use itertools::Itertools; + use platform_host::Platform; use uv_cache::Cache; use crate::python_query::find_requested_python; use crate::Error; - fn format_err(err: Result) -> String { + fn format_err(err: Result) -> String { anyhow::Error::new(err.unwrap_err()) .chain() .join("\n Caused by: ") } #[test] - #[cfg(unix)] fn no_such_python_version() { let request = "3.1000"; let result = find_requested_python( @@ -284,7 +483,6 @@ mod tests { } #[test] - #[cfg(unix)] fn no_such_python_binary() { let request = "python3.1000"; let result = find_requested_python( @@ -300,7 +498,6 @@ mod tests { ); } - #[cfg(unix)] #[test] fn no_such_python_path() { let result = find_requested_python( @@ -314,26 +511,4 @@ mod tests { Caused by: No such file or directory (os error 2) "###); } - - #[cfg(windows)] - #[test] - fn no_such_python_path() { - let result = find_requested_python( - r"C:\does\not\exists\python3.12", - &Platform::current().unwrap(), - &Cache::temp().unwrap(), - ); - insta::with_settings!({ - filters => vec![ - // The exact message is host language dependent - (r"Caused by: .* \(os error 3\)", "Caused by: The system cannot find the path specified. (os error 3)") - ] - }, { - assert_display_snapshot!( - format_err(result), @r###" - failed to canonicalize path `C:\does\not\exists\python3.12` - Caused by: The system cannot find the path specified. (os error 3) - "###); - }); - } } diff --git a/crates/uv-interpreter/src/virtual_env.rs b/crates/uv-interpreter/src/virtual_env.rs index 46c727c3c..2b2039463 100644 --- a/crates/uv-interpreter/src/virtual_env.rs +++ b/crates/uv-interpreter/src/virtual_env.rs @@ -30,6 +30,13 @@ impl Virtualenv { let executable = platform.venv_python(&venv); let interpreter = Interpreter::query(&executable, &platform.0, cache)?; + debug_assert!( + interpreter.base_prefix == interpreter.base_exec_prefix, + "Not a virtualenv (Python: {}, prefix: {})", + executable.display(), + interpreter.base_prefix.display() + ); + Ok(Self { root: venv, interpreter, diff --git a/crates/uv/tests/common/mod.rs b/crates/uv/tests/common/mod.rs index b80532b42..10904e7fd 100644 --- a/crates/uv/tests/common/mod.rs +++ b/crates/uv/tests/common/mod.rs @@ -12,6 +12,7 @@ use fs_err::os::windows::fs::symlink_file; use regex::{self, Regex}; use std::borrow::BorrowMut; use std::env; +use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::process::Output; use uv_fs::Normalized; @@ -271,7 +272,7 @@ pub fn get_bin() -> PathBuf { pub fn create_bin_with_executables( temp_dir: &assert_fs::TempDir, python_versions: &[&str], -) -> anyhow::Result { +) -> anyhow::Result { if let Some(bootstrapped_pythons) = bootstrapped_pythons() { let selected_pythons = bootstrapped_pythons.into_iter().filter(|path| { python_versions.iter().any(|python_version| { @@ -281,7 +282,7 @@ pub fn create_bin_with_executables( .contains(&format!("@{python_version}")) }) }); - return Ok(env::join_paths(selected_pythons)?.into()); + return Ok(env::join_paths(selected_pythons)?); } let bin = temp_dir.child("bin"); @@ -299,7 +300,7 @@ pub fn create_bin_with_executables( .expect("Discovered executable must have a filename"); symlink_file(interpreter.sys_executable(), bin.child(name))?; } - Ok(bin.canonicalize()?) + Ok(bin.canonicalize()?.into()) } /// Execute the command and format its output status, stdout and stderr into a snapshot string. diff --git a/crates/uv/tests/venv.rs b/crates/uv/tests/venv.rs index f2d9585f9..81277e7df 100644 --- a/crates/uv/tests/venv.rs +++ b/crates/uv/tests/venv.rs @@ -271,7 +271,7 @@ fn create_venv_unknown_python_minor() -> Result<()> { ----- stdout ----- ----- stderr ----- - × No Python 3.15 found through `py --list-paths`. Is Python 3.15 installed? + × No Python 3.15 found through `py --list-paths` or in `PATH`. Is Python 3.15 installed? "### ); } else { @@ -292,7 +292,6 @@ fn create_venv_unknown_python_minor() -> Result<()> { } #[test] -#[cfg(unix)] // TODO(konstin): Support patch versions on Windows fn create_venv_unknown_python_patch() -> Result<()> { let temp_dir = assert_fs::TempDir::new()?; let cache_dir = assert_fs::TempDir::new()?; @@ -305,6 +304,10 @@ fn create_venv_unknown_python_patch() -> Result<()> { 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?", + ), (&filter_venv, "/home/ferris/project/.venv"), ]; uv_snapshot!(filters, Command::new(get_bin()) @@ -334,8 +337,6 @@ fn create_venv_unknown_python_patch() -> Result<()> { } #[test] -#[ignore] // TODO(konstin): Switch patch version strategy -#[cfg(unix)] // TODO(konstin): Support patch versions on Windows fn create_venv_python_patch() -> Result<()> { let temp_dir = assert_fs::TempDir::new()?; let cache_dir = assert_fs::TempDir::new()?; @@ -372,6 +373,7 @@ fn create_venv_python_patch() -> Result<()> { ----- stderr ----- Using Python 3.12.1 interpreter at [PATH] Creating virtualenv at: /home/ferris/project/.venv + Activate with: source /home/ferris/project/.venv/bin/activate "### ); @@ -526,6 +528,73 @@ fn non_empty_dir_exists() -> Result<()> { Ok(()) } +#[test] +#[cfg(windows)] +fn windows_shims() -> Result<()> { + let temp_dir = assert_fs::TempDir::new()?; + let cache_dir = assert_fs::TempDir::new()?; + let bin = + create_bin_with_executables(&temp_dir, &["3.8", "3.9"]).expect("Failed to create bin dir"); + let venv = temp_dir.child(".venv"); + let shim_path = temp_dir.child("shim"); + + let py38 = std::env::split_paths(&bin) + .last() + .expect("create_bin_with_executables to set up the python versions"); + // We want 3.8 and the first version should be 3.9. + // Picking the last is necessary to prove that shims work because the python version selects + // the python version from the first path segment by default, so we take the last to prove it's not + // returning that version. + assert!(py38.to_str().unwrap().contains("3.8")); + + // Write the shim script that forwards the arguments to the python3.8 installation. + std::fs::create_dir(&shim_path)?; + std::fs::write( + shim_path.child("python.bat"), + format!("@echo off\r\n{}/python.exe %*", py38.display()), + )?; + + // Create a virtual environment at `.venv`, passing the redundant `--clear` flag. + let filter_venv = regex::escape(&venv.normalized_display().to_string()); + let filter_prompt = r"Activate with: (?:.*)\\Scripts\\activate"; + let filters = &[ + ( + r"Using Python 3\.8.\d+ interpreter at .+", + "Using Python 3.8.x interpreter at [PATH]", + ), + (&filter_venv, "/home/ferris/project/.venv"), + ( + &filter_prompt, + "Activate with: source /home/ferris/project/.venv/bin/activate", + ), + ]; + uv_snapshot!(filters, Command::new(get_bin()) + .arg("venv") + .arg(venv.as_os_str()) + .arg("--clear") + .arg("--cache-dir") + .arg(cache_dir.path()) + .arg("--exclude-newer") + .arg(EXCLUDE_NEWER) + .env("UV_TEST_PYTHON_PATH", format!("{};{}", shim_path.display(), bin.normalized_display())) + .current_dir(&temp_dir), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: virtualenv's `--clear` has no effect (uv always clears the virtual environment). + Using Python 3.8.x interpreter at [PATH] + Creating virtualenv at: /home/ferris/project/.venv + Activate with: source /home/ferris/project/.venv/bin/activate + "### + ); + + venv.assert(predicates::path::is_dir()); + + Ok(()) +} + #[test] fn virtualenv_compatibility() -> Result<()> { let temp_dir = assert_fs::TempDir::new()?;