Skip to main content

uv_distribution_filename/
source_dist.rs

1use std::fmt::{Display, Formatter};
2use std::str::FromStr;
3
4use crate::SourceDistExtension;
5use serde::{Deserialize, Serialize};
6use thiserror::Error;
7use uv_normalize::{InvalidNameError, PackageName};
8use uv_pep440::{Version, VersionParseError};
9
10/// Note that this is a normalized and not an exact representation, keep the original string if you
11/// need the latter.
12#[derive(
13    Clone,
14    Debug,
15    PartialEq,
16    Eq,
17    PartialOrd,
18    Ord,
19    Serialize,
20    Deserialize,
21    rkyv::Archive,
22    rkyv::Deserialize,
23    rkyv::Serialize,
24)]
25#[rkyv(derive(Debug))]
26pub struct SourceDistFilename {
27    pub name: PackageName,
28    pub version: Version,
29    pub extension: SourceDistExtension,
30}
31
32impl SourceDistFilename {
33    /// No `FromStr` impl since we need to know the package name to be able to reasonable parse
34    /// these (consider e.g. `a-1-1.zip`)
35    pub fn parse(
36        filename: &str,
37        extension: SourceDistExtension,
38        package_name: &PackageName,
39    ) -> Result<Self, SourceDistFilenameError> {
40        // Drop the extension (e.g., given `tar.gz`, drop `.tar.gz`).
41        if filename.len() <= extension.name().len() + 1 {
42            return Err(SourceDistFilenameError {
43                filename: filename.to_string(),
44                kind: SourceDistFilenameErrorKind::Extension,
45            });
46        }
47
48        let stem = &filename[..(filename.len() - (extension.name().len() + 1))];
49
50        if stem.len() <= package_name.as_ref().len() + "-".len() {
51            return Err(SourceDistFilenameError {
52                filename: filename.to_string(),
53                kind: SourceDistFilenameErrorKind::Filename(package_name.clone()),
54            });
55        }
56        let actual_package_name = PackageName::from_str(&stem[..package_name.as_ref().len()])
57            .map_err(|err| SourceDistFilenameError {
58                filename: filename.to_string(),
59                kind: SourceDistFilenameErrorKind::PackageName(err),
60            })?;
61        if actual_package_name != *package_name {
62            return Err(SourceDistFilenameError {
63                filename: filename.to_string(),
64                kind: SourceDistFilenameErrorKind::Filename(package_name.clone()),
65            });
66        }
67
68        // We checked the length above
69        let version =
70            Version::from_str(&stem[package_name.as_ref().len() + "-".len()..]).map_err(|err| {
71                SourceDistFilenameError {
72                    filename: filename.to_string(),
73                    kind: SourceDistFilenameErrorKind::Version(err),
74                }
75            })?;
76
77        Ok(Self {
78            name: package_name.clone(),
79            version,
80            extension,
81        })
82    }
83
84    /// Like [`SourceDistFilename::parse`], but without knowing the package name.
85    ///
86    /// Source dist filenames can be ambiguous, e.g. `a-1-1.tar.gz`. Without knowing the package name, we assume that
87    /// source dist filename version doesn't contain minus (the version is normalized).
88    pub fn parsed_normalized_filename(filename: &str) -> Result<Self, SourceDistFilenameError> {
89        let Ok(extension) = SourceDistExtension::from_path(filename) else {
90            return Err(SourceDistFilenameError {
91                filename: filename.to_string(),
92                kind: SourceDistFilenameErrorKind::Extension,
93            });
94        };
95
96        // Drop the extension (e.g., given `tar.gz`, drop `.tar.gz`).
97        if filename.len() <= extension.name().len() + 1 {
98            return Err(SourceDistFilenameError {
99                filename: filename.to_string(),
100                kind: SourceDistFilenameErrorKind::Extension,
101            });
102        }
103
104        let stem = &filename[..(filename.len() - (extension.name().len() + 1))];
105
106        let Some((package_name, version)) = stem.rsplit_once('-') else {
107            return Err(SourceDistFilenameError {
108                filename: filename.to_string(),
109                kind: SourceDistFilenameErrorKind::Minus,
110            });
111        };
112        let package_name =
113            PackageName::from_str(package_name).map_err(|err| SourceDistFilenameError {
114                filename: filename.to_string(),
115                kind: SourceDistFilenameErrorKind::PackageName(err),
116            })?;
117
118        // We checked the length above
119        let version = Version::from_str(version).map_err(|err| SourceDistFilenameError {
120            filename: filename.to_string(),
121            kind: SourceDistFilenameErrorKind::Version(err),
122        })?;
123
124        Ok(Self {
125            name: package_name,
126            version,
127            extension,
128        })
129    }
130}
131
132impl Display for SourceDistFilename {
133    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
134        write!(
135            f,
136            "{}-{}.{}",
137            self.name.as_dist_info_name(),
138            self.version,
139            self.extension
140        )
141    }
142}
143
144#[derive(Error, Debug, Clone)]
145pub struct SourceDistFilenameError {
146    filename: String,
147    kind: SourceDistFilenameErrorKind,
148}
149
150impl Display for SourceDistFilenameError {
151    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
152        write!(
153            f,
154            "Failed to parse source distribution filename {}: {}",
155            self.filename, self.kind
156        )
157    }
158}
159
160#[derive(Error, Debug, Clone)]
161enum SourceDistFilenameErrorKind {
162    #[error("Name doesn't start with package name {0}")]
163    Filename(PackageName),
164    #[error("File extension is invalid")]
165    Extension,
166    #[error("Version section is invalid")]
167    Version(#[from] VersionParseError),
168    #[error(transparent)]
169    PackageName(#[from] InvalidNameError),
170    #[error("Missing name-version separator")]
171    Minus,
172}
173
174#[cfg(test)]
175mod tests {
176    use std::str::FromStr;
177
178    use uv_normalize::PackageName;
179
180    use crate::{SourceDistExtension, SourceDistFilename};
181
182    /// Only test already normalized names since the parsing is lossy
183    ///
184    /// <https://packaging.python.org/en/latest/specifications/source-distribution-format/#source-distribution-file-name>
185    /// <https://packaging.python.org/en/latest/specifications/binary-distribution-format/#escaping-and-unicode>
186    #[test]
187    fn roundtrip() {
188        for normalized in [
189            "foo_lib-1.2.3.zip",
190            "foo_lib-1.2.3a3.zip",
191            "foo_lib-1.2.3.tar.gz",
192            "foo_lib-1.2.3.tar.bz2",
193            "foo_lib-1.2.3.tar.zst",
194            "foo_lib-1.2.3.tar.xz",
195            "foo_lib-1.2.3.tar.lz",
196            "foo_lib-1.2.3.tar.lzma",
197            "foo_lib-1.2.3.tgz",
198            "foo_lib-1.2.3.tbz",
199            "foo_lib-1.2.3.tlz",
200            "foo_lib-1.2.3.txz",
201        ] {
202            let ext = SourceDistExtension::from_path(normalized).unwrap();
203            assert_eq!(
204                SourceDistFilename::parse(
205                    normalized,
206                    ext,
207                    &PackageName::from_str("foo_lib").unwrap()
208                )
209                .unwrap()
210                .to_string(),
211                normalized
212            );
213        }
214    }
215
216    #[test]
217    fn errors() {
218        for invalid in ["b-1.2.3.zip", "a-1.2.3-gamma.3.zip"] {
219            let ext = SourceDistExtension::from_path(invalid).unwrap();
220            assert!(
221                SourceDistFilename::parse(invalid, ext, &PackageName::from_str("a").unwrap())
222                    .is_err()
223            );
224        }
225    }
226
227    #[test]
228    fn name_too_long() {
229        assert!(
230            SourceDistFilename::parse(
231                "foo.zip",
232                SourceDistExtension::Zip,
233                &PackageName::from_str("foo-lib").unwrap()
234            )
235            .is_err()
236        );
237    }
238}