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, 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::exclude_newer::ExcludeNewerSpan;
58use crate::fork_strategy::ForkStrategy;
59pub(crate) use crate::lock::export::PylockTomlPackage;
60pub use crate::lock::export::RequirementsTxtExport;
61pub use crate::lock::export::{PylockToml, PylockTomlErrorKind, cyclonedx_json};
62pub use crate::lock::installable::Installable;
63pub use crate::lock::map::PackageMap;
64pub use crate::lock::tree::TreeDisplay;
65use crate::resolution::{AnnotatedDist, ResolutionGraphNode};
66use crate::universal_marker::{ConflictMarker, UniversalMarker};
67use crate::{
68    ExcludeNewer, ExcludeNewerPackage, ExcludeNewerValue, InMemoryIndex, MetadataResponse,
69    PackageExcludeNewer, PrereleaseMode, ResolutionMode, ResolverOutput,
70};
71
72mod export;
73mod installable;
74mod map;
75mod tree;
76
77/// The current version of the lockfile format.
78pub const VERSION: u32 = 1;
79
80/// The current revision of the lockfile format.
81const REVISION: u32 = 3;
82
83static LINUX_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
84    let pep508 = MarkerTree::from_str("os_name == 'posix' and sys_platform == 'linux'").unwrap();
85    UniversalMarker::new(pep508, ConflictMarker::TRUE)
86});
87static WINDOWS_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
88    let pep508 = MarkerTree::from_str("os_name == 'nt' and sys_platform == 'win32'").unwrap();
89    UniversalMarker::new(pep508, ConflictMarker::TRUE)
90});
91static MAC_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
92    let pep508 = MarkerTree::from_str("os_name == 'posix' and sys_platform == 'darwin'").unwrap();
93    UniversalMarker::new(pep508, ConflictMarker::TRUE)
94});
95static ANDROID_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
96    let pep508 = MarkerTree::from_str("sys_platform == 'android'").unwrap();
97    UniversalMarker::new(pep508, ConflictMarker::TRUE)
98});
99static ARM_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
100    let pep508 =
101        MarkerTree::from_str("platform_machine == 'aarch64' or platform_machine == 'arm64' or platform_machine == 'ARM64'")
102            .unwrap();
103    UniversalMarker::new(pep508, ConflictMarker::TRUE)
104});
105static X86_64_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
106    let pep508 =
107        MarkerTree::from_str("platform_machine == 'x86_64' or platform_machine == 'amd64' or platform_machine == 'AMD64'")
108            .unwrap();
109    UniversalMarker::new(pep508, ConflictMarker::TRUE)
110});
111static X86_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
112    let pep508 = MarkerTree::from_str(
113        "platform_machine == 'i686' or platform_machine == 'i386' or platform_machine == 'win32' or platform_machine == 'x86'",
114    )
115    .unwrap();
116    UniversalMarker::new(pep508, ConflictMarker::TRUE)
117});
118static PPC64LE_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
119    let pep508 = MarkerTree::from_str("platform_machine == 'ppc64le'").unwrap();
120    UniversalMarker::new(pep508, ConflictMarker::TRUE)
121});
122static PPC64_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
123    let pep508 = MarkerTree::from_str("platform_machine == 'ppc64'").unwrap();
124    UniversalMarker::new(pep508, ConflictMarker::TRUE)
125});
126static S390X_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
127    let pep508 = MarkerTree::from_str("platform_machine == 's390x'").unwrap();
128    UniversalMarker::new(pep508, ConflictMarker::TRUE)
129});
130static RISCV64_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
131    let pep508 = MarkerTree::from_str("platform_machine == 'riscv64'").unwrap();
132    UniversalMarker::new(pep508, ConflictMarker::TRUE)
133});
134static LOONGARCH64_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
135    let pep508 = MarkerTree::from_str("platform_machine == 'loongarch64'").unwrap();
136    UniversalMarker::new(pep508, ConflictMarker::TRUE)
137});
138static ARMV7L_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
139    let pep508 =
140        MarkerTree::from_str("platform_machine == 'armv7l' or platform_machine == 'armv8l'")
141            .unwrap();
142    UniversalMarker::new(pep508, ConflictMarker::TRUE)
143});
144static ARMV6L_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
145    let pep508 = MarkerTree::from_str("platform_machine == 'armv6l'").unwrap();
146    UniversalMarker::new(pep508, ConflictMarker::TRUE)
147});
148static LINUX_ARM_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
149    let mut marker = *LINUX_MARKERS;
150    marker.and(*ARM_MARKERS);
151    marker
152});
153static LINUX_X86_64_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
154    let mut marker = *LINUX_MARKERS;
155    marker.and(*X86_64_MARKERS);
156    marker
157});
158static LINUX_X86_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
159    let mut marker = *LINUX_MARKERS;
160    marker.and(*X86_MARKERS);
161    marker
162});
163static LINUX_PPC64LE_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
164    let mut marker = *LINUX_MARKERS;
165    marker.and(*PPC64LE_MARKERS);
166    marker
167});
168static LINUX_PPC64_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
169    let mut marker = *LINUX_MARKERS;
170    marker.and(*PPC64_MARKERS);
171    marker
172});
173static LINUX_S390X_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
174    let mut marker = *LINUX_MARKERS;
175    marker.and(*S390X_MARKERS);
176    marker
177});
178static LINUX_RISCV64_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
179    let mut marker = *LINUX_MARKERS;
180    marker.and(*RISCV64_MARKERS);
181    marker
182});
183static LINUX_LOONGARCH64_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
184    let mut marker = *LINUX_MARKERS;
185    marker.and(*LOONGARCH64_MARKERS);
186    marker
187});
188static LINUX_ARMV7L_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
189    let mut marker = *LINUX_MARKERS;
190    marker.and(*ARMV7L_MARKERS);
191    marker
192});
193static LINUX_ARMV6L_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
194    let mut marker = *LINUX_MARKERS;
195    marker.and(*ARMV6L_MARKERS);
196    marker
197});
198static WINDOWS_ARM_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
199    let mut marker = *WINDOWS_MARKERS;
200    marker.and(*ARM_MARKERS);
201    marker
202});
203static WINDOWS_X86_64_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
204    let mut marker = *WINDOWS_MARKERS;
205    marker.and(*X86_64_MARKERS);
206    marker
207});
208static WINDOWS_X86_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
209    let mut marker = *WINDOWS_MARKERS;
210    marker.and(*X86_MARKERS);
211    marker
212});
213static MAC_ARM_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
214    let mut marker = *MAC_MARKERS;
215    marker.and(*ARM_MARKERS);
216    marker
217});
218static MAC_X86_64_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
219    let mut marker = *MAC_MARKERS;
220    marker.and(*X86_64_MARKERS);
221    marker
222});
223static MAC_X86_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
224    let mut marker = *MAC_MARKERS;
225    marker.and(*X86_MARKERS);
226    marker
227});
228static ANDROID_ARM_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
229    let mut marker = *ANDROID_MARKERS;
230    marker.and(*ARM_MARKERS);
231    marker
232});
233static ANDROID_X86_64_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
234    let mut marker = *ANDROID_MARKERS;
235    marker.and(*X86_64_MARKERS);
236    marker
237});
238static ANDROID_X86_MARKERS: LazyLock<UniversalMarker> = LazyLock::new(|| {
239    let mut marker = *ANDROID_MARKERS;
240    marker.and(*X86_MARKERS);
241    marker
242});
243
244/// A distribution with its associated hash.
245///
246/// This pairs a [`Dist`] with the [`HashDigests`] for the specific wheel or
247/// sdist that would be installed.
248pub(crate) struct HashedDist {
249    pub(crate) dist: Dist,
250    pub(crate) hashes: HashDigests,
251}
252
253#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
254#[serde(try_from = "LockWire")]
255pub struct Lock {
256    /// The (major) version of the lockfile format.
257    ///
258    /// Changes to the major version indicate backwards- and forwards-incompatible changes to the
259    /// lockfile format. A given uv version only supports a single major version of the lockfile
260    /// format.
261    ///
262    /// In other words, a version of uv that supports version 2 of the lockfile format will not be
263    /// able to read lockfiles generated under version 1 or 3.
264    version: u32,
265    /// The revision of the lockfile format.
266    ///
267    /// Changes to the revision indicate backwards-compatible changes to the lockfile format.
268    /// In other words, versions of uv that only support revision 1 _will_ be able to read lockfiles
269    /// with a revision greater than 1 (though they may ignore newer fields).
270    revision: u32,
271    /// If this lockfile was built from a forking resolution with non-identical forks, store the
272    /// forks in the lockfile so we can recreate them in subsequent resolutions.
273    fork_markers: Vec<UniversalMarker>,
274    /// The conflicting groups/extras specified by the user.
275    conflicts: Conflicts,
276    /// The list of supported environments specified by the user.
277    supported_environments: Vec<MarkerTree>,
278    /// The list of required platforms specified by the user.
279    required_environments: Vec<MarkerTree>,
280    /// The range of supported Python versions.
281    requires_python: RequiresPython,
282    /// We discard the lockfile if these options don't match.
283    options: ResolverOptions,
284    /// The actual locked version and their metadata.
285    packages: Vec<Package>,
286    /// A map from package ID to index in `packages`.
287    ///
288    /// This can be used to quickly lookup the full package for any ID
289    /// in this lock. For example, the dependencies for each package are
290    /// listed as package IDs. This map can be used to find the full
291    /// package for each such dependency.
292    ///
293    /// It is guaranteed that every package in this lock has an entry in
294    /// this map, and that every dependency for every package has an ID
295    /// that exists in this map. That is, there are no dependencies that don't
296    /// have a corresponding locked package entry in the same lockfile.
297    by_id: FxHashMap<PackageId, usize>,
298    /// The input requirements to the resolution.
299    manifest: ResolverManifest,
300}
301
302impl Lock {
303    /// Initialize a [`Lock`] from a [`ResolverOutput`].
304    pub fn from_resolution(
305        resolution: &ResolverOutput,
306        root: &Path,
307        supported_environments: Vec<MarkerTree>,
308    ) -> Result<Self, LockError> {
309        let mut packages = BTreeMap::new();
310        let requires_python = resolution.requires_python.clone();
311        let supported_environments = supported_environments
312            .into_iter()
313            .map(|marker| requires_python.complexify_markers(marker))
314            .collect::<Vec<_>>();
315        let supported_environments_marker = if supported_environments.is_empty() {
316            None
317        } else {
318            let mut combined = MarkerTree::FALSE;
319            for marker in &supported_environments {
320                combined.or(*marker);
321            }
322            Some(UniversalMarker::new(combined, ConflictMarker::TRUE))
323        };
324
325        // Determine the set of packages included at multiple versions.
326        let mut seen = FxHashSet::default();
327        let mut duplicates = FxHashSet::default();
328        for node_index in resolution.graph.node_indices() {
329            let ResolutionGraphNode::Dist(dist) = &resolution.graph[node_index] else {
330                continue;
331            };
332            if !dist.is_base() {
333                continue;
334            }
335            if !seen.insert(dist.name()) {
336                duplicates.insert(dist.name());
337            }
338        }
339
340        // Lock all base packages.
341        for node_index in resolution.graph.node_indices() {
342            let ResolutionGraphNode::Dist(dist) = &resolution.graph[node_index] else {
343                continue;
344            };
345            if !dist.is_base() {
346                continue;
347            }
348
349            // If there are multiple distributions for the same package, include the markers of all
350            // forks that included the current distribution.
351            //
352            // Normalize with a simplify/complexify round-trip through `requires-python` to
353            // match markers deserialized from the lockfile (which get complexified on read).
354            let fork_markers = if duplicates.contains(dist.name()) {
355                resolution
356                    .fork_markers
357                    .iter()
358                    .filter(|fork_markers| !fork_markers.is_disjoint(dist.marker))
359                    .map(|marker| {
360                        let simplified =
361                            SimplifiedMarkerTree::new(&requires_python, marker.combined());
362                        UniversalMarker::from_combined(simplified.into_marker(&requires_python))
363                    })
364                    .collect()
365            } else {
366                vec![]
367            };
368
369            let mut package = Package::from_annotated_dist(dist, fork_markers, root)?;
370            let mut wheel_marker = dist.marker;
371            if let Some(supported_environments_marker) = supported_environments_marker {
372                wheel_marker.and(supported_environments_marker);
373            }
374            let wheels = &mut package.wheels;
375            wheels.retain(|wheel| {
376                !is_wheel_unreachable_for_marker(
377                    &wheel.filename,
378                    &requires_python,
379                    &wheel_marker,
380                    None,
381                )
382            });
383
384            // Add all dependencies
385            for edge in resolution.graph.edges(node_index) {
386                let ResolutionGraphNode::Dist(dependency_dist) = &resolution.graph[edge.target()]
387                else {
388                    continue;
389                };
390                let marker = *edge.weight();
391                package.add_dependency(&requires_python, dependency_dist, marker, root)?;
392            }
393
394            let id = package.id.clone();
395            if let Some(locked_dist) = packages.insert(id, package) {
396                return Err(LockErrorKind::DuplicatePackage {
397                    id: locked_dist.id.clone(),
398                }
399                .into());
400            }
401        }
402
403        // Lock all extras and development dependencies.
404        for node_index in resolution.graph.node_indices() {
405            let ResolutionGraphNode::Dist(dist) = &resolution.graph[node_index] else {
406                continue;
407            };
408            if let Some(extra) = dist.extra.as_ref() {
409                let id = PackageId::from_annotated_dist(dist, root)?;
410                let Some(package) = packages.get_mut(&id) else {
411                    return Err(LockErrorKind::MissingExtraBase {
412                        id,
413                        extra: extra.clone(),
414                    }
415                    .into());
416                };
417                for edge in resolution.graph.edges(node_index) {
418                    let ResolutionGraphNode::Dist(dependency_dist) =
419                        &resolution.graph[edge.target()]
420                    else {
421                        continue;
422                    };
423                    let marker = *edge.weight();
424                    package.add_optional_dependency(
425                        &requires_python,
426                        extra.clone(),
427                        dependency_dist,
428                        marker,
429                        root,
430                    )?;
431                }
432            }
433            if let Some(group) = dist.group.as_ref() {
434                let id = PackageId::from_annotated_dist(dist, root)?;
435                let Some(package) = packages.get_mut(&id) else {
436                    return Err(LockErrorKind::MissingDevBase {
437                        id,
438                        group: group.clone(),
439                    }
440                    .into());
441                };
442                for edge in resolution.graph.edges(node_index) {
443                    let ResolutionGraphNode::Dist(dependency_dist) =
444                        &resolution.graph[edge.target()]
445                    else {
446                        continue;
447                    };
448                    let marker = *edge.weight();
449                    package.add_group_dependency(
450                        &requires_python,
451                        group.clone(),
452                        dependency_dist,
453                        marker,
454                        root,
455                    )?;
456                }
457            }
458        }
459
460        let packages = packages.into_values().collect();
461
462        let options = ResolverOptions {
463            resolution_mode: resolution.options.resolution_mode,
464            prerelease_mode: resolution.options.prerelease_mode,
465            fork_strategy: resolution.options.fork_strategy,
466            exclude_newer: resolution.options.exclude_newer.clone().into(),
467        };
468        // Normalize fork markers with a simplify/complexify round-trip through
469        // `requires-python`. This ensures markers from the resolver (which don't include
470        // `requires-python` bounds) match markers deserialized from the lockfile (which get
471        // complexified on read).
472        let fork_markers = resolution
473            .fork_markers
474            .iter()
475            .map(|marker| {
476                let simplified = SimplifiedMarkerTree::new(&requires_python, marker.combined());
477                UniversalMarker::from_combined(simplified.into_marker(&requires_python))
478            })
479            .collect();
480        let lock = Self::new(
481            VERSION,
482            REVISION,
483            packages,
484            requires_python,
485            options,
486            ResolverManifest::default(),
487            Conflicts::empty(),
488            supported_environments,
489            vec![],
490            fork_markers,
491        )?;
492        Ok(lock)
493    }
494
495    /// Initialize a [`Lock`] from a list of [`Package`] entries.
496    fn new(
497        version: u32,
498        revision: u32,
499        mut packages: Vec<Package>,
500        requires_python: RequiresPython,
501        options: ResolverOptions,
502        manifest: ResolverManifest,
503        conflicts: Conflicts,
504        supported_environments: Vec<MarkerTree>,
505        required_environments: Vec<MarkerTree>,
506        fork_markers: Vec<UniversalMarker>,
507    ) -> Result<Self, LockError> {
508        // Put all dependencies for each package in a canonical order and
509        // check for duplicates.
510        for package in &mut packages {
511            package.dependencies.sort();
512            for windows in package.dependencies.windows(2) {
513                let (dep1, dep2) = (&windows[0], &windows[1]);
514                if dep1 == dep2 {
515                    return Err(LockErrorKind::DuplicateDependency {
516                        id: package.id.clone(),
517                        dependency: dep1.clone(),
518                    }
519                    .into());
520                }
521            }
522
523            // Perform the same validation for optional dependencies.
524            for (extra, dependencies) in &mut package.optional_dependencies {
525                dependencies.sort();
526                for windows in dependencies.windows(2) {
527                    let (dep1, dep2) = (&windows[0], &windows[1]);
528                    if dep1 == dep2 {
529                        return Err(LockErrorKind::DuplicateOptionalDependency {
530                            id: package.id.clone(),
531                            extra: extra.clone(),
532                            dependency: dep1.clone(),
533                        }
534                        .into());
535                    }
536                }
537            }
538
539            // Perform the same validation for dev dependencies.
540            for (group, dependencies) in &mut package.dependency_groups {
541                dependencies.sort();
542                for windows in dependencies.windows(2) {
543                    let (dep1, dep2) = (&windows[0], &windows[1]);
544                    if dep1 == dep2 {
545                        return Err(LockErrorKind::DuplicateDevDependency {
546                            id: package.id.clone(),
547                            group: group.clone(),
548                            dependency: dep1.clone(),
549                        }
550                        .into());
551                    }
552                }
553            }
554        }
555        packages.sort_by(|dist1, dist2| dist1.id.cmp(&dist2.id));
556
557        // Check for duplicate package IDs and also build up the map for
558        // packages keyed by their ID.
559        let mut by_id = FxHashMap::default();
560        for (i, dist) in packages.iter().enumerate() {
561            if by_id.insert(dist.id.clone(), i).is_some() {
562                return Err(LockErrorKind::DuplicatePackage {
563                    id: dist.id.clone(),
564                }
565                .into());
566            }
567        }
568
569        // Build up a map from ID to extras.
570        let mut extras_by_id = FxHashMap::default();
571        for dist in &packages {
572            for extra in dist.optional_dependencies.keys() {
573                extras_by_id
574                    .entry(dist.id.clone())
575                    .or_insert_with(FxHashSet::default)
576                    .insert(extra.clone());
577            }
578        }
579
580        // Remove any non-existent extras (e.g., extras that were requested but don't exist).
581        for dist in &mut packages {
582            for dep in dist
583                .dependencies
584                .iter_mut()
585                .chain(dist.optional_dependencies.values_mut().flatten())
586                .chain(dist.dependency_groups.values_mut().flatten())
587            {
588                dep.extra.retain(|extra| {
589                    extras_by_id
590                        .get(&dep.package_id)
591                        .is_some_and(|extras| extras.contains(extra))
592                });
593            }
594        }
595
596        // Check that every dependency has an entry in `by_id`. If any don't,
597        // it implies we somehow have a dependency with no corresponding locked
598        // package.
599        for dist in &packages {
600            for dep in &dist.dependencies {
601                if !by_id.contains_key(&dep.package_id) {
602                    return Err(LockErrorKind::UnrecognizedDependency {
603                        id: dist.id.clone(),
604                        dependency: dep.clone(),
605                    }
606                    .into());
607                }
608            }
609
610            // Perform the same validation for optional dependencies.
611            for dependencies in dist.optional_dependencies.values() {
612                for dep in dependencies {
613                    if !by_id.contains_key(&dep.package_id) {
614                        return Err(LockErrorKind::UnrecognizedDependency {
615                            id: dist.id.clone(),
616                            dependency: dep.clone(),
617                        }
618                        .into());
619                    }
620                }
621            }
622
623            // Perform the same validation for dev dependencies.
624            for dependencies in dist.dependency_groups.values() {
625                for dep in dependencies {
626                    if !by_id.contains_key(&dep.package_id) {
627                        return Err(LockErrorKind::UnrecognizedDependency {
628                            id: dist.id.clone(),
629                            dependency: dep.clone(),
630                        }
631                        .into());
632                    }
633                }
634            }
635
636            // Also check that our sources are consistent with whether we have
637            // hashes or not.
638            if let Some(requires_hash) = dist.id.source.requires_hash() {
639                for wheel in &dist.wheels {
640                    if requires_hash != wheel.hash.is_some() {
641                        return Err(LockErrorKind::Hash {
642                            id: dist.id.clone(),
643                            artifact_type: "wheel",
644                            expected: requires_hash,
645                        }
646                        .into());
647                    }
648                }
649            }
650        }
651        let lock = Self {
652            version,
653            revision,
654            fork_markers,
655            conflicts,
656            supported_environments,
657            required_environments,
658            requires_python,
659            options,
660            packages,
661            by_id,
662            manifest,
663        };
664        Ok(lock)
665    }
666
667    /// Record the requirements that were used to generate this lock.
668    #[must_use]
669    pub fn with_manifest(mut self, manifest: ResolverManifest) -> Self {
670        self.manifest = manifest;
671        self
672    }
673
674    /// Record the conflicting groups that were used to generate this lock.
675    #[must_use]
676    pub fn with_conflicts(mut self, conflicts: Conflicts) -> Self {
677        self.conflicts = conflicts;
678        self
679    }
680
681    /// Record the supported environments that were used to generate this lock.
682    #[must_use]
683    pub fn with_supported_environments(mut self, supported_environments: Vec<MarkerTree>) -> Self {
684        // We "complexify" the markers given, since the supported
685        // environments given might be coming directly from what's written in
686        // `pyproject.toml`, and those are assumed to be simplified (i.e.,
687        // they assume `requires-python` is true). But a `Lock` always uses
688        // non-simplified markers internally, so we need to re-complexify them
689        // here.
690        //
691        // The nice thing about complexifying is that it's a no-op if the
692        // markers given have already been complexified.
693        self.supported_environments = supported_environments
694            .into_iter()
695            .map(|marker| self.requires_python.complexify_markers(marker))
696            .collect();
697        self
698    }
699
700    /// Record the required platforms that were used to generate this lock.
701    #[must_use]
702    pub fn with_required_environments(mut self, required_environments: Vec<MarkerTree>) -> Self {
703        self.required_environments = required_environments
704            .into_iter()
705            .map(|marker| self.requires_python.complexify_markers(marker))
706            .collect();
707        self
708    }
709
710    /// Returns `true` if this [`Lock`] includes `provides-extra` metadata.
711    pub fn supports_provides_extra(&self) -> bool {
712        // `provides-extra` was added in Version 1 Revision 1.
713        (self.version(), self.revision()) >= (1, 1)
714    }
715
716    /// Returns `true` if this [`Lock`] includes entries for empty `dependency-group` metadata.
717    pub fn includes_empty_groups(&self) -> bool {
718        // Empty dependency groups are included as of https://github.com/astral-sh/uv/pull/8598,
719        // but Version 1 Revision 1 is the first revision published after that change.
720        (self.version(), self.revision()) >= (1, 1)
721    }
722
723    /// Returns the lockfile version.
724    pub fn version(&self) -> u32 {
725        self.version
726    }
727
728    /// Returns the lockfile revision.
729    pub fn revision(&self) -> u32 {
730        self.revision
731    }
732
733    /// Returns the number of packages in the lockfile.
734    pub fn len(&self) -> usize {
735        self.packages.len()
736    }
737
738    /// Returns `true` if the lockfile contains no packages.
739    pub fn is_empty(&self) -> bool {
740        self.packages.is_empty()
741    }
742
743    /// Returns the [`Package`] entries in this lock.
744    pub fn packages(&self) -> &[Package] {
745        &self.packages
746    }
747
748    /// Returns the supported Python version range for the lockfile, if present.
749    pub fn requires_python(&self) -> &RequiresPython {
750        &self.requires_python
751    }
752
753    /// Returns the resolution mode used to generate this lock.
754    pub fn resolution_mode(&self) -> ResolutionMode {
755        self.options.resolution_mode
756    }
757
758    /// Returns the pre-release mode used to generate this lock.
759    pub fn prerelease_mode(&self) -> PrereleaseMode {
760        self.options.prerelease_mode
761    }
762
763    /// Returns the multi-version mode used to generate this lock.
764    pub fn fork_strategy(&self) -> ForkStrategy {
765        self.options.fork_strategy
766    }
767
768    /// Returns the exclude newer setting used to generate this lock.
769    pub fn exclude_newer(&self) -> ExcludeNewer {
770        // TODO(zanieb): It'd be nice not to hide this clone here, but I am hesitant to introduce
771        // a whole new `ExcludeNewerRef` type just for this
772        self.options.exclude_newer.clone().into()
773    }
774
775    /// Returns the conflicting groups that were used to generate this lock.
776    pub fn conflicts(&self) -> &Conflicts {
777        &self.conflicts
778    }
779
780    /// Returns the supported environments that were used to generate this lock.
781    pub fn supported_environments(&self) -> &[MarkerTree] {
782        &self.supported_environments
783    }
784
785    /// Returns the required platforms that were used to generate this lock.
786    pub fn required_environments(&self) -> &[MarkerTree] {
787        &self.required_environments
788    }
789
790    /// Returns the workspace members that were used to generate this lock.
791    pub fn members(&self) -> &BTreeSet<PackageName> {
792        &self.manifest.members
793    }
794
795    /// Returns the dependency groups that were used to generate this lock.
796    pub fn requirements(&self) -> &BTreeSet<Requirement> {
797        &self.manifest.requirements
798    }
799
800    /// Returns the dependency groups that were used to generate this lock.
801    pub fn dependency_groups(&self) -> &BTreeMap<GroupName, BTreeSet<Requirement>> {
802        &self.manifest.dependency_groups
803    }
804
805    /// Returns the build constraints that were used to generate this lock.
806    pub fn build_constraints(&self, root: &Path) -> Constraints {
807        Constraints::from_requirements(
808            self.manifest
809                .build_constraints
810                .iter()
811                .cloned()
812                .map(|requirement| requirement.to_absolute(root)),
813        )
814    }
815
816    /// Returns the set of packages that should be audited, respecting the given
817    /// extras and dependency groups filters.
818    ///
819    /// Workspace members and packages without version information are excluded
820    /// unconditionally, since neither can be meaningfully looked up in a
821    /// vulnerability database.
822    pub fn packages_for_audit<'lock>(
823        &'lock self,
824        extras: &'lock ExtrasSpecificationWithDefaults,
825        groups: &'lock DependencyGroupsWithDefaults,
826    ) -> Vec<(&'lock PackageName, &'lock Version)> {
827        // Enqueue a dependency for auditability checks: base package (no extra) first, then each activated extra.
828        fn enqueue_dep<'lock>(
829            lock: &'lock Lock,
830            seen: &mut FxHashSet<(&'lock PackageId, Option<&'lock ExtraName>)>,
831            queue: &mut VecDeque<(&'lock Package, Option<&'lock ExtraName>)>,
832            dep: &'lock Dependency,
833        ) {
834            let dep_pkg = lock.find_by_id(&dep.package_id);
835            for maybe_extra in std::iter::once(None).chain(dep.extra.iter().map(Some)) {
836                if seen.insert((&dep.package_id, maybe_extra)) {
837                    queue.push_back((dep_pkg, maybe_extra));
838                }
839            }
840        }
841
842        // Identify workspace members (the implicit root counts for single-member workspaces).
843        let workspace_member_ids: FxHashSet<&PackageId> = if self.members().is_empty() {
844            self.root().into_iter().map(|package| &package.id).collect()
845        } else {
846            self.packages
847                .iter()
848                .filter(|package| self.members().contains(&package.id.name))
849                .map(|package| &package.id)
850                .collect()
851        };
852
853        // Lockfile traversal state: (package, optional extra to activate on that package).
854        let mut queue: VecDeque<(&Package, Option<&ExtraName>)> = VecDeque::new();
855        let mut seen: FxHashSet<(&PackageId, Option<&ExtraName>)> = FxHashSet::default();
856
857        // Seed from workspace members. Always queue with `None` so that we can traverse
858        // their dependency groups; only queue extras when prod mode is active.
859        for package in self
860            .packages
861            .iter()
862            .filter(|p| workspace_member_ids.contains(&p.id))
863        {
864            if seen.insert((&package.id, None)) {
865                queue.push_back((package, None));
866            }
867            if groups.prod() {
868                for extra in extras.extra_names(package.optional_dependencies.keys()) {
869                    if seen.insert((&package.id, Some(extra))) {
870                        queue.push_back((package, Some(extra)));
871                    }
872                }
873            }
874        }
875
876        // Seed from requirements attached directly to the lock (e.g., PEP 723 scripts).
877        for requirement in self.requirements() {
878            for package in self
879                .packages
880                .iter()
881                .filter(|p| p.id.name == requirement.name)
882            {
883                if seen.insert((&package.id, None)) {
884                    queue.push_back((package, None));
885                }
886            }
887        }
888
889        // Seed from dependency groups attached directly to the lock (e.g., project-less
890        // workspace roots).
891        for (group, requirements) in self.dependency_groups() {
892            if !groups.contains(group) {
893                continue;
894            }
895            for requirement in requirements {
896                for package in self
897                    .packages
898                    .iter()
899                    .filter(|p| p.id.name == requirement.name)
900                {
901                    if seen.insert((&package.id, None)) {
902                        queue.push_back((package, None));
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                            PackageExcludeNewer::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                            PackageExcludeNewer::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        #[allow(clippy::enum_glob_use)]
4081        use self::SourceWire::*;
4082
4083        match wire {
4084            Registry { registry } => Ok(Self::Registry(registry.into())),
4085            Git { git } => {
4086                let url = DisplaySafeUrl::parse(&git)
4087                    .map_err(|err| SourceParseError::InvalidUrl {
4088                        given: git.clone(),
4089                        err,
4090                    })
4091                    .map_err(LockErrorKind::InvalidGitSourceUrl)?;
4092
4093                let git_source = GitSource::from_url(&url)
4094                    .map_err(|err| match err {
4095                        GitSourceError::InvalidSha => SourceParseError::InvalidSha { given: git },
4096                        GitSourceError::MissingSha => SourceParseError::MissingSha { given: git },
4097                    })
4098                    .map_err(LockErrorKind::InvalidGitSourceUrl)?;
4099
4100                Ok(Self::Git(UrlString::from(url), git_source))
4101            }
4102            Direct { url, subdirectory } => Ok(Self::Direct(
4103                url,
4104                DirectSource {
4105                    subdirectory: subdirectory.map(Box::<std::path::Path>::from),
4106                },
4107            )),
4108            Path { path } => Ok(Self::Path(path.into())),
4109            Directory { directory } => Ok(Self::Directory(directory.into())),
4110            Editable { editable } => Ok(Self::Editable(editable.into())),
4111            Virtual { r#virtual } => Ok(Self::Virtual(r#virtual.into())),
4112        }
4113    }
4114}
4115
4116/// The source for a registry, which could be a URL or a relative path.
4117#[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)]
4118enum RegistrySource {
4119    /// Ex) `https://pypi.org/simple`
4120    Url(UrlString),
4121    /// Ex) `../path/to/local/index`
4122    Path(Box<Path>),
4123}
4124
4125impl Display for RegistrySource {
4126    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
4127        match self {
4128            Self::Url(url) => write!(f, "{url}"),
4129            Self::Path(path) => write!(f, "{}", path.display()),
4130        }
4131    }
4132}
4133
4134#[derive(Clone, Debug)]
4135enum RegistrySourceWire {
4136    /// Ex) `https://pypi.org/simple`
4137    Url(UrlString),
4138    /// Ex) `../path/to/local/index`
4139    Path(PortablePathBuf),
4140}
4141
4142impl<'de> serde::de::Deserialize<'de> for RegistrySourceWire {
4143    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
4144    where
4145        D: serde::de::Deserializer<'de>,
4146    {
4147        struct Visitor;
4148
4149        impl serde::de::Visitor<'_> for Visitor {
4150            type Value = RegistrySourceWire;
4151
4152            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
4153                formatter.write_str("a valid URL or a file path")
4154            }
4155
4156            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
4157            where
4158                E: serde::de::Error,
4159            {
4160                if split_scheme(value).is_some_and(|(scheme, _)| Scheme::parse(scheme).is_some()) {
4161                    Ok(
4162                        serde::Deserialize::deserialize(serde::de::value::StrDeserializer::new(
4163                            value,
4164                        ))
4165                        .map(RegistrySourceWire::Url)?,
4166                    )
4167                } else {
4168                    Ok(
4169                        serde::Deserialize::deserialize(serde::de::value::StrDeserializer::new(
4170                            value,
4171                        ))
4172                        .map(RegistrySourceWire::Path)?,
4173                    )
4174                }
4175            }
4176        }
4177
4178        deserializer.deserialize_str(Visitor)
4179    }
4180}
4181
4182impl From<RegistrySourceWire> for RegistrySource {
4183    fn from(wire: RegistrySourceWire) -> Self {
4184        match wire {
4185            RegistrySourceWire::Url(url) => Self::Url(url),
4186            RegistrySourceWire::Path(path) => Self::Path(path.into()),
4187        }
4188    }
4189}
4190
4191#[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord, serde::Deserialize)]
4192#[serde(rename_all = "kebab-case")]
4193struct DirectSource {
4194    subdirectory: Option<Box<Path>>,
4195}
4196
4197/// NOTE: Care should be taken when adding variants to this enum. Namely, new
4198/// variants should be added without changing the relative ordering of other
4199/// variants. Otherwise, this could cause the lockfile to have a different
4200/// canonical ordering of package entries.
4201#[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)]
4202struct GitSource {
4203    precise: GitOid,
4204    subdirectory: Option<Box<Path>>,
4205    kind: GitSourceKind,
4206    lfs: GitLfs,
4207}
4208
4209/// An error that occurs when a source string could not be parsed.
4210#[derive(Clone, Debug, Eq, PartialEq)]
4211enum GitSourceError {
4212    InvalidSha,
4213    MissingSha,
4214}
4215
4216impl GitSource {
4217    /// Extracts a Git source reference from the query pairs and the hash
4218    /// fragment in the given URL.
4219    fn from_url(url: &Url) -> Result<Self, GitSourceError> {
4220        let mut kind = GitSourceKind::DefaultBranch;
4221        let mut subdirectory = None;
4222        let mut lfs = GitLfs::Disabled;
4223        for (key, val) in url.query_pairs() {
4224            match &*key {
4225                "tag" => kind = GitSourceKind::Tag(val.into_owned()),
4226                "branch" => kind = GitSourceKind::Branch(val.into_owned()),
4227                "rev" => kind = GitSourceKind::Rev(val.into_owned()),
4228                "subdirectory" => subdirectory = Some(PortablePathBuf::from(val.as_ref()).into()),
4229                "lfs" => lfs = GitLfs::from(val.eq_ignore_ascii_case("true")),
4230                _ => {}
4231            }
4232        }
4233
4234        let precise = GitOid::from_str(url.fragment().ok_or(GitSourceError::MissingSha)?)
4235            .map_err(|_| GitSourceError::InvalidSha)?;
4236
4237        Ok(Self {
4238            precise,
4239            subdirectory,
4240            kind,
4241            lfs,
4242        })
4243    }
4244}
4245
4246#[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord, serde::Deserialize)]
4247#[serde(rename_all = "kebab-case")]
4248enum GitSourceKind {
4249    Tag(String),
4250    Branch(String),
4251    Rev(String),
4252    DefaultBranch,
4253}
4254
4255/// Inspired by: <https://discuss.python.org/t/lock-files-again-but-this-time-w-sdists/46593>
4256#[derive(Clone, Debug, serde::Deserialize, PartialEq, Eq)]
4257#[serde(rename_all = "kebab-case")]
4258struct SourceDistMetadata {
4259    /// A hash of the source distribution.
4260    hash: Option<Hash>,
4261    /// The size of the source distribution in bytes.
4262    ///
4263    /// This is only present for source distributions that come from registries.
4264    size: Option<u64>,
4265    /// The upload time of the source distribution.
4266    #[serde(alias = "upload_time")]
4267    upload_time: Option<Timestamp>,
4268}
4269
4270/// A URL or file path where the source dist that was
4271/// locked against was found. The location does not need to exist in the
4272/// future, so this should be treated as only a hint to where to look
4273/// and/or recording where the source dist file originally came from.
4274#[derive(Clone, Debug, serde::Deserialize, PartialEq, Eq)]
4275#[serde(from = "SourceDistWire")]
4276enum SourceDist {
4277    Url {
4278        url: UrlString,
4279        #[serde(flatten)]
4280        metadata: SourceDistMetadata,
4281    },
4282    Path {
4283        path: Box<Path>,
4284        #[serde(flatten)]
4285        metadata: SourceDistMetadata,
4286    },
4287    Metadata {
4288        #[serde(flatten)]
4289        metadata: SourceDistMetadata,
4290    },
4291}
4292
4293impl SourceDist {
4294    fn filename(&self) -> Option<Cow<'_, str>> {
4295        match self {
4296            Self::Metadata { .. } => None,
4297            Self::Url { url, .. } => url.filename().ok(),
4298            Self::Path { path, .. } => path.file_name().map(|filename| filename.to_string_lossy()),
4299        }
4300    }
4301
4302    fn url(&self) -> Option<&UrlString> {
4303        match self {
4304            Self::Metadata { .. } => None,
4305            Self::Url { url, .. } => Some(url),
4306            Self::Path { .. } => None,
4307        }
4308    }
4309
4310    pub(crate) fn hash(&self) -> Option<&Hash> {
4311        match self {
4312            Self::Metadata { metadata } => metadata.hash.as_ref(),
4313            Self::Url { metadata, .. } => metadata.hash.as_ref(),
4314            Self::Path { metadata, .. } => metadata.hash.as_ref(),
4315        }
4316    }
4317
4318    pub(crate) fn size(&self) -> Option<u64> {
4319        match self {
4320            Self::Metadata { metadata } => metadata.size,
4321            Self::Url { metadata, .. } => metadata.size,
4322            Self::Path { metadata, .. } => metadata.size,
4323        }
4324    }
4325
4326    pub(crate) fn upload_time(&self) -> Option<Timestamp> {
4327        match self {
4328            Self::Metadata { metadata } => metadata.upload_time,
4329            Self::Url { metadata, .. } => metadata.upload_time,
4330            Self::Path { metadata, .. } => metadata.upload_time,
4331        }
4332    }
4333}
4334
4335impl SourceDist {
4336    fn from_annotated_dist(
4337        id: &PackageId,
4338        annotated_dist: &AnnotatedDist,
4339    ) -> Result<Option<Self>, LockError> {
4340        match annotated_dist.dist {
4341            // We pass empty installed packages for locking.
4342            ResolvedDist::Installed { .. } => unreachable!(),
4343            ResolvedDist::Installable { ref dist, .. } => Self::from_dist(
4344                id,
4345                dist,
4346                annotated_dist.hashes.as_slice(),
4347                annotated_dist.index(),
4348            ),
4349        }
4350    }
4351
4352    fn from_dist(
4353        id: &PackageId,
4354        dist: &Dist,
4355        hashes: &[HashDigest],
4356        index: Option<&IndexUrl>,
4357    ) -> Result<Option<Self>, LockError> {
4358        match *dist {
4359            Dist::Built(BuiltDist::Registry(ref built_dist)) => {
4360                let Some(sdist) = built_dist.sdist.as_ref() else {
4361                    return Ok(None);
4362                };
4363                Self::from_registry_dist(sdist, index)
4364            }
4365            Dist::Built(_) => Ok(None),
4366            Dist::Source(ref source_dist) => Self::from_source_dist(id, source_dist, hashes, index),
4367        }
4368    }
4369
4370    fn from_source_dist(
4371        id: &PackageId,
4372        source_dist: &uv_distribution_types::SourceDist,
4373        hashes: &[HashDigest],
4374        index: Option<&IndexUrl>,
4375    ) -> Result<Option<Self>, LockError> {
4376        match *source_dist {
4377            uv_distribution_types::SourceDist::Registry(ref reg_dist) => {
4378                Self::from_registry_dist(reg_dist, index)
4379            }
4380            uv_distribution_types::SourceDist::DirectUrl(_) => {
4381                Self::from_direct_dist(id, hashes).map(Some)
4382            }
4383            uv_distribution_types::SourceDist::Path(_) => {
4384                Self::from_path_dist(id, hashes).map(Some)
4385            }
4386            // An actual sdist entry in the lockfile is only required when
4387            // it's from a registry or a direct URL. Otherwise, it's strictly
4388            // redundant with the information in all other kinds of `source`.
4389            uv_distribution_types::SourceDist::Git(_)
4390            | uv_distribution_types::SourceDist::Directory(_) => Ok(None),
4391        }
4392    }
4393
4394    fn from_registry_dist(
4395        reg_dist: &RegistrySourceDist,
4396        index: Option<&IndexUrl>,
4397    ) -> Result<Option<Self>, LockError> {
4398        // Reject distributions from registries that don't match the index URL, as can occur with
4399        // `--find-links`.
4400        if index.is_none_or(|index| *index != reg_dist.index) {
4401            return Ok(None);
4402        }
4403
4404        match &reg_dist.index {
4405            IndexUrl::Pypi(_) | IndexUrl::Url(_) => {
4406                let url = normalize_file_location(&reg_dist.file.url)
4407                    .map_err(LockErrorKind::InvalidUrl)
4408                    .map_err(LockError::from)?;
4409                let hash = reg_dist.file.hashes.iter().max().cloned().map(Hash::from);
4410                let size = reg_dist.file.size;
4411                let upload_time = reg_dist
4412                    .file
4413                    .upload_time_utc_ms
4414                    .map(Timestamp::from_millisecond)
4415                    .transpose()
4416                    .map_err(LockErrorKind::InvalidTimestamp)?;
4417                Ok(Some(Self::Url {
4418                    url,
4419                    metadata: SourceDistMetadata {
4420                        hash,
4421                        size,
4422                        upload_time,
4423                    },
4424                }))
4425            }
4426            IndexUrl::Path(path) => {
4427                let index_path = path
4428                    .to_file_path()
4429                    .map_err(|()| LockErrorKind::UrlToPath { url: path.to_url() })?;
4430                let url = reg_dist
4431                    .file
4432                    .url
4433                    .to_url()
4434                    .map_err(LockErrorKind::InvalidUrl)?;
4435
4436                if url.scheme() == "file" {
4437                    let reg_dist_path = url
4438                        .to_file_path()
4439                        .map_err(|()| LockErrorKind::UrlToPath { url })?;
4440                    let path =
4441                        try_relative_to_if(&reg_dist_path, index_path, !path.was_given_absolute())
4442                            .map_err(LockErrorKind::DistributionRelativePath)?
4443                            .into_boxed_path();
4444                    let hash = reg_dist.file.hashes.iter().max().cloned().map(Hash::from);
4445                    let size = reg_dist.file.size;
4446                    let upload_time = reg_dist
4447                        .file
4448                        .upload_time_utc_ms
4449                        .map(Timestamp::from_millisecond)
4450                        .transpose()
4451                        .map_err(LockErrorKind::InvalidTimestamp)?;
4452                    Ok(Some(Self::Path {
4453                        path,
4454                        metadata: SourceDistMetadata {
4455                            hash,
4456                            size,
4457                            upload_time,
4458                        },
4459                    }))
4460                } else {
4461                    let url = normalize_file_location(&reg_dist.file.url)
4462                        .map_err(LockErrorKind::InvalidUrl)
4463                        .map_err(LockError::from)?;
4464                    let hash = reg_dist.file.hashes.iter().max().cloned().map(Hash::from);
4465                    let size = reg_dist.file.size;
4466                    let upload_time = reg_dist
4467                        .file
4468                        .upload_time_utc_ms
4469                        .map(Timestamp::from_millisecond)
4470                        .transpose()
4471                        .map_err(LockErrorKind::InvalidTimestamp)?;
4472                    Ok(Some(Self::Url {
4473                        url,
4474                        metadata: SourceDistMetadata {
4475                            hash,
4476                            size,
4477                            upload_time,
4478                        },
4479                    }))
4480                }
4481            }
4482        }
4483    }
4484
4485    fn from_direct_dist(id: &PackageId, hashes: &[HashDigest]) -> Result<Self, LockError> {
4486        let Some(hash) = hashes.iter().max().cloned().map(Hash::from) else {
4487            let kind = LockErrorKind::Hash {
4488                id: id.clone(),
4489                artifact_type: "direct URL source distribution",
4490                expected: true,
4491            };
4492            return Err(kind.into());
4493        };
4494        Ok(Self::Metadata {
4495            metadata: SourceDistMetadata {
4496                hash: Some(hash),
4497                size: None,
4498                upload_time: None,
4499            },
4500        })
4501    }
4502
4503    fn from_path_dist(id: &PackageId, hashes: &[HashDigest]) -> Result<Self, LockError> {
4504        let Some(hash) = hashes.iter().max().cloned().map(Hash::from) else {
4505            let kind = LockErrorKind::Hash {
4506                id: id.clone(),
4507                artifact_type: "path source distribution",
4508                expected: true,
4509            };
4510            return Err(kind.into());
4511        };
4512        Ok(Self::Metadata {
4513            metadata: SourceDistMetadata {
4514                hash: Some(hash),
4515                size: None,
4516                upload_time: None,
4517            },
4518        })
4519    }
4520}
4521
4522#[derive(Clone, Debug, serde::Deserialize)]
4523#[serde(untagged, rename_all = "kebab-case")]
4524enum SourceDistWire {
4525    Url {
4526        url: UrlString,
4527        #[serde(flatten)]
4528        metadata: SourceDistMetadata,
4529    },
4530    Path {
4531        path: PortablePathBuf,
4532        #[serde(flatten)]
4533        metadata: SourceDistMetadata,
4534    },
4535    Metadata {
4536        #[serde(flatten)]
4537        metadata: SourceDistMetadata,
4538    },
4539}
4540
4541impl SourceDist {
4542    /// Returns the TOML representation of this source distribution.
4543    fn to_toml(&self) -> Result<InlineTable, toml_edit::ser::Error> {
4544        let mut table = InlineTable::new();
4545        match self {
4546            Self::Metadata { .. } => {}
4547            Self::Url { url, .. } => {
4548                table.insert("url", Value::from(url.as_ref()));
4549            }
4550            Self::Path { path, .. } => {
4551                table.insert("path", Value::from(PortablePath::from(path).to_string()));
4552            }
4553        }
4554        if let Some(hash) = self.hash() {
4555            table.insert("hash", Value::from(hash.to_string()));
4556        }
4557        if let Some(size) = self.size() {
4558            table.insert(
4559                "size",
4560                toml_edit::ser::ValueSerializer::new().serialize_u64(size)?,
4561            );
4562        }
4563        if let Some(upload_time) = self.upload_time() {
4564            table.insert("upload-time", Value::from(upload_time.to_string()));
4565        }
4566        Ok(table)
4567    }
4568}
4569
4570impl From<SourceDistWire> for SourceDist {
4571    fn from(wire: SourceDistWire) -> Self {
4572        match wire {
4573            SourceDistWire::Url { url, metadata } => Self::Url { url, metadata },
4574            SourceDistWire::Path { path, metadata } => Self::Path {
4575                path: path.into(),
4576                metadata,
4577            },
4578            SourceDistWire::Metadata { metadata } => Self::Metadata { metadata },
4579        }
4580    }
4581}
4582
4583impl From<GitReference> for GitSourceKind {
4584    fn from(value: GitReference) -> Self {
4585        match value {
4586            GitReference::Branch(branch) => Self::Branch(branch),
4587            GitReference::Tag(tag) => Self::Tag(tag),
4588            GitReference::BranchOrTag(rev) => Self::Rev(rev),
4589            GitReference::BranchOrTagOrCommit(rev) => Self::Rev(rev),
4590            GitReference::NamedRef(rev) => Self::Rev(rev),
4591            GitReference::DefaultBranch => Self::DefaultBranch,
4592        }
4593    }
4594}
4595
4596impl From<GitSourceKind> for GitReference {
4597    fn from(value: GitSourceKind) -> Self {
4598        match value {
4599            GitSourceKind::Branch(branch) => Self::Branch(branch),
4600            GitSourceKind::Tag(tag) => Self::Tag(tag),
4601            GitSourceKind::Rev(rev) => Self::from_rev(rev),
4602            GitSourceKind::DefaultBranch => Self::DefaultBranch,
4603        }
4604    }
4605}
4606
4607/// Construct the lockfile-compatible [`DisplaySafeUrl`] for a [`GitSourceDist`].
4608fn locked_git_url(git_dist: &GitSourceDist) -> DisplaySafeUrl {
4609    let mut url = git_dist.git.repository().clone();
4610
4611    // Remove the credentials.
4612    url.remove_credentials();
4613
4614    // Clear out any existing state.
4615    url.set_fragment(None);
4616    url.set_query(None);
4617
4618    // Put the subdirectory in the query.
4619    if let Some(subdirectory) = git_dist
4620        .subdirectory
4621        .as_deref()
4622        .map(PortablePath::from)
4623        .as_ref()
4624        .map(PortablePath::to_string)
4625    {
4626        url.query_pairs_mut()
4627            .append_pair("subdirectory", &subdirectory);
4628    }
4629
4630    // Put lfs=true in the package source git url only when explicitly enabled.
4631    if git_dist.git.lfs().enabled() {
4632        url.query_pairs_mut().append_pair("lfs", "true");
4633    }
4634
4635    // Put the requested reference in the query.
4636    match git_dist.git.reference() {
4637        GitReference::Branch(branch) => {
4638            url.query_pairs_mut().append_pair("branch", branch.as_str());
4639        }
4640        GitReference::Tag(tag) => {
4641            url.query_pairs_mut().append_pair("tag", tag.as_str());
4642        }
4643        GitReference::BranchOrTag(rev)
4644        | GitReference::BranchOrTagOrCommit(rev)
4645        | GitReference::NamedRef(rev) => {
4646            url.query_pairs_mut().append_pair("rev", rev.as_str());
4647        }
4648        GitReference::DefaultBranch => {}
4649    }
4650
4651    // Put the precise commit in the fragment.
4652    url.set_fragment(
4653        git_dist
4654            .git
4655            .precise()
4656            .as_ref()
4657            .map(GitOid::to_string)
4658            .as_deref(),
4659    );
4660
4661    url
4662}
4663
4664#[derive(Clone, Debug, serde::Deserialize, PartialEq, Eq)]
4665struct ZstdWheel {
4666    hash: Option<Hash>,
4667    size: Option<u64>,
4668}
4669
4670/// Inspired by: <https://discuss.python.org/t/lock-files-again-but-this-time-w-sdists/46593>
4671#[derive(Clone, Debug, serde::Deserialize, PartialEq, Eq)]
4672#[serde(try_from = "WheelWire")]
4673struct Wheel {
4674    /// A URL or file path (via `file://`) where the wheel that was locked
4675    /// against was found. The location does not need to exist in the future,
4676    /// so this should be treated as only a hint to where to look and/or
4677    /// recording where the wheel file originally came from.
4678    url: WheelWireSource,
4679    /// A hash of the built distribution.
4680    ///
4681    /// This is only present for wheels that come from registries and direct
4682    /// URLs. Wheels from git or path dependencies do not have hashes
4683    /// associated with them.
4684    hash: Option<Hash>,
4685    /// The size of the built distribution in bytes.
4686    ///
4687    /// This is only present for wheels that come from registries.
4688    size: Option<u64>,
4689    /// The upload time of the built distribution.
4690    ///
4691    /// This is only present for wheels that come from registries.
4692    upload_time: Option<Timestamp>,
4693    /// The filename of the wheel.
4694    ///
4695    /// This isn't part of the wire format since it's redundant with the
4696    /// URL. But we do use it for various things, and thus compute it at
4697    /// deserialization time. Not being able to extract a wheel filename from a
4698    /// wheel URL is thus a deserialization error.
4699    filename: WheelFilename,
4700    /// The zstandard-compressed wheel metadata, if any.
4701    zstd: Option<ZstdWheel>,
4702}
4703
4704impl Wheel {
4705    fn from_annotated_dist(annotated_dist: &AnnotatedDist) -> Result<Vec<Self>, LockError> {
4706        match annotated_dist.dist {
4707            // We pass empty installed packages for locking.
4708            ResolvedDist::Installed { .. } => unreachable!(),
4709            ResolvedDist::Installable { ref dist, .. } => Self::from_dist(
4710                dist,
4711                annotated_dist.hashes.as_slice(),
4712                annotated_dist.index(),
4713            ),
4714        }
4715    }
4716
4717    fn from_dist(
4718        dist: &Dist,
4719        hashes: &[HashDigest],
4720        index: Option<&IndexUrl>,
4721    ) -> Result<Vec<Self>, LockError> {
4722        match *dist {
4723            Dist::Built(ref built_dist) => Self::from_built_dist(built_dist, hashes, index),
4724            Dist::Source(uv_distribution_types::SourceDist::Registry(ref source_dist)) => {
4725                source_dist
4726                    .wheels
4727                    .iter()
4728                    .filter(|wheel| {
4729                        // Reject distributions from registries that don't match the index URL, as can occur with
4730                        // `--find-links`.
4731                        index.is_some_and(|index| *index == wheel.index)
4732                    })
4733                    .map(Self::from_registry_wheel)
4734                    .collect()
4735            }
4736            Dist::Source(_) => Ok(vec![]),
4737        }
4738    }
4739
4740    fn from_built_dist(
4741        built_dist: &BuiltDist,
4742        hashes: &[HashDigest],
4743        index: Option<&IndexUrl>,
4744    ) -> Result<Vec<Self>, LockError> {
4745        match *built_dist {
4746            BuiltDist::Registry(ref reg_dist) => Self::from_registry_dist(reg_dist, index),
4747            BuiltDist::DirectUrl(ref direct_dist) => {
4748                Ok(vec![Self::from_direct_dist(direct_dist, hashes)])
4749            }
4750            BuiltDist::Path(ref path_dist) => Ok(vec![Self::from_path_dist(path_dist, hashes)]),
4751        }
4752    }
4753
4754    fn from_registry_dist(
4755        reg_dist: &RegistryBuiltDist,
4756        index: Option<&IndexUrl>,
4757    ) -> Result<Vec<Self>, LockError> {
4758        reg_dist
4759            .wheels
4760            .iter()
4761            .filter(|wheel| {
4762                // Reject distributions from registries that don't match the index URL, as can occur with
4763                // `--find-links`.
4764                index.is_some_and(|index| *index == wheel.index)
4765            })
4766            .map(Self::from_registry_wheel)
4767            .collect()
4768    }
4769
4770    fn from_registry_wheel(wheel: &RegistryBuiltWheel) -> Result<Self, LockError> {
4771        let url = match &wheel.index {
4772            IndexUrl::Pypi(_) | IndexUrl::Url(_) => {
4773                let url = normalize_file_location(&wheel.file.url)
4774                    .map_err(LockErrorKind::InvalidUrl)
4775                    .map_err(LockError::from)?;
4776                WheelWireSource::Url { url }
4777            }
4778            IndexUrl::Path(path) => {
4779                let index_path = path
4780                    .to_file_path()
4781                    .map_err(|()| LockErrorKind::UrlToPath { url: path.to_url() })?;
4782                let wheel_url = wheel.file.url.to_url().map_err(LockErrorKind::InvalidUrl)?;
4783
4784                if wheel_url.scheme() == "file" {
4785                    let wheel_path = wheel_url
4786                        .to_file_path()
4787                        .map_err(|()| LockErrorKind::UrlToPath { url: wheel_url })?;
4788                    let path =
4789                        try_relative_to_if(&wheel_path, index_path, !path.was_given_absolute())
4790                            .map_err(LockErrorKind::DistributionRelativePath)?
4791                            .into_boxed_path();
4792                    WheelWireSource::Path { path }
4793                } else {
4794                    let url = normalize_file_location(&wheel.file.url)
4795                        .map_err(LockErrorKind::InvalidUrl)
4796                        .map_err(LockError::from)?;
4797                    WheelWireSource::Url { url }
4798                }
4799            }
4800        };
4801        let filename = wheel.filename.clone();
4802        let hash = wheel.file.hashes.iter().max().cloned().map(Hash::from);
4803        let size = wheel.file.size;
4804        let upload_time = wheel
4805            .file
4806            .upload_time_utc_ms
4807            .map(Timestamp::from_millisecond)
4808            .transpose()
4809            .map_err(LockErrorKind::InvalidTimestamp)?;
4810        let zstd = wheel.file.zstd.as_ref().map(|zstd| ZstdWheel {
4811            hash: zstd.hashes.iter().max().cloned().map(Hash::from),
4812            size: zstd.size,
4813        });
4814        Ok(Self {
4815            url,
4816            hash,
4817            size,
4818            upload_time,
4819            filename,
4820            zstd,
4821        })
4822    }
4823
4824    fn from_direct_dist(direct_dist: &DirectUrlBuiltDist, hashes: &[HashDigest]) -> Self {
4825        Self {
4826            url: WheelWireSource::Url {
4827                url: normalize_url(direct_dist.url.to_url()),
4828            },
4829            hash: hashes.iter().max().cloned().map(Hash::from),
4830            size: None,
4831            upload_time: None,
4832            filename: direct_dist.filename.clone(),
4833            zstd: None,
4834        }
4835    }
4836
4837    fn from_path_dist(path_dist: &PathBuiltDist, hashes: &[HashDigest]) -> Self {
4838        Self {
4839            url: WheelWireSource::Filename {
4840                filename: path_dist.filename.clone(),
4841            },
4842            hash: hashes.iter().max().cloned().map(Hash::from),
4843            size: None,
4844            upload_time: None,
4845            filename: path_dist.filename.clone(),
4846            zstd: None,
4847        }
4848    }
4849
4850    pub(crate) fn to_registry_wheel(
4851        &self,
4852        source: &RegistrySource,
4853        root: &Path,
4854    ) -> Result<RegistryBuiltWheel, LockError> {
4855        let filename: WheelFilename = self.filename.clone();
4856
4857        match source {
4858            RegistrySource::Url(url) => {
4859                let file_location = match &self.url {
4860                    WheelWireSource::Url { url: file_url } => {
4861                        FileLocation::AbsoluteUrl(file_url.clone())
4862                    }
4863                    WheelWireSource::Path { .. } | WheelWireSource::Filename { .. } => {
4864                        return Err(LockErrorKind::MissingUrl {
4865                            name: filename.name,
4866                            version: filename.version,
4867                        }
4868                        .into());
4869                    }
4870                };
4871                let file = Box::new(uv_distribution_types::File {
4872                    dist_info_metadata: false,
4873                    filename: SmallString::from(filename.to_string()),
4874                    hashes: self.hash.iter().map(|h| h.0.clone()).collect(),
4875                    requires_python: None,
4876                    size: self.size,
4877                    upload_time_utc_ms: self.upload_time.map(Timestamp::as_millisecond),
4878                    url: file_location,
4879                    yanked: None,
4880                    zstd: self
4881                        .zstd
4882                        .as_ref()
4883                        .map(|zstd| uv_distribution_types::Zstd {
4884                            hashes: zstd.hash.iter().map(|h| h.0.clone()).collect(),
4885                            size: zstd.size,
4886                        })
4887                        .map(Box::new),
4888                });
4889                let index = IndexUrl::from(VerbatimUrl::from_url(
4890                    url.to_url().map_err(LockErrorKind::InvalidUrl)?,
4891                ));
4892                Ok(RegistryBuiltWheel {
4893                    filename,
4894                    file,
4895                    index,
4896                })
4897            }
4898            RegistrySource::Path(index_path) => {
4899                let file_location = match &self.url {
4900                    WheelWireSource::Url { url: file_url } => {
4901                        FileLocation::AbsoluteUrl(file_url.clone())
4902                    }
4903                    WheelWireSource::Path { path: file_path } => {
4904                        let file_path = root.join(index_path).join(file_path);
4905                        let file_url =
4906                            DisplaySafeUrl::from_file_path(&file_path).map_err(|()| {
4907                                LockErrorKind::PathToUrl {
4908                                    path: file_path.into_boxed_path(),
4909                                }
4910                            })?;
4911                        FileLocation::AbsoluteUrl(UrlString::from(file_url))
4912                    }
4913                    WheelWireSource::Filename { .. } => {
4914                        return Err(LockErrorKind::MissingPath {
4915                            name: filename.name,
4916                            version: filename.version,
4917                        }
4918                        .into());
4919                    }
4920                };
4921                let file = Box::new(uv_distribution_types::File {
4922                    dist_info_metadata: false,
4923                    filename: SmallString::from(filename.to_string()),
4924                    hashes: self.hash.iter().map(|h| h.0.clone()).collect(),
4925                    requires_python: None,
4926                    size: self.size,
4927                    upload_time_utc_ms: self.upload_time.map(Timestamp::as_millisecond),
4928                    url: file_location,
4929                    yanked: None,
4930                    zstd: self
4931                        .zstd
4932                        .as_ref()
4933                        .map(|zstd| uv_distribution_types::Zstd {
4934                            hashes: zstd.hash.iter().map(|h| h.0.clone()).collect(),
4935                            size: zstd.size,
4936                        })
4937                        .map(Box::new),
4938                });
4939                let index = IndexUrl::from(
4940                    VerbatimUrl::from_absolute_path(root.join(index_path))
4941                        .map_err(LockErrorKind::RegistryVerbatimUrl)?,
4942                );
4943                Ok(RegistryBuiltWheel {
4944                    filename,
4945                    file,
4946                    index,
4947                })
4948            }
4949        }
4950    }
4951}
4952
4953#[derive(Clone, Debug, serde::Deserialize)]
4954#[serde(rename_all = "kebab-case")]
4955struct WheelWire {
4956    #[serde(flatten)]
4957    url: WheelWireSource,
4958    /// A hash of the built distribution.
4959    ///
4960    /// This is only present for wheels that come from registries and direct
4961    /// URLs. Wheels from git or path dependencies do not have hashes
4962    /// associated with them.
4963    hash: Option<Hash>,
4964    /// The size of the built distribution in bytes.
4965    ///
4966    /// This is only present for wheels that come from registries.
4967    size: Option<u64>,
4968    /// The upload time of the built distribution.
4969    ///
4970    /// This is only present for wheels that come from registries.
4971    #[serde(alias = "upload_time")]
4972    upload_time: Option<Timestamp>,
4973    /// The zstandard-compressed wheel metadata, if any.
4974    #[serde(alias = "zstd")]
4975    zstd: Option<ZstdWheel>,
4976}
4977
4978#[derive(Clone, Debug, serde::Deserialize, PartialEq, Eq)]
4979#[serde(untagged, rename_all = "kebab-case")]
4980enum WheelWireSource {
4981    /// Used for all wheels that come from remote sources.
4982    Url {
4983        /// A URL where the wheel that was locked against was found. The location
4984        /// does not need to exist in the future, so this should be treated as
4985        /// only a hint to where to look and/or recording where the wheel file
4986        /// originally came from.
4987        url: UrlString,
4988    },
4989    /// Used for wheels that come from local registries (like `--find-links`).
4990    Path {
4991        /// The path to the wheel, relative to the index.
4992        path: Box<Path>,
4993    },
4994    /// Used for path wheels.
4995    ///
4996    /// We only store the filename for path wheel, since we can't store a relative path in the url
4997    Filename {
4998        /// We duplicate the filename since a lot of code relies on having the filename on the
4999        /// wheel entry.
5000        filename: WheelFilename,
5001    },
5002}
5003
5004impl Wheel {
5005    /// Returns the TOML representation of this wheel.
5006    fn to_toml(&self) -> Result<InlineTable, toml_edit::ser::Error> {
5007        let mut table = InlineTable::new();
5008        match &self.url {
5009            WheelWireSource::Url { url } => {
5010                table.insert("url", Value::from(url.as_ref()));
5011            }
5012            WheelWireSource::Path { path } => {
5013                table.insert("path", Value::from(PortablePath::from(path).to_string()));
5014            }
5015            WheelWireSource::Filename { filename } => {
5016                table.insert("filename", Value::from(filename.to_string()));
5017            }
5018        }
5019        if let Some(ref hash) = self.hash {
5020            table.insert("hash", Value::from(hash.to_string()));
5021        }
5022        if let Some(size) = self.size {
5023            table.insert(
5024                "size",
5025                toml_edit::ser::ValueSerializer::new().serialize_u64(size)?,
5026            );
5027        }
5028        if let Some(upload_time) = self.upload_time {
5029            table.insert("upload-time", Value::from(upload_time.to_string()));
5030        }
5031        if let Some(zstd) = &self.zstd {
5032            let mut inner = InlineTable::new();
5033            if let Some(ref hash) = zstd.hash {
5034                inner.insert("hash", Value::from(hash.to_string()));
5035            }
5036            if let Some(size) = zstd.size {
5037                inner.insert(
5038                    "size",
5039                    toml_edit::ser::ValueSerializer::new().serialize_u64(size)?,
5040                );
5041            }
5042            table.insert("zstd", Value::from(inner));
5043        }
5044        Ok(table)
5045    }
5046}
5047
5048impl TryFrom<WheelWire> for Wheel {
5049    type Error = String;
5050
5051    fn try_from(wire: WheelWire) -> Result<Self, String> {
5052        let filename = match &wire.url {
5053            WheelWireSource::Url { url } => {
5054                let filename = url.filename().map_err(|err| err.to_string())?;
5055                filename.parse::<WheelFilename>().map_err(|err| {
5056                    format!("failed to parse `{filename}` as wheel filename: {err}")
5057                })?
5058            }
5059            WheelWireSource::Path { path } => {
5060                let filename = path
5061                    .file_name()
5062                    .and_then(|file_name| file_name.to_str())
5063                    .ok_or_else(|| {
5064                        format!("path `{}` has no filename component", path.display())
5065                    })?;
5066                filename.parse::<WheelFilename>().map_err(|err| {
5067                    format!("failed to parse `{filename}` as wheel filename: {err}")
5068                })?
5069            }
5070            WheelWireSource::Filename { filename } => filename.clone(),
5071        };
5072
5073        Ok(Self {
5074            url: wire.url,
5075            hash: wire.hash,
5076            size: wire.size,
5077            upload_time: wire.upload_time,
5078            zstd: wire.zstd,
5079            filename,
5080        })
5081    }
5082}
5083
5084/// A single dependency of a package in a lockfile.
5085#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
5086pub struct Dependency {
5087    package_id: PackageId,
5088    extra: BTreeSet<ExtraName>,
5089    /// A marker simplified from the PEP 508 marker in `complexified_marker`
5090    /// by assuming `requires-python` is satisfied. So if
5091    /// `requires-python = '>=3.8'`, then
5092    /// `python_version >= '3.8' and python_version < '3.12'`
5093    /// gets simplified to `python_version < '3.12'`.
5094    ///
5095    /// Generally speaking, this marker should not be exposed to
5096    /// anything outside this module unless it's for a specialized use
5097    /// case. But specifically, it should never be used to evaluate
5098    /// against a marker environment or for disjointness checks or any
5099    /// other kind of marker algebra.
5100    ///
5101    /// It exists because there are some cases where we do actually
5102    /// want to compare markers in their "simplified" form. For
5103    /// example, when collapsing the extras on duplicate dependencies.
5104    /// Even if a dependency has different complexified markers,
5105    /// they might have identical markers once simplified. And since
5106    /// `requires-python` applies to the entire lock file, it's
5107    /// acceptable to do comparisons on the simplified form.
5108    simplified_marker: SimplifiedMarkerTree,
5109    /// The "complexified" marker is a universal marker whose PEP 508
5110    /// marker can stand on its own independent of `requires-python`.
5111    /// It can be safely used for any kind of marker algebra.
5112    complexified_marker: UniversalMarker,
5113}
5114
5115impl Dependency {
5116    fn new(
5117        requires_python: &RequiresPython,
5118        package_id: PackageId,
5119        extra: BTreeSet<ExtraName>,
5120        complexified_marker: UniversalMarker,
5121    ) -> Self {
5122        let simplified_marker =
5123            SimplifiedMarkerTree::new(requires_python, complexified_marker.combined());
5124        let complexified_marker = simplified_marker.into_marker(requires_python);
5125        Self {
5126            package_id,
5127            extra,
5128            simplified_marker,
5129            complexified_marker: UniversalMarker::from_combined(complexified_marker),
5130        }
5131    }
5132
5133    fn from_annotated_dist(
5134        requires_python: &RequiresPython,
5135        annotated_dist: &AnnotatedDist,
5136        complexified_marker: UniversalMarker,
5137        root: &Path,
5138    ) -> Result<Self, LockError> {
5139        let package_id = PackageId::from_annotated_dist(annotated_dist, root)?;
5140        let extra = annotated_dist.extra.iter().cloned().collect();
5141        Ok(Self::new(
5142            requires_python,
5143            package_id,
5144            extra,
5145            complexified_marker,
5146        ))
5147    }
5148
5149    /// Returns the TOML representation of this dependency.
5150    fn to_toml(
5151        &self,
5152        _requires_python: &RequiresPython,
5153        dist_count_by_name: &FxHashMap<PackageName, u64>,
5154    ) -> Table {
5155        let mut table = Table::new();
5156        self.package_id
5157            .to_toml(Some(dist_count_by_name), &mut table);
5158        if !self.extra.is_empty() {
5159            let extra_array = self
5160                .extra
5161                .iter()
5162                .map(ToString::to_string)
5163                .collect::<Array>();
5164            table.insert("extra", value(extra_array));
5165        }
5166        if let Some(marker) = self.simplified_marker.try_to_string() {
5167            table.insert("marker", value(marker));
5168        }
5169
5170        table
5171    }
5172
5173    /// Returns the package name of this dependency.
5174    pub fn package_name(&self) -> &PackageName {
5175        &self.package_id.name
5176    }
5177
5178    /// Returns the extras specified on this dependency.
5179    pub fn extra(&self) -> &BTreeSet<ExtraName> {
5180        &self.extra
5181    }
5182}
5183
5184impl Display for Dependency {
5185    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
5186        match (self.extra.is_empty(), self.package_id.version.as_ref()) {
5187            (true, Some(version)) => write!(f, "{}=={}", self.package_id.name, version),
5188            (true, None) => write!(f, "{}", self.package_id.name),
5189            (false, Some(version)) => write!(
5190                f,
5191                "{}[{}]=={}",
5192                self.package_id.name,
5193                self.extra.iter().join(","),
5194                version
5195            ),
5196            (false, None) => write!(
5197                f,
5198                "{}[{}]",
5199                self.package_id.name,
5200                self.extra.iter().join(",")
5201            ),
5202        }
5203    }
5204}
5205
5206/// A single dependency of a package in a lockfile.
5207#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord, serde::Deserialize)]
5208#[serde(rename_all = "kebab-case")]
5209struct DependencyWire {
5210    #[serde(flatten)]
5211    package_id: PackageIdForDependency,
5212    #[serde(default)]
5213    extra: BTreeSet<ExtraName>,
5214    #[serde(default)]
5215    marker: SimplifiedMarkerTree,
5216}
5217
5218impl DependencyWire {
5219    fn unwire(
5220        self,
5221        requires_python: &RequiresPython,
5222        unambiguous_package_ids: &FxHashMap<PackageName, PackageId>,
5223    ) -> Result<Dependency, LockError> {
5224        let complexified_marker = self.marker.into_marker(requires_python);
5225        Ok(Dependency {
5226            package_id: self.package_id.unwire(unambiguous_package_ids)?,
5227            extra: self.extra,
5228            simplified_marker: self.marker,
5229            complexified_marker: UniversalMarker::from_combined(complexified_marker),
5230        })
5231    }
5232}
5233
5234/// A single hash for a distribution artifact in a lockfile.
5235///
5236/// A hash is encoded as a single TOML string in the format
5237/// `{algorithm}:{digest}`.
5238#[derive(Clone, Debug, PartialEq, Eq)]
5239struct Hash(HashDigest);
5240
5241impl From<HashDigest> for Hash {
5242    fn from(hd: HashDigest) -> Self {
5243        Self(hd)
5244    }
5245}
5246
5247impl FromStr for Hash {
5248    type Err = HashParseError;
5249
5250    fn from_str(s: &str) -> Result<Self, HashParseError> {
5251        let (algorithm, digest) = s.split_once(':').ok_or(HashParseError(
5252            "expected '{algorithm}:{digest}', but found no ':' in hash digest",
5253        ))?;
5254        let algorithm = algorithm
5255            .parse()
5256            .map_err(|_| HashParseError("unrecognized hash algorithm"))?;
5257        Ok(Self(HashDigest {
5258            algorithm,
5259            digest: digest.into(),
5260        }))
5261    }
5262}
5263
5264impl Display for Hash {
5265    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
5266        write!(f, "{}:{}", self.0.algorithm, self.0.digest)
5267    }
5268}
5269
5270impl<'de> serde::Deserialize<'de> for Hash {
5271    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
5272    where
5273        D: serde::de::Deserializer<'de>,
5274    {
5275        struct Visitor;
5276
5277        impl serde::de::Visitor<'_> for Visitor {
5278            type Value = Hash;
5279
5280            fn expecting(&self, f: &mut Formatter) -> std::fmt::Result {
5281                f.write_str("a string")
5282            }
5283
5284            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
5285                Hash::from_str(v).map_err(serde::de::Error::custom)
5286            }
5287        }
5288
5289        deserializer.deserialize_str(Visitor)
5290    }
5291}
5292
5293impl From<Hash> for Hashes {
5294    fn from(value: Hash) -> Self {
5295        match value.0.algorithm {
5296            HashAlgorithm::Md5 => Self {
5297                md5: Some(value.0.digest),
5298                sha256: None,
5299                sha384: None,
5300                sha512: None,
5301                blake2b: None,
5302            },
5303            HashAlgorithm::Sha256 => Self {
5304                md5: None,
5305                sha256: Some(value.0.digest),
5306                sha384: None,
5307                sha512: None,
5308                blake2b: None,
5309            },
5310            HashAlgorithm::Sha384 => Self {
5311                md5: None,
5312                sha256: None,
5313                sha384: Some(value.0.digest),
5314                sha512: None,
5315                blake2b: None,
5316            },
5317            HashAlgorithm::Sha512 => Self {
5318                md5: None,
5319                sha256: None,
5320                sha384: None,
5321                sha512: Some(value.0.digest),
5322                blake2b: None,
5323            },
5324            HashAlgorithm::Blake2b => Self {
5325                md5: None,
5326                sha256: None,
5327                sha384: None,
5328                sha512: None,
5329                blake2b: Some(value.0.digest),
5330            },
5331        }
5332    }
5333}
5334
5335/// Convert a [`FileLocation`] into a normalized [`UrlString`].
5336fn normalize_file_location(location: &FileLocation) -> Result<UrlString, ToUrlError> {
5337    match location {
5338        FileLocation::AbsoluteUrl(absolute) => Ok(absolute.without_fragment().into_owned()),
5339        FileLocation::RelativeUrl(_, _) => Ok(normalize_url(location.to_url()?)),
5340    }
5341}
5342
5343/// Convert a [`DisplaySafeUrl`] into a normalized [`UrlString`] by removing the fragment.
5344fn normalize_url(mut url: DisplaySafeUrl) -> UrlString {
5345    url.set_fragment(None);
5346    UrlString::from(url)
5347}
5348
5349/// Normalize a [`Requirement`], which could come from a lockfile, a `pyproject.toml`, etc.
5350///
5351/// Performs the following steps:
5352///
5353/// 1. Removes any sensitive credentials.
5354/// 2. Ensures that the lock and install paths are appropriately framed with respect to the
5355///    current [`Workspace`].
5356/// 3. Removes the `origin` field, which is only used in `requirements.txt`.
5357/// 4. Simplifies the markers using the provided [`RequiresPython`] instance.
5358fn normalize_requirement(
5359    mut requirement: Requirement,
5360    root: &Path,
5361    requires_python: &RequiresPython,
5362) -> Result<Requirement, LockError> {
5363    // Sort the extras and groups for consistency.
5364    requirement.extras.sort();
5365    requirement.groups.sort();
5366
5367    // Normalize the requirement source.
5368    match requirement.source {
5369        RequirementSource::Git {
5370            git,
5371            subdirectory,
5372            url: _,
5373        } => {
5374            // Reconstruct the Git URL.
5375            let git = {
5376                let mut repository = git.repository().clone();
5377
5378                // Remove the credentials.
5379                repository.remove_credentials();
5380
5381                // Remove the fragment and query from the URL; they're already present in the source.
5382                repository.set_fragment(None);
5383                repository.set_query(None);
5384
5385                GitUrl::from_fields(
5386                    repository,
5387                    git.reference().clone(),
5388                    git.precise(),
5389                    git.lfs(),
5390                )?
5391            };
5392
5393            // Reconstruct the PEP 508 URL from the underlying data.
5394            let url = DisplaySafeUrl::from(ParsedGitUrl {
5395                url: git.clone(),
5396                subdirectory: subdirectory.clone(),
5397            });
5398
5399            Ok(Requirement {
5400                name: requirement.name,
5401                extras: requirement.extras,
5402                groups: requirement.groups,
5403                marker: requires_python.simplify_markers(requirement.marker),
5404                source: RequirementSource::Git {
5405                    git,
5406                    subdirectory,
5407                    url: VerbatimUrl::from_url(url),
5408                },
5409                origin: None,
5410            })
5411        }
5412        RequirementSource::Path {
5413            install_path,
5414            ext,
5415            url: _,
5416        } => {
5417            let install_path =
5418                uv_fs::normalize_path_buf(root.join(&install_path)).into_boxed_path();
5419            let url = VerbatimUrl::from_normalized_path(&install_path)
5420                .map_err(LockErrorKind::RequirementVerbatimUrl)?;
5421
5422            Ok(Requirement {
5423                name: requirement.name,
5424                extras: requirement.extras,
5425                groups: requirement.groups,
5426                marker: requires_python.simplify_markers(requirement.marker),
5427                source: RequirementSource::Path {
5428                    install_path,
5429                    ext,
5430                    url,
5431                },
5432                origin: None,
5433            })
5434        }
5435        RequirementSource::Directory {
5436            install_path,
5437            editable,
5438            r#virtual,
5439            url: _,
5440        } => {
5441            let install_path =
5442                uv_fs::normalize_path_buf(root.join(&install_path)).into_boxed_path();
5443            let url = VerbatimUrl::from_normalized_path(&install_path)
5444                .map_err(LockErrorKind::RequirementVerbatimUrl)?;
5445
5446            Ok(Requirement {
5447                name: requirement.name,
5448                extras: requirement.extras,
5449                groups: requirement.groups,
5450                marker: requires_python.simplify_markers(requirement.marker),
5451                source: RequirementSource::Directory {
5452                    install_path,
5453                    editable: Some(editable.unwrap_or(false)),
5454                    r#virtual: Some(r#virtual.unwrap_or(false)),
5455                    url,
5456                },
5457                origin: None,
5458            })
5459        }
5460        RequirementSource::Registry {
5461            specifier,
5462            index,
5463            conflict,
5464        } => {
5465            // Round-trip the index to remove anything apart from the URL.
5466            let index = index
5467                .map(|index| index.url.into_url())
5468                .map(|mut index| {
5469                    index.remove_credentials();
5470                    index
5471                })
5472                .map(|index| IndexMetadata::from(IndexUrl::from(VerbatimUrl::from_url(index))));
5473            Ok(Requirement {
5474                name: requirement.name,
5475                extras: requirement.extras,
5476                groups: requirement.groups,
5477                marker: requires_python.simplify_markers(requirement.marker),
5478                source: RequirementSource::Registry {
5479                    specifier,
5480                    index,
5481                    conflict,
5482                },
5483                origin: None,
5484            })
5485        }
5486        RequirementSource::Url {
5487            mut location,
5488            subdirectory,
5489            ext,
5490            url: _,
5491        } => {
5492            // Remove the credentials.
5493            location.remove_credentials();
5494
5495            // Remove the fragment from the URL; it's already present in the source.
5496            location.set_fragment(None);
5497
5498            // Reconstruct the PEP 508 URL from the underlying data.
5499            let url = DisplaySafeUrl::from(ParsedArchiveUrl {
5500                url: location.clone(),
5501                subdirectory: subdirectory.clone(),
5502                ext,
5503            });
5504
5505            Ok(Requirement {
5506                name: requirement.name,
5507                extras: requirement.extras,
5508                groups: requirement.groups,
5509                marker: requires_python.simplify_markers(requirement.marker),
5510                source: RequirementSource::Url {
5511                    location,
5512                    subdirectory,
5513                    ext,
5514                    url: VerbatimUrl::from_url(url),
5515                },
5516                origin: None,
5517            })
5518        }
5519    }
5520}
5521
5522#[derive(Debug)]
5523pub struct LockError {
5524    kind: Box<LockErrorKind>,
5525    hint: Option<WheelTagHint>,
5526}
5527
5528impl std::error::Error for LockError {
5529    fn source(&self) -> Option<&(dyn Error + 'static)> {
5530        self.kind.source()
5531    }
5532}
5533
5534impl std::fmt::Display for LockError {
5535    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5536        write!(f, "{}", self.kind)?;
5537        if let Some(hint) = &self.hint {
5538            write!(f, "\n\n{hint}")?;
5539        }
5540        Ok(())
5541    }
5542}
5543
5544impl LockError {
5545    /// Returns true if the [`LockError`] is a resolver error.
5546    pub fn is_resolution(&self) -> bool {
5547        matches!(&*self.kind, LockErrorKind::Resolution { .. })
5548    }
5549}
5550
5551impl<E> From<E> for LockError
5552where
5553    LockErrorKind: From<E>,
5554{
5555    fn from(err: E) -> Self {
5556        Self {
5557            kind: Box::new(LockErrorKind::from(err)),
5558            hint: None,
5559        }
5560    }
5561}
5562
5563#[derive(Debug, Clone, PartialEq, Eq)]
5564#[expect(clippy::enum_variant_names)]
5565enum WheelTagHint {
5566    /// None of the available wheels for a package have a compatible Python language tag (e.g.,
5567    /// `cp310` in `cp310-abi3-manylinux_2_17_x86_64.whl`).
5568    LanguageTags {
5569        package: PackageName,
5570        version: Option<Version>,
5571        tags: BTreeSet<LanguageTag>,
5572        best: Option<LanguageTag>,
5573    },
5574    /// None of the available wheels for a package have a compatible ABI tag (e.g., `abi3` in
5575    /// `cp310-abi3-manylinux_2_17_x86_64.whl`).
5576    AbiTags {
5577        package: PackageName,
5578        version: Option<Version>,
5579        tags: BTreeSet<AbiTag>,
5580        best: Option<AbiTag>,
5581    },
5582    /// None of the available wheels for a package have a compatible platform tag (e.g.,
5583    /// `manylinux_2_17_x86_64` in `cp310-abi3-manylinux_2_17_x86_64.whl`).
5584    PlatformTags {
5585        package: PackageName,
5586        version: Option<Version>,
5587        tags: BTreeSet<PlatformTag>,
5588        best: Option<PlatformTag>,
5589        markers: MarkerEnvironment,
5590    },
5591}
5592
5593impl WheelTagHint {
5594    /// Generate a [`WheelTagHint`] from the given (incompatible) wheels.
5595    fn from_wheels(
5596        name: &PackageName,
5597        version: Option<&Version>,
5598        filenames: &[&WheelFilename],
5599        tags: &Tags,
5600        markers: &MarkerEnvironment,
5601    ) -> Option<Self> {
5602        let incompatibility = filenames
5603            .iter()
5604            .map(|filename| {
5605                tags.compatibility(
5606                    filename.python_tags(),
5607                    filename.abi_tags(),
5608                    filename.platform_tags(),
5609                )
5610            })
5611            .max()?;
5612        match incompatibility {
5613            TagCompatibility::Incompatible(IncompatibleTag::Python) => {
5614                let best = tags.python_tag();
5615                let tags = Self::python_tags(filenames.iter().copied()).collect::<BTreeSet<_>>();
5616                if tags.is_empty() {
5617                    None
5618                } else {
5619                    Some(Self::LanguageTags {
5620                        package: name.clone(),
5621                        version: version.cloned(),
5622                        tags,
5623                        best,
5624                    })
5625                }
5626            }
5627            TagCompatibility::Incompatible(IncompatibleTag::Abi) => {
5628                let best = tags.abi_tag();
5629                let tags = Self::abi_tags(filenames.iter().copied())
5630                    // Ignore `none`, which is universally compatible.
5631                    //
5632                    // As an example, `none` can appear here if we're solving for Python 3.13, and
5633                    // the distribution includes a wheel for `cp312-none-macosx_11_0_arm64`.
5634                    //
5635                    // In that case, the wheel isn't compatible, but when solving for Python 3.13,
5636                    // the `cp312` Python tag _can_ be compatible (e.g., for `cp312-abi3-macosx_11_0_arm64.whl`),
5637                    // so this is considered an ABI incompatibility rather than Python incompatibility.
5638                    .filter(|tag| *tag != AbiTag::None)
5639                    .collect::<BTreeSet<_>>();
5640                if tags.is_empty() {
5641                    None
5642                } else {
5643                    Some(Self::AbiTags {
5644                        package: name.clone(),
5645                        version: version.cloned(),
5646                        tags,
5647                        best,
5648                    })
5649                }
5650            }
5651            TagCompatibility::Incompatible(IncompatibleTag::Platform) => {
5652                let best = tags.platform_tag().cloned();
5653                let incompatible_tags = Self::platform_tags(filenames.iter().copied(), tags)
5654                    .cloned()
5655                    .collect::<BTreeSet<_>>();
5656                if incompatible_tags.is_empty() {
5657                    None
5658                } else {
5659                    Some(Self::PlatformTags {
5660                        package: name.clone(),
5661                        version: version.cloned(),
5662                        tags: incompatible_tags,
5663                        best,
5664                        markers: markers.clone(),
5665                    })
5666                }
5667            }
5668            _ => None,
5669        }
5670    }
5671
5672    /// Returns an iterator over the compatible Python tags of the available wheels.
5673    fn python_tags<'a>(
5674        filenames: impl Iterator<Item = &'a WheelFilename> + 'a,
5675    ) -> impl Iterator<Item = LanguageTag> + 'a {
5676        filenames.flat_map(WheelFilename::python_tags).copied()
5677    }
5678
5679    /// Returns an iterator over the compatible Python tags of the available wheels.
5680    fn abi_tags<'a>(
5681        filenames: impl Iterator<Item = &'a WheelFilename> + 'a,
5682    ) -> impl Iterator<Item = AbiTag> + 'a {
5683        filenames.flat_map(WheelFilename::abi_tags).copied()
5684    }
5685
5686    /// Returns the set of platform tags for the distribution that are ABI-compatible with the given
5687    /// tags.
5688    fn platform_tags<'a>(
5689        filenames: impl Iterator<Item = &'a WheelFilename> + 'a,
5690        tags: &'a Tags,
5691    ) -> impl Iterator<Item = &'a PlatformTag> + 'a {
5692        filenames.flat_map(move |filename| {
5693            if filename.python_tags().iter().any(|wheel_py| {
5694                filename
5695                    .abi_tags()
5696                    .iter()
5697                    .any(|wheel_abi| tags.is_compatible_abi(*wheel_py, *wheel_abi))
5698            }) {
5699                filename.platform_tags().iter()
5700            } else {
5701                [].iter()
5702            }
5703        })
5704    }
5705
5706    fn suggest_environment_marker(markers: &MarkerEnvironment) -> String {
5707        let sys_platform = markers.sys_platform();
5708        let platform_machine = markers.platform_machine();
5709
5710        // Generate the marker string based on actual environment values
5711        if platform_machine.is_empty() {
5712            format!("sys_platform == '{sys_platform}'")
5713        } else {
5714            format!("sys_platform == '{sys_platform}' and platform_machine == '{platform_machine}'")
5715        }
5716    }
5717}
5718
5719impl std::fmt::Display for WheelTagHint {
5720    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5721        match self {
5722            Self::LanguageTags {
5723                package,
5724                version,
5725                tags,
5726                best,
5727            } => {
5728                if let Some(best) = best {
5729                    let s = if tags.len() == 1 { "" } else { "s" };
5730                    let best = if let Some(pretty) = best.pretty() {
5731                        format!("{} (`{}`)", pretty.cyan(), best.cyan())
5732                    } else {
5733                        format!("{}", best.cyan())
5734                    };
5735                    if let Some(version) = version {
5736                        write!(
5737                            f,
5738                            "{}{} You're using {}, but `{}` ({}) only has wheels with the following Python implementation tag{s}: {}",
5739                            "hint".bold().cyan(),
5740                            ":".bold(),
5741                            best,
5742                            package.cyan(),
5743                            format!("v{version}").cyan(),
5744                            tags.iter()
5745                                .map(|tag| format!("`{}`", tag.cyan()))
5746                                .join(", "),
5747                        )
5748                    } else {
5749                        write!(
5750                            f,
5751                            "{}{} You're using {}, but `{}` only has wheels with the following Python implementation tag{s}: {}",
5752                            "hint".bold().cyan(),
5753                            ":".bold(),
5754                            best,
5755                            package.cyan(),
5756                            tags.iter()
5757                                .map(|tag| format!("`{}`", tag.cyan()))
5758                                .join(", "),
5759                        )
5760                    }
5761                } else {
5762                    let s = if tags.len() == 1 { "" } else { "s" };
5763                    if let Some(version) = version {
5764                        write!(
5765                            f,
5766                            "{}{} Wheels are available for `{}` ({}) with the following Python implementation tag{s}: {}",
5767                            "hint".bold().cyan(),
5768                            ":".bold(),
5769                            package.cyan(),
5770                            format!("v{version}").cyan(),
5771                            tags.iter()
5772                                .map(|tag| format!("`{}`", tag.cyan()))
5773                                .join(", "),
5774                        )
5775                    } else {
5776                        write!(
5777                            f,
5778                            "{}{} Wheels are available for `{}` with the following Python implementation tag{s}: {}",
5779                            "hint".bold().cyan(),
5780                            ":".bold(),
5781                            package.cyan(),
5782                            tags.iter()
5783                                .map(|tag| format!("`{}`", tag.cyan()))
5784                                .join(", "),
5785                        )
5786                    }
5787                }
5788            }
5789            Self::AbiTags {
5790                package,
5791                version,
5792                tags,
5793                best,
5794            } => {
5795                if let Some(best) = best {
5796                    let s = if tags.len() == 1 { "" } else { "s" };
5797                    let best = if let Some(pretty) = best.pretty() {
5798                        format!("{} (`{}`)", pretty.cyan(), best.cyan())
5799                    } else {
5800                        format!("{}", best.cyan())
5801                    };
5802                    if let Some(version) = version {
5803                        write!(
5804                            f,
5805                            "{}{} You're using {}, but `{}` ({}) only has wheels with the following Python ABI tag{s}: {}",
5806                            "hint".bold().cyan(),
5807                            ":".bold(),
5808                            best,
5809                            package.cyan(),
5810                            format!("v{version}").cyan(),
5811                            tags.iter()
5812                                .map(|tag| format!("`{}`", tag.cyan()))
5813                                .join(", "),
5814                        )
5815                    } else {
5816                        write!(
5817                            f,
5818                            "{}{} You're using {}, but `{}` only has wheels with the following Python ABI tag{s}: {}",
5819                            "hint".bold().cyan(),
5820                            ":".bold(),
5821                            best,
5822                            package.cyan(),
5823                            tags.iter()
5824                                .map(|tag| format!("`{}`", tag.cyan()))
5825                                .join(", "),
5826                        )
5827                    }
5828                } else {
5829                    let s = if tags.len() == 1 { "" } else { "s" };
5830                    if let Some(version) = version {
5831                        write!(
5832                            f,
5833                            "{}{} Wheels are available for `{}` ({}) with the following Python ABI tag{s}: {}",
5834                            "hint".bold().cyan(),
5835                            ":".bold(),
5836                            package.cyan(),
5837                            format!("v{version}").cyan(),
5838                            tags.iter()
5839                                .map(|tag| format!("`{}`", tag.cyan()))
5840                                .join(", "),
5841                        )
5842                    } else {
5843                        write!(
5844                            f,
5845                            "{}{} Wheels are available for `{}` with the following Python ABI tag{s}: {}",
5846                            "hint".bold().cyan(),
5847                            ":".bold(),
5848                            package.cyan(),
5849                            tags.iter()
5850                                .map(|tag| format!("`{}`", tag.cyan()))
5851                                .join(", "),
5852                        )
5853                    }
5854                }
5855            }
5856            Self::PlatformTags {
5857                package,
5858                version,
5859                tags,
5860                best,
5861                markers,
5862            } => {
5863                let s = if tags.len() == 1 { "" } else { "s" };
5864                if let Some(best) = best {
5865                    let example_marker = Self::suggest_environment_marker(markers);
5866                    let best = if let Some(pretty) = best.pretty() {
5867                        format!("{} (`{}`)", pretty.cyan(), best.cyan())
5868                    } else {
5869                        format!("`{}`", best.cyan())
5870                    };
5871                    let package_ref = if let Some(version) = version {
5872                        format!("`{}` ({})", package.cyan(), format!("v{version}").cyan())
5873                    } else {
5874                        format!("`{}`", package.cyan())
5875                    };
5876                    write!(
5877                        f,
5878                        "{}{} 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",
5879                        "hint".bold().cyan(),
5880                        ":".bold(),
5881                        best,
5882                        package_ref,
5883                        tags.iter()
5884                            .map(|tag| format!("`{}`", tag.cyan()))
5885                            .join(", "),
5886                        format!("\"{example_marker}\"").cyan(),
5887                        "tool.uv.required-environments".green()
5888                    )
5889                } else {
5890                    if let Some(version) = version {
5891                        write!(
5892                            f,
5893                            "{}{} Wheels are available for `{}` ({}) on the following platform{s}: {}",
5894                            "hint".bold().cyan(),
5895                            ":".bold(),
5896                            package.cyan(),
5897                            format!("v{version}").cyan(),
5898                            tags.iter()
5899                                .map(|tag| format!("`{}`", tag.cyan()))
5900                                .join(", "),
5901                        )
5902                    } else {
5903                        write!(
5904                            f,
5905                            "{}{} Wheels are available for `{}` on the following platform{s}: {}",
5906                            "hint".bold().cyan(),
5907                            ":".bold(),
5908                            package.cyan(),
5909                            tags.iter()
5910                                .map(|tag| format!("`{}`", tag.cyan()))
5911                                .join(", "),
5912                        )
5913                    }
5914                }
5915            }
5916        }
5917    }
5918}
5919
5920/// An error that occurs when generating a `Lock` data structure.
5921///
5922/// These errors are sometimes the result of possible programming bugs.
5923/// For example, if there are two or more duplicative distributions given
5924/// to `Lock::new`, then an error is returned. It's likely that the fault
5925/// is with the caller somewhere in such cases.
5926#[derive(Debug, thiserror::Error)]
5927enum LockErrorKind {
5928    /// An error that occurs when multiple packages with the same
5929    /// ID were found.
5930    #[error("Found duplicate package `{id}`", id = id.cyan())]
5931    DuplicatePackage {
5932        /// The ID of the conflicting package.
5933        id: PackageId,
5934    },
5935    /// An error that occurs when there are multiple dependencies for the
5936    /// same package that have identical identifiers.
5937    #[error("For package `{id}`, found duplicate dependency `{dependency}`", id = id.cyan(), dependency = dependency.cyan())]
5938    DuplicateDependency {
5939        /// The ID of the package for which a duplicate dependency was
5940        /// found.
5941        id: PackageId,
5942        /// The ID of the conflicting dependency.
5943        dependency: Dependency,
5944    },
5945    /// An error that occurs when there are multiple dependencies for the
5946    /// same package that have identical identifiers, as part of the
5947    /// that package's optional dependencies.
5948    #[error("For package `{id}`, found duplicate dependency `{dependency}`", id = format!("{id}[{extra}]").cyan(), dependency = dependency.cyan())]
5949    DuplicateOptionalDependency {
5950        /// The ID of the package for which a duplicate dependency was
5951        /// found.
5952        id: PackageId,
5953        /// The name of the extra.
5954        extra: ExtraName,
5955        /// The ID of the conflicting dependency.
5956        dependency: Dependency,
5957    },
5958    /// An error that occurs when there are multiple dependencies for the
5959    /// same package that have identical identifiers, as part of the
5960    /// that package's development dependencies.
5961    #[error("For package `{id}`, found duplicate dependency `{dependency}`", id = format!("{id}:{group}").cyan(), dependency = dependency.cyan())]
5962    DuplicateDevDependency {
5963        /// The ID of the package for which a duplicate dependency was
5964        /// found.
5965        id: PackageId,
5966        /// The name of the dev dependency group.
5967        group: GroupName,
5968        /// The ID of the conflicting dependency.
5969        dependency: Dependency,
5970    },
5971    /// An error that occurs when the URL to a file for a wheel or
5972    /// source dist could not be converted to a structured `url::Url`.
5973    #[error(transparent)]
5974    InvalidUrl(
5975        /// The underlying error that occurred. This includes the
5976        /// errant URL in its error message.
5977        #[from]
5978        ToUrlError,
5979    ),
5980    /// An error that occurs when the extension can't be determined
5981    /// for a given wheel or source distribution.
5982    #[error("Failed to parse file extension for `{id}`; expected one of: {err}", id = id.cyan())]
5983    MissingExtension {
5984        /// The filename that was expected to have an extension.
5985        id: PackageId,
5986        /// The list of valid extensions that were expected.
5987        err: ExtensionError,
5988    },
5989    /// Failed to parse a Git source URL.
5990    #[error("Failed to parse Git URL")]
5991    InvalidGitSourceUrl(
5992        /// The underlying error that occurred. This includes the
5993        /// errant URL in the message.
5994        #[source]
5995        SourceParseError,
5996    ),
5997    #[error("Failed to parse timestamp")]
5998    InvalidTimestamp(
5999        /// The underlying error that occurred. This includes the
6000        /// errant timestamp in the message.
6001        #[source]
6002        jiff::Error,
6003    ),
6004    /// An error that occurs when there's an unrecognized dependency.
6005    ///
6006    /// That is, a dependency for a package that isn't in the lockfile.
6007    #[error("For package `{id}`, found dependency `{dependency}` with no locked package", id = id.cyan(), dependency = dependency.cyan())]
6008    UnrecognizedDependency {
6009        /// The ID of the package that has an unrecognized dependency.
6010        id: PackageId,
6011        /// The ID of the dependency that doesn't have a corresponding package
6012        /// entry.
6013        dependency: Dependency,
6014    },
6015    /// An error that occurs when a hash is expected (or not) for a particular
6016    /// artifact, but one was not found (or was).
6017    #[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" })]
6018    Hash {
6019        /// The ID of the package that has a missing hash.
6020        id: PackageId,
6021        /// The specific type of artifact, e.g., "source package"
6022        /// or "wheel".
6023        artifact_type: &'static str,
6024        /// When true, a hash is expected to be present.
6025        expected: bool,
6026    },
6027    /// An error that occurs when a package is included with an extra name,
6028    /// but no corresponding base package (i.e., without the extra) exists.
6029    #[error("Found package `{id}` with extra `{extra}` but no base package", id = id.cyan(), extra = extra.cyan())]
6030    MissingExtraBase {
6031        /// The ID of the package that has a missing base.
6032        id: PackageId,
6033        /// The extra name that was found.
6034        extra: ExtraName,
6035    },
6036    /// An error that occurs when a package is included with a development
6037    /// dependency group, but no corresponding base package (i.e., without
6038    /// the group) exists.
6039    #[error("Found package `{id}` with development dependency group `{group}` but no base package", id = id.cyan())]
6040    MissingDevBase {
6041        /// The ID of the package that has a missing base.
6042        id: PackageId,
6043        /// The development dependency group that was found.
6044        group: GroupName,
6045    },
6046    /// An error that occurs from an invalid lockfile where a wheel comes from a non-wheel source
6047    /// such as a directory.
6048    #[error("Wheels cannot come from {source_type} sources")]
6049    InvalidWheelSource {
6050        /// The ID of the distribution that has a missing base.
6051        id: PackageId,
6052        /// The kind of the invalid source.
6053        source_type: &'static str,
6054    },
6055    /// An error that occurs when a distribution indicates that it is sourced from a remote
6056    /// registry, but is missing a URL.
6057    #[error("Found registry distribution `{name}` ({version}) without a valid URL", name = name.cyan(), version = format!("v{version}").cyan())]
6058    MissingUrl {
6059        /// The name of the distribution that is missing a URL.
6060        name: PackageName,
6061        /// The version of the distribution that is missing a URL.
6062        version: Version,
6063    },
6064    /// An error that occurs when a distribution indicates that it is sourced from a local registry,
6065    /// but is missing a path.
6066    #[error("Found registry distribution `{name}` ({version}) without a valid path", name = name.cyan(), version = format!("v{version}").cyan())]
6067    MissingPath {
6068        /// The name of the distribution that is missing a path.
6069        name: PackageName,
6070        /// The version of the distribution that is missing a path.
6071        version: Version,
6072    },
6073    /// An error that occurs when a distribution indicates that it is sourced from a registry, but
6074    /// is missing a filename.
6075    #[error("Found registry distribution `{id}` without a valid filename", id = id.cyan())]
6076    MissingFilename {
6077        /// The ID of the distribution that is missing a filename.
6078        id: PackageId,
6079    },
6080    /// An error that occurs when a distribution is included with neither wheels nor a source
6081    /// distribution.
6082    #[error("Distribution `{id}` can't be installed because it doesn't have a source distribution or wheel for the current platform", id = id.cyan())]
6083    NeitherSourceDistNorWheel {
6084        /// The ID of the distribution.
6085        id: PackageId,
6086    },
6087    /// An error that occurs when a distribution is marked as both `--no-binary` and `--no-build`.
6088    #[error("Distribution `{id}` can't be installed because it is marked as both `--no-binary` and `--no-build`", id = id.cyan())]
6089    NoBinaryNoBuild {
6090        /// The ID of the distribution.
6091        id: PackageId,
6092    },
6093    /// An error that occurs when a distribution is marked as `--no-binary`, but no source
6094    /// distribution is available.
6095    #[error("Distribution `{id}` can't be installed because it is marked as `--no-binary` but has no source distribution", id = id.cyan())]
6096    NoBinary {
6097        /// The ID of the distribution.
6098        id: PackageId,
6099    },
6100    /// An error that occurs when a distribution is marked as `--no-build`, but no binary
6101    /// distribution is available.
6102    #[error("Distribution `{id}` can't be installed because it is marked as `--no-build` but has no binary distribution", id = id.cyan())]
6103    NoBuild {
6104        /// The ID of the distribution.
6105        id: PackageId,
6106    },
6107    /// An error that occurs when a wheel-only distribution is incompatible with the current
6108    /// platform.
6109    #[error("Distribution `{id}` can't be installed because the binary distribution is incompatible with the current platform", id = id.cyan())]
6110    IncompatibleWheelOnly {
6111        /// The ID of the distribution.
6112        id: PackageId,
6113    },
6114    /// An error that occurs when a wheel-only source is marked as `--no-binary`.
6115    #[error("Distribution `{id}` can't be installed because it is marked as `--no-binary` but is itself a binary distribution", id = id.cyan())]
6116    NoBinaryWheelOnly {
6117        /// The ID of the distribution.
6118        id: PackageId,
6119    },
6120    /// An error that occurs when converting between URLs and paths.
6121    #[error("Found dependency `{id}` with no locked distribution", id = id.cyan())]
6122    VerbatimUrl {
6123        /// The ID of the distribution that has a missing base.
6124        id: PackageId,
6125        /// The inner error we forward.
6126        #[source]
6127        err: VerbatimUrlError,
6128    },
6129    /// An error that occurs when parsing an existing requirement.
6130    #[error("Could not compute relative path between workspace and distribution")]
6131    DistributionRelativePath(
6132        /// The inner error we forward.
6133        #[source]
6134        io::Error,
6135    ),
6136    /// An error that occurs when converting an index URL to a relative path
6137    #[error("Could not compute relative path between workspace and index")]
6138    IndexRelativePath(
6139        /// The inner error we forward.
6140        #[source]
6141        io::Error,
6142    ),
6143    /// An error that occurs when converting a lockfile path from relative to absolute.
6144    #[error("Could not compute absolute path from workspace root and lockfile path")]
6145    AbsolutePath(
6146        /// The inner error we forward.
6147        #[source]
6148        io::Error,
6149    ),
6150    /// An error that occurs when an ambiguous `package.dependency` is
6151    /// missing a `version` field.
6152    #[error("Dependency `{name}` has missing `version` field but has more than one matching package", name = name.cyan())]
6153    MissingDependencyVersion {
6154        /// The name of the dependency that is missing a `version` field.
6155        name: PackageName,
6156    },
6157    /// An error that occurs when an ambiguous `package.dependency` is
6158    /// missing a `source` field.
6159    #[error("Dependency `{name}` has missing `source` field but has more than one matching package", name = name.cyan())]
6160    MissingDependencySource {
6161        /// The name of the dependency that is missing a `source` field.
6162        name: PackageName,
6163    },
6164    /// An error that occurs when parsing an existing requirement.
6165    #[error("Could not compute relative path between workspace and requirement")]
6166    RequirementRelativePath(
6167        /// The inner error we forward.
6168        #[source]
6169        io::Error,
6170    ),
6171    /// An error that occurs when parsing an existing requirement.
6172    #[error("Could not convert between URL and path")]
6173    RequirementVerbatimUrl(
6174        /// The inner error we forward.
6175        #[source]
6176        VerbatimUrlError,
6177    ),
6178    /// An error that occurs when parsing a registry's index URL.
6179    #[error("Could not convert between URL and path")]
6180    RegistryVerbatimUrl(
6181        /// The inner error we forward.
6182        #[source]
6183        VerbatimUrlError,
6184    ),
6185    /// An error that occurs when converting a path to a URL.
6186    #[error("Failed to convert path to URL: {path}", path = path.display().cyan())]
6187    PathToUrl { path: Box<Path> },
6188    /// An error that occurs when converting a URL to a path
6189    #[error("Failed to convert URL to path: {url}", url = url.cyan())]
6190    UrlToPath { url: DisplaySafeUrl },
6191    /// An error that occurs when multiple packages with the same
6192    /// name were found when identifying the root packages.
6193    #[error("Found multiple packages matching `{name}`", name = name.cyan())]
6194    MultipleRootPackages {
6195        /// The ID of the package.
6196        name: PackageName,
6197    },
6198    /// An error that occurs when a root package can't be found.
6199    #[error("Could not find root package `{name}`", name = name.cyan())]
6200    MissingRootPackage {
6201        /// The ID of the package.
6202        name: PackageName,
6203    },
6204    /// An error that occurs when resolving metadata for a package.
6205    #[error("Failed to generate package metadata for `{id}`", id = id.cyan())]
6206    Resolution {
6207        /// The ID of the distribution that failed to resolve.
6208        id: PackageId,
6209        /// The inner error we forward.
6210        #[source]
6211        err: uv_distribution::Error,
6212    },
6213    /// A package has inconsistent versions in a single entry
6214    // Using name instead of id since the version in the id is part of the conflict.
6215    #[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())]
6216    InconsistentVersions {
6217        /// The name of the package with the inconsistent entry.
6218        name: PackageName,
6219        /// The version of the package with the inconsistent entry.
6220        version: Version,
6221        /// The wheel with the inconsistent version.
6222        wheel: Wheel,
6223    },
6224    #[error(
6225        "Found conflicting extras `{package1}[{extra1}]` \
6226         and `{package2}[{extra2}]` enabled simultaneously"
6227    )]
6228    ConflictingExtra {
6229        package1: PackageName,
6230        extra1: ExtraName,
6231        package2: PackageName,
6232        extra2: ExtraName,
6233    },
6234    #[error(transparent)]
6235    GitUrlParse(#[from] GitUrlParseError),
6236    #[error("Failed to read `{path}`")]
6237    UnreadablePyprojectToml {
6238        path: PathBuf,
6239        #[source]
6240        err: std::io::Error,
6241    },
6242    #[error("Failed to parse `{path}`")]
6243    InvalidPyprojectToml {
6244        path: PathBuf,
6245        #[source]
6246        err: uv_pypi_types::MetadataError,
6247    },
6248    /// An error that occurs when a workspace member has a non-local source.
6249    #[error("Workspace member `{id}` has non-local source", id = id.cyan())]
6250    NonLocalWorkspaceMember {
6251        /// The ID of the workspace member with an invalid source.
6252        id: PackageId,
6253    },
6254}
6255
6256/// An error that occurs when a source string could not be parsed.
6257#[derive(Debug, thiserror::Error)]
6258enum SourceParseError {
6259    /// An error that occurs when the URL in the source is invalid.
6260    #[error("Invalid URL in source `{given}`")]
6261    InvalidUrl {
6262        /// The source string given.
6263        given: String,
6264        /// The URL parse error.
6265        #[source]
6266        err: DisplaySafeUrlError,
6267    },
6268    /// An error that occurs when a Git URL is missing a precise commit SHA.
6269    #[error("Missing SHA in source `{given}`")]
6270    MissingSha {
6271        /// The source string given.
6272        given: String,
6273    },
6274    /// An error that occurs when a Git URL has an invalid SHA.
6275    #[error("Invalid SHA in source `{given}`")]
6276    InvalidSha {
6277        /// The source string given.
6278        given: String,
6279    },
6280}
6281
6282/// An error that occurs when a hash digest could not be parsed.
6283#[derive(Clone, Debug, Eq, PartialEq)]
6284struct HashParseError(&'static str);
6285
6286impl std::error::Error for HashParseError {}
6287
6288impl Display for HashParseError {
6289    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
6290        Display::fmt(self.0, f)
6291    }
6292}
6293
6294/// Format an array so that each element is on its own line and has a trailing comma.
6295///
6296/// Example:
6297///
6298/// ```toml
6299/// dependencies = [
6300///     { name = "idna" },
6301///     { name = "sniffio" },
6302/// ]
6303/// ```
6304fn each_element_on_its_line_array(elements: impl Iterator<Item = impl Into<Value>>) -> Array {
6305    let mut array = elements
6306        .map(|item| {
6307            let mut value = item.into();
6308            // Each dependency is on its own line and indented.
6309            value.decor_mut().set_prefix("\n    ");
6310            value
6311        })
6312        .collect::<Array>();
6313    // With a trailing comma, inserting another entry doesn't change the preceding line,
6314    // reducing the diff noise.
6315    array.set_trailing_comma(true);
6316    // The line break between the last element's comma and the closing square bracket.
6317    array.set_trailing("\n");
6318    array
6319}
6320
6321/// Returns the simplified string-ified version of each marker given.
6322///
6323/// Note that the marker strings returned will include conflict markers if they
6324/// are present.
6325fn simplified_universal_markers(
6326    markers: &[UniversalMarker],
6327    requires_python: &RequiresPython,
6328) -> Vec<String> {
6329    let mut pep508_only = vec![];
6330    let mut seen = FxHashSet::default();
6331    for marker in markers {
6332        let simplified =
6333            SimplifiedMarkerTree::new(requires_python, marker.pep508()).as_simplified_marker_tree();
6334        if seen.insert(simplified) {
6335            pep508_only.push(simplified);
6336        }
6337    }
6338    let any_overlap = pep508_only
6339        .iter()
6340        .tuple_combinations()
6341        .any(|(&marker1, &marker2)| !marker1.is_disjoint(marker2));
6342    let markers = if !any_overlap {
6343        pep508_only
6344    } else {
6345        markers
6346            .iter()
6347            .map(|marker| {
6348                SimplifiedMarkerTree::new(requires_python, marker.combined())
6349                    .as_simplified_marker_tree()
6350            })
6351            .collect()
6352    };
6353    markers
6354        .into_iter()
6355        .filter_map(MarkerTree::try_to_string)
6356        .collect()
6357}
6358
6359/// Filter out wheels that can't be selected for installation due to environment markers.
6360///
6361/// For example, a package included under `sys_platform == 'win32'` does not need Linux
6362/// wheels.
6363///
6364/// Returns `true` if the wheel is definitely unreachable, and `false` if it may be reachable,
6365/// including if the wheel tag isn't recognized.
6366fn is_wheel_unreachable_for_marker(
6367    filename: &WheelFilename,
6368    requires_python: &RequiresPython,
6369    marker: &UniversalMarker,
6370    tags: Option<&Tags>,
6371) -> bool {
6372    if let Some(tags) = tags
6373        && !filename.compatibility(tags).is_compatible()
6374    {
6375        return true;
6376    }
6377    // Remove wheels that don't match `requires-python` and can't be selected for installation.
6378    if !requires_python.matches_wheel_tag(filename) {
6379        return true;
6380    }
6381
6382    // Filter by platform tags.
6383
6384    // Naively, we'd check whether `platform_system == 'Linux'` is disjoint, or
6385    // `os_name == 'posix'` is disjoint, or `sys_platform == 'linux'` is disjoint (each on its
6386    // own sufficient to exclude linux wheels), but due to
6387    // `(A ∩ (B ∩ C) = ∅) => ((A ∩ B = ∅) or (A ∩ C = ∅))`
6388    // a single disjointness check with the intersection is sufficient, so we have one
6389    // constant per platform.
6390    let platform_tags = filename.platform_tags();
6391
6392    if platform_tags.iter().all(PlatformTag::is_any) {
6393        return false;
6394    }
6395
6396    if platform_tags.iter().all(PlatformTag::is_linux) {
6397        if platform_tags.iter().all(PlatformTag::is_arm) {
6398            if marker.is_disjoint(*LINUX_ARM_MARKERS) {
6399                return true;
6400            }
6401        } else if platform_tags.iter().all(PlatformTag::is_x86_64) {
6402            if marker.is_disjoint(*LINUX_X86_64_MARKERS) {
6403                return true;
6404            }
6405        } else if platform_tags.iter().all(PlatformTag::is_x86) {
6406            if marker.is_disjoint(*LINUX_X86_MARKERS) {
6407                return true;
6408            }
6409        } else if platform_tags.iter().all(PlatformTag::is_ppc64le) {
6410            if marker.is_disjoint(*LINUX_PPC64LE_MARKERS) {
6411                return true;
6412            }
6413        } else if platform_tags.iter().all(PlatformTag::is_ppc64) {
6414            if marker.is_disjoint(*LINUX_PPC64_MARKERS) {
6415                return true;
6416            }
6417        } else if platform_tags.iter().all(PlatformTag::is_s390x) {
6418            if marker.is_disjoint(*LINUX_S390X_MARKERS) {
6419                return true;
6420            }
6421        } else if platform_tags.iter().all(PlatformTag::is_riscv64) {
6422            if marker.is_disjoint(*LINUX_RISCV64_MARKERS) {
6423                return true;
6424            }
6425        } else if platform_tags.iter().all(PlatformTag::is_loongarch64) {
6426            if marker.is_disjoint(*LINUX_LOONGARCH64_MARKERS) {
6427                return true;
6428            }
6429        } else if platform_tags.iter().all(PlatformTag::is_armv7l) {
6430            if marker.is_disjoint(*LINUX_ARMV7L_MARKERS) {
6431                return true;
6432            }
6433        } else if platform_tags.iter().all(PlatformTag::is_armv6l) {
6434            if marker.is_disjoint(*LINUX_ARMV6L_MARKERS) {
6435                return true;
6436            }
6437        } else if marker.is_disjoint(*LINUX_MARKERS) {
6438            return true;
6439        }
6440    }
6441
6442    if platform_tags.iter().all(PlatformTag::is_windows) {
6443        if platform_tags.iter().all(PlatformTag::is_arm) {
6444            if marker.is_disjoint(*WINDOWS_ARM_MARKERS) {
6445                return true;
6446            }
6447        } else if platform_tags.iter().all(PlatformTag::is_x86_64) {
6448            if marker.is_disjoint(*WINDOWS_X86_64_MARKERS) {
6449                return true;
6450            }
6451        } else if platform_tags.iter().all(PlatformTag::is_x86) {
6452            if marker.is_disjoint(*WINDOWS_X86_MARKERS) {
6453                return true;
6454            }
6455        } else if marker.is_disjoint(*WINDOWS_MARKERS) {
6456            return true;
6457        }
6458    }
6459
6460    if platform_tags.iter().all(PlatformTag::is_macos) {
6461        if platform_tags.iter().all(PlatformTag::is_arm) {
6462            if marker.is_disjoint(*MAC_ARM_MARKERS) {
6463                return true;
6464            }
6465        } else if platform_tags.iter().all(PlatformTag::is_x86_64) {
6466            if marker.is_disjoint(*MAC_X86_64_MARKERS) {
6467                return true;
6468            }
6469        } else if platform_tags.iter().all(PlatformTag::is_x86) {
6470            if marker.is_disjoint(*MAC_X86_MARKERS) {
6471                return true;
6472            }
6473        } else if marker.is_disjoint(*MAC_MARKERS) {
6474            return true;
6475        }
6476    }
6477
6478    if platform_tags.iter().all(PlatformTag::is_android) {
6479        if platform_tags.iter().all(PlatformTag::is_arm) {
6480            if marker.is_disjoint(*ANDROID_ARM_MARKERS) {
6481                return true;
6482            }
6483        } else if platform_tags.iter().all(PlatformTag::is_x86_64) {
6484            if marker.is_disjoint(*ANDROID_X86_64_MARKERS) {
6485                return true;
6486            }
6487        } else if platform_tags.iter().all(PlatformTag::is_x86) {
6488            if marker.is_disjoint(*ANDROID_X86_MARKERS) {
6489                return true;
6490            }
6491        } else if marker.is_disjoint(*ANDROID_MARKERS) {
6492            return true;
6493        }
6494    }
6495
6496    if platform_tags.iter().all(PlatformTag::is_arm) {
6497        if marker.is_disjoint(*ARM_MARKERS) {
6498            return true;
6499        }
6500    }
6501
6502    if platform_tags.iter().all(PlatformTag::is_x86_64) {
6503        if marker.is_disjoint(*X86_64_MARKERS) {
6504            return true;
6505        }
6506    }
6507
6508    if platform_tags.iter().all(PlatformTag::is_x86) {
6509        if marker.is_disjoint(*X86_MARKERS) {
6510            return true;
6511        }
6512    }
6513
6514    if platform_tags.iter().all(PlatformTag::is_ppc64le) {
6515        if marker.is_disjoint(*PPC64LE_MARKERS) {
6516            return true;
6517        }
6518    }
6519
6520    if platform_tags.iter().all(PlatformTag::is_ppc64) {
6521        if marker.is_disjoint(*PPC64_MARKERS) {
6522            return true;
6523        }
6524    }
6525
6526    if platform_tags.iter().all(PlatformTag::is_s390x) {
6527        if marker.is_disjoint(*S390X_MARKERS) {
6528            return true;
6529        }
6530    }
6531
6532    if platform_tags.iter().all(PlatformTag::is_riscv64) {
6533        if marker.is_disjoint(*RISCV64_MARKERS) {
6534            return true;
6535        }
6536    }
6537
6538    if platform_tags.iter().all(PlatformTag::is_loongarch64) {
6539        if marker.is_disjoint(*LOONGARCH64_MARKERS) {
6540            return true;
6541        }
6542    }
6543
6544    if platform_tags.iter().all(PlatformTag::is_armv7l) {
6545        if marker.is_disjoint(*ARMV7L_MARKERS) {
6546            return true;
6547        }
6548    }
6549
6550    if platform_tags.iter().all(PlatformTag::is_armv6l) {
6551        if marker.is_disjoint(*ARMV6L_MARKERS) {
6552            return true;
6553        }
6554    }
6555
6556    false
6557}
6558
6559pub(crate) fn is_wheel_unreachable(
6560    filename: &WheelFilename,
6561    graph: &ResolverOutput,
6562    requires_python: &RequiresPython,
6563    node_index: NodeIndex,
6564    tags: Option<&Tags>,
6565) -> bool {
6566    is_wheel_unreachable_for_marker(
6567        filename,
6568        requires_python,
6569        graph.graph[node_index].marker(),
6570        tags,
6571    )
6572}
6573
6574#[cfg(test)]
6575mod tests {
6576    use uv_warnings::anstream;
6577
6578    use super::*;
6579
6580    /// Assert a given display snapshot, stripping ANSI color codes.
6581    macro_rules! assert_stripped_snapshot {
6582        ($expr:expr, @$snapshot:literal) => {{
6583            let expr = format!("{}", $expr);
6584            let expr = format!("{}", anstream::adapter::strip_str(&expr));
6585            insta::assert_snapshot!(expr, @$snapshot);
6586        }};
6587    }
6588
6589    #[test]
6590    fn missing_dependency_source_unambiguous() {
6591        let data = r#"
6592version = 1
6593requires-python = ">=3.12"
6594
6595[[package]]
6596name = "a"
6597version = "0.1.0"
6598source = { registry = "https://pypi.org/simple" }
6599sdist = { url = "https://example.com", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 0 }
6600
6601[[package]]
6602name = "b"
6603version = "0.1.0"
6604source = { registry = "https://pypi.org/simple" }
6605sdist = { url = "https://example.com", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 0 }
6606
6607[[package.dependencies]]
6608name = "a"
6609version = "0.1.0"
6610"#;
6611        let result: Result<Lock, _> = toml::from_str(data);
6612        insta::assert_debug_snapshot!(result);
6613    }
6614
6615    #[test]
6616    fn missing_dependency_version_unambiguous() {
6617        let data = r#"
6618version = 1
6619requires-python = ">=3.12"
6620
6621[[package]]
6622name = "a"
6623version = "0.1.0"
6624source = { registry = "https://pypi.org/simple" }
6625sdist = { url = "https://example.com", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 0 }
6626
6627[[package]]
6628name = "b"
6629version = "0.1.0"
6630source = { registry = "https://pypi.org/simple" }
6631sdist = { url = "https://example.com", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 0 }
6632
6633[[package.dependencies]]
6634name = "a"
6635source = { registry = "https://pypi.org/simple" }
6636"#;
6637        let result: Result<Lock, _> = toml::from_str(data);
6638        insta::assert_debug_snapshot!(result);
6639    }
6640
6641    #[test]
6642    fn missing_dependency_source_version_unambiguous() {
6643        let data = r#"
6644version = 1
6645requires-python = ">=3.12"
6646
6647[[package]]
6648name = "a"
6649version = "0.1.0"
6650source = { registry = "https://pypi.org/simple" }
6651sdist = { url = "https://example.com", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 0 }
6652
6653[[package]]
6654name = "b"
6655version = "0.1.0"
6656source = { registry = "https://pypi.org/simple" }
6657sdist = { url = "https://example.com", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 0 }
6658
6659[[package.dependencies]]
6660name = "a"
6661"#;
6662        let result: Result<Lock, _> = toml::from_str(data);
6663        insta::assert_debug_snapshot!(result);
6664    }
6665
6666    #[test]
6667    fn missing_dependency_source_ambiguous() {
6668        let data = r#"
6669version = 1
6670requires-python = ">=3.12"
6671
6672[[package]]
6673name = "a"
6674version = "0.1.0"
6675source = { registry = "https://pypi.org/simple" }
6676sdist = { url = "https://example.com", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 0 }
6677
6678[[package]]
6679name = "a"
6680version = "0.1.1"
6681source = { registry = "https://pypi.org/simple" }
6682sdist = { url = "https://example.com", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 0 }
6683
6684[[package]]
6685name = "b"
6686version = "0.1.0"
6687source = { registry = "https://pypi.org/simple" }
6688sdist = { url = "https://example.com", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 0 }
6689
6690[[package.dependencies]]
6691name = "a"
6692version = "0.1.0"
6693"#;
6694        let result = toml::from_str::<Lock>(data).unwrap_err();
6695        assert_stripped_snapshot!(result, @"Dependency `a` has missing `source` field but has more than one matching package");
6696    }
6697
6698    #[test]
6699    fn missing_dependency_version_ambiguous() {
6700        let data = r#"
6701version = 1
6702requires-python = ">=3.12"
6703
6704[[package]]
6705name = "a"
6706version = "0.1.0"
6707source = { registry = "https://pypi.org/simple" }
6708sdist = { url = "https://example.com", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 0 }
6709
6710[[package]]
6711name = "a"
6712version = "0.1.1"
6713source = { registry = "https://pypi.org/simple" }
6714sdist = { url = "https://example.com", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 0 }
6715
6716[[package]]
6717name = "b"
6718version = "0.1.0"
6719source = { registry = "https://pypi.org/simple" }
6720sdist = { url = "https://example.com", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 0 }
6721
6722[[package.dependencies]]
6723name = "a"
6724source = { registry = "https://pypi.org/simple" }
6725"#;
6726        let result = toml::from_str::<Lock>(data).unwrap_err();
6727        assert_stripped_snapshot!(result, @"Dependency `a` has missing `version` field but has more than one matching package");
6728    }
6729
6730    #[test]
6731    fn missing_dependency_source_version_ambiguous() {
6732        let data = r#"
6733version = 1
6734requires-python = ">=3.12"
6735
6736[[package]]
6737name = "a"
6738version = "0.1.0"
6739source = { registry = "https://pypi.org/simple" }
6740sdist = { url = "https://example.com", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 0 }
6741
6742[[package]]
6743name = "a"
6744version = "0.1.1"
6745source = { registry = "https://pypi.org/simple" }
6746sdist = { url = "https://example.com", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 0 }
6747
6748[[package]]
6749name = "b"
6750version = "0.1.0"
6751source = { registry = "https://pypi.org/simple" }
6752sdist = { url = "https://example.com", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 0 }
6753
6754[[package.dependencies]]
6755name = "a"
6756"#;
6757        let result = toml::from_str::<Lock>(data).unwrap_err();
6758        assert_stripped_snapshot!(result, @"Dependency `a` has missing `source` field but has more than one matching package");
6759    }
6760
6761    #[test]
6762    fn missing_dependency_version_dynamic() {
6763        let data = r#"
6764version = 1
6765requires-python = ">=3.12"
6766
6767[[package]]
6768name = "a"
6769source = { editable = "path/to/a" }
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"
6785source = { editable = "path/to/a" }
6786"#;
6787        let result = toml::from_str::<Lock>(data);
6788        insta::assert_debug_snapshot!(result);
6789    }
6790
6791    #[test]
6792    fn hash_optional_missing() {
6793        let data = r#"
6794version = 1
6795requires-python = ">=3.12"
6796
6797[[package]]
6798name = "anyio"
6799version = "4.3.0"
6800source = { registry = "https://pypi.org/simple" }
6801wheels = [{ url = "https://files.pythonhosted.org/packages/14/fd/2f20c40b45e4fb4324834aea24bd4afdf1143390242c0b33774da0e2e34f/anyio-4.3.0-py3-none-any.whl" }]
6802"#;
6803        let result: Result<Lock, _> = toml::from_str(data);
6804        insta::assert_debug_snapshot!(result);
6805    }
6806
6807    #[test]
6808    fn hash_optional_present() {
6809        let data = r#"
6810version = 1
6811requires-python = ">=3.12"
6812
6813[[package]]
6814name = "anyio"
6815version = "4.3.0"
6816source = { registry = "https://pypi.org/simple" }
6817wheels = [{ url = "https://files.pythonhosted.org/packages/14/fd/2f20c40b45e4fb4324834aea24bd4afdf1143390242c0b33774da0e2e34f/anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8" }]
6818"#;
6819        let result: Result<Lock, _> = toml::from_str(data);
6820        insta::assert_debug_snapshot!(result);
6821    }
6822
6823    #[test]
6824    fn hash_required_present() {
6825        let data = r#"
6826version = 1
6827requires-python = ">=3.12"
6828
6829[[package]]
6830name = "anyio"
6831version = "4.3.0"
6832source = { path = "file:///foo/bar" }
6833wheels = [{ url = "file:///foo/bar/anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8" }]
6834"#;
6835        let result: Result<Lock, _> = toml::from_str(data);
6836        insta::assert_debug_snapshot!(result);
6837    }
6838
6839    #[test]
6840    fn source_direct_no_subdir() {
6841        let data = r#"
6842version = 1
6843requires-python = ">=3.12"
6844
6845[[package]]
6846name = "anyio"
6847version = "4.3.0"
6848source = { url = "https://burntsushi.net" }
6849"#;
6850        let result: Result<Lock, _> = toml::from_str(data);
6851        insta::assert_debug_snapshot!(result);
6852    }
6853
6854    #[test]
6855    fn source_direct_has_subdir() {
6856        let data = r#"
6857version = 1
6858requires-python = ">=3.12"
6859
6860[[package]]
6861name = "anyio"
6862version = "4.3.0"
6863source = { url = "https://burntsushi.net", subdirectory = "wat/foo/bar" }
6864"#;
6865        let result: Result<Lock, _> = toml::from_str(data);
6866        insta::assert_debug_snapshot!(result);
6867    }
6868
6869    #[test]
6870    fn source_directory() {
6871        let data = r#"
6872version = 1
6873requires-python = ">=3.12"
6874
6875[[package]]
6876name = "anyio"
6877version = "4.3.0"
6878source = { directory = "path/to/dir" }
6879"#;
6880        let result: Result<Lock, _> = toml::from_str(data);
6881        insta::assert_debug_snapshot!(result);
6882    }
6883
6884    #[test]
6885    fn source_editable() {
6886        let data = r#"
6887version = 1
6888requires-python = ">=3.12"
6889
6890[[package]]
6891name = "anyio"
6892version = "4.3.0"
6893source = { editable = "path/to/dir" }
6894"#;
6895        let result: Result<Lock, _> = toml::from_str(data);
6896        insta::assert_debug_snapshot!(result);
6897    }
6898
6899    /// Windows drive letter paths like `C:/...` should be deserialized as local path registry
6900    /// sources, not as URLs. The `C:` prefix must not be misinterpreted as a URL scheme.
6901    #[test]
6902    fn registry_source_windows_drive_letter() {
6903        let data = r#"
6904version = 1
6905requires-python = ">=3.12"
6906
6907[[package]]
6908name = "tqdm"
6909version = "1000.0.0"
6910source = { registry = "C:/Users/user/links" }
6911wheels = [
6912    { path = "C:/Users/user/links/tqdm-1000.0.0-py3-none-any.whl" },
6913]
6914"#;
6915        let lock: Lock = toml::from_str(data).unwrap();
6916        assert_eq!(
6917            lock.packages[0].id.source,
6918            Source::Registry(RegistrySource::Path(
6919                Path::new("C:/Users/user/links").into()
6920            ))
6921        );
6922    }
6923}