Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
41e8247
Added an API for working with wheel files
agronholm May 21, 2024
15a8a1f
Updated import
agronholm May 21, 2024
db14129
Shuffled code around and improved test coverage
agronholm May 22, 2024
f41d38c
Added changelog note and stub documentation
agronholm May 22, 2024
ba54f21
Updated default metadata version to 2.3
agronholm Jul 21, 2024
9aa561b
Fixed trailing zeroes missing from version number in make_wheel_filen…
agronholm Jul 31, 2024
00dfcac
Lots of bug fixes, refactorings and coverage improvements
agronholm Aug 2, 2024
d7d9329
Fixed Ruff error
agronholm Apr 26, 2025
d86a85c
Fixed test failures
agronholm Apr 27, 2025
d06a8ef
style: satisfy current lint config after rebase
henryiii Jul 21, 2026
cf2f413
fix(utils): validate version in make_wheel_filename
henryiii Jul 21, 2026
740b8b9
fix(wheelfile): reject path traversal in WheelReader.extractall
henryiii Jul 21, 2026
b708e1f
fix(wheelfile): raise WheelError on malformed RECORD rows
henryiii Jul 21, 2026
414cdf0
test(wheelfile): move valid_wheel to a module-level fixture
henryiii Jul 21, 2026
2fa2c8b
test(wheelfile): cover remaining WheelReader/WheelWriter branches
henryiii Jul 22, 2026
9c1cb3d
fix(wheelfile): make WheelContentElement repr and tests Windows-safe
henryiii Jul 22, 2026
5e7644f
test: re-enable the tmpdir pytest plugin for wheelfile tests
henryiii Oct 5, 2026
5ed447b
fix(wheelfile): fix dist-info naming, modes, timestamps, and ordering
henryiii Oct 7, 2026
1964550
feat(wheelfile): add mode and writer-level timestamp/mode defaults
henryiii Oct 7, 2026
713eb9e
refactor(wheelfile): verify hashes in iterate_contents, validate data…
henryiii Oct 7, 2026
2336714
docs(wheelfile): document the module and add docstrings
henryiii Oct 7, 2026
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
3 changes: 3 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ Removals:

Features:

* Add the ``packaging.wheelfile`` module for reading and creating wheel files.
(:issue:`697`)

* Add a public :class:`~packaging.ranges.VersionRange` API and
:meth:`SpecifierSet.to_range() <packaging.specifiers.SpecifierSet.to_range>`,
representing the versions a specifier set accepts as an interval set that
Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ The ``packaging`` library uses calendar-based versioning (``YY.N``).
requirements
metadata
tags
wheelfile
pylock
direct_url
dependency_groups
Expand Down
111 changes: 111 additions & 0 deletions docs/wheelfile.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
Wheel Files
===========

.. currentmodule:: packaging.wheelfile

Read and write `wheel files`_. The reader checks each file against the
``RECORD`` file as it is read. The writer computes ``RECORD`` as files are
added and generates the ``WHEEL`` file from the wheel's name.

.. versionadded:: 26.4

Usage
-----

Write a wheel by name. The name, version, build tag and tags come from the
filename, and ``WHEEL`` and ``RECORD`` are generated when the context exits:

.. code-block:: python

from packaging.wheelfile import WheelWriter

with WheelWriter("example-1.0-py3-none-any.whl", generator="mytool 1.0") as writer:
writer.write_file("example/__init__.py", b"print('hello')\n")
writer.write_data_file("scripts/example", b"#!python\n", mode=0o755)
writer.write_dist_info_file("METADATA", metadata_bytes)

A file object can be used instead of a path. The wheel name must then be given
as :class:`WheelMetadata`, since there is no filename to parse. The same applies
to reading:

.. doctest::

>>> from io import BytesIO
>>> from packaging.wheelfile import WheelMetadata, WheelReader, WheelWriter
>>> buffer = BytesIO()
>>> metadata = WheelMetadata.from_filename("example-1.0-py3-none-any.whl")
>>> with WheelWriter(buffer, generator="mytool 1.0", metadata=metadata) as writer:
... writer.write_file("example/__init__.py", b"print('hello')\n")
... writer.write_dist_info_file("METADATA", b"Metadata-Version: 2.4\nName: example\nVersion: 1.0\n")
>>> with WheelReader(buffer) as reader:
... (reader.name, str(reader.version))
... reader.dist_info_dir
... reader.validate_record()
... reader.read_file("example/__init__.py")
... print(reader.read_dist_info_file("WHEEL").decode())
('example', '1.0')
'example-1.0.dist-info'
b"print('hello')\n"
Wheel-Version: 1.0
Generator: mytool 1.0
Root-Is-Purelib: true
Tag: py3-none-any
<BLANKLINE>
<BLANKLINE>

