Search PATH when python can't be found with py (#1711)

This commit is contained in:
Micha Reiser 2024-02-22 08:47:33 +01:00 committed by GitHub
parent 12462e5730
commit 9f3ccf7fe1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 537 additions and 272 deletions

View file

@ -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<Self, Error> {
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<Self, Error> {
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

View file

@ -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),
}

View file

@ -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<Regex> = 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<Regex> = 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::<u8>)
.collect::<Result<Vec<_>, _>>();
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<Interpreter, Error> {
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<Option<Interpreter>, 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 <https://peps.python.org/pep-0514/> to read python
/// installations from the registry instead.
fn installed_pythons_windows() -> Result<Vec<(u8, u8, PathBuf)>, 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<Option<Interpreter>, 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<Option<Interpreter>, 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<Interpreter, Error> {
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<Cow<'static, str>>; 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<Regex> = 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 <https://peps.python.org/pep-0514/> to read python installations from the registry instead.
pub(super) fn py_list_paths(
selector: PythonVersionSelector,
platform: &Platform,
cache: &Cache,
) -> Result<Option<Interpreter>, 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::<u8>().ok()?,
minor.parse::<u8>().ok()?,
PathBuf::from(path),
))
})
.collect();
Ok(pythons)
}
pub(crate) fn find_python_windows(major: u8, minor: u8) -> Result<Option<PathBuf>, 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::<u8>().ok(), minor.parse::<u8>().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<T: Debug>(err: Result<T, Error>) -> 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<T: Debug>(err: Result<T, Error>) -> String {
fn format_err<T: std::fmt::Debug>(err: Result<T, Error>) -> 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)
"###);
});
}
}

View file

@ -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,