Rewrite Python interpreter discovery (#3266)

Updates our Python interpreter discovery to conform to the rules
described in #2386, please see that issue for a full description of the
behavior. Briefly, we now will search for interpreters that satisfy a
requested version without stopping at the first Python executable.
Additionally, if retrieving information about an interpreter fails we
will continue to search for a working interpreter. We also add the
plumbing necessary to request Python implementations other than CPython,
though we do not add support for other implementations at this time.

A major internal goal of this work is to prepare for user-facing managed
toolchains i.e. fetching a requested version during `uv run`. These APIs
are not introduced, but there is some managed toolchain handling as
required for our test suite.

Some noteworthy implementation changes:

- The `uv_interpreter::find_python` module has been removed in favor of
a `uv_interpreter::discovery` module.
- There are new types to help structure interpreter requests and track
sources
- Executable discovery is implemented as a big lazy iterator and is a
central authority for source precedence
- `uv_interpreter::Error` variants were split into scoped types in each
module
- There's much more unit test coverage, but not for Windows yet

Remaining work:

- [x] Write new test cases
- [x] Determine correct behavior around executables in the current
directory
- _Future_: Combine `PythonVersion` and `VersionRequest`
- _Future_: Consider splitting `ManagedToolchain` into local and remote
variants
- _Future_: Add Windows unit test coverage
- _Future_: Explore behavior around implementation precedence (i.e.
CPython over PyPy)

Refactors split into:

- #3329 
- #3330 
- #3331
- #3332

Closes #2386
This commit is contained in:
Zanie Blue 2024-05-21 15:37:23 -04:00 committed by GitHub
parent c14a7dbef3
commit d540d0f28b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 3100 additions and 1165 deletions

View File

@ -144,6 +144,7 @@ jobs:
- name: "Cargo test"
run: |
export UV_BOOTSTRAP_DIR="$(pwd)/bin"
cargo nextest run \
--workspace \
--status-level skip --failure-output immediate-final --no-fail-fast -j 12 --final-status-level slow

13
Cargo.lock generated
View File

@ -3821,6 +3821,15 @@ version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f227968ec00f0e5322f9b8173c7a0cbcff6181a0a5b28e9892491c286277231"
[[package]]
name = "temp-env"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96374855068f47402c3121c6eed88d29cb1de8f3ab27090e273e420bdabcf050"
dependencies = [
"parking_lot 0.12.2",
]
[[package]]
name = "tempfile"
version = "3.10.1"
@ -4889,12 +4898,12 @@ name = "uv-interpreter"
version = "0.0.1"
dependencies = [
"anyhow",
"assert_fs",
"cache-key",
"configparser",
"fs-err",
"futures",
"indoc",
"insta",
"install-wheel-rs",
"itertools 0.13.0",
"once_cell",
@ -4910,7 +4919,9 @@ dependencies = [
"schemars",
"serde",
"serde_json",
"temp-env",
"tempfile",
"test-log",
"thiserror",
"tokio-util",
"tracing",

View File

@ -25,11 +25,13 @@ pub(crate) struct FetchPythonArgs {
pub(crate) async fn fetch_python(args: FetchPythonArgs) -> Result<()> {
let start = Instant::now();
let bootstrap_dir = TOOLCHAIN_DIRECTORY
.as_ref()
.expect("The toolchain directory must exist for bootstrapping");
let bootstrap_dir = TOOLCHAIN_DIRECTORY.clone().unwrap_or_else(|| {
std::env::current_dir()
.expect("Use `UV_BOOTSTRAP_DIR` if the current directory is not usable.")
.join("bin")
});
fs_err::create_dir_all(bootstrap_dir)?;
fs_err::create_dir_all(&bootstrap_dir)?;
let versions = if args.versions.is_empty() {
info!("Reading versions from file...");
@ -59,7 +61,7 @@ pub(crate) async fn fetch_python(args: FetchPythonArgs) -> Result<()> {
let mut tasks = futures::stream::iter(downloads.iter())
.map(|download| {
async {
let result = download.fetch(&client, bootstrap_dir).await;
let result = download.fetch(&client, &bootstrap_dir).await;
(download.python_version(), result)
}
.instrument(info_span!("download", key = %download))
@ -130,6 +132,10 @@ pub(crate) async fn fetch_python(args: FetchPythonArgs) -> Result<()> {
};
info!("Installed {} versions", requests.len());
info!(
r#"To enable discovery: export UV_BOOTSTRAP_DIR="{}""#,
bootstrap_dir.display()
);
Ok(())
}

View File

@ -51,7 +51,9 @@ winapi = { workspace = true }
[dev-dependencies]
anyhow = { version = "1.0.80" }
assert_fs = { version = "1.1.1" }
indoc = { version = "2.0.4" }
insta = { version = "1.36.1", features = ["filters"] }
itertools = { version = "0.13.0" }
temp-env = { version = "0.3.6" }
tempfile = { version = "3.9.0" }
test-log = { version = "0.2.15", features = ["trace"], default-features = false }

File diff suppressed because it is too large Load Diff

View File

@ -8,8 +8,9 @@ use same_file::is_same_file;
use uv_cache::Cache;
use uv_fs::{LockedFile, Simplified};
use crate::virtualenv::{detect_virtualenv, virtualenv_python_executable, PyVenvConfiguration};
use crate::{find_default_python, find_requested_python, Error, Interpreter, Target};
use crate::discovery::{InterpreterRequest, SourceSelector, SystemPython, VersionRequest};
use crate::virtualenv::{virtualenv_python_executable, PyVenvConfiguration};
use crate::{find_default_interpreter, find_interpreter, Error, Interpreter, Target};
/// A Python environment, consisting of a Python [`Interpreter`] and its associated paths.
#[derive(Debug, Clone)]
@ -22,13 +23,39 @@ struct PythonEnvironmentShared {
}
impl PythonEnvironment {
/// Create a [`PythonEnvironment`] for an existing virtual environment, detected from the
/// environment variables and filesystem.
/// Create a [`PythonEnvironment`] from a user request.
pub fn find(python: Option<&str>, system: SystemPython, cache: &Cache) -> Result<Self, Error> {
// Detect the current Python interpreter.
if let Some(python) = python {
Self::from_requested_python(python, system, cache)
} else if system.is_preferred() {
Self::from_default_python(cache)
} else {
match Self::from_virtualenv(cache) {
Ok(venv) => Ok(venv),
Err(Error::NotFound(_)) if system.is_allowed() => Self::from_default_python(cache),
Err(err) => Err(err),
}
}
}
/// Create a [`PythonEnvironment`] for an existing virtual environment.
pub fn from_virtualenv(cache: &Cache) -> Result<Self, Error> {
let Some(venv) = detect_virtualenv()? else {
return Err(Error::VenvNotFound);
};
Self::from_root(&venv, cache)
let sources = SourceSelector::virtualenvs();
let request = InterpreterRequest::Version(VersionRequest::Default);
let found = find_interpreter(&request, &sources, cache)??;
debug_assert!(
found.interpreter().base_prefix() == found.interpreter().base_exec_prefix(),
"Not a virtualenv (source: {}, prefix: {})",
found.source(),
found.interpreter().base_prefix().display()
);
Ok(Self(Arc::new(PythonEnvironmentShared {
root: found.interpreter().prefix().to_path_buf(),
interpreter: found.into_interpreter(),
})))
}
/// Create a [`PythonEnvironment`] from the virtual environment at the given root.
@ -36,31 +63,30 @@ impl PythonEnvironment {
let venv = match fs_err::canonicalize(root) {
Ok(venv) => venv,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Err(Error::VenvDoesNotExist(root.to_path_buf()));
return Err(Error::NotFound(
crate::InterpreterNotFound::DirectoryNotFound(root.to_path_buf()),
));
}
Err(err) => return Err(err.into()),
Err(err) => return Err(Error::Discovery(err.into())),
};
let executable = virtualenv_python_executable(&venv);
let interpreter = Interpreter::query(&executable, cache)?;
debug_assert!(
interpreter.base_prefix() == interpreter.base_exec_prefix(),
"Not a virtualenv (Python: {}, prefix: {})",
executable.display(),
interpreter.base_prefix().display()
);
let executable = virtualenv_python_executable(venv);
let interpreter = Interpreter::query(executable, cache)?;
Ok(Self(Arc::new(PythonEnvironmentShared {
root: venv,
root: interpreter.prefix().to_path_buf(),
interpreter,
})))
}
/// Create a [`PythonEnvironment`] for a Python interpreter specifier (e.g., a path or a binary name).
pub fn from_requested_python(python: &str, cache: &Cache) -> Result<Self, Error> {
let Some(interpreter) = find_requested_python(python, cache)? else {
return Err(Error::RequestedPythonNotFound(python.to_string()));
};
pub fn from_requested_python(
request: &str,
system: SystemPython,
cache: &Cache,
) -> Result<Self, Error> {
let sources = SourceSelector::from_env(system);
let request = InterpreterRequest::parse(request);
let interpreter = find_interpreter(&request, &sources, cache)??.into_interpreter();
Ok(Self(Arc::new(PythonEnvironmentShared {
root: interpreter.prefix().to_path_buf(),
interpreter,
@ -69,14 +95,14 @@ impl PythonEnvironment {
/// Create a [`PythonEnvironment`] for the default Python interpreter.
pub fn from_default_python(cache: &Cache) -> Result<Self, Error> {
let interpreter = find_default_python(cache)?;
let interpreter = find_default_interpreter(cache)??.into_interpreter();
Ok(Self(Arc::new(PythonEnvironmentShared {
root: interpreter.prefix().to_path_buf(),
interpreter,
})))
}
/// Create a [`PythonEnvironment`] from an existing [`Interpreter`] and root directory.
/// Create a [`PythonEnvironment`] from an existing [`Interpreter`].
pub fn from_interpreter(interpreter: Interpreter) -> Self {
Self(Arc::new(PythonEnvironmentShared {
root: interpreter.prefix().to_path_buf(),
@ -100,11 +126,13 @@ impl PythonEnvironment {
}
/// Return the [`Interpreter`] for this virtual environment.
///
/// See also [`PythonEnvironment::into_interpreter`].
pub fn interpreter(&self) -> &Interpreter {
&self.0.interpreter
}
/// Return the [`PyVenvConfiguration`] for this virtual environment, as extracted from the
/// Return the [`PyVenvConfiguration`] for this environment, as extracted from the
/// `pyvenv.cfg` file.
pub fn cfg(&self) -> Result<PyVenvConfiguration, Error> {
Ok(PyVenvConfiguration::parse(self.0.root.join("pyvenv.cfg"))?)
@ -115,7 +143,7 @@ impl PythonEnvironment {
self.0.interpreter.sys_executable()
}
/// Returns an iterator over the `site-packages` directories inside a virtual environment.
/// Returns an iterator over the `site-packages` directories inside the environment.
///
/// In most cases, `purelib` and `platlib` will be the same, and so the iterator will contain
/// a single element; however, in some distributions, they may be different.
@ -138,12 +166,12 @@ impl PythonEnvironment {
}
}
/// Returns the path to the `bin` directory inside a virtual environment.
/// Returns the path to the `bin` directory inside this environment.
pub fn scripts(&self) -> &Path {
self.0.interpreter.scripts()
}
/// Grab a file lock for the virtual environment to prevent concurrent writes across processes.
/// Grab a file lock for the environment to prevent concurrent writes across processes.
pub fn lock(&self) -> Result<LockedFile, std::io::Error> {
if let Some(target) = self.0.interpreter.target() {
// If we're installing into a `--target`, use a target-specific lock file.
@ -163,7 +191,9 @@ impl PythonEnvironment {
}
}
/// Return the [`Interpreter`] for this virtual environment.
/// Return the [`Interpreter`] for this environment.
///
/// See also [`PythonEnvironment::interpreter`].
pub fn into_interpreter(self) -> Interpreter {
Arc::unwrap_or_clone(self.0).interpreter
}

View File

@ -1,800 +0,0 @@
use std::borrow::Cow;
use std::env;
use std::ffi::{OsStr, OsString};
use std::path::PathBuf;
use tracing::{debug, instrument};
use uv_cache::Cache;
use uv_warnings::warn_user_once;
use crate::interpreter::InterpreterInfoError;
use crate::py_launcher::{py_list_paths, Error as PyLauncherError, PyListPath};
use crate::virtualenv::{detect_virtualenv, virtualenv_python_executable};
use crate::PythonVersion;
use crate::{Error, Interpreter};
/// Find a Python of a specific version, a binary with a name or a path to a binary.
///
/// Supported formats:
/// * `-p 3.10` searches for an installed Python 3.10 (`py --list-paths` on Windows, `python3.10` on
/// Linux/Mac). Specifying a patch version is not supported.
/// * `-p python3.10` or `-p python.exe` looks for a binary in `PATH`.
/// * `-p /home/ferris/.local/bin/python3.10` uses this exact Python.
///
/// When the user passes a patch version (e.g. 3.12.1), we currently search for a matching minor
/// 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(skip_all, fields(%request))]
pub fn find_requested_python(request: &str, cache: &Cache) -> Result<Option<Interpreter>, Error> {
debug!("Starting interpreter discovery for Python @ `{request}`");
let versions = request
.splitn(3, '.')
.map(str::parse::<u8>)
.collect::<Result<Vec<_>, _>>();
if let Ok(versions) = versions {
// `-p 3.10` or `-p 3.10.1`
let selector = match versions.as_slice() {
[requested_major] => PythonVersionSelector::Major(*requested_major),
[major, minor] => PythonVersionSelector::MajorMinor(*major, *minor),
[major, minor, requested_patch] => {
PythonVersionSelector::MajorMinorPatch(*major, *minor, *requested_patch)
}
// SAFETY: Guaranteed by the Ok(versions) guard
_ => unreachable!(),
};
let interpreter = find_python(selector, cache)?;
interpreter
.as_ref()
.inspect(|inner| warn_on_unsupported_python(inner));
Ok(interpreter)
} else {
match fs_err::metadata(request) {
Ok(metadata) => {
// Map from user-provided path to an executable.
let path = uv_fs::absolutize_path(request.as_ref())?;
let executable = if metadata.is_dir() {
// If the user provided a directory, assume it's a virtual environment.
// `-p /home/ferris/.venv`
if cfg!(windows) {
Cow::Owned(path.join("Scripts/python.exe"))
} else {
Cow::Owned(path.join("bin/python"))
}
} else {
// Otherwise, assume it's a Python executable.
// `-p /home/ferris/.local/bin/python3.10`
path
};
Interpreter::query(executable, cache)
.inspect(warn_on_unsupported_python)
.map(Some)
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
// `-p python3.10`; Generally not used on windows because all Python are `python.exe`.
let Some(executable) = find_executable(request)? else {
return Ok(None);
};
Interpreter::query(executable, cache)
.inspect(warn_on_unsupported_python)
.map(Some)
}
Err(err) => return Err(err.into()),
}
}
}
/// 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(skip_all)]
pub fn find_default_python(cache: &Cache) -> Result<Interpreter, Error> {
debug!("Starting interpreter discovery for default Python");
try_find_default_python(cache)?
.ok_or(if cfg!(windows) {
Error::NoPythonInstalledWindows
} else if cfg!(unix) {
Error::NoPythonInstalledUnix
} else {
unreachable!("Only Unix and Windows are supported")
})
.inspect(warn_on_unsupported_python)
}
/// 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(cache: &Cache) -> Result<Option<Interpreter>, Error> {
find_python(PythonVersionSelector::Default, cache)
}
/// Find a Python version matching `selector`.
///
/// It searches for an existing installation in the following order:
/// * 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): Discover installations using `py --list-paths` (PEP514). Continue if `py` is not installed.
///
/// (Windows): Filter out the Windows store shim (Enabled in Settings/Apps/Advanced app settings/App execution aliases).
fn find_python(
selector: PythonVersionSelector,
cache: &Cache,
) -> Result<Option<Interpreter>, Error> {
#[allow(non_snake_case)]
let UV_TEST_PYTHON_PATH = env::var_os("UV_TEST_PYTHON_PATH");
let use_override = UV_TEST_PYTHON_PATH.is_some();
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 {
#[cfg(windows)]
if windows::is_windows_store_shim(&path) {
continue;
}
let interpreter = match Interpreter::query(&path, cache) {
Ok(interpreter) => interpreter,
// If the Python version is < 3.4, the `-I` flag is not supported, so
// we can't run the script at all, and need to sniff it from the output.
Err(Error::PythonSubcommandOutput { stderr, .. })
if stderr.contains("Unknown option: -I") =>
{
// If the user _requested_ a version prior to 3.4, raise an error, as
// 3.4 is the minimum supported version for invoking the interpreter
// query script at all.
match selector {
PythonVersionSelector::Major(major) if major < 3 => {
return Err(Error::UnsupportedPython(major.to_string()));
}
PythonVersionSelector::MajorMinor(major, minor)
if (major, minor) < (3, 4) =>
{
return Err(Error::UnsupportedPython(format!(
"{major}.{minor}"
)));
}
PythonVersionSelector::MajorMinorPatch(major, minor, patch)
if (major, minor) < (3, 4) =>
{
return Err(Error::UnsupportedPython(format!(
"{major}.{minor}.{patch}"
)));
}
_ => {}
}
debug!(
"Found a Python installation that isn't supported by uv, skipping."
);
continue;
}
Err(Error::QueryScript {
err: InterpreterInfoError::UnsupportedPythonVersion { .. },
..
}) => {
// If the user _requested_ a version prior to 3.7, raise an error, as
// 3.7 is the minimum supported version for running the interpreter
// query script.
match selector {
PythonVersionSelector::Major(major) if major < 3 => {
return Err(Error::UnsupportedPython(major.to_string()));
}
PythonVersionSelector::MajorMinor(major, minor)
if (major, minor) < (3, 7) =>
{
return Err(Error::UnsupportedPython(format!(
"{major}.{minor}"
)));
}
PythonVersionSelector::MajorMinorPatch(major, minor, patch)
if (major, minor) < (3, 7) =>
{
return Err(Error::UnsupportedPython(format!(
"{major}.{minor}.{patch}"
)));
}
_ => {}
}
debug!(
"Found a Python installation that isn't supported by uv, skipping."
);
continue;
}
Err(error) => return Err(error),
};
let installation = PythonInstallation::Interpreter(interpreter);
if let Some(interpreter) = installation.select(selector, 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, 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, cache)?
{
return Ok(Some(interpreter));
}
}
}
}
}
if cfg!(windows) && !use_override {
// Use `py` to find the python installation on the system.
match py_list_paths() {
Ok(paths) => {
for entry in paths {
let installation = PythonInstallation::PyListPath(entry);
if let Some(interpreter) = installation.select(selector, cache)? {
return Ok(Some(interpreter));
}
}
}
// Do not error when `py` is not available
Err(PyLauncherError::NotFound) => debug!("`py` is not installed"),
Err(error) => return Err(Error::PyLauncher(error)),
}
}
Ok(None)
}
/// Find the Python interpreter in `PATH` matching the given name (e.g., `python3`), respecting
/// `UV_PYTHON_PATH`.
///
/// Returns `Ok(None)` if not found.
fn find_executable<R: AsRef<OsStr> + Into<OsString> + Copy>(
requested: R,
) -> Result<Option<PathBuf>, Error> {
#[allow(non_snake_case)]
let UV_TEST_PYTHON_PATH = env::var_os("UV_TEST_PYTHON_PATH");
let use_override = UV_TEST_PYTHON_PATH.is_some();
#[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) {
let paths = match which::which_in_global(requested, Some(&path)) {
Ok(paths) => paths,
Err(which::Error::CannotFindBinaryPath) => continue,
Err(err) => return Err(Error::WhichError(requested.into(), err)),
};
#[allow(clippy::never_loop)]
for path in paths {
#[cfg(windows)]
if windows::is_windows_store_shim(&path) {
continue;
}
return Ok(Some(path));
}
}
if cfg!(windows) && !use_override {
// Use `py` to find the python installation on the system.
match py_list_paths() {
Ok(paths) => {
for entry in paths {
// Ex) `--python python3.12.exe`
if entry.executable_path.file_name() == Some(requested.as_ref()) {
return Ok(Some(entry.executable_path));
}
// Ex) `--python python3.12`
if entry
.executable_path
.file_stem()
.is_some_and(|stem| stem == requested.as_ref())
{
return Ok(Some(entry.executable_path));
}
}
}
// Do not error when `py` is not available
Err(PyLauncherError::NotFound) => debug!("`py` is not installed"),
Err(error) => return Err(Error::PyLauncher(error)),
}
}
Ok(None)
}
#[derive(Debug, Clone)]
enum PythonInstallation {
PyListPath(PyListPath),
Interpreter(Interpreter),
}
impl PythonInstallation {
fn major(&self) -> u8 {
match self {
Self::PyListPath(PyListPath { major, .. }) => *major,
Self::Interpreter(interpreter) => interpreter.python_major(),
}
}
fn minor(&self) -> u8 {
match self {
Self::PyListPath(PyListPath { minor, .. }) => *minor,
Self::Interpreter(interpreter) => interpreter.python_minor(),
}
}
/// Selects the interpreter if it matches the selector (version specification).
fn select(
self,
selector: PythonVersionSelector,
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(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(cache).map(Some)
} else {
Ok(None)
}
}
pub(super) fn into_interpreter(self, cache: &Cache) -> Result<Interpreter, Error> {
match self {
Self::PyListPath(PyListPath {
executable_path, ..
}) => Interpreter::query(executable_path, cache),
Self::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 {
Self::Default => [Some(python3), Some(python), None, None],
Self::Major(major) => [
Some(Cow::Owned(format!("python{major}{extension}"))),
Some(python),
None,
None,
],
Self::MajorMinor(major, minor) => [
Some(Cow::Owned(format!("python{major}.{minor}{extension}"))),
Some(Cow::Owned(format!("python{major}{extension}"))),
Some(python),
None,
],
Self::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),
],
}
}
}
fn warn_on_unsupported_python(interpreter: &Interpreter) {
// Warn on usage with an unsupported Python version
if interpreter.python_tuple() < (3, 8) {
warn_user_once!(
"uv is only compatible with Python 3.8+, found Python {}.",
interpreter.python_version()
);
}
}
/// Find a matching Python or any fallback Python.
///
/// If no Python version is provided, we will use the first available interpreter.
///
/// If a Python version is provided, we will first try to find an exact match. If
/// that cannot be found and a patch version was requested, we will look for a match
/// without comparing the patch version number. If that cannot be found, we fall back to
/// the first available version.
///
/// See [`Self::find_version`] for details on the precedence of Python lookup locations.
#[instrument(skip_all, fields(?python_version))]
pub fn find_best_python(
python_version: Option<&PythonVersion>,
system: bool,
cache: &Cache,
) -> Result<Interpreter, Error> {
if let Some(python_version) = python_version {
debug!(
"Starting interpreter discovery for Python {}",
python_version
);
} else {
debug!("Starting interpreter discovery for active Python");
}
// First, check for an exact match (or the first available version if no Python version was provided)
if let Some(interpreter) = find_version(python_version, system, cache)? {
warn_on_unsupported_python(&interpreter);
return Ok(interpreter);
}
if let Some(python_version) = python_version {
// If that fails, and a specific patch version was requested try again allowing a
// different patch version
if python_version.patch().is_some() {
if let Some(interpreter) =
find_version(Some(&python_version.without_patch()), system, cache)?
{
warn_on_unsupported_python(&interpreter);
return Ok(interpreter);
}
}
}
// If a Python version was requested but cannot be fulfilled, just take any version
if let Some(interpreter) = find_version(None, system, cache)? {
return Ok(interpreter);
}
Err(Error::PythonNotFound)
}
/// Find a Python interpreter.
///
/// We check, in order, the following locations:
///
/// - `UV_DEFAULT_PYTHON`, which is set to the python interpreter when using `python -m uv`.
/// - `VIRTUAL_ENV` and `CONDA_PREFIX`
/// - A `.venv` folder
/// - If a python version is given: Search `PATH` and `py --list-paths`, see `find_python`
/// - `python3` (unix) or `python.exe` (windows)
///
/// If `UV_TEST_PYTHON_PATH` is set, we will not check for Python versions in the
/// global PATH, instead we will search using the provided path. Virtual environments
/// will still be respected.
///
/// If a version is provided and an interpreter cannot be found with the given version,
/// we will return [`None`].
fn find_version(
python_version: Option<&PythonVersion>,
system: bool,
cache: &Cache,
) -> Result<Option<Interpreter>, Error> {
let version_matches = |interpreter: &Interpreter| -> bool {
if let Some(python_version) = python_version {
// If a patch version was provided, check for an exact match
interpreter.satisfies(python_version)
} else {
// The version always matches if one was not provided
true
}
};
// Check if the venv Python matches.
if !system {
if let Some(venv) = detect_virtualenv()? {
let executable = virtualenv_python_executable(venv);
let interpreter = Interpreter::query(executable, cache)?;
if version_matches(&interpreter) {
return Ok(Some(interpreter));
}
};
}
// Look for the requested version with by search for `python{major}.{minor}` in `PATH` on
// Unix and `py --list-paths` on Windows.
let interpreter = if let Some(python_version) = python_version {
find_requested_python(&python_version.string, cache)?
} else {
try_find_default_python(cache)?
};
if let Some(interpreter) = interpreter {
debug_assert!(version_matches(&interpreter));
Ok(Some(interpreter))
} else {
Ok(None)
}
}
mod windows {
/// On Windows we might encounter the Windows Store proxy shim (enabled in:
/// Settings/Apps/Advanced app settings/App execution aliases). When Python is _not_ installed
/// via the Windows Store, but the proxy shim is enabled, then executing `python.exe` or
/// `python3.exe` will redirect to the Windows Store installer.
///
/// We need to detect that these `python.exe` and `python3.exe` files are _not_ Python
/// executables.
///
/// This method is taken from Rye:
///
/// > 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.
///
/// See: <https://github.com/astral-sh/rye/blob/b0e9eccf05fe4ff0ae7b0250a248c54f2d780b4d/rye/src/cli/shim.rs#L108>
#[cfg(windows)]
pub(super) fn is_windows_store_shim(path: &std::path::Path) -> bool {
use std::os::windows::fs::MetadataExt;
use std::os::windows::prelude::OsStrExt;
use winapi::um::fileapi::{CreateFileW, OPEN_EXISTING};
use winapi::um::handleapi::{CloseHandle, INVALID_HANDLE_VALUE};
use winapi::um::ioapiset::DeviceIoControl;
use winapi::um::winbase::{FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT};
use winapi::um::winioctl::FSCTL_GET_REPARSE_POINT;
use winapi::um::winnt::{FILE_ATTRIBUTE_REPARSE_POINT, MAXIMUM_REPARSE_DATA_BUFFER_SIZE};
// The path must be absolute.
if !path.is_absolute() {
return false;
}
// The path must point to something like:
// `C:\Users\crmar\AppData\Local\Microsoft\WindowsApps\python3.exe`
let mut components = path.components().rev();
// Ex) `python.exe` or `python3.exe`
if !components
.next()
.and_then(|component| component.as_os_str().to_str())
.is_some_and(|component| component == "python.exe" || component == "python3.exe")
{
return false;
}
// Ex) `WindowsApps`
if !components
.next()
.is_some_and(|component| component.as_os_str() == "WindowsApps")
{
return false;
}
// Ex) `Microsoft`
if !components
.next()
.is_some_and(|component| component.as_os_str() == "Microsoft")
{
return false;
}
// The file is only relevant if it's a reparse point.
let Ok(md) = fs_err::symlink_metadata(path) else {
return false;
};
if md.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0 {
return false;
}
let mut path_encoded = path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect::<Vec<_>>();
// SAFETY: The path is null-terminated.
#[allow(unsafe_code)]
let reparse_handle = unsafe {
CreateFileW(
path_encoded.as_mut_ptr(),
0,
0,
std::ptr::null_mut(),
OPEN_EXISTING,
FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT,
std::ptr::null_mut(),
)
};
if reparse_handle == INVALID_HANDLE_VALUE {
return false;
}
let mut buf = [0u16; MAXIMUM_REPARSE_DATA_BUFFER_SIZE as usize];
let mut bytes_returned = 0;
// SAFETY: The buffer is large enough to hold the reparse point.
#[allow(unsafe_code, clippy::cast_possible_truncation)]
let success = unsafe {
DeviceIoControl(
reparse_handle,
FSCTL_GET_REPARSE_POINT,
std::ptr::null_mut(),
0,
buf.as_mut_ptr().cast(),
buf.len() as u32 * 2,
&mut bytes_returned,
std::ptr::null_mut(),
) != 0
};
// SAFETY: The handle is valid.
#[allow(unsafe_code)]
unsafe {
CloseHandle(reparse_handle);
}
// If the operation failed, assume it's not a reparse point.
if !success {
return false;
}
let reparse_point = String::from_utf16_lossy(&buf[..bytes_returned as usize]);
reparse_point.contains("\\AppInstallerPythonRedirector.exe")
}
#[cfg(test)]
mod tests {
use std::fmt::Debug;
use insta::assert_snapshot;
use itertools::Itertools;
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]
#[cfg_attr(not(windows), ignore)]
fn no_such_python_path() {
let cache = Cache::temp().unwrap().init().unwrap();
let result = find_requested_python(r"C:\does\not\exists\python3.12", &cache)
.unwrap()
.ok_or(Error::RequestedPythonNotFound(
r"C:\does\not\exists\python3.12".to_string(),
));
assert_snapshot!(
format_err(result),
@"Failed to locate Python interpreter at: `C:\\does\\not\\exists\\python3.12`"
);
}
}
}
#[cfg(test)]
mod tests {
use insta::assert_snapshot;
use itertools::Itertools;
use uv_cache::Cache;
use crate::find_python::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]
#[cfg_attr(not(unix), ignore)]
fn no_such_python_version() {
let cache = Cache::temp().unwrap().init().unwrap();
let request = "3.1000";
let result = find_requested_python(request, &cache)
.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_attr(not(unix), ignore)]
fn no_such_python_binary() {
let cache = Cache::temp().unwrap().init().unwrap();
let request = "python3.1000";
let result = find_requested_python(request, &cache)
.unwrap()
.ok_or(Error::NoSuchPython(request.to_string()));
assert_snapshot!(
format_err(result),
@"No Python python3.1000 in `PATH`. Is Python python3.1000 installed?"
);
}
#[test]
#[cfg_attr(not(unix), ignore)]
fn no_such_python_path() {
let cache = Cache::temp().unwrap().init().unwrap();
let result = find_requested_python("/does/not/exists/python3.12", &cache)
.unwrap()
.ok_or(Error::RequestedPythonNotFound(
"/does/not/exists/python3.12".to_string(),
));
assert_snapshot!(
format_err(result), @"Failed to locate Python interpreter at: `/does/not/exists/python3.12`");
}
}

View File

@ -16,7 +16,6 @@ pub enum ImplementationName {
}
impl ImplementationName {
#[allow(dead_code)]
pub(crate) fn iter() -> impl Iterator<Item = &'static ImplementationName> {
static NAMES: &[ImplementationName] = &[ImplementationName::Cpython];
NAMES.iter()

View File

@ -1,11 +1,13 @@
use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::process::{Command, ExitStatus};
use configparser::ini::Ini;
use fs_err as fs;
use once_cell::sync::OnceCell;
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
use thiserror::Error;
use tracing::{trace, warn};
use cache_key::digest;
use install_wheel_rs::Layout;
@ -18,7 +20,7 @@ use uv_cache::{Cache, CacheBucket, CachedByTimestamp, Freshness, Timestamp};
use uv_fs::{write_atomic_sync, PythonExt, Simplified};
use crate::pointer_size::PointerSize;
use crate::{Error, PythonVersion, Target, VirtualEnvironment};
use crate::{PythonVersion, Target, VirtualEnvironment};
/// A Python executable and its associated platform markers.
#[derive(Debug, Clone)]
@ -407,6 +409,41 @@ impl ExternallyManaged {
}
}
#[derive(Debug, Error)]
pub enum Error {
#[error(transparent)]
Io(#[from] io::Error),
#[error("Failed to query Python interpreter at `{path}`")]
SpawnFailed {
path: PathBuf,
#[source]
err: io::Error,
},
#[error("Querying Python at `{}` did not return the expected data\n{err}\n--- stdout:\n{stdout}\n--- stderr:\n{stderr}\n---", path.display())]
UnexpectedResponse {
err: serde_json::Error,
stdout: String,
stderr: String,
path: PathBuf,
},
#[error("Querying Python at `{}` failed with exit status {code}\n--- stdout:\n{stdout}\n--- stderr:\n{stderr}\n---", path.display())]
StatusCode {
code: ExitStatus,
stdout: String,
stderr: String,
path: PathBuf,
},
#[error("Can't use Python at `{path}`")]
QueryScript {
#[source]
err: InterpreterInfoError,
path: PathBuf,
},
#[error("Failed to write to cache")]
Encode(#[from] rmp_serde::encode::Error),
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(tag = "result", rename_all = "lowercase")]
enum InterpreterInfoResult {
@ -423,6 +460,8 @@ pub enum InterpreterInfoError {
UnknownOperatingSystem { operating_system: String },
#[error("Python {python_version} is not supported. Please use Python 3.8 or newer.")]
UnsupportedPythonVersion { python_version: String },
#[error("Python executable does not support `-I` flag. Please use Python 3.8 or newer.")]
UnsupportedPython,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
@ -460,41 +499,54 @@ impl InterpreterInfo {
.arg("-c")
.arg(script)
.output()
.map_err(|err| Error::PythonSubcommandLaunch {
interpreter: interpreter.to_path_buf(),
.map_err(|err| Error::SpawnFailed {
path: interpreter.to_path_buf(),
err,
})?;
if !output.status.success() {
return Err(Error::PythonSubcommandOutput {
message: format!(
"Querying Python at `{}` failed with status {}",
interpreter.display(),
output.status,
),
exit_code: output.status,
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
// If the Python version is too old, we may not even be able to invoke the query script
if stderr.contains("Unknown option: -I") {
return Err(Error::QueryScript {
err: InterpreterInfoError::UnsupportedPython,
path: interpreter.to_path_buf(),
});
}
return Err(Error::StatusCode {
code: output.status,
stderr,
stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(),
stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
path: interpreter.to_path_buf(),
});
}
let result: InterpreterInfoResult =
serde_json::from_slice(&output.stdout).map_err(|err| {
Error::PythonSubcommandOutput {
message: format!(
"Querying Python at `{}` did not return the expected data: {err}",
interpreter.display(),
),
exit_code: output.status,
stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(),
stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
// If the Python version is too old, we may not even be able to invoke the query script
if stderr.contains("Unknown option: -I") {
Error::QueryScript {
err: InterpreterInfoError::UnsupportedPython,
path: interpreter.to_path_buf(),
}
} else {
Error::UnexpectedResponse {
err,
stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(),
stderr,
path: interpreter.to_path_buf(),
}
}
})?;
match result {
InterpreterInfoResult::Error(err) => Err(Error::QueryScript {
err,
interpreter: interpreter.to_path_buf(),
path: interpreter.to_path_buf(),
}),
InterpreterInfoResult::Success(data) => Ok(*data),
}
@ -557,7 +609,7 @@ impl InterpreterInfo {
match rmp_serde::from_slice::<CachedByTimestamp<Self>>(&data) {
Ok(cached) => {
if cached.timestamp == modified {
debug!(
trace!(
"Cached interpreter info for Python {}, skipping probing: {}",
cached.data.markers.python_full_version(),
executable.user_display()
@ -565,14 +617,14 @@ impl InterpreterInfo {
return Ok(cached.data);
}
debug!(
"Ignoring stale cached markers for: {}",
trace!(
"Ignoring stale interpreter markers for: {}",
executable.user_display()
);
}
Err(err) => {
warn!(
"Broken cache entry at {}, removing: {err}",
"Broken interpreter cache entry at {}, removing: {err}",
cache_entry.path().user_display()
);
let _ = fs_err::remove_file(cache_entry.path());
@ -582,10 +634,13 @@ impl InterpreterInfo {
}
// Otherwise, run the Python script.
debug!("Probing interpreter info for: {}", executable.display());
trace!(
"Querying interpreter executable at {}",
executable.display()
);
let info = Self::query(executable, cache)?;
debug!(
"Found Python {} for: {}",
trace!(
"Found Python {} at {}",
info.markers.python_full_version(),
executable.display()
);
@ -687,6 +742,7 @@ mod tests {
"##},
)
.unwrap();
fs::set_permissions(
&mocked_interpreter,
std::os::unix::fs::PermissionsExt::from_mode(0o770),

File diff suppressed because it is too large Load Diff

View File

@ -46,6 +46,8 @@ pub enum Error {
#[source]
err: io::Error,
},
#[error("failed to parse toolchain directory name: {0}")]
NameError(String),
}
#[derive(Debug, PartialEq)]

View File

@ -1,38 +1,77 @@
use std::collections::BTreeSet;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use crate::managed::downloads::Error;
use crate::{
platform::{Arch, Libc, Os},
python_version::PythonVersion,
};
use std::path::PathBuf;
use std::str::FromStr;
use once_cell::sync::Lazy;
use tracing::debug;
use uv_fs::Simplified;
use crate::managed::downloads::Error;
use crate::platform::{Arch, Libc, Os};
use crate::python_version::PythonVersion;
/// The directory where Python toolchains we install are stored.
pub static TOOLCHAIN_DIRECTORY: Lazy<Option<PathBuf>> = Lazy::new(|| {
std::env::var_os("UV_BOOTSTRAP_DIR").map_or(
std::env::var_os("CARGO_MANIFEST_DIR").map(|manifest_dir| {
Path::new(&manifest_dir)
.parent()
.expect("CARGO_MANIFEST_DIR should be nested in workspace")
.parent()
.expect("CARGO_MANIFEST_DIR should be doubly nested in workspace")
.join("bin")
}),
|bootstrap_dir| Some(PathBuf::from(bootstrap_dir)),
)
});
pub static TOOLCHAIN_DIRECTORY: Lazy<Option<PathBuf>> =
Lazy::new(|| std::env::var_os("UV_BOOTSTRAP_DIR").map(PathBuf::from));
pub fn toolchains_for_current_platform() -> Result<impl Iterator<Item = Toolchain>, Error> {
let platform_key = platform_key_from_env()?;
let iter = toolchain_directories()?
.into_iter()
// Sort "newer" versions of Python first
.rev()
.filter_map(move |path| {
if path
.file_name()
.map(OsStr::to_string_lossy)
.is_some_and(|filename| filename.ends_with(&platform_key))
{
Toolchain::new(path.clone())
.inspect_err(|err| {
debug!(
"Ignoring invalid toolchain directory {}: {err}",
path.user_display()
);
})
.ok()
} else {
None
}
});
Ok(iter)
}
/// An installed Python toolchain.
#[derive(Debug, Clone)]
pub struct Toolchain {
/// The path to the top-level directory of the installed toolchain.
path: PathBuf,
python_version: PythonVersion,
}
impl Toolchain {
pub fn new(path: PathBuf) -> Result<Self, Error> {
let python_version = PythonVersion::from_str(
path.file_name()
.ok_or(Error::NameError("No directory name".to_string()))?
.to_str()
.ok_or(Error::NameError("Name not a valid string".to_string()))?
.split('-')
.nth(1)
.ok_or(Error::NameError(
"Not enough `-` separarated values".to_string(),
))?,
)
.map_err(|err| Error::NameError(format!("Name has invalid Python version: {err}")))?;
Ok(Self {
path,
python_version,
})
}
pub fn executable(&self) -> PathBuf {
if cfg!(windows) {
self.path.join("install").join("python.exe")
@ -42,8 +81,18 @@ impl Toolchain {
unimplemented!("Only Windows and Unix systems are supported.")
}
}
pub fn python_version(&self) -> &PythonVersion {
&self.python_version
}
}
/// Return the directories in the toolchain directory.
///
/// Toolchain directories are sorted descending by name, such that we get deterministic
/// ordering across platforms. This also results in newer Python versions coming first,
/// but should not be relied on — instead the toolchains should be sorted later by
/// the parsed Python version.
fn toolchain_directories() -> Result<BTreeSet<PathBuf>, Error> {
let Some(toolchain_dir) = TOOLCHAIN_DIRECTORY.as_ref() else {
return Ok(BTreeSet::default());
@ -101,7 +150,14 @@ pub fn toolchains_for_version(version: &PythonVersion) -> Result<Vec<Toolchain>,
&& filename.ends_with(&platform_key)
})
{
Some(Toolchain { path })
Toolchain::new(path.clone())
.inspect_err(|err| {
debug!(
"Ignoring invalid toolchain directory {}: {err}",
path.user_display()
);
})
.ok()
} else {
None
}

View File

@ -1,5 +1,7 @@
pub use crate::managed::downloads::{DownloadResult, Error, PythonDownload, PythonDownloadRequest};
pub use crate::managed::find::{toolchains_for_version, Toolchain, TOOLCHAIN_DIRECTORY};
pub use crate::managed::find::{
toolchains_for_current_platform, toolchains_for_version, Toolchain, TOOLCHAIN_DIRECTORY,
};
mod downloads;
mod find;

View File

@ -14,6 +14,7 @@ pub(crate) struct PyListPath {
pub(crate) executable_path: PathBuf,
}
/// An error was encountered when using the `py` launcher on Windows.
#[derive(Error, Debug)]
pub enum Error {
#[error("{message} with {exit_code}\n--- stdout:\n{stdout}\n--- stderr:\n{stderr}\n---")]
@ -91,36 +92,3 @@ pub(crate) fn py_list_paths() -> Result<Vec<PyListPath>, Error> {
})
.collect())
}
#[cfg(test)]
mod tests {
use std::fmt::Debug;
use insta::assert_snapshot;
use itertools::Itertools;
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]
#[cfg_attr(not(windows), ignore)]
fn no_such_python_path() {
let cache = Cache::temp().unwrap().init().unwrap();
let result = find_requested_python(r"C:\does\not\exists\python3.12", &cache)
.unwrap()
.ok_or(Error::RequestedPythonNotFound(
r"C:\does\not\exists\python3.12".to_string(),
));
assert_snapshot!(
format_err(result),
@"Failed to locate Python interpreter at: `C:\\does\\not\\exists\\python3.12`"
);
}
}

View File

@ -37,15 +37,8 @@ pub enum Error {
MissingPyVenvCfg(PathBuf),
#[error("Broken virtualenv `{0}`: `pyvenv.cfg` could not be parsed")]
ParsePyVenvCfg(PathBuf, #[source] io::Error),
}
/// Locate the current virtual environment.
pub(crate) fn detect_virtualenv() -> Result<Option<PathBuf>, Error> {
let from_env = virtualenv_from_env();
if from_env.is_some() {
return Ok(from_env);
}
virtualenv_from_working_dir()
#[error(transparent)]
IO(#[from] io::Error),
}
/// Locate an active virtual environment by inspecting environment variables.
@ -54,7 +47,7 @@ pub(crate) fn detect_virtualenv() -> Result<Option<PathBuf>, Error> {
pub(crate) fn virtualenv_from_env() -> Option<PathBuf> {
if let Some(dir) = env::var_os("VIRTUAL_ENV").filter(|value| !value.is_empty()) {
info!(
"Found a virtualenv through VIRTUAL_ENV at: {}",
"Found active virtual environment (via VIRTUAL_ENV) at: {}",
Path::new(&dir).display()
);
return Some(PathBuf::from(dir));
@ -62,7 +55,7 @@ pub(crate) fn virtualenv_from_env() -> Option<PathBuf> {
if let Some(dir) = env::var_os("CONDA_PREFIX").filter(|value| !value.is_empty()) {
info!(
"Found a virtualenv through CONDA_PREFIX at: {}",
"Found active virtual environment (via CONDA_PREFIX) at: {}",
Path::new(&dir).display()
);
return Some(PathBuf::from(dir));
@ -77,12 +70,12 @@ pub(crate) fn virtualenv_from_env() -> Option<PathBuf> {
/// directory is itself a virtual environment (or a subdirectory of a virtual environment), the
/// containing virtual environment is returned.
pub(crate) fn virtualenv_from_working_dir() -> Result<Option<PathBuf>, Error> {
let current_dir = env::current_dir().expect("Failed to detect current directory");
let current_dir = crate::current_dir()?;
for dir in current_dir.ancestors() {
// If we're _within_ a virtualenv, return it.
if dir.join("pyvenv.cfg").is_file() {
debug!("Found a virtualenv at: {}", dir.display());
debug!("Found a virtual environment at: {}", dir.display());
return Ok(Some(dir.to_path_buf()));
}
@ -92,7 +85,7 @@ pub(crate) fn virtualenv_from_working_dir() -> Result<Option<PathBuf>, Error> {
if !dot_venv.join("pyvenv.cfg").is_file() {
return Err(Error::MissingPyVenvCfg(dot_venv));
}
debug!("Found a virtualenv named .venv at: {}", dot_venv.display());
debug!("Found a virtual environment at: {}", dot_venv.display());
return Ok(Some(dot_venv));
}
}
@ -105,9 +98,9 @@ pub(crate) fn virtualenv_python_executable(venv: impl AsRef<Path>) -> PathBuf {
let venv = venv.as_ref();
if cfg!(windows) {
// Search for `python.exe` in the `Scripts` directory.
let executable = venv.join("Scripts").join("python.exe");
if executable.exists() {
return executable;
let default_executable = venv.join("Scripts").join("python.exe");
if default_executable.exists() {
return default_executable;
}
// Apparently, Python installed via msys2 on Windows _might_ produce a POSIX-like layout.
@ -118,7 +111,13 @@ pub(crate) fn virtualenv_python_executable(venv: impl AsRef<Path>) -> PathBuf {
}
// Fallback for Conda environments.
venv.join("python.exe")
let executable = venv.join("python.exe");
if executable.exists() {
return executable;
}
// If none of these exist, return the standard location
default_executable
} else {
// Search for `python` in the `bin` directory.
venv.join("bin").join("python")

View File

@ -19,7 +19,7 @@ use uv_configuration::{
BuildKind, Concurrency, Constraints, NoBinary, NoBuild, Overrides, SetupPyStrategy,
};
use uv_distribution::DistributionDatabase;
use uv_interpreter::{find_default_python, Interpreter, PythonEnvironment};
use uv_interpreter::{find_default_interpreter, Interpreter, PythonEnvironment};
use uv_resolver::{
DisplayResolutionGraph, ExcludeNewer, Exclusions, FlatIndex, InMemoryIndex, Manifest, Options,
OptionsBuilder, PreReleaseMode, Preference, PythonRequirement, ResolutionGraph, ResolutionMode,
@ -124,10 +124,13 @@ async fn resolve(
tags: &Tags,
) -> Result<ResolutionGraph> {
let cache = Cache::temp().unwrap().init().unwrap();
let real_interpreter = find_default_python(&cache).expect("Expected a python to be installed");
let client = RegistryClientBuilder::new(cache).build();
let flat_index = FlatIndex::default();
let index = InMemoryIndex::default();
let real_interpreter = find_default_interpreter(&Cache::temp().unwrap())
.unwrap()
.expect("Python should be installed")
.into_interpreter();
let interpreter = Interpreter::artificial(real_interpreter.platform().clone(), markers.clone());
let python_requirement = PythonRequirement::from_marker_environment(&interpreter, markers);
let cache = Cache::temp().unwrap().init().unwrap();

View File

@ -14,8 +14,10 @@ mod bare;
pub enum Error {
#[error(transparent)]
IO(#[from] io::Error),
#[error("Failed to determine python interpreter to use")]
InterpreterError(#[from] uv_interpreter::Error),
#[error("Failed to determine Python interpreter to use")]
Discovery(#[from] uv_interpreter::DiscoveryError),
#[error("Failed to determine Python interpreter to use")]
InterpreterNotFound(#[from] uv_interpreter::InterpreterNotFound),
#[error(transparent)]
Platform(#[from] PlatformError),
#[error("Could not find a suitable Python executable for the virtual environment based on the interpreter: {0}")]

View File

@ -9,7 +9,7 @@ use tracing::debug;
use uv_cache::Cache;
use uv_fs::Simplified;
use uv_installer::{Diagnostic, SitePackages};
use uv_interpreter::PythonEnvironment;
use uv_interpreter::{PythonEnvironment, SystemPython};
use crate::commands::{elapsed, ExitStatus};
use crate::printer::Printer;
@ -24,19 +24,12 @@ pub(crate) fn pip_check(
let start = Instant::now();
// Detect the current Python interpreter.
let venv = if let Some(python) = python {
PythonEnvironment::from_requested_python(python, cache)?
} else if system {
PythonEnvironment::from_default_python(cache)?
let system = if system {
SystemPython::Required
} else {
match PythonEnvironment::from_virtualenv(cache) {
Ok(venv) => venv,
Err(uv_interpreter::Error::VenvNotFound) => {
PythonEnvironment::from_default_python(cache)?
}
Err(err) => return Err(err.into()),
}
SystemPython::Allowed
};
let venv = PythonEnvironment::find(python, system, cache)?;
debug!(
"Using Python {} environment at {}",

View File

@ -35,8 +35,11 @@ use uv_dispatch::BuildDispatch;
use uv_distribution::DistributionDatabase;
use uv_fs::Simplified;
use uv_installer::Downloader;
use uv_interpreter::PythonVersion;
use uv_interpreter::{find_best_python, find_requested_python, PythonEnvironment};
use uv_interpreter::{
find_best_interpreter, find_interpreter, InterpreterRequest, PythonEnvironment, SystemPython,
VersionRequest,
};
use uv_interpreter::{PythonVersion, SourceSelector};
use uv_normalize::{ExtraName, PackageName};
use uv_requirements::{
upgrade::read_lockfile, ExtrasSpecification, LookaheadResolver, NamedRequirementsResolver,
@ -160,12 +163,26 @@ pub(crate) async fn pip_compile(
}
// Find an interpreter to use for building distributions
let interpreter = if let Some(python) = python.as_ref() {
find_requested_python(python, &cache)?
.ok_or_else(|| uv_interpreter::Error::RequestedPythonNotFound(python.to_string()))?
let system = if system {
SystemPython::Required
} else {
find_best_python(python_version.as_ref(), system, &cache)?
SystemPython::Allowed
};
let interpreter = if let Some(python) = python.as_ref() {
let request = InterpreterRequest::parse(python);
let sources = SourceSelector::from_env(system);
find_interpreter(&request, &sources, &cache)??
} else {
let request = if let Some(version) = python_version.as_ref() {
// TODO(zanieb): We should consolidate `VersionRequest` and `PythonVersion`
InterpreterRequest::Version(VersionRequest::from(version))
} else {
InterpreterRequest::Version(VersionRequest::Default)
};
find_best_interpreter(&request, system, &cache)??
}
.into_interpreter();
debug!(
"Using Python {} interpreter at {} for builds",
interpreter.python_version(),

View File

@ -9,7 +9,7 @@ use distribution_types::{InstalledDist, Name};
use uv_cache::Cache;
use uv_fs::Simplified;
use uv_installer::SitePackages;
use uv_interpreter::PythonEnvironment;
use uv_interpreter::{PythonEnvironment, SystemPython};
use crate::commands::ExitStatus;
use crate::printer::Printer;
@ -24,19 +24,12 @@ pub(crate) fn pip_freeze(
printer: Printer,
) -> Result<ExitStatus> {
// Detect the current Python interpreter.
let venv = if let Some(python) = python {
PythonEnvironment::from_requested_python(python, cache)?
} else if system {
PythonEnvironment::from_default_python(cache)?
let system = if system {
SystemPython::Required
} else {
match PythonEnvironment::from_virtualenv(cache) {
Ok(venv) => venv,
Err(uv_interpreter::Error::VenvNotFound) => {
PythonEnvironment::from_default_python(cache)?
}
Err(err) => return Err(err.into()),
}
SystemPython::Allowed
};
let venv = PythonEnvironment::find(python, system, cache)?;
debug!(
"Using Python {} environment at {}",

View File

@ -32,7 +32,7 @@ use uv_dispatch::BuildDispatch;
use uv_distribution::DistributionDatabase;
use uv_fs::Simplified;
use uv_installer::{Downloader, Plan, Planner, ResolvedEditable, SatisfiesResult, SitePackages};
use uv_interpreter::{Interpreter, PythonEnvironment, PythonVersion, Target};
use uv_interpreter::{Interpreter, PythonEnvironment, PythonVersion, SystemPython, Target};
use uv_normalize::PackageName;
use uv_requirements::{
ExtrasSpecification, LookaheadResolver, NamedRequirementsResolver, RequirementsSource,
@ -125,13 +125,13 @@ pub(crate) async fn pip_install(
.await?;
// Detect the current Python interpreter.
let venv = if let Some(python) = python.as_ref() {
PythonEnvironment::from_requested_python(python, &cache)?
} else if system {
PythonEnvironment::from_default_python(&cache)?
let system = if system {
SystemPython::Required
} else {
PythonEnvironment::from_virtualenv(&cache)?
SystemPython::Disallowed
};
let venv = PythonEnvironment::find(python.as_deref(), system, &cache)?;
debug!(
"Using Python {} environment at {}",
venv.interpreter().python_version(),

View File

@ -12,7 +12,7 @@ use distribution_types::{InstalledDist, Name};
use uv_cache::Cache;
use uv_fs::Simplified;
use uv_installer::SitePackages;
use uv_interpreter::PythonEnvironment;
use uv_interpreter::{PythonEnvironment, SystemPython};
use uv_normalize::PackageName;
use crate::commands::ExitStatus;
@ -33,19 +33,12 @@ pub(crate) fn pip_list(
printer: Printer,
) -> Result<ExitStatus> {
// Detect the current Python interpreter.
let venv = if let Some(python) = python {
PythonEnvironment::from_requested_python(python, cache)?
} else if system {
PythonEnvironment::from_default_python(cache)?
let system = if system {
SystemPython::Required
} else {
match PythonEnvironment::from_virtualenv(cache) {
Ok(venv) => venv,
Err(uv_interpreter::Error::VenvNotFound) => {
PythonEnvironment::from_default_python(cache)?
}
Err(err) => return Err(err.into()),
}
SystemPython::Allowed
};
let venv = PythonEnvironment::find(python, system, cache)?;
debug!(
"Using Python {} environment at {}",

View File

@ -10,7 +10,7 @@ use distribution_types::Name;
use uv_cache::Cache;
use uv_fs::Simplified;
use uv_installer::SitePackages;
use uv_interpreter::PythonEnvironment;
use uv_interpreter::{PythonEnvironment, SystemPython};
use uv_normalize::PackageName;
use crate::commands::ExitStatus;
@ -39,19 +39,12 @@ pub(crate) fn pip_show(
}
// Detect the current Python interpreter.
let venv = if let Some(python) = python {
PythonEnvironment::from_requested_python(python, cache)?
} else if system {
PythonEnvironment::from_default_python(cache)?
let system = if system {
SystemPython::Required
} else {
match PythonEnvironment::from_virtualenv(cache) {
Ok(venv) => venv,
Err(uv_interpreter::Error::VenvNotFound) => {
PythonEnvironment::from_default_python(cache)?
}
Err(err) => return Err(err.into()),
}
SystemPython::Allowed
};
let venv = PythonEnvironment::find(python, system, cache)?;
debug!(
"Using Python {} environment at {}",

View File

@ -23,7 +23,7 @@ use uv_dispatch::BuildDispatch;
use uv_distribution::DistributionDatabase;
use uv_fs::Simplified;
use uv_installer::{Downloader, Plan, Planner, SitePackages};
use uv_interpreter::{PythonEnvironment, PythonVersion, Target};
use uv_interpreter::{PythonEnvironment, PythonVersion, SystemPython, Target};
use uv_requirements::{
ExtrasSpecification, NamedRequirementsResolver, RequirementsSource, RequirementsSpecification,
SourceTreeResolver,
@ -101,13 +101,13 @@ pub(crate) async fn pip_sync(
}
// Detect the current Python interpreter.
let venv = if let Some(python) = python.as_ref() {
PythonEnvironment::from_requested_python(python, &cache)?
} else if system {
PythonEnvironment::from_default_python(&cache)?
let system = if system {
SystemPython::Required
} else {
PythonEnvironment::from_virtualenv(&cache)?
SystemPython::Disallowed
};
let venv = PythonEnvironment::find(python.as_deref(), system, &cache)?;
debug!(
"Using Python {} environment at {}",
venv.interpreter().python_version(),

View File

@ -11,7 +11,7 @@ use uv_cache::Cache;
use uv_client::{BaseClientBuilder, Connectivity};
use uv_configuration::{KeyringProviderType, PreviewMode};
use uv_fs::Simplified;
use uv_interpreter::{PythonEnvironment, Target};
use uv_interpreter::{PythonEnvironment, SystemPython, Target};
use uv_requirements::{RequirementsSource, RequirementsSpecification};
use crate::commands::{elapsed, ExitStatus};
@ -43,13 +43,13 @@ pub(crate) async fn pip_uninstall(
RequirementsSpecification::from_simple_sources(sources, &client_builder, preview).await?;
// Detect the current Python interpreter.
let venv = if let Some(python) = python.as_ref() {
PythonEnvironment::from_requested_python(python, &cache)?
} else if system {
PythonEnvironment::from_default_python(&cache)?
let system = if system {
SystemPython::Required
} else {
PythonEnvironment::from_virtualenv(&cache)?
SystemPython::Disallowed
};
let venv = PythonEnvironment::find(python.as_deref(), system, &cache)?;
debug!(
"Using Python {} environment at {}",
venv.interpreter().python_version(),

View File

@ -20,7 +20,7 @@ use uv_dispatch::BuildDispatch;
use uv_distribution::DistributionDatabase;
use uv_fs::Simplified;
use uv_installer::{Downloader, Plan, Planner, SatisfiesResult, SitePackages};
use uv_interpreter::{find_default_python, Interpreter, PythonEnvironment};
use uv_interpreter::{find_default_interpreter, Interpreter, PythonEnvironment};
use uv_requirements::{
ExtrasSpecification, LookaheadResolver, NamedRequirementsResolver, ProjectWorkspace,
RequirementsSource, RequirementsSpecification, SourceTreeResolver,
@ -88,9 +88,12 @@ pub(crate) fn init(
// TODO(charlie): If the environment isn't compatible with `--python`, recreate it.
match PythonEnvironment::from_root(&venv, cache) {
Ok(venv) => Ok(venv),
Err(uv_interpreter::Error::VenvDoesNotExist(_)) => {
Err(uv_interpreter::Error::NotFound(_)) => {
// TODO(charlie): Respect `--python`; if unset, respect `Requires-Python`.
let interpreter = find_default_python(cache)?;
let interpreter = find_default_interpreter(cache)
.map_err(uv_interpreter::Error::from)?
.map_err(uv_interpreter::Error::from)?
.into_interpreter();
writeln!(
printer.stderr(),

View File

@ -9,7 +9,7 @@ use tracing::debug;
use uv_cache::Cache;
use uv_configuration::PreviewMode;
use uv_interpreter::PythonEnvironment;
use uv_interpreter::{PythonEnvironment, SystemPython};
use uv_requirements::{ProjectWorkspace, RequirementsSource};
use uv_warnings::warn_user;
@ -76,7 +76,8 @@ pub(crate) async fn run(
let interpreter = if let Some(project_env) = &project_env {
project_env.interpreter().clone()
} else if let Some(python) = python.as_ref() {
PythonEnvironment::from_requested_python(python, cache)?.into_interpreter()
PythonEnvironment::from_requested_python(python, SystemPython::Allowed, cache)?
.into_interpreter()
} else {
PythonEnvironment::from_default_python(cache)?.into_interpreter()
};

View File

@ -19,7 +19,9 @@ use uv_configuration::{Concurrency, KeyringProviderType};
use uv_configuration::{ConfigSettings, IndexStrategy, NoBinary, NoBuild, SetupPyStrategy};
use uv_dispatch::BuildDispatch;
use uv_fs::Simplified;
use uv_interpreter::{find_default_python, find_requested_python, Error};
use uv_interpreter::{
find_default_interpreter, find_interpreter, InterpreterRequest, SourceSelector,
};
use uv_resolver::{ExcludeNewer, FlatIndex, InMemoryIndex, OptionsBuilder};
use uv_types::{BuildContext, BuildIsolation, HashStrategy, InFlight};
@ -116,14 +118,16 @@ async fn venv_impl(
printer: Printer,
) -> miette::Result<ExitStatus> {
// Locate the Python interpreter.
let interpreter = if let Some(python_request) = python_request {
find_requested_python(python_request, cache)
.into_diagnostic()?
.ok_or(Error::NoSuchPython(python_request.to_string()))
.into_diagnostic()?
let interpreter = if let Some(python) = python_request.as_ref() {
let request = InterpreterRequest::parse(python);
let sources = SourceSelector::from_env(uv_interpreter::SystemPython::Allowed);
find_interpreter(&request, &sources, cache)
} else {
find_default_python(cache).into_diagnostic()?
};
find_default_interpreter(cache)
}
.into_diagnostic()?
.into_diagnostic()?
.into_interpreter();
// Add all authenticated sources to the cache.
for url in index_locations.urls() {

View File

@ -4,7 +4,6 @@
use assert_cmd::assert::{Assert, OutputAssertExt};
use assert_cmd::Command;
use assert_fs::assert::PathAssert;
use assert_fs::fixture::PathChild;
use regex::Regex;
use std::borrow::BorrowMut;
@ -13,10 +12,13 @@ use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Output;
use std::str::FromStr;
use uv_cache::Cache;
use uv_fs::Simplified;
use uv_interpreter::managed::toolchains_for_version;
use uv_interpreter::{find_requested_python, PythonVersion};
use uv_interpreter::{
find_interpreter, InterpreterRequest, PythonVersion, SourceSelector, VersionRequest,
};
// Exclude any packages uploaded after this date.
pub static EXCLUDE_NEWER: &str = "2024-03-25T00:00:00Z";
@ -394,8 +396,14 @@ pub fn python_path_with_versions(
.collect::<Vec<_>>();
if inner.is_empty() {
// Fallback to a system lookup if we failed to find one in the toolchain directory
if let Some(interpreter) = find_requested_python(python_version, &cache).unwrap() {
vec![interpreter
let request = InterpreterRequest::Version(
VersionRequest::from_str(python_version)
.expect("The test version request must be valid"),
);
let sources = SourceSelector::All;
if let Ok(found) = find_interpreter(&request, &sources, &cache).unwrap() {
vec![found
.into_interpreter()
.sys_executable()
.parent()
.expect("Python executable should always be in a directory")
@ -409,6 +417,11 @@ pub fn python_path_with_versions(
})
.collect::<Vec<_>>();
assert!(
python_versions.is_empty() || !selected_pythons.is_empty(),
"Failed to fulfill requested test Python versions: {selected_pythons:?}"
);
Ok(env::join_paths(selected_pythons)?)
}

View File

@ -8,7 +8,6 @@ use std::process::Command;
use anyhow::{bail, Context, Result};
use assert_fs::prelude::*;
use assert_fs::TempDir;
use indoc::indoc;
use url::Url;
@ -127,29 +126,42 @@ fn missing_requirements_in() {
#[test]
fn missing_venv() -> Result<()> {
let temp_dir = TempDir::new()?;
let cache_dir = TempDir::new()?;
let venv = temp_dir.child(".venv");
let context = TestContext::new("3.12");
context.temp_dir.child("requirements.in").touch()?;
fs_err::remove_dir_all(context.temp_dir.child(".venv").path())?;
uv_snapshot!(Command::new(get_bin())
.arg("pip")
.arg("compile")
.arg("requirements.in")
.arg("--cache-dir")
.arg(cache_dir.path())
.env("VIRTUAL_ENV", venv.as_os_str())
.current_dir(&temp_dir), @r###"
success: false
exit_code: 2
----- stdout -----
if cfg!(windows) {
uv_snapshot!(context.filters(), context.compile()
.arg("requirements.in"), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: failed to read from file `requirements.in`
Caused by: No such file or directory (os error 2)
"###
);
----- stderr -----
warning: Requirements file requirements.in does not contain any dependencies
error: failed to canonicalize path `[VENV]/Scripts/python.exe`
Caused by: The system cannot find the path specified. (os error 3)
"###
);
} else {
uv_snapshot!(context.filters(), context.compile()
.arg("requirements.in"), @r###"
success: false
exit_code: 2
----- stdout -----
venv.assert(predicates::path::missing());
----- stderr -----
warning: Requirements file requirements.in does not contain any dependencies
error: failed to canonicalize path `[VENV]/bin/python`
Caused by: No such file or directory (os error 2)
"###
);
}
context
.temp_dir
.child(".venv")
.assert(predicates::path::missing());
Ok(())
}

View File

@ -102,14 +102,27 @@ fn missing_venv() -> Result<()> {
requirements.write_str("anyio")?;
fs::remove_dir_all(&context.venv)?;
uv_snapshot!(context.filters(), command(&context).arg("requirements.txt"), @r###"
success: false
exit_code: 2
----- stdout -----
if cfg!(windows) {
uv_snapshot!(context.filters(), command(&context).arg("requirements.txt"), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Virtualenv does not exist at: `[VENV]/`
"###);
----- stderr -----
error: failed to canonicalize path `[VENV]/Scripts/python.exe`
Caused by: The system cannot find the path specified. (os error 3)
"###);
} else {
uv_snapshot!(context.filters(), command(&context).arg("requirements.txt"), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: failed to canonicalize path `[VENV]/bin/python`
Caused by: No such file or directory (os error 2)
"###);
}
assert!(predicates::path::missing().eval(&context.venv));

View File

@ -220,27 +220,18 @@ fn create_venv_unknown_python_minor() {
.arg(context.venv.as_os_str())
.arg("--python")
.arg("3.15");
if cfg!(windows) {
uv_snapshot!(&mut command, @r###"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
× No Python 3.15 found through `py --list-paths` or in `PATH`. Is Python 3.15 installed?
"###
);
} else {
uv_snapshot!(&mut command, @r###"
success: false
exit_code: 1
----- stdout -----
// Note the `py` launcher is not included in the search in Windows due to
// `UV_TEST_PYTHON_PATH` being set
uv_snapshot!(&mut command, @r###"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
× No Python 3.15 in `PATH`. Is Python 3.15 installed?
"###
);
}
----- stderr -----
× No interpreter found for Python 3.15 in active virtual environment or search path
"###
);
context.venv.assert(predicates::path::missing());
}
@ -249,17 +240,7 @@ fn create_venv_unknown_python_minor() {
fn create_venv_unknown_python_patch() {
let context = VenvTestContext::new(&["3.12"]);
let filters = &[
(
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?",
),
];
uv_snapshot!(filters, context.venv_command()
uv_snapshot!(context.filters(), context.venv_command()
.arg(context.venv.as_os_str())
.arg("--python")
.arg("3.8.0"), @r###"
@ -268,7 +249,7 @@ fn create_venv_unknown_python_patch() {
----- stdout -----
----- stderr -----
× No Python 3.8.0 in `PATH`. Is Python 3.8.0 installed?
× No interpreter found for Python 3.8.0 in active virtual environment or search path
"###
);