use std::fmt;
use std::str::FromStr;
use crate::pypi::name::normalize;
const SDIST_EXTENSIONS: [&str; 5] = [".tar.gz", ".tar.bz2", ".tar.xz", ".tgz", ".zip"];
const WHEEL_EXTENSION: &str = ".whl";
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct FileIdentity {
pub project: String,
pub version: String,
}
pub fn file_identity(filename: &str, project: &str) -> Result<FileIdentity, UnsupportedFilename> {
if filename.is_empty() {
return Err(UnsupportedFilename::Empty);
}
if let Some(bad) = filename
.chars()
.find(|c| c.is_control() || matches!(c, '/' | '\\'))
{
return Err(UnsupportedFilename::Character(bad));
}
if let Some(stem) = filename.strip_suffix(WHEEL_EXTENSION) {
return wheel_identity(stem, project);
}
for extension in SDIST_EXTENSIONS {
if let Some(stem) = filename.strip_suffix(extension) {
return sdist_identity(stem, project);
}
}
Err(UnsupportedFilename::UnknownExtension)
}
fn wheel_identity(stem: &str, project: &str) -> Result<FileIdentity, UnsupportedFilename> {
let parts: Vec<&str> = stem.split('-').collect();
let (distribution, version) = match parts.as_slice() {
[distribution, version, _python, _abi, _platform] => (*distribution, *version),
[distribution, version, build, _python, _abi, _platform]
if build.starts_with(|c: char| c.is_ascii_digit()) =>
{
(*distribution, *version)
}
_ => {
return Err(UnsupportedFilename::WheelShape {
components: parts.len(),
});
}
};
confirm(distribution, version, project)
}
fn sdist_identity(stem: &str, project: &str) -> Result<FileIdentity, UnsupportedFilename> {
let mut identity: Option<FileIdentity> = None;
for (index, _) in stem.match_indices('-') {
let (name, version) = (&stem[..index], &stem[index + 1..]);
if name.is_empty() || version.is_empty() {
continue;
}
if normalize(name) != project {
continue;
}
if parse_version(version).is_none() {
continue;
}
if identity.is_some() {
return Err(UnsupportedFilename::AmbiguousSplit);
}
identity = Some(FileIdentity {
project: project.to_owned(),
version: version.to_owned(),
});
}
identity.ok_or(UnsupportedFilename::NoIdentity)
}
fn confirm(
distribution: &str,
version: &str,
project: &str,
) -> Result<FileIdentity, UnsupportedFilename> {
if distribution.is_empty() {
return Err(UnsupportedFilename::NoIdentity);
}
let normalized = normalize(distribution);
if normalized != project {
return Err(UnsupportedFilename::OtherProject { named: normalized });
}
if parse_version(version).is_none() {
return Err(UnsupportedFilename::UnparsableVersion);
}
Ok(FileIdentity {
project: normalized,
version: version.to_owned(),
})
}
fn parse_version(text: &str) -> Option<pep440_rs::Version> {
if text.trim() != text {
return None;
}
let first = text.chars().next()?;
if !(first.is_ascii_digit() || first == 'v' || first == 'V') {
return None;
}
pep440_rs::Version::from_str(text).ok()
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum UnsupportedFilename {
Empty,
Character(char),
UnknownExtension,
WheelShape {
components: usize,
},
OtherProject {
named: String,
},
UnparsableVersion,
NoIdentity,
AmbiguousSplit,
}
impl fmt::Display for UnsupportedFilename {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
UnsupportedFilename::Empty => f.write_str("the filename is empty"),
UnsupportedFilename::Character(c) => {
write!(f, "`{}` cannot appear in a filename", c.escape_default())
}
UnsupportedFilename::UnknownExtension => {
f.write_str("the archive extension is not one this release can name")
}
UnsupportedFilename::WheelShape { components } => write!(
f,
"a wheel name has five or six components, this has {components}"
),
UnsupportedFilename::OtherProject { named } => {
write!(f, "the filename names the project `{named}`")
}
UnsupportedFilename::UnparsableVersion => {
f.write_str("the version is not a PEP 440 version")
}
UnsupportedFilename::NoIdentity => {
f.write_str("no split of the name yields this project and a version")
}
UnsupportedFilename::AmbiguousSplit => {
f.write_str("the name splits into this project and a version in more than one way")
}
}
}
}
impl std::error::Error for UnsupportedFilename {}
#[cfg(test)]
mod tests {
use super::*;
fn identity(filename: &str, project: &str) -> Option<String> {
file_identity(filename, project)
.ok()
.map(|found| found.version)
}
#[test]
fn wheel_names_split_on_their_fixed_component_count() {
assert_eq!(
identity("friendly_bard-1.0-py3-none-any.whl", "friendly-bard").as_deref(),
Some("1.0")
);
assert_eq!(
identity("friendly_bard-1.0-7-py3-none-any.whl", "friendly-bard").as_deref(),
Some("1.0"),
"a build tag is the sixth component and does not move the version"
);
assert_eq!(
identity(
"friendly_bard-1.0+ubuntu_1-py3-none-any.whl",
"friendly-bard"
)
.as_deref(),
Some("1.0+ubuntu_1"),
"a local version label is part of the version, not a component of its own"
);
}
#[test]
fn an_unsanctioned_wheel_shape_is_excluded() {
assert!(matches!(
file_identity("bard-1.0-extra-py3-none-any.whl", "bard"),
Err(UnsupportedFilename::WheelShape { components: 6 })
));
assert!(matches!(
file_identity("bard-1.0-py3-none.whl", "bard"),
Err(UnsupportedFilename::WheelShape { components: 4 })
));
}
#[test]
fn a_filename_naming_another_project_is_never_reinterpreted() {
assert!(matches!(
file_identity("bard_1.0-2.0-py3-none-any.whl", "bard"),
Err(UnsupportedFilename::OtherProject { .. })
));
assert!(matches!(
file_identity("other-1.0.tar.gz", "bard"),
Err(UnsupportedFilename::NoIdentity)
));
}
#[test]
fn an_sdist_split_must_yield_this_project_and_a_whole_version() {
assert_eq!(identity("bard-1.0.tar.gz", "bard").as_deref(), Some("1.0"));
assert_eq!(
identity("friendly-bard-1.0.tar.gz", "friendly-bard").as_deref(),
Some("1.0"),
"a legacy unescaped name is split at the point that names this project"
);
assert_eq!(
identity("friendly_bard-1.0.zip", "friendly-bard").as_deref(),
Some("1.0")
);
assert!(
matches!(
file_identity("friendly-bard-notaversion.tar.gz", "friendly-bard"),
Err(UnsupportedFilename::NoIdentity)
),
"the right half has to be a version, not merely the rest of the name"
);
}
#[test]
fn unusable_filenames_are_refused_rather_than_parsed() {
assert!(matches!(
file_identity("", "bard"),
Err(UnsupportedFilename::Empty)
));
assert!(matches!(
file_identity("../../etc/passwd", "bard"),
Err(UnsupportedFilename::Character('/'))
));
assert!(matches!(
file_identity("bard-1.0.egg", "bard"),
Err(UnsupportedFilename::UnknownExtension)
));
assert!(matches!(
file_identity("bard-1.0.tar.gz\u{0}", "bard"),
Err(UnsupportedFilename::Character('\u{0}'))
));
}
#[test]
fn a_version_must_be_a_whole_pep_440_version() {
assert!(parse_version("1.0").is_some());
assert!(parse_version("1.0.0rc1").is_some());
assert!(parse_version("2!1.0").is_some());
assert!(parse_version("v1.0").is_some());
assert!(parse_version("1.0+local.1").is_some());
assert!(parse_version("").is_none());
assert!(parse_version(" 1.0").is_none());
assert!(parse_version("1.0 ").is_none());
assert!(parse_version("bard-1.0").is_none());
assert!(parse_version("notaversion").is_none());
}
}