diff --git a/Cargo.lock b/Cargo.lock index a30a0cbe1..6c1978cd0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/crates/uv-build-frontend/src/lib.rs b/crates/uv-build-frontend/src/lib.rs index 1c29b2c31..34037ffdd 100644 --- a/crates/uv-build-frontend/src/lib.rs +++ b/crates/uv-build-frontend/src/lib.rs @@ -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, level: BuildOutput, concurrent_builds: usize, + preview: PreviewMode, ) -> Result { let temp_dir = build_context.cache().venv_dir()?; @@ -325,6 +327,8 @@ impl SourceBuild { false, false, false, + false, + preview, )? }; diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index bd06f9a82..e58f6c079 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -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, + + /// 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, + + /// 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, + + /// 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, + + /// 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, +} + #[derive(Args)] pub struct PythonUninstallArgs { /// The directory where the Python was installed. diff --git a/crates/uv-dev/src/compile.rs b/crates/uv-dev/src/compile.rs index 434b5e791..d2b685b23 100644 --- a/crates/uv-dev/src/compile.rs +++ b/crates/uv-dev/src/compile.rs @@ -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() diff --git a/crates/uv-dispatch/src/lib.rs b/crates/uv-dispatch/src/lib.rs index 3b0ad5555..207f241ad 100644 --- a/crates/uv-dispatch/src/lib.rs +++ b/crates/uv-dispatch/src/lib.rs @@ -433,6 +433,7 @@ impl BuildContext for BuildDispatch<'_> { self.build_extra_env_vars.clone(), build_output, self.concurrency.builds, + self.preview, ) .boxed_local() .await?; diff --git a/crates/uv-fs/Cargo.toml b/crates/uv-fs/Cargo.toml index 12a5f94b7..fba4910e6 100644 --- a/crates/uv-fs/Cargo.toml +++ b/crates/uv-fs/Cargo.toml @@ -16,7 +16,6 @@ doctest = false workspace = true [dependencies] - dunce = { workspace = true } either = { workspace = true } encoding_rs_io = { workspace = true } diff --git a/crates/uv-fs/src/path.rs b/crates/uv-fs/src/path.rs index 7a75c76c3..90d0ce80a 100644 --- a/crates/uv-fs/src/path.rs +++ b/crates/uv-fs/src/path.rs @@ -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) -> std::io::Result { - 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` diff --git a/crates/uv-python/Cargo.toml b/crates/uv-python/Cargo.toml index 59a9829e0..d008b2d4e 100644 --- a/crates/uv-python/Cargo.toml +++ b/crates/uv-python/Cargo.toml @@ -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 } diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index 27853e3db..eaf4b4830 100644 --- a/crates/uv-python/src/discovery.rs +++ b/crates/uv-python/src/discovery.rs @@ -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> + '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> + '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> + '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> + '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 { - 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 { 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, diff --git a/crates/uv-python/src/downloads.rs b/crates/uv-python/src/downloads.rs index fd3f6c417..51f6f1d45 100644 --- a/crates/uv-python/src/downloads.rs +++ b/crates/uv-python/src/downloads.rs @@ -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, pub(crate) implementation: Option, @@ -131,7 +131,7 @@ pub struct PythonDownloadRequest { pub(crate) prereleases: Option, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ArchRequest { Explicit(Arch), Environment(Arch), diff --git a/crates/uv-python/src/environment.rs b/crates/uv-python/src/environment.rs index 34fa3eee9..02f9fd683 100644 --- a/crates/uv-python/src/environment.rs +++ b/crates/uv-python/src/environment.rs @@ -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 { 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()), diff --git a/crates/uv-python/src/installation.rs b/crates/uv-python/src/installation.rs index 611dc2007..d46643d21 100644 --- a/crates/uv-python/src/installation.rs +++ b/crates/uv-python/src/installation.rs @@ -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 { - 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 { 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 { 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 { 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 + where + I: IntoIterator, + { + 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(&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 for PythonInstallationMinorVersionKey { + fn from(key: PythonInstallationKey) -> Self { + PythonInstallationMinorVersionKey(key) + } +} diff --git a/crates/uv-python/src/interpreter.rs b/crates/uv-python/src/interpreter.rs index 26d810db5..19e790b7e 100644 --- a/crates/uv-python/src/interpreter.rs +++ b/crates/uv-python/src/interpreter.rs @@ -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: + #[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) -> std::io::Result { + 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: @@ -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 { diff --git a/crates/uv-python/src/lib.rs b/crates/uv-python/src/lib.rs index 0fa2d46ab..d408bc199 100644 --- a/crates/uv-python/src/lib.rs +++ b/crates/uv-python/src/lib.rs @@ -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, ) })??; diff --git a/crates/uv-python/src/managed.rs b/crates/uv-python/src/managed.rs index edf4087e7..e7287fe72 100644 --- a/crates/uv-python/src/managed.rs +++ b/crates/uv-python/src/managed.rs @@ -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 `/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 { + 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 { + 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., `/bin/python3.10`. On Windows, this is, +/// e.g., `\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 { diff --git a/crates/uv-python/src/python_version.rs b/crates/uv-python/src/python_version.rs index 30dfccecd..39e8e3429 100644 --- a/crates/uv-python/src/python_version.rs +++ b/crates/uv-python/src/python_version.rs @@ -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 for PythonVersion { diff --git a/crates/uv-tool/Cargo.toml b/crates/uv-tool/Cargo.toml index d01a3209d..210c17c00 100644 --- a/crates/uv-tool/Cargo.toml +++ b/crates/uv-tool/Cargo.toml @@ -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 } diff --git a/crates/uv-tool/src/lib.rs b/crates/uv-tool/src/lib.rs index f85075ea6..ee80a2854 100644 --- a/crates/uv-tool/src/lib.rs +++ b/crates/uv-tool/src/lib.rs @@ -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 { let environment_path = self.tool_dir(name); @@ -286,6 +288,8 @@ impl InstalledTools { false, false, false, + false, + preview, )?; Ok(venv) diff --git a/crates/uv-trampoline/src/bounce.rs b/crates/uv-trampoline/src/bounce.rs index 1e90f035d..8d658bdab 100644 --- a/crates/uv-trampoline/src/bounce.rs +++ b/crates/uv-trampoline/src/bounce.rs @@ -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 `/Scripts/black` and Python then fails @@ -118,6 +145,20 @@ fn push_quoted_path(path: &Path, command: &mut Vec) { 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) } diff --git a/crates/uv-trampoline/trampolines/uv-trampoline-aarch64-console.exe b/crates/uv-trampoline/trampolines/uv-trampoline-aarch64-console.exe index 3b7f76564..5f2d6115e 100755 Binary files a/crates/uv-trampoline/trampolines/uv-trampoline-aarch64-console.exe and b/crates/uv-trampoline/trampolines/uv-trampoline-aarch64-console.exe differ diff --git a/crates/uv-trampoline/trampolines/uv-trampoline-aarch64-gui.exe b/crates/uv-trampoline/trampolines/uv-trampoline-aarch64-gui.exe index 74080d4db..3a5a2e348 100755 Binary files a/crates/uv-trampoline/trampolines/uv-trampoline-aarch64-gui.exe and b/crates/uv-trampoline/trampolines/uv-trampoline-aarch64-gui.exe differ diff --git a/crates/uv-trampoline/trampolines/uv-trampoline-i686-console.exe b/crates/uv-trampoline/trampolines/uv-trampoline-i686-console.exe index 3fd1e0aff..bdc225e4d 100755 Binary files a/crates/uv-trampoline/trampolines/uv-trampoline-i686-console.exe and b/crates/uv-trampoline/trampolines/uv-trampoline-i686-console.exe differ diff --git a/crates/uv-trampoline/trampolines/uv-trampoline-i686-gui.exe b/crates/uv-trampoline/trampolines/uv-trampoline-i686-gui.exe index 4221696a1..d6753380d 100755 Binary files a/crates/uv-trampoline/trampolines/uv-trampoline-i686-gui.exe and b/crates/uv-trampoline/trampolines/uv-trampoline-i686-gui.exe differ diff --git a/crates/uv-trampoline/trampolines/uv-trampoline-x86_64-console.exe b/crates/uv-trampoline/trampolines/uv-trampoline-x86_64-console.exe index 5b8fa6acc..b93c242e7 100755 Binary files a/crates/uv-trampoline/trampolines/uv-trampoline-x86_64-console.exe and b/crates/uv-trampoline/trampolines/uv-trampoline-x86_64-console.exe differ diff --git a/crates/uv-trampoline/trampolines/uv-trampoline-x86_64-gui.exe b/crates/uv-trampoline/trampolines/uv-trampoline-x86_64-gui.exe index 8cb19cf8f..c81d8e4e5 100755 Binary files a/crates/uv-trampoline/trampolines/uv-trampoline-x86_64-gui.exe and b/crates/uv-trampoline/trampolines/uv-trampoline-x86_64-gui.exe differ diff --git a/crates/uv-virtualenv/Cargo.toml b/crates/uv-virtualenv/Cargo.toml index e9610176b..cb0ae1b9d 100644 --- a/crates/uv-virtualenv/Cargo.toml +++ b/crates/uv-virtualenv/Cargo.toml @@ -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 } diff --git a/crates/uv-virtualenv/src/lib.rs b/crates/uv-virtualenv/src/lib.rs index 8c4e1feab..277ab6a8c 100644 --- a/crates/uv-virtualenv/src/lib.rs +++ b/crates/uv-virtualenv/src/lib.rs @@ -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 { // 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`. diff --git a/crates/uv-virtualenv/src/virtualenv.rs b/crates/uv-virtualenv/src/virtualenv.rs index a641e5541..bb6db01a3 100644 --- a/crates/uv-virtualenv/src/virtualenv.rs +++ b/crates/uv-virtualenv/src/virtualenv.rs @@ -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 { // 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, + )?; + } } } diff --git a/crates/uv/Cargo.toml b/crates/uv/Cargo.toml index b3accc211..de4fa3c38 100644 --- a/crates/uv/Cargo.toml +++ b/crates/uv/Cargo.toml @@ -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 } diff --git a/crates/uv/src/commands/build_frontend.rs b/crates/uv/src/commands/build_frontend.rs index dd174ca06..9b97b40b1 100644 --- a/crates/uv/src/commands/build_frontend.rs +++ b/crates/uv/src/commands/build_frontend.rs @@ -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(); diff --git a/crates/uv/src/commands/pip/check.rs b/crates/uv/src/commands/pip/check.rs index f504503af..bfbb20ee6 100644 --- a/crates/uv/src/commands/pip/check.rs +++ b/crates/uv/src/commands/pip/check.rs @@ -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 { 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)?; diff --git a/crates/uv/src/commands/pip/compile.rs b/crates/uv/src/commands/pip/compile.rs index 197455aae..db80c2a8a 100644 --- a/crates/uv/src/commands/pip/compile.rs +++ b/crates/uv/src/commands/pip/compile.rs @@ -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(); diff --git a/crates/uv/src/commands/pip/freeze.rs b/crates/uv/src/commands/pip/freeze.rs index 7ad5517af..8c8491d45 100644 --- a/crates/uv/src/commands/pip/freeze.rs +++ b/crates/uv/src/commands/pip/freeze.rs @@ -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>, cache: &Cache, printer: Printer, + preview: PreviewMode, ) -> Result { // 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)?; diff --git a/crates/uv/src/commands/pip/install.rs b/crates/uv/src/commands/pip/install.rs index e4f524c57..a92c36665 100644 --- a/crates/uv/src/commands/pip/install.rs +++ b/crates/uv/src/commands/pip/install.rs @@ -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 diff --git a/crates/uv/src/commands/pip/list.rs b/crates/uv/src/commands/pip/list.rs index 824c9db2b..356574436 100644 --- a/crates/uv/src/commands/pip/list.rs +++ b/crates/uv/src/commands/pip/list.rs @@ -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 { // 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)?; diff --git a/crates/uv/src/commands/pip/show.rs b/crates/uv/src/commands/pip/show.rs index a77c29cd5..4d2b3c3a7 100644 --- a/crates/uv/src/commands/pip/show.rs +++ b/crates/uv/src/commands/pip/show.rs @@ -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 { 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)?; diff --git a/crates/uv/src/commands/pip/sync.rs b/crates/uv/src/commands/pip/sync.rs index e5bf92ae4..e5145400a 100644 --- a/crates/uv/src/commands/pip/sync.rs +++ b/crates/uv/src/commands/pip/sync.rs @@ -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 diff --git a/crates/uv/src/commands/pip/tree.rs b/crates/uv/src/commands/pip/tree.rs index aed364068..b0ba44c35 100644 --- a/crates/uv/src/commands/pip/tree.rs +++ b/crates/uv/src/commands/pip/tree.rs @@ -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 { // 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)?; diff --git a/crates/uv/src/commands/pip/uninstall.rs b/crates/uv/src/commands/pip/uninstall.rs index 787ba5aae..4424fee37 100644 --- a/crates/uv/src/commands/pip/uninstall.rs +++ b/crates/uv/src/commands/pip/uninstall.rs @@ -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 { 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)?; diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 1c5297f90..ae20b31d4 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -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()?; diff --git a/crates/uv/src/commands/project/environment.rs b/crates/uv/src/commands/project/environment.rs index f7ba006c5..da3dc7f63 100644 --- a/crates/uv/src/commands/project/environment.rs +++ b/crates/uv/src/commands/project/environment.rs @@ -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( diff --git a/crates/uv/src/commands/project/export.rs b/crates/uv/src/commands/project/export.rs index ac228989c..88a847d04 100644 --- a/crates/uv/src/commands/project/export.rs +++ b/crates/uv/src/commands/project/export.rs @@ -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(), diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index 71aacdc1b..15fed409e 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -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(); diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 1ab4441b0..97ee01767 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -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(), diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 85defd4dd..a3249b11a 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -625,6 +625,7 @@ impl ScriptInterpreter { active: Option, cache: &Cache, printer: Printer, + preview: PreviewMode, ) -> Result { // 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, cache: &Cache, printer: Printer, + preview: PreviewMode, ) -> Result { // 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 { // 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 { // 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 { 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(); diff --git a/crates/uv/src/commands/project/remove.rs b/crates/uv/src/commands/project/remove.rs index d17cd88ed..7fd02277e 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -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(); diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index f97ffdbc1..8a510fa8c 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -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 { diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 940b3a653..f1a73b8c8 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -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?, ), diff --git a/crates/uv/src/commands/project/tree.rs b/crates/uv/src/commands/project/tree.rs index 9c42a8a86..2ff6ad98e 100644 --- a/crates/uv/src/commands/project/tree.rs +++ b/crates/uv/src/commands/project/tree.rs @@ -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(), diff --git a/crates/uv/src/commands/project/version.rs b/crates/uv/src/commands/project/version.rs index f76744186..fdba41978 100644 --- a/crates/uv/src/commands/project/version.rs +++ b/crates/uv/src/commands/project/version.rs @@ -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()?; diff --git a/crates/uv/src/commands/python/find.rs b/crates/uv/src/commands/python/find.rs index 1e5693c65..e188e9d20 100644 --- a/crates/uv/src/commands/python/find.rs +++ b/crates/uv/src/commands/python/find.rs @@ -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 { 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 { let interpreter = match ScriptInterpreter::discover( script, @@ -134,6 +137,7 @@ pub(crate) async fn find_script( Some(false), cache, printer, + preview, ) .await { diff --git a/crates/uv/src/commands/python/install.rs b/crates/uv/src/commands/python/install.rs index 8f5beedc9..7ad96fffe 100644 --- a/crates/uv/src/commands/python/install.rs +++ b/crates/uv/src/commands/python/install.rs @@ -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, targets: Vec, reinstall: bool, + upgrade: bool, force: bool, python_install_mirror: Option, pypy_install_mirror: Option, @@ -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::>>()? + if upgrade { + is_unspecified_upgrade = true; + let mut minor_version_requests = IndexSet::::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::>() + } 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::>>()? + } } 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::>(); + + 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 ` 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, 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") }; diff --git a/crates/uv/src/commands/python/list.rs b/crates/uv/src/commands/python/list.rs index 71bfb9c55..2cd54747c 100644 --- a/crates/uv/src/commands/python/list.rs +++ b/crates/uv/src/commands/python/list.rs @@ -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 { 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| { diff --git a/crates/uv/src/commands/python/pin.rs b/crates/uv/src/commands/python/pin.rs index a0af7ec41..26714e4d7 100644 --- a/crates/uv/src/commands/python/pin.rs +++ b/crates/uv/src/commands/python/pin.rs @@ -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 { 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(); diff --git a/crates/uv/src/commands/python/uninstall.rs b/crates/uv/src/commands/python/uninstall.rs index ac159344c..8a63b015c 100644 --- a/crates/uv/src/commands/python/uninstall.rs +++ b/crates/uv/src/commands/python/uninstall.rs @@ -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::>>()?; - 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() { diff --git a/crates/uv/src/commands/tool/common.rs b/crates/uv/src/commands/tool/common.rs index 807225cbc..166b4fc6f 100644 --- a/crates/uv/src/commands/tool/common.rs +++ b/crates/uv/src/commands/tool/common.rs @@ -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, 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(); diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index a65ad3af2..e816e771e 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -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. diff --git a/crates/uv/src/commands/tool/run.rs b/crates/uv/src/commands/tool/run.rs index 4d270c445..2746d65ad 100644 --- a/crates/uv/src/commands/tool/run.rs +++ b/crates/uv/src/commands/tool/run.rs @@ -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() diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs index c930ecada..166e00349 100644 --- a/crates/uv/src/commands/tool/upgrade.rs +++ b/crates/uv/src/commands/tool/upgrade.rs @@ -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, diff --git a/crates/uv/src/commands/venv.rs b/crates/uv/src/commands/venv.rs index a50c0e155..fe20634d0 100644 --- a/crates/uv/src/commands/venv.rs +++ b/crates/uv/src/commands/venv.rs @@ -47,7 +47,7 @@ use super::project::default_dependency_groups; pub(crate) async fn venv( project_dir: &Path, path: Option, - python_request: Option<&str>, + python_request: Option, install_mirrors: PythonInstallMirrors, python_preference: PythonPreference, python_downloads: PythonDownloads, @@ -130,7 +130,7 @@ enum VenvError { async fn venv_impl( project_dir: &Path, path: Option, - python_request: Option<&str>, + python_request: Option, 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)?; diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 51041bcbc..fd2e28fae 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -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 { &globals.network_settings, args.dry_run, printer, + globals.preview, ) .await } @@ -814,6 +816,7 @@ async fn run(mut cli: Cli) -> Result { args.paths, &cache, printer, + globals.preview, ) } Commands::Pip(PipNamespace { @@ -845,6 +848,7 @@ async fn run(mut cli: Cli) -> Result { args.settings.system, &cache, printer, + globals.preview, ) .await } @@ -866,6 +870,7 @@ async fn run(mut cli: Cli) -> Result { args.files, &cache, printer, + globals.preview, ) } Commands::Pip(PipNamespace { @@ -897,6 +902,7 @@ async fn run(mut cli: Cli) -> Result { args.settings.system, &cache, printer, + globals.preview, ) .await } @@ -915,6 +921,7 @@ async fn run(mut cli: Cli) -> Result { args.settings.system, &cache, printer, + globals.preview, ) } Commands::Cache(CacheNamespace { @@ -1016,10 +1023,13 @@ async fn run(mut cli: Cli) -> Result { } }); + let python_request: Option = + 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 { globals.python_downloads, &cache, printer, + globals.preview, ) .await } @@ -1379,12 +1390,43 @@ async fn run(mut cli: Cli) -> Result { // 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 { cli.top_level.no_config, &cache, printer, + globals.preview, ) .await } else { @@ -1446,6 +1489,7 @@ async fn run(mut cli: Cli) -> Result { globals.python_preference, &cache, printer, + globals.preview, ) .await } @@ -1472,6 +1516,7 @@ async fn run(mut cli: Cli) -> Result { globals.network_settings, &cache, printer, + globals.preview, ) .await } diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index fb1a62b41..5cbeb1886 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -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, + pub(crate) targets: Vec, + pub(crate) force: bool, + pub(crate) python_install_mirror: Option, + pub(crate) pypy_install_mirror: Option, + pub(crate) python_downloads_json_url: Option, + 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) -> 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 { diff --git a/crates/uv/tests/it/common/mod.rs b/crates/uv/tests/it/common/mod.rs index 7ef7cfff6..4d65aa4a3 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -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 { diff --git a/crates/uv/tests/it/help.rs b/crates/uv/tests/it/help.rs index 6fd9bd466..8faebd040 100644 --- a/crates/uv/tests/it/help.rs +++ b/crates/uv/tests/it/help.rs @@ -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 diff --git a/crates/uv/tests/it/main.rs b/crates/uv/tests/it/main.rs index 7835fa461..872c88d4b 100644 --- a/crates/uv/tests/it/main.rs +++ b/crates/uv/tests/it/main.rs @@ -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; diff --git a/crates/uv/tests/it/python_install.rs b/crates/uv/tests/it/python_install.rs index 913711c7c..d5dec1977 100644 --- a/crates/uv/tests/it/python_install.rs +++ b/crates/uv/tests/it/python_install.rs @@ -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' + "# + ); +} diff --git a/crates/uv/tests/it/python_upgrade.rs b/crates/uv/tests/it/python_upgrade.rs new file mode 100644 index 000000000..cbea1d404 --- /dev/null +++ b/crates/uv/tests/it/python_upgrade.rs @@ -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(()) +} diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 966dd41d2..e6fe831d3 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -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(()) diff --git a/crates/uv/tests/it/tool_upgrade.rs b/crates/uv/tests/it/tool_upgrade.rs index a36db9b92..70309f04d 100644 --- a/crates/uv/tests/it/tool_upgrade.rs +++ b/crates/uv/tests/it/tool_upgrade.rs @@ -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]"); }); } diff --git a/crates/uv/tests/it/venv.rs b/crates/uv/tests/it/venv.rs index bc35f9490..f1860efa2 100644 --- a/crates/uv/tests/it/venv.rs +++ b/crates/uv/tests/it/venv.rs @@ -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 - "### + " ); } diff --git a/docs/concepts/python-versions.md b/docs/concepts/python-versions.md index 0d409ff50..a7472bea8 100644 --- a/docs/concepts/python-versions.md +++ b/docs/concepts/python-versions.md @@ -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 diff --git a/docs/guides/install-python.md b/docs/guides/install-python.md index 0b80589a5..da841eac6 100644 --- a/docs/guides/install-python.md +++ b/docs/guides/install-python.md @@ -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) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 9ae05a8e0..48b0351fe 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -2559,6 +2559,7 @@ uv python [OPTIONS]
uv python list

