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
72 changes: 71 additions & 1 deletion crates/uv-distribution/src/archive.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -19,6 +20,23 @@ pub struct Archive {
pub size: Option<u64>,
}

impl serde::Serialize for Archive {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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(
Expand Down Expand Up @@ -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<u64>,
}

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);
}
}
56 changes: 55 additions & 1 deletion crates/uv-distribution/src/source/revision.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use serde::ser::SerializeMap;
use serde::{Deserialize, Serialize};
use std::path::Path;
use uv_distribution_types::Hashed;
Expand All @@ -10,14 +11,29 @@ 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,
#[serde(default)]
size: Option<u64>,
}

impl Serialize for Revision {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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 {
Expand Down Expand Up @@ -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<u64>,
}

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());
}
}
71 changes: 39 additions & 32 deletions crates/uv/tests/pip_install/pip_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
Expand Down
Loading