Skip to main content

deps_pypi/
name.rs

1//! Canonical PyPI package name normalization (PEP 503).
2//!
3//! This is the single normalization used across deps-pypi — registry lookups,
4//! `PypiFormatter`'s [`normalize_package_name`](deps_core::lsp_helpers::PackageNaming::normalize_package_name)
5//! override, and lock file parsing — so a name declared any of several ways
6//! (`Zope.Interface`, `zope_interface`, `zope-interface`) resolves to the
7//! same lookup key everywhere. See [`crate::types::PypiDependency::name`] for
8//! why the *declared* name is not itself always normalized.
9
10/// Normalizes `name` per [PEP 503](https://peps.python.org/pep-0503/#normalized-names):
11/// lowercases, then collapses any run of `-`, `_`, or `.` into a single `-`.
12///
13/// This matches the actual PyPI Simple API URL contract
14/// (`https://pypi.org/simple/<name>/`) and what both `poetry.lock` and
15/// `uv.lock` store, unlike the historical `_`-based key space this replaces.
16///
17/// **Deliberate deviation from PEP 503's own regex** (`re.sub(r"[-_.]+",
18/// "-", name).lower()`): that regex collapses a *leading or trailing*
19/// separator run into a single leading/trailing `-` (`"_package_"` ->
20/// `"-package-"`), which this function strips entirely instead
21/// (`"_package_"` -> `"package"`). PyPI's own package-name validation
22/// regex forbids a name from starting or ending with a separator, so no
23/// real published package name can exercise this difference — stripping
24/// matches what a human clearly meant by a manifest name like `_package_`
25/// better than preserving a leading/trailing hyphen would.
26///
27/// # Examples
28///
29/// ```
30/// use deps_pypi::name::normalize;
31///
32/// assert_eq!(normalize("Flask"), "flask");
33/// assert_eq!(normalize("django_rest_framework"), "django-rest-framework");
34/// assert_eq!(normalize("Pillow.Image"), "pillow-image");
35/// assert_eq!(normalize("my__package"), "my-package");
36/// assert_eq!(normalize("---"), "");
37/// ```
38pub fn normalize(name: &str) -> String {
39    name.to_lowercase()
40        .replace(['_', '.'], "-")
41        .split('-')
42        .filter(|s| !s.is_empty())
43        .collect::<Vec<_>>()
44        .join("-")
45}
46
47#[cfg(test)]
48mod tests {
49    use super::*;
50
51    #[test]
52    fn test_normalize_lowercase() {
53        assert_eq!(normalize("Flask"), "flask");
54        assert_eq!(normalize("DJANGO"), "django");
55        assert_eq!(normalize("Requests"), "requests");
56    }
57
58    #[test]
59    fn test_normalize_underscores() {
60        assert_eq!(normalize("django_rest_framework"), "django-rest-framework");
61        assert_eq!(normalize("my_package"), "my-package");
62    }
63
64    #[test]
65    fn test_normalize_dots() {
66        assert_eq!(normalize("Pillow.Image"), "pillow-image");
67        assert_eq!(normalize("zope.interface"), "zope-interface");
68    }
69
70    #[test]
71    fn test_normalize_consecutive_separators() {
72        assert_eq!(normalize("my__package"), "my-package");
73        assert_eq!(normalize("my..package"), "my-package");
74        assert_eq!(normalize("my_.package"), "my-package");
75    }
76
77    #[test]
78    fn test_normalize_mixed() {
79        assert_eq!(normalize("My_Package.Name"), "my-package-name");
80        assert_eq!(normalize("SOME__Weird.._Package"), "some-weird-package");
81    }
82
83    #[test]
84    fn test_normalize_already_normalized() {
85        assert_eq!(normalize("my-package"), "my-package");
86        assert_eq!(normalize("django-rest-framework"), "django-rest-framework");
87    }
88
89    #[test]
90    fn test_normalize_edge_cases() {
91        assert_eq!(normalize("a"), "a");
92        assert_eq!(normalize("A_B_C"), "a-b-c");
93        assert_eq!(normalize("---"), "");
94        assert_eq!(normalize(""), "");
95    }
96
97    #[test]
98    fn test_normalize_leading_trailing_separators() {
99        assert_eq!(normalize("_package_"), "package");
100        assert_eq!(normalize(".package."), "package");
101        assert_eq!(normalize("__package__"), "package");
102    }
103}