Skip to main content

znippy_plugin_python/
wheel.rs

1//! Wheel filename parser per PEP 427.
2//!
3//! Format: `{name}-{version}(-{build})?-{python}-{abi}-{platform}.whl`
4//!
5//! Handles all variants: cp39, pp310, py3, etc.
6
7/// Parsed wheel filename metadata.
8#[derive(Debug, Clone, PartialEq)]
9pub struct WheelInfo {
10    pub name: String,
11    pub version: String,
12    pub build_tag: Option<String>,
13    pub python_tag: String,
14    pub abi_tag: String,
15    pub platform_tag: String,
16}
17
18impl WheelInfo {
19    /// Normalized package name per PEP 503 (lowercase, replace [-_.] with -)
20    pub fn normalized_name(&self) -> String {
21        normalize_name(&self.name)
22    }
23
24    /// The dist-info directory name inside the wheel
25    pub fn dist_info_dir(&self) -> String {
26        format!("{}-{}.dist-info", self.name, self.version)
27    }
28}
29
30/// Normalize a package name per PEP 503: lowercase, replace [-_.] with -
31pub fn normalize_name(s: &str) -> String {
32    s.to_lowercase().replace(['_', '.', '-'], "-")
33}
34
35/// Parse a wheel filename into its components.
36///
37/// Returns None if the filename doesn't match the wheel naming convention.
38pub fn parse_wheel_filename(filename: &str) -> Option<WheelInfo> {
39    let stem = filename.strip_suffix(".whl")?;
40    let parts: Vec<&str> = stem.split('-').collect();
41
42    // Minimum: name-version-python-abi-platform (5 parts)
43    // With build tag: name-version-build-python-abi-platform (6 parts)
44    match parts.len() {
45        5 => Some(WheelInfo {
46            name: parts[0].to_string(),
47            version: parts[1].to_string(),
48            build_tag: None,
49            python_tag: parts[2].to_string(),
50            abi_tag: parts[3].to_string(),
51            platform_tag: parts[4].to_string(),
52        }),
53        6 => Some(WheelInfo {
54            name: parts[0].to_string(),
55            version: parts[1].to_string(),
56            build_tag: Some(parts[2].to_string()),
57            python_tag: parts[3].to_string(),
58            abi_tag: parts[4].to_string(),
59            platform_tag: parts[5].to_string(),
60        }),
61        // Handle names with hyphens encoded as underscores + complex platform tags
62        n if n >= 5 => {
63            // Platform tag can contain dots (manylinux_2_17_x86_64.manylinux2014_x86_64)
64            // which don't split on '-', so we should be fine. But version can be complex
65            // like "1.0.0rc1" — still no dashes.
66            // The tricky case: name might have been multi-word e.g. "my_cool_pkg"
67            // PEP 427 says name uses _ for separator, so split on - is safe.
68            // If we get > 6 parts, the extra parts are likely part of the platform tag
69            // that somehow got a dash. Try from the end:
70            let platform_tag = parts[n - 1].to_string();
71            let abi_tag = parts[n - 2].to_string();
72            let python_tag = parts[n - 3].to_string();
73
74            // Check if there's a build tag (numeric prefix)
75            let version_end = n - 3;
76            if version_end >= 3 && parts[version_end - 1].chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) {
77                // Might be a build tag
78                let maybe_build = parts[version_end - 1];
79                // Build tags start with a digit per PEP 427
80                Some(WheelInfo {
81                    name: parts[..version_end - 2].join("_"),
82                    version: parts[version_end - 2].to_string(),
83                    build_tag: Some(maybe_build.to_string()),
84                    python_tag,
85                    abi_tag,
86                    platform_tag,
87                })
88            } else {
89                Some(WheelInfo {
90                    name: parts[..version_end - 1].join("_"),
91                    version: parts[version_end - 1].to_string(),
92                    build_tag: None,
93                    python_tag,
94                    abi_tag,
95                    platform_tag,
96                })
97            }
98        }
99        _ => None,
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn simple_wheel() {
109        let info = parse_wheel_filename("numpy-1.26.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl").unwrap();
110        assert_eq!(info.name, "numpy");
111        assert_eq!(info.version, "1.26.0");
112        assert_eq!(info.build_tag, None);
113        assert_eq!(info.python_tag, "cp311");
114        assert_eq!(info.abi_tag, "cp311");
115        assert_eq!(info.platform_tag, "manylinux_2_17_x86_64.manylinux2014_x86_64");
116    }
117
118    #[test]
119    fn pure_python_wheel() {
120        let info = parse_wheel_filename("requests-2.31.0-py3-none-any.whl").unwrap();
121        assert_eq!(info.name, "requests");
122        assert_eq!(info.version, "2.31.0");
123        assert_eq!(info.python_tag, "py3");
124        assert_eq!(info.abi_tag, "none");
125        assert_eq!(info.platform_tag, "any");
126    }
127
128    #[test]
129    fn with_build_tag() {
130        let info = parse_wheel_filename("package-1.0.0-1-cp39-cp39-linux_x86_64.whl").unwrap();
131        assert_eq!(info.name, "package");
132        assert_eq!(info.version, "1.0.0");
133        assert_eq!(info.build_tag, Some("1".to_string()));
134        assert_eq!(info.python_tag, "cp39");
135    }
136
137    #[test]
138    fn normalized_name() {
139        let info = parse_wheel_filename("My_Cool.Package-1.0-py3-none-any.whl").unwrap();
140        assert_eq!(info.normalized_name(), "my-cool-package");
141    }
142
143    #[test]
144    fn not_a_wheel() {
145        assert!(parse_wheel_filename("requests-2.31.0.tar.gz").is_none());
146        assert!(parse_wheel_filename("something.zip").is_none());
147    }
148
149    #[test]
150    fn dist_info_dir() {
151        let info = parse_wheel_filename("numpy-1.26.0-cp311-cp311-linux_x86_64.whl").unwrap();
152        assert_eq!(info.dist_info_dir(), "numpy-1.26.0.dist-info");
153    }
154}