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
18 changes: 17 additions & 1 deletion crates/uv-pep440/src/version_specifier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,14 @@ impl VersionSpecifiers {
fn from_unsorted(mut specifiers: Vec<VersionSpecifier>) -> Self {
// TODO(konsti): This seems better than sorting on insert and not getting the size hint,
// but i haven't measured it.
specifiers.sort_by(|a, b| a.version().cmp(b.version()));
//
// Tie-break on the operator so semantically equivalent same-version intervals such as
// `>=1.4.4,<=1.4.4` and `<=1.4.4,>=1.4.4` normalize to the same representation.
specifiers.sort_by(|a, b| {
a.version()
.cmp(b.version())
.then_with(|| a.operator().cmp(b.operator()))
});
Self(specifiers.into_boxed_slice())
}

Expand Down Expand Up @@ -1951,6 +1958,15 @@ mod tests {
);
}

#[test]
fn test_version_specifiers_singular_interval() {
let lower_then_upper = VersionSpecifiers::from_str(">=1.4.4, <=1.4.4").unwrap();
let upper_then_lower = VersionSpecifiers::from_str("<=1.4.4, >=1.4.4").unwrap();

assert_eq!(lower_then_upper, upper_then_lower);
assert_eq!(lower_then_upper.to_string(), "<=1.4.4, >=1.4.4");
}

/// These occur in the simple api, e.g.
/// <https://pypi.org/simple/geopandas/?format=application/vnd.pypi.simple.v1+json>
#[test]
Expand Down
73 changes: 73 additions & 0 deletions crates/uv/tests/it/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5405,6 +5405,79 @@ fn read_metadata_statically_over_the_cache() -> Result<()> {
Ok(())
}

/// Accept equivalent singular version intervals in static `requires-dist` metadata.
///
/// See: <https://github.com/astral-sh/uv/issues/17639>
#[test]
fn no_install_project_singular_interval_requires_dist() -> Result<()> {
let context = uv_test::test_context!("3.12");

let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
requires-python = ">=3.12"
dynamic = ["version"]
dependencies = ["iniconfig>=2.0.0,<=2.0.0"]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.uv]
cache-keys = [{ file = "pyproject.toml" }, { file = "src/__about__.py" }]

[tool.hatch.version]
path = "src/__about__.py"
scheme = "standard"
"#,
)?;
context
.temp_dir
.child("src")
.child("__about__.py")
.write_str("__version__ = '0.1.0'")?;
context
.temp_dir
.child("src")
.child("project")
.child("__init__.py")
.touch()?;

context.lock().assert().success();

let lock_path = context.temp_dir.join("uv.lock");
let lock = fs_err::read_to_string(&lock_path)?;
let lock = lock.replacen(
r#"requires-dist = [{ name = "iniconfig", specifier = ">=2.0.0,<=2.0.0" }]"#,
r#"requires-dist = [{ name = "iniconfig", specifier = "<=2.0.0,>=2.0.0" }]"#,
1,
);
assert!(
lock.contains(r#"requires-dist = [{ name = "iniconfig", specifier = "<=2.0.0,>=2.0.0" }]"#),
"expected to rewrite the dynamic package metadata in `uv.lock`"
);
fs_err::write(&lock_path, lock)?;

fs_err::remove_dir_all(&context.cache_dir)?;
fs_err::remove_file(context.temp_dir.join("src").join("__about__.py"))?;

uv_snapshot!(context.filters(), context.sync().arg("--locked").arg("--no-install-project"), @"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Resolved 2 packages in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ iniconfig==2.0.0
");

Ok(())
}

/// Avoid syncing the project package when `--no-install-project` is provided.
#[test]
fn no_install_project() -> Result<()> {
Expand Down
Loading