Skip to main content

uv_resolver/lock/
mod.rs

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