mirror of
https://github.com/astral-sh/uv.git
synced 2025-10-04 07:44:59 +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,39 +167,19 @@ 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) =
|
||||
let interpreter = if let Some(python_version) = python_version {
|
||||
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));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
unimplemented!("Only Windows and Unix are supported");
|
||||
}
|
||||
try_find_default_python(platform, cache)?
|
||||
};
|
||||
|
||||
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,9 +301,46 @@ 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()
|
||||
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,
|
||||
|
|
|
@ -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,147 +32,314 @@ 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
|
||||
}
|
||||
[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 {
|
||||
unimplemented!("Only Windows and Unix are supported")
|
||||
}
|
||||
} 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
|
||||
try_find_default_python(platform, cache)?.ok_or(if cfg!(windows) {
|
||||
Error::NoPythonInstalledWindows
|
||||
} else if cfg!(unix) {
|
||||
Error::NoPythonInstalledUnix
|
||||
} else {
|
||||
installed_pythons_windows()?
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or(Error::NoPythonInstalledWindows)?
|
||||
.2
|
||||
}
|
||||
} 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.
|
||||
///
|
||||
/// 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.
|
||||
/// 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)
|
||||
}
|
||||
|
||||
// TODO(konstin): Special case the not found error
|
||||
/// 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.
|
||||
///
|
||||
/// (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");
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
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)?;
|
||||
|
@ -202,63 +358,64 @@ fn installed_pythons_windows() -> Result<Vec<(u8, u8, PathBuf)>, 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 {
|
||||
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(&output.stdout).trim().to_string(),
|
||||
stdout: String::from_utf8_lossy(err.as_bytes()).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 (_, [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")
|
||||
})
|
||||
}));
|
||||
for captures in PY_LIST_PATHS.captures_iter(&stdout) {
|
||||
let (_, [major, minor, path]) = captures.extract();
|
||||
|
||||
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(installed_pythons_windows()?
|
||||
.into_iter()
|
||||
.find(|(major_, minor_, _path)| *major_ == major && *minor_ == minor)
|
||||
.map(|(_, _, path)| path))
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// 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;
|
||||
#[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;
|
||||
use crate::{find_requested_python, Error};
|
||||
|
||||
fn format_err<T: Debug>(err: Result<T, Error>) -> String {
|
||||
anyhow::Error::new(err.unwrap_err())
|
||||
|
@ -266,56 +423,6 @@ mod tests {
|
|||
.join("\n Caused by: ")
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn no_such_python_version() {
|
||||
let request = "3.1000";
|
||||
let result = find_requested_python(
|
||||
request,
|
||||
&Platform::current().unwrap(),
|
||||
&Cache::temp().unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
.ok_or(Error::NoSuchPython(request.to_string()));
|
||||
assert_snapshot!(
|
||||
format_err(result),
|
||||
@"No Python 3.1000 In `PATH`. Is Python 3.1000 installed?"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn no_such_python_binary() {
|
||||
let request = "python3.1000";
|
||||
let result = find_requested_python(
|
||||
request,
|
||||
&Platform::current().unwrap(),
|
||||
&Cache::temp().unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
.ok_or(Error::NoSuchPython(request.to_string()));
|
||||
assert_display_snapshot!(
|
||||
format_err(result),
|
||||
@"No Python python3.1000 In `PATH`. Is Python python3.1000 installed?"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn no_such_python_path() {
|
||||
let result = find_requested_python(
|
||||
"/does/not/exists/python3.12",
|
||||
&Platform::current().unwrap(),
|
||||
&Cache::temp().unwrap(),
|
||||
);
|
||||
assert_display_snapshot!(
|
||||
format_err(result), @r###"
|
||||
failed to canonicalize path `/does/not/exists/python3.12`
|
||||
Caused by: No such file or directory (os error 2)
|
||||
"###);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn no_such_python_path() {
|
||||
let result = find_requested_python(
|
||||
|
@ -336,4 +443,72 @@ mod tests {
|
|||
"###);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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: std::fmt::Debug>(err: Result<T, Error>) -> String {
|
||||
anyhow::Error::new(err.unwrap_err())
|
||||
.chain()
|
||||
.join("\n Caused by: ")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_such_python_version() {
|
||||
let request = "3.1000";
|
||||
let result = find_requested_python(
|
||||
request,
|
||||
&Platform::current().unwrap(),
|
||||
&Cache::temp().unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
.ok_or(Error::NoSuchPython(request.to_string()));
|
||||
assert_snapshot!(
|
||||
format_err(result),
|
||||
@"No Python 3.1000 In `PATH`. Is Python 3.1000 installed?"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_such_python_binary() {
|
||||
let request = "python3.1000";
|
||||
let result = find_requested_python(
|
||||
request,
|
||||
&Platform::current().unwrap(),
|
||||
&Cache::temp().unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
.ok_or(Error::NoSuchPython(request.to_string()));
|
||||
assert_display_snapshot!(
|
||||
format_err(result),
|
||||
@"No Python python3.1000 In `PATH`. Is Python python3.1000 installed?"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_such_python_path() {
|
||||
let result = find_requested_python(
|
||||
"/does/not/exists/python3.12",
|
||||
&Platform::current().unwrap(),
|
||||
&Cache::temp().unwrap(),
|
||||
);
|
||||
assert_display_snapshot!(
|
||||
format_err(result), @r###"
|
||||
failed to canonicalize path `/does/not/exists/python3.12`
|
||||
Caused by: No such file or directory (os error 2)
|
||||
"###);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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<PathBuf> {
|
||||
) -> anyhow::Result<OsString> {
|
||||
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.
|
||||
|
|
|
@ -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()?;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue