diff --git a/docs/manpages/wheel.rst b/docs/manpages/wheel.rst index 2b21fdc0..6873ecf9 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/news.rst b/docs/news.rst index 9e1563de..a70f69f1 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 diff --git a/docs/reference/index.rst b/docs/reference/index.rst index f332026d..755dd149 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 00000000..3153a260 --- /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 c8b5a347..1b74b211 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 42f1d7ef..c60772c6 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 00000000..27ad47a2 --- /dev/null +++ b/src/wheel/_commands/info.py @@ -0,0 +1,124 @@ +""" +Display information about wheel files. +""" + +from __future__ import annotations + +import email.policy +import sys +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") + 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}") + + generators = wheel_metadata.get_all("Generator", []) + for generator in generators: + print(f"Generator: {generator}") + except KeyError: + print("Warning: WHEEL metadata file not found", file=sys.stderr) + + # 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=sys.stderr) + + # 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 00000000..7c0d31d2 --- /dev/null +++ b/tests/commands/test_info.py @@ -0,0 +1,200 @@ +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__) +TESTWHEEL_NAME = "test-1.0-py2.py3-none-any.whl" +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) + + # 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_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) + + # 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.""" + 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 "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