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#[derive(Debug, serde::Serialize)]
55pub struct Metadata {
56 schema: SchemaReport,
58 workspace_root: PortablePathBuf,
63 requires_python: RequiresPython,
67 conflicts: MetadataConflicts,
69 #[serde(skip_serializing_if = "Vec::is_empty", default)]
73 members: Vec<MetadataWorkspaceMember>,
74 #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
76 resolution: BTreeMap<MetadataNodeIdFlat, MetadataNode>,
77}
78
79#[derive(serde::Serialize, Debug, Default)]
81#[serde(rename_all = "snake_case")]
82enum SchemaVersion {
83 #[default]
85 Preview,
86}
87
88#[derive(serde::Serialize, Debug, Default)]
90struct SchemaReport {
91 version: SchemaVersion,
93}
94
95#[derive(Debug, serde::Serialize)]
97struct MetadataWorkspaceMember {
98 name: PackageName,
100 path: PortablePathBuf,
102 id: MetadataNodeIdFlat,
104}
105
106#[derive(Debug, Clone, serde::Serialize)]
152struct MetadataNode {
153 #[serde(flatten)]
155 id: MetadataNodeId,
156 dependencies: Vec<MetadataDependency>,
158 #[serde(skip_serializing_if = "Vec::is_empty", default)]
160 optional_dependencies: Vec<MetadataExtra>,
161 #[serde(skip_serializing_if = "Vec::is_empty", default)]
163 dependency_groups: Vec<MetadataGroup>,
164 #[serde(skip_serializing_if = "Option::is_none", default)]
166 build_system: Option<MetadataBuildSystem>,
167 #[serde(skip_serializing_if = "Option::is_none", default)]
169 sdist: Option<MetadataSourceDist>,
170 #[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#[derive(Debug, Clone, serde::Serialize)]
228struct MetadataNodeId {
229 name: PackageName,
231 #[serde(skip_serializing_if = "Option::is_none", default)]
233 version: Option<Version>,
234 source: MetadataSource,
236 kind: MetadataNodeKind,
238}
239
240type 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
290#[serde(rename_all = "snake_case")]
291enum MetadataNodeKind {
292 Package,
295 #[expect(dead_code)]
298 Build,
299 Extra(ExtraName),
302 Group(GroupName),
305}
306
307impl Display for MetadataNodeKind {
308 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
309 match self {
310 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 Url(UrlString),
435 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 #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
481 hashes: BTreeMap<HashAlgorithm, Hash>,
482 #[serde(skip_serializing_if = "Option::is_none", default)]
486 size: Option<u64>,
487 #[serde(skip_serializing_if = "Option::is_none", default)]
489 upload_time: Option<jiff::Timestamp>,
490}
491
492type HashAlgorithm = String;
494type Hash = String;
496
497fn 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 #[serde(flatten)]
523 source: Option<MetadataWheelWireSource>,
524 #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
530 hashes: BTreeMap<HashAlgorithm, Hash>,
531 #[serde(skip_serializing_if = "Option::is_none", default)]
535 size: Option<u64>,
536 #[serde(skip_serializing_if = "Option::is_none", default)]
540 upload_time: Option<jiff::Timestamp>,
541 filename: WheelFilename,
548 #[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 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 build_backend: String,
619 id: MetadataNodeIdFlat,
620}
621
622#[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 package: PackageName,
667 kind: MetadataConflictKind,
668 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 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 for dependency in &lock_package.dependencies {
740 meta_package.add_dependency(&workspace_root, dependency);
741 }
742
743 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 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 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 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 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 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}