diff --git a/crates/uv-client/src/flat_index.rs b/crates/uv-client/src/flat_index.rs index 50620fe09..b0d41c930 100644 --- a/crates/uv-client/src/flat_index.rs +++ b/crates/uv-client/src/flat_index.rs @@ -34,10 +34,18 @@ pub enum FindLinksDirectoryError { VerbatimUrl(#[from] uv_pep508::VerbatimUrlError), } +/// An entry in a `--find-links` index. +#[derive(Debug, Clone)] +pub struct FlatIndexEntry { + pub filename: DistFilename, + pub file: File, + pub index: IndexUrl, +} + #[derive(Debug, Default, Clone)] pub struct FlatIndexEntries { /// The list of `--find-links` entries. - pub entries: Vec<(DistFilename, File, IndexUrl)>, + pub entries: Vec, /// Whether any `--find-links` entries could not be resolved due to a lack of network /// connectivity. pub offline: bool, @@ -45,7 +53,7 @@ pub struct FlatIndexEntries { impl FlatIndexEntries { /// Create a [`FlatIndexEntries`] from a list of `--find-links` entries. - fn from_entries(entries: Vec<(DistFilename, File, IndexUrl)>) -> Self { + fn from_entries(entries: Vec) -> Self { Self { entries, offline: false, @@ -130,6 +138,9 @@ impl<'a> FlatIndexClient<'a> { while let Some(entries) = fetches.next().await.transpose()? { results.extend(entries); } + results + .entries + .sort_by(|a, b| a.filename.cmp(&b.filename).then(a.index.cmp(&b.index))); Ok(results) } @@ -211,11 +222,11 @@ impl<'a> FlatIndexClient<'a> { .expect("archived version always deserializes") }) .filter_map(|file| { - Some(( - DistFilename::try_from_normalized_filename(&file.filename)?, + Some(FlatIndexEntry { + filename: DistFilename::try_from_normalized_filename(&file.filename)?, file, - flat_index.clone(), - )) + index: flat_index.clone(), + }) }) .collect(); Ok(FlatIndexEntries::from_entries(files)) @@ -283,7 +294,11 @@ impl<'a> FlatIndexClient<'a> { ); continue; }; - dists.push((filename, file, flat_index.clone())); + dists.push(FlatIndexEntry { + filename, + file, + index: flat_index.clone(), + }); } Ok(FlatIndexEntries::from_entries(dists)) } diff --git a/crates/uv-distribution-filename/src/lib.rs b/crates/uv-distribution-filename/src/lib.rs index 5edd1454a..f16d2c7c3 100644 --- a/crates/uv-distribution-filename/src/lib.rs +++ b/crates/uv-distribution-filename/src/lib.rs @@ -15,7 +15,7 @@ mod extension; mod source_dist; mod wheel; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum DistFilename { SourceDistFilename(SourceDistFilename), WheelFilename(WheelFilename), diff --git a/crates/uv-distribution-filename/src/source_dist.rs b/crates/uv-distribution-filename/src/source_dist.rs index a3920a32d..2dfacc090 100644 --- a/crates/uv-distribution-filename/src/source_dist.rs +++ b/crates/uv-distribution-filename/src/source_dist.rs @@ -14,6 +14,8 @@ use uv_pep440::{Version, VersionParseError}; Debug, PartialEq, Eq, + PartialOrd, + Ord, Serialize, Deserialize, rkyv::Archive, diff --git a/crates/uv-distribution-filename/src/wheel.rs b/crates/uv-distribution-filename/src/wheel.rs index a91c3e141..0d4762135 100644 --- a/crates/uv-distribution-filename/src/wheel.rs +++ b/crates/uv-distribution-filename/src/wheel.rs @@ -11,7 +11,18 @@ use uv_platform_tags::{TagCompatibility, Tags}; use crate::{BuildTag, BuildTagError}; -#[derive(Debug, Clone, Eq, PartialEq, Hash, rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)] +#[derive( + Debug, + Clone, + Eq, + PartialEq, + Ord, + PartialOrd, + Hash, + rkyv::Archive, + rkyv::Deserialize, + rkyv::Serialize, +)] #[rkyv(derive(Debug))] pub struct WheelFilename { pub name: PackageName, diff --git a/crates/uv-resolver/src/flat_index.rs b/crates/uv-resolver/src/flat_index.rs index 8c47f4322..a7570f250 100644 --- a/crates/uv-resolver/src/flat_index.rs +++ b/crates/uv-resolver/src/flat_index.rs @@ -40,16 +40,16 @@ impl FlatIndex { ) -> Self { // Collect compatible distributions. let mut index = FxHashMap::default(); - for (filename, file, url) in entries.entries { - let distributions = index.entry(filename.name().clone()).or_default(); + for entry in entries.entries { + let distributions = index.entry(entry.filename.name().clone()).or_default(); Self::add_file( distributions, - file, - filename, + entry.file, + entry.filename, tags, hasher, build_options, - url, + entry.index, ); } diff --git a/crates/uv/tests/it/pip_install.rs b/crates/uv/tests/it/pip_install.rs index 7f7104ea4..416b80eb5 100644 --- a/crates/uv/tests/it/pip_install.rs +++ b/crates/uv/tests/it/pip_install.rs @@ -12,7 +12,8 @@ use predicates::prelude::predicate; use url::Url; use crate::common::{ - self, build_vendor_links_url, decode_token, get_bin, uv_snapshot, venv_bin_path, TestContext, + self, build_vendor_links_url, decode_token, get_bin, uv_snapshot, venv_bin_path, + venv_to_interpreter, TestContext, }; use uv_fs::Simplified; use uv_static::EnvVars; @@ -7516,3 +7517,40 @@ fn test_dynamic_version_sdist_wrong_version() -> Result<()> { Ok(()) } + +/// Install a package with multiple wheels at the same version, differing only in the build tag. We +/// should choose the wheel with the highest build tag. +#[test] +fn build_tag() { + let context = TestContext::new("3.12"); + + uv_snapshot!(context.filters(), context.pip_install() + .arg("build-tag") + .arg("--find-links") + .arg(context.workspace_root.join("scripts/links/")), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + build-tag==1.0.0 + "### + ); + + // Ensure that we choose the highest build tag (5). + uv_snapshot!(Command::new(venv_to_interpreter(&context.venv)) + .arg("-B") + .arg("-c") + .arg("import build_tag; build_tag.main()") + .current_dir(&context.temp_dir), @r###" + success: true + exit_code: 0 + ----- stdout ----- + 5 + + ----- stderr ----- + "###); +} diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 7f419b83e..1f9fd3abb 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -1,13 +1,13 @@ use anyhow::Result; use assert_cmd::prelude::*; use assert_fs::{fixture::ChildPath, prelude::*}; -use indoc::indoc; +use indoc::{formatdoc, indoc}; use insta::assert_snapshot; +use crate::common::{download_to_disk, uv_snapshot, venv_bin_path, TestContext}; use predicates::prelude::predicate; use tempfile::tempdir_in; - -use crate::common::{download_to_disk, uv_snapshot, venv_bin_path, TestContext}; +use uv_fs::Simplified; use uv_static::EnvVars; #[test] @@ -5589,3 +5589,124 @@ fn sync_git_path_dependency() -> Result<()> { Ok(()) } + +/// Sync a package with multiple wheels at the same version, differing only in the build tag. We +/// should choose the wheel with the highest build tag. +#[test] +fn sync_build_tag() -> Result<()> { + let context = TestContext::new("3.12"); + + // Populate the `--find-links` entries. + fs_err::create_dir_all(context.temp_dir.join("links"))?; + + for entry in fs_err::read_dir(context.workspace_root.join("scripts/links"))? { + let entry = entry?; + let path = entry.path(); + if path + .file_name() + .and_then(|file_name| file_name.to_str()) + .is_some_and(|file_name| file_name.starts_with("build_tag-")) + { + let dest = context + .temp_dir + .join("links") + .join(path.file_name().unwrap()); + fs_err::copy(&path, &dest)?; + } + } + + context + .temp_dir + .child("pyproject.toml") + .write_str(&formatdoc! { r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["build-tag"] + + [tool.uv] + find-links = ["{}"] + "#, + context.temp_dir.join("links/").portable_display(), + })?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "###); + + let lock = fs_err::read_to_string(context.temp_dir.child("uv.lock")).unwrap(); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "build-tag" + version = "1.0.0" + source = { registry = "links" } + wheels = [ + { path = "build_tag-1.0.0-1-py2.py3-none-any.whl" }, + { path = "build_tag-1.0.0-3-py2.py3-none-any.whl" }, + { path = "build_tag-1.0.0-5-py2.py3-none-any.whl" }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "build-tag" }, + ] + + [package.metadata] + requires-dist = [{ name = "build-tag" }] + "### + ); + }); + + // Re-run with `--locked`. + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "###); + + // Install from the lockfile. + uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Installed 1 package in [TIME] + + build-tag==1.0.0 + "###); + + // Ensure that we choose the highest build tag (5). + uv_snapshot!(context.filters(), context.run().arg("--no-sync").arg("python").arg("-c").arg("import build_tag; build_tag.main()"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + 5 + + ----- stderr ----- + "###); + + Ok(()) +} diff --git a/scripts/links/build_tag-1.0.0-1-py2.py3-none-any.whl b/scripts/links/build_tag-1.0.0-1-py2.py3-none-any.whl new file mode 100644 index 000000000..2a0bc4ae1 Binary files /dev/null and b/scripts/links/build_tag-1.0.0-1-py2.py3-none-any.whl differ diff --git a/scripts/links/build_tag-1.0.0-3-py2.py3-none-any.whl b/scripts/links/build_tag-1.0.0-3-py2.py3-none-any.whl new file mode 100644 index 000000000..18ed315fc Binary files /dev/null and b/scripts/links/build_tag-1.0.0-3-py2.py3-none-any.whl differ diff --git a/scripts/links/build_tag-1.0.0-5-py2.py3-none-any.whl b/scripts/links/build_tag-1.0.0-5-py2.py3-none-any.whl new file mode 100644 index 000000000..c2ad0c14d Binary files /dev/null and b/scripts/links/build_tag-1.0.0-5-py2.py3-none-any.whl differ