From aa714191b20e5fecc7752d5402aef7d0adb35f7e Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sat, 4 Apr 2026 22:17:02 -0400 Subject: [PATCH] Sort by comparator to break specifier ties --- crates/uv-pep440/src/version_specifier.rs | 18 +++++- crates/uv/tests/it/sync.rs | 73 +++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/crates/uv-pep440/src/version_specifier.rs b/crates/uv-pep440/src/version_specifier.rs index 17928b881218c..c86e8004c12d1 100644 --- a/crates/uv-pep440/src/version_specifier.rs +++ b/crates/uv-pep440/src/version_specifier.rs @@ -67,7 +67,14 @@ impl VersionSpecifiers { fn from_unsorted(mut specifiers: Vec) -> 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()) } @@ -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. /// #[test] diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 61a7e6d13a225..ab37a54afbc08 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -5405,6 +5405,79 @@ fn read_metadata_statically_over_the_cache() -> Result<()> { Ok(()) } +/// Accept equivalent singular version intervals in static `requires-dist` metadata. +/// +/// See: +#[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<()> {