mirror of
https://github.com/astral-sh/uv.git
synced 2025-08-03 18:38:21 +00:00
Search PATH
when python
can't be found with py
(#1711)
This commit is contained in:
parent
12462e5730
commit
9f3ccf7fe1
6 changed files with 537 additions and 272 deletions
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
"###);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue