Skip to main content

provenant/parsers/
requirements_txt.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// SPDX-FileCopyrightText: Provenant contributors
3// SPDX-License-Identifier: Apache-2.0
4// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
5
6//! Parser for pip requirements.txt files.
7//!
8//! Extracts Python package dependencies from requirements.txt files using PEP 508
9//! specification parsing with support for includes, environment markers, and URLs.
10//!
11//! # Supported Formats
12//! - requirements.txt (pip dependency specification files)
13//! - Supports includes: `-r requirements.txt`, `-c constraints.txt`
14//! - Supports markers: `package; python_version >= '3.6'`
15//! - Supports VCS refs: `git+https://...`, `git+ssh://...`
16//!
17//! # Key Features
18//! - PEP 508 requirement parsing with environment marker evaluation
19//! - Recursive file inclusion support (`-r` and `-c` directives)
20//! - VCS/URL dependency detection and handling
21//! - Package URL (purl) generation for PyPI packages
22//! - Line comment handling and continuation lines
23//!
24//! # Implementation Notes
25//! - Uses PEP 508 parser from `pep508` module
26//! - Recursively resolves included files relative to parent file
27//! - Comments (lines starting with `#`) are skipped
28//! - Environment markers are preserved for dependency filtering
29
30use std::collections::HashMap;
31use std::path::{Path, PathBuf};
32
33use crate::parser_warn as warn;
34use packageurl::PackageUrl;
35use serde_json::Value as JsonValue;
36
37use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
38use crate::parsers::pep508::{Pep508Requirement, parse_pep508_requirement};
39use crate::parsers::utils::{
40    MAX_ITERATION_COUNT, MAX_RECURSION_DEPTH, RecursionGuard, read_file_to_string, truncate_field,
41};
42
43use super::PackageParser;
44
45/// pip requirements.txt parser supporting PEP 508 dependency specifications.
46///
47/// Handles requirements.txt files with -r/-c includes, environment markers,
48/// and VCS/URL references. Recursively resolves included requirement files.
49pub struct RequirementsTxtParser;
50
51impl PackageParser for RequirementsTxtParser {
52    const PACKAGE_TYPE: PackageType = PackageType::Pypi;
53
54    fn extract_packages(path: &Path) -> Vec<PackageData> {
55        vec![extract_from_requirements_txt(path)]
56    }
57
58    fn is_match(path: &Path) -> bool {
59        let filename = path.file_name().and_then(|name| name.to_str());
60        let Some(name) = filename else {
61            return false;
62        };
63
64        is_requirements_txt_filename(name)
65            || (is_requirements_like_extension(name) && has_requirements_like_ancestor(path))
66    }
67
68    fn metadata() -> Vec<super::metadata::ParserMetadata> {
69        vec![super::metadata::ParserMetadata {
70            description: "pip requirements file",
71            file_patterns: &[
72                "**/requirements*.txt",
73                "**/*requirements.txt",
74                "**/reqs.txt",
75                "**/minreqs.txt",
76                "**/*-reqs.txt",
77                "**/*_reqs.txt",
78                "**/*.reqs.txt",
79                "**/*-minreqs.txt",
80                "**/*_minreqs.txt",
81                "**/*.minreqs.txt",
82                "**/requirements*.in",
83                "**/*requirements.in",
84                "**/requires.txt",
85                "**/requirements/*.txt",
86                "**/requirements/*.in",
87                "**/requirements/**/*.txt",
88                "**/requirements/**/*.in",
89                "**/requirements*/*.txt",
90                "**/requirements*/*.in",
91                "**/requirements*/**/*.txt",
92                "**/requirements*/**/*.in",
93            ],
94            package_type: "pypi",
95            primary_language: "Python",
96            documentation_url: Some(
97                "https://pip.pypa.io/en/latest/reference/requirements-file-format/",
98            ),
99        }]
100    }
101}
102
103fn is_requirements_txt_filename(name: &str) -> bool {
104    if name == "requirements.txt" || name == "requires.txt" {
105        return true;
106    }
107
108    let (stem, extension) = if let Some(stem) = name.strip_suffix(".txt") {
109        (stem, "txt")
110    } else if let Some(stem) = name.strip_suffix(".in") {
111        (stem, "in")
112    } else {
113        return false;
114    };
115
116    // Keep parity with ScanCode's documented *reqs.txt support while avoiding
117    // extending that broader alias to .in files or unrelated stems such as
118    // `prereqs.txt` that only happen to end with the same letters.
119    stem == "requirements"
120        || stem.starts_with("requirements")
121        || stem.ends_with("requirements")
122        || (extension == "txt" && is_reqs_alias_stem(stem))
123}
124
125fn is_reqs_alias_stem(stem: &str) -> bool {
126    matches_requirement_alias_stem(stem, "reqs") || matches_requirement_alias_stem(stem, "minreqs")
127}
128
129fn matches_requirement_alias_stem(stem: &str, alias: &str) -> bool {
130    stem == alias
131        || stem
132            .strip_suffix(alias)
133            .is_some_and(|prefix| matches!(prefix.chars().last(), Some('-' | '_' | '.')))
134}
135
136fn is_requirements_like_extension(name: &str) -> bool {
137    name.ends_with(".txt") || name.ends_with(".in")
138}
139
140fn has_requirements_like_ancestor(path: &Path) -> bool {
141    path.parent()
142        .into_iter()
143        .flat_map(Path::ancestors)
144        .filter_map(|ancestor| ancestor.file_name())
145        .filter_map(|name| name.to_str())
146        .any(is_requirements_like_dir_name)
147}
148
149fn is_requirements_like_dir_name(name: &str) -> bool {
150    name == "requirements" || name.starts_with("requirements") || name.ends_with("requirements")
151}
152
153struct ParseState {
154    dependencies: Vec<Dependency>,
155    extra_index_urls: Vec<String>,
156    index_url: Option<String>,
157    includes: Vec<String>,
158    constraints: Vec<String>,
159    guard: RecursionGuard<PathBuf>,
160}
161
162fn extract_from_requirements_txt(path: &Path) -> PackageData {
163    let mut state = ParseState {
164        dependencies: Vec::new(),
165        extra_index_urls: Vec::new(),
166        index_url: None,
167        includes: Vec::new(),
168        constraints: Vec::new(),
169        guard: RecursionGuard::new(),
170    };
171
172    let (scope, is_runtime) = scope_from_filename(path);
173
174    parse_requirements_with_includes(path, &mut state, &scope, is_runtime);
175
176    let mut extra_data = HashMap::new();
177    if let Some(url) = state.index_url {
178        extra_data.insert(
179            "index_url".to_string(),
180            JsonValue::String(truncate_field(url)),
181        );
182    }
183    if !state.extra_index_urls.is_empty() {
184        extra_data.insert(
185            "extra_index_urls".to_string(),
186            JsonValue::Array(
187                state
188                    .extra_index_urls
189                    .into_iter()
190                    .map(|u| JsonValue::String(truncate_field(u)))
191                    .collect(),
192            ),
193        );
194    }
195    if !state.includes.is_empty() {
196        extra_data.insert(
197            "requirements_includes".to_string(),
198            JsonValue::Array(
199                state
200                    .includes
201                    .into_iter()
202                    .map(|i| JsonValue::String(truncate_field(i)))
203                    .collect(),
204            ),
205        );
206    }
207    if !state.constraints.is_empty() {
208        extra_data.insert(
209            "constraints".to_string(),
210            JsonValue::Array(
211                state
212                    .constraints
213                    .into_iter()
214                    .map(|c| JsonValue::String(truncate_field(c)))
215                    .collect(),
216            ),
217        );
218    }
219
220    let extra_data = if extra_data.is_empty() {
221        None
222    } else {
223        Some(extra_data)
224    };
225
226    default_package_data(state.dependencies, extra_data)
227}
228
229fn parse_requirements_with_includes(
230    path: &Path,
231    state: &mut ParseState,
232    scope: &str,
233    is_runtime: bool,
234) {
235    if state.guard.exceeded() {
236        warn!(
237            "Maximum recursion depth ({}) exceeded for include: {:?}",
238            MAX_RECURSION_DEPTH, path
239        );
240        return;
241    }
242
243    let abs_path = match path.canonicalize() {
244        Ok(p) => p,
245        Err(_) => {
246            warn!("Cannot resolve path: {:?}", path);
247            return;
248        }
249    };
250
251    if state.guard.enter(abs_path.clone()) {
252        warn!("Circular include detected: {:?}", path);
253        return;
254    }
255
256    let content = match read_file_to_string(&abs_path, None) {
257        Ok(c) => c,
258        Err(e) => {
259            warn!("Cannot read file {:?}: {}", abs_path, e);
260            return;
261        }
262    };
263
264    for line in collect_logical_lines(&content)
265        .into_iter()
266        .take(MAX_ITERATION_COUNT)
267    {
268        let cleaned = strip_inline_comment(&line);
269        let trimmed = cleaned.trim();
270        if trimmed.is_empty() || trimmed.starts_with('#') {
271            continue;
272        }
273
274        if let Some(url) = parse_option_value(trimmed, "--extra-index-url") {
275            state.extra_index_urls.push(truncate_field(url));
276            continue;
277        }
278
279        if let Some(url) = parse_option_value(trimmed, "--index-url") {
280            state.index_url = Some(truncate_field(url));
281            continue;
282        }
283
284        if let Some(path_value) = parse_option_value(trimmed, "-r")
285            .or_else(|| parse_option_value(trimmed, "--requirement"))
286        {
287            state.includes.push(truncate_field(path_value.clone()));
288            let included_path = abs_path
289                .parent()
290                .unwrap_or_else(|| Path::new("."))
291                .join(&path_value);
292
293            if included_path.exists() {
294                parse_requirements_with_includes(&included_path, state, scope, is_runtime);
295            } else {
296                warn!("Included file not found: {:?}", included_path);
297            }
298            continue;
299        }
300
301        if let Some(path_value) = parse_option_value(trimmed, "-c")
302            .or_else(|| parse_option_value(trimmed, "--constraint"))
303        {
304            state.constraints.push(truncate_field(path_value.clone()));
305            let constraint_path = abs_path
306                .parent()
307                .unwrap_or_else(|| Path::new("."))
308                .join(&path_value);
309
310            if constraint_path.exists() {
311                parse_requirements_with_includes(&constraint_path, state, scope, is_runtime);
312            } else {
313                warn!("Constraint file not found: {:?}", constraint_path);
314            }
315            continue;
316        }
317
318        if trimmed.starts_with('-')
319            && !trimmed.starts_with("-e")
320            && !trimmed.starts_with("--editable")
321        {
322            continue;
323        }
324
325        if let Some(dependency) = build_dependency(trimmed, scope, is_runtime) {
326            if state.dependencies.len() >= MAX_ITERATION_COUNT {
327                warn!(
328                    "Reached maximum dependency count ({}) in {:?}",
329                    MAX_ITERATION_COUNT, abs_path
330                );
331                break;
332            }
333            state.dependencies.push(dependency);
334        }
335    }
336
337    state.guard.leave(abs_path);
338}
339
340fn default_package_data(
341    dependencies: Vec<Dependency>,
342    extra_data: Option<HashMap<String, JsonValue>>,
343) -> PackageData {
344    PackageData {
345        package_type: Some(RequirementsTxtParser::PACKAGE_TYPE),
346        primary_language: Some("Python".to_string()),
347        extra_data,
348        dependencies,
349        datasource_id: Some(DatasourceId::PipRequirements),
350        ..Default::default()
351    }
352}
353
354fn collect_logical_lines(content: &str) -> Vec<String> {
355    let mut lines = Vec::new();
356    let mut current = String::new();
357
358    for raw_line in content.lines().take(MAX_ITERATION_COUNT) {
359        let line = raw_line.trim_end_matches('\r');
360        let trimmed = line.trim_end();
361        let is_continuation = trimmed.ends_with('\\');
362        let line_without = if is_continuation {
363            trimmed.trim_end_matches('\\')
364        } else {
365            line
366        };
367
368        if !line_without.trim().is_empty() {
369            if !current.is_empty() {
370                current.push(' ');
371            }
372            current.push_str(line_without.trim());
373        }
374
375        if !is_continuation && !current.is_empty() {
376            lines.push(current.trim().to_string());
377            current.clear();
378        }
379    }
380
381    if !current.is_empty() {
382        lines.push(current.trim().to_string());
383    }
384
385    lines
386}
387
388fn strip_inline_comment(line: &str) -> String {
389    let mut in_single = false;
390    let mut in_double = false;
391    for (idx, ch) in line.char_indices() {
392        match ch {
393            '\'' if !in_double => in_single = !in_single,
394            '"' if !in_single => in_double = !in_double,
395            '#' if !in_single && !in_double => {
396                let prefix = &line[..idx];
397                if prefix.trim_end().is_empty() || prefix.ends_with(char::is_whitespace) {
398                    return prefix.trim_end().to_string();
399                }
400            }
401            _ => {}
402        }
403    }
404    line.to_string()
405}
406
407fn parse_option_value(line: &str, option: &str) -> Option<String> {
408    let stripped = line.strip_prefix(option)?;
409    let mut rest = stripped.trim();
410    if let Some(rest_stripped) = rest.strip_prefix('=') {
411        rest = rest_stripped.trim();
412    }
413    if rest.is_empty() {
414        None
415    } else {
416        Some(rest.to_string())
417    }
418}
419
420fn scope_from_filename(path: &Path) -> (String, bool) {
421    let filename = path
422        .file_name()
423        .and_then(|name| name.to_str())
424        .unwrap_or_default()
425        .to_ascii_lowercase();
426
427    if filename.contains("dev") {
428        return ("develop".to_string(), false);
429    }
430    if filename.contains("test") {
431        return ("test".to_string(), false);
432    }
433    if filename.contains("doc") {
434        return ("docs".to_string(), false);
435    }
436
437    ("install".to_string(), true)
438}
439
440fn build_dependency(line: &str, scope: &str, is_runtime: bool) -> Option<Dependency> {
441    let trimmed = line.trim();
442    if trimmed.is_empty() {
443        return None;
444    }
445
446    let mut is_editable = false;
447    let mut requirement = truncate_field(trimmed.to_string());
448    let mut extracted_requirement = truncate_field(trimmed.to_string());
449
450    if let Some(rest) = trimmed.strip_prefix("-e") {
451        is_editable = true;
452        requirement = truncate_field(rest.trim().to_string());
453        extracted_requirement = truncate_field(format!("--editable {}", requirement));
454    } else if let Some(rest) = trimmed.strip_prefix("--editable") {
455        is_editable = true;
456        requirement = truncate_field(rest.trim().to_string());
457        extracted_requirement = truncate_field(format!("--editable {}", requirement));
458    }
459
460    let (requirement, hash_options) = split_hash_options(&requirement);
461    let requirement = requirement.trim();
462    if requirement.is_empty() {
463        return None;
464    }
465
466    if looks_like_hash_only_requirement(requirement) {
467        return None;
468    }
469
470    let parsed = parse_requirement(requirement);
471
472    let pinned_version = parsed
473        .specifiers
474        .as_deref()
475        .and_then(extract_pinned_version);
476    let is_pinned = pinned_version.is_some();
477
478    let purl = parsed
479        .name
480        .as_ref()
481        .and_then(|name| create_pypi_purl(name, pinned_version.as_deref()));
482
483    let mut extra_data = HashMap::new();
484    extra_data.insert("is_editable".to_string(), JsonValue::Bool(is_editable));
485    extra_data.insert(
486        "link".to_string(),
487        parsed
488            .link
489            .clone()
490            .map(|l| JsonValue::String(truncate_field(l)))
491            .unwrap_or(JsonValue::Null),
492    );
493    extra_data.insert(
494        "hash_options".to_string(),
495        JsonValue::Array(
496            hash_options
497                .into_iter()
498                .map(|h| JsonValue::String(truncate_field(h)))
499                .collect(),
500        ),
501    );
502    extra_data.insert("is_constraint".to_string(), JsonValue::Bool(false));
503    extra_data.insert(
504        "is_archive".to_string(),
505        parsed
506            .is_archive
507            .map(JsonValue::Bool)
508            .unwrap_or(JsonValue::Null),
509    );
510    extra_data.insert("is_wheel".to_string(), JsonValue::Bool(parsed.is_wheel));
511    extra_data.insert(
512        "is_url".to_string(),
513        parsed
514            .is_url
515            .map(JsonValue::Bool)
516            .unwrap_or(JsonValue::Null),
517    );
518    extra_data.insert(
519        "is_vcs_url".to_string(),
520        parsed
521            .is_vcs_url
522            .map(JsonValue::Bool)
523            .unwrap_or(JsonValue::Null),
524    );
525    extra_data.insert(
526        "is_name_at_url".to_string(),
527        JsonValue::Bool(parsed.is_name_at_url),
528    );
529    extra_data.insert(
530        "is_local_path".to_string(),
531        parsed
532            .is_local_path
533            .map(|value| value || is_editable)
534            .map(JsonValue::Bool)
535            .unwrap_or(JsonValue::Null),
536    );
537
538    if let Some(marker) = parsed.marker {
539        extra_data.insert(
540            "markers".to_string(),
541            JsonValue::String(truncate_field(marker)),
542        );
543    }
544
545    Some(Dependency {
546        purl,
547        extracted_requirement: Some(truncate_field(extracted_requirement)),
548        scope: Some(scope.to_string()),
549        is_runtime: Some(is_runtime),
550        is_optional: Some(false),
551        is_pinned: Some(is_pinned),
552        is_direct: Some(true),
553        resolved_package: None,
554        extra_data: Some(extra_data),
555    })
556}
557
558fn looks_like_hash_only_requirement(requirement: &str) -> bool {
559    let trimmed = requirement.trim();
560    if !matches!(trimmed.len(), 32 | 40 | 64 | 96 | 128) {
561        return false;
562    }
563
564    if trimmed.contains(char::is_whitespace)
565        || trimmed.contains(['[', ']', '@', ';', '/', '\\'])
566        || trimmed.contains("==")
567        || trimmed.contains("://")
568        || trimmed.contains("git+")
569    {
570        return false;
571    }
572
573    trimmed.chars().all(|ch| ch.is_ascii_hexdigit())
574}
575
576fn split_hash_options(input: &str) -> (String, Vec<String>) {
577    let mut filtered = Vec::new();
578    let mut hashes = Vec::new();
579
580    for token in input.split_whitespace() {
581        if let Some(value) = token.strip_prefix("--hash=") {
582            if !value.is_empty() {
583                hashes.push(value.to_string());
584            }
585        } else {
586            filtered.push(token);
587        }
588    }
589
590    (filtered.join(" "), hashes)
591}
592
593struct ParsedRequirement {
594    name: Option<String>,
595    specifiers: Option<String>,
596    marker: Option<String>,
597    link: Option<String>,
598    is_url: Option<bool>,
599    is_vcs_url: Option<bool>,
600    is_local_path: Option<bool>,
601    is_name_at_url: bool,
602    is_archive: Option<bool>,
603    is_wheel: bool,
604}
605
606fn parse_requirement(input: &str) -> ParsedRequirement {
607    if let Some(parsed) = parse_pep508_requirement(input) {
608        if let Some(url) = parsed.url.clone() {
609            return parsed_with_link(parsed, &url);
610        }
611
612        if !is_link_like(input) {
613            let name = Some(normalize_pypi_name(&parsed.name));
614            return ParsedRequirement {
615                name,
616                specifiers: parsed.specifiers.map(truncate_field),
617                marker: parsed.marker.map(truncate_field),
618                link: None,
619                is_url: None,
620                is_vcs_url: None,
621                is_local_path: None,
622                is_name_at_url: false,
623                is_archive: None,
624                is_wheel: false,
625            };
626        }
627    }
628
629    if let Some((name, link)) = parse_link_with_name(input) {
630        let normalized_name = normalize_pypi_name(&name);
631        let link_info = parse_link_flags(&link);
632        return ParsedRequirement {
633            name: Some(normalized_name),
634            specifiers: None,
635            marker: None,
636            link: Some(truncate_field(link)),
637            is_url: Some(link_info.is_url),
638            is_vcs_url: Some(link_info.is_vcs_url),
639            is_local_path: Some(link_info.is_local_path),
640            is_name_at_url: link_info.is_name_at_url,
641            is_archive: link_info.is_archive,
642            is_wheel: link_info.is_wheel,
643        };
644    }
645
646    let link_info = parse_link_flags(input);
647    ParsedRequirement {
648        name: None,
649        specifiers: None,
650        marker: None,
651        link: Some(truncate_field(input.to_string())),
652        is_url: Some(link_info.is_url),
653        is_vcs_url: Some(link_info.is_vcs_url),
654        is_local_path: Some(link_info.is_local_path),
655        is_name_at_url: link_info.is_name_at_url,
656        is_archive: link_info.is_archive,
657        is_wheel: link_info.is_wheel,
658    }
659}
660
661fn parsed_with_link(parsed: Pep508Requirement, link: &str) -> ParsedRequirement {
662    let name = normalize_pypi_name(&parsed.name);
663    let link_info = parse_link_flags(link);
664    ParsedRequirement {
665        name: Some(name),
666        specifiers: parsed.specifiers.map(truncate_field),
667        marker: parsed.marker.map(truncate_field),
668        link: Some(truncate_field(link.to_string())),
669        is_url: Some(link_info.is_url),
670        is_vcs_url: Some(link_info.is_vcs_url),
671        is_local_path: Some(link_info.is_local_path),
672        is_name_at_url: parsed.is_name_at_url,
673        is_archive: link_info.is_archive,
674        is_wheel: link_info.is_wheel,
675    }
676}
677
678fn parse_link_with_name(input: &str) -> Option<(String, String)> {
679    if let Some(egg) = extract_egg_name(input) {
680        return Some((egg, input.to_string()));
681    }
682    None
683}
684
685fn extract_egg_name(input: &str) -> Option<String> {
686    let fragment = input.split('#').nth(1)?;
687    let egg_part = fragment.strip_prefix("egg=")?;
688    let name_part = egg_part.split('&').next()?.trim();
689    if name_part.is_empty() {
690        return None;
691    }
692    let (name, _extras, _) = parse_pep508_requirement(name_part)
693        .map(|parsed| (parsed.name, parsed.extras, parsed.specifiers))
694        .unwrap_or_else(|| (name_part.to_string(), Vec::new(), None));
695    Some(name)
696}
697
698struct LinkFlags {
699    is_url: bool,
700    is_vcs_url: bool,
701    is_local_path: bool,
702    is_name_at_url: bool,
703    is_archive: Option<bool>,
704    is_wheel: bool,
705}
706
707fn parse_link_flags(link: &str) -> LinkFlags {
708    let trimmed = link.trim();
709    let is_vcs_url = trimmed.starts_with("git+")
710        || trimmed.starts_with("hg+")
711        || trimmed.starts_with("svn+")
712        || trimmed.starts_with("bzr+");
713    let has_scheme = trimmed.contains("://") || trimmed.starts_with("file:");
714    let is_local_path = trimmed.starts_with("./")
715        || trimmed.starts_with("../")
716        || trimmed.starts_with('/')
717        || trimmed.starts_with('~')
718        || trimmed.starts_with("file:");
719
720    let is_wheel = trimmed.ends_with(".whl");
721    let is_archive = if is_wheel
722        || trimmed.ends_with(".zip")
723        || trimmed.ends_with(".tar.gz")
724        || trimmed.ends_with(".tgz")
725        || trimmed.ends_with(".tar.bz2")
726        || trimmed.ends_with(".tar")
727    {
728        Some(true)
729    } else if has_scheme || is_local_path {
730        Some(false)
731    } else {
732        None
733    };
734
735    LinkFlags {
736        is_url: has_scheme || is_vcs_url,
737        is_vcs_url,
738        is_local_path,
739        is_name_at_url: false,
740        is_archive,
741        is_wheel,
742    }
743}
744
745fn is_link_like(input: &str) -> bool {
746    let trimmed = input.trim();
747    trimmed.starts_with("git+")
748        || trimmed.starts_with("hg+")
749        || trimmed.starts_with("svn+")
750        || trimmed.starts_with("bzr+")
751        || trimmed.starts_with("file:")
752        || trimmed.contains("://")
753        || trimmed.starts_with("./")
754        || trimmed.starts_with("../")
755        || trimmed.starts_with('/')
756        || trimmed.starts_with('~')
757}
758
759fn extract_pinned_version(specifiers: &str) -> Option<String> {
760    let trimmed = specifiers.trim();
761    if trimmed.contains(',') {
762        return None;
763    }
764
765    let stripped = if let Some(version) = trimmed.strip_prefix("===") {
766        version
767    } else {
768        trimmed.strip_prefix("==")?
769    };
770
771    let version = stripped.trim();
772    if version.is_empty() {
773        None
774    } else {
775        Some(version.to_string())
776    }
777}
778
779fn create_pypi_purl(name: &str, version: Option<&str>) -> Option<String> {
780    PackageUrl::new(RequirementsTxtParser::PACKAGE_TYPE.as_str(), name)
781        .ok()
782        .map(|_| match version {
783            Some(version) => format!("pkg:pypi/{name}@{}", encode_pypi_purl_version(version)),
784            None => format!("pkg:pypi/{name}"),
785        })
786}
787
788fn encode_pypi_purl_version(version: &str) -> String {
789    version.replace('*', "%2A")
790}
791
792fn normalize_pypi_name(name: &str) -> String {
793    let lower = name.trim().to_ascii_lowercase();
794    let mut normalized = String::new();
795    let mut last_was_sep = false;
796    for ch in lower.chars() {
797        let is_sep = matches!(ch, '-' | '_' | '.');
798        if is_sep {
799            if !last_was_sep {
800                normalized.push('-');
801                last_was_sep = true;
802            }
803        } else {
804            normalized.push(ch);
805            last_was_sep = false;
806        }
807    }
808    normalized
809}