1use std::collections::BTreeMap;
2use std::fmt::Display;
3use std::path::Path;
4
5use uv_distribution_filename::WheelFilename;
6use uv_distribution_types::{Name, RequiresPython, ResolvedDist, UrlString};
7use uv_fs::PortablePathBuf;
8use uv_normalize::{ExtraName, GroupName, PackageName};
9use uv_pep440::Version;
10use uv_pypi_types::{ConflictItem, ConflictKind, ConflictSet, Conflicts, ModuleName};
11use uv_workspace::Workspace;
12
13use crate::lock::{
14 Dependency, DirectSource, PackageId, RegistrySource, Source, SourceDist, SourceDistMetadata,
15 Wheel, WheelWireSource, ZstdWheel,
16};
17use crate::{Lock, LockError};
18
19#[derive(Debug, thiserror::Error)]
20enum MetadataErrorKind {
21 #[error(transparent)]
22 Serialize(#[from] serde_json::error::Error),
23 #[error(transparent)]
24 Lock(#[from] LockError),
25}
26
27#[derive(Debug)]
28pub struct MetadataError {
29 kind: Box<MetadataErrorKind>,
30}
31
32impl std::error::Error for MetadataError {
33 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
34 self.kind.source()
35 }
36}
37
38impl std::fmt::Display for MetadataError {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 write!(f, "{}", self.kind)?;
41 Ok(())
42 }
43}
44
45impl<E> From<E> for MetadataError
46where
47 MetadataErrorKind: From<E>,
48{
49 fn from(err: E) -> Self {
50 Self {
51 kind: Box::new(MetadataErrorKind::from(err)),
52 }
53 }
54}
55
56#[derive(Debug, serde::Serialize)]
58pub struct Metadata {
59 schema: SchemaReport,
61 workspace_root: PortablePathBuf,
66 #[serde(skip_serializing_if = "Option::is_none", default)]
68 environment: Option<MetadataEnvironment>,
69 requires_python: RequiresPython,
73 conflicts: MetadataConflicts,
75 #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
77 module_owners: BTreeMap<ModuleName, Vec<MetadataModuleOwner>>,
78 #[serde(skip_serializing_if = "Vec::is_empty", default)]
82 members: Vec<MetadataWorkspaceMember>,
83 #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
85 resolution: BTreeMap<MetadataNodeIdFlat, MetadataNode>,
86}
87
88#[derive(serde::Serialize, Debug, Default)]
90#[serde(rename_all = "snake_case")]
91enum SchemaVersion {
92 #[default]
94 Preview,
95}
96
97#[derive(serde::Serialize, Debug, Default)]
99struct SchemaReport {
100 version: SchemaVersion,
102}
103
104#[derive(Debug, serde::Serialize)]
106struct MetadataEnvironment {
107 root: PortablePathBuf,
109}
110
111#[derive(Debug, serde::Serialize)]
113struct MetadataWorkspaceMember {
114 name: PackageName,
116 path: PortablePathBuf,
118 id: MetadataNodeIdFlat,
120}
121
122#[derive(Debug, serde::Serialize)]
124struct MetadataModuleOwner {
125 package_id: MetadataNodeIdFlat,
127}
128
129#[derive(Debug, Clone, serde::Serialize)]
175struct MetadataNode {
176 #[serde(flatten)]
178 id: MetadataNodeId,
179 dependencies: Vec<MetadataDependency>,
181 #[serde(skip_serializing_if = "Vec::is_empty", default)]
183 optional_dependencies: Vec<MetadataExtra>,
184 #[serde(skip_serializing_if = "Vec::is_empty", default)]
186 dependency_groups: Vec<MetadataGroup>,
187 #[serde(skip_serializing_if = "Option::is_none", default)]
189 build_system: Option<MetadataBuildSystem>,
190 #[serde(skip_serializing_if = "Option::is_none", default)]
192 sdist: Option<MetadataSourceDist>,
193 #[serde(skip_serializing_if = "Vec::is_empty", default)]
195 wheels: Vec<MetadataWheel>,
196}
197
198impl MetadataNode {
199 fn new(id: MetadataNodeId) -> Self {
200 Self {
201 id,
202 dependencies: Vec::new(),
203 dependency_groups: Vec::new(),
204 optional_dependencies: Vec::new(),
205 wheels: Vec::new(),
206 build_system: None,
207 sdist: None,
208 }
209 }
210
211 fn from_package_id(
212 workspace_root: &PortablePathBuf,
213 id: &PackageId,
214 kind: MetadataNodeKind,
215 ) -> Self {
216 Self::new(MetadataNodeId::from_package_id(workspace_root, id, kind))
217 }
218
219 fn add_dependency(&mut self, workspace_root: &PortablePathBuf, dependency: &Dependency) {
220 let extras = dependency.extra();
221 if extras.is_empty() {
222 let id = MetadataNodeId::from_package_id(
223 workspace_root,
224 &dependency.package_id,
225 MetadataNodeKind::Package,
226 );
227 self.dependencies.push(MetadataDependency {
228 id: id.to_flat(),
229 marker: dependency.simplified_marker.try_to_string(),
230 });
231 return;
232 }
233 for extra in extras {
234 let id = MetadataNodeId::from_package_id(
235 workspace_root,
236 &dependency.package_id,
237 MetadataNodeKind::Extra(extra.clone()),
238 );
239 self.dependencies.push(MetadataDependency {
240 id: id.to_flat(),
241 marker: dependency.simplified_marker.try_to_string(),
242 });
243 }
244 }
245}
246
247#[derive(Debug, Clone, serde::Serialize)]
251struct MetadataNodeId {
252 name: PackageName,
254 #[serde(skip_serializing_if = "Option::is_none", default)]
256 version: Option<Version>,
257 source: MetadataSource,
259 kind: MetadataNodeKind,
261}
262
263type MetadataNodeIdFlat = String;
269
270impl MetadataNodeId {
271 fn from_package_id(
272 workspace_root: &PortablePathBuf,
273 id: &PackageId,
274 kind: MetadataNodeKind,
275 ) -> Self {
276 let name = id.name.clone();
277 let version = id.version.clone();
278 let source = MetadataSource::from_source(workspace_root, id.source.clone());
279
280 Self {
281 name,
282 version,
283 source,
284 kind,
285 }
286 }
287
288 fn to_flat(&self) -> MetadataNodeIdFlat {
289 self.to_string()
290 }
291}
292
293impl Display for MetadataNodeId {
294 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
295 match &self.version {
296 Some(version) => write!(f, "{}{}=={version}@{}", self.name, self.kind, self.source),
297 None => write!(f, "{}{}@{}", self.name, self.kind, self.source),
298 }
299 }
300}
301
302#[derive(Debug, Clone, serde::Serialize)]
303struct MetadataDependency {
304 id: MetadataNodeIdFlat,
305 #[serde(skip_serializing_if = "Option::is_none", default)]
306 marker: Option<MetadataMarker>,
307}
308
309type MetadataMarker = String;
310
311#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
313#[serde(rename_all = "snake_case")]
314enum MetadataNodeKind {
315 Package,
318 #[expect(dead_code)]
321 Build,
322 Extra(ExtraName),
325 Group(GroupName),
328}
329
330impl Display for MetadataNodeKind {
331 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
332 match self {
333 Self::Package => Ok(()),
335 Self::Build => f.write_str("(build)"),
336 Self::Extra(extra_name) => write!(f, "[{extra_name}]"),
337 Self::Group(group_name) => write!(f, ":{group_name}"),
338 }
339 }
340}
341
342#[derive(Clone, Debug, serde::Serialize)]
343#[serde(untagged, rename_all = "snake_case")]
344enum MetadataSource {
345 Registry {
346 registry: MetadataRegistrySource,
347 },
348 Git {
349 git: UrlString,
350 },
351 Direct {
352 url: UrlString,
353 subdirectory: Option<PortablePathBuf>,
354 },
355 Path {
356 path: PortablePathBuf,
357 },
358 Directory {
359 directory: PortablePathBuf,
360 },
361 Editable {
362 editable: PortablePathBuf,
363 },
364 Virtual {
365 r#virtual: PortablePathBuf,
366 },
367}
368
369impl Display for MetadataSource {
370 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
371 match self {
372 Self::Registry {
373 registry: MetadataRegistrySource::Url(url),
374 }
375 | Self::Git { git: url }
376 | Self::Direct { url, .. } => {
377 write!(f, "{}+{}", self.name(), url)
378 }
379 Self::Registry {
380 registry: MetadataRegistrySource::Path(path),
381 }
382 | Self::Path { path }
383 | Self::Directory { directory: path }
384 | Self::Editable { editable: path }
385 | Self::Virtual { r#virtual: path } => {
386 write!(f, "{}+{}", self.name(), path)
387 }
388 }
389 }
390}
391
392impl MetadataSource {
393 fn name(&self) -> &str {
394 match self {
395 Self::Registry { .. } => "registry",
396 Self::Git { .. } => "git",
397 Self::Direct { .. } => "direct",
398 Self::Path { .. } => "path",
399 Self::Directory { .. } => "directory",
400 Self::Editable { .. } => "editable",
401 Self::Virtual { .. } => "virtual",
402 }
403 }
404}
405
406impl MetadataSource {
407 fn from_source(workspace_root: &PortablePathBuf, source: Source) -> Self {
408 match source {
409 Source::Registry(source) => match source {
410 RegistrySource::Url(url) => Self::Registry {
411 registry: MetadataRegistrySource::Url(url),
412 },
413 RegistrySource::Path(path) => Self::Registry {
414 registry: MetadataRegistrySource::Path(normalize_workspace_relative_path(
415 workspace_root,
416 &path,
417 )),
418 },
419 },
420 Source::Git(url, _) => Self::Git { git: url },
421 Source::Direct(url, DirectSource { subdirectory }) => Self::Direct {
422 url,
423 subdirectory: subdirectory
424 .map(|path| normalize_workspace_relative_path(workspace_root, &path)),
425 },
426 Source::Path(path) => Self::Path {
427 path: normalize_workspace_relative_path(workspace_root, &path),
428 },
429 Source::Directory(path) => Self::Directory {
430 directory: normalize_workspace_relative_path(workspace_root, &path),
431 },
432 Source::Editable(path) => Self::Editable {
433 editable: normalize_workspace_relative_path(workspace_root, &path),
434 },
435 Source::Virtual(path) => Self::Virtual {
436 r#virtual: normalize_workspace_relative_path(workspace_root, &path),
437 },
438 }
439 }
440}
441
442fn normalize_workspace_relative_path(
443 workspace_root: &PortablePathBuf,
444 maybe_rel: &std::path::Path,
445) -> PortablePathBuf {
446 if maybe_rel.is_absolute() {
447 PortablePathBuf::from(maybe_rel)
448 } else {
449 PortablePathBuf::from(workspace_root.as_ref().join(maybe_rel).as_path())
450 }
451}
452
453#[derive(Clone, Debug, serde::Serialize)]
454#[serde(rename_all = "snake_case")]
455enum MetadataRegistrySource {
456 Url(UrlString),
458 Path(PortablePathBuf),
460}
461
462#[derive(Clone, Debug, serde::Serialize)]
463#[serde(untagged, rename_all = "snake_case")]
464enum MetadataSourceDist {
465 Url {
466 url: UrlString,
467 #[serde(flatten)]
468 metadata: MetadataSourceDistMetadata,
469 },
470 Path {
471 path: PortablePathBuf,
472 #[serde(flatten)]
473 metadata: MetadataSourceDistMetadata,
474 },
475 Metadata {
476 #[serde(flatten)]
477 metadata: MetadataSourceDistMetadata,
478 },
479}
480
481impl MetadataSourceDist {
482 fn from_sdist(workspace_root: &PortablePathBuf, sdist: &SourceDist) -> Self {
483 match sdist {
484 SourceDist::Url { url, metadata } => Self::Url {
485 url: url.clone(),
486 metadata: MetadataSourceDistMetadata::from_sdist(metadata),
487 },
488 SourceDist::Path { path, metadata } => Self::Path {
489 path: normalize_workspace_relative_path(workspace_root, path),
490 metadata: MetadataSourceDistMetadata::from_sdist(metadata),
491 },
492 SourceDist::Metadata { metadata } => Self::Metadata {
493 metadata: MetadataSourceDistMetadata::from_sdist(metadata),
494 },
495 }
496 }
497}
498
499#[derive(Clone, Debug, serde::Serialize)]
500#[serde(rename_all = "snake_case")]
501struct MetadataSourceDistMetadata {
502 #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
504 hashes: BTreeMap<HashAlgorithm, Hash>,
505 #[serde(skip_serializing_if = "Option::is_none", default)]
509 size: Option<u64>,
510 #[serde(skip_serializing_if = "Option::is_none", default)]
512 upload_time: Option<jiff::Timestamp>,
513}
514
515type HashAlgorithm = String;
517type Hash = String;
519
520fn hashes_map(hash: &crate::lock::Hash) -> BTreeMap<HashAlgorithm, Hash> {
525 Some((hash.0.algorithm.to_string(), hash.0.digest.to_string()))
526 .into_iter()
527 .collect()
528}
529
530impl MetadataSourceDistMetadata {
531 fn from_sdist(sdist: &SourceDistMetadata) -> Self {
532 Self {
533 hashes: sdist.hash.as_ref().map(hashes_map).unwrap_or_default(),
534 size: sdist.size,
535 upload_time: sdist.upload_time,
536 }
537 }
538}
539#[derive(Clone, Debug, serde::Serialize)]
540struct MetadataWheel {
541 #[serde(flatten)]
546 source: Option<MetadataWheelWireSource>,
547 #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
553 hashes: BTreeMap<HashAlgorithm, Hash>,
554 #[serde(skip_serializing_if = "Option::is_none", default)]
558 size: Option<u64>,
559 #[serde(skip_serializing_if = "Option::is_none", default)]
563 upload_time: Option<jiff::Timestamp>,
564 filename: WheelFilename,
571 #[serde(skip_serializing_if = "Option::is_none", default)]
573 zstd: Option<MetadataZstdWheel>,
574}
575
576impl MetadataWheel {
577 fn from_wheel(workspace_root: &PortablePathBuf, wheel: &Wheel) -> Self {
578 Self {
579 source: MetadataWheelWireSource::from_wheel(workspace_root, &wheel.url),
580 hashes: wheel.hash.as_ref().map(hashes_map).unwrap_or_default(),
581 size: wheel.size,
582 upload_time: wheel.upload_time,
583 filename: wheel.filename.clone(),
584 zstd: wheel.zstd.as_ref().map(MetadataZstdWheel::from_wheel),
585 }
586 }
587}
588
589#[derive(Clone, Debug, serde::Serialize)]
590#[serde(untagged, rename_all = "snake_case")]
591enum MetadataWheelWireSource {
592 Url { url: UrlString },
593 Path { path: PortablePathBuf },
594}
595
596impl MetadataWheelWireSource {
597 fn from_wheel(workspace_root: &PortablePathBuf, wheel: &WheelWireSource) -> Option<Self> {
598 match wheel {
599 WheelWireSource::Url { url } => Some(Self::Url { url: url.clone() }),
600 WheelWireSource::Path { path } => Some(Self::Path {
601 path: normalize_workspace_relative_path(workspace_root, path),
602 }),
603 WheelWireSource::Filename { .. } => None,
605 }
606 }
607}
608
609#[derive(Clone, Debug, serde::Serialize)]
610struct MetadataZstdWheel {
611 #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
612 hashes: BTreeMap<HashAlgorithm, Hash>,
613 #[serde(skip_serializing_if = "Option::is_none", default)]
614 size: Option<u64>,
615}
616
617impl MetadataZstdWheel {
618 fn from_wheel(wheel: &ZstdWheel) -> Self {
619 Self {
620 hashes: wheel.hash.as_ref().map(hashes_map).unwrap_or_default(),
621 size: wheel.size,
622 }
623 }
624}
625
626#[derive(Clone, Debug, serde::Serialize)]
627struct MetadataExtra {
628 name: ExtraName,
629 id: MetadataNodeIdFlat,
630}
631
632#[derive(Clone, Debug, serde::Serialize)]
633struct MetadataGroup {
634 name: GroupName,
635 id: MetadataNodeIdFlat,
636}
637
638#[derive(Clone, Debug, serde::Serialize)]
639struct MetadataBuildSystem {
640 build_backend: String,
642 id: MetadataNodeIdFlat,
643}
644
645#[derive(Clone, Debug, serde::Serialize)]
647struct MetadataConflicts {
648 sets: Vec<MetadataConflictSet>,
649}
650
651impl MetadataConflicts {
652 fn from_conflicts(
653 members: &[MetadataWorkspaceMember],
654 resolve: &BTreeMap<MetadataNodeIdFlat, MetadataNode>,
655 conflicts: &Conflicts,
656 ) -> Self {
657 Self {
658 sets: conflicts
659 .iter()
660 .map(|set| MetadataConflictSet::from_conflicts(members, resolve, set))
661 .collect(),
662 }
663 }
664}
665
666#[derive(Clone, Debug, serde::Serialize)]
667struct MetadataConflictSet {
668 items: Vec<MetadataConflictItem>,
669}
670
671impl MetadataConflictSet {
672 fn from_conflicts(
673 members: &[MetadataWorkspaceMember],
674 resolve: &BTreeMap<MetadataNodeIdFlat, MetadataNode>,
675 set: &ConflictSet,
676 ) -> Self {
677 Self {
678 items: set
679 .iter()
680 .map(|item| MetadataConflictItem::from_conflicts(members, resolve, item))
681 .collect(),
682 }
683 }
684}
685
686#[derive(Clone, Debug, serde::Serialize)]
687struct MetadataConflictItem {
688 package: PackageName,
690 kind: MetadataConflictKind,
691 id: Option<MetadataNodeIdFlat>,
694}
695
696impl MetadataConflictItem {
697 fn from_conflicts(
698 members: &[MetadataWorkspaceMember],
699 resolve: &BTreeMap<MetadataNodeIdFlat, MetadataNode>,
700 item: &ConflictItem,
701 ) -> Self {
702 let kind = MetadataConflictKind::from_conflicts(item.kind());
703 let id = members
704 .iter()
705 .find(|member| &member.name == item.package())
706 .and_then(|member| {
707 let package_node = resolve.get(&member.id)?;
708 let id = MetadataNodeId {
709 kind: kind.to_node_kind(),
710 ..package_node.id.clone()
711 };
712 Some(id.to_flat())
713 });
714 Self {
715 package: item.package().clone(),
716 kind,
717 id,
718 }
719 }
720}
721
722#[derive(Clone, Debug, serde::Serialize)]
723enum MetadataConflictKind {
724 Group(GroupName),
725 Extra(ExtraName),
726 Project,
727}
728
729impl MetadataConflictKind {
730 fn from_conflicts(item: &ConflictKind) -> Self {
731 match item {
732 ConflictKind::Extra(name) => Self::Extra(name.clone()),
733 ConflictKind::Group(name) => Self::Group(name.clone()),
734 ConflictKind::Project => Self::Project,
735 }
736 }
737
738 fn to_node_kind(&self) -> MetadataNodeKind {
739 match self {
740 Self::Group(name) => MetadataNodeKind::Group(name.clone()),
741 Self::Extra(name) => MetadataNodeKind::Extra(name.clone()),
742 Self::Project => MetadataNodeKind::Package,
743 }
744 }
745}
746
747impl Metadata {
748 pub fn from_lock(workspace: &Workspace, lock: &Lock) -> Result<Self, MetadataError> {
750 let mut resolve = BTreeMap::new();
751 let mut members = Vec::new();
752 let workspace_root = PortablePathBuf::from(workspace.install_path().as_path());
753
754 for lock_package in lock.packages() {
755 let mut meta_package = MetadataNode::from_package_id(
756 &workspace_root,
757 &lock_package.id,
758 MetadataNodeKind::Package,
759 );
760
761 for dependency in &lock_package.dependencies {
763 meta_package.add_dependency(&workspace_root, dependency);
764 }
765
766 for (extra, dependencies) in &lock_package.optional_dependencies {
768 let mut meta_extra = MetadataNode::from_package_id(
769 &workspace_root,
770 &lock_package.id,
771 MetadataNodeKind::Extra(extra.clone()),
772 );
773 meta_extra.dependencies.push(MetadataDependency {
775 id: meta_package.id.to_flat(),
776 marker: None,
777 });
778 for dependency in dependencies {
779 meta_extra.add_dependency(&workspace_root, dependency);
780 }
781
782 meta_package.optional_dependencies.push(MetadataExtra {
783 name: extra.clone(),
784 id: meta_extra.id.to_flat(),
785 });
786
787 resolve.insert(meta_extra.id.to_flat(), meta_extra);
788 }
789
790 for (group, dependencies) in &lock_package.dependency_groups {
792 let mut meta_group = MetadataNode::from_package_id(
793 &workspace_root,
794 &lock_package.id,
795 MetadataNodeKind::Group(group.clone()),
796 );
797 for dependency in dependencies {
799 meta_group.add_dependency(&workspace_root, dependency);
800 }
801
802 meta_package.dependency_groups.push(MetadataGroup {
803 name: group.clone(),
804 id: meta_group.id.to_flat(),
805 });
806
807 resolve.insert(meta_group.id.to_flat(), meta_group);
808 }
809
810 if let Some(workspace_package) = workspace.packages().get(lock_package.name()) {
812 let member = MetadataWorkspaceMember {
813 name: meta_package.id.name.clone(),
814 path: normalize_workspace_relative_path(
815 &workspace_root,
816 workspace_package.root().as_path(),
817 ),
818 id: meta_package.id.to_flat(),
819 };
820 members.push(member);
821 }
822
823 if let Some(sdist) = &lock_package.sdist {
825 meta_package.sdist = Some(MetadataSourceDist::from_sdist(&workspace_root, sdist));
826 }
827
828 for wheel in &lock_package.wheels {
829 meta_package
830 .wheels
831 .push(MetadataWheel::from_wheel(&workspace_root, wheel));
832 }
833
834 resolve.insert(meta_package.id.to_flat(), meta_package);
835 }
836
837 let conflicts = MetadataConflicts::from_conflicts(&members, &resolve, &lock.conflicts);
838
839 Ok(Self {
840 schema: SchemaReport {
841 version: SchemaVersion::Preview,
842 },
843 conflicts,
844 environment: None,
845 module_owners: BTreeMap::new(),
846 workspace_root,
847 requires_python: lock.requires_python.clone(),
848 members,
849 resolution: resolve,
850 })
851 }
852
853 pub fn package_node_id(
854 workspace_root: &PortablePathBuf,
855 dist: &ResolvedDist,
856 ) -> Result<String, MetadataError> {
857 let source = Source::from_resolved_dist(dist, workspace_root.as_ref())?;
858 Ok(MetadataNodeId {
859 name: dist.name().clone(),
860 version: dist.version().cloned(),
861 source: MetadataSource::from_source(workspace_root, source),
862 kind: MetadataNodeKind::Package,
863 }
864 .to_flat())
865 }
866
867 #[must_use]
868 pub fn with_environment_root(mut self, environment_root: &Path) -> Self {
869 self.environment = Some(MetadataEnvironment {
870 root: PortablePathBuf::from(environment_root),
871 });
872 self
873 }
874
875 #[must_use]
876 pub fn with_module_owners(mut self, module_owners: BTreeMap<ModuleName, Vec<String>>) -> Self {
877 self.module_owners = module_owners
878 .into_iter()
879 .filter_map(|(module, owners)| {
880 let owners = owners
881 .into_iter()
882 .filter(|package_id| self.resolution.contains_key(package_id))
883 .map(|package_id| MetadataModuleOwner { package_id })
884 .collect::<Vec<_>>();
885 (!owners.is_empty()).then_some((module, owners))
886 })
887 .collect();
888 self
889 }
890
891 pub fn to_json(&self) -> Result<String, MetadataError> {
892 Ok(serde_json::to_string_pretty(self)?)
893 }
894}