///|
/// PEP 503 / PEP 427 / PEP 625 helpers: distribution names, version key forms
/// and distribution filenames.
///
/// Everything here is a pure function over strings. The semantics are aligned
/// with PyPA `packaging` 26.3 and verified by the differential corpus in
/// `examples/diff` (record kinds `canon` and `file`).
///
/// Known boundary: `packaging` validates wheel project names with a Unicode
/// aware regex (`^[\w._]+$`) and lowercases names with Unicode rules, while this
/// module works on ASCII. PEP 427 requires escaped ASCII names in wheel
/// filenames, so real inputs are unaffected; the corpus keeps non-ASCII out of
/// these records on purpose.
///|
/// True for the three characters PEP 503 treats as equivalent separators.
fn is_name_separator(c : Char) -> Bool {
c == '-' || c == '_' || c == '.'
}
///|
/// ASCII word character, matching `\w` for the ASCII range.
fn is_word_char(c : Char) -> Bool {
c.is_ascii_alphabetic() || c.is_ascii_digit() || c == '_'
}
///|
/// True when `c` may appear in a wheel project name: a word character or `.`
/// (that is, `packaging`'s `[\w._]+` restricted to ASCII).
fn is_wheel_name_char(c : Char) -> Bool {
is_word_char(c) || c == '.'
}
///|
/// True when `c` may start a Python identifier, used to validate interpreter
/// components of a tag.
fn is_identifier_start(c : Char) -> Bool {
c.is_ascii_alphabetic() || c == '_'
}
///|
/// True when `c` may continue a Python identifier.
fn is_identifier_continue(c : Char) -> Bool {
is_word_char(c)
}
///|
/// Python's `str.isidentifier()` for the ASCII range: non-empty, starts with a
/// letter or `_`, and continues with word characters. Keywords are identifiers
/// for this purpose, which is what `packaging` relies on.
fn is_identifier(text : String) -> Bool {
let chars = text.to_array()
if chars.length() == 0 {
return false
}
if !is_identifier_start(chars[0]) {
return false
}
for i = 1; i < chars.length(); i = i + 1 {
if !is_identifier_continue(chars[i]) {
return false
}
}
true
}
///|
/// ```mbt check
/// test {
/// inspect(
/// @moon_pyversion.canonicalize_name("Flask_Login"),
/// content="flask-login",
/// )
/// }
/// ```
pub fn canonicalize_name(name : String) -> String {
let out : Array[Char] = []
let mut pending_separator = false
for c in name.to_lower() {
if is_name_separator(c) {
pending_separator = true
} else {
if pending_separator {
out.push('-')
pending_separator = false
}
out.push(c)
}
}
if pending_separator {
out.push('-')
}
String::from_array(out)
}
///|
/// Release segments without trailing zeros, but never empty: `_TrimmedRelease`
/// in `packaging`, which "leaves one 0".
fn trim_release(release : Array[@bigint.BigInt]) -> Array[@bigint.BigInt] {
let mut keep = release.length()
while keep > 1 && release[keep - 1].compare(@bigint.BigInt::from_int(0)) == 0 {
keep = keep - 1
}
let out : Array[@bigint.BigInt] = []
for i = 0; i < keep; i = i + 1 {
out.push(release[i])
}
out
}
///|
/// PEP 625 canonical version form. With `strip_trailing_zero` (the default)
/// the release segment loses its trailing zeros, which is the form used as an
/// index lookup key; without it the spelling is only normalized.
///
/// Invalid versions are returned unaltered rather than raising, matching
/// `packaging.utils.canonicalize_version`.
///
/// ```moonbit nocheck
/// canonicalize_version("1.0.0") // "1"
/// canonicalize_version("1.4.0.0.0") // "1.4"
/// canonicalize_version("1.0.0.post0") // "1.post0"
/// canonicalize_version("1!2.0") // "1!2"
/// canonicalize_version("0.0") // "0"
/// canonicalize_version("nope") // "nope"
/// canonicalize_version("1.0.0", strip_trailing_zero=false) // "1.0.0"
/// ```
pub fn canonicalize_version(
version : String,
strip_trailing_zero? : Bool = true,
) -> String {
match parse_version_opt(version) {
Some(v) =>
if strip_trailing_zero {
Version::to_string({ ..v, release: trim_release(v.release), })
} else {
Version::to_string(v)
}
None => version
}
}
///|
fn parse_version_opt(raw : String) -> Version? {
Some(Version::parse(raw)) catch {
_ => None
}
}
///|
/// Split `text` on `-` into at most `max_parts` pieces, the remainder staying
/// in the last piece (`str.split(sep, maxsplit)`).
fn split_limited(text : String, max_parts : Int) -> Array[String] {
let out : Array[String] = []
let chars = text.to_array()
let current : Array[Char] = []
for i = 0; i < chars.length(); i = i + 1 {
if chars[i] == '-' && out.length() < max_parts - 1 {
out.push(String::from_array(current))
current.clear()
} else {
current.push(chars[i])
}
}
out.push(String::from_array(current))
out
}
///|
fn count_char(text : String, needle : Char) -> Int {
let mut count = 0
for c in text {
if c == needle {
count = count + 1
}
}
count
}
///|
/// A parsed wheel filename: a normalized project name, the version, the
/// optional PEP 427 build tag split into `(leading digits, remainder)` and the
/// expanded tag set.
pub(all) struct WheelFilename {
name : String
version : Version
build : (Int, String)?
tags : Array[String]
} derive(Eq, @debug.Debug)
///|
pub extend WheelFilename with Eq::{equal, not_equal}
///|
pub extend WheelFilename with @debug.Debug::{to_repr}
///|
/// A parsed source distribution filename.
pub(all) struct SdistFilename {
name : String
version : Version
} derive(Eq, @debug.Debug)
///|
pub extend SdistFilename with Eq::{equal, not_equal}
///|
pub extend SdistFilename with @debug.Debug::{to_repr}
///|
/// Parse a PEP 427 wheel filename. Only the compressed tag set is expanded:
/// the returned tags are strings in `interpreter-abi-platform` form, sorted and
/// deduplicated, which is the stable projection of `packaging`'s frozenset of
/// `Tag` objects.
///
/// ```moonbit nocheck
/// let w = parse_wheel_filename("numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.whl")
/// w.name // "numpy"
/// w.version.to_string() // "1.26.4"
/// w.build // None
/// w.tags // ["cp312-cp312-manylinux_2_17_x86_64"]
/// ```
pub fn parse_wheel_filename(
filename : String,
) -> WheelFilename raise VersionError {
let stem = match filename.strip_suffix(".whl") {
Some(view) => view.to_owned()
None => raise VersionError::InvalidFilename("WHEEL_EXTENSION", 0)
}
let dashes = count_char(stem, '-')
if dashes != 4 && dashes != 5 {
raise VersionError::InvalidFilename("WHEEL_PART_COUNT", 0)
}
let parts = split_limited(stem, dashes - 1)
let name_part = parts[0]
if name_part.contains("__") || !is_valid_wheel_name(name_part) {
raise VersionError::InvalidFilename("WHEEL_PROJECT_NAME", 0)
}
let name = canonicalize_name(name_part)
let version = match parse_version_opt(parts[1]) {
Some(v) => v
None => raise VersionError::InvalidFilename("WHEEL_VERSION", 0)
}
let build : (Int, String)? = if dashes == 5 {
let build_part = parts[2]
match split_build_tag(build_part) {
Some(tag) => Some(tag)
None => raise VersionError::InvalidFilename("WHEEL_BUILD_TAG", 0)
}
} else {
None
}
let tags = parse_tag_set(parts[parts.length() - 1])
{ name, version, build, tags, }
}
///|
/// `[\w._]+` restricted to ASCII, and non-empty.
fn is_valid_wheel_name(name : String) -> Bool {
let chars = name.to_array()
if chars.length() == 0 {
return false
}
for c in chars {
if !is_wheel_name_char(c) {
return false
}
}
true
}
///|
/// PEP 427 build tag: leading ASCII digits plus an arbitrary remainder.
fn split_build_tag(text : String) -> (Int, String)? {
let chars = text.to_array()
if chars.length() == 0 || !chars[0].is_ascii_digit() {
return None
}
let mut end = 0
while end < chars.length() && chars[end].is_ascii_digit() {
end = end + 1
}
let digits = String::from_array(chars[:end].to_owned())
let rest = String::from_array(chars[end:].to_owned())
Some((@string.parse_int(digits), rest)) catch {
_ => None
}
}
///|
/// Expand a compressed tag set (`py2.py3-none-any`) into sorted, deduplicated
/// `interpreter-abi-platform` strings. Rejects empty components and
/// interpreters that are not identifiers, and requires exactly three
/// dash separated components.
///
/// Every component is lowercased, because `packaging`'s `Tag` lowercases all
/// three in its constructor: `CP312-CP312-Manylinux_2_17_X86_64` and
/// `cp312-cp312-manylinux_2_17_x86_64` are the same tag, and the corpus
/// projection is the lowercase spelling. A wheel filename is allowed to spell
/// them either way, so this is not a leniency -- it is the normalization the
/// reference implementation performs.
fn parse_tag_set(tag : String) -> Array[String] raise VersionError {
let components = tag.split("-")
let groups : Array[Array[String]] = []
for component in components {
let parts : Array[String] = []
for part in component.split(".") {
let text = part.to_owned()
if text.is_empty() {
raise VersionError::InvalidFilename("TAG_EMPTY_COMPONENT", 0)
}
parts.push(text.to_lower())
}
groups.push(parts)
}
if groups.length() != 3 {
raise VersionError::InvalidFilename("TAG_COMPONENT_COUNT", 0)
}
for interpreter in groups[0] {
if !is_identifier(interpreter) {
raise VersionError::InvalidFilename("TAG_INTERPRETER", 0)
}
}
let out : Array[String] = []
for interpreter in groups[0] {
for abi in groups[1] {
for platform in groups[2] {
let one = "\{interpreter}-\{abi}-\{platform}"
if !out.contains(one) {
out.push(one)
}
}
}
}
// `Array::sort` would order by length first for strings, which is not the
// code point order that packaging's `sorted()` produces.
out.sort_by(fn(a, b) { String::lexical_compare(a, b) })
out
}
///|
/// Parse a PEP 625 source distribution filename. The name is normalized; the
/// version must be a valid PEP 440 version. The stem is split on the last
/// dash, because a PEP 440 version cannot contain one.
///
/// ```moonbit nocheck
/// let s = parse_sdist_filename("flask-3.0.3.tar.gz")
/// s.name // "flask"
/// s.version.to_string() // "3.0.3"
/// ```
pub fn parse_sdist_filename(
filename : String,
) -> SdistFilename raise VersionError {
let stem = match filename.strip_suffix(".tar.gz") {
Some(view) => view.to_owned()
None =>
match filename.strip_suffix(".zip") {
Some(view) => view.to_owned()
None => raise VersionError::InvalidFilename("SDIST_EXTENSION", 0)
}
}
let chars = stem.to_array()
let mut cut = -1
for i = chars.length() - 1; i >= 0; i = i - 1 {
if chars[i] == '-' {
cut = i
break
}
}
if cut < 0 {
raise VersionError::InvalidFilename("SDIST_NO_SEPARATOR", 0)
}
if cut == 0 {
raise VersionError::InvalidFilename("SDIST_EMPTY_NAME", 0)
}
let name = canonicalize_name(String::from_array(chars[:cut].to_owned()))
let version_part = String::from_array(chars[cut + 1:].to_owned())
let version = match parse_version_opt(version_part) {
Some(v) => v
None => raise VersionError::InvalidFilename("SDIST_VERSION", 0)
}
{ name, version, }
}