Skip to content

Commit 905c90c

Browse files
r266-techhenryiiibrettcannon
authored
feat: option to validate compressed tag set sort order in parse_wheel_filename (#1150)
* fix(utils): validate compressed tag set sort order per PEP 425 (closes #909) * tests(utils): add tests for compressed tag set sort order validation * tests(utils): add tests for compressed tag set sort order validation * refactor: move compressed tag set sorted-order check into parse_tag Per review suggestion from pradyunsg: the PEP 425 sorted-order invariant is now enforced at the parse_tag level, raising ValueError when a compressed tag set component is not sorted. parse_wheel_filename catches this and re-raises as InvalidWheelFilename with the full filename for better error context. * refactor: move compressed tag set sorted-order check into parse_tag Per review suggestion from pradyunsg: the PEP 425 sorted-order invariant is now enforced at the parse_tag level, raising ValueError when a compressed tag set component is not sorted. parse_wheel_filename catches this and re-raises as InvalidWheelFilename with the full filename for better error context. * refactor: move compressed tag set sorted-order check into parse_tag Per review suggestion from pradyunsg: the PEP 425 sorted-order invariant is now enforced at the parse_tag level, raising ValueError when a compressed tag set component is not sorted. parse_wheel_filename catches this and re-raises as InvalidWheelFilename with the full filename for better error context. * Make sorted-tag validation opt-in via validate_order parameter Per review feedback: 370K+ wheels on PyPI have unsorted compressed tag sets, so the check must be opt-in for backwards compatibility. - Add validate_order=False to parse_tag() and parse_wheel_filename() - Only check sorted order when validate_order=True - Fix exception chaining (raise ... from None) - Update tests: unsorted tags parse by default, rejected with validate * style: fix lint (trailing newline + line length) * chore: one more edit from prek Signed-off-by: Henry Schreiner <henryfs@princeton.edu> * refactor: use UnsortedTagsError Signed-off-by: Henry Schreiner <henryfs@princeton.edu> --------- Signed-off-by: Henry Schreiner <henryfs@princeton.edu> Co-authored-by: r266-tech <r266-tech@users.noreply.github.com> Co-authored-by: Henry Schreiner <henryfs@princeton.edu> Co-authored-by: Brett Cannon <brett@python.org>
1 parent af0026c commit 905c90c

4 files changed

Lines changed: 107 additions & 3 deletions

File tree

src/packaging/tags.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"AppleVersion",
3737
"PythonVersion",
3838
"Tag",
39+
"UnsortedTagsError",
3940
"android_platforms",
4041
"compatible_tags",
4142
"cpython_tags",
@@ -79,6 +80,12 @@ def _compute_32_bit_interpreter() -> bool:
7980
_32_BIT_INTERPRETER = _compute_32_bit_interpreter()
8081

8182

83+
class UnsortedTagsError(ValueError):
84+
"""
85+
Raised when a tag component is not in sorted order per PEP 425.
86+
"""
87+
88+
8289
class Tag:
8390
"""
8491
A representation of the tag triple for a wheel.
@@ -159,7 +166,7 @@ def __setstate__(self, state: tuple[None, dict[str, Any]]) -> None:
159166
self._hash = hash((self._interpreter, self._abi, self._platform))
160167

161168

162-
def parse_tag(tag: str) -> frozenset[Tag]:
169+
def parse_tag(tag: str, *, validate_order: bool = False) -> frozenset[Tag]:
163170
"""
164171
Parses the provided tag (e.g. `py3-none-any`) into a frozenset of
165172
:class:`Tag` instances.
@@ -168,10 +175,27 @@ def parse_tag(tag: str) -> frozenset[Tag]:
168175
`compressed tag set`_, e.g. ``"py2.py3-none-any"`` which supports both
169176
Python 2 and Python 3.
170177
178+
If **validate_order** is true, compressed tag set components are checked
179+
to be in sorted order as required by PEP 425.
180+
171181
:param str tag: The tag to parse, e.g. ``"py3-none-any"``.
182+
:param bool validate_order: Check whether compressed tag set components
183+
are in sorted order.
184+
:raises UnsortedTagsError: If **validate_order** is true and any compressed tag
185+
set component is not in sorted order.
186+
187+
.. versionadded:: 26.1
188+
The *validate_order* parameter.
172189
"""
173190
tags = set()
174191
interpreters, abis, platforms = tag.split("-")
192+
if validate_order:
193+
for component in (interpreters, abis, platforms):
194+
parts = component.split(".")
195+
if parts != sorted(parts):
196+
raise UnsortedTagsError(
197+
f"Tag component {component!r} is not in sorted order per PEP 425"
198+
)
175199
for interpreter in interpreters.split("."):
176200
for abi in abis.split("."):
177201
for platform_ in platforms.split("."):

src/packaging/utils.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import re
88
from typing import NewType, Tuple, Union, cast
99

10-
from .tags import Tag, parse_tag
10+
from .tags import Tag, UnsortedTagsError, parse_tag
1111
from .version import InvalidVersion, Version, _TrimmedRelease
1212

1313
__all__ = [
@@ -156,6 +156,8 @@ def canonicalize_version(
156156

157157
def parse_wheel_filename(
158158
filename: str,
159+
*,
160+
validate_order: bool = False,
159161
) -> tuple[NormalizedName, Version, BuildTag, frozenset[Tag]]:
160162
"""
161163
This function takes the filename of a wheel file, and parses it,
@@ -171,7 +173,12 @@ def parse_wheel_filename(
171173
string format allows multiple tags to be combined into a single
172174
string).
173175
176+
If **validate_order** is true, compressed tag set components are
177+
checked to be in sorted order as required by PEP 425.
178+
174179
:param str filename: The name of the wheel file.
180+
:param bool validate_order: Check whether compressed tag set components
181+
are in sorted order.
175182
:raises InvalidWheelFilename: If the filename in question
176183
does not follow the :ref:`wheel specification
177184
<pypug:binary-distribution-format>`.
@@ -188,6 +195,9 @@ def parse_wheel_filename(
188195
True
189196
>>> not build
190197
True
198+
199+
.. versionadded:: 26.1
200+
The *validate_order* parameter.
191201
"""
192202
if not filename.endswith(".whl"):
193203
raise InvalidWheelFilename(
@@ -225,7 +235,14 @@ def parse_wheel_filename(
225235
build = cast("BuildTag", (int(build_match.group(1)), build_match.group(2)))
226236
else:
227237
build = ()
228-
tags = parse_tag(parts[-1])
238+
tag_str = parts[-1]
239+
try:
240+
tags = parse_tag(tag_str, validate_order=validate_order)
241+
except UnsortedTagsError:
242+
raise InvalidWheelFilename(
243+
f"Invalid wheel filename (compressed tag set components must be in "
244+
f"sorted order per PEP 425): {filename!r}"
245+
) from None
229246
return (name, version, build, tags)
230247

231248

tests/test_tags.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,35 @@ def test_multi_platform(self) -> None:
167167
)
168168
assert given == expected
169169

170+
def test_unsorted_interpreter_parses_by_default(self) -> None:
171+
# Unsorted tags should parse fine without validate_order
172+
result = tags.parse_tag("py3.py2-none-any")
173+
assert tags.Tag("py3", "none", "any") in result
174+
assert tags.Tag("py2", "none", "any") in result
175+
176+
def test_unsorted_interpreter_raises_with_validate(self) -> None:
177+
with pytest.raises(ValueError, match="not in sorted order"):
178+
tags.parse_tag("py3.py2-none-any", validate_order=True)
179+
180+
def test_unsorted_platform_parses_by_default(self) -> None:
181+
result = tags.parse_tag(
182+
"cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64"
183+
)
184+
assert len(result) == 2
185+
186+
def test_unsorted_platform_raises_with_validate(self) -> None:
187+
with pytest.raises(ValueError, match="not in sorted order"):
188+
tags.parse_tag(
189+
"cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64",
190+
validate_order=True,
191+
)
192+
193+
def test_sorted_multi_interpreter_valid(self) -> None:
194+
# py2 < py3 alphabetically — should not raise even with validation
195+
result = tags.parse_tag("py2.py3-none-any", validate_order=True)
196+
assert tags.Tag("py2", "none", "any") in result
197+
assert tags.Tag("py3", "none", "any") in result
198+
170199

171200
class TestInterpreterName:
172201
def test_sys_implementation_name(self, monkeypatch: pytest.MonkeyPatch) -> None:

tests/test_utils.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,16 @@ def test_canonicalize_version_no_strip_trailing_zero(version: str) -> None:
137137
(1000, "abc"),
138138
{Tag("py3", "none", "any")},
139139
),
140+
(
141+
"pyvirtualcam-0.13.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl",
142+
"pyvirtualcam",
143+
Version("0.13.0"),
144+
(),
145+
{
146+
Tag("cp310", "cp310", "manylinux2014_x86_64"),
147+
Tag("cp310", "cp310", "manylinux_2_17_x86_64"),
148+
},
149+
),
140150
(
141151
"foo_bár-1.0-py3-none-any.whl",
142152
"foo-bár",
@@ -177,6 +187,30 @@ def test_parse_wheel_invalid_filename(filename: str) -> None:
177187
parse_wheel_filename(filename)
178188

179189

190+
@pytest.mark.parametrize(
191+
"filename",
192+
[
193+
"pyvirtualcam-0.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
194+
"foo-1.0-py3.py2-none-any.whl",
195+
],
196+
)
197+
def test_parse_wheel_unsorted_tags_valid_by_default(filename: str) -> None:
198+
# Unsorted compressed tags should parse fine without validate_order
199+
parse_wheel_filename(filename)
200+
201+
202+
@pytest.mark.parametrize(
203+
"filename",
204+
[
205+
"pyvirtualcam-0.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
206+
"foo-1.0-py3.py2-none-any.whl",
207+
],
208+
)
209+
def test_parse_wheel_unsorted_tags_invalid_with_validate(filename: str) -> None:
210+
with pytest.raises(InvalidWheelFilename):
211+
parse_wheel_filename(filename, validate_order=True)
212+
213+
180214
@pytest.mark.parametrize(
181215
("filename", "name", "version"),
182216
[("foo-1.0.tar.gz", "foo", Version("1.0")), ("foo-1.0.zip", "foo", Version("1.0"))],

0 commit comments

Comments
 (0)