Skip to content

Bug: parse_wheel_filenames accepts wheel filenames with unsorted compressed tag sets #909

Description

@woodruffw

Hello packaging maintainers!

I'm filing this as a bug report for some observed behavior.

TL;DR: parse_wheel_filename appears to allow compressed tag sets (defined in PEP 425) to appear in any order, while the PEP and living spec both require the order to be "sorted" (my emphasis below):

To allow for compact filenames of bdists that work with more than one compatibility tag triple, each tag in a filename can instead be a ‘.’-separated, sorted, set of tags. For example, pip, a pure-Python package that is written to run under Python 2 and 3 with the same source code, could distribute a bdist with the tag py2.py3-none-any.

(Permalinks: https://peps.python.org/pep-0425/#compressed-tag-sets and https://packaging.python.org/en/latest/specifications/platform-compatibility-tags/#compressed-tag-sets)

This can be seen by using parse_wheel_filename to parse a wheel whose compressed tags are out of order:

from packaging.utils import parse_wheel_filename

name = "pyvirtualcam-0.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"
parse_wheel_filename(name) 

Actual behavior

The above call yields:

('pyvirtualcam', <Version('0.13.0')>, (), frozenset({<cp310-cp310-manylinux_2_17_x86_64 @ 4370315200>, <cp310-cp310-manylinux2014_x86_64 @ 4370315520>}))

Expected behavior

I expected the above the raise a (subclass of) ValueError, since the compressed tag set appears in the wrong order in the wheel's filename.

The right order would be: pyvirtualcam-0.13.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.

Additional context

This was noticed due to an interaction of a few different components in pypi/warehouse#18128:

  1. A user built a wheel (named pyvirtualcam-0.13.0-cp310-cp310-linux_x86_64.whl);
  2. They called auditwheel repair on the wheel, which re-tagged it as pyvirtualcam-0.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl). This tag ordering is incorrect per PEP 425, and is being tracked against auditwheel with Compressed tag sets are not sorted when rewriting wheel filename. auditwheel#583;
  3. packaging doesn't warn or fail with the new wheel distribution name, per this bug report;
  4. pypi-attestations then "ultranormalized" the wheel name for signing purposes, fixing it up to pyvirtualcam-0.13.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
  5. Finally, the end user attempted to retrieve their provenance metadata via https://pypi.org/integrity/pyvirtualcam/0.13.0/pyvirtualcam-0.13.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl/provenance and were confused when it wasn't present (since it's actually present at https://pypi.org/integrity/pyvirtualcam/0.13.0/pyvirtualcam-0.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl/provenance)

TL;DR: multiple components behaved in slightly different ways here, resulting in maximum confusion:

  1. auditwheel repair produced an incorrect tag ordering;
  2. packaging silently honored that incorrect tag ordering, causing PyPI to also honor it;
  3. pypi-attestations attempted to "fix" it per PEP 425, leading to a correct tag ordering but one that confused the user (since all other components used the invalid-but-accepted one). This last part has been fixed with fix: remove ultranormalization of distribution filenames pypi/pypi-attestations#124

See also #873 for a related (but different) issue in wheel filename parsing 🙂

CC @di for viz

Activity

  1. brettcannon commented on Jun 20, 2025

    @brettcannon
    Member

    I don't know if I forgot about this or always glossed over the fact. 😅

    This would probably require updating:

    def parse_tag(tag: str) -> frozenset[Tag]:
    """
    Parses the provided tag (e.g. `py3-none-any`) into a frozenset of Tag instances.
    Returning a set is required due to the possibility that the tag is a
    compressed tag set.
    """
    tags = set()
    interpreters, abis, platforms = tag.split("-")
    for interpreter in interpreters.split("."):
    for abi in abis.split("."):
    for platform_ in platforms.split("."):
    tags.add(Tag(interpreter, abi, platform_))
    return frozenset(tags)

  2. added a commit that references this issue on Apr 7, 2026
    9f30c27
  3. dstufft commented on Apr 7, 2026

    @dstufft
    Member

    Do we know how many wheels on PyPI are affected by this? While this is technically a bug fix, it's a bug fix that is a breaking change and we probably want to consider what the impact of fixing this bug is.

  4. henryiii commented on Apr 7, 2026

    @henryiii
    Contributor

    Basically every manylinux wheel for something like a couple years? I forget exactly when the bug was fixed, but it used to be that auditwheel produced multiple tags in the wrong order.

  5. dstufft commented on Apr 7, 2026

    @dstufft
    Member

    Then we probably can't enforce this in any API that consumers (like pip) are going to use? That would break a ton of wheels?

    We could enforce it in PyPI though.

  6. henryiii commented on Apr 8, 2026

    @henryiii
    Contributor

    Yes. PyPI already started enforcing normalized sdist names, so this would be similar.

    I think this would make sense in #1113 as either always-chose or opt-out (IIRC I might have already added it there), but it should be opt-in for any of the legacy APIs.

    There are at least 370,069 wheels uploaded to PyPI (out of the 1,023,6875 wheels I checked with a compressed tag set) that have unsorted compressed tags.

    Copilot CLI gpt-5-mini prompt

    I have a urls table with a url field in @pypi-data.sqlite that I want to process. I want to get the filename from each,   
    and then see if there are unsorted tag sets in the wheel names. To make it simpler, we can filter anything that doesn't   
    end in `.whl`, and we can remove anything that doesn't have a compressed tag set (no `.` inside the filename). For the    
    things with a dot, we are checking simple lexographic sorting between the surrounding `-` characters. If using Python, the
     packaging library can parse filenames and get tag sets, but it's also easy to do this manually. How many unsorted tag    
    sets are in this dataset?
    

    Script generated

    Missing newlines.

    import sqlite3, urllib.parse, os, sys p='/Users/henryschreiner/Downloads/pypi-data.sqlite' conn=sqlite3.connect(p) 
    cur=conn.cursor() try:  cur.execute("SELECT url FROM urls WHERE url LIKE '%.whl'") except Exception as e: 
    print('ERROR-QUERY', e)  sys.exit(1) count=0 considered=0 for row in cur:  url=row[0]  try: 
    path=urllib.parse.urlparse(url).path  filename=os.path.basename(path)  if not filename.lower().endswith('.whl'):  continue
    name=filename[:-4]  parts=name.split('-')  if len(parts) < 1:  continue  tags=parts[-3:]  # only consider if any tag
    component contains a dot  if not any('.' in t for t in tags):  continue  considered += 1  unsorted=False  for t in tags: 
    if '.' in t:  tokens=t.split('.')  if tokens != sorted(tokens):  unsorted=True  break  if unsorted:  count += 1  except
    Exception:  continue print('UNSORTED_COUNT', count) print('CONSIDERED', considered)
    

  7. henryiii commented on Apr 8, 2026

    @henryiii
    Contributor

    #1150 adds opt-in validation. I'd like to go with that for 26.1 unless there are objections!

  8. added a commit that references this issue on Apr 8, 2026
    3a4729b
  9. added a commit that references this issue on Apr 9, 2026
    a434a87
  10. added a commit that references this issue on Apr 10, 2026
    905c90c
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions