Skip to main content

uv_resolver/lock/export/
metadata.rs

1use std::collections::BTreeMap;
2use std::fmt::Display;
3use std::path::Path;
4
5use uv_distribution_filename::WheelFilename;
6use uv_distribution_types::{Name, RequiresPython, ResolvedDist, UrlString};
7use uv_fs::PortablePathBuf;
8use uv_normalize::{ExtraName, GroupName, PackageName};
9use uv_pep440::Version;
10use uv_pypi_types::{ConflictItem, ConflictKind, ConflictSet, Conflicts, ModuleName};
11use uv_workspace::Workspace;
12
13use crate::lock::{
14    Dependency, DirectSource, PackageId, RegistrySource, Source, SourceDist, SourceDistMetadata,
15    Wheel, WheelWireSource, ZstdWheel,
16};
17use crate::{Lock, LockError};
18
19#[derive(Debug, thiserror::Error)]
20enum MetadataErrorKind {
21    #[error(transparent)]
22    Serialize(#[from] serde_json::error::Error),
23    #[error(transparent)]
24    Lock(#[from] LockError),
25}
26
27#[derive(Debug)]
28pub struct MetadataError {
29    kind: Box<MetadataErrorKind>,
30}
31
32impl std::error::Error for MetadataError {
33    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
34        self.kind.source()
35    }
36}
37
38impl std::fmt::Display for MetadataError {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        write!(f, "{}", self.kind)?;
41        Ok(())
42    }
43}
44
45impl<E> From<E> for MetadataError
46where
47    MetadataErrorKind: From<E>,
48{
49    fn from(err: E) -> Self {
50        Self {
51            kind: Box::new(MetadataErrorKind::from(err)),
52        }
53    }
54}
55
56/// The full `uv workspace metadata` JSON object
57#[derive(Debug, serde::Serialize)]
58pub struct Metadata {
59    /// Format information
60    schema: SchemaReport,
61    /// Absolute path to the workspace root
62    ///
63    /// Ideally absolute paths to things that are found in subdirs of this should have exactly
64    /// this as a prefix so it can be stripped to get relative paths if one wants.
65    workspace_root: PortablePathBuf,
66    /// Information about the synchronized environment, when `--sync` was used.
67    #[serde(skip_serializing_if = "Option::is_none", default)]
68    environment: Option<MetadataEnvironment>,
69    /// The version of python required by the workspace
70    ///
71    /// Every `marker` we emit implicitly assumes this constraint to keep things clean
72    requires_python: RequiresPython,
73    /// Info about conflicting packages
74    conflicts: MetadataConflicts,
75    /// A mapping from importable module names to the package nodes that provide them
76    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
77    module_owners: BTreeMap<ModuleName, Vec<MetadataModuleOwner>>,
78    /// An index of which nodes are workspace members
79    ///
80    /// These entries are often what you should use as the entry-points into the `resolve` graph.
81    #[serde(skip_serializing_if = "Vec::is_empty", default)]
82    members: Vec<MetadataWorkspaceMember>,
83    /// The dependency graph
84    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
85    resolution: BTreeMap<MetadataNodeIdFlat, MetadataNode>,
86}
87
88/// The schema version for the metadata report.
89#[derive(serde::Serialize, Debug, Default)]
90#[serde(rename_all = "snake_case")]
91enum SchemaVersion {
92    /// An unstable, experimental schema.
93    #[default]
94    Preview,
95}
96
97/// The schema metadata for the metadata report.
98#[derive(serde::Serialize, Debug, Default)]
99struct SchemaReport {
100    /// The version of the schema.
101    version: SchemaVersion,
102}
103
104/// Information about the environment synchronized for the workspace.
105#[derive(Debug, serde::Serialize)]
106struct MetadataEnvironment {
107    /// Absolute path to the environment root.
108    root: PortablePathBuf,
109}
110
111/// Info for looking up workspace members, most information is stored in the node behind `id`
112#[derive(Debug, serde::Serialize)]
113struct MetadataWorkspaceMember {
114    /// Package name
115    name: PackageName,
116    /// Absolute path to the member
117    path: PortablePathBuf,
118    /// Key for the package's node in the `resolve` graph
119    id: MetadataNodeIdFlat,
120}
121
122/// An installed distribution that provides an importable module.
123#[derive(Debug, serde::Serialize)]
124struct MetadataModuleOwner {
125    /// Key for the package node in the `resolution` graph.
126    package_id: MetadataNodeIdFlat,
127}
128
129/// A node in the dependency graph
130///
131/// There are 4 kinds of nodes:
132///
133/// * packages: `mypackage==1.0.0@registry+https://pypi.org/simple`
134/// * extras:   `mypackage[myextra]==1.0.0@registry+https://pypi.org/simple`
135/// * groups:   `mypackage:mygroup==1.0.0@registry+https://pypi.org/simple`
136/// * build:    `mypackage(build)==1.0.0@registry+https://pypi.org/simple`
137///
138/// -----------
139///
140/// A package like this:
141///
142/// ```toml
143/// [project]
144/// name = "mypackage"
145/// version = 1.0.0
146///
147/// dependencies = ["httpx"]
148///
149/// [project.optional-dependencies]
150/// cli = ["rich"]
151///
152/// [dependency-groups]
153/// dev = ["typing-extensions"]
154///
155/// [build-system]
156/// requires = ["hatchling"]
157/// ```
158///
159/// will get 4 nodes with the following edges (Version and Source omitted here for brevity):
160///
161/// * `mypackage`
162///   * `httpx`
163/// * `mypackage(build)`
164///   * `hatchling`
165/// * `mypackage[cli]`
166///   * `mypackage`
167///   * `rich`
168/// * `mypackage:dev`
169///   * `typing-extensions`
170///
171/// Note that `mypackage[cli]` has a dependency edge on `mypackage` while `mypackage:dev` does not.
172/// This is because `mypackage[cli]` is fundamentally an augmentation of `mypackage` while `mypackage:dev`
173/// is just a list of packages that happens to be defined by `mypackage`'s pyproject.toml.
174#[derive(Debug, Clone, serde::Serialize)]
175struct MetadataNode {
176    /// A unique id for this node that will be used to refer to it
177    #[serde(flatten)]
178    id: MetadataNodeId,
179    /// Dependencies of this node (the edges of The Graph)
180    dependencies: Vec<MetadataDependency>,
181    /// Extras
182    #[serde(skip_serializing_if = "Vec::is_empty", default)]
183    optional_dependencies: Vec<MetadataExtra>,
184    /// Groups
185    #[serde(skip_serializing_if = "Vec::is_empty", default)]
186    dependency_groups: Vec<MetadataGroup>,
187    /// Info about building the package
188    #[serde(skip_serializing_if = "Option::is_none", default)]
189    build_system: Option<MetadataBuildSystem>,
190    /// The source distribution found
191    #[serde(skip_serializing_if = "Option::is_none", default)]
192    sdist: Option<MetadataSourceDist>,
193    /// Wheels we found
194    #[serde(skip_serializing_if = "Vec::is_empty", default)]
195    wheels: Vec<MetadataWheel>,
196}
197
198impl MetadataNode {
199    fn new(id: MetadataNodeId) -> Self {
200        Self {
201            id,
202            dependencies: Vec::new(),
203            dependency_groups: Vec::new(),
204            optional_dependencies: Vec::new(),
205            wheels: Vec::new(),
206            build_system: None,
207            sdist: None,
208        }
209    }
210
211    fn from_package_id(
212        workspace_root: &PortablePathBuf,
213        id: &PackageId,
214        kind: MetadataNodeKind,
215    ) -> Self {
216        Self::new(MetadataNodeId::from_package_id(workspace_root, id, kind))
217    }
218
219    fn add_dependency(&mut self, workspace_root: &PortablePathBuf, dependency: &Dependency) {
220        let extras = dependency.extra();
221        if extras.is_empty() {
222            let id = MetadataNodeId::from_package_id(
223                workspace_root,
224                &dependency.package_id,
225                MetadataNodeKind::Package,
226            );
227            self.dependencies.push(MetadataDependency {
228                id: id.to_flat(),
229                marker: dependency.simplified_marker.try_to_string(),
230            });
231            return;
232        }
233        for extra in extras {
234            let id = MetadataNodeId::from_package_id(
235                workspace_root,
236                &dependency.package_id,
237                MetadataNodeKind::Extra(extra.clone()),
238            );
239            self.dependencies.push(MetadataDependency {
240                id: id.to_flat(),
241                marker: dependency.simplified_marker.try_to_string(),
242            });
243        }
244    }
245}
246
247/// The unique key for every node in the graph
248///
249/// (It's not entirely clear to me that two nodes can differ only by `source` but it doesn't hurt.)
250#[derive(Debug, Clone, serde::Serialize)]
251struct MetadataNodeId {
252    /// The name of the package
253    name: PackageName,
254    /// The version of the package, if any could be found (source trees may have no version)
255    #[serde(skip_serializing_if = "Option::is_none", default)]
256    version: Option<Version>,
257    /// The source of the package (directory, registry, URL...)
258    source: MetadataSource,
259    /// What kind of node is this?
260    kind: MetadataNodeKind,
261}
262
263/// This is intended to be an opaque unique id for referring to a node
264///
265/// It's human readable for convenience but parsing it or relying on it is inadvisable.
266/// As currently implemented this is just a concatenation of the 4 fields in `MetadataNodeId`
267/// which every node includes, so parsing it is just making more work for yourself.
268type MetadataNodeIdFlat = String;
269
270impl MetadataNodeId {
271    fn from_package_id(
272        workspace_root: &PortablePathBuf,
273        id: &PackageId,
274        kind: MetadataNodeKind,
275    ) -> Self {
276        let name = id.name.clone();
277        let version = id.version.clone();
278        let source = MetadataSource::from_source(workspace_root, id.source.clone());
279
280        Self {
281            name,
282            version,
283            source,
284            kind,
285        }
286    }
287
288    fn to_flat(&self) -> MetadataNodeIdFlat {
289        self.to_string()
290    }
291}
292
293impl Display for MetadataNodeId {
294    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
295        match &self.version {
296            Some(version) => write!(f, "{}{}=={version}@{}", self.name, self.kind, self.source),
297            None => write!(f, "{}{}@{}", self.name, self.kind, self.source),
298        }
299    }
300}
301
302#[derive(Debug, Clone, serde::Serialize)]
303struct MetadataDependency {
304    id: MetadataNodeIdFlat,
305    #[serde(skip_serializing_if = "Option::is_none", default)]
306    marker: Option<MetadataMarker>,
307}
308
309type MetadataMarker = String;
310
311/// The kind a node can have in the dependency graph
312#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
313#[serde(rename_all = "snake_case")]
314enum MetadataNodeKind {
315    /// The node is the package itself
316    /// its edges are `project.dependencies`
317    Package,
318    /// The node is for building the package's sdist into a wheel
319    /// its edges are `build-system.requires`
320    #[expect(dead_code)]
321    Build,
322    /// The node is for an extra defined on the package
323    /// its edges are `project.optional-dependencies.myextra`
324    Extra(ExtraName),
325    /// The node is for a dependency-group defined on the package
326    /// its edges are `dependency-groups.mygroup`
327    Group(GroupName),
328}
329
330impl Display for MetadataNodeKind {
331    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
332        match self {
333            // Don't apply any special decoration, this is the default
334            Self::Package => Ok(()),
335            Self::Build => f.write_str("(build)"),
336            Self::Extra(extra_name) => write!(f, "[{extra_name}]"),
337            Self::Group(group_name) => write!(f, ":{group_name}"),
338        }
339    }
340}
341
342#[derive(Clone, Debug, serde::Serialize)]
343#[serde(untagged, rename_all = "snake_case")]
344enum MetadataSource {
345    Registry {
346        registry: MetadataRegistrySource,
347    },
348    Git {
349        git: UrlString,
350    },
351    Direct {
352        url: UrlString,
353        subdirectory: Option<PortablePathBuf>,
354    },
355    Path {
356        path: PortablePathBuf,
357    },
358    Directory {
359        directory: PortablePathBuf,
360    },
361    Editable {
362        editable: PortablePathBuf,
363    },
364    Virtual {
365        r#virtual: PortablePathBuf,
366    },
367}
368
369impl Display for MetadataSource {
370    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
371        match self {
372            Self::Registry {
373                registry: MetadataRegistrySource::Url(url),
374            }
375            | Self::Git { git: url }
376            | Self::Direct { url, .. } => {
377                write!(f, "{}+{}", self.name(), url)
378            }
379            Self::Registry {
380                registry: MetadataRegistrySource::Path(path),
381            }
382            | Self::Path { path }
383            | Self::Directory { directory: path }
384            | Self::Editable { editable: path }
385            | Self::Virtual { r#virtual: path } => {
386                write!(f, "{}+{}", self.name(), path)
387            }
388        }
389    }
390}
391
392impl MetadataSource {
393    fn name(&self) -> &str {
394        match self {
395            Self::Registry { .. } => "registry",
396            Self::Git { .. } => "git",
397            Self::Direct { .. } => "direct",
398            Self::Path { .. } => "path",
399            Self::Directory { .. } => "directory",
400            Self::Editable { .. } => "editable",
401            Self::Virtual { .. } => "virtual",
402        }
403    }
404}
405
406impl MetadataSource {
407    fn from_source(workspace_root: &PortablePathBuf, source: Source) -> Self {
408        match source {
409            Source::Registry(source) => match source {
410                RegistrySource::Url(url) => Self::Registry {
411                    registry: MetadataRegistrySource::Url(url),
412                },
413                RegistrySource::Path(path) => Self::Registry {
414                    registry: MetadataRegistrySource::Path(normalize_workspace_relative_path(
415                        workspace_root,
416                        &path,
417                    )),
418                },
419            },
420            Source::Git(url, _) => Self::Git { git: url },
421            Source::Direct(url, DirectSource { subdirectory }) => Self::Direct {
422                url,
423                subdirectory: subdirectory
424                    .map(|path| normalize_workspace_relative_path(workspace_root, &path)),
425            },
426            Source::Path(path) => Self::Path {
427                path: normalize_workspace_relative_path(workspace_root, &path),
428            },
429            Source::Directory(path) => Self::Directory {
430                directory: normalize_workspace_relative_path(workspace_root, &path),
431            },
432            Source::Editable(path) => Self::Editable {
433                editable: normalize_workspace_relative_path(workspace_root, &path),
434            },
435            Source::Virtual(path) => Self::Virtual {
436                r#virtual: normalize_workspace_relative_path(workspace_root, &path),
437            },
438        }
439    }
440}
441
442fn normalize_workspace_relative_path(
443    workspace_root: &PortablePathBuf,
444    maybe_rel: &std::path::Path,
445) -> PortablePathBuf {
446    if maybe_rel.is_absolute() {
447        PortablePathBuf::from(maybe_rel)
448    } else {
449        PortablePathBuf::from(workspace_root.as_ref().join(maybe_rel).as_path())
450    }
451}
452
453#[derive(Clone, Debug, serde::Serialize)]
454#[serde(rename_all = "snake_case")]
455enum MetadataRegistrySource {
456    /// Ex) `https://pypi.org/simple`
457    Url(UrlString),
458    /// Ex) `/path/to/local/index`
459    Path(PortablePathBuf),
460}
461
462#[derive(Clone, Debug, serde::Serialize)]
463#[serde(untagged, rename_all = "snake_case")]
464enum MetadataSourceDist {
465    Url {
466        url: UrlString,
467        #[serde(flatten)]
468        metadata: MetadataSourceDistMetadata,
469    },
470    Path {
471        path: PortablePathBuf,
472        #[serde(flatten)]
473        metadata: MetadataSourceDistMetadata,
474    },
475    Metadata {
476        #[serde(flatten)]
477        metadata: MetadataSourceDistMetadata,
478    },
479}
480
481impl MetadataSourceDist {
482    fn from_sdist(workspace_root: &PortablePathBuf, sdist: &SourceDist) -> Self {
483        match sdist {
484            SourceDist::Url { url, metadata } => Self::Url {
485                url: url.clone(),
486                metadata: MetadataSourceDistMetadata::from_sdist(metadata),
487            },
488            SourceDist::Path { path, metadata } => Self::Path {
489                path: normalize_workspace_relative_path(workspace_root, path),
490                metadata: MetadataSourceDistMetadata::from_sdist(metadata),
491            },
492            SourceDist::Metadata { metadata } => Self::Metadata {
493                metadata: MetadataSourceDistMetadata::from_sdist(metadata),
494            },
495        }
496    }
497}
498
499#[derive(Clone, Debug, serde::Serialize)]
500#[serde(rename_all = "snake_case")]
501struct MetadataSourceDistMetadata {
502    /// A hash of the source distribution.
503    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
504    hashes: BTreeMap<HashAlgorithm, Hash>,
505    /// The size of the source distribution in bytes.
506    ///
507    /// This is only present for source distributions that come from registries.
508    #[serde(skip_serializing_if = "Option::is_none", default)]
509    size: Option<u64>,
510    /// The upload time of the source distribution.
511    #[serde(skip_serializing_if = "Option::is_none", default)]
512    upload_time: Option<jiff::Timestamp>,
513}
514
515/// The name of a hash algorithm ("sha256", "blake2b", "md5", etc)
516type HashAlgorithm = String;
517/// A hex encoded digest of the file
518type Hash = String;
519
520/// Oh you wanted a hash map? No this is the hashes map, a sorted map of hashes!
521///
522/// We prefer matching PEP 691 (JSON-based Simple API for Python) here for future-proofing
523/// and convenience of consumption.
524fn hashes_map(hash: &crate::lock::Hash) -> BTreeMap<HashAlgorithm, Hash> {
525    Some((hash.0.algorithm.to_string(), hash.0.digest.to_string()))
526        .into_iter()
527        .collect()
528}
529
530impl MetadataSourceDistMetadata {
531    fn from_sdist(sdist: &SourceDistMetadata) -> Self {
532        Self {
533            hashes: sdist.hash.as_ref().map(hashes_map).unwrap_or_default(),
534            size: sdist.size,
535            upload_time: sdist.upload_time,
536        }
537    }
538}
539#[derive(Clone, Debug, serde::Serialize)]
540struct MetadataWheel {
541    /// A URL or file path (via `file://`) where the wheel that was locked
542    /// against was found. The location does not need to exist in the future,
543    /// so this should be treated as only a hint to where to look and/or
544    /// recording where the wheel file originally came from.
545    #[serde(flatten)]
546    source: Option<MetadataWheelWireSource>,
547    /// A hash of the built distribution.
548    ///
549    /// This is only present for wheels that come from registries and direct
550    /// URLs. Wheels from git or path dependencies do not have hashes
551    /// associated with them.
552    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
553    hashes: BTreeMap<HashAlgorithm, Hash>,
554    /// The size of the built distribution in bytes.
555    ///
556    /// This is only present for wheels that come from registries.
557    #[serde(skip_serializing_if = "Option::is_none", default)]
558    size: Option<u64>,
559    /// The upload time of the built distribution.
560    ///
561    /// This is only present for wheels that come from registries.
562    #[serde(skip_serializing_if = "Option::is_none", default)]
563    upload_time: Option<jiff::Timestamp>,
564    /// The filename of the wheel.
565    ///
566    /// This isn't part of the wire format since it's redundant with the
567    /// URL. But we do use it for various things, and thus compute it at
568    /// deserialization time. Not being able to extract a wheel filename from a
569    /// wheel URL is thus a deserialization error.
570    filename: WheelFilename,
571    /// The zstandard-compressed wheel metadata, if any.
572    #[serde(skip_serializing_if = "Option::is_none", default)]
573    zstd: Option<MetadataZstdWheel>,
574}
575
576impl MetadataWheel {
577    fn from_wheel(workspace_root: &PortablePathBuf, wheel: &Wheel) -> Self {
578        Self {
579            source: MetadataWheelWireSource::from_wheel(workspace_root, &wheel.url),
580            hashes: wheel.hash.as_ref().map(hashes_map).unwrap_or_default(),
581            size: wheel.size,
582            upload_time: wheel.upload_time,
583            filename: wheel.filename.clone(),
584            zstd: wheel.zstd.as_ref().map(MetadataZstdWheel::from_wheel),
585        }
586    }
587}
588
589#[derive(Clone, Debug, serde::Serialize)]
590#[serde(untagged, rename_all = "snake_case")]
591enum MetadataWheelWireSource {
592    Url { url: UrlString },
593    Path { path: PortablePathBuf },
594}
595
596impl MetadataWheelWireSource {
597    fn from_wheel(workspace_root: &PortablePathBuf, wheel: &WheelWireSource) -> Option<Self> {
598        match wheel {
599            WheelWireSource::Url { url } => Some(Self::Url { url: url.clone() }),
600            WheelWireSource::Path { path } => Some(Self::Path {
601                path: normalize_workspace_relative_path(workspace_root, path),
602            }),
603            // We guarantee this as a separate field so it's redundant
604            WheelWireSource::Filename { .. } => None,
605        }
606    }
607}
608
609#[derive(Clone, Debug, serde::Serialize)]
610struct MetadataZstdWheel {
611    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
612    hashes: BTreeMap<HashAlgorithm, Hash>,
613    #[serde(skip_serializing_if = "Option::is_none", default)]
614    size: Option<u64>,
615}
616
617impl MetadataZstdWheel {
618    fn from_wheel(wheel: &ZstdWheel) -> Self {
619        Self {
620            hashes: wheel.hash.as_ref().map(hashes_map).unwrap_or_default(),
621            size: wheel.size,
622        }
623    }
624}
625
626#[derive(Clone, Debug, serde::Serialize)]
627struct MetadataExtra {
628    name: ExtraName,
629    id: MetadataNodeIdFlat,
630}
631
632#[derive(Clone, Debug, serde::Serialize)]
633struct MetadataGroup {
634    name: GroupName,
635    id: MetadataNodeIdFlat,
636}
637
638#[derive(Clone, Debug, serde::Serialize)]
639struct MetadataBuildSystem {
640    /// The `build-backend` specified in the pyproject.toml
641    build_backend: String,
642    id: MetadataNodeIdFlat,
643}
644
645/// Conflicts
646#[derive(Clone, Debug, serde::Serialize)]
647struct MetadataConflicts {
648    sets: Vec<MetadataConflictSet>,
649}
650
651impl MetadataConflicts {
652    fn from_conflicts(
653        members: &[MetadataWorkspaceMember],
654        resolve: &BTreeMap<MetadataNodeIdFlat, MetadataNode>,
655        conflicts: &Conflicts,
656    ) -> Self {
657        Self {
658            sets: conflicts
659                .iter()
660                .map(|set| MetadataConflictSet::from_conflicts(members, resolve, set))
661                .collect(),
662        }
663    }
664}
665
666#[derive(Clone, Debug, serde::Serialize)]
667struct MetadataConflictSet {
668    items: Vec<MetadataConflictItem>,
669}
670
671impl MetadataConflictSet {
672    fn from_conflicts(
673        members: &[MetadataWorkspaceMember],
674        resolve: &BTreeMap<MetadataNodeIdFlat, MetadataNode>,
675        set: &ConflictSet,
676    ) -> Self {
677        Self {
678            items: set
679                .iter()
680                .map(|item| MetadataConflictItem::from_conflicts(members, resolve, item))
681                .collect(),
682        }
683    }
684}
685
686#[derive(Clone, Debug, serde::Serialize)]
687struct MetadataConflictItem {
688    /// These should always be names of packages referred to in [`Metadata::members`]
689    package: PackageName,
690    kind: MetadataConflictKind,
691    /// This should never be None (should be a validation error way earlier in uv)
692    /// ...but I'd rather not error if wrong.
693    id: Option<MetadataNodeIdFlat>,
694}
695
696impl MetadataConflictItem {
697    fn from_conflicts(
698        members: &[MetadataWorkspaceMember],
699        resolve: &BTreeMap<MetadataNodeIdFlat, MetadataNode>,
700        item: &ConflictItem,
701    ) -> Self {
702        let kind = MetadataConflictKind::from_conflicts(item.kind());
703        let id = members
704            .iter()
705            .find(|member| &member.name == item.package())
706            .and_then(|member| {
707                let package_node = resolve.get(&member.id)?;
708                let id = MetadataNodeId {
709                    kind: kind.to_node_kind(),
710                    ..package_node.id.clone()
711                };
712                Some(id.to_flat())
713            });
714        Self {
715            package: item.package().clone(),
716            kind,
717            id,
718        }
719    }
720}
721
722#[derive(Clone, Debug, serde::Serialize)]
723enum MetadataConflictKind {
724    Group(GroupName),
725    Extra(ExtraName),
726    Project,
727}
728
729impl MetadataConflictKind {
730    fn from_conflicts(item: &ConflictKind) -> Self {
731        match item {
732            ConflictKind::Extra(name) => Self::Extra(name.clone()),
733            ConflictKind::Group(name) => Self::Group(name.clone()),
734            ConflictKind::Project => Self::Project,
735        }
736    }
737
738    fn to_node_kind(&self) -> MetadataNodeKind {
739        match self {
740            Self::Group(name) => MetadataNodeKind::Group(name.clone()),
741            Self::Extra(name) => MetadataNodeKind::Extra(name.clone()),
742            Self::Project => MetadataNodeKind::Package,
743        }
744    }
745}
746
747impl Metadata {
748    /// Construct a [`PylockToml`] from a uv lockfile.
749    pub fn from_lock(workspace: &Workspace, lock: &Lock) -> Result<Self, MetadataError> {
750        let mut resolve = BTreeMap::new();
751        let mut members = Vec::new();
752        let workspace_root = PortablePathBuf::from(workspace.install_path().as_path());
753
754        for lock_package in lock.packages() {
755            let mut meta_package = MetadataNode::from_package_id(
756                &workspace_root,
757                &lock_package.id,
758                MetadataNodeKind::Package,
759            );
760
761            // Direct dependencies go on the package node
762            for dependency in &lock_package.dependencies {
763                meta_package.add_dependency(&workspace_root, dependency);
764            }
765
766            // Extras get their own nodes
767            for (extra, dependencies) in &lock_package.optional_dependencies {
768                let mut meta_extra = MetadataNode::from_package_id(
769                    &workspace_root,
770                    &lock_package.id,
771                    MetadataNodeKind::Extra(extra.clone()),
772                );
773                // Extras always depend on the base package
774                meta_extra.dependencies.push(MetadataDependency {
775                    id: meta_package.id.to_flat(),
776                    marker: None,
777                });
778                for dependency in dependencies {
779                    meta_extra.add_dependency(&workspace_root, dependency);
780                }
781
782                meta_package.optional_dependencies.push(MetadataExtra {
783                    name: extra.clone(),
784                    id: meta_extra.id.to_flat(),
785                });
786
787                resolve.insert(meta_extra.id.to_flat(), meta_extra);
788            }
789
790            // Groups get their own nodes
791            for (group, dependencies) in &lock_package.dependency_groups {
792                let mut meta_group = MetadataNode::from_package_id(
793                    &workspace_root,
794                    &lock_package.id,
795                    MetadataNodeKind::Group(group.clone()),
796                );
797                // Groups *do not* depend on the base package, so don't add that
798                for dependency in dependencies {
799                    meta_group.add_dependency(&workspace_root, dependency);
800                }
801
802                meta_package.dependency_groups.push(MetadataGroup {
803                    name: group.clone(),
804                    id: meta_group.id.to_flat(),
805                });
806
807                resolve.insert(meta_group.id.to_flat(), meta_group);
808            }
809
810            // Register this package if it appears to be a workspace member
811            if let Some(workspace_package) = workspace.packages().get(lock_package.name()) {
812                let member = MetadataWorkspaceMember {
813                    name: meta_package.id.name.clone(),
814                    path: normalize_workspace_relative_path(
815                        &workspace_root,
816                        workspace_package.root().as_path(),
817                    ),
818                    id: meta_package.id.to_flat(),
819                };
820                members.push(member);
821            }
822
823            // Record sdist/wheel information
824            if let Some(sdist) = &lock_package.sdist {
825                meta_package.sdist = Some(MetadataSourceDist::from_sdist(&workspace_root, sdist));
826            }
827
828            for wheel in &lock_package.wheels {
829                meta_package
830                    .wheels
831                    .push(MetadataWheel::from_wheel(&workspace_root, wheel));
832            }
833
834            resolve.insert(meta_package.id.to_flat(), meta_package);
835        }
836
837        let conflicts = MetadataConflicts::from_conflicts(&members, &resolve, &lock.conflicts);
838
839        Ok(Self {
840            schema: SchemaReport {
841                version: SchemaVersion::Preview,
842            },
843            conflicts,
844            environment: None,
845            module_owners: BTreeMap::new(),
846            workspace_root,
847            requires_python: lock.requires_python.clone(),
848            members,
849            resolution: resolve,
850        })
851    }
852
853    pub fn package_node_id(
854        workspace_root: &PortablePathBuf,
855        dist: &ResolvedDist,
856    ) -> Result<String, MetadataError> {
857        let source = Source::from_resolved_dist(dist, workspace_root.as_ref())?;
858        Ok(MetadataNodeId {
859            name: dist.name().clone(),
860            version: dist.version().cloned(),
861            source: MetadataSource::from_source(workspace_root, source),
862            kind: MetadataNodeKind::Package,
863        }
864        .to_flat())
865    }
866
867    #[must_use]
868    pub fn with_environment_root(mut self, environment_root: &Path) -> Self {
869        self.environment = Some(MetadataEnvironment {
870            root: PortablePathBuf::from(environment_root),
871        });
872        self
873    }
874
875    #[must_use]
876    pub fn with_module_owners(mut self, module_owners: BTreeMap<ModuleName, Vec<String>>) -> Self {
877        self.module_owners = module_owners
878            .into_iter()
879            .filter_map(|(module, owners)| {
880                let owners = owners
881                    .into_iter()
882                    .filter(|package_id| self.resolution.contains_key(package_id))
883                    .map(|package_id| MetadataModuleOwner { package_id })
884                    .collect::<Vec<_>>();
885                (!owners.is_empty()).then_some((module, owners))
886            })
887            .collect();
888        self
889    }
890
891    pub fn to_json(&self) -> Result<String, MetadataError> {
892        Ok(serde_json::to_string_pretty(self)?)
893    }
894}