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