Avoid rendering desugared prefix matches in error messages (#14195)

## Summary

When the user provides a requirement like `==2.4.*`, we desugar that to
`>=2.4.dev0,<2.5.dev0`. These bounds then appear in error messages, and
worse, they also trick the error message reporter into thinking that the
user asked for a pre-release.

This PR adds logic to convert to the more-concise `==2.4.*`
representation when possible. We could probably do a similar thing for
the compatible release operator (`~=`).

Closes https://github.com/astral-sh/uv/issues/14177.

Co-authored-by: Zanie Blue <contact@zanie.dev>
This commit is contained in:
Charlie Marsh 2025-06-27 14:06:19 -04:00 committed by GitHub
parent f892b8564f
commit 4eef79e5e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 115 additions and 5 deletions

View file

@ -1213,6 +1213,69 @@ impl SentinelRange<'_> {
}
}
/// A prefix match, e.g., `==2.4.*`, which is desugared to a range like `>=2.4.dev0,<2.5.dev0`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PrefixMatch<'a> {
version: &'a Version,
}
impl<'a> PrefixMatch<'a> {
/// Determine whether a given range is equivalent to a prefix match (e.g., `==2.4.*`).
///
/// Prefix matches are desugared to (e.g.) `>=2.4.dev0,<2.5.dev0`, but we want to render them
/// as `==2.4.*` in error messages.
pub(crate) fn from_range(lower: &'a Bound<Version>, upper: &'a Bound<Version>) -> Option<Self> {
let Bound::Included(lower) = lower else {
return None;
};
let Bound::Excluded(upper) = upper else {
return None;
};
if lower.is_pre() || lower.is_post() || lower.is_local() {
return None;
}
if upper.is_pre() || upper.is_post() || upper.is_local() {
return None;
}
if lower.dev() != Some(0) {
return None;
}
if upper.dev() != Some(0) {
return None;
}
if lower.release().len() != upper.release().len() {
return None;
}
// All segments should be the same, except the last one, which should be incremented.
let num_segments = lower.release().len();
for (i, (lower, upper)) in lower
.release()
.iter()
.zip(upper.release().iter())
.enumerate()
{
if i == num_segments - 1 {
if lower + 1 != *upper {
return None;
}
} else {
if lower != upper {
return None;
}
}
}
Some(PrefixMatch { version: lower })
}
}
impl std::fmt::Display for PrefixMatch<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "=={}.*", self.version.only_release())
}
}
#[derive(Debug)]
pub struct NoSolutionHeader {
/// The [`ResolverEnvironment`] that caused the failure.

View file

@ -18,7 +18,7 @@ use uv_pep440::{Version, VersionSpecifiers};
use uv_platform_tags::{AbiTag, IncompatibleTag, LanguageTag, PlatformTag, Tags};
use crate::candidate_selector::CandidateSelector;
use crate::error::ErrorTree;
use crate::error::{ErrorTree, PrefixMatch};
use crate::fork_indexes::ForkIndexes;
use crate::fork_urls::ForkUrls;
use crate::prerelease::AllowPrerelease;
@ -944,17 +944,30 @@ impl PubGrubReportFormatter<'_> {
hints: &mut IndexSet<PubGrubHint>,
) {
let any_prerelease = set.iter().any(|(start, end)| {
// Ignore, e.g., `>=2.4.dev0,<2.5.dev0`, which is the desugared form of `==2.4.*`.
if PrefixMatch::from_range(start, end).is_some() {
return false;
}
let is_pre1 = match start {
Bound::Included(version) => version.any_prerelease(),
Bound::Excluded(version) => version.any_prerelease(),
Bound::Unbounded => false,
};
if is_pre1 {
return true;
}
let is_pre2 = match end {
Bound::Included(version) => version.any_prerelease(),
Bound::Excluded(version) => version.any_prerelease(),
Bound::Unbounded => false,
};
is_pre1 || is_pre2
if is_pre2 {
return true;
}
false
});
if any_prerelease {
@ -1928,11 +1941,11 @@ impl std::fmt::Display for PackageRange<'_> {
PackageRangeKind::Available => write!(f, "are available:")?,
}
}
for segment in &segments {
for (lower, upper) in &segments {
if segments.len() > 1 {
write!(f, "\n ")?;
}
match segment {
match (lower, upper) {
(Bound::Unbounded, Bound::Unbounded) => match self.kind {
PackageRangeKind::Dependency => write!(f, "{package}")?,
PackageRangeKind::Compatibility => write!(f, "all versions of {package}")?,
@ -1948,7 +1961,13 @@ impl std::fmt::Display for PackageRange<'_> {
write!(f, "{package}>={v},<={b}")?;
}
}
(Bound::Included(v), Bound::Excluded(b)) => write!(f, "{package}>={v},<{b}")?,
(Bound::Included(v), Bound::Excluded(b)) => {
if let Some(prefix) = PrefixMatch::from_range(lower, upper) {
write!(f, "{package}{prefix}")?;
} else {
write!(f, "{package}>={v},<{b}")?;
}
}
(Bound::Excluded(v), Bound::Unbounded) => write!(f, "{package}>{v}")?,
(Bound::Excluded(v), Bound::Included(b)) => write!(f, "{package}>{v},<={b}")?,
(Bound::Excluded(v), Bound::Excluded(b)) => write!(f, "{package}>{v},<{b}")?,

View file

@ -28452,3 +28452,31 @@ fn lock_with_index_trailing_slashes_in_lockfile() -> Result<()> {
Ok(())
}
#[test]
fn lock_prefix_match() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["anyio==5.4.*"]
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
× No solution found when resolving dependencies:
Because only anyio<=4.3.0 is available and your project depends on anyio==5.4.*, we can conclude that your project's requirements are unsatisfiable.
");
Ok(())
}