Skip to main content

uv_resolver/lock/export/
metadata.rs

1use std::collections::{BTreeMap, VecDeque};
2use std::fmt::Display;
3use std::path::Path;
4
5use uv_distribution_filename::WheelFilename;
6use uv_distribution_types::{Name, Requirement, RequiresPython, ResolvedDist, UrlString};
7use uv_fs::PortablePathBuf;
8use uv_normalize::{ExtraName, GroupName, PackageName};
9use uv_pep440::Version;
10use uv_pep508::MarkerTree;
11use uv_pypi_types::{ConflictItem, ConflictKind, ConflictSet, Conflicts, ModuleName};
12use uv_workspace::Workspace;
13
14use crate::lock::{
15    Dependency, DirectSource, Package, PackageId, RegistrySource, Source, SourceDist,
16    SourceDistMetadata, Wheel, WheelWireSource, ZstdWheel,
17};
18use crate::{Lock, LockError};
19
20#[derive(Debug, thiserror::Error)]
21enum MetadataErrorKind {
22    #[error(transparent)]
23    Serialize(#[from] serde_json::error::Error),
24    #[error(transparent)]
25    Lock(#[from] LockError),
26}
27
28#[derive(Debug)]
29pub struct MetadataError {
30    kind: Box<MetadataErrorKind>,
31}
32
33impl std::error::Error for MetadataError {
34    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
35        self.kind.source()
36    }
37}
38
39impl std::fmt::Display for MetadataError {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        write!(f, "{}", self.kind)?;
42        Ok(())
43    }
44}
45
46impl<E> From<E> for MetadataError
47where
48    MetadataErrorKind: From<E>,
49{
50    fn from(err: E) -> Self {
51        Self {
52            kind: Box::new(MetadataErrorKind::from(err)),
53        }
54    }
55}
56
57/// The full `uv workspace metadata` JSON object
58#[derive(Debug, serde::Serialize)]
59pub struct Metadata {
60    /// Format information
61    schema: SchemaReport,
62    /// Absolute path to the workspace root
63    ///
64    /// Ideally absolute paths to things that are found in subdirs of this should have exactly
65    /// this as a prefix so it can be stripped to get relative paths if one wants.
66    workspace_root: PortablePathBuf,
67    /// Information about the synchronized environment, when `--sync` was used.
68    #[serde(skip_serializing_if = "Option::is_none", default)]
69    environment: Option<MetadataEnvironment>,
70    /// Information about the script root, when metadata was requested for a script.
71    #[serde(skip_serializing_if = "Option::is_none", default)]
72    script: Option<MetadataScript>,
73    /// Information about the workspace root, when metadata was requested for a workspace.
74    #[serde(skip_serializing_if = "Option::is_none", default)]
75    workspace: Option<MetadataWorkspace>,
76    /// The version of python required by the workspace
77    ///
78    /// Every `marker` we emit implicitly assumes this constraint to keep things clean
79    requires_python: RequiresPython,
80    /// Info about conflicting packages
81    conflicts: MetadataConflicts,
82    /// A mapping from importable module names to the package nodes that provide them
83    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
84    module_owners: BTreeMap<ModuleName, Vec<MetadataModuleOwner>>,
85    /// An index of which nodes are workspace members
86    ///
87    /// These entries are often what you should use as the entry-points into the `resolve` graph.
88    #[serde(skip_serializing_if = "Vec::is_empty", default)]
89    members: Vec<MetadataWorkspaceMember>,
90    /// The dependency graph
91    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
92    resolution: BTreeMap<MetadataNodeIdFlat, MetadataNode>,
93}
94
95/// The schema version for the metadata report.
96#[derive(serde::Serialize, Debug, Default)]
97#[serde(rename_all = "snake_case")]
98enum SchemaVersion {
99    /// An unstable, experimental schema.
100    #[default]
101    Preview,
102}
103
104/// The schema metadata for the metadata report.
105#[derive(serde::Serialize, Debug, Default)]
106struct SchemaReport {
107    /// The version of the schema.
108    version: SchemaVersion,
109}
110
111/// Information about the environment synchronized for the workspace.
112#[derive(Debug, serde::Serialize)]
113struct MetadataEnvironment {
114    /// Absolute path to the environment root.
115    root: PortablePathBuf,
116}
117
118/// The script entry-point.
119#[derive(Debug, serde::Serialize)]
120struct MetadataScript {
121    /// Absolute path to the script.
122    path: PortablePathBuf,
123    /// Key for the script's node in the `resolution` graph.
124    id: MetadataNodeIdFlat,
125}
126
127/// The workspace entry-point.
128#[derive(Debug, serde::Serialize)]
129struct MetadataWorkspace {
130    /// Absolute path to the workspace root.
131    path: PortablePathBuf,
132    /// Key for the workspace's node in the `resolution` graph.
133    id: MetadataNodeIdFlat,
134}
135
136/// Info for looking up workspace members, most information is stored in the node behind `id`
137#[derive(Debug, serde::Serialize)]
138struct MetadataWorkspaceMember {
139    /// Package name
140    name: PackageName,
141    /// Absolute path to the member
142    path: PortablePathBuf,
143    /// Key for the package's node in the `resolve` graph
144    id: MetadataNodeIdFlat,
145}
146
147/// An installed distribution that provides an importable module.
148#[derive(Debug, serde::Serialize)]
149struct MetadataModuleOwner {
150    /// Key for the package node in the `resolution` graph.
151    package_id: MetadataNodeIdFlat,
152}
153
154/// A node in the dependency graph.
155///
156/// There are 5 kinds of nodes:
157///
158/// * scripts:  `script+/workspace/script.py`
159/// * packages: `mypackage==1.0.0@registry+https://pypi.org/simple`
160/// * extras:   `mypackage[myextra]==1.0.0@registry+https://pypi.org/simple`
161/// * groups:   `mypackage:mygroup==1.0.0@registry+https://pypi.org/simple`
162/// * build:    `mypackage(build)==1.0.0@registry+https://pypi.org/simple`
163///
164/// -----------
165///
166/// A package like this:
167///
168/// ```toml
169/// [project]
170/// name = "mypackage"
171/// version = 1.0.0
172///
173/// dependencies = ["httpx"]
174///
175/// [project.optional-dependencies]
176/// cli = ["rich"]
177///
178/// [dependency-groups]
179/// dev = ["typing-extensions"]
180///
181/// [build-system]
182/// requires = ["hatchling"]
183/// ```
184///
185/// will get 4 nodes with the following edges (Version and Source omitted here for brevity):
186///
187/// * `mypackage`
188///   * `httpx`
189/// * `mypackage(build)`
190///   * `hatchling`
191/// * `mypackage[cli]`
192///   * `mypackage`
193///   * `rich`
194/// * `mypackage:dev`
195///   * `typing-extensions`
196///
197/// Note that `mypackage[cli]` has a dependency edge on `mypackage` while `mypackage:dev` does not.
198/// This is because `mypackage[cli]` is fundamentally an augmentation of `mypackage` while `mypackage:dev`
199/// is just a list of packages that happens to be defined by `mypackage`'s pyproject.toml.
200#[derive(Debug, Clone, serde::Serialize)]
201struct MetadataNode {
202    /// A unique id for this node that will be used to refer to it
203    #[serde(flatten)]
204    id: MetadataNodeId,
205    /// Dependencies of this node (the edges of The Graph)
206    dependencies: Vec<MetadataDependency>,
207    /// Extras
208    #[serde(skip_serializing_if = "Vec::is_empty", default)]
209    optional_dependencies: Vec<MetadataExtra>,
210    /// Groups
211    #[serde(skip_serializing_if = "Vec::is_empty", default)]
212    dependency_groups: Vec<MetadataGroup>,
213    /// Info about building the package
214    #[serde(skip_serializing_if = "Option::is_none", default)]
215    build_system: Option<MetadataBuildSystem>,
216    /// The source distribution found
217    #[serde(skip_serializing_if = "Option::is_none", default)]
218    sdist: Option<MetadataSourceDist>,
219    /// Wheels we found
220    #[serde(skip_serializing_if = "Vec::is_empty", default)]
221    wheels: Vec<MetadataWheel>,
222}
223
224impl MetadataNode {
225    fn new(id: MetadataNodeId) -> Self {
226        Self {
227            id,
228            dependencies: Vec::new(),
229            dependency_groups: Vec::new(),
230            optional_dependencies: Vec::new(),
231            wheels: Vec::new(),
232            build_system: None,
233            sdist: None,
234        }
235    }
236
237    fn from_package_id(
238        workspace_root: &PortablePathBuf,
239        id: &PackageId,
240        kind: MetadataNodeKind,
241    ) -> Self {
242        Self::new(MetadataNodeId::from_package_id(workspace_root, id, kind))
243    }
244
245    fn from_script(path: PortablePathBuf, dependencies: Vec<MetadataDependency>) -> Self {
246        let mut node = Self::new(MetadataNodeId::Script(MetadataScriptNodeId {
247            kind: MetadataScriptNodeKind::Script,
248            path,
249        }));
250        node.dependencies = dependencies;
251        node
252    }
253
254    fn from_workspace(path: PortablePathBuf, dependency_groups: Vec<MetadataGroup>) -> Self {
255        let mut node = Self::new(MetadataNodeId::Workspace(MetadataWorkspaceNodeId {
256            kind: MetadataWorkspaceNodeKind::Workspace,
257            path,
258        }));
259        node.dependency_groups = dependency_groups;
260        node
261    }
262
263    fn from_workspace_group(
264        path: PortablePathBuf,
265        group: GroupName,
266        dependencies: Vec<MetadataDependency>,
267    ) -> Self {
268        let mut node = Self::new(MetadataNodeId::WorkspaceGroup(
269            MetadataWorkspaceGroupNodeId {
270                kind: MetadataWorkspaceGroupNodeKind::Group(group),
271                path,
272            },
273        ));
274        node.dependencies = dependencies;
275        node
276    }
277
278    fn add_dependency(
279        &mut self,
280        workspace_root: &PortablePathBuf,
281        dependency: &Dependency,
282        parent_reachability: MarkerTree,
283    ) {
284        let mut marker = dependency.simplified_marker.as_simplified_marker_tree();
285        marker.and(parent_reachability);
286        let marker = marker.try_to_string();
287        let extras = dependency.extra();
288        if extras.is_empty() {
289            let id = MetadataNodeId::from_package_id(
290                workspace_root,
291                &dependency.package_id,
292                MetadataNodeKind::Package,
293            );
294            self.dependencies.push(MetadataDependency {
295                id: id.to_flat(),
296                marker,
297            });
298            return;
299        }
300        for extra in extras {
301            let id = MetadataNodeId::from_package_id(
302                workspace_root,
303                &dependency.package_id,
304                MetadataNodeKind::Extra(extra.clone()),
305            );
306            self.dependencies.push(MetadataDependency {
307                id: id.to_flat(),
308                marker: marker.clone(),
309            });
310        }
311    }
312}
313
314#[derive(Debug, Clone, serde::Serialize)]
315#[serde(rename_all = "snake_case")]
316enum MetadataWorkspaceNodeKind {
317    Workspace,
318}
319
320#[derive(Debug, Clone, serde::Serialize)]
321#[serde(rename_all = "snake_case")]
322enum MetadataWorkspaceGroupNodeKind {
323    Group(GroupName),
324}
325#[derive(Debug, Clone, serde::Serialize)]
326#[serde(rename_all = "snake_case")]
327enum MetadataScriptNodeKind {
328    Script,
329}
330
331fn root_dependencies<'lock>(
332    workspace_root: &PortablePathBuf,
333    lock: &'lock Lock,
334    requirements: impl IntoIterator<Item = &'lock Requirement>,
335) -> Vec<MetadataDependency> {
336    let mut dependencies = Vec::new();
337
338    // Root requirements retain names, extras, and markers rather than resolved package IDs. Match
339    // them to the locked packages using the same name and fork-marker logic as lock export.
340    for requirement in requirements {
341        for package in lock
342            .packages()
343            .iter()
344            .filter(|package| package.name() == &requirement.name)
345        {
346            let Some(marker) = lock.root_requirement_marker(requirement, package) else {
347                continue;
348            };
349
350            let marker = marker.try_to_string();
351            let mut has_extra_node = false;
352            for extra in requirement
353                .extras
354                .iter()
355                .filter(|extra| package.optional_dependencies.contains_key(*extra))
356            {
357                let id = MetadataNodeId::from_package_id(
358                    workspace_root,
359                    &package.id,
360                    MetadataNodeKind::Extra(extra.clone()),
361                );
362                dependencies.push(MetadataDependency {
363                    id: id.to_flat(),
364                    marker: marker.clone(),
365                });
366                has_extra_node = true;
367            }
368
369            if !has_extra_node {
370                let id = MetadataNodeId::from_package_id(
371                    workspace_root,
372                    &package.id,
373                    MetadataNodeKind::Package,
374                );
375                dependencies.push(MetadataDependency {
376                    id: id.to_flat(),
377                    marker,
378                });
379            }
380        }
381    }
382
383    dependencies
384}
385
386/// Determine the standalone reachability marker for every package-derived metadata node.
387///
388/// Dependency markers in the lock are simplified under the conditions required to reach their
389/// parent. Metadata consumers evaluate each edge marker independently, so restore those conditions
390/// by propagating markers from the metadata graph's entry points.
391fn metadata_reachability(
392    workspace_root: &PortablePathBuf,
393    workspace: Option<&Workspace>,
394    lock: &Lock,
395) -> BTreeMap<MetadataNodeIdFlat, MarkerTree> {
396    let mut reachability = BTreeMap::new();
397    let mut queue = VecDeque::new();
398    let always = MarkerTree::TRUE;
399
400    if let Some(workspace) = workspace {
401        for package in lock
402            .packages()
403            .iter()
404            .filter(|package| workspace.packages().contains_key(package.name()))
405        {
406            add_metadata_reachability(
407                workspace_root,
408                &mut reachability,
409                &mut queue,
410                package,
411                MetadataNodeKind::Package,
412                always,
413            );
414            for extra in package.optional_dependencies.keys() {
415                add_metadata_reachability(
416                    workspace_root,
417                    &mut reachability,
418                    &mut queue,
419                    package,
420                    MetadataNodeKind::Extra(extra.clone()),
421                    always,
422                );
423            }
424            for group in package.dependency_groups.keys() {
425                add_metadata_reachability(
426                    workspace_root,
427                    &mut reachability,
428                    &mut queue,
429                    package,
430                    MetadataNodeKind::Group(group.clone()),
431                    always,
432                );
433            }
434        }
435    }
436
437    for requirement in lock
438        .requirements()
439        .iter()
440        .chain(lock.dependency_groups().values().flatten())
441    {
442        for package in lock
443            .packages()
444            .iter()
445            .filter(|package| package.name() == &requirement.name)
446        {
447            let Some(marker) = lock.root_requirement_marker(requirement, package) else {
448                continue;
449            };
450            let mut has_extra_node = false;
451            for extra in requirement
452                .extras
453                .iter()
454                .filter(|extra| package.optional_dependencies.contains_key(*extra))
455            {
456                add_metadata_reachability(
457                    workspace_root,
458                    &mut reachability,
459                    &mut queue,
460                    package,
461                    MetadataNodeKind::Extra(extra.clone()),
462                    marker,
463                );
464                has_extra_node = true;
465            }
466            if !has_extra_node {
467                add_metadata_reachability(
468                    workspace_root,
469                    &mut reachability,
470                    &mut queue,
471                    package,
472                    MetadataNodeKind::Package,
473                    marker,
474                );
475            }
476        }
477    }
478
479    while let Some((package, kind)) = queue.pop_front() {
480        let id =
481            MetadataNodeId::from_package_id(workspace_root, &package.id, kind.clone()).to_flat();
482        let Some(parent_reachability) = reachability.get(&id).copied() else {
483            continue;
484        };
485
486        if matches!(kind, MetadataNodeKind::Extra(_)) {
487            add_metadata_reachability(
488                workspace_root,
489                &mut reachability,
490                &mut queue,
491                package,
492                MetadataNodeKind::Package,
493                parent_reachability,
494            );
495        }
496
497        let dependencies: &[Dependency] = match &kind {
498            MetadataNodeKind::Package => package.dependencies.as_slice(),
499            MetadataNodeKind::Extra(extra) => package
500                .optional_dependencies
501                .get(extra)
502                .map_or(&[], Vec::as_slice),
503            MetadataNodeKind::Group(group) => package
504                .dependency_groups
505                .get(group)
506                .map_or(&[], Vec::as_slice),
507            MetadataNodeKind::Build => &[],
508        };
509        for dependency in dependencies {
510            let mut dependency_reachability =
511                dependency.simplified_marker.as_simplified_marker_tree();
512            dependency_reachability.and(parent_reachability);
513            let dependency_package = lock.find_by_id(&dependency.package_id);
514            if dependency.extra.is_empty() {
515                add_metadata_reachability(
516                    workspace_root,
517                    &mut reachability,
518                    &mut queue,
519                    dependency_package,
520                    MetadataNodeKind::Package,
521                    dependency_reachability,
522                );
523            } else {
524                for extra in &dependency.extra {
525                    add_metadata_reachability(
526                        workspace_root,
527                        &mut reachability,
528                        &mut queue,
529                        dependency_package,
530                        MetadataNodeKind::Extra(extra.clone()),
531                        dependency_reachability,
532                    );
533                }
534            }
535        }
536    }
537
538    reachability
539}
540
541fn add_metadata_reachability<'lock>(
542    workspace_root: &PortablePathBuf,
543    reachability: &mut BTreeMap<MetadataNodeIdFlat, MarkerTree>,
544    queue: &mut VecDeque<(&'lock Package, MetadataNodeKind)>,
545    package: &'lock Package,
546    kind: MetadataNodeKind,
547    marker: MarkerTree,
548) {
549    let id = MetadataNodeId::from_package_id(workspace_root, &package.id, kind.clone()).to_flat();
550    let changed = if let Some(existing) = reachability.get_mut(&id) {
551        let previous = *existing;
552        existing.or(marker);
553        *existing != previous
554    } else {
555        reachability.insert(id, marker);
556        true
557    };
558    if changed {
559        queue.push_back((package, kind));
560    }
561}
562
563/// The unique key for every node in the graph.
564#[derive(Debug, Clone, serde::Serialize)]
565#[serde(untagged)]
566enum MetadataNodeId {
567    Package(MetadataPackageNodeId),
568    Script(MetadataScriptNodeId),
569    Workspace(MetadataWorkspaceNodeId),
570    WorkspaceGroup(MetadataWorkspaceGroupNodeId),
571}
572
573/// The unique key for a package-derived node.
574///
575/// (It's not entirely clear to me that two nodes can differ only by `source` but it doesn't hurt.)
576#[derive(Debug, Clone, serde::Serialize)]
577struct MetadataPackageNodeId {
578    /// The name of the package
579    name: PackageName,
580    /// The version of the package, if any could be found (source trees may have no version)
581    #[serde(skip_serializing_if = "Option::is_none", default)]
582    version: Option<Version>,
583    /// The source of the package (directory, registry, URL...)
584    source: MetadataSource,
585    /// What kind of node is this?
586    kind: MetadataNodeKind,
587}
588
589/// The unique key for a script node.
590#[derive(Debug, Clone, serde::Serialize)]
591struct MetadataScriptNodeId {
592    kind: MetadataScriptNodeKind,
593    /// Absolute path to the script.
594    path: PortablePathBuf,
595}
596
597/// The unique key for a workspace node.
598#[derive(Debug, Clone, serde::Serialize)]
599struct MetadataWorkspaceNodeId {
600    kind: MetadataWorkspaceNodeKind,
601    /// Absolute path to the workspace root.
602    path: PortablePathBuf,
603}
604
605/// The unique key for a dependency group defined on the workspace root.
606#[derive(Debug, Clone, serde::Serialize)]
607struct MetadataWorkspaceGroupNodeId {
608    kind: MetadataWorkspaceGroupNodeKind,
609    /// Absolute path to the workspace root.
610    path: PortablePathBuf,
611}
612
613/// This is intended to be an opaque unique id for referring to a node
614///
615/// It's human readable for convenience but parsing it or relying on it is inadvisable.
616type MetadataNodeIdFlat = String;
617
618impl MetadataNodeId {
619    fn from_package_id(
620        workspace_root: &PortablePathBuf,
621        id: &PackageId,
622        kind: MetadataNodeKind,
623    ) -> Self {
624        let name = id.name.clone();
625        let version = id.version.clone();
626        let source = MetadataSource::from_source(workspace_root, id.source.clone());
627
628        Self::Package(MetadataPackageNodeId {
629            name,
630            version,
631            source,
632            kind,
633        })
634    }
635
636    fn as_package(&self) -> Option<&MetadataPackageNodeId> {
637        match self {
638            Self::Package(package) => Some(package),
639            Self::Script(_) | Self::Workspace(_) | Self::WorkspaceGroup(_) => None,
640        }
641    }
642
643    fn to_flat(&self) -> MetadataNodeIdFlat {
644        self.to_string()
645    }
646}
647
648impl Display for MetadataNodeId {
649    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
650        match self {
651            Self::Package(package) => match &package.version {
652                Some(version) => write!(
653                    f,
654                    "{}{}=={version}@{}",
655                    package.name, package.kind, package.source
656                ),
657                None => write!(f, "{}{}@{}", package.name, package.kind, package.source),
658            },
659            Self::Script(script) => write!(f, "script+{}", script.path),
660            Self::Workspace(workspace) => write!(f, "workspace+{}", workspace.path),
661            Self::WorkspaceGroup(group) => {
662                let MetadataWorkspaceGroupNodeKind::Group(name) = &group.kind;
663                write!(f, "workspace+{}:{name}", group.path)
664            }
665        }
666    }
667}
668
669#[derive(Debug, Clone, serde::Serialize)]
670struct MetadataDependency {
671    id: MetadataNodeIdFlat,
672    #[serde(skip_serializing_if = "Option::is_none", default)]
673    marker: Option<MetadataMarker>,
674}
675
676type MetadataMarker = String;
677
678/// The kind a node can have in the dependency graph
679#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
680#[serde(rename_all = "snake_case")]
681enum MetadataNodeKind {
682    /// The node is the package itself
683    /// its edges are `project.dependencies`
684    Package,
685    /// The node is for building the package's sdist into a wheel
686    /// its edges are `build-system.requires`
687    #[expect(dead_code)]
688    Build,
689    /// The node is for an extra defined on the package
690    /// its edges are `project.optional-dependencies.myextra`
691    Extra(ExtraName),
692    /// The node is for a dependency-group defined on the package
693    /// its edges are `dependency-groups.mygroup`
694    Group(GroupName),
695}
696
697impl Display for MetadataNodeKind {
698    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
699        match self {
700            // Don't apply any special decoration, this is the default
701            Self::Package => Ok(()),
702            Self::Build => f.write_str("(build)"),
703            Self::Extra(extra_name) => write!(f, "[{extra_name}]"),
704            Self::Group(group_name) => write!(f, ":{group_name}"),
705        }
706    }
707}
708
709#[derive(Clone, Debug, serde::Serialize)]
710#[serde(untagged, rename_all = "snake_case")]
711enum MetadataSource {
712    Registry {
713        registry: MetadataRegistrySource,
714    },
715    Git {
716        git: UrlString,
717    },
718    Direct {
719        url: UrlString,
720        subdirectory: Option<PortablePathBuf>,
721    },
722    Path {
723        path: PortablePathBuf,
724    },
725    Directory {
726        directory: PortablePathBuf,
727    },
728    Editable {
729        editable: PortablePathBuf,
730    },
731    Virtual {
732        r#virtual: PortablePathBuf,
733    },
734}
735
736impl Display for MetadataSource {
737    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
738        match self {
739            Self::Registry {
740                registry: MetadataRegistrySource::Url(url),
741            }
742            | Self::Git { git: url }
743            | Self::Direct { url, .. } => {
744                write!(f, "{}+{}", self.name(), url)
745            }
746            Self::Registry {
747                registry: MetadataRegistrySource::Path(path),
748            }
749            | Self::Path { path }
750            | Self::Directory { directory: path }
751            | Self::Editable { editable: path }
752            | Self::Virtual { r#virtual: path } => {
753                write!(f, "{}+{}", self.name(), path)
754            }
755        }
756    }
757}
758
759impl MetadataSource {
760    fn name(&self) -> &str {
761        match self {
762            Self::Registry { .. } => "registry",
763            Self::Git { .. } => "git",
764            Self::Direct { .. } => "direct",
765            Self::Path { .. } => "path",
766            Self::Directory { .. } => "directory",
767            Self::Editable { .. } => "editable",
768            Self::Virtual { .. } => "virtual",
769        }
770    }
771}
772
773impl MetadataSource {
774    fn from_source(workspace_root: &PortablePathBuf, source: Source) -> Self {
775        match source {
776            Source::Registry(source) => match source {
777                RegistrySource::Url(url) => Self::Registry {
778                    registry: MetadataRegistrySource::Url(url),
779                },
780                RegistrySource::Path(path) => Self::Registry {
781                    registry: MetadataRegistrySource::Path(normalize_workspace_relative_path(
782                        workspace_root,
783                        &path,
784                    )),
785                },
786            },
787            Source::Git(url, _) => Self::Git { git: url },
788            Source::Direct(url, DirectSource { subdirectory }) => Self::Direct {
789                url,
790                subdirectory: subdirectory
791                    .map(|path| normalize_workspace_relative_path(workspace_root, &path)),
792            },
793            Source::Path(path) => Self::Path {
794                path: normalize_workspace_relative_path(workspace_root, &path),
795            },
796            Source::Directory(path) => Self::Directory {
797                directory: normalize_workspace_relative_path(workspace_root, &path),
798            },
799            Source::Editable(path) => Self::Editable {
800                editable: normalize_workspace_relative_path(workspace_root, &path),
801            },
802            Source::Virtual(path) => Self::Virtual {
803                r#virtual: normalize_workspace_relative_path(workspace_root, &path),
804            },
805        }
806    }
807}
808
809fn normalize_workspace_relative_path(
810    workspace_root: &PortablePathBuf,
811    maybe_rel: &std::path::Path,
812) -> PortablePathBuf {
813    if maybe_rel.is_absolute() {
814        PortablePathBuf::from(maybe_rel)
815    } else {
816        PortablePathBuf::from(workspace_root.as_ref().join(maybe_rel).as_path())
817    }
818}
819
820#[derive(Clone, Debug, serde::Serialize)]
821#[serde(rename_all = "snake_case")]
822enum MetadataRegistrySource {
823    /// Ex) `https://pypi.org/simple`
824    Url(UrlString),
825    /// Ex) `/path/to/local/index`
826    Path(PortablePathBuf),
827}
828
829#[derive(Clone, Debug, serde::Serialize)]
830#[serde(untagged, rename_all = "snake_case")]
831enum MetadataSourceDist {
832    Url {
833        url: UrlString,
834        #[serde(flatten)]
835        metadata: MetadataSourceDistMetadata,
836    },
837    Path {
838        path: PortablePathBuf,
839        #[serde(flatten)]
840        metadata: MetadataSourceDistMetadata,
841    },
842    Metadata {
843        #[serde(flatten)]
844        metadata: MetadataSourceDistMetadata,
845    },
846}
847
848impl MetadataSourceDist {
849    fn from_sdist(workspace_root: &PortablePathBuf, sdist: &SourceDist) -> Self {
850        match sdist {
851            SourceDist::Url { url, metadata } => Self::Url {
852                url: url.clone(),
853                metadata: MetadataSourceDistMetadata::from_sdist(metadata),
854            },
855            SourceDist::Path { path, metadata } => Self::Path {
856                path: normalize_workspace_relative_path(workspace_root, path),
857                metadata: MetadataSourceDistMetadata::from_sdist(metadata),
858            },
859            SourceDist::Metadata { metadata } => Self::Metadata {
860                metadata: MetadataSourceDistMetadata::from_sdist(metadata),
861            },
862        }
863    }
864}
865
866#[derive(Clone, Debug, serde::Serialize)]
867#[serde(rename_all = "snake_case")]
868struct MetadataSourceDistMetadata {
869    /// A hash of the source distribution.
870    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
871    hashes: BTreeMap<HashAlgorithm, Hash>,
872    /// The size of the source distribution in bytes.
873    ///
874    /// This is only present for source distributions that come from registries.
875    #[serde(skip_serializing_if = "Option::is_none", default)]
876    size: Option<u64>,
877    /// The upload time of the source distribution.
878    #[serde(skip_serializing_if = "Option::is_none", default)]
879    upload_time: Option<jiff::Timestamp>,
880}
881
882/// The name of a hash algorithm ("sha256", "blake2b", "md5", etc)
883type HashAlgorithm = String;
884/// A hex encoded digest of the file
885type Hash = String;
886
887/// Oh you wanted a hash map? No this is the hashes map, a sorted map of hashes!
888///
889/// We prefer matching PEP 691 (JSON-based Simple API for Python) here for future-proofing
890/// and convenience of consumption.
891fn hashes_map(hash: &crate::lock::Hash) -> BTreeMap<HashAlgorithm, Hash> {
892    Some((hash.0.algorithm.to_string(), hash.0.digest.to_string()))
893        .into_iter()
894        .collect()
895}
896
897impl MetadataSourceDistMetadata {
898    fn from_sdist(sdist: &SourceDistMetadata) -> Self {
899        Self {
900            hashes: sdist.hash.as_ref().map(hashes_map).unwrap_or_default(),
901            size: sdist.size,
902            upload_time: sdist.upload_time,
903        }
904    }
905}
906#[derive(Clone, Debug, serde::Serialize)]
907struct MetadataWheel {
908    /// A URL or file path (via `file://`) where the wheel that was locked
909    /// against was found. The location does not need to exist in the future,
910    /// so this should be treated as only a hint to where to look and/or
911    /// recording where the wheel file originally came from.
912    #[serde(flatten)]
913    source: Option<MetadataWheelWireSource>,
914    /// A hash of the built distribution.
915    ///
916    /// This is only present for wheels that come from registries and direct
917    /// URLs. Wheels from git or path dependencies do not have hashes
918    /// associated with them.
919    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
920    hashes: BTreeMap<HashAlgorithm, Hash>,
921    /// The size of the built distribution in bytes.
922    ///
923    /// This is only present for wheels that come from registries.
924    #[serde(skip_serializing_if = "Option::is_none", default)]
925    size: Option<u64>,
926    /// The upload time of the built distribution.
927    ///
928    /// This is only present for wheels that come from registries.
929    #[serde(skip_serializing_if = "Option::is_none", default)]
930    upload_time: Option<jiff::Timestamp>,
931    /// The filename of the wheel.
932    ///
933    /// This isn't part of the wire format since it's redundant with the
934    /// URL. But we do use it for various things, and thus compute it at
935    /// deserialization time. Not being able to extract a wheel filename from a
936    /// wheel URL is thus a deserialization error.
937    filename: WheelFilename,
938    /// The zstandard-compressed wheel metadata, if any.
939    #[serde(skip_serializing_if = "Option::is_none", default)]
940    zstd: Option<MetadataZstdWheel>,
941}
942
943impl MetadataWheel {
944    fn from_wheel(workspace_root: &PortablePathBuf, wheel: &Wheel) -> Self {
945        Self {
946            source: MetadataWheelWireSource::from_wheel(workspace_root, &wheel.url),
947            hashes: wheel.hash.as_ref().map(hashes_map).unwrap_or_default(),
948            size: wheel.size,
949            upload_time: wheel.upload_time,
950            filename: wheel.filename.clone(),
951            zstd: wheel.zstd.as_ref().map(MetadataZstdWheel::from_wheel),
952        }
953    }
954}
955
956#[derive(Clone, Debug, serde::Serialize)]
957#[serde(untagged, rename_all = "snake_case")]
958enum MetadataWheelWireSource {
959    Url { url: UrlString },
960    Path { path: PortablePathBuf },
961}
962
963impl MetadataWheelWireSource {
964    fn from_wheel(workspace_root: &PortablePathBuf, wheel: &WheelWireSource) -> Option<Self> {
965        match wheel {
966            WheelWireSource::Url { url } => Some(Self::Url { url: url.clone() }),
967            WheelWireSource::Path { path } => Some(Self::Path {
968                path: normalize_workspace_relative_path(workspace_root, path),
969            }),
970            // We guarantee this as a separate field so it's redundant
971            WheelWireSource::Filename { .. } => None,
972        }
973    }
974}
975
976#[derive(Clone, Debug, serde::Serialize)]
977struct MetadataZstdWheel {
978    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
979    hashes: BTreeMap<HashAlgorithm, Hash>,
980    #[serde(skip_serializing_if = "Option::is_none", default)]
981    size: Option<u64>,
982}
983
984impl MetadataZstdWheel {
985    fn from_wheel(wheel: &ZstdWheel) -> Self {
986        Self {
987            hashes: wheel.hash.as_ref().map(hashes_map).unwrap_or_default(),
988            size: wheel.size,
989        }
990    }
991}
992
993#[derive(Clone, Debug, serde::Serialize)]
994struct MetadataExtra {
995    name: ExtraName,
996    id: MetadataNodeIdFlat,
997}
998
999#[derive(Clone, Debug, serde::Serialize)]
1000struct MetadataGroup {
1001    name: GroupName,
1002    id: MetadataNodeIdFlat,
1003}
1004
1005#[derive(Clone, Debug, serde::Serialize)]
1006struct MetadataBuildSystem {
1007    /// The `build-backend` specified in the pyproject.toml
1008    build_backend: String,
1009    id: MetadataNodeIdFlat,
1010}
1011
1012/// Conflicts
1013#[derive(Clone, Debug, serde::Serialize)]
1014struct MetadataConflicts {
1015    sets: Vec<MetadataConflictSet>,
1016}
1017
1018impl MetadataConflicts {
1019    fn from_conflicts(
1020        members: &[MetadataWorkspaceMember],
1021        resolve: &BTreeMap<MetadataNodeIdFlat, MetadataNode>,
1022        conflicts: &Conflicts,
1023    ) -> Self {
1024        Self {
1025            sets: conflicts
1026                .iter()
1027                .map(|set| MetadataConflictSet::from_conflicts(members, resolve, set))
1028                .collect(),
1029        }
1030    }
1031}
1032
1033#[derive(Clone, Debug, serde::Serialize)]
1034struct MetadataConflictSet {
1035    items: Vec<MetadataConflictItem>,
1036}
1037
1038impl MetadataConflictSet {
1039    fn from_conflicts(
1040        members: &[MetadataWorkspaceMember],
1041        resolve: &BTreeMap<MetadataNodeIdFlat, MetadataNode>,
1042        set: &ConflictSet,
1043    ) -> Self {
1044        Self {
1045            items: set
1046                .iter()
1047                .map(|item| MetadataConflictItem::from_conflicts(members, resolve, item))
1048                .collect(),
1049        }
1050    }
1051}
1052
1053#[derive(Clone, Debug, serde::Serialize)]
1054struct MetadataConflictItem {
1055    /// These should always be names of packages referred to in [`Metadata::members`]
1056    package: PackageName,
1057    kind: MetadataConflictKind,
1058    /// This should never be None (should be a validation error way earlier in uv)
1059    /// ...but I'd rather not error if wrong.
1060    id: Option<MetadataNodeIdFlat>,
1061}
1062
1063impl MetadataConflictItem {
1064    fn from_conflicts(
1065        members: &[MetadataWorkspaceMember],
1066        resolve: &BTreeMap<MetadataNodeIdFlat, MetadataNode>,
1067        item: &ConflictItem,
1068    ) -> Self {
1069        let kind = MetadataConflictKind::from_conflicts(item.kind());
1070        let id = members
1071            .iter()
1072            .find(|member| &member.name == item.package())
1073            .and_then(|member| {
1074                let package_id = resolve.get(&member.id)?.id.as_package()?;
1075                let id = MetadataNodeId::Package(MetadataPackageNodeId {
1076                    kind: kind.to_node_kind(),
1077                    ..package_id.clone()
1078                });
1079                Some(id.to_flat())
1080            });
1081        Self {
1082            package: item.package().clone(),
1083            kind,
1084            id,
1085        }
1086    }
1087}
1088
1089#[derive(Clone, Debug, serde::Serialize)]
1090enum MetadataConflictKind {
1091    Group(GroupName),
1092    Extra(ExtraName),
1093    Project,
1094}
1095
1096impl MetadataConflictKind {
1097    fn from_conflicts(item: &ConflictKind) -> Self {
1098        match item {
1099            ConflictKind::Extra(name) => Self::Extra(name.clone()),
1100            ConflictKind::Group(name) => Self::Group(name.clone()),
1101            ConflictKind::Project => Self::Project,
1102        }
1103    }
1104
1105    fn to_node_kind(&self) -> MetadataNodeKind {
1106        match self {
1107            Self::Group(name) => MetadataNodeKind::Group(name.clone()),
1108            Self::Extra(name) => MetadataNodeKind::Extra(name.clone()),
1109            Self::Project => MetadataNodeKind::Package,
1110        }
1111    }
1112}
1113
1114impl Metadata {
1115    /// Construct [`Metadata`] for a workspace from a uv lockfile.
1116    pub fn from_lock(workspace: &Workspace, lock: &Lock) -> Result<Self, MetadataError> {
1117        Ok(Self::from_lock_target(
1118            workspace.install_path(),
1119            Some(workspace),
1120            None,
1121            lock,
1122        ))
1123    }
1124
1125    /// Construct [`Metadata`] for a script from a uv lockfile.
1126    pub fn from_script(script_path: &Path, lock: &Lock) -> Result<Self, MetadataError> {
1127        let workspace_root = script_path.parent().unwrap_or_else(|| Path::new(""));
1128        Ok(Self::from_lock_target(
1129            workspace_root,
1130            None,
1131            Some(script_path),
1132            lock,
1133        ))
1134    }
1135
1136    fn from_lock_target(
1137        workspace_root: &Path,
1138        workspace: Option<&Workspace>,
1139        script_path: Option<&Path>,
1140        lock: &Lock,
1141    ) -> Self {
1142        let mut resolve = BTreeMap::new();
1143        let mut members = Vec::new();
1144        let workspace_root = PortablePathBuf::from(workspace_root);
1145        let reachability = metadata_reachability(&workspace_root, workspace, lock);
1146
1147        for lock_package in lock.packages() {
1148            let mut meta_package = MetadataNode::from_package_id(
1149                &workspace_root,
1150                &lock_package.id,
1151                MetadataNodeKind::Package,
1152            );
1153            let package_reachability = reachability
1154                .get(&meta_package.id.to_flat())
1155                .copied()
1156                .unwrap_or(MarkerTree::FALSE);
1157
1158            // Direct dependencies go on the package node
1159            for dependency in &lock_package.dependencies {
1160                meta_package.add_dependency(&workspace_root, dependency, package_reachability);
1161            }
1162
1163            // Extras get their own nodes
1164            for (extra, dependencies) in &lock_package.optional_dependencies {
1165                let mut meta_extra = MetadataNode::from_package_id(
1166                    &workspace_root,
1167                    &lock_package.id,
1168                    MetadataNodeKind::Extra(extra.clone()),
1169                );
1170                let extra_reachability = reachability
1171                    .get(&meta_extra.id.to_flat())
1172                    .copied()
1173                    .unwrap_or(MarkerTree::FALSE);
1174                // Extras always depend on the base package
1175                meta_extra.dependencies.push(MetadataDependency {
1176                    id: meta_package.id.to_flat(),
1177                    marker: None,
1178                });
1179                for dependency in dependencies {
1180                    meta_extra.add_dependency(&workspace_root, dependency, extra_reachability);
1181                }
1182
1183                meta_package.optional_dependencies.push(MetadataExtra {
1184                    name: extra.clone(),
1185                    id: meta_extra.id.to_flat(),
1186                });
1187
1188                resolve.insert(meta_extra.id.to_flat(), meta_extra);
1189            }
1190
1191            // Groups get their own nodes
1192            for (group, dependencies) in &lock_package.dependency_groups {
1193                let mut meta_group = MetadataNode::from_package_id(
1194                    &workspace_root,
1195                    &lock_package.id,
1196                    MetadataNodeKind::Group(group.clone()),
1197                );
1198                let group_reachability = reachability
1199                    .get(&meta_group.id.to_flat())
1200                    .copied()
1201                    .unwrap_or(MarkerTree::FALSE);
1202                // Groups *do not* depend on the base package, so don't add that
1203                for dependency in dependencies {
1204                    meta_group.add_dependency(&workspace_root, dependency, group_reachability);
1205                }
1206
1207                meta_package.dependency_groups.push(MetadataGroup {
1208                    name: group.clone(),
1209                    id: meta_group.id.to_flat(),
1210                });
1211
1212                resolve.insert(meta_group.id.to_flat(), meta_group);
1213            }
1214
1215            // Register this package if it appears to be a workspace member
1216            if let Some(workspace_package) =
1217                workspace.and_then(|workspace| workspace.packages().get(lock_package.name()))
1218            {
1219                let member = MetadataWorkspaceMember {
1220                    name: lock_package.name().clone(),
1221                    path: normalize_workspace_relative_path(
1222                        &workspace_root,
1223                        workspace_package.root().as_path(),
1224                    ),
1225                    id: meta_package.id.to_flat(),
1226                };
1227                members.push(member);
1228            }
1229
1230            // Record sdist/wheel information
1231            if let Some(sdist) = &lock_package.sdist {
1232                meta_package.sdist = Some(MetadataSourceDist::from_sdist(&workspace_root, sdist));
1233            }
1234
1235            for wheel in &lock_package.wheels {
1236                meta_package
1237                    .wheels
1238                    .push(MetadataWheel::from_wheel(&workspace_root, wheel));
1239            }
1240
1241            resolve.insert(meta_package.id.to_flat(), meta_package);
1242        }
1243
1244        let script = script_path.map(|path| {
1245            let path = PortablePathBuf::from(path);
1246            let node = MetadataNode::from_script(
1247                path.clone(),
1248                root_dependencies(&workspace_root, lock, lock.requirements()),
1249            );
1250            let id = node.id.to_flat();
1251            resolve.insert(id.clone(), node);
1252            MetadataScript { path, id }
1253        });
1254
1255        let workspace_metadata = workspace.map(|_| {
1256            let mut dependency_groups = Vec::new();
1257            for (group, requirements) in lock.dependency_groups() {
1258                let node = MetadataNode::from_workspace_group(
1259                    workspace_root.clone(),
1260                    group.clone(),
1261                    root_dependencies(&workspace_root, lock, requirements),
1262                );
1263                let id = node.id.to_flat();
1264                resolve.insert(id.clone(), node);
1265                dependency_groups.push(MetadataGroup {
1266                    name: group.clone(),
1267                    id,
1268                });
1269            }
1270
1271            let node = MetadataNode::from_workspace(workspace_root.clone(), dependency_groups);
1272            let id = node.id.to_flat();
1273            resolve.insert(id.clone(), node);
1274            MetadataWorkspace {
1275                path: workspace_root.clone(),
1276                id,
1277            }
1278        });
1279        let conflicts = MetadataConflicts::from_conflicts(&members, &resolve, &lock.conflicts);
1280
1281        Self {
1282            schema: SchemaReport {
1283                version: SchemaVersion::Preview,
1284            },
1285            conflicts,
1286            environment: None,
1287            script,
1288            workspace: workspace_metadata,
1289            module_owners: BTreeMap::new(),
1290            workspace_root,
1291            requires_python: lock.requires_python.clone(),
1292            members,
1293            resolution: resolve,
1294        }
1295    }
1296
1297    pub fn package_node_id(
1298        workspace_root: &PortablePathBuf,
1299        dist: &ResolvedDist,
1300    ) -> Result<String, MetadataError> {
1301        let source = Source::from_resolved_dist(dist, workspace_root.as_ref())?;
1302        Ok(MetadataNodeId::Package(MetadataPackageNodeId {
1303            name: dist.name().clone(),
1304            version: dist.version().cloned(),
1305            source: MetadataSource::from_source(workspace_root, source),
1306            kind: MetadataNodeKind::Package,
1307        })
1308        .to_flat())
1309    }
1310
1311    #[must_use]
1312    pub fn with_environment_root(mut self, environment_root: &Path) -> Self {
1313        self.environment = Some(MetadataEnvironment {
1314            root: PortablePathBuf::from(environment_root),
1315        });
1316        self
1317    }
1318
1319    #[must_use]
1320    pub fn with_module_owners(mut self, module_owners: BTreeMap<ModuleName, Vec<String>>) -> Self {
1321        self.module_owners = module_owners
1322            .into_iter()
1323            .filter_map(|(module, owners)| {
1324                let owners = owners
1325                    .into_iter()
1326                    .filter(|package_id| self.resolution.contains_key(package_id))
1327                    .map(|package_id| MetadataModuleOwner { package_id })
1328                    .collect::<Vec<_>>();
1329                (!owners.is_empty()).then_some((module, owners))
1330            })
1331            .collect();
1332        self
1333    }
1334
1335    pub fn to_json(&self) -> Result<String, MetadataError> {
1336        Ok(serde_json::to_string_pretty(self)?)
1337    }
1338}