From 35a2fefa31cb2ea3ebfc020e63ba8969435362dc Mon Sep 17 00:00:00 2001 From: pctablet505 Date: Wed, 15 Jul 2026 19:14:40 +0000 Subject: [PATCH 1/5] feat: add --local-version option to wheel pack Fixes #570 --- src/wheel/_commands/__init__.py | 5 +- src/wheel/_commands/pack.py | 54 ++++++++++++- tests/commands/test_pack.py | 134 ++++++++++++++++++++++++++++++++ 3 files changed, 190 insertions(+), 3 deletions(-) diff --git a/src/wheel/_commands/__init__.py b/src/wheel/_commands/__init__.py index c60772c6..7931c786 100644 --- a/src/wheel/_commands/__init__.py +++ b/src/wheel/_commands/__init__.py @@ -21,7 +21,7 @@ def unpack_f(args: argparse.Namespace) -> None: def pack_f(args: argparse.Namespace) -> None: from .pack import pack - pack(args.directory, args.dest_dir, args.build_number) + pack(args.directory, args.dest_dir, args.build_number, args.local_version) def convert_f(args: argparse.Namespace) -> None: @@ -104,6 +104,9 @@ def parser() -> argparse.ArgumentParser: repack_parser.add_argument( "--build-number", help="Build tag to use in the wheel name" ) + repack_parser.add_argument( + "--local-version", help="Local version identifier to add or replace" + ) repack_parser.set_defaults(func=pack_f) convert_parser = s.add_parser("convert", help="Convert egg or wininst to wheel") diff --git a/src/wheel/_commands/pack.py b/src/wheel/_commands/pack.py index 1321ce93..cd5c711c 100644 --- a/src/wheel/_commands/pack.py +++ b/src/wheel/_commands/pack.py @@ -6,12 +6,19 @@ from email.generator import BytesGenerator from email.parser import BytesParser +from packaging.version import InvalidVersion, Version + from ..wheelfile import WheelError, WheelFile DIST_INFO_RE = re.compile(r"^(?P(?P.+?)-(?P\d.*?))\.dist-info$") -def pack(directory: str, dest_dir: str, build_number: str | None) -> None: +def pack( + directory: str, + dest_dir: str, + build_number: str | None, + local_version: str | None = None, +) -> None: """Repack a previously unpacked wheel directory into a new wheel file. The .dist-info/WHEEL file must contain one or more tags so that the target @@ -19,6 +26,8 @@ def pack(directory: str, dest_dir: str, build_number: str | None) -> None: :param directory: The unpacked wheel directory :param dest_dir: Destination directory (defaults to the current directory) + :param build_number: Build tag to use in the wheel name + :param local_version: Local version identifier to add or replace """ # Find the .dist-info directory dist_info_dirs = [ @@ -33,7 +42,48 @@ def pack(directory: str, dest_dir: str, build_number: str | None) -> None: # Determine the target wheel filename dist_info_dir = dist_info_dirs[0] - name_version = DIST_INFO_RE.match(dist_info_dir).group("namever") + match = DIST_INFO_RE.match(dist_info_dir) + name_version = match.group("namever") + name = match.group("name") + version = match.group("ver") + + # Update the local version identifier if requested + if local_version is not None: + base_version = version.split("+", 1)[0] + new_version = base_version if local_version == "" else f"{base_version}+{local_version}" + try: + Version(new_version) + except InvalidVersion as e: + raise WheelError(f"Invalid version {new_version!r}: {e}") from None + + if "-" in new_version: + raise WheelError( + f"Invalid local version identifier {local_version!r}: " + "wheel filenames cannot contain '-' in the version; " + "use '_' instead of '-'" + ) + + new_namever = f"{name}-{new_version}" + new_dist_info_dir = f"{new_namever}.dist-info" + os.rename( + os.path.join(directory, dist_info_dir), + os.path.join(directory, new_dist_info_dir), + ) + dist_info_dir = new_dist_info_dir + name_version = new_namever + + # Update the Version field in .dist-info/METADATA + metadata_path = os.path.join(directory, dist_info_dir, "METADATA") + try: + with open(metadata_path, "rb") as f: + metadata = BytesParser(policy=email.policy.compat32).parse(f) + except FileNotFoundError: + raise WheelError(f"Missing {dist_info_dir}/METADATA file") from None + + del metadata["Version"] + metadata["Version"] = new_version + with open(metadata_path, "wb") as f: + BytesGenerator(f, maxheaderlen=0).flatten(metadata) # Read the tags and the existing build number from .dist-info/WHEEL wheel_file_path = os.path.join(directory, dist_info_dir, "WHEEL") diff --git a/tests/commands/test_pack.py b/tests/commands/test_pack.py index 70f62ee7..c554a6ef 100644 --- a/tests/commands/test_pack.py +++ b/tests/commands/test_pack.py @@ -2,13 +2,18 @@ import email.policy import os +import sys +from email.generator import BytesGenerator from email.message import Message from email.parser import BytesParser +from io import StringIO from zipfile import Path, ZipFile import pytest from pytest import TempPathFactory +from wheel._commands import main + from .util import run_command THISDIR = os.path.dirname(__file__) @@ -90,3 +95,132 @@ def test_pack( assert sorted(new_wheel_file_content.items()) == sorted( expected_wheel_content.items() ) + + +@pytest.mark.parametrize( + "local_version_arg, existing_local_version, filename, expected_version", + [ + pytest.param( + "local", + None, + "test-1.0+local-py2.py3-none-any.whl", + "1.0+local", + id="addlocal", + ), + pytest.param( + "new", + "old", + "test-1.0+new-py2.py3-none-any.whl", + "1.0+new", + id="replacelocal", + ), + pytest.param( + "", + "old", + "test-1.0-py2.py3-none-any.whl", + "1.0", + id="removelocal", + ), + ], +) +def test_pack_local_version( + tmp_path_factory: TempPathFactory, + tmp_path: Path, + local_version_arg: str, + existing_local_version: str | None, + filename: str, + expected_version: str, +) -> None: + unpack_dir = tmp_path_factory.mktemp("wheeldir") + with ZipFile(TESTWHEEL_PATH) as zf: + zf.extractall(unpack_dir) + + dist_info_dir = unpack_dir.joinpath("test-1.0.dist-info") + if existing_local_version: + existing_version = f"1.0+{existing_local_version}" + new_dist_info_dir = unpack_dir.joinpath(f"test-{existing_version}.dist-info") + dist_info_dir.rename(new_dist_info_dir) + dist_info_dir = new_dist_info_dir + + wheel_file_path = dist_info_dir.joinpath("WHEEL") + wheel_file_content = wheel_file_path.read_bytes() + assert b"Build" not in wheel_file_content + + metadata_path = dist_info_dir.joinpath("METADATA") + metadata = BytesParser(policy=email.policy.compat32).parsebytes( + metadata_path.read_bytes() + ) + del metadata["Version"] + metadata["Version"] = existing_version + with open(metadata_path, "wb") as f: + BytesGenerator(f, maxheaderlen=0).flatten(metadata) + + args = ["--dest", tmp_path, unpack_dir, "--local-version", local_version_arg] + run_command("pack", *args) + new_wheel_path = tmp_path.joinpath(filename) + assert new_wheel_path.is_file() + + with ZipFile(new_wheel_path) as zf: + dist_info_prefix = f"test-{expected_version}.dist-info" + parser = BytesParser(policy=email.policy.compat32) + metadata = parser.parsebytes(zf.read(f"{dist_info_prefix}/METADATA")) + assert metadata["Version"] == expected_version + + wheel_file_content = parser.parsebytes(zf.read(f"{dist_info_prefix}/WHEEL")) + assert wheel_file_content["Wheel-Version"] == "1.0" + + +def test_pack_local_version_rejects_hyphen( + tmp_path_factory: TempPathFactory, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + unpack_dir = tmp_path_factory.mktemp("wheeldir") + with ZipFile(TESTWHEEL_PATH) as zf: + zf.extractall(unpack_dir) + + argv = [ + "wheel", + "pack", + "--dest", + str(tmp_path), + str(unpack_dir), + "--local-version", + "bad-local", + ] + stdout = StringIO() + stderr = StringIO() + with monkeypatch.context() as m: + m.setattr(sys, "argv", argv) + m.setattr(sys, "stdout", stdout) + m.setattr(sys, "stderr", stderr) + returncode = main() + + assert returncode == 1 + assert "bad-local" in stderr.getvalue() + + +def test_pack_local_version_rejects_invalid( + tmp_path_factory: TempPathFactory, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + unpack_dir = tmp_path_factory.mktemp("wheeldir") + with ZipFile(TESTWHEEL_PATH) as zf: + zf.extractall(unpack_dir) + + argv = [ + "wheel", + "pack", + "--dest", + str(tmp_path), + str(unpack_dir), + "--local-version", + "!invalid", + ] + stdout = StringIO() + stderr = StringIO() + with monkeypatch.context() as m: + m.setattr(sys, "argv", argv) + m.setattr(sys, "stdout", stdout) + m.setattr(sys, "stderr", stderr) + returncode = main() + + assert returncode == 1 + assert "!invalid" in stderr.getvalue() From 3bcbfa3cc1b325da8ae0434bfea6b95f560c08ea Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:14:59 +0000 Subject: [PATCH 2/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/wheel/_commands/pack.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/wheel/_commands/pack.py b/src/wheel/_commands/pack.py index cd5c711c..5164d008 100644 --- a/src/wheel/_commands/pack.py +++ b/src/wheel/_commands/pack.py @@ -50,7 +50,9 @@ def pack( # Update the local version identifier if requested if local_version is not None: base_version = version.split("+", 1)[0] - new_version = base_version if local_version == "" else f"{base_version}+{local_version}" + new_version = ( + base_version if local_version == "" else f"{base_version}+{local_version}" + ) try: Version(new_version) except InvalidVersion as e: From 876cce0533b028636d48e98a7544e01df2cf1bc2 Mon Sep 17 00:00:00 2001 From: pctablet505 Date: Thu, 16 Jul 2026 11:16:47 +0530 Subject: [PATCH 3/5] docs: add changelog entry for --local-version --- docs/news.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/news.rst b/docs/news.rst index 31710fa3..d786c91a 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -1,6 +1,12 @@ Release Notes ============= +**UNRELEASED** + +- Added a ``--local-version`` option to ``wheel pack`` to add, replace, or remove a + PEP 440 local version identifier from a wheel + (`#570 `_) + **0.47.0 (2026-04-22)** - Added the ``wheel info`` subcommand to display metadata about wheel files without From d3ab134a30bef249c0be2a04cca1ccc0d7d3f8aa Mon Sep 17 00:00:00 2001 From: pctablet505 Date: Fri, 17 Jul 2026 15:54:53 +0530 Subject: [PATCH 4/5] fix: exclude .egg-info dirs from sdist include glob check-sdist's --inject-junk check (bumped to v1.5.0 in a recent pre-commit autoupdate) caught that [tool.flit.sdist]'s tests/**/*.py include glob is untracked-file-blind: an injected junk file under a tests/**/*.egg-info/ dir leaked into the sdist even though .egg-info is already gitignored. Add a matching exclude. --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 730bb86e..13476b28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,7 @@ exclude = [ ".github/**", ".gitignore", ".pre-commit-config.yaml", + "**/*.egg-info/**", ".readthedocs.yml", "**/__pycache__", ] From a3b96f978439d3a828118112d91ead959563cd0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 19 Jul 2026 17:36:00 +0300 Subject: [PATCH 5/5] Apply suggestion from @agronholm --- tests/commands/test_pack.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/commands/test_pack.py b/tests/commands/test_pack.py index c554a6ef..e25bbeb3 100644 --- a/tests/commands/test_pack.py +++ b/tests/commands/test_pack.py @@ -101,10 +101,10 @@ def test_pack( "local_version_arg, existing_local_version, filename, expected_version", [ pytest.param( - "local", + "l0.cal", None, - "test-1.0+local-py2.py3-none-any.whl", - "1.0+local", + "test-1.0+l0.cal-py2.py3-none-any.whl", + "1.0+l0.cal", id="addlocal", ), pytest.param(