diff --git a/Cargo.lock b/Cargo.lock index c345bc25276..fd08e7af479 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6242,7 +6242,9 @@ dependencies = [ "astral_async_http_range_reader", "astral_async_zip", "async-trait", + "bitflags 2.13.1", "bytecheck", + "crc32fast", "fs-err", "futures", "h2", @@ -6286,6 +6288,7 @@ dependencies = [ "uv-distribution-types", "uv-errors", "uv-extract", + "uv-fastid", "uv-fs", "uv-git", "uv-metadata", @@ -6306,6 +6309,7 @@ dependencies = [ "webpki-root-certs", "wiremock", "x509-parser", + "zerocopy", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c4e5c1b0046..9d97a4ba2e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -144,6 +144,7 @@ clap = { version = "4.5.17", default-features = false, features = [ clap_complete_command = { version = "0.6.1", default-features = false, features = ["nushell"] } configparser = { version = "3.1.0", default-features = false } console = { version = "0.16.0", default-features = false, features = ["std"] } +crc32fast = { version = "1.5.0", default-features = false, features = ["std"] } csv = { version = "1.3.0", default-features = false } ctrlc = { version = "3.4.5", default-features = false } cyclonedx-bom = { version = "0.8.1", default-features = false } @@ -363,6 +364,7 @@ windows = { version = "0.61.0", default-features = false, features = [ windows-registry = { version = "0.5.0", default-features = false, features = ["std"] } windows-version = { version = "0.1.6", default-features = false } wiremock = { version = "0.6.4", default-features = false } +zerocopy = { version = "0.8.56", default-features = false, features = ["derive"] } zeroize = { version = "1.8.1", default-features = false, features = ["alloc"] } # dev-dependencies diff --git a/crates/uv-cache/src/lib.rs b/crates/uv-cache/src/lib.rs index d1536e6e0f7..ee39258fa30 100644 --- a/crates/uv-cache/src/lib.rs +++ b/crates/uv-cache/src/lib.rs @@ -1291,7 +1291,7 @@ pub enum CacheBucket { /// Cached vulnerability data from [OSV](https://osv.dev/). /// /// Cache structure: - /// * `osv-v0/vulnerability/.msgpack` — cached full vulnerability records + /// * `osv-v1/vulnerability/.msgpack` — cached full vulnerability records Osv, } @@ -1300,29 +1300,27 @@ impl CacheBucket { match self { // Note that when bumping this, you'll also need to bump it // in `crates/uv/tests/build/cache_prune.rs`. - // TODO(ww): Remove `uv_pypi_types::HashDigestWire` on the next cache bump - // or breaking release. - Self::SourceDistributions => "sdists-v9", + Self::SourceDistributions => "sdists-v10", // Note that when bumping this, you'll also need to bump it // in `crates/uv/tests/lock/lock.rs`. - Self::FlatIndex => "flat-index-v5", + Self::FlatIndex => "flat-index-v6", Self::Git => "git-v1", Self::Interpreter => "interpreter-v4", // Note that when bumping this, you'll also need to bump it // in `crates/uv/tests/build/cache_clean.rs`. - Self::Simple => "simple-v25", + Self::Simple => "simple-v26", // Note that when bumping this, you'll also need to bump it // in `crates/uv/tests/build/cache_prune.rs`. - Self::Wheels => "wheels-v6", + Self::Wheels => "wheels-v7", // Note that when bumping this, you'll also need to bump // `ARCHIVE_VERSION` in `crates/uv-cache/src/lib.rs`. Self::Archive => "archive-v0", Self::Files => "files-v0", Self::Builds => "builds-v0", Self::Environments => "environments-v2", - Self::Python => "python-v0", + Self::Python => "python-v1", Self::Binaries => "binaries-v0", - Self::Osv => "osv-v0", + Self::Osv => "osv-v1", } } diff --git a/crates/uv-client/Cargo.toml b/crates/uv-client/Cargo.toml index e5b4dceb472..02521b195c4 100644 --- a/crates/uv-client/Cargo.toml +++ b/crates/uv-client/Cargo.toml @@ -27,6 +27,7 @@ uv-distribution-filename = { workspace = true } uv-distribution-types = { workspace = true } uv-errors = { workspace = true } uv-extract = { workspace = true } +uv-fastid = { workspace = true } uv-fs = { workspace = true, features = ["tokio"] } uv-git = { workspace = true } uv-metadata = { workspace = true } @@ -49,7 +50,9 @@ astral-tl = { workspace = true } async-trait = { workspace = true } async_http_range_reader = { workspace = true } async_zip = { workspace = true } +bitflags = { workspace = true } bytecheck = { workspace = true } +crc32fast = { workspace = true } fs-err = { workspace = true, features = ["tokio"] } futures = { workspace = true } hex = { workspace = true } @@ -80,6 +83,7 @@ tokio-util = { workspace = true } tracing = { workspace = true } url = { workspace = true } webpki-root-certs = { workspace = true } +zerocopy = { workspace = true } [dev-dependencies] anyhow = { workspace = true } diff --git a/crates/uv-client/src/cached_client.rs b/crates/uv-client/src/cached_client.rs index dcc56e528e5..0a85ffdeed6 100644 --- a/crates/uv-client/src/cached_client.rs +++ b/crates/uv-client/src/cached_client.rs @@ -1,19 +1,27 @@ +use std::borrow::Cow; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::Path; use std::time::{Duration, Instant}; -use std::{borrow::Cow, io::Read, path::Path}; +use crc32fast::Hasher; use futures::FutureExt; use reqwest::{Request, Response}; use rkyv::util::AlignedVec; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use tracing::{Instrument, Span, debug, info_span, instrument, trace, warn}; +use zerocopy::byteorder::little_endian::{U32, U64}; +use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout, Unaligned}; use uv_cache::{CacheEntry, Freshness}; +use uv_fastid::Id; use uv_fs::write_atomic; use uv_redacted::DisplaySafeUrl; use crate::base_client::CertificateSource; -use crate::httpcache::{AfterResponse, BeforeRequest, CachePolicy, CachePolicyBuilder}; +use crate::httpcache::{ + AfterResponse, ArchivedCachePolicy, BeforeRequest, CachePolicy, CachePolicyBuilder, +}; use crate::{BaseClient, Error, ErrorKind, OwnedArchive, ProblemDetails, RetryState}; /// A trait the generalizes (de)serialization at a high level. @@ -335,18 +343,18 @@ impl CachedClient { let refresh_cache = info_span!("refresh_cache", file = %cache_entry.path().display()); async { - let data_with_cache_policy_bytes = - DataWithCachePolicy::serialize(&new_policy, &cached.data)?; - write_atomic(cache_entry.path(), data_with_cache_policy_bytes) - .await - .map_err(ErrorKind::CacheWrite)?; - match self - .0 - .cache_read_runtime() - .spawn_blocking(move || Payload::from_aligned_bytes(cached.data)) - .await - .expect("cache payload decoding task panicked") - { + let path = cache_entry.path().to_path_buf(); + let span = Span::current(); + let payload = tokio::task::spawn_blocking(move || { + span.in_scope(|| { + cached.refresh_policy(&path, &new_policy)?; + // Keep decoding errors separate so a corrupt payload can be refetched. + Ok::<_, Error>(Payload::from_aligned_bytes(cached.into_data())) + }) + }) + .await + .expect("cache refresh task panicked")?; + match payload { Ok(payload) => Ok(payload), Err(err) => { warn!( @@ -515,10 +523,12 @@ impl CachedClient { ); } let url = DisplaySafeUrl::from_url(req.url().clone()); - match cached.cache_policy.before_request(&mut req) { + match cached.cache_policy().before_request(&mut req) { BeforeRequest::Fresh => { debug!("Found fresh response for: {url}"); - Some(CachedEntry::Fresh(Payload::from_aligned_bytes(cached.data))) + Some(CachedEntry::Fresh(Payload::from_aligned_bytes( + cached.into_data(), + ))) } BeforeRequest::Stale(new_cache_policy_builder) => { debug!("Found stale response for: {url}"); @@ -573,8 +583,8 @@ impl CachedClient { .cache_read_runtime() .spawn_blocking(move || { let cached = DataWithCachePolicy::from_path_sync(&path).and_then(|cached| { - if cached.cache_policy.matches_stale_request(&req) { - Payload::from_aligned_bytes(cached.data).map(Some) + if cached.cache_policy().matches_stale_request(&req) { + Payload::from_aligned_bytes(cached.into_data()).map(Some) } else { Ok(None) } @@ -632,7 +642,7 @@ impl CachedClient { } match cached - .cache_policy + .cache_policy() .after_response(new_cache_policy_builder, &response) { AfterResponse::NotModified(new_policy) => { @@ -847,8 +857,7 @@ enum CachedResponse { /// from data received in a revalidation response, which might change /// the parameters of cache behavior. /// - /// The policy is large (352 bytes at time of writing), so we reduce - /// the stack size by boxing it. + /// Box the policy to reduce the enum's stack size. new_policy: Box, }, /// There was no prior cached response or the cache was outdated @@ -860,55 +869,47 @@ enum CachedResponse { /// The [`CachePolicy`] is used to determine if the response is fresh or /// stale when making subsequent requests for the same resource. /// - /// The policy is large (352 bytes at time of writing), so we reduce - /// the stack size by boxing it. + /// Box the policy to reduce the enum's stack size. cache_policy: Option>, }, } -/// Represents an arbitrary data blob with an associated HTTP cache policy. -/// -/// The cache policy is used to determine whether the data blob is stale or -/// not. +/// Cached data with an HTTP policy that can be refreshed in place. /// /// # Format /// -/// This type encapsulates the format for how blobs of data are stored on -/// disk. The format is very simple. First, the blob of data is written as-is. -/// Second, the archived representation of a `CachePolicy` is written. Thirdly, -/// the length, in bytes, of the archived `CachePolicy` is written as a 64-bit -/// little endian integer. -/// -/// Reading the format is done via an `AlignedVec` so that `rkyv` can correctly -/// read the archived representation of the data blob. The cache policy is -/// split into its own `AlignedVec` allocation. -/// -/// # Future ideas +/// Each file contains the payload, zero padding to a 16-byte boundary, a random 16-byte +/// entry ID, the archived HTTP cache policy, a 4-byte CRC32 checksum, and the payload +/// length as a little-endian `u64`. The checksum covers the padding, entry ID, policy, +/// and encoded payload length. The policy is read directly from the same [`AlignedVec`] +/// as the payload, which remains first so the buffer can be truncated without moving it. /// -/// This format was also chosen because it should in theory permit rewriting -/// the cache policy without needing to rewrite the data blob if the blob has -/// not changed. For example, this case occurs when a revalidation request -/// responds with HTTP 304 NOT MODIFIED. At time of writing, this is not yet -/// implemented because 1) the synchronization specifics of mutating a cache -/// file have not been worked out and 2) it's not clear if it's a win. +/// New responses atomically replace the entire file with a new entry ID. HTTP 304 +/// responses overwrite only the policy, checksum, and length. A writer opens the file +/// and verifies its entry ID before updating it, so a delayed 304 cannot mutate a +/// newer response. If a replacement occurs after opening, the writer updates only the +/// old file through its open handle. /// -/// An alternative format would be to write the cache policy and the -/// blob in two distinct files. This would avoid needing to worry about -/// synchronization, but it means reading two files instead of one for every -/// cached response in the fast path. It's unclear whether it's worth it. -/// (Experiments have not yet been done.) -/// -/// Another approach here would be to memory map the file and rejigger -/// `OwnedArchive` (or create a new type) that works with a memory map instead -/// of an `AlignedVec`. This will require care to ensure alignment is handled -/// correctly. This approach has not been litigated yet. I did not start with -/// it because experiments with ripgrep have tended to show that (on Linux) -/// memory mapping a bunch of small files ends up being quite a bit slower than -/// just reading them on to the heap. +/// Readers validate the checksum before using the policy to detect interrupted or +/// overlapping policy writes. An invalid entry requires a full fetch, so it cannot be +/// used offline. The checksum detects accidental corruption, not malicious changes. +/// No additional file or reader lock is needed. #[derive(Debug)] pub struct DataWithCachePolicy { - pub data: AlignedVec, - cache_policy: OwnedArchive, + bytes: AlignedVec, + data_len: usize, + policy_start: usize, + entry_id: [u8; 16], +} + +/// The fixed-size footer following the archived HTTP cache policy. +/// +/// Byte-aligned, little-endian fields allow the footer to follow a policy of any length. +#[derive(Debug, FromBytes, Immutable, IntoBytes, KnownLayout, Unaligned)] +#[repr(C)] +struct CachePolicyFooter { + checksum: U32, + data_len: U64, } impl DataWithCachePolicy { @@ -958,131 +959,293 @@ impl DataWithCachePolicy { Self::from_aligned_bytes(aligned_bytes) } - /// Loads cached data and its associated HTTP cache policy form an in - /// memory byte buffer. - /// - /// # Errors - /// - /// If the given byte buffer is not in a valid format, then this - /// returns an error. + /// Validate the policy within its aligned portion of the shared buffer. fn from_aligned_bytes(mut bytes: AlignedVec) -> Result { - let cache_policy = Self::deserialize_cache_policy(&mut bytes)?; + let (contents, footer) = CachePolicyFooter::ref_from_suffix(&bytes).map_err(|_| { + ErrorKind::ArchiveRead("HTTP cache entry is shorter than its trailer".to_owned()) + })?; + let data_len = usize::try_from(footer.data_len.get()) + .map_err(|_| ErrorKind::ArchiveRead("invalid HTTP cache payload length".to_owned()))?; + let tail = contents.get(data_len..).ok_or_else(|| { + ErrorKind::ArchiveRead("invalid HTTP cache payload length".to_owned()) + })?; + let padding_len = Self::padding_len(data_len); + let aligned_tail = tail.get(padding_len..).ok_or_else(|| { + ErrorKind::ArchiveRead("invalid HTTP cache payload length".to_owned()) + })?; + let (entry_id, policy_bytes) = <[u8; 16]>::ref_from_prefix(aligned_tail) + .map_err(|_| ErrorKind::ArchiveRead("invalid HTTP cache payload length".to_owned()))?; + + let mut hasher = Hasher::new(); + hasher.update(tail); + hasher.update(footer.data_len.as_bytes()); + if hasher.finalize() != footer.checksum.get() { + return Err( + ErrorKind::ArchiveRead("HTTP cache policy checksum mismatch".to_owned()).into(), + ); + } + + // Validate only the policy slice: relative pointers must not reach into the payload, + // entry ID, or footer, even when those bytes happen to contain a valid archived value. + rkyv::access::(policy_bytes) + .map_err(|err| ErrorKind::ArchiveRead(err.to_string()))?; + let entry_id = *entry_id; + let policy_start = contents.len() - policy_bytes.len(); + let contents_len = contents.len(); + bytes.resize(contents_len, 0); Ok(Self { - data: bytes, - cache_policy, + bytes, + data_len, + policy_start, + entry_id, }) } - /// Serializes the given cache policy and arbitrary data blob to an in - /// memory byte buffer. - /// - /// # Errors - /// - /// If there was a problem converting the given cache policy to its - /// serialized representation, then this routine will return an error. - fn serialize(cache_policy: &CachePolicy, data: &[u8]) -> Result, Error> { - let mut buf = vec![]; - Self::serialize_to_writer(cache_policy, data, &mut buf)?; - Ok(buf) + /// Serialize a new response with a unique entry ID for atomic publication. + fn serialize(policy: &CachePolicy, data: &[u8]) -> Result, Error> { + let entry_id = *Id::secure() + .as_bytes() + .as_array::<16>() + .expect("IDs have 16 bytes"); + let tail = Self::serialize_policy(policy, &entry_id, data.len())?; + let entry_id_start = data.len() + Self::padding_len(data.len()); + let mut bytes = Vec::with_capacity(entry_id_start + entry_id.len() + tail.len()); + bytes.extend_from_slice(data); + bytes.resize(entry_id_start, 0); + bytes.extend_from_slice(&entry_id); + bytes.extend_from_slice(&tail); + Ok(bytes) } - /// Serializes the given cache policy and arbitrary data blob to the given - /// writer. - /// - /// # Errors - /// - /// If there was a problem converting the given cache policy to its - /// serialized representation or if the writer returns an error, then - /// this routine will return an error. - fn serialize_to_writer( - cache_policy: &CachePolicy, - data: &[u8], - mut wtr: impl std::io::Write, - ) -> Result<(), Error> { - let cache_policy_archived = OwnedArchive::from_unarchived(cache_policy)?; - let cache_policy_bytes = OwnedArchive::as_bytes(&cache_policy_archived); - wtr.write_all(data).map_err(ErrorKind::Io)?; - wtr.write_all(cache_policy_bytes).map_err(ErrorKind::Io)?; - let len = u64::try_from(cache_policy_bytes.len()).map_err(|_| { - let msg = format!( - "failed to represent {} (length of cache policy) in a u64", - cache_policy_bytes.len() - ); - ErrorKind::Io(std::io::Error::other(msg)) - })?; - wtr.write_all(&len.to_le_bytes()).map_err(ErrorKind::Io)?; + /// Serialize the mutable tail, binding the policy to its entry ID and payload boundary. + fn serialize_policy( + policy: &CachePolicy, + entry_id: &[u8; 16], + data_len: usize, + ) -> Result, Error> { + let policy = OwnedArchive::from_unarchived(policy)?; + let policy = OwnedArchive::as_bytes(&policy); + let padding_len = Self::padding_len(data_len); + let data_len = U64::new( + u64::try_from(data_len).map_err(|err| ErrorKind::ArchiveWrite(err.to_string()))?, + ); + + let mut hasher = Hasher::new(); + hasher.update(&[0; AlignedVec::<16>::ALIGNMENT][..padding_len]); + hasher.update(entry_id); + hasher.update(policy); + hasher.update(data_len.as_bytes()); + let footer = CachePolicyFooter { + checksum: U32::new(hasher.finalize()), + data_len, + }; + + let mut bytes = Vec::with_capacity(policy.len() + size_of::()); + bytes.extend_from_slice(policy); + bytes.extend_from_slice(footer.as_bytes()); + Ok(bytes) + } + + /// Return the payload in the original allocation, discarding the policy and its padding. + pub fn into_data(mut self) -> AlignedVec { + self.bytes.resize(self.data_len, 0); + self.bytes + } + + /// Borrow the policy from the validated, immutable portion of the shared buffer. + fn cache_policy(&self) -> &ArchivedCachePolicy { + // SAFETY: `from_aligned_bytes` validates this exact aligned slice as an + // `ArchivedCachePolicy`. The private buffer and its bounds remain immutable until + // `into_data` consumes `self`, so the archive remains valid for the returned lifetime. + #[expect(unsafe_code)] + unsafe { + rkyv::access_unchecked::(&self.bytes[self.policy_start..]) + } + } + + /// Return the zero padding needed to align the entry ID and policy to the buffer. + fn padding_len(data_len: usize) -> usize { + (AlignedVec::<16>::ALIGNMENT - data_len % AlignedVec::<16>::ALIGNMENT) + % AlignedVec::<16>::ALIGNMENT + } + + /// Refresh only the policy tail of the file whose payload was revalidated. + fn refresh_policy(&self, path: &Path, policy: &CachePolicy) -> Result<(), Error> { + let mut file = match fs_err::OpenOptions::new().read(true).write(true).open(path) { + Ok(file) => file, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(err) + if err.kind() == std::io::ErrorKind::PermissionDenied + || err.kind() == std::io::ErrorKind::ReadOnlyFilesystem => + { + // The server has validated the payload even if its new policy cannot be saved. + debug!("Skipping cache policy update at {}: {err}", path.display()); + return Ok(()); + } + Err(err) => return Err(ErrorKind::CacheWrite(err).into()), + }; + file.seek(SeekFrom::Start( + (self.policy_start - self.entry_id.len()) as u64, + )) + .map_err(ErrorKind::CacheWrite)?; + let mut entry_id = [0; 16]; + match file.read_exact(&mut entry_id) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(()), + Err(err) => return Err(ErrorKind::CacheWrite(err).into()), + } + if entry_id != self.entry_id { + // A newer response replaced the entry while this request was in flight. + return Ok(()); + } + let tail = Self::serialize_policy(policy, &entry_id, self.data_len)?; + file.write_all(&tail).map_err(ErrorKind::CacheWrite)?; + file.set_len((self.policy_start + tail.len()) as u64) + .map_err(ErrorKind::CacheWrite)?; Ok(()) } +} - /// Deserializes a `OwnedArchive` off the end of the given - /// aligned bytes. Upon success, the given bytes will only contain the - /// data itself. The bytes representing the cached policy will have been - /// removed. - /// - /// # Errors - /// - /// This returns an error if the cache policy could not be deserialized - /// from the end of the given bytes. - fn deserialize_cache_policy( - bytes: &mut AlignedVec, - ) -> Result, Error> { - let len = Self::deserialize_cache_policy_len(bytes)?; - let cache_policy_bytes_start = bytes.len() - (len + 8); - let cache_policy_bytes = &bytes[cache_policy_bytes_start..][..len]; - let mut cache_policy_bytes_aligned = AlignedVec::with_capacity(len); - cache_policy_bytes_aligned.extend_from_slice(cache_policy_bytes); - assert!( - cache_policy_bytes_start <= bytes.len(), - "slicing cache policy should result in a truncation" +#[cfg(test)] +mod tests { + use anyhow::Result; + use rkyv::util::AlignedVec; + use zerocopy::IntoBytes; + use zerocopy::byteorder::little_endian::{U32, U64}; + + use super::{ + ArchivedCachePolicy, CachePolicy, CachePolicyBuilder, CachePolicyFooter, + DataWithCachePolicy, Hasher, OwnedArchive, + }; + + /// Build policies with different serialized sizes without depending on wall-clock changes. + fn policy(vary: &str) -> Result { + let request = reqwest::Request::new(http::Method::GET, "https://example.com/".parse()?); + let response = reqwest::Response::from( + http::Response::builder() + .header("cache-control", "public, max-age=3600") + .header("vary", vary) + .body(Vec::new())?, ); - // Technically this will keep the extra capacity used to store the - // cache policy around. But it should be pretty small, and it saves a - // realloc. (It's unclear whether that matters more or less than the - // extra memory usage.) - bytes.resize(cache_policy_bytes_start, 0); - OwnedArchive::new(cache_policy_bytes_aligned) + Ok(CachePolicyBuilder::new(&request).build(&response)) } - /// Deserializes the length, in bytes, of the cache policy given a complete - /// serialized byte buffer of a `DataWithCachePolicy`. - /// - /// Upon success, callers are guaranteed that - /// `&bytes[bytes.len() - (len + 8)..][..len]` will not panic. - /// - /// # Errors - /// - /// This returns an error if the length could not be read as a `usize` or is - /// otherwise known to be invalid. (For example, it is a length that is bigger - /// than `bytes.len()`.) - fn deserialize_cache_policy_len(bytes: &[u8]) -> Result { - let Some(cache_policy_len_start) = bytes.len().checked_sub(8) else { - let msg = format!( - "data-with-cache-policy buffer should be at least 8 bytes \ - in length, but is {} bytes", - bytes.len(), - ); - return Err(ErrorKind::ArchiveRead(msg).into()); - }; - let cache_policy_len_bytes = bytes[cache_policy_len_start..] - .as_array::<8>() - .expect("cache policy length is 8 bytes"); - let len_u64 = u64::from_le_bytes(*cache_policy_len_bytes); - let Ok(len_usize) = usize::try_from(len_u64) else { - let msg = format!( - "data-with-cache-policy has cache policy length of {len_u64}, \ - but overflows usize", + #[test] + fn payload_and_policy_share_an_aligned_buffer() -> Result<()> { + let policy = policy("x-small")?; + for data_len in 0..32 { + let payload = vec![42; data_len]; + let serialized = DataWithCachePolicy::serialize(&policy, &payload)?; + let mut bytes = AlignedVec::with_capacity(serialized.len()); + bytes.extend_from_slice(&serialized); + let allocation = bytes.as_ptr(); + let cached = DataWithCachePolicy::from_aligned_bytes(bytes)?; + + assert_eq!(cached.policy_start % AlignedVec::<16>::ALIGNMENT, 0); + assert_eq!( + &cached.bytes[cached.policy_start..], + OwnedArchive::as_bytes(&policy.to_archived()), ); - return Err(ErrorKind::ArchiveRead(msg).into()); + let data = cached.into_data(); + assert_eq!(data.as_ptr(), allocation); + assert_eq!(data.as_slice(), payload); + } + Ok(()) + } + + #[test] + fn policy_pointers_cannot_reference_the_payload() -> Result<()> { + let policy = policy(&format!("x-{}", "large".repeat(20)))?; + let serialized = DataWithCachePolicy::serialize(&policy, b"")?; + let mut bytes = AlignedVec::new(); + bytes.extend_from_slice(&serialized); + let footer_start = bytes.len() - size_of::(); + + // Move the payload boundary into the policy's strings, leaving the archive's root and + // relative pointers intact. Recompute the checksum so rkyv must reject those pointers. + let data_len = U64::new(16); + let mut hasher = Hasher::new(); + hasher.update(&bytes[usize::try_from(data_len.get())?..footer_start]); + hasher.update(data_len.as_bytes()); + let footer = CachePolicyFooter { + checksum: U32::new(hasher.finalize()), + data_len, }; - if len_usize > cache_policy_len_start { - let msg = format!( - "invalid cache entry: data-with-cache-policy has cache policy length of {}, \ - but total buffer size is {}", - len_usize, - bytes.len(), - ); - return Err(ErrorKind::ArchiveRead(msg).into()); + bytes[footer_start..].copy_from_slice(footer.as_bytes()); + + assert!( + rkyv::access::(&bytes[..footer_start]) + .is_ok() + ); + assert!(DataWithCachePolicy::from_aligned_bytes(bytes).is_err()); + Ok(()) + } + + #[test] + fn policy_can_grow_and_shrink_in_place() -> Result<()> { + let directory = tempfile::tempdir()?; + let path = directory.path().join("response"); + let small = policy("x-small")?; + let large = policy(&format!("x-{}", "large".repeat(1000)))?; + let payload = vec![42; 1024 * 1024 + 7]; + let original = DataWithCachePolicy::serialize(&small, &payload)?; + fs_err::write(&path, &original)?; + let cached = DataWithCachePolicy::from_path_sync(&path)?; + + cached.refresh_policy(&path, &large)?; + assert!(fs_err::metadata(&path)?.len() > original.len() as u64); + let grown = DataWithCachePolicy::from_path_sync(&path)?; + assert_eq!(&grown.bytes[..grown.data_len], payload); + assert_eq!(grown.entry_id, cached.entry_id); + assert_eq!( + &grown.bytes[grown.policy_start..], + OwnedArchive::as_bytes(&large.to_archived()), + ); + + grown.refresh_policy(&path, &small)?; + assert_eq!(fs_err::read(&path)?, original); + Ok(()) + } + + #[test] + fn interrupted_policy_writes_never_accept_a_mixed_policy() -> Result<()> { + let small = policy("x-small")?; + let large = policy(&format!("x-{}", "large".repeat(20)))?; + for (before, after) in [(&small, &large), (&large, &small)] { + let original = DataWithCachePolicy::serialize(before, b"payload")?; + let cached = DataWithCachePolicy::from_reader(original.as_slice())?; + let tail = + DataWithCachePolicy::serialize_policy(after, &cached.entry_id, cached.data_len)?; + let offset = cached.policy_start; + let before = before.to_archived(); + let after = after.to_archived(); + // Model every interruption point in write_all, before set_len truncates a shorter policy. + for written in 0..=tail.len() { + let mut interrupted = original.clone(); + interrupted.resize(interrupted.len().max(offset + written), 0); + interrupted[offset..offset + written].copy_from_slice(&tail[..written]); + if let Ok(read) = DataWithCachePolicy::from_reader(interrupted.as_slice()) { + assert_eq!(&read.bytes[..read.data_len], b"payload"); + let policy = &read.bytes[read.policy_start..]; + assert!( + policy == OwnedArchive::as_bytes(&before) + || policy == OwnedArchive::as_bytes(&after) + ); + } + } } - Ok(len_usize) + Ok(()) + } + + #[test] + fn cache_trailer_corruption_is_rejected() -> Result<()> { + let bytes = DataWithCachePolicy::serialize(&policy("x-small")?, b"payload")?; + // The checksum covers the padding, entry ID, policy, and length; corrupt each byte in turn. + for index in b"payload".len()..bytes.len() { + let mut corrupted = bytes.clone(); + corrupted[index] ^= 1; + assert!(DataWithCachePolicy::from_reader(corrupted.as_slice()).is_err()); + } + Ok(()) } } diff --git a/crates/uv-client/src/httpcache/control.rs b/crates/uv-client/src/httpcache/control.rs index 9aebf7f44c1..61e098e5c32 100644 --- a/crates/uv-client/src/httpcache/control.rs +++ b/crates/uv-client/src/httpcache/control.rs @@ -1,62 +1,135 @@ use std::collections::HashSet; +use bitflags::bitflags; +use rkyv::primitive::{ArchivedU16, ArchivedU64}; + /// Represents values for relevant cache-control directives. /// /// This does include some directives that we don't use mostly because they are /// trivial to support. (For example, `must-understand` at time of writing is /// not used in our HTTP cache semantics. Neither is `proxy-revalidate` since /// we are not a proxy.) +/// +/// Endian-aware integers let the same packed representation serve both parsed and archived +/// directives. Flags record boolean directives and distinguish absent values from zero. #[derive( Clone, Debug, Default, Eq, PartialEq, - serde::Serialize, - serde::Deserialize, rkyv::Archive, rkyv::Deserialize, + rkyv::Portable, rkyv::Serialize, + bytecheck::CheckBytes, )] -#[rkyv(derive(Debug))] +#[rkyv(as = Self)] +#[repr(C)] pub struct CacheControl { - // directives for requests and responses - /// * - /// * - pub max_age_seconds: Option, + max_age_seconds: ArchivedU64, + max_stale_seconds: ArchivedU64, + min_fresh_seconds: ArchivedU64, + s_maxage_seconds: ArchivedU64, + // The low ten bits encode boolean directives. The remaining bits record the presence of + // optional integers, retaining both zero and u64::MAX as valid values. + flags: ArchivedU16, +} + +bitflags! { + #[derive(Debug, Clone, Copy)] + struct CacheControlFlags: u16 { + const NO_CACHE = 1 << 0; + const NO_STORE = 1 << 1; + /// * + /// * + const NO_TRANSFORM = 1 << 2; + /// + const ONLY_IF_CACHED = 1 << 3; + const MUST_REVALIDATE = 1 << 4; + /// + const MUST_UNDERSTAND = 1 << 5; + const PRIVATE = 1 << 6; + /// + const PROXY_REVALIDATE = 1 << 7; + const PUBLIC = 1 << 8; + const IMMUTABLE = 1 << 9; + const MAX_AGE_SECONDS = 1 << 10; + const MAX_STALE_SECONDS = 1 << 11; + const MIN_FRESH_SECONDS = 1 << 12; + const S_MAXAGE_SECONDS = 1 << 13; + } +} + +impl CacheControl { + fn flags(&self) -> CacheControlFlags { + CacheControlFlags::from_bits_retain(self.flags.to_native()) + } + + fn insert(&mut self, flag: CacheControlFlags) { + self.flags = (self.flags() | flag).bits().into(); + } + /// * /// * - pub no_cache: bool, + pub fn no_cache(&self) -> bool { + self.flags().contains(CacheControlFlags::NO_CACHE) + } + /// * /// * - pub no_store: bool, - /// * - /// * - pub no_transform: bool, - - // request-only directives - /// - pub max_stale_seconds: Option, - /// - pub min_fresh_seconds: Option, + pub fn no_store(&self) -> bool { + self.flags().contains(CacheControlFlags::NO_STORE) + } - // response-only directives - /// - pub only_if_cached: bool, /// - pub must_revalidate: bool, - /// - pub must_understand: bool, + pub fn must_revalidate(&self) -> bool { + self.flags().contains(CacheControlFlags::MUST_REVALIDATE) + } + /// - pub private: bool, - /// - pub proxy_revalidate: bool, + pub fn private(&self) -> bool { + self.flags().contains(CacheControlFlags::PRIVATE) + } + /// - pub public: bool, - /// - pub s_maxage_seconds: Option, + pub fn public(&self) -> bool { + self.flags().contains(CacheControlFlags::PUBLIC) + } + /// - pub immutable: bool, + pub fn immutable(&self) -> bool { + self.flags().contains(CacheControlFlags::IMMUTABLE) + } + + /// * + /// * + pub fn max_age_seconds(&self) -> Option { + self.flags() + .contains(CacheControlFlags::MAX_AGE_SECONDS) + .then_some(self.max_age_seconds.to_native()) + } + + /// + pub fn max_stale_seconds(&self) -> Option { + self.flags() + .contains(CacheControlFlags::MAX_STALE_SECONDS) + .then_some(self.max_stale_seconds.to_native()) + } + + /// + pub fn min_fresh_seconds(&self) -> Option { + self.flags() + .contains(CacheControlFlags::MIN_FRESH_SECONDS) + .then_some(self.min_fresh_seconds.to_native()) + } + + /// + pub fn s_maxage_seconds(&self) -> Option { + self.flags() + .contains(CacheControlFlags::S_MAXAGE_SECONDS) + .then_some(self.s_maxage_seconds.to_native()) + } } impl<'b, B: 'b + ?Sized + AsRef<[u8]>> FromIterator<&'b B> for CacheControl { @@ -85,12 +158,15 @@ impl FromIterator for CacheControl { match &*ccd.name { // request + response directives "max-age" => match parse_int(&ccd.value) { - None => cc.must_revalidate = true, - Some(seconds) => cc.max_age_seconds = Some(seconds), + None => cc.insert(CacheControlFlags::MUST_REVALIDATE), + Some(seconds) => { + cc.max_age_seconds = seconds.into(); + cc.insert(CacheControlFlags::MAX_AGE_SECONDS); + } }, - "no-cache" => cc.no_cache = true, - "no-store" => cc.no_store = true, - "no-transform" => cc.no_transform = true, + "no-cache" => cc.insert(CacheControlFlags::NO_CACHE), + "no-store" => cc.insert(CacheControlFlags::NO_STORE), + "no-transform" => cc.insert(CacheControlFlags::NO_TRANSFORM), // request-only directives "max-stale" => { // As per [RFC 9111 S5.2.1.2], "If no value is assigned to @@ -99,30 +175,40 @@ impl FromIterator for CacheControl { // number of seconds. // // [RFC 9111 S5.2.1.2]: https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.2 - if ccd.value.is_empty() { - cc.max_stale_seconds = Some(u64::MAX); + let seconds = if ccd.value.is_empty() { + Some(u64::MAX) } else { - match parse_int(&ccd.value) { - None => cc.must_revalidate = true, - Some(seconds) => cc.max_stale_seconds = Some(seconds), + parse_int(&ccd.value) + }; + match seconds { + None => cc.insert(CacheControlFlags::MUST_REVALIDATE), + Some(seconds) => { + cc.max_stale_seconds = seconds.into(); + cc.insert(CacheControlFlags::MAX_STALE_SECONDS); } } } "min-fresh" => match parse_int(&ccd.value) { - None => cc.must_revalidate = true, - Some(seconds) => cc.min_fresh_seconds = Some(seconds), + None => cc.insert(CacheControlFlags::MUST_REVALIDATE), + Some(seconds) => { + cc.min_fresh_seconds = seconds.into(); + cc.insert(CacheControlFlags::MIN_FRESH_SECONDS); + } }, - "only-if-cached" => cc.only_if_cached = true, - "must-revalidate" => cc.must_revalidate = true, - "must-understand" => cc.must_understand = true, - "private" => cc.private = true, - "proxy-revalidate" => cc.proxy_revalidate = true, - "public" => cc.public = true, + "only-if-cached" => cc.insert(CacheControlFlags::ONLY_IF_CACHED), + "must-revalidate" => cc.insert(CacheControlFlags::MUST_REVALIDATE), + "must-understand" => cc.insert(CacheControlFlags::MUST_UNDERSTAND), + "private" => cc.insert(CacheControlFlags::PRIVATE), + "proxy-revalidate" => cc.insert(CacheControlFlags::PROXY_REVALIDATE), + "public" => cc.insert(CacheControlFlags::PUBLIC), "s-maxage" => match parse_int(&ccd.value) { - None => cc.must_revalidate = true, - Some(seconds) => cc.s_maxage_seconds = Some(seconds), + None => cc.insert(CacheControlFlags::MUST_REVALIDATE), + Some(seconds) => { + cc.s_maxage_seconds = seconds.into(); + cc.insert(CacheControlFlags::S_MAXAGE_SECONDS); + } }, - "immutable" => cc.immutable = true, + "immutable" => cc.insert(CacheControlFlags::IMMUTABLE), _ => {} } } @@ -439,20 +525,54 @@ impl CacheControlDirective { #[cfg(test)] mod tests { + use crate::rkyvutil::OwnedArchive; + use super::*; + #[test] + fn archived_cache_control_preserves_optional_values() { + let mut values = [None, Some(0), Some(42), Some(u64::MAX)]; + for _ in 0..values.len() { + let directives = ["max-age", "max-stale", "min-fresh", "s-maxage"] + .into_iter() + .zip(values) + .filter_map(|(name, value)| value.map(|value| format!("{name}={value}"))) + .collect::>(); + let control = directives.iter().collect::(); + let bytes = + rkyv::to_bytes::(&control).expect("archive cache control"); + let archived = + OwnedArchive::::new(bytes).expect("validate cache control"); + assert_eq!(OwnedArchive::deserialize(&archived), control); + assert_eq!( + [ + archived.max_age_seconds(), + archived.max_stale_seconds(), + archived.min_fresh_seconds(), + archived.s_maxage_seconds(), + ], + values, + ); + values.rotate_left(1); + } + + let control = ["max-stale"].into_iter().collect::(); + let archived = OwnedArchive::from_unarchived(&control).expect("archive cache control"); + assert_eq!(archived.max_stale_seconds(), Some(u64::MAX)); + } + #[test] fn cache_control_token() { let cc: CacheControl = CacheControlParser::new(["no-cache"]).collect(); - assert!(cc.no_cache); - assert!(!cc.must_revalidate); + assert!(cc.no_cache()); + assert!(!cc.must_revalidate()); } #[test] fn cache_control_max_age() { let cc: CacheControl = CacheControlParser::new(["max-age=60"]).collect(); - assert_eq!(Some(60), cc.max_age_seconds); - assert!(!cc.must_revalidate); + assert_eq!(Some(60), cc.max_age_seconds()); + assert!(!cc.must_revalidate()); } // [RFC 9111 S5.2.1.1] says that client MUST NOT quote max-age, but we @@ -462,40 +582,40 @@ mod tests { #[test] fn cache_control_max_age_quoted() { let cc: CacheControl = CacheControlParser::new([r#"max-age="60""#]).collect(); - assert_eq!(Some(60), cc.max_age_seconds); - assert!(!cc.must_revalidate); + assert_eq!(Some(60), cc.max_age_seconds()); + assert!(!cc.must_revalidate()); } #[test] fn cache_control_max_age_invalid() { let cc: CacheControl = CacheControlParser::new(["max-age=6a0"]).collect(); - assert_eq!(None, cc.max_age_seconds); - assert!(cc.must_revalidate); + assert_eq!(None, cc.max_age_seconds()); + assert!(cc.must_revalidate()); } #[test] fn cache_control_immutable() { let cc: CacheControl = CacheControlParser::new(["max-age=31536000, immutable"]).collect(); - assert_eq!(Some(31_536_000), cc.max_age_seconds); - assert!(cc.immutable); - assert!(!cc.must_revalidate); + assert_eq!(Some(31_536_000), cc.max_age_seconds()); + assert!(cc.immutable()); + assert!(!cc.must_revalidate()); } #[test] fn cache_control_unrecognized() { let cc: CacheControl = CacheControlParser::new(["lion,max-age=60,zebra"]).collect(); - assert_eq!(Some(60), cc.max_age_seconds); + assert_eq!(Some(60), cc.max_age_seconds()); } #[test] fn cache_control_invalid_squashes_remainder() { let cc: CacheControl = CacheControlParser::new(["no-cache,\x00,max-age=60"]).collect(); // The invalid data doesn't impact things before it. - assert!(cc.no_cache); + assert!(cc.no_cache()); // The invalid data precludes parsing anything after. - assert_eq!(None, cc.max_age_seconds); + assert_eq!(None, cc.max_age_seconds()); // The invalid contents should force revalidation. - assert!(cc.must_revalidate); + assert!(cc.must_revalidate()); } #[test] @@ -503,13 +623,13 @@ mod tests { let cc: CacheControl = CacheControlParser::new(["no-cache,\x00,max-age=60", "max-stale=30"]).collect(); // The invalid data doesn't impact things before it. - assert!(cc.no_cache); + assert!(cc.no_cache()); // The invalid data precludes parsing anything after // in the same header value, but not in other // header values. - assert_eq!(Some(30), cc.max_stale_seconds); + assert_eq!(Some(30), cc.max_stale_seconds()); // The invalid contents should force revalidation. - assert!(cc.must_revalidate); + assert!(cc.must_revalidate()); } #[test] diff --git a/crates/uv-client/src/httpcache/mod.rs b/crates/uv-client/src/httpcache/mod.rs index da700cbdc90..e65a6a7d42a 100644 --- a/crates/uv-client/src/httpcache/mod.rs +++ b/crates/uv-client/src/httpcache/mod.rs @@ -137,7 +137,9 @@ actually need to make an HTTP request). use std::time::{Duration, SystemTime}; +use bitflags::bitflags; use http::header::HeaderValue; +use rkyv::primitive::ArchivedU64; use crate::rkyvutil::OwnedArchive; @@ -362,7 +364,7 @@ impl ArchivedCachePolicy { } // "the stored response does not contain the no-cache directive, unless // it is successfully validated, and..." - if self.response.headers.cc.no_cache { + if self.response.headers.cc.no_cache() { self.set_revalidation_headers(request); return BeforeRequest::Stale(self.new_cache_policy_builder(request)); } @@ -453,21 +455,20 @@ impl ArchivedCachePolicy { // we check `Last-Modified`. // // [RFC 9111 S4.3.4]: https://www.rfc-editor.org/rfc/rfc9111.html#section-4.3.4 - if let Some(old_last_modified) = self.response.headers.last_modified_unix_timestamp.as_ref() + let old_last_modified = self.response.headers.timing.last_modified_unix_timestamp(); + let new_last_modified = new_policy + .response + .headers + .timing + .last_modified_unix_timestamp(); + if let (Some(old_last_modified), Some(new_last_modified)) = + (old_last_modified, new_last_modified) + && old_last_modified == new_last_modified { - if let Some(new_last_modified) = new_policy - .response - .headers - .last_modified_unix_timestamp - .as_ref() - { - if old_last_modified == new_last_modified { - tracing::trace!( - "Resource is not modified because modified times ({new_last_modified:?}) match", - ); - return false; - } - } + tracing::trace!( + "Resource is not modified because modified times ({new_last_modified:?}) match", + ); + return false; } // As per [RFC 9111 S4.3.4], if we have no validators anywhere, then // we can just rely on the HTTP 304 status code and reuse the cached @@ -476,12 +477,8 @@ impl ArchivedCachePolicy { // [RFC 9111 S4.3.4]: https://www.rfc-editor.org/rfc/rfc9111.html#section-4.3.4 if self.response.headers.etag.is_none() && new_policy.response.headers.etag.is_none() - && self.response.headers.last_modified_unix_timestamp.is_none() - && new_policy - .response - .headers - .last_modified_unix_timestamp - .is_none() + && old_last_modified.is_none() + && new_last_modified.is_none() { tracing::trace!( "Resource is not modified because there are no etags or last modified \ @@ -535,18 +532,14 @@ impl ArchivedCachePolicy { // // [RFC 9110 S13.1.3]: https://www.rfc-editor.org/rfc/rfc9110#section-13.1.3 // [RFC 9111 S4.3.1]: https://www.rfc-editor.org/rfc/rfc9111.html#section-4.3.1 - if !request.headers().contains_key("if-modified-since") { - if let Some(&last_modified_unix_timestamp) = - self.response.headers.last_modified_unix_timestamp.as_ref() - { - if let Some(last_modified) = - unix_timestamp_to_header(last_modified_unix_timestamp.into()) - { - request - .headers_mut() - .insert("if-modified-since", last_modified); - } - } + if !request.headers().contains_key("if-modified-since") + && let Some(last_modified_unix_timestamp) = + self.response.headers.timing.last_modified_unix_timestamp() + && let Some(last_modified) = unix_timestamp_to_header(last_modified_unix_timestamp) + { + request + .headers_mut() + .insert("if-modified-since", last_modified); } } @@ -614,7 +607,7 @@ impl ArchivedCachePolicy { // // (This is from RFC 9111 S5.2.1.5, and doesn't seem to be mentioned in // S3.) - if self.request.headers.cc.no_store { + if self.request.headers.cc.no_store() { tracing::trace!( "Response from {} is not storable because its request has \ a 'no-store' cache-control directive", @@ -623,7 +616,7 @@ impl ArchivedCachePolicy { return false; } // "the no-store cache directive is not present in the response" - if self.response.headers.cc.no_store { + if self.response.headers.cc.no_store() { tracing::trace!( "Response from {} is not storable because it has \ a 'no-store' cache-control directive", @@ -640,7 +633,7 @@ impl ArchivedCachePolicy { // caching all of a private HTTP response in a shared cache only after // removing some subset of the response's headers that are deemed // private). - if self.response.headers.cc.private { + if self.response.headers.cc.private() { tracing::trace!( "Response from {} is not storable because this is a shared \ cache and has a 'private' cache-control directive", @@ -666,7 +659,7 @@ impl ArchivedCachePolicy { // "the response contains at least one of the following ..." // // "a public response directive" - if self.response.headers.cc.public { + if self.response.headers.cc.public() { tracing::trace!( "Response from {} is storable because it has \ a 'public' cache-control directive", @@ -675,7 +668,7 @@ impl ArchivedCachePolicy { return true; } // "a private response directive, if the cache is not shared" - if !self.config.shared && self.response.headers.cc.private { + if !self.config.shared && self.response.headers.cc.private() { tracing::trace!( "Response from {} is storable because this is a shared cache \ and has a 'private' cache-control directive", @@ -684,7 +677,13 @@ impl ArchivedCachePolicy { return true; } // "an Expires header field" - if self.response.headers.expires_unix_timestamp.is_some() { + if self + .response + .headers + .timing + .expires_unix_timestamp() + .is_some() + { tracing::trace!( "Response from {} is storable because it has an \ 'Expires' header set", @@ -693,7 +692,7 @@ impl ArchivedCachePolicy { return true; } // "a max-age response directive" - if self.response.headers.cc.max_age_seconds.is_some() { + if self.response.headers.cc.max_age_seconds().is_some() { tracing::trace!( "Response from {} is storable because it has an \ 'max-age' cache-control directive", @@ -702,7 +701,7 @@ impl ArchivedCachePolicy { return true; } // "if the cache is shared: an s-maxage response directive" - if self.config.shared && self.response.headers.cc.s_maxage_seconds.is_some() { + if self.config.shared && self.response.headers.cc.s_maxage_seconds().is_some() { tracing::trace!( "Response from {} is storable because this is a shared cache \ and has a 's-maxage' cache-control directive", @@ -737,9 +736,9 @@ impl ArchivedCachePolicy { /// /// [RFC 9111 S3.5]: https://www.rfc-editor.org/rfc/rfc9111.html#section-3.5 fn allows_authorization_storage(&self) -> bool { - self.response.headers.cc.must_revalidate - || self.response.headers.cc.public - || self.response.headers.cc.s_maxage_seconds.is_some() + self.response.headers.cc.must_revalidate() + || self.response.headers.cc.public() + || self.response.headers.cc.s_maxage_seconds().is_some() } /// Returns true if the response is considered fresh as per [RFC 9111 @@ -764,7 +763,7 @@ impl ArchivedCachePolicy { // request are ignored. // // [RFC 8246]: https://httpwg.org/specs/rfc8246.html - if !self.response.headers.cc.immutable { + if !self.response.headers.cc.immutable() { let reqcc = request .headers() .get_all("cache-control") @@ -775,7 +774,7 @@ impl ArchivedCachePolicy { // respect that. // // [RFC 9111 S5.2.1.4]: https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.4 - if reqcc.no_cache { + if reqcc.no_cache() { tracing::trace!( "Request to {} does not have a fresh cache entry because \ it has a 'no-cache' cache-control directive", @@ -788,7 +787,7 @@ impl ArchivedCachePolicy { // as per [RFC 9111 S5.2.1.1]. // // [RFC 9111 S5.2.1.1]: https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.1 - if let Some(&max_age) = reqcc.max_age_seconds.as_ref() { + if let Some(max_age) = reqcc.max_age_seconds() { if age > max_age { tracing::trace!( "Request to {} does not have a fresh cache entry because \ @@ -807,7 +806,7 @@ impl ArchivedCachePolicy { // the threshold provided, as per [RFC 9111 S5.2.1.3]. // // [RFC 9111 S5.2.1.3]: https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.3 - if let Some(&min_fresh) = reqcc.min_fresh_seconds.as_ref() { + if let Some(min_fresh) = reqcc.min_fresh_seconds() { let time_to_live = freshness_lifetime.saturating_sub(unix_timestamp(now)); if time_to_live < min_fresh { tracing::trace!( @@ -859,7 +858,7 @@ impl ArchivedCachePolicy { // must-revalidate takes precedent. // // [RFC 9111 S5.2.2.2]: https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.2 - if self.response.headers.cc.must_revalidate { + if self.response.headers.cc.must_revalidate() { tracing::trace!( "Request to {} has a cached response that does not \ permit staleness because the response has a 'must-revalidate' \ @@ -868,7 +867,7 @@ impl ArchivedCachePolicy { ); return false; } - if let Some(&max_stale) = self.request.headers.cc.max_stale_seconds.as_ref() { + if let Some(max_stale) = self.request.headers.cc.max_stale_seconds() { // As per [RFC 9111 S5.2.1.2], if the client has max-stale set, // then stale responses are allowed, but only if they are stale // within a given threshold. @@ -878,7 +877,7 @@ impl ArchivedCachePolicy { .age(now) .as_secs() .saturating_sub(self.freshness_lifetime().as_secs()); - if stale_amount <= max_stale.into() { + if stale_amount <= max_stale { tracing::trace!( "Request to {} has a cached response that allows staleness \ in this case because the stale amount is {} seconds and the \ @@ -938,8 +937,8 @@ impl ArchivedCachePolicy { /// [RFC 9111 S4.2.1]: https://www.rfc-editor.org/rfc/rfc9111.html#section-4.2.1 fn freshness_lifetime(&self) -> Duration { if self.config.shared { - if let Some(&s_maxage) = self.response.headers.cc.s_maxage_seconds.as_ref() { - let duration = Duration::from_secs(s_maxage.into()); + if let Some(s_maxage) = self.response.headers.cc.s_maxage_seconds() { + let duration = Duration::from_secs(s_maxage); tracing::trace!( "Freshness lifetime found via shared \ cache-control max age setting: {duration:?}" @@ -947,20 +946,25 @@ impl ArchivedCachePolicy { return duration; } } - if let Some(&max_age) = self.response.headers.cc.max_age_seconds.as_ref() { - let duration = Duration::from_secs(max_age.into()); + if let Some(max_age) = self.response.headers.cc.max_age_seconds() { + let duration = Duration::from_secs(max_age); tracing::trace!( "Freshness lifetime found via cache-control max age setting: {duration:?}" ); return duration; } - if let Some(&expires) = self.response.headers.expires_unix_timestamp.as_ref() { - let duration = - Duration::from_secs(u64::from(expires).saturating_sub(self.response.header_date())); + if let Some(expires) = self.response.headers.timing.expires_unix_timestamp() { + let duration = Duration::from_secs(expires.saturating_sub(self.response.header_date())); tracing::trace!("Freshness lifetime found via expires header: {duration:?}"); return duration; } - if self.response.headers.last_modified_unix_timestamp.is_some() { + if self + .response + .headers + .timing + .last_modified_unix_timestamp() + .is_some() + { // We previously computed this heuristic freshness lifetime by // looking at the difference between the last modified header and // the response's date header. We then asserted that the cached @@ -1007,7 +1011,6 @@ impl ArchivedCachePolicy { /// This dictates what the caller should do next by indicating whether the /// cached response is stale or not. #[derive(Debug)] -#[expect(clippy::large_enum_variant)] pub(crate) enum BeforeRequest { /// The cached response is still fresh, and the caller may return the /// cached response without issuing an HTTP requests. @@ -1121,11 +1124,7 @@ impl ArchivedResponse { /// /// [RFC 9111 S4.2.3]: https://www.rfc-editor.org/rfc/rfc9111.html#section-4.2.3 fn header_age(&self) -> u64 { - self.headers - .age_seconds - .as_ref() - .map(u64::from) - .unwrap_or(0) + self.headers.timing.age_seconds().unwrap_or(0) } /// Returns the "date" header value on this response, with a fallback to @@ -1134,9 +1133,9 @@ impl ArchivedResponse { /// [RFC 9110 S6.6.1]: https://www.rfc-editor.org/rfc/rfc9110#section-6.6.1 fn header_date(&self) -> u64 { self.headers - .date_unix_timestamp - .unwrap_or(self.unix_timestamp) - .into() + .timing + .date_unix_timestamp() + .unwrap_or(self.unix_timestamp.into()) } /// Returns true when this response has a status code that is considered @@ -1163,12 +1162,84 @@ impl<'a> From<&'a reqwest::Response> for Response { struct ResponseHeaders { /// The directives from the `Cache-Control` header. cc: CacheControl, + /// The optional age and timestamp headers. + timing: ResponseTiming, + /// The "entity tag" from the response as per [RFC 9110 S8.8.3], which is + /// used in revalidation requests. + /// + /// [RFC 9110 S8.8.3]: https://www.rfc-editor.org/rfc/rfc9110#section-8.8.3 + etag: Option, +} + +/// Numeric response headers with a shared presence mask instead of four `Option` tags. +#[derive( + Debug, rkyv::Archive, rkyv::Deserialize, rkyv::Portable, rkyv::Serialize, bytecheck::CheckBytes, +)] +#[rkyv(as = Self)] +#[repr(C)] +struct ResponseTiming { + age_seconds: ArchivedU64, + date_unix_timestamp: ArchivedU64, + expires_unix_timestamp: ArchivedU64, + last_modified_unix_timestamp: ArchivedU64, + presence: u8, +} + +bitflags! { + #[derive(Debug, Clone, Copy)] + struct ResponseTimingFlags: u8 { + const AGE_SECONDS = 1 << 0; + const DATE_UNIX_TIMESTAMP = 1 << 1; + const EXPIRES_UNIX_TIMESTAMP = 1 << 2; + const LAST_MODIFIED_UNIX_TIMESTAMP = 1 << 3; + } +} + +impl ResponseTiming { + fn new( + age_seconds: Option, + date_unix_timestamp: Option, + expires_unix_timestamp: Option, + last_modified_unix_timestamp: Option, + ) -> Self { + let mut presence = ResponseTimingFlags::empty(); + presence.set(ResponseTimingFlags::AGE_SECONDS, age_seconds.is_some()); + presence.set( + ResponseTimingFlags::DATE_UNIX_TIMESTAMP, + date_unix_timestamp.is_some(), + ); + presence.set( + ResponseTimingFlags::EXPIRES_UNIX_TIMESTAMP, + expires_unix_timestamp.is_some(), + ); + presence.set( + ResponseTimingFlags::LAST_MODIFIED_UNIX_TIMESTAMP, + last_modified_unix_timestamp.is_some(), + ); + Self { + age_seconds: age_seconds.unwrap_or_default().into(), + date_unix_timestamp: date_unix_timestamp.unwrap_or_default().into(), + expires_unix_timestamp: expires_unix_timestamp.unwrap_or_default().into(), + last_modified_unix_timestamp: last_modified_unix_timestamp.unwrap_or_default().into(), + presence: presence.bits(), + } + } + + fn presence(&self) -> ResponseTimingFlags { + ResponseTimingFlags::from_bits_retain(self.presence) + } + /// The value of the `Age` header corresponding to `age_value` as defined /// in [RFC 9111 S4.2.3]. If the `Age` header is not present, it should be /// interpreted at `0`. /// /// [RFC 9111 S4.2.3]: https://www.rfc-editor.org/rfc/rfc9111.html#name-calculating-age - age_seconds: Option, + fn age_seconds(&self) -> Option { + self.presence() + .contains(ResponseTimingFlags::AGE_SECONDS) + .then_some(self.age_seconds.to_native()) + } + /// This is `date_value` from [RFC 9111 S4.2.3], which says it corresponds /// to the `Date` header on a response as defined in [RFC 7231 S7.1.1.2]. /// In RFC 7231, if the `Date` header is not present, then the recipient @@ -1177,7 +1248,12 @@ struct ResponseHeaders { /// /// [RFC 9111 S4.2.3]: https://www.rfc-editor.org/rfc/rfc9111.html#name-calculating-age /// [RFC 7231 S7.1.1.2]: https://httpwg.org/specs/rfc7231.html#header.date - date_unix_timestamp: Option, + fn date_unix_timestamp(&self) -> Option { + self.presence() + .contains(ResponseTimingFlags::DATE_UNIX_TIMESTAMP) + .then_some(self.date_unix_timestamp.to_native()) + } + /// This is from the `Expires` header as per [RFC 9111 S5.3]. Note that this /// is overridden by the presence of either the `max-age` or `s-maxage` cache /// control directives. @@ -1187,40 +1263,42 @@ struct ResponseHeaders { /// past, which implies the response has already expired.) /// /// [RFC 9111 S5.3]: https://www.rfc-editor.org/rfc/rfc9111.html#section-5.3 - expires_unix_timestamp: Option, + fn expires_unix_timestamp(&self) -> Option { + self.presence() + .contains(ResponseTimingFlags::EXPIRES_UNIX_TIMESTAMP) + .then_some(self.expires_unix_timestamp.to_native()) + } + /// The date from the `Last-Modified` header as specified in [RFC 9110 S8.8.2] /// in RFC 2822 format. It's used to compute a heuristic freshness lifetime for /// the response when other indicators are missing as per [RFC 9111 S4.2.2]. /// /// [RFC 9110 S8.8.2]: https://www.rfc-editor.org/rfc/rfc9110#section-8.8.2 /// [RFC 9111 S4.2.2]: https://www.rfc-editor.org/rfc/rfc9111.html#section-4.2.2 - last_modified_unix_timestamp: Option, - /// The "entity tag" from the response as per [RFC 9110 S8.8.3], which is - /// used in revalidation requests. - /// - /// [RFC 9110 S8.8.3]: https://www.rfc-editor.org/rfc/rfc9110#section-8.8.3 - etag: Option, + fn last_modified_unix_timestamp(&self) -> Option { + self.presence() + .contains(ResponseTimingFlags::LAST_MODIFIED_UNIX_TIMESTAMP) + .then_some(self.last_modified_unix_timestamp.to_native()) + } } impl<'a> From<&'a http::HeaderMap> for ResponseHeaders { fn from(from: &'a http::HeaderMap) -> Self { Self { cc: from.get_all("cache-control").iter().collect(), - age_seconds: from - .get("age") - .and_then(|header| parse_seconds(header.as_bytes())), - date_unix_timestamp: from - .get("date") - .and_then(|header| header.to_str().ok()) - .and_then(rfc2822_to_unix_timestamp), - expires_unix_timestamp: from - .get("expires") - .and_then(|header| header.to_str().ok()) - .and_then(rfc2822_to_unix_timestamp), - last_modified_unix_timestamp: from - .get("last-modified") - .and_then(|header| header.to_str().ok()) - .and_then(rfc2822_to_unix_timestamp), + timing: ResponseTiming::new( + from.get("age") + .and_then(|header| parse_seconds(header.as_bytes())), + from.get("date") + .and_then(|header| header.to_str().ok()) + .and_then(rfc2822_to_unix_timestamp), + from.get("expires") + .and_then(|header| header.to_str().ok()) + .and_then(rfc2822_to_unix_timestamp), + from.get("last-modified") + .and_then(|header| header.to_str().ok()) + .and_then(rfc2822_to_unix_timestamp), + ), etag: from .get("etag") .map(|header| ETag::parse(header.as_bytes())), @@ -1435,6 +1513,28 @@ mod tests { .to_archived() } + #[test] + fn archived_response_timing_preserves_optional_values() { + let mut values = [None, Some(0), Some(42), Some(u64::MAX)]; + for _ in 0..values.len() { + let timing = ResponseTiming::new(values[0], values[1], values[2], values[3]); + let bytes = + rkyv::to_bytes::(&timing).expect("archive response timing"); + let archived = + OwnedArchive::::new(bytes).expect("validate response timing"); + assert_eq!( + [ + archived.age_seconds(), + archived.date_unix_timestamp(), + archived.expires_unix_timestamp(), + archived.last_modified_unix_timestamp(), + ], + values, + ); + values.rotate_left(1); + } + } + /// A server or proxy is free to send an arbitrarily large `Age` header, up /// to `u64::MAX`. Combined with the resident age of a cached response, the /// RFC 9111 S4.2.3 age computation must not overflow. Every term uses diff --git a/crates/uv-client/src/registry_client.rs b/crates/uv-client/src/registry_client.rs index 29bfe54e86d..33b111a4dc8 100644 --- a/crates/uv-client/src/registry_client.rs +++ b/crates/uv-client/src/registry_client.rs @@ -1103,7 +1103,7 @@ impl RegistryClient { // Acquire an advisory lock, to guard against concurrent writes. #[cfg(windows)] let _lock = { - let lock_entry = cache_entry.with_file(format!("{}.lock", filename.stem())); + let lock_entry = cache_entry.with_file(format!("{}.lock", filename.cache_key())); lock_entry.lock().await.map_err(ErrorKind::CacheLock)? }; @@ -1194,7 +1194,7 @@ impl RegistryClient { // Acquire an advisory lock, to guard against concurrent writes. #[cfg(windows)] let _lock = { - let lock_entry = cache_entry.with_file(format!("{}.lock", filename.stem())); + let lock_entry = cache_entry.with_file(format!("{}.lock", filename.cache_key())); lock_entry.lock().await.map_err(ErrorKind::CacheLock)? }; diff --git a/crates/uv-client/tests/it/cached_client.rs b/crates/uv-client/tests/it/cached_client.rs index 34b2ad46972..0d85ad42c97 100644 --- a/crates/uv-client/tests/it/cached_client.rs +++ b/crates/uv-client/tests/it/cached_client.rs @@ -1,8 +1,10 @@ use std::cell::RefCell; +use std::io::{Seek, SeekFrom, Write}; use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, UNIX_EPOCH}; use std::{assert_matches, io}; -use anyhow::Result; +use anyhow::{Result, anyhow}; use reqwest::Response; use wiremock::matchers::{any, header, method, path}; use wiremock::{Mock, MockServer, Request, ResponseTemplate}; @@ -14,10 +16,370 @@ use uv_client::{ }; #[test] -fn reject_overflowing_cache_policy_length() { - let error = DataWithCachePolicy::from_reader(&[u8::MAX; 8][..]).unwrap_err(); +fn reject_invalid_cache_lengths() { + for bytes in [&[u8::MAX; 8][..], &[u8::MAX; 32][..]] { + let error = DataWithCachePolicy::from_reader(bytes).unwrap_err(); + assert_matches!(error.kind(), ErrorKind::ArchiveRead(_)); + } +} + +/// Fetch a text payload through the persistent HTTP cache. +async fn cached_text( + client: &CachedClient, + server: &MockServer, + entry: &CacheEntry, + control: CacheControl, +) -> Result { + let url = format!("{}/metadata", server.uri()).parse()?; + let request = client.uncached().for_host(&url).get(url.as_str()).build()?; + client + .get_serde_with_retry(request, entry, control, async |response, _| { + response.text().await + }) + .await + .map_err(|err| anyhow!("{err:?}")) +} + +#[tokio::test] +async fn revalidation_updates_only_policy() -> Result<()> { + let server = MockServer::start().await; + let client = CachedClient::new(BaseClientBuilder::default().build()?); + let temp_dir = tempfile::tempdir()?; + let entry = CacheEntry::new(temp_dir.path(), "response"); + let body = "cached metadata".repeat(100_000); + + Mock::given(method("GET")) + .and(path("/metadata")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("cache-control", "public, max-age=0") + .insert_header("etag", "\"cached\"") + .set_body_string(&body), + ) + .expect(1) + .mount(&server) + .await; + assert_eq!( + cached_text(&client, &server, &entry, CacheControl::None).await?, + body + ); + server.verify().await; + server.reset().await; + + let original = fs_err::read(&entry)?; + let data_len = DataWithCachePolicy::from_reader(original.as_slice())? + .into_data() + .len(); + // A hardlink observes in-place changes, but would retain the old file after a rename. + let alias = temp_dir.path().join("alias"); + fs_err::hard_link(entry.path(), &alias)?; + let modified = UNIX_EPOCH + Duration::from_hours(24); + fs_err::OpenOptions::new() + .write(true) + .open(entry.path())? + .set_modified(modified)?; + + Mock::given(method("GET")) + .and(path("/metadata")) + .and(header("if-none-match", "\"cached\"")) + .respond_with( + ResponseTemplate::new(304) + .insert_header("etag", "\"cached\"") + .insert_header("cache-control", "public, max-age=3600"), + ) + .expect(1) + .mount(&server) + .await; + assert_eq!( + cached_text(&client, &server, &entry, CacheControl::MustRevalidate).await?, + body + ); + let refreshed = fs_err::read(&entry)?; + assert_eq!( + refreshed[..data_len.next_multiple_of(16) + 16], + original[..data_len.next_multiple_of(16) + 16] + ); + assert_ne!(refreshed, original); + assert_eq!(fs_err::read(alias)?, refreshed); + assert!(refreshed.len() - data_len < 1024); + assert!(fs_err::metadata(&entry)?.modified()? > modified); + server.verify().await; + server.reset().await; + + // A new client must honor the persisted freshness without another HTTP request. + let client = CachedClient::new(BaseClientBuilder::default().build()?); + assert_eq!( + cached_text(&client, &server, &entry, CacheControl::None).await?, + body + ); + assert_eq!( + cached_text(&client, &server, &entry, CacheControl::AllowStale).await?, + body + ); + assert!( + server + .received_requests() + .await + .expect("request recording is enabled") + .is_empty() + ); + Ok(()) +} + +#[tokio::test] +async fn revalidation_of_readonly_entry() -> Result<()> { + let server = MockServer::start().await; + let client = CachedClient::new(BaseClientBuilder::default().build()?); + let temp_dir = tempfile::tempdir()?; + let entry = CacheEntry::new(temp_dir.path(), "response"); + Mock::given(method("GET")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("cache-control", "public, max-age=0") + .insert_header("etag", "\"cached\"") + .set_body_string("cached"), + ) + .expect(1) + .mount(&server) + .await; + assert_eq!( + cached_text(&client, &server, &entry, CacheControl::None).await?, + "cached" + ); + server.verify().await; + server.reset().await; + + let original = fs_err::read(&entry)?; + let permissions = fs_err::metadata(&entry)?.permissions(); + let mut readonly = permissions.clone(); + readonly.set_readonly(true); + fs_err::set_permissions(&entry, readonly)?; + // Privileged users can write despite the read-only permissions. + if fs_err::OpenOptions::new() + .write(true) + .open(entry.path()) + .is_ok() + { + fs_err::set_permissions(&entry, permissions)?; + return Ok(()); + } + + Mock::given(method("GET")) + .and(header("if-none-match", "\"cached\"")) + .respond_with( + ResponseTemplate::new(304) + .insert_header("etag", "\"cached\"") + .insert_header("cache-control", "public, max-age=3600"), + ) + .expect(1) + .mount(&server) + .await; + let result = cached_text(&client, &server, &entry, CacheControl::MustRevalidate).await; + fs_err::set_permissions(&entry, permissions)?; + assert_eq!(result?, "cached"); + assert_eq!(fs_err::read(&entry)?, original); + server.verify().await; + Ok(()) +} + +#[tokio::test] +async fn overlapping_policy_updates_are_refetched() -> Result<()> { + let server = MockServer::start().await; + let client = CachedClient::new(BaseClientBuilder::default().build()?); + let temp_dir = tempfile::tempdir()?; + let entry = CacheEntry::new(temp_dir.path(), "response"); + Mock::given(method("GET")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("cache-control", "public, max-age=3600") + .insert_header("etag", "\"cached\"") + .insert_header("vary", format!("x-{}", "large".repeat(100))) + .set_body_string("cached"), + ) + .expect(1) + .mount(&server) + .await; + assert_eq!( + cached_text(&client, &server, &entry, CacheControl::None).await?, + "cached" + ); + let large = fs_err::read(&entry)?; + let data_len = DataWithCachePolicy::from_reader(large.as_slice())? + .into_data() + .len(); + server.verify().await; + server.reset().await; + + Mock::given(method("GET")) + .and(header("if-none-match", "\"cached\"")) + .respond_with( + ResponseTemplate::new(304) + .insert_header("etag", "\"cached\"") + .insert_header("cache-control", "public, max-age=7200"), + ) + .expect(2) + .mount(&server) + .await; + assert_eq!( + cached_text(&client, &server, &entry, CacheControl::MustRevalidate).await?, + "cached" + ); + assert!(fs_err::metadata(&entry)?.len() < large.len() as u64); + + // Model a writer paused between writing a longer policy and setting the file length. + let mut writer = fs_err::OpenOptions::new().write(true).open(entry.path())?; + let policy_start = data_len.next_multiple_of(16) + 16; + writer.seek(SeekFrom::Start(policy_start as u64))?; + writer.write_all(&large[policy_start..])?; + // A second writer completes a shorter refresh before the first writer resumes. + assert_eq!( + cached_text(&client, &server, &entry, CacheControl::MustRevalidate).await?, + "cached" + ); + assert!(fs_err::metadata(&entry)?.len() < large.len() as u64); + writer.set_len(large.len() as u64)?; + drop(writer); + let raced = fs_err::read(&entry)?; + assert_eq!(raced[..policy_start], large[..policy_start]); + let error = DataWithCachePolicy::from_reader(raced.as_slice()).unwrap_err(); assert_matches!(error.kind(), ErrorKind::ArchiveRead(_)); + server.verify().await; + server.reset().await; + + // An invalid policy must trigger a full fetch without sending its cached validator. + Mock::given(method("GET")) + .respond_with(|request: &Request| { + assert!(!request.headers.contains_key("if-none-match")); + ResponseTemplate::new(200) + .insert_header("cache-control", "public, max-age=3600") + .set_body_string("replacement") + }) + .expect(1) + .mount(&server) + .await; + assert_eq!( + cached_text(&client, &server, &entry, CacheControl::None).await?, + "replacement" + ); + server.verify().await; + server.reset().await; + assert_eq!( + cached_text(&client, &server, &entry, CacheControl::None).await?, + "replacement" + ); + assert!( + server + .received_requests() + .await + .expect("request recording is enabled") + .is_empty() + ); + Ok(()) +} + +#[tokio::test] +async fn delayed_revalidation_cannot_modify_replaced_payload() -> Result<()> { + let server = MockServer::start().await; + let client = CachedClient::new(BaseClientBuilder::default().build()?); + let temp_dir = tempfile::tempdir()?; + let entry = CacheEntry::new(temp_dir.path(), "response"); + let newer_entry = CacheEntry::new(temp_dir.path(), "newer"); + let requests = AtomicUsize::new(0); + Mock::given(method("GET")) + .respond_with(move |_: &Request| { + let body = if requests.fetch_add(1, Ordering::Relaxed) == 0 { + "first" + } else { + "other" + }; + ResponseTemplate::new(200) + .insert_header("cache-control", "public, max-age=3600") + .insert_header("etag", format!("\"{body}\"")) + .set_body_string(body) + }) + .expect(2) + .mount(&server) + .await; + assert_eq!( + cached_text(&client, &server, &entry, CacheControl::None).await?, + "first" + ); + assert_eq!( + cached_text(&client, &server, &newer_entry, CacheControl::None).await?, + "other" + ); + let newer = fs_err::read(&newer_entry)?; + server.verify().await; + server.reset().await; + + let path = entry.path().to_path_buf(); + let replacement = newer.clone(); + Mock::given(method("GET")) + .and(header("if-none-match", "\"first\"")) + .respond_with(move |_: &Request| { + // Publish a newer 200 while the original entry is awaiting revalidation. + uv_fs::write_atomic_sync(&path, &replacement).expect("replace cached response"); + ResponseTemplate::new(304) + .insert_header("etag", "\"first\"") + .insert_header("cache-control", "public, max-age=7200") + }) + .expect(1) + .mount(&server) + .await; + assert_eq!( + cached_text(&client, &server, &entry, CacheControl::MustRevalidate).await?, + "first" + ); + assert_eq!(fs_err::read(&entry)?, newer); + assert_eq!( + cached_text(&client, &server, &entry, CacheControl::None).await?, + "other" + ); + server.verify().await; + Ok(()) +} + +#[tokio::test] +async fn torn_policy_is_refetched() -> Result<()> { + let server = MockServer::start().await; + let client = CachedClient::new(BaseClientBuilder::default().build()?); + let temp_dir = tempfile::tempdir()?; + let entry = CacheEntry::new(temp_dir.path(), "response"); + Mock::given(method("GET")) + .respond_with(|request: &Request| { + assert!(!request.headers.contains_key("if-none-match")); + ResponseTemplate::new(200) + .insert_header("cache-control", "public, max-age=3600") + .set_body_string("cached") + }) + .expect(3) + .mount(&server) + .await; + assert_eq!( + cached_text(&client, &server, &entry, CacheControl::None).await?, + "cached" + ); + let original = fs_err::read(&entry)?; + let data_len = DataWithCachePolicy::from_reader(original.as_slice())? + .into_data() + .len(); + let mut torn = original.clone(); + torn[data_len.next_multiple_of(16) + 16] ^= 1; + fs_err::write(&entry, &torn)?; + assert_eq!( + cached_text(&client, &server, &entry, CacheControl::None).await?, + "cached" + ); + + // The allow-stale path also validates integrity before returning the payload. + fs_err::write(&entry, &torn)?; + assert_eq!( + cached_text(&client, &server, &entry, CacheControl::AllowStale).await?, + "cached" + ); + server.verify().await; + Ok(()) } /// Exercise the shared budget through both cached and forced-refresh requests. diff --git a/crates/uv-distribution/Cargo.toml b/crates/uv-distribution/Cargo.toml index 9944bb33e53..c775b87f363 100644 --- a/crates/uv-distribution/Cargo.toml +++ b/crates/uv-distribution/Cargo.toml @@ -25,7 +25,7 @@ uv-distribution-filename = { workspace = true } uv-distribution-types = { workspace = true } uv-errors = { workspace = true } uv-extract = { workspace = true } -uv-fastid = { workspace = true } +uv-fastid = { workspace = true, features = ["serde"] } uv-flags = { workspace = true } uv-fs = { workspace = true, features = ["tokio"] } uv-git = { workspace = true } diff --git a/crates/uv-distribution/src/archive.rs b/crates/uv-distribution/src/archive.rs index 9e2ef3d2bee..ed69fd06bd6 100644 --- a/crates/uv-distribution/src/archive.rs +++ b/crates/uv-distribution/src/archive.rs @@ -1,11 +1,10 @@ -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::Deserialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Archive { /// The unique ID of the entry in the wheel's archive bucket. pub id: ArchiveId, @@ -16,27 +15,9 @@ pub struct Archive { /// The version of the archive bucket. pub version: u8, /// The size of the downloaded archive. - #[serde(default)] 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( @@ -73,77 +54,27 @@ mod tests { use super::*; #[test] - fn deserialize_legacy_archive() { - #[derive(serde::Serialize)] - struct LegacyArchive { - id: ArchiveId, - hashes: HashDigests, - filename: WheelFilename, - version: u8, - } - - let legacy = LegacyArchive { - 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, - }; - let bytes = rmp_serde::to_vec(&legacy).expect("serialize legacy archive"); - let archive: Archive = rmp_serde::from_slice(&bytes).expect("deserialize legacy archive"); - - 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, + fn round_trip_current_archive() { + for size in [None, Some(42)] { + let archive = Archive::new( + ArchiveId::default(), + HashDigests::from( + "sha256:0000000000000000000000000000000000000000000000000000000000000000" + .parse::() + .expect("valid SHA-256 digest"), + ), + WheelFilename::from_str("iniconfig-2.0.0-py3-none-any.whl") + .expect("valid wheel filename"), + size, + ); + let bytes = rmp_serde::to_vec(&archive).expect("serialize archive"); + let parsed: Archive = rmp_serde::from_slice(&bytes).expect("deserialize archive"); + + assert_eq!(parsed.id, archive.id); + assert_eq!(parsed.hashes, archive.hashes); + assert_eq!(parsed.filename, archive.filename); + assert_eq!(parsed.version, archive.version); + assert_eq!(parsed.size, archive.size); } - - 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/distribution_database.rs b/crates/uv-distribution/src/distribution_database.rs index 9de85d28407..fcff8826cde 100644 --- a/crates/uv-distribution/src/distribution_database.rs +++ b/crates/uv-distribution/src/distribution_database.rs @@ -184,13 +184,7 @@ impl<'a, Context: BuildContext> DistributionDatabase<'a, Context> { wheel_entry: &CacheEntry, filename: &WheelFilename, ) -> Result { - // For backwards compatibility, we use the full wheel stem on Windows. Local wheel - // extraction and older uv versions use the same key, so changing it would prevent them - // from coordinating through a shared cache. - #[cfg(windows)] - let lock_key = filename.stem(); - // On other platforms, we use the bounded cache key to avoid filesystem filename limits. - #[cfg(not(windows))] + // Use the bounded cache key to avoid filesystem filename limits. let lock_key = filename.cache_key(); let lock_entry = wheel_entry.with_file(format!("{lock_key}.lock")); @@ -1830,7 +1824,7 @@ impl HttpArchivePointer { pub fn read_from(path: impl AsRef) -> Result, Error> { match fs_err::File::open(path.as_ref()) { Ok(file) => { - let data = DataWithCachePolicy::from_reader(file)?.data; + let data = DataWithCachePolicy::from_reader(file)?.into_data(); let archive = rmp_serde::from_slice::(&data)?; Ok(Some(Self { archive })) } diff --git a/crates/uv-distribution/src/source/mod.rs b/crates/uv-distribution/src/source/mod.rs index cec2ebeaff7..4174f8914d5 100644 --- a/crates/uv-distribution/src/source/mod.rs +++ b/crates/uv-distribution/src/source/mod.rs @@ -3504,7 +3504,7 @@ impl HttpRevisionPointer { pub(crate) fn read_from(path: impl AsRef) -> Result, Error> { match fs_err::File::open(path.as_ref()) { Ok(file) => { - let data = DataWithCachePolicy::from_reader(file)?.data; + let data = DataWithCachePolicy::from_reader(file)?.into_data(); let revision = rmp_serde::from_slice::(&data)?; Ok(Some(Self { revision })) } diff --git a/crates/uv-distribution/src/source/revision.rs b/crates/uv-distribution/src/source/revision.rs index ed459c62609..3dc9c9cfe55 100644 --- a/crates/uv-distribution/src/source/revision.rs +++ b/crates/uv-distribution/src/source/revision.rs @@ -1,7 +1,7 @@ -use serde::ser::SerializeMap; use serde::{Deserialize, Serialize}; use std::path::Path; use uv_distribution_types::Hashed; +use uv_fastid::Id; use uv_pypi_types::{HashDigest, HashDigests}; @@ -11,29 +11,13 @@ 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, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct Revision { id: RevisionId, hashes: HashDigests, - #[serde(default)] 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 { @@ -87,16 +71,14 @@ impl Hashed for Revision { /// A unique identifier for a revision of a source distribution. /// -/// Note: for compatibility with the existing `sdists-v9` bucket, this is a newtype around a -/// `String` rather than a newtype around `uv_fastid::Id`. In the future, we may want to bump -/// to `sdists-v10` and switch to using `uv_fastid::Id` directly. +/// The identifier is serialized as a string in the source-distribution cache. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub(crate) struct RevisionId(String); +pub(crate) struct RevisionId(Id); impl RevisionId { - /// Generate a new unique identifier for an archive. + /// Generate a new unique identifier for a source distribution revision. fn new() -> Self { - Self(uv_fastid::Id::secure().to_string()) + Self(Id::secure()) } pub(crate) fn as_str(&self) -> &str { @@ -120,74 +102,41 @@ impl AsRef for RevisionId { mod tests { use super::*; - /// Regression test for . - #[test] - fn deserialize_legacy_nanoid_revision() { - #[derive(Serialize)] - struct LegacyRevision { - id: RevisionId, - hashes: HashDigests, - } - - // A representative 21-character nanoid ID, drawn from the same alphabet - // used by both the old `nanoid` crate and `uv_fastid`. - let legacy = LegacyRevision { - id: RevisionId("HM0NxJml5hc7UjbfTWT1r".to_string()), - hashes: HashDigests::empty(), - }; - let bytes = rmp_serde::to_vec(&legacy).expect("serialize legacy revision"); - let parsed: Revision = rmp_serde::from_slice(&bytes).expect("deserialize legacy revision"); - assert_eq!(parsed.id().as_str(), "HM0NxJml5hc7UjbfTWT1r"); - } - #[test] fn round_trip_current_revision() { - let original = Revision::new().with_hashes(HashDigests::from( - "sha256:0000000000000000000000000000000000000000000000000000000000000000" - .parse::() - .expect("valid SHA-256 digest"), - )); - let bytes = rmp_serde::to_vec(&original).expect("serialize revision"); - let parsed: Revision = rmp_serde::from_slice(&bytes).expect("deserialize revision"); - assert_eq!(parsed.id().as_str(), original.id().as_str()); - assert_eq!(parsed.hashes(), original.hashes()); - } - - #[test] - fn deserialize_uv_0_12_revision() { - #[derive(Serialize)] - struct ReleasedRevision { - id: RevisionId, - hashes: HashDigests, - size: Option, + for size in [None, Some(42)] { + let mut original = Revision::new().with_hashes(HashDigests::from( + "sha256:0000000000000000000000000000000000000000000000000000000000000000" + .parse::() + .expect("valid SHA-256 digest"), + )); + if let Some(size) = size { + original = original.with_size(size); + } + let bytes = rmp_serde::to_vec(&original).expect("serialize revision"); + let parsed: Revision = rmp_serde::from_slice(&bytes).expect("deserialize revision"); + assert_eq!(parsed.id().as_str(), original.id().as_str()); + assert_eq!(parsed.hashes(), original.hashes()); + assert_eq!(parsed.size(), original.size()); } - - 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() { + fn serialize_revision_id_as_string() { #[derive(Deserialize)] - struct LegacyRevision { - id: RevisionId, + struct SerializedRevision { + id: String, hashes: HashDigests, + size: Option, } 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"); + let serialized: SerializedRevision = + rmp_serde::from_slice(&bytes).expect("deserialize revision with string ID"); - assert_eq!(legacy.id.as_str(), revision.id().as_str()); - assert_eq!(legacy.hashes, HashDigests::empty()); + assert_eq!(serialized.id.as_str(), revision.id().as_str()); + assert_eq!(serialized.hashes, HashDigests::empty()); + assert_eq!(serialized.size, Some(42)); } } diff --git a/crates/uv-lock/src/lib.rs b/crates/uv-lock/src/lib.rs index 08e6f5a15b2..45cbcf0b656 100644 --- a/crates/uv-lock/src/lib.rs +++ b/crates/uv-lock/src/lib.rs @@ -3,8 +3,8 @@ mod lock; pub use lock::{ - CanonicalLockError, DependencySelection, Installable, InstallableRootKind, Lock, LockError, - LockParseError, Metadata, Package, PackageMap, PylockToml, PylockTomlError, + CanonicalLockError, DependencySelection, GroupMetadata, Installable, InstallableRootKind, Lock, + LockError, LockParseError, Metadata, Package, PackageMap, PylockToml, PylockTomlError, PylockTomlErrorKind, PythonReport, RequirementsTxtExport, ResolverManifest, SatisfiesResult, SelectedDependency, TreeDisplay, TreeJsonTarget, cyclonedx_json, implicit_constraints_marker, }; diff --git a/crates/uv-lock/src/lock/deserialize.rs b/crates/uv-lock/src/lock/deserialize.rs index e5dcfd2103f..dc4c3aadc7c 100644 --- a/crates/uv-lock/src/lock/deserialize.rs +++ b/crates/uv-lock/src/lock/deserialize.rs @@ -340,6 +340,8 @@ enum MapKind { Options, OptionsExcludeNewerPackage, Manifest, + ManifestDefaultGroups, + ManifestGroupMetadata, ManifestDependencyGroups, ManifestDependencyMetadata, Package, @@ -468,7 +470,10 @@ impl<'de> DocumentMapAccess<'_, 'de> { } ( MapKind::Root, - "[manifest.dependency-groups]" | "[[manifest.dependency-metadata]]", + "[manifest.default-groups]" + | "[manifest.group-metadata]" + | "[manifest.dependency-groups]" + | "[[manifest.dependency-metadata]]", ) => { // The manifest map consumes the first subtable when its parent is implicit. self.track_key("manifest")?; @@ -492,6 +497,16 @@ impl<'de> DocumentMapAccess<'_, 'de> { Pending::Map(MapKind::ManifestDependencyGroups), "[manifest.dependency-groups]", )), + (MapKind::Manifest, "[manifest.default-groups]") => Some(( + "default-groups", + Pending::Map(MapKind::ManifestDefaultGroups), + "[manifest.default-groups]", + )), + (MapKind::Manifest, "[manifest.group-metadata]") => Some(( + "group-metadata", + Pending::Map(MapKind::ManifestGroupMetadata), + "[manifest.group-metadata]", + )), (MapKind::Manifest, "[[manifest.dependency-metadata]]") => Some(( "dependency-metadata", Pending::Sequence(SequenceKind::ManifestDependencyMetadata), diff --git a/crates/uv-lock/src/lock/mod.rs b/crates/uv-lock/src/lock/mod.rs index 5f2855e5ea2..40fb73b5289 100644 --- a/crates/uv-lock/src/lock/mod.rs +++ b/crates/uv-lock/src/lock/mod.rs @@ -47,7 +47,7 @@ use uv_distribution_types::{ use uv_fs::{PortablePath, PortablePathBuf, Simplified, normalize_path, try_relative_to_if}; use uv_git::{RepositoryReference, ResolvedRepositoryReference}; use uv_git_types::{GitLfs, GitOid, GitReference, GitUrl, GitUrlParseError}; -use uv_normalize::{ExtraName, GroupName, PackageName}; +use uv_normalize::{DEV_DEPENDENCIES, DefaultGroups, ExtraName, GroupName, PackageName}; use uv_pep440::{Version, VersionSpecifiers}; use uv_pep508::{ MarkerEnvironment, MarkerTree, Scheme, VerbatimUrl, VerbatimUrlError, split_scheme, @@ -68,6 +68,7 @@ use uv_resolver_types::{ use uv_small_str::SmallString; use uv_types::{BuildContext, HashStrategy}; use uv_warnings::warn_user_once; +use uv_workspace::dependency_groups::{DependencyGroupError, FlatDependencyGroups}; use uv_workspace::{Editability, WorkspaceMember}; pub use crate::lock::deserialize::Error as CanonicalLockError; @@ -118,10 +119,21 @@ pub enum LockParseError { } /// The current revision of the lockfile format. -const REVISION: u32 = 3; +/// +/// Version 1 revisions: +/// - 0: The original format; a missing `revision` is read as 0. +/// - 1: Record `provides-extras` and empty dependency group metadata. +/// - 2: Record distribution upload times. +/// - 3: Record package-specific `exclude-newer` values. +/// - 4: Support omitting package declaration metadata and record empty extras and groups. +/// - 5: Record workspace member default groups and dependency group metadata. +const REVISION: u32 = 5; + +/// The first lockfile revision that records workspace member default groups. +const MEMBER_DEFAULT_GROUPS_REVISION: u32 = 5; -/// The first lockfile revision that supports omitting package declaration metadata. -const METADATA_FREE_REVISION: u32 = 4; +/// The first lockfile revision that records workspace member dependency group metadata. +const MEMBER_GROUP_METADATA_REVISION: u32 = 5; static LINUX_MARKERS: LazyLock = LazyLock::new(|| { let pep508 = MarkerTree::from_str("os_name == 'posix' and sys_platform == 'linux'").unwrap(); @@ -2810,13 +2822,33 @@ impl Lock { self } + /// Record the default groups for workspace members in a revision 5 or newer lockfile. + /// + /// The implicit `["dev"]` default is omitted, and group lists are sorted and deduplicated. + #[must_use] + pub fn with_member_default_groups( + mut self, + groups: BTreeMap, + ) -> Self { + self.manifest.default_groups = canonicalize_member_default_groups(groups); + self + } + + /// Record member group metadata, including Python requirements inherited from included groups. + pub fn with_member_group_metadata( + mut self, + packages: &BTreeMap, + ) -> Result { + self.manifest.group_metadata = collect_member_group_metadata(packages)?; + Ok(self) + } + /// Omit package metadata except for remote URL and Git dependencies. /// /// Local declarations can be reread from disk. Remote URL and Git declarations remain in the /// lockfile so freshness checks can determine offline whether a source is requested or stale. #[must_use] fn without_package_metadata(mut self) -> Self { - self.revision = METADATA_FREE_REVISION; let workspace_root = self.root().map(|package| package.id.clone()); for package in &mut self.packages { if matches!(package.id.source, Source::Direct(..) | Source::Git(..)) { @@ -2867,11 +2899,6 @@ impl Lock { (self.version(), self.revision()) >= (1, 1) } - /// Returns `true` if this [`Lock`] can validate packages without declaration metadata. - pub fn supports_missing_package_metadata(&self) -> bool { - (self.version(), self.revision()) >= (VERSION, METADATA_FREE_REVISION) - } - /// Returns `true` if this [`Lock`] includes entries for empty `dependency-group` metadata. fn includes_empty_groups(&self) -> bool { // Empty dependency groups are included as of https://github.com/astral-sh/uv/pull/8598, @@ -3092,6 +3119,47 @@ impl Lock { &self.manifest.members } + /// Return the recorded default groups for workspace members, if supported by the lockfile. + /// + /// uv-generated lockfiles omit entries for the standard `dev` default. + pub fn configured_member_default_groups( + &self, + ) -> Option<&BTreeMap> { + ((self.version(), self.revision()) >= (VERSION, MEMBER_DEFAULT_GROUPS_REVISION)) + .then_some(&self.manifest.default_groups) + } + + /// Return the dependency group metadata recorded for workspace members. + /// + /// `None` means the lockfile predates this metadata; an absent group in a returned map has no + /// group-specific metadata. + pub fn member_group_metadata( + &self, + ) -> Option<&BTreeMap>> { + ((self.version(), self.revision()) >= (VERSION, MEMBER_GROUP_METADATA_REVISION)) + .then_some(&self.manifest.group_metadata) + } + + /// Return a workspace member's default groups, assuming `dev` when not recorded. + /// + /// Returns `None` if the lockfile predates this metadata or the name is not a workspace member. + /// A single-project lockfile's root counts as a member even when the workspace member list is + /// omitted. + pub fn member_default_groups(&self, name: &PackageName) -> Option { + let default_groups = self.configured_member_default_groups()?; + if !(self.members().contains(name) + || self.members().is_empty() && self.root().is_some_and(|root| root.name() == name)) + { + return None; + } + Some( + default_groups + .get(name) + .cloned() + .unwrap_or_else(|| DefaultGroups::List(vec![DEV_DEPENDENCIES.clone()])), + ) + } + /// Returns `true` if the package is a workspace member. fn is_workspace_member(&self, package: &Package) -> bool { self.members().contains(&package.id.name) @@ -4085,8 +4153,6 @@ impl Lock { database: &DistributionDatabase<'_, Context>, allow_missing_package_metadata: bool, ) -> Result, LockError> { - let allow_missing_package_metadata = - allow_missing_package_metadata && self.supports_missing_package_metadata(); let mut queue: VecDeque = VecDeque::new(); let mut seen = FxHashSet::default(); let mut activated_extras: FxHashMap> = @@ -4103,6 +4169,46 @@ impl Lock { } } + // Older lockfiles did not record defaults, so they remain valid without this check. + if let Some(actual) = self.configured_member_default_groups() { + let expected = canonicalize_member_default_groups( + packages + .iter() + .filter_map(|(name, member)| { + member + .pyproject_toml() + .configured_default_groups() + .cloned() + .map(|groups| (name.clone(), groups)) + }) + .collect(), + ); + let actual = canonicalize_member_default_groups(actual.clone()); + if expected != actual { + return Ok(SatisfiesResult::MismatchedMemberDefaultGroups( + expected, actual, + )); + } + } + + if let Some(actual) = self.member_group_metadata() { + let expected = collect_member_group_metadata(packages)?; + if expected != *actual { + // Unknown fields can leave a group with no known metadata. Such entries are + // equivalent to an absent group for the settings this version understands. + let mut canonical = actual.clone(); + canonical.retain(|_, groups| { + groups.retain(|_, metadata| *metadata != GroupMetadata::default()); + !groups.is_empty() + }); + if expected != canonical { + return Ok(SatisfiesResult::MismatchedMemberGroupMetadata( + expected, actual, + )); + } + } + } + // Validate that the member sources have not changed (e.g., that they've switched from // virtual to non-virtual or vice versa). for (name, member) in packages { @@ -5916,6 +6022,16 @@ pub enum SatisfiesResult<'lock> { Satisfied, /// The lockfile uses a different set of workspace members. MismatchedMembers(BTreeSet, &'lock BTreeSet), + /// The lockfile records different default groups for workspace members. + MismatchedMemberDefaultGroups( + BTreeMap, + BTreeMap, + ), + /// The lockfile records different dependency group metadata. + MismatchedMemberGroupMetadata( + BTreeMap>, + &'lock BTreeMap>, + ), /// A workspace member switched from virtual to non-virtual or vice versa. MismatchedVirtual(PackageName, bool), /// A workspace member switched from editable to non-editable or vice versa. @@ -6088,6 +6204,12 @@ pub struct ResolverManifest { /// The workspace members included in the lockfile. #[serde(default)] members: BTreeSet, + /// Nonstandard default dependency groups configured by workspace members. + #[serde(default)] + default_groups: BTreeMap, + /// Metadata for dependency groups of workspace members. + #[serde(default)] + group_metadata: BTreeMap>, /// The requirements provided to the resolver, exclusive of the workspace members. /// /// These are requirements that are attached to the project, but not to any of its @@ -6118,6 +6240,58 @@ pub struct ResolverManifest { dependency_metadata: BTreeSet, } +/// Sort and deduplicate group lists, omitting entries equivalent to the implicit `dev` default. +fn canonicalize_member_default_groups( + mut groups: BTreeMap, +) -> BTreeMap { + groups.retain(|_, groups| { + if let DefaultGroups::List(groups) = groups { + groups.sort_unstable(); + groups.dedup(); + groups.as_slice() != [DEV_DEPENDENCIES.clone()] + } else { + true + } + }); + groups +} + +/// Metadata for a workspace member's dependency group. +#[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub struct GroupMetadata { + /// The effective Python requirement, including requirements from included groups. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requires_python: Option, +} + +/// Collect metadata for each member's dependency groups. +fn collect_member_group_metadata( + packages: &BTreeMap, +) -> Result>, DependencyGroupError> { + let mut members = BTreeMap::new(); + for (name, member) in packages { + let groups = + FlatDependencyGroups::from_pyproject_toml(member.root(), member.pyproject_toml())? + .into_iter() + .filter_map(|(group, flat)| { + flat.requires_python.map(|requires_python| { + ( + group, + GroupMetadata { + requires_python: Some(requires_python), + }, + ) + }) + }) + .collect::>(); + if !groups.is_empty() { + members.insert(name.clone(), groups); + } + } + Ok(members) +} + impl ResolverManifest { /// Initialize a [`ResolverManifest`] with the given members, requirements, constraints, and /// overrides. @@ -6134,6 +6308,8 @@ impl ResolverManifest { let normalize = uv_preview::is_enabled(PreviewFeature::LockfileNormalization); Self { members: members.into_iter().collect(), + default_groups: BTreeMap::new(), + group_metadata: BTreeMap::new(), requirements: normalize_collection::<_, NormalizedRequirements>( requirements, normalize, @@ -6163,6 +6339,8 @@ impl ResolverManifest { pub fn relative_to(self, root: &Path) -> Result { Ok(Self { members: self.members, + default_groups: self.default_groups, + group_metadata: self.group_metadata, requirements: self .requirements .into_iter() @@ -9519,6 +9697,9 @@ impl std::fmt::Display for WheelTagHint { /// is with the caller somewhere in such cases. #[derive(Debug, thiserror::Error)] enum LockErrorKind { + /// An error that occurs when collecting dependency-group settings. + #[error(transparent)] + DependencyGroups(#[from] DependencyGroupError), /// An error that occurs when the overrides for validating a /// metadata-free lockfile cannot be scoped to their packages. #[error(transparent)] diff --git a/crates/uv-lock/src/lock/serialize.rs b/crates/uv-lock/src/lock/serialize.rs index 61123e28350..71ab7bbe346 100644 --- a/crates/uv-lock/src/lock/serialize.rs +++ b/crates/uv-lock/src/lock/serialize.rs @@ -128,7 +128,6 @@ fn write_lock(writer: &mut LockWriter, lock: &Lock) -> Result<(), WriteError> { &lock.requires_python, simplified_environment, &dist_count_by_name, - lock.supports_missing_package_metadata(), )?; } @@ -239,6 +238,20 @@ fn write_manifest(writer: &mut LockWriter, manifest: &ResolverManifest) -> Resul write_serialized_non_empty_array(writer, "excludes", &manifest.excludes)?; write_serialized_non_empty_array(writer, "build-constraints", &manifest.build_constraints)?; + if !manifest.default_groups.is_empty() { + writer.table(&["manifest", "default-groups"])?; + for (name, groups) in &manifest.default_groups { + writer.key_value(name.as_ref(), serialize_value(groups)?)?; + } + } + + if !manifest.group_metadata.is_empty() { + writer.table(&["manifest", "group-metadata"])?; + for (name, groups) in &manifest.group_metadata { + writer.key_value(name.as_ref(), serialize_value(groups)?)?; + } + } + if has_dependency_groups { writer.table(&["manifest", "dependency-groups"])?; for (group, requirements) in &manifest.dependency_groups { @@ -277,7 +290,6 @@ fn write_package( requires_python: &RequiresPython, simplified_environment: MarkerTree, dist_count_by_name: &FxHashMap, - preserve_empty_contexts: bool, ) -> Result<(), WriteError> { writer.array_of_tables(&["package"])?; write_package_id(writer, &package.id, None, PackageIdLocation::Table)?; @@ -316,18 +328,12 @@ fn write_package( writer.key_multiline_array("wheels", &package.wheels, write_wheel_inline)?; } - if package - .optional_dependencies - .values() - .any(|dependencies| preserve_empty_contexts || !dependencies.is_empty()) - { + if !package.optional_dependencies.is_empty() { writer.table(&["package", "optional-dependencies"])?; for (extra, dependencies) in &package.optional_dependencies { if dependencies.is_empty() { - if preserve_empty_contexts { - writer.key_start(extra.as_ref())?; - writer.raw("[]\n"); - } + writer.key_start(extra.as_ref())?; + writer.raw("[]\n"); continue; } writer.key_multiline_array(extra.as_ref(), dependencies, |writer, dependency| { @@ -341,18 +347,12 @@ fn write_package( } } - if package - .dependency_groups - .values() - .any(|dependencies| preserve_empty_contexts || !dependencies.is_empty()) - { + if !package.dependency_groups.is_empty() { writer.table(&["package", "dev-dependencies"])?; for (group, dependencies) in &package.dependency_groups { if dependencies.is_empty() { - if preserve_empty_contexts { - writer.key_start(group.as_ref())?; - writer.raw("[]\n"); - } + writer.key_start(group.as_ref())?; + writer.raw("[]\n"); continue; } writer.key_multiline_array(group.as_ref(), dependencies, |writer, dependency| { diff --git a/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__hash_optional_missing.snap b/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__hash_optional_missing.snap index 6ac8e103378..a2c44a115f6 100644 --- a/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__hash_optional_missing.snap +++ b/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__hash_optional_missing.snap @@ -127,6 +127,8 @@ Ok( }, manifest: ResolverManifest { members: {}, + default_groups: {}, + group_metadata: {}, requirements: {}, dependency_groups: {}, constraints: {}, diff --git a/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__hash_optional_present.snap b/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__hash_optional_present.snap index 79697a2341b..59c7b663768 100644 --- a/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__hash_optional_present.snap +++ b/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__hash_optional_present.snap @@ -131,6 +131,8 @@ Ok( }, manifest: ResolverManifest { members: {}, + default_groups: {}, + group_metadata: {}, requirements: {}, dependency_groups: {}, constraints: {}, diff --git a/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__hash_required_present.snap b/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__hash_required_present.snap index 01a86fd9435..dbba155d867 100644 --- a/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__hash_required_present.snap +++ b/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__hash_required_present.snap @@ -123,6 +123,8 @@ Ok( }, manifest: ResolverManifest { members: {}, + default_groups: {}, + group_metadata: {}, requirements: {}, dependency_groups: {}, constraints: {}, diff --git a/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__missing_dependency_source_unambiguous.snap b/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__missing_dependency_source_unambiguous.snap index 824011fcfb7..14aea9c795b 100644 --- a/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__missing_dependency_source_unambiguous.snap +++ b/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__missing_dependency_source_unambiguous.snap @@ -205,6 +205,8 @@ Ok( }, manifest: ResolverManifest { members: {}, + default_groups: {}, + group_metadata: {}, requirements: {}, dependency_groups: {}, constraints: {}, diff --git a/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__missing_dependency_source_version_unambiguous.snap b/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__missing_dependency_source_version_unambiguous.snap index 824011fcfb7..14aea9c795b 100644 --- a/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__missing_dependency_source_version_unambiguous.snap +++ b/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__missing_dependency_source_version_unambiguous.snap @@ -205,6 +205,8 @@ Ok( }, manifest: ResolverManifest { members: {}, + default_groups: {}, + group_metadata: {}, requirements: {}, dependency_groups: {}, constraints: {}, diff --git a/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__missing_dependency_version_dynamic.snap b/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__missing_dependency_version_dynamic.snap index 7674ab27fd3..d4e696b427a 100644 --- a/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__missing_dependency_version_dynamic.snap +++ b/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__missing_dependency_version_dynamic.snap @@ -232,6 +232,8 @@ Ok( }, manifest: ResolverManifest { members: {}, + default_groups: {}, + group_metadata: {}, requirements: {}, dependency_groups: {}, constraints: {}, diff --git a/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__missing_dependency_version_unambiguous.snap b/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__missing_dependency_version_unambiguous.snap index 824011fcfb7..14aea9c795b 100644 --- a/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__missing_dependency_version_unambiguous.snap +++ b/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__missing_dependency_version_unambiguous.snap @@ -205,6 +205,8 @@ Ok( }, manifest: ResolverManifest { members: {}, + default_groups: {}, + group_metadata: {}, requirements: {}, dependency_groups: {}, constraints: {}, diff --git a/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__source_direct_has_subdir.snap b/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__source_direct_has_subdir.snap index ef40e1c341c..cc6f4bf2f99 100644 --- a/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__source_direct_has_subdir.snap +++ b/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__source_direct_has_subdir.snap @@ -106,6 +106,8 @@ Ok( }, manifest: ResolverManifest { members: {}, + default_groups: {}, + group_metadata: {}, requirements: {}, dependency_groups: {}, constraints: {}, diff --git a/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__source_direct_no_subdir.snap b/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__source_direct_no_subdir.snap index 1bd0b450023..1ca90e610c1 100644 --- a/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__source_direct_no_subdir.snap +++ b/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__source_direct_no_subdir.snap @@ -102,6 +102,8 @@ Ok( }, manifest: ResolverManifest { members: {}, + default_groups: {}, + group_metadata: {}, requirements: {}, dependency_groups: {}, constraints: {}, diff --git a/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__source_directory.snap b/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__source_directory.snap index 437facddeed..0c950502344 100644 --- a/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__source_directory.snap +++ b/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__source_directory.snap @@ -92,6 +92,8 @@ Ok( }, manifest: ResolverManifest { members: {}, + default_groups: {}, + group_metadata: {}, requirements: {}, dependency_groups: {}, constraints: {}, diff --git a/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__source_editable.snap b/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__source_editable.snap index 39d835fc2d5..2d7779fef80 100644 --- a/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__source_editable.snap +++ b/crates/uv-lock/src/lock/snapshots/uv_lock__lock__tests__source_editable.snap @@ -92,6 +92,8 @@ Ok( }, manifest: ResolverManifest { members: {}, + default_groups: {}, + group_metadata: {}, requirements: {}, dependency_groups: {}, constraints: {}, diff --git a/crates/uv-pypi-types/src/simple_json.rs b/crates/uv-pypi-types/src/simple_json.rs index 9430526c24a..ed9f2a195f3 100644 --- a/crates/uv-pypi-types/src/simple_json.rs +++ b/crates/uv-pypi-types/src/simple_json.rs @@ -496,8 +496,11 @@ impl std::fmt::Display for HashAlgorithm { } /// A validated, lowercase hexadecimal digest containing exactly `BYTES` bytes. -#[derive(Clone, Ord, PartialOrd, Eq, PartialEq, Hash, Serialize)] +#[derive( + Clone, Ord, PartialOrd, Eq, PartialEq, Hash, Serialize, rkyv::Archive, rkyv::Serialize, +)] #[serde(transparent)] +#[rkyv(derive(Debug), bytecheck(verify))] pub struct Digest(SmallString); impl Digest { @@ -570,9 +573,46 @@ impl<'de, const BYTES: usize> Deserialize<'de> for Digest { } } +impl rkyv::Deserialize, D> for ArchivedDigest +where + D: Fallible + ?Sized, + D::Error: Source, +{ + fn deserialize(&self, deserializer: &mut D) -> Result, D::Error> { + let digest: SmallString = rkyv::Deserialize::deserialize(&self.0, deserializer)?; + Digest::from_hex(digest).map_err(D::Error::new) + } +} + +// SAFETY: The derived byte check validates the string before calling `verify`. Checking its +// length and hexadecimal characters guarantees it can become a validated `Digest`. +#[expect(unsafe_code)] +unsafe impl rkyv::bytecheck::Verify for ArchivedDigest +where + C: Fallible + ?Sized, + C::Error: Source, +{ + fn verify(&self, _context: &mut C) -> Result<(), C::Error> { + validate_hex(self.0.as_str(), BYTES).map_err(C::Error::new) + } +} + /// A hash name and hex encoded digest of the file. -#[derive(Debug, Clone, Ord, PartialOrd, Eq, PartialEq, Hash, Serialize, Deserialize)] -#[serde(try_from = "HashDigestWire", into = "HashDigestWire")] +#[derive( + Debug, + Clone, + Ord, + PartialOrd, + Eq, + PartialEq, + Hash, + Serialize, + Deserialize, + rkyv::Archive, + rkyv::Deserialize, + rkyv::Serialize, +)] +#[rkyv(derive(Debug))] pub enum HashDigest { Md5(Digest<16>), Sha256(Digest<32>), @@ -610,15 +650,11 @@ impl HashDigest { /// Return the hex-encoded digest. pub fn digest(&self) -> &str { - self.digest_string() - } - - fn digest_string(&self) -> &SmallString { match self { - Self::Md5(digest) => &digest.0, - Self::Sha256(digest) | Self::Blake2b256(digest) => &digest.0, - Self::Sha384(digest) => &digest.0, - Self::Sha512(digest) => &digest.0, + Self::Md5(digest) => digest.as_str(), + Self::Sha256(digest) | Self::Blake2b256(digest) => digest.as_str(), + Self::Sha384(digest) => digest.as_str(), + Self::Sha512(digest) => digest.as_str(), } } } @@ -647,97 +683,6 @@ impl FromStr for HashDigest { } } -/// The hash record used by the shared Serde and rkyv cache formats. -/// -/// This representation can be removed when all cache buckets containing [`HashDigest`] are bumped. -#[doc(hidden)] -#[derive(Serialize, Deserialize, rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)] -#[serde(rename = "HashDigest")] -#[rkyv(derive(Debug), bytecheck(verify))] -pub struct HashDigestWire { - algorithm: HashAlgorithm, - digest: SmallString, -} - -impl From<&HashDigest> for HashDigestWire { - fn from(hash: &HashDigest) -> Self { - Self { - algorithm: hash.algorithm(), - digest: hash.digest_string().clone(), - } - } -} - -impl From for HashDigestWire { - fn from(hash: HashDigest) -> Self { - let algorithm = hash.algorithm(); - let digest = match hash { - HashDigest::Md5(digest) => digest.0, - HashDigest::Sha256(digest) | HashDigest::Blake2b256(digest) => digest.0, - HashDigest::Sha384(digest) => digest.0, - HashDigest::Sha512(digest) => digest.0, - }; - Self { algorithm, digest } - } -} - -impl TryFrom for HashDigest { - type Error = HashError; - - fn try_from(hash: HashDigestWire) -> Result { - Self::new(hash.algorithm, hash.digest) - } -} - -impl rkyv::Archive for HashDigest { - type Archived = ArchivedHashDigestWire; - type Resolver = HashDigestWireResolver; - - fn resolve(&self, resolver: Self::Resolver, out: rkyv::Place) { - HashDigestWire::from(self).resolve(resolver, out); - } -} - -impl rkyv::Serialize for HashDigest -where - S: Fallible + ?Sized, - HashDigestWire: rkyv::Serialize, -{ - fn serialize(&self, serializer: &mut S) -> Result { - rkyv::Serialize::serialize(&HashDigestWire::from(self), serializer) - } -} - -impl rkyv::Deserialize for ArchivedHashDigestWire -where - D: Fallible + ?Sized, - D::Error: Source, -{ - fn deserialize(&self, deserializer: &mut D) -> Result { - let hash: HashDigestWire = rkyv::Deserialize::deserialize(self, deserializer)?; - HashDigest::try_from(hash).map_err(D::Error::new) - } -} - -// SAFETY: The derived byte check validates both fields before calling `verify`. Checking the -// digest's length and hexadecimal characters guarantees it can become a validated `HashDigest`. -#[expect(unsafe_code)] -unsafe impl rkyv::bytecheck::Verify for ArchivedHashDigestWire -where - C: Fallible + ?Sized, - C::Error: Source, -{ - fn verify(&self, _context: &mut C) -> Result<(), C::Error> { - let bytes = match self.algorithm { - ArchivedHashAlgorithm::Md5 => 16, - ArchivedHashAlgorithm::Sha256 | ArchivedHashAlgorithm::Blake2b256 => 32, - ArchivedHashAlgorithm::Sha384 => 48, - ArchivedHashAlgorithm::Sha512 => 64, - }; - validate_hex(self.digest.as_str(), bytes).map_err(C::Error::new) - } -} - fn validate_hex(digest: &str, bytes: usize) -> Result<(), HashError> { if digest.len() != bytes * 2 { return Err(HashError::InvalidDigestLength { @@ -910,8 +855,11 @@ pub enum HashError { #[cfg(test)] mod tests { - use crate::{ - CoreMetadata, Digest, HashAlgorithm, HashDigest, HashDigests, HashError, Hashes, PypiFile, + use rkyv::rancor::Error as RkyvError; + + use super::{ + ArchivedHashDigest, CoreMetadata, Digest, HashAlgorithm, HashDigest, HashDigests, + HashError, Hashes, PypiFile, }; #[test] @@ -963,7 +911,7 @@ mod tests { (HashAlgorithm::Sha256, "sha256", "Sha256", 32), (HashAlgorithm::Sha384, "sha384", "Sha384", 48), (HashAlgorithm::Sha512, "sha512", "Sha512", 64), - (HashAlgorithm::Blake2b256, "blake2b", "Blake2b", 32), + (HashAlgorithm::Blake2b256, "blake2b", "Blake2b256", 32), ]; for (algorithm, name, variant, bytes) in variants { @@ -976,17 +924,21 @@ mod tests { assert_eq!(parsed.digest(), digest); assert_eq!(parsed.to_string(), format!("{name}:{digest}")); let serialized = serde_json::to_string(&parsed).expect("serialize hash digest"); + assert_eq!(serialized, format!(r#"{{"{variant}":"{digest}"}}"#)); assert_eq!( - serialized, - format!(r#"{{"algorithm":"{variant}","digest":"{digest}"}}"#) + serde_json::from_str::(&serialized).expect("deserialize hash digest"), + parsed ); + + let archived = rkyv::to_bytes::(&parsed).expect("archive hash digest"); assert_eq!( - serde_json::from_str::(&serialized).expect("deserialize hash digest"), + rkyv::from_bytes::(&archived) + .expect("deserialize archived hash digest"), parsed ); let uppercase = serde_json::from_str::(&format!( - r#"{{"algorithm":"{variant}","digest":"{}"}}"#, + r#"{{"{variant}":"{}"}}"#, digest.to_ascii_uppercase() )) .expect("deserialize uppercase hash digest"); @@ -1037,14 +989,29 @@ mod tests { Err(HashError::UnsupportedHashAlgorithm(_)) )); - assert!( - serde_json::from_str::(r#"{"algorithm":"Sha256","digest":"short"}"#) - .is_err() - ); + assert!(serde_json::from_str::(r#"{"Sha256":"short"}"#).is_err()); assert!(serde_json::from_str::(r#"{"sha256":"short"}"#).is_err()); assert!(serde_json::from_str::>(&format!(r#""{}""#, "g".repeat(64))).is_err()); } + #[test] + fn hash_digest_archive_rejects_invalid_digests() { + for digest in ["short".to_string(), "g".repeat(64)] { + let hash = HashDigest::Sha256(Digest(digest.into())); + let archived = rkyv::to_bytes::(&hash).expect("archive invalid hash digest"); + assert!(rkyv::access::(&archived).is_err()); + } + } + + #[test] + fn hash_digest_archive_normalizes_uppercase() { + let hash = HashDigest::Sha256(Digest("AB".repeat(32).into())); + let archived = rkyv::to_bytes::(&hash).expect("archive uppercase hash digest"); + let parsed = rkyv::from_bytes::(&archived) + .expect("deserialize archived hash digest"); + assert_eq!(parsed, HashDigest::Sha256(Digest::from_bytes([0xab; 32]))); + } + #[test] fn hash_digests_round_trip_hashes() { let hashes = Hashes { diff --git a/crates/uv-workspace/src/pyproject.rs b/crates/uv-workspace/src/pyproject.rs index 452cbc9fa9a..6dde79bf7c2 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -94,13 +94,19 @@ pub struct PyProjectToml { } impl PyProjectToml { + /// Return explicitly configured default groups without validating the group names. + /// + /// `None` means the setting is absent, so uv uses `dev`; an empty list disables defaults. + pub fn configured_default_groups(&self) -> Option<&DefaultGroups> { + self.tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.default_groups.as_ref()) + } + /// Return the default dependency groups, validating explicitly configured group names. pub(crate) fn default_groups(&self) -> Result { - if let Some(defaults) = self - .tool - .as_ref() - .and_then(|tool| tool.uv.as_ref().and_then(|uv| uv.default_groups.as_ref())) - { + if let Some(defaults) = self.configured_default_groups() { if let DefaultGroups::List(defaults) = defaults { for group in defaults { if !self diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index f170b085971..b4c8b47e868 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -1435,7 +1435,9 @@ impl WorkspaceMember { } /// Return the default dependency groups for this workspace member. - fn default_groups(&self) -> Result { + /// + /// Returns an error if an explicitly configured group is not declared by the member. + pub fn default_groups(&self) -> Result { self.pyproject_toml.default_groups() } } diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 227a09acb13..08bec14ab92 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -338,6 +338,7 @@ pub(crate) async fn add( // Discover or create the virtual environment. let environment = ProjectEnvironment::get_or_init( project.workspace(), + None, &defaulted_groups, python.as_deref().map(PythonRequest::parse), &install_mirrors, diff --git a/crates/uv/src/commands/project/check.rs b/crates/uv/src/commands/project/check.rs index b0dc1c3bf6b..b7d73c17ea8 100644 --- a/crates/uv/src/commands/project/check.rs +++ b/crates/uv/src/commands/project/check.rs @@ -31,8 +31,8 @@ use crate::commands::project::lock::LockMode; use crate::commands::project::lock_target::LockTarget; use crate::commands::project::{ LinkErrorReporting, ProjectEnvironment, ProjectEnvironmentPolicy, ProjectInterpreter, - ScriptEnvironment, ScriptInterpreter, UniversalState, WorkspacePython, - validate_project_requires_python, + PythonRequirementSource, ScriptEnvironment, ScriptInterpreter, UniversalState, WorkspacePython, + validate_python_requirement, }; use crate::commands::reporters::PythonDownloadReporter; use crate::commands::{ExitStatus, UvError, project}; @@ -313,7 +313,7 @@ pub(crate) async fn check( let WorkspacePython { source, python_request, - requires_python, + requirement, } = WorkspacePython::from_request( python.as_deref().map(PythonRequest::parse), workspace, @@ -339,13 +339,12 @@ pub(crate) async fn check( .await? .into_interpreter(); - if let Some(requires_python) = requires_python.as_ref() { - validate_project_requires_python( + if let Some(requirement) = requirement.as_ref() { + validate_python_requirement( &interpreter, - workspace, - &groups, - requires_python, + &requirement.requires_python, &source, + PythonRequirementSource::Workspace(workspace, &groups), )?; } interpreter @@ -510,6 +509,7 @@ pub(crate) async fn check( } else { ProjectEnvironment::get_or_init( project.workspace(), + None, &groups, python.as_deref().map(PythonRequest::parse), &install_mirrors, diff --git a/crates/uv/src/commands/project/install_target.rs b/crates/uv/src/commands/project/install_target.rs index c399bb200d3..63e0f9dae18 100644 --- a/crates/uv/src/commands/project/install_target.rs +++ b/crates/uv/src/commands/project/install_target.rs @@ -418,21 +418,17 @@ impl<'lock> InstallTarget<'lock> { return Ok(()); } - let metadata_free_lock = lock.supports_missing_package_metadata(); let roots = self.roots().collect::>(); - // Revision 1.4 records even empty extras in the resolved dependency table. Read - // only the lockfile so frozen installs cannot select newly declared extras. + // Read only the lockfile so frozen installs cannot select newly declared extras. let known_extras = lock .packages() .iter() .filter(|package| roots.contains(package.name())) .flat_map(|package| { - package.provides_extras().iter().chain( - metadata_free_lock - .then(|| package.optional_dependencies().keys()) - .into_iter() - .flatten(), - ) + package + .provides_extras() + .iter() + .chain(package.optional_dependencies().keys()) }) .collect::>(); @@ -486,7 +482,6 @@ impl<'lock> InstallTarget<'lock> { lock, workspace, .. } | Self::NonProjectWorkspace { lock, workspace } => { - let metadata_free_lock = lock.supports_missing_package_metadata(); // Validate inherited root groups even when `--no-group` excludes them from // installation and therefore omits the root from the selected group roots. let workspace_root = matches!(self, Self::Project { .. }) @@ -499,17 +494,11 @@ impl<'lock> InstallTarget<'lock> { .iter() .filter(|package| roots.contains(package.name())) .flat_map(|package| { - // Revision 1.4 retains empty groups as resolved dependency table entries. // Reject groups added to the workspace after the lock was written. package .dependency_groups() .keys() - .chain( - metadata_free_lock - .then(|| package.resolved_dependency_groups().keys()) - .into_iter() - .flatten(), - ) + .chain(package.resolved_dependency_groups().keys()) .map(Cow::Borrowed) }); diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 337dce7a43f..d6a1ff60f53 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -542,6 +542,13 @@ async fn do_lock( let members = target.members(); let packages = target.packages(); let required_members = target.required_members(); + + // Validate explicit defaults before omitting `["dev"]` from the lockfile. Unlike the + // implicit default, an explicit `["dev"]` requires the `dev` group to exist. + for member in packages.values() { + member.default_groups()?; + } + let first_party_packages = match target { LockTarget::Workspace(workspace) => { FirstPartyPackages::from_workspace(workspace, &first_party_exclusions) @@ -1170,7 +1177,20 @@ async fn do_lock( preview.is_enabled(PreviewFeature::LockWithoutMetadata), )? .with_conflicts(conflicts) - .with_required_environments(lock_required_environments.into_markers()); + .with_required_environments(lock_required_environments.into_markers()) + .with_member_default_groups( + packages + .iter() + .filter_map(|(name, member)| { + member + .pyproject_toml() + .configured_default_groups() + .cloned() + .map(|groups| (name.clone(), groups)) + }) + .collect(), + ) + .with_member_group_metadata(packages)?; let lock = if let Some(recorder) = recorder { lock.prune_unused(recorder.take()) @@ -1476,6 +1496,20 @@ impl ValidatedLock { ); Ok(Self::Preferable(lock)) } + SatisfiesResult::MismatchedMemberDefaultGroups(expected, actual) => { + debug!( + "Resolving despite existing lockfile due to mismatched member default groups:\n Requested: {:?}\n Existing: {:?}", + expected, actual + ); + Ok(Self::Preferable(lock)) + } + SatisfiesResult::MismatchedMemberGroupMetadata(expected, actual) => { + debug!( + "Resolving despite existing lockfile due to mismatched group metadata:\n Requested: {:?}\n Existing: {:?}", + expected, actual + ); + Ok(Self::Preferable(lock)) + } SatisfiesResult::MismatchedEditable(name, expected) => { if expected { debug!( diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index bae93686a52..be5bfcc1608 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -163,28 +163,24 @@ pub(crate) enum ProjectError { Conflict(#[from] ConflictError), #[error( - "The requested interpreter resolved to Python {_0}, which is incompatible with the project's Python requirement: `{_1}`{}", - format_optional_requires_python_sources(_2, *_3) + "The requested interpreter resolved to Python {_0}, which is incompatible with the project's Python requirement: `{_1}`{_2}" )] - RequestedPythonProjectIncompatibility(Version, RequiresPython, RequiresPythonSources, bool), + RequestedPythonProjectIncompatibility(Version, RequiresPython, Box), #[error( - "The Python request from `{python_request}` resolved to Python {version}, which is incompatible with the project's Python requirement: `{requires_python}`{}\nUse `uv python pin` to update the `.python-version` file to a compatible version", - format_optional_requires_python_sources(requires_python_sources, *workspace), + "The Python request from `{python_request}` resolved to Python {version}, which is incompatible with the project's Python requirement: `{requires_python}`{requires_python_sources}\nUse `uv python pin` to update the `.python-version` file to a compatible version" )] DotPythonVersionProjectIncompatibility { python_request: String, version: Version, requires_python: RequiresPython, - requires_python_sources: Box, - workspace: bool, + requires_python_sources: Box, }, #[error( - "The resolved Python interpreter (Python {_0}) is incompatible with the project's Python requirement: `{_1}`{}", - format_optional_requires_python_sources(_2, *_3) + "The resolved Python interpreter (Python {_0}) is incompatible with the project's Python requirement: `{_1}`{_2}" )] - RequiresPythonProjectIncompatibility(Version, RequiresPython, RequiresPythonSources, bool), + RequiresPythonProjectIncompatibility(Version, RequiresPython, Box), #[error( "The requested interpreter resolved to Python {0}, which is incompatible with the script's Python requirement: `{1}`" @@ -233,6 +229,15 @@ pub(crate) enum ProjectError { )] DisjointRequiresPython(BTreeMap<(PackageName, Option), VersionSpecifiers>), + #[error( + "Found conflicting Python requirements:\n- lockfile: {locked}\n{groups}", + groups = format_requires_python_sources(.groups) + )] + DisjointLockedRequiresPython { + locked: RequiresPython, + groups: RequiresPythonSources, + }, + #[error("Environment marker is empty")] EmptyEnvironment, @@ -610,37 +615,187 @@ pub(crate) fn find_requires_python( } } -/// Returns an error if the [`Interpreter`] does not satisfy the [`Workspace`] `requires-python`. +/// Intersect the lockfile's Python requirement with the selected groups' requirements. +fn find_lockfile_requires_python( + target: InstallTarget<'_>, + groups: &DependencyGroupsWithDefaults, +) -> Result { + let lock = target.lock(); + let mut group_requirements = RequiresPythonSources::new(); + + if let Some(members) = lock.member_group_metadata() { + let group_root = target.group_root(groups); + + for (member, member_groups) in members { + // The group root can contribute groups without being an install root. + let is_install_root = target.roots().any(|root| root == member); + if !is_install_root && group_root != Some(member) { + continue; + } + + for (group, metadata) in member_groups { + if target.includes_group(Some(member), group, groups) + && let Some(requires_python) = &metadata.requires_python + { + group_requirements.insert( + (member.clone(), Some(group.clone())), + requires_python.clone(), + ); + } + } + } + } + + let Some(requires_python) = RequiresPython::intersection( + std::iter::once(lock.requires_python().specifiers()).chain(group_requirements.values()), + ) else { + return Err(ProjectError::DisjointLockedRequiresPython { + locked: lock.requires_python().clone(), + groups: group_requirements, + }); + }; + Ok(WorkspacePythonRequirement { + requires_python, + source: WorkspacePythonRequirementSource::Lockfile { + locked: lock.requires_python().clone(), + groups: group_requirements, + }, + }) +} + +/// The requirements that exclude a Python version, and where they were read. /// -/// If no [`Workspace`] is provided, the `requires-python` will be validated against the originating -/// source (e.g., a `.python-version` file or a `--python` command-line argument). -pub(crate) fn validate_project_requires_python( +/// Formats as an optional suffix to a Python incompatibility diagnostic. +#[derive(Debug)] +pub(crate) enum PythonRequirementConflicts { + Workspace { + sources: RequiresPythonSources, + /// Whether the workspace has multiple members, so a single-conflict diagnostic names the member. + multiple_members: bool, + }, + Lockfile { + locked: Option, + groups: RequiresPythonSources, + }, +} + +impl std::fmt::Display for PythonRequirementConflicts { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Workspace { + sources, + multiple_members, + } => { + if sources.len() > 1 { + return write!( + f, + ".\nThe following `requires-python` declarations do not permit this version:\n{}", + format_requires_python_sources(sources) + ); + } + if let Some(((package, group), _)) = sources.iter().next() { + if let Some(group) = group { + if *multiple_members { + return write!( + f, + " (from workspace member `{package}`'s `tool.uv.dependency-groups.{group}.requires-python`)." + ); + } + return write!( + f, + " (from `tool.uv.dependency-groups.{group}.requires-python`)." + ); + } + if *multiple_members { + return write!( + f, + " (from workspace member `{package}`'s `project.requires-python`)." + ); + } + return f.write_str(" (from `project.requires-python`)"); + } + Ok(()) + } + Self::Lockfile { locked, groups } => { + let count = usize::from(locked.is_some()) + groups.len(); + if count > 1 { + write!( + f, + ".\nThe following requirements in `uv.lock` do not permit this version:\n" + )?; + if let Some(locked) = locked { + writeln!(f, "- lockfile: {locked}")?; + } + return f.write_str(&format_requires_python_sources(groups)); + } + if locked.is_some() { + return f.write_str(" (from `requires-python` in `uv.lock`)."); + } + if let Some(((package, Some(group)), _)) = groups.iter().next() { + return write!(f, " (from `{package}:{group}` in `uv.lock`)."); + } + f.write_str(" (from `uv.lock`).") + } + } + } +} + +/// Where to read individual Python requirements for incompatibility diagnostics. +#[derive(Clone, Copy)] +pub(crate) enum PythonRequirementSource<'a> { + Workspace(Option<&'a Workspace>, &'a DependencyGroupsWithDefaults), + Lockfile { + locked: &'a RequiresPython, + groups: &'a RequiresPythonSources, + }, +} + +/// Returns an error if the [`Interpreter`] does not satisfy `requires_python`. +/// +/// The requirement source determines which conflicting declarations are included in the diagnostic. +pub(crate) fn validate_python_requirement( interpreter: &Interpreter, - workspace: Option<&Workspace>, - groups: &DependencyGroupsWithDefaults, requires_python: &RequiresPython, source: &PythonRequestSource, + requirement_source: PythonRequirementSource<'_>, ) -> Result<(), ProjectError> { if requires_python.contains(interpreter.python_version()) { return Ok(()); } - // Find all the individual requires_python constraints that conflict - let conflicting_requires = workspace - .and_then(|workspace| workspace.requires_python(groups).ok()) - .into_iter() - .flatten() - .filter(|(.., requires)| !requires.contains(interpreter.python_version())) - .collect::(); - let workspace_non_trivial = workspace.is_some_and(|workspace| workspace.packages().len() > 1); + let conflicting_requires = match requirement_source { + PythonRequirementSource::Workspace(workspace, groups) => { + let sources = workspace + .and_then(|workspace| workspace.requires_python(groups).ok()) + .into_iter() + .flatten() + .filter(|(.., requires)| !requires.contains(interpreter.python_version())) + .collect(); + PythonRequirementConflicts::Workspace { + sources, + multiple_members: workspace.is_some_and(|workspace| workspace.packages().len() > 1), + } + } + PythonRequirementSource::Lockfile { locked, groups } => { + let version = interpreter.python_version().only_release(); + let groups = groups + .iter() + .filter(|(_, requires)| !requires.contains(&version)) + .map(|(key, requires)| (key.clone(), requires.clone())) + .collect(); + PythonRequirementConflicts::Lockfile { + locked: (!locked.contains(interpreter.python_version())).then(|| locked.clone()), + groups, + } + } + }; match source { PythonRequestSource::UserRequest => { Err(ProjectError::RequestedPythonProjectIncompatibility( interpreter.python_version().clone(), requires_python.clone(), - conflicting_requires, - workspace_non_trivial, + Box::new(conflicting_requires), )) } PythonRequestSource::DotPythonVersion(file) => { @@ -649,15 +804,13 @@ pub(crate) fn validate_project_requires_python( version: interpreter.python_version().clone(), requires_python: requires_python.clone(), requires_python_sources: Box::new(conflicting_requires), - workspace: workspace_non_trivial, }) } PythonRequestSource::RequiresPython => { Err(ProjectError::RequiresPythonProjectIncompatibility( interpreter.python_version().clone(), requires_python.clone(), - conflicting_requires, - workspace_non_trivial, + Box::new(conflicting_requires), )) } } @@ -874,15 +1027,15 @@ impl ScriptInterpreter { .into_interpreter(); if let Err(err) = match requires_python { - Some((requires_python, RequiresPythonSource::Project)) => { - validate_project_requires_python( - &interpreter, + Some((requires_python, RequiresPythonSource::Project)) => validate_python_requirement( + &interpreter, + &requires_python, + &source, + PythonRequirementSource::Workspace( workspace, &DependencyGroupsWithDefaults::none(), - &requires_python, - &source, - ) - } + ), + ), Some((requires_python, RequiresPythonSource::Script)) => { validate_script_requires_python(&interpreter, &requires_python, &source) } @@ -1427,8 +1580,11 @@ impl ProjectInterpreter { let WorkspacePython { source, python_request, - requires_python, + requirement, } = workspace_python; + let requires_python = requirement + .as_ref() + .map(|requirement| &requirement.requires_python); let environment_selection = workspace.environment_selection(active); let centralized = centralized_environments_enabled(&environment_selection, cache); @@ -1457,7 +1613,7 @@ impl ProjectInterpreter { &root, python_request.as_ref(), python_preference, - requires_python.as_ref(), + requires_python, policy, centralized, cache, @@ -1478,7 +1634,7 @@ impl ProjectInterpreter { &project_environment_path, python_request.as_ref(), python_preference, - requires_python.as_ref(), + requires_python, policy, centralized, cache, @@ -1512,7 +1668,7 @@ impl ProjectInterpreter { &root, python_request.as_ref(), python_preference, - requires_python.as_ref(), + requires_python, policy, centralized, cache, @@ -1544,13 +1700,20 @@ impl ProjectInterpreter { )?; } - if let Some(requires_python) = requires_python.as_ref() { - validate_project_requires_python( + if let Some(requirement) = requirement.as_ref() { + let requirement_source = match &requirement.source { + WorkspacePythonRequirementSource::Workspace => { + PythonRequirementSource::Workspace(Some(workspace), groups) + } + WorkspacePythonRequirementSource::Lockfile { locked, groups } => { + PythonRequirementSource::Lockfile { locked, groups } + } + }; + validate_python_requirement( &interpreter, - Some(workspace), - groups, - requires_python, + &requirement.requires_python, &source, + requirement_source, )?; } @@ -1612,21 +1775,56 @@ impl std::fmt::Display for PythonRequestSource { } } +/// A Python requirement and the source used to derive it. +#[derive(Debug, Clone)] +pub(crate) struct WorkspacePythonRequirement { + pub(crate) requires_python: RequiresPython, + source: WorkspacePythonRequirementSource, +} + +#[derive(Debug, Clone)] +enum WorkspacePythonRequirementSource { + /// The requirement was derived from the workspace manifests. + Workspace, + /// The lockfile's overall requirement and the selected groups' requirements. + Lockfile { + locked: RequiresPython, + groups: RequiresPythonSources, + }, +} + /// The resolved Python request and requirement for a [`Workspace`]. #[derive(Debug, Clone)] pub(crate) struct WorkspacePython { /// The source of the Python request. pub(crate) source: PythonRequestSource, /// The resolved Python request, computed by considering (1) any explicit request from the user - /// via `--python`, (2) any implicit request from the user via `.python-version`, and (3) any - /// `Requires-Python` specifier in the `pyproject.toml`. + /// via `--python`, (2) any implicit request from the user via `.python-version`, and (3) the + /// workspace or lockfile's `Requires-Python` specifier. pub(crate) python_request: Option, - /// The resolved Python requirement for the project, computed by taking the intersection of all - /// `Requires-Python` specifiers in the workspace. - pub(crate) requires_python: Option, + /// The resolved Python requirement for the project and its source. + pub(crate) requirement: Option, } impl WorkspacePython { + /// Determine the Python request and requirement from a frozen lockfile. + async fn from_lockfile( + python_request: Option, + workspace: &Workspace, + target: InstallTarget<'_>, + groups: &DependencyGroupsWithDefaults, + config_discovery: ConfigDiscovery, + ) -> Result { + Self::from_requirements( + python_request, + Some(workspace.install_path()), + Some(find_lockfile_requires_python(target, groups)?), + workspace.install_path(), + config_discovery, + ) + .await + } + /// Determine the [`WorkspacePython`] for the current [`Workspace`]. pub(crate) async fn from_request( python_request: Option, @@ -1635,13 +1833,33 @@ impl WorkspacePython { project_dir: &Path, config_discovery: ConfigDiscovery, ) -> Result { - let requires_python = workspace + let requirement = workspace .map(|workspace| find_requires_python(workspace, groups)) .transpose()? - .flatten(); + .flatten() + .map(|requires_python| WorkspacePythonRequirement { + requires_python, + source: WorkspacePythonRequirementSource::Workspace, + }); - let workspace_root = workspace.map(Workspace::install_path); + Self::from_requirements( + python_request, + workspace.map(|workspace| workspace.install_path().as_path()), + requirement, + project_dir, + config_discovery, + ) + .await + } + /// Select a Python request using a project's root and Python requirement. + async fn from_requirements( + python_request: Option, + workspace_root: Option<&Path>, + requirement: Option, + project_dir: &Path, + config_discovery: ConfigDiscovery, + ) -> Result { let (source, python_request) = if let Some(request) = python_request { // (1) Explicit request from user let source = PythonRequestSource::UserRequest; @@ -1650,7 +1868,7 @@ impl WorkspacePython { } else if let Some(file) = PythonVersionFile::discover( project_dir, &VersionFileDiscoveryOptions::default() - .with_stop_discovery_at(workspace_root.map(PathBuf::as_ref)) + .with_stop_discovery_at(workspace_root) .with_config_discovery(config_discovery), ) .await? @@ -1659,10 +1877,10 @@ impl WorkspacePython { if !file.is_global() { return true; } - match (file.version(), requires_python.as_ref()) { - (Some(request), Some(requires_python)) => request + match (file.version(), requirement.as_ref()) { + (Some(request), Some(requirement)) => request .as_pep440_version() - .is_none_or(|version| requires_python.contains(&version)), + .is_none_or(|version| requirement.requires_python.contains(&version)), _ => true, } }) { @@ -1672,9 +1890,9 @@ impl WorkspacePython { (source, request) } else { // (3) `requires-python` in `pyproject.toml` - let request = requires_python - .as_ref() - .and_then(PythonRequest::from_requires_python); + let request = requirement.as_ref().and_then(|requirement| { + PythonRequest::from_requires_python(&requirement.requires_python) + }); let source = PythonRequestSource::RequiresPython; (source, request) }; @@ -1689,7 +1907,7 @@ impl WorkspacePython { Ok(Self { source, python_request, - requires_python, + requirement, }) } } @@ -1834,6 +2052,7 @@ impl ProjectEnvironment { /// Initialize a virtual environment for the current project. pub(crate) async fn get_or_init( workspace: &Workspace, + frozen_target: Option>, groups: &DependencyGroupsWithDefaults, python: Option, install_mirrors: &PythonInstallMirrors, @@ -1859,14 +2078,19 @@ impl ProjectEnvironment { }) .ok(); - let workspace_python = WorkspacePython::from_request( - python, - Some(workspace), - groups, - workspace.install_path().as_ref(), - config_discovery, - ) - .await?; + let workspace_python = if let Some(target) = frozen_target { + WorkspacePython::from_lockfile(python, workspace, target, groups, config_discovery) + .await? + } else { + WorkspacePython::from_request( + python, + Some(workspace), + groups, + workspace.install_path(), + config_discovery, + ) + .await? + }; let upgradeable = workspace_python .python_request .as_ref() @@ -3542,34 +3766,3 @@ fn format_requires_python_sources(conflicts: &RequiresPythonSources) -> String { }) .join("\n") } - -fn format_optional_requires_python_sources( - conflicts: &RequiresPythonSources, - workspace_non_trivial: bool, -) -> String { - // If there's lots of conflicts, print a list - if conflicts.len() > 1 { - return format!( - ".\nThe following `requires-python` declarations do not permit this version:\n{}", - format_requires_python_sources(conflicts) - ); - } - // If there's one conflict, give a clean message - if conflicts.len() == 1 { - let ((package, group), _) = conflicts.iter().next().unwrap(); - if let Some(group) = group { - if workspace_non_trivial { - return format!( - " (from workspace member `{package}`'s `tool.uv.dependency-groups.{group}.requires-python`)." - ); - } - return format!(" (from `tool.uv.dependency-groups.{group}.requires-python`)."); - } - if workspace_non_trivial { - return format!(" (from workspace member `{package}`'s `project.requires-python`)."); - } - return " (from `project.requires-python`)".to_owned(); - } - // Otherwise don't elaborate - String::new() -} diff --git a/crates/uv/src/commands/project/remove.rs b/crates/uv/src/commands/project/remove.rs index fe3fa0b7cc4..e6dd32585d2 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -271,6 +271,7 @@ pub(crate) async fn remove( // Discover or create the virtual environment. let environment = ProjectEnvironment::get_or_init( project.workspace(), + None, &groups, python.as_deref().map(PythonRequest::parse), &install_mirrors, diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index d6c1e23ec10..6eea5e9db22 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -73,9 +73,9 @@ use crate::commands::project::lock::LockMode; use crate::commands::project::lock_target::LockTarget; use crate::commands::project::{ EnvironmentSpecification, LinkErrorReporting, PreferenceLocation, ProjectEnvironment, - ProjectError, ScriptEnvironment, ScriptInterpreter, UniversalState, WorkspacePython, - script_extra_build_requires, script_specification, update_environment, - validate_project_requires_python, + ProjectError, PythonRequirementSource, ScriptEnvironment, ScriptInterpreter, UniversalState, + WorkspacePython, script_extra_build_requires, script_specification, update_environment, + validate_python_requirement, }; use crate::commands::reporters::PythonDownloadReporter; use crate::commands::{ExitStatus, UvError, project, read_env_files}; @@ -649,7 +649,7 @@ pub(crate) async fn run( let WorkspacePython { source, python_request, - requires_python, + requirement, } = WorkspacePython::from_request( python.as_deref().map(PythonRequest::parse), Some(project.workspace()), @@ -674,13 +674,12 @@ pub(crate) async fn run( .await? .into_interpreter(); - if let Some(requires_python) = requires_python.as_ref() { - validate_project_requires_python( + if let Some(requirement) = requirement.as_ref() { + validate_python_requirement( &interpreter, - Some(project.workspace()), - &groups, - requires_python, + &requirement.requires_python, &source, + PythonRequirementSource::Workspace(Some(project.workspace()), &groups), )?; } @@ -703,6 +702,7 @@ pub(crate) async fn run( // project. ProjectEnvironment::get_or_init( project.workspace(), + None, &groups, python.as_deref().map(PythonRequest::parse), &install_mirrors, diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 0a9dfac6e8f..89c738ea1d6 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -53,9 +53,9 @@ use crate::commands::project::install_target::InstallTarget; use crate::commands::project::lock::{LockMode, LockOperation, LockResult}; use crate::commands::project::lock_target::LockTarget; use crate::commands::project::{ - EnvironmentUpdate, LinkErrorReporting, MalwareFindings, PlatformState, ProjectEnvironment, - ProjectError, ScriptEnvironment, UniversalState, detect_conflicts, script_extra_build_requires, - script_specification, update_environment, + EnvironmentUpdate, LinkErrorReporting, MalwareFindings, MissingLockfileSource, PlatformState, + ProjectEnvironment, ProjectError, ScriptEnvironment, UniversalState, detect_conflicts, + script_extra_build_requires, script_specification, update_environment, }; use crate::commands::{ExitStatus, UvError}; use crate::printer::Printer; @@ -149,9 +149,42 @@ pub(crate) async fn sync( SyncTarget::Project(project) }; + let lock_target = match &target { + SyncTarget::Project(project) => LockTarget::from(project.workspace()), + SyncTarget::Script(script) => LockTarget::from(script), + }; + + // Read the frozen lock before selecting an environment, since the selected member's default + // groups can affect the Python requirement. + let frozen_lock = if let Some(source) = frozen { + Some( + lock_target + .read_frozen(MissingLockfileSource::from(source)) + .await + .map_err(|err| match (err, &target) { + (ProjectError::MissingLockfile(..), SyncTarget::Script(script)) => anyhow::anyhow!( + "`uv sync --frozen` requires a script lockfile; run `{}` to lock the script", + format!("uv lock --script {}", script.path.user_display()).green(), + ), + (err, _) => UvError::from(err).into(), + })?, + ) + } else { + None + }; + + let locked_default_groups = match (&frozen_lock, package.as_slice()) { + (Some(lock), [name]) => lock.member_default_groups(name), + _ => None, + }; + let use_locked_python = locked_default_groups.is_some(); + // Determine the groups and extras to include. let default_groups = match &target { - SyncTarget::Project(project) => project.default_groups()?, + SyncTarget::Project(project) => match locked_default_groups { + Some(defaults) => defaults, + None => project.default_groups()?, + }, SyncTarget::Script(..) => DefaultGroups::default(), }; let default_extras = match &target { @@ -166,6 +199,12 @@ pub(crate) async fn sync( SyncTarget::Project(project) => SyncEnvironment::Project( ProjectEnvironment::get_or_init( project.workspace(), + frozen_lock + .as_ref() + .filter(|_| use_locked_python) + .map(|lock| { + identify_installation_target(&target, lock, all_packages, &package) + }), &groups, python.as_deref().map(PythonRequest::parse), &install_mirrors, @@ -224,16 +263,10 @@ pub(crate) async fn sync( // Special-case: we're syncing a script that doesn't have an associated lockfile. In that case, // we don't create a lockfile, so the resolve-and-install semantics are different. - if let SyncTarget::Script(script) = &target { - let lockfile = LockTarget::from(script).lock_path(); - if !lockfile.is_file() { - if frozen.is_some() { - return Err(anyhow::anyhow!( - "`uv sync --frozen` requires a script lockfile; run `{}` to lock the script", - format!("uv lock --script {}", script.path.user_display()).green(), - )); - } - + if let SyncTarget::Script(script) = &target + && frozen_lock.is_none() + { + if !lock_target.lock_path().is_file() { if let LockCheck::Enabled(lock_check) = lock_check { return Err(anyhow::anyhow!( "`uv sync {lock_check}` requires a script lockfile; run `{}` to lock the script", @@ -348,33 +381,32 @@ pub(crate) async fn sync( LockMode::Write(environment.interpreter()) }; - let lock_target = match &target { - SyncTarget::Project(project) => LockTarget::from(project.workspace()), - SyncTarget::Script(script) => LockTarget::from(script), - }; - let first_party_exclusions = target.project().map_or_else(BTreeSet::new, |project| { first_party_exclusions(project, all_packages, &package, &install_options) }); - let outcome = match Box::pin( - LockOperation::new( - mode, - &settings.resolver, - &client_builder, - &state, - Box::new(DefaultResolveLogger), - &concurrency, - cache, - workspace_cache, - printer, - preview, + let result = if let Some(lock) = frozen_lock { + Ok(LockResult::Unchanged(lock)) + } else { + Box::pin( + LockOperation::new( + mode, + &settings.resolver, + &client_builder, + &state, + Box::new(DefaultResolveLogger), + &concurrency, + cache, + workspace_cache, + printer, + preview, + ) + .with_first_party_exclusions(first_party_exclusions) + .execute(lock_target), ) - .with_first_party_exclusions(first_party_exclusions) - .execute(lock_target), - ) - .await - { + .await + }; + let outcome = match result { Ok(result) => Outcome::Success(result), Err(ProjectError::Operation(err)) => { return Err(UvError::from(err).into()); diff --git a/crates/uv/src/commands/project/version.rs b/crates/uv/src/commands/project/version.rs index cd879d59734..722b05f1939 100644 --- a/crates/uv/src/commands/project/version.rs +++ b/crates/uv/src/commands/project/version.rs @@ -604,6 +604,7 @@ async fn lock_and_sync( // Discover or create the virtual environment. let environment = ProjectEnvironment::get_or_init( project.workspace(), + None, &groups, python.as_deref().map(PythonRequest::parse), &install_mirrors, diff --git a/crates/uv/src/commands/python/find.rs b/crates/uv/src/commands/python/find.rs index 1a8ad14d3de..0fcd0d59eb5 100644 --- a/crates/uv/src/commands/python/find.rs +++ b/crates/uv/src/commands/python/find.rs @@ -18,7 +18,9 @@ use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceCache, WorkspaceEr use crate::commands::{ ExitStatus, - project::{ScriptInterpreter, WorkspacePython, validate_project_requires_python}, + project::{ + PythonRequirementSource, ScriptInterpreter, WorkspacePython, validate_python_requirement, + }, }; use crate::printer::Printer; @@ -77,7 +79,7 @@ pub(crate) async fn find( let WorkspacePython { source, python_request, - requires_python, + requirement, } = WorkspacePython::from_request( request.map(|request| PythonRequest::parse(&request)), project.as_ref().map(VirtualProject::workspace), @@ -104,13 +106,15 @@ pub(crate) async fn find( .await?; // Warn if the discovered Python version is incompatible with the current workspace - if let Some(requires_python) = requires_python { - match validate_project_requires_python( + if let Some(requirement) = requirement { + match validate_python_requirement( python.interpreter(), - project.as_ref().map(VirtualProject::workspace), - &groups, - &requires_python, + &requirement.requires_python, &source, + PythonRequirementSource::Workspace( + project.as_ref().map(VirtualProject::workspace), + &groups, + ), ) { Ok(()) => {} Err(err) => { diff --git a/crates/uv/src/commands/venv.rs b/crates/uv/src/commands/venv.rs index af4ffd17cc4..29c07e7061c 100644 --- a/crates/uv/src/commands/venv.rs +++ b/crates/uv/src/commands/venv.rs @@ -41,9 +41,9 @@ use crate::commands::ExitStatus; use crate::commands::pip::loggers::{DefaultInstallLogger, InstallLogger}; use crate::commands::pip::operations::{Changelog, report_interpreter}; use crate::commands::project::{ - LinkErrorReporting, WorkspacePython, centralized_environment_root, + LinkErrorReporting, PythonRequirementSource, WorkspacePython, centralized_environment_root, centralized_environments_enabled, is_centralized_environment_reference, - lock_project_environment, update_project_environment_link, validate_project_requires_python, + lock_project_environment, update_project_environment_link, validate_python_requirement, }; use crate::commands::reporters::PythonDownloadReporter; use crate::printer::Printer; @@ -150,7 +150,7 @@ pub(crate) async fn venv( let WorkspacePython { source, python_request, - requires_python, + requirement, } = WorkspacePython::from_request( python_request, project.as_ref().map(VirtualProject::workspace), @@ -198,13 +198,15 @@ pub(crate) async fn venv( }; // Check if the discovered Python version is incompatible with the current workspace - if let Some(requires_python) = requires_python { - match validate_project_requires_python( + if let Some(requirement) = requirement { + match validate_python_requirement( &interpreter, - project.as_ref().map(VirtualProject::workspace), - &groups, - &requires_python, + &requirement.requires_python, &source, + PythonRequirementSource::Workspace( + project.as_ref().map(VirtualProject::workspace), + &groups, + ), ) { Ok(()) => {} Err(err) => { diff --git a/crates/uv/src/commands/workspace/metadata.rs b/crates/uv/src/commands/workspace/metadata.rs index b4411b7aed2..06571f2e92d 100644 --- a/crates/uv/src/commands/workspace/metadata.rs +++ b/crates/uv/src/commands/workspace/metadata.rs @@ -178,6 +178,7 @@ pub(crate) async fn metadata( Some(match target { LockTarget::Workspace(workspace) => ProjectEnvironment::get_or_init( workspace, + None, &groups, python.as_deref().map(PythonRequest::parse), &install_mirrors, diff --git a/crates/uv/tests/build/build.rs b/crates/uv/tests/build/build.rs index b8199362da9..a31320fa22f 100644 --- a/crates/uv/tests/build/build.rs +++ b/crates/uv/tests/build/build.rs @@ -3589,7 +3589,7 @@ fn venv_included_in_sdist() -> Result<()> { Building source distribution... error: Failed to build `[TEMP_DIR]/` cause: Invalid tar file - cause: failed to unpack `[CACHE_DIR]/sdists-v9/[TMP]/project-0.1.0/.venv/bin/python` + cause: failed to unpack `[CACHE_DIR]/sdists-v10/[TMP]/project-0.1.0/.venv/bin/python` cause: symlink path `[PYTHON-3.12]` is absolute, but external symlinks are not allowed hint: The source distribution includes a virtual environment. Virtual environments must be excluded from source distributions. @@ -3622,7 +3622,7 @@ fn venv_included_in_sdist() -> Result<()> { ----- stderr ----- error: Failed to build `[TEMP_DIR]/` cause: Invalid tar file - cause: failed to unpack `[CACHE_DIR]/sdists-v9/[TMP]/project-0.1.0/.venv/bin/python` + cause: failed to unpack `[CACHE_DIR]/sdists-v10/[TMP]/project-0.1.0/.venv/bin/python` cause: symlink path `[PYTHON-3.12]` is absolute, but external symlinks are not allowed hint: The source distribution includes a virtual environment. Virtual environments must be excluded from source distributions. diff --git a/crates/uv/tests/build/cache_clean.rs b/crates/uv/tests/build/cache_clean.rs index 5da512f06b5..680aa617062 100644 --- a/crates/uv/tests/build/cache_clean.rs +++ b/crates/uv/tests/build/cache_clean.rs @@ -354,7 +354,7 @@ fn clean_package_pypi() -> Result<()> { // Assert that the `.rkyv` file is created for `iniconfig`. let rkyv = context .cache_dir - .child("simple-v25") + .child("simple-v26") .child("pypi") .child("iniconfig.rkyv"); assert!( @@ -417,7 +417,7 @@ fn clean_package_index() -> Result<()> { // Assert that the `.rkyv` file is created for `iniconfig`. let rkyv = context .cache_dir - .child("simple-v25") + .child("simple-v26") .child("index") .child("e8208120cae3ba69") .child("iniconfig.rkyv"); @@ -452,7 +452,7 @@ fn clean_package_does_not_follow_symlinks() -> Result<()> { let archive_entry = context.cache_dir.child("archive-v0").child("archive"); let package_entry = context .cache_dir - .child("wheels-v6") + .child("wheels-v7") .child("pypi") .child("demo"); @@ -555,7 +555,7 @@ fn clean_handles_verbatim_paths() -> Result<()> { // Cached sdist path resembling the uwsgi==2.0.31 build failure. let uwsgi_shard = context .cache_dir - .child("sdists-v9") + .child("sdists-v10") .child("pypi") .child("uwsgi") .child("2.0.31") diff --git a/crates/uv/tests/build/cache_prune.rs b/crates/uv/tests/build/cache_prune.rs index c674255fa8c..e9f6ab6c4ff 100644 --- a/crates/uv/tests/build/cache_prune.rs +++ b/crates/uv/tests/build/cache_prune.rs @@ -136,7 +136,7 @@ fn prune_stale_directory() -> Result<()> { fn prune_python_downloads() -> Result<()> { let context = uv_test::test_context!("3.12"); - let python_cache = context.cache_dir.child("python-v0"); + let python_cache = context.cache_dir.child("python-v1"); python_cache.create_dir_all()?; let download = python_cache.child("python.tar.gz"); download.write_binary(b"cached Python download")?; @@ -214,7 +214,7 @@ fn prune_stale_symlink() -> Result<()> { .success(); // Remove the wheels directory, causing the symlink to become stale. - let wheels = context.cache_dir.child("wheels-v6"); + let wheels = context.cache_dir.child("wheels-v7"); fs_err::remove_dir_all(wheels)?; let filters: Vec<_> = context @@ -441,7 +441,7 @@ fn prune_stale_revision() -> Result<()> { DEBUG Searching for user configuration in: `[UV_USER_CONFIG_DIR]/uv.toml` DEBUG uv [VERSION] ([COMMIT] DATE) Pruning cache at: [CACHE_DIR]/ - DEBUG Removing dangling source revision: [CACHE_DIR]/sdists-v9/[ENTRY] + DEBUG Removing dangling source revision: [CACHE_DIR]/sdists-v10/[ENTRY] DEBUG Removing dangling cache archive: [CACHE_DIR]/archive-v0/[ENTRY] Removed [N] files ([SIZE]) "); @@ -543,7 +543,7 @@ fn prune_stale_revision_content_addressed_cache() -> Result<()> { DEBUG Searching for user configuration in: `[UV_USER_CONFIG_DIR]/uv.toml` DEBUG uv [VERSION] ([COMMIT] DATE) Pruning cache at: [CACHE_DIR]/ - DEBUG Removing dangling source revision: [CACHE_DIR]/sdists-v9/[ENTRY] + DEBUG Removing dangling source revision: [CACHE_DIR]/sdists-v10/[ENTRY] Removed [N] files ([SIZE]) "); diff --git a/crates/uv/tests/it/branching_urls.rs b/crates/uv/tests/it/branching_urls.rs index 716519ef80a..584d374c644 100644 --- a/crates/uv/tests/it/branching_urls.rs +++ b/crates/uv/tests/it/branching_urls.rs @@ -199,7 +199,7 @@ fn root_package_splits_transitive_too() -> Result<()> { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11, <3.13" resolution-markers = [ "python_full_version >= '3.12'", @@ -393,7 +393,7 @@ fn root_package_splits_other_dependencies_too() -> Result<()> { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11, <3.13" resolution-markers = [ "python_full_version >= '3.12'", @@ -553,7 +553,7 @@ fn branching_between_registry_and_direct_url() -> Result<()> { // We have source dist and wheel for the registry, but only the wheel for the direct URL. assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11, <3.13" resolution-markers = [ "python_full_version >= '3.12'", @@ -637,7 +637,7 @@ fn branching_urls_of_different_sources_disjoint() -> Result<()> { // We have source dist and wheel for the registry, but only the wheel for the direct URL. assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11, <3.13" resolution-markers = [ "python_full_version >= '3.12'", @@ -761,7 +761,7 @@ fn dont_pre_visit_url_packages() -> Result<()> { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11, <3.13" [options] diff --git a/crates/uv/tests/it/snapshots/it__ecosystem__black-lock-file.snap b/crates/uv/tests/it/snapshots/it__ecosystem__black-lock-file.snap index 0d80f912075..510b86a98e9 100644 --- a/crates/uv/tests/it/snapshots/it__ecosystem__black-lock-file.snap +++ b/crates/uv/tests/it/snapshots/it__ecosystem__black-lock-file.snap @@ -3,7 +3,7 @@ source: crates/uv/tests/it/ecosystem.rs expression: lock --- version = 1 -revision = 3 +revision = 5 requires-python = ">=3.8" resolution-markers = [ "python_full_version >= '3.11' and implementation_name == 'pypy' and sys_platform == 'win32'", diff --git a/crates/uv/tests/it/snapshots/it__ecosystem__cibuildwheel-lock-file.snap b/crates/uv/tests/it/snapshots/it__ecosystem__cibuildwheel-lock-file.snap index 3529da50a55..7d6d760253a 100644 --- a/crates/uv/tests/it/snapshots/it__ecosystem__cibuildwheel-lock-file.snap +++ b/crates/uv/tests/it/snapshots/it__ecosystem__cibuildwheel-lock-file.snap @@ -3,7 +3,7 @@ source: crates/uv/tests/it/ecosystem.rs expression: lock --- version = 1 -revision = 3 +revision = 5 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.12'", diff --git a/crates/uv/tests/it/snapshots/it__ecosystem__cookiecutter-lock-file.snap b/crates/uv/tests/it/snapshots/it__ecosystem__cookiecutter-lock-file.snap index e977f9ef869..48401ca5f8f 100644 --- a/crates/uv/tests/it/snapshots/it__ecosystem__cookiecutter-lock-file.snap +++ b/crates/uv/tests/it/snapshots/it__ecosystem__cookiecutter-lock-file.snap @@ -3,7 +3,7 @@ source: crates/uv/tests/it/ecosystem.rs expression: lock --- version = 1 -revision = 3 +revision = 5 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.12'", diff --git a/crates/uv/tests/it/snapshots/it__ecosystem__flask-lock-file.snap b/crates/uv/tests/it/snapshots/it__ecosystem__flask-lock-file.snap index 4af9155a9be..9a79669669e 100644 --- a/crates/uv/tests/it/snapshots/it__ecosystem__flask-lock-file.snap +++ b/crates/uv/tests/it/snapshots/it__ecosystem__flask-lock-file.snap @@ -3,7 +3,7 @@ source: crates/uv/tests/it/ecosystem.rs expression: lock --- version = 1 -revision = 3 +revision = 5 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.15'", @@ -15,6 +15,9 @@ resolution-markers = [ [options] exclude-newer = "2026-06-30T00:00:00Z" +[manifest.default-groups] +flask = ["dev", "pre-commit", "tests", "typing"] + [[package]] name = "alabaster" version = "1.0.0" diff --git a/crates/uv/tests/it/snapshots/it__ecosystem__github-wikidata-bot-lock-file.snap b/crates/uv/tests/it/snapshots/it__ecosystem__github-wikidata-bot-lock-file.snap index 43f7ea67dc7..eb1fd367aba 100644 --- a/crates/uv/tests/it/snapshots/it__ecosystem__github-wikidata-bot-lock-file.snap +++ b/crates/uv/tests/it/snapshots/it__ecosystem__github-wikidata-bot-lock-file.snap @@ -3,7 +3,7 @@ source: crates/uv/tests/it/ecosystem.rs expression: lock --- version = 1 -revision = 3 +revision = 5 requires-python = ">=3.12" [options] diff --git a/crates/uv/tests/it/snapshots/it__ecosystem__home-assistant-core-lock-file.snap b/crates/uv/tests/it/snapshots/it__ecosystem__home-assistant-core-lock-file.snap index e05c372c4f0..4a7212877a0 100644 --- a/crates/uv/tests/it/snapshots/it__ecosystem__home-assistant-core-lock-file.snap +++ b/crates/uv/tests/it/snapshots/it__ecosystem__home-assistant-core-lock-file.snap @@ -3,7 +3,7 @@ source: crates/uv/tests/it/ecosystem.rs expression: lock --- version = 1 -revision = 3 +revision = 5 requires-python = ">=3.12.[X]" resolution-markers = [ "python_full_version >= '3.14'", diff --git a/crates/uv/tests/it/snapshots/it__ecosystem__httpx-lock-file.snap b/crates/uv/tests/it/snapshots/it__ecosystem__httpx-lock-file.snap index 81592accce8..2fc9ad9f324 100644 --- a/crates/uv/tests/it/snapshots/it__ecosystem__httpx-lock-file.snap +++ b/crates/uv/tests/it/snapshots/it__ecosystem__httpx-lock-file.snap @@ -3,7 +3,7 @@ source: crates/uv/tests/it/ecosystem.rs expression: lock --- version = 1 -revision = 3 +revision = 5 requires-python = ">=3.9" resolution-markers = [ "python_full_version >= '3.13'", diff --git a/crates/uv/tests/it/snapshots/it__ecosystem__jupyterlab-lock-file.snap b/crates/uv/tests/it/snapshots/it__ecosystem__jupyterlab-lock-file.snap index 4f4e536afa6..f916e8a3eaf 100644 --- a/crates/uv/tests/it/snapshots/it__ecosystem__jupyterlab-lock-file.snap +++ b/crates/uv/tests/it/snapshots/it__ecosystem__jupyterlab-lock-file.snap @@ -3,7 +3,7 @@ source: crates/uv/tests/it/ecosystem.rs expression: lock --- version = 1 -revision = 3 +revision = 5 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14'", diff --git a/crates/uv/tests/it/snapshots/it__ecosystem__llm-lock-file.snap b/crates/uv/tests/it/snapshots/it__ecosystem__llm-lock-file.snap index f05154632be..e069ba46b4e 100644 --- a/crates/uv/tests/it/snapshots/it__ecosystem__llm-lock-file.snap +++ b/crates/uv/tests/it/snapshots/it__ecosystem__llm-lock-file.snap @@ -3,7 +3,7 @@ source: crates/uv/tests/it/ecosystem.rs expression: lock --- version = 1 -revision = 3 +revision = 5 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.15'", diff --git a/crates/uv/tests/it/snapshots/it__ecosystem__openai-python-lock-file.snap b/crates/uv/tests/it/snapshots/it__ecosystem__openai-python-lock-file.snap index f92bc51c839..225ca5b4463 100644 --- a/crates/uv/tests/it/snapshots/it__ecosystem__openai-python-lock-file.snap +++ b/crates/uv/tests/it/snapshots/it__ecosystem__openai-python-lock-file.snap @@ -3,7 +3,7 @@ source: crates/uv/tests/it/ecosystem.rs expression: lock --- version = 1 -revision = 3 +revision = 5 requires-python = ">=3.9" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", diff --git a/crates/uv/tests/it/snapshots/it__ecosystem__packse-lock-file.snap b/crates/uv/tests/it/snapshots/it__ecosystem__packse-lock-file.snap index 31c3ecde0cc..2a37a49758e 100644 --- a/crates/uv/tests/it/snapshots/it__ecosystem__packse-lock-file.snap +++ b/crates/uv/tests/it/snapshots/it__ecosystem__packse-lock-file.snap @@ -3,7 +3,7 @@ source: crates/uv/tests/it/ecosystem.rs expression: lock --- version = 1 -revision = 3 +revision = 5 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.13'", diff --git a/crates/uv/tests/it/snapshots/it__ecosystem__pandas-lock-file.snap b/crates/uv/tests/it/snapshots/it__ecosystem__pandas-lock-file.snap index 2284c77d0ba..9371048263d 100644 --- a/crates/uv/tests/it/snapshots/it__ecosystem__pandas-lock-file.snap +++ b/crates/uv/tests/it/snapshots/it__ecosystem__pandas-lock-file.snap @@ -3,7 +3,7 @@ source: crates/uv/tests/it/ecosystem.rs expression: lock --- version = 1 -revision = 3 +revision = 5 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", diff --git a/crates/uv/tests/it/snapshots/it__ecosystem__poetry-lock-file.snap b/crates/uv/tests/it/snapshots/it__ecosystem__poetry-lock-file.snap index 240e1f76be9..bbbe7fb13e8 100644 --- a/crates/uv/tests/it/snapshots/it__ecosystem__poetry-lock-file.snap +++ b/crates/uv/tests/it/snapshots/it__ecosystem__poetry-lock-file.snap @@ -3,7 +3,7 @@ source: crates/uv/tests/it/ecosystem.rs expression: lock --- version = 1 -revision = 3 +revision = 5 requires-python = ">=3.10, <4.0" [options] diff --git a/crates/uv/tests/it/snapshots/it__ecosystem__pytest-cov-lock-file.snap b/crates/uv/tests/it/snapshots/it__ecosystem__pytest-cov-lock-file.snap index 7fcca16a246..c8804a0212f 100644 --- a/crates/uv/tests/it/snapshots/it__ecosystem__pytest-cov-lock-file.snap +++ b/crates/uv/tests/it/snapshots/it__ecosystem__pytest-cov-lock-file.snap @@ -3,7 +3,7 @@ source: crates/uv/tests/it/ecosystem.rs expression: lock --- version = 1 -revision = 3 +revision = 5 requires-python = ">=3.9" resolution-markers = [ "python_full_version >= '3.10'", diff --git a/crates/uv/tests/it/snapshots/it__ecosystem__pyx-external-lock-file.snap b/crates/uv/tests/it/snapshots/it__ecosystem__pyx-external-lock-file.snap index b413159b795..144f0db6552 100644 --- a/crates/uv/tests/it/snapshots/it__ecosystem__pyx-external-lock-file.snap +++ b/crates/uv/tests/it/snapshots/it__ecosystem__pyx-external-lock-file.snap @@ -3,7 +3,7 @@ source: crates/uv/tests/it/ecosystem.rs expression: lock --- version = 1 -revision = 3 +revision = 5 requires-python = "==3.14.*" [options] diff --git a/crates/uv/tests/it/snapshots/it__ecosystem__pyx-workspace-lock-file.snap b/crates/uv/tests/it/snapshots/it__ecosystem__pyx-workspace-lock-file.snap index 6a87514e047..38bfc4cbda5 100644 --- a/crates/uv/tests/it/snapshots/it__ecosystem__pyx-workspace-lock-file.snap +++ b/crates/uv/tests/it/snapshots/it__ecosystem__pyx-workspace-lock-file.snap @@ -3,7 +3,7 @@ source: crates/uv/tests/it/ecosystem.rs expression: lock --- version = 1 -revision = 3 +revision = 5 requires-python = "==3.14.*" [options] diff --git a/crates/uv/tests/it/snapshots/it__ecosystem__saleor-lock-file.snap b/crates/uv/tests/it/snapshots/it__ecosystem__saleor-lock-file.snap index fca6857401f..ab96200aaec 100644 --- a/crates/uv/tests/it/snapshots/it__ecosystem__saleor-lock-file.snap +++ b/crates/uv/tests/it/snapshots/it__ecosystem__saleor-lock-file.snap @@ -3,7 +3,7 @@ source: crates/uv/tests/it/ecosystem.rs expression: lock --- version = 1 -revision = 3 +revision = 5 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.14'", diff --git a/crates/uv/tests/it/snapshots/it__ecosystem__semantic-kernel-lock-file.snap b/crates/uv/tests/it/snapshots/it__ecosystem__semantic-kernel-lock-file.snap index 643088b0bb3..6ad66b230f9 100644 --- a/crates/uv/tests/it/snapshots/it__ecosystem__semantic-kernel-lock-file.snap +++ b/crates/uv/tests/it/snapshots/it__ecosystem__semantic-kernel-lock-file.snap @@ -3,7 +3,7 @@ source: crates/uv/tests/it/ecosystem.rs expression: lock --- version = 1 -revision = 3 +revision = 5 requires-python = ">=3.10, <3.13" resolution-markers = [ "python_full_version >= '3.12' and sys_platform == 'darwin'", diff --git a/crates/uv/tests/it/snapshots/it__ecosystem__sentry-lock-file.snap b/crates/uv/tests/it/snapshots/it__ecosystem__sentry-lock-file.snap index 2ea48c2420d..d6b93141df3 100644 --- a/crates/uv/tests/it/snapshots/it__ecosystem__sentry-lock-file.snap +++ b/crates/uv/tests/it/snapshots/it__ecosystem__sentry-lock-file.snap @@ -3,7 +3,7 @@ source: crates/uv/tests/it/ecosystem.rs expression: lock --- version = 1 -revision = 3 +revision = 5 requires-python = "==3.13.*" resolution-markers = [ "sys_platform == 'darwin' or sys_platform == 'linux'", diff --git a/crates/uv/tests/it/snapshots/it__ecosystem__transformers-lock-file.snap b/crates/uv/tests/it/snapshots/it__ecosystem__transformers-lock-file.snap index 89c5ec65293..d818f7b05e5 100644 --- a/crates/uv/tests/it/snapshots/it__ecosystem__transformers-lock-file.snap +++ b/crates/uv/tests/it/snapshots/it__ecosystem__transformers-lock-file.snap @@ -3,7 +3,7 @@ source: crates/uv/tests/it/ecosystem.rs expression: lock --- version = 1 -revision = 3 +revision = 5 requires-python = ">=3.9.0" resolution-markers = [ "python_full_version >= '3.14' and platform_machine != 's390x' and sys_platform == 'darwin'", diff --git a/crates/uv/tests/it/snapshots/it__ecosystem__warehouse-lock-file.snap b/crates/uv/tests/it/snapshots/it__ecosystem__warehouse-lock-file.snap index ee6f04cb9a4..60c4fe9c6f5 100644 --- a/crates/uv/tests/it/snapshots/it__ecosystem__warehouse-lock-file.snap +++ b/crates/uv/tests/it/snapshots/it__ecosystem__warehouse-lock-file.snap @@ -3,7 +3,7 @@ source: crates/uv/tests/it/ecosystem.rs expression: lock --- version = 1 -revision = 3 +revision = 5 requires-python = "==3.11.*" [options] diff --git a/crates/uv/tests/it/snapshots/it__ecosystem__zulip-lock-file.snap b/crates/uv/tests/it/snapshots/it__ecosystem__zulip-lock-file.snap index 4f50c9e502b..4e0f1ded9bf 100644 --- a/crates/uv/tests/it/snapshots/it__ecosystem__zulip-lock-file.snap +++ b/crates/uv/tests/it/snapshots/it__ecosystem__zulip-lock-file.snap @@ -3,7 +3,7 @@ source: crates/uv/tests/it/ecosystem.rs expression: lock --- version = 1 -revision = 3 +revision = 5 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.15' and platform_python_implementation == 'CPython'", diff --git a/crates/uv/tests/it/version.rs b/crates/uv/tests/it/version.rs index 53b202b454c..f180decb0a5 100644 --- a/crates/uv/tests/it/version.rs +++ b/crates/uv/tests/it/version.rs @@ -2603,7 +2603,7 @@ fn version_bump_locked_preserves_pyproject() -> Result<()> { "#); assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -2796,7 +2796,7 @@ fn version_set_workspace() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -2851,7 +2851,7 @@ fn version_set_workspace() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -2959,7 +2959,7 @@ fn version_set_workspace() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -3101,7 +3101,7 @@ fn version_set_evil_constraints() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -3182,7 +3182,7 @@ fn version_set_evil_constraints() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] diff --git a/crates/uv/tests/lock/lock.rs b/crates/uv/tests/lock/lock.rs index 2bceb3037ee..81dd4a1f0d3 100644 --- a/crates/uv/tests/lock/lock.rs +++ b/crates/uv/tests/lock/lock.rs @@ -125,7 +125,6 @@ fn lock_without_package_metadata(lock: &str) -> Result { } package.remove("metadata"); } - lock["revision"] = toml_edit::value(4); Ok(lock) } @@ -211,7 +210,7 @@ fn lock_equivalent_requirements() -> Result<()> { insta::with_settings!({ filters => context.filters() }, { assert_snapshot!(locked, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -287,7 +286,7 @@ fn lock_equivalent_requirements() -> Result<()> { insta::with_settings!({ filters => context.filters() }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -394,7 +393,7 @@ fn lock_equivalent_manifest_inputs() -> Result<()> { let locked = context.read("uv.lock"); assert_snapshot!(locked, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -581,7 +580,7 @@ fn lock_equivalent_pruned_inputs() -> Result<()> { insta::with_settings!({ filters => context.filters() }, { assert_snapshot!(locked, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -717,7 +716,7 @@ fn lock_wheel_registry() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -835,7 +834,7 @@ fn lock_sdist_registry() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -964,7 +963,7 @@ fn lock_sdist_git() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -1042,7 +1041,7 @@ fn lock_sdist_git() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -1097,7 +1096,7 @@ fn lock_sdist_git() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -1151,7 +1150,7 @@ fn lock_sdist_git() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -1210,7 +1209,7 @@ fn lock_sdist_git_subdirectory() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -1292,7 +1291,7 @@ fn lock_sdist_git_pep508() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -1350,7 +1349,7 @@ fn lock_sdist_git_pep508() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -1402,7 +1401,7 @@ fn lock_sdist_git_pep508() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -1453,7 +1452,7 @@ fn lock_sdist_git_pep508() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -1515,7 +1514,7 @@ fn lock_sdist_git_short_rev() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -1616,7 +1615,7 @@ fn lock_sdist_git_archive() -> Result<()> { assert_snapshot!( lock, @r###" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -1754,7 +1753,7 @@ fn lock_wheel_git_archive() -> Result<()> { assert_snapshot!( lock, @r###" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -1890,7 +1889,7 @@ fn lock_wheel_url() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -2032,7 +2031,7 @@ fn lock_sdist_url() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -2950,7 +2949,7 @@ async fn lock_sdist_url_locked_hash_mismatch() -> Result<()> { }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -3081,7 +3080,7 @@ async fn lock_sdist_url_locked_hash_mismatch() -> Result<()> { }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -3787,7 +3786,7 @@ fn lock_sdist_url_subdirectory() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -3910,7 +3909,7 @@ fn lock_sdist_url_subdirectory_pep508() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -4036,7 +4035,7 @@ fn lock_project_extra() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -4223,7 +4222,7 @@ fn lock_project_with_scoped_overrides() -> Result<()> { }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -4701,7 +4700,7 @@ fn lock_project_with_excludes() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -5084,7 +5083,7 @@ fn lock_dependency_extra() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -5274,7 +5273,7 @@ fn lock_conditional_dependency_extra() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.7" resolution-markers = [ "python_full_version >= '3.10'", @@ -5563,7 +5562,7 @@ fn lock_dependency_non_existent_extra() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -5785,7 +5784,7 @@ fn lock_conflicting_project_basic1() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "project", group = "foo" }, @@ -5990,7 +5989,7 @@ fn lock_conflicting_workspace_members() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "example" }, @@ -6241,7 +6240,7 @@ fn lock_conflicting_workspace_members_depends_direct_extra() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "example", extra = "foo" }, @@ -6559,7 +6558,7 @@ fn lock_conflicting_workspace_members_depends_transitive_extra() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "example" }, @@ -6742,7 +6741,7 @@ fn lock_conflicting_project_basic2() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "example", group = "foo" }, @@ -6943,7 +6942,7 @@ fn lock_conflicting_mixed() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "project", extra = "project2" }, @@ -7093,7 +7092,7 @@ fn lock_upgrade_log() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -7169,7 +7168,7 @@ fn lock_upgrade_log() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -7255,7 +7254,7 @@ fn lock_upgrade_log_multi_version() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform != 'win32'", @@ -7335,7 +7334,7 @@ fn lock_upgrade_log_multi_version() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -7489,7 +7488,7 @@ fn lock_check_refresh_workspace_conflicts() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "package-a", extra = "non-prod" }, @@ -7632,7 +7631,7 @@ fn lock_preference() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -7689,7 +7688,7 @@ fn lock_preference() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -7733,7 +7732,7 @@ fn lock_preference() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -7799,7 +7798,7 @@ fn lock_git_plus_prefix() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -7876,7 +7875,7 @@ fn lock_partial_git() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.12'", @@ -8152,7 +8151,7 @@ fn lock_git_sha() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -8245,7 +8244,7 @@ fn lock_requires_python() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.7" resolution-markers = [ "python_full_version >= '3.8'", @@ -8533,7 +8532,7 @@ fn lock_requires_python() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.7.9" resolution-markers = [ "python_full_version >= '3.8'", @@ -8750,7 +8749,7 @@ fn lock_requires_python() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -8881,7 +8880,7 @@ fn lock_requires_python_upper() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = "==3.11.*" [options] @@ -9000,7 +8999,7 @@ fn lock_requires_python_exact() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = "==3.13" [options] @@ -9128,7 +9127,7 @@ fn lock_requires_python_fork() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.9" [options] @@ -9235,7 +9234,7 @@ fn lock_requires_python_fork_wheels() -> Result<()> { insta::with_settings!({ filters => context.filters() }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.13'", @@ -9480,7 +9479,7 @@ fn lock_requires_python_wheels() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = "==3.12.*" [options] @@ -9559,7 +9558,7 @@ fn lock_requires_python_wheels() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = "==3.11.*" [options] @@ -9648,7 +9647,7 @@ fn lock_requires_python_star() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = "==3.11.*" [options] @@ -9765,7 +9764,7 @@ fn lock_requires_python_not_equal() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">3.10, !=3.10.9, !=3.10.10, !=3.11.*, <3.13" [options] @@ -9837,7 +9836,7 @@ fn lock_requires_python_not_equal_consecutive_wildcards() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.10, !=3.11.*, !=3.12.*, <3.14" [options] @@ -9911,7 +9910,7 @@ fn lock_requires_python_pre() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11" [options] @@ -10028,7 +10027,7 @@ fn lock_requires_python_unbounded() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = "<=3.12" resolution-markers = [ "python_full_version >= '3.7'", @@ -10162,7 +10161,7 @@ fn lock_requires_python_maximum_version() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.8" resolution-markers = [ "python_full_version >= '3.9'", @@ -10316,7 +10315,7 @@ fn lock_requires_python_fewest_versions() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.8" [options] @@ -10428,7 +10427,7 @@ fn lock_python_version_marker_complement() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.8" resolution-markers = [ "python_full_version >= '3.11'", @@ -10535,7 +10534,7 @@ fn lock_dev() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -10641,7 +10640,7 @@ fn lock_conditional_unconditional() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -10714,7 +10713,7 @@ fn lock_multiple_markers() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -10824,7 +10823,7 @@ fn lock_relative_and_absolute_paths() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11, <3.13" [options] @@ -11861,7 +11860,7 @@ fn lock_pep508_urls_with_vars() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11, <3.13" [options] @@ -11969,7 +11968,7 @@ fn lock_constraint_dependency_absolute_path() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -12107,7 +12106,7 @@ fn lock_index_absolute_path_from_config() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -12169,7 +12168,7 @@ fn lock_cycles() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -12364,7 +12363,7 @@ fn lock_new_extras() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -12485,7 +12484,7 @@ fn lock_new_extras() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -12946,7 +12945,7 @@ fn lock_mixed_hashes() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.13" [[package]] @@ -13022,7 +13021,7 @@ fn lock_mixed_hashes() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.13" [[package]] @@ -13256,7 +13255,7 @@ async fn lock_index_hash_algorithm() -> Result<()> { }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.13" [[package]] @@ -13295,7 +13294,7 @@ async fn lock_index_hash_algorithm() -> Result<()> { }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.13" [[package]] @@ -13425,7 +13424,7 @@ fn lock_resolution_mode() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -13500,7 +13499,7 @@ fn lock_resolution_mode() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -13642,7 +13641,7 @@ fn lock_prerelease_package_configuration() -> Result<()> { insta::with_settings!({ filters => context.filters() }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -13783,7 +13782,7 @@ fn lock_same_version_multiple_urls() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform == 'darwin'", @@ -13995,7 +13994,7 @@ fn lock_exclusion() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -14301,7 +14300,7 @@ fn lock_workspace_member_with_standalone_path_source() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform == 'darwin'", @@ -14397,7 +14396,7 @@ fn lock_external_workspace_source() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -14583,7 +14582,7 @@ fn lock_peer_member() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -14671,7 +14670,7 @@ fn lock_workspace_member_with_fragment_delimiter() -> Result<()> { }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -14780,7 +14779,7 @@ async fn lock_index_workspace_member() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -14928,7 +14927,7 @@ fn lock_dev_transitive() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -15057,7 +15056,7 @@ async fn lock_redact_http() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -15290,7 +15289,7 @@ fn lock_redact_git_pep508() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -15369,7 +15368,7 @@ fn lock_redact_git_sources() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -15446,7 +15445,7 @@ fn lock_redact_git_pep508_non_project() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -15522,7 +15521,7 @@ async fn lock_redact_index_sources() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -15603,7 +15602,7 @@ async fn lock_redact_url_sources() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -15702,7 +15701,7 @@ async fn lock_env_credentials() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -15863,7 +15862,7 @@ async fn lock_relative_index() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -15968,7 +15967,7 @@ fn lock_no_sources() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -16053,7 +16052,7 @@ fn lock_no_sources() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -16219,7 +16218,7 @@ fn lock_migrate() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -16313,7 +16312,7 @@ fn lock_upgrade_package() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -16403,7 +16402,7 @@ fn lock_upgrade_package() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -16481,7 +16480,7 @@ fn lock_upgrade_package() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -17022,7 +17021,7 @@ fn lock_find_links_local_wheel() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -17135,7 +17134,7 @@ fn lock_find_links_ignore_explicit_index() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -17247,7 +17246,7 @@ fn lock_find_links_relative_url() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -17355,7 +17354,7 @@ fn lock_find_links_local_sdist() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -17431,7 +17430,7 @@ fn lock_find_links_http_wheel() -> Result<()> { Resolved 2 packages in [TIME] "); - assert!(context.cache_dir.child("flat-index-v5").is_dir()); + assert!(context.cache_dir.child("flat-index-v6").is_dir()); let lock = context.read("uv.lock"); @@ -17441,7 +17440,7 @@ fn lock_find_links_http_wheel() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -17525,7 +17524,7 @@ fn lock_find_links_http_sdist() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -17635,7 +17634,7 @@ fn lock_find_links_explicit_index() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -17732,7 +17731,7 @@ fn lock_find_links_higher_priority_index() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -17825,7 +17824,7 @@ fn lock_find_links_lower_priority_index() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -17959,7 +17958,7 @@ fn lock_local_index() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.13" [[package]] @@ -18039,7 +18038,7 @@ fn lock_sources_url() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -18233,7 +18232,7 @@ fn lock_sources_archive() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -18375,7 +18374,7 @@ fn lock_sources_source_tree() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -18504,7 +18503,7 @@ fn lock_editable() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -18679,7 +18678,7 @@ fn lock_mixed_extras() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -18865,7 +18864,7 @@ fn lock_transitive_extra() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -19005,7 +19004,7 @@ fn lock_mismatched_sources() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -19046,7 +19045,7 @@ fn lock_mismatched_sources() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -19111,7 +19110,7 @@ fn lock_mismatched_versions() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -19194,7 +19193,7 @@ fn lock_no_sources_package() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -19298,7 +19297,7 @@ fn lock_no_sources_package_multiple() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -19412,7 +19411,7 @@ fn lock_no_sources_with_no_sources_package() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -19518,7 +19517,7 @@ fn lock_no_sources_package_env_var() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -19829,7 +19828,7 @@ fn lock_reuses_newer_exclude_newer_timestamp() -> Result<()> { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11" [options] @@ -20008,7 +20007,7 @@ fn normalize_false_marker_dependency_groups() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11" [options] @@ -20074,7 +20073,7 @@ fn normalize_false_marker_requires_dist() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11" [options] @@ -20132,7 +20131,7 @@ fn lock_impossible_platform_markers() -> Result<()> { }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.9" [options] @@ -20186,7 +20185,7 @@ async fn lock_change_index() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -20230,7 +20229,7 @@ async fn lock_change_index() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -20319,7 +20318,7 @@ fn lock_remove_member() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -20436,7 +20435,7 @@ fn lock_remove_member() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -20538,7 +20537,7 @@ fn lock_remove_member() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -20594,7 +20593,7 @@ fn lock_add_member_with_build_system() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -20692,7 +20691,7 @@ fn lock_add_member_with_build_system() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -20791,7 +20790,7 @@ fn lock_add_member_without_build_system() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -20885,7 +20884,7 @@ fn lock_add_member_without_build_system() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -20997,7 +20996,7 @@ fn lock_add_member_without_build_system() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -21105,7 +21104,7 @@ fn lock_redundant_add_member() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -21203,7 +21202,7 @@ fn lock_redundant_add_member() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -21293,7 +21292,7 @@ fn lock_new_constraints() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -21393,7 +21392,7 @@ fn lock_new_constraints() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -21496,7 +21495,7 @@ fn lock_remove_member_non_project() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -21598,7 +21597,7 @@ fn lock_remove_member_non_project() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -21643,7 +21642,7 @@ fn lock_rename_project() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -21717,7 +21716,7 @@ fn lock_rename_project() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -21749,7 +21748,7 @@ fn lock_rename_project() -> Result<()> { Ok(()) } -/// Write metadata-free revision 1.4 locks without invalidating fresh revision 1.3 locks. +/// Write metadata-free locks without invalidating locks that include package metadata. #[cfg(feature = "test-universal")] #[test] fn lock_writes_without_package_metadata() -> Result<()> { @@ -21782,7 +21781,7 @@ fn lock_writes_without_package_metadata() -> Result<()> { let preview_lock = context.read("uv.lock"); assert_snapshot!(preview_lock, @r#" version = 1 - revision = 4 + revision = 5 requires-python = ">=3.12" [options] @@ -21811,8 +21810,7 @@ fn lock_writes_without_package_metadata() -> Result<()> { Resolved 1 package in [TIME] "); - // Re-lock without the preview feature, causing the lockfile to be invalid and be reverted to - // 1.3. + // Re-lock without the preview feature, restoring the package metadata. uv_snapshot!(context.filters(), context.lock().arg("--offline"), @" exit_code: 0 (success) ----- stderr ----- @@ -21822,7 +21820,7 @@ fn lock_writes_without_package_metadata() -> Result<()> { let standard_lock = context.read("uv.lock"); assert_snapshot!(standard_lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -22152,7 +22150,7 @@ fn lock_metadata_free_many_conflicts() -> Result<()> { .child("uv.lock") .write_str(&formatdoc! {r#" version = 1 - revision = 4 + revision = 5 requires-python = ">=3.12" conflicts = [ {lock_conflicts} @@ -22336,7 +22334,7 @@ fn lock_removed_empty_extra() -> Result<()> { assert_snapshot!( context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -24949,7 +24947,7 @@ fn lock_regenerates_marker_specific_requested_extras() -> Result<()> { lockfile.write_str(&formatdoc! {r#" version = 1 - revision = 4 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform != 'win32'", @@ -25098,7 +25096,7 @@ fn lock_missing_metadata() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -25247,7 +25245,7 @@ fn lock_dev_dependencies_alias() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -25328,7 +25326,7 @@ fn lock_reorder() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -25470,7 +25468,7 @@ fn lock_narrowed_python_version_upper() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.7, <4" resolution-markers = [ "python_full_version >= '3.10'", @@ -25576,7 +25574,7 @@ fn lock_narrowed_python_version() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.7" resolution-markers = [ "python_full_version >= '3.11'", @@ -25671,7 +25669,7 @@ fn lock_exclude_unnecessary_python_forks() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform == 'darwin'", @@ -25774,7 +25772,7 @@ fn lock_constrained_environment() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform != 'win32'", @@ -25939,7 +25937,7 @@ fn lock_constrained_environment() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -26083,7 +26081,7 @@ fn lock_constrained_environment_non_project() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform != 'win32'", @@ -26262,7 +26260,7 @@ fn lock_non_project_fork() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.11'", @@ -26439,7 +26437,7 @@ fn lock_non_project_conditional() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -26538,7 +26536,7 @@ fn lock_non_project_group() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.10" [options] @@ -26668,7 +26666,7 @@ fn lock_non_project_group_standard() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.10" [options] @@ -26754,7 +26752,7 @@ fn lock_non_project_sources() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -26878,7 +26876,7 @@ fn lock_non_project_member_conflicts() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "member-a" }, @@ -27035,7 +27033,7 @@ fn lock_dropped_dev_extra() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -27142,7 +27140,7 @@ fn lock_empty_dev_dependencies() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -27238,7 +27236,7 @@ fn lock_empty_dependency_group() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -27328,7 +27326,7 @@ fn lock_add_empty_dependency_group() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -27403,7 +27401,7 @@ fn lock_add_empty_dependency_group() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -27478,7 +27476,7 @@ fn lock_add_empty_dependency_group() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -27551,7 +27549,7 @@ fn lock_trailing_slash_index_url() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -27714,7 +27712,7 @@ fn lock_explicit_index() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -27820,7 +27818,7 @@ fn lock_explicit_default_index() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -27907,7 +27905,7 @@ fn lock_explicit_default_index() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -28071,7 +28069,7 @@ async fn lock_named_index() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -28138,7 +28136,7 @@ fn lock_default_index() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -28198,7 +28196,7 @@ fn lock_default_index() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -28273,7 +28271,7 @@ fn lock_named_index_cli() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -28559,7 +28557,7 @@ fn lock_repeat_named_index_member() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -28646,7 +28644,7 @@ fn lock_unique_named_index() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -28718,7 +28716,7 @@ fn lock_repeat_named_index_cli() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -28783,7 +28781,7 @@ fn lock_repeat_named_index_cli() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -28880,7 +28878,7 @@ fn lock_named_index_overlap() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform == 'linux'", @@ -28961,7 +28959,7 @@ fn lock_explicit_virtual_project() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -29172,7 +29170,7 @@ fn lock_implicit_virtual_project() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -29392,7 +29390,7 @@ fn lock_implicit_package_path() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -29563,7 +29561,7 @@ fn lock_split_python_environment() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.7" resolution-markers = [ "python_full_version >= '3.8'", @@ -29677,7 +29675,7 @@ fn lock_fork_strategy_with_python_environments() -> Result<()> { }, { assert_snapshot!(context.read("requires-python/uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11, <3.13" resolution-markers = [ "python_full_version >= '3.12'", @@ -29766,7 +29764,7 @@ fn lock_fork_strategy_with_python_environments() -> Result<()> { }, { assert_snapshot!(context.read("fewest/uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11, <3.13" resolution-markers = [ "python_full_version < '3.12'", @@ -29841,7 +29839,7 @@ fn lock_fork_strategy_with_python_environments() -> Result<()> { }, { assert_snapshot!(context.read("lowest/uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11, <3.13" resolution-markers = [ "python_full_version < '3.12'", @@ -29917,7 +29915,7 @@ fn lock_python_upper_bound() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.8" resolution-markers = [ "python_full_version >= '3.9' and python_full_version < '3.13'", @@ -30281,7 +30279,7 @@ fn lock_simplified_environments() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = "==3.11.*" resolution-markers = [ "sys_platform == 'darwin'", @@ -30380,7 +30378,7 @@ fn lock_dependency_metadata() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -30596,7 +30594,7 @@ fn lock_dependency_metadata_git() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -30696,7 +30694,7 @@ fn lock_strip_fragment() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -31098,7 +31096,7 @@ fn lock_change_requires_python() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.13'", @@ -31205,7 +31203,7 @@ fn lock_change_requires_python() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.13'", @@ -31350,7 +31348,7 @@ async fn lock_keyring_credentials() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -31537,7 +31535,7 @@ async fn lock_keyring_credentials_always_authenticate_fetches_username() -> Resu assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -31662,7 +31660,7 @@ fn lock_multiple_sources() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform != 'win32'", @@ -31835,7 +31833,7 @@ fn lock_multiple_sources_index_disjoint_markers() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform == 'win32'", @@ -31961,7 +31959,7 @@ fn lock_multiple_sources_index_mixed() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform == 'win32'", @@ -32090,7 +32088,7 @@ fn lock_multiple_sources_index_non_total() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform == 'win32'", @@ -32186,7 +32184,7 @@ fn lock_multiple_sources_index_explicit() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform == 'win32'", @@ -32327,7 +32325,7 @@ fn lock_multiple_sources_non_total() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform == 'darwin'", @@ -32423,7 +32421,7 @@ fn lock_multiple_sources_respect_marker() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -32522,7 +32520,7 @@ fn lock_extra_marker_preserves_production_platform() -> Result<()> { }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -32622,7 +32620,7 @@ fn lock_multiple_sources_extra() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -33024,7 +33022,7 @@ fn lock_group_include() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -33225,7 +33223,7 @@ fn lock_group_requires_python() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.13'", @@ -33235,6 +33233,9 @@ fn lock_group_requires_python() -> Result<()> { [options] exclude-newer = "2024-03-25T00:00:00Z" + [manifest.group-metadata] + project = { bar = { requires-python = ">=3.13" } } + [[package]] name = "idna" version = "3.6" @@ -33361,7 +33362,7 @@ fn lock_group_includes_requires_python() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.13.1'", @@ -33373,6 +33374,9 @@ fn lock_group_includes_requires_python() -> Result<()> { [options] exclude-newer = "2024-03-25T00:00:00Z" + [manifest.group-metadata] + project = { bar = { requires-python = ">=3.13" }, baz = { requires-python = ">=3.13,>=3.13.1" }, blargh = { requires-python = ">=3.12.[X],>=3.13" }, foo = { requires-python = ">=3.13" } } + [[package]] name = "idna" version = "3.6" @@ -33587,7 +33591,7 @@ fn lock_group_includes_requires_python_contradiction() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.13'", @@ -33597,6 +33601,9 @@ fn lock_group_includes_requires_python_contradiction() -> Result<()> { [options] exclude-newer = "2024-03-25T00:00:00Z" + [manifest.group-metadata] + project = { bar = { requires-python = ">=3.13" }, foo = { requires-python = "<3.13,>=3.13" } } + [[package]] name = "idna" version = "3.6" @@ -34057,7 +34064,7 @@ fn lock_group_workspace() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -34245,7 +34252,7 @@ fn lock_transitive_git() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -34397,7 +34404,7 @@ fn lock_dynamic_version() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -34433,7 +34440,7 @@ fn lock_dynamic_version() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -34502,7 +34509,7 @@ fn lock_dynamic_version_dependencies() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -34538,7 +34545,7 @@ fn lock_dynamic_version_dependencies() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -34725,7 +34732,7 @@ fn lock_dynamic_version_workspace_member() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -34792,7 +34799,7 @@ fn lock_dynamic_version_workspace_member() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -34908,7 +34915,7 @@ fn lock_dynamic_version_path_dependency() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -34969,7 +34976,7 @@ fn lock_dynamic_version_path_dependency() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -35067,7 +35074,7 @@ fn lock_dynamic_version_self_extra_hatchling() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -35235,7 +35242,7 @@ fn lock_dynamic_version_self_extra_setuptools() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -35395,7 +35402,7 @@ fn lock_dynamic_built_cache() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -35436,7 +35443,7 @@ fn lock_dynamic_built_cache() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -35509,7 +35516,7 @@ fn lock_shared_build_dependency() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.8" resolution-markers = [ "python_full_version >= '3.9'", @@ -35783,7 +35790,7 @@ fn lock_dynamic_to_static() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -35836,7 +35843,7 @@ fn lock_dynamic_to_static() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -35888,7 +35895,7 @@ fn lock_static_to_dynamic() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -35961,7 +35968,7 @@ fn lock_static_to_dynamic() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -36007,7 +36014,7 @@ fn lock_bump_static_version() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -36057,7 +36064,7 @@ fn lock_bump_static_version() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -36325,7 +36332,7 @@ fn lock_relative_project() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -36419,7 +36426,7 @@ fn lock_recursive_extra() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -36598,7 +36605,7 @@ fn lock_no_build_static_metadata() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -37046,7 +37053,7 @@ fn lock_self_compatible() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -37135,7 +37142,7 @@ fn lock_self_exact() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -37255,7 +37262,7 @@ fn lock_self_extra_to_extra_compatible() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -37411,7 +37418,7 @@ fn lock_self_extra_compatible() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -37532,7 +37539,7 @@ fn lock_self_marker_compatible() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -37654,7 +37661,7 @@ fn lock_split_on_windows() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform != 'win32'", @@ -37773,7 +37780,7 @@ fn lock_arm() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "platform_machine == 'arm64'", @@ -37846,7 +37853,7 @@ fn lock_x86_64() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "platform_machine == 'x86_64'", @@ -37920,7 +37927,7 @@ fn lock_x86() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "platform_machine == 'i686'", @@ -37990,7 +37997,7 @@ fn lock_script() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11" [options] @@ -38121,7 +38128,7 @@ fn lock_script_path() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11" [options] @@ -38255,7 +38262,7 @@ fn lock_script_editable_path_dependency_change() -> Result<()> { lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11" [options] @@ -38339,7 +38346,7 @@ fn lock_script_editable_path_dependency_change() -> Result<()> { lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11" [options] @@ -38406,7 +38413,7 @@ fn lock_script_initialize() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -38512,7 +38519,7 @@ fn lock_pytorch_cpu() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12.[X]" resolution-markers = [ "(python_full_version >= '3.13' and extra != 'extra-7-project-cpu' and extra == 'extra-7-project-cu124') or (platform_machine != 'aarch64' and extra != 'extra-7-project-cpu' and extra == 'extra-7-project-cu124') or (platform_python_implementation != 'CPython' and extra != 'extra-7-project-cpu' and extra == 'extra-7-project-cu124') or (sys_platform != 'linux' and extra != 'extra-7-project-cpu' and extra == 'extra-7-project-cu124')", @@ -39163,7 +39170,7 @@ fn lock_pytorch_index_preferences() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.10.0" resolution-markers = [ "sys_platform != 'darwin' and extra != 'extra-7-project-cpu' and extra == 'extra-7-project-cu118'", @@ -39628,7 +39635,7 @@ fn lock_intel_mac() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11" resolution-markers = [ "(python_full_version >= '3.12' and platform_machine != 'x86_64') or (python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'win32')", @@ -40017,7 +40024,7 @@ fn lock_pytorch_local_preference() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12.[X]" resolution-markers = [ "sys_platform == 'darwin'", @@ -40350,7 +40357,7 @@ fn windows_arm() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = "==3.12.*" resolution-markers = [ "platform_machine == 'x86_64' and sys_platform == 'linux'", @@ -40425,7 +40432,7 @@ fn windows_amd64_required() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = "==3.12.*" required-markers = [ "platform_machine == 'x86' and sys_platform == 'win32'", @@ -40497,7 +40504,7 @@ fn windows_arm64_required() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = "==3.12.*" required-markers = [ "platform_machine == 'ARM64' and sys_platform == 'win32'", @@ -40563,7 +40570,7 @@ fn lock_empty_extra() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -40662,7 +40669,7 @@ fn lock_empty_extra() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -40818,7 +40825,7 @@ fn lock_omit_wheels_exclude_newer() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -40922,7 +40929,7 @@ fn lock_omit_attached_artifacts_exclude_newer() -> Result<()> { }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -41062,7 +41069,7 @@ fn lock_requires_python_empty_lock_file() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = "==3.13.0" resolution-markers = [ "sys_platform == 'darwin'", @@ -41135,7 +41142,7 @@ fn lock_requires_python_empty_lock_file() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = "==3.13.2" resolution-markers = [ "sys_platform == 'darwin'", @@ -41310,7 +41317,7 @@ async fn lock_trailing_slash_index_url_in_pyproject_not_index_argument() -> Resu assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -41678,7 +41685,7 @@ fn lock_trailing_slash_find_links() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -41752,7 +41759,7 @@ fn lock_trailing_slash_find_links() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -41874,7 +41881,7 @@ fn lock_exclude_newer_disable_cli() -> Result<()> { }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [[package]] @@ -41920,7 +41927,7 @@ fn lock_exclude_newer_disable_environment() -> Result<()> { }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [[package]] @@ -41966,7 +41973,7 @@ fn lock_exclude_newer_disable_config() -> Result<()> { }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [[package]] @@ -42017,7 +42024,7 @@ fn lock_exclude_newer_package_disable() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -42100,7 +42107,7 @@ fn lock_exclude_newer_package_order() -> Result<()> { }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -42150,7 +42157,7 @@ fn lock_exclude_newer_package_absent() -> Result<()> { }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -42264,7 +42271,7 @@ fn lock_exclude_newer_package_absent_preview() -> Result<()> { }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [[package]] @@ -42338,7 +42345,7 @@ fn lock_exclude_newer_package() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -43295,7 +43302,7 @@ fn lock_android() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform == 'android'", @@ -43387,7 +43394,7 @@ fn lock_required_intersection() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "platform_machine == 'x86_64' and sys_platform == 'linux'", @@ -43598,7 +43605,7 @@ fn lock_refresh() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -43731,7 +43738,7 @@ fn lock_refresh_deindents_lockfile() -> Result<()> { "); assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -43983,7 +43990,7 @@ fn lock_required_environment_python_fork() -> Result<()> { insta::with_settings!({ filters => context.filters() }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12, <3.14" resolution-markers = [ "python_full_version >= '3.13' and sys_platform == 'win32'", @@ -44094,7 +44101,7 @@ fn lock_required_environment_macos_release() -> Result<()> { insta::with_settings!({ filters => context.filters() }, { assert_snapshot!(lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "platform_machine == 'arm64' and platform_release >= '25' and sys_platform == 'darwin'", @@ -44235,7 +44242,7 @@ fn lock_required_environment_macos_release_python_fork() -> Result<()> { insta::with_settings!({ filters => context.filters() }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12, <3.14" resolution-markers = [ "python_full_version >= '3.13' and platform_machine == 'arm64' and platform_release >= '25' and sys_platform == 'darwin'", @@ -44611,7 +44618,7 @@ fn lock_supported_environment_abi3_wheel() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "python_full_version < '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'", @@ -45115,7 +45122,7 @@ fn lock_resolution_inputs_version_constraints() -> Result<()> { insta::with_settings!({ filters => context.filters() }, { assert_snapshot!(lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -45399,7 +45406,7 @@ fn lock_resolution_inputs_individual_constraints() -> Result<()> { insta::with_settings!({ filters => context.filters() }, { assert_snapshot!(lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -45748,7 +45755,7 @@ fn lock_resolution_inputs_recursive_extra_constraints() -> Result<()> { insta::with_settings!({ filters => context.filters() }, { assert_snapshot!(retained_lock, @r#" version = 1 - revision = 4 + revision = 5 requires-python = ">=3.12" [options] @@ -45876,7 +45883,7 @@ fn lock_resolution_inputs_constraint_markers() -> Result<()> { insta::with_settings!({ filters => context.filters() }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 4 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform == 'linux'", @@ -45961,7 +45968,7 @@ fn lock_resolution_inputs_constraint_markers() -> Result<()> { insta::with_settings!({ filters => context.filters() }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 4 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform == 'linux'", @@ -46199,7 +46206,7 @@ fn lock_resolution_inputs_source_constraints() -> Result<()> { insta::with_settings!({ filters => context.filters() }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 4 + revision = 5 requires-python = ">=3.12" [options] @@ -46414,7 +46421,7 @@ fn lock_resolution_inputs_prerelease_constraints() -> Result<()> { insta::with_settings!({ filters => context.filters() }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -46716,7 +46723,7 @@ fn lock_resolution_inputs_dynamic_constraints() -> Result<()> { insta::with_settings!({ filters => context.filters() }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 4 + revision = 5 requires-python = ">=3.12" [options] @@ -46840,7 +46847,7 @@ fn lock_resolution_inputs_prune_unused_inputs() -> Result<()> { insta::with_settings!({ filters => context.filters() }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -47029,7 +47036,7 @@ fn lock_resolution_inputs_new_exclusion() -> Result<()> { insta::with_settings!({ filters => context.filters() }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -47126,7 +47133,7 @@ fn lock_resolution_inputs_empty_scopes() -> Result<()> { insta::with_settings!({ filters => context.filters() }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -47306,7 +47313,7 @@ fn lock_resolution_inputs_ignores_build_dependency_metadata() -> Result<()> { insta::with_settings!({ filters => context.filters() }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -47377,7 +47384,7 @@ fn lock_resolution_inputs_metadata_declarations() -> Result<()> { insta::with_settings!({ filters => context.filters() }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -47769,7 +47776,7 @@ fn lock_resolution_inputs_backtracking() -> Result<()> { insta::with_settings!({ filters => context.filters() }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -48145,7 +48152,7 @@ fn lock_resolution_inputs_metadata_unknown_version() -> Result<()> { insta::with_settings!({ filters => context.filters() }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -48279,7 +48286,7 @@ fn lock_resolution_inputs_if_necessary_prerelease_constraint() -> Result<()> { insta::with_settings!({ filters => context.filters() }, { assert_snapshot!(lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -48427,7 +48434,7 @@ fn lock_resolution_inputs_package_prerelease_constraint() -> Result<()> { insta::with_settings!({ filters => context.filters() }, { assert_snapshot!(lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -48552,7 +48559,7 @@ fn lock_resolution_inputs_package_prerelease_constraint() -> Result<()> { insta::with_settings!({ filters => context.filters() }, { assert_snapshot!(lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] diff --git a/crates/uv/tests/lock/minimum_libc.rs b/crates/uv/tests/lock/minimum_libc.rs index 0daee7ced06..7428c071839 100644 --- a/crates/uv/tests/lock/minimum_libc.rs +++ b/crates/uv/tests/lock/minimum_libc.rs @@ -72,7 +72,7 @@ fn minimum_libc_retains_locked_wheels() -> Result<()> { insta::with_settings!({filters => context.filters()}, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" required-markers = [ "platform_machine == 'x86_64' and sys_platform == 'linux'", @@ -225,7 +225,7 @@ fn minimum_libc_switch_families() -> Result<()> { insta::with_settings!({filters => context.filters()}, { assert_snapshot!(lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "platform_machine == 'x86_64' and sys_platform == 'linux'", @@ -288,7 +288,7 @@ fn minimum_libc_switch_families() -> Result<()> { insta::with_settings!({filters => context.filters()}, { assert_snapshot!(lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "platform_machine == 'x86_64' and sys_platform == 'linux'", @@ -410,7 +410,7 @@ fn minimum_libc_local_version_fallback() -> Result<()> { insta::with_settings!({filters => context.filters()}, { assert_snapshot!(lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.13' or platform_machine != 'aarch64' or platform_python_implementation != 'CPython' or sys_platform != 'linux'", @@ -539,7 +539,7 @@ fn minimum_libc_backtracks_and_invalidates_lock() -> Result<()> { insta::with_settings!({filters => context.filters()}, { assert_snapshot!(lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" required-markers = [ "platform_machine == 'x86_64' and sys_platform == 'linux'", @@ -751,7 +751,7 @@ fn minimum_libc_allows_sdist_fallback() -> Result<()> { insta::with_settings!({filters => context.filters()}, { assert_snapshot!(lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" required-markers = [ "platform_machine == 'x86_64' and sys_platform == 'linux'", @@ -945,7 +945,7 @@ fn minimum_libc_architectures_and_markers() -> Result<()> { insta::with_settings!({filters => context.filters()}, { assert_snapshot!(lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "platform_machine == 'x86_64' and sys_platform == 'linux'", diff --git a/crates/uv/tests/lock_scenarios/lock_conflict.rs b/crates/uv/tests/lock_scenarios/lock_conflict.rs index 21d18e33406..9efeb94fc60 100644 --- a/crates/uv/tests/lock_scenarios/lock_conflict.rs +++ b/crates/uv/tests/lock_scenarios/lock_conflict.rs @@ -178,7 +178,7 @@ fn extra_basic() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "project", extra = "extra1" }, @@ -348,7 +348,7 @@ fn extra_basic_three_extras() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "project", extra = "extra1" }, @@ -778,7 +778,7 @@ fn extra_multiple_independent() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "project", extra = "extra1" }, @@ -925,7 +925,7 @@ fn extra_config_change_ignore_lockfile() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "project", extra = "extra1" }, @@ -1739,7 +1739,7 @@ fn extra_depends_on_conflicting_extra_transitive() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "example", extra = "bar" }, @@ -1909,7 +1909,7 @@ fn group_basic() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "project", group = "group1" }, @@ -2046,7 +2046,7 @@ fn group_default() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "project", group = "group1" }, @@ -2056,6 +2056,9 @@ fn group_default() -> Result<()> { [options] exclude-newer = "2024-03-25T00:00:00Z" + [manifest.default-groups] + project = ["group1"] + [[package]] name = "project" version = "0.1.0" @@ -2261,7 +2264,7 @@ fn groups_respect_supported_environments_when_filtering_wheels() -> Result<()> { lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "platform_machine == 'x86_64' and sys_platform == 'linux'", @@ -2366,7 +2369,7 @@ fn extra_conflict_environments_omit_redundant_markers() -> Result<()> { lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.10.0" resolution-markers = [ "platform_machine == 'x86_64' and sys_platform == 'darwin'", @@ -2575,7 +2578,7 @@ fn mixed() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "project", extra = "extra1" }, @@ -2713,7 +2716,7 @@ fn group_activates_self_extra() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "project", extra = "dev" }, @@ -2964,7 +2967,7 @@ fn multiple_sources_index_disjoint_extras() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "project", extra = "cu118" }, @@ -3108,7 +3111,7 @@ fn multiple_sources_index_disjoint_groups() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "project", group = "cu118" }, @@ -3251,7 +3254,7 @@ fn multiple_sources_index_disjoint_extras_with_extra() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "project", extra = "cu118" }, @@ -3414,7 +3417,7 @@ fn multiple_sources_index_disjoint_extras_with_marker() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "extra != 'extra-7-project-cu118' and extra == 'extra-7-project-cu124'", @@ -3725,7 +3728,7 @@ fn shared_optional_dependency_extra1() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "project", extra = "bar" }, @@ -3862,7 +3865,7 @@ fn shared_optional_dependency_group1() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "project", group = "bar" }, @@ -4000,7 +4003,7 @@ fn shared_optional_dependency_mixed1() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "project", extra = "foo" }, @@ -4142,7 +4145,7 @@ fn shared_optional_dependency_extra2() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = "==3.11.*" conflicts = [[ { package = "project", extra = "bar" }, @@ -4280,7 +4283,7 @@ fn shared_optional_dependency_group2() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = "==3.11.*" conflicts = [[ { package = "project", group = "bar" }, @@ -4423,7 +4426,7 @@ fn shared_optional_dependency_mixed2() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = "==3.11.*" conflicts = [[ { package = "project", extra = "foo" }, @@ -4564,7 +4567,7 @@ fn shared_dependency_extra() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "project", extra = "bar" }, @@ -4727,7 +4730,7 @@ fn shared_dependency_group() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "project", group = "bar" }, @@ -4891,7 +4894,7 @@ fn shared_dependency_mixed() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "project", extra = "foo" }, @@ -5094,7 +5097,7 @@ conflicts = [ assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = "==3.11.*" conflicts = [[ { package = "project", extra = "x1" }, @@ -5277,7 +5280,7 @@ fn jinja_no_conflict_markers1() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "project", extra = "cu118" }, @@ -5436,7 +5439,7 @@ fn jinja_no_conflict_markers2() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "extra != 'extra-7-project-cu118' and extra == 'extra-7-project-cu124'", @@ -5594,7 +5597,7 @@ fn collision_extra() -> Result<()> { lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "pkg", extra = "bar" }, @@ -5808,7 +5811,7 @@ fn extra_inferences() -> Result<()> { lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "pkg", extra = "x1" }, @@ -7839,7 +7842,7 @@ fn deduplicate_resolution_markers() -> Result<()> { lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform != 'linux' and extra != 'extra-3-pkg-x1' and extra == 'extra-3-pkg-x2'", @@ -8008,7 +8011,7 @@ fn incorrect_extra_simplification_leads_to_multiple_torch_packages() -> Result<( }, { assert_snapshot!(lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "test", extra = "chgnet" }, @@ -8243,7 +8246,7 @@ fn duplicate_torch_and_sympy_because_of_wrong_inferences() -> Result<()> { }, { assert_snapshot!(lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "test", extra = "alignn" }, @@ -8503,7 +8506,7 @@ fn overlapping_resolution_markers() -> Result<()> { lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = "==3.10.*" resolution-markers = [ "sys_platform == 'linux' and extra != 'extra-14-ads-mega-model-cpu' and extra == 'extra-14-ads-mega-model-cu118'", @@ -9173,7 +9176,7 @@ fn conditional_sources_keep_default_platform_specific_transitive_dependencies() lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = "==3.12.*" resolution-markers = [ "extra != 'extra-10-test-torch-cpu' and extra == 'extra-10-test-torch-cu124'", @@ -9634,7 +9637,7 @@ fn avoids_exponential_lock_file_growth() -> Result<()> { lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "extra != 'extra-27-resolution-markers-for-days-cpu' and extra == 'extra-27-resolution-markers-for-days-cu124'", @@ -10046,7 +10049,7 @@ fn avoids_exponential_lock_file_growth() -> Result<()> { lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "extra != 'extra-27-resolution-markers-for-days-cpu' and extra == 'extra-27-resolution-markers-for-days-cu124'", @@ -10492,7 +10495,7 @@ fn do_not_simplify_if_not_all_conflict_extras_satisfy_the_marker_by_themselves() lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = "==3.12.*" resolution-markers = [ "platform_machine != 'inapplicable' and extra != 'extra-5-debug-a' and extra == 'extra-5-debug-b'", @@ -10709,7 +10712,7 @@ fn many_pairwise_conflicts_shared_extra() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "project", extra = "a" }, @@ -10873,7 +10876,7 @@ fn project_level_conflict_with_extra() -> Result<()> { lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "pkg-a" }, @@ -11051,7 +11054,7 @@ fn project_level_conflict_with_extras_and_cross_dependency() -> Result<()> { lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "pkg-a" }, @@ -11263,7 +11266,7 @@ fn project_level_conflict_with_group() -> Result<()> { lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "pkg-a" }, diff --git a/crates/uv/tests/lock_scenarios/lock_exclude_newer_relative.rs b/crates/uv/tests/lock_scenarios/lock_exclude_newer_relative.rs index c3b63825e67..da729f66541 100644 --- a/crates/uv/tests/lock_scenarios/lock_exclude_newer_relative.rs +++ b/crates/uv/tests/lock_scenarios/lock_exclude_newer_relative.rs @@ -41,7 +41,7 @@ fn lock_exclude_newer_relative() -> Result<()> { // Should resolve to idna 3.6 (released 2023-11-25, before cutoff of 2024-04-10) assert_snapshot!(lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -105,7 +105,7 @@ fn lock_exclude_newer_relative() -> Result<()> { let lock = context.read("uv.lock"); assert_snapshot!(lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -151,7 +151,7 @@ fn lock_exclude_newer_relative() -> Result<()> { let lock = context.read("uv.lock"); assert_snapshot!(lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -337,7 +337,7 @@ fn lock_exclude_newer_package_relative() -> Result<()> { // Should resolve to idna 3.6 (released 2023-11-25, before cutoff of 2024-04-10) assert_snapshot!(lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -400,7 +400,7 @@ fn lock_exclude_newer_package_relative() -> Result<()> { let lock = context.read("uv.lock"); assert_snapshot!(lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -447,7 +447,7 @@ fn lock_exclude_newer_package_relative() -> Result<()> { let lock = context.read("uv.lock"); assert_snapshot!(lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -516,7 +516,7 @@ fn lock_exclude_newer_relative_pyproject() -> Result<()> { // Should resolve to idna 3.6 (released 2023-11-25, before cutoff of 2024-04-10) assert_snapshot!(lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -584,7 +584,7 @@ fn lock_exclude_newer_package_relative_pyproject() -> Result<()> { // Should resolve to idna 3.6 (released 2023-11-25, before cutoff of 2024-04-10) assert_snapshot!(lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -664,7 +664,7 @@ fn lock_exclude_newer_relative_global_and_package() -> Result<()> { // typing-extensions 4.11.0 (per-package cutoff 2024-04-17 is after 4.11.0 release on 2024-04-05) assert_snapshot!(lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -777,7 +777,7 @@ fn lock_exclude_newer_relative_global_and_package() -> Result<()> { // typing-extensions 4.11.0 (relative cutoff 2024-04-17) assert_snapshot!(lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -842,7 +842,7 @@ fn lock_exclude_newer_relative_global_and_package() -> Result<()> { // typing-extensions 4.10.0 (absolute cutoff 2024-04-01 is before 4.11.0 release on 2024-04-05) assert_snapshot!(lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -1119,7 +1119,7 @@ fn lock_exclude_newer_relative_no_timestamp_in_lockfile() -> Result<()> { let lock = context.read("uv.lock"); assert_snapshot!(lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -1167,7 +1167,7 @@ fn lock_exclude_newer_relative_no_timestamp_in_lockfile() -> Result<()> { let lock = context.read("uv.lock"); assert_snapshot!(lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -1227,7 +1227,7 @@ fn lock_exclude_newer_package_relative_no_timestamp_in_lockfile() -> Result<()> let lock = context.read("uv.lock"); assert_snapshot!(lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] diff --git a/crates/uv/tests/lock_scenarios/lock_scenarios.rs b/crates/uv/tests/lock_scenarios/lock_scenarios.rs index 24169347a33..1b25c8a6a71 100644 --- a/crates/uv/tests/lock_scenarios/lock_scenarios.rs +++ b/crates/uv/tests/lock_scenarios/lock_scenarios.rs @@ -118,7 +118,7 @@ fn wrong_backtracking_basic() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [[package]] @@ -281,7 +281,7 @@ fn wrong_backtracking_indirect() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [[package]] @@ -420,7 +420,7 @@ fn fork_allows_non_conflicting_non_overlapping_dependencies() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform == 'darwin'", @@ -532,7 +532,7 @@ fn fork_allows_non_conflicting_repeated_dependencies() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [[package]] @@ -629,7 +629,7 @@ fn fork_basic() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform == 'darwin'", @@ -904,7 +904,7 @@ fn fork_filter_sibling_dependencies() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform == 'linux'", @@ -1080,7 +1080,7 @@ fn fork_upgrade() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [[package]] @@ -1198,7 +1198,7 @@ fn fork_incomplete_markers() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.14'", @@ -1349,7 +1349,7 @@ fn fork_marker_accrue() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [[package]] @@ -1547,7 +1547,7 @@ fn fork_marker_inherit_combined_allowed() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "implementation_name == 'pypy' and sys_platform == 'darwin'", @@ -1725,7 +1725,7 @@ fn fork_marker_inherit_combined_disallowed() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "implementation_name == 'pypy' and sys_platform == 'darwin'", @@ -1892,7 +1892,7 @@ fn fork_marker_inherit_combined() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "implementation_name == 'pypy' and sys_platform == 'darwin'", @@ -2052,7 +2052,7 @@ fn fork_marker_inherit_isolated() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform == 'darwin'", @@ -2198,7 +2198,7 @@ fn fork_marker_inherit_transitive() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform == 'darwin'", @@ -2352,7 +2352,7 @@ fn fork_marker_inherit() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform == 'darwin'", @@ -2488,7 +2488,7 @@ fn fork_marker_limited_inherit() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform == 'darwin'", @@ -2641,7 +2641,7 @@ fn fork_marker_selection() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform == 'darwin'", @@ -2793,7 +2793,7 @@ fn fork_marker_track() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform == 'darwin'", @@ -2943,7 +2943,7 @@ fn fork_non_fork_marker_transitive() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [[package]] @@ -3230,7 +3230,7 @@ fn fork_overlapping_markers_basic() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.14'", @@ -3395,7 +3395,7 @@ fn preferences_dependent_forking_bistable() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform == 'linux'", @@ -3768,7 +3768,7 @@ fn preferences_dependent_forking_tristable() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform == 'linux'", @@ -4049,7 +4049,7 @@ fn preferences_dependent_forking() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform == 'linux'", @@ -4221,7 +4221,7 @@ fn fork_remaining_universe_partitioning() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "os_name == 'darwin' and sys_platform == 'illumos'", @@ -4370,7 +4370,7 @@ fn fork_requires_python_full_prerelease() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.10" [[package]] @@ -4451,7 +4451,7 @@ fn fork_requires_python_full() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.10" [[package]] @@ -4537,7 +4537,7 @@ fn fork_requires_python_patch_overlap() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.10.1" [[package]] @@ -4627,7 +4627,7 @@ fn fork_requires_python() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.10" [[package]] @@ -4712,7 +4712,7 @@ fn prerelease_base_marker_stable_preference_explicit_first() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform == 'linux'", @@ -4815,7 +4815,7 @@ fn prerelease_base_marker_stable_preference_plain_first() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform == 'linux'", @@ -4918,7 +4918,7 @@ fn prerelease_marker_equivalent_stable_preference() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [[package]] @@ -5033,7 +5033,7 @@ fn prerelease_marker_stable_preference_backtracks() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [[package]] @@ -5154,7 +5154,7 @@ fn transitive_prerelease_forks() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform != 'linux'", @@ -5275,7 +5275,7 @@ fn requires_python_wheels() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.10" [[package]] @@ -5368,7 +5368,7 @@ fn unreachable_package() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [[package]] @@ -5466,7 +5466,7 @@ fn unreachable_wheels() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [[package]] @@ -5593,7 +5593,7 @@ fn marker_variants_have_different_extras() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "platform_python_implementation != 'PyPy'", @@ -5727,7 +5727,7 @@ fn virtual_package_extra_priorities() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [[package]] @@ -5836,7 +5836,7 @@ fn requires_python_subset() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" required-markers = [ "sys_platform == 'linux'", @@ -5939,7 +5939,7 @@ fn specific_architecture() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [[package]] diff --git a/crates/uv/tests/pip_install/pip_install.rs b/crates/uv/tests/pip_install/pip_install.rs index b11af28ed2b..6731acd01ef 100644 --- a/crates/uv/tests/pip_install/pip_install.rs +++ b/crates/uv/tests/pip_install/pip_install.rs @@ -224,6 +224,7 @@ fn install_wheel_cache_incompatible_with_older_uv() -> Result<()> { Installed 1 package in [TIME] + uv==[VERSION] Resolved 1 package in [TIME] + Prepared 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) "); diff --git a/crates/uv/tests/project/check.rs b/crates/uv/tests/project/check.rs index 2b4b13c6c1e..9bb74c30a24 100644 --- a/crates/uv/tests/project/check.rs +++ b/crates/uv/tests/project/check.rs @@ -1316,7 +1316,7 @@ fn check_no_sync_creates_lock_without_sync() -> Result<()> { }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -1495,7 +1495,7 @@ fn check_no_sync_updates_stale_lock_without_sync() -> Result<()> { +++ new @@ -1,26 +1,26 @@ version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] diff --git a/crates/uv/tests/project/edit.rs b/crates/uv/tests/project/edit.rs index 1ce964205f2..873db668287 100644 --- a/crates/uv/tests/project/edit.rs +++ b/crates/uv/tests/project/edit.rs @@ -82,7 +82,7 @@ fn add_registry() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -221,7 +221,7 @@ fn add_git() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -346,7 +346,7 @@ fn add_git_private_source() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -434,7 +434,7 @@ fn add_git_private_raw() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -714,7 +714,7 @@ fn add_git_lfs() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.13" [options] @@ -916,7 +916,7 @@ fn add_git_raw() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -1183,7 +1183,7 @@ fn add_unnamed() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -1273,7 +1273,7 @@ fn add_remove_dev() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -1382,7 +1382,7 @@ fn add_remove_dev() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -1466,7 +1466,7 @@ fn add_remove_optional() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -1575,7 +1575,7 @@ fn add_remove_optional() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -1792,7 +1792,7 @@ fn add_remove_workspace() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -1870,7 +1870,7 @@ fn add_remove_workspace() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -2418,7 +2418,7 @@ fn add_workspace_editable() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -2544,7 +2544,7 @@ fn add_workspace_path() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -2666,7 +2666,7 @@ fn add_path_implicit_workspace() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -2782,7 +2782,7 @@ fn add_path_no_workspace() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -2890,7 +2890,7 @@ fn add_path_adjacent_directory() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -3094,7 +3094,7 @@ fn add_relative_and_absolute_paths() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -3351,7 +3351,7 @@ fn update() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -4056,7 +4056,7 @@ fn add_inexact() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -4169,7 +4169,7 @@ fn remove_registry() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -4766,7 +4766,7 @@ fn add_lower_bound_existing() -> Result<()> { "); let lock = context.read("uv.lock").parse::()?; - assert_eq!(lock["revision"].as_integer(), Some(4)); + assert_eq!(lock["revision"].as_integer(), Some(5)); uv_snapshot!(context.filters(), context.add() .arg("--preview-features") @@ -4944,7 +4944,7 @@ fn add_lower_bound_optional() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -5055,7 +5055,7 @@ fn add_lower_bound_local() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [[package]] @@ -5148,7 +5148,7 @@ fn add_non_project() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -6103,7 +6103,7 @@ fn add_requirements_file_constraints() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -7024,7 +7024,7 @@ fn add_script_settings() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11" [options] @@ -7670,7 +7670,7 @@ fn add_remove_script_lock() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11" [options] @@ -7850,7 +7850,7 @@ fn add_remove_script_lock() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11" [options] @@ -8052,7 +8052,7 @@ fn add_remove_script_lock() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11" [options] @@ -9869,7 +9869,7 @@ fn add_warn_index_url() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -9967,7 +9967,7 @@ fn add_no_warn_index_url() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -10057,7 +10057,7 @@ fn add_index() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -10140,7 +10140,7 @@ fn add_index() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -10249,7 +10249,7 @@ fn add_index() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -10366,7 +10366,7 @@ fn add_index() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -10492,7 +10492,7 @@ fn add_index() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -10625,7 +10625,7 @@ fn add_default_index_url() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -10695,7 +10695,7 @@ fn add_default_index_url() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -10794,7 +10794,7 @@ async fn add_index_credentials() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -10891,7 +10891,7 @@ async fn existing_index_credentials() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -10981,7 +10981,7 @@ fn add_index_with_trailing_slash() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -11074,7 +11074,7 @@ fn add_index_without_trailing_slash() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -11602,7 +11602,7 @@ fn add_group_comment() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11" [options] @@ -11730,7 +11730,7 @@ fn add_index_comments() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -12080,7 +12080,7 @@ fn add_direct_url_subdirectory() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -12204,7 +12204,7 @@ fn add_direct_url_subdirectory_raw() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -12919,7 +12919,7 @@ fn multiple_index_cli() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -13024,7 +13024,7 @@ fn repeated_index_cli_environment_variable() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -13124,7 +13124,7 @@ fn repeated_index_cli_environment_variable_newline() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -13228,7 +13228,7 @@ fn repeated_index_cli() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -13332,7 +13332,7 @@ fn repeated_index_cli_reversed() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -15406,7 +15406,7 @@ fn add_no_install_project() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] diff --git a/crates/uv/tests/project/export.rs b/crates/uv/tests/project/export.rs index 9da5d45d44f..ea3a2a4fa95 100644 --- a/crates/uv/tests/project/export.rs +++ b/crates/uv/tests/project/export.rs @@ -817,7 +817,7 @@ fn requirements_txt_dependency_conflicting_markers() -> Result<()> { insta::assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform == 'darwin'", @@ -2089,7 +2089,7 @@ fn requirements_txt_non_project_fork() -> Result<()> { insta::assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" resolution-markers = [ "sys_platform == 'win32'", @@ -3263,7 +3263,7 @@ fn requirements_txt_script() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11" resolution-markers = [ "sys_platform == 'win32'", @@ -3381,7 +3381,7 @@ fn requirements_txt_script() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11" resolution-markers = [ "sys_platform == 'win32'", diff --git a/crates/uv/tests/project/lock.rs b/crates/uv/tests/project/lock.rs new file mode 100644 index 00000000000..65b1ed4b58c --- /dev/null +++ b/crates/uv/tests/project/lock.rs @@ -0,0 +1,1049 @@ +use anyhow::{Context, Result}; +use assert_cmd::assert::OutputAssertExt; +use assert_fs::prelude::*; +use indoc::indoc; +use insta::assert_snapshot; +use uv_lock::Lock; +use uv_normalize::DefaultGroups; +use uv_test::uv_snapshot; + +/// Record nonstandard workspace member defaults and update them when the selection changes. +#[test] +fn member_default_groups_in_lockfile() -> Result<()> { + let context = uv_test::test_context!("3.12"); + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "root" + version = "1.0.0" + requires-python = ">=3.12" + + [tool.uv] + package = false + + [tool.uv.workspace] + members = ["plain", "custom", "empty", "all"] + "#})?; + + // Cover implicit defaults, a normalized list, no defaults, and all groups. + context + .temp_dir + .child("plain/pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "plain" + version = "1.0.0" + + [tool.uv] + package = false + + [dependency-groups] + dev = [] + docs = [] + "#})?; + let custom = context.temp_dir.child("custom/pyproject.toml"); + custom.write_str(indoc! {r#" + [project] + name = "custom" + version = "1.0.0" + + [tool.uv] + package = false + default-groups = ["docs", "dev", "docs"] + + [dependency-groups] + dev = [] + docs = [] + "#})?; + context + .temp_dir + .child("empty/pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "empty" + version = "1.0.0" + + [tool.uv] + package = false + default-groups = [] + + [dependency-groups] + dev = [] + docs = [] + "#})?; + context + .temp_dir + .child("all/pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "all" + version = "1.0.0" + + [tool.uv] + package = false + default-groups = "all" + + [dependency-groups] + dev = [] + docs = [] + "#})?; + + uv_snapshot!(context.filters(), context.lock().arg("--offline"), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 5 packages in [TIME] + "); + let contents = context.read("uv.lock"); + assert_snapshot!(contents, @r#" + version = 1 + revision = 5 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [manifest] + members = [ + "all", + "custom", + "empty", + "plain", + "root", + ] + + [manifest.default-groups] + all = "all" + custom = ["dev", "docs"] + empty = [] + + [[package]] + name = "all" + version = "1.0.0" + source = { virtual = "all" } + + [package.metadata] + + [package.metadata.requires-dev] + dev = [] + docs = [] + + [[package]] + name = "custom" + version = "1.0.0" + source = { virtual = "custom" } + + [package.metadata] + + [package.metadata.requires-dev] + dev = [] + docs = [] + + [[package]] + name = "empty" + version = "1.0.0" + source = { virtual = "empty" } + + [package.metadata] + + [package.metadata.requires-dev] + dev = [] + docs = [] + + [[package]] + name = "plain" + version = "1.0.0" + source = { virtual = "plain" } + + [package.metadata] + + [package.metadata.requires-dev] + dev = [] + docs = [] + + [[package]] + name = "root" + version = "1.0.0" + source = { virtual = "." } + "#); + + // Both parsers accept the recorded defaults and preserve their canonical serialization. + let lock = Lock::from_canonical_toml(&contents)?; + assert_eq!(Lock::from_toml(&contents)?, lock); + assert_eq!(toml::from_str::(&contents)?, lock); + assert_eq!(lock.to_toml()?, contents); + assert_eq!( + lock.member_default_groups(&"plain".parse()?), + Some(DefaultGroups::List(vec!["dev".parse()?])) + ); + assert_eq!(lock.member_default_groups(&"missing".parse()?), None); + uv_snapshot!(context.filters(), context.lock().args(["--offline", "--locked"]), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 5 packages in [TIME] + "); + + // Changing only the default selection makes the lock stale. + custom.write_str(indoc! {r#" + [project] + name = "custom" + version = "1.0.0" + + [tool.uv] + package = false + default-groups = [] + + [dependency-groups] + dev = [] + docs = [] + "#})?; + uv_snapshot!(context.filters(), context.lock().args(["--offline", "--locked"]), @" + exit_code: 1 (failure) + ----- stderr ----- + Resolved 5 packages in [TIME] + error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. + + hint: To update the lockfile, run `uv lock`. + "); + assert_eq!(context.read("uv.lock"), contents); + + // Relocking updates the defaults without changing the resolved packages. + uv_snapshot!(context.filters(), context.lock().arg("--offline"), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 5 packages in [TIME] + "); + let updated = Lock::from_canonical_toml(&context.read("uv.lock"))?; + assert_eq!(updated.packages(), lock.packages()); + insta::assert_json_snapshot!(updated.configured_member_default_groups(), @r#" + { + "all": "all", + "custom": [], + "empty": [] + } + "#); + uv_snapshot!(context.filters(), context.lock().args(["--offline", "--locked"]), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 5 packages in [TIME] + "); + + // Explicit `dev` matches the implicit default and does not need a lockfile entry. + custom.write_str(indoc! {r#" + [project] + name = "custom" + version = "1.0.0" + + [tool.uv] + package = false + default-groups = ["dev"] + + [dependency-groups] + dev = [] + docs = [] + "#})?; + uv_snapshot!(context.filters(), context.lock().arg("--offline"), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 5 packages in [TIME] + "); + let updated = Lock::from_canonical_toml(&context.read("uv.lock"))?; + insta::assert_json_snapshot!(updated.configured_member_default_groups(), @r#" + { + "all": "all", + "empty": [] + } + "#); + Ok(()) +} + +/// Record effective group Python requirements, including those inherited from other groups. +#[test] +fn member_group_python_requirements_in_lockfile() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + // Give the root an included group and the member its own Python requirement. + let pyproject = context.temp_dir.child("pyproject.toml"); + pyproject.write_str(indoc! {r#" + [project] + name = "root" + version = "1.0.0" + requires-python = ">=3.12" + + [dependency-groups] + base = [] + combined = [{ include-group = "base" }] + unrestricted = [] + + [tool.uv] + package = false + default-groups = ["combined"] + + [tool.uv.workspace] + members = ["member"] + + [tool.uv.dependency-groups] + base = { requires-python = ">=3.13" } + combined = { requires-python = "<3.15" } + "#})?; + let member = context.temp_dir.child("member"); + member.create_dir_all()?; + member.child("pyproject.toml").write_str(indoc! {r#" + [project] + name = "member" + version = "1.0.0" + + [dependency-groups] + docs = [] + + [tool.uv] + package = false + default-groups = [] + + [tool.uv.dependency-groups] + docs = { requires-python = ">=3.14" } + "#})?; + + // Record the declared bounds, including the combined group's inherited requirement. + uv_snapshot!(context.filters(), context.lock().arg("--offline"), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + let contents = context.read("uv.lock"); + assert_snapshot!(contents, @r#" + version = 1 + revision = 5 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [manifest] + members = [ + "member", + "root", + ] + + [manifest.default-groups] + member = [] + root = ["combined"] + + [manifest.group-metadata] + member = { docs = { requires-python = ">=3.14" } } + root = { base = { requires-python = ">=3.13" }, combined = { requires-python = ">=3.13,<3.15" } } + + [[package]] + name = "member" + version = "1.0.0" + source = { virtual = "member" } + + [package.metadata] + + [package.metadata.requires-dev] + docs = [] + + [[package]] + name = "root" + version = "1.0.0" + source = { virtual = "." } + + [package.metadata] + + [package.metadata.requires-dev] + base = [] + combined = [] + unrestricted = [] + "#); + let lock = Lock::from_canonical_toml(&contents)?; + assert_eq!(Lock::from_toml(&contents)?, lock); + assert_eq!(toml::from_str::(&contents)?, lock); + assert_eq!(lock.to_toml()?, contents); + uv_snapshot!(context.filters(), context.lock().args(["--offline", "--locked"]), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + + // A group with only an unknown future setting has no known Python requirement. + let extended = context.read("uv.lock").replace( + "combined = { requires-python = \">=3.13,<3.15\" } }", + "combined = { requires-python = \">=3.13,<3.15\" }, unrestricted = { future-setting = true } }", + ); + assert_ne!(context.read("uv.lock"), extended); + context.temp_dir.child("uv.lock").write_str(&extended)?; + uv_snapshot!(context.filters(), context.lock().args(["--offline", "--locked"]), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + assert_eq!(context.read("uv.lock"), extended); + + // Changing the included group's bound makes the lock stale. + pyproject.write_str(indoc! {r#" + [project] + name = "root" + version = "1.0.0" + requires-python = ">=3.12" + + [dependency-groups] + base = [] + combined = [{ include-group = "base" }] + unrestricted = [] + + [tool.uv] + package = false + default-groups = ["combined"] + + [tool.uv.workspace] + members = ["member"] + + [tool.uv.dependency-groups] + base = { requires-python = ">=3.14" } + combined = { requires-python = "<3.15" } + "#})?; + uv_snapshot!(context.filters(), context.lock().args(["--offline", "--locked"]), @" + exit_code: 1 (failure) + ----- stderr ----- + Resolved 2 packages in [TIME] + error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. + + hint: To update the lockfile, run `uv lock`. + "); + + // Relocking records the new bound and makes the lock current again. + uv_snapshot!(context.filters(), context.lock().arg("--offline"), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + uv_snapshot!(context.filters(), context.lock().args(["--offline", "--locked"]), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + Ok(()) +} + +/// A metadata-free lock records group requirements without an explicit manifest parent table. +#[test] +fn metadata_free_group_python_requirements_in_lockfile() -> Result<()> { + let context = uv_test::test_context!("3.12"); + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "project" + version = "1.0.0" + requires-python = ">=3.12" + + [dependency-groups] + bar = [] + + [tool.uv] + package = false + + [tool.uv.dependency-groups] + bar = { requires-python = ">=3.13" } + "#})?; + + uv_snapshot!(context.filters(), context.lock().args([ + "--offline", + "--preview-features", + "lock-without-metadata", + ]), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 1 package in [TIME] + "); + let contents = context.read("uv.lock"); + assert_snapshot!(contents, @r#" + version = 1 + revision = 5 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [manifest.group-metadata] + project = { bar = { requires-python = ">=3.13" } } + + [[package]] + name = "project" + version = "1.0.0" + source = { virtual = "." } + + [package.dev-dependencies] + bar = [] + "#); + + // Both parsers accept the implicit parent, and serialization preserves the layout. + let lock = Lock::from_canonical_toml(&contents)?; + assert_eq!(toml::from_str::(&contents)?, lock); + assert_eq!(lock.to_toml()?, contents); + assert_eq!( + lock.member_default_groups(&"project".parse()?), + Some(DefaultGroups::List(vec!["dev".parse()?])) + ); + uv_snapshot!(context.filters(), context.lock().args([ + "--offline", + "--locked", + "--preview-features", + "lock-without-metadata", + ]), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 1 package in [TIME] + "); + Ok(()) +} + +/// Existing revision 3 lockfiles remain valid until a new resolution is needed. +#[test] +fn legacy_default_groups_in_lockfile() -> Result<()> { + let context = uv_test::test_context!("3.12"); + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "root" + version = "1.0.0" + requires-python = ">=3.12" + + [tool.uv] + package = false + default-groups = ["docs"] + + [dependency-groups] + docs = [] + "#})?; + context.lock().arg("--offline").assert().success(); + let current = context.read("uv.lock"); + + let legacy = indoc! {r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "root" + version = "1.0.0" + source = { virtual = "." } + + [package.metadata] + + [package.metadata.requires-dev] + docs = [] + "#}; + let old_lock = Lock::from_canonical_toml(legacy)?; + assert_eq!(old_lock.member_default_groups(&"root".parse()?), None); + assert_eq!(old_lock.configured_member_default_groups(), None); + context.temp_dir.child("uv.lock").write_str(legacy)?; + context + .lock() + .args(["--offline", "--locked"]) + .assert() + .success(); + context.lock().arg("--offline").assert().success(); + assert_eq!(context.read("uv.lock"), legacy); + context + .lock() + .args(["--offline", "--upgrade"]) + .assert() + .success(); + assert_eq!(context.read("uv.lock"), current); + Ok(()) +} + +/// Metadata-free locks do not require a particular revision. +#[test] +fn legacy_metadata_free_lockfile() -> Result<()> { + let context = uv_test::test_context!("3.12"); + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "root" + version = "1.0.0" + requires-python = ">=3.12" + + [project.optional-dependencies] + feature = [] + + [dependency-groups] + docs = [] + + [tool.uv] + package = false + "#})?; + let legacy = indoc! {r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "root" + version = "1.0.0" + source = { virtual = "." } + + [package.optional-dependencies] + feature = [] + + [package.dev-dependencies] + docs = [] + "#}; + let lock = Lock::from_canonical_toml(legacy)?; + assert_eq!(lock.to_toml()?, legacy); + context.temp_dir.child("uv.lock").write_str(legacy)?; + context + .lock() + .args(["--offline", "--locked"]) + .assert() + .failure(); + context + .lock() + .args([ + "--offline", + "--locked", + "--preview-features", + "lock-without-metadata", + ]) + .assert() + .success(); + context + .export() + .args(["--offline", "--frozen", "--extra", "feature"]) + .assert() + .success(); + context + .export() + .args(["--offline", "--frozen", "--only-group", "docs"]) + .assert() + .success(); + // Unknown extras and groups are rejected even without requirement metadata. + uv_snapshot!(context.filters(), context.export() + .args(["--offline", "--frozen", "--extra", "missing"]), @" + exit_code: 2 (failure) + ----- stderr ----- + error: Extra `missing` is not defined in the `optional-dependencies` table for `root` + "); + uv_snapshot!(context.filters(), context.export() + .args(["--offline", "--frozen", "--only-group", "missing"]), @" + exit_code: 2 (failure) + ----- stderr ----- + error: Group `missing` is not defined in the project's `dependency-groups` table + "); + assert_eq!(context.read("uv.lock"), legacy); + Ok(()) +} + +/// Revision 4 preview locks do not record member defaults or group Python requirements. +#[test] +fn metadata_free_default_groups_in_lockfile() -> Result<()> { + let context = uv_test::test_context!("3.12"); + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "root" + version = "1.0.0" + requires-python = ">=3.12" + + [tool.uv] + package = false + default-groups = ["docs"] + + [dependency-groups] + docs = [] + "#})?; + let legacy = indoc! {r#" + version = 1 + revision = 4 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "root" + version = "1.0.0" + source = { virtual = "." } + + [package.dev-dependencies] + docs = [] + "#}; + let old_lock = Lock::from_canonical_toml(legacy)?; + assert_eq!(old_lock.member_default_groups(&"root".parse()?), None); + assert_eq!(old_lock.member_group_metadata(), None); + context.temp_dir.child("uv.lock").write_str(legacy)?; + context + .lock() + .args([ + "--offline", + "--locked", + "--preview-features", + "lock-without-metadata", + ]) + .assert() + .success(); + assert_eq!(context.read("uv.lock"), legacy); + context + .lock() + .args([ + "--offline", + "--upgrade", + "--preview-features", + "lock-without-metadata", + ]) + .assert() + .success(); + let updated = Lock::from_canonical_toml(&context.read("uv.lock"))?; + assert_eq!(updated.packages(), old_lock.packages()); + assert_eq!( + updated.member_default_groups(&"root".parse()?), + Some(DefaultGroups::List(vec!["docs".parse()?])) + ); + context + .lock() + .args([ + "--offline", + "--locked", + "--preview-features", + "lock-without-metadata", + ]) + .assert() + .success(); + + // Changing defaults also invalidates a lock without requirement metadata. + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "root" + version = "1.0.0" + requires-python = ">=3.12" + + [tool.uv] + package = false + default-groups = [] + + [dependency-groups] + docs = [] + "#})?; + uv_snapshot!(context.filters(), context.lock().args([ + "--offline", + "--locked", + "--preview-features", + "lock-without-metadata", + ]), @" + exit_code: 1 (failure) + ----- stderr ----- + Resolved 1 package in [TIME] + error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. + + hint: To update the lockfile, run `uv lock`. + "); + uv_snapshot!(context.filters(), context.lock().args([ + "--offline", + "--preview-features", + "lock-without-metadata", + ]), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 1 package in [TIME] + "); + let updated = Lock::from_canonical_toml(&context.read("uv.lock"))?; + insta::assert_json_snapshot!(updated.configured_member_default_groups(), @r#" + { + "root": [] + } + "#); + + Ok(()) +} + +/// Compare recorded default groups without depending on their order or duplicate entries. +#[test] +fn unordered_default_groups_in_lockfile() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let pyproject = context.temp_dir.child("pyproject.toml"); + pyproject.write_str(indoc! {r#" + [project] + name = "root" + version = "1.0.0" + requires-python = ">=3.12" + + [tool.uv] + package = false + default-groups = ["dev", "docs"] + + [dependency-groups] + dev = [] + docs = [] + "#})?; + let noncanonical = indoc! {r#" + version = 1 + revision = 5 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [manifest] + + [manifest.default-groups] + root = ["docs", "dev", "docs"] + + [[package]] + name = "root" + version = "1.0.0" + source = { virtual = "." } + + [package.metadata] + + [package.metadata.requires-dev] + dev = [] + docs = [] + "#}; + let lockfile = context.temp_dir.child("uv.lock"); + lockfile.write_str(noncanonical)?; + context + .lock() + .args(["--offline", "--check"]) + .assert() + .success(); + context.lock().arg("--offline").assert().success(); + assert_eq!(context.read("uv.lock"), noncanonical); + + pyproject.write_str(indoc! {r#" + [project] + name = "root" + version = "1.0.0" + requires-python = ">=3.12" + + [tool.uv] + package = false + default-groups = ["dev"] + + [dependency-groups] + dev = [] + docs = [] + "#})?; + context + .lock() + .args(["--offline", "--check"]) + .assert() + .failure(); + let explicit_dev = indoc! {r#" + version = 1 + revision = 5 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [manifest] + + [manifest.default-groups] + root = ["dev", "dev"] + + [[package]] + name = "root" + version = "1.0.0" + source = { virtual = "." } + + [package.metadata] + + [package.metadata.requires-dev] + dev = [] + docs = [] + "#}; + lockfile.write_str(explicit_dev)?; + context + .lock() + .args(["--offline", "--check"]) + .assert() + .success(); + context.lock().arg("--offline").assert().success(); + assert_eq!(context.read("uv.lock"), explicit_dev); + Ok(()) +} + +/// A lockfile without a workspace member list still records its root project's defaults. +#[test] +fn single_project_default_groups_in_lockfile() -> Result<()> { + let context = uv_test::test_context!("3.12"); + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "root" + version = "1.0.0" + requires-python = ">=3.12" + + [dependency-groups] + docs = [] + + [tool.uv] + package = false + default-groups = ["docs"] + "#})?; + context.lock().arg("--offline").assert().success(); + assert_snapshot!(context.read("uv.lock"), @r#" + version = 1 + revision = 5 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [manifest.default-groups] + root = ["docs"] + + [[package]] + name = "root" + version = "1.0.0" + source = { virtual = "." } + + [package.metadata] + + [package.metadata.requires-dev] + docs = [] + "#); + let lock = Lock::from_canonical_toml(&context.read("uv.lock"))?; + assert!(lock.members().is_empty()); + assert_eq!( + lock.member_default_groups(&"root".parse()?), + Some(DefaultGroups::List(vec!["docs".parse()?])) + ); + Ok(()) +} + +/// Member defaults and non-project workspace groups share the lock manifest. +#[test] +fn non_project_workspace_default_groups_in_lockfile() -> Result<()> { + let context = uv_test::test_context!("3.12"); + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [tool.uv.workspace] + members = ["member"] + + [tool.uv.sources] + member = { workspace = true } + + [dependency-groups] + root-only = ["member"] + "#})?; + context + .temp_dir + .child("member/pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "member" + version = "1.0.0" + + [dependency-groups] + docs = [] + + [tool.uv] + package = false + default-groups = ["docs"] + "#})?; + context.lock().arg("--offline").assert().success(); + let contents = context.read("uv.lock"); + let lock = Lock::from_canonical_toml(&contents)?; + assert_eq!(lock.to_toml()?, contents); + let lockfile = toml::from_str::(&contents)?; + assert!( + lockfile["manifest"]["dependency-groups"] + .get("root-only") + .is_some() + ); + assert_eq!( + lock.member_default_groups(&"member".parse()?), + Some(DefaultGroups::List(vec!["docs".parse()?])) + ); + Ok(()) +} + +/// Explicit defaults must name declared groups, even when they match the implicit default. +#[test] +fn invalid_default_groups_in_lockfile() -> Result<()> { + let context = uv_test::test_context!("3.12"); + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [tool.uv.workspace] + members = ["member"] + "#})?; + let member = context.temp_dir.child("member/pyproject.toml"); + member.write_str(indoc! {r#" + [project] + name = "member" + version = "1.0.0" + requires-python = ">=3.12" + + [tool.uv] + package = false + "#})?; + context.lock().arg("--offline").assert().success(); + + member.write_str(indoc! {r#" + [project] + name = "member" + version = "1.0.0" + requires-python = ">=3.12" + + [tool.uv] + package = false + default-groups = ["dev"] + "#})?; + uv_snapshot!(context.filters(), context.lock().arg("--offline"), @" + exit_code: 2 (failure) + ----- stderr ----- + error: Default group `dev` (from `tool.uv.default-groups`) is not defined in the project's `dependency-groups` table + "); + uv_snapshot!(context.filters(), context.lock().args(["--offline", "--check"]), @" + exit_code: 2 (failure) + ----- stderr ----- + error: Default group `dev` (from `tool.uv.default-groups`) is not defined in the project's `dependency-groups` table + "); + + member.write_str(indoc! {r#" + [project] + name = "member" + version = "1.0.0" + requires-python = ">=3.12" + + [dependency-groups] + dev = [] + + [tool.uv] + package = false + default-groups = ["dev"] + "#})?; + context.lock().arg("--offline").assert().success(); + let lock = Lock::from_canonical_toml(&context.read("uv.lock"))?; + assert!( + lock.configured_member_default_groups() + .context("Missing default groups")? + .is_empty() + ); + Ok(()) +} diff --git a/crates/uv/tests/project/main.rs b/crates/uv/tests/project/main.rs index 16dd2150512..5e66ed1f464 100644 --- a/crates/uv/tests/project/main.rs +++ b/crates/uv/tests/project/main.rs @@ -18,6 +18,9 @@ mod format; #[cfg(all(feature = "test-python", feature = "test-pypi", feature = "test-git"))] mod init; +#[cfg(feature = "test-python")] +mod lock; + #[cfg(all(feature = "test-python", feature = "test-pypi"))] mod run; diff --git a/crates/uv/tests/project/run.rs b/crates/uv/tests/project/run.rs index 661210cad9f..72924e54985 100644 --- a/crates/uv/tests/project/run.rs +++ b/crates/uv/tests/project/run.rs @@ -1135,7 +1135,7 @@ fn run_pep723_script_lock() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11" [options] @@ -1239,7 +1239,7 @@ fn run_pep723_script_lock() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11" [options] @@ -2558,7 +2558,7 @@ fn run_locked() -> Result<()> { assert_snapshot!( existing, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -6122,7 +6122,7 @@ fn run_pep723_script_with_constraints_lock() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11" [options] diff --git a/crates/uv/tests/project/snapshots/project__workflow__packse_add_remove_existing_package_noop-2.snap b/crates/uv/tests/project/snapshots/project__workflow__packse_add_remove_existing_package_noop-2.snap index b8486bfd372..0b52069835f 100644 --- a/crates/uv/tests/project/snapshots/project__workflow__packse_add_remove_existing_package_noop-2.snap +++ b/crates/uv/tests/project/snapshots/project__workflow__packse_add_remove_existing_package_noop-2.snap @@ -3,7 +3,7 @@ source: crates/uv/tests/project/workflow.rs expression: lock --- version = 1 -revision = 3 +revision = 5 requires-python = ">=3.12" [options] diff --git a/crates/uv/tests/project/snapshots/project__workflow__packse_add_remove_one_package-2.snap b/crates/uv/tests/project/snapshots/project__workflow__packse_add_remove_one_package-2.snap index b8486bfd372..0b52069835f 100644 --- a/crates/uv/tests/project/snapshots/project__workflow__packse_add_remove_one_package-2.snap +++ b/crates/uv/tests/project/snapshots/project__workflow__packse_add_remove_one_package-2.snap @@ -3,7 +3,7 @@ source: crates/uv/tests/project/workflow.rs expression: lock --- version = 1 -revision = 3 +revision = 5 requires-python = ">=3.12" [options] diff --git a/crates/uv/tests/project/snapshots/project__workflow__packse_promote_transitive_to_direct_then_remove-2.snap b/crates/uv/tests/project/snapshots/project__workflow__packse_promote_transitive_to_direct_then_remove-2.snap index b8486bfd372..0b52069835f 100644 --- a/crates/uv/tests/project/snapshots/project__workflow__packse_promote_transitive_to_direct_then_remove-2.snap +++ b/crates/uv/tests/project/snapshots/project__workflow__packse_promote_transitive_to_direct_then_remove-2.snap @@ -3,7 +3,7 @@ source: crates/uv/tests/project/workflow.rs expression: lock --- version = 1 -revision = 3 +revision = 5 requires-python = ">=3.12" [options] diff --git a/crates/uv/tests/project/tree.rs b/crates/uv/tests/project/tree.rs index e981c5ba447..f5de6a16434 100644 --- a/crates/uv/tests/project/tree.rs +++ b/crates/uv/tests/project/tree.rs @@ -4064,7 +4064,7 @@ fn script() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11" [options] @@ -4249,7 +4249,7 @@ fn script() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11" [options] diff --git a/crates/uv/tests/sync/sync.rs b/crates/uv/tests/sync/sync.rs index be89b432d84..b281658e89a 100644 --- a/crates/uv/tests/sync/sync.rs +++ b/crates/uv/tests/sync/sync.rs @@ -588,6 +588,49 @@ fn frozen() -> Result<()> { Ok(()) } +/// Frozen sync reads the project lockfile before selecting an environment. +#[test] +fn sync_frozen_lockfile_before_environment() -> Result<()> { + let context = uv_test::test_context_with_versions!(&["3.12"]); + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "project" + version = "1.0.0" + requires-python = ">=3.13" + "#})?; + + // Report the missing lockfile before checking the requested Python. + uv_snapshot!(context.filters(), context.sync() + .args(["--frozen", "--python", "3.12", "--offline"]), @" + exit_code: 1 (failure) + ----- stderr ----- + error: Unable to find lockfile at `uv.lock`, but `--frozen` was provided. To create a lockfile, run `uv lock` or `uv sync` without the flag. + "); + + // A malformed lockfile also fails before creating an environment. + context.temp_dir.child("uv.lock").write_str(indoc! {r" + version = 1 + revision = 4 + "})?; + uv_snapshot!(context.filters(), context.sync() + .args(["--frozen", "--package", "project", "--python", "3.12", "--offline"]), @" + exit_code: 2 (failure) + ----- stderr ----- + error: Failed to parse `uv.lock` + cause: TOML parse error at line 1, column 1 + | + 1 | version = 1 + | ^ + missing field `requires-python` + "); + + assert!(!context.venv.exists()); + Ok(()) +} + #[test] fn empty() -> Result<()> { let context = uv_test::test_context!("3.12"); @@ -1655,6 +1698,475 @@ fn sync_non_project_dev_dependencies() -> Result<()> { Ok(()) } +/// Frozen sync uses the selected member's recorded default groups. +#[test] +fn sync_frozen_member_default_groups() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + // Use local wheel fixtures to distinguish the root and member dependency groups. + let wheels = context.temp_dir.child("wheels"); + wheels.create_dir_all()?; + fs_err::copy( + context + .workspace_root + .join("test/links/ok-1.0.0-py3-none-any.whl"), + wheels.child("ok-1.0.0-py3-none-any.whl"), + )?; + fs_err::copy( + context + .workspace_root + .join("test/links/validation-1.0.0-py3-none-any.whl"), + wheels.child("validation-1.0.0-py3-none-any.whl"), + )?; + + // Record different default groups for the root and member. + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "root" + version = "1.0.0" + requires-python = ">=3.12" + + [dependency-groups] + root-group = ["ok==1.0.0"] + + [tool.uv] + package = false + default-groups = ["root-group"] + no-index = true + find-links = ["wheels"] + + [tool.uv.workspace] + members = ["member"] + "#})?; + + context + .temp_dir + .child("member/pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "member" + version = "1.0.0" + + [dependency-groups] + member-group = ["validation==1.0.0"] + root-group = ["ok==1.0.0"] + + [tool.uv] + package = false + default-groups = ["member-group"] + "#})?; + + context.lock().arg("--offline").assert().success(); + + // Without a package selection, use the current project's defaults. + uv_snapshot!(context.filters(), context.sync() + .args(["--frozen", "--offline", "--dry-run"]), @" + exit_code: 0 (success) + ----- stderr ----- + Would use project environment at: .venv + Would download 1 package + Would install 1 package + + ok==1.0.0 + "); + + // Selecting a package uses its recorded defaults. + uv_snapshot!(context.filters(), context.sync() + .args(["--frozen", "--package", "member", "--offline", "--dry-run"]), @" + exit_code: 0 (success) + ----- stderr ----- + Would use project environment at: .venv + Would download 1 package + Would install 1 package + + validation==1.0.0 + "); + + uv_snapshot!(context.filters(), context.sync() + .args(["--frozen", "--package", "root", "--offline", "--dry-run"]), @" + exit_code: 0 (success) + ----- stderr ----- + Would use project environment at: .venv + Would download 1 package + Would install 1 package + + ok==1.0.0 + "); + + // Explicit group selection overrides the defaults. + uv_snapshot!(context.filters(), context.sync() + .args(["--frozen", "--package", "member", "--only-group", "root-group", "--offline", "--dry-run"]), @" + exit_code: 0 (success) + ----- stderr ----- + Would use project environment at: .venv + Would download 1 package + Would install 1 package + + ok==1.0.0 + "); + + // Changing the manifest does not change a frozen selection. + context + .temp_dir + .child("member/pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "member" + version = "1.0.0" + + [dependency-groups] + member-group = ["validation==1.0.0"] + root-group = ["ok==1.0.0"] + + [tool.uv] + package = false + default-groups = ["root-group"] + "#})?; + + uv_snapshot!(context.filters(), context.sync() + .args(["--frozen", "--package", "member", "--offline", "--dry-run"]), @" + exit_code: 0 (success) + ----- stderr ----- + Would use project environment at: .venv + Would download 1 package + Would install 1 package + + validation==1.0.0 + "); + + // The same selection works without the member's manifest. + fs_err::remove_file(context.temp_dir.child("member/pyproject.toml"))?; + + uv_snapshot!(context.filters(), context.sync() + .args(["--frozen", "--package", "member", "--offline", "--dry-run"]), @" + exit_code: 0 (success) + ----- stderr ----- + Would use project environment at: .venv + Would download 1 package + Would install 1 package + + validation==1.0.0 + "); + + // Verify the selected dependencies are installed by a non-dry-run sync. + uv_snapshot!(context.filters(), context.sync() + .args(["--frozen", "--package", "member", "--offline"]), @" + exit_code: 0 (success) + ----- stderr ----- + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + validation==1.0.0 + "); + + Ok(()) +} + +/// Python requirements for selected default groups come from the lockfile, too. +#[test] +fn sync_frozen_member_default_groups_requires_python() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + // The root and member's `docs` groups have different Python requirements. + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "root" + version = "1.0.0" + requires-python = ">=3.12" + + [dependency-groups] + docs = [] + + [tool.uv] + package = false + default-groups = [] + + [tool.uv.workspace] + members = ["member"] + + [tool.uv.dependency-groups] + docs = { requires-python = ">=3.14" } + "#})?; + + context + .temp_dir + .child("member/pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "member" + version = "1.0.0" + + [dependency-groups] + docs = [] + + [tool.uv] + package = false + default-groups = ["docs"] + + [tool.uv.dependency-groups] + docs = { requires-python = ">=3.13" } + "#})?; + + context.lock().arg("--offline").assert().success(); + + // The lockfile remains authoritative when the member's requirement changes. + context + .temp_dir + .child("member/pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "member" + version = "1.0.0" + + [dependency-groups] + docs = [] + + [tool.uv] + package = false + default-groups = ["docs"] + + [tool.uv.dependency-groups] + docs = { requires-python = ">=3.14" } + "#})?; + + uv_snapshot!(context.filters(), context.sync() + .args(["--frozen", "--package", "member", "--python", "3.12", "--offline", "--dry-run"]), @" + exit_code: 2 (failure) + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + error: The requested interpreter resolved to Python 3.12.[X], which is incompatible with the project's Python requirement: `>=3.13` (from `member:docs` in `uv.lock`). + "); + + // The diagnostic still identifies the member's group without its manifest. + fs_err::remove_file(context.temp_dir.child("member/pyproject.toml"))?; + + uv_snapshot!(context.filters(), context.sync() + .args(["--frozen", "--package", "member", "--python", "3.12", "--offline", "--dry-run"]), @" + exit_code: 2 (failure) + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + error: The requested interpreter resolved to Python 3.12.[X], which is incompatible with the project's Python requirement: `>=3.13` (from `member:docs` in `uv.lock`). + "); + + // Disabling the default groups removes the member group's requirement. + uv_snapshot!(context.filters(), context.sync() + .args(["--frozen", "--package", "member", "--no-default-groups", "--python", "3.12", "--offline", "--dry-run"]), @" + exit_code: 0 (success) + ----- stderr ----- + Would use project environment at: .venv + Checked in [TIME] + Would make no changes + "); + + // Without a selected member, Python requirements still come from the manifest. + uv_snapshot!(context.filters(), context.sync() + .args(["--frozen", "--group", "docs", "--python", "3.12", "--offline", "--dry-run"]), @" + exit_code: 2 (failure) + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + error: The requested interpreter resolved to Python 3.12.[X], which is incompatible with the project's Python requirement: `>=3.14` (from `tool.uv.dependency-groups.docs.requires-python`). + "); + + Ok(()) +} + +/// Frozen sync reports the lockfile requirements that exclude the requested Python. +#[test] +fn sync_frozen_member_default_groups_python_sources() -> Result<()> { + let context = uv_test::test_context!("3.12"); + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "root" + version = "1.0.0" + requires-python = ">=3.12" + + [tool.uv] + package = false + + [tool.uv.workspace] + members = ["member"] + "#})?; + + // Record workspace and member group Python requirements. + context.temp_dir.child("uv.lock").write_str(indoc! {r#" + version = 1 + revision = 5 + requires-python = ">=3.13" + + [manifest] + members = ["member", "root"] + + [manifest.default-groups] + member = ["docs"] + + [manifest.group-metadata] + member = { docs = { requires-python = ">=3.14" }, test = { requires-python = ">=3.13" }, unbounded = {} } + + [[package]] + name = "member" + version = "1.0.0" + source = { virtual = "member" } + + [package.dev-dependencies] + docs = [] + test = [] + unbounded = [] + + [[package]] + name = "root" + version = "1.0.0" + source = { virtual = "." } + "#})?; + + // With no default groups, only the workspace requirement applies. + uv_snapshot!(context.filters(), context.sync() + .args(["--frozen", "--package", "member", "--no-default-groups", "--python", "3.12", "--offline", "--dry-run"]), @" + exit_code: 2 (failure) + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + error: The requested interpreter resolved to Python 3.12.[X], which is incompatible with the project's Python requirement: `>=3.13` (from `requires-python` in `uv.lock`). + "); + + // Report all incompatible lockfile requirements when both groups are selected. + uv_snapshot!(context.filters(), context.sync() + .args(["--frozen", "--package", "member", "--all-groups", "--python", "3.12", "--offline", "--dry-run"]), @" + exit_code: 2 (failure) + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + error: The requested interpreter resolved to Python 3.12.[X], which is incompatible with the project's Python requirement: `>=3.14`. + The following requirements in `uv.lock` do not permit this version: + - lockfile: >=3.13 + - member:docs: >=3.14 + - member:test: >=3.13 + "); + + // A `.python-version` request reports the requirements for the default group. + context + .temp_dir + .child(".python-version") + .write_str("3.12")?; + + uv_snapshot!(context.filters(), context.sync() + .args(["--frozen", "--package", "member", "--offline", "--dry-run"]), @" + exit_code: 2 (failure) + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + error: The Python request from `.python-version` resolved to Python 3.12.[X], which is incompatible with the project's Python requirement: `>=3.14`. + The following requirements in `uv.lock` do not permit this version: + - lockfile: >=3.13 + - member:docs: >=3.14 + Use `uv python pin` to update the `.python-version` file to a compatible version + "); + + Ok(()) +} + +/// Older lockfiles continue to use the current project's default groups. +#[test] +fn sync_frozen_member_default_groups_revision_4() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + // The root's default group requires Python 3.13 or newer. + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "root" + version = "1.0.0" + requires-python = ">=3.12" + + [dependency-groups] + root-group = [] + + [tool.uv] + package = false + default-groups = ["root-group"] + + [tool.uv.workspace] + members = ["member"] + + [tool.uv.dependency-groups] + root-group = { requires-python = ">=3.13" } + "#})?; + + context + .temp_dir + .child("member/pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "member" + version = "1.0.0" + + [dependency-groups] + member-group = [] + + [tool.uv] + package = false + default-groups = ["member-group"] + "#})?; + + // Revision 4 does not record the member's default groups or Python requirements. + context.temp_dir.child("uv.lock").write_str(indoc! {r#" + version = 1 + revision = 4 + requires-python = ">=3.12" + + [manifest] + members = ["member", "root"] + + [[package]] + name = "member" + version = "1.0.0" + source = { virtual = "member" } + + [package.dev-dependencies] + member-group = [] + + [[package]] + name = "root" + version = "1.0.0" + source = { virtual = "." } + + [package.dev-dependencies] + root-group = [] + "#})?; + + // Frozen sync uses the current project's defaults when the member exists. + uv_snapshot!(context.filters(), context.sync() + .args(["--frozen", "--package", "member", "--python", "3.12", "--offline", "--dry-run"]), @" + exit_code: 2 (failure) + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + error: The requested interpreter resolved to Python 3.12.[X], which is incompatible with the project's Python requirement: `>=3.13` (from workspace member `root`'s `tool.uv.dependency-groups.root-group.requires-python`). + "); + + // The same defaults apply when the member manifest is missing. + fs_err::remove_file(context.temp_dir.child("member/pyproject.toml"))?; + + uv_snapshot!(context.filters(), context.sync() + .args(["--frozen", "--package", "member", "--python", "3.12", "--offline", "--dry-run"]), @" + exit_code: 2 (failure) + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + error: The requested interpreter resolved to Python 3.12.[X], which is incompatible with the project's Python requirement: `>=3.13` (from `tool.uv.dependency-groups.root-group.requires-python`). + "); + + // Explicitly disabling defaults allows Python 3.12. + uv_snapshot!(context.filters(), context.sync() + .args(["--frozen", "--package", "member", "--no-default-groups", "--python", "3.12", "--offline", "--dry-run"]), @" + exit_code: 0 (success) + ----- stderr ----- + Would use project environment at: .venv + Checked in [TIME] + Would make no changes + "); + + Ok(()) +} + /// Sync development dependencies in a non-project workspace root with `--frozen`. #[test] fn sync_non_project_frozen() -> Result<()> { @@ -3325,7 +3837,7 @@ fn sync_relative_wheel() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -4239,12 +4751,27 @@ fn sync_non_existent_default_group() -> Result<()> { foo = [] [tool.uv] - default-groups = ["bar"] + default-groups = ["foo"] "#, )?; - context.lock().assert().success(); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["typing-extensions"] + + [dependency-groups] + foo = [] + + [tool.uv] + default-groups = ["bar"] + "#, + )?; + uv_snapshot!(context.filters(), context.sync(), @" exit_code: 2 (failure) ----- stderr ----- @@ -4902,7 +5429,7 @@ fn sync_group_non_project_member() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -5055,7 +5582,7 @@ fn sync_group_self() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -6985,7 +7512,7 @@ fn convert_to_virtual() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -7042,7 +7569,7 @@ fn convert_to_virtual() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -7108,7 +7635,7 @@ fn convert_to_package() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -7176,7 +7703,7 @@ fn convert_to_package() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -9282,7 +9809,7 @@ fn sync_dynamic_extra() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -10679,7 +11206,7 @@ fn sync_stale_egg_info() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.13" [options] @@ -10780,7 +11307,7 @@ fn sync_git_repeated_member_static_metadata() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.13" [options] @@ -10868,7 +11395,7 @@ fn sync_git_repeated_member_dynamic_metadata() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.13" [options] @@ -10980,7 +11507,7 @@ fn sync_git_repeated_member_backwards_path() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.13" [options] @@ -11067,7 +11594,7 @@ fn sync_git_path_archive() -> Result<()> { assert_snapshot!( lock, @r###" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.13" [options] @@ -11343,7 +11870,7 @@ fn sync_git_path_dependency() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.13" [options] @@ -11497,7 +12024,7 @@ fn lock_git_poetry_path_dependency() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.13" [options] @@ -11634,7 +12161,7 @@ fn sync_git_metadata_archive_dependency() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.13" [options] @@ -11736,7 +12263,7 @@ fn sync_build_tag() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -12300,13 +12827,55 @@ fn sync_script() -> Result<()> { uv_snapshot!(context.filters(), context.sync().arg("--script").arg("script.py").arg("--frozen"), @" exit_code: 2 (failure) ----- stderr ----- - Using script environment at: [CACHE_DIR]/environments-v2/script-[HASH] error: `uv sync --frozen` requires a script lockfile; run `uv lock --script script.py` to lock the script "); Ok(()) } +/// Frozen script locks are read before selecting or creating the script environment. +#[test] +fn sync_frozen_script_lockfile_before_environment() -> Result<()> { + let context = uv_test::test_context_with_versions!(&["3.12"]); + context.temp_dir.child("script.py").write_str(indoc! {r#" + # /// script + # requires-python = ">=3.13" + # dependencies = [] + # /// + "#})?; + + // Report the missing lockfile before checking the requested Python. + uv_snapshot!(context.filters(), context.sync() + .args(["--script", "script.py", "--frozen", "--python", "3.12", "--offline"]), @" + exit_code: 2 (failure) + ----- stderr ----- + error: `uv sync --frozen` requires a script lockfile; run `uv lock --script script.py` to lock the script + "); + + // A malformed lockfile also fails before creating an environment. + context + .temp_dir + .child("script.py.lock") + .write_str(indoc! {r" + version = 1 + revision = 4 + "})?; + uv_snapshot!(context.filters(), context.sync() + .args(["--script", "script.py", "--frozen", "--python", "3.12", "--offline"]), @" + exit_code: 2 (failure) + ----- stderr ----- + error: Failed to parse `uv.lock` + cause: TOML parse error at line 1, column 1 + | + 1 | version = 1 + | ^ + missing field `requires-python` + "); + + assert!(!context.cache_dir.child("environments-v2").exists()); + Ok(()) +} + #[test] fn sync_locked_script() -> Result<()> { let context = uv_test::test_context_with_versions!(&["3.9", "3.12"]); @@ -12339,7 +12908,7 @@ fn sync_locked_script() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11" [options] @@ -12408,6 +12977,15 @@ fn sync_locked_script() -> Result<()> { "# })?; + // Frozen sync keeps the recorded resolution when the script's dependencies change. + uv_snapshot!(context.filters(), context.sync().arg("--script").arg("script.py").arg("--frozen"), @" + exit_code: 0 (success) + ----- stderr ----- + Using script environment at: [CACHE_DIR]/environments-v2/script-[HASH] + Checked 3 packages in [TIME] + "); + assert_eq!(context.read("script.py.lock"), lock); + // Re-run with `--locked`. uv_snapshot!(context.filters(), context.sync().arg("--script").arg("script.py").arg("--locked"), @" exit_code: 1 (failure) @@ -12437,7 +13015,7 @@ fn sync_locked_script() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11" [options] @@ -13118,7 +13696,7 @@ fn locked_version_coherence() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -13213,7 +13791,7 @@ fn sync_build_constraints() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -14071,7 +14649,7 @@ fn conflicting_editable() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "project", group = "bar" }, @@ -14222,7 +14800,7 @@ fn undeclared_editable() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" conflicts = [[ { package = "project", group = "bar" }, @@ -15059,7 +15637,7 @@ fn sync_git_lfs() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.13" [options] @@ -15173,7 +15751,7 @@ fn sync_git_lfs() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.13" [options] @@ -15311,7 +15889,7 @@ fn sync_git_lfs() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.13" [options] @@ -15363,7 +15941,7 @@ fn sync_git_lfs() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.13" [options] @@ -15948,7 +16526,7 @@ fn toggle_workspace_editable() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -16052,7 +16630,7 @@ fn toggle_workspace_editable() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -16203,7 +16781,7 @@ fn workspace_editable_conflict() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -16297,7 +16875,7 @@ fn workspace_editable_conflict() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -17219,7 +17797,7 @@ fn project_build_hashes_lock_and_sync() -> Result<()> { insta::with_settings!({filters => context.filters()}, { assert_snapshot!(lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -17572,7 +18150,7 @@ fn project_build_hashes_locked_script_run_with_no_sync() -> Result<()> { insta::with_settings!({filters => context.filters()}, { assert_snapshot!(context.read("script.py.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] diff --git a/crates/uv/tests/tool/tool_install.rs b/crates/uv/tests/tool/tool_install.rs index 76b036c9730..faab00ae430 100644 --- a/crates/uv/tests/tool/tool_install.rs +++ b/crates/uv/tests/tool/tool_install.rs @@ -5870,7 +5870,7 @@ fn tool_install_locks_are_preview() { insta::with_settings!({ filters => context.filters() }, { assert_snapshot!(context.read("tools/simple-launcher/uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -5938,7 +5938,7 @@ fn tool_install_lock_supports_local_wheel() { insta::with_settings!({ filters => context.filters() }, { assert_snapshot!(context.read("tools/simple-launcher/uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -6196,7 +6196,7 @@ fn tool_install_lock_resolution_inputs_constraints() -> Result<()> { insta::with_settings!({ filters => context.filters() }, { assert_snapshot!(lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] diff --git a/crates/uv/tests/tool/tool_upgrade.rs b/crates/uv/tests/tool/tool_upgrade.rs index 0fffc7bcd01..bb809f05a1e 100644 --- a/crates/uv/tests/tool/tool_upgrade.rs +++ b/crates/uv/tests/tool/tool_upgrade.rs @@ -1624,7 +1624,7 @@ fn tool_upgrade_writes_preview_lock() { insta::with_settings!({ filters => context.filters() }, { assert_snapshot!(context.read("tools/simple-launcher/uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] diff --git a/crates/uv/tests/workspace/workspace.rs b/crates/uv/tests/workspace/workspace.rs index d00e5af9e41..6c8e5da62a6 100644 --- a/crates/uv/tests/workspace/workspace.rs +++ b/crates/uv/tests/workspace/workspace.rs @@ -1382,7 +1382,7 @@ fn workspace_inherit_sources() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -2137,7 +2137,7 @@ fn transitive_dep_in_git_workspace_with_cache_inside_workspace() -> Result<()> { }, { assert_snapshot!(context.read("uv.lock"), @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] diff --git a/docs/concepts/cache.md b/docs/concepts/cache.md index 032c1194f98..176d6399d02 100644 --- a/docs/concepts/cache.md +++ b/docs/concepts/cache.md @@ -126,9 +126,12 @@ package's `pyproject.toml`, `setup.py`, or `setup.cfg` file has changed. ## Cache safety It's safe to run multiple uv commands concurrently, even against the same virtual environment. uv's -cache is designed to be thread-safe and append-only, and thus robust to multiple concurrent readers -and writers. uv applies a file-based lock to the target virtual environment when installing, to -avoid concurrent modifications across processes. +cache supports multiple concurrent readers and writers. uv applies a file-based lock to the target +virtual environment when installing, to avoid concurrent modifications across processes. + +Interrupted or overlapping HTTP cache refreshes can invalidate individual cache entries. uv detects +incomplete policy updates and downloads the response again instead of using the invalid entry. With +`--offline`, an affected entry is unavailable until it can be refreshed online. Note that it's _never_ safe to modify the cache directly (e.g., by removing a file or directory).