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