diff --git a/crates/uv-distribution/src/archive.rs b/crates/uv-distribution/src/archive.rs index 799044f9a50..9e2ef3d2bee 100644 --- a/crates/uv-distribution/src/archive.rs +++ b/crates/uv-distribution/src/archive.rs @@ -1,10 +1,11 @@ +use serde::ser::SerializeMap; use uv_cache::{ARCHIVE_VERSION, ArchiveId, Cache}; use uv_distribution_filename::WheelFilename; use uv_distribution_types::Hashed; use uv_pypi_types::{HashDigest, HashDigests}; /// An archive (unzipped wheel) that exists in the local cache. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, serde::Deserialize)] pub struct Archive { /// The unique ID of the entry in the wheel's archive bucket. pub id: ArchiveId, @@ -19,6 +20,23 @@ pub struct Archive { pub size: Option, } +impl serde::Serialize for Archive { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + // Cache buckets are shared with older uv versions, whose readers can ignore unknown map + // entries but reject MessagePack arrays with additional fields. + let mut map = serializer.serialize_map(Some(5))?; + map.serialize_entry("id", &self.id)?; + map.serialize_entry("hashes", &self.hashes)?; + map.serialize_entry("filename", &self.filename)?; + map.serialize_entry("version", &self.version)?; + map.serialize_entry("size", &self.size)?; + map.end() + } +} + impl Archive { /// Create a new [`Archive`] with the given ID and hashes. pub(crate) fn new( @@ -76,4 +94,56 @@ mod tests { assert_eq!(archive.size, None); } + + #[test] + fn deserialize_uv_0_12_archive() { + #[derive(serde::Serialize)] + struct ReleasedArchive { + id: ArchiveId, + hashes: HashDigests, + filename: WheelFilename, + version: u8, + size: Option, + } + + let released = ReleasedArchive { + id: ArchiveId::default(), + hashes: HashDigests::empty(), + filename: WheelFilename::from_str("iniconfig-2.0.0-py3-none-any.whl") + .expect("valid wheel filename"), + version: ARCHIVE_VERSION, + size: Some(42), + }; + let bytes = rmp_serde::to_vec(&released).expect("serialize uv 0.12 archive"); + let archive: Archive = rmp_serde::from_slice(&bytes).expect("deserialize uv 0.12 archive"); + + assert_eq!(archive.size, Some(42)); + } + + #[test] + fn serialize_archive_for_legacy_reader() { + #[derive(serde::Deserialize)] + struct LegacyArchive { + id: ArchiveId, + hashes: HashDigests, + filename: WheelFilename, + version: u8, + } + + let archive = Archive::new( + ArchiveId::default(), + HashDigests::empty(), + WheelFilename::from_str("iniconfig-2.0.0-py3-none-any.whl") + .expect("valid wheel filename"), + Some(42), + ); + let bytes = rmp_serde::to_vec(&archive).expect("serialize archive"); + let legacy: LegacyArchive = + rmp_serde::from_slice(&bytes).expect("deserialize archive with legacy reader"); + + assert_eq!(legacy.id, archive.id); + assert_eq!(legacy.hashes, archive.hashes); + assert_eq!(legacy.filename, archive.filename); + assert_eq!(legacy.version, archive.version); + } } diff --git a/crates/uv-distribution/src/source/revision.rs b/crates/uv-distribution/src/source/revision.rs index 8bc257e353d..0b368b259ae 100644 --- a/crates/uv-distribution/src/source/revision.rs +++ b/crates/uv-distribution/src/source/revision.rs @@ -1,3 +1,4 @@ +use serde::ser::SerializeMap; use serde::{Deserialize, Serialize}; use std::path::Path; use uv_distribution_types::Hashed; @@ -10,7 +11,7 @@ use uv_pypi_types::{HashDigest, HashDigests}; /// (e.g.) the version number of the distribution itself. For example, a source distribution hosted /// at a URL or a local file path may have multiple revisions, each representing a unique state of /// the distribution, despite the reported version number remaining the same. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub(crate) struct Revision { id: RevisionId, hashes: HashDigests, @@ -18,6 +19,21 @@ pub(crate) struct Revision { size: Option, } +impl Serialize for Revision { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + // Cache buckets are shared with older uv versions, whose readers can ignore unknown map + // entries but reject MessagePack arrays with additional fields. + let mut map = serializer.serialize_map(Some(3))?; + map.serialize_entry("id", &self.id)?; + map.serialize_entry("hashes", &self.hashes)?; + map.serialize_entry("size", &self.size)?; + map.end() + } +} + impl Revision { /// Initialize a new [`Revision`] with a random UUID. pub(crate) fn new() -> Self { @@ -131,4 +147,42 @@ mod tests { let parsed: Revision = rmp_serde::from_slice(&bytes).expect("deserialize revision"); assert_eq!(parsed.id().as_str(), original.id().as_str()); } + + #[test] + fn deserialize_uv_0_12_revision() { + #[derive(Serialize)] + struct ReleasedRevision { + id: RevisionId, + hashes: HashDigests, + size: Option, + } + + let released = ReleasedRevision { + id: RevisionId("HM0NxJml5hc7UjbfTWT1r".to_string()), + hashes: HashDigests::empty(), + size: Some(42), + }; + let bytes = rmp_serde::to_vec(&released).expect("serialize uv 0.12 revision"); + let revision: Revision = + rmp_serde::from_slice(&bytes).expect("deserialize uv 0.12 revision"); + + assert_eq!(revision.size(), Some(42)); + } + + #[test] + fn serialize_revision_for_legacy_reader() { + #[derive(Deserialize)] + struct LegacyRevision { + id: RevisionId, + hashes: HashDigests, + } + + let revision = Revision::new().with_size(42); + let bytes = rmp_serde::to_vec(&revision).expect("serialize revision"); + let legacy: LegacyRevision = + rmp_serde::from_slice(&bytes).expect("deserialize revision with legacy reader"); + + assert_eq!(legacy.id.as_str(), revision.id().as_str()); + assert_eq!(legacy.hashes, HashDigests::empty()); + } } diff --git a/crates/uv/tests/pip_install/pip_install.rs b/crates/uv/tests/pip_install/pip_install.rs index ff36c19d578..8bcaf75585e 100644 --- a/crates/uv/tests/pip_install/pip_install.rs +++ b/crates/uv/tests/pip_install/pip_install.rs @@ -103,38 +103,45 @@ fn write_many_files_wheel(path: &Path, source_files: usize) -> Result<()> { #[test] fn install_wheel_cache_incompatible_with_older_uv() -> Result<()> { - let context = uv_test::test_context!("3.12"); - let wheel = context.temp_dir.join("large_wheel-1.0.0-py3-none-any.whl"); - write_many_files_wheel(&wheel, 1)?; - - context.pip_install().arg(&wheel).assert().success(); - context.venv().arg("--clear").assert().success(); - - // New cache entries should not make older uv versions fail; see astral-sh/uv#20949. - uv_snapshot!(context.filters(), context.tool_run() - .arg("--from") - .arg("uv==0.11.1") - .arg("uv") - .arg("pip") - .arg("install") - .arg("--python") - .arg(context.venv.path()) - .arg(&wheel) - .arg("--cache-dir") - .arg(context.cache_dir.path()) - .env_remove(EnvVars::UV_EXCLUDE_NEWER) - .env_remove(EnvVars::UV_TEST_AVAILABLE_VERSION_CUTOFF), @" - exit_code: 1 (failure) - ----- stderr ----- - Resolved 1 package in [TIME] - Prepared 1 package in [TIME] - Installed 1 package in [TIME] - + uv==0.11.1 - Resolved 1 package in [TIME] - × Failed to read `large-wheel @ file://[TEMP_DIR]/large_wheel-1.0.0-py3-none-any.whl` - ├─▶ Failed to deserialize cache entry - ╰─▶ array had incorrect length, expected 4 - "); + allow_duplicates! { + for version in ["0.11.1", "0.12.0"] { + let context = uv_test::test_context!("3.12") + // TODO: Remove this once the older `interpreter-v4` cache layout is supported. + .with_filter((r"(?m)^WARN Broken interpreter cache entry at .*\n", "")) + .with_filter((r" \+ uv==0\.(?:11\.1|12\.0)", " + uv==[VERSION]")); + let wheel = context.temp_dir.join("large_wheel-1.0.0-py3-none-any.whl"); + write_many_files_wheel(&wheel, 1)?; + + context.pip_install().arg(&wheel).assert().success(); + context.venv().arg("--clear").assert().success(); + + // New cache entries should not make older uv versions fail; see astral-sh/uv#20949. + uv_snapshot!(context.filters(), context.tool_run() + .arg("--from") + .arg(format!("uv=={version}")) + .arg("uv") + .arg("pip") + .arg("install") + .arg("--python") + .arg(context.venv.path()) + .arg(&wheel) + .arg("--cache-dir") + .arg(context.cache_dir.path()) + .env_remove(EnvVars::UV_EXCLUDE_NEWER) + .env_remove(EnvVars::UV_TEST_AVAILABLE_VERSION_CUTOFF), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + uv==[VERSION] + Resolved 1 package in [TIME] + Installed 1 package in [TIME] + + large-wheel==1.0.0 (from file://[TEMP_DIR]/large_wheel-1.0.0-py3-none-any.whl) + "); + } + Ok::<(), anyhow::Error>(()) + }?; Ok(()) }