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.
This commit is contained in:
Charlie Marsh 2025-12-13 10:39:01 -05:00 committed by GitHub
parent ed37f3b432
commit e77ee15204
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 147 additions and 24 deletions

View File

@ -12,7 +12,7 @@ use uv_warnings::warn_user_once;
/// A client to fetch the latest version of a package from an index. /// 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 /// The returned distribution is guaranteed to be compatible with the provided tags and Python
/// requirement. /// requirement (if specified).
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct LatestClient<'env> { pub(crate) struct LatestClient<'env> {
pub(crate) client: &'env RegistryClient, pub(crate) client: &'env RegistryClient,
@ -20,7 +20,7 @@ pub(crate) struct LatestClient<'env> {
pub(crate) prerelease: PrereleaseMode, pub(crate) prerelease: PrereleaseMode,
pub(crate) exclude_newer: &'env ExcludeNewer, pub(crate) exclude_newer: &'env ExcludeNewer,
pub(crate) tags: Option<&'env Tags>, pub(crate) tags: Option<&'env Tags>,
pub(crate) requires_python: &'env RequiresPython, pub(crate) requires_python: Option<&'env RequiresPython>,
} }
impl LatestClient<'_> { impl LatestClient<'_> {
@ -30,7 +30,7 @@ impl LatestClient<'_> {
package: &PackageName, package: &PackageName,
index: Option<&IndexUrl>, index: Option<&IndexUrl>,
download_concurrency: &Semaphore, download_concurrency: &Semaphore,
) -> anyhow::Result<Option<DistFilename>, uv_client::Error> { ) -> Result<Option<DistFilename>, uv_client::Error> {
debug!("Fetching latest version of: `{package}`"); debug!("Fetching latest version of: `{package}`");
let archives = match self let archives = match self
@ -101,15 +101,17 @@ impl LatestClient<'_> {
} }
// Skip distributions that are incompatible with the Python requirement. // Skip distributions that are incompatible with the Python requirement.
if let Some(requires_python) = self.requires_python {
if file if file
.requires_python .requires_python
.as_ref() .as_ref()
.is_some_and(|requires_python| { .is_some_and(|file_requires_python| {
!self.requires_python.is_contained_by(requires_python) !requires_python.is_contained_by(file_requires_python)
}) })
{ {
continue; continue;
} }
}
// Skip distributions that are incompatible with the current platform. // Skip distributions that are incompatible with the current platform.
if let DistFilename::WheelFilename(filename) = &filename { if let DistFilename::WheelFilename(filename) = &filename {

View File

@ -133,7 +133,7 @@ pub(crate) async fn pip_list(
prerelease, prerelease,
exclude_newer: &exclude_newer, exclude_newer: &exclude_newer,
tags: Some(tags), tags: Some(tags),
requires_python: &requires_python, requires_python: Some(&requires_python),
}; };
let reporter = LatestVersionReporter::from(printer).with_length(results.len() as u64); let reporter = LatestVersionReporter::from(printer).with_length(results.len() as u64);

View File

@ -115,7 +115,7 @@ pub(crate) async fn pip_tree(
prerelease, prerelease,
exclude_newer: &exclude_newer, exclude_newer: &exclude_newer,
tags: Some(tags), tags: Some(tags),
requires_python: &requires_python, requires_python: Some(&requires_python),
}; };
let reporter = LatestVersionReporter::from(printer).with_length(packages.len() as u64); let reporter = LatestVersionReporter::from(printer).with_length(packages.len() as u64);

View File

