Skip to main content

uv_resolver/lock/export/
metadata.rs

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