Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 2 additions & 23 deletions crates/uv-distribution-types/src/requires_python.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,10 @@ use version_ranges::Ranges;
use uv_distribution_filename::WheelFilename;
use uv_pep440::{
LowerBound, UpperBound, Version, VersionSpecifier, VersionSpecifiers,
release_specifier_to_range, release_specifiers_to_ranges,
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.
///
Expand Down Expand Up @@ -67,27 +66,7 @@ impl RequiresPython {
) -> Option<Self> {
// Convert to PubGrub range and perform an intersection.
let range = specifiers
.map(|specs| {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This moved up a level, where was have access to the project and group names the specifiers come from

// 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(), false)
.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())
})
.map(|specs| release_specifiers_to_ranges(specs.clone()))
.reduce(|acc, r| acc.intersection(&r))?;

// If the intersection is empty, return `None`.
Expand Down
2 changes: 1 addition & 1 deletion crates/uv-pep440/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ pub use {
VersionPatternParseError,
},
version_specifier::{
VersionSpecifier, VersionSpecifierBuildError, VersionSpecifiers,
TildeVersionSpecifier, VersionSpecifier, VersionSpecifierBuildError, VersionSpecifiers,
VersionSpecifiersParseError,
},
};
Expand Down
89 changes: 84 additions & 5 deletions crates/uv-pep440/src/version_specifier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -665,11 +665,6 @@ 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 {
Expand Down Expand Up @@ -893,6 +888,90 @@ pub(crate) fn parse_version_specifiers(
Ok(version_ranges)
}

/// A simple `~=` version specifier with a major, minor and (optional) patch version, e.g., `~=3.13`
/// or `~=3.13.0`.
#[derive(Clone, Debug)]
pub struct TildeVersionSpecifier<'a> {
inner: Cow<'a, VersionSpecifier>,
Copy link
Copy Markdown
Member Author

@zanieb zanieb Jul 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alas I wrote this as using a borrowed version specifier then wanted to use bounding_specifiers alongside a with_patch utility to improve error messages and needed the ability to own the specifier. It's not a big deal, but that's why it is this way.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

imo we can just always clone those outside the resolver, it's not worth the complexity

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The annoying part is that I didn't want to take an owned value for the None case and didn't want to hide a clone. I think if I knew I'd need an owned value later I would have just done it anyway, but I don't expect it to be a big deal to maintain now that it's there.

}

impl<'a> TildeVersionSpecifier<'a> {
/// Create a new [`TildeVersionSpecifier`] from a [`VersionSpecifier`] value.
///
/// If a [`Operator::TildeEqual`] is not used, or the version includes more than minor and patch
/// segments, this will return [`None`].
pub fn from_specifier(specifier: VersionSpecifier) -> Option<TildeVersionSpecifier<'a>> {
TildeVersionSpecifier::new(Cow::Owned(specifier))
}

/// Create a new [`TildeVersionSpecifier`] from a [`VersionSpecifier`] reference.
///
/// See [`TildeVersionSpecifier::from_specifier`].
pub fn from_specifier_ref(
specifier: &'a VersionSpecifier,
) -> Option<TildeVersionSpecifier<'a>> {
TildeVersionSpecifier::new(Cow::Borrowed(specifier))
}

fn new(specifier: Cow<'a, VersionSpecifier>) -> Option<Self> {
if specifier.operator != Operator::TildeEqual {
return None;
}
if specifier.version().release().len() < 2 || specifier.version().release().len() > 3 {
return None;
}
if specifier.version().any_prerelease()
|| specifier.version().is_local()
|| specifier.version().is_post()
{
return None;
}
Some(Self { inner: specifier })
}

/// Whether a patch version is present in this tilde version specifier.
pub fn has_patch(&self) -> bool {
self.inner.version.release().len() == 3
}

