Warn users on ~= python version specifier (#14008)

Close #7426

## Summary

Picking up on #8284, I noticed that the `requires_python` object already
has its specifiers canonicalized in the `intersection` method, meaning
`~=3.12` is converted to `>=3.12, <4`. To fix this, we check and warn in
`intersection`.

## Test Plan

Used the same tests from #8284.
This commit is contained in:
Aaron Ang 2025-06-27 13:48:41 -07:00 committed by GitHub
parent 6a5d2f1ec4
commit eab938b7b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 97 additions and 15 deletions

View file

@ -5,10 +5,11 @@ use version_ranges::Ranges;
use uv_distribution_filename::WheelFilename;
use uv_pep440::{
LowerBound, UpperBound, Version, VersionSpecifier, VersionSpecifiers,
release_specifiers_to_ranges,
release_specifier_to_range, release_specifiers_to_ranges,
};
use uv_pep508::{MarkerExpression, MarkerTree, MarkerValueVersion};
use uv_platform_tags::{AbiTag, LanguageTag};
use uv_warnings::warn_user_once;
/// The `Requires-Python` requirement specifier.
///
@ -66,15 +67,28 @@ impl RequiresPython {
) -> Option<Self> {
// Convert to PubGrub range and perform an intersection.
let range = specifiers
.into_iter()
.map(|specifier| release_specifiers_to_ranges(specifier.clone()))
.fold(None, |range: Option<Ranges<Version>>, requires_python| {
if let Some(range) = range {
Some(range.intersection(&requires_python))
} else {
Some(requires_python)
.map(|specs| {
// Warn if theres exactly one `~=` specifier without a patch.
if let [spec] = &specs[..] {
if spec.is_tilde_without_patch() {
if let Some((lo_b, hi_b)) = release_specifier_to_range(spec.clone())
.bounding_range()
.map(|(l, u)| (l.cloned(), u.cloned()))
{
let lo_spec = LowerBound::new(lo_b).specifier().unwrap();
let hi_spec = UpperBound::new(hi_b).specifier().unwrap();
warn_user_once!(
"The release specifier (`{spec}`) contains a compatible release \
match without a patch version. This will be interpreted as \
`{lo_spec}, {hi_spec}`. Did you mean `{spec}.0` to freeze the minor \
version?"
);
}
}
}
})?;
release_specifiers_to_ranges(specs.clone())
})
.reduce(|acc, r| acc.intersection(&r))?;
// If the intersection is empty, return `None`.
if range.is_empty() {

View file

@ -130,11 +130,10 @@ impl From<VersionSpecifier> for Ranges<Version> {
///
/// See: <https://github.com/pypa/pip/blob/a432c7f4170b9ef798a15f035f5dfdb4cc939f35/src/pip/_internal/resolution/resolvelib/candidates.py#L540>
pub fn release_specifiers_to_ranges(specifiers: VersionSpecifiers) -> Ranges<Version> {
let mut range = Ranges::full();
for specifier in specifiers {
range = range.intersection(&release_specifier_to_range(specifier));
}
range
specifiers
.into_iter()
.map(release_specifier_to_range)
.fold(Ranges::full(), |acc, range| acc.intersection(&range))
}
/// Convert the [`VersionSpecifier`] to a PubGrub-compatible version range, using release-only

View file

@ -416,7 +416,7 @@ impl VersionSpecifier {
&self.operator
}
/// Get the version, e.g. `<=` in `<= 2.0.0`
/// Get the version, e.g. `2.0.0` in `<= 2.0.0`
pub fn version(&self) -> &Version {
&self.version
}
@ -615,6 +615,11 @@ impl VersionSpecifier {
| Operator::NotEqual => false,
}
}
/// Returns true if this is a `~=` specifier without a patch version (e.g. `~=3.11`).
pub fn is_tilde_without_patch(&self) -> bool {
self.operator == Operator::TildeEqual && self.version.release().len() == 2
}
}
impl FromStr for VersionSpecifier {

View file

@ -4535,6 +4535,70 @@ fn lock_requires_python_exact() -> Result<()> {
Ok(())
}
/// Lock a requirement from PyPI with a compatible release Python bound.
#[cfg(feature = "python-patch")]
#[test]
fn lock_requires_python_compatible_specifier() -> Result<()> {
let context = TestContext::new("3.13.0");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "warehouse"
version = "1.0.0"
requires-python = "~=3.13"
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: The release specifier (`~=3.13`) contains a compatible release match without a patch version. This will be interpreted as `>=3.13, <4`. Did you mean `~=3.13.0` to freeze the minor version?
Resolved 1 package in [TIME]
"###);
pyproject_toml.write_str(
r#"
[project]
name = "warehouse"
version = "1.0.0"
requires-python = "~=3.13, <3.14"
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 1 package in [TIME]
");
pyproject_toml.write_str(
r#"
[project]
name = "warehouse"
version = "1.0.0"
requires-python = "~=3.13.0"
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 1 package in [TIME]
");
Ok(())
}
/// Fork, even with a single dependency, if the minimum Python version is increased.
#[test]
fn lock_requires_python_fork() -> Result<()> {