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#[derive(Debug, serde::Serialize)]
57pub struct Metadata {
58 schema: SchemaReport,
60 workspace_root: PortablePathBuf,
65 requires_python: RequiresPython,
69 conflicts: MetadataConflicts,
71 #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
73 module_owners: BTreeMap<ModuleName, Vec<MetadataModuleOwner>>,
74 #[serde(skip_serializing_if = "Vec::is_empty", default)]
78 members: Vec<MetadataWorkspaceMember>,
79 #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
81 resolution: BTreeMap<MetadataNodeIdFlat, MetadataNode>,
82}
83
84#[derive(serde::Serialize, Debug, Default)]
86#[serde(rename_all = "snake_case")]
87enum SchemaVersion {
88 #[default]
90 Preview,
91}
92
93#[derive(serde::Serialize, Debug, Default)]
95struct SchemaReport {
96 version: SchemaVersion,
98}
99
100#[derive(Debug, serde::Serialize)]
102struct MetadataWorkspaceMember {
103 name: PackageName,
105 path: PortablePathBuf,
107 id: MetadataNodeIdFlat,
109}
110
111#[derive(Debug, serde::Serialize)]
113struct MetadataModuleOwner {
114 package_id: MetadataNodeIdFlat,
116}
117
118#[derive(Debug, Clone, serde::Serialize)]
164struct MetadataNode {
165 #[serde(flatten)]
167 id: MetadataNodeId,
168 dependencies: Vec<MetadataDependency>,
170 #[serde(skip_serializing_if = "Vec::is_empty", default)]
172 optional_dependencies: Vec<MetadataExtra>,
173 #[serde(skip_serializing_if = "Vec::is_empty", default)]
175 dependency_groups: Vec<MetadataGroup>,
176 #[serde(skip_serializing_if = "Option::is_none", default)]
178 build_system: Option<MetadataBuildSystem>,
179 #[serde(skip_serializing_if = "Option::is_none", default)]
181 sdist: Option<MetadataSourceDist>,
182 #[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#[derive(Debug, Clone, serde::Serialize)]
240struct MetadataNodeId {
241 name: PackageName,
243 #[serde(skip_serializing_if = "Option::is_none", default)]
245 version: Option<Version>,
246 source: MetadataSource,
248 kind: MetadataNodeKind,
250}
251
252type 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
302#[serde(rename_all = "snake_case")]
303enum MetadataNodeKind {
304 Package,
307 #[expect(dead_code)]
310 Build,
311 Extra(ExtraName),
314 Group(GroupName),
317}
318
319impl Display for MetadataNodeKind {
320 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
321 match self {
322 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 Url(UrlString),
447 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 #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
493 hashes: BTreeMap<HashAlgorithm, Hash>,
494 #[serde(skip_serializing_if = "Option::is_none", default)]
498 size: Option<u64>,
499 #[serde(skip_serializing_if = "Option::is_none", default)]
501 upload_time: Option<jiff::Timestamp>,
502}
503
504type HashAlgorithm = String;
506type Hash = String;
508
509fn 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 #[serde(flatten)]
535 source: Option<MetadataWheelWireSource>,
536 #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
542 hashes: BTreeMap<HashAlgorithm, Hash>,
543 #[serde(skip_serializing_if = "Option::is_none", default)]
547 size: Option<u64>,
548 #[serde(skip_serializing_if = "Option::is_none", default)]
552 upload_time: Option<jiff::Timestamp>,
553 filename: WheelFilename,
560 #[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 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 build_backend: String,
631 id: MetadataNodeIdFlat,
632}
633
634#[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 package: PackageName,
679 kind: MetadataConflictKind,
680 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 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 for dependency in &lock_package.dependencies {
752 meta_package.add_dependency(&workspace_root, dependency);
753 }
754
755 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 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 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 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 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 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}