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