Support transparent Python patch version upgrades (#13954)

> NOTE: The PRs that were merged into this feature branch have all been
independently reviewed. But it's also useful to see all of the changes
in their final form. I've added comments to significant changes
throughout the PR to aid discussion.

This PR introduces transparent Python version upgrades to uv, allowing
for a smoother experience when upgrading to new patch versions.
Previously, upgrading Python patch versions required manual updates to
each virtual environment. Now, virtual environments can transparently
upgrade to newer patch versions.

Due to significant changes in how uv installs and executes managed
Python executables, this functionality is initially available behind a
`--preview` flag. Once an installation has been made upgradeable through
`--preview`, subsequent operations (like `uv venv -p 3.10` or patch
upgrades) will work without requiring the flag again. This is
accomplished by checking for the existence of a minor version symlink
directory (or junction on Windows).

### Features

* New `uv python upgrade` command to upgrade installed Python versions
to the latest available patch release:
``` 
# Upgrade specific minor version 
uv python upgrade 3.12 --preview
# Upgrade all installed minor versions
uv python upgrade --preview
```
* Transparent upgrades also occur when installing newer patch versions: 
```
uv python install 3.10.8 --preview
# Automatically upgrades existing 3.10 environments
uv python install 3.10.18
```
* Support for transparently upgradeable Python `bin` installations via
`--preview` flag
```
uv python install 3.13 --preview
# Automatically upgrades the `bin` installation if there is a newer patch version available
uv python upgrade 3.13 --preview
```
* Virtual environments can still be tied to a patch version if desired
(ignoring patch upgrades):
```
uv venv -p 3.10.8
```

### Implementation

Transparent upgrades are implemented using:
* Minor version symlink directories (Unix) or junctions (Windows)
* On Windows, trampolines simulate paths with junctions
* Symlink directory naming follows Python build standalone format: e.g.,
`cpython-3.10-macos-aarch64-none`
* Upgrades are scoped to the minor version key (as represented in the
naming format: implementation-minor version+variant-os-arch-libc)
* If the context does not provide a patch version request and the
interpreter is from a managed CPython installation, the `Interpreter`
used by `uv python run` will use the full symlink directory executable
path when available, enabling transparently upgradeable environments
created with the `venv` module (`uv run python -m venv`)

New types:
* `PythonMinorVersionLink`: in a sense, the core type for this PR, this
is a representation of a minor version symlink directory (or junction on
Windows) that points to the highest installed managed CPython patch
version for a minor version key.
* `PythonInstallationMinorVersionKey`: provides a view into a
`PythonInstallationKey` that excludes the patch and prerelease. This is
used for grouping installations by minor version key (e.g., to find the
highest available patch installation for that minor version key) and for
minor version directory naming.

### Compatibility

* Supports virtual environments created with:
  * `uv venv`
* `uv run python -m venv` (using managed Python that was installed or
upgraded with `--preview`)
  * Virtual environments created within these environments
* Existing virtual environments from before these changes continue to
work but aren't transparently upgradeable without being recreated
* Supports both standard Python (`python3.10`) and freethreaded Python
(`python3.10t`)
* Support for transparently upgrades is currently only available for
managed CPython installations

Closes #7287
Closes #7325
Closes #7892
Closes #9031
Closes #12977

---------

Co-authored-by: Zanie Blue <contact@zanie.dev>
This commit is contained in:
John Mumm 2025-06-20 10:17:13 -04:00 committed by GitHub
parent 62365d4ec8
commit e9d5780369
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
73 changed files with 3022 additions and 306 deletions

8
Cargo.lock generated
View file

@ -4582,6 +4582,7 @@ dependencies = [
"console",
"ctrlc",
"dotenvy",
"dunce",
"etcetera",
"filetime",
"flate2",
@ -4589,6 +4590,7 @@ dependencies = [
"futures",
"http",
"ignore",
"indexmap",
"indicatif",
"indoc",
"insta",
@ -5548,15 +5550,18 @@ dependencies = [
"assert_fs",
"clap",
"configparser",
"dunce",
"fs-err 3.1.1",
"futures",
"goblin",
"indexmap",
"indoc",
"insta",
"itertools 0.14.0",
"once_cell",
"owo-colors",
"procfs",
"ref-cast",
"regex",
"reqwest",
"reqwest-middleware",
@ -5581,6 +5586,7 @@ dependencies = [
"uv-cache-info",
"uv-cache-key",
"uv-client",
"uv-configuration",
"uv-dirs",
"uv-distribution-filename",
"uv-extract",
@ -5844,6 +5850,7 @@ dependencies = [
"toml_edit",
"tracing",
"uv-cache",
"uv-configuration",
"uv-dirs",
"uv-distribution-types",
"uv-fs",
@ -5928,6 +5935,7 @@ dependencies = [
"self-replace",
"thiserror 2.0.12",
"tracing",
"uv-configuration",
"uv-fs",
"uv-pypi-types",
"uv-python",

View file

@ -27,6 +27,7 @@ use tokio::process::Command;
use tokio::sync::{Mutex, Semaphore};
use tracing::{Instrument, debug, info_span, instrument};
use uv_configuration::PreviewMode;
use uv_configuration::{BuildKind, BuildOutput, ConfigSettings, SourceStrategy};
use uv_distribution::BuildRequires;
use uv_distribution_types::{IndexLocations, Requirement, Resolution};
@ -278,6 +279,7 @@ impl SourceBuild {
mut environment_variables: FxHashMap<OsString, OsString>,
level: BuildOutput,
concurrent_builds: usize,
preview: PreviewMode,
) -> Result<Self, Error> {
let temp_dir = build_context.cache().venv_dir()?;
@ -325,6 +327,8 @@ impl SourceBuild {
false,
false,
false,
false,
preview,
)?
};

View file

@ -4722,6 +4722,24 @@ pub enum PythonCommand {
/// See `uv help python` to view supported request formats.
Install(PythonInstallArgs),
/// Upgrade installed Python versions to the latest supported patch release (requires the
/// `--preview` flag).
///
/// A target Python minor version to upgrade may be provided, e.g., `3.13`. Multiple versions
/// may be provided to perform more than one upgrade.
///
/// If no target version is provided, then uv will upgrade all managed CPython versions.
///
/// During an upgrade, uv will not uninstall outdated patch versions.
///
/// When an upgrade is performed, virtual environments created by uv will automatically
/// use the new version. However, if the virtual environment was created before the
/// upgrade functionality was added, it will continue to use the old Python version; to enable
/// upgrades, the environment must be recreated.
///
/// Upgrades are not yet supported for alternative implementations, like PyPy.
Upgrade(PythonUpgradeArgs),
/// Search for a Python installation.
///
/// Displays the path to the Python executable.
@ -4907,6 +4925,50 @@ pub struct PythonInstallArgs {
pub default: bool,
}
#[derive(Args)]
pub struct PythonUpgradeArgs {
/// The directory Python installations are stored in.
///
/// If provided, `UV_PYTHON_INSTALL_DIR` will need to be set for subsequent operations for uv to
/// discover the Python installation.
///
/// See `uv python dir` to view the current Python installation directory. Defaults to
/// `~/.local/share/uv/python`.
#[arg(long, short, env = EnvVars::UV_PYTHON_INSTALL_DIR)]
pub install_dir: Option<PathBuf>,
/// The Python minor version(s) to upgrade.
///
/// If no target version is provided, then uv will upgrade all managed CPython versions.
#[arg(env = EnvVars::UV_PYTHON)]
pub targets: Vec<String>,
/// Set the URL to use as the source for downloading Python installations.
///
/// The provided URL will replace
/// `https://github.com/astral-sh/python-build-standalone/releases/download` in, e.g.,
/// `https://github.com/astral-sh/python-build-standalone/releases/download/20240713/cpython-3.12.4%2B20240713-aarch64-apple-darwin-install_only.tar.gz`.
///
/// Distributions can be read from a local directory by using the `file://` URL scheme.
#[arg(long, env = EnvVars::UV_PYTHON_INSTALL_MIRROR)]
pub mirror: Option<String>,
/// Set the URL to use as the source for downloading PyPy installations.
///
/// The provided URL will replace `https://downloads.python.org/pypy` in, e.g.,
/// `https://downloads.python.org/pypy/pypy3.8-v7.3.7-osx64.tar.bz2`.
///
/// Distributions can be read from a local directory by using the `file://` URL scheme.
#[arg(long, env = EnvVars::UV_PYPY_INSTALL_MIRROR)]
pub pypy_mirror: Option<String>,
/// URL pointing to JSON of custom Python installations.
///
/// Note that currently, only local paths are supported.
#[arg(long, env = EnvVars::UV_PYTHON_DOWNLOADS_JSON_URL)]
pub python_downloads_json_url: Option<String>,
}
#[derive(Args)]
pub struct PythonUninstallArgs {
/// The directory where the Python was installed.

View file

@ -4,7 +4,7 @@ use clap::Parser;
use tracing::info;
use uv_cache::{Cache, CacheArgs};
use uv_configuration::Concurrency;
use uv_configuration::{Concurrency, PreviewMode};
use uv_python::{EnvironmentPreference, PythonEnvironment, PythonRequest};
#[derive(Parser)]
@ -26,6 +26,7 @@ pub(crate) async fn compile(args: CompileArgs) -> anyhow::Result<()> {
&PythonRequest::default(),
EnvironmentPreference::OnlyVirtual,
&cache,
PreviewMode::Disabled,
)?
.into_interpreter();
interpreter.sys_executable().to_path_buf()

View file

@ -433,6 +433,7 @@ impl BuildContext for BuildDispatch<'_> {
self.build_extra_env_vars.clone(),
build_output,
self.concurrency.builds,
self.preview,
)
.boxed_local()
.await?;

View file

@ -16,7 +16,6 @@ doctest = false
workspace = true
[dependencies]
dunce = { workspace = true }
either = { workspace = true }
encoding_rs_io = { workspace = true }

View file

@ -277,21 +277,6 @@ fn normalized(path: &Path) -> PathBuf {
normalized
}
/// Like `fs_err::canonicalize`, but avoids attempting to resolve symlinks on Windows.
pub fn canonicalize_executable(path: impl AsRef<Path>) -> std::io::Result<PathBuf> {
let path = path.as_ref();
debug_assert!(
path.is_absolute(),
"path must be absolute: {}",
path.display()
);
if cfg!(windows) {
Ok(path.to_path_buf())
} else {
fs_err::canonicalize(path)
}
}
/// Compute a path describing `path` relative to `base`.
///
/// `lib/python/site-packages/foo/__init__.py` and `lib/python/site-packages` -> `foo/__init__.py`

View file

@ -20,6 +20,7 @@ uv-cache = { workspace = true }
uv-cache-info = { workspace = true }
uv-cache-key = { workspace = true }
uv-client = { workspace = true }
uv-configuration = { workspace = true }
uv-dirs = { workspace = true }
uv-distribution-filename = { workspace = true }
uv-extract = { workspace = true }
@ -38,11 +39,14 @@ uv-warnings = { workspace = true }
anyhow = { workspace = true }
clap = { workspace = true, optional = true }
configparser = { workspace = true }
dunce = { workspace = true }
fs-err = { workspace = true, features = ["tokio"] }
futures = { workspace = true }
goblin = { workspace = true, default-features = false }
indexmap = { workspace = true }
itertools = { workspace = true }
owo-colors = { workspace = true }
ref-cast = { workspace = true }
regex = { workspace = true }
reqwest = { workspace = true }
reqwest-middleware = { workspace = true }

View file

@ -8,6 +8,7 @@ use std::{env, io, iter};
use std::{path::Path, path::PathBuf, str::FromStr};
use thiserror::Error;
use tracing::{debug, instrument, trace};
use uv_configuration::PreviewMode;
use which::{which, which_all};
use uv_cache::Cache;
@ -25,7 +26,7 @@ use crate::implementation::ImplementationName;
use crate::installation::PythonInstallation;
use crate::interpreter::Error as InterpreterError;
use crate::interpreter::{StatusCodeError, UnexpectedResponseError};
use crate::managed::ManagedPythonInstallations;
use crate::managed::{ManagedPythonInstallations, PythonMinorVersionLink};
#[cfg(windows)]
use crate::microsoft_store::find_microsoft_store_pythons;
use crate::virtualenv::Error as VirtualEnvError;
@ -35,12 +36,12 @@ use crate::virtualenv::{
};
#[cfg(windows)]
use crate::windows_registry::{WindowsPython, registry_pythons};
use crate::{BrokenSymlink, Interpreter, PythonVersion};
use crate::{BrokenSymlink, Interpreter, PythonInstallationKey, PythonVersion};
/// A request to find a Python installation.
///
/// See [`PythonRequest::from_str`].
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[derive(Debug, Clone, PartialEq, Eq, Default, Hash)]
pub enum PythonRequest {
/// An appropriate default Python installation
///
@ -173,7 +174,7 @@ pub enum PythonVariant {
}
/// A Python discovery version request.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
pub enum VersionRequest {
/// Allow an appropriate default Python version.
#[default]
@ -334,6 +335,7 @@ fn python_executables_from_installed<'a>(
implementation: Option<&'a ImplementationName>,
platform: PlatformRequest,
preference: PythonPreference,
preview: PreviewMode,
) -> Box<dyn Iterator<Item = Result<(PythonSource, PathBuf), Error>> + 'a> {
let from_managed_installations = iter::once_with(move || {
ManagedPythonInstallations::from_settings(None)
@ -359,7 +361,29 @@ fn python_executables_from_installed<'a>(
true
})
.inspect(|installation| debug!("Found managed installation `{installation}`"))
.map(|installation| (PythonSource::Managed, installation.executable(false))))
.map(move |installation| {
// If it's not a patch version request, then attempt to read the stable
// minor version link.
let executable = version
.patch()
.is_none()
.then(|| {
PythonMinorVersionLink::from_installation(
&installation,
preview,
)
.filter(PythonMinorVersionLink::exists)
.map(
|minor_version_link| {
minor_version_link.symlink_executable.clone()
},
)
})
.flatten()
.unwrap_or_else(|| installation.executable(false));
(PythonSource::Managed, executable)
})
)
})
})
.flatten_ok();
@ -452,6 +476,7 @@ fn python_executables<'a>(
platform: PlatformRequest,
environments: EnvironmentPreference,
preference: PythonPreference,
preview: PreviewMode,
) -> Box<dyn Iterator<Item = Result<(PythonSource, PathBuf), Error>> + 'a> {
// Always read from `UV_INTERNAL__PARENT_INTERPRETER` — it could be a system interpreter
let from_parent_interpreter = iter::once_with(|| {
@ -472,7 +497,7 @@ fn python_executables<'a>(
let from_virtual_environments = python_executables_from_virtual_environments();
let from_installed =
python_executables_from_installed(version, implementation, platform, preference);
python_executables_from_installed(version, implementation, platform, preference, preview);
// Limit the search to the relevant environment preference; this avoids unnecessary work like
// traversal of the file system. Subsequent filtering should be done by the caller with
@ -671,16 +696,23 @@ fn python_interpreters<'a>(
environments: EnvironmentPreference,
preference: PythonPreference,
cache: &'a Cache,
preview: PreviewMode,
) -> impl Iterator<Item = Result<(PythonSource, Interpreter), Error>> + 'a {
python_interpreters_from_executables(
// Perform filtering on the discovered executables based on their source. This avoids
// unnecessary interpreter queries, which are generally expensive. We'll filter again
// with `interpreter_satisfies_environment_preference` after querying.
python_executables(version, implementation, platform, environments, preference).filter_ok(
move |(source, path)| {
source_satisfies_environment_preference(*source, path, environments)
},
),
python_executables(
version,
implementation,
platform,
environments,
preference,
preview,
)
.filter_ok(move |(source, path)| {
source_satisfies_environment_preference(*source, path, environments)
}),
cache,
)
.filter_ok(move |(source, interpreter)| {
@ -919,6 +951,7 @@ pub fn find_python_installations<'a>(
environments: EnvironmentPreference,
preference: PythonPreference,
cache: &'a Cache,
preview: PreviewMode,
) -> Box<dyn Iterator<Item = Result<FindPythonResult, Error>> + 'a> {
let sources = DiscoveryPreferences {
python_preference: preference,
@ -1010,6 +1043,7 @@ pub fn find_python_installations<'a>(
environments,
preference,
cache,
preview,
)
.map_ok(|tuple| Ok(PythonInstallation::from_tuple(tuple)))
}),
@ -1022,6 +1056,7 @@ pub fn find_python_installations<'a>(
environments,
preference,
cache,
preview,
)
.map_ok(|tuple| Ok(PythonInstallation::from_tuple(tuple)))
}),
@ -1038,6 +1073,7 @@ pub fn find_python_installations<'a>(
environments,
preference,
cache,
preview,
)
.map_ok(|tuple| Ok(PythonInstallation::from_tuple(tuple)))
})
@ -1051,6 +1087,7 @@ pub fn find_python_installations<'a>(
environments,
preference,
cache,
preview,
)
.filter_ok(|(_source, interpreter)| {
interpreter
@ -1072,6 +1109,7 @@ pub fn find_python_installations<'a>(
environments,
preference,
cache,
preview,
)
.filter_ok(|(_source, interpreter)| {
interpreter
@ -1096,6 +1134,7 @@ pub fn find_python_installations<'a>(
environments,
preference,
cache,
preview,
)
.filter_ok(|(_source, interpreter)| request.satisfied_by_interpreter(interpreter))
.map_ok(|tuple| Ok(PythonInstallation::from_tuple(tuple)))
@ -1113,8 +1152,10 @@ pub(crate) fn find_python_installation(
environments: EnvironmentPreference,
preference: PythonPreference,
cache: &Cache,
preview: PreviewMode,
) -> Result<FindPythonResult, Error> {
let installations = find_python_installations(request, environments, preference, cache);
let installations =
find_python_installations(request, environments, preference, cache, preview);
let mut first_prerelease = None;
let mut first_error = None;
for result in installations {
@ -1210,12 +1251,13 @@ pub(crate) fn find_best_python_installation(
environments: EnvironmentPreference,
preference: PythonPreference,
cache: &Cache,
preview: PreviewMode,
) -> Result<FindPythonResult, Error> {
debug!("Starting Python discovery for {}", request);
// First, check for an exact match (or the first available version if no Python version was provided)
debug!("Looking for exact match for request {request}");
let result = find_python_installation(request, environments, preference, cache);
let result = find_python_installation(request, environments, preference, cache, preview);
match result {
Ok(Ok(installation)) => {
warn_on_unsupported_python(installation.interpreter());
@ -1243,7 +1285,7 @@ pub(crate) fn find_best_python_installation(
_ => None,
} {
debug!("Looking for relaxed patch version {request}");
let result = find_python_installation(&request, environments, preference, cache);
let result = find_python_installation(&request, environments, preference, cache, preview);
match result {
Ok(Ok(installation)) => {
warn_on_unsupported_python(installation.interpreter());
@ -1260,14 +1302,16 @@ pub(crate) fn find_best_python_installation(
debug!("Looking for a default Python installation");
let request = PythonRequest::Default;
Ok(
find_python_installation(&request, environments, preference, cache)?.map_err(|err| {
// Use a more general error in this case since we looked for multiple versions
PythonNotFound {
request,
python_preference: err.python_preference,
environment_preference: err.environment_preference,
}
}),
find_python_installation(&request, environments, preference, cache, preview)?.map_err(
|err| {
// Use a more general error in this case since we looked for multiple versions
PythonNotFound {
request,
python_preference: err.python_preference,
environment_preference: err.environment_preference,
}
},
),
)
}
@ -1645,6 +1689,24 @@ impl PythonRequest {
Ok(rest.parse().ok())
}
/// Check if this request includes a specific patch version.
pub fn includes_patch(&self) -> bool {
match self {
PythonRequest::Default => false,
PythonRequest::Any => false,
PythonRequest::Version(version_request) => version_request.patch().is_some(),
PythonRequest::Directory(..) => false,
PythonRequest::File(..) => false,
PythonRequest::ExecutableName(..) => false,
PythonRequest::Implementation(..) => false,
PythonRequest::ImplementationVersion(_, version) => version.patch().is_some(),
PythonRequest::Key(request) => request
.version
.as_ref()
.is_some_and(|request| request.patch().is_some()),
}
}
/// Check if a given interpreter satisfies the interpreter request.
pub fn satisfied(&self, interpreter: &Interpreter, cache: &Cache) -> bool {
/// Returns `true` if the two paths refer to the same interpreter executable.
@ -2086,6 +2148,11 @@ impl fmt::Display for ExecutableName {
}
impl VersionRequest {
/// Derive a [`VersionRequest::MajorMinor`] from a [`PythonInstallationKey`]
pub fn major_minor_request_from_key(key: &PythonInstallationKey) -> Self {
Self::MajorMinor(key.major, key.minor, key.variant)
}
/// Return possible executable names for the given version request.
pub(crate) fn executable_names(
&self,

View file

@ -111,14 +111,14 @@ pub enum Error {
},
}
#[derive(Debug, PartialEq, Clone)]
#[derive(Debug, PartialEq, Eq, Clone, Hash)]
pub struct ManagedPythonDownload {
key: PythonInstallationKey,
url: &'static str,
sha256: Option<&'static str>,
}
#[derive(Debug, Clone, Default, Eq, PartialEq)]
#[derive(Debug, Clone, Default, Eq, PartialEq, Hash)]
pub struct PythonDownloadRequest {
pub(crate) version: Option<VersionRequest>,
pub(crate) implementation: Option<ImplementationName>,
@ -131,7 +131,7 @@ pub struct PythonDownloadRequest {
pub(crate) prereleases: Option<bool>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ArchRequest {
Explicit(Arch),
Environment(Arch),

View file

@ -7,6 +7,7 @@ use owo_colors::OwoColorize;
use tracing::debug;
use uv_cache::Cache;
use uv_configuration::PreviewMode;
use uv_fs::{LockedFile, Simplified};
use uv_pep440::Version;
@ -152,6 +153,7 @@ impl PythonEnvironment {
request: &PythonRequest,
preference: EnvironmentPreference,
cache: &Cache,
preview: PreviewMode,
) -> Result<Self, Error> {
let installation = match find_python_installation(
request,
@ -159,6 +161,7 @@ impl PythonEnvironment {
// Ignore managed installations when looking for environments
PythonPreference::OnlySystem,
cache,
preview,
)? {
Ok(installation) => installation,
Err(err) => return Err(EnvironmentNotFound::from(err).into()),

View file

@ -1,10 +1,14 @@
use std::fmt;
use std::hash::{Hash, Hasher};
use std::str::FromStr;
use indexmap::IndexMap;
use ref_cast::RefCast;
use tracing::{debug, info};
use uv_cache::Cache;
use uv_client::BaseClientBuilder;
use uv_configuration::PreviewMode;
use uv_pep440::{Prerelease, Version};
use crate::discovery::{
@ -54,8 +58,10 @@ impl PythonInstallation {
environments: EnvironmentPreference,
preference: PythonPreference,
cache: &Cache,
preview: PreviewMode,
) -> Result<Self, Error> {
let installation = find_python_installation(request, environments, preference, cache)??;
let installation =
find_python_installation(request, environments, preference, cache, preview)??;
Ok(installation)
}
@ -66,12 +72,14 @@ impl PythonInstallation {
environments: EnvironmentPreference,
preference: PythonPreference,
cache: &Cache,
preview: PreviewMode,
) -> Result<Self, Error> {
Ok(find_best_python_installation(
request,
environments,
preference,
cache,
preview,
)??)
}
@ -89,11 +97,12 @@ impl PythonInstallation {
python_install_mirror: Option<&str>,
pypy_install_mirror: Option<&str>,
python_downloads_json_url: Option<&str>,
preview: PreviewMode,
) -> Result<Self, Error> {
let request = request.unwrap_or(&PythonRequest::Default);
// Search for the installation
let err = match Self::find(request, environments, preference, cache) {
let err = match Self::find(request, environments, preference, cache, preview) {
Ok(installation) => return Ok(installation),
Err(err) => err,
};
@ -129,6 +138,7 @@ impl PythonInstallation {
python_install_mirror,
pypy_install_mirror,
python_downloads_json_url,
preview,
)
.await
{
@ -149,6 +159,7 @@ impl PythonInstallation {
python_install_mirror: Option<&str>,
pypy_install_mirror: Option<&str>,
python_downloads_json_url: Option<&str>,
preview: PreviewMode,
) -> Result<Self, Error> {
let installations = ManagedPythonInstallations::from_settings(None)?.init()?;
let installations_dir = installations.root();
@ -180,6 +191,21 @@ impl PythonInstallation {
installed.ensure_externally_managed()?;
installed.ensure_sysconfig_patched()?;
installed.ensure_canonical_executables()?;
let minor_version = installed.minor_version_key();
let highest_patch = installations
.find_all()?
.filter(|installation| installation.minor_version_key() == minor_version)
.filter_map(|installation| installation.version().patch())
.fold(0, std::cmp::max);
if installed
.version()
.patch()
.is_some_and(|p| p >= highest_patch)
{
installed.ensure_minor_version_link(preview)?;
}
if let Err(e) = installed.ensure_dylib_patched() {
e.warn_user(&installed);
}
@ -340,6 +366,14 @@ impl PythonInstallationKey {
format!("{}.{}.{}", self.major, self.minor, self.patch)
}
pub fn major(&self) -> u8 {
self.major
}
pub fn minor(&self) -> u8 {
self.minor
}
pub fn arch(&self) -> &Arch {
&self.arch
}
@ -490,3 +524,112 @@ impl Ord for PythonInstallationKey {
.then_with(|| self.variant.cmp(&other.variant).reverse())
}
}
/// A view into a [`PythonInstallationKey`] that excludes the patch and prerelease versions.
#[derive(Clone, Eq, Ord, PartialOrd, RefCast)]
#[repr(transparent)]
pub struct PythonInstallationMinorVersionKey(PythonInstallationKey);
impl PythonInstallationMinorVersionKey {
/// Cast a `&PythonInstallationKey` to a `&PythonInstallationMinorVersionKey` using ref-cast.
#[inline]
pub fn ref_cast(key: &PythonInstallationKey) -> &Self {
RefCast::ref_cast(key)
}
/// Takes an [`IntoIterator`] of [`ManagedPythonInstallation`]s and returns an [`FxHashMap`] from
/// [`PythonInstallationMinorVersionKey`] to the installation with highest [`PythonInstallationKey`]
/// for that minor version key.
#[inline]
pub fn highest_installations_by_minor_version_key<'a, I>(
installations: I,
) -> IndexMap<Self, ManagedPythonInstallation>
where
I: IntoIterator<Item = &'a ManagedPythonInstallation>,
{
let mut minor_versions = IndexMap::default();
for installation in installations {
minor_versions
.entry(installation.minor_version_key().clone())
.and_modify(|high_installation: &mut ManagedPythonInstallation| {
if installation.key() >= high_installation.key() {
*high_installation = installation.clone();
}
})
.or_insert_with(|| installation.clone());
}
minor_versions
}
}
impl fmt::Display for PythonInstallationMinorVersionKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// Display every field on the wrapped key except the patch
// and prerelease (with special formatting for the variant).
let variant = match self.0.variant {
PythonVariant::Default => String::new(),
PythonVariant::Freethreaded => format!("+{}", self.0.variant),
};
write!(
f,
"{}-{}.{}{}-{}-{}-{}",
self.0.implementation,
self.0.major,
self.0.minor,
variant,
self.0.os,
self.0.arch,
self.0.libc,
)
}
}
impl fmt::Debug for PythonInstallationMinorVersionKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// Display every field on the wrapped key except the patch
// and prerelease.
f.debug_struct("PythonInstallationMinorVersionKey")
.field("implementation", &self.0.implementation)
.field("major", &self.0.major)
.field("minor", &self.0.minor)
.field("variant", &self.0.variant)
.field("os", &self.0.os)
.field("arch", &self.0.arch)
.field("libc", &self.0.libc)
.finish()
}
}
impl PartialEq for PythonInstallationMinorVersionKey {
fn eq(&self, other: &Self) -> bool {
// Compare every field on the wrapped key except the patch
// and prerelease.
self.0.implementation == other.0.implementation
&& self.0.major == other.0.major
&& self.0.minor == other.0.minor
&& self.0.os == other.0.os
&& self.0.arch == other.0.arch
&& self.0.libc == other.0.libc
&& self.0.variant == other.0.variant
}
}
impl Hash for PythonInstallationMinorVersionKey {
fn hash<H: Hasher>(&self, state: &mut H) {
// Hash every field on the wrapped key except the patch
// and prerelease.
self.0.implementation.hash(state);
self.0.major.hash(state);
self.0.minor.hash(state);
self.0.os.hash(state);
self.0.arch.hash(state);
self.0.libc.hash(state);
self.0.variant.hash(state);
}
}
impl From<PythonInstallationKey> for PythonInstallationMinorVersionKey {
fn from(key: PythonInstallationKey) -> Self {
PythonInstallationMinorVersionKey(key)
}
}

View file

@ -26,6 +26,7 @@ use uv_platform_tags::{Tags, TagsError};
use uv_pypi_types::{ResolverMarkerEnvironment, Scheme};
use crate::implementation::LenientImplementationName;
use crate::managed::ManagedPythonInstallations;
use crate::platform::{Arch, Libc, Os};
use crate::pointer_size::PointerSize;
use crate::{
@ -168,7 +169,7 @@ impl Interpreter {
Ok(path) => path,
Err(err) => {
warn!("Failed to find base Python executable: {err}");
uv_fs::canonicalize_executable(base_executable)?
canonicalize_executable(base_executable)?
}
};
Ok(base_python)
@ -263,6 +264,21 @@ impl Interpreter {
self.prefix.is_some()
}
/// Returns `true` if this interpreter is managed by uv.
///
/// Returns `false` if we cannot determine the path of the uv managed Python interpreters.
pub fn is_managed(&self) -> bool {
let Ok(installations) = ManagedPythonInstallations::from_settings(None) else {
return false;
};
installations
.find_all()
.into_iter()
.flatten()
.any(|install| install.path() == self.sys_base_prefix)
}
/// Returns `Some` if the environment is externally managed, optionally including an error
/// message from the `EXTERNALLY-MANAGED` file.
///
@ -483,10 +499,19 @@ impl Interpreter {
/// `python-build-standalone`.
///
/// See: <https://github.com/astral-sh/python-build-standalone/issues/382>
#[cfg(unix)]
pub fn is_standalone(&self) -> bool {
self.standalone
}
/// Returns `true` if an [`Interpreter`] may be a `python-build-standalone` interpreter.
// TODO(john): Replace this approach with patching sysconfig on Windows to
// set `PYTHON_BUILD_STANDALONE=1`.`
#[cfg(windows)]
pub fn is_standalone(&self) -> bool {
self.standalone || (self.is_managed() && self.markers().implementation_name() == "cpython")
}
/// Return the [`Layout`] environment used to install wheels into this interpreter.
pub fn layout(&self) -> Layout {
Layout {
@ -608,6 +633,29 @@ impl Interpreter {
}
}
/// Calls `fs_err::canonicalize` on Unix. On Windows, avoids attempting to resolve symlinks
/// but will resolve junctions if they are part of a trampoline target.
pub fn canonicalize_executable(path: impl AsRef<Path>) -> std::io::Result<PathBuf> {
let path = path.as_ref();
debug_assert!(
path.is_absolute(),
"path must be absolute: {}",
path.display()
);
#[cfg(windows)]
{
if let Ok(Some(launcher)) = uv_trampoline_builder::Launcher::try_from_path(path) {
Ok(dunce::canonicalize(launcher.python_path)?)
} else {
Ok(path.to_path_buf())
}
}
#[cfg(unix)]
fs_err::canonicalize(path)
}
/// The `EXTERNALLY-MANAGED` file in a Python installation.
///
/// See: <https://packaging.python.org/en/latest/specifications/externally-managed-environments/>
@ -935,7 +983,7 @@ impl InterpreterInfo {
// We check the timestamp of the canonicalized executable to check if an underlying
// interpreter has been modified.
let modified = uv_fs::canonicalize_executable(&absolute)
let modified = canonicalize_executable(&absolute)
.and_then(Timestamp::from_path)
.map_err(|err| {
if err.kind() == io::ErrorKind::NotFound {

View file

@ -11,9 +11,13 @@ pub use crate::discovery::{
};
pub use crate::downloads::PlatformRequest;
pub use crate::environment::{InvalidEnvironmentKind, PythonEnvironment};
pub use crate::implementation::ImplementationName;
pub use crate::installation::{PythonInstallation, PythonInstallationKey};
pub use crate::interpreter::{BrokenSymlink, Error as InterpreterError, Interpreter};
pub use crate::implementation::{ImplementationName, LenientImplementationName};
pub use crate::installation::{
PythonInstallation, PythonInstallationKey, PythonInstallationMinorVersionKey,
};
pub use crate::interpreter::{
BrokenSymlink, Error as InterpreterError, Interpreter, canonicalize_executable,
};
pub use crate::pointer_size::PointerSize;
pub use crate::prefix::Prefix;
pub use crate::python_version::PythonVersion;
@ -115,6 +119,7 @@ mod tests {
use indoc::{formatdoc, indoc};
use temp_env::with_vars;
use test_log::test;
use uv_configuration::PreviewMode;
use uv_static::EnvVars;
use uv_cache::Cache;
@ -447,6 +452,7 @@ mod tests {
EnvironmentPreference::OnlySystem,
PythonPreference::default(),
&context.cache,
PreviewMode::Disabled,
)
});
assert!(
@ -461,6 +467,7 @@ mod tests {
EnvironmentPreference::OnlySystem,
PythonPreference::default(),
&context.cache,
PreviewMode::Disabled,
)
});
assert!(
@ -485,6 +492,7 @@ mod tests {
EnvironmentPreference::OnlySystem,
PythonPreference::default(),
&context.cache,
PreviewMode::Disabled,
)
});
assert!(
@ -506,6 +514,7 @@ mod tests {
EnvironmentPreference::OnlySystem,
PythonPreference::default(),
&context.cache,
PreviewMode::Disabled,
)
})??;
assert!(
@ -567,6 +576,7 @@ mod tests {
EnvironmentPreference::OnlySystem,
PythonPreference::default(),
&context.cache,
PreviewMode::Disabled,
)
})??;
assert!(
@ -598,6 +608,7 @@ mod tests {
EnvironmentPreference::OnlySystem,
PythonPreference::default(),
&context.cache,
PreviewMode::Disabled,
)
});
assert!(
@ -634,6 +645,7 @@ mod tests {
EnvironmentPreference::OnlySystem,
PythonPreference::default(),
&context.cache,
PreviewMode::Disabled,
)
})??;
assert!(
@ -665,6 +677,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -686,6 +699,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -711,6 +725,7 @@ mod tests {
EnvironmentPreference::OnlySystem,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -736,6 +751,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -758,6 +774,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
@ -791,6 +808,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
@ -824,6 +842,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})?;
assert!(
@ -845,6 +864,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})?;
assert!(
@ -866,6 +886,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
@ -899,6 +920,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
@ -935,6 +957,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert!(
@ -965,6 +988,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert!(
@ -999,6 +1023,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -1024,6 +1049,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -1050,6 +1076,7 @@ mod tests {
EnvironmentPreference::OnlyVirtual,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
},
)??;
@ -1074,6 +1101,7 @@ mod tests {
EnvironmentPreference::OnlyVirtual,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
},
)?;
@ -1095,6 +1123,7 @@ mod tests {
EnvironmentPreference::OnlySystem,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
},
)??;
@ -1117,6 +1146,7 @@ mod tests {
EnvironmentPreference::OnlyVirtual,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
},
)??;
@ -1149,6 +1179,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
},
)??;
@ -1169,6 +1200,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
},
)??;
@ -1195,6 +1227,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
@ -1212,6 +1245,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
@ -1240,6 +1274,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -1277,6 +1312,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
},
)??;
@ -1304,6 +1340,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
},
)??;
@ -1328,6 +1365,7 @@ mod tests {
EnvironmentPreference::ExplicitSystem,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
},
)??;
@ -1352,6 +1390,7 @@ mod tests {
EnvironmentPreference::OnlySystem,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
},
)??;
@ -1376,6 +1415,7 @@ mod tests {
EnvironmentPreference::OnlyVirtual,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
},
)??;
@ -1413,6 +1453,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
},
)??;
@ -1440,6 +1481,7 @@ mod tests {
EnvironmentPreference::OnlySystem,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -1456,6 +1498,7 @@ mod tests {
EnvironmentPreference::OnlySystem,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -1472,6 +1515,7 @@ mod tests {
EnvironmentPreference::OnlySystem,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})?;
assert!(
@ -1493,6 +1537,7 @@ mod tests {
EnvironmentPreference::OnlyVirtual,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})?;
assert!(
@ -1509,6 +1554,7 @@ mod tests {
EnvironmentPreference::OnlySystem,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
},
)?;
@ -1530,6 +1576,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -1544,6 +1591,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})?;
assert!(
@ -1557,6 +1605,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})?;
assert!(
@ -1585,6 +1634,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -1600,6 +1650,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -1629,6 +1680,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -1644,6 +1696,7 @@ mod tests {
EnvironmentPreference::ExplicitSystem,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -1659,6 +1712,7 @@ mod tests {
EnvironmentPreference::OnlyVirtual,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -1674,6 +1728,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -1697,6 +1752,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -1711,6 +1767,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -1734,6 +1791,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -1753,6 +1811,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
},
)??;
@ -1781,6 +1840,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -1802,6 +1862,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})?;
assert!(
@ -1831,6 +1892,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -1846,6 +1908,7 @@ mod tests {
EnvironmentPreference::ExplicitSystem,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})?;
assert!(
@ -1872,6 +1935,7 @@ mod tests {
EnvironmentPreference::ExplicitSystem,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})
.unwrap()
@ -1896,6 +1960,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})?;
assert!(
@ -1912,6 +1977,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -1926,6 +1992,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -1951,6 +2018,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -1965,6 +2033,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -1990,6 +2059,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -2016,6 +2086,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -2042,6 +2113,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -2068,6 +2140,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -2094,6 +2167,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -2121,6 +2195,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})?;
assert!(
@ -2142,6 +2217,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -2156,6 +2232,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -2181,6 +2258,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -2195,6 +2273,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
assert_eq!(
@ -2232,6 +2311,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})
.unwrap()
@ -2249,6 +2329,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})
.unwrap()
@ -2290,6 +2371,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})
.unwrap()
@ -2307,6 +2389,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})
.unwrap()
@ -2343,6 +2426,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})
.unwrap()
@ -2365,6 +2449,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})
.unwrap()
@ -2387,6 +2472,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})
.unwrap()
@ -2425,6 +2511,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;
@ -2477,6 +2564,7 @@ mod tests {
EnvironmentPreference::Any,
PythonPreference::OnlySystem,
&context.cache,
PreviewMode::Disabled,
)
})??;

