From e77ee15204e5ff6c44d6bfe0b36eaa25da32d06c Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sat, 13 Dec 2025 10:39:01 -0500 Subject: [PATCH] Enforce latest-version in `@latest` requests (#17114) ## Summary Given `uv tool install {name}@latest`, we make revalidation requests for `{name}`, but we don't actually add a "latest" constraint when resolving -- we just assume that since the package is unpinned, and we're fetching the latest available versions, the resolver will select the latest version. However, imagine a package in which the latest version requires Python 3.13 or later, but prior versions support Python 3.9 and up. If we happen to select Python 3.9 ahead of resolution, and the user requests `{name}@latest`, we would backtrack to the non-latest version due to the Python mismatch. This PR modifies `uv tool install` and `uv tool run` to first determine the latest version, then provide it as a constraint when resolving. --- crates/uv/src/commands/pip/latest.rs | 24 +++++---- crates/uv/src/commands/pip/list.rs | 2 +- crates/uv/src/commands/pip/tree.rs | 2 +- crates/uv/src/commands/project/mod.rs | 3 ++ crates/uv/src/commands/project/tree.rs | 2 +- crates/uv/src/commands/tool/install.rs | 71 +++++++++++++++++++++++--- crates/uv/src/commands/tool/run.rs | 67 ++++++++++++++++++++++-- 7 files changed, 147 insertions(+), 24 deletions(-) diff --git a/crates/uv/src/commands/pip/latest.rs b/crates/uv/src/commands/pip/latest.rs index 486d034d0..d8b0de47f 100644 --- a/crates/uv/src/commands/pip/latest.rs +++ b/crates/uv/src/commands/pip/latest.rs @@ -12,7 +12,7 @@ use uv_warnings::warn_user_once; /// A client to fetch the latest version of a package from an index. /// /// The returned distribution is guaranteed to be compatible with the provided tags and Python -/// requirement. +/// requirement (if specified). #[derive(Debug, Clone)] pub(crate) struct LatestClient<'env> { pub(crate) client: &'env RegistryClient, @@ -20,7 +20,7 @@ pub(crate) struct LatestClient<'env> { pub(crate) prerelease: PrereleaseMode, pub(crate) exclude_newer: &'env ExcludeNewer, pub(crate) tags: Option<&'env Tags>, - pub(crate) requires_python: &'env RequiresPython, + pub(crate) requires_python: Option<&'env RequiresPython>, } impl LatestClient<'_> { @@ -30,7 +30,7 @@ impl LatestClient<'_> { package: &PackageName, index: Option<&IndexUrl>, download_concurrency: &Semaphore, - ) -> anyhow::Result, uv_client::Error> { + ) -> Result, uv_client::Error> { debug!("Fetching latest version of: `{package}`"); let archives = match self @@ -101,14 +101,16 @@ impl LatestClient<'_> { } // Skip distributions that are incompatible with the Python requirement. - if file - .requires_python - .as_ref() - .is_some_and(|requires_python| { - !self.requires_python.is_contained_by(requires_python) - }) - { - continue; + if let Some(requires_python) = self.requires_python { + if file + .requires_python + .as_ref() + .is_some_and(|file_requires_python| { + !requires_python.is_contained_by(file_requires_python) + }) + { + continue; + } } // Skip distributions that are incompatible with the current platform. diff --git a/crates/uv/src/commands/pip/list.rs b/crates/uv/src/commands/pip/list.rs index 0c7879d18..63e5a9f51 100644 --- a/crates/uv/src/commands/pip/list.rs +++ b/crates/uv/src/commands/pip/list.rs @@ -133,7 +133,7 @@ pub(crate) async fn pip_list( prerelease, exclude_newer: &exclude_newer, tags: Some(tags), - requires_python: &requires_python, + requires_python: Some(&requires_python), }; let reporter = LatestVersionReporter::from(printer).with_length(results.len() as u64); diff --git a/crates/uv/src/commands/pip/tree.rs b/crates/uv/src/commands/pip/tree.rs index a0b172fba..b87615f08 100644 --- a/crates/uv/src/commands/pip/tree.rs +++ b/crates/uv/src/commands/pip/tree.rs @@ -115,7 +115,7 @@ pub(crate) async fn pip_tree( prerelease, exclude_newer: &exclude_newer, tags: Some(tags), - requires_python: &requires_python, + requires_python: Some(&requires_python), }; let reporter = LatestVersionReporter::from(printer).with_length(packages.len() as u64); diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 8b18e247c..bfbd4d772 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -219,6 +219,9 @@ pub(crate) enum ProjectError { #[error(transparent)] DependencyGroup(#[from] DependencyGroupError), + #[error(transparent)] + Client(#[from] uv_client::Error), + #[error(transparent)] Python(#[from] uv_python::Error), diff --git a/crates/uv/src/commands/project/tree.rs b/crates/uv/src/commands/project/tree.rs index d75c66d84..4ee2e80f8 100644 --- a/crates/uv/src/commands/project/tree.rs +++ b/crates/uv/src/commands/project/tree.rs @@ -232,7 +232,7 @@ pub(crate) async fn tree( capabilities: &capabilities, prerelease: lock.prerelease_mode(), exclude_newer: &lock.exclude_newer(), - requires_python: lock.requires_python(), + requires_python: Some(lock.requires_python()), tags: None, }; diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index 5cba0a4b4..ac8f215d3 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -3,16 +3,17 @@ use std::str::FromStr; use anyhow::{Result, bail}; use owo_colors::OwoColorize; +use tokio::sync::Semaphore; use tracing::{debug, trace}; use uv_cache::{Cache, Refresh}; use uv_cache_info::Timestamp; -use uv_client::BaseClientBuilder; +use uv_client::{BaseClientBuilder, RegistryClientBuilder}; use uv_configuration::{Concurrency, Constraints, DryRun, Reinstall, TargetTriple, Upgrade}; use uv_distribution::LoweredExtraBuildDependencies; use uv_distribution_types::{ - ExtraBuildRequires, NameRequirementSpecification, Requirement, RequirementSource, - UnresolvedRequirementSpecification, + ExtraBuildRequires, IndexCapabilities, NameRequirementSpecification, Requirement, + RequirementSource, UnresolvedRequirementSpecification, }; use uv_installer::{InstallationStrategy, SatisfiesResult, SitePackages}; use uv_normalize::PackageName; @@ -29,6 +30,7 @@ use uv_warnings::warn_user; use uv_workspace::WorkspaceCache; use crate::commands::ExitStatus; +use crate::commands::pip::latest::LatestClient; use crate::commands::pip::loggers::{DefaultInstallLogger, DefaultResolveLogger}; use crate::commands::pip::operations::{self, Modifications}; use crate::commands::pip::{resolution_markers, resolution_tags}; @@ -224,6 +226,62 @@ pub(crate) async fn install( } }; + // For `@latest`, fetch the latest version and create a constraint. + let latest = if let ToolRequest::Package { + target: Target::Latest(_, name, _), + .. + } = &request + { + // Build the registry client to fetch the latest version. + let client = RegistryClientBuilder::new(client_builder.clone(), cache.clone()) + .index_locations(settings.resolver.index_locations.clone()) + .index_strategy(settings.resolver.index_strategy) + .markers(interpreter.markers()) + .platform(interpreter.platform()) + .build(); + + // Initialize the capabilities. + let capabilities = IndexCapabilities::default(); + let download_concurrency = Semaphore::new(concurrency.downloads); + + // Initialize the client to fetch the latest version. + let latest_client = LatestClient { + client: &client, + capabilities: &capabilities, + prerelease: settings.resolver.prerelease, + exclude_newer: &settings.resolver.exclude_newer, + tags: None, + requires_python: None, + }; + + // Fetch the latest version. + if let Some(dist_filename) = latest_client + .find_latest(name, None, &download_concurrency) + .await? + { + let version = dist_filename.version().clone(); + debug!("Resolved `{name}@latest` to `{name}=={version}`"); + + // The constraint pins the version during resolution to prevent backtracking. + Some(Requirement { + name: name.clone(), + extras: vec![].into_boxed_slice(), + groups: Box::new([]), + marker: MarkerTree::default(), + source: RequirementSource::Registry { + specifier: VersionSpecifiers::from(VersionSpecifier::equals_version(version)), + index: None, + conflict: None, + }, + origin: None, + }) + } else { + None + } + } else { + None + }; + let package_name = &requirement.name; // If the user passed, e.g., `ruff@latest`, we need to mark it as upgradable. @@ -284,11 +342,11 @@ pub(crate) async fn install( }; // Resolve the constraints. - let constraints = spec + let constraints: Vec<_> = spec .constraints .into_iter() .map(|constraint| constraint.requirement) - .collect::>(); + .collect(); // Resolve the overrides. let overrides = resolve_names( @@ -412,7 +470,7 @@ pub(crate) async fn install( if matches!( site_packages.satisfies_requirements( requirements.iter(), - constraints.iter(), + constraints.iter().chain(latest.iter()), overrides.iter(), InstallationStrategy::Permissive, &markers, @@ -454,6 +512,7 @@ pub(crate) async fn install( constraints: constraints .iter() .cloned() + .chain(latest.into_iter()) .map(NameRequirementSpecification::from) .collect(), overrides: overrides diff --git a/crates/uv/src/commands/tool/run.rs b/crates/uv/src/commands/tool/run.rs index 06522c8c7..f5d3ce883 100644 --- a/crates/uv/src/commands/tool/run.rs +++ b/crates/uv/src/commands/tool/run.rs @@ -10,20 +10,21 @@ use console::Term; use itertools::Itertools; use owo_colors::OwoColorize; use tokio::process::Command; +use tokio::sync::Semaphore; use tracing::{debug, warn}; use uv_cache::{Cache, Refresh}; use uv_cache_info::Timestamp; use uv_cli::ExternalCommand; -use uv_client::BaseClientBuilder; +use uv_client::{BaseClientBuilder, RegistryClientBuilder}; use uv_configuration::Concurrency; use uv_configuration::Constraints; use uv_configuration::TargetTriple; use uv_distribution::LoweredExtraBuildDependencies; use uv_distribution_types::InstalledDist; use uv_distribution_types::{ - IndexUrl, Name, NameRequirementSpecification, Requirement, RequirementSource, - UnresolvedRequirement, UnresolvedRequirementSpecification, + IndexCapabilities, IndexUrl, Name, NameRequirementSpecification, Requirement, + RequirementSource, UnresolvedRequirement, UnresolvedRequirementSpecification, }; use uv_fs::Simplified; use uv_installer::{InstallationStrategy, SatisfiesResult, SitePackages}; @@ -47,6 +48,7 @@ use uv_workspace::WorkspaceCache; use crate::child::run_to_completion; use crate::commands::ExitStatus; use crate::commands::pip; +use crate::commands::pip::latest::LatestClient; use crate::commands::pip::loggers::{ DefaultInstallLogger, DefaultResolveLogger, SummaryInstallLogger, SummaryResolveLogger, }; @@ -905,6 +907,62 @@ async fn get_or_create_environment( } }; + // For `@latest`, fetch the latest version and create a constraint. + let latest = if let ToolRequest::Package { + target: Target::Latest(_, name, _), + .. + } = &request + { + // Build the registry client to fetch the latest version. + let client = RegistryClientBuilder::new(client_builder.clone(), cache.clone()) + .index_locations(settings.resolver.index_locations.clone()) + .index_strategy(settings.resolver.index_strategy) + .markers(interpreter.markers()) + .platform(interpreter.platform()) + .build(); + + // Initialize the capabilities. + let capabilities = IndexCapabilities::default(); + let download_concurrency = Semaphore::new(concurrency.downloads); + + // Initialize the client to fetch the latest version. + let latest_client = LatestClient { + client: &client, + capabilities: &capabilities, + prerelease: settings.resolver.prerelease, + exclude_newer: &settings.resolver.exclude_newer, + tags: None, + requires_python: None, + }; + + // Fetch the latest version. + if let Some(dist_filename) = latest_client + .find_latest(name, None, &download_concurrency) + .await? + { + let version = dist_filename.version().clone(); + debug!("Resolved `{name}@latest` to `{name}=={version}`"); + + // The constraint pins the version during resolution to prevent backtracking. + Some(Requirement { + name: name.clone(), + extras: vec![].into_boxed_slice(), + groups: Box::new([]), + marker: MarkerTree::default(), + source: RequirementSource::Registry { + specifier: VersionSpecifiers::from(VersionSpecifier::equals_version(version)), + index: None, + conflict: None, + }, + origin: None, + }) + } else { + None + } + } else { + None + }; + // Read the `--with` requirements. let spec = RequirementsSpecification::from_sources( with, @@ -1016,7 +1074,7 @@ async fn get_or_create_environment( if matches!( site_packages.satisfies_requirements( requirements.iter(), - constraints.iter(), + constraints.iter().chain(latest.iter()), overrides.iter(), InstallationStrategy::Permissive, &markers, @@ -1044,6 +1102,7 @@ async fn get_or_create_environment( .collect(), constraints: constraints .into_iter() + .chain(latest.into_iter()) .map(NameRequirementSpecification::from) .collect(), overrides: overrides