1use std::collections::{HashMap, HashSet, VecDeque};
2use std::path::Path;
3
4use crate::parser_warn as warn;
5use packageurl::PackageUrl;
6use serde_json::Value as JsonValue;
7use toml::Value as TomlValue;
8use toml::map::Map as TomlMap;
9
10use crate::models::{
11 DatasourceId, Dependency, PackageData, PackageType, ResolvedPackage, Sha256Digest,
12};
13use crate::parsers::python::read_toml_file;
14
15use super::PackageParser;
16
17const FIELD_PACKAGE: &str = "package";
18const FIELD_NAME: &str = "name";
19const FIELD_VERSION: &str = "version";
20const FIELD_SOURCE: &str = "source";
21const FIELD_DEPENDENCIES: &str = "dependencies";
22const FIELD_OPTIONAL_DEPENDENCIES: &str = "optional-dependencies";
23const FIELD_DEV_DEPENDENCIES: &str = "dev-dependencies";
24const FIELD_METADATA: &str = "metadata";
25const FIELD_REQUIRES_DIST: &str = "requires-dist";
26const FIELD_REQUIRES_DEV: &str = "requires-dev";
27const FIELD_METADATA_OPTIONAL_DEPENDENCIES: &str = "optional-dependencies";
28const FIELD_MARKER: &str = "marker";
29const FIELD_EXTRA: &str = "extra";
30const FIELD_SPECIFIER: &str = "specifier";
31const FIELD_REVISION: &str = "revision";
32const FIELD_REQUIRES_PYTHON: &str = "requires-python";
33const FIELD_RESOLUTION_MARKERS: &str = "resolution-markers";
34const FIELD_MANIFEST: &str = "manifest";
35
36pub struct UvLockParser;
37
38#[derive(Clone, Debug, Default)]
39struct DirectDependencyInfo {
40 extracted_requirement: Option<String>,
41 scope: Option<String>,
42 is_runtime: bool,
43 is_optional: bool,
44 extra_data: Option<HashMap<String, JsonValue>>,
45 source_key: Option<String>,
46}
47
48#[derive(Clone, Debug)]
49struct DependencyEdge {
50 name: String,
51 extracted_requirement: Option<String>,
52 scope: Option<String>,
53 is_runtime: bool,
54 is_optional: bool,
55 source_key: Option<String>,
56 extra_data: Option<HashMap<String, JsonValue>>,
57}
58
59impl PackageParser for UvLockParser {
60 const PACKAGE_TYPE: PackageType = PackageType::Pypi;
61
62 fn is_match(path: &Path) -> bool {
63 path.file_name()
64 .and_then(|name| name.to_str())
65 .is_some_and(|name| name == "uv.lock")
66 }
67
68 fn extract_packages(path: &Path) -> Vec<PackageData> {
69 let toml_content = match read_toml_file(path) {
70 Ok(content) => content,
71 Err(e) => {
72 warn!("Failed to read uv.lock at {:?}: {}", path, e);
73 return vec![default_package_data()];
74 }
75 };
76
77 vec![parse_uv_lock(&toml_content)]
78 }
79}
80
81fn parse_uv_lock(toml_content: &TomlValue) -> PackageData {
82 let packages = toml_content
83 .get(FIELD_PACKAGE)
84 .and_then(TomlValue::as_array)
85 .cloned()
86 .unwrap_or_default();
87
88 if packages.is_empty() {
89 return default_package_data();
90 }
91
92 let package_tables: Vec<&TomlMap<String, TomlValue>> =
93 packages.iter().filter_map(TomlValue::as_table).collect();
94
95 if package_tables.is_empty() {
96 return default_package_data();
97 }
98
99 let root_index = find_root_package_index(&package_tables);
100 let package_lookup = build_package_lookup(&package_tables);
101
102 let direct_infos = root_index
103 .and_then(|index| package_tables.get(index).copied())
104 .map(collect_root_direct_dependencies)
105 .unwrap_or_default();
106
107 let runtime_roots: Vec<(String, Option<String>)> = direct_infos
108 .iter()
109 .filter(|(_, info)| info.is_runtime)
110 .map(|(name, info)| (name.clone(), info.source_key.clone()))
111 .collect();
112 let dev_roots: Vec<(String, Option<String>)> = direct_infos
113 .iter()
114 .filter(|(_, info)| !info.is_runtime && !info.is_optional)
115 .map(|(name, info)| (name.clone(), info.source_key.clone()))
116 .collect();
117 let optional_roots: Vec<(String, Option<String>)> = direct_infos
118 .iter()
119 .filter(|(_, info)| info.is_optional)
120 .map(|(name, info)| (name.clone(), info.source_key.clone()))
121 .collect();
122
123 let runtime_reachable =
124 collect_reachable_packages(&package_tables, &package_lookup, &runtime_roots, false);
125 let dev_reachable =
126 collect_reachable_packages(&package_tables, &package_lookup, &dev_roots, true);
127 let optional_reachable =
128 collect_reachable_packages(&package_tables, &package_lookup, &optional_roots, true);
129
130 let mut package_data = default_package_data();
131 package_data.extra_data = build_lock_extra_data(toml_content);
132
133 if let Some(index) = root_index
134 && let Some(root_table) = package_tables.get(index)
135 {
136 package_data.name = root_table
137 .get(FIELD_NAME)
138 .and_then(TomlValue::as_str)
139 .map(normalize_pypi_name);
140 package_data.version = root_table
141 .get(FIELD_VERSION)
142 .and_then(TomlValue::as_str)
143 .map(|value| value.to_string());
144 package_data.is_virtual =
145 package_source_table(root_table).is_some_and(|source| source.contains_key("virtual"));
146 package_data.purl = package_data
147 .name
148 .as_deref()
149 .and_then(|name| create_pypi_purl(name, package_data.version.as_deref()));
150 }
151
152 package_data.dependencies = package_tables
153 .iter()
154 .enumerate()
155 .filter(|(index, _)| Some(*index) != root_index)
156 .filter_map(|(_, package_table)| {
157 build_top_level_dependency(
158 package_table,
159 root_index.is_none(),
160 &direct_infos,
161 &runtime_reachable,
162 &dev_reachable,
163 &optional_reachable,
164 &package_lookup,
165 )
166 })
167 .collect();
168
169 package_data
170}
171
172fn build_top_level_dependency(
173 package_table: &TomlMap<String, TomlValue>,
174 no_root_package: bool,
175 direct_infos: &HashMap<String, DirectDependencyInfo>,
176 runtime_reachable: &HashSet<String>,
177 dev_reachable: &HashSet<String>,
178 optional_reachable: &HashSet<String>,
179 package_lookup: &HashMap<String, Vec<usize>>,
180) -> Option<Dependency> {
181 let name = package_table
182 .get(FIELD_NAME)
183 .and_then(TomlValue::as_str)
184 .map(normalize_pypi_name)?;
185 let version = package_table
186 .get(FIELD_VERSION)
187 .and_then(TomlValue::as_str)
188 .map(|value| value.to_string())?;
189
190 let direct_info = direct_infos.get(&name);
191 let is_direct = direct_info.is_some();
192 let is_runtime = if no_root_package {
193 true
194 } else if let Some(info) = direct_info {
195 info.is_runtime
196 } else if runtime_reachable.contains(&name) {
197 true
198 } else {
199 !dev_reachable.contains(&name) && !optional_reachable.contains(&name)
200 };
201 let is_optional = direct_info.is_some_and(|info| info.is_optional)
202 || (!is_direct && optional_reachable.contains(&name) && !runtime_reachable.contains(&name));
203
204 Some(Dependency {
205 purl: create_pypi_purl(&name, Some(&version)),
206 extracted_requirement: direct_info.and_then(|info| info.extracted_requirement.clone()),
207 scope: direct_info.and_then(|info| info.scope.clone()),
208 is_runtime: Some(is_runtime),
209 is_optional: Some(is_optional),
210 is_pinned: Some(true),
211 is_direct: Some(is_direct),
212 resolved_package: Some(Box::new(build_resolved_package(
213 package_table,
214 package_lookup,
215 ))),
216 extra_data: direct_info.and_then(|info| info.extra_data.clone()),
217 })
218}
219
220fn build_resolved_package(
221 package_table: &TomlMap<String, TomlValue>,
222 package_lookup: &HashMap<String, Vec<usize>>,
223) -> ResolvedPackage {
224 let name = package_table
225 .get(FIELD_NAME)
226 .and_then(TomlValue::as_str)
227 .map(normalize_pypi_name)
228 .unwrap_or_default();
229 let version = package_table
230 .get(FIELD_VERSION)
231 .and_then(TomlValue::as_str)
232 .map(|value| value.to_string())
233 .unwrap_or_default();
234
235 let (_, repository_download_url, api_data_url, purl) =
236 build_pypi_urls(Some(&name), Some(&version));
237 let repository_homepage_url = Some(format!("https://pypi.org/project/{}", name));
238 let (download_url, sha256) = extract_artifact_metadata(package_table);
239
240 ResolvedPackage {
241 primary_language: Some("Python".to_string()),
242 download_url,
243 sha1: None,
244 sha256: sha256.and_then(|h| Sha256Digest::from_hex(&h).ok()),
245 sha512: None,
246 md5: None,
247 is_virtual: true,
248 extra_data: build_package_extra_data(package_table),
249 dependencies: collect_package_dependency_edges(package_table)
250 .into_iter()
251 .map(|edge| edge_to_dependency(edge, package_lookup))
252 .collect(),
253 repository_homepage_url,
254 repository_download_url,
255 api_data_url,
256 datasource_id: Some(DatasourceId::PypiUvLock),
257 purl,
258 ..ResolvedPackage::new(UvLockParser::PACKAGE_TYPE, String::new(), name, version)
259 }
260}
261
262fn edge_to_dependency(
263 edge: DependencyEdge,
264 package_lookup: &HashMap<String, Vec<usize>>,
265) -> Dependency {
266 let is_pinned = edge
267 .source_key
268 .as_ref()
269 .map(|_| !package_lookup.contains_key(&edge.name))
270 .unwrap_or(false);
271
272 Dependency {
273 purl: create_pypi_purl(&edge.name, None),
274 extracted_requirement: edge.extracted_requirement,
275 scope: edge.scope,
276 is_runtime: Some(edge.is_runtime),
277 is_optional: Some(edge.is_optional),
278 is_pinned: Some(is_pinned),
279 is_direct: Some(true),
280 resolved_package: None,
281 extra_data: edge.extra_data,
282 }
283}
284
285fn collect_root_direct_dependencies(
286 root_table: &TomlMap<String, TomlValue>,
287) -> HashMap<String, DirectDependencyInfo> {
288 let mut infos = HashMap::new();
289 let metadata = root_table.get(FIELD_METADATA).and_then(TomlValue::as_table);
290 let runtime_requirements = metadata
291 .and_then(|metadata| metadata.get(FIELD_REQUIRES_DIST))
292 .map(parse_requirement_metadata_array)
293 .unwrap_or_default();
294 let dev_requirements = metadata
295 .and_then(|metadata| metadata.get(FIELD_REQUIRES_DEV))
296 .and_then(TomlValue::as_table)
297 .map(parse_requirement_metadata_table)
298 .unwrap_or_default();
299 let optional_requirements = metadata
300 .and_then(|metadata| metadata.get(FIELD_METADATA_OPTIONAL_DEPENDENCIES))
301 .and_then(TomlValue::as_table)
302 .map(parse_requirement_metadata_table)
303 .unwrap_or_default();
304
305 for edge in collect_dependency_edges_from_array(
306 root_table
307 .get(FIELD_DEPENDENCIES)
308 .and_then(TomlValue::as_array),
309 None,
310 true,
311 false,
312 runtime_requirements.get("__runtime__"),
313 ) {
314 merge_direct_dependency_info(&mut infos, edge);
315 }
316
317 if let Some(optional_table) = root_table
318 .get(FIELD_OPTIONAL_DEPENDENCIES)
319 .and_then(TomlValue::as_table)
320 {
321 for (group, value) in optional_table {
322 let requirement_map = optional_requirements.get(group);
323 for edge in collect_dependency_edges_from_array(
324 value.as_array(),
325 Some(group.to_string()),
326 false,
327 true,
328 requirement_map,
329 ) {
330 merge_direct_dependency_info(&mut infos, edge);
331 }
332 }
333 }
334
335 if let Some(dev_table) = root_table
336 .get(FIELD_DEV_DEPENDENCIES)
337 .and_then(TomlValue::as_table)
338 {
339 for (group, value) in dev_table {
340 let requirement_map = dev_requirements.get(group);
341 for edge in collect_dependency_edges_from_array(
342 value.as_array(),
343 Some(group.to_string()),
344 false,
345 false,
346 requirement_map,
347 ) {
348 merge_direct_dependency_info(&mut infos, edge);
349 }
350 }
351 }
352
353 infos
354}
355
356fn merge_direct_dependency_info(
357 infos: &mut HashMap<String, DirectDependencyInfo>,
358 edge: DependencyEdge,
359) {
360 let name = edge.name.clone();
361 let new_info = direct_info_from_edge(edge);
362
363 if let Some(existing) = infos.get_mut(&name) {
364 existing.is_runtime |= new_info.is_runtime;
365 existing.is_optional &= new_info.is_optional;
366
367 if existing.extracted_requirement.is_none() {
368 existing.extracted_requirement = new_info.extracted_requirement.clone();
369 }
370
371 existing.scope = merge_scope(existing.scope.as_ref(), new_info.scope.as_ref());
372 existing.extra_data =
373 merge_optional_json_maps(existing.extra_data.take(), new_info.extra_data);
374
375 if existing.source_key != new_info.source_key {
376 existing.source_key = None;
377 }
378 } else {
379 infos.insert(name, new_info);
380 }
381}
382
383fn merge_scope(current: Option<&String>, new: Option<&String>) -> Option<String> {
384 match (current, new) {
385 (None, None) => None,
386 (None, Some(_)) | (Some(_), None) => None,
387 (Some(left), Some(right)) if left == right => Some(left.clone()),
388 _ => None,
389 }
390}
391
392fn merge_optional_json_maps(
393 current: Option<HashMap<String, JsonValue>>,
394 new: Option<HashMap<String, JsonValue>>,
395) -> Option<HashMap<String, JsonValue>> {
396 match (current, new) {
397 (None, None) => None,
398 (Some(map), None) | (None, Some(map)) => Some(map),
399 (Some(mut current), Some(new)) => {
400 for (key, value) in new {
401 current.entry(key).or_insert(value);
402 }
403 Some(current)
404 }
405 }
406}
407
408fn direct_info_from_edge(edge: DependencyEdge) -> DirectDependencyInfo {
409 DirectDependencyInfo {
410 extracted_requirement: edge.extracted_requirement,
411 scope: edge.scope,
412 is_runtime: edge.is_runtime,
413 is_optional: edge.is_optional,
414 extra_data: edge.extra_data,
415 source_key: edge.source_key,
416 }
417}
418
419fn collect_package_dependency_edges(
420 package_table: &TomlMap<String, TomlValue>,
421) -> Vec<DependencyEdge> {
422 let mut edges = Vec::new();
423
424 edges.extend(collect_dependency_edges_from_array(
425 package_table
426 .get(FIELD_DEPENDENCIES)
427 .and_then(TomlValue::as_array),
428 None,
429 true,
430 false,
431 None,
432 ));
433
434 if let Some(optional_table) = package_table
435 .get(FIELD_OPTIONAL_DEPENDENCIES)
436 .and_then(TomlValue::as_table)
437 {
438 for (group, value) in optional_table {
439 edges.extend(collect_dependency_edges_from_array(
440 value.as_array(),
441 Some(group.to_string()),
442 false,
443 true,
444 None,
445 ));
446 }
447 }
448
449 if let Some(dev_table) = package_table
450 .get(FIELD_DEV_DEPENDENCIES)
451 .and_then(TomlValue::as_table)
452 {
453 for (group, value) in dev_table {
454 edges.extend(collect_dependency_edges_from_array(
455 value.as_array(),
456 Some(group.to_string()),
457 false,
458 false,
459 None,
460 ));
461 }
462 }
463
464 edges
465}
466
467fn collect_dependency_edges_from_array(
468 values: Option<&Vec<TomlValue>>,
469 scope: Option<String>,
470 is_runtime: bool,
471 is_optional: bool,
472 requirement_map: Option<&HashMap<String, String>>,
473) -> Vec<DependencyEdge> {
474 values
475 .into_iter()
476 .flatten()
477 .filter_map(|value| {
478 build_dependency_edge(
479 value,
480 scope.clone(),
481 is_runtime,
482 is_optional,
483 requirement_map,
484 )
485 })
486 .collect()
487}
488
489fn build_dependency_edge(
490 value: &TomlValue,
491 scope: Option<String>,
492 is_runtime: bool,
493 is_optional: bool,
494 requirement_map: Option<&HashMap<String, String>>,
495) -> Option<DependencyEdge> {
496 let table = value.as_table()?;
497 let name = table
498 .get(FIELD_NAME)
499 .and_then(TomlValue::as_str)
500 .map(normalize_pypi_name)?;
501
502 let mut extra_data = HashMap::new();
503 if let Some(marker) = table.get(FIELD_MARKER).and_then(TomlValue::as_str) {
504 extra_data.insert(
505 FIELD_MARKER.to_string(),
506 JsonValue::String(marker.to_string()),
507 );
508 }
509 if let Some(extra_value) = table.get(FIELD_EXTRA) {
510 let json_value = toml_value_to_json(extra_value);
511 extra_data.insert(FIELD_EXTRA.to_string(), json_value);
512 }
513
514 let source_key = table
515 .get(FIELD_SOURCE)
516 .and_then(TomlValue::as_table)
517 .and_then(source_table_key);
518 if let Some(source) = table.get(FIELD_SOURCE) {
519 extra_data.insert(FIELD_SOURCE.to_string(), toml_value_to_json(source));
520 }
521
522 let extracted_requirement = requirement_map
523 .and_then(|map| map.get(&name).cloned())
524 .or_else(|| {
525 table
526 .get(FIELD_SPECIFIER)
527 .and_then(TomlValue::as_str)
528 .map(|value| value.to_string())
529 });
530
531 Some(DependencyEdge {
532 name,
533 extracted_requirement,
534 scope,
535 is_runtime,
536 is_optional,
537 source_key,
538 extra_data: (!extra_data.is_empty()).then_some(extra_data),
539 })
540}
541
542fn parse_requirement_metadata_array(value: &TomlValue) -> HashMap<String, HashMap<String, String>> {
543 let mut grouped = HashMap::new();
544 let runtime = value
545 .as_array()
546 .map(|values| parse_requirement_entries(values))
547 .unwrap_or_default();
548 grouped.insert("__runtime__".to_string(), runtime);
549 grouped
550}
551
552fn parse_requirement_metadata_table(
553 table: &TomlMap<String, TomlValue>,
554) -> HashMap<String, HashMap<String, String>> {
555 table
556 .iter()
557 .map(|(group, value)| {
558 (
559 group.to_string(),
560 value
561 .as_array()
562 .map(|values| parse_requirement_entries(values))
563 .unwrap_or_default(),
564 )
565 })
566 .collect()
567}
568
569fn parse_requirement_entries(values: &[TomlValue]) -> HashMap<String, String> {
570 values
571 .iter()
572 .filter_map(|value| {
573 let table = value.as_table()?;
574 let name = table
575 .get(FIELD_NAME)
576 .and_then(TomlValue::as_str)
577 .map(normalize_pypi_name)?;
578 let specifier = table
579 .get(FIELD_SPECIFIER)
580 .and_then(TomlValue::as_str)
581 .map(|value| value.to_string())?;
582 Some((name, specifier))
583 })
584 .collect()
585}
586
587fn collect_reachable_packages(
588 package_tables: &[&TomlMap<String, TomlValue>],
589 package_lookup: &HashMap<String, Vec<usize>>,
590 roots: &[(String, Option<String>)],
591 include_non_runtime_edges: bool,
592) -> HashSet<String> {
593 let mut visited = HashSet::new();
594 let mut queue: VecDeque<(String, Option<String>)> = roots.iter().cloned().collect();
595
596 while let Some((name, source_key)) = queue.pop_front() {
597 let Some(index) =
598 match_package_index(package_tables, package_lookup, &name, source_key.as_deref())
599 else {
600 continue;
601 };
602
603 let Some(package_table) = package_tables.get(index) else {
604 continue;
605 };
606
607 let package_name = package_table
608 .get(FIELD_NAME)
609 .and_then(TomlValue::as_str)
610 .map(normalize_pypi_name)
611 .unwrap_or(name);
612
613 if !visited.insert(package_name.clone()) {
614 continue;
615 }
616
617 let edges = if include_non_runtime_edges {
618 collect_package_dependency_edges(package_table)
619 } else {
620 collect_dependency_edges_from_array(
621 package_table
622 .get(FIELD_DEPENDENCIES)
623 .and_then(TomlValue::as_array),
624 None,
625 true,
626 false,
627 None,
628 )
629 };
630
631 for edge in edges {
632 queue.push_back((edge.name, edge.source_key));
633 }
634 }
635
636 visited
637}
638
639fn build_package_lookup(
640 package_tables: &[&TomlMap<String, TomlValue>],
641) -> HashMap<String, Vec<usize>> {
642 let mut lookup: HashMap<String, Vec<usize>> = HashMap::new();
643 for (index, package_table) in package_tables.iter().enumerate() {
644 if let Some(name) = package_table
645 .get(FIELD_NAME)
646 .and_then(TomlValue::as_str)
647 .map(normalize_pypi_name)
648 {
649 lookup.entry(name).or_default().push(index);
650 }
651 }
652 lookup
653}
654
655fn match_package_index(
656 package_tables: &[&TomlMap<String, TomlValue>],
657 package_lookup: &HashMap<String, Vec<usize>>,
658 name: &str,
659 source_key: Option<&str>,
660) -> Option<usize> {
661 let candidates = package_lookup.get(name)?;
662 if candidates.len() == 1 {
663 return candidates.first().copied();
664 }
665
666 let source_key = source_key?;
667 candidates.iter().copied().find(|index| {
668 package_tables
669 .get(*index)
670 .and_then(|table| package_source_table(table))
671 .and_then(source_table_key)
672 .as_deref()
673 == Some(source_key)
674 })
675}
676
677fn find_root_package_index(package_tables: &[&TomlMap<String, TomlValue>]) -> Option<usize> {
678 if let Some(index) = package_tables.iter().position(|table| {
679 package_source_table(table)
680 .and_then(local_source_path)
681 .is_some_and(|path| path == ".")
682 }) {
683 return Some(index);
684 }
685
686 package_tables.iter().position(|table| {
687 package_source_table(table)
688 .is_some_and(|source| source.contains_key("editable") || source.contains_key("virtual"))
689 })
690}
691
692fn local_source_path(source_table: &TomlMap<String, TomlValue>) -> Option<&str> {
693 source_table
694 .get("virtual")
695 .and_then(TomlValue::as_str)
696 .or_else(|| source_table.get("editable").and_then(TomlValue::as_str))
697}
698
699fn build_lock_extra_data(toml_content: &TomlValue) -> Option<HashMap<String, JsonValue>> {
700 let mut extra_data = HashMap::new();
701
702 if let Some(version) = toml_content
703 .get(FIELD_VERSION)
704 .and_then(TomlValue::as_integer)
705 {
706 extra_data.insert(
707 "lockfile_version".to_string(),
708 JsonValue::String(version.to_string()),
709 );
710 }
711
712 if let Some(revision) = toml_content
713 .get(FIELD_REVISION)
714 .and_then(TomlValue::as_integer)
715 {
716 extra_data.insert(
717 FIELD_REVISION.to_string(),
718 JsonValue::String(revision.to_string()),
719 );
720 }
721
722 if let Some(requires_python) = toml_content
723 .get(FIELD_REQUIRES_PYTHON)
724 .and_then(TomlValue::as_str)
725 {
726 extra_data.insert(
727 "requires_python".to_string(),
728 JsonValue::String(requires_python.to_string()),
729 );
730 }
731
732 if let Some(markers) = toml_content.get(FIELD_RESOLUTION_MARKERS) {
733 extra_data.insert(
734 FIELD_RESOLUTION_MARKERS.to_string(),
735 toml_value_to_json(markers),
736 );
737 }
738
739 if let Some(manifest) = toml_content.get(FIELD_MANIFEST) {
740 extra_data.insert(FIELD_MANIFEST.to_string(), toml_value_to_json(manifest));
741 }
742
743 (!extra_data.is_empty()).then_some(extra_data)
744}
745
746fn build_package_extra_data(
747 package_table: &TomlMap<String, TomlValue>,
748) -> Option<HashMap<String, JsonValue>> {
749 let mut extra_data = HashMap::new();
750
751 if let Some(source) = package_table.get(FIELD_SOURCE) {
752 extra_data.insert(FIELD_SOURCE.to_string(), toml_value_to_json(source));
753 }
754
755 if let Some(metadata) = package_table.get(FIELD_METADATA) {
756 extra_data.insert(FIELD_METADATA.to_string(), toml_value_to_json(metadata));
757 }
758
759 (!extra_data.is_empty()).then_some(extra_data)
760}
761
762fn extract_artifact_metadata(
763 package_table: &TomlMap<String, TomlValue>,
764) -> (Option<String>, Option<String>) {
765 if let Some(sdist_table) = package_table.get("sdist").and_then(TomlValue::as_table) {
766 let download_url = sdist_table
767 .get("url")
768 .and_then(TomlValue::as_str)
769 .map(|value| value.to_string());
770 let sha256 = sdist_table
771 .get("hash")
772 .and_then(TomlValue::as_str)
773 .and_then(strip_sha256_prefix);
774 if download_url.is_some() || sha256.is_some() {
775 return (download_url, sha256);
776 }
777 }
778
779 let wheel_table = package_table
780 .get("wheels")
781 .and_then(TomlValue::as_array)
782 .and_then(|wheels| wheels.first())
783 .and_then(TomlValue::as_table);
784
785 let download_url = wheel_table
786 .and_then(|table| table.get("url"))
787 .and_then(TomlValue::as_str)
788 .map(|value| value.to_string());
789 let sha256 = wheel_table
790 .and_then(|table| table.get("hash"))
791 .and_then(TomlValue::as_str)
792 .and_then(strip_sha256_prefix);
793
794 (download_url, sha256)
795}
796
797fn strip_sha256_prefix(value: &str) -> Option<String> {
798 value.strip_prefix("sha256:").map(|hash| hash.to_string())
799}
800
801fn package_source_table(
802 package_table: &TomlMap<String, TomlValue>,
803) -> Option<&TomlMap<String, TomlValue>> {
804 package_table
805 .get(FIELD_SOURCE)
806 .and_then(TomlValue::as_table)
807}
808
809fn source_table_key(source_table: &TomlMap<String, TomlValue>) -> Option<String> {
810 ["registry", "editable", "virtual", "git"]
811 .into_iter()
812 .find_map(|key| {
813 source_table
814 .get(key)
815 .and_then(TomlValue::as_str)
816 .map(|value| format!("{}:{}", key, value))
817 })
818}
819
820fn build_pypi_urls(
821 name: Option<&str>,
822 version: Option<&str>,
823) -> (
824 Option<String>,
825 Option<String>,
826 Option<String>,
827 Option<String>,
828) {
829 let repository_homepage_url = name.map(|value| format!("https://pypi.org/project/{}", value));
830
831 let repository_download_url = name.and_then(|value| {
832 version.map(|ver| {
833 format!(
834 "https://pypi.org/packages/source/{}/{}/{}-{}.tar.gz",
835 &value[..1.min(value.len())],
836 value,
837 value,
838 ver
839 )
840 })
841 });
842
843 let api_data_url = name.map(|value| {
844 if let Some(ver) = version {
845 format!("https://pypi.org/pypi/{}/{}/json", value, ver)
846 } else {
847 format!("https://pypi.org/pypi/{}/json", value)
848 }
849 });
850
851 let purl = name.and_then(|value| create_pypi_purl(value, version));
852
853 (
854 repository_homepage_url,
855 repository_download_url,
856 api_data_url,
857 purl,
858 )
859}
860
861fn normalize_pypi_name(name: &str) -> String {
862 name.trim().to_ascii_lowercase()
863}
864
865fn create_pypi_purl(name: &str, version: Option<&str>) -> Option<String> {
866 if name.contains('[') || name.contains(']') {
867 return Some(build_manual_pypi_purl(name, version));
868 }
869
870 if let Ok(mut purl) = PackageUrl::new(UvLockParser::PACKAGE_TYPE.as_str(), name) {
871 if let Some(version) = version
872 && purl.with_version(version).is_err()
873 {
874 return None;
875 }
876 return Some(purl.to_string());
877 }
878
879 Some(build_manual_pypi_purl(name, version))
880}
881
882fn build_manual_pypi_purl(name: &str, version: Option<&str>) -> String {
883 let encoded_name = name.replace('[', "%5b").replace(']', "%5d");
884 let mut purl = format!("pkg:pypi/{}", encoded_name);
885 if let Some(version) = version
886 && !version.is_empty()
887 {
888 purl.push('@');
889 purl.push_str(version);
890 }
891 purl
892}
893
894fn toml_value_to_json(value: &TomlValue) -> JsonValue {
895 match value {
896 TomlValue::String(value) => JsonValue::String(value.clone()),
897 TomlValue::Integer(value) => JsonValue::String(value.to_string()),
898 TomlValue::Float(value) => JsonValue::String(value.to_string()),
899 TomlValue::Boolean(value) => JsonValue::Bool(*value),
900 TomlValue::Datetime(value) => JsonValue::String(value.to_string()),
901 TomlValue::Array(values) => {
902 JsonValue::Array(values.iter().map(toml_value_to_json).collect())
903 }
904 TomlValue::Table(values) => JsonValue::Object(
905 values
906 .iter()
907 .map(|(key, value)| (key.clone(), toml_value_to_json(value)))
908 .collect(),
909 ),
910 }
911}
912
913fn default_package_data() -> PackageData {
914 PackageData {
915 package_type: Some(UvLockParser::PACKAGE_TYPE),
916 primary_language: Some("Python".to_string()),
917 datasource_id: Some(DatasourceId::PypiUvLock),
918 ..Default::default()
919 }
920}
921
922crate::register_parser!(
923 "uv lockfile",
924 &["**/uv.lock"],
925 "pypi",
926 "Python",
927 Some("https://docs.astral.sh/uv/concepts/projects/layout/"),
928);