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    CappedIterExt, MAX_ITERATION_COUNT, MAX_RECURSION_DEPTH, RecursionGuard,
41    capped_iteration_limit, read_file_to_string, truncate_field,
42};
43
44use super::PackageParser;
45
46/// pip requirements.txt parser supporting PEP 508 dependency specifications.
47///
48/// Handles requirements.txt files with -r/-c includes, environment markers,
49/// and VCS/URL references. Recursively resolves included requirement files.
50pub struct RequirementsTxtParser;
51
52impl PackageParser for RequirementsTxtParser {
53    const PACKAGE_TYPE: PackageType = PackageType::Pypi;
54
55    fn extract_packages(path: &Path) -> Vec<PackageData> {
56        vec![extract_from_requirements_txt(path)]
57    }
58
59    fn is_match(path: &Path) -> bool {
60        let filename = path.file_name().and_then(|name| name.to_str());
61        let Some(name) = filename else {
62            return false;
63        };
64
65        is_requirements_txt_filename(name)
66            || (is_requirements_like_extension(name) && has_requirements_like_ancestor(path))
67    }
68
69    fn metadata() -> Vec<super::metadata::ParserMetadata> {
70        vec![super::metadata::ParserMetadata {
71            description: "pip requirements file",
72            file_patterns: &[
73                "**/requirements*.txt",
74                "**/*requirements.txt",
75                "**/reqs.txt",
76                "**/minreqs.txt",
77                "**/*-reqs.txt",
78                "**/*_reqs.txt",
79                "**/*.reqs.txt",
80                "**/*-minreqs.txt",
81                "**/*_minreqs.txt",
82                "**/*.minreqs.txt",
83                "**/requirements*.in",
84                "**/*requirements.in",
85                "**/requires.txt",
86                "**/requirements/*.txt",
87                "**/requirements/*.in",
88                "**/requirements/**/*.txt",
89                "**/requirements/**/*.in",
90                "**/requirements*/*.txt",
91                "**/requirements*/*.in",
92                "**/requirements*/**/*.txt",
93                "**/requirements*/**/*.in",
94            ],
95            package_type: "pypi",
96            primary_language: "Python",
97            documentation_url: Some(
98                "https://pip.pypa.io/en/latest/reference/requirements-file-format/",
99            ),
100        }]
101    }
102}
103
104fn is_requirements_txt_filename(name: &str) -> bool {
105    if name == "requirements.txt" || name == "requires.txt" {
106        return true;
107    }
108
109    let (stem, extension) = if let Some(stem) = name.strip_suffix(".txt") {
110        (stem, "txt")
111    } else if let Some(stem) = name.strip_suffix(".in") {
112        (stem, "in")
113    } else {
114        return false;
115    };
116
117    // Keep parity with ScanCode's documented *reqs.txt support while avoiding
118    // extending that broader alias to .in files or unrelated stems such as
119    // `prereqs.txt` that only happen to end with the same letters.
120    stem == "requirements"
121        || stem.starts_with("requirements")
122        || stem.ends_with("requirements")
123        || (extension == "txt" && is_reqs_alias_stem(stem))
124}
125
126fn is_reqs_alias_stem(stem: &str) -> bool {
127    matches_requirement_alias_stem(stem, "reqs") || matches_requirement_alias_stem(stem, "minreqs")
128}
129
130fn matches_requirement_alias_stem(stem: &str, alias: &str) -> bool {
131    stem == alias
132        || stem
133            .strip_suffix(alias)
134            .is_some_and(|prefix| matches!(prefix.chars().last(), Some('-' | '_' | '.')))
135}
136
137fn is_requirements_like_extension(name: &str) -> bool {
138    name.ends_with(".txt") || name.ends_with(".in")
139}
140
141fn has_requirements_like_ancestor(path: &Path) -> bool {
142    path.parent()
143        .into_iter()
144        .flat_map(Path::ancestors)
145        .filter_map(|ancestor| ancestor.file_name())
146        .filter_map(|name| name.to_str())
147        .any(is_requirements_like_dir_name)
148}
149
150fn is_requirements_like_dir_name(name: &str) -> bool {
151    name == "requirements" || name.starts_with("requirements") || name.ends_with("requirements")
152}
153
154struct ParseState {
155    dependencies: Vec<Dependency>,
156    extra_index_urls: Vec<String>,
157    index_url: Option<String>,
158    includes: Vec<String>,
159    constraints: Vec<String>,
160    guard: RecursionGuard<PathBuf>,
161}
162
163fn extract_from_requirements_txt(path: &Path) -> PackageData {
164    let mut state = ParseState {
165        dependencies: Vec::new(),
166        extra_index_urls: Vec::new(),
167        index_url: None,
168        includes: Vec::new(),
169        constraints: Vec::new(),
170        guard: RecursionGuard::new(),
171    };
172
173    let (scope, is_runtime) = scope_from_filename(path);
174
175    parse_requirements_with_includes(path, &mut state, &scope, is_runtime);
176
177    let mut extra_data = HashMap::new();
178    if let Some(url) = state.index_url {
179        extra_data.insert(
180            "index_url".to_string(),
181            JsonValue::String(truncate_field(url)),
182        );
183    }
184    if !state.extra_index_urls.is_empty() {
185        extra_data.insert(
186            "extra_index_urls".to_string(),
187            JsonValue::Array(
188                state
189                    .extra_index_urls
190                    .into_iter()
191                    .map(|u| JsonValue::String(truncate_field(u)))
192                    .collect(),
193            ),
194        );
195    }
196    if !state.includes.is_empty() {
197        extra_data.insert(
198            "requirements_includes".to_string(),
199            JsonValue::Array(
200                state
201                    .includes
202                    .into_iter()
203                    .map(|i| JsonValue::String(truncate_field(i)))
204                    .collect(),
205            ),
206        );
207    }
208    if !state.constraints.is_empty() {
209        extra_data.insert(
210            "constraints".to_string(),
211            JsonValue::Array(
212                state
213                    .constraints
214                    .into_iter()
215                    .map(|c| JsonValue::String(truncate_field(c)))
216                    .collect(),
217            ),
218        );
219    }
220
221    let extra_data = if extra_data.is_empty() {
222        None
223    } else {
224        Some(extra_data)
225    };
226
227    default_package_data(state.dependencies, extra_data)
228}
229
230fn parse_requirements_with_includes(
231    path: &Path,
232    state: &mut ParseState,
233    scope: &str,
234    is_runtime: bool,
235) {
236    if state.guard.exceeded() {
237        warn!(
238            "Maximum recursion depth ({}) exceeded for include: {:?}",
239            MAX_RECURSION_DEPTH, path
240        );
241        return;
242    }
243
244    let abs_path = match path.canonicalize() {
245        Ok(p) => p,
246        Err(_) => {
247            warn!("Cannot resolve path: {:?}", path);
248            return;
249        }
250    };
251
252    if state.guard.enter(abs_path.clone()) {
253        warn!("Circular include detected: {:?}", path);
254        return;
255    }
256
257    let content = match read_file_to_string(&abs_path, None) {
258        Ok(c) => c,
259        Err(e) => {
260            warn!("Cannot read file {:?}: {}", abs_path, e);
261            return;
262        }
263    };
264
265    let logical_lines = collect_logical_lines(&content);
266    let limit = capped_iteration_limit(logical_lines.len(), "requirements.txt logical lines");
267    for line in logical_lines.into_iter().take(limit) {
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().capped("requirements.txt raw lines") {
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}