Skip to main content

provenant/parsers/
requirements_txt.rs

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