List the available Python installations

uv python install

Download and install Python versions

+
uv python upgrade

Upgrade installed Python versions to the latest supported patch release (requires the --preview flag)

uv python find

Search for a Python installation

uv python pin

Pin to a specific Python version

uv python dir

Show the uv Python installation directory

@@ -2753,6 +2754,91 @@ uv python install [OPTIONS] [TARGETS]...

You can configure fine-grained logging using the RUST_LOG environment variable. (https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives)

+### 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. + +

Usage

+ +``` +uv python upgrade [OPTIONS] [TARGETS]... +``` + +

Arguments

+ +
TARGETS

The Python minor version(s) to upgrade.

+

If no target version is provided, then uv will upgrade all managed CPython versions.

+
+ +

Options

+ +
--allow-insecure-host, --trusted-host allow-insecure-host

Allow insecure connections to a host.

+

Can be provided multiple times.

+

Expects to receive either a hostname (e.g., localhost), a host-port pair (e.g., localhost:8080), or a URL (e.g., https://localhost).

+

WARNING: Hosts included in this list will not be verified against the system's certificate store. Only use --allow-insecure-host in a secure network with verified sources, as it bypasses SSL verification and could expose you to MITM attacks.

+

May also be set with the UV_INSECURE_HOST environment variable.

--cache-dir cache-dir

Path to the cache directory.

+

Defaults to $XDG_CACHE_HOME/uv or $HOME/.cache/uv on macOS and Linux, and %LOCALAPPDATA%\uv\cache on Windows.

+

To view the location of the cache directory, run uv cache dir.

+

May also be set with the UV_CACHE_DIR environment variable.

--color color-choice

Control the use of color in output.

+

By default, uv will automatically detect support for colors when writing to a terminal.

+

Possible values:

+
    +
  • auto: Enables colored output only when the output is going to a terminal or TTY with support
  • +
  • always: Enables colored output regardless of the detected environment
  • +
  • never: Disables colored output
  • +
--config-file config-file

The path to a uv.toml file to use for configuration.

+

While uv configuration can be included in a pyproject.toml file, it is not allowed in this context.

+

May also be set with the UV_CONFIG_FILE environment variable.

--directory directory

Change to the given directory prior to running the command.

+

Relative paths are resolved with the given directory as the base.

+

See --project to only change the project root directory.

+
--help, -h

Display the concise help for this command

+
--install-dir, -i install-dir

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.

+

May also be set with the UV_PYTHON_INSTALL_DIR environment variable.

--managed-python

Require use of uv-managed Python versions.

+

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.

+

May also be set with the UV_MANAGED_PYTHON environment variable.

--mirror mirror

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.

+

May also be set with the UV_PYTHON_INSTALL_MIRROR environment variable.

--native-tls

Whether to load TLS certificates from the platform's native certificate store.

+

By default, uv loads certificates from the bundled webpki-roots crate. The webpki-roots are a reliable set of trust roots from Mozilla, and including them in uv improves portability and performance (especially on macOS).

+

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.

+

May also be set with the UV_NATIVE_TLS environment variable.

--no-cache, --no-cache-dir, -n

Avoid reading from or writing to the cache, instead using a temporary directory for the duration of the operation

+

May also be set with the UV_NO_CACHE environment variable.

--no-config

Avoid discovering configuration files (pyproject.toml, uv.toml).

+

Normally, configuration files are discovered in the current directory, parent directories, or user configuration directories.

+

May also be set with the UV_NO_CONFIG environment variable.

--no-managed-python

Disable use of uv-managed Python versions.

+

Instead, uv will search for a suitable Python version on the system.

+

May also be set with the UV_NO_MANAGED_PYTHON environment variable.

--no-progress

Hide all progress outputs.

+

For example, spinners or progress bars.

+

May also be set with the UV_NO_PROGRESS environment variable.

--no-python-downloads

Disable automatic downloads of Python.

+
--offline

Disable network access.

+

When disabled, uv will only use locally cached data and locally available files.

+

May also be set with the UV_OFFLINE environment variable.

--project project

Run the command within the given project directory.

+

All pyproject.toml, uv.toml, and .python-version files will be discovered by walking up the directory tree from the project root, as will the project's virtual environment (.venv).

+

Other command-line arguments (such as relative paths) will be resolved relative to the current working directory.

+

See --directory to change the working directory entirely.

+

This setting has no effect when used in the uv pip interface.

+

May also be set with the UV_PROJECT environment variable.

--pypy-mirror pypy-mirror

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.

+

May also be set with the UV_PYPY_INSTALL_MIRROR environment variable.

--python-downloads-json-url python-downloads-json-url

URL pointing to JSON of custom Python installations.

+

Note that currently, only local paths are supported.

+

May also be set with the UV_PYTHON_DOWNLOADS_JSON_URL environment variable.

--quiet, -q

Use quiet output.

+

Repeating this option, e.g., -qq, will enable a silent mode in which uv will write no output to stdout.

+
--verbose, -v

Use verbose output.

+

You can configure fine-grained logging using the RUST_LOG environment variable. (https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives)

+
+ ### uv python find Search for a Python installation.