Skip to main content

provenant/parsers/
pylock_toml.rs

1use std::collections::{HashMap, HashSet, VecDeque};
2use std::path::Path;
3
4use log::warn;
5use packageurl::PackageUrl;
6use regex::Regex;
7use serde_json::{Map as JsonMap, Value as JsonValue};
8use toml::Value as TomlValue;
9use toml::map::Map as TomlMap;
10
11use crate::models::{DatasourceId, Dependency, PackageData, PackageType, ResolvedPackage};
12use crate::parsers::python::read_toml_file;
13
14use super::PackageParser;
15
16const FIELD_LOCK_VERSION: &str = "lock-version";
17const FIELD_CREATED_BY: &str = "created-by";
18const SUPPORTED_LOCK_VERSION: &str = "1.0";
19const FIELD_REQUIRES_PYTHON: &str = "requires-python";
20const FIELD_ENVIRONMENTS: &str = "environments";
21const FIELD_EXTRAS: &str = "extras";
22const FIELD_DEPENDENCY_GROUPS: &str = "dependency-groups";
23const FIELD_DEFAULT_GROUPS: &str = "default-groups";
24const FIELD_PACKAGES: &str = "packages";
25const FIELD_NAME: &str = "name";
26const FIELD_VERSION: &str = "version";
27const FIELD_MARKER: &str = "marker";
28const FIELD_DEPENDENCIES: &str = "dependencies";
29const FIELD_INDEX: &str = "index";
30const FIELD_VCS: &str = "vcs";
31const FIELD_DIRECTORY: &str = "directory";
32const FIELD_ARCHIVE: &str = "archive";
33const FIELD_SDIST: &str = "sdist";
34const FIELD_WHEELS: &str = "wheels";
35const FIELD_HASHES: &str = "hashes";
36const FIELD_TOOL: &str = "tool";
37const FIELD_ATTESTATION_IDENTITIES: &str = "attestation-identities";
38
39pub struct PylockTomlParser;
40
41#[derive(Clone, Debug, Default)]
42struct MarkerClassification {
43    is_runtime: bool,
44    is_optional: bool,
45    scope: Option<String>,
46}
47
48struct DependencyAnalysisContext<'a> {
49    package_tables: &'a [&'a TomlMap<String, TomlValue>],
50    dependency_indices: &'a [Vec<usize>],
51    incoming_counts: &'a [usize],
52    root_classifications: &'a [MarkerClassification],
53    runtime_reachable: &'a HashSet<usize>,
54    optional_reachable: &'a HashSet<usize>,
55    scope_sets: &'a HashMap<String, HashSet<usize>>,
56}
57
58impl PackageParser for PylockTomlParser {
59    const PACKAGE_TYPE: PackageType = PackageType::Pypi;
60
61    fn is_match(path: &Path) -> bool {
62        let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
63            return false;
64        };
65
66        file_name == "pylock.toml"
67            || file_name
68                .strip_prefix("pylock.")
69                .and_then(|suffix| suffix.strip_suffix(".toml"))
70                .is_some_and(|middle| !middle.is_empty() && !middle.contains('.'))
71    }
72
73    fn extract_packages(path: &Path) -> Vec<PackageData> {
74        let toml_content = match read_toml_file(path) {
75            Ok(content) => content,
76            Err(e) => {
77                warn!("Failed to read pylock.toml at {:?}: {}", path, e);
78                return vec![default_package_data()];
79            }
80        };
81
82        vec![parse_pylock_toml(&toml_content)]
83    }
84}
85
86fn parse_pylock_toml(toml_content: &TomlValue) -> PackageData {
87    let lock_version = toml_content
88        .get(FIELD_LOCK_VERSION)
89        .and_then(TomlValue::as_str);
90    if lock_version != Some(SUPPORTED_LOCK_VERSION) {
91        warn!(
92            "Invalid pylock.toml: missing or unsupported lock-version {:?}",
93            lock_version
94        );
95        return default_package_data();
96    }
97
98    let created_by = toml_content
99        .get(FIELD_CREATED_BY)
100        .and_then(TomlValue::as_str);
101    if created_by.is_none() {
102        warn!("Invalid pylock.toml: missing required created-by field");
103        return default_package_data();
104    }
105
106    let Some(package_values) = toml_content
107        .get(FIELD_PACKAGES)
108        .and_then(TomlValue::as_array)
109    else {
110        warn!("Invalid pylock.toml: missing required packages array");
111        return default_package_data();
112    };
113
114    let package_tables: Vec<&TomlMap<String, TomlValue>> = package_values
115        .iter()
116        .filter_map(TomlValue::as_table)
117        .collect();
118    if package_tables.is_empty() {
119        warn!("Invalid pylock.toml: packages array does not contain package tables");
120        return default_package_data();
121    }
122
123    let dependency_indices = build_dependency_indices(&package_tables);
124    let incoming_counts = build_incoming_counts(package_tables.len(), &dependency_indices);
125    let default_groups = extract_string_set(toml_content, FIELD_DEFAULT_GROUPS);
126
127    let root_classifications: Vec<MarkerClassification> = package_tables
128        .iter()
129        .enumerate()
130        .map(|(index, table)| {
131            if incoming_counts[index] == 0 {
132                classify_marker(
133                    table.get(FIELD_MARKER).and_then(TomlValue::as_str),
134                    &default_groups,
135                )
136            } else {
137                MarkerClassification::default()
138            }
139        })
140        .collect();
141
142    let runtime_roots: Vec<usize> = root_classifications
143        .iter()
144        .enumerate()
145        .filter_map(|(index, info)| {
146            (incoming_counts[index] == 0 && info.is_runtime).then_some(index)
147        })
148        .collect();
149    let optional_roots: Vec<usize> = root_classifications
150        .iter()
151        .enumerate()
152        .filter_map(|(index, info)| {
153            (incoming_counts[index] == 0 && info.is_optional).then_some(index)
154        })
155        .collect();
156
157    let runtime_reachable = collect_reachable_indices(&dependency_indices, &runtime_roots);
158    let optional_reachable = collect_reachable_indices(&dependency_indices, &optional_roots);
159
160    let mut scope_sets: HashMap<String, HashSet<usize>> = HashMap::new();
161    for (index, info) in root_classifications.iter().enumerate() {
162        if incoming_counts[index] != 0 {
163            continue;
164        }
165
166        if let Some(scope) = info.scope.as_ref() {
167            scope_sets.insert(
168                scope.clone(),
169                collect_reachable_indices(&dependency_indices, &[index]),
170            );
171        }
172    }
173
174    let analysis = DependencyAnalysisContext {
175        package_tables: &package_tables,
176        dependency_indices: &dependency_indices,
177        incoming_counts: &incoming_counts,
178        root_classifications: &root_classifications,
179        runtime_reachable: &runtime_reachable,
180        optional_reachable: &optional_reachable,
181        scope_sets: &scope_sets,
182    };
183
184    let mut package_data = default_package_data();
185    package_data.extra_data = build_lock_extra_data(toml_content);
186    package_data.dependencies = package_tables
187        .iter()
188        .enumerate()
189        .filter_map(|(index, package_table)| {
190            build_top_level_dependency(index, package_table, &analysis)
191        })
192        .collect();
193
194    package_data
195}
196
197fn build_top_level_dependency(
198    index: usize,
199    package_table: &TomlMap<String, TomlValue>,
200    analysis: &DependencyAnalysisContext<'_>,
201) -> Option<Dependency> {
202    let name = normalized_package_name(package_table)?;
203    let version = package_version(package_table);
204    let direct = analysis
205        .incoming_counts
206        .get(index)
207        .copied()
208        .unwrap_or_default()
209        == 0;
210
211    let (is_runtime, is_optional, scope) = if direct {
212        let classification = analysis
213            .root_classifications
214            .get(index)
215            .cloned()
216            .unwrap_or_default();
217        (
218            classification.is_runtime,
219            classification.is_optional,
220            classification.scope,
221        )
222    } else {
223        let is_runtime = analysis.runtime_reachable.contains(&index);
224        let is_optional = !is_runtime && analysis.optional_reachable.contains(&index);
225        let scope = scope_for_index(analysis.scope_sets, index);
226        (is_runtime, is_optional, scope)
227    };
228
229    Some(Dependency {
230        purl: create_pypi_purl(&name, version.as_deref()),
231        extracted_requirement: None,
232        scope,
233        is_runtime: Some(is_runtime),
234        is_optional: Some(is_optional),
235        is_pinned: Some(is_package_pinned(package_table)),
236        is_direct: Some(direct),
237        resolved_package: Some(Box::new(build_resolved_package(
238            package_table,
239            analysis.package_tables,
240            analysis
241                .dependency_indices
242                .get(index)
243                .map(Vec::as_slice)
244                .unwrap_or(&[]),
245        ))),
246        extra_data: build_package_extra_data(package_table),
247    })
248}
249
250fn build_resolved_package(
251    package_table: &TomlMap<String, TomlValue>,
252    package_tables: &[&TomlMap<String, TomlValue>],
253    dependency_indices: &[usize],
254) -> ResolvedPackage {
255    let name = normalized_package_name(package_table).unwrap_or_default();
256    let version = package_version(package_table).unwrap_or_default();
257    let (_, repository_download_url, api_data_url, purl) = build_pypi_urls(
258        Some(&name),
259        (!version.is_empty()).then_some(version.as_str()),
260    );
261    let repository_homepage_url = Some(format!("https://pypi.org/project/{}", name));
262    let (download_url, sha256, sha512, md5) = extract_artifact_metadata(package_table);
263
264    ResolvedPackage {
265        package_type: PylockTomlParser::PACKAGE_TYPE,
266        namespace: String::new(),
267        name,
268        version,
269        primary_language: Some("Python".to_string()),
270        download_url,
271        sha1: None,
272        sha256,
273        sha512,
274        md5,
275        is_virtual: false,
276        extra_data: build_package_extra_data(package_table),
277        dependencies: dependency_indices
278            .iter()
279            .filter_map(|child_index| package_tables.get(*child_index))
280            .filter_map(|child| build_resolved_dependency(child))
281            .collect(),
282        repository_homepage_url,
283        repository_download_url,
284        api_data_url,
285        datasource_id: Some(DatasourceId::PypiPylockToml),
286        purl,
287    }
288}
289
290fn build_resolved_dependency(package_table: &TomlMap<String, TomlValue>) -> Option<Dependency> {
291    let name = normalized_package_name(package_table)?;
292    let version = package_version(package_table);
293
294    Some(Dependency {
295        purl: create_pypi_purl(&name, version.as_deref()),
296        extracted_requirement: None,
297        scope: None,
298        is_runtime: None,
299        is_optional: None,
300        is_pinned: Some(is_package_pinned(package_table)),
301        is_direct: Some(true),
302        resolved_package: None,
303        extra_data: build_package_extra_data(package_table),
304    })
305}
306
307fn build_dependency_indices(package_tables: &[&TomlMap<String, TomlValue>]) -> Vec<Vec<usize>> {
308    package_tables
309        .iter()
310        .map(|package_table| {
311            package_table
312                .get(FIELD_DEPENDENCIES)
313                .and_then(TomlValue::as_array)
314                .into_iter()
315                .flatten()
316                .filter_map(TomlValue::as_table)
317                .flat_map(|reference| {
318                    resolve_dependency_reference_indices(package_tables, reference)
319                })
320                .collect()
321        })
322        .collect()
323}
324
325fn resolve_dependency_reference_indices(
326    package_tables: &[&TomlMap<String, TomlValue>],
327    reference: &TomlMap<String, TomlValue>,
328) -> Vec<usize> {
329    let matches: Vec<usize> = package_tables
330        .iter()
331        .enumerate()
332        .filter_map(|(index, package_table)| {
333            package_reference_matches(package_table, reference).then_some(index)
334        })
335        .collect();
336
337    if matches.len() == 1 {
338        matches
339    } else {
340        Vec::new()
341    }
342}
343
344fn package_reference_matches(
345    package_table: &TomlMap<String, TomlValue>,
346    reference: &TomlMap<String, TomlValue>,
347) -> bool {
348    reference.iter().all(|(key, ref_value)| {
349        package_table
350            .get(key)
351            .is_some_and(|pkg_value| toml_values_match(pkg_value, ref_value))
352    })
353}
354
355fn toml_values_match(left: &TomlValue, right: &TomlValue) -> bool {
356    match (left, right) {
357        (TomlValue::String(left), TomlValue::String(right)) => left == right,
358        (TomlValue::Integer(left), TomlValue::Integer(right)) => left == right,
359        (TomlValue::Float(left), TomlValue::Float(right)) => left == right,
360        (TomlValue::Boolean(left), TomlValue::Boolean(right)) => left == right,
361        (TomlValue::Datetime(left), TomlValue::Datetime(right)) => left == right,
362        (TomlValue::Array(left), TomlValue::Array(right)) => {
363            left.len() == right.len()
364                && left
365                    .iter()
366                    .zip(right.iter())
367                    .all(|(left, right)| toml_values_match(left, right))
368        }
369        (TomlValue::Table(left), TomlValue::Table(right)) => {
370            right.iter().all(|(key, right_value)| {
371                left.get(key)
372                    .is_some_and(|left_value| toml_values_match(left_value, right_value))
373            })
374        }
375        _ => false,
376    }
377}
378
379fn build_incoming_counts(package_count: usize, dependency_indices: &[Vec<usize>]) -> Vec<usize> {
380    let mut incoming = vec![0; package_count];
381    for dependency_list in dependency_indices {
382        for &child_index in dependency_list {
383            if let Some(count) = incoming.get_mut(child_index) {
384                *count += 1;
385            }
386        }
387    }
388    incoming
389}
390
391fn collect_reachable_indices(dependency_indices: &[Vec<usize>], roots: &[usize]) -> HashSet<usize> {
392    let mut visited = HashSet::new();
393    let mut queue: VecDeque<usize> = roots.iter().copied().collect();
394
395    while let Some(index) = queue.pop_front() {
396        if !visited.insert(index) {
397            continue;
398        }
399
400        for &child_index in dependency_indices.get(index).into_iter().flatten() {
401            queue.push_back(child_index);
402        }
403    }
404
405    visited
406}
407
408fn classify_marker(marker: Option<&str>, default_groups: &HashSet<String>) -> MarkerClassification {
409    let Some(marker) = marker else {
410        return MarkerClassification {
411            is_runtime: true,
412            is_optional: false,
413            scope: None,
414        };
415    };
416
417    let extras = extract_marker_memberships(marker, "extras");
418    if !extras.is_empty() {
419        return MarkerClassification {
420            is_runtime: false,
421            is_optional: true,
422            scope: single_scope(extras),
423        };
424    }
425
426    let groups = extract_marker_memberships(marker, "dependency_groups");
427    let non_default_groups: Vec<String> = groups
428        .into_iter()
429        .filter(|group| !default_groups.contains(group))
430        .collect();
431    if !non_default_groups.is_empty() {
432        return MarkerClassification {
433            is_runtime: false,
434            is_optional: false,
435            scope: single_scope(non_default_groups),
436        };
437    }
438
439    MarkerClassification {
440        is_runtime: true,
441        is_optional: false,
442        scope: None,
443    }
444}
445
446fn extract_marker_memberships(marker: &str, variable_name: &str) -> Vec<String> {
447    let pattern = format!(
448        r#"['\"]([^'\"]+)['\"]\s+in\s+{}\b"#,
449        regex::escape(variable_name)
450    );
451    let Ok(regex) = Regex::new(&pattern) else {
452        return Vec::new();
453    };
454
455    let mut memberships: Vec<String> = regex
456        .captures_iter(marker)
457        .filter_map(|captures| {
458            captures
459                .get(1)
460                .map(|value| value.as_str().trim().to_string())
461        })
462        .filter(|value| !value.is_empty())
463        .collect();
464    memberships.sort();
465    memberships.dedup();
466    memberships
467}
468
469fn single_scope(values: Vec<String>) -> Option<String> {
470    (values.len() == 1).then(|| values[0].clone())
471}
472
473fn scope_for_index(scope_sets: &HashMap<String, HashSet<usize>>, index: usize) -> Option<String> {
474    let matches: Vec<String> = scope_sets
475        .iter()
476        .filter_map(|(scope, indices)| indices.contains(&index).then_some(scope.clone()))
477        .collect();
478    single_scope(matches)
479}
480
481fn normalized_package_name(package_table: &TomlMap<String, TomlValue>) -> Option<String> {
482    package_table
483        .get(FIELD_NAME)
484        .and_then(TomlValue::as_str)
485        .map(|value| value.trim().to_ascii_lowercase())
486}
487
488fn package_version(package_table: &TomlMap<String, TomlValue>) -> Option<String> {
489    package_table
490        .get(FIELD_VERSION)
491        .and_then(TomlValue::as_str)
492        .map(|value| value.to_string())
493}
494
495fn is_package_pinned(package_table: &TomlMap<String, TomlValue>) -> bool {
496    package_table.contains_key(FIELD_VERSION)
497        || package_table
498            .get(FIELD_VCS)
499            .and_then(TomlValue::as_table)
500            .is_some_and(|table| table.contains_key("commit-id"))
501        || has_hashes(package_table.get(FIELD_ARCHIVE))
502        || has_hashes(package_table.get(FIELD_SDIST))
503        || package_table
504            .get(FIELD_WHEELS)
505            .and_then(TomlValue::as_array)
506            .into_iter()
507            .flatten()
508            .filter_map(TomlValue::as_table)
509            .any(|wheel| wheel.contains_key(FIELD_HASHES))
510}
511
512fn has_hashes(value: Option<&TomlValue>) -> bool {
513    value
514        .and_then(TomlValue::as_table)
515        .is_some_and(|table| table.contains_key(FIELD_HASHES))
516}
517
518fn build_lock_extra_data(toml_content: &TomlValue) -> Option<HashMap<String, JsonValue>> {
519    let mut extra_data = HashMap::new();
520
521    for (source_key, target_key) in [
522        (FIELD_LOCK_VERSION, "lock_version"),
523        (FIELD_CREATED_BY, "created_by"),
524        (FIELD_REQUIRES_PYTHON, "requires_python"),
525        (FIELD_ENVIRONMENTS, FIELD_ENVIRONMENTS),
526        (FIELD_EXTRAS, FIELD_EXTRAS),
527        (FIELD_DEPENDENCY_GROUPS, FIELD_DEPENDENCY_GROUPS),
528        (FIELD_DEFAULT_GROUPS, FIELD_DEFAULT_GROUPS),
529    ] {
530        if let Some(value) = toml_content.get(source_key) {
531            extra_data.insert(target_key.to_string(), toml_value_to_json(value));
532        }
533    }
534
535    if let Some(tool) = toml_content.get(FIELD_TOOL) {
536        extra_data.insert(FIELD_TOOL.to_string(), toml_value_to_json(tool));
537    }
538
539    (!extra_data.is_empty()).then_some(extra_data)
540}
541
542fn build_package_extra_data(
543    package_table: &TomlMap<String, TomlValue>,
544) -> Option<HashMap<String, JsonValue>> {
545    let mut extra_data = HashMap::new();
546
547    for key in [
548        FIELD_MARKER,
549        FIELD_REQUIRES_PYTHON,
550        FIELD_INDEX,
551        FIELD_VCS,
552        FIELD_DIRECTORY,
553        FIELD_ARCHIVE,
554        FIELD_SDIST,
555        FIELD_WHEELS,
556        FIELD_TOOL,
557        FIELD_ATTESTATION_IDENTITIES,
558    ] {
559        if let Some(value) = package_table.get(key) {
560            extra_data.insert(key.to_string(), toml_value_to_json(value));
561        }
562    }
563
564    (!extra_data.is_empty()).then_some(extra_data)
565}
566
567fn extract_artifact_metadata(
568    package_table: &TomlMap<String, TomlValue>,
569) -> (
570    Option<String>,
571    Option<String>,
572    Option<String>,
573    Option<String>,
574) {
575    if let Some(archive_table) = package_table
576        .get(FIELD_ARCHIVE)
577        .and_then(TomlValue::as_table)
578    {
579        return (
580            archive_table
581                .get("url")
582                .and_then(TomlValue::as_str)
583                .map(|value| value.to_string())
584                .or_else(|| {
585                    archive_table
586                        .get("path")
587                        .and_then(TomlValue::as_str)
588                        .map(|value| value.to_string())
589                }),
590            extract_hash_by_name(archive_table, "sha256"),
591            extract_hash_by_name(archive_table, "sha512"),
592            extract_hash_by_name(archive_table, "md5"),
593        );
594    }
595
596    if let Some(sdist_table) = package_table.get(FIELD_SDIST).and_then(TomlValue::as_table) {
597        return (
598            sdist_table
599                .get("url")
600                .and_then(TomlValue::as_str)
601                .map(|value| value.to_string())
602                .or_else(|| {
603                    sdist_table
604                        .get("path")
605                        .and_then(TomlValue::as_str)
606                        .map(|value| value.to_string())
607                }),
608            extract_hash_by_name(sdist_table, "sha256"),
609            extract_hash_by_name(sdist_table, "sha512"),
610            extract_hash_by_name(sdist_table, "md5"),
611        );
612    }
613
614    let wheel_table = package_table
615        .get(FIELD_WHEELS)
616        .and_then(TomlValue::as_array)
617        .and_then(|wheels| wheels.first())
618        .and_then(TomlValue::as_table);
619
620    (
621        wheel_table
622            .and_then(|table| table.get("url"))
623            .and_then(TomlValue::as_str)
624            .map(|value| value.to_string())
625            .or_else(|| {
626                wheel_table
627                    .and_then(|table| table.get("path"))
628                    .and_then(TomlValue::as_str)
629                    .map(|value| value.to_string())
630            }),
631        wheel_table.and_then(|table| extract_hash_by_name(table, "sha256")),
632        wheel_table.and_then(|table| extract_hash_by_name(table, "sha512")),
633        wheel_table.and_then(|table| extract_hash_by_name(table, "md5")),
634    )
635}
636
637fn extract_hash_by_name(table: &TomlMap<String, TomlValue>, name: &str) -> Option<String> {
638    table
639        .get(FIELD_HASHES)
640        .and_then(TomlValue::as_table)
641        .and_then(|hashes| hashes.get(name))
642        .and_then(TomlValue::as_str)
643        .map(|value| value.to_string())
644}
645
646fn extract_string_set(toml_content: &TomlValue, key: &str) -> HashSet<String> {
647    toml_content
648        .get(key)
649        .and_then(TomlValue::as_array)
650        .into_iter()
651        .flatten()
652        .filter_map(TomlValue::as_str)
653        .map(|value| value.to_string())
654        .collect()
655}
656
657fn build_pypi_urls(
658    name: Option<&str>,
659    version: Option<&str>,
660) -> (
661    Option<String>,
662    Option<String>,
663    Option<String>,
664    Option<String>,
665) {
666    let repository_homepage_url = name.map(|value| format!("https://pypi.org/project/{}", value));
667    let repository_download_url = name.and_then(|value| {
668        version.map(|ver| {
669            format!(
670                "https://pypi.org/packages/source/{}/{}/{}-{}.tar.gz",
671                &value[..1.min(value.len())],
672                value,
673                value,
674                ver
675            )
676        })
677    });
678    let api_data_url = name.map(|value| {
679        if let Some(ver) = version {
680            format!("https://pypi.org/pypi/{}/{}/json", value, ver)
681        } else {
682            format!("https://pypi.org/pypi/{}/json", value)
683        }
684    });
685    let purl = name.and_then(|value| create_pypi_purl(value, version));
686
687    (
688        repository_homepage_url,
689        repository_download_url,
690        api_data_url,
691        purl,
692    )
693}
694
695fn create_pypi_purl(name: &str, version: Option<&str>) -> Option<String> {
696    if let Ok(mut purl) = PackageUrl::new(PylockTomlParser::PACKAGE_TYPE.as_str(), name) {
697        if let Some(version) = version
698            && purl.with_version(version).is_err()
699        {
700            return None;
701        }
702        return Some(purl.to_string());
703    }
704
705    let mut purl = format!("pkg:pypi/{}", name);
706    if let Some(version) = version
707        && !version.is_empty()
708    {
709        purl.push('@');
710        purl.push_str(version);
711    }
712    Some(purl)
713}
714
715fn toml_value_to_json(value: &TomlValue) -> JsonValue {
716    match value {
717        TomlValue::String(value) => JsonValue::String(value.clone()),
718        TomlValue::Integer(value) => JsonValue::String(value.to_string()),
719        TomlValue::Float(value) => JsonValue::String(value.to_string()),
720        TomlValue::Boolean(value) => JsonValue::Bool(*value),
721        TomlValue::Datetime(value) => JsonValue::String(value.to_string()),
722        TomlValue::Array(values) => {
723            JsonValue::Array(values.iter().map(toml_value_to_json).collect())
724        }
725        TomlValue::Table(values) => JsonValue::Object(
726            values
727                .iter()
728                .map(|(key, value)| (key.clone(), toml_value_to_json(value)))
729                .collect::<JsonMap<String, JsonValue>>(),
730        ),
731    }
732}
733
734fn default_package_data() -> PackageData {
735    PackageData {
736        package_type: Some(PylockTomlParser::PACKAGE_TYPE),
737        primary_language: Some("Python".to_string()),
738        datasource_id: Some(DatasourceId::PypiPylockToml),
739        ..Default::default()
740    }
741}
742
743crate::register_parser!(
744    "pylock.toml lockfile",
745    &["**/pylock.toml", "**/pylock.*.toml"],
746    "pypi",
747    "Python",
748    Some("https://packaging.python.org/en/latest/specifications/pylock-toml/"),
749);