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