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

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,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)
"###);
}
}

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,

View file

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

View file

@ -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()?;