From 1b38b47a3fc628b467b06bbb7fe32cd481068072 Mon Sep 17 00:00:00 2001 From: liam Date: Wed, 12 Nov 2025 08:45:31 -0500 Subject: [PATCH] Warn on managed prerelease interpreters when a stable build is available (#16619) Resolves https://github.com/astral-sh/uv/issues/16616 This PR detects managed prerelease interpreters during discovery and warns when a matching stable build is available, wiring the new helper into `PythonInstallation::find`, `find_best`, and `find_or_download`. --- crates/uv-python/src/downloads.rs | 271 +++++++++++++++++++++++++++ crates/uv-python/src/installation.rs | 100 +++++++++- crates/uv/tests/it/python_find.rs | 2 + 3 files changed, 364 insertions(+), 9 deletions(-) diff --git a/crates/uv-python/src/downloads.rs b/crates/uv-python/src/downloads.rs index 418e34147..792abfd90 100644 --- a/crates/uv-python/src/downloads.rs +++ b/crates/uv-python/src/downloads.rs @@ -415,6 +415,86 @@ impl PythonDownloadRequest { self.version.take() } + /// Remove default implementation and platform details so the request only contains + /// explicitly user-specified segments. + #[must_use] + pub fn unset_defaults(self) -> Self { + let request = self.unset_non_platform_defaults(); + + if let Ok(host) = Platform::from_env() { + request.unset_platform_defaults(&host) + } else { + request + } + } + + fn unset_non_platform_defaults(mut self) -> Self { + self.implementation = self + .implementation + .filter(|implementation_name| *implementation_name != ImplementationName::default()); + + self.version = self + .version + .filter(|version| !matches!(version, VersionRequest::Any | VersionRequest::Default)); + + // Drop implicit architecture derived from environment so only user overrides remain. + self.arch = self + .arch + .filter(|arch| !matches!(arch, ArchRequest::Environment(_))); + + self + } + + #[cfg(test)] + pub(crate) fn unset_defaults_for_host(self, host: &Platform) -> Self { + self.unset_non_platform_defaults() + .unset_platform_defaults(host) + } + + pub(crate) fn unset_platform_defaults(mut self, host: &Platform) -> Self { + self.os = self.os.filter(|os| *os != host.os); + + self.libc = self.libc.filter(|libc| *libc != host.libc); + + self.arch = self + .arch + .filter(|arch| !matches!(arch, ArchRequest::Explicit(explicit_arch) if *explicit_arch == host.arch)); + + self + } + + /// Drop patch and prerelease information so the request can be re-used for upgrades. + #[must_use] + pub fn without_patch(mut self) -> Self { + self.version = self.version.take().map(VersionRequest::only_minor); + self.prereleases = None; + self.build = None; + self + } + + /// Return a compact string representation suitable for user-facing display. + /// + /// The resulting string only includes explicitly-set pieces of the request and returns + /// [`None`] when no segments are explicitly set. + pub fn simplified_display(self) -> Option { + let parts = [ + self.implementation + .map(|implementation| implementation.to_string()), + self.version.map(|version| version.to_string()), + self.os.map(|os| os.to_string()), + self.arch.map(|arch| arch.to_string()), + self.libc.map(|libc| libc.to_string()), + ]; + + let joined = parts.into_iter().flatten().collect::>().join("-"); + + if joined.is_empty() { + None + } else { + Some(joined) + } + } + /// Iterate over all [`PythonDownload`]'s that match this request. pub fn iter_downloads<'a>( &'a self, @@ -554,6 +634,30 @@ impl PythonDownloadRequest { } } +impl TryFrom<&PythonInstallationKey> for PythonDownloadRequest { + type Error = LenientImplementationName; + + fn try_from(key: &PythonInstallationKey) -> Result { + let implementation = match key.implementation().into_owned() { + LenientImplementationName::Known(name) => name, + unknown @ LenientImplementationName::Unknown(_) => return Err(unknown), + }; + + Ok(Self::new( + Some(VersionRequest::MajorMinor( + key.major(), + key.minor(), + *key.variant(), + )), + Some(implementation), + Some(ArchRequest::Explicit(*key.arch())), + Some(*key.os()), + Some(*key.libc()), + Some(key.prerelease().is_some()), + )) + } +} + impl From<&ManagedPythonInstallation> for PythonDownloadRequest { fn from(installation: &ManagedPythonInstallation) -> Self { let key = installation.key(); @@ -1601,6 +1705,7 @@ async fn read_url( #[cfg(test)] mod tests { + use crate::PythonVariant; use crate::implementation::LenientImplementationName; use crate::installation::PythonInstallationKey; use uv_platform::{Arch, Libc, Os, Platform}; @@ -1859,6 +1964,172 @@ mod tests { assert_eq!(downloads.len(), 0); } + #[test] + fn upgrade_request_native_defaults() { + let request = PythonDownloadRequest::default() + .with_implementation(ImplementationName::CPython) + .with_version(VersionRequest::MajorMinorPatch( + 3, + 13, + 1, + PythonVariant::Default, + )) + .with_os(Os::from_str("linux").unwrap()) + .with_arch(Arch::from_str("x86_64").unwrap()) + .with_libc(Libc::from_str("gnu").unwrap()) + .with_prereleases(false); + + let host = Platform::new( + Os::from_str("linux").unwrap(), + Arch::from_str("x86_64").unwrap(), + Libc::from_str("gnu").unwrap(), + ); + + assert_eq!( + request + .clone() + .unset_defaults_for_host(&host) + .without_patch() + .simplified_display() + .as_deref(), + Some("3.13") + ); + } + + #[test] + fn upgrade_request_preserves_variant() { + let request = PythonDownloadRequest::default() + .with_implementation(ImplementationName::CPython) + .with_version(VersionRequest::MajorMinorPatch( + 3, + 13, + 0, + PythonVariant::Freethreaded, + )) + .with_os(Os::from_str("linux").unwrap()) + .with_arch(Arch::from_str("x86_64").unwrap()) + .with_libc(Libc::from_str("gnu").unwrap()) + .with_prereleases(false); + + let host = Platform::new( + Os::from_str("linux").unwrap(), + Arch::from_str("x86_64").unwrap(), + Libc::from_str("gnu").unwrap(), + ); + + assert_eq!( + request + .clone() + .unset_defaults_for_host(&host) + .without_patch() + .simplified_display() + .as_deref(), + Some("3.13+freethreaded") + ); + } + + #[test] + fn upgrade_request_preserves_non_default_platform() { + let request = PythonDownloadRequest::default() + .with_implementation(ImplementationName::CPython) + .with_version(VersionRequest::MajorMinorPatch( + 3, + 12, + 4, + PythonVariant::Default, + )) + .with_os(Os::from_str("linux").unwrap()) + .with_arch(Arch::from_str("aarch64").unwrap()) + .with_libc(Libc::from_str("gnu").unwrap()) + .with_prereleases(false); + + let host = Platform::new( + Os::from_str("linux").unwrap(), + Arch::from_str("x86_64").unwrap(), + Libc::from_str("gnu").unwrap(), + ); + + assert_eq!( + request + .clone() + .unset_defaults_for_host(&host) + .without_patch() + .simplified_display() + .as_deref(), + Some("3.12-aarch64") + ); + } + + #[test] + fn upgrade_request_preserves_custom_implementation() { + let request = PythonDownloadRequest::default() + .with_implementation(ImplementationName::PyPy) + .with_version(VersionRequest::MajorMinorPatch( + 3, + 10, + 5, + PythonVariant::Default, + )) + .with_os(Os::from_str("linux").unwrap()) + .with_arch(Arch::from_str("x86_64").unwrap()) + .with_libc(Libc::from_str("gnu").unwrap()) + .with_prereleases(false); + + let host = Platform::new( + Os::from_str("linux").unwrap(), + Arch::from_str("x86_64").unwrap(), + Libc::from_str("gnu").unwrap(), + ); + + assert_eq!( + request + .clone() + .unset_defaults_for_host(&host) + .without_patch() + .simplified_display() + .as_deref(), + Some("pypy-3.10") + ); + } + + #[test] + fn simplified_display_returns_none_when_empty() { + let request = PythonDownloadRequest::default() + .fill_platform() + .expect("should populate defaults"); + + let host = Platform::from_env().expect("host platform"); + + assert_eq!( + request.unset_defaults_for_host(&host).simplified_display(), + None + ); + } + + #[test] + fn simplified_display_omits_environment_arch() { + let mut request = PythonDownloadRequest::default() + .with_version(VersionRequest::MajorMinor(3, 12, PythonVariant::Default)) + .with_os(Os::from_str("linux").unwrap()) + .with_libc(Libc::from_str("gnu").unwrap()); + + request.arch = Some(ArchRequest::Environment(Arch::from_str("x86_64").unwrap())); + + let host = Platform::new( + Os::from_str("linux").unwrap(), + Arch::from_str("aarch64").unwrap(), + Libc::from_str("gnu").unwrap(), + ); + + assert_eq!( + request + .unset_defaults_for_host(&host) + .simplified_display() + .as_deref(), + Some("3.12") + ); + } + /// Test build display #[test] fn test_managed_python_download_build_display() { diff --git a/crates/uv-python/src/installation.rs b/crates/uv-python/src/installation.rs index cbe9144fc..22bcc854a 100644 --- a/crates/uv-python/src/installation.rs +++ b/crates/uv-python/src/installation.rs @@ -6,6 +6,7 @@ use std::str::FromStr; use indexmap::IndexMap; use ref_cast::RefCast; use tracing::{debug, info}; +use uv_warnings::warn_user; use uv_cache::Cache; use uv_client::BaseClientBuilder; @@ -63,6 +64,7 @@ impl PythonInstallation { ) -> Result { let installation = find_python_installation(request, environments, preference, cache, preview)??; + installation.warn_if_outdated_prerelease(request, None); Ok(installation) } @@ -75,13 +77,10 @@ impl PythonInstallation { cache: &Cache, preview: Preview, ) -> Result { - Ok(find_best_python_installation( - request, - environments, - preference, - cache, - preview, - )??) + let installation = + find_best_python_installation(request, environments, preference, cache, preview)??; + installation.warn_if_outdated_prerelease(request, None); + Ok(installation) } /// Find or fetch a [`PythonInstallation`]. @@ -201,7 +200,7 @@ impl PythonInstallation { return Err(err); } - Self::fetch( + let installation = Self::fetch( download, client_builder, cache, @@ -210,7 +209,11 @@ impl PythonInstallation { pypy_install_mirror, preview, ) - .await + .await?; + + installation.warn_if_outdated_prerelease(request, python_downloads_json_url); + + Ok(installation) } /// Download and install the requested installation. @@ -343,6 +346,81 @@ impl PythonInstallation { pub fn into_interpreter(self) -> Interpreter { self.interpreter } + + /// Emit a warning when the interpreter is a managed prerelease and a matching stable + /// build can be installed via `uv python upgrade`. + pub(crate) fn warn_if_outdated_prerelease( + &self, + request: &PythonRequest, + python_downloads_json_url: Option<&str>, + ) { + if request.allows_prereleases() { + return; + } + + let interpreter = self.interpreter(); + let version = interpreter.python_version(); + + if version.pre().is_none() { + return; + } + + if !interpreter.is_managed() { + return; + } + + // Transparent upgrades only exist for CPython, so skip the warning for other + // managed implementations. + // + // See: https://github.com/astral-sh/uv/issues/16675 + if !interpreter + .implementation_name() + .eq_ignore_ascii_case("cpython") + { + return; + } + + let release = version.only_release(); + + let Ok(download_request) = PythonDownloadRequest::try_from(&interpreter.key()) else { + return; + }; + + let download_request = download_request.with_prereleases(false); + + let has_stable_download = { + let Ok(mut downloads) = download_request.iter_downloads(python_downloads_json_url) + else { + return; + }; + + downloads.any(|download| { + let download_version = download.key().version().into_version(); + download_version.pre().is_none() && download_version.only_release() >= release + }) + }; + + if !has_stable_download { + return; + } + + if let Some(upgrade_request) = download_request + .unset_defaults() + .without_patch() + .simplified_display() + { + warn_user!( + "You're using a pre-release version of Python ({}) but a stable version is available. Use `uv python upgrade {}` to upgrade.", + version, + upgrade_request + ); + } else { + warn_user!( + "You're using a pre-release version of Python ({}) but a stable version is available. Run `uv python upgrade` to update your managed interpreters.", + version, + ); + } + } } #[derive(Error, Debug)] @@ -434,6 +512,10 @@ impl PythonInstallationKey { self.minor } + pub fn prerelease(&self) -> Option { + self.prerelease + } + pub fn platform(&self) -> &Platform { &self.platform } diff --git a/crates/uv/tests/it/python_find.rs b/crates/uv/tests/it/python_find.rs index 544bb88a6..b792115d3 100644 --- a/crates/uv/tests/it/python_find.rs +++ b/crates/uv/tests/it/python_find.rs @@ -1396,6 +1396,7 @@ fn python_find_prerelease_version_specifiers() { [TEMP_DIR]/managed/cpython-3.14.0rc3-[PLATFORM]/[INSTALL-BIN]/[PYTHON] ----- stderr ----- + warning: You're using a pre-release version of Python (3.14.0rc3) but a stable version is available. Use `uv python upgrade 3.14` to upgrade. "); // `>3.14rc2` should not match rc2 @@ -1494,6 +1495,7 @@ fn python_find_prerelease_with_patch_request() { [TEMP_DIR]/managed/cpython-3.14.0rc3-[PLATFORM]/[INSTALL-BIN]/[PYTHON] ----- stderr ----- + warning: You're using a pre-release version of Python (3.14.0rc3) but a stable version is available. Use `uv python upgrade 3.14` to upgrade. "); // When `.0` is explicitly included, we will require a stable release