View file

@ -2,6 +2,8 @@ use core::fmt;
use std::cmp::Reverse;
use std::ffi::OsStr;
use std::io::{self, Write};
#[cfg(windows)]
use std::os::windows::fs::MetadataExt;
use std::path::{Path, PathBuf};
use std::str::FromStr;
@ -10,8 +12,11 @@ use itertools::Itertools;
use same_file::is_same_file;
use thiserror::Error;
use tracing::{debug, warn};
use uv_configuration::PreviewMode;
#[cfg(windows)]
use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT;
use uv_fs::{LockedFile, Simplified, symlink_or_copy_file};
use uv_fs::{LockedFile, Simplified, replace_symlink, symlink_or_copy_file};
use uv_state::{StateBucket, StateStore};
use uv_static::EnvVars;
use uv_trampoline_builder::{Launcher, windows_python_launcher};
@ -25,7 +30,9 @@ use crate::libc::LibcDetectionError;
use crate::platform::Error as PlatformError;
use crate::platform::{Arch, Libc, Os};
use crate::python_version::PythonVersion;
use crate::{PythonRequest, PythonVariant, macos_dylib, sysconfig};
use crate::{
PythonInstallationMinorVersionKey, PythonRequest, PythonVariant, macos_dylib, sysconfig,
};
#[derive(Error, Debug)]
pub enum Error {
@ -51,6 +58,8 @@ pub enum Error {
},
#[error("Missing expected Python executable at {}", _0.user_display())]
MissingExecutable(PathBuf),
#[error("Missing expected target directory for Python minor version link at {}", _0.user_display())]
MissingPythonMinorVersionLinkTargetDirectory(PathBuf),
#[error("Failed to create canonical Python executable at {} from {}", to.user_display(), from.user_display())]
CanonicalizeExecutable {
from: PathBuf,
@ -65,6 +74,13 @@ pub enum Error {
#[source]
err: io::Error,
},
#[error("Failed to create Python minor version link directory at {} from {}", to.user_display(), from.user_display())]
PythonMinorVersionLinkDirectory {
from: PathBuf,
to: PathBuf,
#[source]
err: io::Error,
},
#[error("Failed to create directory for Python executable link at {}", to.user_display())]
ExecutableDirectory {
to: PathBuf,
@ -339,7 +355,7 @@ impl ManagedPythonInstallation {
/// The path to this managed installation's Python executable.
///
/// If the installation has multiple execututables i.e., `python`, `python3`, etc., this will
/// If the installation has multiple executables i.e., `python`, `python3`, etc., this will
/// return the _canonical_ executable name which the other names link to. On Unix, this is
/// `python{major}.{minor}{variant}` and on Windows, this is `python{exe}`.
///
@ -383,13 +399,11 @@ impl ManagedPythonInstallation {
exe = std::env::consts::EXE_SUFFIX
);
let executable = if cfg!(unix) || *self.implementation() == ImplementationName::GraalPy {
self.python_dir().join("bin").join(name)
} else if cfg!(windows) {
self.python_dir().join(name)
} else {
unimplemented!("Only Windows and Unix systems are supported.")
};
let executable = executable_path_from_base(
self.python_dir().as_path(),
&name,
&LenientImplementationName::from(*self.implementation()),
);
// Workaround for python-build-standalone v20241016 which is missing the standard
// `python.exe` executable in free-threaded distributions on Windows.
@ -442,6 +456,10 @@ impl ManagedPythonInstallation {
&self.key
}
pub fn minor_version_key(&self) -> &PythonInstallationMinorVersionKey {
PythonInstallationMinorVersionKey::ref_cast(&self.key)
}
pub fn satisfies(&self, request: &PythonRequest) -> bool {
match request {
PythonRequest::File(path) => self.executable(false) == *path,
@ -503,6 +521,30 @@ impl ManagedPythonInstallation {
Ok(())
}
/// Ensure the environment contains the symlink directory (or junction on Windows)
/// pointing to the patch directory for this minor version.
pub fn ensure_minor_version_link(&self, preview: PreviewMode) -> Result<(), Error> {
if let Some(minor_version_link) = PythonMinorVersionLink::from_installation(self, preview) {
minor_version_link.create_directory()?;
}
Ok(())
}
/// If the environment contains a symlink directory (or junction on Windows),
/// update it to the latest patch directory for this minor version.
///
/// Unlike [`ensure_minor_version_link`], will not create a new symlink directory
/// if one doesn't already exist,
pub fn update_minor_version_link(&self, preview: PreviewMode) -> Result<(), Error> {
if let Some(minor_version_link) = PythonMinorVersionLink::from_installation(self, preview) {
if !minor_version_link.exists() {
return Ok(());
}
minor_version_link.create_directory()?;
}
Ok(())
}
/// Ensure the environment is marked as externally managed with the
/// standard `EXTERNALLY-MANAGED` file.
pub fn ensure_externally_managed(&self) -> Result<(), Error> {
@ -567,54 +609,8 @@ impl ManagedPythonInstallation {
Ok(())
}
/// Create a link to the managed Python executable.
///
/// If the file already exists at the target path, an error will be returned.
pub fn create_bin_link(&self, target: &Path) -> Result<(), Error> {
let python = self.executable(false);
let bin = target.parent().ok_or(Error::NoExecutableDirectory)?;
fs_err::create_dir_all(bin).map_err(|err| Error::ExecutableDirectory {
to: bin.to_path_buf(),
err,
})?;
if cfg!(unix) {
// Note this will never copy on Unix — we use it here to allow compilation on Windows
match symlink_or_copy_file(&python, target) {
Ok(()) => Ok(()),
Err(err) if err.kind() == io::ErrorKind::NotFound => {
Err(Error::MissingExecutable(python.clone()))
}
Err(err) => Err(Error::LinkExecutable {
from: python,
to: target.to_path_buf(),
err,
}),
}
} else if cfg!(windows) {
// TODO(zanieb): Install GUI launchers as well
let launcher = windows_python_launcher(&python, false)?;
// OK to use `std::fs` here, `fs_err` does not support `File::create_new` and we attach
// error context anyway
#[allow(clippy::disallowed_types)]
{
std::fs::File::create_new(target)
.and_then(|mut file| file.write_all(launcher.as_ref()))
.map_err(|err| Error::LinkExecutable {
from: python,
to: target.to_path_buf(),
err,
})
}
} else {
unimplemented!("Only Windows and Unix systems are supported.")
}
}
/// Returns `true` if the path is a link to this installation's binary, e.g., as created by
/// [`ManagedPythonInstallation::create_bin_link`].
/// [`create_bin_link`].
pub fn is_bin_link(&self, path: &Path) -> bool {
if cfg!(unix) {
is_same_file(path, self.executable(false)).unwrap_or_default()
@ -625,7 +621,11 @@ impl ManagedPythonInstallation {
if !matches!(launcher.kind, uv_trampoline_builder::LauncherKind::Python) {
return false;
}
launcher.python_path == self.executable(false)
// We canonicalize the target path of the launcher in case it includes a minor version
// junction directory. If canonicalization fails, we check against the launcher path
// directly.
dunce::canonicalize(&launcher.python_path).unwrap_or(launcher.python_path)
== self.executable(false)
} else {
unreachable!("Only Windows and Unix are supported")
}
@ -669,6 +669,229 @@ impl ManagedPythonInstallation {
}
}
/// A representation of a minor version symlink directory (or junction on Windows)
/// linking to the home directory of a Python installation.
#[derive(Clone, Debug)]
pub struct PythonMinorVersionLink {
/// The symlink directory (or junction on Windows).
pub symlink_directory: PathBuf,
/// The full path to the executable including the symlink directory
/// (or junction on Windows).
pub symlink_executable: PathBuf,
/// The target directory for the symlink. This is the home directory for
/// a Python installation.
pub target_directory: PathBuf,
}
impl PythonMinorVersionLink {
/// Attempt to derive a path from an executable path that substitutes a minor
/// version symlink directory (or junction on Windows) for the patch version
/// directory.
///
/// The implementation is expected to be CPython and, on Unix, the base Python is
/// expected to be in `<home>/bin/` on Unix. If either condition isn't true,
/// return [`None`].
///
/// # Examples
///
/// ## Unix
/// For a Python 3.10.8 installation in `/path/to/uv/python/cpython-3.10.8-macos-aarch64-none/bin/python3.10`,
/// the symlink directory would be `/path/to/uv/python/cpython-3.10-macos-aarch64-none` and the executable path including the
/// symlink directory would be `/path/to/uv/python/cpython-3.10-macos-aarch64-none/bin/python3.10`.
///
/// ## Windows
/// For a Python 3.10.8 installation in `C:\path\to\uv\python\cpython-3.10.8-windows-x86_64-none\python.exe`,
/// the junction would be `C:\path\to\uv\python\cpython-3.10-windows-x86_64-none` and the executable path including the
/// junction would be `C:\path\to\uv\python\cpython-3.10-windows-x86_64-none\python.exe`.
pub fn from_executable(
executable: &Path,
key: &PythonInstallationKey,
preview: PreviewMode,
) -> Option<Self> {
let implementation = key.implementation();
if !matches!(
implementation,
LenientImplementationName::Known(ImplementationName::CPython)
) {
// We don't currently support transparent upgrades for PyPy or GraalPy.
return None;
}
let executable_name = executable
.file_name()
.expect("Executable file name should exist");
let symlink_directory_name = PythonInstallationMinorVersionKey::ref_cast(key).to_string();
let parent = executable
.parent()
.expect("Executable should have parent directory");
// The home directory of the Python installation
let target_directory = if cfg!(unix) {
if parent
.components()
.next_back()
.is_some_and(|c| c.as_os_str() == "bin")
{
parent.parent()?.to_path_buf()
} else {
return None;
}
} else if cfg!(windows) {
parent.to_path_buf()
} else {
unimplemented!("Only Windows and Unix systems are supported.")
};
let symlink_directory = target_directory.with_file_name(symlink_directory_name);
// If this would create a circular link, return `None`.
if target_directory == symlink_directory {
return None;
}
// The full executable path including the symlink directory (or junction).
let symlink_executable = executable_path_from_base(
symlink_directory.as_path(),
&executable_name.to_string_lossy(),
implementation,
);
let minor_version_link = Self {
symlink_directory,
symlink_executable,
target_directory,
};
// If preview mode is disabled, still return a `MinorVersionSymlink` for
// existing symlinks, allowing continued operations without the `--preview`
// flag after initial symlink directory installation.
if preview.is_disabled() && !minor_version_link.exists() {
return None;
}
Some(minor_version_link)
}
pub fn from_installation(
installation: &ManagedPythonInstallation,
preview: PreviewMode,
) -> Option<Self> {
PythonMinorVersionLink::from_executable(
installation.executable(false).as_path(),
installation.key(),
preview,
)
}
pub fn create_directory(&self) -> Result<(), Error> {
match replace_symlink(
self.target_directory.as_path(),
self.symlink_directory.as_path(),
) {
Ok(()) => {
debug!(
"Created link {} -> {}",
&self.symlink_directory.user_display(),
&self.target_directory.user_display(),
);
}
Err(err) if err.kind() == io::ErrorKind::NotFound => {
return Err(Error::MissingPythonMinorVersionLinkTargetDirectory(
self.target_directory.clone(),
));
}
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {}
Err(err) => {
return Err(Error::PythonMinorVersionLinkDirectory {
from: self.symlink_directory.clone(),
to: self.target_directory.clone(),
err,
});
}
}
Ok(())
}
pub fn exists(&self) -> bool {
#[cfg(unix)]
{
self.symlink_directory
.symlink_metadata()
.map(|metadata| metadata.file_type().is_symlink())
.unwrap_or(false)
}
#[cfg(windows)]
{
self.symlink_directory
.symlink_metadata()
.is_ok_and(|metadata| {
// Check that this is a reparse point, which indicates this
// is a symlink or junction.
(metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT) != 0
})
}
}
}
/// Derive the full path to an executable from the given base path and executable
/// name. On Unix, this is, e.g., `<base>/bin/python3.10`. On Windows, this is,
/// e.g., `<base>\python.exe`.
fn executable_path_from_base(
base: &Path,
executable_name: &str,
implementation: &LenientImplementationName,
) -> PathBuf {
if cfg!(unix)
|| matches!(
implementation,
&LenientImplementationName::Known(ImplementationName::GraalPy)
)
{
base.join("bin").join(executable_name)
} else if cfg!(windows) {
base.join(executable_name)
} else {
unimplemented!("Only Windows and Unix systems are supported.")
}
}
/// Create a link to a managed Python executable.
///
/// If the file already exists at the link path, an error will be returned.
pub fn create_link_to_executable(link: &Path, executable: PathBuf) -> Result<(), Error> {
let link_parent = link.parent().ok_or(Error::NoExecutableDirectory)?;
fs_err::create_dir_all(link_parent).map_err(|err| Error::ExecutableDirectory {
to: link_parent.to_path_buf(),
err,
})?;
if cfg!(unix) {
// Note this will never copy on Unix — we use it here to allow compilation on Windows
match symlink_or_copy_file(&executable, link) {
Ok(()) => Ok(()),
Err(err) if err.kind() == io::ErrorKind::NotFound => {
Err(Error::MissingExecutable(executable.clone()))
}
Err(err) => Err(Error::LinkExecutable {
from: executable,
to: link.to_path_buf(),
err,
}),
}
} else if cfg!(windows) {
// TODO(zanieb): Install GUI launchers as well
let launcher = windows_python_launcher(&executable, false)?;
// OK to use `std::fs` here, `fs_err` does not support `File::create_new` and we attach
// error context anyway
#[allow(clippy::disallowed_types)]
{
std::fs::File::create_new(link)
.and_then(|mut file| file.write_all(launcher.as_ref()))
.map_err(|err| Error::LinkExecutable {
from: executable,
to: link.to_path_buf(),
err,
})
}
} else {
unimplemented!("Only Windows and Unix systems are supported.")
}
}
// TODO(zanieb): Only used in tests now.
/// Generate a platform portion of a key from the environment.
pub fn platform_key_from_env() -> Result<String, Error> {

View file

@ -5,7 +5,7 @@ use std::str::FromStr;
use uv_pep440::Version;
use uv_pep508::{MarkerEnvironment, StringVersion};
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct PythonVersion(StringVersion);
impl From<StringVersion> for PythonVersion {

View file

@ -17,6 +17,7 @@ workspace = true
[dependencies]
uv-cache = { workspace = true }
uv-configuration = { workspace = true }
uv-dirs = { workspace = true }
uv-distribution-types = { workspace = true }
uv-fs = { workspace = true }

View file

@ -1,6 +1,7 @@
use core::fmt;
use fs_err as fs;
use uv_configuration::PreviewMode;
use uv_dirs::user_executable_directory;
use uv_pep440::Version;
use uv_pep508::{InvalidNameError, PackageName};
@ -257,6 +258,7 @@ impl InstalledTools {
&self,
name: &PackageName,
interpreter: Interpreter,
preview: PreviewMode,
) -> Result<PythonEnvironment, Error> {
let environment_path = self.tool_dir(name);
@ -286,6 +288,8 @@ impl InstalledTools {
false,
false,
false,
false,
preview,
)?;
Ok(venv)

View file

@ -78,7 +78,34 @@ fn make_child_cmdline() -> CString {
// Only execute the trampoline again if it's a script, otherwise, just invoke Python.
match kind {
TrampolineKind::Python => {}
TrampolineKind::Python => {
// SAFETY: `std::env::set_var` is safe to call on Windows, and
// this code only ever runs on Windows.
unsafe {
// Setting this env var will cause `getpath.py` to set
// `executable` to the path to this trampoline. This is
// the approach taken by CPython for Python Launchers
// (in `launcher.c`). This allows virtual environments to
// be correctly detected when using trampolines.
std::env::set_var("__PYVENV_LAUNCHER__", &executable_name);
// If this is not a virtual environment and `PYTHONHOME` has
// not been set, then set `PYTHONHOME` to the parent directory of
// the executable. This ensures that the correct installation
// directories are added to `sys.path` when running with a junction
// trampoline.
let python_home_set =
std::env::var("PYTHONHOME").is_ok_and(|home| !home.is_empty());
if !is_virtualenv(python_exe.as_path()) && !python_home_set {
std::env::set_var(
"PYTHONHOME",
python_exe
.parent()
.expect("Python executable should have a parent directory"),
);
}
}
}
TrampolineKind::Script => {
// Use the full executable name because CMD only passes the name of the executable (but not the path)
// when e.g. invoking `black` instead of `<PATH_TO_VENV>/Scripts/black` and Python then fails
@ -118,6 +145,20 @@ fn push_quoted_path(path: &Path, command: &mut Vec<u8>) {
command.extend(br#"""#);
}
/// Checks if the given executable is part of a virtual environment
///
/// Checks if a `pyvenv.cfg` file exists in grandparent directory of the given executable.
/// PEP 405 specifies a more robust procedure (checking both the parent and grandparent
/// directory and then scanning for a `home` key), but in practice we have found this to
/// be unnecessary.
fn is_virtualenv(executable: &Path) -> bool {
executable
.parent()
.and_then(Path::parent)
.map(|path| path.join("pyvenv.cfg").is_file())
.unwrap_or(false)
}
/// Reads the executable binary from the back to find:
///
/// * The path to the Python executable
@ -240,10 +281,18 @@ fn read_trampoline_metadata(executable_name: &Path) -> (TrampolineKind, PathBuf)
parent_dir.join(path)
};
// NOTICE: dunce adds 5kb~
let path = dunce::canonicalize(path.as_path()).unwrap_or_else(|_| {
error_and_exit("Failed to canonicalize script path");
});
let path = if !path.is_absolute() || matches!(kind, TrampolineKind::Script) {
// NOTICE: dunce adds 5kb~
// TODO(john): In order to avoid resolving junctions and symlinks for relative paths and
// scripts, we can consider reverting https://github.com/astral-sh/uv/pull/5750/files#diff-969979506be03e89476feade2edebb4689a9c261f325988d3c7efc5e51de26d1L273-L277.
dunce::canonicalize(path.as_path()).unwrap_or_else(|_| {
error_and_exit("Failed to canonicalize script path");
})
} else {
// For Python trampolines with absolute paths, we skip `dunce::canonicalize` to
// avoid resolving junctions.
path
};
(kind, path)
}

View file

@ -20,6 +20,7 @@ doctest = false
workspace = true
[dependencies]
uv-configuration = { workspace = true }
uv-fs = { workspace = true }
uv-pypi-types = { workspace = true }
uv-python = { workspace = true }

View file

@ -3,6 +3,7 @@ use std::path::Path;
use thiserror::Error;
use uv_configuration::PreviewMode;
use uv_python::{Interpreter, PythonEnvironment};
mod virtualenv;
@ -15,6 +16,8 @@ pub enum Error {
"Could not find a suitable Python executable for the virtual environment based on the interpreter: {0}"
)]
NotFound(String),
#[error(transparent)]
Python(#[from] uv_python::managed::Error),
}
/// The value to use for the shell prompt when inside a virtual environment.
@ -50,6 +53,8 @@ pub fn create_venv(
allow_existing: bool,
relocatable: bool,
seed: bool,
upgradeable: bool,
preview: PreviewMode,
) -> Result<PythonEnvironment, Error> {
// Create the virtualenv at the given location.
let virtualenv = virtualenv::create(
@ -60,6 +65,8 @@ pub fn create_venv(
allow_existing,
relocatable,
seed,
upgradeable,
preview,
)?;
// Create the corresponding `PythonEnvironment`.

View file

@ -10,8 +10,10 @@ use fs_err::File;
use itertools::Itertools;
use tracing::debug;
use uv_configuration::PreviewMode;
use uv_fs::{CWD, Simplified, cachedir};
use uv_pypi_types::Scheme;
use uv_python::managed::{PythonMinorVersionLink, create_link_to_executable};
use uv_python::{Interpreter, VirtualEnvironment};
use uv_shell::escape_posix_for_single_quotes;
use uv_version::version;
@ -53,6 +55,8 @@ pub(crate) fn create(
allow_existing: bool,
relocatable: bool,
seed: bool,
upgradeable: bool,
preview: PreviewMode,
) -> Result<VirtualEnvironment, Error> {
// Determine the base Python executable; that is, the Python executable that should be
// considered the "base" for the virtual environment.
@ -143,13 +147,49 @@ pub(crate) fn create(
// Create a `.gitignore` file to ignore all files in the venv.
fs::write(location.join(".gitignore"), "*")?;
let executable_target = if upgradeable && interpreter.is_standalone() {
if let Some(minor_version_link) = PythonMinorVersionLink::from_executable(
base_python.as_path(),
&interpreter.key(),
preview,
) {
if !minor_version_link.exists() {
base_python.clone()
} else {
let debug_symlink_term = if cfg!(windows) {
"junction"
} else {
"symlink directory"
};
debug!(
"Using {} {} instead of base Python path: {}",
debug_symlink_term,
&minor_version_link.symlink_directory.display(),
&base_python.display()
);
minor_version_link.symlink_executable.clone()
}
} else {
base_python.clone()
}
} else {
base_python.clone()
};
// Per PEP 405, the Python `home` is the parent directory of the interpreter.
let python_home = base_python.parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"The Python interpreter needs to have a parent directory",
)
})?;
// In preview mode, for standalone interpreters, this `home` value will include a
// symlink directory on Unix or junction on Windows to enable transparent Python patch
// upgrades.
let python_home = executable_target
.parent()
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"The Python interpreter needs to have a parent directory",
)
})?
.to_path_buf();
let python_home = python_home.as_path();
// Different names for the python interpreter
fs::create_dir_all(&scripts)?;
@ -157,7 +197,7 @@ pub(crate) fn create(
#[cfg(unix)]
{
uv_fs::replace_symlink(&base_python, &executable)?;
uv_fs::replace_symlink(&executable_target, &executable)?;
uv_fs::replace_symlink(
"python",
scripts.join(format!("python{}", interpreter.python_major())),
@ -184,91 +224,102 @@ pub(crate) fn create(
}
}
// No symlinking on Windows, at least not on a regular non-dev non-admin Windows install.
// On Windows, we use trampolines that point to an executable target. For standalone
// interpreters, this target path includes a minor version junction to enable
// transparent upgrades.
if cfg!(windows) {
copy_launcher_windows(
WindowsExecutable::Python,
interpreter,
&base_python,
&scripts,
python_home,
)?;
if interpreter.markers().implementation_name() == "graalpy" {
copy_launcher_windows(
WindowsExecutable::GraalPy,
interpreter,
&base_python,
&scripts,
python_home,
)?;
copy_launcher_windows(
WindowsExecutable::PythonMajor,
interpreter,
&base_python,
&scripts,
python_home,
)?;
if interpreter.is_standalone() {
let target = scripts.join(WindowsExecutable::Python.exe(interpreter));
create_link_to_executable(target.as_path(), executable_target.clone())
.map_err(Error::Python)?;
let targetw = scripts.join(WindowsExecutable::Pythonw.exe(interpreter));
create_link_to_executable(targetw.as_path(), executable_target)
.map_err(Error::Python)?;
} else {
copy_launcher_windows(
WindowsExecutable::Pythonw,
WindowsExecutable::Python,
interpreter,
&base_python,
&scripts,
python_home,
)?;
}
if interpreter.markers().implementation_name() == "pypy" {
copy_launcher_windows(
WindowsExecutable::PythonMajor,
interpreter,
&base_python,
&scripts,
python_home,
)?;
copy_launcher_windows(
WindowsExecutable::PythonMajorMinor,
interpreter,
&base_python,
&scripts,
python_home,
)?;
copy_launcher_windows(
WindowsExecutable::PyPy,
interpreter,
&base_python,
&scripts,
python_home,
)?;
copy_launcher_windows(
WindowsExecutable::PyPyMajor,
interpreter,
&base_python,
&scripts,
python_home,
)?;
copy_launcher_windows(
WindowsExecutable::PyPyMajorMinor,
interpreter,
&base_python,
&scripts,
python_home,
)?;
copy_launcher_windows(
WindowsExecutable::PyPyw,
interpreter,
&base_python,
&scripts,
python_home,
)?;
copy_launcher_windows(
WindowsExecutable::PyPyMajorMinorw,
interpreter,
&base_python,
&scripts,
python_home,
)?;
if interpreter.markers().implementation_name() == "graalpy" {
copy_launcher_windows(
WindowsExecutable::GraalPy,
interpreter,
&base_python,
&scripts,
python_home,
)?;
copy_launcher_windows(
WindowsExecutable::PythonMajor,
interpreter,
&base_python,
&scripts,
python_home,
)?;
} else {
copy_launcher_windows(
WindowsExecutable::Pythonw,
interpreter,
&base_python,
&scripts,
python_home,
)?;
}
if interpreter.markers().implementation_name() == "pypy" {
copy_launcher_windows(
WindowsExecutable::PythonMajor,
interpreter,
&base_python,
&scripts,
python_home,
)?;
copy_launcher_windows(
WindowsExecutable::PythonMajorMinor,
interpreter,
&base_python,
&scripts,
python_home,
)?;
copy_launcher_windows(
WindowsExecutable::PyPy,
interpreter,
&base_python,
&scripts,
python_home,
)?;
copy_launcher_windows(
WindowsExecutable::PyPyMajor,
interpreter,
&base_python,
&scripts,
python_home,
)?;
copy_launcher_windows(
WindowsExecutable::PyPyMajorMinor,
interpreter,
&base_python,
&scripts,
python_home,
)?;
copy_launcher_windows(
WindowsExecutable::PyPyw,
interpreter,
&base_python,
&scripts,
python_home,
)?;
copy_launcher_windows(
WindowsExecutable::PyPyMajorMinorw,
interpreter,
&base_python,
&scripts,
python_home,
)?;
}
}
}

View file

@ -70,10 +70,12 @@ clap = { workspace = true, features = ["derive", "string", "wrap_help"] }
console = { workspace = true }
ctrlc = { workspace = true }
dotenvy = { workspace = true }
dunce = { workspace = true }
flate2 = { workspace = true, default-features = false }
fs-err = { workspace = true, features = ["tokio"] }
futures = { workspace = true }
http = { workspace = true }
indexmap = { workspace = true }
indicatif = { workspace = true }
indoc = { workspace = true }
itertools = { workspace = true }

View file

@ -499,6 +499,7 @@ async fn build_package(
install_mirrors.python_install_mirror.as_deref(),
install_mirrors.pypy_install_mirror.as_deref(),
install_mirrors.python_downloads_json_url.as_deref(),
preview,
)
.await?
.into_interpreter();

View file

@ -5,6 +5,7 @@ use anyhow::Result;
use owo_colors::OwoColorize;
use uv_cache::Cache;
use uv_configuration::PreviewMode;
use uv_distribution_types::{Diagnostic, InstalledDist};
use uv_installer::{SitePackages, SitePackagesDiagnostic};
use uv_python::{EnvironmentPreference, PythonEnvironment, PythonRequest};
@ -19,6 +20,7 @@ pub(crate) fn pip_check(
system: bool,
cache: &Cache,
printer: Printer,
preview: PreviewMode,
) -> Result<ExitStatus> {
let start = Instant::now();
@ -27,6 +29,7 @@ pub(crate) fn pip_check(
&python.map(PythonRequest::parse).unwrap_or_default(),
EnvironmentPreference::from_system_flag(system, false),
cache,
preview,
)?;
report_target_environment(&environment, cache, printer)?;

View file

@ -271,7 +271,13 @@ pub(crate) async fn pip_compile(
let environment_preference = EnvironmentPreference::from_system_flag(system, false);
let interpreter = if let Some(python) = python.as_ref() {
let request = PythonRequest::parse(python);
PythonInstallation::find(&request, environment_preference, python_preference, &cache)
PythonInstallation::find(
&request,
environment_preference,
python_preference,
&cache,
preview,
)
} else {
// TODO(zanieb): The split here hints at a problem with the request abstraction; we should
// be able to use `PythonInstallation::find(...)` here.
@ -281,7 +287,13 @@ pub(crate) async fn pip_compile(
} else {
PythonRequest::default()
};
PythonInstallation::find_best(&request, environment_preference, python_preference, &cache)
PythonInstallation::find_best(
&request,
environment_preference,
python_preference,
&cache,
preview,
)
}?
.into_interpreter();

View file

@ -6,6 +6,7 @@ use itertools::Itertools;
use owo_colors::OwoColorize;
use uv_cache::Cache;
use uv_configuration::PreviewMode;
use uv_distribution_types::{Diagnostic, InstalledDist, Name};
use uv_installer::SitePackages;
use uv_python::{EnvironmentPreference, PythonEnvironment, PythonRequest};
@ -23,12 +24,14 @@ pub(crate) fn pip_freeze(
paths: Option<Vec<PathBuf>>,
cache: &Cache,
printer: Printer,
preview: PreviewMode,
) -> Result<ExitStatus> {
// Detect the current Python interpreter.
let environment = PythonEnvironment::find(
&python.map(PythonRequest::parse).unwrap_or_default(),
EnvironmentPreference::from_system_flag(system, false),
cache,
preview,
)?;
report_target_environment(&environment, cache, printer)?;

View file

@ -182,6 +182,7 @@ pub(crate) async fn pip_install(
EnvironmentPreference::from_system_flag(system, false),
python_preference,
&cache,
preview,
)?;
report_interpreter(&installation, true, printer)?;
PythonEnvironment::from_installation(installation)
@ -193,6 +194,7 @@ pub(crate) async fn pip_install(
.unwrap_or_default(),
EnvironmentPreference::from_system_flag(system, true),
&cache,
preview,
)?;
report_target_environment(&environment, &cache, printer)?;
environment

View file

@ -15,7 +15,7 @@ use uv_cache::{Cache, Refresh};
use uv_cache_info::Timestamp;
use uv_cli::ListFormat;
use uv_client::{BaseClientBuilder, RegistryClientBuilder};
use uv_configuration::{Concurrency, IndexStrategy, KeyringProviderType};
use uv_configuration::{Concurrency, IndexStrategy, KeyringProviderType, PreviewMode};
use uv_distribution_filename::DistFilename;
use uv_distribution_types::{
Diagnostic, IndexCapabilities, IndexLocations, InstalledDist, Name, RequiresPython,
@ -54,6 +54,7 @@ pub(crate) async fn pip_list(
system: bool,
cache: &Cache,
printer: Printer,
preview: PreviewMode,
) -> Result<ExitStatus> {
// Disallow `--outdated` with `--format freeze`.
if outdated && matches!(format, ListFormat::Freeze) {
@ -65,6 +66,7 @@ pub(crate) async fn pip_list(
&python.map(PythonRequest::parse).unwrap_or_default(),
EnvironmentPreference::from_system_flag(system, false),
cache,
preview,
)?;
report_target_environment(&environment, cache, printer)?;

View file

@ -7,6 +7,7 @@ use owo_colors::OwoColorize;
use rustc_hash::FxHashMap;
use uv_cache::Cache;
use uv_configuration::PreviewMode;
use uv_distribution_types::{Diagnostic, Name};
use uv_fs::Simplified;
use uv_install_wheel::read_record_file;
@ -27,6 +28,7 @@ pub(crate) fn pip_show(
files: bool,
cache: &Cache,
printer: Printer,
preview: PreviewMode,
) -> Result<ExitStatus> {
if packages.is_empty() {
#[allow(clippy::print_stderr)]
@ -46,6 +48,7 @@ pub(crate) fn pip_show(
&python.map(PythonRequest::parse).unwrap_or_default(),
EnvironmentPreference::from_system_flag(system, false),
cache,
preview,
)?;
report_target_environment(&environment, cache, printer)?;

View file

@ -157,6 +157,7 @@ pub(crate) async fn pip_sync(
EnvironmentPreference::from_system_flag(system, false),
python_preference,
&cache,
preview,
)?;
report_interpreter(&installation, true, printer)?;
PythonEnvironment::from_installation(installation)
@ -168,6 +169,7 @@ pub(crate) async fn pip_sync(
.unwrap_or_default(),
EnvironmentPreference::from_system_flag(system, true),
&cache,
preview,
)?;
report_target_environment(&environment, &cache, printer)?;
environment

View file

@ -13,7 +13,7 @@ use tokio::sync::Semaphore;
use uv_cache::{Cache, Refresh};
use uv_cache_info::Timestamp;
use uv_client::{BaseClientBuilder, RegistryClientBuilder};
use uv_configuration::{Concurrency, IndexStrategy, KeyringProviderType};
use uv_configuration::{Concurrency, IndexStrategy, KeyringProviderType, PreviewMode};
use uv_distribution_types::{Diagnostic, IndexCapabilities, IndexLocations, Name, RequiresPython};
use uv_installer::SitePackages;
use uv_normalize::PackageName;
@ -52,12 +52,14 @@ pub(crate) async fn pip_tree(
system: bool,
cache: &Cache,
printer: Printer,
preview: PreviewMode,
) -> Result<ExitStatus> {
// Detect the current Python interpreter.
let environment = PythonEnvironment::find(
&python.map(PythonRequest::parse).unwrap_or_default(),
EnvironmentPreference::from_system_flag(system, false),
cache,
preview,
)?;
report_target_environment(&environment, cache, printer)?;

View file

@ -7,7 +7,7 @@ use tracing::debug;
use uv_cache::Cache;
use uv_client::BaseClientBuilder;
use uv_configuration::{DryRun, KeyringProviderType};
use uv_configuration::{DryRun, KeyringProviderType, PreviewMode};
use uv_distribution_types::Requirement;
use uv_distribution_types::{InstalledMetadata, Name, UnresolvedRequirement};
use uv_fs::Simplified;
@ -37,6 +37,7 @@ pub(crate) async fn pip_uninstall(
network_settings: &NetworkSettings,
dry_run: DryRun,
printer: Printer,
preview: PreviewMode,
) -> Result<ExitStatus> {
let start = std::time::Instant::now();
@ -57,6 +58,7 @@ pub(crate) async fn pip_uninstall(
.unwrap_or_default(),
EnvironmentPreference::from_system_flag(system, true),
&cache,
preview,
)?;
report_target_environment(&environment, &cache, printer)?;

View file

@ -195,6 +195,7 @@ pub(crate) async fn add(
&client_builder,
cache,
&reporter,
preview,
)
.await?;
Pep723Script::init(&path, requires_python.specifiers()).await?
@ -217,6 +218,7 @@ pub(crate) async fn add(
active,
cache,
printer,
preview,
)
.await?
.into_interpreter();
@ -286,6 +288,7 @@ pub(crate) async fn add(
active,
cache,
printer,
preview,
)
.await?
.into_interpreter();
@ -307,6 +310,7 @@ pub(crate) async fn add(
cache,
DryRun::Disabled,
printer,
preview,
)
.await?
.into_environment()?;

View file

@ -7,7 +7,7 @@ use uv_cache_key::{cache_digest, hash_digest};
use uv_configuration::{Concurrency, Constraints, PreviewMode};
use uv_distribution_types::{Name, Resolution};
use uv_fs::PythonExt;
use uv_python::{Interpreter, PythonEnvironment};
use uv_python::{Interpreter, PythonEnvironment, canonicalize_executable};
use crate::commands::pip::loggers::{InstallLogger, ResolveLogger};
use crate::commands::pip::operations::Modifications;
@ -74,7 +74,8 @@ impl CachedEnvironment {
// Hash the interpreter based on its path.
// TODO(charlie): Come up with a robust hash for the interpreter.
let interpreter_hash = cache_digest(&interpreter.sys_executable());
let interpreter_hash =
cache_digest(&canonicalize_executable(interpreter.sys_executable())?);
// Search in the content-addressed cache.
let cache_entry = cache.entry(CacheBucket::Environments, interpreter_hash, resolution_hash);
@ -97,6 +98,8 @@ impl CachedEnvironment {
false,
true,
false,
false,
preview,
)?;
sync_environment(

View file

@ -142,6 +142,7 @@ pub(crate) async fn export(
Some(false),
cache,
printer,
preview,
)
.await?
.into_interpreter(),
@ -159,6 +160,7 @@ pub(crate) async fn export(
Some(false),
cache,
printer,
preview,
)
.await?
.into_interpreter(),

View file

@ -87,6 +87,7 @@ pub(crate) async fn init(
pin_python,
package,
no_config,
preview,
)
.await?;
@ -202,6 +203,7 @@ async fn init_script(
pin_python: bool,
package: bool,
no_config: bool,
preview: PreviewMode,
) -> Result<()> {
if no_workspace {
warn_user_once!("`--no-workspace` is a no-op for Python scripts, which are standalone");
@ -258,6 +260,7 @@ async fn init_script(
&client_builder,
cache,
&reporter,
preview,
)
.await?;
@ -434,6 +437,7 @@ async fn init_project(
install_mirrors.python_install_mirror.as_deref(),
install_mirrors.pypy_install_mirror.as_deref(),
install_mirrors.python_downloads_json_url.as_deref(),
preview,
)
.await?
.into_interpreter();
@ -461,6 +465,7 @@ async fn init_project(
install_mirrors.python_install_mirror.as_deref(),
install_mirrors.pypy_install_mirror.as_deref(),
install_mirrors.python_downloads_json_url.as_deref(),
preview,
)
.await?
.into_interpreter();
@ -527,6 +532,7 @@ async fn init_project(
install_mirrors.python_install_mirror.as_deref(),
install_mirrors.pypy_install_mirror.as_deref(),
install_mirrors.python_downloads_json_url.as_deref(),
preview,
)
.await?
.into_interpreter();
@ -554,6 +560,7 @@ async fn init_project(
install_mirrors.python_install_mirror.as_deref(),
install_mirrors.pypy_install_mirror.as_deref(),
install_mirrors.python_downloads_json_url.as_deref(),
preview,
)
.await?
.into_interpreter();

View file

@ -114,6 +114,7 @@ pub(crate) async fn lock(
&client_builder,
cache,
&reporter,
preview,
)
.await?;
Some(Pep723Script::init(&path, requires_python.specifiers()).await?)
@ -155,6 +156,7 @@ pub(crate) async fn lock(
Some(false),
cache,
printer,
preview,
)
.await?
.into_interpreter(),
@ -170,6 +172,7 @@ pub(crate) async fn lock(
Some(false),
cache,
printer,
preview,
)
.await?
.into_interpreter(),

View file

@ -625,6 +625,7 @@ impl ScriptInterpreter {
active: Option<bool>,
cache: &Cache,
printer: Printer,
preview: PreviewMode,
) -> Result<Self, ProjectError> {
// For now, we assume that scripts are never evaluated in the context of a workspace.
let workspace = None;
@ -682,6 +683,7 @@ impl ScriptInterpreter {
install_mirrors.python_install_mirror.as_deref(),
install_mirrors.pypy_install_mirror.as_deref(),
install_mirrors.python_downloads_json_url.as_deref(),
preview,
)
.await?
.into_interpreter();
@ -841,6 +843,7 @@ impl ProjectInterpreter {
active: Option<bool>,
cache: &Cache,
printer: Printer,
preview: PreviewMode,
) -> Result<Self, ProjectError> {
// Resolve the Python request and requirement for the workspace.
let WorkspacePython {
@ -937,6 +940,7 @@ impl ProjectInterpreter {
install_mirrors.python_install_mirror.as_deref(),
install_mirrors.pypy_install_mirror.as_deref(),
install_mirrors.python_downloads_json_url.as_deref(),
preview,
)
.await?;
@ -1213,10 +1217,16 @@ impl ProjectEnvironment {
cache: &Cache,
dry_run: DryRun,
printer: Printer,
preview: PreviewMode,
) -> Result<Self, ProjectError> {
// Lock the project environment to avoid synchronization issues.
let _lock = ProjectInterpreter::lock(workspace).await?;
let upgradeable = preview.is_enabled()
&& python
.as_ref()
.is_none_or(|request| !request.includes_patch());
match ProjectInterpreter::discover(
workspace,
workspace.install_path().as_ref(),
@ -1231,6 +1241,7 @@ impl ProjectEnvironment {
active,
cache,
printer,
preview,
)
.await?
{
@ -1300,6 +1311,8 @@ impl ProjectEnvironment {
false,
false,
false,
upgradeable,
preview,
)?;
return Ok(if replace {
Self::WouldReplace(root, environment, temp_dir)
@ -1337,6 +1350,8 @@ impl ProjectEnvironment {
false,
false,
false,
upgradeable,
preview,
)?;
if replace {
@ -1420,9 +1435,13 @@ impl ScriptEnvironment {
cache: &Cache,
dry_run: DryRun,
printer: Printer,
preview: PreviewMode,
) -> Result<Self, ProjectError> {
// Lock the script environment to avoid synchronization issues.
let _lock = ScriptInterpreter::lock(script).await?;
let upgradeable = python_request
.as_ref()
.is_none_or(|request| !request.includes_patch());
match ScriptInterpreter::discover(
script,
@ -1436,6 +1455,7 @@ impl ScriptEnvironment {
active,
cache,
printer,
preview,
)
.await?
{
@ -1468,6 +1488,8 @@ impl ScriptEnvironment {
false,
false,
false,
upgradeable,
preview,
)?;
return Ok(if root.exists() {
Self::WouldReplace(root, environment, temp_dir)
@ -1502,6 +1524,8 @@ impl ScriptEnvironment {
false,
false,
false,
upgradeable,
preview,
)?;
Ok(if replaced {
@ -2333,6 +2357,7 @@ pub(crate) async fn init_script_python_requirement(
client_builder: &BaseClientBuilder<'_>,
cache: &Cache,
reporter: &PythonDownloadReporter,
preview: PreviewMode,
) -> anyhow::Result<RequiresPython> {
let python_request = if let Some(request) = python {
// (1) Explicit request from user
@ -2364,6 +2389,7 @@ pub(crate) async fn init_script_python_requirement(
install_mirrors.python_install_mirror.as_deref(),
install_mirrors.pypy_install_mirror.as_deref(),
install_mirrors.python_downloads_json_url.as_deref(),
preview,
)
.await?
.into_interpreter();

View file

@ -229,6 +229,7 @@ pub(crate) async fn remove(
active,
cache,
printer,
preview,
)
.await?
.into_interpreter();
@ -250,6 +251,7 @@ pub(crate) async fn remove(
cache,
DryRun::Disabled,
printer,
preview,
)
.await?
.into_environment()?;
@ -270,6 +272,7 @@ pub(crate) async fn remove(
active,
cache,
printer,
preview,
)
.await?
.into_interpreter();

View file

@ -235,6 +235,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl
cache,
DryRun::Disabled,
printer,
preview,
)
.await?
.into_environment()?;
@ -359,6 +360,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl
cache,
DryRun::Disabled,
printer,
preview,
)
.await?
.into_environment()?;
@ -433,6 +435,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl
active.map_or(Some(false), Some),
cache,
printer,
preview,
)
.await?
.into_interpreter();
@ -446,6 +449,8 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl
false,
false,
false,
false,
preview,
)?;
Some(environment.into_interpreter())
@ -624,6 +629,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl
install_mirrors.python_install_mirror.as_deref(),
install_mirrors.pypy_install_mirror.as_deref(),
install_mirrors.python_downloads_json_url.as_deref(),
preview,
)
.await?
.into_interpreter();
@ -648,6 +654,8 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl
false,
false,
false,
false,
preview,
)?
} else {
// If we're not isolating the environment, reuse the base environment for the
@ -666,6 +674,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl
cache,
DryRun::Disabled,
printer,
preview,
)
.await?
.into_environment()?
@ -850,6 +859,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl
install_mirrors.python_install_mirror.as_deref(),
install_mirrors.pypy_install_mirror.as_deref(),
install_mirrors.python_downloads_json_url.as_deref(),
preview,
)
.await?;
@ -869,6 +879,8 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl
false,
false,
false,
false,
preview,
)?;
venv.into_interpreter()
} else {

View file

@ -145,6 +145,7 @@ pub(crate) async fn sync(
cache,
dry_run,
printer,
preview,
)
.await?,
),
@ -162,6 +163,7 @@ pub(crate) async fn sync(
cache,
dry_run,
printer,
preview,
)
.await?,
),

View file

@ -97,6 +97,7 @@ pub(crate) async fn tree(
Some(false),
cache,
printer,
preview,
)
.await?
.into_interpreter(),
@ -114,6 +115,7 @@ pub(crate) async fn tree(
Some(false),
cache,
printer,
preview,
)
.await?
.into_interpreter(),

View file

@ -296,6 +296,7 @@ async fn print_frozen_version(
active,
cache,
printer,
preview,
)
.await?
.into_interpreter();
@ -403,6 +404,7 @@ async fn lock_and_sync(
active,
cache,
printer,
preview,
)
.await?
.into_interpreter();
@ -424,6 +426,7 @@ async fn lock_and_sync(
cache,
DryRun::Disabled,
printer,
preview,
)
.await?
.into_environment()?;

View file

@ -1,9 +1,9 @@
use anyhow::Result;
use std::fmt::Write;
use std::path::Path;
use uv_configuration::DependencyGroupsWithDefaults;
use uv_cache::Cache;
use uv_configuration::{DependencyGroupsWithDefaults, PreviewMode};
use uv_fs::Simplified;
use uv_python::{
EnvironmentPreference, PythonDownloads, PythonInstallation, PythonPreference, PythonRequest,
@ -32,6 +32,7 @@ pub(crate) async fn find(
python_preference: PythonPreference,
cache: &Cache,
printer: Printer,
preview: PreviewMode,
) -> Result<ExitStatus> {
let environment_preference = if system {
EnvironmentPreference::OnlySystem
@ -77,6 +78,7 @@ pub(crate) async fn find(
environment_preference,
python_preference,
cache,
preview,
)?;
// Warn if the discovered Python version is incompatible with the current workspace
@ -121,6 +123,7 @@ pub(crate) async fn find_script(
no_config: bool,
cache: &Cache,
printer: Printer,
preview: PreviewMode,
) -> Result<ExitStatus> {
let interpreter = match ScriptInterpreter::discover(
script,
@ -134,6 +137,7 @@ pub(crate) async fn find_script(
Some(false),
cache,
printer,
preview,
)
.await
{

View file

@ -2,10 +2,12 @@ use std::borrow::Cow;
use std::fmt::Write;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use anyhow::{Error, Result};
use futures::StreamExt;
use futures::stream::FuturesUnordered;
use indexmap::IndexSet;
use itertools::{Either, Itertools};
use owo_colors::OwoColorize;
use rustc_hash::{FxHashMap, FxHashSet};
@ -15,12 +17,13 @@ use uv_configuration::PreviewMode;
use uv_fs::Simplified;
use uv_python::downloads::{self, DownloadResult, ManagedPythonDownload, PythonDownloadRequest};
use uv_python::managed::{
ManagedPythonInstallation, ManagedPythonInstallations, python_executable_dir,
ManagedPythonInstallation, ManagedPythonInstallations, PythonMinorVersionLink,
create_link_to_executable, python_executable_dir,
};
use uv_python::platform::{Arch, Libc};
use uv_python::{
PythonDownloads, PythonInstallationKey, PythonRequest, PythonVersionFile,
VersionFileDiscoveryOptions, VersionFilePreference,
PythonDownloads, PythonInstallationKey, PythonInstallationMinorVersionKey, PythonRequest,
PythonVersionFile, VersionFileDiscoveryOptions, VersionFilePreference, VersionRequest,
};
use uv_shell::Shell;
use uv_trampoline_builder::{Launcher, LauncherKind};
@ -32,7 +35,7 @@ use crate::commands::{ExitStatus, elapsed};
use crate::printer::Printer;
use crate::settings::NetworkSettings;
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct InstallRequest {
/// The original request from the user
request: PythonRequest,
@ -82,6 +85,10 @@ impl InstallRequest {
fn matches_installation(&self, installation: &ManagedPythonInstallation) -> bool {
self.download_request.satisfied_by_key(installation.key())
}
fn python_request(&self) -> &PythonRequest {
&self.request
}
}
impl std::fmt::Display for InstallRequest {
@ -132,6 +139,7 @@ pub(crate) async fn install(
install_dir: Option<PathBuf>,
targets: Vec<String>,
reinstall: bool,
upgrade: bool,
force: bool,
python_install_mirror: Option<String>,
pypy_install_mirror: Option<String>,
@ -153,34 +161,66 @@ pub(crate) async fn install(
return Ok(ExitStatus::Failure);
}
if upgrade && preview.is_disabled() {
warn_user!(
"`uv python upgrade` is experimental and may change without warning. Pass `--preview` to disable this warning"
);
}
if default && targets.len() > 1 {
anyhow::bail!("The `--default` flag cannot be used with multiple targets");
}
// Read the existing installations, lock the directory for the duration
let installations = ManagedPythonInstallations::from_settings(install_dir.clone())?.init()?;
let installations_dir = installations.root();
let scratch_dir = installations.scratch();
let _lock = installations.lock().await?;
let existing_installations: Vec<_> = installations
.find_all()?
.inspect(|installation| trace!("Found existing installation {}", installation.key()))
.collect();
// Resolve the requests
let mut is_default_install = false;
let mut is_unspecified_upgrade = false;
let requests: Vec<_> = if targets.is_empty() {
PythonVersionFile::discover(
project_dir,
&VersionFileDiscoveryOptions::default()
.with_no_config(no_config)
.with_preference(VersionFilePreference::Versions),
)
.await?
.map(PythonVersionFile::into_versions)
.unwrap_or_else(|| {
// If no version file is found and no requests were made
is_default_install = true;
vec![if reinstall {
// On bare `--reinstall`, reinstall all Python versions
PythonRequest::Any
} else {
PythonRequest::Default
}]
})
.into_iter()
.map(|a| InstallRequest::new(a, python_downloads_json_url.as_deref()))
.collect::<Result<Vec<_>>>()?
if upgrade {
is_unspecified_upgrade = true;
let mut minor_version_requests = IndexSet::<InstallRequest>::default();
for installation in &existing_installations {
let request = VersionRequest::major_minor_request_from_key(installation.key());
if let Ok(request) = InstallRequest::new(
PythonRequest::Version(request),
python_downloads_json_url.as_deref(),
) {
minor_version_requests.insert(request);
}
}
minor_version_requests.into_iter().collect::<Vec<_>>()
} else {
PythonVersionFile::discover(
project_dir,
&VersionFileDiscoveryOptions::default()
.with_no_config(no_config)
.with_preference(VersionFilePreference::Versions),
)
.await?
.map(PythonVersionFile::into_versions)
.unwrap_or_else(|| {
// If no version file is found and no requests were made
is_default_install = true;
vec![if reinstall {
// On bare `--reinstall`, reinstall all Python versions
PythonRequest::Any
} else {
PythonRequest::Default
}]
})
.into_iter()
.map(|a| InstallRequest::new(a, python_downloads_json_url.as_deref()))
.collect::<Result<Vec<_>>>()?
}
} else {
targets
.iter()
@ -190,18 +230,39 @@ pub(crate) async fn install(
};
let Some(first_request) = requests.first() else {
if upgrade {
writeln!(
printer.stderr(),
"There are no installed versions to upgrade"
)?;
}
return Ok(ExitStatus::Success);
};
// Read the existing installations, lock the directory for the duration
let installations = ManagedPythonInstallations::from_settings(install_dir)?.init()?;
let installations_dir = installations.root();
let scratch_dir = installations.scratch();
let _lock = installations.lock().await?;
let existing_installations: Vec<_> = installations
.find_all()?
.inspect(|installation| trace!("Found existing installation {}", installation.key()))
.collect();
let requested_minor_versions = requests
.iter()
.filter_map(|request| {
if let PythonRequest::Version(VersionRequest::MajorMinor(major, minor, ..)) =
request.python_request()
{
uv_pep440::Version::from_str(&format!("{major}.{minor}")).ok()
} else {
None
}
})
.collect::<IndexSet<_>>();
if upgrade
&& requests
.iter()
.any(|request| request.request.includes_patch())
{
writeln!(
printer.stderr(),
"error: `uv python upgrade` only accepts minor versions"
)?;
return Ok(ExitStatus::Failure);
}
// Find requests that are already satisfied
let mut changelog = Changelog::default();
@ -259,15 +320,20 @@ pub(crate) async fn install(
}
}
}
(vec![], unsatisfied)
} else {
// If we can find one existing installation that matches the request, it is satisfied
requests.iter().partition_map(|request| {
if let Some(installation) = existing_installations
.iter()
.find(|installation| request.matches_installation(installation))
{
if let Some(installation) = existing_installations.iter().find(|installation| {
if upgrade {
// If this is an upgrade, the requested version is a minor version
// but the requested download is the highest patch for that minor
// version. We need to install it unless an exact match is found.
request.download.key() == installation.key()
} else {
request.matches_installation(installation)
}
}) {
debug!(
"Found `{}` for request `{}`",
installation.key().green(),
@ -385,18 +451,24 @@ pub(crate) async fn install(
.expect("We should have a bin directory with preview enabled")
.as_path();
let upgradeable = preview.is_enabled() && is_default_install
|| requested_minor_versions.contains(&installation.key().version().python_version());
create_bin_links(
installation,
bin,
reinstall,
force,
default,
upgradeable,
upgrade,
is_default_install,
first_request,
&existing_installations,
&installations,
&mut changelog,
&mut errors,
preview,
)?;
if preview.is_enabled() {
@ -407,14 +479,51 @@ pub(crate) async fn install(
}
}
let minor_versions =
PythonInstallationMinorVersionKey::highest_installations_by_minor_version_key(
installations
.iter()
.copied()
.chain(existing_installations.iter()),
);
for installation in minor_versions.values() {
if upgrade {
// During an upgrade, update existing symlinks but avoid
// creating new ones.
installation.update_minor_version_link(preview)?;
} else {
installation.ensure_minor_version_link(preview)?;
}
}
if changelog.installed.is_empty() && errors.is_empty() {
if is_default_install {
writeln!(
printer.stderr(),
"Python is already installed. Use `uv python install <request>` to install another version.",
)?;
} else if upgrade && requests.is_empty() {
writeln!(
printer.stderr(),
"There are no installed versions to upgrade"
)?;
} else if requests.len() > 1 {
writeln!(printer.stderr(), "All requested versions already installed")?;
if upgrade {
if is_unspecified_upgrade {
writeln!(
printer.stderr(),
"All versions already on latest supported patch release"
)?;
} else {
writeln!(
printer.stderr(),
"All requested versions already on latest supported patch release"
)?;
}
} else {
writeln!(printer.stderr(), "All requested versions already installed")?;
}
}
return Ok(ExitStatus::Success);
}
@ -520,12 +629,15 @@ fn create_bin_links(
reinstall: bool,
force: bool,
default: bool,
upgradeable: bool,
upgrade: bool,
is_default_install: bool,
first_request: &InstallRequest,
existing_installations: &[ManagedPythonInstallation],
installations: &[&ManagedPythonInstallation],
changelog: &mut Changelog,
errors: &mut Vec<(PythonInstallationKey, Error)>,
preview: PreviewMode,
) -> Result<(), Error> {
let targets =
if (default || is_default_install) && first_request.matches_installation(installation) {
@ -540,7 +652,19 @@ fn create_bin_links(
for target in targets {
let target = bin.join(target);
match installation.create_bin_link(&target) {
let executable = if upgradeable {
if let Some(minor_version_link) =
PythonMinorVersionLink::from_installation(installation, preview)
{
minor_version_link.symlink_executable.clone()
} else {
installation.executable(false)
}
} else {
installation.executable(false)
};
match create_link_to_executable(&target, executable.clone()) {
Ok(()) => {
debug!(
"Installed executable at `{}` for {}",
@ -589,13 +713,23 @@ fn create_bin_links(
// There's an existing executable we don't manage, require `--force`
if valid_link {
if !force {
errors.push((
installation.key().clone(),
anyhow::anyhow!(
"Executable already exists at `{}` but is not managed by uv; use `--force` to replace it",
to.simplified_display()
),
));
if upgrade {
warn_user!(
"Executable already exists at `{}` but is not managed by uv; use `uv python install {}.{}{} --force` to replace it",
to.simplified_display(),
installation.key().major(),
installation.key().minor(),
installation.key().variant().suffix()
);
} else {
errors.push((
installation.key().clone(),
anyhow::anyhow!(
"Executable already exists at `{}` but is not managed by uv; use `--force` to replace it",
to.simplified_display()
),
));
}
continue;
}
debug!(
@ -676,7 +810,7 @@ fn create_bin_links(
.remove(&target);
}
installation.create_bin_link(&target)?;
create_link_to_executable(&target, executable)?;
debug!(
"Updated executable at `{}` to {}",
target.simplified_display(),
@ -747,8 +881,7 @@ fn warn_if_not_on_path(bin: &Path) {
/// Find the [`ManagedPythonInstallation`] corresponding to an executable link installed at the
/// given path, if any.
///
/// Like [`ManagedPythonInstallation::is_bin_link`], but this method will only resolve the
/// given path one time.
/// Will resolve symlinks on Unix. On Windows, will resolve the target link for a trampoline.
fn find_matching_bin_link<'a>(
mut installations: impl Iterator<Item = &'a ManagedPythonInstallation>,
path: &Path,
@ -757,13 +890,13 @@ fn find_matching_bin_link<'a>(
if !path.is_symlink() {
return None;
}
path.read_link().ok()?
fs_err::canonicalize(path).ok()?
} else if cfg!(windows) {
let launcher = Launcher::try_from_path(path).ok()??;
if !matches!(launcher.kind, LauncherKind::Python) {
return None;
}
launcher.python_path
dunce::canonicalize(launcher.python_path).ok()?
} else {
unreachable!("Only Windows and Unix are supported")
};

View file

@ -2,6 +2,7 @@ use serde::Serialize;
use std::collections::BTreeSet;
use std::fmt::Write;
use uv_cli::PythonListFormat;
use uv_configuration::PreviewMode;
use uv_pep440::Version;
use anyhow::Result;
@ -64,6 +65,7 @@ pub(crate) async fn list(
python_downloads: PythonDownloads,
cache: &Cache,
printer: Printer,
preview: PreviewMode,
) -> Result<ExitStatus> {
let request = request.as_deref().map(PythonRequest::parse);
let base_download_request = if python_preference == PythonPreference::OnlySystem {
@ -124,6 +126,7 @@ pub(crate) async fn list(
EnvironmentPreference::OnlySystem,
python_preference,
cache,
preview,
)
// Raise discovery errors if critical
.filter(|result| {

View file

@ -8,7 +8,7 @@ use tracing::debug;
use uv_cache::Cache;
use uv_client::BaseClientBuilder;
use uv_configuration::DependencyGroupsWithDefaults;
use uv_configuration::{DependencyGroupsWithDefaults, PreviewMode};
use uv_dirs::user_uv_config_dir;
use uv_fs::Simplified;
use uv_python::{
@ -40,6 +40,7 @@ pub(crate) async fn pin(
network_settings: NetworkSettings,
cache: &Cache,
printer: Printer,
preview: PreviewMode,
) -> Result<ExitStatus> {
let workspace_cache = WorkspaceCache::default();
let virtual_project = if no_project {
@ -94,6 +95,7 @@ pub(crate) async fn pin(
virtual_project,
python_preference,
cache,
preview,
);
}
}
@ -124,6 +126,7 @@ pub(crate) async fn pin(
install_mirrors.python_install_mirror.as_deref(),
install_mirrors.pypy_install_mirror.as_deref(),
install_mirrors.python_downloads_json_url.as_deref(),
preview,
)
.await
{
@ -260,6 +263,7 @@ fn warn_if_existing_pin_incompatible_with_project(
virtual_project: &VirtualProject,
python_preference: PythonPreference,
cache: &Cache,
preview: PreviewMode,
) {
// Check if the pinned version is compatible with the project.
if let Some(pin_version) = pep440_version_from_request(pin) {
@ -284,6 +288,7 @@ fn warn_if_existing_pin_incompatible_with_project(
EnvironmentPreference::OnlySystem,
python_preference,
cache,
preview,
) {
Ok(python) => {
let python_version = python.python_version();

View file

@ -5,6 +5,7 @@ use std::path::PathBuf;
use anyhow::Result;
use futures::StreamExt;
use futures::stream::FuturesUnordered;
use indexmap::IndexSet;
use itertools::Itertools;
use owo_colors::OwoColorize;
use rustc_hash::{FxHashMap, FxHashSet};
@ -13,8 +14,10 @@ use tracing::{debug, warn};
use uv_configuration::PreviewMode;
use uv_fs::Simplified;
use uv_python::downloads::PythonDownloadRequest;
use uv_python::managed::{ManagedPythonInstallations, python_executable_dir};
use uv_python::{PythonInstallationKey, PythonRequest};
use uv_python::managed::{
ManagedPythonInstallations, PythonMinorVersionLink, python_executable_dir,
};
use uv_python::{PythonInstallationKey, PythonInstallationMinorVersionKey, PythonRequest};
use crate::commands::python::install::format_executables;
use crate::commands::python::{ChangeEvent, ChangeEventKind};
@ -87,7 +90,6 @@ async fn do_uninstall(
// Always include pre-releases in uninstalls
.map(|result| result.map(|request| request.with_prereleases(true)))
.collect::<Result<Vec<_>>>()?;
let installed_installations: Vec<_> = installations.find_all()?.collect();
let mut matching_installations = BTreeSet::default();
for (request, download_request) in requests.iter().zip(download_requests) {
@ -218,6 +220,63 @@ async fn do_uninstall(
uv_python::windows_registry::remove_orphan_registry_entries(&installed_installations);
}
// Read all existing managed installations and find the highest installed patch
// for each installed minor version. Ensure the minor version link directory
// is still valid.
let uninstalled_minor_versions = &uninstalled.iter().fold(
IndexSet::<&PythonInstallationMinorVersionKey>::default(),
|mut minor_versions, key| {
minor_versions.insert(PythonInstallationMinorVersionKey::ref_cast(key));
minor_versions
},
);
let remaining_installations: Vec<_> = installations.find_all()?.collect();
let remaining_minor_versions =
PythonInstallationMinorVersionKey::highest_installations_by_minor_version_key(
remaining_installations.iter(),
);
for (_, installation) in remaining_minor_versions
.iter()
.filter(|(minor_version, _)| uninstalled_minor_versions.contains(minor_version))
{
installation.ensure_minor_version_link(preview)?;
}
// For each uninstalled installation, check if there are no remaining installations
// for its minor version. If there are none remaining, remove the symlink directory
// (or junction on Windows) if it exists.
for installation in &matching_installations {
if !remaining_minor_versions.contains_key(installation.minor_version_key()) {
if let Some(minor_version_link) =
PythonMinorVersionLink::from_installation(installation, preview)
{
if minor_version_link.exists() {
let result = if cfg!(windows) {
fs_err::remove_dir(minor_version_link.symlink_directory.as_path())
} else {
fs_err::remove_file(minor_version_link.symlink_directory.as_path())
};
if result.is_err() {
return Err(anyhow::anyhow!(
"Failed to remove symlink directory {}",
minor_version_link.symlink_directory.display()
));
}
let symlink_term = if cfg!(windows) {
"junction"
} else {
"symlink directory"
};
debug!(
"Removed {}: {}",
symlink_term,
minor_version_link.symlink_directory.to_string_lossy()
);
}
}
}
}
// Report on any uninstalled installations.
if !uninstalled.is_empty() {
if let [uninstalled] = uninstalled.as_slice() {

View file

@ -7,6 +7,7 @@ use std::{collections::BTreeSet, ffi::OsString};
use tracing::{debug, warn};
use uv_cache::Cache;
use uv_client::BaseClientBuilder;
use uv_configuration::PreviewMode;
use uv_distribution_types::Requirement;
use uv_distribution_types::{InstalledDist, Name};
use uv_fs::Simplified;
@ -80,6 +81,7 @@ pub(crate) async fn refine_interpreter(
python_preference: PythonPreference,
python_downloads: PythonDownloads,
cache: &Cache,
preview: PreviewMode,
) -> anyhow::Result<Option<Interpreter>, ProjectError> {
let pip::operations::Error::Resolve(uv_resolver::ResolveError::NoSolution(no_solution_err)) =
err
@ -151,6 +153,7 @@ pub(crate) async fn refine_interpreter(
install_mirrors.python_install_mirror.as_deref(),
install_mirrors.pypy_install_mirror.as_deref(),
install_mirrors.python_downloads_json_url.as_deref(),
preview,
)
.await?
.into_interpreter();

View file

@ -87,6 +87,7 @@ pub(crate) async fn install(
install_mirrors.python_install_mirror.as_deref(),
install_mirrors.pypy_install_mirror.as_deref(),
install_mirrors.python_downloads_json_url.as_deref(),
preview,
)
.await?
.into_interpreter();
@ -508,6 +509,7 @@ pub(crate) async fn install(
python_preference,
python_downloads,
&cache,
preview,
)
.await
.ok()
@ -554,7 +556,7 @@ pub(crate) async fn install(
},
};
let environment = installed_tools.create_environment(&from.name, interpreter)?;
let environment = installed_tools.create_environment(&from.name, interpreter, preview)?;
// At this point, we removed any existing environment, so we should remove any of its
// executables.

View file

@ -747,6 +747,7 @@ async fn get_or_create_environment(
install_mirrors.python_install_mirror.as_deref(),
install_mirrors.pypy_install_mirror.as_deref(),
install_mirrors.python_downloads_json_url.as_deref(),
preview,
)
.await?
.into_interpreter();
@ -1036,6 +1037,7 @@ async fn get_or_create_environment(
python_preference,
python_downloads,
cache,
preview,
)
.await
.ok()

View file

@ -99,6 +99,7 @@ pub(crate) async fn upgrade(
install_mirrors.python_install_mirror.as_deref(),
install_mirrors.pypy_install_mirror.as_deref(),
install_mirrors.python_downloads_json_url.as_deref(),
preview,
)
.await?
.into_interpreter(),
@ -308,7 +309,7 @@ async fn upgrade_tool(
)
.await?;
let environment = installed_tools.create_environment(name, interpreter.clone())?;
let environment = installed_tools.create_environment(name, interpreter.clone(), preview)?;
let environment = sync_environment(
environment,

View file

@ -47,7 +47,7 @@ use super::project::default_dependency_groups;
pub(crate) async fn venv(
project_dir: &Path,
path: Option<PathBuf>,
python_request: Option<&str>,
python_request: Option<PythonRequest>,
install_mirrors: PythonInstallMirrors,
python_preference: PythonPreference,
python_downloads: PythonDownloads,
@ -130,7 +130,7 @@ enum VenvError {
async fn venv_impl(
project_dir: &Path,
path: Option<PathBuf>,
python_request: Option<&str>,
python_request: Option<PythonRequest>,
install_mirrors: PythonInstallMirrors,
link_mode: LinkMode,
index_locations: &IndexLocations,
@ -212,7 +212,7 @@ async fn venv_impl(
python_request,
requires_python,
} = WorkspacePython::from_request(
python_request.map(PythonRequest::parse),
python_request,
project.as_ref().map(VirtualProject::workspace),
&groups,
project_dir,
@ -234,6 +234,7 @@ async fn venv_impl(
install_mirrors.python_install_mirror.as_deref(),
install_mirrors.pypy_install_mirror.as_deref(),
install_mirrors.python_downloads_json_url.as_deref(),
preview,
)
.await
.into_diagnostic()?;
@ -276,6 +277,11 @@ async fn venv_impl(
)
.into_diagnostic()?;
let upgradeable = preview.is_enabled()
&& python_request
.as_ref()
.is_none_or(|request| !request.includes_patch());
// Create the virtual environment.
let venv = uv_virtualenv::create_venv(
&path,
@ -285,6 +291,8 @@ async fn venv_impl(
allow_existing,
relocatable,
seed,
upgradeable,
preview,
)
.map_err(VenvError::Creation)?;

View file

@ -35,6 +35,7 @@ use uv_fs::{CWD, Simplified};
use uv_pep440::release_specifiers_to_ranges;
use uv_pep508::VersionOrUrl;
use uv_pypi_types::{ParsedDirectoryUrl, ParsedUrl};
use uv_python::PythonRequest;
use uv_requirements::RequirementsSource;
use uv_requirements_txt::RequirementsTxtRequirement;
use uv_scripts::{Pep723Error, Pep723Item, Pep723ItemRef, Pep723Metadata, Pep723Script};
@ -793,6 +794,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
&globals.network_settings,
args.dry_run,
printer,
globals.preview,
)
.await
}
@ -814,6 +816,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
args.paths,
&cache,
printer,
globals.preview,
)
}
Commands::Pip(PipNamespace {
@ -845,6 +848,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
args.settings.system,
&cache,
printer,
globals.preview,
)
.await
}
@ -866,6 +870,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
args.files,
&cache,
printer,
globals.preview,
)
}
Commands::Pip(PipNamespace {
@ -897,6 +902,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
args.settings.system,
&cache,
printer,
globals.preview,
)
.await
}
@ -915,6 +921,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
args.settings.system,
&cache,
printer,
globals.preview,
)
}
Commands::Cache(CacheNamespace {
@ -1016,10 +1023,13 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
}
});
let python_request: Option<PythonRequest> =
args.settings.python.as_deref().map(PythonRequest::parse);
commands::venv(
&project_dir,
args.path,
args.settings.python.as_deref(),
python_request,
args.settings.install_mirrors,
globals.python_preference,
globals.python_downloads,
@ -1370,6 +1380,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
globals.python_downloads,
&cache,
printer,
globals.preview,
)
.await
}
@ -1379,12 +1390,43 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
// Resolve the settings from the command-line arguments and workspace configuration.
let args = settings::PythonInstallSettings::resolve(args, filesystem);
show_settings!(args);
// TODO(john): If we later want to support `--upgrade`, we need to replace this.
let upgrade = false;
commands::python_install(
&project_dir,
args.install_dir,
args.targets,
args.reinstall,
upgrade,
args.force,
args.python_install_mirror,
args.pypy_install_mirror,
args.python_downloads_json_url,
globals.network_settings,
args.default,
globals.python_downloads,
cli.top_level.no_config,
globals.preview,
printer,
)
.await
}
Commands::Python(PythonNamespace {
command: PythonCommand::Upgrade(args),
}) => {
// Resolve the settings from the command-line arguments and workspace configuration.
let args = settings::PythonUpgradeSettings::resolve(args, filesystem);
show_settings!(args);
let reinstall = false;
let upgrade = true;
commands::python_install(
&project_dir,
args.install_dir,
args.targets,
reinstall,
upgrade,
args.force,
args.python_install_mirror,
args.pypy_install_mirror,
@ -1433,6 +1475,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
cli.top_level.no_config,
&cache,
printer,
globals.preview,
)
.await
} else {
@ -1446,6 +1489,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
globals.python_preference,
&cache,
printer,
globals.preview,
)
.await
}
@ -1472,6 +1516,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
globals.network_settings,
&cache,
printer,
globals.preview,
)
.await
}

View file

@ -10,9 +10,9 @@ use uv_cli::{
AddArgs, ColorChoice, ExternalCommand, GlobalArgs, InitArgs, ListFormat, LockArgs, Maybe,
PipCheckArgs, PipCompileArgs, PipFreezeArgs, PipInstallArgs, PipListArgs, PipShowArgs,
PipSyncArgs, PipTreeArgs, PipUninstallArgs, PythonFindArgs, PythonInstallArgs, PythonListArgs,
PythonListFormat, PythonPinArgs, PythonUninstallArgs, RemoveArgs, RunArgs, SyncArgs,
ToolDirArgs, ToolInstallArgs, ToolListArgs, ToolRunArgs, ToolUninstallArgs, TreeArgs, VenvArgs,
VersionArgs, VersionBump, VersionFormat,
PythonListFormat, PythonPinArgs, PythonUninstallArgs, PythonUpgradeArgs, RemoveArgs, RunArgs,
SyncArgs, ToolDirArgs, ToolInstallArgs, ToolListArgs, ToolRunArgs, ToolUninstallArgs, TreeArgs,
VenvArgs, VersionArgs, VersionBump, VersionFormat,
};
use uv_cli::{
AuthorFrom, BuildArgs, ExportArgs, PublishArgs, PythonDirArgs, ResolverInstallerArgs,
@ -973,6 +973,59 @@ impl PythonInstallSettings {
}
}
/// The resolved settings to use for a `python upgrade` invocation.
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone)]
pub(crate) struct PythonUpgradeSettings {
pub(crate) install_dir: Option<PathBuf>,
pub(crate) targets: Vec<String>,
pub(crate) force: bool,
pub(crate) python_install_mirror: Option<String>,
pub(crate) pypy_install_mirror: Option<String>,
pub(crate) python_downloads_json_url: Option<String>,
pub(crate) default: bool,
}
impl PythonUpgradeSettings {
/// Resolve the [`PythonUpgradeSettings`] from the CLI and filesystem configuration.
#[allow(clippy::needless_pass_by_value)]
pub(crate) fn resolve(args: PythonUpgradeArgs, filesystem: Option<FilesystemOptions>) -> Self {
let options = filesystem.map(FilesystemOptions::into_options);
let (python_mirror, pypy_mirror, python_downloads_json_url) = match options {
Some(options) => (
options.install_mirrors.python_install_mirror,
options.install_mirrors.pypy_install_mirror,
options.install_mirrors.python_downloads_json_url,
),
None => (None, None, None),
};
let python_mirror = args.mirror.or(python_mirror);
let pypy_mirror = args.pypy_mirror.or(pypy_mirror);
let python_downloads_json_url =
args.python_downloads_json_url.or(python_downloads_json_url);
let force = false;
let default = false;
let PythonUpgradeArgs {
install_dir,
targets,
mirror: _,
pypy_mirror: _,
python_downloads_json_url: _,
} = args;
Self {
install_dir,
targets,
force,
python_install_mirror: python_mirror,
pypy_install_mirror: pypy_mirror,
python_downloads_json_url,
default,
}
}
}
/// The resolved settings to use for a `python uninstall` invocation.
#[derive(Debug, Clone)]
pub(crate) struct PythonUninstallSettings {

View file

@ -22,6 +22,7 @@ use regex::Regex;
use tokio::io::AsyncWriteExt;
use uv_cache::Cache;
use uv_configuration::PreviewMode;
use uv_fs::Simplified;
use uv_python::managed::ManagedPythonInstallations;
use uv_python::{
@ -959,6 +960,14 @@ impl TestContext {
command
}
/// Create a `uv python upgrade` command with options shared across scenarios.
pub fn python_upgrade(&self) -> Command {
let mut command = self.new_command();
self.add_shared_options(&mut command, true);
command.arg("python").arg("upgrade");
command
}
/// Create a `uv python pin` command with options shared across scenarios.
pub fn python_pin(&self) -> Command {
let mut command = self.new_command();
@ -1434,6 +1443,7 @@ pub fn python_installations_for_versions(
EnvironmentPreference::OnlySystem,
PythonPreference::Managed,
&cache,
PreviewMode::Disabled,
) {
python.into_interpreter().sys_executable().to_owned()
} else {

View file

@ -292,6 +292,8 @@ fn help_subcommand() {
Commands:
list List the available Python installations
install Download and install Python versions
upgrade Upgrade installed Python versions to the latest supported patch release (requires the
`--preview` flag)
find Search for a Python installation
pin Pin to a specific Python version
dir Show the uv Python installation directory
@ -719,6 +721,8 @@ fn help_flag_subcommand() {
Commands:
list List the available Python installations
install Download and install Python versions
upgrade Upgrade installed Python versions to the latest supported patch release (requires the
`--preview` flag)
find Search for a Python installation
pin Pin to a specific Python version
dir Show the uv Python installation directory
@ -915,6 +919,7 @@ fn help_unknown_subsubcommand() {
error: There is no command `foobar` for `uv python`. Did you mean one of:
list
install
upgrade
find
pin
dir

View file

@ -84,6 +84,9 @@ mod python_install;
#[cfg(feature = "python")]
mod python_pin;
#[cfg(feature = "python-managed")]
mod python_upgrade;
#[cfg(all(feature = "python", feature = "pypi"))]
mod run;

View file

@ -1,3 +1,6 @@
#[cfg(windows)]
use std::path::PathBuf;
use std::{env, path::Path, process::Command};
use crate::common::{TestContext, uv_snapshot};
@ -8,6 +11,7 @@ use assert_fs::{
use indoc::indoc;
use predicates::prelude::predicate;
use tracing::debug;
use uv_fs::Simplified;
use uv_static::EnvVars;
@ -351,6 +355,32 @@ fn python_install_preview() {
#[cfg(unix)]
bin_python.assert(predicate::path::is_symlink());
// The link should be to a path containing a minor version symlink directory
#[cfg(unix)]
{
insta::with_settings!({
filters => context.filters(),
}, {
insta::assert_snapshot!(
&bin_python
.read_link()
.unwrap_or_else(|_| panic!("{} should be readable", bin_python.path().display()))
.as_os_str().to_string_lossy(),
@"[TEMP_DIR]/managed/cpython-3.13-[PLATFORM]/bin/python3.13"
);
});
}
#[cfg(windows)]
{
insta::with_settings!({
filters => context.filters(),
}, {
insta::assert_snapshot!(
launcher_path(&bin_python).display(), @"[TEMP_DIR]/managed/cpython-3.13-[PLATFORM]/python"
);
});
}
// The executable should "work"
uv_snapshot!(context.filters(), Command::new(bin_python.as_os_str())
.arg("-c").arg("import subprocess; print('hello world')"), @r###"
@ -459,8 +489,60 @@ fn python_install_preview() {
// The executable should be removed
bin_python.assert(predicate::path::missing());
// Install a minor version
uv_snapshot!(context.filters(), context.python_install().arg("3.11").arg("--preview"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.11.13 in [TIME]
+ cpython-3.11.13-[PLATFORM] (python3.11)
");
let bin_python = context
.bin_dir
.child(format!("python3.11{}", std::env::consts::EXE_SUFFIX));
// The link should be to a path containing a minor version symlink directory
#[cfg(unix)]
{
insta::with_settings!({
filters => context.filters(),
}, {
insta::assert_snapshot!(
&bin_python
.read_link()
.unwrap_or_else(|_| panic!("{} should be readable", bin_python.path().display()))
.as_os_str().to_string_lossy(),
@"[TEMP_DIR]/managed/cpython-3.11-[PLATFORM]/bin/python3.11"
);
});
}
#[cfg(windows)]
{
insta::with_settings!({
filters => context.filters(),
}, {
insta::assert_snapshot!(
launcher_path(&bin_python).display(), @"[TEMP_DIR]/managed/cpython-3.11-[PLATFORM]/python"
);
});
}
uv_snapshot!(context.filters(), context.python_uninstall().arg("3.11"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Searching for Python versions matching: Python 3.11
Uninstalled Python 3.11.13 in [TIME]
- cpython-3.11.13-[PLATFORM] (python3.11)
");
// Install multiple patch versions
uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.12.8").arg("3.12.6"), @r###"
uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.12.8").arg("3.12.6"), @r"
success: true
exit_code: 0
----- stdout -----
@ -469,13 +551,13 @@ fn python_install_preview() {
Installed 2 versions in [TIME]
+ cpython-3.12.6-[PLATFORM]
+ cpython-3.12.8-[PLATFORM] (python3.12)
"###);
");
let bin_python = context
.bin_dir
.child(format!("python3.12{}", std::env::consts::EXE_SUFFIX));
// The link should be for the newer patch version
// The link should resolve to the newer patch version
if cfg!(unix) {
insta::with_settings!({
filters => context.filters(),
@ -517,6 +599,32 @@ fn python_install_preview_upgrade() {
+ cpython-3.12.5-[PLATFORM] (python3.12)
"###);
// Installing with a patch version should cause the link to be to the patch installation.
#[cfg(unix)]
{
insta::with_settings!({
filters => context.filters(),
}, {
insta::assert_snapshot!(
&bin_python
.read_link()
.unwrap_or_else(|_| panic!("{} should be readable", bin_python.display()))
.display(),
@"[TEMP_DIR]/managed/cpython-3.12.5-[PLATFORM]/bin/python3.12"
);
});
}
#[cfg(windows)]
{
insta::with_settings!({
filters => context.filters(),
}, {
insta::assert_snapshot!(
launcher_path(&bin_python).display(), @"[TEMP_DIR]/managed/cpython-3.12.5-[PLATFORM]/python"
);
});
}
// Installing 3.12.4 should not replace the executable, but also shouldn't fail
uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.12.4"), @r###"
success: true
@ -1023,22 +1131,25 @@ fn python_install_default() {
}
}
fn read_link_path(path: &Path) -> String {
if cfg!(unix) {
path.read_link()
.unwrap_or_else(|_| panic!("{} should be readable", path.display()))
.simplified_display()
.to_string()
} else if cfg!(windows) {
let launcher = uv_trampoline_builder::Launcher::try_from_path(path)
.ok()
.unwrap_or_else(|| panic!("{} should be readable", path.display()))
.unwrap_or_else(|| panic!("{} should be a valid launcher", path.display()));
#[cfg(windows)]
fn launcher_path(path: &Path) -> PathBuf {
let launcher = uv_trampoline_builder::Launcher::try_from_path(path)
.unwrap_or_else(|_| panic!("{} should be readable", path.display()))
.unwrap_or_else(|| panic!("{} should be a valid launcher", path.display()));
launcher.python_path
}
launcher.python_path.simplified_display().to_string()
} else {
unreachable!()
}
fn read_link_path(path: &Path) -> String {
#[cfg(unix)]
let canonical_path = fs_err::canonicalize(path);
#[cfg(windows)]
let canonical_path = dunce::canonicalize(launcher_path(path));
canonical_path
.unwrap_or_else(|_| panic!("{} should be readable", path.display()))
.simplified_display()
.to_string()
}
#[test]
@ -1486,3 +1597,557 @@ fn python_install_emulated_macos() {
----- stderr -----
");
}
// A virtual environment should track the latest patch version installed.
#[test]
fn install_transparent_patch_upgrade_uv_venv() {
let context = TestContext::new_with_versions(&["3.13"])
.with_filtered_python_keys()
.with_filtered_exe_suffix()
.with_managed_python_dirs()
.with_filtered_python_install_bin();
// Install a lower patch version.
uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.12.9"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.12.9 in [TIME]
+ cpython-3.12.9-[PLATFORM] (python3.12)
"
);
// Create a virtual environment.
uv_snapshot!(context.filters(), context.venv().arg("-p").arg("3.12")
.arg(context.venv.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.12.9
Creating virtual environment at: .venv
Activate with: source .venv/[BIN]/activate
"
);
uv_snapshot!(context.filters(), context.run().arg("python").arg("--version"), @r"
success: true
exit_code: 0
----- stdout -----
Python 3.12.9
----- stderr -----
"
);
// Install a higher patch version.
uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.12.11"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.12.11 in [TIME]
+ cpython-3.12.11-[PLATFORM] (python3.12)
"
);
// Virtual environment should reflect higher version.
uv_snapshot!(context.filters(), context.run().arg("python").arg("--version"), @r"
success: true
exit_code: 0
----- stdout -----
Python 3.12.11
----- stderr -----
"
);
// Install a lower patch version.
uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.12.8"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.12.8 in [TIME]
+ cpython-3.12.8-[PLATFORM]
"
);
// Virtual environment should reflect highest version.
uv_snapshot!(context.filters(), context.run().arg("python").arg("--version"), @r"
success: true
exit_code: 0
----- stdout -----
Python 3.12.11
----- stderr -----
"
);
}
// When installing multiple patches simultaneously, a virtual environment on that
// minor version should point to the highest.
#[test]
fn install_multiple_patches() {
let context = TestContext::new_with_versions(&[])
.with_filtered_python_keys()
.with_filtered_exe_suffix()
.with_managed_python_dirs()
.with_filtered_python_install_bin();
// Install 3.12 patches in ascending order list
uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.12.9").arg("3.12.11"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed 2 versions in [TIME]
+ cpython-3.12.9-[PLATFORM]
+ cpython-3.12.11-[PLATFORM] (python3.12)
"
);
// Create a virtual environment.
uv_snapshot!(context.filters(), context.venv().arg("-p").arg("3.12")
.arg(context.venv.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.12.11
Creating virtual environment at: .venv
Activate with: source .venv/[BIN]/activate
"
);
// Virtual environment should be on highest installed patch.
uv_snapshot!(context.filters(), context.run().arg("python").arg("--version"), @r"
success: true
exit_code: 0
----- stdout -----
Python 3.12.11
----- stderr -----
"
);
// Remove the original virtual environment
fs_err::remove_dir_all(&context.venv).unwrap();
// Install 3.10 patches in descending order list
uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.10.17").arg("3.10.8"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed 2 versions in [TIME]
+ cpython-3.10.8-[PLATFORM]
+ cpython-3.10.17-[PLATFORM] (python3.10)
"
);
// Create a virtual environment on 3.10.
uv_snapshot!(context.filters(), context.venv().arg("-p").arg("3.10")
.arg(context.venv.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.10.17
Creating virtual environment at: .venv
Activate with: source .venv/[BIN]/activate
"
);
// Virtual environment should be on highest installed patch.
uv_snapshot!(context.filters(), context.run().arg("python").arg("--version"), @r"
success: true
exit_code: 0
----- stdout -----
Python 3.10.17
----- stderr -----
"
);
}
// After uninstalling the highest patch, a virtual environment should point to the
// next highest.
#[test]
fn uninstall_highest_patch() {
let context = TestContext::new_with_versions(&[])
.with_filtered_python_keys()
.with_filtered_exe_suffix()
.with_managed_python_dirs()
.with_filtered_python_install_bin();
// Install patches in ascending order list
uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.12.11").arg("3.12.9").arg("3.12.8"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed 3 versions in [TIME]
+ cpython-3.12.8-[PLATFORM]
+ cpython-3.12.9-[PLATFORM]
+ cpython-3.12.11-[PLATFORM] (python3.12)
"
);
uv_snapshot!(context.filters(), context.venv().arg("-p").arg("3.12")
.arg(context.venv.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.12.11
Creating virtual environment at: .venv
Activate with: source .venv/[BIN]/activate
"
);
uv_snapshot!(context.filters(), context.run().arg("python").arg("--version"), @r"
success: true
exit_code: 0
----- stdout -----
Python 3.12.11
----- stderr -----
"
);
// Uninstall the highest patch version
uv_snapshot!(context.filters(), context.python_uninstall().arg("--preview").arg("3.12.11"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Searching for Python versions matching: Python 3.12.11
Uninstalled Python 3.12.11 in [TIME]
- cpython-3.12.11-[PLATFORM] (python3.12)
"
);
// Virtual environment should be on highest patch version remaining.
uv_snapshot!(context.filters(), context.run().arg("python").arg("--version"), @r"
success: true
exit_code: 0
----- stdout -----
Python 3.12.9
----- stderr -----
"
);
}
// Virtual environments only record minor versions. `uv venv -p 3.x.y` will
// not prevent a virtual environment from tracking the latest patch version
// installed.
#[test]
fn install_no_transparent_upgrade_with_venv_patch_specification() {
let context = TestContext::new_with_versions(&["3.13"])
.with_filtered_python_keys()
.with_filtered_exe_suffix()
.with_managed_python_dirs()
.with_filtered_python_install_bin();
uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.12.9"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.12.9 in [TIME]
+ cpython-3.12.9-[PLATFORM] (python3.12)
"
);
// Create a virtual environment with a patch version
uv_snapshot!(context.filters(), context.venv().arg("-p").arg("3.12.9")
.arg(context.venv.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.12.9
Creating virtual environment at: .venv
Activate with: source .venv/[BIN]/activate
"
);
uv_snapshot!(context.filters(), context.run().arg("python").arg("--version"), @r"
success: true
exit_code: 0
----- stdout -----
Python 3.12.9
----- stderr -----
"
);
// Install a higher patch version.
uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.12.11"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.12.11 in [TIME]
+ cpython-3.12.11-[PLATFORM] (python3.12)
"
);
// The virtual environment Python version is transparently upgraded.
uv_snapshot!(context.filters(), context.run().arg("python").arg("--version"), @r"
success: true
exit_code: 0
----- stdout -----
Python 3.12.9
----- stderr -----
"
);
}
// A virtual environment created using the `venv` module should track
// the latest patch version installed.
#[test]
fn install_transparent_patch_upgrade_venv_module() {
let context = TestContext::new_with_versions(&[])
.with_filtered_python_keys()
.with_filtered_exe_suffix()
.with_managed_python_dirs()
.with_filtered_python_install_bin();
let bin_dir = context.temp_dir.child("bin");
uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.12.9"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.12.9 in [TIME]
+ cpython-3.12.9-[PLATFORM] (python3.12)
"
);
uv_snapshot!(context.filters(), context.run().arg("python").arg("--version"), @r"
success: true
exit_code: 0
----- stdout -----
Python 3.12.9
----- stderr -----
"
);
// Create a virtual environment using venv module.
uv_snapshot!(context.filters(), context.run().arg("python").arg("-m").arg("venv").arg(context.venv.as_os_str()).arg("--without-pip")
.env(EnvVars::PATH, bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
");
uv_snapshot!(context.filters(), context.run().arg("python").arg("--version"), @r"
success: true
exit_code: 0
----- stdout -----
Python 3.12.9
----- stderr -----
"
);
// Install a higher patch version
uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.12.11"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.12.11 in [TIME]
+ cpython-3.12.11-[PLATFORM] (python3.12)
"
);
// Virtual environment should reflect highest patch version.
uv_snapshot!(context.filters(), context.run().arg("python").arg("--version"), @r"
success: true
exit_code: 0
----- stdout -----
Python 3.12.11
----- stderr -----
"
);
}
// Automatically installing a lower patch version when running a command like
// `uv run` should not downgrade virtual environments.
#[test]
fn install_lower_patch_automatically() {
let context = TestContext::new_with_versions(&[])
.with_filtered_python_keys()
.with_filtered_exe_suffix()
.with_managed_python_dirs()
.with_filtered_python_install_bin();
uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.12.11"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.12.11 in [TIME]
+ cpython-3.12.11-[PLATFORM] (python3.12)
"
);
uv_snapshot!(context.filters(), context.venv().arg("-p").arg("3.12")
.arg(context.venv.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.12.11
Creating virtual environment at: .venv
Activate with: source .venv/[BIN]/activate
"
);
uv_snapshot!(context.filters(), context.init().arg("-p").arg("3.12.9").arg("proj"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Initialized project `proj` at `[TEMP_DIR]/proj`
"
);
// Create a new virtual environment to trigger automatic installation of
// lower patch version
uv_snapshot!(context.filters(), context.venv()
.arg("--directory").arg("proj")
.arg("-p").arg("3.12.9"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.12.9
Creating virtual environment at: .venv
Activate with: source .venv/[BIN]/activate
");
// Original virtual environment should still point to higher patch
uv_snapshot!(context.filters(), context.run().arg("python").arg("--version"), @r"
success: true
exit_code: 0
----- stdout -----
Python 3.12.11
----- stderr -----
"
);
}
#[test]
fn uninstall_last_patch() {
let context = TestContext::new_with_versions(&[])
.with_filtered_python_keys()
.with_filtered_exe_suffix()
.with_managed_python_dirs()
.with_filtered_virtualenv_bin();
uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.10.17"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.10.17 in [TIME]
+ cpython-3.10.17-[PLATFORM] (python3.10)
"
);
uv_snapshot!(context.filters(), context.venv().arg("-p").arg("3.10"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.10.17
Creating virtual environment at: .venv
Activate with: source .venv/[BIN]/activate
"
);
uv_snapshot!(context.filters(), context.run().arg("python").arg("--version"), @r"
success: true
exit_code: 0
----- stdout -----
Python 3.10.17
----- stderr -----
"
);
uv_snapshot!(context.filters(), context.python_uninstall().arg("--preview").arg("3.10.17"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Searching for Python versions matching: Python 3.10.17
Uninstalled Python 3.10.17 in [TIME]
- cpython-3.10.17-[PLATFORM] (python3.10)
"
);
let mut filters = context.filters();
filters.push(("python3", "python"));
#[cfg(unix)]
uv_snapshot!(filters, context.run().arg("python").arg("--version"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Failed to inspect Python interpreter from active virtual environment at `.venv/[BIN]/python`
Caused by: Broken symlink at `.venv/[BIN]/python`, was the underlying Python interpreter removed?
hint: Consider recreating the environment (e.g., with `uv venv`)
"
);
#[cfg(windows)]
uv_snapshot!(filters, context.run().arg("python").arg("--version"), @r#"
success: false
exit_code: 103
----- stdout -----
----- stderr -----
No Python at '"[TEMP_DIR]/managed/cpython-3.10-[PLATFORM]/python'
"#
);
}

View file

@ -0,0 +1,703 @@
use crate::common::{TestContext, uv_snapshot};
use anyhow::Result;
use assert_fs::fixture::FileTouch;
use assert_fs::prelude::PathChild;
use uv_static::EnvVars;
#[test]
fn python_upgrade() {
let context: TestContext = TestContext::new_with_versions(&[])
.with_filtered_python_keys()
.with_filtered_exe_suffix()
.with_managed_python_dirs();
// Install an earlier patch version
uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.10.8"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.10.8 in [TIME]
+ cpython-3.10.8-[PLATFORM] (python3.10)
");
// Don't accept patch version as argument to upgrade command
uv_snapshot!(context.filters(), context.python_upgrade().arg("--preview").arg("3.10.8"), @r"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
error: `uv python upgrade` only accepts minor versions
");
// Upgrade patch version
uv_snapshot!(context.filters(), context.python_upgrade().arg("--preview").arg("3.10"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.10.18 in [TIME]
+ cpython-3.10.18-[PLATFORM] (python3.10)
");
// Should be a no-op when already upgraded
uv_snapshot!(context.filters(), context.python_upgrade().arg("--preview").arg("3.10"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
");
}
#[test]
fn python_upgrade_without_version() {
let context: TestContext = TestContext::new_with_versions(&[])
.with_filtered_python_keys()
.with_filtered_exe_suffix()
.with_managed_python_dirs();
// Should be a no-op when no versions have been installed
uv_snapshot!(context.filters(), context.python_upgrade().arg("--preview"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
There are no installed versions to upgrade
");
// Install earlier patch versions for different minor versions
uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.11.8").arg("3.12.8").arg("3.13.1"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed 3 versions in [TIME]
+ cpython-3.11.8-[PLATFORM] (python3.11)
+ cpython-3.12.8-[PLATFORM] (python3.12)
+ cpython-3.13.1-[PLATFORM] (python3.13)
");
let mut filters = context.filters().clone();
filters.push((r"3.13.\d+", "3.13.[X]"));
// Upgrade one patch version
uv_snapshot!(filters, context.python_upgrade().arg("--preview").arg("3.13"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.13.[X] in [TIME]
+ cpython-3.13.[X]-[PLATFORM] (python3.13)
");
// Providing no minor version to `uv python upgrade` should upgrade the rest
// of the patch versions
uv_snapshot!(context.filters(), context.python_upgrade().arg("--preview"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed 2 versions in [TIME]
+ cpython-3.11.13-[PLATFORM] (python3.11)
+ cpython-3.12.11-[PLATFORM] (python3.12)
");
// Should be a no-op when every version is already upgraded
uv_snapshot!(context.filters(), context.python_upgrade().arg("--preview"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
All versions already on latest supported patch release
");
}
#[test]
fn python_upgrade_transparent_from_venv() {
let context: TestContext = TestContext::new_with_versions(&["3.13"])
.with_filtered_python_keys()
.with_filtered_exe_suffix()
.with_managed_python_dirs();
// Install an earlier patch version
uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.10.8"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.10.8 in [TIME]
+ cpython-3.10.8-[PLATFORM] (python3.10)
");
// Create a virtual environment
uv_snapshot!(context.filters(), context.venv(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.10.8
Creating virtual environment at: .venv
Activate with: source .venv/[BIN]/activate
");
uv_snapshot!(context.filters(), context.run().arg("python").arg("--version"), @r"
success: true
exit_code: 0
----- stdout -----
Python 3.10.8
----- stderr -----
"
);
let second_venv = ".venv2";
// Create a second virtual environment with minor version request
uv_snapshot!(context.filters(), context.venv().arg(second_venv).arg("-p").arg("3.10"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.10.8
Creating virtual environment at: .venv2
Activate with: source .venv2/[BIN]/activate
");
uv_snapshot!(context.filters(), context.run().arg("python").arg("--version")
.env(EnvVars::VIRTUAL_ENV, second_venv), @r"
success: true
exit_code: 0
----- stdout -----
Python 3.10.8
----- stderr -----
"
);
// Upgrade patch version
uv_snapshot!(context.filters(), context.python_upgrade().arg("--preview").arg("3.10"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.10.18 in [TIME]
+ cpython-3.10.18-[PLATFORM] (python3.10)
");
// First virtual environment should reflect upgraded patch
uv_snapshot!(context.filters(), context.run().arg("python").arg("--version"), @r"
success: true
exit_code: 0
----- stdout -----
Python 3.10.18
----- stderr -----
"
);
// Second virtual environment should reflect upgraded patch
uv_snapshot!(context.filters(), context.run().arg("python").arg("--version")
.env(EnvVars::VIRTUAL_ENV, second_venv), @r"
success: true
exit_code: 0
----- stdout -----
Python 3.10.18
----- stderr -----
"
);
}
// Installing Python in preview mode should not prevent virtual environments
// from transparently upgrading.
#[test]
fn python_upgrade_transparent_from_venv_preview() {
let context: TestContext = TestContext::new_with_versions(&["3.13"])
.with_filtered_python_keys()
.with_filtered_exe_suffix()
.with_managed_python_dirs();
// Install an earlier patch version using `--preview`
uv_snapshot!(context.filters(), context.python_install().arg("3.10.8").arg("--preview"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.10.8 in [TIME]
+ cpython-3.10.8-[PLATFORM] (python3.10)
");
// Create a virtual environment
uv_snapshot!(context.filters(), context.venv().arg("-p").arg("3.10"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.10.8
Creating virtual environment at: .venv
Activate with: source .venv/[BIN]/activate
");
uv_snapshot!(context.filters(), context.run().arg("python").arg("--version"), @r"
success: true
exit_code: 0
----- stdout -----
Python 3.10.8
----- stderr -----
"
);
// Upgrade patch version
uv_snapshot!(context.filters(), context.python_upgrade().arg("--preview").arg("3.10"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.10.18 in [TIME]
+ cpython-3.10.18-[PLATFORM] (python3.10)
");
// Virtual environment should reflect upgraded patch
uv_snapshot!(context.filters(), context.run().arg("python").arg("--version"), @r"
success: true
exit_code: 0
----- stdout -----
Python 3.10.18
----- stderr -----
"
);
}
#[test]
fn python_upgrade_ignored_with_python_pin() {
let context: TestContext = TestContext::new_with_versions(&["3.13"])
.with_filtered_python_keys()
.with_filtered_exe_suffix()
.with_managed_python_dirs();
// Install an earlier patch version
uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.10.8"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.10.8 in [TIME]
+ cpython-3.10.8-[PLATFORM] (python3.10)
");
// Create a virtual environment
uv_snapshot!(context.filters(), context.venv(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.10.8
Creating virtual environment at: .venv
Activate with: source .venv/[BIN]/activate
");
// Pin to older patch version
uv_snapshot!(context.filters(), context.python_pin().arg("3.10.8"), @r"
success: true
exit_code: 0
----- stdout -----
Pinned `.python-version` to `3.10.8`
----- stderr -----
");
// Upgrade patch version
uv_snapshot!(context.filters(), context.python_upgrade().arg("--preview").arg("3.10"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.10.18 in [TIME]
+ cpython-3.10.18-[PLATFORM] (python3.10)
");
// Virtual environment should continue to respect pinned patch version
uv_snapshot!(context.filters(), context.run().arg("python").arg("--version"), @r"
success: true
exit_code: 0
----- stdout -----
Python 3.10.8
----- stderr -----
"
);
}
// Virtual environments only record minor versions. `uv venv -p 3.x.y` will
// not prevent transparent upgrades.
#[test]
fn python_no_transparent_upgrade_with_venv_patch_specification() {
let context: TestContext = TestContext::new_with_versions(&["3.13"])
.with_filtered_python_keys()
.with_filtered_exe_suffix()
.with_managed_python_dirs();
// Install an earlier patch version
uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.10.8"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.10.8 in [TIME]
+ cpython-3.10.8-[PLATFORM] (python3.10)
");
// Create a virtual environment with a patch version
uv_snapshot!(context.filters(), context.venv().arg("-p").arg("3.10.8"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.10.8
Creating virtual environment at: .venv
Activate with: source .venv/[BIN]/activate
");
uv_snapshot!(context.filters(), context.run().arg("python").arg("--version"), @r"
success: true
exit_code: 0
----- stdout -----
Python 3.10.8
----- stderr -----
"
);
// Upgrade patch version
uv_snapshot!(context.filters(), context.python_upgrade().arg("--preview").arg("3.10"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.10.18 in [TIME]
+ cpython-3.10.18-[PLATFORM] (python3.10)
");
// The virtual environment Python version is transparently upgraded.
uv_snapshot!(context.filters(), context.run().arg("python").arg("--version"), @r"
success: true
exit_code: 0
----- stdout -----
Python 3.10.8
----- stderr -----
"
);
}
// Transparent upgrades should work for virtual environments created within
// virtual environments.
#[test]
fn python_transparent_upgrade_venv_venv() {
let context: TestContext = TestContext::new_with_versions(&["3.13"])
.with_filtered_python_keys()
.with_filtered_exe_suffix()
.with_filtered_virtualenv_bin()
.with_managed_python_dirs();
// Install an earlier patch version
uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.10.8"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.10.8 in [TIME]
+ cpython-3.10.8-[PLATFORM] (python3.10)
");
// Create an initial virtual environment
uv_snapshot!(context.filters(), context.venv().arg("-p").arg("3.10"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.10.8
Creating virtual environment at: .venv
Activate with: source .venv/[BIN]/activate
");
let venv_python = if cfg!(windows) {
context.venv.child("Scripts/python.exe")
} else {
context.venv.child("bin/python")
};
let second_venv = ".venv2";
// Create a new virtual environment from within a virtual environment
uv_snapshot!(context.filters(), context.venv()
.arg(second_venv)
.arg("-p").arg(venv_python.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.10.8 interpreter at: .venv/[BIN]/python
Creating virtual environment at: .venv2
Activate with: source .venv2/[BIN]/activate
");
// Check version from within second virtual environment
uv_snapshot!(context.filters(), context.run()
.arg("python").arg("--version")
.env(EnvVars::VIRTUAL_ENV, second_venv), @r"
success: true
exit_code: 0
----- stdout -----
Python 3.10.8
----- stderr -----
"
);
// Upgrade patch version
uv_snapshot!(context.filters(), context.python_upgrade().arg("--preview").arg("3.10"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.10.18 in [TIME]
+ cpython-3.10.18-[PLATFORM] (python3.10)
");
// Should have transparently upgraded in second virtual environment
uv_snapshot!(context.filters(), context.run()
.arg("python").arg("--version")
.env(EnvVars::VIRTUAL_ENV, second_venv), @r"
success: true
exit_code: 0
----- stdout -----
Python 3.10.18
----- stderr -----
"
);
}
// Transparent upgrades should work for virtual environments created using
// the `venv` module.
#[test]
fn python_upgrade_transparent_from_venv_module() {
let context = TestContext::new_with_versions(&["3.13"])
.with_filtered_python_keys()
.with_filtered_exe_suffix()
.with_managed_python_dirs()
.with_filtered_python_install_bin();
let bin_dir = context.temp_dir.child("bin");
// Install earlier patch version
uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.12.9"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.12.9 in [TIME]
+ cpython-3.12.9-[PLATFORM] (python3.12)
");
// Create a virtual environment using venv module
uv_snapshot!(context.filters(), context.run().arg("python").arg("-m").arg("venv").arg(context.venv.as_os_str()).arg("--without-pip")
.env(EnvVars::PATH, bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
");
uv_snapshot!(context.filters(), context.run().arg("python").arg("--version"), @r"
success: true
exit_code: 0
----- stdout -----
Python 3.12.9
----- stderr -----
"
);
// Upgrade patch version
uv_snapshot!(context.filters(), context.python_upgrade().arg("--preview").arg("3.12"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.12.11 in [TIME]
+ cpython-3.12.11-[PLATFORM] (python3.12)
"
);
// Virtual environment should reflect upgraded patch
uv_snapshot!(context.filters(), context.run().arg("python").arg("--version"), @r"
success: true
exit_code: 0
----- stdout -----
Python 3.12.11
----- stderr -----
"
);
}
// Transparent Python upgrades should work in environments created using
// the `venv` module within an existing virtual environment.
#[test]
fn python_upgrade_transparent_from_venv_module_in_venv() {
let context = TestContext::new_with_versions(&["3.13"])
.with_filtered_python_keys()
.with_filtered_exe_suffix()
.with_managed_python_dirs()
.with_filtered_python_install_bin();
let bin_dir = context.temp_dir.child("bin");
// Install earlier patch version
uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.10.8"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.10.8 in [TIME]
+ cpython-3.10.8-[PLATFORM] (python3.10)
");
// Create first virtual environment
uv_snapshot!(context.filters(), context.venv().arg("-p").arg("3.10"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.10.8
Creating virtual environment at: .venv
Activate with: source .venv/[BIN]/activate
");
let second_venv = ".venv2";
// Create a virtual environment using `venv`` module from within the first virtual environment.
uv_snapshot!(context.filters(), context.run()
.arg("python").arg("-m").arg("venv").arg(second_venv).arg("--without-pip")
.env(EnvVars::PATH, bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
");
// Check version within second virtual environment
uv_snapshot!(context.filters(), context.run()
.env(EnvVars::VIRTUAL_ENV, second_venv)
.arg("python").arg("--version"), @r"
success: true
exit_code: 0
----- stdout -----
Python 3.10.8
----- stderr -----
"
);
// Upgrade patch version
uv_snapshot!(context.filters(), context.python_upgrade().arg("--preview").arg("3.10"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.10.18 in [TIME]
+ cpython-3.10.18-[PLATFORM] (python3.10)
"
);
// Second virtual environment should reflect upgraded patch.
uv_snapshot!(context.filters(), context.run()
.env(EnvVars::VIRTUAL_ENV, second_venv)
.arg("python").arg("--version"), @r"
success: true
exit_code: 0
----- stdout -----
Python 3.10.18
----- stderr -----
"
);
}
// Tests that `uv python upgrade 3.12` will warn if trying to install over non-managed
// interpreter.
#[test]
fn python_upgrade_force_install() -> Result<()> {
let context = TestContext::new_with_versions(&["3.13"])
.with_filtered_python_keys()
.with_filtered_exe_suffix()
.with_managed_python_dirs();
context
.bin_dir
.child(format!("python3.12{}", std::env::consts::EXE_SUFFIX))
.touch()?;
// Try to upgrade with a non-managed interpreter installed in `bin`.
uv_snapshot!(context.filters(), context.python_upgrade().arg("--preview").arg("3.12"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: Executable already exists at `[BIN]/python3.12` but is not managed by uv; use `uv python install 3.12 --force` to replace it
Installed Python 3.12.11 in [TIME]
+ cpython-3.12.11-[PLATFORM]
");
// Force the `bin` install.
uv_snapshot!(context.filters(), context.python_install().arg("3.12").arg("--force").arg("--preview").arg("3.12"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Installed Python 3.12.11 in [TIME]
+ cpython-3.12.11-[PLATFORM] (python3.12)
");
Ok(())
}

View file

@ -9619,9 +9619,7 @@ fn sync_when_virtual_environment_incompatible_with_interpreter() -> Result<()> {
}, {
let contents = fs_err::read_to_string(&pyvenv_cfg).unwrap();
let lines: Vec<&str> = contents.split('\n').collect();
assert_snapshot!(lines[3], @r###"
version_info = 3.12.[X]
"###);
assert_snapshot!(lines[3], @"version_info = 3.12.[X]");
});
// Simulate an incompatible `pyvenv.cfg:version_info` value created
@ -9660,9 +9658,7 @@ fn sync_when_virtual_environment_incompatible_with_interpreter() -> Result<()> {
}, {
let contents = fs_err::read_to_string(&pyvenv_cfg).unwrap();
let lines: Vec<&str> = contents.split('\n').collect();
assert_snapshot!(lines[3], @r###"
version_info = 3.12.[X]
"###);
assert_snapshot!(lines[3], @"version_info = 3.12.[X]");
});
Ok(())

View file

@ -741,9 +741,7 @@ fn tool_upgrade_python() {
}, {
let content = fs_err::read_to_string(tool_dir.join("babel").join("pyvenv.cfg")).unwrap();
let lines: Vec<&str> = content.split('\n').collect();
assert_snapshot!(lines[lines.len() - 3], @r###"
version_info = 3.12.[X]
"###);
assert_snapshot!(lines[lines.len() - 3], @"version_info = 3.12.[X]");
});
}
@ -826,9 +824,7 @@ fn tool_upgrade_python_with_all() {
}, {
let content = fs_err::read_to_string(tool_dir.join("babel").join("pyvenv.cfg")).unwrap();
let lines: Vec<&str> = content.split('\n').collect();
assert_snapshot!(lines[lines.len() - 3], @r###"
version_info = 3.12.[X]
"###);
assert_snapshot!(lines[lines.len() - 3], @"version_info = 3.12.[X]");
});
insta::with_settings!({
@ -836,8 +832,6 @@ fn tool_upgrade_python_with_all() {
}, {
let content = fs_err::read_to_string(tool_dir.join("python-dotenv").join("pyvenv.cfg")).unwrap();
let lines: Vec<&str> = content.split('\n').collect();
assert_snapshot!(lines[lines.len() - 3], @r###"
version_info = 3.12.[X]
"###);
assert_snapshot!(lines[lines.len() - 3], @"version_info = 3.12.[X]");
});
}

View file

@ -868,14 +868,14 @@ fn create_venv_unknown_python_patch() {
"###
);
} else {
uv_snapshot!(&mut command, @r###"
uv_snapshot!(&mut command, @r"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
× No interpreter found for Python 3.12.100 in managed installations or search path
"###
"
);
}

View file

@ -123,7 +123,7 @@ present, uv will install all the Python versions listed in the file.
!!! important
Support for installing Python executables is in _preview_, this means the behavior is experimental
Support for installing Python executables is in _preview_. This means the behavior is experimental
and subject to change.
To install Python executables into your `PATH`, provide the `--preview` option:
@ -158,6 +158,70 @@ $ uv python install 3.12.6 --preview # Does not update `python3.12`
$ uv python install 3.12.8 --preview # Updates `python3.12` to point to 3.12.8
```
## Upgrading Python versions
!!! important
Support for upgrading Python versions is in _preview_. This means the behavior is experimental
and subject to change.
Upgrades are only supported for uv-managed Python versions.
Upgrades are not currently supported for PyPy and GraalPy.
uv allows transparently upgrading Python versions to the latest patch release, e.g., 3.13.4 to
3.13.5. uv does not allow transparently upgrading across minor Python versions, e.g., 3.12 to 3.13,
because changing minor versions can affect dependency resolution.
uv-managed Python versions can be upgraded to the latest supported patch release with the
`python upgrade` command:
To upgrade a Python version to the latest supported patch release:
```console
$ uv python upgrade 3.12
```
To upgrade all installed Python versions:
```console
$ uv python upgrade
```
After an upgrade, uv will prefer the new version, but will retain the existing version as it may
still be used by virtual environments.
If the Python version was installed with preview enabled, e.g., `uv python install 3.12 --preview`,
virtual environments using the Python version will be automatically upgraded to the new patch
version.
!!! note
If the virtual environment was created _before_ opting in to the preview mode, it will not be
included in the automatic upgrades.
If a virtual environment was created with an explicitly requested patch version, e.g.,
`uv venv -p 3.10.8`, it will not be transparently upgraded to a new version.
### Minor version directories
Automatic upgrades for virtual environments are implemented using a directory with the Python minor
version, e.g.:
```
~/.local/share/uv/python/cpython-3.12-macos-aarch64-none
```
which is a symbolic link (on Unix) or junction (on Windows) pointing to a specific patch version:
```console
$ readlink ~/.local/share/uv/python/cpython-3.12-macos-aarch64-none
~/.local/share/uv/python/cpython-3.12.11-macos-aarch64-none
```
If this link is resolved by another tool, e.g., by canonicalizing the Python interpreter path, and
used to create a virtual environment, it will not be automatically upgraded.
## Project Python versions
uv will respect Python requirements defined in `requires-python` in the `pyproject.toml` file during

View file

@ -120,6 +120,28 @@ To force uv to use the system Python, provide the `--no-managed-python` flag. Se
[Python version preference](../concepts/python-versions.md#requiring-or-disabling-managed-python-versions)
documentation for more details.
## Upgrading Python versions
!!! important
Support for upgrading Python patch versions is in _preview_. This means the behavior is
experimental and subject to change.
To upgrade a Python version to the latest supported patch release:
```console
$ uv python upgrade 3.12
```
To upgrade all uv-managed Python versions:
```console
$ uv python upgrade
```
See the [`python upgrade`](../concepts/python-versions.md#upgrading-python-versions) documentation
for more details.
## Next steps
To learn more about `uv python`, see the [Python version concept](../concepts/python-versions.md)

View file

@ -2559,6 +2559,7 @@ uv python [OPTIONS] <COMMAND>
<dl class="cli-reference"><dt><a href="#uv-python-list"><code>uv python list</code></a></dt><dd><p>List the available Python installations</p></dd>
<dt><a href="#uv-python-install"><code>uv python install</code></a></dt><dd><p>Download and install Python versions</p></dd>
<dt><a href="#uv-python-upgrade"><code>uv python upgrade</code></a></dt><dd><p>Upgrade installed Python versions to the latest supported patch release (requires the <code>--preview</code> flag)</p></dd>
<dt><a href="#uv-python-find"><code>uv python find</code></a></dt><dd><p>Search for a Python installation</p></dd>
<dt><a href="#uv-python-pin"><code>uv python pin</code></a></dt><dd><p>Pin to a specific Python version</p></dd>
<dt><a href="#uv-python-dir"><code>uv python dir</code></a></dt><dd><p>Show the uv Python installation directory</p></dd>
@ -2753,6 +2754,91 @@ uv python install [OPTIONS] [TARGETS]...
<p>You can configure fine-grained logging using the <code>RUST_LOG</code> environment variable. (<a href="https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives">https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives</a>)</p>
</dd></dl>
### uv python upgrade
Upgrade installed Python versions to the latest supported patch release (requires the `--preview` flag).
A target Python minor version to upgrade may be provided, e.g., `3.13`. Multiple versions may be provided to perform more than one upgrade.
If no target version is provided, then uv will upgrade all managed CPython versions.
During an upgrade, uv will not uninstall outdated patch versions.
When an upgrade is performed, virtual environments created by uv will automatically use the new version. However, if the virtual environment was created before the upgrade functionality was added, it will continue to use the old Python version; to enable upgrades, the environment must be recreated.
Upgrades are not yet supported for alternative implementations, like PyPy.
<h3 class="cli-reference">Usage</h3>
```
uv python upgrade [OPTIONS] [TARGETS]...
```
<h3 class="cli-reference">Arguments</h3>
<dl class="cli-reference"><dt id="uv-python-upgrade--targets"><a href="#uv-python-upgrade--targets"<code>TARGETS</code></a></dt><dd><p>The Python minor version(s) to upgrade.</p>
<p>If no target version is provided, then uv will upgrade all managed CPython versions.</p>
</dd></dl>
<h3 class="cli-reference">Options</h3>
<dl class="cli-reference"><dt id="uv-python-upgrade--allow-insecure-host"><a href="#uv-python-upgrade--allow-insecure-host"><code>--allow-insecure-host</code></a>, <code>--trusted-host</code> <i>allow-insecure-host</i></dt><dd><p>Allow insecure connections to a host.</p>
<p>Can be provided multiple times.</p>
<p>Expects to receive either a hostname (e.g., <code>localhost</code>), a host-port pair (e.g., <code>localhost:8080</code>), or a URL (e.g., <code>https://localhost</code>).</p>
<p>WARNING: Hosts included in this list will not be verified against the system's certificate store. Only use <code>--allow-insecure-host</code> in a secure network with verified sources, as it bypasses SSL verification and could expose you to MITM attacks.</p>
<p>May also be set with the <code>UV_INSECURE_HOST</code> environment variable.</p></dd><dt id="uv-python-upgrade--cache-dir"><a href="#uv-python-upgrade--cache-dir"><code>--cache-dir</code></a> <i>cache-dir</i></dt><dd><p>Path to the cache directory.</p>
<p>Defaults to <code>$XDG_CACHE_HOME/uv</code> or <code>$HOME/.cache/uv</code> on macOS and Linux, and <code>%LOCALAPPDATA%\uv\cache</code> on Windows.</p>
<p>To view the location of the cache directory, run <code>uv cache dir</code>.</p>
<p>May also be set with the <code>UV_CACHE_DIR</code> environment variable.</p></dd><dt id="uv-python-upgrade--color"><a href="#uv-python-upgrade--color"><code>--color</code></a> <i>color-choice</i></dt><dd><p>Control the use of color in output.</p>
<p>By default, uv will automatically detect support for colors when writing to a terminal.</p>
<p>Possible values:</p>
<ul>
<li><code>auto</code>: Enables colored output only when the output is going to a terminal or TTY with support</li>
<li><code>always</code>: Enables colored output regardless of the detected environment</li>
<li><code>never</code>: Disables colored output</li>
</ul></dd><dt id="uv-python-upgrade--config-file"><a href="#uv-python-upgrade--config-file"><code>--config-file</code></a> <i>config-file</i></dt><dd><p>The path to a <code>uv.toml</code> file to use for configuration.</p>
<p>While uv configuration can be included in a <code>pyproject.toml</code> file, it is not allowed in this context.</p>
<p>May also be set with the <code>UV_CONFIG_FILE</code> environment variable.</p></dd><dt id="uv-python-upgrade--directory"><a href="#uv-python-upgrade--directory"><code>--directory</code></a> <i>directory</i></dt><dd><p>Change to the given directory prior to running the command.</p>
<p>Relative paths are resolved with the given directory as the base.</p>
<p>See <code>--project</code> to only change the project root directory.</p>
</dd><dt id="uv-python-upgrade--help"><a href="#uv-python-upgrade--help"><code>--help</code></a>, <code>-h</code></dt><dd><p>Display the concise help for this command</p>
</dd><dt id="uv-python-upgrade--install-dir"><a href="#uv-python-upgrade--install-dir"><code>--install-dir</code></a>, <code>-i</code> <i>install-dir</i></dt><dd><p>The directory Python installations are stored in.</p>
<p>If provided, <code>UV_PYTHON_INSTALL_DIR</code> will need to be set for subsequent operations for uv to discover the Python installation.</p>
<p>See <code>uv python dir</code> to view the current Python installation directory. Defaults to <code>~/.local/share/uv/python</code>.</p>
<p>May also be set with the <code>UV_PYTHON_INSTALL_DIR</code> environment variable.</p></dd><dt id="uv-python-upgrade--managed-python"><a href="#uv-python-upgrade--managed-python"><code>--managed-python</code></a></dt><dd><p>Require use of uv-managed Python versions.</p>
<p>By default, uv prefers using Python versions it manages. However, it will use system Python versions if a uv-managed Python is not installed. This option disables use of system Python versions.</p>
<p>May also be set with the <code>UV_MANAGED_PYTHON</code> environment variable.</p></dd><dt id="uv-python-upgrade--mirror"><a href="#uv-python-upgrade--mirror"><code>--mirror</code></a> <i>mirror</i></dt><dd><p>Set the URL to use as the source for downloading Python installations.</p>
<p>The provided URL will replace <code>https://github.com/astral-sh/python-build-standalone/releases/download</code> in, e.g., <code>https://github.com/astral-sh/python-build-standalone/releases/download/20240713/cpython-3.12.4%2B20240713-aarch64-apple-darwin-install_only.tar.gz</code>.</p>
<p>Distributions can be read from a local directory by using the <code>file://</code> URL scheme.</p>
<p>May also be set with the <code>UV_PYTHON_INSTALL_MIRROR</code> environment variable.</p></dd><dt id="uv-python-upgrade--native-tls"><a href="#uv-python-upgrade--native-tls"><code>--native-tls</code></a></dt><dd><p>Whether to load TLS certificates from the platform's native certificate store.</p>
<p>By default, uv loads certificates from the bundled <code>webpki-roots</code> crate. The <code>webpki-roots</code> are a reliable set of trust roots from Mozilla, and including them in uv improves portability and performance (especially on macOS).</p>
<p>However, in some cases, you may want to use the platform's native certificate store, especially if you're relying on a corporate trust root (e.g., for a mandatory proxy) that's included in your system's certificate store.</p>
<p>May also be set with the <code>UV_NATIVE_TLS</code> environment variable.</p></dd><dt id="uv-python-upgrade--no-cache"><a href="#uv-python-upgrade--no-cache"><code>--no-cache</code></a>, <code>--no-cache-dir</code>, <code>-n</code></dt><dd><p>Avoid reading from or writing to the cache, instead using a temporary directory for the duration of the operation</p>
<p>May also be set with the <code>UV_NO_CACHE</code> environment variable.</p></dd><dt id="uv-python-upgrade--no-config"><a href="#uv-python-upgrade--no-config"><code>--no-config</code></a></dt><dd><p>Avoid discovering configuration files (<code>pyproject.toml</code>, <code>uv.toml</code>).</p>
<p>Normally, configuration files are discovered in the current directory, parent directories, or user configuration directories.</p>
<p>May also be set with the <code>UV_NO_CONFIG</code> environment variable.</p></dd><dt id="uv-python-upgrade--no-managed-python"><a href="#uv-python-upgrade--no-managed-python"><code>--no-managed-python</code></a></dt><dd><p>Disable use of uv-managed Python versions.</p>
<p>Instead, uv will search for a suitable Python version on the system.</p>
<p>May also be set with the <code>UV_NO_MANAGED_PYTHON</code> environment variable.</p></dd><dt id="uv-python-upgrade--no-progress"><a href="#uv-python-upgrade--no-progress"><code>--no-progress</code></a></dt><dd><p>Hide all progress outputs.</p>
<p>For example, spinners or progress bars.</p>
<p>May also be set with the <code>UV_NO_PROGRESS</code> environment variable.</p></dd><dt id="uv-python-upgrade--no-python-downloads"><a href="#uv-python-upgrade--no-python-downloads"><code>--no-python-downloads</code></a></dt><dd><p>Disable automatic downloads of Python.</p>
</dd><dt id="uv-python-upgrade--offline"><a href="#uv-python-upgrade--offline"><code>--offline</code></a></dt><dd><p>Disable network access.</p>
<p>When disabled, uv will only use locally cached data and locally available files.</p>
<p>May also be set with the <code>UV_OFFLINE</code> environment variable.</p></dd><dt id="uv-python-upgrade--project"><a href="#uv-python-upgrade--project"><code>--project</code></a> <i>project</i></dt><dd><p>Run the command within the given project directory.</p>
<p>All <code>pyproject.toml</code>, <code>uv.toml</code>, and <code>.python-version</code> files will be discovered by walking up the directory tree from the project root, as will the project's virtual environment (<code>.venv</code>).</p>
<p>Other command-line arguments (such as relative paths) will be resolved relative to the current working directory.</p>
<p>See <code>--directory</code> to change the working directory entirely.</p>
<p>This setting has no effect when used in the <code>uv pip</code> interface.</p>
<p>May also be set with the <code>UV_PROJECT</code> environment variable.</p></dd><dt id="uv-python-upgrade--pypy-mirror"><a href="#uv-python-upgrade--pypy-mirror"><code>--pypy-mirror</code></a> <i>pypy-mirror</i></dt><dd><p>Set the URL to use as the source for downloading PyPy installations.</p>
<p>The provided URL will replace <code>https://downloads.python.org/pypy</code> in, e.g., <code>https://downloads.python.org/pypy/pypy3.8-v7.3.7-osx64.tar.bz2</code>.</p>
<p>Distributions can be read from a local directory by using the <code>file://</code> URL scheme.</p>
<p>May also be set with the <code>UV_PYPY_INSTALL_MIRROR</code> environment variable.</p></dd><dt id="uv-python-upgrade--python-downloads-json-url"><a href="#uv-python-upgrade--python-downloads-json-url"><code>--python-downloads-json-url</code></a> <i>python-downloads-json-url</i></dt><dd><p>URL pointing to JSON of custom Python installations.</p>
<p>Note that currently, only local paths are supported.</p>
<p>May also be set with the <code>UV_PYTHON_DOWNLOADS_JSON_URL</code> environment variable.</p></dd><dt id="uv-python-upgrade--quiet"><a href="#uv-python-upgrade--quiet"><code>--quiet</code></a>, <code>-q</code></dt><dd><p>Use quiet output.</p>
<p>Repeating this option, e.g., <code>-qq</code>, will enable a silent mode in which uv will write no output to stdout.</p>
</dd><dt id="uv-python-upgrade--verbose"><a href="#uv-python-upgrade--verbose"><code>--verbose</code></a>, <code>-v</code></dt><dd><p>Use verbose output.</p>
<p>You can configure fine-grained logging using the <code>RUST_LOG</code> environment variable. (<a href="https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives">https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives</a>)</p>
</dd></dl>
### uv python find
Search for a Python installation.