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