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