mirror of
https://github.com/astral-sh/uv.git
synced 2025-07-07 13:25:00 +00:00
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:
parent
6a5d2f1ec4
commit
eab938b7b4
4 changed files with 97 additions and 15 deletions
|
@ -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 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() {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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<()> {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue