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.
///
/// 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<Option<DistFilename>, uv_client::Error> {
) -> Result<Option<DistFilename>, 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.

View File

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

View File

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

View File

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

View File

@ -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,
};

View File

@ -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::<Vec<_>>();
.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

View File

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