Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Features:
representing the versions a specifier set accepts as an interval set that
supports intersection, union, complement, membership tests, and filtering.
(:pull:`1267`)
* Add a ``limit`` argument to ``parse_tag()`` for compressed tag sets.
(:issue:`1220`)

Behavior adaptations:

Expand Down
2 changes: 2 additions & 0 deletions docs/tags.rst
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ items that applications should need to reference, in order to parse and check ta

.. autofunction:: create_compatible_tags_selector

.. autoexception:: TooManyTagsError

.. autoexception:: UnsortedTagsError

.. autoexception:: InvalidTag
Expand Down
36 changes: 35 additions & 1 deletion src/packaging/tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"InvalidTag",
"PythonVersion",
"Tag",
"TooManyTagsError",
"UnsortedTagsError",
"android_platforms",
"compatible_tags",
Expand Down Expand Up @@ -100,6 +101,14 @@ class InvalidTag(ValueError):
"""


class TooManyTagsError(ValueError):
"""
Raised when a compressed tag set exceeds the configured limit.

.. versionadded:: 26.3
"""


class Tag:
"""
A representation of the tag triple for a wheel.
Expand Down Expand Up @@ -215,7 +224,9 @@ def __setstate__(self, state: object) -> None:
raise TypeError(f"Cannot restore Tag from {state!r}")


def parse_tag(tag: str, *, validate_order: bool = False) -> frozenset[Tag]:
def parse_tag(
tag: str, *, validate_order: bool = False, limit: int | None = None
) -> frozenset[Tag]:
"""
Parses the provided tag (e.g. `py3-none-any`) into a frozenset of
:class:`Tag` instances.
Expand All @@ -227,20 +238,33 @@ def parse_tag(tag: str, *, validate_order: bool = False) -> frozenset[Tag]:
If **validate_order** is true, compressed tag set components are checked
to be in sorted order as required by PEP 425.

If **limit** is not ``None``, the compressed tag set can generate at most
that many tags.

:param str tag: The tag to parse, e.g. ``"py3-none-any"``.
:param bool validate_order: Check whether compressed tag set components
are in sorted order.
: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, ABI, or platform field (or any member
of a compressed tag set) is empty.
:raises TooManyTagsError: If **limit** is not ``None`` and the compressed tag
set would generate more than **limit** tags.
:raises ValueError: If **limit** is negative.

.. versionadded:: 26.1
The *validate_order* parameter.

.. versionadded:: 26.3
Raises :class:`InvalidTag` on empty tag components.
Added the *limit* parameter. Raises :class:`TooManyTagsError` if the compressed
tag set would generate more than *limit* tags.
"""

if limit is not None and limit < 0:
raise ValueError("limit must be non-negative")

component_parts = [component.split(".") for component in tag.split("-")]
for parts in component_parts:
if "" in parts:
Expand All @@ -252,6 +276,16 @@ def parse_tag(tag: str, *, validate_order: bool = False) -> frozenset[Tag]:
f"Tag component {component!r} is not in sorted order per PEP 425"
)

tag_count = 1
for parts in component_parts:
tag_count *= len(parts)

if limit is not None and tag_count > limit:
raise TooManyTagsError(
f"Compressed tag set would generate {tag_count} tags, exceeding "
f"limit {limit}"
)

interpreters, abis, platforms = component_parts
return frozenset(
Tag(interpreter, abi, platform_)
Expand Down
23 changes: 23 additions & 0 deletions tests/test_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,29 @@ def test_multi_platform(self) -> None:
)
assert given == expected

def test_limit_allows_exact_number_of_tags(self) -> None:
expected = {tags.Tag("py2", "none", "any"), tags.Tag("py3", "none", "any")}
given = tags.parse_tag("py2.py3-none-any", limit=2)
assert given == expected

def test_limit_raises_when_exceeded(self) -> None:
with pytest.raises(tags.TooManyTagsError, match="would generate 2 tags"):
tags.parse_tag("py2.py3-none-any", limit=1)

def test_limit_counts_cartesian_product_entries(self) -> None:
# "py3.py3" generates two entries, even though both collapse to the
# same Tag in the returned frozenset.
with pytest.raises(tags.TooManyTagsError, match="would generate 2 tags"):
tags.parse_tag("py3.py3-none-any", limit=1)
Comment thread
brettcannon marked this conversation as resolved.

def test_limit_rejects_negative_value(self) -> None:
with pytest.raises(ValueError, match="limit must be non-negative"):
tags.parse_tag("py3-none-any", limit=-1)

def test_limit_can_be_combined_with_validate_order(self) -> None:
with pytest.raises(ValueError, match="not in sorted order"):
tags.parse_tag("py3.py2-none-any", validate_order=True, limit=2)

def test_unsorted_interpreter_parses_by_default(self) -> None:
# Unsorted tags should parse fine without validate_order
result = tags.parse_tag("py3.py2-none-any")
Expand Down
Loading