Skip to content
Open
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
30 changes: 27 additions & 3 deletions src/packaging/tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,13 @@ def __init__(self, interpreter: str, abi: str, platform: str) -> None:
:param str abi: The ABI that a wheel supports, e.g. ``"cp37m"``.
:param str platform: The OS/platform the wheel supports,
e.g. ``"win_amd64"``.
:raises InvalidTag: If any component is empty.
"""
if not interpreter or not abi or not platform:
raise InvalidTag(
f"Tag components must be non-empty "
f"(got interpreter={interpreter!r}, abi={abi!r}, platform={platform!r})"
)
self._interpreter = interpreter.lower()
self._abi = abi.lower()
self._platform = platform.lower()
Expand Down Expand Up @@ -231,6 +237,15 @@ def __setstate__(self, state: object) -> None:
raise TypeError(f"Cannot restore Tag from {state!r}")


# ABI is not a Python identifier. The compatibility-tag spec allows an
# unstable ABI to be 8 standard base64 characters, so a component may start
# with a digit and may include "+". "/" stays rejected: it is not a wheel
# filename character. "-" stays rejected: it separates filename fields.
# Platform tags are ASCII letters, digits, and underscores.
_ABI_COMPONENT_RE = re.compile(r"[0-9A-Za-z_+]+")
_PLATFORM_COMPONENT_RE = re.compile(r"[0-9A-Za-z_]+")


def parse_tag(
tag: str, *, validate_order: bool = False, limit: int | None = None
) -> frozenset[Tag]:
Expand All @@ -254,9 +269,12 @@ def parse_tag(
:param int | None limit: The maximum number of tags to parse.
:raises UnsortedTagsError: If **validate_order** is true and any compressed tag
set component is not in sorted order.
:raises InvalidTag: If the interpreter field is not an identifier; if the
interpreter, ABI, or platform field (or any member of a compressed tag
set) is empty; or if the tag does not have exactly three components.
:raises InvalidTag: If the interpreter field is not an identifier; if an
ABI field is not letters, digits, underscore, or "+" (including a
leading digit); if a platform field is not letters, digits, and
underscore; if the interpreter, ABI, or platform field (or any member
of a compressed tag set) is empty; or if the tag does not have exactly
three components.
:raises TooManyTagsError: If **limit** is not ``None`` and the compressed tag
set would generate more than **limit** tags.
:raises ValueError: If **limit** is negative.
Expand Down Expand Up @@ -303,6 +321,12 @@ def parse_tag(
for interpreter in interpreters:
if not interpreter.isidentifier():
raise InvalidTag(f"Tag {tag!r} has an invalid interpreter: {interpreter!r}")
for abi in abis:
if _ABI_COMPONENT_RE.fullmatch(abi) is None:
raise InvalidTag(f"Tag {tag!r} has an invalid abi: {abi!r}")
for platform_ in platforms:
if _PLATFORM_COMPONENT_RE.fullmatch(platform_) is None:
raise InvalidTag(f"Tag {tag!r} has an invalid platform: {platform_!r}")
return frozenset(
Tag(interpreter, abi, platform_)
for interpreter in interpreters
Expand Down
37 changes: 37 additions & 0 deletions tests/test_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,43 @@ def test_empty_component_raises(self, tag: str) -> None:
with pytest.raises(tags.InvalidTag, match="empty component"):
tags.parse_tag(tag)

@pytest.mark.parametrize(
"args",
[
("", "none", "any"),
("py3", "", "any"),
("py3", "none", ""),
],
)
def test_tag_constructor_rejects_empty(self, args: tuple[str, str, str]) -> None:
with pytest.raises(tags.InvalidTag, match="non-empty"):
tags.Tag(*args)

@pytest.mark.parametrize(
"tag",
[
"py3-none-any,", # Trailing junk on platform
"py3-none-any ", # Trailing whitespace on platform
"py3-none/-any", # Slash is not a wheel filename character
"py3-none-any+", # Plus is legal in ABI, not in platform
],
)
def test_invalid_abi_or_platform_raises(self, tag: str) -> None:
with pytest.raises(tags.InvalidTag, match="invalid (abi|platform)"):
tags.parse_tag(tag)

def test_digit_leading_abi_parses(self) -> None:
# Spec ABI hashes are base64 and may start with a digit.
assert tags.parse_tag("py3-0abcDEF1-any") == {
tags.Tag("py3", "0abcdef1", "any")
}

def test_plus_in_abi_parses(self) -> None:
# Unstable ABI hashes may be 8 standard base64 characters, including +.
assert tags.parse_tag("py3-0abc+EF1-any") == {
tags.Tag("py3", "0abc+ef1", "any")
}

@pytest.mark.parametrize(
"tag",
[
Expand Down
9 changes: 9 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,13 @@ def test_canonicalize_version_no_strip_trailing_zero(version: str) -> None:
(1000, ""),
{Tag("py3", "none", "any")},
),
(
"foo-1.0-py3-0abc+EF1-any.whl",
"foo",
Version("1.0"),
(),
{Tag("py3", "0abc+ef1", "any")},
),
],
)
def test_parse_wheel_filename(
Expand All @@ -201,6 +208,8 @@ def test_parse_wheel_filename(
("foo-1.0-py3-none-.whl"), # Empty platform component
("foo-1.0-py3.-none-any.whl"), # Empty member in a compressed tag set
("playlyfe-0.1.1-2.7.6-none-any.whl"), # Invalid interpreter components
("foo-1.0-py3-none/-any.whl"), # Slash is not a wheel filename character
("foo-1.0-py3-none-any+.whl"), # Plus is legal in ABI, not in platform
],
)
def test_parse_wheel_invalid_filename(filename: str) -> None:
Expand Down