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`.
This commit is contained in:
liam 2025-11-12 08:45:31 -05:00 committed by GitHub
parent 92230ba679
commit 1b38b47a3f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 364 additions and 9 deletions

View file

@ -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<String> {
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::<Vec<_>>().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<Self, Self::Error> {
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() {

View file

@ -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<Self, Error> {
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<Self, Error> {
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<Prerelease> {
self.prerelease
}
pub fn platform(&self) -> &Platform {
&self.platform
}

View file

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