Reading a file that does not match ``RECORD`` raises :exc:`WheelError`:

.. doctest::

>>> from zipfile import ZipFile
>>> with ZipFile(buffer, "a") as zf:
... zf.writestr("extra.txt", "not in RECORD")
>>> with WheelReader(buffer) as reader:
... reader.read_file("extra.txt")
Traceback (most recent call last):
...
packaging.wheelfile.WheelError: No hash found for file 'extra.txt'

Reproducible wheels
-------------------

Every archive member has a timestamp and permission bits. By default, members
written from bytes or file objects get mode ``0o664`` and the timestamp
1980-01-01, the earliest the ZIP format can store. Members written from a path
keep the permissions of the file on disk. The ``timestamp`` and ``mode``
arguments of :class:`WheelWriter` change the defaults for every member,
including the generated ``WHEEL`` and ``RECORD`` files, and the same arguments
on each write method override them for one member.

If the ``SOURCE_DATE_EPOCH`` environment variable is set when a
:class:`WheelWriter` is created and no ``timestamp`` is given, its value is
used as the default timestamp. Timestamps outside the range the ZIP format can
store are clamped to that range.

Reference
---------

.. autoclass:: WheelWriter
:members:

.. autofunction:: write_wheelfile

.. autoclass:: WheelReader
:members:

.. autoclass:: WheelArchiveFile
:members:

.. autoclass:: WheelMetadata
:members:

.. autoclass:: WheelRecordEntry
:members:

.. autoclass:: WheelContentElement
:members:

.. autoclass:: WheelError


.. _wheel files: https://packaging.python.org/en/latest/specifications/binary-distribution-format/
6 changes: 4 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ exclude = [
"build/__pycache__",
]

[tool.typos.default]
# Base64 hash digests in wheel RECORD test data.
extend-ignore-re = ["[A-Za-z0-9_-]{22,}={0,2}"]

[tool.typos.default.extend-identifiers]
iMatix = "iMatix"
ANDed = "ANDed"
Expand Down Expand Up @@ -110,8 +114,6 @@ addopts = [
"-p",
"no:threadexception", # no threads, avoids extra gc
"-p",
"no:tmpdir", # no usage of tempdir (mostly sans IO)
"-p",
"no:legacypath", # no legacy paths
]
xfail_strict = true
Expand Down
29 changes: 28 additions & 1 deletion src/packaging/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@
from __future__ import annotations

import re
from typing import NewType, cast
from typing import TYPE_CHECKING, NewType, cast

from .tags import InvalidTag, Tag, UnsortedTagsError, parse_tag
from .version import InvalidVersion, Version, _TrimmedRelease

if TYPE_CHECKING:
from collections.abc import Collection

__all__ = [
"BuildTag",
"InvalidName",
Expand All @@ -19,6 +22,7 @@
"canonicalize_name",
"canonicalize_version",
"is_normalized_name",
"make_wheel_filename",
"parse_sdist_filename",
"parse_wheel_filename",
]
Expand Down Expand Up @@ -197,6 +201,29 @@ def canonicalize_version(
return str(_TrimmedRelease(version) if strip_trailing_zero else version)


def make_wheel_filename(
name: str,
version: str | Version,
tags: Collection[Tag],
*,
build_tag: BuildTag | None = None,
) -> str:
if not tags:
raise ValueError("At least one tag is required")

name = canonicalize_name(name).replace("-", "_")
if isinstance(version, str):
version = Version(version)
filename = f"{name}-{version}"
if build_tag:
filename = f"{filename}-{build_tag[0]}{build_tag[1]}"

interpreter_tags = ".".join(tag.interpreter for tag in tags)
abi_tags = ".".join(tag.abi for tag in tags)
platform_tags = ".".join(tag.platform for tag in tags)
return f"{filename}-{interpreter_tags}-{abi_tags}-{platform_tags}.whl"


def parse_wheel_filename(
filename: str,
*,
Expand Down
Loading
Loading