From 2f04f14f3babffbb6a2f34ab8461b5543807ea0e Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 29 Sep 2026 21:30:04 -0400 Subject: [PATCH 01/10] Record workspace member default groups in lockfiles (#22010) ## Summary Record workspace members' default dependency groups in `uv.lock`, so frozen commands can select them without reading member manifests in the future: ```toml [manifest.default-groups] member = ["docs"] ``` (We omit the implicit `["dev"]` default.) As part of this change, I've bumped to revision 5. This effectively removes the behavior where if revision 4 is set, we "retain" metadata-free locks; I think it's fine to require that the relevant flag is set in those cases? --- crates/uv-lock/src/lock/deserialize.rs | 10 +- crates/uv-lock/src/lock/mod.rs | 116 ++- crates/uv-lock/src/lock/serialize.rs | 33 +- ...k__lock__tests__hash_optional_missing.snap | 1 + ...k__lock__tests__hash_optional_present.snap | 1 + ...k__lock__tests__hash_required_present.snap | 1 + ...missing_dependency_source_unambiguous.snap | 1 + ...dependency_source_version_unambiguous.snap | 1 + ...s__missing_dependency_version_dynamic.snap | 1 + ...issing_dependency_version_unambiguous.snap | 1 + ...lock__tests__source_direct_has_subdir.snap | 1 + ..._lock__tests__source_direct_no_subdir.snap | 1 + ...v_lock__lock__tests__source_directory.snap | 1 + ...uv_lock__lock__tests__source_editable.snap | 1 + crates/uv-workspace/src/pyproject.rs | 16 +- crates/uv-workspace/src/workspace.rs | 4 +- .../uv/src/commands/project/install_target.rs | 23 +- crates/uv/src/commands/project/lock.rs | 28 +- crates/uv/tests/it/branching_urls.rs | 10 +- .../it__ecosystem__black-lock-file.snap | 2 +- ...it__ecosystem__cibuildwheel-lock-file.snap | 2 +- ...it__ecosystem__cookiecutter-lock-file.snap | 2 +- .../it__ecosystem__flask-lock-file.snap | 5 +- ...system__github-wikidata-bot-lock-file.snap | 2 +- ...system__home-assistant-core-lock-file.snap | 2 +- .../it__ecosystem__httpx-lock-file.snap | 2 +- .../it__ecosystem__jupyterlab-lock-file.snap | 2 +- .../it__ecosystem__llm-lock-file.snap | 2 +- ...t__ecosystem__openai-python-lock-file.snap | 2 +- .../it__ecosystem__packse-lock-file.snap | 2 +- .../it__ecosystem__pandas-lock-file.snap | 2 +- .../it__ecosystem__poetry-lock-file.snap | 2 +- .../it__ecosystem__pytest-cov-lock-file.snap | 2 +- ...it__ecosystem__pyx-external-lock-file.snap | 2 +- ...t__ecosystem__pyx-workspace-lock-file.snap | 2 +- .../it__ecosystem__saleor-lock-file.snap | 2 +- ..._ecosystem__semantic-kernel-lock-file.snap | 2 +- .../it__ecosystem__sentry-lock-file.snap | 2 +- ...it__ecosystem__transformers-lock-file.snap | 2 +- .../it__ecosystem__warehouse-lock-file.snap | 2 +- .../it__ecosystem__zulip-lock-file.snap | 2 +- crates/uv/tests/it/version.rs | 12 +- crates/uv/tests/lock/lock.rs | 614 +++++++------ crates/uv/tests/lock/minimum_libc.rs | 14 +- .../uv/tests/lock_scenarios/lock_conflict.rs | 85 +- .../lock_exclude_newer_relative.rs | 28 +- .../uv/tests/lock_scenarios/lock_scenarios.rs | 80 +- crates/uv/tests/project/check.rs | 4 +- crates/uv/tests/project/edit.rs | 108 +-- crates/uv/tests/project/export.rs | 8 +- crates/uv/tests/project/lock.rs | 806 ++++++++++++++++++ crates/uv/tests/project/main.rs | 3 + crates/uv/tests/project/run.rs | 8 +- ...se_add_remove_existing_package_noop-2.snap | 2 +- ...flow__packse_add_remove_one_package-2.snap | 2 +- ...te_transitive_to_direct_then_remove-2.snap | 2 +- crates/uv/tests/project/tree.rs | 4 +- crates/uv/tests/sync/sync.rs | 85 +- crates/uv/tests/tool/tool_install.rs | 6 +- crates/uv/tests/tool/tool_upgrade.rs | 2 +- crates/uv/tests/workspace/workspace.rs | 4 +- 61 files changed, 1564 insertions(+), 611 deletions(-) create mode 100644 crates/uv/tests/project/lock.rs diff --git a/crates/uv-lock/src/lock/deserialize.rs b/crates/uv-lock/src/lock/deserialize.rs index e5dcfd2103f..4c342552eec 100644 --- a/crates/uv-lock/src/lock/deserialize.rs +++ b/crates/uv-lock/src/lock/deserialize.rs @@ -340,6 +340,7 @@ enum MapKind { Options, OptionsExcludeNewerPackage, Manifest, + ManifestDefaultGroups, ManifestDependencyGroups, ManifestDependencyMetadata, Package, @@ -468,7 +469,9 @@ impl<'de> DocumentMapAccess<'_, 'de> { } ( MapKind::Root, - "[manifest.dependency-groups]" | "[[manifest.dependency-metadata]]", + "[manifest.default-groups]" + | "[manifest.dependency-groups]" + | "[[manifest.dependency-metadata]]", ) => { // The manifest map consumes the first subtable when its parent is implicit. self.track_key("manifest")?; @@ -492,6 +495,11 @@ 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.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..bcf45c067cd 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, @@ -118,10 +118,18 @@ pub enum LockParseError { } /// The current revision of the lockfile format. -const REVISION: u32 = 3; - -/// The first lockfile revision that supports omitting package declaration metadata. -const METADATA_FREE_REVISION: u32 = 4; +/// +/// 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 dependency groups. +const REVISION: u32 = 5; + +/// The first lockfile revision that records workspace member default groups. +const MEMBER_DEFAULT_GROUPS_REVISION: u32 = 5; static LINUX_MARKERS: LazyLock = LazyLock::new(|| { let pep508 = MarkerTree::from_str("os_name == 'posix' and sys_platform == 'linux'").unwrap(); @@ -2810,13 +2818,24 @@ 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 + } + /// 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 +2886,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 +3106,36 @@ 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 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 +4129,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 +4145,28 @@ 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, + )); + } + } + // 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 +5980,11 @@ 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, + ), /// 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 +6157,9 @@ 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, /// 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 +6190,22 @@ 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 +} + impl ResolverManifest { /// Initialize a [`ResolverManifest`] with the given members, requirements, constraints, and /// overrides. @@ -6134,6 +6222,7 @@ impl ResolverManifest { let normalize = uv_preview::is_enabled(PreviewFeature::LockfileNormalization); Self { members: members.into_iter().collect(), + default_groups: BTreeMap::new(), requirements: normalize_collection::<_, NormalizedRequirements>( requirements, normalize, @@ -6163,6 +6252,7 @@ impl ResolverManifest { pub fn relative_to(self, root: &Path) -> Result { Ok(Self { members: self.members, + default_groups: self.default_groups, requirements: self .requirements .into_iter() diff --git a/crates/uv-lock/src/lock/serialize.rs b/crates/uv-lock/src/lock/serialize.rs index 61123e28350..569dbc250d2 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,13 @@ 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 has_dependency_groups { writer.table(&["manifest", "dependency-groups"])?; for (group, requirements) in &manifest.dependency_groups { @@ -277,7 +283,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 +321,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 +340,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..ea557567e1a 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,7 @@ Ok( }, manifest: ResolverManifest { members: {}, + default_groups: {}, 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..8b3a8f10fb4 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,7 @@ Ok( }, manifest: ResolverManifest { members: {}, + default_groups: {}, 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..1c4894cb225 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,7 @@ Ok( }, manifest: ResolverManifest { members: {}, + default_groups: {}, 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..01fbc6ac837 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,7 @@ Ok( }, manifest: ResolverManifest { members: {}, + default_groups: {}, 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..01fbc6ac837 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,7 @@ Ok( }, manifest: ResolverManifest { members: {}, + default_groups: {}, 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..1debddb344b 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,7 @@ Ok( }, manifest: ResolverManifest { members: {}, + default_groups: {}, 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..01fbc6ac837 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,7 @@ Ok( }, manifest: ResolverManifest { members: {}, + default_groups: {}, 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..de41c76935f 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,7 @@ Ok( }, manifest: ResolverManifest { members: {}, + default_groups: {}, 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..749b5b9007c 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,7 @@ Ok( }, manifest: ResolverManifest { members: {}, + default_groups: {}, 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..5cae8d76d19 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,7 @@ Ok( }, manifest: ResolverManifest { members: {}, + default_groups: {}, 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..64e79d341d7 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,7 @@ Ok( }, manifest: ResolverManifest { members: {}, + default_groups: {}, requirements: {}, dependency_groups: {}, constraints: {}, 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/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..4a7f29225c1 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,19 @@ 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(), + ); let lock = if let Some(recorder) = recorder { lock.prune_unused(recorder.take()) @@ -1476,6 +1495,13 @@ 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::MismatchedEditable(name, expected) => { if expected { debug!( 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..c1f2eb29993 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] @@ -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'", @@ -33361,7 +33359,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'", @@ -33587,7 +33585,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'", @@ -34057,7 +34055,7 @@ fn lock_group_workspace() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -34245,7 +34243,7 @@ fn lock_transitive_git() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -34397,7 +34395,7 @@ fn lock_dynamic_version() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -34433,7 +34431,7 @@ fn lock_dynamic_version() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -34502,7 +34500,7 @@ fn lock_dynamic_version_dependencies() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -34538,7 +34536,7 @@ fn lock_dynamic_version_dependencies() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -34725,7 +34723,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 +34790,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 +34906,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 +34967,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 +35065,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 +35233,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 +35393,7 @@ fn lock_dynamic_built_cache() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -35436,7 +35434,7 @@ fn lock_dynamic_built_cache() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -35509,7 +35507,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 +35781,7 @@ fn lock_dynamic_to_static() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -35836,7 +35834,7 @@ fn lock_dynamic_to_static() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -35888,7 +35886,7 @@ fn lock_static_to_dynamic() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -35961,7 +35959,7 @@ fn lock_static_to_dynamic() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -36007,7 +36005,7 @@ fn lock_bump_static_version() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -36057,7 +36055,7 @@ fn lock_bump_static_version() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -36325,7 +36323,7 @@ fn lock_relative_project() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -36419,7 +36417,7 @@ fn lock_recursive_extra() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -36598,7 +36596,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 +37044,7 @@ fn lock_self_compatible() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -37135,7 +37133,7 @@ fn lock_self_exact() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -37255,7 +37253,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 +37409,7 @@ fn lock_self_extra_compatible() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -37532,7 +37530,7 @@ fn lock_self_marker_compatible() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -37654,7 +37652,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 +37771,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 +37844,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 +37918,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 +37988,7 @@ fn lock_script() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11" [options] @@ -38121,7 +38119,7 @@ fn lock_script_path() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11" [options] @@ -38255,7 +38253,7 @@ fn lock_script_editable_path_dependency_change() -> Result<()> { lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11" [options] @@ -38339,7 +38337,7 @@ fn lock_script_editable_path_dependency_change() -> Result<()> { lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11" [options] @@ -38406,7 +38404,7 @@ fn lock_script_initialize() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -38512,7 +38510,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 +39161,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 +39626,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 +40015,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 +40348,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 +40423,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 +40495,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 +40561,7 @@ fn lock_empty_extra() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -40662,7 +40660,7 @@ fn lock_empty_extra() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -40818,7 +40816,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 +40920,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 +41060,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 +41133,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 +41308,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 +41676,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 +41750,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 +41872,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 +41918,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 +41964,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 +42015,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 +42098,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 +42148,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 +42262,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 +42336,7 @@ fn lock_exclude_newer_package() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -43295,7 +43293,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 +43385,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 +43596,7 @@ fn lock_refresh() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -43731,7 +43729,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 +43981,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 +44092,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 +44233,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 +44609,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 +45113,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 +45397,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 +45746,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 +45874,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 +45959,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 +46197,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 +46412,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 +46714,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 +46838,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 +47027,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 +47124,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 +47304,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 +47375,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 +47767,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 +48143,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 +48277,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 +48425,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 +48550,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/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..14ba4a34843 --- /dev/null +++ b/crates/uv/tests/project/lock.rs @@ -0,0 +1,806 @@ +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(()) +} + +/// 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. +#[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); + 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..0f10387da33 100644 --- a/crates/uv/tests/sync/sync.rs +++ b/crates/uv/tests/sync/sync.rs @@ -3325,7 +3325,7 @@ fn sync_relative_wheel() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -4239,12 +4239,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 +4917,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 +5070,7 @@ fn sync_group_self() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -6985,7 +7000,7 @@ fn convert_to_virtual() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -7042,7 +7057,7 @@ fn convert_to_virtual() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -7108,7 +7123,7 @@ fn convert_to_package() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -7176,7 +7191,7 @@ fn convert_to_package() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -9282,7 +9297,7 @@ fn sync_dynamic_extra() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -10679,7 +10694,7 @@ fn sync_stale_egg_info() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.13" [options] @@ -10780,7 +10795,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 +10883,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 +10995,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 +11082,7 @@ fn sync_git_path_archive() -> Result<()> { assert_snapshot!( lock, @r###" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.13" [options] @@ -11343,7 +11358,7 @@ fn sync_git_path_dependency() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.13" [options] @@ -11497,7 +11512,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 +11649,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 +11751,7 @@ fn sync_build_tag() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -12339,7 +12354,7 @@ fn sync_locked_script() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11" [options] @@ -12437,7 +12452,7 @@ fn sync_locked_script() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.11" [options] @@ -13118,7 +13133,7 @@ fn locked_version_coherence() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -13213,7 +13228,7 @@ fn sync_build_constraints() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -14071,7 +14086,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 +14237,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 +15074,7 @@ fn sync_git_lfs() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.13" [options] @@ -15173,7 +15188,7 @@ fn sync_git_lfs() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.13" [options] @@ -15311,7 +15326,7 @@ fn sync_git_lfs() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.13" [options] @@ -15363,7 +15378,7 @@ fn sync_git_lfs() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.13" [options] @@ -15948,7 +15963,7 @@ fn toggle_workspace_editable() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -16052,7 +16067,7 @@ fn toggle_workspace_editable() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -16203,7 +16218,7 @@ fn workspace_editable_conflict() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -16297,7 +16312,7 @@ fn workspace_editable_conflict() -> Result<()> { assert_snapshot!( lock, @r#" version = 1 - revision = 3 + revision = 5 requires-python = ">=3.12" [options] @@ -17219,7 +17234,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 +17587,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] From a6d5110679d32ad0ff36f93d987beaba8643f5a2 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 29 Sep 2026 21:30:04 -0400 Subject: [PATCH 02/10] Record dependency group metadata in lockfiles (#22044) ## Summary Record dependency group metadata for workspace members in `uv.lock`, so frozen commands can select a compatible Python interpreter without reading member manifests: ```toml [manifest.group-metadata] member = { docs = { requires-python = ">=3.13" } } ``` The recorded Python requirement includes bounds inherited through `include-group`. Groups without metadata are omitted, and changing a recorded requirement makes the lockfile stale. Member default groups are recorded separately in `[manifest.default-groups]`. The group metadata is recorded in lockfile revision 5 alongside member defaults. Older lockfiles remain valid without a group metadata freshness check. Requirements for non-project workspace roots are not yet recorded. --- crates/uv-lock/src/lib.rs | 4 +- crates/uv-lock/src/lock/deserialize.rs | 7 + crates/uv-lock/src/lock/mod.rs | 93 ++++++- crates/uv-lock/src/lock/serialize.rs | 7 + ...k__lock__tests__hash_optional_missing.snap | 1 + ...k__lock__tests__hash_optional_present.snap | 1 + ...k__lock__tests__hash_required_present.snap | 1 + ...missing_dependency_source_unambiguous.snap | 1 + ...dependency_source_version_unambiguous.snap | 1 + ...s__missing_dependency_version_dynamic.snap | 1 + ...issing_dependency_version_unambiguous.snap | 1 + ...lock__tests__source_direct_has_subdir.snap | 1 + ..._lock__tests__source_direct_no_subdir.snap | 1 + ...v_lock__lock__tests__source_directory.snap | 1 + ...uv_lock__lock__tests__source_editable.snap | 1 + crates/uv/src/commands/project/lock.rs | 10 +- crates/uv/tests/lock/lock.rs | 9 + crates/uv/tests/project/lock.rs | 245 +++++++++++++++++- 18 files changed, 381 insertions(+), 5 deletions(-) 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 4c342552eec..dc4c3aadc7c 100644 --- a/crates/uv-lock/src/lock/deserialize.rs +++ b/crates/uv-lock/src/lock/deserialize.rs @@ -341,6 +341,7 @@ enum MapKind { OptionsExcludeNewerPackage, Manifest, ManifestDefaultGroups, + ManifestGroupMetadata, ManifestDependencyGroups, ManifestDependencyMetadata, Package, @@ -470,6 +471,7 @@ impl<'de> DocumentMapAccess<'_, 'de> { ( MapKind::Root, "[manifest.default-groups]" + | "[manifest.group-metadata]" | "[manifest.dependency-groups]" | "[[manifest.dependency-metadata]]", ) => { @@ -500,6 +502,11 @@ impl<'de> DocumentMapAccess<'_, 'de> { 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 bcf45c067cd..1c196223c1c 100644 --- a/crates/uv-lock/src/lock/mod.rs +++ b/crates/uv-lock/src/lock/mod.rs @@ -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; @@ -125,12 +126,15 @@ pub enum LockParseError { /// - 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 dependency 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 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(); UniversalMarker::new(pep508, ConflictMarker::TRUE) @@ -2830,6 +2834,15 @@ impl Lock { 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 @@ -3116,6 +3129,17 @@ impl Lock { .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. @@ -4167,6 +4191,24 @@ impl Lock { } } + 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 { @@ -5985,6 +6027,11 @@ pub enum SatisfiesResult<'lock> { 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. @@ -6160,6 +6207,9 @@ pub struct ResolverManifest { /// 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 @@ -6206,6 +6256,42 @@ fn canonicalize_member_default_groups( 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")] + 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. @@ -6223,6 +6309,7 @@ impl ResolverManifest { Self { members: members.into_iter().collect(), default_groups: BTreeMap::new(), + group_metadata: BTreeMap::new(), requirements: normalize_collection::<_, NormalizedRequirements>( requirements, normalize, @@ -6253,6 +6340,7 @@ impl ResolverManifest { Ok(Self { members: self.members, default_groups: self.default_groups, + group_metadata: self.group_metadata, requirements: self .requirements .into_iter() @@ -9609,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 569dbc250d2..71ab7bbe346 100644 --- a/crates/uv-lock/src/lock/serialize.rs +++ b/crates/uv-lock/src/lock/serialize.rs @@ -245,6 +245,13 @@ fn write_manifest(writer: &mut LockWriter, manifest: &ResolverManifest) -> Resul } } + 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 { 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 ea557567e1a..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 @@ -128,6 +128,7 @@ 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 8b3a8f10fb4..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 @@ -132,6 +132,7 @@ 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 1c4894cb225..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 @@ -124,6 +124,7 @@ 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 01fbc6ac837..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 @@ -206,6 +206,7 @@ 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 01fbc6ac837..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 @@ -206,6 +206,7 @@ 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 1debddb344b..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 @@ -233,6 +233,7 @@ 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 01fbc6ac837..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 @@ -206,6 +206,7 @@ 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 de41c76935f..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 @@ -107,6 +107,7 @@ 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 749b5b9007c..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 @@ -103,6 +103,7 @@ 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 5cae8d76d19..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 @@ -93,6 +93,7 @@ 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 64e79d341d7..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 @@ -93,6 +93,7 @@ Ok( manifest: ResolverManifest { members: {}, default_groups: {}, + group_metadata: {}, requirements: {}, dependency_groups: {}, constraints: {}, diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 4a7f29225c1..d6a1ff60f53 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -1189,7 +1189,8 @@ async fn do_lock( .map(|groups| (name.clone(), groups)) }) .collect(), - ); + ) + .with_member_group_metadata(packages)?; let lock = if let Some(recorder) = recorder { lock.prune_unused(recorder.take()) @@ -1502,6 +1503,13 @@ impl ValidatedLock { ); 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/tests/lock/lock.rs b/crates/uv/tests/lock/lock.rs index c1f2eb29993..4fbf4bd3ab9 100644 --- a/crates/uv/tests/lock/lock.rs +++ b/crates/uv/tests/lock/lock.rs @@ -33233,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" @@ -33371,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" @@ -33595,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" diff --git a/crates/uv/tests/project/lock.rs b/crates/uv/tests/project/lock.rs index 14ba4a34843..65b1ed4b58c 100644 --- a/crates/uv/tests/project/lock.rs +++ b/crates/uv/tests/project/lock.rs @@ -258,6 +258,248 @@ fn member_default_groups_in_lockfile() -> Result<()> { 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<()> { @@ -405,7 +647,7 @@ fn legacy_metadata_free_lockfile() -> Result<()> { Ok(()) } -/// Revision 4 preview locks do not record member defaults. +/// 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"); @@ -443,6 +685,7 @@ fn metadata_free_default_groups_in_lockfile() -> Result<()> { "#}; 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() From 831a46a7c04f8934415fbddffd6a853dca703c54 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 29 Sep 2026 21:30:05 -0400 Subject: [PATCH 03/10] Use recorded member defaults for frozen sync (#22015) ## Summary `uv sync --package member` uses the selected member's default dependency groups, but adding `--frozen` makes us use the defaults of the project we're running from instead. For example, when run from a workspace root whose defaults are `["dev"]`, we select `dev` even if the requested member's defaults are `["docs"]`. The same package selection can therefore install different dependencies depending on whether you pass `--frozen`. For revision 5 and later lockfiles, frozen sync uses the selected member's recorded default groups and group Python requirements, including when the member's manifest is missing. --- crates/uv-lock/src/lock/mod.rs | 2 +- crates/uv/src/commands/project/add.rs | 1 + crates/uv/src/commands/project/check.rs | 16 +- crates/uv/src/commands/project/mod.rs | 393 +++++++++---- crates/uv/src/commands/project/remove.rs | 1 + crates/uv/src/commands/project/run.rs | 18 +- crates/uv/src/commands/project/sync.rs | 104 ++-- crates/uv/src/commands/project/version.rs | 1 + crates/uv/src/commands/python/find.rs | 18 +- crates/uv/src/commands/venv.rs | 18 +- crates/uv/src/commands/workspace/metadata.rs | 1 + crates/uv/tests/sync/sync.rs | 565 ++++++++++++++++++- 12 files changed, 968 insertions(+), 170 deletions(-) diff --git a/crates/uv-lock/src/lock/mod.rs b/crates/uv-lock/src/lock/mod.rs index 1c196223c1c..40fb73b5289 100644 --- a/crates/uv-lock/src/lock/mod.rs +++ b/crates/uv-lock/src/lock/mod.rs @@ -6262,7 +6262,7 @@ fn canonicalize_member_default_groups( pub struct GroupMetadata { /// The effective Python requirement, including requirements from included groups. #[serde(default, skip_serializing_if = "Option::is_none")] - requires_python: Option, + pub requires_python: Option, } /// Collect metadata for each member's dependency 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/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/sync/sync.rs b/crates/uv/tests/sync/sync.rs index 0f10387da33..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<()> { @@ -12315,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"]); @@ -12423,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) From f060188cee709802449880e3f9c97a53e921bb72 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 29 Sep 2026 22:07:11 -0400 Subject: [PATCH 04/10] Refresh HTTP cache policies in place (#22052) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We now update the HTTP cache policy in place on a 304 response, avoiding a rewrite of the cached payload. The format change bumps the six cache buckets containing HTTP responses. Warm-cache benchmarks on Linux/ext4, with 96 entries and 16 concurrent requests per batch (`c9dab42be` → `1562e401b`, using SeaHash): | Warm-cache workload | Main | PR | Paired change | |---|---:|---:|---:| | 304 refresh, 256 KiB | 80.39 ms | 9.84 ms | 87.4% faster | | 304 refresh, 1 MiB | 323.20 ms | 17.59 ms | 94.6% faster | | Fresh reads, 4 KiB | 0.711 ms | 0.712 ms | Approximately unchanged | | Fresh reads, 256 KiB | 2.140 ms | 2.112 ms | 2.9% faster | | Fresh reads, 1 MiB | 7.529 ms | 7.698 ms | No clear difference | --- Cargo.lock | 3 + Cargo.toml | 2 + crates/uv-cache/src/lib.rs | 14 +- crates/uv-client/Cargo.toml | 3 + crates/uv-client/src/cached_client.rs | 380 ++++++++++-------- crates/uv-client/tests/it/cached_client.rs | 365 ++++++++++++++++- crates/uv-distribution/src/source/revision.rs | 4 +- crates/uv/tests/build/build.rs | 4 +- crates/uv/tests/build/cache_clean.rs | 8 +- crates/uv/tests/build/cache_prune.rs | 8 +- crates/uv/tests/lock/lock.rs | 2 +- crates/uv/tests/pip_install/pip_install.rs | 1 + docs/concepts/cache.md | 9 +- 13 files changed, 619 insertions(+), 184 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c345bc25276..be9c5aa04e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6243,6 +6243,7 @@ dependencies = [ "astral_async_zip", "async-trait", "bytecheck", + "crc32fast", "fs-err", "futures", "h2", @@ -6286,6 +6287,7 @@ dependencies = [ "uv-distribution-types", "uv-errors", "uv-extract", + "uv-fastid", "uv-fs", "uv-git", "uv-metadata", @@ -6306,6 +6308,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..d28f53ada70 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, } @@ -1302,27 +1302,27 @@ impl CacheBucket { // 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..ac641af392d 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 } @@ -50,6 +51,7 @@ async-trait = { workspace = true } async_http_range_reader = { workspace = true } async_zip = { workspace = true } bytecheck = { workspace = true } +crc32fast = { workspace = true } fs-err = { workspace = true, features = ["tokio"] } futures = { workspace = true } hex = { workspace = true } @@ -80,6 +82,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..739a44e3a4c 100644 --- a/crates/uv-client/src/cached_client.rs +++ b/crates/uv-client/src/cached_client.rs @@ -1,14 +1,20 @@ +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; @@ -335,18 +341,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.data)) + }) + }) + .await + .expect("cache refresh task panicked")?; + match payload { Ok(payload) => Ok(payload), Err(err) => { warn!( @@ -866,49 +872,40 @@ enum CachedResponse { }, } -/// 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. +/// Each file contains the payload, 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 entry ID, policy, and encoded payload length. +/// The payload remains first so an [`AlignedVec`] can be truncated without moving it. /// -/// # Future ideas +/// 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. /// -/// 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. -/// -/// 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, + 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 +955,200 @@ 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 and separate it from the aligned payload. 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.split_at_checked(data_len).ok_or_else(|| { + ErrorKind::ArchiveRead("invalid HTTP cache payload length".to_owned()) + })?; + let (entry_id, policy_bytes) = <[u8; 16]>::ref_from_prefix(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(), + ); + } + + let entry_id = *entry_id; + let mut policy = AlignedVec::with_capacity(policy_bytes.len()); + policy.extend_from_slice(policy_bytes); + let cache_policy = OwnedArchive::new(policy)?; + + bytes.resize(data_len, 0); Ok(Self { data: bytes, cache_policy, + 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 mut bytes = Vec::with_capacity(data.len() + entry_id.len() + tail.len()); + bytes.extend_from_slice(data); + 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 data_len = U64::new( + u64::try_from(data_len).map_err(|err| ErrorKind::ArchiveWrite(err.to_string()))?, + ); + + let mut hasher = Hasher::new(); + 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) + } + + /// 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.data.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.data.len() + entry_id.len() + 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 super::{CachePolicy, CachePolicyBuilder, DataWithCachePolicy, 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", - ); - return Err(ErrorKind::ArchiveRead(msg).into()); - }; - 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()); + #[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]; + 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.data.as_slice(), payload); + assert_eq!(grown.entry_id, cached.entry_id); + assert_eq!( + OwnedArchive::as_bytes(&grown.cache_policy), + 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.data.len() + cached.entry_id.len(); + 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.data.as_slice(), b"payload"); + let policy = OwnedArchive::as_bytes(&read.cache_policy); + assert!( + policy == OwnedArchive::as_bytes(&before) + || policy == OwnedArchive::as_bytes(&after) + ); + } + } + } + Ok(()) + } + + #[test] + fn cache_trailer_corruption_is_rejected() -> Result<()> { + let bytes = DataWithCachePolicy::serialize(&policy("x-small")?, b"payload")?; + // The checksum covers the 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(len_usize) + Ok(()) } } diff --git a/crates/uv-client/tests/it/cached_client.rs b/crates/uv-client/tests/it/cached_client.rs index 34b2ad46972..3cdd12eac8f 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,367 @@ 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())? + .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 + 16], original[..data_len + 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())? + .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 + 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())? + .data + .len(); + let mut torn = original.clone(); + torn[data_len + 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/src/source/revision.rs b/crates/uv-distribution/src/source/revision.rs index ed459c62609..f66014ea879 100644 --- a/crates/uv-distribution/src/source/revision.rs +++ b/crates/uv-distribution/src/source/revision.rs @@ -87,9 +87,7 @@ 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); 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/lock/lock.rs b/crates/uv/tests/lock/lock.rs index 4fbf4bd3ab9..81dd4a1f0d3 100644 --- a/crates/uv/tests/lock/lock.rs +++ b/crates/uv/tests/lock/lock.rs @@ -17430,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"); 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/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). From 991e7241ede849ac3444a933f937d1ad9443964b Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 29 Sep 2026 22:07:12 -0400 Subject: [PATCH 05/10] Store source distribution revision IDs inline (#22059) We store source-distribution revision IDs as `uv_fastid::Id`, avoiding the `String` allocation when creating or cloning them. IDs still serialize as strings. The `sdists-v10` bump allows us to reject legacy 21-character IDs. --- crates/uv-distribution/Cargo.toml | 2 +- crates/uv-distribution/src/source/revision.rs | 54 +++++++------------ 2 files changed, 19 insertions(+), 37 deletions(-) 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/source/revision.rs b/crates/uv-distribution/src/source/revision.rs index f66014ea879..8776250e813 100644 --- a/crates/uv-distribution/src/source/revision.rs +++ b/crates/uv-distribution/src/source/revision.rs @@ -2,6 +2,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}; @@ -89,12 +90,12 @@ impl Hashed for Revision { /// /// 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 { @@ -118,26 +119,6 @@ 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( @@ -152,40 +133,41 @@ mod tests { } #[test] - fn deserialize_uv_0_12_revision() { + fn deserialize_sequence_revision() { #[derive(Serialize)] - struct ReleasedRevision { + struct SequenceRevision { id: RevisionId, hashes: HashDigests, size: Option, } - let released = ReleasedRevision { - id: RevisionId("HM0NxJml5hc7UjbfTWT1r".to_string()), + let sequence = SequenceRevision { + id: RevisionId::new(), hashes: HashDigests::empty(), size: Some(42), }; - let bytes = rmp_serde::to_vec(&released).expect("serialize uv 0.12 revision"); + let bytes = rmp_serde::to_vec(&sequence).expect("serialize sequence revision"); let revision: Revision = - rmp_serde::from_slice(&bytes).expect("deserialize uv 0.12 revision"); + rmp_serde::from_slice(&bytes).expect("deserialize sequence revision"); + assert_eq!(revision.id().as_str(), sequence.id.as_str()); 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, } 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()); } } From c426475bf78354a53305971ee3ca031292108dcd Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 29 Sep 2026 22:07:13 -0400 Subject: [PATCH 06/10] Remove the legacy hash digest cache representation (#22060) We serialize `HashDigest` directly with Serde and rkyv, removing the legacy `HashDigestWire` adapter. Archived digests validate their length and hexadecimal characters through `Digest` and normalize to lowercase on deserialization. The cache bumps cover the source-distribution, wheel, simple-index, and flat-index buckets that contain these hashes. Lockfile and index hash formats are unchanged. --- crates/uv-cache/src/lib.rs | 2 - crates/uv-pypi-types/src/simple_json.rs | 193 ++++++++++-------------- 2 files changed, 80 insertions(+), 115 deletions(-) diff --git a/crates/uv-cache/src/lib.rs b/crates/uv-cache/src/lib.rs index d28f53ada70..ee39258fa30 100644 --- a/crates/uv-cache/src/lib.rs +++ b/crates/uv-cache/src/lib.rs @@ -1300,8 +1300,6 @@ 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-v10", // Note that when bumping this, you'll also need to bump it // in `crates/uv/tests/lock/lock.rs`. 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 { From d5c2b10ac1188464e9ec30e609203a3328a50e09 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 29 Sep 2026 22:07:13 -0400 Subject: [PATCH 07/10] Derive serialization for cached artifact records (#22061) We derive `Serialize` for `Revision` and `Archive`, writing MessagePack arrays instead of maps. This removes the custom serializers and saves 15 bytes per source revision and 32 bytes per wheel archive record. The `sdists-v10` and `wheels-v7` bumps remove the need to support older readers that cannot accept the added `size` field. --- crates/uv-distribution/src/archive.rs | 113 ++++-------------- crates/uv-distribution/src/source/revision.rs | 65 +++------- 2 files changed, 39 insertions(+), 139 deletions(-) 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/source/revision.rs b/crates/uv-distribution/src/source/revision.rs index 8776250e813..3dc9c9cfe55 100644 --- a/crates/uv-distribution/src/source/revision.rs +++ b/crates/uv-distribution/src/source/revision.rs @@ -1,4 +1,3 @@ -use serde::ser::SerializeMap; use serde::{Deserialize, Serialize}; use std::path::Path; use uv_distribution_types::Hashed; @@ -12,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 { @@ -121,37 +104,21 @@ mod tests { #[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_sequence_revision() { - #[derive(Serialize)] - struct SequenceRevision { - 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 sequence = SequenceRevision { - id: RevisionId::new(), - hashes: HashDigests::empty(), - size: Some(42), - }; - let bytes = rmp_serde::to_vec(&sequence).expect("serialize sequence revision"); - let revision: Revision = - rmp_serde::from_slice(&bytes).expect("deserialize sequence revision"); - - assert_eq!(revision.id().as_str(), sequence.id.as_str()); - assert_eq!(revision.size(), Some(42)); } #[test] @@ -160,6 +127,7 @@ mod tests { struct SerializedRevision { id: String, hashes: HashDigests, + size: Option, } let revision = Revision::new().with_size(42); @@ -169,5 +137,6 @@ mod tests { assert_eq!(serialized.id.as_str(), revision.id().as_str()); assert_eq!(serialized.hashes, HashDigests::empty()); + assert_eq!(serialized.size, Some(42)); } } From 394f848a8fc74277e8e691795c17a2667bfdefea Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 29 Sep 2026 22:07:13 -0400 Subject: [PATCH 08/10] Use bounded wheel cache lock names on Windows (#22062) We use `WheelFilename::cache_key()` for Windows wheel download, extraction, and metadata locks, limiting lock filenames to 69 characters, including `.lock`. This covers both PEP 658 metadata requests and the range-request fallback. The `wheels-v7` and `sdists-v10` bumps remove the need to share lock filenames with older uv versions. --- crates/uv-client/src/registry_client.rs | 4 ++-- crates/uv-distribution/src/distribution_database.rs | 8 +------- 2 files changed, 3 insertions(+), 9 deletions(-) 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-distribution/src/distribution_database.rs b/crates/uv-distribution/src/distribution_database.rs index 9de85d28407..02a00430fc4 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")); From f4f42c350a51f34a65a99114298594b6a843f9f4 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 29 Sep 2026 22:07:14 -0400 Subject: [PATCH 09/10] Pack HTTP cache policy flags and optional integers (#22078) We store the same HTTP cache information in fewer bytes, without changing cache freshness or revalidation behavior. - **Booleans become bits.** Ten fields like `no_cache`, `no_store`, and `immutable` each occupied a byte. We pack them into bits of one integer. - **Optional numbers share their presence flags.** Each archived `Option` occupies 16 bytes: the number, an indicator of whether it exists, and alignment padding. We store the numbers together and use one bit per number to indicate whether it exists. For example, `max_age = None` and `max_age = Some(0)` both store the number `0`, but their presence bits differ. Getters reconstruct the normal `Option`. We apply this to cache-control directives and response timestamps. The fixed archived policy shrinks from **320 to 200 bytes**. We use `bitflags` for typed flag operations and retain endian-aware integers in the archived representation. The cache bucket bumps cover the new representation. --- Cargo.lock | 1 + crates/uv-client/Cargo.toml | 1 + crates/uv-client/src/cached_client.rs | 6 +- crates/uv-client/src/httpcache/control.rs | 260 ++++++++++++++------ crates/uv-client/src/httpcache/mod.rs | 284 +++++++++++++++------- 5 files changed, 386 insertions(+), 166 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index be9c5aa04e3..fd08e7af479 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6242,6 +6242,7 @@ dependencies = [ "astral_async_http_range_reader", "astral_async_zip", "async-trait", + "bitflags 2.13.1", "bytecheck", "crc32fast", "fs-err", diff --git a/crates/uv-client/Cargo.toml b/crates/uv-client/Cargo.toml index ac641af392d..02521b195c4 100644 --- a/crates/uv-client/Cargo.toml +++ b/crates/uv-client/Cargo.toml @@ -50,6 +50,7 @@ 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"] } diff --git a/crates/uv-client/src/cached_client.rs b/crates/uv-client/src/cached_client.rs index 739a44e3a4c..df274c3cc32 100644 --- a/crates/uv-client/src/cached_client.rs +++ b/crates/uv-client/src/cached_client.rs @@ -853,8 +853,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 @@ -866,8 +865,7 @@ 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>, }, } 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 From 06fdf0cdb591adae479feb5b769b0213eabb229c Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 29 Sep 2026 22:07:14 -0400 Subject: [PATCH 10/10] Align cached HTTP policies within the payload buffer (#22079) We align HTTP cache policies within the payload buffer and access them directly after validation, removing a separate allocation and copy from every cache read. Extracting the payload reuses the same allocation. The file adds up to 15 bytes of padding, covered by the CRC32. Policy validation is restricted to its own slice, and refreshes retain the entry ID check before overwriting the policy. --- crates/uv-client/src/cached_client.rs | 173 ++++++++++++++---- crates/uv-client/tests/it/cached_client.rs | 15 +- .../src/distribution_database.rs | 2 +- crates/uv-distribution/src/source/mod.rs | 2 +- 4 files changed, 147 insertions(+), 45 deletions(-) diff --git a/crates/uv-client/src/cached_client.rs b/crates/uv-client/src/cached_client.rs index df274c3cc32..0a85ffdeed6 100644 --- a/crates/uv-client/src/cached_client.rs +++ b/crates/uv-client/src/cached_client.rs @@ -19,7 +19,9 @@ 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. @@ -347,7 +349,7 @@ impl CachedClient { 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.data)) + Ok::<_, Error>(Payload::from_aligned_bytes(cached.into_data())) }) }) .await @@ -521,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}"); @@ -579,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) } @@ -638,7 +642,7 @@ impl CachedClient { } match cached - .cache_policy + .cache_policy() .after_response(new_cache_policy_builder, &response) { AfterResponse::NotModified(new_policy) => { @@ -874,10 +878,11 @@ enum CachedResponse { /// /// # Format /// -/// Each file contains the payload, 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 entry ID, policy, and encoded payload length. -/// The payload remains first so an [`AlignedVec`] can be truncated without moving it. +/// 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. /// /// 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 @@ -891,8 +896,9 @@ enum CachedResponse { /// 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], } @@ -953,17 +959,21 @@ impl DataWithCachePolicy { Self::from_aligned_bytes(aligned_bytes) } - /// Validate the policy and separate it from the aligned payload. + /// Validate the policy within its aligned portion of the shared buffer. fn from_aligned_bytes(mut bytes: AlignedVec) -> Result { 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.split_at_checked(data_len).ok_or_else(|| { + let tail = contents.get(data_len..).ok_or_else(|| { ErrorKind::ArchiveRead("invalid HTTP cache payload length".to_owned()) })?; - let (entry_id, policy_bytes) = <[u8; 16]>::ref_from_prefix(tail) + 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(); @@ -975,15 +985,18 @@ impl DataWithCachePolicy { ); } + // 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 mut policy = AlignedVec::with_capacity(policy_bytes.len()); - policy.extend_from_slice(policy_bytes); - let cache_policy = OwnedArchive::new(policy)?; - - bytes.resize(data_len, 0); + 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, }) } @@ -995,8 +1008,10 @@ impl DataWithCachePolicy { .as_array::<16>() .expect("IDs have 16 bytes"); let tail = Self::serialize_policy(policy, &entry_id, data.len())?; - let mut bytes = Vec::with_capacity(data.len() + entry_id.len() + tail.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) @@ -1010,11 +1025,13 @@ impl DataWithCachePolicy { ) -> 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()); @@ -1029,6 +1046,29 @@ impl DataWithCachePolicy { 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) { @@ -1044,8 +1084,10 @@ impl DataWithCachePolicy { } Err(err) => return Err(ErrorKind::CacheWrite(err).into()), }; - file.seek(SeekFrom::Start(self.data.len() as u64)) - .map_err(ErrorKind::CacheWrite)?; + 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(()) => {} @@ -1056,9 +1098,9 @@ impl DataWithCachePolicy { // 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())?; + let tail = Self::serialize_policy(policy, &entry_id, self.data_len)?; file.write_all(&tail).map_err(ErrorKind::CacheWrite)?; - file.set_len((self.data.len() + entry_id.len() + tail.len()) as u64) + file.set_len((self.policy_start + tail.len()) as u64) .map_err(ErrorKind::CacheWrite)?; Ok(()) } @@ -1067,8 +1109,14 @@ impl DataWithCachePolicy { #[cfg(test)] mod tests { use anyhow::Result; + use rkyv::util::AlignedVec; + use zerocopy::IntoBytes; + use zerocopy::byteorder::little_endian::{U32, U64}; - use super::{CachePolicy, CachePolicyBuilder, DataWithCachePolicy, OwnedArchive}; + 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 { @@ -1082,13 +1130,64 @@ mod tests { Ok(CachePolicyBuilder::new(&request).build(&response)) } + #[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()), + ); + 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, + }; + 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]; + 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)?; @@ -1096,10 +1195,10 @@ mod tests { 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.data.as_slice(), payload); + assert_eq!(&grown.bytes[..grown.data_len], payload); assert_eq!(grown.entry_id, cached.entry_id); assert_eq!( - OwnedArchive::as_bytes(&grown.cache_policy), + &grown.bytes[grown.policy_start..], OwnedArchive::as_bytes(&large.to_archived()), ); @@ -1116,8 +1215,8 @@ mod tests { 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.data.len() + cached.entry_id.len(); + 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. @@ -1126,8 +1225,8 @@ mod tests { 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.data.as_slice(), b"payload"); - let policy = OwnedArchive::as_bytes(&read.cache_policy); + 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) @@ -1141,7 +1240,7 @@ mod tests { #[test] fn cache_trailer_corruption_is_rejected() -> Result<()> { let bytes = DataWithCachePolicy::serialize(&policy("x-small")?, b"payload")?; - // The checksum covers the entry ID, policy, and length; corrupt each byte in turn. + // 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; diff --git a/crates/uv-client/tests/it/cached_client.rs b/crates/uv-client/tests/it/cached_client.rs index 3cdd12eac8f..0d85ad42c97 100644 --- a/crates/uv-client/tests/it/cached_client.rs +++ b/crates/uv-client/tests/it/cached_client.rs @@ -68,7 +68,7 @@ async fn revalidation_updates_only_policy() -> Result<()> { let original = fs_err::read(&entry)?; let data_len = DataWithCachePolicy::from_reader(original.as_slice())? - .data + .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"); @@ -95,7 +95,10 @@ async fn revalidation_updates_only_policy() -> Result<()> { body ); let refreshed = fs_err::read(&entry)?; - assert_eq!(refreshed[..data_len + 16], original[..data_len + 16]); + 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); @@ -202,7 +205,7 @@ async fn overlapping_policy_updates_are_refetched() -> Result<()> { ); let large = fs_err::read(&entry)?; let data_len = DataWithCachePolicy::from_reader(large.as_slice())? - .data + .into_data() .len(); server.verify().await; server.reset().await; @@ -225,7 +228,7 @@ async fn overlapping_policy_updates_are_refetched() -> Result<()> { // 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 + 16; + let policy_start = data_len.next_multiple_of(16) + 16; writer.seek(SeekFrom::Start(policy_start as u64))?; writer.write_all(&large[policy_start..])?; @@ -359,10 +362,10 @@ async fn torn_policy_is_refetched() -> Result<()> { ); let original = fs_err::read(&entry)?; let data_len = DataWithCachePolicy::from_reader(original.as_slice())? - .data + .into_data() .len(); let mut torn = original.clone(); - torn[data_len + 16] ^= 1; + torn[data_len.next_multiple_of(16) + 16] ^= 1; fs_err::write(&entry, &torn)?; assert_eq!( cached_text(&client, &server, &entry, CacheControl::None).await?, diff --git a/crates/uv-distribution/src/distribution_database.rs b/crates/uv-distribution/src/distribution_database.rs index 02a00430fc4..fcff8826cde 100644 --- a/crates/uv-distribution/src/distribution_database.rs +++ b/crates/uv-distribution/src/distribution_database.rs @@ -1824,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 })) }