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