Skip to main content

uv_resolver/lock/
mod.rs

1use std::borrow::Cow;
2use std::collections::{BTreeMap, BTreeSet, VecDeque};
3use std::error::Error;
4use std::fmt::{Debug, Display, Formatter};
5use std::io;
6use std::path::{Path, PathBuf};
7use std::str::FromStr;
8use std::sync::{Arc, LazyLock};
9
10use itertools::Itertools;
11use jiff::Timestamp;
12use owo_colors::OwoColorize;
13use petgraph::graph::NodeIndex;
14use petgraph::visit::EdgeRef;
15use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet};
16use serde::Serializer;
17use toml_edit::{Array, ArrayOfTables, InlineTable, Item, Table, Value, value};
18use tracing::{debug, instrument, trace};
19use url::Url;
20
21use uv_cache_key::RepositoryUrl;
22use uv_configuration::{
23    BuildOptions, Constraints, DependencyGroupsWithDefaults, ExcludeDependency,
24    ExtrasSpecificationWithDefaults, InstallTarget, Override, PackageOverride,
25};
26use uv_distribution::{DistributionDatabase, FlatRequiresDist, RequiresDist};
27use uv_distribution_filename::{
28    BuildTag, DistExtension, ExtensionError, SourceDistExtension, WheelFilename,
29};
30use uv_distribution_types::{
31    BuiltDist, DependencyMetadata, DirectUrlBuiltDist, DirectUrlSourceDist, DirectorySourceDist,
32    Dist, FileLocation, GitDirectorySourceDist, GitPathBuiltDist, GitPathSourceDist, Identifier,
33    IndexLocations, IndexMetadata, IndexUrl, Name, PYPI_URL, PathBuiltDist, PathSourceDist,
34    RegistryBuiltDist, RegistryBuiltWheel, RegistrySourceDist, RemoteSource, Requirement,
35    RequirementSource, RequiresPython, ResolvedDist, SimplifiedMarkerTree, StaticMetadata,
36    ToUrlError, UrlString,
37};
38use uv_fs::{
39    PortablePath, PortablePathBuf, Simplified, normalize_path, relative_to, try_relative_to_if,
40};
41use uv_git::{RepositoryReference, ResolvedRepositoryReference};
42use uv_git_types::{GitLfs, GitOid, GitReference, GitUrl, GitUrlParseError};
43use uv_normalize::{ExtraName, GroupName, PackageName};
44use uv_pep440::Version;
45use uv_pep508::{
46    MarkerEnvironment, MarkerTree, Scheme, VerbatimUrl, VerbatimUrlError, split_scheme,
47};
48use uv_platform_tags::{
49    AbiTag, IncompatibleTag, LanguageTag, PlatformTag, TagCompatibility, TagPriority, Tags,
50};
51use uv_pypi_types::{
52    ConflictKind, Conflicts, HashAlgorithm, HashDigest, HashDigests, Hashes, ParsedArchiveUrl,
53    ParsedGitDirectoryUrl, ParsedGitPathUrl, PyProjectToml,
54};
55use uv_redacted::{DisplaySafeUrl, DisplaySafeUrlError};
56use uv_small_str::SmallString;
57use uv_types::{BuildContext, HashStrategy};
58use uv_workspace::{Editability, WorkspaceMember};
59
60use crate::fork_strategy::ForkStrategy;
61pub(crate) use crate::lock::export::PylockTomlPackage;
62pub use crate::lock::export::RequirementsTxtExport;
63pub use crate::lock::export::{
64    Metadata, PylockToml, PylockTomlError, PylockTomlErrorKind, cyclonedx_json,
65};
66pub use crate::lock::installable::Installable;
67pub use crate::lock::map::PackageMap;
68pub use crate::lock::tree::TreeDisplay;
69use crate::resolution::{AnnotatedDist, ResolutionGraphNode};
70use crate::universal_marker::{ConflictMarker, UniversalMarker};
71use crate::{
72    ExcludeNewer, ExcludeNewerOverride, ExcludeNewerPackage, ExcludeNewerSpan, ExcludeNewerValue,
73    InMemoryIndex, MetadataResponse, PrereleaseMode, ResolutionMode, ResolverOutput,
74};
75
76pub(crate) mod export;
77mod installable;
78mod map;
79mod tree;
80
81/// The current version of the lockfile format.
82pub const VERSION: u32 = 1;
83
84/// The current revision of the lockfile format.
85const REVISION: u32 = 3;
86
87static LINUX_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
88    let pep508 = MarkerTree::from_str("os_name == 'posix' and sys_platform == 'linux'").unwrap();
89    UniversalMarker::new(pep508, ConflictMarker::TRUE)
90});
91static WINDOWS_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
92    let pep508 = MarkerTree::from_str("os_name == 'nt' and sys_platform == 'win32'").unwrap();
93    UniversalMarker::new(pep508, ConflictMarker::TRUE)
94});
95static MAC_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
96    let pep508 = MarkerTree::from_str("os_name == 'posix' and sys_platform == 'darwin'").unwrap();
97    UniversalMarker::new(pep508, ConflictMarker::TRUE)
98});
99static ANDROID_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
100    let pep508 = MarkerTree::from_str("sys_platform == 'android'").unwrap();
101    UniversalMarker::new(pep508, ConflictMarker::TRUE)
102});
103static ARM_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
104    let pep508 =
105        MarkerTree::from_str("platform_machine == 'aarch64' or platform_machine == 'arm64' or platform_machine == 'ARM64'")
106            .unwrap();
107    UniversalMarker::new(pep508, ConflictMarker::TRUE)
108});
109static X86_64_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
110    let pep508 =
111        MarkerTree::from_str("platform_machine == 'x86_64' or platform_machine == 'amd64' or platform_machine == 'AMD64'")
112            .unwrap();
113    UniversalMarker::new(pep508, ConflictMarker::TRUE)
114});
115static X86_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
116    let pep508 = MarkerTree::from_str(
117        "platform_machine == 'i686' or platform_machine == 'i386' or platform_machine == 'win32' or platform_machine == 'x86'",
118    )
119    .unwrap();
120    UniversalMarker::new(pep508, ConflictMarker::TRUE)
121});
122static PPC64LE_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
123    let pep508 = MarkerTree::from_str("platform_machine == 'ppc64le'").unwrap();
124    UniversalMarker::new(pep508, ConflictMarker::TRUE)
125});
126static PPC64_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
127    let pep508 = MarkerTree::from_str("platform_machine == 'ppc64'").unwrap();
128    UniversalMarker::new(pep508, ConflictMarker::TRUE)
129});
130static S390X_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
131    let pep508 = MarkerTree::from_str("platform_machine == 's390x'").unwrap();
132    UniversalMarker::new(pep508, ConflictMarker::TRUE)
133});
134static RISCV64_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
135    let pep508 = MarkerTree::from_str("platform_machine == 'riscv64'").unwrap();
136    UniversalMarker::new(pep508, ConflictMarker::TRUE)
137});
138static LOONGARCH64_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
139    let pep508 = MarkerTree::from_str("platform_machine == 'loongarch64'").unwrap();
140    UniversalMarker::new(pep508, ConflictMarker::TRUE)
141});
142static ARMV7L_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
143    let pep508 =
144        MarkerTree::from_str("platform_machine == 'armv7l' or platform_machine == 'armv8l'")
145            .unwrap();
146    UniversalMarker::new(pep508, ConflictMarker::TRUE)
147});
148static ARMV6L_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
149    let pep508 = MarkerTree::from_str("platform_machine == 'armv6l'").unwrap();
150    UniversalMarker::new(pep508, ConflictMarker::TRUE)
151});
152static LINUX_ARM_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
153    let mut marker = *LINUX_MARKERS;
154    marker.and(*ARM_MARKERS);
155    marker
156});
157static LINUX_X86_64_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
158    let mut marker = *LINUX_MARKERS;
159    marker.and(*X86_64_MARKERS);
160    marker
161});
162static LINUX_X86_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
163    let mut marker = *LINUX_MARKERS;
164    marker.and(*X86_MARKERS);
165    marker
166});
167static LINUX_PPC64LE_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
168    let mut marker = *LINUX_MARKERS;
169    marker.and(*PPC64LE_MARKERS);
170    marker
171});
172static LINUX_PPC64_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
173    let mut marker = *LINUX_MARKERS;
174    marker.and(*PPC64_MARKERS);
175    marker
176});
177static LINUX_S390X_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
178    let mut marker = *LINUX_MARKERS;
179    marker.and(*S390X_MARKERS);
180    marker
181});
182static LINUX_RISCV64_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
183    let mut marker = *LINUX_MARKERS;
184    marker.and(*RISCV64_MARKERS);
185    marker
186});
187static LINUX_LOONGARCH64_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
188    let mut marker = *LINUX_MARKERS;
189    marker.and(*LOONGARCH64_MARKERS);
190    marker
191});
192static LINUX_ARMV7L_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
193    let mut marker = *LINUX_MARKERS;
194    marker.and(*ARMV7L_MARKERS);
195    marker
196});
197static LINUX_ARMV6L_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
198    let mut marker = *LINUX_MARKERS;
199    marker.and(*ARMV6L_MARKERS);
200    marker
201});
202static WINDOWS_ARM_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
203    let mut marker = *WINDOWS_MARKERS;
204    marker.and(*ARM_MARKERS);
205    marker
206});
207static WINDOWS_X86_64_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
208    let mut marker = *WINDOWS_MARKERS;
209    marker.and(*X86_64_MARKERS);
210    marker
211});
212static WINDOWS_X86_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
213    let mut marker = *WINDOWS_MARKERS;
214    marker.and(*X86_MARKERS);
215    marker
216});
217static MAC_ARM_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
218    let mut marker = *MAC_MARKERS;
219    marker.and(*ARM_MARKERS);
220    marker
221});
222static MAC_X86_64_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
223    let mut marker = *MAC_MARKERS;
224    marker.and(*X86_64_MARKERS);
225    marker
226});
227static MAC_X86_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
228    let mut marker = *MAC_MARKERS;
229    marker.and(*X86_MARKERS);
230    marker
231});
232static ANDROID_ARM_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
233    let mut marker = *ANDROID_MARKERS;
234    marker.and(*ARM_MARKERS);
235    marker
236});
237static ANDROID_X86_64_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
238    let mut marker = *ANDROID_MARKERS;
239    marker.and(*X86_64_MARKERS);
240    marker
241});
242static ANDROID_X86_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
243    let mut marker = *ANDROID_MARKERS;
244    marker.and(*X86_MARKERS);
245    marker
246});
247
248/// A distribution with its associated hash.
249///
250/// This pairs a [`Dist`] with the [`HashDigests`] for the specific wheel or
251/// sdist that would be installed.
252pub(crate) struct HashedDist {
253    dist: Dist,
254    hashes: HashDigests,
255}
256
257#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
258#[serde(try_from = "LockWire")]
259pub struct Lock {
260    /// The (major) version of the lockfile format.
261    ///
262    /// Changes to the major version indicate backwards- and forwards-incompatible changes to the
263    /// lockfile format. A given uv version only supports a single major version of the lockfile
264    /// format.
265    ///
266    /// In other words, a version of uv that supports version 2 of the lockfile format will not be
267    /// able to read lockfiles generated under version 1 or 3.
268    version: u32,
269    /// The revision of the lockfile format.
270    ///
271    /// Changes to the revision indicate backwards-compatible changes to the lockfile format.
272    /// In other words, versions of uv that only support revision 1 _will_ be able to read lockfiles
273    /// with a revision greater than 1 (though they may ignore newer fields).
274    revision: u32,
275    /// If this lockfile was built from a forking resolution with non-identical forks, store the
276    /// forks in the lockfile so we can recreate them in subsequent resolutions.
277    fork_markers: Vec<UniversalMarker>,
278    /// The conflicting groups/extras specified by the user.
279    conflicts: Conflicts,
280    /// The list of supported environments specified by the user.
281    supported_environments: Vec<MarkerTree>,
282    /// The list of required platforms specified by the user.
283    required_environments: Vec<MarkerTree>,
284    /// The range of supported Python versions.
285    requires_python: RequiresPython,
286    /// We discard the lockfile if these options don't match.
287    options: ResolverOptions,
288    /// The actual locked version and their metadata.
289    packages: Vec<Package>,
290    /// A map from package ID to index in `packages`.
291    ///
292    /// This can be used to quickly lookup the full package for any ID
293    /// in this lock. For example, the dependencies for each package are
294    /// listed as package IDs. This map can be used to find the full
295    /// package for each such dependency.
296    ///
297    /// It is guaranteed that every package in this lock has an entry in
298    /// this map, and that every dependency for every package has an ID
299    /// that exists in this map. That is, there are no dependencies that don't
300    /// have a corresponding locked package entry in the same lockfile.
301    by_id: FxHashMap<PackageId, usize>,
302    /// The input requirements to the resolution.
303    manifest: ResolverManifest,
304}
305
306/// Package selections from a [`Lock`] for a named direct dependency.
307///
308/// The dependency can come from the lock manifest, a dependency group, the production packages,
309/// or a combination thereof.
310#[derive(Debug)]
311pub struct DependencySelection<'lock> {
312    root: Option<&'lock Package>,
313    production: Option<&'lock Package>,
314    groups: BTreeMap<&'lock GroupName, &'lock Package>,
315}
316
317impl<'lock> DependencySelection<'lock> {
318    /// Returns the package selected by a direct requirement on the lock manifest.
319    pub fn root(&self) -> Option<&'lock Package> {
320        self.root
321    }
322
323    /// Returns the package selected by the production dependency.
324    pub fn production(&self) -> Option<&'lock Package> {
325        self.production
326    }
327
328    /// Returns the package selected by the given dependency group.
329    pub fn group(&self, group: &GroupName) -> Option<&'lock Package> {
330        self.groups.get(group).copied()
331    }
332}
333
334impl Lock {
335    /// Initialize a [`Lock`] from a [`ResolverOutput`].
336    pub fn from_resolution(
337        resolution: &ResolverOutput,
338        root: &Path,
339        supported_environments: Vec<MarkerTree>,
340    ) -> Result<Self, LockError> {
341        let mut packages = BTreeMap::new();
342        let requires_python = resolution.requires_python.clone();
343        let supported_environments = supported_environments
344            .into_iter()
345            .map(|marker| requires_python.complexify_markers(marker))
346            .collect::<Vec<_>>();
347        let supported_environments_marker = if supported_environments.is_empty() {
348            None
349        } else {
350            let mut combined = MarkerTree::FALSE;
351            for marker in &supported_environments {
352                combined.or(*marker);
353            }
354            Some(UniversalMarker::new(combined, ConflictMarker::TRUE))
355        };
356        let environment = SimplifiedMarkerTree::new(
357            &requires_python,
358            fork_markers_union(&resolution.fork_markers, &requires_python),
359        );
360
361        // Determine the set of packages included at multiple versions.
362        let mut seen = FxHashSet::default();
363        let mut duplicates = FxHashSet::default();
364        for (_, dist) in resolution.base_dists() {
365            if !seen.insert(dist.name()) {
366                duplicates.insert(dist.name());
367            }
368        }
369
370        // Lock all base packages.
371        for (node_index, dist) in resolution.base_dists() {
372            // If there are multiple distributions for the same package, include the markers of all
373            // forks that included the current distribution.
374            //
375            // Canonicalize the subset of fork markers that selected this distribution to
376            // match the form persisted in `uv.lock`.
377            let fork_markers = if duplicates.contains(dist.name()) {
378                let fork_markers = resolution
379                    .fork_markers
380                    .iter()
381                    .filter(|fork_markers| !fork_markers.is_disjoint(dist.marker))
382                    .copied()
383                    .collect::<Vec<_>>();
384                canonicalize_universal_markers(&fork_markers, &requires_python)
385            } else {
386                vec![]
387            };
388
389            let mut package = Package::from_annotated_dist(dist, fork_markers, root)?;
390            let mut wheel_marker = dist.marker;
391            if let Some(supported_environments_marker) = supported_environments_marker {
392                wheel_marker.and(supported_environments_marker);
393            }
394            let wheels = &mut package.wheels;
395            wheels.retain(|wheel| {
396                !is_wheel_unreachable_for_marker(
397                    &wheel.filename,
398                    &requires_python,
399                    &wheel_marker,
400                    None,
401                )
402            });
403
404            // Add all dependencies
405            for edge in resolution.graph.edges(node_index) {
406                let ResolutionGraphNode::Dist(dependency_dist) = &resolution.graph[edge.target()]
407                else {
408                    continue;
409                };
410                let marker = simplify_dependency_marker(
411                    &requires_python,
412                    environment,
413                    dist.marker,
414                    *edge.weight(),
415                );
416                package.add_dependency(&requires_python, dependency_dist, marker, root)?;
417            }
418
419            let id = package.id.clone();
420            if let Some(locked_dist) = packages.insert(id, package) {
421                return Err(LockErrorKind::DuplicatePackage {
422                    id: locked_dist.id.clone(),
423                }
424                .into());
425            }
426        }
427
428        // Lock all extras and development dependencies.
429        for node_index in resolution.graph.node_indices() {
430            let ResolutionGraphNode::Dist(dist) = &resolution.graph[node_index] else {
431                continue;
432            };
433            if let Some(extra) = dist.extra.as_ref() {
434                let id = PackageId::from_annotated_dist(dist, root)?;
435                let Some(package) = packages.get_mut(&id) else {
436                    return Err(LockErrorKind::MissingExtraBase {
437                        id,
438                        extra: extra.clone(),
439                    }
440                    .into());
441                };
442                for edge in resolution.graph.edges(node_index) {
443                    let ResolutionGraphNode::Dist(dependency_dist) =
444                        &resolution.graph[edge.target()]
445                    else {
446                        continue;
447                    };
448                    let marker = simplify_dependency_marker(
449                        &requires_python,
450                        environment,
451                        dist.marker,
452                        *edge.weight(),
453                    );
454                    package.add_optional_dependency(
455                        &requires_python,
456                        extra.clone(),
457                        dependency_dist,
458                        marker,
459                        root,
460                    )?;
461                }
462            }
463            if let Some(group) = dist.group.as_ref() {
464                let id = PackageId::from_annotated_dist(dist, root)?;
465                let Some(package) = packages.get_mut(&id) else {
466                    return Err(LockErrorKind::MissingDevBase {
467                        id,
468                        group: group.clone(),
469                    }
470                    .into());
471                };
472                for edge in resolution.graph.edges(node_index) {
473                    let ResolutionGraphNode::Dist(dependency_dist) =
474                        &resolution.graph[edge.target()]
475                    else {
476                        continue;
477                    };
478                    let marker = simplify_dependency_marker(
479                        &requires_python,
480                        environment,
481                        dist.marker,
482                        *edge.weight(),
483                    );
484                    package.add_group_dependency(
485                        &requires_python,
486                        group.clone(),
487                        dependency_dist,
488                        marker,
489                        root,
490                    )?;
491                }
492            }
493        }
494
495        let packages = packages.into_values().collect();
496
497        let options = ResolverOptions {
498            resolution_mode: resolution.options.resolution_mode,
499            prerelease_mode: resolution.options.prerelease_mode,
500            fork_strategy: resolution.options.fork_strategy,
501            exclude_newer: resolution.options.exclude_newer.clone().into(),
502        };
503        // Canonicalize the top-level fork markers to match what is persisted in
504        // `uv.lock`. In particular, conflict-only fork markers can serialize to
505        // nothing at the top level, and `uv lock --check` should compare against
506        // that canonical form rather than the raw resolver output.
507        let fork_markers =
508            canonicalize_universal_markers(&resolution.fork_markers, &requires_python);
509        let lock = Self::new(
510            VERSION,
511            REVISION,
512            packages,
513            requires_python,
514            options,
515            ResolverManifest::default(),
516            Conflicts::empty(),
517            supported_environments,
518            vec![],
519            fork_markers,
520        )?;
521        Ok(lock)
522    }
523
524    /// Initialize a [`Lock`] from a list of [`Package`] entries.
525    fn new(
526        version: u32,
527        revision: u32,
528        mut packages: Vec<Package>,
529        requires_python: RequiresPython,
530        options: ResolverOptions,
531        manifest: ResolverManifest,
532        conflicts: Conflicts,
533        supported_environments: Vec<MarkerTree>,
534        required_environments: Vec<MarkerTree>,
535        fork_markers: Vec<UniversalMarker>,
536    ) -> Result<Self, LockError> {
537        // Put all dependencies for each package in a canonical order and
538        // check for duplicates.
539        for package in &mut packages {
540            package.dependencies.sort();
541            for [dep1, dep2] in package.dependencies.array_windows() {
542                if dep1 == dep2 {
543                    return Err(LockErrorKind::DuplicateDependency {
544                        id: package.id.clone(),
545                        dependency: dep1.clone(),
546                    }
547                    .into());
548                }
549            }
550
551            // Perform the same validation for optional dependencies.
552            for (extra, dependencies) in &mut package.optional_dependencies {
553                dependencies.sort();
554                for [dep1, dep2] in dependencies.array_windows() {
555                    if dep1 == dep2 {
556                        return Err(LockErrorKind::DuplicateOptionalDependency {
557                            id: package.id.clone(),
558                            extra: extra.clone(),
559                            dependency: dep1.clone(),
560                        }
561                        .into());
562                    }
563                }
564            }
565
566            // Perform the same validation for dev dependencies.
567            for (group, dependencies) in &mut package.dependency_groups {
568                dependencies.sort();
569                for [dep1, dep2] in dependencies.array_windows() {
570                    if dep1 == dep2 {
571                        return Err(LockErrorKind::DuplicateDevDependency {
572                            id: package.id.clone(),
573                            group: group.clone(),
574                            dependency: dep1.clone(),
575                        }
576                        .into());
577                    }
578                }
579            }
580        }
581        packages.sort_by(|dist1, dist2| dist1.id.cmp(&dist2.id));
582
583        // Check for duplicate package IDs and also build up the map for
584        // packages keyed by their ID.
585        let mut by_id = FxHashMap::default();
586        for (i, dist) in packages.iter().enumerate() {
587            if by_id.insert(dist.id.clone(), i).is_some() {
588                return Err(LockErrorKind::DuplicatePackage {
589                    id: dist.id.clone(),
590                }
591                .into());
592            }
593        }
594
595        // Build up a map from ID to extras.
596        let mut extras_by_id = FxHashMap::default();
597        for dist in &packages {
598            for extra in dist.optional_dependencies.keys() {
599                extras_by_id
600                    .entry(dist.id.clone())
601                    .or_insert_with(FxHashSet::default)
602                    .insert(extra.clone());
603            }
604        }
605
606        // Remove any non-existent extras (e.g., extras that were requested but don't exist).
607        for dist in &mut packages {
608            for dep in dist
609                .dependencies
610                .iter_mut()
611                .chain(dist.optional_dependencies.values_mut().flatten())
612                .chain(dist.dependency_groups.values_mut().flatten())
613            {
614                dep.extra.retain(|extra| {
615                    extras_by_id
616                        .get(&dep.package_id)
617                        .is_some_and(|extras| extras.contains(extra))
618                });
619            }
620        }
621
622        // Check that every dependency has an entry in `by_id`. If any don't,
623        // it implies we somehow have a dependency with no corresponding locked
624        // package.
625        for dist in &packages {
626            for dep in &dist.dependencies {
627                if !by_id.contains_key(&dep.package_id) {
628                    return Err(LockErrorKind::UnrecognizedDependency {
629                        id: dist.id.clone(),
630                        dependency: dep.clone(),
631                    }
632                    .into());
633                }
634            }
635
636            // Perform the same validation for optional dependencies.
637            for dependencies in dist.optional_dependencies.values() {
638                for dep in dependencies {
639                    if !by_id.contains_key(&dep.package_id) {
640                        return Err(LockErrorKind::UnrecognizedDependency {
641                            id: dist.id.clone(),
642                            dependency: dep.clone(),
643                        }
644                        .into());
645                    }
646                }
647            }
648
649            // Perform the same validation for dev dependencies.
650            for dependencies in dist.dependency_groups.values() {
651                for dep in dependencies {
652                    if !by_id.contains_key(&dep.package_id) {
653                        return Err(LockErrorKind::UnrecognizedDependency {
654                            id: dist.id.clone(),
655                            dependency: dep.clone(),
656                        }
657                        .into());
658                    }
659                }
660            }
661
662            // Also check that our sources are consistent with whether we have
663            // hashes or not.
664            if let Some(requires_hash) = dist.id.source.requires_hash() {
665                for wheel in &dist.wheels {
666                    if requires_hash != wheel.hash.is_some() {
667                        return Err(LockErrorKind::Hash {
668                            id: dist.id.clone(),
669                            artifact_type: "wheel",
670                            expected: requires_hash,
671                        }
672                        .into());
673                    }
674                }
675            }
676        }
677        let lock = Self {
678            version,
679            revision,
680            fork_markers,
681            conflicts,
682            supported_environments,
683            required_environments,
684            requires_python,
685            options,
686            packages,
687            by_id,
688            manifest,
689        };
690        Ok(lock)
691    }
692
693    /// Record the requirements that were used to generate this lock.
694    #[must_use]
695    pub fn with_manifest(mut self, manifest: ResolverManifest) -> Self {
696        self.manifest = manifest;
697        self
698    }
699
700    /// Record the conflicting groups that were used to generate this lock.
701    #[must_use]
702    pub fn with_conflicts(mut self, conflicts: Conflicts) -> Self {
703        self.conflicts = conflicts;
704        self
705    }
706
707    /// Record the required platforms that were used to generate this lock.
708    #[must_use]
709    pub fn with_required_environments(mut self, required_environments: Vec<MarkerTree>) -> Self {
710        self.required_environments = required_environments
711            .into_iter()
712            .map(|marker| self.requires_python.complexify_markers(marker))
713            .collect();
714        self
715    }
716
717    /// Returns `true` if this [`Lock`] includes `provides-extra` metadata.
718    pub fn supports_provides_extra(&self) -> bool {
719        // `provides-extra` was added in Version 1 Revision 1.
720        (self.version(), self.revision()) >= (1, 1)
721    }
722
723    /// Returns `true` if this [`Lock`] includes entries for empty `dependency-group` metadata.
724    fn includes_empty_groups(&self) -> bool {
725        // Empty dependency groups are included as of https://github.com/astral-sh/uv/pull/8598,
726        // but Version 1 Revision 1 is the first revision published after that change.
727        (self.version(), self.revision()) >= (1, 1)
728    }
729
730    /// Returns the lockfile version.
731    pub fn version(&self) -> u32 {
732        self.version
733    }
734
735    /// Returns the lockfile revision.
736    fn revision(&self) -> u32 {
737        self.revision
738    }
739
740    /// Returns the number of packages in the lockfile.
741    pub fn len(&self) -> usize {
742        self.packages.len()
743    }
744
745    /// Returns `true` if the lockfile contains no packages.
746    pub fn is_empty(&self) -> bool {
747        self.packages.is_empty()
748    }
749
750    /// Returns the [`Package`] entries in this lock.
751    pub fn packages(&self) -> &[Package] {
752        &self.packages
753    }
754
755    /// Returns the supported Python version range for the lockfile, if present.
756    pub fn requires_python(&self) -> &RequiresPython {
757        &self.requires_python
758    }
759
760    /// Returns the resolution mode used to generate this lock.
761    pub fn resolution_mode(&self) -> ResolutionMode {
762        self.options.resolution_mode
763    }
764
765    /// Returns the pre-release mode used to generate this lock.
766    pub fn prerelease_mode(&self) -> PrereleaseMode {
767        self.options.prerelease_mode
768    }
769
770    /// Returns the multi-version mode used to generate this lock.
771    pub fn fork_strategy(&self) -> ForkStrategy {
772        self.options.fork_strategy
773    }
774
775    /// Returns the exclude newer setting used to generate this lock.
776    pub fn exclude_newer(&self) -> ExcludeNewer {
777        // TODO(zanieb): It'd be nice not to hide this clone here, but I am hesitant to introduce
778        // a whole new `ExcludeNewerRef` type just for this
779        self.options.exclude_newer.clone().into()
780    }
781
782    /// Returns the conflicting groups that were used to generate this lock.
783    pub fn conflicts(&self) -> &Conflicts {
784        &self.conflicts
785    }
786
787    /// Returns the supported environments that were used to generate this lock.
788    pub fn supported_environments(&self) -> &[MarkerTree] {
789        &self.supported_environments
790    }
791
792    /// Returns the required platforms that were used to generate this lock.
793    fn required_environments(&self) -> &[MarkerTree] {
794        &self.required_environments
795    }
796
797    /// Returns the workspace members that were used to generate this lock.
798    pub fn members(&self) -> &BTreeSet<PackageName> {
799        &self.manifest.members
800    }
801
802    /// Returns the root requirements that were used to generate this lock.
803    fn requirements(&self) -> &BTreeSet<Requirement> {
804        &self.manifest.requirements
805    }
806
807    /// Intersect a requirement marker with the forks that contain a package, then simplify it
808    /// under the lockfile's Python requirement.
809    pub(crate) fn root_requirement_marker(
810        &self,
811        requirement: &Requirement,
812        package: &Package,
813    ) -> Option<MarkerTree> {
814        let marker = if package.fork_markers.is_empty() {
815            requirement.marker
816        } else {
817            let mut combined = MarkerTree::FALSE;
818            for fork_marker in &package.fork_markers {
819                combined.or(fork_marker.pep508());
820            }
821            combined.and(requirement.marker);
822            combined
823        };
824
825        (!marker.is_false()).then(|| self.simplify_environment(marker))
826    }
827
828    /// Returns the dependency groups that were used to generate this lock.
829    pub(crate) fn dependency_groups(&self) -> &BTreeMap<GroupName, BTreeSet<Requirement>> {
830        &self.manifest.dependency_groups
831    }
832
833    /// Returns the environment-specific direct dependency selections for a lock target.
834    ///
835    /// If `project_name` is provided, dependencies attached to that package are used. Otherwise,
836    /// requirements and dependency groups attached directly to the lock manifest are used.
837    pub fn dependency_selection<'lock>(
838        &'lock self,
839        project_name: Option<&PackageName>,
840        dependency_name: &PackageName,
841        marker_environment: &MarkerEnvironment,
842    ) -> Result<DependencySelection<'lock>, String> {
843        let (root, production, groups) = if let Some(project_name) = project_name {
844            let Some(project) = self.find_by_name(project_name)? else {
845                return Ok(DependencySelection {
846                    root: None,
847                    production: None,
848                    groups: BTreeMap::new(),
849                });
850            };
851            let production =
852                self.find_project_dependency_package(project, dependency_name, marker_environment)?;
853            let mut groups = BTreeMap::new();
854            for group in project.resolved_dependency_groups().keys() {
855                if let Some(package) = self.find_project_dependency_group_package(
856                    project,
857                    group,
858                    dependency_name,
859                    marker_environment,
860                )? {
861                    groups.insert(group, package);
862                }
863            }
864            (None, production, groups)
865        } else {
866            let root_applies = self.manifest.requirements.iter().any(|requirement| {
867                &requirement.name == dependency_name
868                    && requirement.marker.evaluate(marker_environment, &[])
869            });
870
871            // Lock-manifest requirements and dependency groups only record requirements, not
872            // resolved package IDs. Select the environment-specific package once, then associate
873            // it with every applicable direct requirement.
874            let mut applicable_groups = self
875                .manifest
876                .dependency_groups
877                .iter()
878                .filter_map(|(group, requirements)| {
879                    requirements
880                        .iter()
881                        .any(|requirement| {
882                            &requirement.name == dependency_name
883                                && requirement.marker.evaluate(marker_environment, &[])
884                        })
885                        .then_some(group)
886                })
887                .peekable();
888            let package = if root_applies || applicable_groups.peek().is_some() {
889                self.find_by_markers(dependency_name, marker_environment)?
890            } else {
891                None
892            };
893            let root = root_applies.then_some(package).flatten();
894            let groups = package.map_or_else(BTreeMap::new, |package| {
895                applicable_groups.map(|group| (group, package)).collect()
896            });
897            (root, None, groups)
898        };
899        Ok(DependencySelection {
900            root,
901            production,
902            groups,
903        })
904    }
905
906    /// Returns the package selected by a dependency group on a non-virtual project.
907    fn find_project_dependency_group_package(
908        &self,
909        project: &Package,
910        group: &GroupName,
911        dependency_name: &PackageName,
912        marker_environment: &MarkerEnvironment,
913    ) -> Result<Option<&Package>, String> {
914        let Some(dependencies) = project.resolved_dependency_groups().get(group) else {
915            return Ok(None);
916        };
917        let project_name = project.name();
918
919        let mut selected = None;
920        for dependency in dependencies
921            .iter()
922            .filter(|dependency| &dependency.package_id.name == dependency_name)
923        {
924            // The complex marker combines the dependency's PEP 508 marker with uv's conflict
925            // markers. Evaluate it with this dependency's extras and the selected group active.
926            // For example, if this group declares `foo; sys_platform == 'linux'`, another
927            // dependency can still keep `foo` in the universal lock on macOS; this group's edge
928            // must not match there.
929            if !dependency.complexified_marker.evaluate(
930                marker_environment,
931                std::iter::empty::<&PackageName>(),
932                dependency
933                    .extra
934                    .iter()
935                    .map(|extra| (&dependency.package_id.name, extra)),
936                std::iter::once((project_name, group)),
937            ) {
938                continue;
939            }
940
941            let package = self.find_by_id(&dependency.package_id);
942            if selected.is_some_and(|selected: &Package| selected.id != package.id) {
943                return Err(format!(
944                    "found multiple packages matching `{dependency_name}` in dependency group `{group}` for `{project_name}`"
945                ));
946            }
947            selected = Some(package);
948        }
949        Ok(selected)
950    }
951
952    /// Returns the package selected by a production dependency on a non-virtual project.
953    fn find_project_dependency_package(
954        &self,
955        project: &Package,
956        dependency_name: &PackageName,
957        marker_environment: &MarkerEnvironment,
958    ) -> Result<Option<&Package>, String> {
959        let project_name = project.name();
960
961        let mut selected = None;
962        for dependency in project
963            .dependencies()
964            .iter()
965            .filter(|dependency| &dependency.package_id.name == dependency_name)
966        {
967            if !dependency.complexified_marker.evaluate(
968                marker_environment,
969                std::iter::once(project_name),
970                dependency
971                    .extra
972                    .iter()
973                    .map(|extra| (&dependency.package_id.name, extra)),
974                std::iter::empty::<(&PackageName, &GroupName)>(),
975            ) {
976                continue;
977            }
978
979            let package = self.find_by_id(&dependency.package_id);
980            if selected.is_some_and(|selected: &Package| selected.id != package.id) {
981                return Err(format!(
982                    "found multiple packages matching production dependency `{dependency_name}` for `{project_name}`"
983                ));
984            }
985            selected = Some(package);
986        }
987        Ok(selected)
988    }
989
990    /// Returns the build constraints that were used to generate this lock.
991    pub fn build_constraints(&self, root: &Path) -> Constraints {
992        Constraints::from_requirements(
993            self.manifest
994                .build_constraints
995                .iter()
996                .cloned()
997                .map(|requirement| requirement.to_absolute(root)),
998        )
999    }
1000
1001    /// Return the set of packages that should be audited, respecting the
1002    /// given extras and dependency group filters.
1003    ///
1004    /// Workspace members and packages without version information are
1005    /// excluded unconditionally, since neither can be meaningfully looked up
1006    /// in an external audit source.
1007    pub fn auditable<'lock>(
1008        &'lock self,
1009        extras: &'lock ExtrasSpecificationWithDefaults,
1010        groups: &'lock DependencyGroupsWithDefaults,
1011        collect_filter: impl Fn(&Package) -> bool,
1012    ) -> Auditable<'lock> {
1013        // Dedupe and sort by `(name, version)` during the walk itself. Keep
1014        // the first `Package` reference we see for each key so that
1015        // downstream views (e.g. index lookup) have access to the lockfile
1016        // package.
1017        let mut by_name_version: BTreeMap<(&PackageName, &Version), &Package> = BTreeMap::default();
1018        self.walk_auditable(extras, groups, collect_filter, |package, version| {
1019            by_name_version
1020                .entry((package.name(), version))
1021                .or_insert(package);
1022        });
1023        let packages = by_name_version
1024            .into_iter()
1025            .map(|((_, version), package)| (package, version))
1026            .collect();
1027        Auditable { packages }
1028    }
1029
1030    /// Walk the auditable dependency graph, invoking `visit` once per
1031    /// non-workspace package with version information.
1032    ///
1033    /// The traversal is seeded from workspace members, lock-level requirements
1034    /// (e.g. PEP 723 scripts), and lock-level dependency groups, then follows
1035    /// each reachable dependency exactly once per `(package, extra)` pair,
1036    /// respecting the provided extras and dependency-group filters. The same
1037    /// package may be visited more than once if it is reached through multiple
1038    /// extras — callers should deduplicate as appropriate.
1039    fn walk_auditable<'lock, F>(
1040        &'lock self,
1041        extras: &'lock ExtrasSpecificationWithDefaults,
1042        groups: &'lock DependencyGroupsWithDefaults,
1043        collect_filter: impl Fn(&Package) -> bool,
1044        mut visit: F,
1045    ) where
1046        F: FnMut(&'lock Package, &'lock Version),
1047    {
1048        // Enqueue a dependency for auditability checks: base package (no extra) first, then each activated extra.
1049        fn enqueue_dep<'lock>(
1050            lock: &'lock Lock,
1051            seen: &mut FxHashSet<(&'lock PackageId, Option<&'lock ExtraName>)>,
1052            queue: &mut VecDeque<(&'lock Package, Option<&'lock ExtraName>)>,
1053            dep: &'lock Dependency,
1054        ) {
1055            let dep_pkg = lock.find_by_id(&dep.package_id);
1056            for maybe_extra in std::iter::once(None).chain(dep.extra.iter().map(Some)) {
1057                if seen.insert((&dep.package_id, maybe_extra)) {
1058                    queue.push_back((dep_pkg, maybe_extra));
1059                }
1060            }
1061        }
1062
1063        // Identify workspace members (the implicit root counts for single-member workspaces).
1064        let workspace_member_ids: FxHashSet<&PackageId> = if self.members().is_empty() {
1065            self.root().into_iter().map(|package| &package.id).collect()
1066        } else {
1067            self.packages
1068                .iter()
1069                .filter(|package| self.members().contains(&package.id.name))
1070                .map(|package| &package.id)
1071                .collect()
1072        };
1073
1074        // Lockfile traversal state: (package, optional extra to activate on that package).
1075        let mut queue: VecDeque<(&Package, Option<&ExtraName>)> = VecDeque::new();
1076        let mut seen: FxHashSet<(&PackageId, Option<&ExtraName>)> = FxHashSet::default();
1077
1078        // Seed from workspace members. Always queue with `None` so that we can traverse
1079        // their dependency groups; only queue extras when prod mode is active.
1080        for package in self
1081            .packages
1082            .iter()
1083            .filter(|p| workspace_member_ids.contains(&p.id))
1084        {
1085            if seen.insert((&package.id, None)) {
1086                queue.push_back((package, None));
1087            }
1088            if groups.prod() {
1089                for extra in extras.extra_names(package.optional_dependencies.keys()) {
1090                    if seen.insert((&package.id, Some(extra))) {
1091                        queue.push_back((package, Some(extra)));
1092                    }
1093                }
1094            }
1095        }
1096
1097        // Seed from requirements attached directly to the lock (e.g., PEP 723 scripts).
1098        for requirement in self.requirements() {
1099            for package in self
1100                .packages
1101                .iter()
1102                .filter(|p| p.id.name == requirement.name)
1103            {
1104                if seen.insert((&package.id, None)) {
1105                    queue.push_back((package, None));
1106                }
1107                for extra in &*requirement.extras {
1108                    if seen.insert((&package.id, Some(extra))) {
1109                        queue.push_back((package, Some(extra)));
1110                    }
1111                }
1112            }
1113        }
1114
1115        // Seed from dependency groups attached directly to the lock (e.g., project-less
1116        // workspace roots).
1117        for (group, requirements) in self.dependency_groups() {
1118            if !groups.contains(group) {
1119                continue;
1120            }
1121            for requirement in requirements {
1122                for package in self
1123                    .packages
1124                    .iter()
1125                    .filter(|p| p.id.name == requirement.name)
1126                {
1127                    if seen.insert((&package.id, None)) {
1128                        queue.push_back((package, None));
1129                    }
1130                    for extra in &*requirement.extras {
1131                        if seen.insert((&package.id, Some(extra))) {
1132                            queue.push_back((package, Some(extra)));
1133                        }
1134                    }
1135                }
1136            }
1137        }
1138
1139        while let Some((package, extra)) = queue.pop_front() {
1140            let is_member = workspace_member_ids.contains(&package.id);
1141
1142            // Collect non-workspace packages that have version information
1143            // and pass the caller's filter.
1144            if !is_member && collect_filter(package) {
1145                if let Some(version) = package.version() {
1146                    visit(package, version);
1147                } else {
1148                    trace!(
1149                        "Skipping audit for `{}` because it has no version information",
1150                        package.name()
1151                    );
1152                }
1153            }
1154
1155            // Follow allowed dependency groups.
1156            if is_member && extra.is_none() {
1157                for dep in package
1158                    .dependency_groups
1159                    .iter()
1160                    .filter(|(group, _)| groups.contains(group))
1161                    .flat_map(|(_, deps)| deps)
1162                {
1163                    enqueue_dep(self, &mut seen, &mut queue, dep);
1164                }
1165            }
1166
1167            // Follow the regular/extra dependencies for this (package, extra) pair.
1168            // For workspace members in only-group mode, skip regular dependencies.
1169            let dependencies: &[Dependency] = match extra {
1170                Some(extra) => package
1171                    .optional_dependencies
1172                    .get(extra)
1173                    .map(Vec::as_slice)
1174                    .unwrap_or_default(),
1175                None if is_member && !groups.prod() => &[],
1176                None => &package.dependencies,
1177            };
1178
1179            for dep in dependencies {
1180                enqueue_dep(self, &mut seen, &mut queue, dep);
1181            }
1182        }
1183    }
1184
1185    /// Return the workspace root used to generate this lock.
1186    pub fn root(&self) -> Option<&Package> {
1187        self.packages.iter().find(|package| {
1188            let (Source::Editable(path) | Source::Virtual(path)) = &package.id.source else {
1189                return false;
1190            };
1191            path.as_ref() == Path::new("")
1192        })
1193    }
1194
1195    /// Returns the supported environments that were used to generate this
1196    /// lock.
1197    ///
1198    /// The markers returned here are "simplified" with respect to the lock
1199    /// file's `requires-python` setting. This means these should only be used
1200    /// for direct comparison purposes with the supported environments written
1201    /// by a human in `pyproject.toml`. (Think of "supported environments" in
1202    /// `pyproject.toml` as having an implicit `and python_full_version >=
1203    /// '{requires-python-bound}'` attached to each one.)
1204    pub fn simplified_supported_environments(&self) -> Vec<MarkerTree> {
1205        self.supported_environments()
1206            .iter()
1207            .copied()
1208            .map(|marker| self.simplify_environment(marker))
1209            .collect()
1210    }
1211
1212    /// Returns the required platforms that were used to generate this
1213    /// lock.
1214    pub fn simplified_required_environments(&self) -> Vec<MarkerTree> {
1215        self.required_environments()
1216            .iter()
1217            .copied()
1218            .map(|marker| self.simplify_environment(marker))
1219            .collect()
1220    }
1221
1222    /// Simplify the given marker environment with respect to the lockfile's
1223    /// `requires-python` setting.
1224    pub fn simplify_environment(&self, marker: MarkerTree) -> MarkerTree {
1225        self.requires_python.simplify_markers(marker)
1226    }
1227
1228    /// If this lockfile was built from a forking resolution with non-identical forks, return the
1229    /// markers of those forks, otherwise `None`.
1230    pub fn fork_markers(&self) -> &[UniversalMarker] {
1231        self.fork_markers.as_slice()
1232    }
1233
1234    /// The marker describing the universe of this resolution.
1235    fn fork_markers_union(&self) -> MarkerTree {
1236        fork_markers_union(&self.fork_markers, &self.requires_python)
1237    }
1238
1239    /// Checks whether the fork markers cover the entire supported marker space.
1240    ///
1241    /// Returns the actually covered and the expected marker space on validation error.
1242    pub fn check_marker_coverage(&self) -> Result<(), (MarkerTree, MarkerTree)> {
1243        let fork_markers_union = self.fork_markers_union();
1244        let mut environments_union = if !self.supported_environments.is_empty() {
1245            let mut environments_union = MarkerTree::FALSE;
1246            for fork_marker in &self.supported_environments {
1247                environments_union.or(*fork_marker);
1248            }
1249            environments_union
1250        } else {
1251            MarkerTree::TRUE
1252        };
1253        // When a user defines environments, they are implicitly constrained by requires-python.
1254        environments_union.and(self.requires_python.to_marker_tree());
1255        if fork_markers_union.negate().is_disjoint(environments_union) {
1256            Ok(())
1257        } else {
1258            Err((fork_markers_union, environments_union))
1259        }
1260    }
1261
1262    /// Checks whether the new requires-python specification is disjoint with
1263    /// the fork markers in this lock file.
1264    ///
1265    /// If they are disjoint, then the union of the fork markers along with the
1266    /// given requires-python specification (converted to a marker tree) are
1267    /// returned.
1268    ///
1269    /// When disjoint, the fork markers in the lock file should be dropped and
1270    /// not used.
1271    pub fn requires_python_coverage(
1272        &self,
1273        new_requires_python: &RequiresPython,
1274    ) -> Result<(), (MarkerTree, MarkerTree)> {
1275        let fork_markers_union = self.fork_markers_union();
1276        let new_requires_python = new_requires_python.to_marker_tree();
1277        if fork_markers_union.is_disjoint(new_requires_python) {
1278            Err((fork_markers_union, new_requires_python))
1279        } else {
1280            Ok(())
1281        }
1282    }
1283
1284    /// Returns the TOML representation of this lockfile.
1285    pub fn to_toml(&self) -> Result<String, toml_edit::ser::Error> {
1286        // Catch a lockfile where the union of fork markers doesn't cover the supported
1287        // environments.
1288        debug_assert!(self.check_marker_coverage().is_ok());
1289
1290        // We construct a TOML document manually instead of going through Serde to enable
1291        // the use of inline tables.
1292        let mut doc = toml_edit::DocumentMut::new();
1293        doc.insert("version", value(i64::from(self.version)));
1294
1295        if self.revision > 0 {
1296            doc.insert("revision", value(i64::from(self.revision)));
1297        }
1298
1299        doc.insert("requires-python", value(self.requires_python.to_string()));
1300
1301        if !self.fork_markers.is_empty() {
1302            let fork_markers = each_element_on_its_line_array(
1303                simplified_universal_markers(&self.fork_markers, &self.requires_python).into_iter(),
1304            );
1305            if !fork_markers.is_empty() {
1306                doc.insert("resolution-markers", value(fork_markers));
1307            }
1308        }
1309
1310        // The simplified marker space covered by this resolution.
1311        let simplified_environment =
1312            SimplifiedMarkerTree::new(&self.requires_python, self.fork_markers_union())
1313                .as_simplified_marker_tree();
1314
1315        if !self.supported_environments.is_empty() {
1316            let supported_environments = each_element_on_its_line_array(
1317                self.supported_environments
1318                    .iter()
1319                    .copied()
1320                    .map(|marker| SimplifiedMarkerTree::new(&self.requires_python, marker))
1321                    .filter_map(SimplifiedMarkerTree::try_to_string),
1322            );
1323            doc.insert("supported-markers", value(supported_environments));
1324        }
1325
1326        if !self.required_environments.is_empty() {
1327            let required_environments = each_element_on_its_line_array(
1328                self.required_environments
1329                    .iter()
1330                    .copied()
1331                    .map(|marker| SimplifiedMarkerTree::new(&self.requires_python, marker))
1332                    .filter_map(SimplifiedMarkerTree::try_to_string),
1333            );
1334            doc.insert("required-markers", value(required_environments));
1335        }
1336
1337        if !self.conflicts.is_empty() {
1338            let mut list = Array::new();
1339            for set in self.conflicts.iter() {
1340                list.push(each_element_on_its_line_array(set.iter().map(|item| {
1341                    let mut table = InlineTable::new();
1342                    table.insert("package", Value::from(item.package().to_string()));
1343                    match item.kind() {
1344                        ConflictKind::Project => {}
1345                        ConflictKind::Extra(extra) => {
1346                            table.insert("extra", Value::from(extra.to_string()));
1347                        }
1348                        ConflictKind::Group(group) => {
1349                            table.insert("group", Value::from(group.to_string()));
1350                        }
1351                    }
1352                    table
1353                })));
1354            }
1355            doc.insert("conflicts", value(list));
1356        }
1357
1358        // Write the settings that were used to generate the resolution.
1359        // This enables us to invalidate the lockfile if the user changes
1360        // their settings.
1361        {
1362            let mut options_table = Table::new();
1363
1364            if self.options.resolution_mode != ResolutionMode::default() {
1365                options_table.insert(
1366                    "resolution-mode",
1367                    value(self.options.resolution_mode.to_string()),
1368                );
1369            }
1370            if self.options.prerelease_mode != PrereleaseMode::default() {
1371                options_table.insert(
1372                    "prerelease-mode",
1373                    value(self.options.prerelease_mode.to_string()),
1374                );
1375            }
1376            if self.options.fork_strategy != ForkStrategy::default() {
1377                options_table.insert(
1378                    "fork-strategy",
1379                    value(self.options.fork_strategy.to_string()),
1380                );
1381            }
1382            let exclude_newer = ExcludeNewer::from(self.options.exclude_newer.clone());
1383            if !exclude_newer.is_empty() {
1384                // Always serialize global exclude-newer as a string
1385                if let Some(global) = &exclude_newer.global {
1386                    if let Some(span) = global.span() {
1387                        // When a relative span is present, write a no-op timestamp to avoid
1388                        // merge conflicts in the lockfile. In a future version of uv, we'll drop
1389                        // this field entirely but it's retained for backwards compatibility for now.
1390                        let mut noop = value(ExcludeNewerValue::PLACEHOLDER);
1391                        if let Item::Value(ref mut v) = noop {
1392                            v.decor_mut().set_suffix(" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.");
1393                        }
1394                        options_table.insert("exclude-newer", noop);
1395                        options_table.insert("exclude-newer-span", value(span.to_string()));
1396                    } else {
1397                        options_table.insert("exclude-newer", value(global.to_string()));
1398                    }
1399                }
1400
1401                // Serialize package-specific exclusions as a separate field
1402                if !exclude_newer.package.is_empty() {
1403                    let mut package_table = toml_edit::Table::new();
1404                    for (name, setting) in &exclude_newer.package {
1405                        match setting {
1406                            ExcludeNewerOverride::Enabled(exclude_newer_value) => {
1407                                if let Some(span) = exclude_newer_value.span() {
1408                                    // When a relative span is present, write a no-op timestamp
1409                                    // for backwards compatibility. This matches treatment for
1410                                    // the global `exclude-newer`.
1411                                    let mut inline = toml_edit::InlineTable::new();
1412                                    inline
1413                                        .insert("timestamp", ExcludeNewerValue::PLACEHOLDER.into());
1414                                    inline.insert("span", span.to_string().into());
1415                                    package_table.insert(name.as_ref(), Item::Value(inline.into()));
1416                                } else {
1417                                    // Serialize as simple string
1418                                    package_table.insert(
1419                                        name.as_ref(),
1420                                        value(exclude_newer_value.to_string()),
1421                                    );
1422                                }
1423                            }
1424                            ExcludeNewerOverride::Disabled => {
1425                                package_table.insert(name.as_ref(), value(false));
1426                            }
1427                        }
1428                    }
1429                    options_table.insert("exclude-newer-package", Item::Table(package_table));
1430                }
1431            }
1432
1433            if !options_table.is_empty() {
1434                doc.insert("options", Item::Table(options_table));
1435            }
1436        }
1437
1438        // Write the manifest that was used to generate the resolution.
1439        {
1440            let mut manifest_table = Table::new();
1441
1442            if !self.manifest.members.is_empty() {
1443                manifest_table.insert(
1444                    "members",
1445                    value(each_element_on_its_line_array(
1446                        self.manifest
1447                            .members
1448                            .iter()
1449                            .map(std::string::ToString::to_string),
1450                    )),
1451                );
1452            }
1453
1454            if !self.manifest.requirements.is_empty() {
1455                let requirements = self
1456                    .manifest
1457                    .requirements
1458                    .iter()
1459                    .map(|requirement| {
1460                        serde::Serialize::serialize(
1461                            &requirement,
1462                            toml_edit::ser::ValueSerializer::new(),
1463                        )
1464                    })
1465                    .collect::<Result<Vec<_>, _>>()?;
1466                let requirements = match requirements.as_slice() {
1467                    [] => Array::new(),
1468                    [requirement] => Array::from_iter([requirement]),
1469                    requirements => each_element_on_its_line_array(requirements.iter()),
1470                };
1471                manifest_table.insert("requirements", value(requirements));
1472            }
1473
1474            if !self.manifest.constraints.is_empty() {
1475                let constraints = self
1476                    .manifest
1477                    .constraints
1478                    .iter()
1479                    .map(|requirement| {
1480                        serde::Serialize::serialize(
1481                            &requirement,
1482                            toml_edit::ser::ValueSerializer::new(),
1483                        )
1484                    })
1485                    .collect::<Result<Vec<_>, _>>()?;
1486                let constraints = match constraints.as_slice() {
1487                    [] => Array::new(),
1488                    [requirement] => Array::from_iter([requirement]),
1489                    constraints => each_element_on_its_line_array(constraints.iter()),
1490                };
1491                manifest_table.insert("constraints", value(constraints));
1492            }
1493
1494            if !self.manifest.overrides.is_empty() {
1495                let overrides = self
1496                    .manifest
1497                    .overrides
1498                    .iter()
1499                    .map(|requirement| {
1500                        serde::Serialize::serialize(
1501                            &requirement,
1502                            toml_edit::ser::ValueSerializer::new(),
1503                        )
1504                    })
1505                    .collect::<Result<Vec<_>, _>>()?;
1506                let overrides = match overrides.as_slice() {
1507                    [] => Array::new(),
1508                    [requirement] => Array::from_iter([requirement]),
1509                    overrides => each_element_on_its_line_array(overrides.iter()),
1510                };
1511                manifest_table.insert("overrides", value(overrides));
1512            }
1513
1514            if !self.manifest.excludes.is_empty() {
1515                let excludes = self
1516                    .manifest
1517                    .excludes
1518                    .iter()
1519                    .map(|name| {
1520                        serde::Serialize::serialize(&name, toml_edit::ser::ValueSerializer::new())
1521                    })
1522                    .collect::<Result<Vec<_>, _>>()?;
1523                let excludes = match excludes.as_slice() {
1524                    [] => Array::new(),
1525                    [name] => Array::from_iter([name]),
1526                    excludes => each_element_on_its_line_array(excludes.iter()),
1527                };
1528                manifest_table.insert("excludes", value(excludes));
1529            }
1530
1531            if !self.manifest.build_constraints.is_empty() {
1532                let build_constraints = self
1533                    .manifest
1534                    .build_constraints
1535                    .iter()
1536                    .map(|requirement| {
1537                        serde::Serialize::serialize(
1538                            &requirement,
1539                            toml_edit::ser::ValueSerializer::new(),
1540                        )
1541                    })
1542                    .collect::<Result<Vec<_>, _>>()?;
1543                let build_constraints = match build_constraints.as_slice() {
1544                    [] => Array::new(),
1545                    [requirement] => Array::from_iter([requirement]),
1546                    build_constraints => each_element_on_its_line_array(build_constraints.iter()),
1547                };
1548                manifest_table.insert("build-constraints", value(build_constraints));
1549            }
1550
1551            if !self.manifest.dependency_groups.is_empty() {
1552                let mut dependency_groups = Table::new();
1553                for (extra, requirements) in &self.manifest.dependency_groups {
1554                    let requirements = requirements
1555                        .iter()
1556                        .map(|requirement| {
1557                            serde::Serialize::serialize(
1558                                &requirement,
1559                                toml_edit::ser::ValueSerializer::new(),
1560                            )
1561                        })
1562                        .collect::<Result<Vec<_>, _>>()?;
1563                    let requirements = match requirements.as_slice() {
1564                        [] => Array::new(),
1565                        [requirement] => Array::from_iter([requirement]),
1566                        requirements => each_element_on_its_line_array(requirements.iter()),
1567                    };
1568                    if !requirements.is_empty() {
1569                        dependency_groups.insert(extra.as_ref(), value(requirements));
1570                    }
1571                }
1572                if !dependency_groups.is_empty() {
1573                    manifest_table.insert("dependency-groups", Item::Table(dependency_groups));
1574                }
1575            }
1576
1577            if !self.manifest.dependency_metadata.is_empty() {
1578                let mut tables = ArrayOfTables::new();
1579                for metadata in &self.manifest.dependency_metadata {
1580                    let mut table = Table::new();
1581                    table.insert("name", value(metadata.name.to_string()));
1582                    if let Some(version) = metadata.version.as_ref() {
1583                        table.insert("version", value(version.to_string()));
1584                    }
1585                    if !metadata.requires_dist.is_empty() {
1586                        table.insert(
1587                            "requires-dist",
1588                            value(serde::Serialize::serialize(
1589                                &metadata.requires_dist,
1590                                toml_edit::ser::ValueSerializer::new(),
1591                            )?),
1592                        );
1593                    }
1594                    if let Some(requires_python) = metadata.requires_python.as_ref() {
1595                        table.insert("requires-python", value(requires_python.to_string()));
1596                    }
1597                    if !metadata.provides_extra.is_empty() {
1598                        table.insert(
1599                            "provides-extras",
1600                            value(serde::Serialize::serialize(
1601                                &metadata.provides_extra,
1602                                toml_edit::ser::ValueSerializer::new(),
1603                            )?),
1604                        );
1605                    }
1606                    tables.push(table);
1607                }
1608                manifest_table.insert("dependency-metadata", Item::ArrayOfTables(tables));
1609            }
1610
1611            if !manifest_table.is_empty() {
1612                doc.insert("manifest", Item::Table(manifest_table));
1613            }
1614        }
1615
1616        // Count the number of packages for each package name. When
1617        // there's only one package for a particular package name (the
1618        // overwhelmingly common case), we can omit some data (like source and
1619        // version) on dependency edges since it is strictly redundant.
1620        let mut dist_count_by_name: FxHashMap<PackageName, u64> = FxHashMap::default();
1621        for dist in &self.packages {
1622            *dist_count_by_name.entry(dist.id.name.clone()).or_default() += 1;
1623        }
1624
1625        let mut packages = ArrayOfTables::new();
1626        for dist in &self.packages {
1627            packages.push(dist.to_toml(
1628                &self.requires_python,
1629                simplified_environment,
1630                &dist_count_by_name,
1631            )?);
1632        }
1633
1634        doc.insert("package", Item::ArrayOfTables(packages));
1635        Ok(doc.to_string())
1636    }
1637
1638    /// Returns the package with the given name. If there are multiple
1639    /// matching packages, then an error is returned. If there are no
1640    /// matching packages, then `Ok(None)` is returned.
1641    pub fn find_by_name(&self, name: &PackageName) -> Result<Option<&Package>, String> {
1642        let mut found_dist = None;
1643        for dist in &self.packages {
1644            if &dist.id.name == name {
1645                if found_dist.is_some() {
1646                    return Err(format!("found multiple packages matching `{name}`"));
1647                }
1648                found_dist = Some(dist);
1649            }
1650        }
1651        Ok(found_dist)
1652    }
1653
1654    /// Returns the package with the given name.
1655    ///
1656    /// If there are multiple matching packages, returns the package that
1657    /// corresponds to the given marker tree.
1658    ///
1659    /// If there are multiple packages that are relevant to the current
1660    /// markers, then an error is returned.
1661    ///
1662    /// If there are no matching packages, then `Ok(None)` is returned.
1663    fn find_by_markers(
1664        &self,
1665        name: &PackageName,
1666        marker_env: &MarkerEnvironment,
1667    ) -> Result<Option<&Package>, String> {
1668        let mut found_dist = None;
1669        for dist in &self.packages {
1670            if &dist.id.name == name {
1671                if dist.fork_markers.is_empty()
1672                    || dist
1673                        .fork_markers
1674                        .iter()
1675                        .any(|marker| marker.evaluate_no_extras(marker_env))
1676                {
1677                    if found_dist.is_some() {
1678                        return Err(format!("found multiple packages matching `{name}`"));
1679                    }
1680                    found_dist = Some(dist);
1681                }
1682            }
1683        }
1684        Ok(found_dist)
1685    }
1686
1687    fn find_by_id(&self, id: &PackageId) -> &Package {
1688        let index = *self.by_id.get(id).expect("locked package for ID");
1689
1690        (self.packages.get(index).expect("valid index for package")) as _
1691    }
1692
1693    /// Return a [`SatisfiesResult`] if the given extras do not match the [`Package`] metadata.
1694    fn satisfies_provides_extra<'lock>(
1695        &self,
1696        provides_extra: Box<[ExtraName]>,
1697        package: &'lock Package,
1698    ) -> SatisfiesResult<'lock> {
1699        if !self.supports_provides_extra() {
1700            return SatisfiesResult::Satisfied;
1701        }
1702
1703        let expected: BTreeSet<_> = provides_extra.iter().collect();
1704        let actual: BTreeSet<_> = package.metadata.provides_extra.iter().collect();
1705
1706        if expected != actual {
1707            let expected = Box::into_iter(provides_extra).collect();
1708            return SatisfiesResult::MismatchedPackageProvidesExtra(
1709                &package.id.name,
1710                package.id.version.as_ref(),
1711                expected,
1712                actual,
1713            );
1714        }
1715
1716        SatisfiesResult::Satisfied
1717    }
1718
1719    /// Return a [`SatisfiesResult`] if the given requirements do not match the [`Package`] metadata.
1720    fn satisfies_requires_dist<'lock>(
1721        &self,
1722        requires_dist: Box<[Requirement]>,
1723        dependency_groups: BTreeMap<GroupName, Box<[Requirement]>>,
1724        package: &'lock Package,
1725        root: &Path,
1726    ) -> Result<SatisfiesResult<'lock>, LockError> {
1727        // Special-case: if the version is dynamic, compare the flattened requirements.
1728        let flattened = if package.is_dynamic() {
1729            Some(
1730                FlatRequiresDist::from_requirements(requires_dist.clone(), &package.id.name)
1731                    .into_iter()
1732                    .map(|requirement| {
1733                        normalize_requirement(requirement, root, &self.requires_python)
1734                    })
1735                    .collect::<Result<BTreeSet<_>, _>>()?,
1736            )
1737        } else {
1738            None
1739        };
1740
1741        // Validate the `requires-dist` metadata.
1742        let expected: BTreeSet<_> = Box::into_iter(requires_dist)
1743            .map(|requirement| normalize_requirement(requirement, root, &self.requires_python))
1744            .collect::<Result<_, _>>()?;
1745        let actual: BTreeSet<_> = package
1746            .metadata
1747            .requires_dist
1748            .iter()
1749            .cloned()
1750            .map(|requirement| normalize_requirement(requirement, root, &self.requires_python))
1751            .collect::<Result<_, _>>()?;
1752
1753        if expected != actual && flattened.is_none_or(|expected| expected != actual) {
1754            return Ok(SatisfiesResult::MismatchedPackageRequirements(
1755                &package.id.name,
1756                package.id.version.as_ref(),
1757                expected,
1758                actual,
1759            ));
1760        }
1761
1762        // Validate the `dependency-groups` metadata.
1763        let expected: BTreeMap<GroupName, BTreeSet<Requirement>> = dependency_groups
1764            .into_iter()
1765            .filter(|(_, requirements)| self.includes_empty_groups() || !requirements.is_empty())
1766            .map(|(group, requirements)| {
1767                Ok::<_, LockError>((
1768                    group,
1769                    Box::into_iter(requirements)
1770                        .map(|requirement| {
1771                            normalize_requirement(requirement, root, &self.requires_python)
1772                        })
1773                        .collect::<Result<_, _>>()?,
1774                ))
1775            })
1776            .collect::<Result<_, _>>()?;
1777        let actual: BTreeMap<GroupName, BTreeSet<Requirement>> = package
1778            .metadata
1779            .dependency_groups
1780            .iter()
1781            .filter(|(_, requirements)| self.includes_empty_groups() || !requirements.is_empty())
1782            .map(|(group, requirements)| {
1783                Ok::<_, LockError>((
1784                    group.clone(),
1785                    requirements
1786                        .iter()
1787                        .cloned()
1788                        .map(|requirement| {
1789                            normalize_requirement(requirement, root, &self.requires_python)
1790                        })
1791                        .collect::<Result<_, _>>()?,
1792                ))
1793            })
1794            .collect::<Result<_, _>>()?;
1795
1796        if expected != actual {
1797            return Ok(SatisfiesResult::MismatchedPackageDependencyGroups(
1798                &package.id.name,
1799                package.id.version.as_ref(),
1800                expected,
1801                actual,
1802            ));
1803        }
1804
1805        Ok(SatisfiesResult::Satisfied)
1806    }
1807
1808    /// Check whether the lock matches the project structure, requirements and configuration.
1809    #[instrument(skip_all)]
1810    pub async fn satisfies<Context: BuildContext>(
1811        &self,
1812        root: &Path,
1813        packages: &BTreeMap<PackageName, WorkspaceMember>,
1814        members: &[PackageName],
1815        required_members: &BTreeMap<PackageName, Editability>,
1816        requirements: &[Requirement],
1817        constraints: &[Requirement],
1818        overrides: &[Override<Requirement>],
1819        excludes: &[ExcludeDependency],
1820        build_constraints: &[Requirement],
1821        dependency_groups: &BTreeMap<GroupName, Vec<Requirement>>,
1822        dependency_metadata: &DependencyMetadata,
1823        indexes: Option<&IndexLocations>,
1824        tags: &Tags,
1825        markers: &MarkerEnvironment,
1826        build_options: &BuildOptions,
1827        hasher: &HashStrategy,
1828        index: &InMemoryIndex,
1829        database: &DistributionDatabase<'_, Context>,
1830    ) -> Result<SatisfiesResult<'_>, LockError> {
1831        let mut queue: VecDeque<&Package> = VecDeque::new();
1832        let mut seen = FxHashSet::default();
1833
1834        // Validate that the lockfile was generated with the same root members.
1835        {
1836            let expected = members.iter().cloned().collect::<BTreeSet<_>>();
1837            let actual = &self.manifest.members;
1838            if expected != *actual {
1839                return Ok(SatisfiesResult::MismatchedMembers(expected, actual));
1840            }
1841        }
1842
1843        // Validate that the member sources have not changed (e.g., that they've switched from
1844        // virtual to non-virtual or vice versa).
1845        for (name, member) in packages {
1846            let source = self.find_by_name(name).ok().flatten();
1847
1848            // Determine whether the member was required by any other member.
1849            let value = required_members.get(name);
1850            let is_required_member = value.is_some();
1851            let editability = value.copied().flatten();
1852
1853            // Verify that the member is virtual (or not).
1854            let expected_virtual = !member.pyproject_toml().is_package(!is_required_member);
1855            let actual_virtual =
1856                source.map(|package| matches!(package.id.source, Source::Virtual(..)));
1857            if actual_virtual != Some(expected_virtual) {
1858                return Ok(SatisfiesResult::MismatchedVirtual(
1859                    name.clone(),
1860                    expected_virtual,
1861                ));
1862            }
1863
1864            // Verify that the member is editable (or not).
1865            let expected_editable = if expected_virtual {
1866                false
1867            } else {
1868                editability.unwrap_or(true)
1869            };
1870            let actual_editable =
1871                source.map(|package| matches!(package.id.source, Source::Editable(..)));
1872            if actual_editable != Some(expected_editable) {
1873                return Ok(SatisfiesResult::MismatchedEditable(
1874                    name.clone(),
1875                    expected_editable,
1876                ));
1877            }
1878        }
1879
1880        // Validate that the lockfile was generated with the same requirements.
1881        {
1882            let expected: BTreeSet<_> = requirements
1883                .iter()
1884                .cloned()
1885                .map(|requirement| normalize_requirement(requirement, root, &self.requires_python))
1886                .collect::<Result<_, _>>()?;
1887            let actual: BTreeSet<_> = self
1888                .manifest
1889                .requirements
1890                .iter()
1891                .cloned()
1892                .map(|requirement| normalize_requirement(requirement, root, &self.requires_python))
1893                .collect::<Result<_, _>>()?;
1894            if expected != actual {
1895                return Ok(SatisfiesResult::MismatchedRequirements(expected, actual));
1896            }
1897        }
1898
1899        // Validate that the lockfile was generated with the same constraints.
1900        {
1901            let expected: BTreeSet<_> = constraints
1902                .iter()
1903                .cloned()
1904                .map(|requirement| normalize_requirement(requirement, root, &self.requires_python))
1905                .collect::<Result<_, _>>()?;
1906            let actual: BTreeSet<_> = self
1907                .manifest
1908                .constraints
1909                .iter()
1910                .cloned()
1911                .map(|requirement| normalize_requirement(requirement, root, &self.requires_python))
1912                .collect::<Result<_, _>>()?;
1913            if expected != actual {
1914                return Ok(SatisfiesResult::MismatchedConstraints(expected, actual));
1915            }
1916        }
1917
1918        // Validate that the lockfile was generated with the same overrides.
1919        {
1920            let normalize = |entry: Override<Requirement>| -> Result<_, LockError> {
1921                match entry {
1922                    Override::Requirement(requirement) => Ok(Override::Requirement(
1923                        normalize_requirement(requirement, root, &self.requires_python)?,
1924                    )),
1925                    Override::Package(package) => Ok(Override::Package(PackageOverride {
1926                        package: package.package,
1927                        dependencies: package
1928                            .dependencies
1929                            .into_vec()
1930                            .into_iter()
1931                            .map(|requirement| {
1932                                normalize_requirement(requirement, root, &self.requires_python)
1933                            })
1934                            .collect::<Result<Vec<_>, _>>()?
1935                            .into_boxed_slice(),
1936                    })),
1937                }
1938            };
1939            let expected: BTreeSet<_> = overrides
1940                .iter()
1941                .cloned()
1942                .map(normalize)
1943                .collect::<Result<_, _>>()?;
1944            let actual: BTreeSet<_> = self
1945                .manifest
1946                .overrides
1947                .iter()
1948                .cloned()
1949                .map(normalize)
1950                .collect::<Result<_, _>>()?;
1951            if expected != actual {
1952                return Ok(SatisfiesResult::MismatchedOverrides(expected, actual));
1953            }
1954        }
1955
1956        // Validate that the lockfile was generated with the same excludes.
1957        {
1958            let expected: BTreeSet<_> = excludes.iter().cloned().collect();
1959            let actual: BTreeSet<_> = self.manifest.excludes.iter().cloned().collect();
1960            if expected != actual {
1961                return Ok(SatisfiesResult::MismatchedExcludes(expected, actual));
1962            }
1963        }
1964
1965        // Validate that the lockfile was generated with the same build constraints.
1966        {
1967            let expected: BTreeSet<_> = build_constraints
1968                .iter()
1969                .cloned()
1970                .map(|requirement| normalize_requirement(requirement, root, &self.requires_python))
1971                .collect::<Result<_, _>>()?;
1972            let actual: BTreeSet<_> = self
1973                .manifest
1974                .build_constraints
1975                .iter()
1976                .cloned()
1977                .map(|requirement| normalize_requirement(requirement, root, &self.requires_python))
1978                .collect::<Result<_, _>>()?;
1979            if expected != actual {
1980                return Ok(SatisfiesResult::MismatchedBuildConstraints(
1981                    expected, actual,
1982                ));
1983            }
1984        }
1985
1986        // Validate that the lockfile was generated with the dependency groups.
1987        {
1988            let expected: BTreeMap<GroupName, BTreeSet<Requirement>> = dependency_groups
1989                .iter()
1990                .filter(|(_, requirements)| !requirements.is_empty())
1991                .map(|(group, requirements)| {
1992                    Ok::<_, LockError>((
1993                        group.clone(),
1994                        requirements
1995                            .iter()
1996                            .cloned()
1997                            .map(|requirement| {
1998                                normalize_requirement(requirement, root, &self.requires_python)
1999                            })
2000                            .collect::<Result<_, _>>()?,
2001                    ))
2002                })
2003                .collect::<Result<_, _>>()?;
2004            let actual: BTreeMap<GroupName, BTreeSet<Requirement>> = self
2005                .manifest
2006                .dependency_groups
2007                .iter()
2008                .filter(|(_, requirements)| !requirements.is_empty())
2009                .map(|(group, requirements)| {
2010                    Ok::<_, LockError>((
2011                        group.clone(),
2012                        requirements
2013                            .iter()
2014                            .cloned()
2015                            .map(|requirement| {
2016                                normalize_requirement(requirement, root, &self.requires_python)
2017                            })
2018                            .collect::<Result<_, _>>()?,
2019                    ))
2020                })
2021                .collect::<Result<_, _>>()?;
2022            if expected != actual {
2023                return Ok(SatisfiesResult::MismatchedDependencyGroups(
2024                    expected, actual,
2025                ));
2026            }
2027        }
2028
2029        // Validate that the lockfile was generated with the same static metadata.
2030        {
2031            let expected = dependency_metadata
2032                .values()
2033                .cloned()
2034                .collect::<BTreeSet<_>>();
2035            let actual = &self.manifest.dependency_metadata;
2036            if expected != *actual {
2037                return Ok(SatisfiesResult::MismatchedStaticMetadata(expected, actual));
2038            }
2039        }
2040
2041        // Collect the set of available indexes (both `--index-url` and `--find-links` entries).
2042        let mut remotes = indexes.map(|locations| {
2043            locations
2044                .allowed_indexes()
2045                .into_iter()
2046                .filter_map(|index| match index.url() {
2047                    IndexUrl::Pypi(_) | IndexUrl::Url(_) => {
2048                        Some(UrlString::from(index.url().without_credentials().as_ref()))
2049                    }
2050                    IndexUrl::Path(_) => None,
2051                })
2052                .collect::<BTreeSet<_>>()
2053        });
2054
2055        let mut locals = indexes.map(|locations| {
2056            locations
2057                .allowed_indexes()
2058                .into_iter()
2059                .filter_map(|index| match index.url() {
2060                    IndexUrl::Pypi(_) | IndexUrl::Url(_) => None,
2061                    IndexUrl::Path(url) => {
2062                        let path = url.to_file_path().ok()?;
2063                        let path = try_relative_to_if(&path, root, !url.was_given_absolute())
2064                            .ok()?
2065                            .into_boxed_path();
2066                        Some(path)
2067                    }
2068                })
2069                .collect::<BTreeSet<_>>()
2070        });
2071
2072        // Add the workspace packages to the queue.
2073        for root_name in packages.keys() {
2074            let root = self
2075                .find_by_name(root_name)
2076                .expect("found too many packages matching root");
2077
2078            let Some(root) = root else {
2079                // The package is not in the lockfile, so it can't be satisfied.
2080                return Ok(SatisfiesResult::MissingRoot(root_name.clone()));
2081            };
2082
2083            if seen.insert(&root.id) {
2084                queue.push_back(root);
2085            }
2086        }
2087
2088        // Add requirements attached directly to the target root (e.g., PEP 723 requirements or
2089        // dependency groups in workspaces without a `[project]` table).
2090        let root_requirements = requirements
2091            .iter()
2092            .chain(dependency_groups.values().flatten())
2093            .collect::<Vec<_>>();
2094
2095        for requirement in &root_requirements {
2096            if let RequirementSource::Registry {
2097                index: Some(index), ..
2098            } = &requirement.source
2099            {
2100                match &index.url {
2101                    IndexUrl::Pypi(_) | IndexUrl::Url(_) => {
2102                        if let Some(remotes) = remotes.as_mut() {
2103                            remotes.insert(UrlString::from(
2104                                index.url().without_credentials().as_ref(),
2105                            ));
2106                        }
2107                    }
2108                    IndexUrl::Path(url) => {
2109                        if let Some(locals) = locals.as_mut() {
2110                            if let Some(path) = url.to_file_path().ok().and_then(|path| {
2111                                try_relative_to_if(&path, root, !url.was_given_absolute()).ok()
2112                            }) {
2113                                locals.insert(path.into_boxed_path());
2114                            }
2115                        }
2116                    }
2117                }
2118            }
2119        }
2120
2121        if !root_requirements.is_empty() {
2122            let names = root_requirements
2123                .iter()
2124                .map(|requirement| &requirement.name)
2125                .collect::<FxHashSet<_>>();
2126
2127            let by_name: FxHashMap<_, Vec<_>> = self.packages.iter().fold(
2128                FxHashMap::with_capacity_and_hasher(self.packages.len(), FxBuildHasher),
2129                |mut by_name, package| {
2130                    if names.contains(&package.id.name) {
2131                        by_name.entry(&package.id.name).or_default().push(package);
2132                    }
2133                    by_name
2134                },
2135            );
2136
2137            for requirement in root_requirements {
2138                for package in by_name.get(&requirement.name).into_iter().flatten() {
2139                    if !package.id.source.is_source_tree() {
2140                        continue;
2141                    }
2142
2143                    let marker = if package.fork_markers.is_empty() {
2144                        requirement.marker
2145                    } else {
2146                        let mut combined = MarkerTree::FALSE;
2147                        for fork_marker in &package.fork_markers {
2148                            combined.or(fork_marker.pep508());
2149                        }
2150                        combined.and(requirement.marker);
2151                        combined
2152                    };
2153                    if marker.is_false() {
2154                        continue;
2155                    }
2156                    if !marker.evaluate(markers, &[]) {
2157                        continue;
2158                    }
2159
2160                    if seen.insert(&package.id) {
2161                        queue.push_back(package);
2162                    }
2163                }
2164            }
2165        }
2166
2167        while let Some(package) = queue.pop_front() {
2168            // If the lockfile references an index that was not provided, we can't validate it.
2169            if let Source::Registry(index) = &package.id.source {
2170                match index {
2171                    RegistrySource::Url(url) => {
2172                        if remotes
2173                            .as_ref()
2174                            .is_some_and(|remotes| !remotes.contains(url))
2175                        {
2176                            let name = &package.id.name;
2177                            let version = &package
2178                                .id
2179                                .version
2180                                .as_ref()
2181                                .expect("version for registry source");
2182                            return Ok(SatisfiesResult::MissingRemoteIndex(name, version, url));
2183                        }
2184                    }
2185                    RegistrySource::Path(path) => {
2186                        if locals.as_ref().is_some_and(|locals| !locals.contains(path)) {
2187                            let name = &package.id.name;
2188                            let version = &package
2189                                .id
2190                                .version
2191                                .as_ref()
2192                                .expect("version for registry source");
2193                            return Ok(SatisfiesResult::MissingLocalIndex(name, version, path));
2194                        }
2195                    }
2196                }
2197            }
2198
2199            // If the package is immutable, we don't need to validate it (or its dependencies).
2200            if package.id.source.is_immutable() {
2201                continue;
2202            }
2203
2204            // Validating a direct URL package requires retrieving metadata from the remote
2205            // artifact. In offline mode, preserve the metadata captured in the lockfile rather
2206            // than requiring that artifact to already be present in the cache.
2207            if matches!(&package.id.source, Source::Direct(..))
2208                && database.client().unmanaged.connectivity().is_offline()
2209            {
2210                trace!(
2211                    "Skipping metadata validation for `{}` because its direct URL cannot be refreshed while offline",
2212                    package.id
2213                );
2214            } else if let Some(version) = package.id.version.as_ref() {
2215                // If the distribution is a source tree, attempt to validate it from statically
2216                // available `pyproject.toml` metadata before converting it to an installable
2217                // distribution. This avoids requiring build permission for static local packages.
2218                let statically_satisfied = if let Some(source_tree) =
2219                    package.id.source.as_source_tree()
2220                    && let Some(SourceTreeRequiresDist {
2221                        version: static_version,
2222                        metadata,
2223                    }) = Self::source_tree_requires_dist(source_tree, root, package, database)
2224                        .await?
2225                {
2226                    // If this local package has become dynamic, the locked package should
2227                    // no longer contain a version.
2228                    if metadata.dynamic {
2229                        return Ok(SatisfiesResult::MismatchedDynamic(&package.id.name, false));
2230                    }
2231
2232                    if let Some(static_version) = static_version {
2233                        // Validate the static `version` metadata.
2234                        if static_version != *version {
2235                            return Ok(SatisfiesResult::MismatchedVersion(
2236                                &package.id.name,
2237                                version.clone(),
2238                                Some(static_version),
2239                            ));
2240                        }
2241
2242                        // Validate the static `provides-extras` metadata.
2243                        match self.satisfies_provides_extra(metadata.provides_extra, package) {
2244                            SatisfiesResult::Satisfied => {}
2245                            result => return Ok(result),
2246                        }
2247
2248                        // Validate that the static requirements are unchanged.
2249                        match self.satisfies_requires_dist(
2250                            metadata.requires_dist,
2251                            metadata.dependency_groups,
2252                            package,
2253                            root,
2254                        )? {
2255                            SatisfiesResult::Satisfied => true,
2256                            result => return Ok(result),
2257                        }
2258                    } else {
2259                        false
2260                    }
2261                } else {
2262                    false
2263                };
2264
2265                if !statically_satisfied {
2266                    // For a non-dynamic package without usable static metadata, fetch the metadata
2267                    // from the distribution database.
2268                    let HashedDist { dist, .. } = package.to_dist(
2269                        root,
2270                        TagPolicy::Preferred(tags),
2271                        build_options,
2272                        markers,
2273                    )?;
2274
2275                    let metadata = {
2276                        let id = dist.distribution_id();
2277                        if let Some(archive) =
2278                            index
2279                                .distributions()
2280                                .get(&id)
2281                                .as_deref()
2282                                .and_then(|response| {
2283                                    if let MetadataResponse::Found(archive, ..) = response {
2284                                        Some(archive)
2285                                    } else {
2286                                        None
2287                                    }
2288                                })
2289                        {
2290                            // If the metadata is already in the index, return it.
2291                            archive.metadata.clone()
2292                        } else {
2293                            // Run the PEP 517 build process to extract metadata from the source distribution.
2294                            let archive = database
2295                                .get_or_build_wheel_metadata(&dist, hasher.get(&dist))
2296                                .await
2297                                .map_err(|err| LockErrorKind::Resolution {
2298                                    id: package.id.clone(),
2299                                    err,
2300                                })?;
2301
2302                            let metadata = archive.metadata.clone();
2303
2304                            // Insert the metadata into the index.
2305                            index
2306                                .distributions()
2307                                .done(id, Arc::new(MetadataResponse::Found(archive)));
2308
2309                            metadata
2310                        }
2311                    };
2312
2313                    // If this is a local package, validate that it hasn't become dynamic (in which
2314                    // case, we'd expect the version to be omitted).
2315                    if package.id.source.is_source_tree() && metadata.dynamic {
2316                        return Ok(SatisfiesResult::MismatchedDynamic(&package.id.name, false));
2317                    }
2318
2319                    // Validate the `version` metadata.
2320                    if metadata.version != *version {
2321                        return Ok(SatisfiesResult::MismatchedVersion(
2322                            &package.id.name,
2323                            version.clone(),
2324                            Some(metadata.version.clone()),
2325                        ));
2326                    }
2327
2328                    // Validate the `provides-extras` metadata.
2329                    match self.satisfies_provides_extra(metadata.provides_extra, package) {
2330                        SatisfiesResult::Satisfied => {}
2331                        result => return Ok(result),
2332                    }
2333
2334                    // Validate that the requirements are unchanged.
2335                    match self.satisfies_requires_dist(
2336                        metadata.requires_dist,
2337                        metadata.dependency_groups,
2338                        package,
2339                        root,
2340                    )? {
2341                        SatisfiesResult::Satisfied => {}
2342                        result => return Ok(result),
2343                    }
2344                }
2345            } else if let Some(source_tree) = package.id.source.as_source_tree() {
2346                // For dynamic packages, we don't need the version. We only need to know that the
2347                // package is still dynamic, and that the requirements are unchanged.
2348                //
2349                // If the distribution is a source tree, attempt to extract the requirements from the
2350                // `pyproject.toml` directly. The distribution database will do this too, but we can be
2351                // even more aggressive here since we _only_ need the requirements. So, for example,
2352                // even if the version is dynamic, we can still extract the requirements without
2353                // performing a build, unlike in the database where we typically construct a "complete"
2354                // metadata object.
2355                let metadata =
2356                    Self::source_tree_requires_dist(source_tree, root, package, database)
2357                        .await?
2358                        .map(|metadata| metadata.metadata);
2359
2360                let satisfied = metadata.is_some_and(|metadata| {
2361                    // Validate that the package is still dynamic.
2362                    if !metadata.dynamic {
2363                        debug!("Static `requires-dist` for `{}` is out-of-date; falling back to distribution database", package.id);
2364                        return false;
2365                    }
2366
2367                    // Validate that the extras are unchanged.
2368                    if let SatisfiesResult::Satisfied = self.satisfies_provides_extra(metadata.provides_extra, package, ) {
2369                        debug!("Static `provides-extra` for `{}` is up-to-date", package.id);
2370                    } else {
2371                        debug!("Static `provides-extra` for `{}` is out-of-date; falling back to distribution database", package.id);
2372                        return false;
2373                    }
2374
2375                    // Validate that the requirements are unchanged.
2376                    match self.satisfies_requires_dist(metadata.requires_dist, metadata.dependency_groups, package, root) {
2377                        Ok(SatisfiesResult::Satisfied) => {
2378                            debug!("Static `requires-dist` for `{}` is up-to-date", package.id);
2379                        },
2380                        Ok(..) => {
2381                            debug!("Static `requires-dist` for `{}` is out-of-date; falling back to distribution database", package.id);
2382                            return false;
2383                        },
2384                        Err(..) => {
2385                            debug!("Static `requires-dist` for `{}` is invalid; falling back to distribution database", package.id);
2386                            return false;
2387                        },
2388                    }
2389
2390                    true
2391                });
2392
2393                // If the `requires-dist` metadata matches the requirements, we're done; otherwise,
2394                // fetch the "full" metadata, which may involve invoking the build system. In some
2395                // cases, build backends return metadata that does _not_ match the `pyproject.toml`
2396                // exactly. For example, `hatchling` will flatten any recursive (or self-referential)
2397                // extras, while `setuptools` will not.
2398                if !satisfied {
2399                    let HashedDist { dist, .. } = package.to_dist(
2400                        root,
2401                        TagPolicy::Preferred(tags),
2402                        build_options,
2403                        markers,
2404                    )?;
2405
2406                    let metadata = {
2407                        let id = dist.distribution_id();
2408                        if let Some(archive) =
2409                            index
2410                                .distributions()
2411                                .get(&id)
2412                                .as_deref()
2413                                .and_then(|response| {
2414                                    if let MetadataResponse::Found(archive, ..) = response {
2415                                        Some(archive)
2416                                    } else {
2417                                        None
2418                                    }
2419                                })
2420                        {
2421                            // If the metadata is already in the index, return it.
2422                            archive.metadata.clone()
2423                        } else {
2424                            // Run the PEP 517 build process to extract metadata from the source distribution.
2425                            let archive = database
2426                                .get_or_build_wheel_metadata(&dist, hasher.get(&dist))
2427                                .await
2428                                .map_err(|err| LockErrorKind::Resolution {
2429                                    id: package.id.clone(),
2430                                    err,
2431                                })?;
2432
2433                            let metadata = archive.metadata.clone();
2434
2435                            // Insert the metadata into the index.
2436                            index
2437                                .distributions()
2438                                .done(id, Arc::new(MetadataResponse::Found(archive)));
2439
2440                            metadata
2441                        }
2442                    };
2443
2444                    // Validate that the package is still dynamic.
2445                    if !metadata.dynamic {
2446                        return Ok(SatisfiesResult::MismatchedDynamic(&package.id.name, true));
2447                    }
2448
2449                    // Validate that the extras are unchanged.
2450                    match self.satisfies_provides_extra(metadata.provides_extra, package) {
2451                        SatisfiesResult::Satisfied => {}
2452                        result => return Ok(result),
2453                    }
2454
2455                    // Validate that the requirements are unchanged.
2456                    match self.satisfies_requires_dist(
2457                        metadata.requires_dist,
2458                        metadata.dependency_groups,
2459                        package,
2460                        root,
2461                    )? {
2462                        SatisfiesResult::Satisfied => {}
2463                        result => return Ok(result),
2464                    }
2465                }
2466            } else {
2467                return Ok(SatisfiesResult::MissingVersion(&package.id.name));
2468            }
2469
2470            // Add any explicit indexes to the list of known locals or remotes. These indexes may
2471            // not be available as top-level configuration (i.e., if they're defined within a
2472            // workspace member), but we already validated that the dependencies are up-to-date, so
2473            // we can consider them "available".
2474            for requirement in package
2475                .metadata
2476                .requires_dist
2477                .iter()
2478                .chain(package.metadata.dependency_groups.values().flatten())
2479            {
2480                if let RequirementSource::Registry {
2481                    index: Some(index), ..
2482                } = &requirement.source
2483                {
2484                    match &index.url {
2485                        IndexUrl::Pypi(_) | IndexUrl::Url(_) => {
2486                            if let Some(remotes) = remotes.as_mut() {
2487                                remotes.insert(UrlString::from(
2488                                    index.url().without_credentials().as_ref(),
2489                                ));
2490                            }
2491                        }
2492                        IndexUrl::Path(url) => {
2493                            if let Some(locals) = locals.as_mut() {
2494                                if let Some(path) = url.to_file_path().ok().and_then(|path| {
2495                                    try_relative_to_if(&path, root, !url.was_given_absolute()).ok()
2496                                }) {
2497                                    locals.insert(path.into_boxed_path());
2498                                }
2499                            }
2500                        }
2501                    }
2502                }
2503            }
2504
2505            // Recurse.
2506            for dep in &package.dependencies {
2507                if seen.insert(&dep.package_id) {
2508                    let dep_dist = self.find_by_id(&dep.package_id);
2509                    queue.push_back(dep_dist);
2510                }
2511            }
2512
2513            for dependencies in package.optional_dependencies.values() {
2514                for dep in dependencies {
2515                    if seen.insert(&dep.package_id) {
2516                        let dep_dist = self.find_by_id(&dep.package_id);
2517                        queue.push_back(dep_dist);
2518                    }
2519                }
2520            }
2521
2522            for dependencies in package.dependency_groups.values() {
2523                for dep in dependencies {
2524                    if seen.insert(&dep.package_id) {
2525                        let dep_dist = self.find_by_id(&dep.package_id);
2526                        queue.push_back(dep_dist);
2527                    }
2528                }
2529            }
2530        }
2531
2532        Ok(SatisfiesResult::Satisfied)
2533    }
2534
2535    async fn source_tree_requires_dist<Context: BuildContext>(
2536        source_tree: &Path,
2537        root: &Path,
2538        package: &Package,
2539        database: &DistributionDatabase<'_, Context>,
2540    ) -> Result<Option<SourceTreeRequiresDist>, LockError> {
2541        let parent = root.join(source_tree);
2542        let path = parent.join("pyproject.toml");
2543        match fs_err::tokio::read_to_string(&path).await {
2544            Ok(contents) => {
2545                let pyproject_toml = PyProjectToml::from_toml(&contents, path.user_display())
2546                    .map_err(|err| LockErrorKind::InvalidPyprojectToml {
2547                        path: path.clone(),
2548                        err,
2549                    })?;
2550                let version = pyproject_toml
2551                    .project
2552                    .as_ref()
2553                    .and_then(|project| project.version.clone());
2554                let metadata = database
2555                    .requires_dist(&parent, &pyproject_toml)
2556                    .await
2557                    .map_err(|err| LockErrorKind::Resolution {
2558                        id: package.id.clone(),
2559                        err,
2560                    })?;
2561                Ok(metadata.map(|metadata| SourceTreeRequiresDist { version, metadata }))
2562            }
2563            Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
2564            Err(err) => Err(LockErrorKind::UnreadablePyprojectToml { path, err }.into()),
2565        }
2566    }
2567}
2568
2569/// The set of lockfile packages that should be audited, materialized from a
2570/// single traversal of the dependency graph.
2571///
2572/// Created via [`Lock::auditable`]. Exposes multiple views so that different
2573/// audit sources (e.g. per-version vulnerability databases and per-project
2574/// status markers) can share one walk rather than each re-traversing the
2575/// lockfile.
2576#[derive(Debug)]
2577pub struct Auditable<'lock> {
2578    /// Packages deduplicated by `(name, version)` and sorted by the same key.
2579    packages: Vec<(&'lock Package, &'lock Version)>,
2580}
2581
2582struct SourceTreeRequiresDist {
2583    version: Option<Version>,
2584    metadata: RequiresDist,
2585}
2586
2587impl<'lock> Auditable<'lock> {
2588    /// Return the number of distinct `(name, version)` pairs to audit.
2589    pub fn len(&self) -> usize {
2590        self.packages.len()
2591    }
2592
2593    /// Return `true` if there are no packages to audit.
2594    pub fn is_empty(&self) -> bool {
2595        self.packages.is_empty()
2596    }
2597
2598    /// Iterate over the distinct `(name, version)` pairs to audit, sorted by that key.
2599    pub fn packages(&self) -> impl Iterator<Item = (&'lock PackageName, &'lock Version)> + '_ {
2600        self.packages
2601            .iter()
2602            .map(|(package, version)| (package.name(), *version))
2603    }
2604
2605    /// Return the distinct registry-hosted projects among the auditable
2606    /// packages, deduplicated by `(name, index URL)`. Non-registry sources
2607    /// (Git, direct URL, path, editable) are excluded.
2608    pub fn projects(&self, root: &Path) -> Result<Vec<(&'lock PackageName, IndexUrl)>, LockError> {
2609        let mut seen: FxHashSet<(&PackageName, String)> = FxHashSet::default();
2610        let mut projects: Vec<(&PackageName, IndexUrl)> = Vec::with_capacity(self.packages.len());
2611        for (package, _version) in &self.packages {
2612            if let Some(index) = package.index(root)?
2613                && seen.insert((package.name(), index.url().to_string()))
2614            {
2615                projects.push((package.name(), index));
2616            }
2617        }
2618        Ok(projects)
2619    }
2620}
2621
2622#[derive(Debug, Copy, Clone)]
2623enum TagPolicy<'tags> {
2624    /// Exclusively consider wheels that match the specified platform tags.
2625    Required(&'tags Tags),
2626    /// Prefer wheels that match the specified platform tags, but fall back to incompatible wheels
2627    /// if necessary.
2628    Preferred(&'tags Tags),
2629}
2630
2631impl<'tags> TagPolicy<'tags> {
2632    /// Returns the platform tags to consider.
2633    fn tags(&self) -> &'tags Tags {
2634        match self {
2635            Self::Required(tags) | Self::Preferred(tags) => tags,
2636        }
2637    }
2638}
2639
2640/// The result of checking if a lockfile satisfies a set of requirements.
2641#[derive(Debug)]
2642pub enum SatisfiesResult<'lock> {
2643    /// The lockfile satisfies the requirements.
2644    Satisfied,
2645    /// The lockfile uses a different set of workspace members.
2646    MismatchedMembers(BTreeSet<PackageName>, &'lock BTreeSet<PackageName>),
2647    /// A workspace member switched from virtual to non-virtual or vice versa.
2648    MismatchedVirtual(PackageName, bool),
2649    /// A workspace member switched from editable to non-editable or vice versa.
2650    MismatchedEditable(PackageName, bool),
2651    /// A source tree switched from dynamic to non-dynamic or vice versa.
2652    MismatchedDynamic(&'lock PackageName, bool),
2653    /// The lockfile uses a different set of version for its workspace members.
2654    MismatchedVersion(&'lock PackageName, Version, Option<Version>),
2655    /// The lockfile uses a different set of requirements.
2656    MismatchedRequirements(BTreeSet<Requirement>, BTreeSet<Requirement>),
2657    /// The lockfile uses a different set of constraints.
2658    MismatchedConstraints(BTreeSet<Requirement>, BTreeSet<Requirement>),
2659    /// The lockfile uses a different set of overrides.
2660    MismatchedOverrides(
2661        BTreeSet<Override<Requirement>>,
2662        BTreeSet<Override<Requirement>>,
2663    ),
2664    /// The lockfile uses a different set of excludes.
2665    MismatchedExcludes(BTreeSet<ExcludeDependency>, BTreeSet<ExcludeDependency>),
2666    /// The lockfile uses a different set of build constraints.
2667    MismatchedBuildConstraints(BTreeSet<Requirement>, BTreeSet<Requirement>),
2668    /// The lockfile uses a different set of dependency groups.
2669    MismatchedDependencyGroups(
2670        BTreeMap<GroupName, BTreeSet<Requirement>>,
2671        BTreeMap<GroupName, BTreeSet<Requirement>>,
2672    ),
2673    /// The lockfile uses different static metadata.
2674    MismatchedStaticMetadata(BTreeSet<StaticMetadata>, &'lock BTreeSet<StaticMetadata>),
2675    /// The lockfile is missing a workspace member.
2676    MissingRoot(PackageName),
2677    /// The lockfile referenced a remote index that was not provided
2678    MissingRemoteIndex(&'lock PackageName, &'lock Version, &'lock UrlString),
2679    /// The lockfile referenced a local index that was not provided
2680    MissingLocalIndex(&'lock PackageName, &'lock Version, &'lock Path),
2681    /// A package in the lockfile contains different `requires-dist` metadata than expected.
2682    MismatchedPackageRequirements(
2683        &'lock PackageName,
2684        Option<&'lock Version>,
2685        BTreeSet<Requirement>,
2686        BTreeSet<Requirement>,
2687    ),
2688    /// A package in the lockfile contains different `provides-extra` metadata than expected.
2689    MismatchedPackageProvidesExtra(
2690        &'lock PackageName,
2691        Option<&'lock Version>,
2692        BTreeSet<ExtraName>,
2693        BTreeSet<&'lock ExtraName>,
2694    ),
2695    /// A package in the lockfile contains different `dependency-groups` metadata than expected.
2696    MismatchedPackageDependencyGroups(
2697        &'lock PackageName,
2698        Option<&'lock Version>,
2699        BTreeMap<GroupName, BTreeSet<Requirement>>,
2700        BTreeMap<GroupName, BTreeSet<Requirement>>,
2701    ),
2702    /// The lockfile is missing a version.
2703    MissingVersion(&'lock PackageName),
2704}
2705
2706/// We discard the lockfile if these options match.
2707#[derive(Clone, Debug, Default, serde::Deserialize, PartialEq, Eq)]
2708#[serde(rename_all = "kebab-case")]
2709struct ResolverOptions {
2710    /// The [`ResolutionMode`] used to generate this lock.
2711    #[serde(default)]
2712    resolution_mode: ResolutionMode,
2713    /// The [`PrereleaseMode`] used to generate this lock.
2714    #[serde(default)]
2715    prerelease_mode: PrereleaseMode,
2716    /// The [`ForkStrategy`] used to generate this lock.
2717    #[serde(default)]
2718    fork_strategy: ForkStrategy,
2719    /// The [`ExcludeNewer`] setting used to generate this lock.
2720    #[serde(flatten)]
2721    exclude_newer: ExcludeNewerWire,
2722}
2723
2724#[expect(clippy::struct_field_names)]
2725#[derive(Clone, Debug, Default, serde::Deserialize, PartialEq, Eq)]
2726#[serde(rename_all = "kebab-case")]
2727struct ExcludeNewerWire {
2728    exclude_newer: Option<Timestamp>,
2729    exclude_newer_span: Option<ExcludeNewerSpan>,
2730    #[serde(default, skip_serializing_if = "ExcludeNewerPackage::is_empty")]
2731    exclude_newer_package: ExcludeNewerPackage,
2732}
2733
2734impl From<ExcludeNewerWire> for ExcludeNewer {
2735    fn from(wire: ExcludeNewerWire) -> Self {
2736        let global = match (wire.exclude_newer, wire.exclude_newer_span) {
2737            (Some(timestamp), None) => Some(ExcludeNewerValue::absolute(timestamp)),
2738            // We're phasing out writing a timestamp when spans are used. uv writes a dummy
2739            // timestamp for backwards compatibility that we can ignore on deserialization.
2740            (Some(_), Some(span)) => Some(ExcludeNewerValue::relative(span)),
2741            // A future version of uv will remove the timestamp entirely, so for forwards
2742            // compatibility we ignore a missing value.
2743            (None, Some(span)) => Some(ExcludeNewerValue::relative(span)),
2744            (None, None) => None,
2745        };
2746        Self {
2747            global,
2748            package: wire.exclude_newer_package,
2749        }
2750    }
2751}
2752
2753impl From<ExcludeNewer> for ExcludeNewerWire {
2754    fn from(exclude_newer: ExcludeNewer) -> Self {
2755        let (timestamp, span) = match exclude_newer.global {
2756            Some(ExcludeNewerValue::Absolute(timestamp)) => (Some(timestamp), None),
2757            Some(ExcludeNewerValue::Relative(span)) => (None, Some(span)),
2758            None => (None, None),
2759        };
2760        Self {
2761            exclude_newer: timestamp,
2762            exclude_newer_span: span,
2763            exclude_newer_package: exclude_newer.package,
2764        }
2765    }
2766}
2767
2768#[derive(Clone, Debug, Default, serde::Deserialize, PartialEq, Eq)]
2769#[serde(rename_all = "kebab-case")]
2770pub struct ResolverManifest {
2771    /// The workspace members included in the lockfile.
2772    #[serde(default)]
2773    members: BTreeSet<PackageName>,
2774    /// The requirements provided to the resolver, exclusive of the workspace members.
2775    ///
2776    /// These are requirements that are attached to the project, but not to any of its
2777    /// workspace members. For example, the requirements in a PEP 723 script would be included here.
2778    #[serde(default)]
2779    requirements: BTreeSet<Requirement>,
2780    /// The dependency groups provided to the resolver, exclusive of the workspace members.
2781    ///
2782    /// These are dependency groups that are attached to the project, but not to any of its
2783    /// workspace members. For example, the dependency groups in a `pyproject.toml` without a
2784    /// `[project]` table would be included here.
2785    #[serde(default)]
2786    dependency_groups: BTreeMap<GroupName, BTreeSet<Requirement>>,
2787    /// The constraints provided to the resolver.
2788    #[serde(default)]
2789    constraints: BTreeSet<Requirement>,
2790    /// The overrides provided to the resolver.
2791    #[serde(default)]
2792    overrides: BTreeSet<Override<Requirement>>,
2793    /// The excludes provided to the resolver.
2794    #[serde(default)]
2795    excludes: BTreeSet<ExcludeDependency>,
2796    /// The build constraints provided to the resolver.
2797    #[serde(default)]
2798    build_constraints: BTreeSet<Requirement>,
2799    /// The static metadata provided to the resolver.
2800    #[serde(default)]
2801    dependency_metadata: BTreeSet<StaticMetadata>,
2802}
2803
2804impl ResolverManifest {
2805    /// Initialize a [`ResolverManifest`] with the given members, requirements, constraints, and
2806    /// overrides.
2807    pub fn new(
2808        members: impl IntoIterator<Item = PackageName>,
2809        requirements: impl IntoIterator<Item = Requirement>,
2810        constraints: impl IntoIterator<Item = Requirement>,
2811        overrides: impl IntoIterator<Item = Override<Requirement>>,
2812        excludes: impl IntoIterator<Item = ExcludeDependency>,
2813        build_constraints: impl IntoIterator<Item = Requirement>,
2814        dependency_groups: impl IntoIterator<Item = (GroupName, Vec<Requirement>)>,
2815        dependency_metadata: impl IntoIterator<Item = StaticMetadata>,
2816    ) -> Self {
2817        Self {
2818            members: members.into_iter().collect(),
2819            requirements: requirements.into_iter().collect(),
2820            constraints: constraints.into_iter().collect(),
2821            overrides: overrides.into_iter().collect(),
2822            excludes: excludes.into_iter().collect(),
2823            build_constraints: build_constraints.into_iter().collect(),
2824            dependency_groups: dependency_groups
2825                .into_iter()
2826                .map(|(group, requirements)| (group, requirements.into_iter().collect()))
2827                .collect(),
2828            dependency_metadata: dependency_metadata.into_iter().collect(),
2829        }
2830    }
2831
2832    /// Convert the manifest to a relative form using the given workspace.
2833    pub fn relative_to(self, root: &Path) -> Result<Self, io::Error> {
2834        Ok(Self {
2835            members: self.members,
2836            requirements: self
2837                .requirements
2838                .into_iter()
2839                .map(|requirement| requirement.relative_to(root))
2840                .collect::<Result<BTreeSet<_>, _>>()?,
2841            constraints: self
2842                .constraints
2843                .into_iter()
2844                .map(|requirement| requirement.relative_to(root))
2845                .collect::<Result<BTreeSet<_>, _>>()?,
2846            overrides: self
2847                .overrides
2848                .into_iter()
2849                .map(|entry| match entry {
2850                    Override::Requirement(requirement) => {
2851                        Ok(Override::Requirement(requirement.relative_to(root)?))
2852                    }
2853                    Override::Package(package) => Ok(Override::Package(PackageOverride {
2854                        package: package.package,
2855                        dependencies: package
2856                            .dependencies
2857                            .into_vec()
2858                            .into_iter()
2859                            .map(|requirement| requirement.relative_to(root))
2860                            .collect::<Result<Vec<_>, _>>()?
2861                            .into_boxed_slice(),
2862                    })),
2863                })
2864                .collect::<Result<BTreeSet<_>, io::Error>>()?,
2865            excludes: self.excludes,
2866            build_constraints: self
2867                .build_constraints
2868                .into_iter()
2869                .map(|requirement| requirement.relative_to(root))
2870                .collect::<Result<BTreeSet<_>, _>>()?,
2871            dependency_groups: self
2872                .dependency_groups
2873                .into_iter()
2874                .map(|(group, requirements)| {
2875                    Ok::<_, io::Error>((
2876                        group,
2877                        requirements
2878                            .into_iter()
2879                            .map(|requirement| requirement.relative_to(root))
2880                            .collect::<Result<BTreeSet<_>, _>>()?,
2881                    ))
2882                })
2883                .collect::<Result<BTreeMap<_, _>, _>>()?,
2884            dependency_metadata: self.dependency_metadata,
2885        })
2886    }
2887}
2888
2889#[derive(Clone, Debug, serde::Deserialize)]
2890#[serde(rename_all = "kebab-case")]
2891struct LockWire {
2892    version: u32,
2893    revision: Option<u32>,
2894    requires_python: RequiresPython,
2895    /// If this lockfile was built from a forking resolution with non-identical forks, store the
2896    /// forks in the lockfile so we can recreate them in subsequent resolutions.
2897    #[serde(rename = "resolution-markers", default)]
2898    fork_markers: Vec<SimplifiedMarkerTree>,
2899    #[serde(rename = "supported-markers", default)]
2900    supported_environments: Vec<SimplifiedMarkerTree>,
2901    #[serde(rename = "required-markers", default)]
2902    required_environments: Vec<SimplifiedMarkerTree>,
2903    #[serde(rename = "conflicts", default)]
2904    conflicts: Option<Conflicts>,
2905    /// We discard the lockfile if these options match.
2906    #[serde(default)]
2907    options: ResolverOptions,
2908    #[serde(default)]
2909    manifest: ResolverManifest,
2910    #[serde(rename = "package", alias = "distribution", default)]
2911    packages: Vec<PackageWire>,
2912}
2913
2914impl TryFrom<LockWire> for Lock {
2915    type Error = LockError;
2916
2917    fn try_from(wire: LockWire) -> Result<Self, LockError> {
2918        // Count the number of sources for each package name. When
2919        // there's only one source for a particular package name (the
2920        // overwhelmingly common case), we can omit some data (like source and
2921        // version) on dependency edges since it is strictly redundant.
2922        let mut unambiguous_package_ids: FxHashMap<PackageName, PackageId> = FxHashMap::default();
2923        let mut ambiguous = FxHashSet::default();
2924        for dist in &wire.packages {
2925            if ambiguous.contains(&dist.id.name) {
2926                continue;
2927            }
2928            if let Some(id) = unambiguous_package_ids.remove(&dist.id.name) {
2929                ambiguous.insert(id.name);
2930                continue;
2931            }
2932            unambiguous_package_ids.insert(dist.id.name.clone(), dist.id.clone());
2933        }
2934
2935        let fork_markers = wire
2936            .fork_markers
2937            .into_iter()
2938            .map(|simplified_marker| simplified_marker.into_marker(&wire.requires_python))
2939            .map(UniversalMarker::from_combined)
2940            .collect::<Vec<_>>();
2941        let environment = SimplifiedMarkerTree::new(
2942            &wire.requires_python,
2943            fork_markers_union(&fork_markers, &wire.requires_python),
2944        );
2945        let packages = wire
2946            .packages
2947            .into_iter()
2948            .map(|dist| dist.unwire(&wire.requires_python, environment, &unambiguous_package_ids))
2949            .collect::<Result<Vec<_>, _>>()?;
2950        let supported_environments = wire
2951            .supported_environments
2952            .into_iter()
2953            .map(|simplified_marker| simplified_marker.into_marker(&wire.requires_python))
2954            .collect();
2955        let required_environments = wire
2956            .required_environments
2957            .into_iter()
2958            .map(|simplified_marker| simplified_marker.into_marker(&wire.requires_python))
2959            .collect();
2960        let mut options = wire.options;
2961        if options.exclude_newer.exclude_newer_span.is_some() {
2962            options.exclude_newer.exclude_newer = None;
2963        }
2964        let lock = Self::new(
2965            wire.version,
2966            wire.revision.unwrap_or(0),
2967            packages,
2968            wire.requires_python,
2969            options,
2970            wire.manifest,
2971            wire.conflicts.unwrap_or_else(Conflicts::empty),
2972            supported_environments,
2973            required_environments,
2974            fork_markers,
2975        )?;
2976
2977        Ok(lock)
2978    }
2979}
2980
2981/// Like [`Lock`], but limited to the version field. Used for error reporting: by limiting parsing
2982/// to the version field, we can verify compatibility for lockfiles that may otherwise be
2983/// unparsable.
2984#[derive(Clone, Debug, serde::Deserialize)]
2985#[serde(rename_all = "kebab-case")]
2986pub struct LockVersion {
2987    version: u32,
2988}
2989
2990impl LockVersion {
2991    /// Returns the lockfile version.
2992    pub fn version(&self) -> u32 {
2993        self.version
2994    }
2995}
2996
2997#[derive(Clone, Debug, PartialEq, Eq)]
2998pub struct Package {
2999    pub(crate) id: PackageId,
3000    sdist: Option<SourceDist>,
3001    wheels: Vec<Wheel>,
3002    /// If there are multiple versions or sources for the same package name, we add the markers of
3003    /// the fork(s) that contained this version or source, so we can set the correct preferences in
3004    /// the next resolution.
3005    ///
3006    /// Named `resolution-markers` in `uv.lock`.
3007    fork_markers: Vec<UniversalMarker>,
3008    /// The resolved dependencies of the package.
3009    dependencies: Vec<Dependency>,
3010    /// The resolved optional dependencies of the package.
3011    optional_dependencies: BTreeMap<ExtraName, Vec<Dependency>>,
3012    /// The resolved PEP 735 dependency groups of the package.
3013    dependency_groups: BTreeMap<GroupName, Vec<Dependency>>,
3014    /// The exact requirements from the package metadata.
3015    metadata: PackageMetadata,
3016}
3017
3018impl Package {
3019    pub fn is_from_pypi_registry(&self) -> bool {
3020        self.id.source.is_pypi_registry()
3021    }
3022
3023    fn from_annotated_dist(
3024        annotated_dist: &AnnotatedDist,
3025        fork_markers: Vec<UniversalMarker>,
3026        root: &Path,
3027    ) -> Result<Self, LockError> {
3028        let id = PackageId::from_annotated_dist(annotated_dist, root)?;
3029        let sdist = SourceDist::from_annotated_dist(&id, annotated_dist)?;
3030        let wheels = Wheel::from_annotated_dist(annotated_dist)?;
3031        let requires_dist = if id.source.is_immutable() {
3032            BTreeSet::default()
3033        } else {
3034            annotated_dist
3035                .metadata
3036                .as_ref()
3037                .expect("metadata is present")
3038                .requires_dist
3039                .iter()
3040                .cloned()
3041                .map(|requirement| requirement.relative_to(root))
3042                .collect::<Result<_, _>>()
3043                .map_err(LockErrorKind::RequirementRelativePath)?
3044        };
3045        let provides_extra = if id.source.is_immutable() {
3046            Box::default()
3047        } else {
3048            annotated_dist
3049                .metadata
3050                .as_ref()
3051                .expect("metadata is present")
3052                .provides_extra
3053                .clone()
3054        };
3055        let dependency_groups = if id.source.is_immutable() {
3056            BTreeMap::default()
3057        } else {
3058            annotated_dist
3059                .metadata
3060                .as_ref()
3061                .expect("metadata is present")
3062                .dependency_groups
3063                .iter()
3064                .map(|(group, requirements)| {
3065                    let requirements = requirements
3066                        .iter()
3067                        .cloned()
3068                        .map(|requirement| requirement.relative_to(root))
3069                        .collect::<Result<_, _>>()
3070                        .map_err(LockErrorKind::RequirementRelativePath)?;
3071                    Ok::<_, LockError>((group.clone(), requirements))
3072                })
3073                .collect::<Result<_, _>>()?
3074        };
3075        Ok(Self {
3076            id,
3077            sdist,
3078            wheels,
3079            fork_markers,
3080            dependencies: vec![],
3081            optional_dependencies: BTreeMap::default(),
3082            dependency_groups: BTreeMap::default(),
3083            metadata: PackageMetadata {
3084                requires_dist,
3085                provides_extra,
3086                dependency_groups,
3087            },
3088        })
3089    }
3090
3091    /// Add the [`AnnotatedDist`] as a dependency of the [`Package`].
3092    fn add_dependency(
3093        &mut self,
3094        requires_python: &RequiresPython,
3095        annotated_dist: &AnnotatedDist,
3096        marker: UniversalMarker,
3097        root: &Path,
3098    ) -> Result<(), LockError> {
3099        let new_dep =
3100            Dependency::from_annotated_dist(requires_python, annotated_dist, marker, root)?;
3101        for existing_dep in &mut self.dependencies {
3102            if existing_dep.package_id == new_dep.package_id
3103                // It's important that we do a comparison on
3104                // *simplified* markers here. In particular, when
3105                // we write markers out to the lock file, we use
3106                // "simplified" markers, or markers that are simplified
3107                // *given* that `requires-python` is satisfied. So if
3108                // we don't do equality based on what the simplified
3109                // marker is, we might wind up not merging dependencies
3110                // that ought to be merged and thus writing out extra
3111                // entries.
3112                //
3113                // For example, if `requires-python = '>=3.8'` and we
3114                // have `foo==1` and
3115                // `foo==1 ; python_version >= '3.8'` dependencies,
3116                // then they don't have equivalent complexified
3117                // markers, but their simplified markers are identical.
3118                //
3119                // NOTE: It does seem like perhaps this should
3120                // be implemented semantically/algebraically on
3121                // `MarkerTree` itself, but it wasn't totally clear
3122                // how to do that. I think `pep508` would need to
3123                // grow a concept of "requires python" and provide an
3124                // operation specifically for that.
3125                && existing_dep.simplified_marker == new_dep.simplified_marker
3126            {
3127                existing_dep.extra.extend(new_dep.extra);
3128                return Ok(());
3129            }
3130        }
3131
3132        self.dependencies.push(new_dep);
3133        Ok(())
3134    }
3135
3136    /// Add the [`AnnotatedDist`] as an optional dependency of the [`Package`].
3137    fn add_optional_dependency(
3138        &mut self,
3139        requires_python: &RequiresPython,
3140        extra: ExtraName,
3141        annotated_dist: &AnnotatedDist,
3142        marker: UniversalMarker,
3143        root: &Path,
3144    ) -> Result<(), LockError> {
3145        let dep = Dependency::from_annotated_dist(requires_python, annotated_dist, marker, root)?;
3146        let optional_deps = self.optional_dependencies.entry(extra).or_default();
3147        for existing_dep in &mut *optional_deps {
3148            if existing_dep.package_id == dep.package_id
3149                // See note in add_dependency for why we use
3150                // simplified markers here.
3151                && existing_dep.simplified_marker == dep.simplified_marker
3152            {
3153                existing_dep.extra.extend(dep.extra);
3154                return Ok(());
3155            }
3156        }
3157
3158        optional_deps.push(dep);
3159        Ok(())
3160    }
3161
3162    /// Add the [`AnnotatedDist`] to a dependency group of the [`Package`].
3163    fn add_group_dependency(
3164        &mut self,
3165        requires_python: &RequiresPython,
3166        group: GroupName,
3167        annotated_dist: &AnnotatedDist,
3168        marker: UniversalMarker,
3169        root: &Path,
3170    ) -> Result<(), LockError> {
3171        let dep = Dependency::from_annotated_dist(requires_python, annotated_dist, marker, root)?;
3172        let deps = self.dependency_groups.entry(group).or_default();
3173        for existing_dep in &mut *deps {
3174            if existing_dep.package_id == dep.package_id
3175                // See note in add_dependency for why we use
3176                // simplified markers here.
3177                && existing_dep.simplified_marker == dep.simplified_marker
3178            {
3179                existing_dep.extra.extend(dep.extra);
3180                return Ok(());
3181            }
3182        }
3183
3184        deps.push(dep);
3185        Ok(())
3186    }
3187
3188    /// Convert the [`Package`] to a [`Dist`] that can be used in installation, along with its hash.
3189    fn to_dist(
3190        &self,
3191        workspace_root: &Path,
3192        tag_policy: TagPolicy<'_>,
3193        build_options: &BuildOptions,
3194        markers: &MarkerEnvironment,
3195    ) -> Result<HashedDist, LockError> {
3196        let no_binary = build_options.no_binary_package(&self.id.name);
3197        let no_build = build_options.no_build_package(&self.id.name);
3198
3199        if !no_binary {
3200            if let Some(best_wheel_index) = self.find_best_wheel(tag_policy) {
3201                let hashes = {
3202                    let wheel = &self.wheels[best_wheel_index];
3203                    HashDigests::from(
3204                        wheel
3205                            .hash
3206                            .iter()
3207                            .chain(wheel.zstd.iter().flat_map(|z| z.hash.iter()))
3208                            .map(|h| h.0.clone())
3209                            .collect::<Vec<_>>(),
3210                    )
3211                };
3212
3213                let dist = match &self.id.source {
3214                    Source::Registry(source) => {
3215                        let wheels = self
3216                            .wheels
3217                            .iter()
3218                            .map(|wheel| wheel.to_registry_wheel(source, workspace_root))
3219                            .collect::<Result<_, LockError>>()?;
3220                        let reg_built_dist = RegistryBuiltDist {
3221                            wheels,
3222                            best_wheel_index,
3223                            sdist: None,
3224                        };
3225                        Dist::Built(BuiltDist::Registry(reg_built_dist))
3226                    }
3227                    Source::Path(path) => {
3228                        let filename: WheelFilename =
3229                            self.wheels[best_wheel_index].filename.clone();
3230                        let install_path = absolute_path(workspace_root, path)?;
3231                        let path_dist = PathBuiltDist {
3232                            filename,
3233                            url: verbatim_url(&install_path, &self.id)?,
3234                            install_path: absolute_path(workspace_root, path)?.into_boxed_path(),
3235                        };
3236                        let built_dist = BuiltDist::Path(path_dist);
3237                        Dist::Built(built_dist)
3238                    }
3239                    Source::Direct(url, direct) => {
3240                        let filename: WheelFilename =
3241                            self.wheels[best_wheel_index].filename.clone();
3242                        let url = DisplaySafeUrl::from(ParsedArchiveUrl {
3243                            url: url.to_url().map_err(LockErrorKind::InvalidUrl)?,
3244                            subdirectory: direct.subdirectory.clone(),
3245                            ext: DistExtension::Wheel,
3246                        });
3247                        let direct_dist = DirectUrlBuiltDist {
3248                            filename,
3249                            location: Box::new(url.clone()),
3250                            url: VerbatimUrl::from_url(url),
3251                        };
3252                        let built_dist = BuiltDist::DirectUrl(direct_dist);
3253                        Dist::Built(built_dist)
3254                    }
3255                    Source::Git(url, git) => {
3256                        let Some(install_path) = git.path.as_ref() else {
3257                            return Err(LockErrorKind::InvalidWheelSource {
3258                                id: self.id.clone(),
3259                                source_type: "Git",
3260                            }
3261                            .into());
3262                        };
3263
3264                        // Remove the fragment and query from the URL; they're already present in the
3265                        // `GitSource`.
3266                        let mut url = url.to_url().map_err(LockErrorKind::InvalidUrl)?;
3267                        url.set_fragment(None);
3268                        url.set_query(None);
3269
3270                        // Reconstruct the `GitUrl` from the `GitSource`.
3271                        let git_url = GitUrl::from_commit(
3272                            url,
3273                            GitReference::from(git.kind.clone()),
3274                            git.precise,
3275                            git.lfs,
3276                        )?;
3277
3278                        // Reconstruct the PEP 508-compatible URL from the `GitSource`.
3279                        let url = DisplaySafeUrl::from(ParsedGitPathUrl {
3280                            url: git_url.clone(),
3281                            install_path: install_path.clone(),
3282                            ext: DistExtension::Wheel,
3283                        });
3284
3285                        let filename: WheelFilename =
3286                            self.wheels[best_wheel_index].filename.clone();
3287
3288                        let git_dist = GitPathBuiltDist {
3289                            filename,
3290                            git: Box::new(git_url),
3291                            install_path: install_path.clone(),
3292                            url: VerbatimUrl::from_url(url),
3293                        };
3294                        let built_dist = BuiltDist::GitPath(git_dist);
3295                        Dist::Built(built_dist)
3296                    }
3297                    Source::Directory(_) => {
3298                        return Err(LockErrorKind::InvalidWheelSource {
3299                            id: self.id.clone(),
3300                            source_type: "directory",
3301                        }
3302                        .into());
3303                    }
3304                    Source::Editable(_) => {
3305                        return Err(LockErrorKind::InvalidWheelSource {
3306                            id: self.id.clone(),
3307                            source_type: "editable",
3308                        }
3309                        .into());
3310                    }
3311                    Source::Virtual(_) => {
3312                        return Err(LockErrorKind::InvalidWheelSource {
3313                            id: self.id.clone(),
3314                            source_type: "virtual",
3315                        }
3316                        .into());
3317                    }
3318                };
3319
3320                return Ok(HashedDist { dist, hashes });
3321            }
3322        }
3323
3324        if let Some(sdist) = self.to_source_dist(workspace_root)? {
3325            // Even with `--no-build`, allow virtual packages. (In the future, we may want to allow
3326            // any local source tree, or at least editable source trees, which we allow in
3327            // `uv pip`.)
3328            if !no_build || sdist.is_virtual() {
3329                let hashes = self
3330                    .sdist
3331                    .as_ref()
3332                    .and_then(|s| s.hash())
3333                    .map(|hash| HashDigests::from(vec![hash.0.clone()]))
3334                    .unwrap_or_else(|| HashDigests::from(vec![]));
3335                return Ok(HashedDist {
3336                    dist: Dist::Source(sdist),
3337                    hashes,
3338                });
3339            }
3340        }
3341
3342        match (no_binary, no_build) {
3343            (true, true) => Err(LockErrorKind::NoBinaryNoBuild {
3344                id: self.id.clone(),
3345            }
3346            .into()),
3347            (true, false) if self.id.source.is_wheel() => Err(LockErrorKind::NoBinaryWheelOnly {
3348                id: self.id.clone(),
3349            }
3350            .into()),
3351            (true, false) => Err(LockErrorKind::NoBinary {
3352                id: self.id.clone(),
3353            }
3354            .into()),
3355            (false, true) => Err(LockErrorKind::NoBuild {
3356                id: self.id.clone(),
3357            }
3358            .into()),
3359            (false, false) if self.id.source.is_wheel() => Err(LockError {
3360                kind: Box::new(LockErrorKind::IncompatibleWheelOnly {
3361                    id: self.id.clone(),
3362                }),
3363                hint: self.tag_hint(tag_policy, markers),
3364            }),
3365            (false, false) => Err(LockError {
3366                kind: Box::new(LockErrorKind::NeitherSourceDistNorWheel {
3367                    id: self.id.clone(),
3368                }),
3369                hint: self.tag_hint(tag_policy, markers),
3370            }),
3371        }
3372    }
3373
3374    /// Generate a [`WheelTagHint`] based on wheel-tag incompatibilities.
3375    fn tag_hint(
3376        &self,
3377        tag_policy: TagPolicy<'_>,
3378        markers: &MarkerEnvironment,
3379    ) -> Option<WheelTagHint> {
3380        let filenames = self
3381            .wheels
3382            .iter()
3383            .map(|wheel| &wheel.filename)
3384            .collect::<Vec<_>>();
3385        WheelTagHint::from_wheels(
3386            &self.id.name,
3387            self.id.version.as_ref(),
3388            &filenames,
3389            tag_policy.tags(),
3390            markers,
3391        )
3392    }
3393
3394    /// Convert the source of this [`Package`] to a [`SourceDist`] that can be used in installation.
3395    ///
3396    /// Returns `Ok(None)` if the source cannot be converted because `self.sdist` is `None`. This is required
3397    /// for registry sources.
3398    fn to_source_dist(
3399        &self,
3400        workspace_root: &Path,
3401    ) -> Result<Option<uv_distribution_types::SourceDist>, LockError> {
3402        let sdist = match &self.id.source {
3403            Source::Path(path) => {
3404                // A direct path source can also be a wheel, so validate the extension.
3405                let DistExtension::Source(ext) = DistExtension::from_path(path).map_err(|err| {
3406                    LockErrorKind::MissingExtension {
3407                        id: self.id.clone(),
3408                        err,
3409                    }
3410                })?
3411                else {
3412                    return Ok(None);
3413                };
3414                let install_path = absolute_path(workspace_root, path)?;
3415                let given = path.to_str().expect("lock file paths must be UTF-8");
3416                let path_dist = PathSourceDist {
3417                    name: self.id.name.clone(),
3418                    version: self.id.version.clone(),
3419                    url: verbatim_url(&install_path, &self.id)?.with_given(given),
3420                    install_path: install_path.into_boxed_path(),
3421                    ext,
3422                };
3423                uv_distribution_types::SourceDist::Path(path_dist)
3424            }
3425            Source::Directory(path) => {
3426                let install_path = absolute_path(workspace_root, path)?;
3427                let given = path.to_str().expect("lock file paths must be UTF-8");
3428                let dir_dist = DirectorySourceDist {
3429                    name: self.id.name.clone(),
3430                    url: verbatim_url(&install_path, &self.id)?.with_given(given),
3431                    install_path: install_path.into_boxed_path(),
3432                    editable: Some(false),
3433                    r#virtual: Some(false),
3434                };
3435                uv_distribution_types::SourceDist::Directory(dir_dist)
3436            }
3437            Source::Editable(path) => {
3438                let install_path = absolute_path(workspace_root, path)?;
3439                let given = path.to_str().expect("lock file paths must be UTF-8");
3440                let dir_dist = DirectorySourceDist {
3441                    name: self.id.name.clone(),
3442                    url: verbatim_url(&install_path, &self.id)?.with_given(given),
3443                    install_path: install_path.into_boxed_path(),
3444                    editable: Some(true),
3445                    r#virtual: Some(false),
3446                };
3447                uv_distribution_types::SourceDist::Directory(dir_dist)
3448            }
3449            Source::Virtual(path) => {
3450                let install_path = absolute_path(workspace_root, path)?;
3451                let given = path.to_str().expect("lock file paths must be UTF-8");
3452                let dir_dist = DirectorySourceDist {
3453                    name: self.id.name.clone(),
3454                    url: verbatim_url(&install_path, &self.id)?.with_given(given),
3455                    install_path: install_path.into_boxed_path(),
3456                    editable: Some(false),
3457                    r#virtual: Some(true),
3458                };
3459                uv_distribution_types::SourceDist::Directory(dir_dist)
3460            }
3461            Source::Git(url, git) => {
3462                // Remove the fragment and query from the URL; they're already present in the
3463                // `GitSource`.
3464                let mut url = url.to_url().map_err(LockErrorKind::InvalidUrl)?;
3465                url.set_fragment(None);
3466                url.set_query(None);
3467
3468                let git_url = GitUrl::from_commit(
3469                    url,
3470                    GitReference::from(git.kind.clone()),
3471                    git.precise,
3472                    git.lfs,
3473                )?;
3474
3475                if let Some(install_path) = git.path.as_ref() {
3476                    // A direct path source can also be a wheel, so validate the extension.
3477                    let DistExtension::Source(ext) = DistExtension::from_path(install_path)
3478                        .map_err(|err| LockErrorKind::MissingExtension {
3479                            id: self.id.clone(),
3480                            err,
3481                        })?
3482                    else {
3483                        return Ok(None);
3484                    };
3485
3486                    // Reconstruct the PEP 508-compatible URL from the `GitSource`.
3487                    let url = DisplaySafeUrl::from(ParsedGitPathUrl {
3488                        url: git_url.clone(),
3489                        install_path: install_path.clone(),
3490                        ext: DistExtension::Source(ext),
3491                    });
3492
3493                    let git_dist = GitPathSourceDist {
3494                        name: self.id.name.clone(),
3495                        url: VerbatimUrl::from_url(url),
3496                        git: Box::new(git_url),
3497                        install_path: install_path.clone(),
3498                        ext,
3499                    };
3500                    uv_distribution_types::SourceDist::GitPath(git_dist)
3501                } else {
3502                    // Reconstruct the PEP 508-compatible URL from the `GitSource`.
3503                    let url = DisplaySafeUrl::from(ParsedGitDirectoryUrl {
3504                        url: git_url.clone(),
3505                        subdirectory: git.subdirectory.clone(),
3506                    });
3507
3508                    let git_dist = GitDirectorySourceDist {
3509                        name: self.id.name.clone(),
3510                        url: VerbatimUrl::from_url(url),
3511                        git: Box::new(git_url),
3512                        subdirectory: git.subdirectory.clone(),
3513                    };
3514                    uv_distribution_types::SourceDist::GitDirectory(git_dist)
3515                }
3516            }
3517            Source::Direct(url, direct) => {
3518                // A direct URL source can also be a wheel, so validate the extension.
3519                let DistExtension::Source(ext) =
3520                    DistExtension::from_path(url.base_str()).map_err(|err| {
3521                        LockErrorKind::MissingExtension {
3522                            id: self.id.clone(),
3523                            err,
3524                        }
3525                    })?
3526                else {
3527                    return Ok(None);
3528                };
3529                let location = url.to_url().map_err(LockErrorKind::InvalidUrl)?;
3530                let url = DisplaySafeUrl::from(ParsedArchiveUrl {
3531                    url: location.clone(),
3532                    subdirectory: direct.subdirectory.clone(),
3533                    ext: DistExtension::Source(ext),
3534                });
3535                let direct_dist = DirectUrlSourceDist {
3536                    name: self.id.name.clone(),
3537                    location: Box::new(location),
3538                    subdirectory: direct.subdirectory.clone(),
3539                    ext,
3540                    url: VerbatimUrl::from_url(url),
3541                };
3542                uv_distribution_types::SourceDist::DirectUrl(direct_dist)
3543            }
3544            Source::Registry(RegistrySource::Url(url)) => {
3545                let Some(ref sdist) = self.sdist else {
3546                    return Ok(None);
3547                };
3548
3549                let name = &self.id.name;
3550                let version = self
3551                    .id
3552                    .version
3553                    .as_ref()
3554                    .expect("version for registry source");
3555
3556                let file_url = sdist.url().ok_or_else(|| LockErrorKind::MissingUrl {
3557                    name: name.clone(),
3558                    version: version.clone(),
3559                })?;
3560                let filename = sdist
3561                    .filename()
3562                    .ok_or_else(|| LockErrorKind::MissingFilename {
3563                        id: self.id.clone(),
3564                    })?;
3565                let ext = SourceDistExtension::from_path(filename.as_ref()).map_err(|err| {
3566                    LockErrorKind::MissingExtension {
3567                        id: self.id.clone(),
3568                        err,
3569                    }
3570                })?;
3571                let file = Box::new(uv_distribution_types::File {
3572                    dist_info_metadata: false,
3573                    filename: SmallString::from(filename),
3574                    hashes: sdist.hash().map_or(HashDigests::empty(), |hash| {
3575                        HashDigests::from(hash.0.clone())
3576                    }),
3577                    requires_python: None,
3578                    size: sdist.size(),
3579                    upload_time_utc_ms: sdist.upload_time().map(Timestamp::as_millisecond),
3580                    url: FileLocation::AbsoluteUrl(file_url.clone()),
3581                    yanked: None,
3582                    zstd: None,
3583                });
3584
3585                let index = IndexUrl::from(VerbatimUrl::from_url(
3586                    url.to_url().map_err(LockErrorKind::InvalidUrl)?,
3587                ));
3588
3589                let reg_dist = RegistrySourceDist {
3590                    name: name.clone(),
3591                    version: version.clone(),
3592                    file,
3593                    ext,
3594                    index,
3595                    wheels: vec![],
3596                };
3597                uv_distribution_types::SourceDist::Registry(reg_dist)
3598            }
3599            Source::Registry(RegistrySource::Path(path)) => {
3600                let Some(ref sdist) = self.sdist else {
3601                    return Ok(None);
3602                };
3603
3604                let name = &self.id.name;
3605                let version = self
3606                    .id
3607                    .version
3608                    .as_ref()
3609                    .expect("version for registry source");
3610
3611                let file_url = match sdist {
3612                    SourceDist::Url { url: file_url, .. } => {
3613                        FileLocation::AbsoluteUrl(file_url.clone())
3614                    }
3615                    SourceDist::Path {
3616                        path: file_path, ..
3617                    } => {
3618                        let file_path = workspace_root.join(path).join(file_path);
3619                        let file_url =
3620                            DisplaySafeUrl::from_file_path(&file_path).map_err(|()| {
3621                                LockErrorKind::PathToUrl {
3622                                    path: file_path.into_boxed_path(),
3623                                }
3624                            })?;
3625                        FileLocation::AbsoluteUrl(UrlString::from(file_url))
3626                    }
3627                    SourceDist::Metadata { .. } => {
3628                        return Err(LockErrorKind::MissingPath {
3629                            name: name.clone(),
3630                            version: version.clone(),
3631                        }
3632                        .into());
3633                    }
3634                };
3635                let filename = sdist
3636                    .filename()
3637                    .ok_or_else(|| LockErrorKind::MissingFilename {
3638                        id: self.id.clone(),
3639                    })?;
3640                let ext = SourceDistExtension::from_path(filename.as_ref()).map_err(|err| {
3641                    LockErrorKind::MissingExtension {
3642                        id: self.id.clone(),
3643                        err,
3644                    }
3645                })?;
3646                let file = Box::new(uv_distribution_types::File {
3647                    dist_info_metadata: false,
3648                    filename: SmallString::from(filename),
3649                    hashes: sdist.hash().map_or(HashDigests::empty(), |hash| {
3650                        HashDigests::from(hash.0.clone())
3651                    }),
3652                    requires_python: None,
3653                    size: sdist.size(),
3654                    upload_time_utc_ms: sdist.upload_time().map(Timestamp::as_millisecond),
3655                    url: file_url,
3656                    yanked: None,
3657                    zstd: None,
3658                });
3659
3660                let index = IndexUrl::from(
3661                    VerbatimUrl::from_absolute_path(workspace_root.join(path))
3662                        .map_err(LockErrorKind::RegistryVerbatimUrl)?,
3663                );
3664
3665                let reg_dist = RegistrySourceDist {
3666                    name: name.clone(),
3667                    version: version.clone(),
3668                    file,
3669                    ext,
3670                    index,
3671                    wheels: vec![],
3672                };
3673                uv_distribution_types::SourceDist::Registry(reg_dist)
3674            }
3675        };
3676
3677        Ok(Some(sdist))
3678    }
3679
3680    fn to_toml(
3681        &self,
3682        requires_python: &RequiresPython,
3683        simplified_environment: MarkerTree,
3684        dist_count_by_name: &FxHashMap<PackageName, u64>,
3685    ) -> Result<Table, toml_edit::ser::Error> {
3686        let mut table = Table::new();
3687
3688        self.id.to_toml(None, &mut table);
3689
3690        if !self.fork_markers.is_empty() {
3691            let fork_markers = each_element_on_its_line_array(
3692                simplified_universal_markers(&self.fork_markers, requires_python).into_iter(),
3693            );
3694            if !fork_markers.is_empty() {
3695                table.insert("resolution-markers", value(fork_markers));
3696            }
3697        }
3698
3699        if !self.dependencies.is_empty() {
3700            let deps = each_element_on_its_line_array(self.dependencies.iter().map(|dep| {
3701                dep.to_toml(simplified_environment, dist_count_by_name)
3702                    .into_inline_table()
3703            }));
3704            table.insert("dependencies", value(deps));
3705        }
3706
3707        if !self.optional_dependencies.is_empty() {
3708            let mut optional_deps = Table::new();
3709            for (extra, deps) in &self.optional_dependencies {
3710                let deps = each_element_on_its_line_array(deps.iter().map(|dep| {
3711                    dep.to_toml(simplified_environment, dist_count_by_name)
3712                        .into_inline_table()
3713                }));
3714                if !deps.is_empty() {
3715                    optional_deps.insert(extra.as_ref(), value(deps));
3716                }
3717            }
3718            if !optional_deps.is_empty() {
3719                table.insert("optional-dependencies", Item::Table(optional_deps));
3720            }
3721        }
3722
3723        if !self.dependency_groups.is_empty() {
3724            let mut dependency_groups = Table::new();
3725            for (extra, deps) in &self.dependency_groups {
3726                let deps = each_element_on_its_line_array(deps.iter().map(|dep| {
3727                    dep.to_toml(simplified_environment, dist_count_by_name)
3728                        .into_inline_table()
3729                }));
3730                if !deps.is_empty() {
3731                    dependency_groups.insert(extra.as_ref(), value(deps));
3732                }
3733            }
3734            if !dependency_groups.is_empty() {
3735                table.insert("dev-dependencies", Item::Table(dependency_groups));
3736            }
3737        }
3738
3739        if let Some(ref sdist) = self.sdist {
3740            table.insert("sdist", value(sdist.to_toml()?));
3741        }
3742
3743        if !self.wheels.is_empty() {
3744            let wheels = each_element_on_its_line_array(
3745                self.wheels
3746                    .iter()
3747                    .map(Wheel::to_toml)
3748                    .collect::<Result<Vec<_>, _>>()?
3749                    .into_iter(),
3750            );
3751            table.insert("wheels", value(wheels));
3752        }
3753
3754        // Write the package metadata, if non-empty.
3755        {
3756            let mut metadata_table = Table::new();
3757
3758            if !self.metadata.requires_dist.is_empty() {
3759                let requires_dist = self
3760                    .metadata
3761                    .requires_dist
3762                    .iter()
3763                    .map(|requirement| {
3764                        serde::Serialize::serialize(
3765                            &requirement,
3766                            toml_edit::ser::ValueSerializer::new(),
3767                        )
3768                    })
3769                    .collect::<Result<Vec<_>, _>>()?;
3770                let requires_dist = match requires_dist.as_slice() {
3771                    [] => Array::new(),
3772                    [requirement] => Array::from_iter([requirement]),
3773                    requires_dist => each_element_on_its_line_array(requires_dist.iter()),
3774                };
3775                metadata_table.insert("requires-dist", value(requires_dist));
3776            }
3777
3778            if !self.metadata.dependency_groups.is_empty() {
3779                let mut dependency_groups = Table::new();
3780                for (extra, deps) in &self.metadata.dependency_groups {
3781                    let deps = deps
3782                        .iter()
3783                        .map(|requirement| {
3784                            serde::Serialize::serialize(
3785                                &requirement,
3786                                toml_edit::ser::ValueSerializer::new(),
3787                            )
3788                        })
3789                        .collect::<Result<Vec<_>, _>>()?;
3790                    let deps = match deps.as_slice() {
3791                        [] => Array::new(),
3792                        [requirement] => Array::from_iter([requirement]),
3793                        deps => each_element_on_its_line_array(deps.iter()),
3794                    };
3795                    dependency_groups.insert(extra.as_ref(), value(deps));
3796                }
3797                if !dependency_groups.is_empty() {
3798                    metadata_table.insert("requires-dev", Item::Table(dependency_groups));
3799                }
3800            }
3801
3802            if !self.metadata.provides_extra.is_empty() {
3803                let provides_extras = self
3804                    .metadata
3805                    .provides_extra
3806                    .iter()
3807                    .map(|extra| {
3808                        serde::Serialize::serialize(&extra, toml_edit::ser::ValueSerializer::new())
3809                    })
3810                    .collect::<Result<Vec<_>, _>>()?;
3811                // This is just a list of names, so linebreaking it is excessive.
3812                let provides_extras = Array::from_iter(provides_extras);
3813                metadata_table.insert("provides-extras", value(provides_extras));
3814            }
3815
3816            if !metadata_table.is_empty() {
3817                table.insert("metadata", Item::Table(metadata_table));
3818            }
3819        }
3820
3821        Ok(table)
3822    }
3823
3824    fn find_best_wheel(&self, tag_policy: TagPolicy<'_>) -> Option<usize> {
3825        type WheelPriority<'lock> = (TagPriority, Option<&'lock BuildTag>);
3826
3827        let mut best: Option<(WheelPriority, usize)> = None;
3828        for (i, wheel) in self.wheels.iter().enumerate() {
3829            let TagCompatibility::Compatible(tag_priority) =
3830                wheel.filename.compatibility(tag_policy.tags())
3831            else {
3832                continue;
3833            };
3834            let build_tag = wheel.filename.build_tag();
3835            let wheel_priority = (tag_priority, build_tag);
3836            match best {
3837                None => {
3838                    best = Some((wheel_priority, i));
3839                }
3840                Some((best_priority, _)) => {
3841                    if wheel_priority > best_priority {
3842                        best = Some((wheel_priority, i));
3843                    }
3844                }
3845            }
3846        }
3847
3848        let best = best.map(|(_, i)| i);
3849        match tag_policy {
3850            TagPolicy::Required(_) => best,
3851            TagPolicy::Preferred(_) => best.or_else(|| self.wheels.first().map(|_| 0)),
3852        }
3853    }
3854
3855    /// Returns the [`PackageName`] of the package.
3856    pub fn name(&self) -> &PackageName {
3857        &self.id.name
3858    }
3859
3860    /// Returns the [`Version`] of the package.
3861    pub fn version(&self) -> Option<&Version> {
3862        self.id.version.as_ref()
3863    }
3864
3865    /// Returns the Git SHA of the package, if it is a Git source.
3866    pub fn git_sha(&self) -> Option<&GitOid> {
3867        match &self.id.source {
3868            Source::Git(_, git) => Some(&git.precise),
3869            _ => None,
3870        }
3871    }
3872
3873    /// Return the fork markers for this package, if any.
3874    pub(crate) fn fork_markers(&self) -> &[UniversalMarker] {
3875        self.fork_markers.as_slice()
3876    }
3877
3878    /// Returns whether this package is included by the given PEP 508 marker.
3879    pub fn is_included_by_marker(&self, marker: MarkerTree) -> bool {
3880        self.fork_markers.is_empty()
3881            || self
3882                .fork_markers
3883                .iter()
3884                .any(|fork_marker| !fork_marker.pep508().is_disjoint(marker))
3885    }
3886
3887    /// Returns the [`IndexUrl`] for the package, if it is a registry source.
3888    pub fn index(&self, root: &Path) -> Result<Option<IndexUrl>, LockError> {
3889        match &self.id.source {
3890            Source::Registry(RegistrySource::Url(url)) => {
3891                let index = IndexUrl::from(VerbatimUrl::from_url(
3892                    url.to_url().map_err(LockErrorKind::InvalidUrl)?,
3893                ));
3894                Ok(Some(index))
3895            }
3896            Source::Registry(RegistrySource::Path(path)) => {
3897                let index = IndexUrl::from(
3898                    VerbatimUrl::from_absolute_path(root.join(path))
3899                        .map_err(LockErrorKind::RegistryVerbatimUrl)?,
3900                );
3901                Ok(Some(index))
3902            }
3903            _ => Ok(None),
3904        }
3905    }
3906
3907    /// Returns all the hashes associated with this [`Package`].
3908    fn hashes(&self) -> HashDigests {
3909        let mut hashes = Vec::with_capacity(
3910            usize::from(self.sdist.as_ref().and_then(|sdist| sdist.hash()).is_some())
3911                + self
3912                    .wheels
3913                    .iter()
3914                    .map(|wheel| usize::from(wheel.hash.is_some()))
3915                    .sum::<usize>(),
3916        );
3917        if let Some(ref sdist) = self.sdist {
3918            if let Some(hash) = sdist.hash() {
3919                hashes.push(hash.0.clone());
3920            }
3921        }
3922        for wheel in &self.wheels {
3923            hashes.extend(wheel.hash.as_ref().map(|h| h.0.clone()));
3924            if let Some(zstd) = wheel.zstd.as_ref() {
3925                hashes.extend(zstd.hash.as_ref().map(|h| h.0.clone()));
3926            }
3927        }
3928        HashDigests::from(hashes)
3929    }
3930
3931    /// Returns the [`ResolvedRepositoryReference`] for the package, if it is a Git source.
3932    pub fn as_git_ref(&self) -> Result<Option<ResolvedRepositoryReference>, LockError> {
3933        match &self.id.source {
3934            Source::Git(url, git) => Ok(Some(ResolvedRepositoryReference {
3935                reference: RepositoryReference {
3936                    url: RepositoryUrl::new(&url.to_url().map_err(LockErrorKind::InvalidUrl)?),
3937                    reference: GitReference::from(git.kind.clone()),
3938                },
3939                sha: git.precise,
3940            })),
3941            _ => Ok(None),
3942        }
3943    }
3944
3945    /// Returns `true` if the package is a dynamic source tree.
3946    fn is_dynamic(&self) -> bool {
3947        self.id.version.is_none()
3948    }
3949
3950    /// Returns the extras the package provides, if any.
3951    pub fn provides_extras(&self) -> &[ExtraName] {
3952        &self.metadata.provides_extra
3953    }
3954
3955    /// Returns the dependency groups the package provides, if any.
3956    pub fn dependency_groups(&self) -> &BTreeMap<GroupName, BTreeSet<Requirement>> {
3957        &self.metadata.dependency_groups
3958    }
3959
3960    /// Returns the dependencies of the package.
3961    pub fn dependencies(&self) -> &[Dependency] {
3962        &self.dependencies
3963    }
3964
3965    /// Returns the optional dependencies of the package.
3966    pub fn optional_dependencies(&self) -> &BTreeMap<ExtraName, Vec<Dependency>> {
3967        &self.optional_dependencies
3968    }
3969
3970    /// Returns the resolved PEP 735 dependency groups of the package.
3971    pub fn resolved_dependency_groups(&self) -> &BTreeMap<GroupName, Vec<Dependency>> {
3972        &self.dependency_groups
3973    }
3974
3975    /// Returns an [`InstallTarget`] view for filtering decisions.
3976    fn as_install_target(&self) -> InstallTarget<'_> {
3977        InstallTarget {
3978            name: self.name(),
3979            is_local: self.id.source.is_local(),
3980        }
3981    }
3982}
3983
3984/// Attempts to construct a `VerbatimUrl` from the given normalized `Path`.
3985fn verbatim_url(path: &Path, id: &PackageId) -> Result<VerbatimUrl, LockError> {
3986    let url =
3987        VerbatimUrl::from_normalized_path(path).map_err(|err| LockErrorKind::VerbatimUrl {
3988            id: id.clone(),
3989            err,
3990        })?;
3991    Ok(url)
3992}
3993
3994/// Attempts to construct an absolute path from the given `Path`.
3995fn absolute_path(workspace_root: &Path, path: &Path) -> Result<PathBuf, LockError> {
3996    let path = uv_fs::normalize_absolute_path(&workspace_root.join(path))
3997        .map_err(LockErrorKind::AbsolutePath)?;
3998    Ok(path)
3999}
4000
4001#[derive(Clone, Debug, serde::Deserialize)]
4002#[serde(rename_all = "kebab-case")]
4003struct PackageWire {
4004    #[serde(flatten)]
4005    id: PackageId,
4006    #[serde(default)]
4007    metadata: PackageMetadata,
4008    #[serde(default)]
4009    sdist: Option<SourceDist>,
4010    #[serde(default)]
4011    wheels: Vec<Wheel>,
4012    #[serde(default, rename = "resolution-markers")]
4013    fork_markers: Vec<SimplifiedMarkerTree>,
4014    #[serde(default)]
4015    dependencies: Vec<DependencyWire>,
4016    #[serde(default)]
4017    optional_dependencies: BTreeMap<ExtraName, Vec<DependencyWire>>,
4018    #[serde(default, rename = "dev-dependencies", alias = "dependency-groups")]
4019    dependency_groups: BTreeMap<GroupName, Vec<DependencyWire>>,
4020}
4021
4022#[derive(Clone, Default, Debug, Eq, PartialEq, serde::Deserialize)]
4023#[serde(rename_all = "kebab-case")]
4024struct PackageMetadata {
4025    #[serde(default)]
4026    requires_dist: BTreeSet<Requirement>,
4027    #[serde(default, rename = "provides-extras")]
4028    provides_extra: Box<[ExtraName]>,
4029    #[serde(default, rename = "requires-dev", alias = "dependency-groups")]
4030    dependency_groups: BTreeMap<GroupName, BTreeSet<Requirement>>,
4031}
4032
4033impl PackageWire {
4034    fn unwire(
4035        self,
4036        requires_python: &RequiresPython,
4037        environment: SimplifiedMarkerTree,
4038        unambiguous_package_ids: &FxHashMap<PackageName, PackageId>,
4039    ) -> Result<Package, LockError> {
4040        // Consistency check
4041        if !uv_flags::contains(uv_flags::EnvironmentFlags::SKIP_WHEEL_FILENAME_CHECK) {
4042            if let Some(version) = &self.id.version {
4043                for wheel in &self.wheels {
4044                    if *version != wheel.filename.version
4045                        && *version != wheel.filename.version.clone().without_local()
4046                    {
4047                        return Err(LockError::from(LockErrorKind::InconsistentVersions {
4048                            name: self.id.name,
4049                            version: version.clone(),
4050                            wheel: wheel.clone(),
4051                        }));
4052                    }
4053                }
4054                // We can't check the source dist version since it does not need to contain the version
4055                // in the filename.
4056            }
4057        }
4058
4059        let unwire_deps = |deps: Vec<DependencyWire>| -> Result<Vec<Dependency>, LockError> {
4060            deps.into_iter()
4061                .map(|dep| dep.unwire(requires_python, environment, unambiguous_package_ids))
4062                .collect()
4063        };
4064
4065        Ok(Package {
4066            id: self.id,
4067            metadata: self.metadata,
4068            sdist: self.sdist,
4069            wheels: self.wheels,
4070            fork_markers: self
4071                .fork_markers
4072                .into_iter()
4073                .map(|simplified_marker| simplified_marker.into_marker(requires_python))
4074                .map(UniversalMarker::from_combined)
4075                .collect(),
4076            dependencies: unwire_deps(self.dependencies)?,
4077            optional_dependencies: self
4078                .optional_dependencies
4079                .into_iter()
4080                .map(|(extra, deps)| Ok((extra, unwire_deps(deps)?)))
4081                .collect::<Result<_, LockError>>()?,
4082            dependency_groups: self
4083                .dependency_groups
4084                .into_iter()
4085                .map(|(group, deps)| Ok((group, unwire_deps(deps)?)))
4086                .collect::<Result<_, LockError>>()?,
4087        })
4088    }
4089}
4090
4091/// Inside the lockfile, we match a dependency entry to a package entry through a key made up
4092/// of the name, the version and the source url.
4093#[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord, serde::Deserialize)]
4094#[serde(rename_all = "kebab-case")]
4095pub(crate) struct PackageId {
4096    pub(crate) name: PackageName,
4097    version: Option<Version>,
4098    source: Source,
4099}
4100
4101impl PackageId {
4102    fn from_annotated_dist(annotated_dist: &AnnotatedDist, root: &Path) -> Result<Self, LockError> {
4103        // Identify the source of the package.
4104        let source = Source::from_resolved_dist(&annotated_dist.dist, root)?;
4105        // Omit versions for dynamic source trees.
4106        let version = if source.is_source_tree()
4107            && annotated_dist
4108                .metadata
4109                .as_ref()
4110                .is_some_and(|metadata| metadata.dynamic)
4111        {
4112            None
4113        } else {
4114            Some(annotated_dist.version.clone())
4115        };
4116        let name = annotated_dist.name.clone();
4117        Ok(Self {
4118            name,
4119            version,
4120            source,
4121        })
4122    }
4123
4124    /// Writes this package ID inline into the table given.
4125    ///
4126    /// When a map is given, and if the package name in this ID is unambiguous
4127    /// (i.e., it has a count of 1 in the map), then the `version` and `source`
4128    /// fields are omitted. In all other cases, including when a map is not
4129    /// given, the `version` and `source` fields are written.
4130    fn to_toml(&self, dist_count_by_name: Option<&FxHashMap<PackageName, u64>>, table: &mut Table) {
4131        let count = dist_count_by_name.and_then(|map| map.get(&self.name).copied());
4132        table.insert("name", value(self.name.to_string()));
4133        if count.is_none_or(|count| count > 1) {
4134            if let Some(version) = &self.version {
4135                table.insert("version", value(version.to_string()));
4136            }
4137            self.source.to_toml(table);
4138        }
4139    }
4140}
4141
4142impl Display for PackageId {
4143    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
4144        if let Some(version) = &self.version {
4145            write!(f, "{}=={} @ {}", self.name, version, self.source)
4146        } else {
4147            write!(f, "{} @ {}", self.name, self.source)
4148        }
4149    }
4150}
4151
4152#[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord, serde::Deserialize)]
4153#[serde(rename_all = "kebab-case")]
4154struct PackageIdForDependency {
4155    name: PackageName,
4156    version: Option<Version>,
4157    source: Option<Source>,
4158}
4159
4160impl PackageIdForDependency {
4161    fn unwire(
4162        self,
4163        unambiguous_package_ids: &FxHashMap<PackageName, PackageId>,
4164    ) -> Result<PackageId, LockError> {
4165        let unambiguous_package_id = unambiguous_package_ids.get(&self.name);
4166        let source = self.source.map(Ok::<_, LockError>).unwrap_or_else(|| {
4167            let Some(package_id) = unambiguous_package_id else {
4168                return Err(LockErrorKind::MissingDependencySource {
4169                    name: self.name.clone(),
4170                }
4171                .into());
4172            };
4173            Ok(package_id.source.clone())
4174        })?;
4175        let version = if let Some(version) = self.version {
4176            Some(version)
4177        } else {
4178            if let Some(package_id) = unambiguous_package_id {
4179                package_id.version.clone()
4180            } else {
4181                // If the package is a source tree, assume that the missing `self.version` field is
4182                // indicative of a dynamic version.
4183                if source.is_source_tree() {
4184                    None
4185                } else {
4186                    return Err(LockErrorKind::MissingDependencyVersion {
4187                        name: self.name.clone(),
4188                    }
4189                    .into());
4190                }
4191            }
4192        };
4193        Ok(PackageId {
4194            name: self.name,
4195            version,
4196            source,
4197        })
4198    }
4199}
4200
4201impl From<PackageId> for PackageIdForDependency {
4202    fn from(id: PackageId) -> Self {
4203        Self {
4204            name: id.name,
4205            version: id.version,
4206            source: Some(id.source),
4207        }
4208    }
4209}
4210
4211/// A unique identifier to differentiate between different sources for the same version of a
4212/// package.
4213///
4214/// NOTE: Care should be taken when adding variants to this enum. Namely, new
4215/// variants should be added without changing the relative ordering of other
4216/// variants. Otherwise, this could cause the lockfile to have a different
4217/// canonical ordering of sources.
4218#[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord, serde::Deserialize)]
4219#[serde(try_from = "SourceWire")]
4220enum Source {
4221    /// A registry or `--find-links` index.
4222    Registry(RegistrySource),
4223    /// A Git repository.
4224    Git(UrlString, GitSource),
4225    /// A direct HTTP(S) URL.
4226    Direct(UrlString, DirectSource),
4227    /// A path to a local source or built archive.
4228    Path(Box<Path>),
4229    /// A path to a local directory.
4230    Directory(Box<Path>),
4231    /// A path to a local directory that should be installed as editable.
4232    Editable(Box<Path>),
4233    /// A path to a local directory that should not be built or installed.
4234    Virtual(Box<Path>),
4235}
4236
4237impl Source {
4238    fn from_resolved_dist(resolved_dist: &ResolvedDist, root: &Path) -> Result<Self, LockError> {
4239        match *resolved_dist {
4240            // We pass empty installed packages for locking.
4241            ResolvedDist::Installed { .. } => unreachable!(),
4242            ResolvedDist::Installable { ref dist, .. } => Self::from_dist(dist, root),
4243        }
4244    }
4245
4246    fn from_dist(dist: &Dist, root: &Path) -> Result<Self, LockError> {
4247        match *dist {
4248            Dist::Built(ref built_dist) => Self::from_built_dist(built_dist, root),
4249            Dist::Source(ref source_dist) => Self::from_source_dist(source_dist, root),
4250        }
4251    }
4252
4253    fn from_built_dist(built_dist: &BuiltDist, root: &Path) -> Result<Self, LockError> {
4254        match *built_dist {
4255            BuiltDist::Registry(ref reg_dist) => Self::from_registry_built_dist(reg_dist, root),
4256            BuiltDist::DirectUrl(ref direct_dist) => Ok(Self::from_direct_built_dist(direct_dist)),
4257            BuiltDist::Path(ref path_dist) => Self::from_path_built_dist(path_dist, root),
4258            BuiltDist::GitPath(ref git_dist) => Self::from_git_path_built_dist(git_dist, root),
4259        }
4260    }
4261
4262    fn from_source_dist(
4263        source_dist: &uv_distribution_types::SourceDist,
4264        root: &Path,
4265    ) -> Result<Self, LockError> {
4266        match *source_dist {
4267            uv_distribution_types::SourceDist::Registry(ref reg_dist) => {
4268                Self::from_registry_source_dist(reg_dist, root)
4269            }
4270            uv_distribution_types::SourceDist::DirectUrl(ref direct_dist) => {
4271                Ok(Self::from_direct_source_dist(direct_dist))
4272            }
4273            uv_distribution_types::SourceDist::GitDirectory(ref git_dist) => {
4274                Ok(Self::from_git_directory_source_dist(git_dist))
4275            }
4276            uv_distribution_types::SourceDist::GitPath(ref git_dist) => {
4277                Self::from_git_path_source_dist(git_dist, root)
4278            }
4279            uv_distribution_types::SourceDist::Path(ref path_dist) => {
4280                Self::from_path_source_dist(path_dist, root)
4281            }
4282            uv_distribution_types::SourceDist::Directory(ref directory) => {
4283                Self::from_directory_source_dist(directory, root)
4284            }
4285        }
4286    }
4287
4288    fn from_registry_built_dist(
4289        reg_dist: &RegistryBuiltDist,
4290        root: &Path,
4291    ) -> Result<Self, LockError> {
4292        Self::from_index_url(&reg_dist.best_wheel().index, root)
4293    }
4294
4295    fn from_registry_source_dist(
4296        reg_dist: &RegistrySourceDist,
4297        root: &Path,
4298    ) -> Result<Self, LockError> {
4299        Self::from_index_url(&reg_dist.index, root)
4300    }
4301
4302    fn from_direct_built_dist(direct_dist: &DirectUrlBuiltDist) -> Self {
4303        Self::Direct(
4304            normalize_url(direct_dist.url.to_url()),
4305            DirectSource { subdirectory: None },
4306        )
4307    }
4308
4309    fn from_direct_source_dist(direct_dist: &DirectUrlSourceDist) -> Self {
4310        Self::Direct(
4311            normalize_url(direct_dist.url.to_url()),
4312            DirectSource {
4313                subdirectory: direct_dist.subdirectory.clone(),
4314            },
4315        )
4316    }
4317
4318    fn from_path_built_dist(path_dist: &PathBuiltDist, root: &Path) -> Result<Self, LockError> {
4319        let path = try_relative_to_if(
4320            &path_dist.install_path,
4321            root,
4322            !path_dist.url.was_given_absolute(),
4323        )
4324        .map_err(LockErrorKind::DistributionRelativePath)?;
4325        Ok(Self::Path(path.into_boxed_path()))
4326    }
4327
4328    fn from_path_source_dist(path_dist: &PathSourceDist, root: &Path) -> Result<Self, LockError> {
4329        let path = try_relative_to_if(
4330            &path_dist.install_path,
4331            root,
4332            !path_dist.url.was_given_absolute(),
4333        )
4334        .map_err(LockErrorKind::DistributionRelativePath)?;
4335        Ok(Self::Path(path.into_boxed_path()))
4336    }
4337
4338    fn from_directory_source_dist(
4339        directory_dist: &DirectorySourceDist,
4340        root: &Path,
4341    ) -> Result<Self, LockError> {
4342        let path = try_relative_to_if(
4343            &directory_dist.install_path,
4344            root,
4345            !directory_dist.url.was_given_absolute(),
4346        )
4347        .map_err(LockErrorKind::DistributionRelativePath)?;
4348        if directory_dist.editable.unwrap_or(false) {
4349            Ok(Self::Editable(path.into_boxed_path()))
4350        } else if directory_dist.r#virtual.unwrap_or(false) {
4351            Ok(Self::Virtual(path.into_boxed_path()))
4352        } else {
4353            Ok(Self::Directory(path.into_boxed_path()))
4354        }
4355    }
4356
4357    fn from_index_url(index_url: &IndexUrl, root: &Path) -> Result<Self, LockError> {
4358        match index_url {
4359            IndexUrl::Pypi(_) | IndexUrl::Url(_) => {
4360                // Remove any sensitive credentials from the index URL.
4361                let redacted = index_url.without_credentials();
4362                let source = RegistrySource::Url(UrlString::from(redacted.as_ref()));
4363                Ok(Self::Registry(source))
4364            }
4365            IndexUrl::Path(url) => {
4366                let path = url
4367                    .to_file_path()
4368                    .map_err(|()| LockErrorKind::UrlToPath { url: url.to_url() })?;
4369                let path = try_relative_to_if(&path, root, !url.was_given_absolute())
4370                    .map_err(LockErrorKind::IndexRelativePath)?;
4371                let source = RegistrySource::Path(path.into_boxed_path());
4372                Ok(Self::Registry(source))
4373            }
4374        }
4375    }
4376
4377    fn from_git_path_built_dist(
4378        git_dist: &GitPathBuiltDist,
4379        root: &Path,
4380    ) -> Result<Self, LockError> {
4381        let path = relative_to(&git_dist.install_path, root)
4382            .or_else(|_| std::path::absolute(&git_dist.install_path))
4383            .map_err(LockErrorKind::DistributionRelativePath)?;
4384        Ok(Self::Git(
4385            UrlString::from(locked_git_url(
4386                &git_dist.git,
4387                None,
4388                Some(git_dist.install_path.as_path()),
4389            )),
4390            GitSource {
4391                kind: GitSourceKind::from(git_dist.git.reference().clone()),
4392                precise: git_dist.git.precise().unwrap_or_else(|| {
4393                    panic!("Git distribution is missing a precise hash: {git_dist}")
4394                }),
4395                subdirectory: None,
4396                path: Some(path),
4397                lfs: git_dist.git.lfs(),
4398            },
4399        ))
4400    }
4401
4402    fn from_git_path_source_dist(
4403        git_dist: &GitPathSourceDist,
4404        root: &Path,
4405    ) -> Result<Self, LockError> {
4406        let path = relative_to(&git_dist.install_path, root)
4407            .or_else(|_| std::path::absolute(&git_dist.install_path))
4408            .map_err(LockErrorKind::DistributionRelativePath)?;
4409        Ok(Self::Git(
4410            UrlString::from(locked_git_url(
4411                &git_dist.git,
4412                None,
4413                Some(git_dist.install_path.as_path()),
4414            )),
4415            GitSource {
4416                kind: GitSourceKind::from(git_dist.git.reference().clone()),
4417                precise: git_dist.git.precise().unwrap_or_else(|| {
4418                    panic!("Git distribution is missing a precise hash: {git_dist}")
4419                }),
4420                subdirectory: None,
4421                path: Some(path),
4422                lfs: git_dist.git.lfs(),
4423            },
4424        ))
4425    }
4426
4427    fn from_git_directory_source_dist(git_dist: &GitDirectorySourceDist) -> Self {
4428        Self::Git(
4429            UrlString::from(locked_git_url(
4430                &git_dist.git,
4431                git_dist.subdirectory.as_deref(),
4432                None,
4433            )),
4434            GitSource {
4435                kind: GitSourceKind::from(git_dist.git.reference().clone()),
4436                precise: git_dist.git.precise().unwrap_or_else(|| {
4437                    panic!("Git distribution is missing a precise hash: {git_dist}")
4438                }),
4439                subdirectory: git_dist.subdirectory.clone(),
4440                path: None,
4441                lfs: git_dist.git.lfs(),
4442            },
4443        )
4444    }
4445
4446    /// Returns `true` if the source is a registry entry pointing at PyPI (`https://pypi.org/simple`).
4447    fn is_pypi_registry(&self) -> bool {
4448        matches!(
4449            self,
4450            Self::Registry(RegistrySource::Url(url)) if url.as_ref() == PYPI_URL.as_str()
4451        )
4452    }
4453
4454    /// Returns `true` if the source should be considered immutable.
4455    ///
4456    /// We assume that registry sources are immutable. In other words, we expect that once a
4457    /// package-version is published to a registry, its metadata will not change.
4458    ///
4459    /// We also assume that Git sources are immutable, since a Git source encodes a specific commit.
4460    fn is_immutable(&self) -> bool {
4461        matches!(self, Self::Registry(..) | Self::Git(_, _))
4462    }
4463
4464    /// Returns `true` if the source is that of a wheel.
4465    fn is_wheel(&self) -> bool {
4466        match self {
4467            Self::Path(path) => {
4468                matches!(
4469                    DistExtension::from_path(path).ok(),
4470                    Some(DistExtension::Wheel)
4471                )
4472            }
4473            Self::Direct(url, _) => {
4474                matches!(
4475                    DistExtension::from_path(url.as_ref()).ok(),
4476                    Some(DistExtension::Wheel)
4477                )
4478            }
4479            Self::Directory(..) => false,
4480            Self::Editable(..) => false,
4481            Self::Virtual(..) => false,
4482            Self::Git(..) => false,
4483            Self::Registry(..) => false,
4484        }
4485    }
4486
4487    /// Returns `true` if the source is that of a source tree.
4488    fn is_source_tree(&self) -> bool {
4489        match self {
4490            Self::Directory(..) | Self::Editable(..) | Self::Virtual(..) => true,
4491            Self::Path(..) | Self::Git(..) | Self::Registry(..) | Self::Direct(..) => false,
4492        }
4493    }
4494
4495    /// Returns the path to the source tree, if the source is a source tree.
4496    fn as_source_tree(&self) -> Option<&Path> {
4497        match self {
4498            Self::Directory(path) | Self::Editable(path) | Self::Virtual(path) => Some(path),
4499            Self::Path(..) | Self::Git(..) | Self::Registry(..) | Self::Direct(..) => None,
4500        }
4501    }
4502
4503    fn to_toml(&self, table: &mut Table) {
4504        let mut source_table = InlineTable::new();
4505        match self {
4506            Self::Registry(source) => match source {
4507                RegistrySource::Url(url) => {
4508                    source_table.insert("registry", Value::from(url.as_ref()));
4509                }
4510                RegistrySource::Path(path) => {
4511                    source_table.insert(
4512                        "registry",
4513                        Value::from(PortablePath::from(path).to_string()),
4514                    );
4515                }
4516            },
4517            Self::Git(url, _) => {
4518                source_table.insert("git", Value::from(url.as_ref()));
4519            }
4520            Self::Direct(url, DirectSource { subdirectory }) => {
4521                source_table.insert("url", Value::from(url.as_ref()));
4522                if let Some(ref subdirectory) = *subdirectory {
4523                    source_table.insert(
4524                        "subdirectory",
4525                        Value::from(PortablePath::from(subdirectory).to_string()),
4526                    );
4527                }
4528            }
4529            Self::Path(path) => {
4530                source_table.insert("path", Value::from(PortablePath::from(path).to_string()));
4531            }
4532            Self::Directory(path) => {
4533                source_table.insert(
4534                    "directory",
4535                    Value::from(PortablePath::from(path).to_string()),
4536                );
4537            }
4538            Self::Editable(path) => {
4539                source_table.insert(
4540                    "editable",
4541                    Value::from(PortablePath::from(path).to_string()),
4542                );
4543            }
4544            Self::Virtual(path) => {
4545                source_table.insert("virtual", Value::from(PortablePath::from(path).to_string()));
4546            }
4547        }
4548        table.insert("source", value(source_table));
4549    }
4550
4551    /// Check if a package is local by examining its source.
4552    fn is_local(&self) -> bool {
4553        matches!(
4554            self,
4555            Self::Path(_) | Self::Directory(_) | Self::Editable(_) | Self::Virtual(_)
4556        )
4557    }
4558}
4559
4560impl Display for Source {
4561    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
4562        match self {
4563            Self::Registry(RegistrySource::Url(url)) | Self::Git(url, _) | Self::Direct(url, _) => {
4564                write!(f, "{}+{}", self.name(), url)
4565            }
4566            Self::Registry(RegistrySource::Path(path))
4567            | Self::Path(path)
4568            | Self::Directory(path)
4569            | Self::Editable(path)
4570            | Self::Virtual(path) => {
4571                write!(f, "{}+{}", self.name(), PortablePath::from(path))
4572            }
4573        }
4574    }
4575}
4576
4577impl Source {
4578    fn name(&self) -> &str {
4579        match self {
4580            Self::Registry(..) => "registry",
4581            Self::Git(..) => "git",
4582            Self::Direct(..) => "direct",
4583            Self::Path(..) => "path",
4584            Self::Directory(..) => "directory",
4585            Self::Editable(..) => "editable",
4586            Self::Virtual(..) => "virtual",
4587        }
4588    }
4589
4590    /// Returns `Some(true)` to indicate that the source kind _must_ include a
4591    /// hash.
4592    ///
4593    /// Returns `Some(false)` to indicate that the source kind _must not_
4594    /// include a hash.
4595    ///
4596    /// Returns `None` to indicate that the source kind _may_ include a hash.
4597    fn requires_hash(&self) -> Option<bool> {
4598        match self {
4599            Self::Registry(..) => None,
4600            Self::Direct(..) | Self::Path(..) => Some(true),
4601            Self::Git(.., GitSource { path, .. }) => Some(path.is_some()),
4602            Self::Directory(..) | Self::Editable(..) | Self::Virtual(..) => Some(false),
4603        }
4604    }
4605}
4606
4607#[derive(Clone, Debug, serde::Deserialize)]
4608#[serde(untagged, rename_all = "kebab-case")]
4609enum SourceWire {
4610    Registry {
4611        registry: RegistrySourceWire,
4612    },
4613    Git {
4614        git: String,
4615    },
4616    Direct {
4617        url: UrlString,
4618        subdirectory: Option<PortablePathBuf>,
4619    },
4620    Path {
4621        path: PortablePathBuf,
4622    },
4623    Directory {
4624        directory: PortablePathBuf,
4625    },
4626    Editable {
4627        editable: PortablePathBuf,
4628    },
4629    Virtual {
4630        r#virtual: PortablePathBuf,
4631    },
4632}
4633
4634impl TryFrom<SourceWire> for Source {
4635    type Error = LockError;
4636
4637    fn try_from(wire: SourceWire) -> Result<Self, LockError> {
4638        use self::SourceWire::{Direct, Directory, Editable, Git, Path, Registry, Virtual};
4639
4640        match wire {
4641            Registry { registry } => Ok(Self::Registry(registry.into())),
4642            Git { git } => {
4643                let url = DisplaySafeUrl::parse(&git)
4644                    .map_err(|err| SourceParseError::InvalidUrl {
4645                        given: git.clone(),
4646                        err,
4647                    })
4648                    .map_err(LockErrorKind::InvalidGitSourceUrl)?;
4649
4650                let git_source = GitSource::from_url(&url)
4651                    .map_err(|err| match err {
4652                        GitSourceError::InvalidSha => SourceParseError::InvalidSha { given: git },
4653                        GitSourceError::MissingSha => SourceParseError::MissingSha { given: git },
4654                    })
4655                    .map_err(LockErrorKind::InvalidGitSourceUrl)?;
4656
4657                Ok(Self::Git(UrlString::from(url), git_source))
4658            }
4659            Direct { url, subdirectory } => Ok(Self::Direct(
4660                url,
4661                DirectSource {
4662                    subdirectory: subdirectory.map(Box::<std::path::Path>::from),
4663                },
4664            )),
4665            Path { path } => Ok(Self::Path(path.into())),
4666            Directory { directory } => Ok(Self::Directory(directory.into())),
4667            Editable { editable } => Ok(Self::Editable(editable.into())),
4668            Virtual { r#virtual } => Ok(Self::Virtual(r#virtual.into())),
4669        }
4670    }
4671}
4672
4673/// The source for a registry, which could be a URL or a relative path.
4674#[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)]
4675enum RegistrySource {
4676    /// Ex) `https://pypi.org/simple`
4677    Url(UrlString),
4678    /// Ex) `../path/to/local/index`
4679    Path(Box<Path>),
4680}
4681
4682impl Display for RegistrySource {
4683    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
4684        match self {
4685            Self::Url(url) => write!(f, "{url}"),
4686            Self::Path(path) => write!(f, "{}", path.display()),
4687        }
4688    }
4689}
4690
4691#[derive(Clone, Debug)]
4692enum RegistrySourceWire {
4693    /// Ex) `https://pypi.org/simple`
4694    Url(UrlString),
4695    /// Ex) `../path/to/local/index`
4696    Path(PortablePathBuf),
4697}
4698
4699impl<'de> serde::de::Deserialize<'de> for RegistrySourceWire {
4700    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
4701    where
4702        D: serde::de::Deserializer<'de>,
4703    {
4704        struct Visitor;
4705
4706        impl serde::de::Visitor<'_> for Visitor {
4707            type Value = RegistrySourceWire;
4708
4709            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
4710                formatter.write_str("a valid URL or a file path")
4711            }
4712
4713            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
4714            where
4715                E: serde::de::Error,
4716            {
4717                if split_scheme(value).is_some_and(|(scheme, _)| Scheme::parse(scheme).is_some()) {
4718                    Ok(
4719                        serde::Deserialize::deserialize(serde::de::value::StrDeserializer::new(
4720                            value,
4721                        ))
4722                        .map(RegistrySourceWire::Url)?,
4723                    )
4724                } else {
4725                    Ok(
4726                        serde::Deserialize::deserialize(serde::de::value::StrDeserializer::new(
4727                            value,
4728                        ))
4729                        .map(RegistrySourceWire::Path)?,
4730                    )
4731                }
4732            }
4733        }
4734
4735        deserializer.deserialize_str(Visitor)
4736    }
4737}
4738
4739impl From<RegistrySourceWire> for RegistrySource {
4740    fn from(wire: RegistrySourceWire) -> Self {
4741        match wire {
4742            RegistrySourceWire::Url(url) => Self::Url(url),
4743            RegistrySourceWire::Path(path) => Self::Path(path.into()),
4744        }
4745    }
4746}
4747
4748#[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord, serde::Deserialize)]
4749#[serde(rename_all = "kebab-case")]
4750struct DirectSource {
4751    subdirectory: Option<Box<Path>>,
4752}
4753
4754/// NOTE: Care should be taken when adding variants to this enum. Namely, new
4755/// variants should be added without changing the relative ordering of other
4756/// variants. Otherwise, this could cause the lockfile to have a different
4757/// canonical ordering of package entries.
4758#[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)]
4759struct GitSource {
4760    precise: GitOid,
4761    subdirectory: Option<Box<Path>>,
4762    path: Option<PathBuf>,
4763    kind: GitSourceKind,
4764    lfs: GitLfs,
4765}
4766
4767/// An error that occurs when a source string could not be parsed.
4768#[derive(Clone, Debug, Eq, PartialEq)]
4769enum GitSourceError {
4770    InvalidSha,
4771    MissingSha,
4772}
4773
4774impl GitSource {
4775    /// Extracts a Git source reference from the query pairs and the hash
4776    /// fragment in the given URL.
4777    fn from_url(url: &Url) -> Result<Self, GitSourceError> {
4778        let mut kind = GitSourceKind::DefaultBranch;
4779        let mut subdirectory = None;
4780        let mut lfs = GitLfs::Disabled;
4781        let mut path = None;
4782        for (key, val) in url.query_pairs() {
4783            match &*key {
4784                "tag" => kind = GitSourceKind::Tag(val.into_owned()),
4785                "branch" => kind = GitSourceKind::Branch(val.into_owned()),
4786                "rev" => kind = GitSourceKind::Rev(val.into_owned()),
4787                "subdirectory" => subdirectory = Some(PortablePathBuf::from(val.as_ref()).into()),
4788                "lfs" => lfs = GitLfs::from(val.eq_ignore_ascii_case("true")),
4789                "path" => {
4790                    path = Some(PathBuf::from(Box::<Path>::from(PortablePathBuf::from(
4791                        val.as_ref(),
4792                    ))));
4793                }
4794                _ => {}
4795            }
4796        }
4797
4798        let precise = GitOid::from_str(url.fragment().ok_or(GitSourceError::MissingSha)?)
4799            .map_err(|_| GitSourceError::InvalidSha)?;
4800
4801        Ok(Self {
4802            precise,
4803            subdirectory,
4804            path,
4805            kind,
4806            lfs,
4807        })
4808    }
4809}
4810
4811#[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord, serde::Deserialize)]
4812#[serde(rename_all = "kebab-case")]
4813enum GitSourceKind {
4814    Tag(String),
4815    Branch(String),
4816    Rev(String),
4817    DefaultBranch,
4818}
4819
4820/// Inspired by: <https://discuss.python.org/t/lock-files-again-but-this-time-w-sdists/46593>
4821#[derive(Clone, Debug, serde::Deserialize, PartialEq, Eq)]
4822#[serde(rename_all = "kebab-case")]
4823struct SourceDistMetadata {
4824    /// A hash of the source distribution.
4825    hash: Option<Hash>,
4826    /// The size of the source distribution in bytes.
4827    ///
4828    /// This is only present for source distributions that come from registries.
4829    size: Option<u64>,
4830    /// The upload time of the source distribution.
4831    #[serde(alias = "upload_time")]
4832    upload_time: Option<Timestamp>,
4833}
4834
4835/// A URL or file path where the source dist that was
4836/// locked against was found. The location does not need to exist in the
4837/// future, so this should be treated as only a hint to where to look
4838/// and/or recording where the source dist file originally came from.
4839#[derive(Clone, Debug, serde::Deserialize, PartialEq, Eq)]
4840#[serde(from = "SourceDistWire")]
4841enum SourceDist {
4842    Url {
4843        url: UrlString,
4844        #[serde(flatten)]
4845        metadata: SourceDistMetadata,
4846    },
4847    Path {
4848        path: Box<Path>,
4849        #[serde(flatten)]
4850        metadata: SourceDistMetadata,
4851    },
4852    Metadata {
4853        #[serde(flatten)]
4854        metadata: SourceDistMetadata,
4855    },
4856}
4857
4858impl SourceDist {
4859    fn filename(&self) -> Option<Cow<'_, str>> {
4860        match self {
4861            Self::Metadata { .. } => None,
4862            Self::Url { url, .. } => url.filename().ok(),
4863            Self::Path { path, .. } => path.file_name().map(|filename| filename.to_string_lossy()),
4864        }
4865    }
4866
4867    fn url(&self) -> Option<&UrlString> {
4868        match self {
4869            Self::Metadata { .. } => None,
4870            Self::Url { url, .. } => Some(url),
4871            Self::Path { .. } => None,
4872        }
4873    }
4874
4875    fn hash(&self) -> Option<&Hash> {
4876        match self {
4877            Self::Metadata { metadata } => metadata.hash.as_ref(),
4878            Self::Url { metadata, .. } => metadata.hash.as_ref(),
4879            Self::Path { metadata, .. } => metadata.hash.as_ref(),
4880        }
4881    }
4882
4883    fn size(&self) -> Option<u64> {
4884        match self {
4885            Self::Metadata { metadata } => metadata.size,
4886            Self::Url { metadata, .. } => metadata.size,
4887            Self::Path { metadata, .. } => metadata.size,
4888        }
4889    }
4890
4891    fn upload_time(&self) -> Option<Timestamp> {
4892        match self {
4893            Self::Metadata { metadata } => metadata.upload_time,
4894            Self::Url { metadata, .. } => metadata.upload_time,
4895            Self::Path { metadata, .. } => metadata.upload_time,
4896        }
4897    }
4898}
4899
4900impl SourceDist {
4901    fn from_annotated_dist(
4902        id: &PackageId,
4903        annotated_dist: &AnnotatedDist,
4904    ) -> Result<Option<Self>, LockError> {
4905        match annotated_dist.dist {
4906            // We pass empty installed packages for locking.
4907            ResolvedDist::Installed { .. } => unreachable!(),
4908            ResolvedDist::Installable { ref dist, .. } => Self::from_dist(
4909                id,
4910                dist,
4911                annotated_dist.hashes.as_slice(),
4912                annotated_dist.index(),
4913            ),
4914        }
4915    }
4916
4917    fn from_dist(
4918        id: &PackageId,
4919        dist: &Dist,
4920        hashes: &[HashDigest],
4921        index: Option<&IndexUrl>,
4922    ) -> Result<Option<Self>, LockError> {
4923        match *dist {
4924            Dist::Built(BuiltDist::Registry(ref built_dist)) => {
4925                let Some(sdist) = built_dist.sdist.as_ref() else {
4926                    return Ok(None);
4927                };
4928                Self::from_registry_dist(sdist, index)
4929            }
4930            Dist::Built(_) => Ok(None),
4931            Dist::Source(ref source_dist) => Self::from_source_dist(id, source_dist, hashes, index),
4932        }
4933    }
4934
4935    fn from_source_dist(
4936        id: &PackageId,
4937        source_dist: &uv_distribution_types::SourceDist,
4938        hashes: &[HashDigest],
4939        index: Option<&IndexUrl>,
4940    ) -> Result<Option<Self>, LockError> {
4941        match *source_dist {
4942            uv_distribution_types::SourceDist::Registry(ref reg_dist) => {
4943                Self::from_registry_dist(reg_dist, index)
4944            }
4945            uv_distribution_types::SourceDist::DirectUrl(_) => {
4946                Self::from_direct_dist(id, hashes).map(Some)
4947            }
4948            uv_distribution_types::SourceDist::Path(_) => {
4949                Self::from_path_dist(id, hashes).map(Some)
4950            }
4951            uv_distribution_types::SourceDist::GitPath(_) => {
4952                Self::from_git_path_dist(id, hashes).map(Some)
4953            }
4954            uv_distribution_types::SourceDist::GitDirectory(_)
4955            | uv_distribution_types::SourceDist::Directory(_) => Ok(None),
4956        }
4957    }
4958
4959    fn from_registry_dist(
4960        reg_dist: &RegistrySourceDist,
4961        index: Option<&IndexUrl>,
4962    ) -> Result<Option<Self>, LockError> {
4963        // Reject distributions from registries that don't match the index URL, as can occur with
4964        // `--find-links`.
4965        if index.is_none_or(|index| *index != reg_dist.index) {
4966            return Ok(None);
4967        }
4968
4969        match &reg_dist.index {
4970            IndexUrl::Pypi(_) | IndexUrl::Url(_) => {
4971                let url = normalize_file_location(&reg_dist.file.url)
4972                    .map_err(LockErrorKind::InvalidUrl)
4973                    .map_err(LockError::from)?;
4974                let hash = reg_dist.file.hashes.iter().max().cloned().map(Hash::from);
4975                let size = reg_dist.file.size;
4976                let upload_time = reg_dist
4977                    .file
4978                    .upload_time_utc_ms
4979                    .map(Timestamp::from_millisecond)
4980                    .transpose()
4981                    .map_err(LockErrorKind::InvalidTimestamp)?;
4982                Ok(Some(Self::Url {
4983                    url,
4984                    metadata: SourceDistMetadata {
4985                        hash,
4986                        size,
4987                        upload_time,
4988                    },
4989                }))
4990            }
4991            IndexUrl::Path(path) => {
4992                let index_path = path
4993                    .to_file_path()
4994                    .map_err(|()| LockErrorKind::UrlToPath { url: path.to_url() })?;
4995                let url = reg_dist
4996                    .file
4997                    .url
4998                    .to_url()
4999                    .map_err(LockErrorKind::InvalidUrl)?;
5000
5001                if url.scheme() == "file" {
5002                    let reg_dist_path = url
5003                        .to_file_path()
5004                        .map_err(|()| LockErrorKind::UrlToPath { url })?;
5005                    let path =
5006                        try_relative_to_if(&reg_dist_path, index_path, !path.was_given_absolute())
5007                            .map_err(LockErrorKind::DistributionRelativePath)?
5008                            .into_boxed_path();
5009                    let hash = reg_dist.file.hashes.iter().max().cloned().map(Hash::from);
5010                    let size = reg_dist.file.size;
5011                    let upload_time = reg_dist
5012                        .file
5013                        .upload_time_utc_ms
5014                        .map(Timestamp::from_millisecond)
5015                        .transpose()
5016                        .map_err(LockErrorKind::InvalidTimestamp)?;
5017                    Ok(Some(Self::Path {
5018                        path,
5019                        metadata: SourceDistMetadata {
5020                            hash,
5021                            size,
5022                            upload_time,
5023                        },
5024                    }))
5025                } else {
5026                    let url = normalize_file_location(&reg_dist.file.url)
5027                        .map_err(LockErrorKind::InvalidUrl)
5028                        .map_err(LockError::from)?;
5029                    let hash = reg_dist.file.hashes.iter().max().cloned().map(Hash::from);
5030                    let size = reg_dist.file.size;
5031                    let upload_time = reg_dist
5032                        .file
5033                        .upload_time_utc_ms
5034                        .map(Timestamp::from_millisecond)
5035                        .transpose()
5036                        .map_err(LockErrorKind::InvalidTimestamp)?;
5037                    Ok(Some(Self::Url {
5038                        url,
5039                        metadata: SourceDistMetadata {
5040                            hash,
5041                            size,
5042                            upload_time,
5043                        },
5044                    }))
5045                }
5046            }
5047        }
5048    }
5049
5050    fn from_direct_dist(id: &PackageId, hashes: &[HashDigest]) -> Result<Self, LockError> {
5051        let Some(hash) = hashes.iter().max().cloned().map(Hash::from) else {
5052            let kind = LockErrorKind::Hash {
5053                id: id.clone(),
5054                artifact_type: "direct URL source distribution",
5055                expected: true,
5056            };
5057            return Err(kind.into());
5058        };
5059        Ok(Self::Metadata {
5060            metadata: SourceDistMetadata {
5061                hash: Some(hash),
5062                size: None,
5063                upload_time: None,
5064            },
5065        })
5066    }
5067
5068    fn from_path_dist(id: &PackageId, hashes: &[HashDigest]) -> Result<Self, LockError> {
5069        let Some(hash) = hashes.iter().max().cloned().map(Hash::from) else {
5070            let kind = LockErrorKind::Hash {
5071                id: id.clone(),
5072                artifact_type: "path source distribution",
5073                expected: true,
5074            };
5075            return Err(kind.into());
5076        };
5077        Ok(Self::Metadata {
5078            metadata: SourceDistMetadata {
5079                hash: Some(hash),
5080                size: None,
5081                upload_time: None,
5082            },
5083        })
5084    }
5085
5086    fn from_git_path_dist(id: &PackageId, hashes: &[HashDigest]) -> Result<Self, LockError> {
5087        let Some(hash) = hashes.iter().max().cloned().map(Hash::from) else {
5088            let kind = LockErrorKind::Hash {
5089                id: id.clone(),
5090                artifact_type: "Git archive source distribution",
5091                expected: true,
5092            };
5093            return Err(kind.into());
5094        };
5095        Ok(Self::Metadata {
5096            metadata: SourceDistMetadata {
5097                hash: Some(hash),
5098                size: None,
5099                upload_time: None,
5100            },
5101        })
5102    }
5103}
5104
5105#[derive(Clone, Debug, serde::Deserialize)]
5106#[serde(untagged, rename_all = "kebab-case")]
5107enum SourceDistWire {
5108    Url {
5109        url: UrlString,
5110        #[serde(flatten)]
5111        metadata: SourceDistMetadata,
5112    },
5113    Path {
5114        path: PortablePathBuf,
5115        #[serde(flatten)]
5116        metadata: SourceDistMetadata,
5117    },
5118    Metadata {
5119        #[serde(flatten)]
5120        metadata: SourceDistMetadata,
5121    },
5122}
5123
5124impl SourceDist {
5125    /// Returns the TOML representation of this source distribution.
5126    fn to_toml(&self) -> Result<InlineTable, toml_edit::ser::Error> {
5127        let mut table = InlineTable::new();
5128        match self {
5129            Self::Metadata { .. } => {}
5130            Self::Url { url, .. } => {
5131                table.insert("url", Value::from(url.as_ref()));
5132            }
5133            Self::Path { path, .. } => {
5134                table.insert("path", Value::from(PortablePath::from(path).to_string()));
5135            }
5136        }
5137        if let Some(hash) = self.hash() {
5138            table.insert("hash", Value::from(hash.to_string()));
5139        }
5140        if let Some(size) = self.size() {
5141            table.insert(
5142                "size",
5143                toml_edit::ser::ValueSerializer::new().serialize_u64(size)?,
5144            );
5145        }
5146        if let Some(upload_time) = self.upload_time() {
5147            table.insert("upload-time", Value::from(upload_time.to_string()));
5148        }
5149        Ok(table)
5150    }
5151}
5152
5153impl From<SourceDistWire> for SourceDist {
5154    fn from(wire: SourceDistWire) -> Self {
5155        match wire {
5156            SourceDistWire::Url { url, metadata } => Self::Url { url, metadata },
5157            SourceDistWire::Path { path, metadata } => Self::Path {
5158                path: path.into(),
5159                metadata,
5160            },
5161            SourceDistWire::Metadata { metadata } => Self::Metadata { metadata },
5162        }
5163    }
5164}
5165
5166impl From<GitReference> for GitSourceKind {
5167    fn from(value: GitReference) -> Self {
5168        match value {
5169            GitReference::Branch(branch) => Self::Branch(branch),
5170            GitReference::Tag(tag) => Self::Tag(tag),
5171            GitReference::BranchOrTag(rev) => Self::Rev(rev),
5172            GitReference::BranchOrTagOrCommit(rev) => Self::Rev(rev),
5173            GitReference::NamedRef(rev) => Self::Rev(rev),
5174            GitReference::DefaultBranch => Self::DefaultBranch,
5175        }
5176    }
5177}
5178
5179impl From<GitSourceKind> for GitReference {
5180    fn from(value: GitSourceKind) -> Self {
5181        match value {
5182            GitSourceKind::Branch(branch) => Self::Branch(branch),
5183            GitSourceKind::Tag(tag) => Self::Tag(tag),
5184            GitSourceKind::Rev(rev) => Self::from_rev(rev),
5185            GitSourceKind::DefaultBranch => Self::DefaultBranch,
5186        }
5187    }
5188}
5189
5190/// Construct the lockfile-compatible [`DisplaySafeUrl`] for a [`GitUrl`].
5191fn locked_git_url(
5192    git: &GitUrl,
5193    subdirectory: Option<&Path>,
5194    path: Option<&Path>,
5195) -> DisplaySafeUrl {
5196    let mut url = git.url().clone();
5197
5198    // Remove the credentials.
5199    url.remove_credentials();
5200
5201    // Clear out any existing state.
5202    url.set_fragment(None);
5203    url.set_query(None);
5204
5205    // Put the subdirectory in the query.
5206    if let Some(subdirectory) = subdirectory
5207        .map(PortablePath::from)
5208        .as_ref()
5209        .map(PortablePath::to_string)
5210    {
5211        url.query_pairs_mut()
5212            .append_pair("subdirectory", &subdirectory);
5213    }
5214
5215    // Put the path in the query.
5216    if let Some(path) = path
5217        .map(PortablePath::from)
5218        .as_ref()
5219        .map(PortablePath::to_string)
5220    {
5221        url.query_pairs_mut().append_pair("path", &path);
5222    }
5223
5224    // Put lfs=true in the package source git url only when explicitly enabled.
5225    if git.lfs().enabled() {
5226        url.query_pairs_mut().append_pair("lfs", "true");
5227    }
5228
5229    // Put the requested reference in the query.
5230    match git.reference() {
5231        GitReference::Branch(branch) => {
5232            url.query_pairs_mut().append_pair("branch", branch.as_str());
5233        }
5234        GitReference::Tag(tag) => {
5235            url.query_pairs_mut().append_pair("tag", tag.as_str());
5236        }
5237        GitReference::BranchOrTag(rev)
5238        | GitReference::BranchOrTagOrCommit(rev)
5239        | GitReference::NamedRef(rev) => {
5240            url.query_pairs_mut().append_pair("rev", rev.as_str());
5241        }
5242        GitReference::DefaultBranch => {}
5243    }
5244
5245    // Put the precise commit in the fragment.
5246    url.set_fragment(git.precise().as_ref().map(GitOid::to_string).as_deref());
5247
5248    url
5249}
5250
5251#[derive(Clone, Debug, serde::Deserialize, PartialEq, Eq)]
5252struct ZstdWheel {
5253    hash: Option<Hash>,
5254    size: Option<u64>,
5255}
5256
5257/// Inspired by: <https://discuss.python.org/t/lock-files-again-but-this-time-w-sdists/46593>
5258#[derive(Clone, Debug, serde::Deserialize, PartialEq, Eq)]
5259#[serde(try_from = "WheelWire")]
5260struct Wheel {
5261    /// A URL or file path (via `file://`) where the wheel that was locked
5262    /// against was found. The location does not need to exist in the future,
5263    /// so this should be treated as only a hint to where to look and/or
5264    /// recording where the wheel file originally came from.
5265    url: WheelWireSource,
5266    /// A hash of the built distribution.
5267    ///
5268    /// This is only present for wheels that come from registries and direct
5269    /// URLs. Wheels from git or path dependencies do not have hashes
5270    /// associated with them.
5271    hash: Option<Hash>,
5272    /// The size of the built distribution in bytes.
5273    ///
5274    /// This is only present for wheels that come from registries.
5275    size: Option<u64>,
5276    /// The upload time of the built distribution.
5277    ///
5278    /// This is only present for wheels that come from registries.
5279    upload_time: Option<Timestamp>,
5280    /// The filename of the wheel.
5281    ///
5282    /// This isn't part of the wire format since it's redundant with the
5283    /// URL. But we do use it for various things, and thus compute it at
5284    /// deserialization time. Not being able to extract a wheel filename from a
5285    /// wheel URL is thus a deserialization error.
5286    filename: WheelFilename,
5287    /// The zstandard-compressed wheel metadata, if any.
5288    zstd: Option<ZstdWheel>,
5289}
5290
5291impl Wheel {
5292    fn from_annotated_dist(annotated_dist: &AnnotatedDist) -> Result<Vec<Self>, LockError> {
5293        match annotated_dist.dist {
5294            // We pass empty installed packages for locking.
5295            ResolvedDist::Installed { .. } => unreachable!(),
5296            ResolvedDist::Installable { ref dist, .. } => Self::from_dist(
5297                dist,
5298                annotated_dist.hashes.as_slice(),
5299                annotated_dist.index(),
5300            ),
5301        }
5302    }
5303
5304    fn from_dist(
5305        dist: &Dist,
5306        hashes: &[HashDigest],
5307        index: Option<&IndexUrl>,
5308    ) -> Result<Vec<Self>, LockError> {
5309        match *dist {
5310            Dist::Built(ref built_dist) => Self::from_built_dist(built_dist, hashes, index),
5311            Dist::Source(uv_distribution_types::SourceDist::Registry(ref source_dist)) => {
5312                source_dist
5313                    .wheels
5314                    .iter()
5315                    .filter(|wheel| {
5316                        // Reject distributions from registries that don't match the index URL, as can occur with
5317                        // `--find-links`.
5318                        index.is_some_and(|index| *index == wheel.index)
5319                    })
5320                    .map(Self::from_registry_wheel)
5321                    .collect()
5322            }
5323            Dist::Source(_) => Ok(vec![]),
5324        }
5325    }
5326
5327    fn from_built_dist(
5328        built_dist: &BuiltDist,
5329        hashes: &[HashDigest],
5330        index: Option<&IndexUrl>,
5331    ) -> Result<Vec<Self>, LockError> {
5332        match *built_dist {
5333            BuiltDist::Registry(ref reg_dist) => Self::from_registry_dist(reg_dist, index),
5334            BuiltDist::DirectUrl(ref direct_dist) => {
5335                Ok(vec![Self::from_direct_dist(direct_dist, hashes)])
5336            }
5337            BuiltDist::Path(ref path_dist) => Ok(vec![Self::from_path_dist(path_dist, hashes)]),
5338            BuiltDist::GitPath(ref git_dist) => {
5339                Ok(vec![Self::from_git_path_dist(git_dist, hashes)])
5340            }
5341        }
5342    }
5343
5344    fn from_registry_dist(
5345        reg_dist: &RegistryBuiltDist,
5346        index: Option<&IndexUrl>,
5347    ) -> Result<Vec<Self>, LockError> {
5348        reg_dist
5349            .wheels
5350            .iter()
5351            .filter(|wheel| {
5352                // Reject distributions from registries that don't match the index URL, as can occur with
5353                // `--find-links`.
5354                index.is_some_and(|index| *index == wheel.index)
5355            })
5356            .map(Self::from_registry_wheel)
5357            .collect()
5358    }
5359
5360    fn from_registry_wheel(wheel: &RegistryBuiltWheel) -> Result<Self, LockError> {
5361        let url = match &wheel.index {
5362            IndexUrl::Pypi(_) | IndexUrl::Url(_) => {
5363                let url = normalize_file_location(&wheel.file.url)
5364                    .map_err(LockErrorKind::InvalidUrl)
5365                    .map_err(LockError::from)?;
5366                WheelWireSource::Url { url }
5367            }
5368            IndexUrl::Path(path) => {
5369                let index_path = path
5370                    .to_file_path()
5371                    .map_err(|()| LockErrorKind::UrlToPath { url: path.to_url() })?;
5372                let wheel_url = wheel.file.url.to_url().map_err(LockErrorKind::InvalidUrl)?;
5373
5374                if wheel_url.scheme() == "file" {
5375                    let wheel_path = wheel_url
5376                        .to_file_path()
5377                        .map_err(|()| LockErrorKind::UrlToPath { url: wheel_url })?;
5378                    let path =
5379                        try_relative_to_if(&wheel_path, index_path, !path.was_given_absolute())
5380                            .map_err(LockErrorKind::DistributionRelativePath)?
5381                            .into_boxed_path();
5382                    WheelWireSource::Path { path }
5383                } else {
5384                    let url = normalize_file_location(&wheel.file.url)
5385                        .map_err(LockErrorKind::InvalidUrl)
5386                        .map_err(LockError::from)?;
5387                    WheelWireSource::Url { url }
5388                }
5389            }
5390        };
5391        let filename = wheel.filename.clone();
5392        let hash = wheel.file.hashes.iter().max().cloned().map(Hash::from);
5393        let size = wheel.file.size;
5394        let upload_time = wheel
5395            .file
5396            .upload_time_utc_ms
5397            .map(Timestamp::from_millisecond)
5398            .transpose()
5399            .map_err(LockErrorKind::InvalidTimestamp)?;
5400        let zstd = wheel.file.zstd.as_ref().map(|zstd| ZstdWheel {
5401            hash: zstd.hashes.iter().max().cloned().map(Hash::from),
5402            size: zstd.size,
5403        });
5404        Ok(Self {
5405            url,
5406            hash,
5407            size,
5408            upload_time,
5409            filename,
5410            zstd,
5411        })
5412    }
5413
5414    fn from_direct_dist(direct_dist: &DirectUrlBuiltDist, hashes: &[HashDigest]) -> Self {
5415        Self {
5416            url: WheelWireSource::Url {
5417                url: normalize_url(direct_dist.url.to_url()),
5418            },
5419            hash: hashes.iter().max().cloned().map(Hash::from),
5420            size: None,
5421            upload_time: None,
5422            filename: direct_dist.filename.clone(),
5423            zstd: None,
5424        }
5425    }
5426
5427    fn from_path_dist(path_dist: &PathBuiltDist, hashes: &[HashDigest]) -> Self {
5428        Self {
5429            url: WheelWireSource::Filename {
5430                filename: path_dist.filename.clone(),
5431            },
5432            hash: hashes.iter().max().cloned().map(Hash::from),
5433            size: None,
5434            upload_time: None,
5435            filename: path_dist.filename.clone(),
5436            zstd: None,
5437        }
5438    }
5439
5440    fn from_git_path_dist(path_dist: &GitPathBuiltDist, hashes: &[HashDigest]) -> Self {
5441        Self {
5442            url: WheelWireSource::Filename {
5443                filename: path_dist.filename.clone(),
5444            },
5445            hash: hashes.iter().max().cloned().map(Hash::from),
5446            size: None,
5447            upload_time: None,
5448            filename: path_dist.filename.clone(),
5449            zstd: None,
5450        }
5451    }
5452
5453    fn to_registry_wheel(
5454        &self,
5455        source: &RegistrySource,
5456        root: &Path,
5457    ) -> Result<RegistryBuiltWheel, LockError> {
5458        let filename: WheelFilename = self.filename.clone();
5459
5460        match source {
5461            RegistrySource::Url(url) => {
5462                let file_location = match &self.url {
5463                    WheelWireSource::Url { url: file_url } => {
5464                        FileLocation::AbsoluteUrl(file_url.clone())
5465                    }
5466                    WheelWireSource::Path { .. } | WheelWireSource::Filename { .. } => {
5467                        return Err(LockErrorKind::MissingUrl {
5468                            name: filename.name,
5469                            version: filename.version,
5470                        }
5471                        .into());
5472                    }
5473                };
5474                let file = Box::new(uv_distribution_types::File {
5475                    dist_info_metadata: false,
5476                    filename: SmallString::from(filename.to_string()),
5477                    hashes: self.hash.iter().map(|h| h.0.clone()).collect(),
5478                    requires_python: None,
5479                    size: self.size,
5480                    upload_time_utc_ms: self.upload_time.map(Timestamp::as_millisecond),
5481                    url: file_location,
5482                    yanked: None,
5483                    zstd: self
5484                        .zstd
5485                        .as_ref()
5486                        .map(|zstd| uv_distribution_types::Zstd {
5487                            hashes: zstd.hash.iter().map(|h| h.0.clone()).collect(),
5488                            size: zstd.size,
5489                        })
5490                        .map(Box::new),
5491                });
5492                let index = IndexUrl::from(VerbatimUrl::from_url(
5493                    url.to_url().map_err(LockErrorKind::InvalidUrl)?,
5494                ));
5495                Ok(RegistryBuiltWheel {
5496                    filename,
5497                    file,
5498                    index,
5499                })
5500            }
5501            RegistrySource::Path(index_path) => {
5502                let file_location = match &self.url {
5503                    WheelWireSource::Url { url: file_url } => {
5504                        FileLocation::AbsoluteUrl(file_url.clone())
5505                    }
5506                    WheelWireSource::Path { path: file_path } => {
5507                        let file_path = root.join(index_path).join(file_path);
5508                        let file_url =
5509                            DisplaySafeUrl::from_file_path(&file_path).map_err(|()| {
5510                                LockErrorKind::PathToUrl {
5511                                    path: file_path.into_boxed_path(),
5512                                }
5513                            })?;
5514                        FileLocation::AbsoluteUrl(UrlString::from(file_url))
5515                    }
5516                    WheelWireSource::Filename { .. } => {
5517                        return Err(LockErrorKind::MissingPath {
5518                            name: filename.name,
5519                            version: filename.version,
5520                        }
5521                        .into());
5522                    }
5523                };
5524                let file = Box::new(uv_distribution_types::File {
5525                    dist_info_metadata: false,
5526                    filename: SmallString::from(filename.to_string()),
5527                    hashes: self.hash.iter().map(|h| h.0.clone()).collect(),
5528                    requires_python: None,
5529                    size: self.size,
5530                    upload_time_utc_ms: self.upload_time.map(Timestamp::as_millisecond),
5531                    url: file_location,
5532                    yanked: None,
5533                    zstd: self
5534                        .zstd
5535                        .as_ref()
5536                        .map(|zstd| uv_distribution_types::Zstd {
5537                            hashes: zstd.hash.iter().map(|h| h.0.clone()).collect(),
5538                            size: zstd.size,
5539                        })
5540                        .map(Box::new),
5541                });
5542                let index = IndexUrl::from(
5543                    VerbatimUrl::from_absolute_path(root.join(index_path))
5544                        .map_err(LockErrorKind::RegistryVerbatimUrl)?,
5545                );
5546                Ok(RegistryBuiltWheel {
5547                    filename,
5548                    file,
5549                    index,
5550                })
5551            }
5552        }
5553    }
5554}
5555
5556#[derive(Clone, Debug, serde::Deserialize)]
5557#[serde(rename_all = "kebab-case")]
5558struct WheelWire {
5559    #[serde(flatten)]
5560    url: WheelWireSource,
5561    /// A hash of the built distribution.
5562    ///
5563    /// This is only present for wheels that come from registries and direct
5564    /// URLs. Wheels from git or path dependencies do not have hashes
5565    /// associated with them.
5566    hash: Option<Hash>,
5567    /// The size of the built distribution in bytes.
5568    ///
5569    /// This is only present for wheels that come from registries.
5570    size: Option<u64>,
5571    /// The upload time of the built distribution.
5572    ///
5573    /// This is only present for wheels that come from registries.
5574    #[serde(alias = "upload_time")]
5575    upload_time: Option<Timestamp>,
5576    /// The zstandard-compressed wheel metadata, if any.
5577    #[serde(alias = "zstd")]
5578    zstd: Option<ZstdWheel>,
5579}
5580
5581#[derive(Clone, Debug, serde::Deserialize, PartialEq, Eq)]
5582#[serde(untagged, rename_all = "kebab-case")]
5583enum WheelWireSource {
5584    /// Used for all wheels that come from remote sources.
5585    Url {
5586        /// A URL where the wheel that was locked against was found. The location
5587        /// does not need to exist in the future, so this should be treated as
5588        /// only a hint to where to look and/or recording where the wheel file
5589        /// originally came from.
5590        url: UrlString,
5591    },
5592    /// Used for wheels that come from local registries (like `--find-links`).
5593    Path {
5594        /// The path to the wheel, relative to the index.
5595        path: Box<Path>,
5596    },
5597    /// Used for path wheels.
5598    ///
5599    /// We only store the filename for path wheel, since we can't store a relative path in the url
5600    Filename {
5601        /// We duplicate the filename since a lot of code relies on having the filename on the
5602        /// wheel entry.
5603        filename: WheelFilename,
5604    },
5605}
5606
5607impl Wheel {
5608    /// Returns the TOML representation of this wheel.
5609    fn to_toml(&self) -> Result<InlineTable, toml_edit::ser::Error> {
5610        let mut table = InlineTable::new();
5611        match &self.url {
5612            WheelWireSource::Url { url } => {
5613                table.insert("url", Value::from(url.as_ref()));
5614            }
5615            WheelWireSource::Path { path } => {
5616                table.insert("path", Value::from(PortablePath::from(path).to_string()));
5617            }
5618            WheelWireSource::Filename { filename } => {
5619                table.insert("filename", Value::from(filename.to_string()));
5620            }
5621        }
5622        if let Some(ref hash) = self.hash {
5623            table.insert("hash", Value::from(hash.to_string()));
5624        }
5625        if let Some(size) = self.size {
5626            table.insert(
5627                "size",
5628                toml_edit::ser::ValueSerializer::new().serialize_u64(size)?,
5629            );
5630        }
5631        if let Some(upload_time) = self.upload_time {
5632            table.insert("upload-time", Value::from(upload_time.to_string()));
5633        }
5634        if let Some(zstd) = &self.zstd {
5635            let mut inner = InlineTable::new();
5636            if let Some(ref hash) = zstd.hash {
5637                inner.insert("hash", Value::from(hash.to_string()));
5638            }
5639            if let Some(size) = zstd.size {
5640                inner.insert(
5641                    "size",
5642                    toml_edit::ser::ValueSerializer::new().serialize_u64(size)?,
5643                );
5644            }
5645            table.insert("zstd", Value::from(inner));
5646        }
5647        Ok(table)
5648    }
5649}
5650
5651impl TryFrom<WheelWire> for Wheel {
5652    type Error = String;
5653
5654    fn try_from(wire: WheelWire) -> Result<Self, String> {
5655        let filename = match &wire.url {
5656            WheelWireSource::Url { url } => {
5657                let filename = url.filename().map_err(|err| err.to_string())?;
5658                filename.parse::<WheelFilename>().map_err(|err| {
5659                    format!("failed to parse `{filename}` as wheel filename: {err}")
5660                })?
5661            }
5662            WheelWireSource::Path { path } => {
5663                let filename = path
5664                    .file_name()
5665                    .and_then(|file_name| file_name.to_str())
5666                    .ok_or_else(|| {
5667                        format!("path `{}` has no filename component", path.display())
5668                    })?;
5669                filename.parse::<WheelFilename>().map_err(|err| {
5670                    format!("failed to parse `{filename}` as wheel filename: {err}")
5671                })?
5672            }
5673            WheelWireSource::Filename { filename } => filename.clone(),
5674        };
5675
5676        Ok(Self {
5677            url: wire.url,
5678            hash: wire.hash,
5679            size: wire.size,
5680            upload_time: wire.upload_time,
5681            zstd: wire.zstd,
5682            filename,
5683        })
5684    }
5685}
5686
5687/// A single dependency of a package in a lockfile.
5688#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
5689pub struct Dependency {
5690    package_id: PackageId,
5691    extra: BTreeSet<ExtraName>,
5692    /// A marker simplified from the PEP 508 marker in `complexified_marker`
5693    /// by assuming `requires-python` and the PEP 508 portion of the parent package's reachability
5694    /// marker are satisfied. The parent's conflict predicates are retained for compatibility with
5695    /// older lockfile readers. So if
5696    /// `requires-python = '>=3.8'`, then
5697    /// `python_version >= '3.8' and python_version < '3.12'`
5698    /// gets simplified to `python_version < '3.12'`.
5699    ///
5700    /// Generally speaking, this marker should not be exposed to anything outside this module
5701    /// unless it's for a specialized use case. But specifically, it should never be used to
5702    /// evaluate against a marker environment or for disjointness checks or any other kind of
5703    /// marker algebra. It is only meaningful while traversing from its parent package.
5704    ///
5705    /// It exists because there are some cases where we do actually
5706    /// want to compare markers in their "simplified" form. For
5707    /// example, when collapsing the extras on duplicate dependencies.
5708    /// Even if a dependency has different complexified markers,
5709    /// they might have identical markers once simplified. And since
5710    /// `requires-python` applies to the entire lock file, it's
5711    /// acceptable to do comparisons on the simplified form.
5712    simplified_marker: SimplifiedMarkerTree,
5713    /// The "complexified" marker is independent of `requires-python`, but remains contextual to
5714    /// the PEP 508 reachability of its parent package. It can be evaluated while traversing
5715    /// dependencies from that package.
5716    complexified_marker: UniversalMarker,
5717}
5718
5719impl Dependency {
5720    fn new(
5721        requires_python: &RequiresPython,
5722        package_id: PackageId,
5723        extra: BTreeSet<ExtraName>,
5724        complexified_marker: UniversalMarker,
5725    ) -> Self {
5726        let simplified_marker =
5727            SimplifiedMarkerTree::new(requires_python, complexified_marker.combined());
5728        let complexified_marker = simplified_marker.into_marker(requires_python);
5729        Self {
5730            package_id,
5731            extra,
5732            simplified_marker,
5733            complexified_marker: UniversalMarker::from_combined(complexified_marker),
5734        }
5735    }
5736
5737    fn from_annotated_dist(
5738        requires_python: &RequiresPython,
5739        annotated_dist: &AnnotatedDist,
5740        complexified_marker: UniversalMarker,
5741        root: &Path,
5742    ) -> Result<Self, LockError> {
5743        let package_id = PackageId::from_annotated_dist(annotated_dist, root)?;
5744        let extra = annotated_dist.extra.iter().cloned().collect();
5745        Ok(Self::new(
5746            requires_python,
5747            package_id,
5748            extra,
5749            complexified_marker,
5750        ))
5751    }
5752
5753    /// Returns the TOML representation of this dependency.
5754    fn to_toml(
5755        &self,
5756        simplified_environment: MarkerTree,
5757        dist_count_by_name: &FxHashMap<PackageName, u64>,
5758    ) -> Table {
5759        let mut table = Table::new();
5760        self.package_id
5761            .to_toml(Some(dist_count_by_name), &mut table);
5762        if !self.extra.is_empty() {
5763            let extra_array = self
5764                .extra
5765                .iter()
5766                .map(ToString::to_string)
5767                .collect::<Array>();
5768            table.insert("extra", value(extra_array));
5769        }
5770        // Avoid restating the resolution's environment on every dependency edge.
5771        if let Some(marker) = self
5772            .simplified_marker
5773            .as_simplified_marker_tree()
5774            .restrict(simplified_environment)
5775            .try_to_string()
5776        {
5777            table.insert("marker", value(marker));
5778        }
5779
5780        table
5781    }
5782
5783    /// Returns the package name of this dependency.
5784    pub fn package_name(&self) -> &PackageName {
5785        &self.package_id.name
5786    }
5787
5788    /// Returns the extras specified on this dependency.
5789    pub fn extra(&self) -> &BTreeSet<ExtraName> {
5790        &self.extra
5791    }
5792}
5793
5794impl Display for Dependency {
5795    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
5796        match (self.extra.is_empty(), self.package_id.version.as_ref()) {
5797            (true, Some(version)) => write!(f, "{}=={}", self.package_id.name, version),
5798            (true, None) => write!(f, "{}", self.package_id.name),
5799            (false, Some(version)) => write!(
5800                f,
5801                "{}[{}]=={}",
5802                self.package_id.name,
5803                self.extra.iter().join(","),
5804                version
5805            ),
5806            (false, None) => write!(
5807                f,
5808                "{}[{}]",
5809                self.package_id.name,
5810                self.extra.iter().join(",")
5811            ),
5812        }
5813    }
5814}
5815
5816/// A single dependency of a package in a lockfile.
5817#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord, serde::Deserialize)]
5818#[serde(rename_all = "kebab-case")]
5819struct DependencyWire {
5820    #[serde(flatten)]
5821    package_id: PackageIdForDependency,
5822    #[serde(default)]
5823    extra: BTreeSet<ExtraName>,
5824    #[serde(default)]
5825    marker: SimplifiedMarkerTree,
5826}
5827
5828impl DependencyWire {
5829    fn unwire(
5830        self,
5831        requires_python: &RequiresPython,
5832        environment: SimplifiedMarkerTree,
5833        unambiguous_package_ids: &FxHashMap<PackageName, PackageId>,
5834    ) -> Result<Dependency, LockError> {
5835        let mut simplified_marker = self.marker;
5836        simplified_marker.and(environment);
5837        let complexified_marker = simplified_marker.into_marker(requires_python);
5838        Ok(Dependency {
5839            package_id: self.package_id.unwire(unambiguous_package_ids)?,
5840            extra: self.extra,
5841            simplified_marker,
5842            complexified_marker: UniversalMarker::from_combined(complexified_marker),
5843        })
5844    }
5845}
5846
5847/// A single hash for a distribution artifact in a lockfile.
5848///
5849/// A hash is encoded as a single TOML string in the format
5850/// `{algorithm}:{digest}`.
5851#[derive(Clone, Debug, PartialEq, Eq)]
5852struct Hash(HashDigest);
5853
5854impl From<HashDigest> for Hash {
5855    fn from(hd: HashDigest) -> Self {
5856        Self(hd)
5857    }
5858}
5859
5860impl FromStr for Hash {
5861    type Err = HashParseError;
5862
5863    fn from_str(s: &str) -> Result<Self, HashParseError> {
5864        let (algorithm, digest) = s.split_once(':').ok_or(HashParseError(
5865            "expected '{algorithm}:{digest}', but found no ':' in hash digest",
5866        ))?;
5867        let algorithm = algorithm
5868            .parse()
5869            .map_err(|_| HashParseError("unrecognized hash algorithm"))?;
5870        Ok(Self(HashDigest {
5871            algorithm,
5872            digest: digest.into(),
5873        }))
5874    }
5875}
5876
5877impl Display for Hash {
5878    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
5879        write!(f, "{}:{}", self.0.algorithm, self.0.digest)
5880    }
5881}
5882
5883impl<'de> serde::Deserialize<'de> for Hash {
5884    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
5885    where
5886        D: serde::de::Deserializer<'de>,
5887    {
5888        struct Visitor;
5889
5890        impl serde::de::Visitor<'_> for Visitor {
5891            type Value = Hash;
5892
5893            fn expecting(&self, f: &mut Formatter) -> std::fmt::Result {
5894                f.write_str("a string")
5895            }
5896
5897            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
5898                Hash::from_str(v).map_err(serde::de::Error::custom)
5899            }
5900        }
5901
5902        deserializer.deserialize_str(Visitor)
5903    }
5904}
5905
5906impl From<Hash> for Hashes {
5907    fn from(value: Hash) -> Self {
5908        match value.0.algorithm {
5909            HashAlgorithm::Md5 => Self {
5910                md5: Some(value.0.digest),
5911                sha256: None,
5912                sha384: None,
5913                sha512: None,
5914                blake2b: None,
5915            },
5916            HashAlgorithm::Sha256 => Self {
5917                md5: None,
5918                sha256: Some(value.0.digest),
5919                sha384: None,
5920                sha512: None,
5921                blake2b: None,
5922            },
5923            HashAlgorithm::Sha384 => Self {
5924                md5: None,
5925                sha256: None,
5926                sha384: Some(value.0.digest),
5927                sha512: None,
5928                blake2b: None,
5929            },
5930            HashAlgorithm::Sha512 => Self {
5931                md5: None,
5932                sha256: None,
5933                sha384: None,
5934                sha512: Some(value.0.digest),
5935                blake2b: None,
5936            },
5937            HashAlgorithm::Blake2b => Self {
5938                md5: None,
5939                sha256: None,
5940                sha384: None,
5941                sha512: None,
5942                blake2b: Some(value.0.digest),
5943            },
5944        }
5945    }
5946}
5947
5948/// Convert a [`FileLocation`] into a normalized [`UrlString`].
5949fn normalize_file_location(location: &FileLocation) -> Result<UrlString, ToUrlError> {
5950    match location {
5951        FileLocation::AbsoluteUrl(absolute) => Ok(absolute.without_fragment().into_owned()),
5952        FileLocation::RelativeUrl(_, _) => Ok(normalize_url(location.to_url()?)),
5953    }
5954}
5955
5956/// Convert a [`DisplaySafeUrl`] into a normalized [`UrlString`] by removing the fragment.
5957fn normalize_url(mut url: DisplaySafeUrl) -> UrlString {
5958    url.set_fragment(None);
5959    UrlString::from(url)
5960}
5961
5962/// Normalize a [`Requirement`], which could come from a lockfile, a `pyproject.toml`, etc.
5963///
5964/// Performs the following steps:
5965///
5966/// 1. Removes any sensitive credentials.
5967/// 2. Ensures that the lock and install paths are appropriately framed with respect to the
5968///    current [`Workspace`].
5969/// 3. Removes the `origin` field, which is only used in `requirements.txt`.
5970/// 4. Simplifies the markers using the provided [`RequiresPython`] instance.
5971fn normalize_requirement(
5972    mut requirement: Requirement,
5973    root: &Path,
5974    requires_python: &RequiresPython,
5975) -> Result<Requirement, LockError> {
5976    // Sort the extras and groups for consistency.
5977    requirement.extras.sort();
5978    requirement.groups.sort();
5979
5980    // Normalize the requirement source.
5981    match requirement.source {
5982        RequirementSource::GitDirectory {
5983            git,
5984            subdirectory,
5985            url: _,
5986        } => {
5987            // Reconstruct the Git URL.
5988            let git = {
5989                let mut repository = git.url().clone();
5990
5991                // Remove the credentials.
5992                repository.remove_credentials();
5993
5994                // Remove the fragment and query from the URL; they're already present in the source.
5995                repository.set_fragment(None);
5996                repository.set_query(None);
5997
5998                GitUrl::from_fields(
5999                    repository,
6000                    git.reference().clone(),
6001                    git.precise(),
6002                    git.lfs(),
6003                )?
6004            };
6005
6006            // Reconstruct the PEP 508 URL from the underlying data.
6007            let url = DisplaySafeUrl::from(ParsedGitDirectoryUrl {
6008                url: git.clone(),
6009                subdirectory: subdirectory.clone(),
6010            });
6011
6012            Ok(Requirement {
6013                name: requirement.name,
6014                extras: requirement.extras,
6015                groups: requirement.groups,
6016                marker: requires_python.simplify_markers(requirement.marker),
6017                source: RequirementSource::GitDirectory {
6018                    git,
6019                    subdirectory,
6020                    url: VerbatimUrl::from_url(url),
6021                },
6022                origin: None,
6023            })
6024        }
6025        RequirementSource::GitPath {
6026            git,
6027            install_path,
6028            ext,
6029            url: _,
6030        } => {
6031            // Reconstruct the Git URL.
6032            let git = {
6033                let mut repository = git.url().clone();
6034
6035                // Remove the credentials.
6036                repository.remove_credentials();
6037
6038                // Remove the fragment and query from the URL; they're already present in the source.
6039                repository.set_fragment(None);
6040                repository.set_query(None);
6041
6042                GitUrl::from_fields(
6043                    repository,
6044                    git.reference().clone(),
6045                    git.precise(),
6046                    git.lfs(),
6047                )?
6048            };
6049
6050            // Reconstruct the PEP 508 URL from the underlying data.
6051            let url = DisplaySafeUrl::from(ParsedGitPathUrl {
6052                url: git.clone(),
6053                install_path: install_path.clone(),
6054                ext,
6055            });
6056
6057            Ok(Requirement {
6058                name: requirement.name,
6059                extras: requirement.extras,
6060                groups: requirement.groups,
6061                marker: requires_python.simplify_markers(requirement.marker),
6062                source: RequirementSource::GitPath {
6063                    git,
6064                    install_path,
6065                    ext,
6066                    url: VerbatimUrl::from_url(url),
6067                },
6068                origin: None,
6069            })
6070        }
6071        RequirementSource::Path {
6072            install_path,
6073            ext,
6074            url: _,
6075        } => {
6076            let path = root.join(&install_path);
6077            let install_path = normalize_path(path).into_owned().into_boxed_path();
6078            let url = VerbatimUrl::from_normalized_path(&install_path)
6079                .map_err(LockErrorKind::RequirementVerbatimUrl)?;
6080
6081            Ok(Requirement {
6082                name: requirement.name,
6083                extras: requirement.extras,
6084                groups: requirement.groups,
6085                marker: requires_python.simplify_markers(requirement.marker),
6086                source: RequirementSource::Path {
6087                    install_path,
6088                    ext,
6089                    url,
6090                },
6091                origin: None,
6092            })
6093        }
6094        RequirementSource::Directory {
6095            install_path,
6096            editable,
6097            r#virtual,
6098            url: _,
6099        } => {
6100            let path = root.join(&install_path);
6101            let install_path = normalize_path(path).into_owned().into_boxed_path();
6102            let url = VerbatimUrl::from_normalized_path(&install_path)
6103                .map_err(LockErrorKind::RequirementVerbatimUrl)?;
6104
6105            Ok(Requirement {
6106                name: requirement.name,
6107                extras: requirement.extras,
6108                groups: requirement.groups,
6109                marker: requires_python.simplify_markers(requirement.marker),
6110                source: RequirementSource::Directory {
6111                    install_path,
6112                    editable: Some(editable.unwrap_or(false)),
6113                    r#virtual: Some(r#virtual.unwrap_or(false)),
6114                    url,
6115                },
6116                origin: None,
6117            })
6118        }
6119        RequirementSource::Registry {
6120            specifier,
6121            index,
6122            conflict,
6123        } => {
6124            // Round-trip the index to remove anything apart from the URL.
6125            let index = index
6126                .map(|index| index.url.into_url())
6127                .map(|mut index| {
6128                    index.remove_credentials();
6129                    index
6130                })
6131                .map(|index| IndexMetadata::from(IndexUrl::from(VerbatimUrl::from_url(index))));
6132            Ok(Requirement {
6133                name: requirement.name,
6134                extras: requirement.extras,
6135                groups: requirement.groups,
6136                marker: requires_python.simplify_markers(requirement.marker),
6137                source: RequirementSource::Registry {
6138                    specifier,
6139                    index,
6140                    conflict,
6141                },
6142                origin: None,
6143            })
6144        }
6145        RequirementSource::Url {
6146            mut location,
6147            subdirectory,
6148            ext,
6149            url: _,
6150        } => {
6151            // Remove the credentials.
6152            location.remove_credentials();
6153
6154            // Remove the fragment from the URL; it's already present in the source.
6155            location.set_fragment(None);
6156
6157            // Reconstruct the PEP 508 URL from the underlying data.
6158            let url = DisplaySafeUrl::from(ParsedArchiveUrl {
6159                url: location.clone(),
6160                subdirectory: subdirectory.clone(),
6161                ext,
6162            });
6163
6164            Ok(Requirement {
6165                name: requirement.name,
6166                extras: requirement.extras,
6167                groups: requirement.groups,
6168                marker: requires_python.simplify_markers(requirement.marker),
6169                source: RequirementSource::Url {
6170                    location,
6171                    subdirectory,
6172                    ext,
6173                    url: VerbatimUrl::from_url(url),
6174                },
6175                origin: None,
6176            })
6177        }
6178    }
6179}
6180
6181#[derive(Debug)]
6182pub struct LockError {
6183    kind: Box<LockErrorKind>,
6184    hint: Option<WheelTagHint>,
6185}
6186
6187impl std::error::Error for LockError {
6188    fn source(&self) -> Option<&(dyn Error + 'static)> {
6189        self.kind.source()
6190    }
6191}
6192
6193impl uv_errors::Hint for LockError {
6194    fn hints(&self) -> uv_errors::Hints<'_> {
6195        if let Some(hint) = &self.hint {
6196            uv_errors::Hints::from(hint.to_string())
6197        } else {
6198            uv_errors::Hints::none()
6199        }
6200    }
6201}
6202
6203impl std::fmt::Display for LockError {
6204    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
6205        write!(f, "{}", self.kind)
6206    }
6207}
6208
6209impl LockError {
6210    /// Returns true if the [`LockError`] is a resolver error.
6211    pub fn is_resolution(&self) -> bool {
6212        matches!(&*self.kind, LockErrorKind::Resolution { .. })
6213    }
6214
6215    /// Returns true if the [`LockError`] is caused by disabled builds.
6216    pub fn is_no_build(&self) -> bool {
6217        matches!(
6218            &*self.kind,
6219            LockErrorKind::NoBuild { .. } | LockErrorKind::NoBinaryNoBuild { .. }
6220        )
6221    }
6222}
6223
6224impl<E> From<E> for LockError
6225where
6226    LockErrorKind: From<E>,
6227{
6228    fn from(err: E) -> Self {
6229        Self {
6230            kind: Box::new(LockErrorKind::from(err)),
6231            hint: None,
6232        }
6233    }
6234}
6235
6236#[derive(Debug, Clone, PartialEq, Eq)]
6237#[expect(clippy::enum_variant_names)]
6238enum WheelTagHint {
6239    /// None of the available wheels for a package have a compatible Python language tag (e.g.,
6240    /// `cp310` in `cp310-abi3-manylinux_2_17_x86_64.whl`).
6241    LanguageTags {
6242        package: PackageName,
6243        version: Option<Version>,
6244        tags: BTreeSet<LanguageTag>,
6245        best: Option<LanguageTag>,
6246    },
6247    /// None of the available wheels for a package have a compatible ABI tag (e.g., `abi3` in
6248    /// `cp310-abi3-manylinux_2_17_x86_64.whl`).
6249    AbiTags {
6250        package: PackageName,
6251        version: Option<Version>,
6252        tags: BTreeSet<AbiTag>,
6253        best: Option<AbiTag>,
6254    },
6255    /// None of the available wheels for a package have a compatible platform tag (e.g.,
6256    /// `manylinux_2_17_x86_64` in `cp310-abi3-manylinux_2_17_x86_64.whl`).
6257    PlatformTags {
6258        package: PackageName,
6259        version: Option<Version>,
6260        tags: BTreeSet<PlatformTag>,
6261        best: Option<PlatformTag>,
6262        markers: MarkerEnvironment,
6263    },
6264}
6265
6266impl WheelTagHint {
6267    /// Generate a [`WheelTagHint`] from the given (incompatible) wheels.
6268    fn from_wheels(
6269        name: &PackageName,
6270        version: Option<&Version>,
6271        filenames: &[&WheelFilename],
6272        tags: &Tags,
6273        markers: &MarkerEnvironment,
6274    ) -> Option<Self> {
6275        let incompatibility = filenames
6276            .iter()
6277            .map(|filename| {
6278                tags.compatibility(
6279                    filename.python_tags(),
6280                    filename.abi_tags(),
6281                    filename.platform_tags(),
6282                )
6283            })
6284            .max()?;
6285        match incompatibility {
6286            TagCompatibility::Incompatible(IncompatibleTag::Python) => {
6287                let best = tags.python_tag();
6288                let tags = Self::python_tags(filenames.iter().copied()).collect::<BTreeSet<_>>();
6289                if tags.is_empty() {
6290                    None
6291                } else {
6292                    Some(Self::LanguageTags {
6293                        package: name.clone(),
6294                        version: version.cloned(),
6295                        tags,
6296                        best,
6297                    })
6298                }
6299            }
6300            TagCompatibility::Incompatible(IncompatibleTag::Abi) => {
6301                let best = tags.abi_tag();
6302                let tags = Self::abi_tags(filenames.iter().copied())
6303                    // Ignore `none`, which is universally compatible.
6304                    //
6305                    // As an example, `none` can appear here if we're solving for Python 3.13, and
6306                    // the distribution includes a wheel for `cp312-none-macosx_11_0_arm64`.
6307                    //
6308                    // In that case, the wheel isn't compatible, but when solving for Python 3.13,
6309                    // the `cp312` Python tag _can_ be compatible (e.g., for `cp312-abi3-macosx_11_0_arm64.whl`),
6310                    // so this is considered an ABI incompatibility rather than Python incompatibility.
6311                    .filter(|tag| *tag != AbiTag::None)
6312                    .collect::<BTreeSet<_>>();
6313                if tags.is_empty() {
6314                    None
6315                } else {
6316                    Some(Self::AbiTags {
6317                        package: name.clone(),
6318                        version: version.cloned(),
6319                        tags,
6320                        best,
6321                    })
6322                }
6323            }
6324            TagCompatibility::Incompatible(IncompatibleTag::Platform) => {
6325                let best = tags.platform_tag().cloned();
6326                let incompatible_tags = Self::platform_tags(filenames.iter().copied(), tags)
6327                    .cloned()
6328                    .collect::<BTreeSet<_>>();
6329                if incompatible_tags.is_empty() {
6330                    None
6331                } else {
6332                    Some(Self::PlatformTags {
6333                        package: name.clone(),
6334                        version: version.cloned(),
6335                        tags: incompatible_tags,
6336                        best,
6337                        markers: markers.clone(),
6338                    })
6339                }
6340            }
6341            _ => None,
6342        }
6343    }
6344
6345    /// Returns an iterator over the compatible Python tags of the available wheels.
6346    fn python_tags<'a>(
6347        filenames: impl Iterator<Item = &'a WheelFilename> + 'a,
6348    ) -> impl Iterator<Item = LanguageTag> + 'a {
6349        filenames.flat_map(WheelFilename::python_tags).copied()
6350    }
6351
6352    /// Returns an iterator over the compatible Python tags of the available wheels.
6353    fn abi_tags<'a>(
6354        filenames: impl Iterator<Item = &'a WheelFilename> + 'a,
6355    ) -> impl Iterator<Item = AbiTag> + 'a {
6356        filenames.flat_map(WheelFilename::abi_tags).copied()
6357    }
6358
6359    /// Returns the set of platform tags for the distribution that are ABI-compatible with the given
6360    /// tags.
6361    fn platform_tags<'a>(
6362        filenames: impl Iterator<Item = &'a WheelFilename> + 'a,
6363        tags: &'a Tags,
6364    ) -> impl Iterator<Item = &'a PlatformTag> + 'a {
6365        filenames.flat_map(move |filename| {
6366            if filename.python_tags().iter().any(|wheel_py| {
6367                filename
6368                    .abi_tags()
6369                    .iter()
6370                    .any(|wheel_abi| tags.is_compatible_abi(*wheel_py, *wheel_abi))
6371            }) {
6372                filename.platform_tags().iter()
6373            } else {
6374                [].iter()
6375            }
6376        })
6377    }
6378
6379    fn suggest_environment_marker(markers: &MarkerEnvironment) -> String {
6380        let sys_platform = markers.sys_platform();
6381        let platform_machine = markers.platform_machine();
6382
6383        // Generate the marker string based on actual environment values
6384        if platform_machine.is_empty() {
6385            format!("sys_platform == '{sys_platform}'")
6386        } else {
6387            format!("sys_platform == '{sys_platform}' and platform_machine == '{platform_machine}'")
6388        }
6389    }
6390}
6391
6392impl std::fmt::Display for WheelTagHint {
6393    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
6394        match self {
6395            Self::LanguageTags {
6396                package,
6397                version,
6398                tags,
6399                best,
6400            } => {
6401                if let Some(best) = best {
6402                    let s = if tags.len() == 1 { "" } else { "s" };
6403                    let best = if let Some(pretty) = best.pretty() {
6404                        format!("{} (`{}`)", pretty.cyan(), best.cyan())
6405                    } else {
6406                        format!("{}", best.cyan())
6407                    };
6408                    if let Some(version) = version {
6409                        write!(
6410                            f,
6411                            "You're using {}, but `{}` ({}) only has wheels with the following Python implementation tag{s}: {}",
6412                            best,
6413                            package.cyan(),
6414                            format!("v{version}").cyan(),
6415                            tags.iter()
6416                                .map(|tag| format!("`{}`", tag.cyan()))
6417                                .join(", "),
6418                        )
6419                    } else {
6420                        write!(
6421                            f,
6422                            "You're using {}, but `{}` only has wheels with the following Python implementation tag{s}: {}",
6423                            best,
6424                            package.cyan(),
6425                            tags.iter()
6426                                .map(|tag| format!("`{}`", tag.cyan()))
6427                                .join(", "),
6428                        )
6429                    }
6430                } else {
6431                    let s = if tags.len() == 1 { "" } else { "s" };
6432                    if let Some(version) = version {
6433                        write!(
6434                            f,
6435                            "Wheels are available for `{}` ({}) with the following Python implementation tag{s}: {}",
6436                            package.cyan(),
6437                            format!("v{version}").cyan(),
6438                            tags.iter()
6439                                .map(|tag| format!("`{}`", tag.cyan()))
6440                                .join(", "),
6441                        )
6442                    } else {
6443                        write!(
6444                            f,
6445                            "Wheels are available for `{}` with the following Python implementation tag{s}: {}",
6446                            package.cyan(),
6447                            tags.iter()
6448                                .map(|tag| format!("`{}`", tag.cyan()))
6449                                .join(", "),
6450                        )
6451                    }
6452                }
6453            }
6454            Self::AbiTags {
6455                package,
6456                version,
6457                tags,
6458                best,
6459            } => {
6460                if let Some(best) = best {
6461                    let s = if tags.len() == 1 { "" } else { "s" };
6462                    let best = if let Some(pretty) = best.pretty() {
6463                        format!("{} (`{}`)", pretty.cyan(), best.cyan())
6464                    } else {
6465                        format!("{}", best.cyan())
6466                    };
6467                    if let Some(version) = version {
6468                        write!(
6469                            f,
6470                            "You're using {}, but `{}` ({}) only has wheels with the following Python ABI tag{s}: {}",
6471                            best,
6472                            package.cyan(),
6473                            format!("v{version}").cyan(),
6474                            tags.iter()
6475                                .map(|tag| format!("`{}`", tag.cyan()))
6476                                .join(", "),
6477                        )
6478                    } else {
6479                        write!(
6480                            f,
6481                            "You're using {}, but `{}` only has wheels with the following Python ABI tag{s}: {}",
6482                            best,
6483                            package.cyan(),
6484                            tags.iter()
6485                                .map(|tag| format!("`{}`", tag.cyan()))
6486                                .join(", "),
6487                        )
6488                    }
6489                } else {
6490                    let s = if tags.len() == 1 { "" } else { "s" };
6491                    if let Some(version) = version {
6492                        write!(
6493                            f,
6494                            "Wheels are available for `{}` ({}) with the following Python ABI tag{s}: {}",
6495                            package.cyan(),
6496                            format!("v{version}").cyan(),
6497                            tags.iter()
6498                                .map(|tag| format!("`{}`", tag.cyan()))
6499                                .join(", "),
6500                        )
6501                    } else {
6502                        write!(
6503                            f,
6504                            "Wheels are available for `{}` with the following Python ABI tag{s}: {}",
6505                            package.cyan(),
6506                            tags.iter()
6507                                .map(|tag| format!("`{}`", tag.cyan()))
6508                                .join(", "),
6509                        )
6510                    }
6511                }
6512            }
6513            Self::PlatformTags {
6514                package,
6515                version,
6516                tags,
6517                best,
6518                markers,
6519            } => {
6520                let s = if tags.len() == 1 { "" } else { "s" };
6521                if let Some(best) = best {
6522                    let example_marker = Self::suggest_environment_marker(markers);
6523                    let best = if let Some(pretty) = best.pretty() {
6524                        format!("{} (`{}`)", pretty.cyan(), best.cyan())
6525                    } else {
6526                        format!("`{}`", best.cyan())
6527                    };
6528                    let package_ref = if let Some(version) = version {
6529                        format!("`{}` ({})", package.cyan(), format!("v{version}").cyan())
6530                    } else {
6531                        format!("`{}`", package.cyan())
6532                    };
6533                    write!(
6534                        f,
6535                        "You're on {}, but {} only has wheels for the following platform{s}: {}; consider adding {} to `{}` to ensure uv resolves to a version with compatible wheels",
6536                        best,
6537                        package_ref,
6538                        tags.iter()
6539                            .map(|tag| format!("`{}`", tag.cyan()))
6540                            .join(", "),
6541                        format!("\"{example_marker}\"").cyan(),
6542                        "tool.uv.required-environments".green()
6543                    )
6544                } else {
6545                    if let Some(version) = version {
6546                        write!(
6547                            f,
6548                            "Wheels are available for `{}` ({}) on the following platform{s}: {}",
6549                            package.cyan(),
6550                            format!("v{version}").cyan(),
6551                            tags.iter()
6552                                .map(|tag| format!("`{}`", tag.cyan()))
6553                                .join(", "),
6554                        )
6555                    } else {
6556                        write!(
6557                            f,
6558                            "Wheels are available for `{}` on the following platform{s}: {}",
6559                            package.cyan(),
6560                            tags.iter()
6561                                .map(|tag| format!("`{}`", tag.cyan()))
6562                                .join(", "),
6563                        )
6564                    }
6565                }
6566            }
6567        }
6568    }
6569}
6570
6571/// An error that occurs when generating a `Lock` data structure.
6572///
6573/// These errors are sometimes the result of possible programming bugs.
6574/// For example, if there are two or more duplicative distributions given
6575/// to `Lock::new`, then an error is returned. It's likely that the fault
6576/// is with the caller somewhere in such cases.
6577#[derive(Debug, thiserror::Error)]
6578enum LockErrorKind {
6579    /// An error that occurs when multiple packages with the same
6580    /// ID were found.
6581    #[error("Found duplicate package `{id}`", id = id.cyan())]
6582    DuplicatePackage {
6583        /// The ID of the conflicting package.
6584        id: PackageId,
6585    },
6586    /// An error that occurs when there are multiple dependencies for the
6587    /// same package that have identical identifiers.
6588    #[error("For package `{id}`, found duplicate dependency `{dependency}`", id = id.cyan(), dependency = dependency.cyan())]
6589    DuplicateDependency {
6590        /// The ID of the package for which a duplicate dependency was
6591        /// found.
6592        id: PackageId,
6593        /// The ID of the conflicting dependency.
6594        dependency: Dependency,
6595    },
6596    /// An error that occurs when there are multiple dependencies for the
6597    /// same package that have identical identifiers, as part of the
6598    /// that package's optional dependencies.
6599    #[error("For package `{id}`, found duplicate dependency `{dependency}`", id = format!("{id}[{extra}]").cyan(), dependency = dependency.cyan())]
6600    DuplicateOptionalDependency {
6601        /// The ID of the package for which a duplicate dependency was
6602        /// found.
6603        id: PackageId,
6604        /// The name of the extra.
6605        extra: ExtraName,
6606        /// The ID of the conflicting dependency.
6607        dependency: Dependency,
6608    },
6609    /// An error that occurs when there are multiple dependencies for the
6610    /// same package that have identical identifiers, as part of the
6611    /// that package's development dependencies.
6612    #[error("For package `{id}`, found duplicate dependency `{dependency}`", id = format!("{id}:{group}").cyan(), dependency = dependency.cyan())]
6613    DuplicateDevDependency {
6614        /// The ID of the package for which a duplicate dependency was
6615        /// found.
6616        id: PackageId,
6617        /// The name of the dev dependency group.
6618        group: GroupName,
6619        /// The ID of the conflicting dependency.
6620        dependency: Dependency,
6621    },
6622    /// An error that occurs when the URL to a file for a wheel or
6623    /// source dist could not be converted to a structured `url::Url`.
6624    #[error(transparent)]
6625    InvalidUrl(
6626        /// The underlying error that occurred. This includes the
6627        /// errant URL in its error message.
6628        #[from]
6629        ToUrlError,
6630    ),
6631    /// An error that occurs when the extension can't be determined
6632    /// for a given wheel or source distribution.
6633    #[error("Failed to parse file extension for `{id}`; expected one of: {err}", id = id.cyan())]
6634    MissingExtension {
6635        /// The filename that was expected to have an extension.
6636        id: PackageId,
6637        /// The list of valid extensions that were expected.
6638        err: ExtensionError,
6639    },
6640    /// Failed to parse a Git source URL.
6641    #[error("Failed to parse Git URL")]
6642    InvalidGitSourceUrl(
6643        /// The underlying error that occurred. This includes the
6644        /// errant URL in the message.
6645        #[source]
6646        SourceParseError,
6647    ),
6648    #[error("Failed to parse timestamp")]
6649    InvalidTimestamp(
6650        /// The underlying error that occurred. This includes the
6651        /// errant timestamp in the message.
6652        #[source]
6653        jiff::Error,
6654    ),
6655    /// An error that occurs when there's an unrecognized dependency.
6656    ///
6657    /// That is, a dependency for a package that isn't in the lockfile.
6658    #[error("For package `{id}`, found dependency `{dependency}` with no locked package", id = id.cyan(), dependency = dependency.cyan())]
6659    UnrecognizedDependency {
6660        /// The ID of the package that has an unrecognized dependency.
6661        id: PackageId,
6662        /// The ID of the dependency that doesn't have a corresponding package
6663        /// entry.
6664        dependency: Dependency,
6665    },
6666    /// An error that occurs when a hash is expected (or not) for a particular
6667    /// artifact, but one was not found (or was).
6668    #[error("Since the package `{id}` comes from a {source} dependency, a hash was {expected} but one was not found for {artifact_type}", id = id.cyan(), source = id.source.name(), expected = if *expected { "expected" } else { "not expected" })]
6669    Hash {
6670        /// The ID of the package that has a missing hash.
6671        id: PackageId,
6672        /// The specific type of artifact, e.g., "source package"
6673        /// or "wheel".
6674        artifact_type: &'static str,
6675        /// Whether a hash was expected.
6676        expected: bool,
6677    },
6678    /// An error that occurs when a package is included with an extra name,
6679    /// but no corresponding base package (i.e., without the extra) exists.
6680    #[error("Found package `{id}` with extra `{extra}` but no base package", id = id.cyan(), extra = extra.cyan())]
6681    MissingExtraBase {
6682        /// The ID of the package that has a missing base.
6683        id: PackageId,
6684        /// The extra name that was found.
6685        extra: ExtraName,
6686    },
6687    /// An error that occurs when a package is included with a development
6688    /// dependency group, but no corresponding base package (i.e., without
6689    /// the group) exists.
6690    #[error("Found package `{id}` with development dependency group `{group}` but no base package", id = id.cyan())]
6691    MissingDevBase {
6692        /// The ID of the package that has a missing base.
6693        id: PackageId,
6694        /// The development dependency group that was found.
6695        group: GroupName,
6696    },
6697    /// An error that occurs from an invalid lockfile where a wheel comes from a non-wheel source
6698    /// such as a directory.
6699    #[error("Wheels cannot come from {source_type} sources")]
6700    InvalidWheelSource {
6701        /// The ID of the distribution that has a missing base.
6702        id: PackageId,
6703        /// The kind of the invalid source.
6704        source_type: &'static str,
6705    },
6706    /// An error that occurs when a distribution indicates that it is sourced from a remote
6707    /// registry, but is missing a URL.
6708    #[error("Found registry distribution `{name}` ({version}) without a valid URL", name = name.cyan(), version = format!("v{version}").cyan())]
6709    MissingUrl {
6710        /// The name of the distribution that is missing a URL.
6711        name: PackageName,
6712        /// The version of the distribution that is missing a URL.
6713        version: Version,
6714    },
6715    /// An error that occurs when a distribution indicates that it is sourced from a local registry,
6716    /// but is missing a path.
6717    #[error("Found registry distribution `{name}` ({version}) without a valid path", name = name.cyan(), version = format!("v{version}").cyan())]
6718    MissingPath {
6719        /// The name of the distribution that is missing a path.
6720        name: PackageName,
6721        /// The version of the distribution that is missing a path.
6722        version: Version,
6723    },
6724    /// An error that occurs when a distribution indicates that it is sourced from a registry, but
6725    /// is missing a filename.
6726    #[error("Found registry distribution `{id}` without a valid filename", id = id.cyan())]
6727    MissingFilename {
6728        /// The ID of the distribution that is missing a filename.
6729        id: PackageId,
6730    },
6731    /// An error that occurs when a distribution is included with neither wheels nor a source
6732    /// distribution.
6733    #[error("Distribution `{id}` can't be installed because it doesn't have a source distribution or wheel for the current platform", id = id.cyan())]
6734    NeitherSourceDistNorWheel {
6735        /// The ID of the distribution.
6736        id: PackageId,
6737    },
6738    /// An error that occurs when a distribution is marked as both `--no-binary` and `--no-build`.
6739    #[error("Distribution `{id}` can't be installed because it is marked as both `--no-binary` and `--no-build`", id = id.cyan())]
6740    NoBinaryNoBuild {
6741        /// The ID of the distribution.
6742        id: PackageId,
6743    },
6744    /// An error that occurs when a distribution is marked as `--no-binary`, but no source
6745    /// distribution is available.
6746    #[error("Distribution `{id}` can't be installed because it is marked as `--no-binary` but has no source distribution", id = id.cyan())]
6747    NoBinary {
6748        /// The ID of the distribution.
6749        id: PackageId,
6750    },
6751    /// An error that occurs when a distribution is marked as `--no-build`, but no binary
6752    /// distribution is available.
6753    #[error("Distribution `{id}` can't be installed because it is marked as `--no-build` but has no binary distribution", id = id.cyan())]
6754    NoBuild {
6755        /// The ID of the distribution.
6756        id: PackageId,
6757    },
6758    /// An error that occurs when a wheel-only distribution is incompatible with the current
6759    /// platform.
6760    #[error("Distribution `{id}` can't be installed because the binary distribution is incompatible with the current platform", id = id.cyan())]
6761    IncompatibleWheelOnly {
6762        /// The ID of the distribution.
6763        id: PackageId,
6764    },
6765    /// An error that occurs when a wheel-only source is marked as `--no-binary`.
6766    #[error("Distribution `{id}` can't be installed because it is marked as `--no-binary` but is itself a binary distribution", id = id.cyan())]
6767    NoBinaryWheelOnly {
6768        /// The ID of the distribution.
6769        id: PackageId,
6770    },
6771    /// An error that occurs when converting between URLs and paths.
6772    #[error("Found dependency `{id}` with no locked distribution", id = id.cyan())]
6773    VerbatimUrl {
6774        /// The ID of the distribution that has a missing base.
6775        id: PackageId,
6776        /// The inner error we forward.
6777        #[source]
6778        err: VerbatimUrlError,
6779    },
6780    /// An error that occurs when parsing an existing requirement.
6781    #[error("Could not compute relative path between workspace and distribution")]
6782    DistributionRelativePath(
6783        /// The inner error we forward.
6784        #[source]
6785        io::Error,
6786    ),
6787    /// An error that occurs when converting an index URL to a relative path
6788    #[error("Could not compute relative path between workspace and index")]
6789    IndexRelativePath(
6790        /// The inner error we forward.
6791        #[source]
6792        io::Error,
6793    ),
6794    /// An error that occurs when converting a lockfile path from relative to absolute.
6795    #[error("Could not compute absolute path from workspace root and lockfile path")]
6796    AbsolutePath(
6797        /// The inner error we forward.
6798        #[source]
6799        io::Error,
6800    ),
6801    /// An error that occurs when an ambiguous `package.dependency` is
6802    /// missing a `version` field.
6803    #[error("Dependency `{name}` has missing `version` field but has more than one matching package", name = name.cyan())]
6804    MissingDependencyVersion {
6805        /// The name of the dependency that is missing a `version` field.
6806        name: PackageName,
6807    },
6808    /// An error that occurs when an ambiguous `package.dependency` is
6809    /// missing a `source` field.
6810    #[error("Dependency `{name}` has missing `source` field but has more than one matching package", name = name.cyan())]
6811    MissingDependencySource {
6812        /// The name of the dependency that is missing a `source` field.
6813        name: PackageName,
6814    },
6815    /// An error that occurs when parsing an existing requirement.
6816    #[error("Could not compute relative path between workspace and requirement")]
6817    RequirementRelativePath(
6818        /// The inner error we forward.
6819        #[source]
6820        io::Error,
6821    ),
6822    /// An error that occurs when parsing an existing requirement.
6823    #[error("Could not convert between URL and path")]
6824    RequirementVerbatimUrl(
6825        /// The inner error we forward.
6826        #[source]
6827        VerbatimUrlError,
6828    ),
6829    /// An error that occurs when parsing a registry's index URL.
6830    #[error("Could not convert between URL and path")]
6831    RegistryVerbatimUrl(
6832        /// The inner error we forward.
6833        #[source]
6834        VerbatimUrlError,
6835    ),
6836    /// An error that occurs when converting a path to a URL.
6837    #[error("Failed to convert path to URL: {path}", path = path.display().cyan())]
6838    PathToUrl { path: Box<Path> },
6839    /// An error that occurs when converting a URL to a path
6840    #[error("Failed to convert URL to path: {url}", url = url.cyan())]
6841    UrlToPath { url: DisplaySafeUrl },
6842    /// An error that occurs when multiple packages with the same
6843    /// name were found when identifying the root packages.
6844    #[error("Found multiple packages matching `{name}`", name = name.cyan())]
6845    MultipleRootPackages {
6846        /// The ID of the package.
6847        name: PackageName,
6848    },
6849    /// An error that occurs when a root package can't be found.
6850    #[error("Could not find root package `{name}`", name = name.cyan())]
6851    MissingRootPackage {
6852        /// The ID of the package.
6853        name: PackageName,
6854    },
6855    /// An error that occurs when a concrete root package does not belong to the lock.
6856    #[error("Could not find root package `{id}` in lock", id = id.cyan())]
6857    RootPackageMissingFromLock {
6858        /// The ID of the package.
6859        id: PackageId,
6860    },
6861    /// A dependency marker depends on a package outside the selected subgraph.
6862    #[error(
6863        "Cannot materialize dependency `{dependency}` of `{package}` because its conflict marker depends on a package outside the selected subgraph",
6864        package = package.cyan(),
6865        dependency = dependency.cyan()
6866    )]
6867    DependencyConflictOutsideSubgraph {
6868        /// The ID of the package that declares the dependency.
6869        package: PackageId,
6870        /// The ID of the dependency whose inclusion is ambiguous.
6871        dependency: PackageId,
6872    },
6873    /// An error that occurs when resolving metadata for a package.
6874    #[error("Failed to generate package metadata for `{id}`", id = id.cyan())]
6875    Resolution {
6876        /// The ID of the distribution that failed to resolve.
6877        id: PackageId,
6878        /// The inner error we forward.
6879        #[source]
6880        err: uv_distribution::Error,
6881    },
6882    /// A package has inconsistent versions in a single entry
6883    // Using name instead of id since the version in the id is part of the conflict.
6884    #[error("The entry for package `{name}` ({version}) has wheel `{wheel_filename}` with inconsistent version ({wheel_version}), which indicates a malformed wheel. If this is intentional, set `{env_var}`.", name = name.cyan(), wheel_filename = wheel.filename, wheel_version = wheel.filename.version, env_var = "UV_SKIP_WHEEL_FILENAME_CHECK=1".green())]
6885    InconsistentVersions {
6886        /// The name of the package with the inconsistent entry.
6887        name: PackageName,
6888        /// The version of the package with the inconsistent entry.
6889        version: Version,
6890        /// The wheel with the inconsistent version.
6891        wheel: Wheel,
6892    },
6893    #[error(
6894        "Found conflicting extras `{package1}[{extra1}]` \
6895         and `{package2}[{extra2}]` enabled simultaneously"
6896    )]
6897    ConflictingExtra {
6898        package1: PackageName,
6899        extra1: ExtraName,
6900        package2: PackageName,
6901        extra2: ExtraName,
6902    },
6903    #[error(transparent)]
6904    GitUrlParse(#[from] GitUrlParseError),
6905    #[error("Failed to read `{path}`")]
6906    UnreadablePyprojectToml {
6907        path: PathBuf,
6908        #[source]
6909        err: std::io::Error,
6910    },
6911    #[error("Failed to parse `{path}`")]
6912    InvalidPyprojectToml {
6913        path: PathBuf,
6914        #[source]
6915        err: uv_pypi_types::MetadataError,
6916    },
6917    /// An error that occurs when a workspace member has a non-local source.
6918    #[error("Workspace member `{id}` has non-local source", id = id.cyan())]
6919    NonLocalWorkspaceMember {
6920        /// The ID of the workspace member with an invalid source.
6921        id: PackageId,
6922    },
6923}
6924
6925/// An error that occurs when a source string could not be parsed.
6926#[derive(Debug, thiserror::Error)]
6927enum SourceParseError {
6928    /// An error that occurs when the URL in the source is invalid.
6929    #[error("Invalid URL in source `{given}`")]
6930    InvalidUrl {
6931        /// The source string given.
6932        given: String,
6933        /// The URL parse error.
6934        #[source]
6935        err: DisplaySafeUrlError,
6936    },
6937    /// An error that occurs when a Git URL is missing a precise commit SHA.
6938    #[error("Missing SHA in source `{given}`")]
6939    MissingSha {
6940        /// The source string given.
6941        given: String,
6942    },
6943    /// An error that occurs when a Git URL has an invalid SHA.
6944    #[error("Invalid SHA in source `{given}`")]
6945    InvalidSha {
6946        /// The source string given.
6947        given: String,
6948    },
6949}
6950
6951/// An error that occurs when a hash digest could not be parsed.
6952#[derive(Clone, Debug, Eq, PartialEq)]
6953struct HashParseError(&'static str);
6954
6955impl std::error::Error for HashParseError {}
6956
6957impl Display for HashParseError {
6958    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
6959        Display::fmt(self.0, f)
6960    }
6961}
6962
6963/// Format an array so that each element is on its own line and has a trailing comma.
6964///
6965/// Example:
6966///
6967/// ```toml
6968/// dependencies = [
6969///     { name = "idna" },
6970///     { name = "sniffio" },
6971/// ]
6972/// ```
6973fn each_element_on_its_line_array(elements: impl Iterator<Item = impl Into<Value>>) -> Array {
6974    let mut array = elements
6975        .map(|item| {
6976            let mut value = item.into();
6977            // Each dependency is on its own line and indented.
6978            value.decor_mut().set_prefix("\n    ");
6979            value
6980        })
6981        .collect::<Array>();
6982    // With a trailing comma, inserting another entry doesn't change the preceding line,
6983    // reducing the diff noise.
6984    array.set_trailing_comma(true);
6985    // The line break between the last element's comma and the closing square bracket.
6986    array.set_trailing("\n");
6987    array
6988}
6989
6990/// Return the PEP 508 marker space covered by the resolution.
6991fn fork_markers_union(
6992    fork_markers: &[UniversalMarker],
6993    requires_python: &RequiresPython,
6994) -> MarkerTree {
6995    if fork_markers.is_empty() {
6996        return requires_python.to_marker_tree();
6997    }
6998    let mut environment = MarkerTree::FALSE;
6999    for fork_marker in fork_markers {
7000        environment.or(fork_marker.pep508());
7001    }
7002    environment
7003}
7004
7005/// Simplify an edge marker using the PEP 508 conditions that must already hold to reach its parent
7006/// node. Parent conflict predicates remain on the edge for compatibility with older lockfile
7007/// readers that evaluate dependency markers independently during conflict discovery.
7008fn simplify_dependency_marker(
7009    requires_python: &RequiresPython,
7010    environment: SimplifiedMarkerTree,
7011    parent: UniversalMarker,
7012    marker: UniversalMarker,
7013) -> UniversalMarker {
7014    let parent =
7015        SimplifiedMarkerTree::new(requires_python, parent.pep508()).as_simplified_marker_tree();
7016    let marker =
7017        SimplifiedMarkerTree::new(requires_python, marker.combined()).as_simplified_marker_tree();
7018    let marker = marker.restrict(parent);
7019
7020    // Retain the resolution environment internally. The lockfile writer removes it from the wire
7021    // marker, and the reader restores it, keeping freshly resolved and deserialized locks equal.
7022    let mut marker = SimplifiedMarkerTree::new(requires_python, marker);
7023    marker.and(environment);
7024    UniversalMarker::from_combined(marker.into_marker(requires_python))
7025}
7026
7027/// Returns the simplified string-ified version of each marker given.
7028///
7029/// Note that the marker strings returned will include conflict markers if they
7030/// are present.
7031fn simplified_universal_markers(
7032    markers: &[UniversalMarker],
7033    requires_python: &RequiresPython,
7034) -> Vec<String> {
7035    canonical_marker_trees(markers, requires_python)
7036        .into_iter()
7037        .filter_map(MarkerTree::try_to_string)
7038        .collect()
7039}
7040
7041/// Canonicalize universal markers to match the form persisted in `uv.lock`.
7042///
7043/// When the PEP 508 portions of the markers are disjoint, the lockfile stores
7044/// only those simplified PEP 508 markers. Otherwise, it stores the simplified
7045/// combined markers (including conflict markers). Markers that serialize to
7046/// `true` are omitted.
7047fn canonicalize_universal_markers(
7048    markers: &[UniversalMarker],
7049    requires_python: &RequiresPython,
7050) -> Vec<UniversalMarker> {
7051    canonical_marker_trees(markers, requires_python)
7052        .into_iter()
7053        .map(|marker| {
7054            let simplified = SimplifiedMarkerTree::new(requires_python, marker);
7055            UniversalMarker::from_combined(simplified.into_marker(requires_python))
7056        })
7057        .collect()
7058}
7059
7060/// Return the simplified marker trees that would be persisted in `uv.lock`.
7061fn canonical_marker_trees(
7062    markers: &[UniversalMarker],
7063    requires_python: &RequiresPython,
7064) -> Vec<MarkerTree> {
7065    let mut pep508_only = vec![];
7066    let mut seen = FxHashSet::default();
7067    for marker in markers {
7068        let simplified =
7069            SimplifiedMarkerTree::new(requires_python, marker.pep508()).as_simplified_marker_tree();
7070        if seen.insert(simplified) {
7071            pep508_only.push(simplified);
7072        }
7073    }
7074    let any_overlap = pep508_only
7075        .iter()
7076        .tuple_combinations()
7077        .any(|(&marker1, &marker2)| !marker1.is_disjoint(marker2));
7078    let markers = if !any_overlap {
7079        pep508_only
7080    } else {
7081        markers
7082            .iter()
7083            .map(|marker| {
7084                SimplifiedMarkerTree::new(requires_python, marker.combined())
7085                    .as_simplified_marker_tree()
7086            })
7087            .collect()
7088    };
7089    markers
7090        .into_iter()
7091        .filter(|marker| !marker.is_true())
7092        .collect()
7093}
7094
7095/// Filter out wheels that can't be selected for installation due to environment markers.
7096///
7097/// For example, a package included under `sys_platform == 'win32'` does not need Linux
7098/// wheels.
7099///
7100/// Returns `true` if the wheel is definitely unreachable, and `false` if it may be reachable,
7101/// including if the wheel tag isn't recognized.
7102fn is_wheel_unreachable_for_marker(
7103    filename: &WheelFilename,
7104    requires_python: &RequiresPython,
7105    marker: &UniversalMarker,
7106    tags: Option<&Tags>,
7107) -> bool {
7108    if let Some(tags) = tags
7109        && !filename.compatibility(tags).is_compatible()
7110    {
7111        return true;
7112    }
7113    // Remove wheels that don't match `requires-python` and can't be selected for installation.
7114    if !requires_python.matches_wheel_tag(filename) {
7115        return true;
7116    }
7117
7118    // Filter by platform tags.
7119
7120    // Naively, we'd check whether `platform_system == 'Linux'` is disjoint, or
7121    // `os_name == 'posix'` is disjoint, or `sys_platform == 'linux'` is disjoint (each on its
7122    // own sufficient to exclude linux wheels), but due to
7123    // `(A ∩ (B ∩ C) = ∅) => ((A ∩ B = ∅) or (A ∩ C = ∅))`
7124    // a single disjointness check with the intersection is sufficient, so we have one
7125    // constant per platform.
7126    let platform_tags = filename.platform_tags();
7127
7128    if platform_tags.iter().all(PlatformTag::is_any) {
7129        return false;
7130    }
7131
7132    if platform_tags.iter().all(PlatformTag::is_linux) {
7133        if platform_tags.iter().all(PlatformTag::is_arm) {
7134            if marker.is_disjoint(*LINUX_ARM_MARKERS) {
7135                return true;
7136            }
7137        } else if platform_tags.iter().all(PlatformTag::is_x86_64) {
7138            if marker.is_disjoint(*LINUX_X86_64_MARKERS) {
7139                return true;
7140            }
7141        } else if platform_tags.iter().all(PlatformTag::is_x86) {
7142            if marker.is_disjoint(*LINUX_X86_MARKERS) {
7143                return true;
7144            }
7145        } else if platform_tags.iter().all(PlatformTag::is_ppc64le) {
7146            if marker.is_disjoint(*LINUX_PPC64LE_MARKERS) {
7147                return true;
7148            }
7149        } else if platform_tags.iter().all(PlatformTag::is_ppc64) {
7150            if marker.is_disjoint(*LINUX_PPC64_MARKERS) {
7151                return true;
7152            }
7153        } else if platform_tags.iter().all(PlatformTag::is_s390x) {
7154            if marker.is_disjoint(*LINUX_S390X_MARKERS) {
7155                return true;
7156            }
7157        } else if platform_tags.iter().all(PlatformTag::is_riscv64) {
7158            if marker.is_disjoint(*LINUX_RISCV64_MARKERS) {
7159                return true;
7160            }
7161        } else if platform_tags.iter().all(PlatformTag::is_loongarch64) {
7162            if marker.is_disjoint(*LINUX_LOONGARCH64_MARKERS) {
7163                return true;
7164            }
7165        } else if platform_tags.iter().all(PlatformTag::is_armv7l) {
7166            if marker.is_disjoint(*LINUX_ARMV7L_MARKERS) {
7167                return true;
7168            }
7169        } else if platform_tags.iter().all(PlatformTag::is_armv6l) {
7170            if marker.is_disjoint(*LINUX_ARMV6L_MARKERS) {
7171                return true;
7172            }
7173        } else if marker.is_disjoint(*LINUX_MARKERS) {
7174            return true;
7175        }
7176    }
7177
7178    if platform_tags.iter().all(PlatformTag::is_windows) {
7179        if platform_tags.iter().all(PlatformTag::is_arm) {
7180            if marker.is_disjoint(*WINDOWS_ARM_MARKERS) {
7181                return true;
7182            }
7183        } else if platform_tags.iter().all(PlatformTag::is_x86_64) {
7184            if marker.is_disjoint(*WINDOWS_X86_64_MARKERS) {
7185                return true;
7186            }
7187        } else if platform_tags.iter().all(PlatformTag::is_x86) {
7188            if marker.is_disjoint(*WINDOWS_X86_MARKERS) {
7189                return true;
7190            }
7191        } else if marker.is_disjoint(*WINDOWS_MARKERS) {
7192            return true;
7193        }
7194    }
7195
7196    if platform_tags.iter().all(PlatformTag::is_macos) {
7197        if platform_tags.iter().all(PlatformTag::is_arm) {
7198            if marker.is_disjoint(*MAC_ARM_MARKERS) {
7199                return true;
7200            }
7201        } else if platform_tags.iter().all(PlatformTag::is_x86_64) {
7202            if marker.is_disjoint(*MAC_X86_64_MARKERS) {
7203                return true;
7204            }
7205        } else if platform_tags.iter().all(PlatformTag::is_x86) {
7206            if marker.is_disjoint(*MAC_X86_MARKERS) {
7207                return true;
7208            }
7209        } else if marker.is_disjoint(*MAC_MARKERS) {
7210            return true;
7211        }
7212    }
7213
7214    if platform_tags.iter().all(PlatformTag::is_android) {
7215        if platform_tags.iter().all(PlatformTag::is_arm) {
7216            if marker.is_disjoint(*ANDROID_ARM_MARKERS) {
7217                return true;
7218            }
7219        } else if platform_tags.iter().all(PlatformTag::is_x86_64) {
7220            if marker.is_disjoint(*ANDROID_X86_64_MARKERS) {
7221                return true;
7222            }
7223        } else if platform_tags.iter().all(PlatformTag::is_x86) {
7224            if marker.is_disjoint(*ANDROID_X86_MARKERS) {
7225                return true;
7226            }
7227        } else if marker.is_disjoint(*ANDROID_MARKERS) {
7228            return true;
7229        }
7230    }
7231
7232    if platform_tags.iter().all(PlatformTag::is_arm) {
7233        if marker.is_disjoint(*ARM_MARKERS) {
7234            return true;
7235        }
7236    }
7237
7238    if platform_tags.iter().all(PlatformTag::is_x86_64) {
7239        if marker.is_disjoint(*X86_64_MARKERS) {
7240            return true;
7241        }
7242    }
7243
7244    if platform_tags.iter().all(PlatformTag::is_x86) {
7245        if marker.is_disjoint(*X86_MARKERS) {
7246            return true;
7247        }
7248    }
7249
7250    if platform_tags.iter().all(PlatformTag::is_ppc64le) {
7251        if marker.is_disjoint(*PPC64LE_MARKERS) {
7252            return true;
7253        }
7254    }
7255
7256    if platform_tags.iter().all(PlatformTag::is_ppc64) {
7257        if marker.is_disjoint(*PPC64_MARKERS) {
7258            return true;
7259        }
7260    }
7261
7262    if platform_tags.iter().all(PlatformTag::is_s390x) {
7263        if marker.is_disjoint(*S390X_MARKERS) {
7264            return true;
7265        }
7266    }
7267
7268    if platform_tags.iter().all(PlatformTag::is_riscv64) {
7269        if marker.is_disjoint(*RISCV64_MARKERS) {
7270            return true;
7271        }
7272    }
7273
7274    if platform_tags.iter().all(PlatformTag::is_loongarch64) {
7275        if marker.is_disjoint(*LOONGARCH64_MARKERS) {
7276            return true;
7277        }
7278    }
7279
7280    if platform_tags.iter().all(PlatformTag::is_armv7l) {
7281        if marker.is_disjoint(*ARMV7L_MARKERS) {
7282            return true;
7283        }
7284    }
7285
7286    if platform_tags.iter().all(PlatformTag::is_armv6l) {
7287        if marker.is_disjoint(*ARMV6L_MARKERS) {
7288            return true;
7289        }
7290    }
7291
7292    false
7293}
7294
7295pub(crate) fn is_wheel_unreachable(
7296    filename: &WheelFilename,
7297    graph: &ResolverOutput,
7298    requires_python: &RequiresPython,
7299    node_index: NodeIndex,
7300    tags: Option<&Tags>,
7301) -> bool {
7302    is_wheel_unreachable_for_marker(
7303        filename,
7304        requires_python,
7305        graph.graph[node_index].marker(),
7306        tags,
7307    )
7308}
7309
7310#[cfg(test)]
7311mod tests {
7312    use uv_pep440::VersionSpecifiers;
7313    use uv_pep508::MarkerEnvironmentBuilder;
7314    use uv_warnings::anstream;
7315
7316    use super::*;
7317
7318    /// Assert a given display snapshot, stripping ANSI color codes.
7319    macro_rules! assert_stripped_snapshot {
7320        ($expr:expr, @$snapshot:literal) => {{
7321            let expr = format!("{}", $expr);
7322            let expr = format!("{}", anstream::adapter::strip_str(&expr));
7323            insta::assert_snapshot!(expr, @$snapshot);
7324        }};
7325    }
7326
7327    fn marker_environment() -> MarkerEnvironment {
7328        MarkerEnvironment::try_from(MarkerEnvironmentBuilder {
7329            implementation_name: "cpython",
7330            implementation_version: "3.12.0",
7331            os_name: "posix",
7332            platform_machine: "arm64",
7333            platform_python_implementation: "CPython",
7334            platform_release: "23.0.0",
7335            platform_system: "Darwin",
7336            platform_version: "test",
7337            python_full_version: "3.12.0",
7338            python_version: "3.12",
7339            sys_platform: "darwin",
7340        })
7341        .expect("valid marker environment")
7342    }
7343
7344    #[test]
7345    fn dependency_marker_preserves_parent_conflicts() {
7346        let requires_python = RequiresPython::from_specifiers(
7347            &VersionSpecifiers::from_str(">=3.12").expect("valid version specifier"),
7348        );
7349        let parent = UniversalMarker::from_combined(
7350            MarkerTree::from_str(
7351                "python_full_version >= '3.12' and sys_platform == 'darwin' and extra != 'extra-1-x-foo'",
7352            )
7353            .expect("valid parent marker"),
7354        );
7355        let environment = SimplifiedMarkerTree::new(&requires_python, MarkerTree::TRUE);
7356
7357        let marker = simplify_dependency_marker(&requires_python, environment, parent, parent);
7358
7359        assert_eq!(
7360            marker.combined().try_to_string().as_deref(),
7361            Some("python_full_version >= '3.12' and extra != 'extra-1-x-foo'")
7362        );
7363    }
7364
7365    #[test]
7366    fn dependency_selection_resolves_included_groups_to_same_package() {
7367        let lock: Lock = toml::from_str(
7368            r#"
7369version = 1
7370revision = 3
7371requires-python = ">=3.12"
7372
7373[[package]]
7374name = "project"
7375version = "0.1.0"
7376source = { virtual = "." }
7377dependencies = [{ name = "ty" }]
7378
7379[package.dependency-groups]
7380dev = [{ name = "ty" }]
7381typing = [{ name = "ty" }]
7382
7383[[package]]
7384name = "ty"
7385version = "1.0.0"
7386source = { registry = "https://example.com/simple" }
7387"#,
7388        )
7389        .expect("valid lock");
7390        let project_name = PackageName::from_str("project").expect("valid package name");
7391        let dependency_name = PackageName::from_str("ty").expect("valid package name");
7392        let dev = GroupName::from_str("dev").expect("valid group name");
7393        let typing = GroupName::from_str("typing").expect("valid group name");
7394        let marker_environment = marker_environment();
7395
7396        let selection = lock
7397            .dependency_selection(Some(&project_name), &dependency_name, &marker_environment)
7398            .expect("unique project package");
7399        let preferred = selection.group(&dev).expect("dev dependency");
7400        let included = selection.group(&typing).expect("typing dependency");
7401        let production = selection.production().expect("production dependency");
7402
7403        assert!(std::ptr::eq(preferred, included));
7404        assert!(std::ptr::eq(preferred, production));
7405    }
7406
7407    #[test]
7408    fn dependency_selection_resolves_lock_manifest_requirement() {
7409        let lock: Lock = toml::from_str(
7410            r#"
7411version = 1
7412revision = 3
7413requires-python = ">=3.12"
7414
7415[manifest]
7416requirements = [{ name = "ty" }]
7417
7418[[package]]
7419name = "ty"
7420version = "1.0.0"
7421source = { registry = "https://example.com/simple" }
7422"#,
7423        )
7424        .expect("valid lock");
7425        let dependency_name = PackageName::from_str("ty").expect("valid package name");
7426        let marker_environment = marker_environment();
7427
7428        let selection = lock
7429            .dependency_selection(None, &dependency_name, &marker_environment)
7430            .expect("unique root package");
7431        let root = selection.root().expect("root dependency");
7432
7433        assert_eq!(root.name(), &dependency_name);
7434        assert!(selection.production().is_none());
7435    }
7436
7437    #[test]
7438    fn dependency_selection_returns_any_selection_error() {
7439        let lock: Lock = toml::from_str(
7440            r#"
7441version = 1
7442revision = 3
7443requires-python = ">=3.12"
7444
7445[[package]]
7446name = "project"
7447version = "0.1.0"
7448source = { virtual = "." }
7449dependencies = [
7450    { name = "ty", version = "1.0.0", source = { registry = "https://example.com/simple" } },
7451    { name = "ty", version = "2.0.0", source = { registry = "https://example.com/simple" } },
7452]
7453
7454[package.dependency-groups]
7455dev = [
7456    { name = "ty", version = "1.0.0", source = { registry = "https://example.com/simple" } },
7457]
7458
7459[[package]]
7460name = "ty"
7461version = "1.0.0"
7462source = { registry = "https://example.com/simple" }
7463
7464[[package]]
7465name = "ty"
7466version = "2.0.0"
7467source = { registry = "https://example.com/simple" }
7468"#,
7469        )
7470        .expect("valid lock");
7471        let project_name = PackageName::from_str("project").expect("valid package name");
7472        let dependency_name = PackageName::from_str("ty").expect("valid package name");
7473        let marker_environment = marker_environment();
7474
7475        let error = lock
7476            .dependency_selection(Some(&project_name), &dependency_name, &marker_environment)
7477            .expect_err("ambiguous production selection");
7478        insta::assert_snapshot!(error, @"found multiple packages matching production dependency `ty` for `project`");
7479    }
7480
7481    #[test]
7482    fn missing_dependency_source_unambiguous() {
7483        let data = r#"
7484version = 1
7485requires-python = ">=3.12"
7486
7487[[package]]
7488name = "a"
7489version = "0.1.0"
7490source = { registry = "https://pypi.org/simple" }
7491sdist = { url = "https://example.com", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 0 }
7492
7493[[package]]
7494name = "b"
7495version = "0.1.0"
7496source = { registry = "https://pypi.org/simple" }
7497sdist = { url = "https://example.com", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 0 }
7498
7499[[package.dependencies]]
7500name = "a"
7501version = "0.1.0"
7502"#;
7503        let result: Result<Lock, _> = toml::from_str(data);
7504        insta::assert_debug_snapshot!(result);
7505    }
7506
7507    #[test]
7508    fn missing_dependency_version_unambiguous() {
7509        let data = r#"
7510version = 1
7511requires-python = ">=3.12"
7512
7513[[package]]
7514name = "a"
7515version = "0.1.0"
7516source = { registry = "https://pypi.org/simple" }
7517sdist = { url = "https://example.com", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 0 }
7518
7519[[package]]
7520name = "b"
7521version = "0.1.0"
7522source = { registry = "https://pypi.org/simple" }
7523sdist = { url = "https://example.com", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 0 }
7524
7525[[package.dependencies]]
7526name = "a"
7527source = { registry = "https://pypi.org/simple" }
7528"#;
7529        let result: Result<Lock, _> = toml::from_str(data);
7530        insta::assert_debug_snapshot!(result);
7531    }
7532
7533    #[test]
7534    fn missing_dependency_source_version_unambiguous() {
7535        let data = r#"
7536version = 1
7537requires-python = ">=3.12"
7538
7539[[package]]
7540name = "a"
7541version = "0.1.0"
7542source = { registry = "https://pypi.org/simple" }
7543sdist = { url = "https://example.com", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 0 }
7544
7545[[package]]
7546name = "b"
7547version = "0.1.0"
7548source = { registry = "https://pypi.org/simple" }
7549sdist = { url = "https://example.com", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 0 }
7550
7551[[package.dependencies]]
7552name = "a"
7553"#;
7554        let result: Result<Lock, _> = toml::from_str(data);
7555        insta::assert_debug_snapshot!(result);
7556    }
7557
7558    #[test]
7559    fn missing_dependency_source_ambiguous() {
7560        let data = r#"
7561version = 1
7562requires-python = ">=3.12"
7563
7564[[package]]
7565name = "a"
7566version = "0.1.0"
7567source = { registry = "https://pypi.org/simple" }
7568sdist = { url = "https://example.com", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 0 }
7569
7570[[package]]
7571name = "a"
7572version = "0.1.1"
7573source = { registry = "https://pypi.org/simple" }
7574sdist = { url = "https://example.com", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 0 }
7575
7576[[package]]
7577name = "b"
7578version = "0.1.0"
7579source = { registry = "https://pypi.org/simple" }
7580sdist = { url = "https://example.com", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 0 }
7581
7582[[package.dependencies]]
7583name = "a"
7584version = "0.1.0"
7585"#;
7586        let result = toml::from_str::<Lock>(data).unwrap_err();
7587        assert_stripped_snapshot!(result, @"Dependency `a` has missing `source` field but has more than one matching package");
7588    }
7589
7590    #[test]
7591    fn missing_dependency_version_ambiguous() {
7592        let data = r#"
7593version = 1
7594requires-python = ">=3.12"
7595
7596[[package]]
7597name = "a"
7598version = "0.1.0"
7599source = { registry = "https://pypi.org/simple" }
7600sdist = { url = "https://example.com", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 0 }
7601
7602[[package]]
7603name = "a"
7604version = "0.1.1"
7605source = { registry = "https://pypi.org/simple" }
7606sdist = { url = "https://example.com", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 0 }
7607
7608[[package]]
7609name = "b"
7610version = "0.1.0"
7611source = { registry = "https://pypi.org/simple" }
7612sdist = { url = "https://example.com", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 0 }
7613
7614[[package.dependencies]]
7615name = "a"
7616source = { registry = "https://pypi.org/simple" }
7617"#;
7618        let result = toml::from_str::<Lock>(data).unwrap_err();
7619        assert_stripped_snapshot!(result, @"Dependency `a` has missing `version` field but has more than one matching package");
7620    }
7621
7622    #[test]
7623    fn missing_dependency_source_version_ambiguous() {
7624        let data = r#"
7625version = 1
7626requires-python = ">=3.12"
7627
7628[[package]]
7629name = "a"
7630version = "0.1.0"
7631source = { registry = "https://pypi.org/simple" }
7632sdist = { url = "https://example.com", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 0 }
7633
7634[[package]]
7635name = "a"
7636version = "0.1.1"
7637source = { registry = "https://pypi.org/simple" }
7638sdist = { url = "https://example.com", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 0 }
7639
7640[[package]]
7641name = "b"
7642version = "0.1.0"
7643source = { registry = "https://pypi.org/simple" }
7644sdist = { url = "https://example.com", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 0 }
7645
7646[[package.dependencies]]
7647name = "a"
7648"#;
7649        let result = toml::from_str::<Lock>(data).unwrap_err();
7650        assert_stripped_snapshot!(result, @"Dependency `a` has missing `source` field but has more than one matching package");
7651    }
7652
7653    #[test]
7654    fn missing_dependency_version_dynamic() {
7655        let data = r#"
7656version = 1
7657requires-python = ">=3.12"
7658
7659[[package]]
7660name = "a"
7661source = { editable = "path/to/a" }
7662
7663[[package]]
7664name = "a"
7665version = "0.1.1"
7666source = { registry = "https://pypi.org/simple" }
7667sdist = { url = "https://example.com", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 0 }
7668
7669[[package]]
7670name = "b"
7671version = "0.1.0"
7672source = { registry = "https://pypi.org/simple" }
7673sdist = { url = "https://example.com", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 0 }
7674
7675[[package.dependencies]]
7676name = "a"
7677source = { editable = "path/to/a" }
7678"#;
7679        let result = toml::from_str::<Lock>(data);
7680        insta::assert_debug_snapshot!(result);
7681    }
7682
7683    #[test]
7684    fn hash_optional_missing() {
7685        let data = r#"
7686version = 1
7687requires-python = ">=3.12"
7688
7689[[package]]
7690name = "anyio"
7691version = "4.3.0"
7692source = { registry = "https://pypi.org/simple" }
7693wheels = [{ url = "https://files.pythonhosted.org/packages/14/fd/2f20c40b45e4fb4324834aea24bd4afdf1143390242c0b33774da0e2e34f/anyio-4.3.0-py3-none-any.whl" }]
7694"#;
7695        let result: Result<Lock, _> = toml::from_str(data);
7696        insta::assert_debug_snapshot!(result);
7697    }
7698
7699    #[test]
7700    fn hash_optional_present() {
7701        let data = r#"
7702version = 1
7703requires-python = ">=3.12"
7704
7705[[package]]
7706name = "anyio"
7707version = "4.3.0"
7708source = { registry = "https://pypi.org/simple" }
7709wheels = [{ url = "https://files.pythonhosted.org/packages/14/fd/2f20c40b45e4fb4324834aea24bd4afdf1143390242c0b33774da0e2e34f/anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8" }]
7710"#;
7711        let result: Result<Lock, _> = toml::from_str(data);
7712        insta::assert_debug_snapshot!(result);
7713    }
7714
7715    #[test]
7716    fn hash_required_present() {
7717        let data = r#"
7718version = 1
7719requires-python = ">=3.12"
7720
7721[[package]]
7722name = "anyio"
7723version = "4.3.0"
7724source = { path = "file:///foo/bar" }
7725wheels = [{ url = "file:///foo/bar/anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8" }]
7726"#;
7727        let result: Result<Lock, _> = toml::from_str(data);
7728        insta::assert_debug_snapshot!(result);
7729    }
7730
7731    #[test]
7732    fn source_direct_no_subdir() {
7733        let data = r#"
7734version = 1
7735requires-python = ">=3.12"
7736
7737[[package]]
7738name = "anyio"
7739version = "4.3.0"
7740source = { url = "https://burntsushi.net" }
7741"#;
7742        let result: Result<Lock, _> = toml::from_str(data);
7743        insta::assert_debug_snapshot!(result);
7744    }
7745
7746    #[test]
7747    fn source_direct_has_subdir() {
7748        let data = r#"
7749version = 1
7750requires-python = ">=3.12"
7751
7752[[package]]
7753name = "anyio"
7754version = "4.3.0"
7755source = { url = "https://burntsushi.net", subdirectory = "wat/foo/bar" }
7756"#;
7757        let result: Result<Lock, _> = toml::from_str(data);
7758        insta::assert_debug_snapshot!(result);
7759    }
7760
7761    #[test]
7762    fn source_directory() {
7763        let data = r#"
7764version = 1
7765requires-python = ">=3.12"
7766
7767[[package]]
7768name = "anyio"
7769version = "4.3.0"
7770source = { directory = "path/to/dir" }
7771"#;
7772        let result: Result<Lock, _> = toml::from_str(data);
7773        insta::assert_debug_snapshot!(result);
7774    }
7775
7776    #[test]
7777    fn source_editable() {
7778        let data = r#"
7779version = 1
7780requires-python = ">=3.12"
7781
7782[[package]]
7783name = "anyio"
7784version = "4.3.0"
7785source = { editable = "path/to/dir" }
7786"#;
7787        let result: Result<Lock, _> = toml::from_str(data);
7788        insta::assert_debug_snapshot!(result);
7789    }
7790
7791    /// Windows drive letter paths like `C:/...` should be deserialized as local path registry
7792    /// sources, not as URLs. The `C:` prefix must not be misinterpreted as a URL scheme.
7793    #[test]
7794    fn registry_source_windows_drive_letter() {
7795        let data = r#"
7796version = 1
7797requires-python = ">=3.12"
7798
7799[[package]]
7800name = "tqdm"
7801version = "1000.0.0"
7802source = { registry = "C:/Users/user/links" }
7803wheels = [
7804    { path = "C:/Users/user/links/tqdm-1000.0.0-py3-none-any.whl" },
7805]
7806"#;
7807        let lock: Lock = toml::from_str(data).unwrap();
7808        assert_eq!(
7809            lock.packages[0].id.source,
7810            Source::Registry(RegistrySource::Path(
7811                Path::new("C:/Users/user/links").into()
7812            ))
7813        );
7814    }
7815}