/// Construct the lower and upper bounding version specifiers for this tilde version specifier,
/// e.g., for `~=3.13` this would return `>=3.13` and `<4` and for `~=3.13.0` it would
/// return `>=3.13.0` and `<3.14`.
pub fn bounding_specifiers(&self) -> (VersionSpecifier, VersionSpecifier) {
let release = self.inner.version().release();
let lower = self.inner.version.clone();
let upper = if self.has_patch() {
Version::new([release[0], release[1] + 1])
} else {
Version::new([release[0] + 1])
};
(
VersionSpecifier::greater_than_equal_version(lower),
VersionSpecifier::less_than_version(upper),
)
}

/// Construct a new tilde `VersionSpecifier` with the given patch version appended.
pub fn with_patch_version(&self, patch: u64) -> TildeVersionSpecifier {
let mut release = self.inner.version.release().to_vec();
if self.has_patch() {
release.pop();
}
release.push(patch);
TildeVersionSpecifier::from_specifier(
VersionSpecifier::from_version(Operator::TildeEqual, Version::new(release))
.expect("We should always derive a valid new version specifier"),
)
.expect("We should always derive a new tilde version specifier")
}
}

impl std::fmt::Display for TildeVersionSpecifier<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.inner)
}
}

#[cfg(test)]
mod tests {
use std::{cmp::Ordering, str::FromStr};
Expand Down
26 changes: 25 additions & 1 deletion crates/uv/src/commands/project/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ use uv_fs::{CWD, LockedFile, Simplified};
use uv_git::ResolvedRepositoryReference;
use uv_installer::{SatisfiesResult, SitePackages};
use uv_normalize::{DEV_DEPENDENCIES, DefaultGroups, ExtraName, GroupName, PackageName};
use uv_pep440::{Version, VersionSpecifiers};
use uv_pep440::{TildeVersionSpecifier, Version, VersionSpecifiers};
use uv_pep508::MarkerTreeContents;
use uv_pypi_types::{ConflictPackage, ConflictSet, Conflicts};
use uv_python::{
Expand Down Expand Up @@ -421,6 +421,30 @@ pub(crate) fn find_requires_python(
if requires_python.is_empty() {
return Ok(None);
}
for ((package, group), specifiers) in &requires_python {
if let [spec] = &specifiers[..] {
if let Some(spec) = TildeVersionSpecifier::from_specifier_ref(spec) {
if spec.has_patch() {
continue;
}
let (lower, upper) = spec.bounding_specifiers();
let spec_0 = spec.with_patch_version(0);
let (lower_0, upper_0) = spec_0.bounding_specifiers();
warn_user_once!(
"The `requires-python` specifier (`{spec}`) in `{package}{group}` \
uses the tilde specifier (`~=`) without a patch version. This will be \
interpreted as `{lower}, {upper}`. Did you mean `{spec_0}` to constrain the \
version as `{lower_0}, {upper_0}`? We recommend only using \
the tilde specifier with a patch version to avoid ambiguity.",
group = if let Some(group) = group {
format!(":{group}")
} else {
String::new()
},
);
}
}
}
match RequiresPython::intersection(requires_python.iter().map(|(.., specifiers)| specifiers)) {
Some(requires_python) => Ok(Some(requires_python)),
None => Err(ProjectError::DisjointRequiresPython(requires_python)),
Expand Down
6 changes: 3 additions & 3 deletions crates/uv/tests/it/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4551,15 +4551,15 @@ fn lock_requires_python_compatible_specifier() -> Result<()> {
"#,
)?;

uv_snapshot!(context.filters(), context.lock(), @r###"
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?
warning: The `requires-python` specifier (`~=3.13`) in `warehouse` uses the tilde specifier (`~=`) without a patch version. This will be interpreted as `>=3.13, <4`. Did you mean `~=3.13.0` to constrain the version as `>=3.13.0, <3.14`? We recommend only using the tilde specifier with a patch version to avoid ambiguity.
Resolved 1 package in [TIME]
"###);
");

pyproject_toml.write_str(
r#"
Expand Down