Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
251 changes: 251 additions & 0 deletions src/packaging/filenames.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
from __future__ import annotations

import re
from typing import cast

from .tags import parse_tag
from .utils import (
BuildTag,
InvalidFilename,
InvalidSdistFilename,
InvalidWheelFilename,
_build_tag_regex,
canonicalize_name,
is_normalized_name,
)
from .version import InvalidVersion, Version

__all__ = [
"Filename",
"InvalidFilename",
"InvalidSdistFilename",
"InvalidWheelFilename",
"SourceFilename",
"WheelFilename",
]


def __dir__() -> list[str]:
return __all__


class Filename:
def __init__(self, *a: object, **kw: object) -> None:
raise NotImplementedError("Use a WheelFilename or SourceFilename instead")

@classmethod
def from_filename(cls, filename: str) -> Filename:
if filename.endswith(".whl"):
return WheelFilename.from_filename(filename)
elif filename.endswith(".tar.gz"):
return SourceFilename.from_filename(filename)
else:
raise InvalidFilename(
"Invalid filename (extension must be '.whl' or '.tar.gz'): "
f"{filename!r}"
)


class WheelFilename(Filename):
def __init__(
self,
name: str,
version: str,
build_tag: str | None,
python_tag: str,
abi_tag: str,
platform_tag: str,
strict: bool = True,
) -> None:
self.original_name = name
self.original_version = version

filename = self._to_filename(
name, version, build_tag, python_tag, abi_tag, platform_tag
)

# See PEP 427 for the rules on escaping the project name.
if (strict and "__" in name) or re.match(
r"^[\w\d._]*$", name, re.UNICODE
) is None:
raise InvalidWheelFilename(
f"Invalid filename (invalid project name {name!r}): {filename!r}"
)

self.name = canonicalize_name(name).replace("-", "_")

# Check that the name is normalized
if strict and self.original_name != self.name:
raise InvalidWheelFilename(
f"Invalid filename (non-normalized project name {name!r}): {filename!r}"
)
Comment on lines +75 to +81
Copy link
Copy Markdown
Member

@notatallshaw notatallshaw Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pip will almost always use strict=False, which is spec complaint:

Tools consuming wheels must be prepared to accept . (FULL STOP) and uppercase letters, however, as these were allowed by an earlier version of this specification.

So we do not want to call canonicalize_name on every instantiation of WheelFilename, perhaps something like:

    if not strict:
        self._name = None
    else:
        # Check that the name is normalized
        self._name = canonicalize_name(name).replace("-", "_")
        if strict and self.original_name != self._name:
            raise InvalidWheelFilename(
                f"Invalid filename (non-normalized project name {name!r}): {filename!r}"
            )
    ...

    @property
    def name(self) -> NormalizedName:
        if self._name is None:
            self._name = canonicalize_name(name).replace("-", "_")
        return self._name


try:
self.version = Version(version)
except InvalidVersion as e:
raise InvalidWheelFilename(
f"Invalid filename (invalid version {version!r}): {filename!r}"
) from e

# Check that the version is normalized
if strict and version != str(self.version):
raise InvalidWheelFilename(
f"Invalid filename (non-normalized version {version!r}): {filename!r}"
)

if build_tag:
build_match = _build_tag_regex.match(build_tag)
if build_match is None:
raise InvalidWheelFilename(
f"Invalid filename (invalid build number {build_tag!r}): "
f"{filename!r}"
)
self.build_tag = cast(
"BuildTag", (int(build_match.group(1)), build_match.group(2))
)
else:
self.build_tag = ()

self.python_tag = python_tag
self.abi_tag = abi_tag
self.platform_tag = platform_tag
self.tags = parse_tag(f"{python_tag}-{abi_tag}-{platform_tag}")

def _to_filename(
self,
name: str,
version: str | Version,
build_tag: str | BuildTag | None,
python_tag: str,
abi_tag: str,
platform_tag: str,
) -> str:
return (
"-".join(
part
for part in [
name,
str(version),
(
"".join(str(x) for x in build_tag)
if isinstance(build_tag, tuple)
else build_tag
),
python_tag,
abi_tag,
platform_tag,
]
if part
)
+ ".whl"
)

def __str__(self) -> str:
return self._to_filename(
self.name,
self.version,
self.build_tag,
self.python_tag,
self.abi_tag,
self.platform_tag,
)

@classmethod
def from_filename(cls, filename: str) -> WheelFilename:
if not filename.endswith(".whl"):
raise InvalidWheelFilename(
f"Invalid filename (extension must be '.whl'): {filename!r}"
)

dashes = filename.count("-")
if dashes not in (4, 5):
raise InvalidWheelFilename(
f"Invalid filename (wrong number of parts): {filename!r}"
)

filename = filename[: -len(".whl")]

# There is no build tag
if dashes == 4:
name, version, python_tag, abi_tag, platform_tag = filename.split("-")
build_tag = None

# There is a build tag
if dashes == 5:
(
name,
version,
build_tag,
python_tag,
abi_tag,
platform_tag,
) = filename.split("-")

return cls(name, version, build_tag, python_tag, abi_tag, platform_tag)


class SourceFilename(Filename):
def __init__(self, name: str, version: str, strict: bool = True) -> None:
# Store the values that were originally passed for use externally
self.original_name = name
self.original_version = version

filename = self._to_filename(name, version)

# Check that the name is normalized
if not is_normalized_name(canonicalize_name(name)):
raise InvalidSdistFilename(
f"Invalid filename (invalid project name {name!r}): {filename!r}"
)
self.name = canonicalize_name(name).replace("-", "_")
# PEP 625: The name must only contain underscores
if strict and self.original_name != self.name:
raise InvalidSdistFilename(
f"Invalid filename (non-normalized project name {name!r}): {filename!r}"
)

# Check that the version is valid
try:
self.version = Version(version)
except InvalidVersion as e:
raise InvalidSdistFilename(
f"Invalid filename (invalid version {version!r}): {filename!r}"
) from e
# Check that the version is normalized
if strict and version != str(self.version):
raise InvalidSdistFilename(
f"Invalid filename (non-normalized version {version!r}): {filename!r}"
)

def _to_filename(self, name: str, version: str | Version) -> str:
return f"{name}-{version}.tar.gz"

def __str__(self) -> str:
return self._to_filename(self.name, self.version)

@classmethod
def from_filename(cls, filename: str) -> SourceFilename:
# PEP 625: Source distributions must end with .tar.gz
if not filename.endswith(".tar.gz"):
raise InvalidSdistFilename(
f"Invalid filename (extension must be '.tar.gz'): {filename!r}"
)

# PEP 625: Source distributions may only have one hyphen, separating
# the name and version
if filename.count("-") > 1:
raise InvalidSdistFilename(
"Invalid filename (name and version parts can not contain hyphens): "
f"{filename!r}"
)

if filename.count("-") == 0:
raise InvalidSdistFilename(
"Invalid filename (hyphen must separate name and version parts): "
f"{filename!r}"
)

# Split the filename into name & version
name, version = filename[: -len(".tar.gz")].split("-")

return cls(name, version)
10 changes: 8 additions & 2 deletions src/packaging/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,19 @@ class InvalidName(ValueError):
"""


class InvalidWheelFilename(ValueError):
class InvalidFilename(ValueError):
"""
.
"""


class InvalidWheelFilename(InvalidFilename):
"""
An invalid wheel filename was found, users should refer to PEP 427.
"""


class InvalidSdistFilename(ValueError):
class InvalidSdistFilename(InvalidFilename):
"""
An invalid sdist filename was found, users should refer to the packaging user guide.
"""
Expand Down
Loading
Loading