From 73445be037a5b29eb2dba2cd517a3d32eaec5e69 Mon Sep 17 00:00:00 2001 From: Quaylyn Rimer Date: Sat, 19 Jul 2025 00:44:30 -0600 Subject: [PATCH 1/9] Add wheel info subcommand Implements GitHub issue #639: Add wheel info subcommand to display metadata about wheel files without unpacking them. Features: - Shows package name, version, and build information - Displays wheel format version and generator - Lists supported Python versions, ABI, and platform tags - Shows package metadata (summary, author, license, classifiers) - Displays dependencies and file count/size information - Optional verbose mode with detailed file listing - Comprehensive error handling for missing files Changes: - Add src/wheel/_commands/info.py with main implementation - Update src/wheel/_commands/__init__.py to register new command - Add tests/commands/test_info.py with comprehensive test coverage - Add docs/reference/wheel_info.rst with usage documentation - Update docs/reference/index.rst to include new command docs - Update docs/user_guide.rst with info command examples - Update docs/manpages/wheel.rst with info command reference --- docs/manpages/wheel.rst | 3 + docs/reference/index.rst | 3 +- docs/reference/wheel_info.rst | 67 ++++++++++++++++++ docs/user_guide.rst | 28 ++++++++ src/wheel/_commands/__init__.py | 16 +++++ src/wheel/_commands/info.py | 117 ++++++++++++++++++++++++++++++++ tests/commands/test_info.py | 90 ++++++++++++++++++++++++ 7 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 docs/reference/wheel_info.rst create mode 100644 src/wheel/_commands/info.py create mode 100644 tests/commands/test_info.py diff --git a/docs/manpages/wheel.rst b/docs/manpages/wheel.rst index df1ac2aa0..8a744fb34 100644 --- a/docs/manpages/wheel.rst +++ b/docs/manpages/wheel.rst @@ -26,6 +26,9 @@ Commands ``convert`` Convert egg or wininst to wheel + ``info`` + Show information about a wheel file + ``tags`` Change the tags on a wheel file diff --git a/docs/reference/index.rst b/docs/reference/index.rst index f332026d4..755dd149c 100644 --- a/docs/reference/index.rst +++ b/docs/reference/index.rst @@ -5,6 +5,7 @@ Reference Guide :maxdepth: 2 wheel_convert - wheel_unpack + wheel_info wheel_pack wheel_tags + wheel_unpack diff --git a/docs/reference/wheel_info.rst b/docs/reference/wheel_info.rst new file mode 100644 index 000000000..b4151941b --- /dev/null +++ b/docs/reference/wheel_info.rst @@ -0,0 +1,67 @@ +wheel info +========== + +Usage +----- + +:: + + wheel info [OPTIONS] + + +Description +----------- + +Display information about a wheel file without unpacking it. + +This command shows comprehensive metadata about a wheel file including: + +* Package name, version, and build information +* Wheel format version and generator +* Supported Python versions, ABI, and platform tags +* Package metadata such as summary, author, and license +* Classifiers and dependencies +* File count and total size +* Optional detailed file listing + + +Options +------- + +.. option:: -v, --verbose + + Show detailed file listing with individual file sizes. + + +Examples +-------- + +Display basic information about a wheel:: + + $ wheel info example_package-1.0-py3-none-any.whl + Name: example-package + Version: 1.0 + Wheel-Version: 1.0 + Root-Is-Purelib: true + Tags: + py3-none-any + Generator: bdist_wheel (0.40.0) + Summary: An example package + Author: John Doe + License: MIT + Files: 12 + Size: 15,234 bytes + +Display detailed information with file listing:: + + $ wheel info --verbose example_package-1.0-py3-none-any.whl + Name: example-package + Version: 1.0 + ... + + File listing: + example_package/__init__.py 45 bytes + example_package/module.py 1,234 bytes + example_package-1.0.dist-info/METADATA 678 bytes + example_package-1.0.dist-info/WHEEL 123 bytes + example_package-1.0.dist-info/RECORD 456 bytes diff --git a/docs/user_guide.rst b/docs/user_guide.rst index c8b5a3474..1b74b211e 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -84,3 +84,31 @@ To install a wheel file, use pip_:: $ pip install someproject-1.5.0-py2-py3-none.whl .. _pip: https://pypi.org/project/pip/ + + +Inspecting Wheels +----------------- + +To inspect the metadata and contents of a wheel file without installing it, +use the ``wheel info`` command:: + + $ wheel info someproject-1.5.0-py2-py3-none.whl + +This will display information about the wheel including: + +* Package name and version +* Supported Python versions and platforms +* Dependencies and other metadata +* File count and total size + +For more detailed information including a complete file listing, use the +``--verbose`` flag:: + + $ wheel info --verbose someproject-1.5.0-py2-py3-none.whl + +This is useful for: + +* Verifying wheel contents before installation +* Debugging packaging issues +* Understanding wheel structure and metadata +* Checking supported platforms and Python versions diff --git a/src/wheel/_commands/__init__.py b/src/wheel/_commands/__init__.py index 42f1d7ef3..c60772c67 100644 --- a/src/wheel/_commands/__init__.py +++ b/src/wheel/_commands/__init__.py @@ -49,6 +49,15 @@ def tags_f(args: argparse.Namespace) -> None: print(name) +def info_f(args: argparse.Namespace) -> None: + from .info import info + + try: + info(args.wheelfile, args.verbose) + except FileNotFoundError as e: + raise WheelError(str(e)) from e + + def version_f(args: argparse.Namespace) -> None: from .. import __version__ @@ -129,6 +138,13 @@ def parser() -> argparse.ArgumentParser: ) tags_parser.set_defaults(func=tags_f) + info_parser = s.add_parser("info", help="Show information about a wheel file") + info_parser.add_argument("wheelfile", help="Wheel file to show information for") + info_parser.add_argument( + "--verbose", "-v", action="store_true", help="Show detailed file listing" + ) + info_parser.set_defaults(func=info_f) + version_parser = s.add_parser("version", help="Print version and exit") version_parser.set_defaults(func=version_f) diff --git a/src/wheel/_commands/info.py b/src/wheel/_commands/info.py new file mode 100644 index 000000000..0066a8969 --- /dev/null +++ b/src/wheel/_commands/info.py @@ -0,0 +1,117 @@ +""" +Display information about wheel files. +""" + +from __future__ import annotations + +import email.policy +from email.parser import BytesParser +from pathlib import Path + +from ..wheelfile import WheelFile + + +def info(path: str, verbose: bool = False) -> None: + """Display information about a wheel file. + + :param path: The path to the wheel file + :param verbose: Show detailed file listing + """ + wheel_path = Path(path) + if not wheel_path.exists(): + raise FileNotFoundError(f"Wheel file not found: {path}") + + with WheelFile(path) as wf: + # Extract basic wheel information from filename + parsed = wf.parsed_filename + name = parsed.group("name") + version = parsed.group("ver") + python_tag = parsed.group("pyver") + abi_tag = parsed.group("abi") + platform_tag = parsed.group("plat") + build_tag = parsed.group("build") + + print(f"Name: {name}") + print(f"Version: {version}") + if build_tag: + print(f"Build: {build_tag}") + + # Read WHEEL metadata + try: + with wf.open(f"{wf.dist_info_path}/WHEEL") as wheel_file: + wheel_metadata = BytesParser(policy=email.policy.compat32).parse(wheel_file) + + print(f"Wheel-Version: {wheel_metadata.get('Wheel-Version', 'Unknown')}") + print(f"Root-Is-Purelib: {wheel_metadata.get('Root-Is-Purelib', 'Unknown')}") + + # Get all tags + tags = wheel_metadata.get_all("Tag", []) + if tags: + print("Tags:") + for tag in sorted(tags): # Sort tags for consistent output + print(f" {tag}") + + generator = wheel_metadata.get("Generator") + if generator: + print(f"Generator: {generator}") + + except KeyError: + print("Warning: WHEEL metadata file not found") + + # Read package METADATA + try: + with wf.open(f"{wf.dist_info_path}/METADATA") as metadata_file: + pkg_metadata = BytesParser(policy=email.policy.compat32).parse(metadata_file) + + summary = pkg_metadata.get('Summary', '') + if summary and summary != 'UNKNOWN': + print(f"Summary: {summary}") + + author = pkg_metadata.get('Author', '') + if author and author != 'UNKNOWN': + print(f"Author: {author}") + + author_email = pkg_metadata.get('Author-email') + if author_email and author_email != 'UNKNOWN': + print(f"Author-email: {author_email}") + + homepage = pkg_metadata.get('Home-page') + if homepage and homepage != 'UNKNOWN': + print(f"Home-page: {homepage}") + + license_info = pkg_metadata.get('License') + if license_info and license_info != 'UNKNOWN': + print(f"License: {license_info}") + + # Show classifiers + classifiers = pkg_metadata.get_all("Classifier", []) + if classifiers: + print("Classifiers:") + for classifier in sorted(classifiers[:5]): # Sort and limit to first 5 + print(f" {classifier}") + if len(classifiers) > 5: + print(f" ... and {len(classifiers) - 5} more") + + # Show dependencies + requires_dist = pkg_metadata.get_all("Requires-Dist", []) + if requires_dist: + print("Requires-Dist:") + for req in sorted(requires_dist): # Sort dependencies + print(f" {req}") + + except KeyError: + print("Warning: METADATA file not found") + + # File information + file_count = len(wf.filelist) + total_size = sum(zinfo.file_size for zinfo in wf.filelist) + + print(f"Files: {file_count}") + print(f"Size: {total_size:,} bytes") + + # Show file listing if verbose + if verbose: + print("\nFile listing:") + for zinfo in wf.filelist: + size_str = f"{zinfo.file_size:,}" if zinfo.file_size > 0 else "0" + print(f" {zinfo.filename:60} {size_str:>10} bytes") diff --git a/tests/commands/test_info.py b/tests/commands/test_info.py new file mode 100644 index 000000000..56b746be5 --- /dev/null +++ b/tests/commands/test_info.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import os +from pathlib import Path + +import pytest +from pytest import TempPathFactory + +from .util import run_command + +THISDIR = os.path.dirname(__file__) +TESTWHEEL_NAME = "test-1.0-py2.py3-none-any.whl" +TESTWHEEL_PATH = os.path.join(THISDIR, "..", "testdata", TESTWHEEL_NAME) + + +def test_info_basic(tmp_path_factory: TempPathFactory) -> None: + """Test basic wheel info display.""" + output = run_command("info", TESTWHEEL_PATH) + + # Check basic package information is displayed + assert "Name: test" in output + assert "Version: 1.0" in output + assert "Wheel-Version: 1.0" in output + assert "Root-Is-Purelib: false" in output + + # Check tags are displayed + assert "Tags:" in output + assert "py2-none-any" in output + assert "py3-none-any" in output + + # Check metadata is displayed + assert "Summary: Test module" in output + assert "Author: Paul Moore" in output + assert "Author-email: test@example.com" in output + assert "Home-page: http://test.example.com/" in output + assert "License: MIT License" in output + + # Check file information + assert "Files: 14" in output + assert "Size: 8,114 bytes" in output + + +def test_info_verbose(tmp_path_factory: TempPathFactory) -> None: + """Test verbose wheel info display with file listing.""" + output = run_command("info", "--verbose", TESTWHEEL_PATH) + + # Check that basic info is still there + assert "Name: test" in output + assert "Version: 1.0" in output + + # Check that file listing is included + assert "File listing:" in output + assert "hello/hello.py" in output + assert "hello.pyd" in output + assert "test-1.0.dist-info/METADATA" in output + assert "test-1.0.dist-info/WHEEL" in output + assert "test-1.0.dist-info/RECORD" in output + + # Check file sizes are displayed + assert "6,656 bytes" in output # hello.pyd + assert "42 bytes" in output # hello.py + + +def test_info_nonexistent_file() -> None: + """Test info command with non-existent wheel file.""" + try: + output = run_command("info", "nonexistent.whl", catch_systemexit=False) + assert False, "Expected an error for non-existent file" + except Exception: + # Expected to fail + pass + + +def test_info_help() -> None: + """Test info command help.""" + output = run_command("info", "--help") + + assert "usage: wheel info" in output + assert "Wheel file to show information for" in output + assert "wheelfile" in output + assert "--verbose" in output + + +def test_info_short_verbose_flag() -> None: + """Test that -v works as alias for --verbose.""" + output = run_command("info", "-v", TESTWHEEL_PATH) + + # Should include file listing like --verbose + assert "File listing:" in output + assert "hello/hello.py" in output From 6d1b71eae336c6507651b43ff98992c9e335cf59 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 19 Jul 2025 06:47:55 +0000 Subject: [PATCH 2/9] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docs/reference/wheel_info.rst | 2 +- src/wheel/_commands/info.py | 60 ++++++++++++++++++++--------------- tests/commands/test_info.py | 22 ++++++------- 3 files changed, 46 insertions(+), 38 deletions(-) diff --git a/docs/reference/wheel_info.rst b/docs/reference/wheel_info.rst index b4151941b..3153a260b 100644 --- a/docs/reference/wheel_info.rst +++ b/docs/reference/wheel_info.rst @@ -58,7 +58,7 @@ Display detailed information with file listing:: Name: example-package Version: 1.0 ... - + File listing: example_package/__init__.py 45 bytes example_package/module.py 1,234 bytes diff --git a/src/wheel/_commands/info.py b/src/wheel/_commands/info.py index 0066a8969..2edcae4a4 100644 --- a/src/wheel/_commands/info.py +++ b/src/wheel/_commands/info.py @@ -20,7 +20,7 @@ def info(path: str, verbose: bool = False) -> None: wheel_path = Path(path) if not wheel_path.exists(): raise FileNotFoundError(f"Wheel file not found: {path}") - + with WheelFile(path) as wf: # Extract basic wheel information from filename parsed = wf.parsed_filename @@ -39,18 +39,24 @@ def info(path: str, verbose: bool = False) -> None: # Read WHEEL metadata try: with wf.open(f"{wf.dist_info_path}/WHEEL") as wheel_file: - wheel_metadata = BytesParser(policy=email.policy.compat32).parse(wheel_file) - - print(f"Wheel-Version: {wheel_metadata.get('Wheel-Version', 'Unknown')}") - print(f"Root-Is-Purelib: {wheel_metadata.get('Root-Is-Purelib', 'Unknown')}") - + wheel_metadata = BytesParser(policy=email.policy.compat32).parse( + wheel_file + ) + + print( + f"Wheel-Version: {wheel_metadata.get('Wheel-Version', 'Unknown')}" + ) + print( + f"Root-Is-Purelib: {wheel_metadata.get('Root-Is-Purelib', 'Unknown')}" + ) + # Get all tags tags = wheel_metadata.get_all("Tag", []) if tags: print("Tags:") for tag in sorted(tags): # Sort tags for consistent output print(f" {tag}") - + generator = wheel_metadata.get("Generator") if generator: print(f"Generator: {generator}") @@ -61,33 +67,37 @@ def info(path: str, verbose: bool = False) -> None: # Read package METADATA try: with wf.open(f"{wf.dist_info_path}/METADATA") as metadata_file: - pkg_metadata = BytesParser(policy=email.policy.compat32).parse(metadata_file) - - summary = pkg_metadata.get('Summary', '') - if summary and summary != 'UNKNOWN': + pkg_metadata = BytesParser(policy=email.policy.compat32).parse( + metadata_file + ) + + summary = pkg_metadata.get("Summary", "") + if summary and summary != "UNKNOWN": print(f"Summary: {summary}") - - author = pkg_metadata.get('Author', '') - if author and author != 'UNKNOWN': + + author = pkg_metadata.get("Author", "") + if author and author != "UNKNOWN": print(f"Author: {author}") - - author_email = pkg_metadata.get('Author-email') - if author_email and author_email != 'UNKNOWN': + + author_email = pkg_metadata.get("Author-email") + if author_email and author_email != "UNKNOWN": print(f"Author-email: {author_email}") - - homepage = pkg_metadata.get('Home-page') - if homepage and homepage != 'UNKNOWN': + + homepage = pkg_metadata.get("Home-page") + if homepage and homepage != "UNKNOWN": print(f"Home-page: {homepage}") - - license_info = pkg_metadata.get('License') - if license_info and license_info != 'UNKNOWN': + + license_info = pkg_metadata.get("License") + if license_info and license_info != "UNKNOWN": print(f"License: {license_info}") # Show classifiers classifiers = pkg_metadata.get_all("Classifier", []) if classifiers: print("Classifiers:") - for classifier in sorted(classifiers[:5]): # Sort and limit to first 5 + for classifier in sorted( + classifiers[:5] + ): # Sort and limit to first 5 print(f" {classifier}") if len(classifiers) > 5: print(f" ... and {len(classifiers) - 5} more") @@ -105,7 +115,7 @@ def info(path: str, verbose: bool = False) -> None: # File information file_count = len(wf.filelist) total_size = sum(zinfo.file_size for zinfo in wf.filelist) - + print(f"Files: {file_count}") print(f"Size: {total_size:,} bytes") diff --git a/tests/commands/test_info.py b/tests/commands/test_info.py index 56b746be5..aa41ae663 100644 --- a/tests/commands/test_info.py +++ b/tests/commands/test_info.py @@ -1,9 +1,7 @@ from __future__ import annotations import os -from pathlib import Path -import pytest from pytest import TempPathFactory from .util import run_command @@ -16,25 +14,25 @@ def test_info_basic(tmp_path_factory: TempPathFactory) -> None: """Test basic wheel info display.""" output = run_command("info", TESTWHEEL_PATH) - + # Check basic package information is displayed assert "Name: test" in output assert "Version: 1.0" in output assert "Wheel-Version: 1.0" in output assert "Root-Is-Purelib: false" in output - + # Check tags are displayed assert "Tags:" in output assert "py2-none-any" in output assert "py3-none-any" in output - + # Check metadata is displayed assert "Summary: Test module" in output assert "Author: Paul Moore" in output assert "Author-email: test@example.com" in output assert "Home-page: http://test.example.com/" in output assert "License: MIT License" in output - + # Check file information assert "Files: 14" in output assert "Size: 8,114 bytes" in output @@ -43,11 +41,11 @@ def test_info_basic(tmp_path_factory: TempPathFactory) -> None: def test_info_verbose(tmp_path_factory: TempPathFactory) -> None: """Test verbose wheel info display with file listing.""" output = run_command("info", "--verbose", TESTWHEEL_PATH) - + # Check that basic info is still there assert "Name: test" in output assert "Version: 1.0" in output - + # Check that file listing is included assert "File listing:" in output assert "hello/hello.py" in output @@ -55,10 +53,10 @@ def test_info_verbose(tmp_path_factory: TempPathFactory) -> None: assert "test-1.0.dist-info/METADATA" in output assert "test-1.0.dist-info/WHEEL" in output assert "test-1.0.dist-info/RECORD" in output - + # Check file sizes are displayed assert "6,656 bytes" in output # hello.pyd - assert "42 bytes" in output # hello.py + assert "42 bytes" in output # hello.py def test_info_nonexistent_file() -> None: @@ -74,7 +72,7 @@ def test_info_nonexistent_file() -> None: def test_info_help() -> None: """Test info command help.""" output = run_command("info", "--help") - + assert "usage: wheel info" in output assert "Wheel file to show information for" in output assert "wheelfile" in output @@ -84,7 +82,7 @@ def test_info_help() -> None: def test_info_short_verbose_flag() -> None: """Test that -v works as alias for --verbose.""" output = run_command("info", "-v", TESTWHEEL_PATH) - + # Should include file listing like --verbose assert "File listing:" in output assert "hello/hello.py" in output From f9b613a934b761b033a591feab574a45049b5524 Mon Sep 17 00:00:00 2001 From: Quaylyn Rimer Date: Wed, 11 Feb 2026 17:49:20 -0700 Subject: [PATCH 3/9] Address maintainer review feedback on wheel info subcommand - Remove unused tmp_path_factory parameter from test_info_basic and test_info_verbose - Use pytest.raises(FileNotFoundError, match=...) instead of try/except in test_info_nonexistent_file - Fix test_info_help to not depend on argv[0] being 'wheel' (fixes Python 3.14 CI failure) - Add changelog entry in docs/news.rst for the new wheel info subcommand --- docs/news.rst | 2 ++ tests/commands/test_info.py | 18 ++++++++---------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/news.rst b/docs/news.rst index 9e1563de7..e25df289c 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -9,6 +9,8 @@ Release Notes **0.46.2 (2026-01-22)** +- Added the ``wheel info`` subcommand to display metadata about wheel files without + unpacking them (`#639 `_) - Restored the ``bdist_wheel`` command for compatibility with ``setuptools`` older than v70.1 - Importing ``wheel.bdist_wheel`` now emits a ``FutureWarning`` instead of a diff --git a/tests/commands/test_info.py b/tests/commands/test_info.py index aa41ae663..57f52751d 100644 --- a/tests/commands/test_info.py +++ b/tests/commands/test_info.py @@ -2,7 +2,7 @@ import os -from pytest import TempPathFactory +import pytest from .util import run_command @@ -11,7 +11,7 @@ TESTWHEEL_PATH = os.path.join(THISDIR, "..", "testdata", TESTWHEEL_NAME) -def test_info_basic(tmp_path_factory: TempPathFactory) -> None: +def test_info_basic() -> None: """Test basic wheel info display.""" output = run_command("info", TESTWHEEL_PATH) @@ -38,7 +38,7 @@ def test_info_basic(tmp_path_factory: TempPathFactory) -> None: assert "Size: 8,114 bytes" in output -def test_info_verbose(tmp_path_factory: TempPathFactory) -> None: +def test_info_verbose() -> None: """Test verbose wheel info display with file listing.""" output = run_command("info", "--verbose", TESTWHEEL_PATH) @@ -61,19 +61,17 @@ def test_info_verbose(tmp_path_factory: TempPathFactory) -> None: def test_info_nonexistent_file() -> None: """Test info command with non-existent wheel file.""" - try: - output = run_command("info", "nonexistent.whl", catch_systemexit=False) - assert False, "Expected an error for non-existent file" - except Exception: - # Expected to fail - pass + from wheel._commands.info import info + + with pytest.raises(FileNotFoundError, match="Wheel file not found: nonexistent.whl"): + info("nonexistent.whl") def test_info_help() -> None: """Test info command help.""" output = run_command("info", "--help") - assert "usage: wheel info" in output + assert "info" in output assert "Wheel file to show information for" in output assert "wheelfile" in output assert "--verbose" in output From 5e4e09625ba2854658abb029bc518eaaab7a0e10 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 12 Feb 2026 00:53:11 +0000 Subject: [PATCH 4/9] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/commands/test_info.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/commands/test_info.py b/tests/commands/test_info.py index 57f52751d..593153d2f 100644 --- a/tests/commands/test_info.py +++ b/tests/commands/test_info.py @@ -63,7 +63,9 @@ def test_info_nonexistent_file() -> None: """Test info command with non-existent wheel file.""" from wheel._commands.info import info - with pytest.raises(FileNotFoundError, match="Wheel file not found: nonexistent.whl"): + with pytest.raises( + FileNotFoundError, match="Wheel file not found: nonexistent.whl" + ): info("nonexistent.whl") From c66555d63235f5343253731839b484ef1964b8f4 Mon Sep 17 00:00:00 2001 From: Quaylyn Rimer Date: Wed, 11 Feb 2026 17:56:02 -0700 Subject: [PATCH 5/9] Remove unused variables flagged by ruff (F841) Remove python_tag, abi_tag, and platform_tag assignments that were extracted from the wheel filename but never used. --- src/wheel/_commands/info.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/wheel/_commands/info.py b/src/wheel/_commands/info.py index 2edcae4a4..4769795f8 100644 --- a/src/wheel/_commands/info.py +++ b/src/wheel/_commands/info.py @@ -26,9 +26,6 @@ def info(path: str, verbose: bool = False) -> None: parsed = wf.parsed_filename name = parsed.group("name") version = parsed.group("ver") - python_tag = parsed.group("pyver") - abi_tag = parsed.group("abi") - platform_tag = parsed.group("plat") build_tag = parsed.group("build") print(f"Name: {name}") From 8e042519bc94a17e3fbe8a6a6e503fd5e0c33725 Mon Sep 17 00:00:00 2001 From: Quaylyn Rimer Date: Thu, 12 Feb 2026 20:15:22 -0700 Subject: [PATCH 6/9] Support multiple Generator values in wheel info output --- src/wheel/_commands/info.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wheel/_commands/info.py b/src/wheel/_commands/info.py index 4769795f8..de736a5ce 100644 --- a/src/wheel/_commands/info.py +++ b/src/wheel/_commands/info.py @@ -54,8 +54,8 @@ def info(path: str, verbose: bool = False) -> None: for tag in sorted(tags): # Sort tags for consistent output print(f" {tag}") - generator = wheel_metadata.get("Generator") - if generator: + generators = wheel_metadata.get_all("Generator", []) + for generator in generators: print(f"Generator: {generator}") except KeyError: From ab163987ec4edf2503a31825784c0dcaf8f2e0a9 Mon Sep 17 00:00:00 2001 From: Quaylyn Rimer Date: Thu, 12 Feb 2026 20:44:09 -0700 Subject: [PATCH 7/9] Improve tests and fix changelog placement for wheel info - Move changelog entry to UNRELEASED section per project release process (was incorrectly placed under the already-released 0.46.2 heading) - Add test for zero Generator values (test_info_no_generator) - Add exact count assertion for multiple Generator lines - Refactor test helpers: extract _build_wheel_with_modified_metadata() and _capture_info_output() for reusable wheel modification with proper RECORD hash updates - Move all imports to module level (no more inline imports in tests) - 8 tests now cover: basic info, single generator, multiple generators, zero generators, verbose output, nonexistent file, help, and -v flag --- docs/news.rst | 7 ++- tests/commands/test_info.py | 108 ++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/docs/news.rst b/docs/news.rst index e25df289c..a70f69f1f 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -1,6 +1,11 @@ Release Notes ============= +**UNRELEASED** + +- Added the ``wheel info`` subcommand to display metadata about wheel files without + unpacking them (`#639 `_) + **0.46.3 (2026-01-22)** - Fixed ``ImportError: cannot import name '_setuptools_logging' from 'wheel'`` when @@ -9,8 +14,6 @@ Release Notes **0.46.2 (2026-01-22)** -- Added the ``wheel info`` subcommand to display metadata about wheel files without - unpacking them (`#639 `_) - Restored the ``bdist_wheel`` command for compatibility with ``setuptools`` older than v70.1 - Importing ``wheel.bdist_wheel`` now emits a ``FutureWarning`` instead of a diff --git a/tests/commands/test_info.py b/tests/commands/test_info.py index 593153d2f..182a62a8d 100644 --- a/tests/commands/test_info.py +++ b/tests/commands/test_info.py @@ -1,9 +1,18 @@ from __future__ import annotations +import base64 +import hashlib import os +import shutil +import sys +import zipfile +from io import StringIO +from unittest.mock import patch import pytest +from wheel._commands.info import info + from .util import run_command THISDIR = os.path.dirname(__file__) @@ -11,6 +20,66 @@ TESTWHEEL_PATH = os.path.join(THISDIR, "..", "testdata", TESTWHEEL_NAME) +def _build_wheel_with_modified_metadata( + src_whl: str, dest_dir: os.PathLike[str], wheel_content: str +) -> str: + """Copy a wheel and replace its WHEEL metadata, updating the RECORD hash. + + Returns the path to the new wheel file. + """ + dest_whl = os.path.join(dest_dir, os.path.basename(src_whl)) + shutil.copy2(src_whl, dest_whl) + + with zipfile.ZipFile(dest_whl, "r") as zr: + wheel_path = record_path = None + for name in zr.namelist(): + if name.endswith("/WHEEL"): + wheel_path = name + elif name.endswith("/RECORD"): + record_path = name + assert wheel_path is not None + assert record_path is not None + original_record = zr.read(record_path).decode() + + modified_bytes = wheel_content.encode() + + # Compute new hash and size for the WHEEL file + digest = hashlib.sha256(modified_bytes).digest() + hash_str = "sha256=" + base64.urlsafe_b64encode(digest).rstrip(b"=").decode() + size_str = str(len(modified_bytes)) + + # Update the RECORD with the new hash for the WHEEL file + new_record_lines = [] + for line in original_record.splitlines(): + if line.startswith(wheel_path): + new_record_lines.append(f"{wheel_path},{hash_str},{size_str}") + else: + new_record_lines.append(line) + modified_record = "\n".join(new_record_lines) + "\n" + + # Rewrite the wheel with the modified WHEEL file and updated RECORD + tmp_whl = os.path.join(dest_dir, "tmp.whl") + with zipfile.ZipFile(dest_whl, "r") as zr, zipfile.ZipFile(tmp_whl, "w") as zw: + for item in zr.infolist(): + if item.filename == wheel_path: + zw.writestr(item, modified_bytes) + elif item.filename == record_path: + zw.writestr(item, modified_record.encode()) + else: + zw.writestr(item, zr.read(item.filename)) + + os.replace(tmp_whl, dest_whl) + return dest_whl + + +def _capture_info_output(wheel_path: str, verbose: bool = False) -> str: + """Run info() and capture its stdout.""" + stdout = StringIO() + with patch.object(sys, "stdout", stdout): + info(wheel_path, verbose=verbose) + return stdout.getvalue() + + def test_info_basic() -> None: """Test basic wheel info display.""" output = run_command("info", TESTWHEEL_PATH) @@ -38,6 +107,45 @@ def test_info_basic() -> None: assert "Size: 8,114 bytes" in output +def test_info_generator() -> None: + """Test that a single Generator value is displayed.""" + output = run_command("info", TESTWHEEL_PATH) + assert "Generator: bdist_wheel (0.30.0)" in output + + +def test_info_multiple_generators(tmp_path: os.PathLike[str]) -> None: + """Test that multiple Generator values are each displayed on their own line.""" + wheel_content = ( + "Wheel-Version: 1.0\n" + "Generator: bdist_wheel (0.30.0)\n" + "Generator: auditwheel (6.0.0)\n" + "Root-Is-Purelib: false\n" + "Tag: py2-none-any\n" + "Tag: py3-none-any\n" + ) + whl = _build_wheel_with_modified_metadata(TESTWHEEL_PATH, str(tmp_path), wheel_content) + output = _capture_info_output(whl) + + assert "Generator: bdist_wheel (0.30.0)" in output + assert "Generator: auditwheel (6.0.0)" in output + # Ensure exactly two Generator lines are printed + assert output.count("Generator:") == 2 + + +def test_info_no_generator(tmp_path: os.PathLike[str]) -> None: + """Test that missing Generator values produce no Generator lines.""" + wheel_content = ( + "Wheel-Version: 1.0\n" + "Root-Is-Purelib: false\n" + "Tag: py2-none-any\n" + "Tag: py3-none-any\n" + ) + whl = _build_wheel_with_modified_metadata(TESTWHEEL_PATH, str(tmp_path), wheel_content) + output = _capture_info_output(whl) + + assert "Generator" not in output + + def test_info_verbose() -> None: """Test verbose wheel info display with file listing.""" output = run_command("info", "--verbose", TESTWHEEL_PATH) From e89598100030d5058a9959990116545650782e89 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 13 Feb 2026 03:44:16 +0000 Subject: [PATCH 8/9] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/commands/test_info.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/commands/test_info.py b/tests/commands/test_info.py index 182a62a8d..7c0d31d29 100644 --- a/tests/commands/test_info.py +++ b/tests/commands/test_info.py @@ -123,7 +123,9 @@ def test_info_multiple_generators(tmp_path: os.PathLike[str]) -> None: "Tag: py2-none-any\n" "Tag: py3-none-any\n" ) - whl = _build_wheel_with_modified_metadata(TESTWHEEL_PATH, str(tmp_path), wheel_content) + whl = _build_wheel_with_modified_metadata( + TESTWHEEL_PATH, str(tmp_path), wheel_content + ) output = _capture_info_output(whl) assert "Generator: bdist_wheel (0.30.0)" in output @@ -140,7 +142,9 @@ def test_info_no_generator(tmp_path: os.PathLike[str]) -> None: "Tag: py2-none-any\n" "Tag: py3-none-any\n" ) - whl = _build_wheel_with_modified_metadata(TESTWHEEL_PATH, str(tmp_path), wheel_content) + whl = _build_wheel_with_modified_metadata( + TESTWHEEL_PATH, str(tmp_path), wheel_content + ) output = _capture_info_output(whl) assert "Generator" not in output From e0487167b4ce138227a24a3a92529c33500f4118 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 15 Feb 2026 02:17:43 +0200 Subject: [PATCH 9/9] Redirect warnings to stderr in info.py Updated warning messages to print to stderr. --- src/wheel/_commands/info.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/wheel/_commands/info.py b/src/wheel/_commands/info.py index de736a5ce..27ad47a28 100644 --- a/src/wheel/_commands/info.py +++ b/src/wheel/_commands/info.py @@ -5,6 +5,7 @@ from __future__ import annotations import email.policy +import sys from email.parser import BytesParser from pathlib import Path @@ -57,9 +58,8 @@ def info(path: str, verbose: bool = False) -> None: generators = wheel_metadata.get_all("Generator", []) for generator in generators: print(f"Generator: {generator}") - except KeyError: - print("Warning: WHEEL metadata file not found") + print("Warning: WHEEL metadata file not found", file=sys.stderr) # Read package METADATA try: @@ -96,6 +96,7 @@ def info(path: str, verbose: bool = False) -> None: classifiers[:5] ): # Sort and limit to first 5 print(f" {classifier}") + if len(classifiers) > 5: print(f" ... and {len(classifiers) - 5} more") @@ -105,9 +106,8 @@ def info(path: str, verbose: bool = False) -> None: print("Requires-Dist:") for req in sorted(requires_dist): # Sort dependencies print(f" {req}") - except KeyError: - print("Warning: METADATA file not found") + print("Warning: METADATA file not found", file=sys.stderr) # File information file_count = len(wf.filelist)