diff --git a/crates/uv-distribution-types/src/requires_python.rs b/crates/uv-distribution-types/src/requires_python.rs index ae9fee7fe..786aed83a 100644 --- a/crates/uv-distribution-types/src/requires_python.rs +++ b/crates/uv-distribution-types/src/requires_python.rs @@ -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 { // 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>, requires_python| { - if let Some(range) = range { - Some(range.intersection(&requires_python)) - } else { - Some(requires_python) + .map(|specs| { + // Warn if there’s 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() { diff --git a/crates/uv-pep440/src/version_ranges.rs b/crates/uv-pep440/src/version_ranges.rs index 2bd7dcd4d..26cd048d3 100644 --- a/crates/uv-pep440/src/version_ranges.rs +++ b/crates/uv-pep440/src/version_ranges.rs @@ -130,11 +130,10 @@ impl From for Ranges { /// /// See: pub fn release_specifiers_to_ranges(specifiers: VersionSpecifiers) -> Ranges { - 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 diff --git a/crates/uv-pep440/src/version_specifier.rs b/crates/uv-pep440/src/version_specifier.rs index 4255c13fa..19acff2eb 100644 --- a/crates/uv-pep440/src/version_specifier.rs +++ b/crates/uv-pep440/src/version_specifier.rs @@ -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 { diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index b1d6c5327..5fb0fcd27 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -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<()> {