@ -219,6 +219,9 @@ pub(crate) enum ProjectError {
#[error(transparent)] #[error(transparent)]
DependencyGroup(#[from] DependencyGroupError), DependencyGroup(#[from] DependencyGroupError),
#[error(transparent)]
Client(#[from] uv_client::Error),
#[error(transparent)] #[error(transparent)]
Python(#[from] uv_python::Error), Python(#[from] uv_python::Error),

View File

@ -232,7 +232,7 @@ pub(crate) async fn tree(
capabilities: &capabilities, capabilities: &capabilities,
prerelease: lock.prerelease_mode(), prerelease: lock.prerelease_mode(),
exclude_newer: &lock.exclude_newer(), exclude_newer: &lock.exclude_newer(),
requires_python: lock.requires_python(), requires_python: Some(lock.requires_python()),
tags: None, tags: None,
}; };

View File

@ -3,16 +3,17 @@ use std::str::FromStr;
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use owo_colors::OwoColorize; use owo_colors::OwoColorize;
use tokio::sync::Semaphore;
use tracing::{debug, trace}; use tracing::{debug, trace};
use uv_cache::{Cache, Refresh}; use uv_cache::{Cache, Refresh};
use uv_cache_info::Timestamp; 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_configuration::{Concurrency, Constraints, DryRun, Reinstall, TargetTriple, Upgrade};
use uv_distribution::LoweredExtraBuildDependencies; use uv_distribution::LoweredExtraBuildDependencies;
use uv_distribution_types::{ use uv_distribution_types::{
ExtraBuildRequires, NameRequirementSpecification, Requirement, RequirementSource, ExtraBuildRequires, IndexCapabilities, NameRequirementSpecification, Requirement,
UnresolvedRequirementSpecification, RequirementSource, UnresolvedRequirementSpecification,
}; };
use uv_installer::{InstallationStrategy, SatisfiesResult, SitePackages}; use uv_installer::{InstallationStrategy, SatisfiesResult, SitePackages};
use uv_normalize::PackageName; use uv_normalize::PackageName;
@ -29,6 +30,7 @@ use uv_warnings::warn_user;
use uv_workspace::WorkspaceCache; use uv_workspace::WorkspaceCache;
use crate::commands::ExitStatus; use crate::commands::ExitStatus;
use crate::commands::pip::latest::LatestClient;
use crate::commands::pip::loggers::{DefaultInstallLogger, DefaultResolveLogger}; use crate::commands::pip::loggers::{DefaultInstallLogger, DefaultResolveLogger};
use crate::commands::pip::operations::{self, Modifications}; use crate::commands::pip::operations::{self, Modifications};
use crate::commands::pip::{resolution_markers, resolution_tags}; 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; let package_name = &requirement.name;
// If the user passed, e.g., `ruff@latest`, we need to mark it as upgradable. // 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. // Resolve the constraints.
let constraints = spec let constraints: Vec<_> = spec
.constraints .constraints
.into_iter() .into_iter()
.map(|constraint| constraint.requirement) .map(|constraint| constraint.requirement)
.collect::<Vec<_>>(); .collect();
// Resolve the overrides. // Resolve the overrides.
let overrides = resolve_names( let overrides = resolve_names(
@ -412,7 +470,7 @@ pub(crate) async fn install(
if matches!( if matches!(
site_packages.satisfies_requirements( site_packages.satisfies_requirements(
requirements.iter(), requirements.iter(),
constraints.iter(), constraints.iter().chain(latest.iter()),
overrides.iter(), overrides.iter(),
InstallationStrategy::Permissive, InstallationStrategy::Permissive,
&markers, &markers,
@ -454,6 +512,7 @@ pub(crate) async fn install(
constraints: constraints constraints: constraints
.iter() .iter()
.cloned() .cloned()
.chain(latest.into_iter())
.map(NameRequirementSpecification::from) .map(NameRequirementSpecification::from)
.collect(), .collect(),
overrides: overrides overrides: overrides

View File

@ -10,20 +10,21 @@ use console::Term;
use itertools::Itertools; use itertools::Itertools;
use owo_colors::OwoColorize; use owo_colors::OwoColorize;
use tokio::process::Command; use tokio::process::Command;
use tokio::sync::Semaphore;
use tracing::{debug, warn}; use tracing::{debug, warn};
use uv_cache::{Cache, Refresh}; use uv_cache::{Cache, Refresh};
use uv_cache_info::Timestamp; use uv_cache_info::Timestamp;
use uv_cli::ExternalCommand; use uv_cli::ExternalCommand;
use uv_client::BaseClientBuilder; use uv_client::{BaseClientBuilder, RegistryClientBuilder};
use uv_configuration::Concurrency; use uv_configuration::Concurrency;
use uv_configuration::Constraints; use uv_configuration::Constraints;
use uv_configuration::TargetTriple; use uv_configuration::TargetTriple;
use uv_distribution::LoweredExtraBuildDependencies; use uv_distribution::LoweredExtraBuildDependencies;
use uv_distribution_types::InstalledDist; use uv_distribution_types::InstalledDist;
use uv_distribution_types::{ use uv_distribution_types::{
IndexUrl, Name, NameRequirementSpecification, Requirement, RequirementSource, IndexCapabilities, IndexUrl, Name, NameRequirementSpecification, Requirement,
UnresolvedRequirement, UnresolvedRequirementSpecification, RequirementSource, UnresolvedRequirement, UnresolvedRequirementSpecification,
}; };
use uv_fs::Simplified; use uv_fs::Simplified;
use uv_installer::{InstallationStrategy, SatisfiesResult, SitePackages}; use uv_installer::{InstallationStrategy, SatisfiesResult, SitePackages};
@ -47,6 +48,7 @@ use uv_workspace::WorkspaceCache;
use crate::child::run_to_completion; use crate::child::run_to_completion;
use crate::commands::ExitStatus; use crate::commands::ExitStatus;
use crate::commands::pip; use crate::commands::pip;
use crate::commands::pip::latest::LatestClient;
use crate::commands::pip::loggers::{ use crate::commands::pip::loggers::{
DefaultInstallLogger, DefaultResolveLogger, SummaryInstallLogger, SummaryResolveLogger, 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. // Read the `--with` requirements.
let spec = RequirementsSpecification::from_sources( let spec = RequirementsSpecification::from_sources(
with, with,
@ -1016,7 +1074,7 @@ async fn get_or_create_environment(
if matches!( if matches!(
site_packages.satisfies_requirements( site_packages.satisfies_requirements(
requirements.iter(), requirements.iter(),
constraints.iter(), constraints.iter().chain(latest.iter()),
overrides.iter(), overrides.iter(),
InstallationStrategy::Permissive, InstallationStrategy::Permissive,
&markers, &markers,
@ -1044,6 +1102,7 @@ async fn get_or_create_environment(
.collect(), .collect(),
constraints: constraints constraints: constraints
.into_iter() .into_iter()
.chain(latest.into_iter())
.map(NameRequirementSpecification::from) .map(NameRequirementSpecification::from)
.collect(), .collect(),
overrides: overrides overrides: overrides