diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7f15fca46..61357e291 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,35 +14,30 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "pypy-3.10"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "pypy-3.11"] include: - os: macos-latest - python-version: "3.8" + python-version: "3.9" - os: windows-latest - python-version: "3.8" + python-version: "3.9" - os: macos-latest - python-version: "3.13" + python-version: "3.14" - os: windows-latest - python-version: "3.13" + python-version: "3.14" - os: macos-latest - python-version: "pypy-3.10" + python-version: "pypy-3.11" runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} allow-prereleases: true cache: pip cache-dependency-path: pyproject.toml - - name: Install the project - run: pip install --no-binary=wheel . - - name: Install test dependencies - run: pip install .[test] coverage[toml] - - name: Include SDist check dependencies - if: matrix.python-version == '3.12' - run: pip install build flit + - name: Install the project and its test dependencies + run: pip install --group test coverage[toml] . - name: Test with pytest run: coverage run -m pytest -v env: @@ -50,7 +45,7 @@ jobs: - name: Generate coverage report run: coverage xml - name: Send coverage data to Codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: file: coverage.xml env: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 90d584ff5..1b68d7711 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,8 +1,6 @@ -exclude: ^src/wheel/vendored - repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: check-added-large-files - id: check-case-conflict @@ -15,17 +13,17 @@ repos: - id: mixed-line-ending args: ["--fix=lf"] - id: trailing-whitespace - exclude: "tests/cli/test_convert.py" + exclude: "tests/commands/test_convert.py" - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.9 + rev: v0.15.9 hooks: - - id: ruff + - id: ruff-check args: [--fix, --show-fixes] - id: ruff-format - repo: https://github.com/codespell-project/codespell - rev: v2.3.0 + rev: v2.4.2 hooks: - id: codespell @@ -36,5 +34,13 @@ repos: - id: rst-directive-colons - id: rst-inline-touching-normal +- repo: https://github.com/henryiii/check-sdist + rev: v1.4.0 + hooks: + - id: check-sdist + args: [--inject-junk] + additional_dependencies: + - flit-core + ci: autoupdate_schedule: quarterly diff --git a/.readthedocs.yml b/.readthedocs.yml index 8c8c16040..901150a3f 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -10,7 +10,7 @@ sphinx: build: os: ubuntu-22.04 tools: - python: "3.8" + python: "3.9" python: install: diff --git a/README.rst b/README.rst index ea16e6546..cb499c945 100644 --- a/README.rst +++ b/README.rst @@ -9,7 +9,7 @@ This is a command line tool for manipulating Python wheel files, as defined in * Repack wheel archives * Add or remove tags in existing wheel archives -.. _PEP 427: https://www.python.org/dev/peps/pep-0427/ +.. _PEP 427: https://peps.python.org/pep-0427/ Historical note --------------- diff --git a/docs/development.rst b/docs/development.rst index 75b3a793d..a70b38cea 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -8,7 +8,7 @@ Pull Requests - Provide a good description of what you're doing and why. - Provide tests that cover your changes and try to run the tests locally first. -**Example**. Assuming you set up GitHub account, forked wheel repository from +**Example**. Assuming you set up a GitHub account, forked the wheel repository from https://github.com/pypa/wheel to your own page via web interface, and your fork is located at https://github.com/yourname/wheel @@ -23,7 +23,7 @@ fork is located at https://github.com/yourname/wheel $ git commit You may reference relevant issues in commit messages (like #1259) to -make GitHub link issues and commits together, and with phrase like +make GitHub link issues and commits together, and with a phrase like "fixes #1259" you can even close relevant issues automatically. Now push the changes to your fork:: @@ -54,7 +54,7 @@ To run the tests via tox against all matching interpreters:: To run the tests via tox against a specific environment:: - $ tox -e py35 + $ tox -e py39 Alternatively, you can run the tests via pytest using your default interpreter:: @@ -92,5 +92,5 @@ To make a new release: #. Create a new git tag matching the version exactly #. Push the new tag to GitHub -Pushing a new tag to GitHub will trigger the publish workflow which package the +Pushing a new tag to GitHub will trigger the publish workflow which will package the project and publish the resulting artifacts to PyPI. diff --git a/docs/index.rst b/docs/index.rst index 5a060d1e4..f8c7e1044 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -3,20 +3,11 @@ wheel `GitHub `_ | `PyPI `_ | -User IRC: #pypa | -Dev IRC: #pypa-dev +`PyPA Discord `_ -This library is the reference implementation of the Python wheel packaging -standard, as defined in `PEP 427`_. - -It has two different roles: - -#. A setuptools_ extension for building wheels that provides the - ``bdist_wheel`` setuptools command -#. A command line tool for working with wheel files - -.. _PEP 427: https://www.python.org/dev/peps/pep-0427/ -.. _setuptools: https://pypi.org/project/setuptools/ +.. include:: ../README.rst + :start-line: 3 + :end-before: Documentation .. toctree:: :maxdepth: 2 diff --git a/docs/installing.rst b/docs/installing.rst index 2f94efa49..972b43241 100644 --- a/docs/installing.rst +++ b/docs/installing.rst @@ -15,10 +15,10 @@ can typically find the wheel package under one of the following package names: * python3-wheel .. _pip: https://pip.pypa.io/en/stable/ -.. _installation instructions: https://pip.pypa.io/en/stable/installing/ +.. _installation instructions: https://pip.pypa.io/en/stable/installation/ Python and OS Compatibility --------------------------- wheel should work on any Python implementation and operating system and is -compatible with Python version 3.7 and upwards. +compatible with Python version 3.9 and upwards. diff --git a/docs/manpages/wheel.rst b/docs/manpages/wheel.rst index df1ac2aa0..6873ecf9b 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 @@ -43,4 +46,4 @@ Options -h, --help show this help message and exit -.. _`PEP 427`: https://www.python.org/dev/peps/pep-0427/ +.. _`PEP 427`: https://peps.python.org/pep-0427/ diff --git a/docs/news.rst b/docs/news.rst index 08127b89f..31710fa3c 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -1,6 +1,48 @@ Release Notes ============= +**0.47.0 (2026-04-22)** + +- Added the ``wheel info`` subcommand to display metadata about wheel files without + unpacking them (`#639 `_) +- Fixed ``WheelFile`` raising ``Missing RECORD file`` when the wheel filename contains + uppercase characters (e.g. ``Django-3.2.5.whl``) but the ``.dist-info`` directory + inside uses normalized lowercase naming + (`#411 `_) + +**0.46.3 (2026-01-22)** + +- Fixed ``ImportError: cannot import name '_setuptools_logging' from 'wheel'`` when + installed alongside an old version of setuptools and running the ``bdist_wheel`` + command (`#676 `_) + +**0.46.2 (2026-01-22)** + +- Restored the ``bdist_wheel`` command for compatibility with ``setuptools`` older than + v70.1 +- Importing ``wheel.bdist_wheel`` now emits a ``FutureWarning`` instead of a + ``DeprecationWarning`` +- Fixed ``wheel unpack`` potentially altering the permissions of files outside of the + destination tree with maliciously crafted wheels (CVE-2026-24049) + +**0.46.1 (2025-04-08)** + +- Temporarily restored the ``wheel.macosx_libfile`` module + (`#659 `_) + +**0.46.0 (2025-04-03)** + +- Dropped support for Python 3.8 +- Removed the ``bdist_wheel`` setuptools command implementation and entry point. + The ``wheel.bdist_wheel`` module is now just an alias to + ``setuptools.command.bdist_wheel``, emitting a deprecation warning on import. +- Removed vendored ``packaging`` in favor of a run-time dependency on it +- Made the ``wheel.metadata`` module private (with a deprecation warning if it's + imported +- Made the ``wheel.cli`` package private (no deprecation warning) +- Fixed an exception when calling the ``convert`` command with an empty description + field + **0.45.1 (2024-11-23)** - Fixed pure Python wheels converted from eggs and wininst files having the ABI tag in @@ -14,7 +56,7 @@ Release Notes ``setuptools.command.bdist_wheel.bdist_wheel`` to improve compatibility with ``setuptools``' latest fixes. - Projects are still advised to migrate away from the deprecated module and import + Projects are still advised to migrate away from the deprecated module and import the ``setuptools``' implementation explicitly. (PR by @abravalheri) **0.44.0 (2024-08-04)** @@ -94,7 +136,7 @@ Release Notes **0.38.1 (2022-11-04)** - Removed install dependency on setuptools -- The future-proof fix in 0.36.0 for converting PyPy's SOABI into a abi tag was +- The future-proof fix in 0.36.0 for converting PyPy's SOABI into an ABI tag was faulty. Fixed so that future changes in the SOABI will not change the tag. **0.38.0 (2022-10-21)** @@ -142,7 +184,7 @@ Release Notes - Updated vendored ``packaging`` library to v20.7 - Switched to always using LF as line separator when generating ``WHEEL`` files (on Windows, CRLF was being used instead) -- The ABI tag is taken from the sysconfig SOABI value. On PyPy the SOABI value +- The ABI tag is taken from the sysconfig SOABI value. On PyPy the SOABI value is ``pypy37-pp73`` which is not compliant with PEP 3149, as it should have both the API tag and the platform tag. This change future-proofs any change in PyPy's SOABI tag to make sure only the ABI tag is used by wheel. diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 13c16a35a..79a860401 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -14,7 +14,7 @@ section to your ``setup.cfg``:: [bdist_wheel] universal = 1 -To convert an ``.egg`` or file to a wheel:: +To convert an ``.egg`` file or directory to a wheel:: wheel convert youreggfile.egg 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_convert.rst b/docs/reference/wheel_convert.rst index ca625b535..d4fec85df 100644 --- a/docs/reference/wheel_convert.rst +++ b/docs/reference/wheel_convert.rst @@ -17,8 +17,8 @@ installers (``.exe``; made with ``bdist_wininst``) into wheels. Egg names must match the standard format: -* ``--pyX.Y`` for pure Python wheels -* ``--pyX.Y-`` for binary wheels +* ``--pyX.Y`` for pure Python eggs +* ``--pyX.Y-`` for binary eggs Options diff --git a/docs/reference/wheel_info.rst b/docs/reference/wheel_info.rst new file mode 100644 index 000000000..3153a260b --- /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/reference/wheel_pack.rst b/docs/reference/wheel_pack.rst index 0e375a8e4..ee76f3cee 100644 --- a/docs/reference/wheel_pack.rst +++ b/docs/reference/wheel_pack.rst @@ -28,7 +28,7 @@ Options .. option:: --build-number - Override the build tag in the new wheel file name + Override the build tag in the new wheel file name. Examples -------- diff --git a/docs/reference/wheel_tags.rst b/docs/reference/wheel_tags.rst index 38dc2c84b..547f66ef5 100644 --- a/docs/reference/wheel_tags.rst +++ b/docs/reference/wheel_tags.rst @@ -12,8 +12,8 @@ Description ----------- Make a new wheel with given tags from an existing wheel. Any tags left -unspecified will remain the same. Multiple tags are separated by a "." Starting -with a "+" will append to the existing tags. Starting with a "-" will remove a +unspecified will remain the same. Multiple tags are separated by a ".". Starting +with a "+" will append to the existing tags. Starting with a "-" will remove a tag. Be sure to use the equals syntax on the shell so that it does not get parsed as an extra option, such as ``--python-tag=-py2``. The original file will remain unless ``--remove`` is given. The output filename(s) will be @@ -29,12 +29,12 @@ Options .. option:: --python-tag=TAG - Override the python tag (prepend with "+" to append, "-" to remove). + Override the Python tag (prepend with "+" to append, "-" to remove). Multiple tags can be separated with a dot. .. option:: --abi-tag=TAG - Override the abi tag (prepend with "+" to append, "-" to remove). + Override the ABI tag (prepend with "+" to append, "-" to remove). Multiple tags can be separated with a dot. .. option:: --platform-tag=TAG diff --git a/docs/story.rst b/docs/story.rst index 101c8cd81..1dd250339 100644 --- a/docs/story.rst +++ b/docs/story.rst @@ -21,7 +21,7 @@ It was a lot of work. The package is worse off than before, and it can’t be built or installed without patching the Python source code itself. It was about that time that distutils-sig had a discussion about the -need to include a generated ``setup.cfg`` from ``setup.cfg`` because +need to include a generated ``setup.py`` from ``setup.cfg`` because ``setup.cfg`` wasn’t static enough. Wait, what? Of course there is a different way to massively simplify the install 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/pyproject.toml b/pyproject.toml index d9a59d43f..752a91e74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,29 +1,32 @@ [build-system] -requires = ["flit_core >=3.8,<4"] +requires = ["flit_core >=3.11,<4"] build-backend = "flit_core.buildapi" [project] name = "wheel" -description = "A built-package format for Python" +description = "Command line tool for manipulating wheel files" readme = "README.rst" -license = {file = "LICENSE.txt"} +license = "MIT" classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Topic :: System :: Archiving :: Packaging", - "License :: OSI Approved :: MIT License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] authors = [{name = "Daniel Holth", email = "dholth@fastmail.fm"}] maintainers = [{name = "Alex Grönholm", email = "alex.gronholm@nextday.fi"}] keywords = ["wheel", "packaging"] -requires-python = ">=3.8" +requires-python = ">=3.9" +dependencies = [ + "packaging >= 24.0" +] dynamic = ["version"] [project.urls] @@ -33,20 +36,20 @@ Changelog = "https://wheel.readthedocs.io/en/stable/news.html" Source = "https://github.com/pypa/wheel" [project.scripts] -wheel = "wheel.cli:main" +wheel = "wheel._commands:main" [project.entry-points."distutils.commands"] bdist_wheel = "wheel.bdist_wheel:bdist_wheel" -[project.optional-dependencies] +[dependency-groups] test = [ + "packaging >= 26.0", "pytest >= 6.0.0", - "setuptools >= 65", + "setuptools >= 77", ] [tool.flit.sdist] include = [ - "LICENSE*", "docs/**/*.py", "docs/**/*.rst", "docs/Makefile", @@ -61,7 +64,6 @@ include = [ "tests/testdata/test-1.0-py2.py3-none-any.whl", ] exclude = [ - ".cirrus.yml", ".github/**", ".gitignore", ".pre-commit-config.yaml", @@ -77,28 +79,23 @@ filterwarnings = [ "error", "ignore::Warning:_pytest.*", ] -log_cli_level = "info" +log_level = "INFO" testpaths = ["test"] [tool.coverage.run] source = ["wheel"] -omit = ["*/vendored/*"] -exclude_also = [ - "@overload", - "if TYPE_CHECKING:" -] [tool.coverage.report] show_missing = true exclude_also = [ "@abstractmethod", + "@overload", + "if TYPE_CHECKING:", ] -[tool.ruff] -extend-exclude = ["src/wheel/vendored"] - [tool.ruff.lint] extend-select = [ + "ANN", # flake8-annotations "B", # flake8-bugbear "G", # flake8-logging-format "I", # isort @@ -109,38 +106,33 @@ extend-select = [ "W", # pycodestyle warnings ] +[tool.ruff.lint.extend-per-file-ignores] +"tests/commands/test_convert.py" = ["W293"] +"src/wheel/macosx_libfile.py" = ["ANN201"] +"src/wheel/_bdist_wheel.py" = ["ANN202", "G004"] + +[tool.ruff.lint.flake8-annotations] +mypy-init-return = true + # Tox (https://tox.wiki/) is a tool for running tests in multiple virtualenvs. # This configuration file will run the test suite on all supported python # versions. To use it, "pipx install tox" and then run "tox" from this # directory. [tool.tox] -legacy_tox_ini = ''' -[tox] -envlist = py38, py39, py310, py311, py312, py313, pypy3, lint, pkg -minversion = 4.0.0 +env_list = ["py39", "py310", "py311", "py312", "py313", "py314", "pypy3", "lint", "pkg"] skip_missing_interpreters = true - -[testenv] -package = wheel -wheel_build_env = .pkg -depends = lint -commands = {env_python} -b -m pytest {posargs} -extras = test -set_env = - PYTHONWARNDEFAULTENCODING = 1 - -[testenv:lint] -depends = -basepython = python3 -deps = pre-commit -commands = pre-commit run --all-files --show-diff-on-failure -skip_install = true - -[testenv:pkg] -basepython = python3 -deps = - build - flit>=3.8 -commands = {envpython} -b -m pytest tests/test_sdist.py {posargs} -''' +requires = ["tox >= 4.22"] + +[tool.tox.env_run_base] +depends = ["lint"] +package = "wheel" +commands = [["pytest", { replace = "posargs", extend = true }]] +dependency_groups = ["test"] +set_env = { PYTHONWARNDEFAULTENCODING = "1" } + +[tool.tox.env.lint] +depends = [] +deps = ["pre-commit"] +package = "skip" +commands = [["pre-commit", "run", "-a"]] diff --git a/src/wheel/__init__.py b/src/wheel/__init__.py index 3ab8f72d8..0d9049b66 100644 --- a/src/wheel/__init__.py +++ b/src/wheel/__init__.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "0.45.1" +__version__ = "0.47.0" diff --git a/src/wheel/__main__.py b/src/wheel/__main__.py index 0be745374..ce9a58d3b 100644 --- a/src/wheel/__main__.py +++ b/src/wheel/__main__.py @@ -1,23 +1,25 @@ """ -Wheel command line tool (enable python -m wheel syntax) +Wheel command line tool (enables the ``python -m wheel`` syntax) """ from __future__ import annotations import sys +from typing import NoReturn -def main(): # needed for console script - if __package__ == "": +def main() -> NoReturn: # needed for console script + if __spec__.parent == "": # To be able to run 'python wheel-0.9.whl/wheel': import os.path path = os.path.dirname(os.path.dirname(__file__)) sys.path[0:0] = [path] - import wheel.cli - sys.exit(wheel.cli.main()) + from ._commands import main as cli_main + + sys.exit(cli_main()) if __name__ == "__main__": - sys.exit(main()) + main() diff --git a/src/wheel/_bdist_wheel.py b/src/wheel/_bdist_wheel.py index 88973ebfb..575fbfb35 100644 --- a/src/wheel/_bdist_wheel.py +++ b/src/wheel/_bdist_wheel.py @@ -6,6 +6,7 @@ from __future__ import annotations +import logging import os import re import shutil @@ -14,21 +15,21 @@ import sys import sysconfig import warnings +from collections.abc import Iterable, Sequence from email.generator import BytesGenerator, Generator from email.policy import EmailPolicy from glob import iglob from shutil import rmtree -from typing import TYPE_CHECKING, Callable, Iterable, Literal, Sequence, cast +from typing import TYPE_CHECKING, Callable, Literal, cast from zipfile import ZIP_DEFLATED, ZIP_STORED import setuptools +from packaging import tags +from packaging import version as _packaging_version from setuptools import Command from . import __version__ as wheel_version -from .metadata import pkginfo_to_metadata -from .util import log -from .vendored.packaging import tags -from .vendored.packaging import version as _packaging_version +from ._metadata import pkginfo_to_metadata from .wheelfile import WheelFile if TYPE_CHECKING: @@ -43,6 +44,8 @@ _setuptools_logging.configure() +log = logging.getLogger("wheel") + def safe_name(name: str) -> str: """Convert an arbitrary string to a standard distribution name @@ -370,9 +373,9 @@ def get_tag(self) -> tuple[str, str, str]: supported_tags = [ (t.interpreter, t.abi, plat_name) for t in tags.sys_tags() ] - assert ( - tag in supported_tags - ), f"would build wheel with unsupported tag {tag}" + assert tag in supported_tags, ( + f"would build wheel with unsupported tag {tag}" + ) return tag def run(self): diff --git a/src/wheel/cli/__init__.py b/src/wheel/_commands/__init__.py similarity index 87% rename from src/wheel/cli/__init__.py rename to src/wheel/_commands/__init__.py index 6ba1217f5..c60772c67 100644 --- a/src/wheel/cli/__init__.py +++ b/src/wheel/_commands/__init__.py @@ -9,9 +9,7 @@ import sys from argparse import ArgumentTypeError - -class WheelError(Exception): - pass +from ..wheelfile import WheelError def unpack_f(args: argparse.Namespace) -> None: @@ -51,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__ @@ -75,7 +82,7 @@ def parse_build_tag(build_tag: str) -> str: """ -def parser(): +def parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser() s = p.add_subparsers(help="commands") @@ -131,6 +138,13 @@ def parser(): ) 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) @@ -140,7 +154,7 @@ def parser(): return p -def main(): +def main() -> int: p = parser() args = p.parse_args() if not hasattr(args, "func"): diff --git a/src/wheel/cli/convert.py b/src/wheel/_commands/convert.py similarity index 96% rename from src/wheel/cli/convert.py rename to src/wheel/_commands/convert.py index 61d4775c5..cafd12c86 100644 --- a/src/wheel/cli/convert.py +++ b/src/wheel/_commands/convert.py @@ -13,9 +13,10 @@ from textwrap import dedent from zipfile import ZipFile +from packaging.tags import parse_tag + from .. import __version__ -from ..metadata import generate_requirements -from ..vendored.packaging.tags import parse_tag +from .._metadata import generate_requirements from ..wheelfile import WheelFile egg_filename_re = re.compile( @@ -63,7 +64,7 @@ def convert_requires(requires: str, metadata: Message) -> None: metadata.add_header(key, value) -def convert_pkg_info(pkginfo: str, metadata: Message): +def convert_pkg_info(pkginfo: str, metadata: Message) -> None: parsed_message = Parser().parsestr(pkginfo) for key, value in parsed_message.items(): key_lower = key.lower() @@ -72,13 +73,17 @@ def convert_pkg_info(pkginfo: str, metadata: Message): if key_lower == "description": description_lines = value.splitlines() - value = "\n".join( - ( - description_lines[0].lstrip(), - dedent("\n".join(description_lines[1:])), - "\n", + if description_lines: + value = "\n".join( + ( + description_lines[0].lstrip(), + dedent("\n".join(description_lines[1:])), + "\n", + ) ) - ) + else: + value = "\n" + metadata.set_payload(value) elif key_lower == "home-page": metadata.add_header("Project-URL", f"Homepage, {value}") diff --git a/src/wheel/_commands/info.py b/src/wheel/_commands/info.py new file mode 100644 index 000000000..27ad47a28 --- /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/src/wheel/cli/pack.py b/src/wheel/_commands/pack.py similarity index 97% rename from src/wheel/cli/pack.py rename to src/wheel/_commands/pack.py index 64469c0c7..1321ce930 100644 --- a/src/wheel/cli/pack.py +++ b/src/wheel/_commands/pack.py @@ -6,8 +6,7 @@ from email.generator import BytesGenerator from email.parser import BytesParser -from wheel.cli import WheelError -from wheel.wheelfile import WheelFile +from ..wheelfile import WheelError, WheelFile DIST_INFO_RE = re.compile(r"^(?P(?P.+?)-(?P\d.*?))\.dist-info$") diff --git a/src/wheel/cli/tags.py b/src/wheel/_commands/tags.py similarity index 97% rename from src/wheel/cli/tags.py rename to src/wheel/_commands/tags.py index 88da72e9e..cec896b55 100644 --- a/src/wheel/cli/tags.py +++ b/src/wheel/_commands/tags.py @@ -119,9 +119,10 @@ def tags( ) final_wheel_path = os.path.join(os.path.dirname(f.filename), final_wheel_name) - with WheelFile(original_wheel_path, "r") as fin, WheelFile( - final_wheel_path, "w" - ) as fout: + with ( + WheelFile(original_wheel_path, "r") as fin, + WheelFile(final_wheel_path, "w") as fout, + ): fout.comment = fin.comment # preserve the comment for item in fin.infolist(): if item.is_dir(): diff --git a/src/wheel/cli/unpack.py b/src/wheel/_commands/unpack.py similarity index 89% rename from src/wheel/cli/unpack.py rename to src/wheel/_commands/unpack.py index d48840e6e..83dc7423f 100644 --- a/src/wheel/cli/unpack.py +++ b/src/wheel/_commands/unpack.py @@ -19,12 +19,12 @@ def unpack(path: str, dest: str = ".") -> None: destination = Path(dest) / namever print(f"Unpacking to: {destination}...", end="", flush=True) for zinfo in wf.filelist: - wf.extract(zinfo, destination) + target_path = Path(wf.extract(zinfo, destination)) # Set permissions to the same values as they were set in the archive # We have to do this manually due to # https://github.com/python/cpython/issues/59999 permissions = zinfo.external_attr >> 16 & 0o777 - destination.joinpath(zinfo.filename).chmod(permissions) + target_path.chmod(permissions) print("OK") diff --git a/src/wheel/_metadata.py b/src/wheel/_metadata.py new file mode 100644 index 000000000..e17a7b924 --- /dev/null +++ b/src/wheel/_metadata.py @@ -0,0 +1,184 @@ +""" +Tools for converting old- to new-style metadata. +""" + +from __future__ import annotations + +import functools +import itertools +import os.path +import re +import textwrap +from collections.abc import Generator, Iterable, Iterator +from email.message import Message +from email.parser import Parser +from typing import Literal + +from packaging.requirements import Requirement + + +def _nonblank(str: str) -> bool | Literal[""]: + return str and not str.startswith("#") + + +@functools.singledispatch +def yield_lines(iterable: Iterable[str]) -> Iterator[str]: + r""" + Yield valid lines of a string or iterable. + >>> list(yield_lines('')) + [] + >>> list(yield_lines(['foo', 'bar'])) + ['foo', 'bar'] + >>> list(yield_lines('foo\nbar')) + ['foo', 'bar'] + >>> list(yield_lines('\nfoo\n#bar\nbaz #comment')) + ['foo', 'baz #comment'] + >>> list(yield_lines(['foo\nbar', 'baz', 'bing\n\n\n'])) + ['foo', 'bar', 'baz', 'bing'] + """ + return itertools.chain.from_iterable(map(yield_lines, iterable)) + + +@yield_lines.register(str) +def _(text: str) -> Iterator[str]: + return filter(_nonblank, map(str.strip, text.splitlines())) + + +def split_sections( + s: str | Iterator[str], +) -> Generator[tuple[str | None, list[str]], None, None]: + """Split a string or iterable thereof into (section, content) pairs + Each ``section`` is a stripped version of the section header ("[section]") + and each ``content`` is a list of stripped lines excluding blank lines and + comment-only lines. If there are any such lines before the first section + header, they're returned in a first ``section`` of ``None``. + """ + section = None + content: list[str] = [] + for line in yield_lines(s): + if line.startswith("["): + if line.endswith("]"): + if section or content: + yield section, content + section = line[1:-1].strip() + content = [] + else: + raise ValueError("Invalid section heading", line) + else: + content.append(line) + + # wrap up last segment + yield section, content + + +def safe_extra(extra: str) -> str: + """Convert an arbitrary string to a standard 'extra' name + Any runs of non-alphanumeric characters are replaced with a single '_', + and the result is always lowercased. + """ + return re.sub("[^A-Za-z0-9.-]+", "_", extra).lower() + + +def safe_name(name: str) -> str: + """Convert an arbitrary string to a standard distribution name + Any runs of non-alphanumeric/. characters are replaced with a single '-'. + """ + return re.sub("[^A-Za-z0-9.]+", "-", name) + + +def requires_to_requires_dist(requirement: Requirement) -> str: + """Return the version specifier for a requirement in PEP 345/566 fashion.""" + if requirement.url: + return " @ " + requirement.url + + requires_dist: list[str] = [] + for spec in requirement.specifier: + requires_dist.append(spec.operator + spec.version) + + if requires_dist: + return " " + ",".join(sorted(requires_dist)) + else: + return "" + + +def convert_requirements(requirements: list[str]) -> Iterator[str]: + """Yield Requires-Dist: strings for parsed requirements strings.""" + for req in requirements: + parsed_requirement = Requirement(req) + spec = requires_to_requires_dist(parsed_requirement) + extras = ",".join(sorted(safe_extra(e) for e in parsed_requirement.extras)) + if extras: + extras = f"[{extras}]" + + yield safe_name(parsed_requirement.name) + extras + spec + + +def generate_requirements( + extras_require: dict[str | None, list[str]], +) -> Iterator[tuple[str, str]]: + """ + Convert requirements from a setup()-style dictionary to + ('Requires-Dist', 'requirement') and ('Provides-Extra', 'extra') tuples. + + extras_require is a dictionary of {extra: [requirements]} as passed to setup(), + using the empty extra {'': [requirements]} to hold install_requires. + """ + for extra, depends in extras_require.items(): + condition = "" + extra = extra or "" + if ":" in extra: # setuptools extra:condition syntax + extra, condition = extra.split(":", 1) + + extra = safe_extra(extra) + if extra: + yield "Provides-Extra", extra + if condition: + condition = "(" + condition + ") and " + condition += f"extra == '{extra}'" + + if condition: + condition = " ; " + condition + + for new_req in convert_requirements(depends): + canonical_req = str(Requirement(new_req + condition)) + yield "Requires-Dist", canonical_req + + +def pkginfo_to_metadata(egg_info_path: str, pkginfo_path: str) -> Message: + """ + Convert .egg-info directory with PKG-INFO to the Metadata 2.1 format + """ + with open(pkginfo_path, encoding="utf-8") as headers: + pkg_info = Parser().parse(headers) + + pkg_info.replace_header("Metadata-Version", "2.1") + # Those will be regenerated from `requires.txt`. + del pkg_info["Provides-Extra"] + del pkg_info["Requires-Dist"] + requires_path = os.path.join(egg_info_path, "requires.txt") + if os.path.exists(requires_path): + with open(requires_path, encoding="utf-8") as requires_file: + requires = requires_file.read() + + parsed_requirements = sorted(split_sections(requires), key=lambda x: x[0] or "") + for extra, reqs in parsed_requirements: + for key, value in generate_requirements({extra: reqs}): + if (key, value) not in pkg_info.items(): + pkg_info[key] = value + + description = pkg_info["Description"] + if description: + description_lines = pkg_info["Description"].splitlines() + dedented_description = "\n".join( + # if the first line of long_description is blank, + # the first line here will be indented. + ( + description_lines[0].lstrip(), + textwrap.dedent("\n".join(description_lines[1:])), + "\n", + ) + ) + pkg_info.set_payload(dedented_description) + del pkg_info["Description"] + + return pkg_info diff --git a/src/wheel/bdist_wheel.py b/src/wheel/bdist_wheel.py index dd7b8629e..24199c246 100644 --- a/src/wheel/bdist_wheel.py +++ b/src/wheel/bdist_wheel.py @@ -5,7 +5,7 @@ "The 'wheel' package is no longer the canonical location of the 'bdist_wheel' " "command, and will be removed in a future release. Please update to setuptools " "v70.1 or later which contains an integrated version of this command.", - DeprecationWarning, + FutureWarning, stacklevel=1, ) diff --git a/src/wheel/macosx_libfile.py b/src/wheel/macosx_libfile.py index abdfc9eda..06e51af29 100644 --- a/src/wheel/macosx_libfile.py +++ b/src/wheel/macosx_libfile.py @@ -1,4 +1,8 @@ """ +IMPORTANT: DO NOT IMPORT THIS MODULE DIRECTLY. +THIS IS ONLY KEPT IN PLACE FOR BACKWARDS COMPATIBILITY WITH +setuptools.command.bdist_wheel. + This module contains function to analyse dynamic library headers to extract system information diff --git a/src/wheel/metadata.py b/src/wheel/metadata.py index b8098fa85..e27900a25 100644 --- a/src/wheel/metadata.py +++ b/src/wheel/metadata.py @@ -1,183 +1,17 @@ -""" -Tools for converting old- to new-style metadata. -""" - -from __future__ import annotations - -import functools -import itertools -import os.path -import re -import textwrap -from email.message import Message -from email.parser import Parser -from typing import Generator, Iterable, Iterator, Literal - -from .vendored.packaging.requirements import Requirement - - -def _nonblank(str: str) -> bool | Literal[""]: - return str and not str.startswith("#") - - -@functools.singledispatch -def yield_lines(iterable: Iterable[str]) -> Iterator[str]: - r""" - Yield valid lines of a string or iterable. - >>> list(yield_lines('')) - [] - >>> list(yield_lines(['foo', 'bar'])) - ['foo', 'bar'] - >>> list(yield_lines('foo\nbar')) - ['foo', 'bar'] - >>> list(yield_lines('\nfoo\n#bar\nbaz #comment')) - ['foo', 'baz #comment'] - >>> list(yield_lines(['foo\nbar', 'baz', 'bing\n\n\n'])) - ['foo', 'bar', 'baz', 'bing'] - """ - return itertools.chain.from_iterable(map(yield_lines, iterable)) - - -@yield_lines.register(str) -def _(text: str) -> Iterator[str]: - return filter(_nonblank, map(str.strip, text.splitlines())) - - -def split_sections( - s: str | Iterator[str], -) -> Generator[tuple[str | None, list[str]], None, None]: - """Split a string or iterable thereof into (section, content) pairs - Each ``section`` is a stripped version of the section header ("[section]") - and each ``content`` is a list of stripped lines excluding blank lines and - comment-only lines. If there are any such lines before the first section - header, they're returned in a first ``section`` of ``None``. - """ - section = None - content: list[str] = [] - for line in yield_lines(s): - if line.startswith("["): - if line.endswith("]"): - if section or content: - yield section, content - section = line[1:-1].strip() - content = [] - else: - raise ValueError("Invalid section heading", line) - else: - content.append(line) - - # wrap up last segment - yield section, content - - -def safe_extra(extra: str) -> str: - """Convert an arbitrary string to a standard 'extra' name - Any runs of non-alphanumeric characters are replaced with a single '_', - and the result is always lowercased. - """ - return re.sub("[^A-Za-z0-9.-]+", "_", extra).lower() - - -def safe_name(name: str) -> str: - """Convert an arbitrary string to a standard distribution name - Any runs of non-alphanumeric/. characters are replaced with a single '-'. - """ - return re.sub("[^A-Za-z0-9.]+", "-", name) - - -def requires_to_requires_dist(requirement: Requirement) -> str: - """Return the version specifier for a requirement in PEP 345/566 fashion.""" - if requirement.url: - return " @ " + requirement.url - - requires_dist: list[str] = [] - for spec in requirement.specifier: - requires_dist.append(spec.operator + spec.version) - - if requires_dist: - return " " + ",".join(sorted(requires_dist)) - else: - return "" - - -def convert_requirements(requirements: list[str]) -> Iterator[str]: - """Yield Requires-Dist: strings for parsed requirements strings.""" - for req in requirements: - parsed_requirement = Requirement(req) - spec = requires_to_requires_dist(parsed_requirement) - extras = ",".join(sorted(safe_extra(e) for e in parsed_requirement.extras)) - if extras: - extras = f"[{extras}]" - - yield safe_name(parsed_requirement.name) + extras + spec - - -def generate_requirements( - extras_require: dict[str | None, list[str]], -) -> Iterator[tuple[str, str]]: - """ - Convert requirements from a setup()-style dictionary to - ('Requires-Dist', 'requirement') and ('Provides-Extra', 'extra') tuples. - - extras_require is a dictionary of {extra: [requirements]} as passed to setup(), - using the empty extra {'': [requirements]} to hold install_requires. - """ - for extra, depends in extras_require.items(): - condition = "" - extra = extra or "" - if ":" in extra: # setuptools extra:condition syntax - extra, condition = extra.split(":", 1) - - extra = safe_extra(extra) - if extra: - yield "Provides-Extra", extra - if condition: - condition = "(" + condition + ") and " - condition += f"extra == '{extra}'" - - if condition: - condition = " ; " + condition - - for new_req in convert_requirements(depends): - canonical_req = str(Requirement(new_req + condition)) - yield "Requires-Dist", canonical_req - - -def pkginfo_to_metadata(egg_info_path: str, pkginfo_path: str) -> Message: - """ - Convert .egg-info directory with PKG-INFO to the Metadata 2.1 format - """ - with open(pkginfo_path, encoding="utf-8") as headers: - pkg_info = Parser().parse(headers) - - pkg_info.replace_header("Metadata-Version", "2.1") - # Those will be regenerated from `requires.txt`. - del pkg_info["Provides-Extra"] - del pkg_info["Requires-Dist"] - requires_path = os.path.join(egg_info_path, "requires.txt") - if os.path.exists(requires_path): - with open(requires_path, encoding="utf-8") as requires_file: - requires = requires_file.read() - - parsed_requirements = sorted(split_sections(requires), key=lambda x: x[0] or "") - for extra, reqs in parsed_requirements: - for key, value in generate_requirements({extra: reqs}): - if (key, value) not in pkg_info.items(): - pkg_info[key] = value - - description = pkg_info["Description"] - if description: - description_lines = pkg_info["Description"].splitlines() - dedented_description = "\n".join( - # if the first line of long_description is blank, - # the first line here will be indented. - ( - description_lines[0].lstrip(), - textwrap.dedent("\n".join(description_lines[1:])), - "\n", - ) - ) - pkg_info.set_payload(dedented_description) - del pkg_info["Description"] - - return pkg_info +from warnings import warn + +from ._metadata import convert_requirements as convert_requirements +from ._metadata import generate_requirements as generate_requirements +from ._metadata import pkginfo_to_metadata as pkginfo_to_metadata +from ._metadata import requires_to_requires_dist as requires_to_requires_dist +from ._metadata import safe_extra as safe_extra +from ._metadata import safe_name as safe_name +from ._metadata import split_sections as split_sections + +warn( + f"The {__name__!r} package has been made private and should no longer be imported. " + f"Please either copy the code or find an alternative library to import it from, as " + f"this warning will be removed in a future version of 'wheel'.", + DeprecationWarning, + stacklevel=2, +) diff --git a/src/wheel/util.py b/src/wheel/util.py deleted file mode 100644 index c928aa403..000000000 --- a/src/wheel/util.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -import base64 -import logging - -log = logging.getLogger("wheel") - - -def urlsafe_b64encode(data: bytes) -> bytes: - """urlsafe_b64encode without padding""" - return base64.urlsafe_b64encode(data).rstrip(b"=") - - -def urlsafe_b64decode(data: bytes) -> bytes: - """urlsafe_b64decode without padding""" - pad = b"=" * (4 - (len(data) & 3)) - return base64.urlsafe_b64decode(data + pad) diff --git a/src/wheel/vendored/packaging/LICENSE b/src/wheel/vendored/packaging/LICENSE deleted file mode 100644 index 6f62d44e4..000000000 --- a/src/wheel/vendored/packaging/LICENSE +++ /dev/null @@ -1,3 +0,0 @@ -This software is made available under the terms of *either* of the licenses -found in LICENSE.APACHE or LICENSE.BSD. Contributions to this software is made -under the terms of *both* these licenses. diff --git a/src/wheel/vendored/packaging/LICENSE.APACHE b/src/wheel/vendored/packaging/LICENSE.APACHE deleted file mode 100644 index f433b1a53..000000000 --- a/src/wheel/vendored/packaging/LICENSE.APACHE +++ /dev/null @@ -1,177 +0,0 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS diff --git a/src/wheel/vendored/packaging/LICENSE.BSD b/src/wheel/vendored/packaging/LICENSE.BSD deleted file mode 100644 index 42ce7b75c..000000000 --- a/src/wheel/vendored/packaging/LICENSE.BSD +++ /dev/null @@ -1,23 +0,0 @@ -Copyright (c) Donald Stufft and individual contributors. -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - - 1. Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - - 2. Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/src/wheel/vendored/packaging/__init__.py b/src/wheel/vendored/packaging/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/wheel/vendored/packaging/_elffile.py b/src/wheel/vendored/packaging/_elffile.py deleted file mode 100644 index 6fb19b30b..000000000 --- a/src/wheel/vendored/packaging/_elffile.py +++ /dev/null @@ -1,108 +0,0 @@ -""" -ELF file parser. - -This provides a class ``ELFFile`` that parses an ELF executable in a similar -interface to ``ZipFile``. Only the read interface is implemented. - -Based on: https://gist.github.com/lyssdod/f51579ae8d93c8657a5564aefc2ffbca -ELF header: https://refspecs.linuxfoundation.org/elf/gabi4+/ch4.eheader.html -""" - -import enum -import os -import struct -from typing import IO, Optional, Tuple - - -class ELFInvalid(ValueError): - pass - - -class EIClass(enum.IntEnum): - C32 = 1 - C64 = 2 - - -class EIData(enum.IntEnum): - Lsb = 1 - Msb = 2 - - -class EMachine(enum.IntEnum): - I386 = 3 - S390 = 22 - Arm = 40 - X8664 = 62 - AArc64 = 183 - - -class ELFFile: - """ - Representation of an ELF executable. - """ - - def __init__(self, f: IO[bytes]) -> None: - self._f = f - - try: - ident = self._read("16B") - except struct.error: - raise ELFInvalid("unable to parse identification") - magic = bytes(ident[:4]) - if magic != b"\x7fELF": - raise ELFInvalid(f"invalid magic: {magic!r}") - - self.capacity = ident[4] # Format for program header (bitness). - self.encoding = ident[5] # Data structure encoding (endianness). - - try: - # e_fmt: Format for program header. - # p_fmt: Format for section header. - # p_idx: Indexes to find p_type, p_offset, and p_filesz. - e_fmt, self._p_fmt, self._p_idx = { - (1, 1): ("HHIIIIIHHH", ">IIIIIIII", (0, 1, 4)), # 32-bit MSB. - (2, 1): ("HHIQQQIHHH", ">IIQQQQQQ", (0, 2, 5)), # 64-bit MSB. - }[(self.capacity, self.encoding)] - except KeyError: - raise ELFInvalid( - f"unrecognized capacity ({self.capacity}) or " - f"encoding ({self.encoding})" - ) - - try: - ( - _, - self.machine, # Architecture type. - _, - _, - self._e_phoff, # Offset of program header. - _, - self.flags, # Processor-specific flags. - _, - self._e_phentsize, # Size of section. - self._e_phnum, # Number of sections. - ) = self._read(e_fmt) - except struct.error as e: - raise ELFInvalid("unable to parse machine and section information") from e - - def _read(self, fmt: str) -> Tuple[int, ...]: - return struct.unpack(fmt, self._f.read(struct.calcsize(fmt))) - - @property - def interpreter(self) -> Optional[str]: - """ - The path recorded in the ``PT_INTERP`` section header. - """ - for index in range(self._e_phnum): - self._f.seek(self._e_phoff + self._e_phentsize * index) - try: - data = self._read(self._p_fmt) - except struct.error: - continue - if data[self._p_idx[0]] != 3: # Not PT_INTERP. - continue - self._f.seek(data[self._p_idx[1]]) - return os.fsdecode(self._f.read(data[self._p_idx[2]])).strip("\0") - return None diff --git a/src/wheel/vendored/packaging/_manylinux.py b/src/wheel/vendored/packaging/_manylinux.py deleted file mode 100644 index 1f5f4ab3e..000000000 --- a/src/wheel/vendored/packaging/_manylinux.py +++ /dev/null @@ -1,260 +0,0 @@ -import collections -import contextlib -import functools -import os -import re -import sys -import warnings -from typing import Dict, Generator, Iterator, NamedTuple, Optional, Sequence, Tuple - -from ._elffile import EIClass, EIData, ELFFile, EMachine - -EF_ARM_ABIMASK = 0xFF000000 -EF_ARM_ABI_VER5 = 0x05000000 -EF_ARM_ABI_FLOAT_HARD = 0x00000400 - - -# `os.PathLike` not a generic type until Python 3.9, so sticking with `str` -# as the type for `path` until then. -@contextlib.contextmanager -def _parse_elf(path: str) -> Generator[Optional[ELFFile], None, None]: - try: - with open(path, "rb") as f: - yield ELFFile(f) - except (OSError, TypeError, ValueError): - yield None - - -def _is_linux_armhf(executable: str) -> bool: - # hard-float ABI can be detected from the ELF header of the running - # process - # https://static.docs.arm.com/ihi0044/g/aaelf32.pdf - with _parse_elf(executable) as f: - return ( - f is not None - and f.capacity == EIClass.C32 - and f.encoding == EIData.Lsb - and f.machine == EMachine.Arm - and f.flags & EF_ARM_ABIMASK == EF_ARM_ABI_VER5 - and f.flags & EF_ARM_ABI_FLOAT_HARD == EF_ARM_ABI_FLOAT_HARD - ) - - -def _is_linux_i686(executable: str) -> bool: - with _parse_elf(executable) as f: - return ( - f is not None - and f.capacity == EIClass.C32 - and f.encoding == EIData.Lsb - and f.machine == EMachine.I386 - ) - - -def _have_compatible_abi(executable: str, archs: Sequence[str]) -> bool: - if "armv7l" in archs: - return _is_linux_armhf(executable) - if "i686" in archs: - return _is_linux_i686(executable) - allowed_archs = { - "x86_64", - "aarch64", - "ppc64", - "ppc64le", - "s390x", - "loongarch64", - "riscv64", - } - return any(arch in allowed_archs for arch in archs) - - -# If glibc ever changes its major version, we need to know what the last -# minor version was, so we can build the complete list of all versions. -# For now, guess what the highest minor version might be, assume it will -# be 50 for testing. Once this actually happens, update the dictionary -# with the actual value. -_LAST_GLIBC_MINOR: Dict[int, int] = collections.defaultdict(lambda: 50) - - -class _GLibCVersion(NamedTuple): - major: int - minor: int - - -def _glibc_version_string_confstr() -> Optional[str]: - """ - Primary implementation of glibc_version_string using os.confstr. - """ - # os.confstr is quite a bit faster than ctypes.DLL. It's also less likely - # to be broken or missing. This strategy is used in the standard library - # platform module. - # https://github.com/python/cpython/blob/fcf1d003bf4f0100c/Lib/platform.py#L175-L183 - try: - # Should be a string like "glibc 2.17". - version_string: Optional[str] = os.confstr("CS_GNU_LIBC_VERSION") - assert version_string is not None - _, version = version_string.rsplit() - except (AssertionError, AttributeError, OSError, ValueError): - # os.confstr() or CS_GNU_LIBC_VERSION not available (or a bad value)... - return None - return version - - -def _glibc_version_string_ctypes() -> Optional[str]: - """ - Fallback implementation of glibc_version_string using ctypes. - """ - try: - import ctypes - except ImportError: - return None - - # ctypes.CDLL(None) internally calls dlopen(NULL), and as the dlopen - # manpage says, "If filename is NULL, then the returned handle is for the - # main program". This way we can let the linker do the work to figure out - # which libc our process is actually using. - # - # We must also handle the special case where the executable is not a - # dynamically linked executable. This can occur when using musl libc, - # for example. In this situation, dlopen() will error, leading to an - # OSError. Interestingly, at least in the case of musl, there is no - # errno set on the OSError. The single string argument used to construct - # OSError comes from libc itself and is therefore not portable to - # hard code here. In any case, failure to call dlopen() means we - # can proceed, so we bail on our attempt. - try: - process_namespace = ctypes.CDLL(None) - except OSError: - return None - - try: - gnu_get_libc_version = process_namespace.gnu_get_libc_version - except AttributeError: - # Symbol doesn't exist -> therefore, we are not linked to - # glibc. - return None - - # Call gnu_get_libc_version, which returns a string like "2.5" - gnu_get_libc_version.restype = ctypes.c_char_p - version_str: str = gnu_get_libc_version() - # py2 / py3 compatibility: - if not isinstance(version_str, str): - version_str = version_str.decode("ascii") - - return version_str - - -def _glibc_version_string() -> Optional[str]: - """Returns glibc version string, or None if not using glibc.""" - return _glibc_version_string_confstr() or _glibc_version_string_ctypes() - - -def _parse_glibc_version(version_str: str) -> Tuple[int, int]: - """Parse glibc version. - - We use a regexp instead of str.split because we want to discard any - random junk that might come after the minor version -- this might happen - in patched/forked versions of glibc (e.g. Linaro's version of glibc - uses version strings like "2.20-2014.11"). See gh-3588. - """ - m = re.match(r"(?P[0-9]+)\.(?P[0-9]+)", version_str) - if not m: - warnings.warn( - f"Expected glibc version with 2 components major.minor," - f" got: {version_str}", - RuntimeWarning, - ) - return -1, -1 - return int(m.group("major")), int(m.group("minor")) - - -@functools.lru_cache -def _get_glibc_version() -> Tuple[int, int]: - version_str = _glibc_version_string() - if version_str is None: - return (-1, -1) - return _parse_glibc_version(version_str) - - -# From PEP 513, PEP 600 -def _is_compatible(arch: str, version: _GLibCVersion) -> bool: - sys_glibc = _get_glibc_version() - if sys_glibc < version: - return False - # Check for presence of _manylinux module. - try: - import _manylinux - except ImportError: - return True - if hasattr(_manylinux, "manylinux_compatible"): - result = _manylinux.manylinux_compatible(version[0], version[1], arch) - if result is not None: - return bool(result) - return True - if version == _GLibCVersion(2, 5): - if hasattr(_manylinux, "manylinux1_compatible"): - return bool(_manylinux.manylinux1_compatible) - if version == _GLibCVersion(2, 12): - if hasattr(_manylinux, "manylinux2010_compatible"): - return bool(_manylinux.manylinux2010_compatible) - if version == _GLibCVersion(2, 17): - if hasattr(_manylinux, "manylinux2014_compatible"): - return bool(_manylinux.manylinux2014_compatible) - return True - - -_LEGACY_MANYLINUX_MAP = { - # CentOS 7 w/ glibc 2.17 (PEP 599) - (2, 17): "manylinux2014", - # CentOS 6 w/ glibc 2.12 (PEP 571) - (2, 12): "manylinux2010", - # CentOS 5 w/ glibc 2.5 (PEP 513) - (2, 5): "manylinux1", -} - - -def platform_tags(archs: Sequence[str]) -> Iterator[str]: - """Generate manylinux tags compatible to the current platform. - - :param archs: Sequence of compatible architectures. - The first one shall be the closest to the actual architecture and be the part of - platform tag after the ``linux_`` prefix, e.g. ``x86_64``. - The ``linux_`` prefix is assumed as a prerequisite for the current platform to - be manylinux-compatible. - - :returns: An iterator of compatible manylinux tags. - """ - if not _have_compatible_abi(sys.executable, archs): - return - # Oldest glibc to be supported regardless of architecture is (2, 17). - too_old_glibc2 = _GLibCVersion(2, 16) - if set(archs) & {"x86_64", "i686"}: - # On x86/i686 also oldest glibc to be supported is (2, 5). - too_old_glibc2 = _GLibCVersion(2, 4) - current_glibc = _GLibCVersion(*_get_glibc_version()) - glibc_max_list = [current_glibc] - # We can assume compatibility across glibc major versions. - # https://sourceware.org/bugzilla/show_bug.cgi?id=24636 - # - # Build a list of maximum glibc versions so that we can - # output the canonical list of all glibc from current_glibc - # down to too_old_glibc2, including all intermediary versions. - for glibc_major in range(current_glibc.major - 1, 1, -1): - glibc_minor = _LAST_GLIBC_MINOR[glibc_major] - glibc_max_list.append(_GLibCVersion(glibc_major, glibc_minor)) - for arch in archs: - for glibc_max in glibc_max_list: - if glibc_max.major == too_old_glibc2.major: - min_minor = too_old_glibc2.minor - else: - # For other glibc major versions oldest supported is (x, 0). - min_minor = -1 - for glibc_minor in range(glibc_max.minor, min_minor, -1): - glibc_version = _GLibCVersion(glibc_max.major, glibc_minor) - tag = "manylinux_{}_{}".format(*glibc_version) - if _is_compatible(arch, glibc_version): - yield f"{tag}_{arch}" - # Handle the legacy manylinux1, manylinux2010, manylinux2014 tags. - if glibc_version in _LEGACY_MANYLINUX_MAP: - legacy_tag = _LEGACY_MANYLINUX_MAP[glibc_version] - if _is_compatible(arch, glibc_version): - yield f"{legacy_tag}_{arch}" diff --git a/src/wheel/vendored/packaging/_musllinux.py b/src/wheel/vendored/packaging/_musllinux.py deleted file mode 100644 index eb4251b5c..000000000 --- a/src/wheel/vendored/packaging/_musllinux.py +++ /dev/null @@ -1,83 +0,0 @@ -"""PEP 656 support. - -This module implements logic to detect if the currently running Python is -linked against musl, and what musl version is used. -""" - -import functools -import re -import subprocess -import sys -from typing import Iterator, NamedTuple, Optional, Sequence - -from ._elffile import ELFFile - - -class _MuslVersion(NamedTuple): - major: int - minor: int - - -def _parse_musl_version(output: str) -> Optional[_MuslVersion]: - lines = [n for n in (n.strip() for n in output.splitlines()) if n] - if len(lines) < 2 or lines[0][:4] != "musl": - return None - m = re.match(r"Version (\d+)\.(\d+)", lines[1]) - if not m: - return None - return _MuslVersion(major=int(m.group(1)), minor=int(m.group(2))) - - -@functools.lru_cache -def _get_musl_version(executable: str) -> Optional[_MuslVersion]: - """Detect currently-running musl runtime version. - - This is done by checking the specified executable's dynamic linking - information, and invoking the loader to parse its output for a version - string. If the loader is musl, the output would be something like:: - - musl libc (x86_64) - Version 1.2.2 - Dynamic Program Loader - """ - try: - with open(executable, "rb") as f: - ld = ELFFile(f).interpreter - except (OSError, TypeError, ValueError): - return None - if ld is None or "musl" not in ld: - return None - proc = subprocess.run([ld], stderr=subprocess.PIPE, text=True) - return _parse_musl_version(proc.stderr) - - -def platform_tags(archs: Sequence[str]) -> Iterator[str]: - """Generate musllinux tags compatible to the current platform. - - :param archs: Sequence of compatible architectures. - The first one shall be the closest to the actual architecture and be the part of - platform tag after the ``linux_`` prefix, e.g. ``x86_64``. - The ``linux_`` prefix is assumed as a prerequisite for the current platform to - be musllinux-compatible. - - :returns: An iterator of compatible musllinux tags. - """ - sys_musl = _get_musl_version(sys.executable) - if sys_musl is None: # Python not dynamically linked against musl. - return - for arch in archs: - for minor in range(sys_musl.minor, -1, -1): - yield f"musllinux_{sys_musl.major}_{minor}_{arch}" - - -if __name__ == "__main__": # pragma: no cover - import sysconfig - - plat = sysconfig.get_platform() - assert plat.startswith("linux-"), "not linux" - - print("plat:", plat) - print("musl:", _get_musl_version(sys.executable)) - print("tags:", end=" ") - for t in platform_tags(re.sub(r"[.-]", "_", plat.split("-", 1)[-1])): - print(t, end="\n ") diff --git a/src/wheel/vendored/packaging/_parser.py b/src/wheel/vendored/packaging/_parser.py deleted file mode 100644 index 513686a21..000000000 --- a/src/wheel/vendored/packaging/_parser.py +++ /dev/null @@ -1,356 +0,0 @@ -"""Handwritten parser of dependency specifiers. - -The docstring for each __parse_* function contains EBNF-inspired grammar representing -the implementation. -""" - -import ast -from typing import Any, List, NamedTuple, Optional, Tuple, Union - -from ._tokenizer import DEFAULT_RULES, Tokenizer - - -class Node: - def __init__(self, value: str) -> None: - self.value = value - - def __str__(self) -> str: - return self.value - - def __repr__(self) -> str: - return f"<{self.__class__.__name__}('{self}')>" - - def serialize(self) -> str: - raise NotImplementedError - - -class Variable(Node): - def serialize(self) -> str: - return str(self) - - -class Value(Node): - def serialize(self) -> str: - return f'"{self}"' - - -class Op(Node): - def serialize(self) -> str: - return str(self) - - -MarkerVar = Union[Variable, Value] -MarkerItem = Tuple[MarkerVar, Op, MarkerVar] -# MarkerAtom = Union[MarkerItem, List["MarkerAtom"]] -# MarkerList = List[Union["MarkerList", MarkerAtom, str]] -# mypy does not support recursive type definition -# https://github.com/python/mypy/issues/731 -MarkerAtom = Any -MarkerList = List[Any] - - -class ParsedRequirement(NamedTuple): - name: str - url: str - extras: List[str] - specifier: str - marker: Optional[MarkerList] - - -# -------------------------------------------------------------------------------------- -# Recursive descent parser for dependency specifier -# -------------------------------------------------------------------------------------- -def parse_requirement(source: str) -> ParsedRequirement: - return _parse_requirement(Tokenizer(source, rules=DEFAULT_RULES)) - - -def _parse_requirement(tokenizer: Tokenizer) -> ParsedRequirement: - """ - requirement = WS? IDENTIFIER WS? extras WS? requirement_details - """ - tokenizer.consume("WS") - - name_token = tokenizer.expect( - "IDENTIFIER", expected="package name at the start of dependency specifier" - ) - name = name_token.text - tokenizer.consume("WS") - - extras = _parse_extras(tokenizer) - tokenizer.consume("WS") - - url, specifier, marker = _parse_requirement_details(tokenizer) - tokenizer.expect("END", expected="end of dependency specifier") - - return ParsedRequirement(name, url, extras, specifier, marker) - - -def _parse_requirement_details( - tokenizer: Tokenizer, -) -> Tuple[str, str, Optional[MarkerList]]: - """ - requirement_details = AT URL (WS requirement_marker?)? - | specifier WS? (requirement_marker)? - """ - - specifier = "" - url = "" - marker = None - - if tokenizer.check("AT"): - tokenizer.read() - tokenizer.consume("WS") - - url_start = tokenizer.position - url = tokenizer.expect("URL", expected="URL after @").text - if tokenizer.check("END", peek=True): - return (url, specifier, marker) - - tokenizer.expect("WS", expected="whitespace after URL") - - # The input might end after whitespace. - if tokenizer.check("END", peek=True): - return (url, specifier, marker) - - marker = _parse_requirement_marker( - tokenizer, span_start=url_start, after="URL and whitespace" - ) - else: - specifier_start = tokenizer.position - specifier = _parse_specifier(tokenizer) - tokenizer.consume("WS") - - if tokenizer.check("END", peek=True): - return (url, specifier, marker) - - marker = _parse_requirement_marker( - tokenizer, - span_start=specifier_start, - after=( - "version specifier" - if specifier - else "name and no valid version specifier" - ), - ) - - return (url, specifier, marker) - - -def _parse_requirement_marker( - tokenizer: Tokenizer, *, span_start: int, after: str -) -> MarkerList: - """ - requirement_marker = SEMICOLON marker WS? - """ - - if not tokenizer.check("SEMICOLON"): - tokenizer.raise_syntax_error( - f"Expected end or semicolon (after {after})", - span_start=span_start, - ) - tokenizer.read() - - marker = _parse_marker(tokenizer) - tokenizer.consume("WS") - - return marker - - -def _parse_extras(tokenizer: Tokenizer) -> List[str]: - """ - extras = (LEFT_BRACKET wsp* extras_list? wsp* RIGHT_BRACKET)? - """ - if not tokenizer.check("LEFT_BRACKET", peek=True): - return [] - - with tokenizer.enclosing_tokens( - "LEFT_BRACKET", - "RIGHT_BRACKET", - around="extras", - ): - tokenizer.consume("WS") - extras = _parse_extras_list(tokenizer) - tokenizer.consume("WS") - - return extras - - -def _parse_extras_list(tokenizer: Tokenizer) -> List[str]: - """ - extras_list = identifier (wsp* ',' wsp* identifier)* - """ - extras: List[str] = [] - - if not tokenizer.check("IDENTIFIER"): - return extras - - extras.append(tokenizer.read().text) - - while True: - tokenizer.consume("WS") - if tokenizer.check("IDENTIFIER", peek=True): - tokenizer.raise_syntax_error("Expected comma between extra names") - elif not tokenizer.check("COMMA"): - break - - tokenizer.read() - tokenizer.consume("WS") - - extra_token = tokenizer.expect("IDENTIFIER", expected="extra name after comma") - extras.append(extra_token.text) - - return extras - - -def _parse_specifier(tokenizer: Tokenizer) -> str: - """ - specifier = LEFT_PARENTHESIS WS? version_many WS? RIGHT_PARENTHESIS - | WS? version_many WS? - """ - with tokenizer.enclosing_tokens( - "LEFT_PARENTHESIS", - "RIGHT_PARENTHESIS", - around="version specifier", - ): - tokenizer.consume("WS") - parsed_specifiers = _parse_version_many(tokenizer) - tokenizer.consume("WS") - - return parsed_specifiers - - -def _parse_version_many(tokenizer: Tokenizer) -> str: - """ - version_many = (SPECIFIER (WS? COMMA WS? SPECIFIER)*)? - """ - parsed_specifiers = "" - while tokenizer.check("SPECIFIER"): - span_start = tokenizer.position - parsed_specifiers += tokenizer.read().text - if tokenizer.check("VERSION_PREFIX_TRAIL", peek=True): - tokenizer.raise_syntax_error( - ".* suffix can only be used with `==` or `!=` operators", - span_start=span_start, - span_end=tokenizer.position + 1, - ) - if tokenizer.check("VERSION_LOCAL_LABEL_TRAIL", peek=True): - tokenizer.raise_syntax_error( - "Local version label can only be used with `==` or `!=` operators", - span_start=span_start, - span_end=tokenizer.position, - ) - tokenizer.consume("WS") - if not tokenizer.check("COMMA"): - break - parsed_specifiers += tokenizer.read().text - tokenizer.consume("WS") - - return parsed_specifiers - - -# -------------------------------------------------------------------------------------- -# Recursive descent parser for marker expression -# -------------------------------------------------------------------------------------- -def parse_marker(source: str) -> MarkerList: - return _parse_full_marker(Tokenizer(source, rules=DEFAULT_RULES)) - - -def _parse_full_marker(tokenizer: Tokenizer) -> MarkerList: - retval = _parse_marker(tokenizer) - tokenizer.expect("END", expected="end of marker expression") - return retval - - -def _parse_marker(tokenizer: Tokenizer) -> MarkerList: - """ - marker = marker_atom (BOOLOP marker_atom)+ - """ - expression = [_parse_marker_atom(tokenizer)] - while tokenizer.check("BOOLOP"): - token = tokenizer.read() - expr_right = _parse_marker_atom(tokenizer) - expression.extend((token.text, expr_right)) - return expression - - -def _parse_marker_atom(tokenizer: Tokenizer) -> MarkerAtom: - """ - marker_atom = WS? LEFT_PARENTHESIS WS? marker WS? RIGHT_PARENTHESIS WS? - | WS? marker_item WS? - """ - - tokenizer.consume("WS") - if tokenizer.check("LEFT_PARENTHESIS", peek=True): - with tokenizer.enclosing_tokens( - "LEFT_PARENTHESIS", - "RIGHT_PARENTHESIS", - around="marker expression", - ): - tokenizer.consume("WS") - marker: MarkerAtom = _parse_marker(tokenizer) - tokenizer.consume("WS") - else: - marker = _parse_marker_item(tokenizer) - tokenizer.consume("WS") - return marker - - -def _parse_marker_item(tokenizer: Tokenizer) -> MarkerItem: - """ - marker_item = WS? marker_var WS? marker_op WS? marker_var WS? - """ - tokenizer.consume("WS") - marker_var_left = _parse_marker_var(tokenizer) - tokenizer.consume("WS") - marker_op = _parse_marker_op(tokenizer) - tokenizer.consume("WS") - marker_var_right = _parse_marker_var(tokenizer) - tokenizer.consume("WS") - return (marker_var_left, marker_op, marker_var_right) - - -def _parse_marker_var(tokenizer: Tokenizer) -> MarkerVar: - """ - marker_var = VARIABLE | QUOTED_STRING - """ - if tokenizer.check("VARIABLE"): - return process_env_var(tokenizer.read().text.replace(".", "_")) - elif tokenizer.check("QUOTED_STRING"): - return process_python_str(tokenizer.read().text) - else: - tokenizer.raise_syntax_error( - message="Expected a marker variable or quoted string" - ) - - -def process_env_var(env_var: str) -> Variable: - if env_var in ("platform_python_implementation", "python_implementation"): - return Variable("platform_python_implementation") - else: - return Variable(env_var) - - -def process_python_str(python_str: str) -> Value: - value = ast.literal_eval(python_str) - return Value(str(value)) - - -def _parse_marker_op(tokenizer: Tokenizer) -> Op: - """ - marker_op = IN | NOT IN | OP - """ - if tokenizer.check("IN"): - tokenizer.read() - return Op("in") - elif tokenizer.check("NOT"): - tokenizer.read() - tokenizer.expect("WS", expected="whitespace after 'not'") - tokenizer.expect("IN", expected="'in' after 'not'") - return Op("not in") - elif tokenizer.check("OP"): - return Op(tokenizer.read().text) - else: - return tokenizer.raise_syntax_error( - "Expected marker operator, one of " - "<=, <, !=, ==, >=, >, ~=, ===, in, not in" - ) diff --git a/src/wheel/vendored/packaging/_structures.py b/src/wheel/vendored/packaging/_structures.py deleted file mode 100644 index 90a6465f9..000000000 --- a/src/wheel/vendored/packaging/_structures.py +++ /dev/null @@ -1,61 +0,0 @@ -# This file is dual licensed under the terms of the Apache License, Version -# 2.0, and the BSD License. See the LICENSE file in the root of this repository -# for complete details. - - -class InfinityType: - def __repr__(self) -> str: - return "Infinity" - - def __hash__(self) -> int: - return hash(repr(self)) - - def __lt__(self, other: object) -> bool: - return False - - def __le__(self, other: object) -> bool: - return False - - def __eq__(self, other: object) -> bool: - return isinstance(other, self.__class__) - - def __gt__(self, other: object) -> bool: - return True - - def __ge__(self, other: object) -> bool: - return True - - def __neg__(self: object) -> "NegativeInfinityType": - return NegativeInfinity - - -Infinity = InfinityType() - - -class NegativeInfinityType: - def __repr__(self) -> str: - return "-Infinity" - - def __hash__(self) -> int: - return hash(repr(self)) - - def __lt__(self, other: object) -> bool: - return True - - def __le__(self, other: object) -> bool: - return True - - def __eq__(self, other: object) -> bool: - return isinstance(other, self.__class__) - - def __gt__(self, other: object) -> bool: - return False - - def __ge__(self, other: object) -> bool: - return False - - def __neg__(self: object) -> InfinityType: - return Infinity - - -NegativeInfinity = NegativeInfinityType() diff --git a/src/wheel/vendored/packaging/_tokenizer.py b/src/wheel/vendored/packaging/_tokenizer.py deleted file mode 100644 index dd0d648d4..000000000 --- a/src/wheel/vendored/packaging/_tokenizer.py +++ /dev/null @@ -1,192 +0,0 @@ -import contextlib -import re -from dataclasses import dataclass -from typing import Dict, Iterator, NoReturn, Optional, Tuple, Union - -from .specifiers import Specifier - - -@dataclass -class Token: - name: str - text: str - position: int - - -class ParserSyntaxError(Exception): - """The provided source text could not be parsed correctly.""" - - def __init__( - self, - message: str, - *, - source: str, - span: Tuple[int, int], - ) -> None: - self.span = span - self.message = message - self.source = source - - super().__init__() - - def __str__(self) -> str: - marker = " " * self.span[0] + "~" * (self.span[1] - self.span[0]) + "^" - return "\n ".join([self.message, self.source, marker]) - - -DEFAULT_RULES: "Dict[str, Union[str, re.Pattern[str]]]" = { - "LEFT_PARENTHESIS": r"\(", - "RIGHT_PARENTHESIS": r"\)", - "LEFT_BRACKET": r"\[", - "RIGHT_BRACKET": r"\]", - "SEMICOLON": r";", - "COMMA": r",", - "QUOTED_STRING": re.compile( - r""" - ( - ('[^']*') - | - ("[^"]*") - ) - """, - re.VERBOSE, - ), - "OP": r"(===|==|~=|!=|<=|>=|<|>)", - "BOOLOP": r"\b(or|and)\b", - "IN": r"\bin\b", - "NOT": r"\bnot\b", - "VARIABLE": re.compile( - r""" - \b( - python_version - |python_full_version - |os[._]name - |sys[._]platform - |platform_(release|system) - |platform[._](version|machine|python_implementation) - |python_implementation - |implementation_(name|version) - |extra - )\b - """, - re.VERBOSE, - ), - "SPECIFIER": re.compile( - Specifier._operator_regex_str + Specifier._version_regex_str, - re.VERBOSE | re.IGNORECASE, - ), - "AT": r"\@", - "URL": r"[^ \t]+", - "IDENTIFIER": r"\b[a-zA-Z0-9][a-zA-Z0-9._-]*\b", - "VERSION_PREFIX_TRAIL": r"\.\*", - "VERSION_LOCAL_LABEL_TRAIL": r"\+[a-z0-9]+(?:[-_\.][a-z0-9]+)*", - "WS": r"[ \t]+", - "END": r"$", -} - - -class Tokenizer: - """Context-sensitive token parsing. - - Provides methods to examine the input stream to check whether the next token - matches. - """ - - def __init__( - self, - source: str, - *, - rules: "Dict[str, Union[str, re.Pattern[str]]]", - ) -> None: - self.source = source - self.rules: Dict[str, re.Pattern[str]] = { - name: re.compile(pattern) for name, pattern in rules.items() - } - self.next_token: Optional[Token] = None - self.position = 0 - - def consume(self, name: str) -> None: - """Move beyond provided token name, if at current position.""" - if self.check(name): - self.read() - - def check(self, name: str, *, peek: bool = False) -> bool: - """Check whether the next token has the provided name. - - By default, if the check succeeds, the token *must* be read before - another check. If `peek` is set to `True`, the token is not loaded and - would need to be checked again. - """ - assert ( - self.next_token is None - ), f"Cannot check for {name!r}, already have {self.next_token!r}" - assert name in self.rules, f"Unknown token name: {name!r}" - - expression = self.rules[name] - - match = expression.match(self.source, self.position) - if match is None: - return False - if not peek: - self.next_token = Token(name, match[0], self.position) - return True - - def expect(self, name: str, *, expected: str) -> Token: - """Expect a certain token name next, failing with a syntax error otherwise. - - The token is *not* read. - """ - if not self.check(name): - raise self.raise_syntax_error(f"Expected {expected}") - return self.read() - - def read(self) -> Token: - """Consume the next token and return it.""" - token = self.next_token - assert token is not None - - self.position += len(token.text) - self.next_token = None - - return token - - def raise_syntax_error( - self, - message: str, - *, - span_start: Optional[int] = None, - span_end: Optional[int] = None, - ) -> NoReturn: - """Raise ParserSyntaxError at the given position.""" - span = ( - self.position if span_start is None else span_start, - self.position if span_end is None else span_end, - ) - raise ParserSyntaxError( - message, - source=self.source, - span=span, - ) - - @contextlib.contextmanager - def enclosing_tokens( - self, open_token: str, close_token: str, *, around: str - ) -> Iterator[None]: - if self.check(open_token): - open_position = self.position - self.read() - else: - open_position = None - - yield - - if open_position is None: - return - - if not self.check(close_token): - self.raise_syntax_error( - f"Expected matching {close_token} for {open_token}, after {around}", - span_start=open_position, - ) - - self.read() diff --git a/src/wheel/vendored/packaging/markers.py b/src/wheel/vendored/packaging/markers.py deleted file mode 100644 index c96d22a5a..000000000 --- a/src/wheel/vendored/packaging/markers.py +++ /dev/null @@ -1,253 +0,0 @@ -# This file is dual licensed under the terms of the Apache License, Version -# 2.0, and the BSD License. See the LICENSE file in the root of this repository -# for complete details. - -import operator -import os -import platform -import sys -from typing import Any, Callable, Dict, List, Optional, Tuple, Union - -from ._parser import ( - MarkerAtom, - MarkerList, - Op, - Value, - Variable, -) -from ._parser import ( - parse_marker as _parse_marker, -) -from ._tokenizer import ParserSyntaxError -from .specifiers import InvalidSpecifier, Specifier -from .utils import canonicalize_name - -__all__ = [ - "InvalidMarker", - "UndefinedComparison", - "UndefinedEnvironmentName", - "Marker", - "default_environment", -] - -Operator = Callable[[str, str], bool] - - -class InvalidMarker(ValueError): - """ - An invalid marker was found, users should refer to PEP 508. - """ - - -class UndefinedComparison(ValueError): - """ - An invalid operation was attempted on a value that doesn't support it. - """ - - -class UndefinedEnvironmentName(ValueError): - """ - A name was attempted to be used that does not exist inside of the - environment. - """ - - -def _normalize_extra_values(results: Any) -> Any: - """ - Normalize extra values. - """ - if isinstance(results[0], tuple): - lhs, op, rhs = results[0] - if isinstance(lhs, Variable) and lhs.value == "extra": - normalized_extra = canonicalize_name(rhs.value) - rhs = Value(normalized_extra) - elif isinstance(rhs, Variable) and rhs.value == "extra": - normalized_extra = canonicalize_name(lhs.value) - lhs = Value(normalized_extra) - results[0] = lhs, op, rhs - return results - - -def _format_marker( - marker: Union[List[str], MarkerAtom, str], first: Optional[bool] = True -) -> str: - assert isinstance(marker, (list, tuple, str)) - - # Sometimes we have a structure like [[...]] which is a single item list - # where the single item is itself it's own list. In that case we want skip - # the rest of this function so that we don't get extraneous () on the - # outside. - if ( - isinstance(marker, list) - and len(marker) == 1 - and isinstance(marker[0], (list, tuple)) - ): - return _format_marker(marker[0]) - - if isinstance(marker, list): - inner = (_format_marker(m, first=False) for m in marker) - if first: - return " ".join(inner) - else: - return "(" + " ".join(inner) + ")" - elif isinstance(marker, tuple): - return " ".join([m.serialize() for m in marker]) - else: - return marker - - -_operators: Dict[str, Operator] = { - "in": lambda lhs, rhs: lhs in rhs, - "not in": lambda lhs, rhs: lhs not in rhs, - "<": operator.lt, - "<=": operator.le, - "==": operator.eq, - "!=": operator.ne, - ">=": operator.ge, - ">": operator.gt, -} - - -def _eval_op(lhs: str, op: Op, rhs: str) -> bool: - try: - spec = Specifier("".join([op.serialize(), rhs])) - except InvalidSpecifier: - pass - else: - return spec.contains(lhs, prereleases=True) - - oper: Optional[Operator] = _operators.get(op.serialize()) - if oper is None: - raise UndefinedComparison(f"Undefined {op!r} on {lhs!r} and {rhs!r}.") - - return oper(lhs, rhs) - - -def _normalize(*values: str, key: str) -> Tuple[str, ...]: - # PEP 685 – Comparison of extra names for optional distribution dependencies - # https://peps.python.org/pep-0685/ - # > When comparing extra names, tools MUST normalize the names being - # > compared using the semantics outlined in PEP 503 for names - if key == "extra": - return tuple(canonicalize_name(v) for v in values) - - # other environment markers don't have such standards - return values - - -def _evaluate_markers(markers: MarkerList, environment: Dict[str, str]) -> bool: - groups: List[List[bool]] = [[]] - - for marker in markers: - assert isinstance(marker, (list, tuple, str)) - - if isinstance(marker, list): - groups[-1].append(_evaluate_markers(marker, environment)) - elif isinstance(marker, tuple): - lhs, op, rhs = marker - - if isinstance(lhs, Variable): - environment_key = lhs.value - lhs_value = environment[environment_key] - rhs_value = rhs.value - else: - lhs_value = lhs.value - environment_key = rhs.value - rhs_value = environment[environment_key] - - lhs_value, rhs_value = _normalize(lhs_value, rhs_value, key=environment_key) - groups[-1].append(_eval_op(lhs_value, op, rhs_value)) - else: - assert marker in ["and", "or"] - if marker == "or": - groups.append([]) - - return any(all(item) for item in groups) - - -def format_full_version(info: "sys._version_info") -> str: - version = "{0.major}.{0.minor}.{0.micro}".format(info) - kind = info.releaselevel - if kind != "final": - version += kind[0] + str(info.serial) - return version - - -def default_environment() -> Dict[str, str]: - iver = format_full_version(sys.implementation.version) - implementation_name = sys.implementation.name - return { - "implementation_name": implementation_name, - "implementation_version": iver, - "os_name": os.name, - "platform_machine": platform.machine(), - "platform_release": platform.release(), - "platform_system": platform.system(), - "platform_version": platform.version(), - "python_full_version": platform.python_version(), - "platform_python_implementation": platform.python_implementation(), - "python_version": ".".join(platform.python_version_tuple()[:2]), - "sys_platform": sys.platform, - } - - -class Marker: - def __init__(self, marker: str) -> None: - # Note: We create a Marker object without calling this constructor in - # packaging.requirements.Requirement. If any additional logic is - # added here, make sure to mirror/adapt Requirement. - try: - self._markers = _normalize_extra_values(_parse_marker(marker)) - # The attribute `_markers` can be described in terms of a recursive type: - # MarkerList = List[Union[Tuple[Node, ...], str, MarkerList]] - # - # For example, the following expression: - # python_version > "3.6" or (python_version == "3.6" and os_name == "unix") - # - # is parsed into: - # [ - # (, ')>, ), - # 'and', - # [ - # (, , ), - # 'or', - # (, , ) - # ] - # ] - except ParserSyntaxError as e: - raise InvalidMarker(str(e)) from e - - def __str__(self) -> str: - return _format_marker(self._markers) - - def __repr__(self) -> str: - return f"" - - def __hash__(self) -> int: - return hash((self.__class__.__name__, str(self))) - - def __eq__(self, other: Any) -> bool: - if not isinstance(other, Marker): - return NotImplemented - - return str(self) == str(other) - - def evaluate(self, environment: Optional[Dict[str, str]] = None) -> bool: - """Evaluate a marker. - - Return the boolean from evaluating the given marker against the - environment. environment is an optional argument to override all or - part of the determined environment. - - The environment is determined from the current Python process. - """ - current_environment = default_environment() - current_environment["extra"] = "" - if environment is not None: - current_environment.update(environment) - # The API used to allow setting extra to None. We need to handle this - # case for backwards compatibility. - if current_environment["extra"] is None: - current_environment["extra"] = "" - - return _evaluate_markers(self._markers, current_environment) diff --git a/src/wheel/vendored/packaging/requirements.py b/src/wheel/vendored/packaging/requirements.py deleted file mode 100644 index bdc43a7e9..000000000 --- a/src/wheel/vendored/packaging/requirements.py +++ /dev/null @@ -1,90 +0,0 @@ -# This file is dual licensed under the terms of the Apache License, Version -# 2.0, and the BSD License. See the LICENSE file in the root of this repository -# for complete details. - -from typing import Any, Iterator, Optional, Set - -from ._parser import parse_requirement as _parse_requirement -from ._tokenizer import ParserSyntaxError -from .markers import Marker, _normalize_extra_values -from .specifiers import SpecifierSet -from .utils import canonicalize_name - - -class InvalidRequirement(ValueError): - """ - An invalid requirement was found, users should refer to PEP 508. - """ - - -class Requirement: - """Parse a requirement. - - Parse a given requirement string into its parts, such as name, specifier, - URL, and extras. Raises InvalidRequirement on a badly-formed requirement - string. - """ - - # TODO: Can we test whether something is contained within a requirement? - # If so how do we do that? Do we need to test against the _name_ of - # the thing as well as the version? What about the markers? - # TODO: Can we normalize the name and extra name? - - def __init__(self, requirement_string: str) -> None: - try: - parsed = _parse_requirement(requirement_string) - except ParserSyntaxError as e: - raise InvalidRequirement(str(e)) from e - - self.name: str = parsed.name - self.url: Optional[str] = parsed.url or None - self.extras: Set[str] = set(parsed.extras or []) - self.specifier: SpecifierSet = SpecifierSet(parsed.specifier) - self.marker: Optional[Marker] = None - if parsed.marker is not None: - self.marker = Marker.__new__(Marker) - self.marker._markers = _normalize_extra_values(parsed.marker) - - def _iter_parts(self, name: str) -> Iterator[str]: - yield name - - if self.extras: - formatted_extras = ",".join(sorted(self.extras)) - yield f"[{formatted_extras}]" - - if self.specifier: - yield str(self.specifier) - - if self.url: - yield f"@ {self.url}" - if self.marker: - yield " " - - if self.marker: - yield f"; {self.marker}" - - def __str__(self) -> str: - return "".join(self._iter_parts(self.name)) - - def __repr__(self) -> str: - return f"" - - def __hash__(self) -> int: - return hash( - ( - self.__class__.__name__, - *self._iter_parts(canonicalize_name(self.name)), - ) - ) - - def __eq__(self, other: Any) -> bool: - if not isinstance(other, Requirement): - return NotImplemented - - return ( - canonicalize_name(self.name) == canonicalize_name(other.name) - and self.extras == other.extras - and self.specifier == other.specifier - and self.url == other.url - and self.marker == other.marker - ) diff --git a/src/wheel/vendored/packaging/specifiers.py b/src/wheel/vendored/packaging/specifiers.py deleted file mode 100644 index 6d4066ae2..000000000 --- a/src/wheel/vendored/packaging/specifiers.py +++ /dev/null @@ -1,1011 +0,0 @@ -# This file is dual licensed under the terms of the Apache License, Version -# 2.0, and the BSD License. See the LICENSE file in the root of this repository -# for complete details. -""" -.. testsetup:: - - from packaging.specifiers import Specifier, SpecifierSet, InvalidSpecifier - from packaging.version import Version -""" - -import abc -import itertools -import re -from typing import Callable, Iterable, Iterator, List, Optional, Tuple, TypeVar, Union - -from .utils import canonicalize_version -from .version import Version - -UnparsedVersion = Union[Version, str] -UnparsedVersionVar = TypeVar("UnparsedVersionVar", bound=UnparsedVersion) -CallableOperator = Callable[[Version, str], bool] - - -def _coerce_version(version: UnparsedVersion) -> Version: - if not isinstance(version, Version): - version = Version(version) - return version - - -class InvalidSpecifier(ValueError): - """ - Raised when attempting to create a :class:`Specifier` with a specifier - string that is invalid. - - >>> Specifier("lolwat") - Traceback (most recent call last): - ... - packaging.specifiers.InvalidSpecifier: Invalid specifier: 'lolwat' - """ - - -class BaseSpecifier(metaclass=abc.ABCMeta): - @abc.abstractmethod - def __str__(self) -> str: - """ - Returns the str representation of this Specifier-like object. This - should be representative of the Specifier itself. - """ - - @abc.abstractmethod - def __hash__(self) -> int: - """ - Returns a hash value for this Specifier-like object. - """ - - @abc.abstractmethod - def __eq__(self, other: object) -> bool: - """ - Returns a boolean representing whether or not the two Specifier-like - objects are equal. - - :param other: The other object to check against. - """ - - @property - @abc.abstractmethod - def prereleases(self) -> Optional[bool]: - """Whether or not pre-releases as a whole are allowed. - - This can be set to either ``True`` or ``False`` to explicitly enable or disable - prereleases or it can be set to ``None`` (the default) to use default semantics. - """ - - @prereleases.setter - def prereleases(self, value: bool) -> None: - """Setter for :attr:`prereleases`. - - :param value: The value to set. - """ - - @abc.abstractmethod - def contains(self, item: str, prereleases: Optional[bool] = None) -> bool: - """ - Determines if the given item is contained within this specifier. - """ - - @abc.abstractmethod - def filter( - self, iterable: Iterable[UnparsedVersionVar], prereleases: Optional[bool] = None - ) -> Iterator[UnparsedVersionVar]: - """ - Takes an iterable of items and filters them so that only items which - are contained within this specifier are allowed in it. - """ - - -class Specifier(BaseSpecifier): - """This class abstracts handling of version specifiers. - - .. tip:: - - It is generally not required to instantiate this manually. You should instead - prefer to work with :class:`SpecifierSet` instead, which can parse - comma-separated version specifiers (which is what package metadata contains). - """ - - _operator_regex_str = r""" - (?P(~=|==|!=|<=|>=|<|>|===)) - """ - _version_regex_str = r""" - (?P - (?: - # The identity operators allow for an escape hatch that will - # do an exact string match of the version you wish to install. - # This will not be parsed by PEP 440 and we cannot determine - # any semantic meaning from it. This operator is discouraged - # but included entirely as an escape hatch. - (?<====) # Only match for the identity operator - \s* - [^\s;)]* # The arbitrary version can be just about anything, - # we match everything except for whitespace, a - # semi-colon for marker support, and a closing paren - # since versions can be enclosed in them. - ) - | - (?: - # The (non)equality operators allow for wild card and local - # versions to be specified so we have to define these two - # operators separately to enable that. - (?<===|!=) # Only match for equals and not equals - - \s* - v? - (?:[0-9]+!)? # epoch - [0-9]+(?:\.[0-9]+)* # release - - # You cannot use a wild card and a pre-release, post-release, a dev or - # local version together so group them with a | and make them optional. - (?: - \.\* # Wild card syntax of .* - | - (?: # pre release - [-_\.]? - (alpha|beta|preview|pre|a|b|c|rc) - [-_\.]? - [0-9]* - )? - (?: # post release - (?:-[0-9]+)|(?:[-_\.]?(post|rev|r)[-_\.]?[0-9]*) - )? - (?:[-_\.]?dev[-_\.]?[0-9]*)? # dev release - (?:\+[a-z0-9]+(?:[-_\.][a-z0-9]+)*)? # local - )? - ) - | - (?: - # The compatible operator requires at least two digits in the - # release segment. - (?<=~=) # Only match for the compatible operator - - \s* - v? - (?:[0-9]+!)? # epoch - [0-9]+(?:\.[0-9]+)+ # release (We have a + instead of a *) - (?: # pre release - [-_\.]? - (alpha|beta|preview|pre|a|b|c|rc) - [-_\.]? - [0-9]* - )? - (?: # post release - (?:-[0-9]+)|(?:[-_\.]?(post|rev|r)[-_\.]?[0-9]*) - )? - (?:[-_\.]?dev[-_\.]?[0-9]*)? # dev release - ) - | - (?: - # All other operators only allow a sub set of what the - # (non)equality operators do. Specifically they do not allow - # local versions to be specified nor do they allow the prefix - # matching wild cards. - (?=": "greater_than_equal", - "<": "less_than", - ">": "greater_than", - "===": "arbitrary", - } - - def __init__(self, spec: str = "", prereleases: Optional[bool] = None) -> None: - """Initialize a Specifier instance. - - :param spec: - The string representation of a specifier which will be parsed and - normalized before use. - :param prereleases: - This tells the specifier if it should accept prerelease versions if - applicable or not. The default of ``None`` will autodetect it from the - given specifiers. - :raises InvalidSpecifier: - If the given specifier is invalid (i.e. bad syntax). - """ - match = self._regex.search(spec) - if not match: - raise InvalidSpecifier(f"Invalid specifier: '{spec}'") - - self._spec: Tuple[str, str] = ( - match.group("operator").strip(), - match.group("version").strip(), - ) - - # Store whether or not this Specifier should accept prereleases - self._prereleases = prereleases - - # https://github.com/python/mypy/pull/13475#pullrequestreview-1079784515 - @property # type: ignore[override] - def prereleases(self) -> bool: - # If there is an explicit prereleases set for this, then we'll just - # blindly use that. - if self._prereleases is not None: - return self._prereleases - - # Look at all of our specifiers and determine if they are inclusive - # operators, and if they are if they are including an explicit - # prerelease. - operator, version = self._spec - if operator in ["==", ">=", "<=", "~=", "==="]: - # The == specifier can include a trailing .*, if it does we - # want to remove before parsing. - if operator == "==" and version.endswith(".*"): - version = version[:-2] - - # Parse the version, and if it is a pre-release than this - # specifier allows pre-releases. - if Version(version).is_prerelease: - return True - - return False - - @prereleases.setter - def prereleases(self, value: bool) -> None: - self._prereleases = value - - @property - def operator(self) -> str: - """The operator of this specifier. - - >>> Specifier("==1.2.3").operator - '==' - """ - return self._spec[0] - - @property - def version(self) -> str: - """The version of this specifier. - - >>> Specifier("==1.2.3").version - '1.2.3' - """ - return self._spec[1] - - def __repr__(self) -> str: - """A representation of the Specifier that shows all internal state. - - >>> Specifier('>=1.0.0') - =1.0.0')> - >>> Specifier('>=1.0.0', prereleases=False) - =1.0.0', prereleases=False)> - >>> Specifier('>=1.0.0', prereleases=True) - =1.0.0', prereleases=True)> - """ - pre = ( - f", prereleases={self.prereleases!r}" - if self._prereleases is not None - else "" - ) - - return f"<{self.__class__.__name__}({str(self)!r}{pre})>" - - def __str__(self) -> str: - """A string representation of the Specifier that can be round-tripped. - - >>> str(Specifier('>=1.0.0')) - '>=1.0.0' - >>> str(Specifier('>=1.0.0', prereleases=False)) - '>=1.0.0' - """ - return "{}{}".format(*self._spec) - - @property - def _canonical_spec(self) -> Tuple[str, str]: - canonical_version = canonicalize_version( - self._spec[1], - strip_trailing_zero=(self._spec[0] != "~="), - ) - return self._spec[0], canonical_version - - def __hash__(self) -> int: - return hash(self._canonical_spec) - - def __eq__(self, other: object) -> bool: - """Whether or not the two Specifier-like objects are equal. - - :param other: The other object to check against. - - The value of :attr:`prereleases` is ignored. - - >>> Specifier("==1.2.3") == Specifier("== 1.2.3.0") - True - >>> (Specifier("==1.2.3", prereleases=False) == - ... Specifier("==1.2.3", prereleases=True)) - True - >>> Specifier("==1.2.3") == "==1.2.3" - True - >>> Specifier("==1.2.3") == Specifier("==1.2.4") - False - >>> Specifier("==1.2.3") == Specifier("~=1.2.3") - False - """ - if isinstance(other, str): - try: - other = self.__class__(str(other)) - except InvalidSpecifier: - return NotImplemented - elif not isinstance(other, self.__class__): - return NotImplemented - - return self._canonical_spec == other._canonical_spec - - def _get_operator(self, op: str) -> CallableOperator: - operator_callable: CallableOperator = getattr( - self, f"_compare_{self._operators[op]}" - ) - return operator_callable - - def _compare_compatible(self, prospective: Version, spec: str) -> bool: - # Compatible releases have an equivalent combination of >= and ==. That - # is that ~=2.2 is equivalent to >=2.2,==2.*. This allows us to - # implement this in terms of the other specifiers instead of - # implementing it ourselves. The only thing we need to do is construct - # the other specifiers. - - # We want everything but the last item in the version, but we want to - # ignore suffix segments. - prefix = _version_join( - list(itertools.takewhile(_is_not_suffix, _version_split(spec)))[:-1] - ) - - # Add the prefix notation to the end of our string - prefix += ".*" - - return self._get_operator(">=")(prospective, spec) and self._get_operator("==")( - prospective, prefix - ) - - def _compare_equal(self, prospective: Version, spec: str) -> bool: - # We need special logic to handle prefix matching - if spec.endswith(".*"): - # In the case of prefix matching we want to ignore local segment. - normalized_prospective = canonicalize_version( - prospective.public, strip_trailing_zero=False - ) - # Get the normalized version string ignoring the trailing .* - normalized_spec = canonicalize_version(spec[:-2], strip_trailing_zero=False) - # Split the spec out by bangs and dots, and pretend that there is - # an implicit dot in between a release segment and a pre-release segment. - split_spec = _version_split(normalized_spec) - - # Split the prospective version out by bangs and dots, and pretend - # that there is an implicit dot in between a release segment and - # a pre-release segment. - split_prospective = _version_split(normalized_prospective) - - # 0-pad the prospective version before shortening it to get the correct - # shortened version. - padded_prospective, _ = _pad_version(split_prospective, split_spec) - - # Shorten the prospective version to be the same length as the spec - # so that we can determine if the specifier is a prefix of the - # prospective version or not. - shortened_prospective = padded_prospective[: len(split_spec)] - - return shortened_prospective == split_spec - else: - # Convert our spec string into a Version - spec_version = Version(spec) - - # If the specifier does not have a local segment, then we want to - # act as if the prospective version also does not have a local - # segment. - if not spec_version.local: - prospective = Version(prospective.public) - - return prospective == spec_version - - def _compare_not_equal(self, prospective: Version, spec: str) -> bool: - return not self._compare_equal(prospective, spec) - - def _compare_less_than_equal(self, prospective: Version, spec: str) -> bool: - # NB: Local version identifiers are NOT permitted in the version - # specifier, so local version labels can be universally removed from - # the prospective version. - return Version(prospective.public) <= Version(spec) - - def _compare_greater_than_equal(self, prospective: Version, spec: str) -> bool: - # NB: Local version identifiers are NOT permitted in the version - # specifier, so local version labels can be universally removed from - # the prospective version. - return Version(prospective.public) >= Version(spec) - - def _compare_less_than(self, prospective: Version, spec_str: str) -> bool: - # Convert our spec to a Version instance, since we'll want to work with - # it as a version. - spec = Version(spec_str) - - # Check to see if the prospective version is less than the spec - # version. If it's not we can short circuit and just return False now - # instead of doing extra unneeded work. - if not prospective < spec: - return False - - # This special case is here so that, unless the specifier itself - # includes is a pre-release version, that we do not accept pre-release - # versions for the version mentioned in the specifier (e.g. <3.1 should - # not match 3.1.dev0, but should match 3.0.dev0). - if not spec.is_prerelease and prospective.is_prerelease: - if Version(prospective.base_version) == Version(spec.base_version): - return False - - # If we've gotten to here, it means that prospective version is both - # less than the spec version *and* it's not a pre-release of the same - # version in the spec. - return True - - def _compare_greater_than(self, prospective: Version, spec_str: str) -> bool: - # Convert our spec to a Version instance, since we'll want to work with - # it as a version. - spec = Version(spec_str) - - # Check to see if the prospective version is greater than the spec - # version. If it's not we can short circuit and just return False now - # instead of doing extra unneeded work. - if not prospective > spec: - return False - - # This special case is here so that, unless the specifier itself - # includes is a post-release version, that we do not accept - # post-release versions for the version mentioned in the specifier - # (e.g. >3.1 should not match 3.0.post0, but should match 3.2.post0). - if not spec.is_postrelease and prospective.is_postrelease: - if Version(prospective.base_version) == Version(spec.base_version): - return False - - # Ensure that we do not allow a local version of the version mentioned - # in the specifier, which is technically greater than, to match. - if prospective.local is not None: - if Version(prospective.base_version) == Version(spec.base_version): - return False - - # If we've gotten to here, it means that prospective version is both - # greater than the spec version *and* it's not a pre-release of the - # same version in the spec. - return True - - def _compare_arbitrary(self, prospective: Version, spec: str) -> bool: - return str(prospective).lower() == str(spec).lower() - - def __contains__(self, item: Union[str, Version]) -> bool: - """Return whether or not the item is contained in this specifier. - - :param item: The item to check for. - - This is used for the ``in`` operator and behaves the same as - :meth:`contains` with no ``prereleases`` argument passed. - - >>> "1.2.3" in Specifier(">=1.2.3") - True - >>> Version("1.2.3") in Specifier(">=1.2.3") - True - >>> "1.0.0" in Specifier(">=1.2.3") - False - >>> "1.3.0a1" in Specifier(">=1.2.3") - False - >>> "1.3.0a1" in Specifier(">=1.2.3", prereleases=True) - True - """ - return self.contains(item) - - def contains( - self, item: UnparsedVersion, prereleases: Optional[bool] = None - ) -> bool: - """Return whether or not the item is contained in this specifier. - - :param item: - The item to check for, which can be a version string or a - :class:`Version` instance. - :param prereleases: - Whether or not to match prereleases with this Specifier. If set to - ``None`` (the default), it uses :attr:`prereleases` to determine - whether or not prereleases are allowed. - - >>> Specifier(">=1.2.3").contains("1.2.3") - True - >>> Specifier(">=1.2.3").contains(Version("1.2.3")) - True - >>> Specifier(">=1.2.3").contains("1.0.0") - False - >>> Specifier(">=1.2.3").contains("1.3.0a1") - False - >>> Specifier(">=1.2.3", prereleases=True).contains("1.3.0a1") - True - >>> Specifier(">=1.2.3").contains("1.3.0a1", prereleases=True) - True - """ - - # Determine if prereleases are to be allowed or not. - if prereleases is None: - prereleases = self.prereleases - - # Normalize item to a Version, this allows us to have a shortcut for - # "2.0" in Specifier(">=2") - normalized_item = _coerce_version(item) - - # Determine if we should be supporting prereleases in this specifier - # or not, if we do not support prereleases than we can short circuit - # logic if this version is a prereleases. - if normalized_item.is_prerelease and not prereleases: - return False - - # Actually do the comparison to determine if this item is contained - # within this Specifier or not. - operator_callable: CallableOperator = self._get_operator(self.operator) - return operator_callable(normalized_item, self.version) - - def filter( - self, iterable: Iterable[UnparsedVersionVar], prereleases: Optional[bool] = None - ) -> Iterator[UnparsedVersionVar]: - """Filter items in the given iterable, that match the specifier. - - :param iterable: - An iterable that can contain version strings and :class:`Version` instances. - The items in the iterable will be filtered according to the specifier. - :param prereleases: - Whether or not to allow prereleases in the returned iterator. If set to - ``None`` (the default), it will be intelligently decide whether to allow - prereleases or not (based on the :attr:`prereleases` attribute, and - whether the only versions matching are prereleases). - - This method is smarter than just ``filter(Specifier().contains, [...])`` - because it implements the rule from :pep:`440` that a prerelease item - SHOULD be accepted if no other versions match the given specifier. - - >>> list(Specifier(">=1.2.3").filter(["1.2", "1.3", "1.5a1"])) - ['1.3'] - >>> list(Specifier(">=1.2.3").filter(["1.2", "1.2.3", "1.3", Version("1.4")])) - ['1.2.3', '1.3', ] - >>> list(Specifier(">=1.2.3").filter(["1.2", "1.5a1"])) - ['1.5a1'] - >>> list(Specifier(">=1.2.3").filter(["1.3", "1.5a1"], prereleases=True)) - ['1.3', '1.5a1'] - >>> list(Specifier(">=1.2.3", prereleases=True).filter(["1.3", "1.5a1"])) - ['1.3', '1.5a1'] - """ - - yielded = False - found_prereleases = [] - - kw = {"prereleases": prereleases if prereleases is not None else True} - - # Attempt to iterate over all the values in the iterable and if any of - # them match, yield them. - for version in iterable: - parsed_version = _coerce_version(version) - - if self.contains(parsed_version, **kw): - # If our version is a prerelease, and we were not set to allow - # prereleases, then we'll store it for later in case nothing - # else matches this specifier. - if parsed_version.is_prerelease and not ( - prereleases or self.prereleases - ): - found_prereleases.append(version) - # Either this is not a prerelease, or we should have been - # accepting prereleases from the beginning. - else: - yielded = True - yield version - - # Now that we've iterated over everything, determine if we've yielded - # any values, and if we have not and we have any prereleases stored up - # then we will go ahead and yield the prereleases. - if not yielded and found_prereleases: - for version in found_prereleases: - yield version - - -_prefix_regex = re.compile(r"^([0-9]+)((?:a|b|c|rc)[0-9]+)$") - - -def _version_split(version: str) -> List[str]: - """Split version into components. - - The split components are intended for version comparison. The logic does - not attempt to retain the original version string, so joining the - components back with :func:`_version_join` may not produce the original - version string. - """ - result: List[str] = [] - - epoch, _, rest = version.rpartition("!") - result.append(epoch or "0") - - for item in rest.split("."): - match = _prefix_regex.search(item) - if match: - result.extend(match.groups()) - else: - result.append(item) - return result - - -def _version_join(components: List[str]) -> str: - """Join split version components into a version string. - - This function assumes the input came from :func:`_version_split`, where the - first component must be the epoch (either empty or numeric), and all other - components numeric. - """ - epoch, *rest = components - return f"{epoch}!{'.'.join(rest)}" - - -def _is_not_suffix(segment: str) -> bool: - return not any( - segment.startswith(prefix) for prefix in ("dev", "a", "b", "rc", "post") - ) - - -def _pad_version(left: List[str], right: List[str]) -> Tuple[List[str], List[str]]: - left_split, right_split = [], [] - - # Get the release segment of our versions - left_split.append(list(itertools.takewhile(lambda x: x.isdigit(), left))) - right_split.append(list(itertools.takewhile(lambda x: x.isdigit(), right))) - - # Get the rest of our versions - left_split.append(left[len(left_split[0]) :]) - right_split.append(right[len(right_split[0]) :]) - - # Insert our padding - left_split.insert(1, ["0"] * max(0, len(right_split[0]) - len(left_split[0]))) - right_split.insert(1, ["0"] * max(0, len(left_split[0]) - len(right_split[0]))) - - return ( - list(itertools.chain.from_iterable(left_split)), - list(itertools.chain.from_iterable(right_split)), - ) - - -class SpecifierSet(BaseSpecifier): - """This class abstracts handling of a set of version specifiers. - - It can be passed a single specifier (``>=3.0``), a comma-separated list of - specifiers (``>=3.0,!=3.1``), or no specifier at all. - """ - - def __init__( - self, specifiers: str = "", prereleases: Optional[bool] = None - ) -> None: - """Initialize a SpecifierSet instance. - - :param specifiers: - The string representation of a specifier or a comma-separated list of - specifiers which will be parsed and normalized before use. - :param prereleases: - This tells the SpecifierSet if it should accept prerelease versions if - applicable or not. The default of ``None`` will autodetect it from the - given specifiers. - - :raises InvalidSpecifier: - If the given ``specifiers`` are not parseable than this exception will be - raised. - """ - - # Split on `,` to break each individual specifier into it's own item, and - # strip each item to remove leading/trailing whitespace. - split_specifiers = [s.strip() for s in specifiers.split(",") if s.strip()] - - # Make each individual specifier a Specifier and save in a frozen set for later. - self._specs = frozenset(map(Specifier, split_specifiers)) - - # Store our prereleases value so we can use it later to determine if - # we accept prereleases or not. - self._prereleases = prereleases - - @property - def prereleases(self) -> Optional[bool]: - # If we have been given an explicit prerelease modifier, then we'll - # pass that through here. - if self._prereleases is not None: - return self._prereleases - - # If we don't have any specifiers, and we don't have a forced value, - # then we'll just return None since we don't know if this should have - # pre-releases or not. - if not self._specs: - return None - - # Otherwise we'll see if any of the given specifiers accept - # prereleases, if any of them do we'll return True, otherwise False. - return any(s.prereleases for s in self._specs) - - @prereleases.setter - def prereleases(self, value: bool) -> None: - self._prereleases = value - - def __repr__(self) -> str: - """A representation of the specifier set that shows all internal state. - - Note that the ordering of the individual specifiers within the set may not - match the input string. - - >>> SpecifierSet('>=1.0.0,!=2.0.0') - =1.0.0')> - >>> SpecifierSet('>=1.0.0,!=2.0.0', prereleases=False) - =1.0.0', prereleases=False)> - >>> SpecifierSet('>=1.0.0,!=2.0.0', prereleases=True) - =1.0.0', prereleases=True)> - """ - pre = ( - f", prereleases={self.prereleases!r}" - if self._prereleases is not None - else "" - ) - - return f"" - - def __str__(self) -> str: - """A string representation of the specifier set that can be round-tripped. - - Note that the ordering of the individual specifiers within the set may not - match the input string. - - >>> str(SpecifierSet(">=1.0.0,!=1.0.1")) - '!=1.0.1,>=1.0.0' - >>> str(SpecifierSet(">=1.0.0,!=1.0.1", prereleases=False)) - '!=1.0.1,>=1.0.0' - """ - return ",".join(sorted(str(s) for s in self._specs)) - - def __hash__(self) -> int: - return hash(self._specs) - - def __and__(self, other: Union["SpecifierSet", str]) -> "SpecifierSet": - """Return a SpecifierSet which is a combination of the two sets. - - :param other: The other object to combine with. - - >>> SpecifierSet(">=1.0.0,!=1.0.1") & '<=2.0.0,!=2.0.1' - =1.0.0')> - >>> SpecifierSet(">=1.0.0,!=1.0.1") & SpecifierSet('<=2.0.0,!=2.0.1') - =1.0.0')> - """ - if isinstance(other, str): - other = SpecifierSet(other) - elif not isinstance(other, SpecifierSet): - return NotImplemented - - specifier = SpecifierSet() - specifier._specs = frozenset(self._specs | other._specs) - - if self._prereleases is None and other._prereleases is not None: - specifier._prereleases = other._prereleases - elif self._prereleases is not None and other._prereleases is None: - specifier._prereleases = self._prereleases - elif self._prereleases == other._prereleases: - specifier._prereleases = self._prereleases - else: - raise ValueError( - "Cannot combine SpecifierSets with True and False prerelease " - "overrides." - ) - - return specifier - - def __eq__(self, other: object) -> bool: - """Whether or not the two SpecifierSet-like objects are equal. - - :param other: The other object to check against. - - The value of :attr:`prereleases` is ignored. - - >>> SpecifierSet(">=1.0.0,!=1.0.1") == SpecifierSet(">=1.0.0,!=1.0.1") - True - >>> (SpecifierSet(">=1.0.0,!=1.0.1", prereleases=False) == - ... SpecifierSet(">=1.0.0,!=1.0.1", prereleases=True)) - True - >>> SpecifierSet(">=1.0.0,!=1.0.1") == ">=1.0.0,!=1.0.1" - True - >>> SpecifierSet(">=1.0.0,!=1.0.1") == SpecifierSet(">=1.0.0") - False - >>> SpecifierSet(">=1.0.0,!=1.0.1") == SpecifierSet(">=1.0.0,!=1.0.2") - False - """ - if isinstance(other, (str, Specifier)): - other = SpecifierSet(str(other)) - elif not isinstance(other, SpecifierSet): - return NotImplemented - - return self._specs == other._specs - - def __len__(self) -> int: - """Returns the number of specifiers in this specifier set.""" - return len(self._specs) - - def __iter__(self) -> Iterator[Specifier]: - """ - Returns an iterator over all the underlying :class:`Specifier` instances - in this specifier set. - - >>> sorted(SpecifierSet(">=1.0.0,!=1.0.1"), key=str) - [, =1.0.0')>] - """ - return iter(self._specs) - - def __contains__(self, item: UnparsedVersion) -> bool: - """Return whether or not the item is contained in this specifier. - - :param item: The item to check for. - - This is used for the ``in`` operator and behaves the same as - :meth:`contains` with no ``prereleases`` argument passed. - - >>> "1.2.3" in SpecifierSet(">=1.0.0,!=1.0.1") - True - >>> Version("1.2.3") in SpecifierSet(">=1.0.0,!=1.0.1") - True - >>> "1.0.1" in SpecifierSet(">=1.0.0,!=1.0.1") - False - >>> "1.3.0a1" in SpecifierSet(">=1.0.0,!=1.0.1") - False - >>> "1.3.0a1" in SpecifierSet(">=1.0.0,!=1.0.1", prereleases=True) - True - """ - return self.contains(item) - - def contains( - self, - item: UnparsedVersion, - prereleases: Optional[bool] = None, - installed: Optional[bool] = None, - ) -> bool: - """Return whether or not the item is contained in this SpecifierSet. - - :param item: - The item to check for, which can be a version string or a - :class:`Version` instance. - :param prereleases: - Whether or not to match prereleases with this SpecifierSet. If set to - ``None`` (the default), it uses :attr:`prereleases` to determine - whether or not prereleases are allowed. - - >>> SpecifierSet(">=1.0.0,!=1.0.1").contains("1.2.3") - True - >>> SpecifierSet(">=1.0.0,!=1.0.1").contains(Version("1.2.3")) - True - >>> SpecifierSet(">=1.0.0,!=1.0.1").contains("1.0.1") - False - >>> SpecifierSet(">=1.0.0,!=1.0.1").contains("1.3.0a1") - False - >>> SpecifierSet(">=1.0.0,!=1.0.1", prereleases=True).contains("1.3.0a1") - True - >>> SpecifierSet(">=1.0.0,!=1.0.1").contains("1.3.0a1", prereleases=True) - True - """ - # Ensure that our item is a Version instance. - if not isinstance(item, Version): - item = Version(item) - - # Determine if we're forcing a prerelease or not, if we're not forcing - # one for this particular filter call, then we'll use whatever the - # SpecifierSet thinks for whether or not we should support prereleases. - if prereleases is None: - prereleases = self.prereleases - - # We can determine if we're going to allow pre-releases by looking to - # see if any of the underlying items supports them. If none of them do - # and this item is a pre-release then we do not allow it and we can - # short circuit that here. - # Note: This means that 1.0.dev1 would not be contained in something - # like >=1.0.devabc however it would be in >=1.0.debabc,>0.0.dev0 - if not prereleases and item.is_prerelease: - return False - - if installed and item.is_prerelease: - item = Version(item.base_version) - - # We simply dispatch to the underlying specs here to make sure that the - # given version is contained within all of them. - # Note: This use of all() here means that an empty set of specifiers - # will always return True, this is an explicit design decision. - return all(s.contains(item, prereleases=prereleases) for s in self._specs) - - def filter( - self, iterable: Iterable[UnparsedVersionVar], prereleases: Optional[bool] = None - ) -> Iterator[UnparsedVersionVar]: - """Filter items in the given iterable, that match the specifiers in this set. - - :param iterable: - An iterable that can contain version strings and :class:`Version` instances. - The items in the iterable will be filtered according to the specifier. - :param prereleases: - Whether or not to allow prereleases in the returned iterator. If set to - ``None`` (the default), it will be intelligently decide whether to allow - prereleases or not (based on the :attr:`prereleases` attribute, and - whether the only versions matching are prereleases). - - This method is smarter than just ``filter(SpecifierSet(...).contains, [...])`` - because it implements the rule from :pep:`440` that a prerelease item - SHOULD be accepted if no other versions match the given specifier. - - >>> list(SpecifierSet(">=1.2.3").filter(["1.2", "1.3", "1.5a1"])) - ['1.3'] - >>> list(SpecifierSet(">=1.2.3").filter(["1.2", "1.3", Version("1.4")])) - ['1.3', ] - >>> list(SpecifierSet(">=1.2.3").filter(["1.2", "1.5a1"])) - [] - >>> list(SpecifierSet(">=1.2.3").filter(["1.3", "1.5a1"], prereleases=True)) - ['1.3', '1.5a1'] - >>> list(SpecifierSet(">=1.2.3", prereleases=True).filter(["1.3", "1.5a1"])) - ['1.3', '1.5a1'] - - An "empty" SpecifierSet will filter items based on the presence of prerelease - versions in the set. - - >>> list(SpecifierSet("").filter(["1.3", "1.5a1"])) - ['1.3'] - >>> list(SpecifierSet("").filter(["1.5a1"])) - ['1.5a1'] - >>> list(SpecifierSet("", prereleases=True).filter(["1.3", "1.5a1"])) - ['1.3', '1.5a1'] - >>> list(SpecifierSet("").filter(["1.3", "1.5a1"], prereleases=True)) - ['1.3', '1.5a1'] - """ - # Determine if we're forcing a prerelease or not, if we're not forcing - # one for this particular filter call, then we'll use whatever the - # SpecifierSet thinks for whether or not we should support prereleases. - if prereleases is None: - prereleases = self.prereleases - - # If we have any specifiers, then we want to wrap our iterable in the - # filter method for each one, this will act as a logical AND amongst - # each specifier. - if self._specs: - for spec in self._specs: - iterable = spec.filter(iterable, prereleases=bool(prereleases)) - return iter(iterable) - # If we do not have any specifiers, then we need to have a rough filter - # which will filter out any pre-releases, unless there are no final - # releases. - else: - filtered: List[UnparsedVersionVar] = [] - found_prereleases: List[UnparsedVersionVar] = [] - - for item in iterable: - parsed_version = _coerce_version(item) - - # Store any item which is a pre-release for later unless we've - # already found a final version or we are accepting prereleases - if parsed_version.is_prerelease and not prereleases: - if not filtered: - found_prereleases.append(item) - else: - filtered.append(item) - - # If we've found no items except for pre-releases, then we'll go - # ahead and use the pre-releases - if not filtered and found_prereleases and prereleases is None: - return iter(found_prereleases) - - return iter(filtered) diff --git a/src/wheel/vendored/packaging/tags.py b/src/wheel/vendored/packaging/tags.py deleted file mode 100644 index 89f192613..000000000 --- a/src/wheel/vendored/packaging/tags.py +++ /dev/null @@ -1,571 +0,0 @@ -# This file is dual licensed under the terms of the Apache License, Version -# 2.0, and the BSD License. See the LICENSE file in the root of this repository -# for complete details. - -import logging -import platform -import re -import struct -import subprocess -import sys -import sysconfig -from importlib.machinery import EXTENSION_SUFFIXES -from typing import ( - Dict, - FrozenSet, - Iterable, - Iterator, - List, - Optional, - Sequence, - Tuple, - Union, - cast, -) - -from . import _manylinux, _musllinux - -logger = logging.getLogger(__name__) - -PythonVersion = Sequence[int] -MacVersion = Tuple[int, int] - -INTERPRETER_SHORT_NAMES: Dict[str, str] = { - "python": "py", # Generic. - "cpython": "cp", - "pypy": "pp", - "ironpython": "ip", - "jython": "jy", -} - - -_32_BIT_INTERPRETER = struct.calcsize("P") == 4 - - -class Tag: - """ - A representation of the tag triple for a wheel. - - Instances are considered immutable and thus are hashable. Equality checking - is also supported. - """ - - __slots__ = ["_interpreter", "_abi", "_platform", "_hash"] - - def __init__(self, interpreter: str, abi: str, platform: str) -> None: - self._interpreter = interpreter.lower() - self._abi = abi.lower() - self._platform = platform.lower() - # The __hash__ of every single element in a Set[Tag] will be evaluated each time - # that a set calls its `.disjoint()` method, which may be called hundreds of - # times when scanning a page of links for packages with tags matching that - # Set[Tag]. Pre-computing the value here produces significant speedups for - # downstream consumers. - self._hash = hash((self._interpreter, self._abi, self._platform)) - - @property - def interpreter(self) -> str: - return self._interpreter - - @property - def abi(self) -> str: - return self._abi - - @property - def platform(self) -> str: - return self._platform - - def __eq__(self, other: object) -> bool: - if not isinstance(other, Tag): - return NotImplemented - - return ( - (self._hash == other._hash) # Short-circuit ASAP for perf reasons. - and (self._platform == other._platform) - and (self._abi == other._abi) - and (self._interpreter == other._interpreter) - ) - - def __hash__(self) -> int: - return self._hash - - def __str__(self) -> str: - return f"{self._interpreter}-{self._abi}-{self._platform}" - - def __repr__(self) -> str: - return f"<{self} @ {id(self)}>" - - -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) - - -def _get_config_var(name: str, warn: bool = False) -> Union[int, str, None]: - value: Union[int, str, None] = sysconfig.get_config_var(name) - if value is None and warn: - logger.debug( - "Config variable '%s' is unset, Python ABI tag may be incorrect", name - ) - return value - - -def _normalize_string(string: str) -> str: - return string.replace(".", "_").replace("-", "_").replace(" ", "_") - - -def _is_threaded_cpython(abis: List[str]) -> bool: - """ - Determine if the ABI corresponds to a threaded (`--disable-gil`) build. - - The threaded builds are indicated by a "t" in the abiflags. - """ - if len(abis) == 0: - return False - # expect e.g., cp313 - m = re.match(r"cp\d+(.*)", abis[0]) - if not m: - return False - abiflags = m.group(1) - return "t" in abiflags - - -def _abi3_applies(python_version: PythonVersion, threading: bool) -> bool: - """ - Determine if the Python version supports abi3. - - PEP 384 was first implemented in Python 3.2. The threaded (`--disable-gil`) - builds do not support abi3. - """ - return len(python_version) > 1 and tuple(python_version) >= (3, 2) and not threading - - -def _cpython_abis(py_version: PythonVersion, warn: bool = False) -> List[str]: - py_version = tuple(py_version) # To allow for version comparison. - abis = [] - version = _version_nodot(py_version[:2]) - threading = debug = pymalloc = ucs4 = "" - with_debug = _get_config_var("Py_DEBUG", warn) - has_refcount = hasattr(sys, "gettotalrefcount") - # Windows doesn't set Py_DEBUG, so checking for support of debug-compiled - # extension modules is the best option. - # https://github.com/pypa/pip/issues/3383#issuecomment-173267692 - has_ext = "_d.pyd" in EXTENSION_SUFFIXES - if with_debug or (with_debug is None and (has_refcount or has_ext)): - debug = "d" - if py_version >= (3, 13) and _get_config_var("Py_GIL_DISABLED", warn): - threading = "t" - if py_version < (3, 8): - with_pymalloc = _get_config_var("WITH_PYMALLOC", warn) - if with_pymalloc or with_pymalloc is None: - pymalloc = "m" - if py_version < (3, 3): - unicode_size = _get_config_var("Py_UNICODE_SIZE", warn) - if unicode_size == 4 or ( - unicode_size is None and sys.maxunicode == 0x10FFFF - ): - ucs4 = "u" - elif debug: - # Debug builds can also load "normal" extension modules. - # We can also assume no UCS-4 or pymalloc requirement. - abis.append(f"cp{version}{threading}") - abis.insert(0, f"cp{version}{threading}{debug}{pymalloc}{ucs4}") - return abis - - -def cpython_tags( - python_version: Optional[PythonVersion] = None, - abis: Optional[Iterable[str]] = None, - platforms: Optional[Iterable[str]] = None, - *, - warn: bool = False, -) -> Iterator[Tag]: - """ - Yields the tags for a CPython interpreter. - - The tags consist of: - - cp-- - - cp-abi3- - - cp-none- - - cp-abi3- # Older Python versions down to 3.2. - - If python_version only specifies a major version then user-provided ABIs and - the 'none' ABItag will be used. - - If 'abi3' or 'none' are specified in 'abis' then they will be yielded at - their normal position and not at the beginning. - """ - if not python_version: - python_version = sys.version_info[:2] - - interpreter = f"cp{_version_nodot(python_version[:2])}" - - if abis is None: - if len(python_version) > 1: - abis = _cpython_abis(python_version, warn) - else: - abis = [] - abis = list(abis) - # 'abi3' and 'none' are explicitly handled later. - for explicit_abi in ("abi3", "none"): - try: - abis.remove(explicit_abi) - except ValueError: - pass - - platforms = list(platforms or platform_tags()) - for abi in abis: - for platform_ in platforms: - yield Tag(interpreter, abi, platform_) - - threading = _is_threaded_cpython(abis) - use_abi3 = _abi3_applies(python_version, threading) - if use_abi3: - yield from (Tag(interpreter, "abi3", platform_) for platform_ in platforms) - yield from (Tag(interpreter, "none", platform_) for platform_ in platforms) - - if use_abi3: - for minor_version in range(python_version[1] - 1, 1, -1): - for platform_ in platforms: - interpreter = "cp{version}".format( - version=_version_nodot((python_version[0], minor_version)) - ) - yield Tag(interpreter, "abi3", platform_) - - -def _generic_abi() -> List[str]: - """ - Return the ABI tag based on EXT_SUFFIX. - """ - # The following are examples of `EXT_SUFFIX`. - # We want to keep the parts which are related to the ABI and remove the - # parts which are related to the platform: - # - linux: '.cpython-310-x86_64-linux-gnu.so' => cp310 - # - mac: '.cpython-310-darwin.so' => cp310 - # - win: '.cp310-win_amd64.pyd' => cp310 - # - win: '.pyd' => cp37 (uses _cpython_abis()) - # - pypy: '.pypy38-pp73-x86_64-linux-gnu.so' => pypy38_pp73 - # - graalpy: '.graalpy-38-native-x86_64-darwin.dylib' - # => graalpy_38_native - - ext_suffix = _get_config_var("EXT_SUFFIX", warn=True) - if not isinstance(ext_suffix, str) or ext_suffix[0] != ".": - raise SystemError("invalid sysconfig.get_config_var('EXT_SUFFIX')") - parts = ext_suffix.split(".") - if len(parts) < 3: - # CPython3.7 and earlier uses ".pyd" on Windows. - return _cpython_abis(sys.version_info[:2]) - soabi = parts[1] - if soabi.startswith("cpython"): - # non-windows - abi = "cp" + soabi.split("-")[1] - elif soabi.startswith("cp"): - # windows - abi = soabi.split("-")[0] - elif soabi.startswith("pypy"): - abi = "-".join(soabi.split("-")[:2]) - elif soabi.startswith("graalpy"): - abi = "-".join(soabi.split("-")[:3]) - elif soabi: - # pyston, ironpython, others? - abi = soabi - else: - return [] - return [_normalize_string(abi)] - - -def generic_tags( - interpreter: Optional[str] = None, - abis: Optional[Iterable[str]] = None, - platforms: Optional[Iterable[str]] = None, - *, - warn: bool = False, -) -> Iterator[Tag]: - """ - Yields the tags for a generic interpreter. - - The tags consist of: - - -- - - The "none" ABI will be added if it was not explicitly provided. - """ - if not interpreter: - interp_name = interpreter_name() - interp_version = interpreter_version(warn=warn) - interpreter = "".join([interp_name, interp_version]) - if abis is None: - abis = _generic_abi() - else: - abis = list(abis) - platforms = list(platforms or platform_tags()) - if "none" not in abis: - abis.append("none") - for abi in abis: - for platform_ in platforms: - yield Tag(interpreter, abi, platform_) - - -def _py_interpreter_range(py_version: PythonVersion) -> Iterator[str]: - """ - Yields Python versions in descending order. - - After the latest version, the major-only version will be yielded, and then - all previous versions of that major version. - """ - if len(py_version) > 1: - yield f"py{_version_nodot(py_version[:2])}" - yield f"py{py_version[0]}" - if len(py_version) > 1: - for minor in range(py_version[1] - 1, -1, -1): - yield f"py{_version_nodot((py_version[0], minor))}" - - -def compatible_tags( - python_version: Optional[PythonVersion] = None, - interpreter: Optional[str] = None, - platforms: Optional[Iterable[str]] = None, -) -> Iterator[Tag]: - """ - Yields the sequence of tags that are compatible with a specific version of Python. - - The tags consist of: - - py*-none- - - -none-any # ... if `interpreter` is provided. - - py*-none-any - """ - if not python_version: - python_version = sys.version_info[:2] - platforms = list(platforms or platform_tags()) - for version in _py_interpreter_range(python_version): - for platform_ in platforms: - yield Tag(version, "none", platform_) - if interpreter: - yield Tag(interpreter, "none", "any") - for version in _py_interpreter_range(python_version): - yield Tag(version, "none", "any") - - -def _mac_arch(arch: str, is_32bit: bool = _32_BIT_INTERPRETER) -> str: - if not is_32bit: - return arch - - if arch.startswith("ppc"): - return "ppc" - - return "i386" - - -def _mac_binary_formats(version: MacVersion, cpu_arch: str) -> List[str]: - formats = [cpu_arch] - if cpu_arch == "x86_64": - if version < (10, 4): - return [] - formats.extend(["intel", "fat64", "fat32"]) - - elif cpu_arch == "i386": - if version < (10, 4): - return [] - formats.extend(["intel", "fat32", "fat"]) - - elif cpu_arch == "ppc64": - # TODO: Need to care about 32-bit PPC for ppc64 through 10.2? - if version > (10, 5) or version < (10, 4): - return [] - formats.append("fat64") - - elif cpu_arch == "ppc": - if version > (10, 6): - return [] - formats.extend(["fat32", "fat"]) - - if cpu_arch in {"arm64", "x86_64"}: - formats.append("universal2") - - if cpu_arch in {"x86_64", "i386", "ppc64", "ppc", "intel"}: - formats.append("universal") - - return formats - - -def mac_platforms( - version: Optional[MacVersion] = None, arch: Optional[str] = None -) -> Iterator[str]: - """ - Yields the platform tags for a macOS system. - - The `version` parameter is a two-item tuple specifying the macOS version to - generate platform tags for. The `arch` parameter is the CPU architecture to - generate platform tags for. Both parameters default to the appropriate value - for the current system. - """ - version_str, _, cpu_arch = platform.mac_ver() - if version is None: - version = cast("MacVersion", tuple(map(int, version_str.split(".")[:2]))) - if version == (10, 16): - # When built against an older macOS SDK, Python will report macOS 10.16 - # instead of the real version. - version_str = subprocess.run( - [ - sys.executable, - "-sS", - "-c", - "import platform; print(platform.mac_ver()[0])", - ], - check=True, - env={"SYSTEM_VERSION_COMPAT": "0"}, - stdout=subprocess.PIPE, - text=True, - ).stdout - version = cast("MacVersion", tuple(map(int, version_str.split(".")[:2]))) - else: - version = version - if arch is None: - arch = _mac_arch(cpu_arch) - else: - arch = arch - - if (10, 0) <= version and version < (11, 0): - # Prior to Mac OS 11, each yearly release of Mac OS bumped the - # "minor" version number. The major version was always 10. - for minor_version in range(version[1], -1, -1): - compat_version = 10, minor_version - binary_formats = _mac_binary_formats(compat_version, arch) - for binary_format in binary_formats: - yield "macosx_{major}_{minor}_{binary_format}".format( - major=10, minor=minor_version, binary_format=binary_format - ) - - if version >= (11, 0): - # Starting with Mac OS 11, each yearly release bumps the major version - # number. The minor versions are now the midyear updates. - for major_version in range(version[0], 10, -1): - compat_version = major_version, 0 - binary_formats = _mac_binary_formats(compat_version, arch) - for binary_format in binary_formats: - yield "macosx_{major}_{minor}_{binary_format}".format( - major=major_version, minor=0, binary_format=binary_format - ) - - if version >= (11, 0): - # Mac OS 11 on x86_64 is compatible with binaries from previous releases. - # Arm64 support was introduced in 11.0, so no Arm binaries from previous - # releases exist. - # - # However, the "universal2" binary format can have a - # macOS version earlier than 11.0 when the x86_64 part of the binary supports - # that version of macOS. - if arch == "x86_64": - for minor_version in range(16, 3, -1): - compat_version = 10, minor_version - binary_formats = _mac_binary_formats(compat_version, arch) - for binary_format in binary_formats: - yield "macosx_{major}_{minor}_{binary_format}".format( - major=compat_version[0], - minor=compat_version[1], - binary_format=binary_format, - ) - else: - for minor_version in range(16, 3, -1): - compat_version = 10, minor_version - binary_format = "universal2" - yield "macosx_{major}_{minor}_{binary_format}".format( - major=compat_version[0], - minor=compat_version[1], - binary_format=binary_format, - ) - - -def _linux_platforms(is_32bit: bool = _32_BIT_INTERPRETER) -> Iterator[str]: - linux = _normalize_string(sysconfig.get_platform()) - if not linux.startswith("linux_"): - # we should never be here, just yield the sysconfig one and return - yield linux - return - if is_32bit: - if linux == "linux_x86_64": - linux = "linux_i686" - elif linux == "linux_aarch64": - linux = "linux_armv8l" - _, arch = linux.split("_", 1) - archs = {"armv8l": ["armv8l", "armv7l"]}.get(arch, [arch]) - yield from _manylinux.platform_tags(archs) - yield from _musllinux.platform_tags(archs) - for arch in archs: - yield f"linux_{arch}" - - -def _generic_platforms() -> Iterator[str]: - yield _normalize_string(sysconfig.get_platform()) - - -def platform_tags() -> Iterator[str]: - """ - Provides the platform tags for this installation. - """ - if platform.system() == "Darwin": - return mac_platforms() - elif platform.system() == "Linux": - return _linux_platforms() - else: - return _generic_platforms() - - -def interpreter_name() -> str: - """ - Returns the name of the running interpreter. - - Some implementations have a reserved, two-letter abbreviation which will - be returned when appropriate. - """ - name = sys.implementation.name - return INTERPRETER_SHORT_NAMES.get(name) or name - - -def interpreter_version(*, warn: bool = False) -> str: - """ - Returns the version of the running interpreter. - """ - version = _get_config_var("py_version_nodot", warn=warn) - if version: - version = str(version) - else: - version = _version_nodot(sys.version_info[:2]) - return version - - -def _version_nodot(version: PythonVersion) -> str: - return "".join(map(str, version)) - - -def sys_tags(*, warn: bool = False) -> Iterator[Tag]: - """ - Returns the sequence of tag triples for the running interpreter. - - The order of the sequence corresponds to priority order for the - interpreter, from most to least important. - """ - - interp_name = interpreter_name() - if interp_name == "cp": - yield from cpython_tags(warn=warn) - else: - yield from generic_tags() - - if interp_name == "pp": - interp = "pp3" - elif interp_name == "cp": - interp = "cp" + interpreter_version(warn=warn) - else: - interp = None - yield from compatible_tags(interpreter=interp) diff --git a/src/wheel/vendored/packaging/utils.py b/src/wheel/vendored/packaging/utils.py deleted file mode 100644 index c2c2f75aa..000000000 --- a/src/wheel/vendored/packaging/utils.py +++ /dev/null @@ -1,172 +0,0 @@ -# This file is dual licensed under the terms of the Apache License, Version -# 2.0, and the BSD License. See the LICENSE file in the root of this repository -# for complete details. - -import re -from typing import FrozenSet, NewType, Tuple, Union, cast - -from .tags import Tag, parse_tag -from .version import InvalidVersion, Version - -BuildTag = Union[Tuple[()], Tuple[int, str]] -NormalizedName = NewType("NormalizedName", str) - - -class InvalidName(ValueError): - """ - An invalid distribution name; users should refer to the packaging user guide. - """ - - -class InvalidWheelFilename(ValueError): - """ - An invalid wheel filename was found, users should refer to PEP 427. - """ - - -class InvalidSdistFilename(ValueError): - """ - An invalid sdist filename was found, users should refer to the packaging user guide. - """ - - -# Core metadata spec for `Name` -_validate_regex = re.compile( - r"^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$", re.IGNORECASE -) -_canonicalize_regex = re.compile(r"[-_.]+") -_normalized_regex = re.compile(r"^([a-z0-9]|[a-z0-9]([a-z0-9-](?!--))*[a-z0-9])$") -# PEP 427: The build number must start with a digit. -_build_tag_regex = re.compile(r"(\d+)(.*)") - - -def canonicalize_name(name: str, *, validate: bool = False) -> NormalizedName: - if validate and not _validate_regex.match(name): - raise InvalidName(f"name is invalid: {name!r}") - # This is taken from PEP 503. - value = _canonicalize_regex.sub("-", name).lower() - return cast(NormalizedName, value) - - -def is_normalized_name(name: str) -> bool: - return _normalized_regex.match(name) is not None - - -def canonicalize_version( - version: Union[Version, str], *, strip_trailing_zero: bool = True -) -> str: - """ - This is very similar to Version.__str__, but has one subtle difference - with the way it handles the release segment. - """ - if isinstance(version, str): - try: - parsed = Version(version) - except InvalidVersion: - # Legacy versions cannot be normalized - return version - else: - parsed = version - - parts = [] - - # Epoch - if parsed.epoch != 0: - parts.append(f"{parsed.epoch}!") - - # Release segment - release_segment = ".".join(str(x) for x in parsed.release) - if strip_trailing_zero: - # NB: This strips trailing '.0's to normalize - release_segment = re.sub(r"(\.0)+$", "", release_segment) - parts.append(release_segment) - - # Pre-release - if parsed.pre is not None: - parts.append("".join(str(x) for x in parsed.pre)) - - # Post-release - if parsed.post is not None: - parts.append(f".post{parsed.post}") - - # Development release - if parsed.dev is not None: - parts.append(f".dev{parsed.dev}") - - # Local version segment - if parsed.local is not None: - parts.append(f"+{parsed.local}") - - return "".join(parts) - - -def parse_wheel_filename( - filename: str, -) -> Tuple[NormalizedName, Version, BuildTag, FrozenSet[Tag]]: - if not filename.endswith(".whl"): - raise InvalidWheelFilename( - f"Invalid wheel filename (extension must be '.whl'): {filename}" - ) - - filename = filename[:-4] - dashes = filename.count("-") - if dashes not in (4, 5): - raise InvalidWheelFilename( - f"Invalid wheel filename (wrong number of parts): {filename}" - ) - - parts = filename.split("-", dashes - 2) - name_part = parts[0] - # See PEP 427 for the rules on escaping the project name. - if "__" in name_part or re.match(r"^[\w\d._]*$", name_part, re.UNICODE) is None: - raise InvalidWheelFilename(f"Invalid project name: {filename}") - name = canonicalize_name(name_part) - - try: - version = Version(parts[1]) - except InvalidVersion as e: - raise InvalidWheelFilename( - f"Invalid wheel filename (invalid version): {filename}" - ) from e - - if dashes == 5: - build_part = parts[2] - build_match = _build_tag_regex.match(build_part) - if build_match is None: - raise InvalidWheelFilename( - f"Invalid build number: {build_part} in '{filename}'" - ) - build = cast(BuildTag, (int(build_match.group(1)), build_match.group(2))) - else: - build = () - tags = parse_tag(parts[-1]) - return (name, version, build, tags) - - -def parse_sdist_filename(filename: str) -> Tuple[NormalizedName, Version]: - if filename.endswith(".tar.gz"): - file_stem = filename[: -len(".tar.gz")] - elif filename.endswith(".zip"): - file_stem = filename[: -len(".zip")] - else: - raise InvalidSdistFilename( - f"Invalid sdist filename (extension must be '.tar.gz' or '.zip'):" - f" {filename}" - ) - - # We are requiring a PEP 440 version, which cannot contain dashes, - # so we split on the last dash. - name_part, sep, version_part = file_stem.rpartition("-") - if not sep: - raise InvalidSdistFilename(f"Invalid sdist filename: {filename}") - - name = canonicalize_name(name_part) - - try: - version = Version(version_part) - except InvalidVersion as e: - raise InvalidSdistFilename( - f"Invalid sdist filename (invalid version): {filename}" - ) from e - - return (name, version) diff --git a/src/wheel/vendored/packaging/version.py b/src/wheel/vendored/packaging/version.py deleted file mode 100644 index cda8e9993..000000000 --- a/src/wheel/vendored/packaging/version.py +++ /dev/null @@ -1,561 +0,0 @@ -# This file is dual licensed under the terms of the Apache License, Version -# 2.0, and the BSD License. See the LICENSE file in the root of this repository -# for complete details. -""" -.. testsetup:: - - from packaging.version import parse, Version -""" - -import itertools -import re -from typing import Any, Callable, NamedTuple, Optional, SupportsInt, Tuple, Union - -from ._structures import Infinity, InfinityType, NegativeInfinity, NegativeInfinityType - -__all__ = ["VERSION_PATTERN", "parse", "Version", "InvalidVersion"] - -LocalType = Tuple[Union[int, str], ...] - -CmpPrePostDevType = Union[InfinityType, NegativeInfinityType, Tuple[str, int]] -CmpLocalType = Union[ - NegativeInfinityType, - Tuple[Union[Tuple[int, str], Tuple[NegativeInfinityType, Union[int, str]]], ...], -] -CmpKey = Tuple[ - int, - Tuple[int, ...], - CmpPrePostDevType, - CmpPrePostDevType, - CmpPrePostDevType, - CmpLocalType, -] -VersionComparisonMethod = Callable[[CmpKey, CmpKey], bool] - - -class _Version(NamedTuple): - epoch: int - release: Tuple[int, ...] - dev: Optional[Tuple[str, int]] - pre: Optional[Tuple[str, int]] - post: Optional[Tuple[str, int]] - local: Optional[LocalType] - - -def parse(version: str) -> "Version": - """Parse the given version string. - - >>> parse('1.0.dev1') - - - :param version: The version string to parse. - :raises InvalidVersion: When the version string is not a valid version. - """ - return Version(version) - - -class InvalidVersion(ValueError): - """Raised when a version string is not a valid version. - - >>> Version("invalid") - Traceback (most recent call last): - ... - packaging.version.InvalidVersion: Invalid version: 'invalid' - """ - - -class _BaseVersion: - _key: Tuple[Any, ...] - - def __hash__(self) -> int: - return hash(self._key) - - # Please keep the duplicated `isinstance` check - # in the six comparisons hereunder - # unless you find a way to avoid adding overhead function calls. - def __lt__(self, other: "_BaseVersion") -> bool: - if not isinstance(other, _BaseVersion): - return NotImplemented - - return self._key < other._key - - def __le__(self, other: "_BaseVersion") -> bool: - if not isinstance(other, _BaseVersion): - return NotImplemented - - return self._key <= other._key - - def __eq__(self, other: object) -> bool: - if not isinstance(other, _BaseVersion): - return NotImplemented - - return self._key == other._key - - def __ge__(self, other: "_BaseVersion") -> bool: - if not isinstance(other, _BaseVersion): - return NotImplemented - - return self._key >= other._key - - def __gt__(self, other: "_BaseVersion") -> bool: - if not isinstance(other, _BaseVersion): - return NotImplemented - - return self._key > other._key - - def __ne__(self, other: object) -> bool: - if not isinstance(other, _BaseVersion): - return NotImplemented - - return self._key != other._key - - -# Deliberately not anchored to the start and end of the string, to make it -# easier for 3rd party code to reuse -_VERSION_PATTERN = r""" - v? - (?: - (?:(?P[0-9]+)!)? # epoch - (?P[0-9]+(?:\.[0-9]+)*) # release segment - (?P
                                          # pre-release
-            [-_\.]?
-            (?Palpha|a|beta|b|preview|pre|c|rc)
-            [-_\.]?
-            (?P[0-9]+)?
-        )?
-        (?P                                         # post release
-            (?:-(?P[0-9]+))
-            |
-            (?:
-                [-_\.]?
-                (?Ppost|rev|r)
-                [-_\.]?
-                (?P[0-9]+)?
-            )
-        )?
-        (?P                                          # dev release
-            [-_\.]?
-            (?Pdev)
-            [-_\.]?
-            (?P[0-9]+)?
-        )?
-    )
-    (?:\+(?P[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?       # local version
-"""
-
-VERSION_PATTERN = _VERSION_PATTERN
-"""
-A string containing the regular expression used to match a valid version.
-
-The pattern is not anchored at either end, and is intended for embedding in larger
-expressions (for example, matching a version number as part of a file name). The
-regular expression should be compiled with the ``re.VERBOSE`` and ``re.IGNORECASE``
-flags set.
-
-:meta hide-value:
-"""
-
-
-class Version(_BaseVersion):
-    """This class abstracts handling of a project's versions.
-
-    A :class:`Version` instance is comparison aware and can be compared and
-    sorted using the standard Python interfaces.
-
-    >>> v1 = Version("1.0a5")
-    >>> v2 = Version("1.0")
-    >>> v1
-    
-    >>> v2
-    
-    >>> v1 < v2
-    True
-    >>> v1 == v2
-    False
-    >>> v1 > v2
-    False
-    >>> v1 >= v2
-    False
-    >>> v1 <= v2
-    True
-    """
-
-    _regex = re.compile(r"^\s*" + VERSION_PATTERN + r"\s*$", re.VERBOSE | re.IGNORECASE)
-    _key: CmpKey
-
-    def __init__(self, version: str) -> None:
-        """Initialize a Version object.
-
-        :param version:
-            The string representation of a version which will be parsed and normalized
-            before use.
-        :raises InvalidVersion:
-            If the ``version`` does not conform to PEP 440 in any way then this
-            exception will be raised.
-        """
-
-        # Validate the version and parse it into pieces
-        match = self._regex.search(version)
-        if not match:
-            raise InvalidVersion(f"Invalid version: '{version}'")
-
-        # Store the parsed out pieces of the version
-        self._version = _Version(
-            epoch=int(match.group("epoch")) if match.group("epoch") else 0,
-            release=tuple(int(i) for i in match.group("release").split(".")),
-            pre=_parse_letter_version(match.group("pre_l"), match.group("pre_n")),
-            post=_parse_letter_version(
-                match.group("post_l"), match.group("post_n1") or match.group("post_n2")
-            ),
-            dev=_parse_letter_version(match.group("dev_l"), match.group("dev_n")),
-            local=_parse_local_version(match.group("local")),
-        )
-
-        # Generate a key which will be used for sorting
-        self._key = _cmpkey(
-            self._version.epoch,
-            self._version.release,
-            self._version.pre,
-            self._version.post,
-            self._version.dev,
-            self._version.local,
-        )
-
-    def __repr__(self) -> str:
-        """A representation of the Version that shows all internal state.
-
-        >>> Version('1.0.0')
-        
-        """
-        return f""
-
-    def __str__(self) -> str:
-        """A string representation of the version that can be rounded-tripped.
-
-        >>> str(Version("1.0a5"))
-        '1.0a5'
-        """
-        parts = []
-
-        # Epoch
-        if self.epoch != 0:
-            parts.append(f"{self.epoch}!")
-
-        # Release segment
-        parts.append(".".join(str(x) for x in self.release))
-
-        # Pre-release
-        if self.pre is not None:
-            parts.append("".join(str(x) for x in self.pre))
-
-        # Post-release
-        if self.post is not None:
-            parts.append(f".post{self.post}")
-
-        # Development release
-        if self.dev is not None:
-            parts.append(f".dev{self.dev}")
-
-        # Local version segment
-        if self.local is not None:
-            parts.append(f"+{self.local}")
-
-        return "".join(parts)
-
-    @property
-    def epoch(self) -> int:
-        """The epoch of the version.
-
-        >>> Version("2.0.0").epoch
-        0
-        >>> Version("1!2.0.0").epoch
-        1
-        """
-        return self._version.epoch
-
-    @property
-    def release(self) -> Tuple[int, ...]:
-        """The components of the "release" segment of the version.
-
-        >>> Version("1.2.3").release
-        (1, 2, 3)
-        >>> Version("2.0.0").release
-        (2, 0, 0)
-        >>> Version("1!2.0.0.post0").release
-        (2, 0, 0)
-
-        Includes trailing zeroes but not the epoch or any pre-release / development /
-        post-release suffixes.
-        """
-        return self._version.release
-
-    @property
-    def pre(self) -> Optional[Tuple[str, int]]:
-        """The pre-release segment of the version.
-
-        >>> print(Version("1.2.3").pre)
-        None
-        >>> Version("1.2.3a1").pre
-        ('a', 1)
-        >>> Version("1.2.3b1").pre
-        ('b', 1)
-        >>> Version("1.2.3rc1").pre
-        ('rc', 1)
-        """
-        return self._version.pre
-
-    @property
-    def post(self) -> Optional[int]:
-        """The post-release number of the version.
-
-        >>> print(Version("1.2.3").post)
-        None
-        >>> Version("1.2.3.post1").post
-        1
-        """
-        return self._version.post[1] if self._version.post else None
-
-    @property
-    def dev(self) -> Optional[int]:
-        """The development number of the version.
-
-        >>> print(Version("1.2.3").dev)
-        None
-        >>> Version("1.2.3.dev1").dev
-        1
-        """
-        return self._version.dev[1] if self._version.dev else None
-
-    @property
-    def local(self) -> Optional[str]:
-        """The local version segment of the version.
-
-        >>> print(Version("1.2.3").local)
-        None
-        >>> Version("1.2.3+abc").local
-        'abc'
-        """
-        if self._version.local:
-            return ".".join(str(x) for x in self._version.local)
-        else:
-            return None
-
-    @property
-    def public(self) -> str:
-        """The public portion of the version.
-
-        >>> Version("1.2.3").public
-        '1.2.3'
-        >>> Version("1.2.3+abc").public
-        '1.2.3'
-        >>> Version("1.2.3+abc.dev1").public
-        '1.2.3'
-        """
-        return str(self).split("+", 1)[0]
-
-    @property
-    def base_version(self) -> str:
-        """The "base version" of the version.
-
-        >>> Version("1.2.3").base_version
-        '1.2.3'
-        >>> Version("1.2.3+abc").base_version
-        '1.2.3'
-        >>> Version("1!1.2.3+abc.dev1").base_version
-        '1!1.2.3'
-
-        The "base version" is the public version of the project without any pre or post
-        release markers.
-        """
-        parts = []
-
-        # Epoch
-        if self.epoch != 0:
-            parts.append(f"{self.epoch}!")
-
-        # Release segment
-        parts.append(".".join(str(x) for x in self.release))
-
-        return "".join(parts)
-
-    @property
-    def is_prerelease(self) -> bool:
-        """Whether this version is a pre-release.
-
-        >>> Version("1.2.3").is_prerelease
-        False
-        >>> Version("1.2.3a1").is_prerelease
-        True
-        >>> Version("1.2.3b1").is_prerelease
-        True
-        >>> Version("1.2.3rc1").is_prerelease
-        True
-        >>> Version("1.2.3dev1").is_prerelease
-        True
-        """
-        return self.dev is not None or self.pre is not None
-
-    @property
-    def is_postrelease(self) -> bool:
-        """Whether this version is a post-release.
-
-        >>> Version("1.2.3").is_postrelease
-        False
-        >>> Version("1.2.3.post1").is_postrelease
-        True
-        """
-        return self.post is not None
-
-    @property
-    def is_devrelease(self) -> bool:
-        """Whether this version is a development release.
-
-        >>> Version("1.2.3").is_devrelease
-        False
-        >>> Version("1.2.3.dev1").is_devrelease
-        True
-        """
-        return self.dev is not None
-
-    @property
-    def major(self) -> int:
-        """The first item of :attr:`release` or ``0`` if unavailable.
-
-        >>> Version("1.2.3").major
-        1
-        """
-        return self.release[0] if len(self.release) >= 1 else 0
-
-    @property
-    def minor(self) -> int:
-        """The second item of :attr:`release` or ``0`` if unavailable.
-
-        >>> Version("1.2.3").minor
-        2
-        >>> Version("1").minor
-        0
-        """
-        return self.release[1] if len(self.release) >= 2 else 0
-
-    @property
-    def micro(self) -> int:
-        """The third item of :attr:`release` or ``0`` if unavailable.
-
-        >>> Version("1.2.3").micro
-        3
-        >>> Version("1").micro
-        0
-        """
-        return self.release[2] if len(self.release) >= 3 else 0
-
-
-def _parse_letter_version(
-    letter: Optional[str], number: Union[str, bytes, SupportsInt, None]
-) -> Optional[Tuple[str, int]]:
-    if letter:
-        # We consider there to be an implicit 0 in a pre-release if there is
-        # not a numeral associated with it.
-        if number is None:
-            number = 0
-
-        # We normalize any letters to their lower case form
-        letter = letter.lower()
-
-        # We consider some words to be alternate spellings of other words and
-        # in those cases we want to normalize the spellings to our preferred
-        # spelling.
-        if letter == "alpha":
-            letter = "a"
-        elif letter == "beta":
-            letter = "b"
-        elif letter in ["c", "pre", "preview"]:
-            letter = "rc"
-        elif letter in ["rev", "r"]:
-            letter = "post"
-
-        return letter, int(number)
-    if not letter and number:
-        # We assume if we are given a number, but we are not given a letter
-        # then this is using the implicit post release syntax (e.g. 1.0-1)
-        letter = "post"
-
-        return letter, int(number)
-
-    return None
-
-
-_local_version_separators = re.compile(r"[\._-]")
-
-
-def _parse_local_version(local: Optional[str]) -> Optional[LocalType]:
-    """
-    Takes a string like abc.1.twelve and turns it into ("abc", 1, "twelve").
-    """
-    if local is not None:
-        return tuple(
-            part.lower() if not part.isdigit() else int(part)
-            for part in _local_version_separators.split(local)
-        )
-    return None
-
-
-def _cmpkey(
-    epoch: int,
-    release: Tuple[int, ...],
-    pre: Optional[Tuple[str, int]],
-    post: Optional[Tuple[str, int]],
-    dev: Optional[Tuple[str, int]],
-    local: Optional[LocalType],
-) -> CmpKey:
-    # When we compare a release version, we want to compare it with all of the
-    # trailing zeros removed. So we'll use a reverse the list, drop all the now
-    # leading zeros until we come to something non zero, then take the rest
-    # re-reverse it back into the correct order and make it a tuple and use
-    # that for our sorting key.
-    _release = tuple(
-        reversed(list(itertools.dropwhile(lambda x: x == 0, reversed(release))))
-    )
-
-    # We need to "trick" the sorting algorithm to put 1.0.dev0 before 1.0a0.
-    # We'll do this by abusing the pre segment, but we _only_ want to do this
-    # if there is not a pre or a post segment. If we have one of those then
-    # the normal sorting rules will handle this case correctly.
-    if pre is None and post is None and dev is not None:
-        _pre: CmpPrePostDevType = NegativeInfinity
-    # Versions without a pre-release (except as noted above) should sort after
-    # those with one.
-    elif pre is None:
-        _pre = Infinity
-    else:
-        _pre = pre
-
-    # Versions without a post segment should sort before those with one.
-    if post is None:
-        _post: CmpPrePostDevType = NegativeInfinity
-
-    else:
-        _post = post
-
-    # Versions without a development segment should sort after those with one.
-    if dev is None:
-        _dev: CmpPrePostDevType = Infinity
-
-    else:
-        _dev = dev
-
-    if local is None:
-        # Versions without a local segment should sort before those with one.
-        _local: CmpLocalType = NegativeInfinity
-    else:
-        # Versions with a local segment need that segment parsed to implement
-        # the sorting rules in PEP440.
-        # - Alpha numeric segments sort before numeric segments
-        # - Alpha numeric segments sort lexicographically
-        # - Numeric segments sort numerically
-        # - Shorter versions sort before longer versions when the prefixes
-        #   match exactly
-        _local = tuple(
-            (i, "") if isinstance(i, int) else (NegativeInfinity, i) for i in local
-        )
-
-    return epoch, _release, _pre, _post, _dev, _local
diff --git a/src/wheel/vendored/vendor.txt b/src/wheel/vendored/vendor.txt
deleted file mode 100644
index 14666103a..000000000
--- a/src/wheel/vendored/vendor.txt
+++ /dev/null
@@ -1 +0,0 @@
-packaging==24.0
diff --git a/src/wheel/wheelfile.py b/src/wheel/wheelfile.py
index 0a0f4596c..bb96a2ac7 100644
--- a/src/wheel/wheelfile.py
+++ b/src/wheel/wheelfile.py
@@ -1,7 +1,11 @@
 from __future__ import annotations
 
+__all__ = ["WHEEL_INFO_RE", "WheelFile", "WheelError"]
+
+import base64
 import csv
 import hashlib
+import logging
 import os.path
 import re
 import stat
@@ -10,17 +14,8 @@
 from typing import IO, TYPE_CHECKING, Literal
 from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
 
-from wheel.cli import WheelError
-from wheel.util import log, urlsafe_b64decode, urlsafe_b64encode
-
 if TYPE_CHECKING:
-    from typing import Protocol, Sized, Union
-
-    from typing_extensions import Buffer
-
-    StrPath = Union[str, os.PathLike[str]]
-
-    class SizedBuffer(Sized, Buffer, Protocol): ...
+    from _typeshed import SizedBuffer, StrPath
 
 
 # Non-greedy matching of an optional build number may be too clever (more
@@ -32,8 +27,27 @@ class SizedBuffer(Sized, Buffer, Protocol): ...
 )
 MINIMUM_TIMESTAMP = 315532800  # 1980-01-01 00:00:00 UTC
 
+log = logging.getLogger("wheel")
+
+
+class WheelError(Exception):
+    pass
+
 
-def get_zipinfo_datetime(timestamp: float | None = None):
+def urlsafe_b64encode(data: bytes) -> bytes:
+    """urlsafe_b64encode without padding"""
+    return base64.urlsafe_b64encode(data).rstrip(b"=")
+
+
+def urlsafe_b64decode(data: bytes) -> bytes:
+    """urlsafe_b64decode without padding"""
+    pad = b"=" * (4 - (len(data) & 3))
+    return base64.urlsafe_b64decode(data + pad)
+
+
+def get_zipinfo_datetime(
+    timestamp: float | None = None,
+) -> tuple[int, int, int, int, int]:
     # Some applications need reproducible .whl files, but they can't do this without
     # forcing the timestamp of the individual ZipInfo objects. See issue #143.
     timestamp = int(os.environ.get("SOURCE_DATE_EPOCH", timestamp or time.time()))
@@ -68,6 +82,17 @@ def __init__(
         self._file_hashes: dict[str, tuple[None, None] | tuple[int, bytes]] = {}
         self._file_sizes = {}
         if mode == "r":
+            # The .dist-info directory inside the wheel may use normalized
+            # (lowercase) naming even when the filename does not. Resolve the
+            # actual path case-insensitively.
+            if self.record_path not in self.namelist():
+                lowered = self.dist_info_path.lower() + "/record"
+                for name in self.namelist():
+                    if name.lower() == lowered:
+                        self.dist_info_path = name.rsplit("/RECORD", 1)[0]
+                        self.record_path = name
+                        break
+
             # Ignore RECORD and any embedded wheel signatures
             self._file_hashes[self.record_path] = None, None
             self._file_hashes[self.record_path + ".jws"] = None, None
@@ -140,8 +165,8 @@ def _update_crc(newdata: bytes) -> None:
 
         return ef
 
-    def write_files(self, base_dir: str):
-        log.info(f"creating '{self.filename}' and adding '{base_dir}' to it")
+    def write_files(self, base_dir: str) -> None:
+        log.info("creating %r and adding %r to it", self.filename, base_dir)
         deferred: list[tuple[str, str]] = []
         for root, dirnames, filenames in os.walk(base_dir):
             # Sort the directory names so that `os.walk` will walk them in a
@@ -184,7 +209,7 @@ def writestr(
         zinfo_or_arcname: str | ZipInfo,
         data: SizedBuffer | str,
         compress_type: int | None = None,
-    ):
+    ) -> None:
         if isinstance(zinfo_or_arcname, str):
             zinfo_or_arcname = ZipInfo(
                 zinfo_or_arcname, date_time=get_zipinfo_datetime()
@@ -201,7 +226,7 @@ def writestr(
             if isinstance(zinfo_or_arcname, ZipInfo)
             else zinfo_or_arcname
         )
-        log.info(f"adding '{fname}'")
+        log.info("adding %r", fname)
         if fname != self.record_path:
             hash_ = self._default_algorithm(data)
             self._file_hashes[fname] = (
@@ -210,7 +235,7 @@ def writestr(
             )
             self._file_sizes[fname] = len(data)
 
-    def close(self):
+    def close(self) -> None:
         # Write RECORD
         if self.fp is not None and self.mode == "w" and self._file_hashes:
             data = StringIO()
diff --git a/tests/cli/test_tags.py b/tests/cli/test_tags.py
deleted file mode 100644
index 4d4dfa153..000000000
--- a/tests/cli/test_tags.py
+++ /dev/null
@@ -1,240 +0,0 @@
-from __future__ import annotations
-
-import shutil
-import sys
-from pathlib import Path
-from zipfile import ZipFile
-
-import pytest
-
-from wheel.cli import main, parser
-from wheel.cli.tags import tags
-from wheel.wheelfile import WheelFile
-
-TESTDIR = Path(__file__).parent.parent
-TESTWHEEL_NAME = "test-1.0-py2.py3-none-any.whl"
-TESTWHEEL_PATH = TESTDIR / "testdata" / TESTWHEEL_NAME
-
-
-@pytest.fixture
-def wheelpath(tmp_path):
-    wheels_dir = tmp_path / "wheels"
-    wheels_dir.mkdir()
-    fn = wheels_dir / TESTWHEEL_NAME
-    # The str calls can be removed for Python 3.8+
-    shutil.copy(str(TESTWHEEL_PATH), str(fn))
-    return fn
-
-
-def test_tags_no_args(wheelpath):
-    newname = tags(str(wheelpath))
-    assert TESTWHEEL_NAME == newname
-    assert wheelpath.exists()
-
-
-def test_python_tags(wheelpath):
-    newname = tags(str(wheelpath), python_tags="py3")
-    assert TESTWHEEL_NAME.replace("py2.py3", "py3") == newname
-    output_file = wheelpath.parent / newname
-    with WheelFile(str(output_file)) as f:
-        output = f.read(f.dist_info_path + "/WHEEL")
-    assert (
-        output == b"Wheel-Version: 1.0\nGenerator: bdist_wheel (0.30.0)"
-        b"\nRoot-Is-Purelib: false\nTag: py3-none-any\n\n"
-    )
-    output_file.unlink()
-
-    newname = tags(str(wheelpath), python_tags="py2.py3")
-    assert TESTWHEEL_NAME == newname
-
-    newname = tags(str(wheelpath), python_tags="+py4", remove=True)
-    assert not wheelpath.exists()
-    assert TESTWHEEL_NAME.replace("py2.py3", "py2.py3.py4") == newname
-    output_file = wheelpath.parent / newname
-    output_file.unlink()
-
-
-def test_abi_tags(wheelpath):
-    newname = tags(str(wheelpath), abi_tags="cp33m")
-    assert TESTWHEEL_NAME.replace("none", "cp33m") == newname
-    output_file = wheelpath.parent / newname
-    output_file.unlink()
-
-    newname = tags(str(wheelpath), abi_tags="cp33m.abi3")
-    assert TESTWHEEL_NAME.replace("none", "abi3.cp33m") == newname
-    output_file = wheelpath.parent / newname
-    output_file.unlink()
-
-    newname = tags(str(wheelpath), abi_tags="none")
-    assert TESTWHEEL_NAME == newname
-
-    newname = tags(str(wheelpath), abi_tags="+abi3.cp33m", remove=True)
-    assert not wheelpath.exists()
-    assert TESTWHEEL_NAME.replace("none", "abi3.cp33m.none") == newname
-    output_file = wheelpath.parent / newname
-    output_file.unlink()
-
-
-def test_plat_tags(wheelpath):
-    newname = tags(str(wheelpath), platform_tags="linux_x86_64")
-    assert TESTWHEEL_NAME.replace("any", "linux_x86_64") == newname
-    output_file = wheelpath.parent / newname
-    assert output_file.exists()
-    output_file.unlink()
-
-    newname = tags(str(wheelpath), platform_tags="linux_x86_64.win32")
-    assert TESTWHEEL_NAME.replace("any", "linux_x86_64.win32") == newname
-    output_file = wheelpath.parent / newname
-    assert output_file.exists()
-    output_file.unlink()
-
-    newname = tags(str(wheelpath), platform_tags="+linux_x86_64.win32")
-    assert TESTWHEEL_NAME.replace("any", "any.linux_x86_64.win32") == newname
-    output_file = wheelpath.parent / newname
-    assert output_file.exists()
-    output_file.unlink()
-
-    newname = tags(str(wheelpath), platform_tags="+linux_x86_64.win32")
-    assert TESTWHEEL_NAME.replace("any", "any.linux_x86_64.win32") == newname
-    output_file = wheelpath.parent / newname
-    assert output_file.exists()
-
-    newname2 = tags(str(output_file), platform_tags="-any")
-    output_file.unlink()
-
-    assert TESTWHEEL_NAME.replace("any", "linux_x86_64.win32") == newname2
-    output_file2 = wheelpath.parent / newname2
-    assert output_file2.exists()
-    output_file2.unlink()
-
-    newname = tags(str(wheelpath), platform_tags="any")
-    assert TESTWHEEL_NAME == newname
-
-
-def test_build_tag(wheelpath):
-    newname = tags(str(wheelpath), build_tag="1bah")
-    assert TESTWHEEL_NAME.replace("-py2", "-1bah-py2") == newname
-    output_file = wheelpath.parent / newname
-    assert output_file.exists()
-    newname = tags(str(wheelpath), build_tag="")
-    assert TESTWHEEL_NAME == newname
-    output_file.unlink()
-
-
-@pytest.mark.parametrize(
-    "build_tag, error",
-    [
-        pytest.param("foo", "build tag must begin with a digit", id="digitstart"),
-        pytest.param("1-f", "invalid character ('-') in build tag", id="hyphen"),
-    ],
-)
-def test_invalid_build_tag(wheelpath, build_tag, error, monkeypatch, capsys):
-    monkeypatch.setattr(sys, "argv", [sys.argv[0], "tags", "--build", build_tag])
-    with pytest.raises(SystemExit) as exc:
-        main()
-
-    _, err = capsys.readouterr()
-    assert exc.value.args[0] == 2
-    assert f"error: argument --build: {error}" in err
-
-
-def test_multi_tags(wheelpath):
-    newname = tags(
-        str(wheelpath),
-        platform_tags="linux_x86_64",
-        python_tags="+py4",
-        build_tag="1",
-    )
-    assert "test-1.0-1-py2.py3.py4-none-linux_x86_64.whl" == newname
-
-    output_file = wheelpath.parent / newname
-    assert output_file.exists()
-    with WheelFile(str(output_file)) as f:
-        output = f.read(f.dist_info_path + "/WHEEL")
-    assert (
-        output
-        == b"Wheel-Version: 1.0\nGenerator: bdist_wheel (0.30.0)\nRoot-Is-Purelib:"
-        b" false\nTag: py2-none-linux_x86_64\nTag: py3-none-linux_x86_64\nTag:"
-        b" py4-none-linux_x86_64\nBuild: 1\n\n"
-    )
-    output_file.unlink()
-
-
-def test_tags_command(capsys, wheelpath):
-    args = [
-        "tags",
-        "--python-tag",
-        "py3",
-        "--abi-tag",
-        "cp33m",
-        "--platform-tag",
-        "linux_x86_64",
-        "--build",
-        "7",
-        str(wheelpath),
-    ]
-    p = parser()
-    args = p.parse_args(args)
-    args.func(args)
-    assert wheelpath.exists()
-
-    newname = capsys.readouterr().out.strip()
-    assert "test-1.0-7-py3-cp33m-linux_x86_64.whl" == newname
-    output_file = wheelpath.parent / newname
-    output_file.unlink()
-
-
-def test_tags_command_del(capsys, wheelpath):
-    args = [
-        "tags",
-        "--python-tag",
-        "+py4",
-        "--abi-tag",
-        "cp33m",
-        "--platform-tag",
-        "linux_x86_64",
-        "--remove",
-        str(wheelpath),
-    ]
-    p = parser()
-    args = p.parse_args(args)
-    args.func(args)
-    assert not wheelpath.exists()
-
-    newname = capsys.readouterr().out.strip()
-    assert "test-1.0-py2.py3.py4-cp33m-linux_x86_64.whl" == newname
-    output_file = wheelpath.parent / newname
-    output_file.unlink()
-
-
-def test_permission_bits(capsys, wheelpath):
-    args = [
-        "tags",
-        "--python-tag=+py4",
-        str(wheelpath),
-    ]
-    p = parser()
-    args = p.parse_args(args)
-    args.func(args)
-
-    newname = capsys.readouterr().out.strip()
-    assert "test-1.0-py2.py3.py4-none-any.whl" == newname
-    output_file = wheelpath.parent / newname
-
-    with ZipFile(str(output_file), "r") as outf:
-        with ZipFile(str(wheelpath), "r") as inf:
-            for member in inf.namelist():
-                member_info = inf.getinfo(member)
-                if member_info.is_dir():
-                    continue
-
-                if member_info.filename.endswith("/RECORD"):
-                    continue
-
-                out_attr = outf.getinfo(member).external_attr
-                inf_attr = member_info.external_attr
-                assert (
-                    out_attr == inf_attr
-                ), f"{member} 0x{out_attr:012o} != 0x{inf_attr:012o}"
-
-    output_file.unlink()
diff --git a/tests/cli/test_unpack.py b/tests/cli/test_unpack.py
deleted file mode 100644
index ae584af0d..000000000
--- a/tests/cli/test_unpack.py
+++ /dev/null
@@ -1,36 +0,0 @@
-from __future__ import annotations
-
-import platform
-import stat
-
-import pytest
-
-from wheel.cli.unpack import unpack
-from wheel.wheelfile import WheelFile
-
-
-def test_unpack(wheel_paths, tmp_path):
-    """
-    Make sure 'wheel unpack' works.
-    This also verifies the integrity of our testing wheel files.
-    """
-    for wheel_path in wheel_paths:
-        unpack(wheel_path, str(tmp_path))
-
-
-@pytest.mark.skipif(
-    platform.system() == "Windows", reason="Windows does not support the executable bit"
-)
-def test_unpack_executable_bit(tmp_path):
-    wheel_path = tmp_path / "test-1.0-py3-none-any.whl"
-    script_path = tmp_path / "script"
-    script_path.write_bytes(b"test script")
-    script_path.chmod(0o755)
-    with WheelFile(wheel_path, "w") as wf:
-        wf.write(str(script_path), "nested/script")
-
-    script_path.unlink()
-    script_path = tmp_path / "test-1.0" / "nested" / "script"
-    unpack(str(wheel_path), str(tmp_path))
-    assert not script_path.is_dir()
-    assert stat.S_IMODE(script_path.stat().st_mode) == 0o755
diff --git a/src/wheel/vendored/__init__.py b/tests/commands/__init__.py
similarity index 100%
rename from src/wheel/vendored/__init__.py
rename to tests/commands/__init__.py
diff --git a/tests/cli/test_convert.py b/tests/commands/test_convert.py
similarity index 81%
rename from tests/cli/test_convert.py
rename to tests/commands/test_convert.py
index 62884c2da..73ed81ef6 100644
--- a/tests/cli/test_convert.py
+++ b/tests/commands/test_convert.py
@@ -1,16 +1,17 @@
 from __future__ import annotations
 
-import os.path
 import zipfile
+from email.message import Message
 from pathlib import Path
 from textwrap import dedent
 
 import pytest
 from _pytest.fixtures import SubRequest
-from pytest import CaptureFixture, TempPathFactory
+from pytest import TempPathFactory
 
 import wheel
-from wheel.cli.convert import convert, egg_filename_re
+from commands.util import run_command
+from wheel._commands.convert import convert_pkg_info, egg_filename_re
 from wheel.wheelfile import WheelFile
 
 PKG_INFO = """\
@@ -30,7 +31,7 @@
     ===================
     
     Test description
-""".encode()  # noqa: W293
+""".encode()
 
 REQUIRES_TXT = b"""\
 somepackage>=1.5
@@ -62,7 +63,7 @@
 
 Test description
 
-""".encode()  # noqa: W293
+""".encode()
 
 
 @pytest.fixture(
@@ -153,7 +154,7 @@ def bdist_wininst_path(arch: str, pyver: str | None, tmp_path: Path) -> str:
 
 
 @pytest.fixture
-def egg_path(arch: str, pyver: str | None, tmp_path: Path) -> str:
+def egg_path(arch: str, pyver: str | None, tmp_path: Path) -> Path:
     if pyver:
         filename = f"Sampledist-1.0.0-{pyver}-{arch}.egg"
     else:
@@ -173,7 +174,7 @@ def egg_path(arch: str, pyver: str | None, tmp_path: Path) -> str:
         zip.writestr("EGG-INFO/requires.txt", REQUIRES_TXT)
         zip.writestr("EGG-INFO/zip-safe", b"")
 
-    return str(bdist_path)
+    return bdist_path
 
 
 @pytest.fixture
@@ -188,8 +189,8 @@ def expected_wheel_filename(pyver: str | None, arch: str) -> str:
 
 def test_egg_re() -> None:
     """Make sure egg_info_re matches."""
-    egg_names_path = os.path.join(os.path.dirname(__file__), "eggnames.txt")
-    with open(egg_names_path, encoding="utf-8") as egg_names:
+    egg_names_path = Path(__file__).parent.parent / "testdata" / "eggnames.txt"
+    with egg_names_path.open(encoding="utf-8") as egg_names:
         for line in egg_names:
             line = line.strip()
             if line:
@@ -197,14 +198,13 @@ def test_egg_re() -> None:
 
 
 def test_convert_egg_file(
-    egg_path: str,
+    egg_path: Path,
     tmp_path: Path,
     arch: str,
     expected_wheelfile: bytes,
     expected_wheel_filename: str,
-    capsys: CaptureFixture,
 ) -> None:
-    convert([egg_path], str(tmp_path), verbose=True)
+    output = run_command("convert", "-v", "--dest", tmp_path, egg_path)
     wheel_path = next(path for path in tmp_path.iterdir() if path.suffix == ".whl")
     assert wheel_path.name == expected_wheel_filename
     with WheelFile(wheel_path) as wf:
@@ -212,25 +212,24 @@ def test_convert_egg_file(
         assert wf.read("sampledist-1.0.0.dist-info/WHEEL") == expected_wheelfile
         assert wf.read("sampledist-1.0.0.dist-info/entry_points.txt") == b""
 
-    assert capsys.readouterr().out == f"{egg_path}...OK\n"
+    assert output == f"{egg_path}...OK\n"
 
 
 def test_convert_egg_directory(
-    egg_path: str,
+    egg_path: Path,
     tmp_path: Path,
     tmp_path_factory: TempPathFactory,
     pyver: str | None,
     arch: str,
     expected_wheelfile: bytes,
     expected_wheel_filename: str,
-    capsys: CaptureFixture,
 ) -> None:
     with zipfile.ZipFile(egg_path) as egg_file:
         egg_dir_path = tmp_path_factory.mktemp("eggdir") / Path(egg_path).name
         egg_dir_path.mkdir()
         egg_file.extractall(egg_dir_path)
 
-    convert([str(egg_dir_path)], str(tmp_path), verbose=True)
+    output = run_command("convert", "-v", "--dest", tmp_path, egg_dir_path)
     wheel_path = next(path for path in tmp_path.iterdir() if path.suffix == ".whl")
     assert wheel_path.name == expected_wheel_filename
     with WheelFile(wheel_path) as wf:
@@ -238,7 +237,7 @@ def test_convert_egg_directory(
         assert wf.read("sampledist-1.0.0.dist-info/WHEEL") == expected_wheelfile
         assert wf.read("sampledist-1.0.0.dist-info/entry_points.txt") == b""
 
-    assert capsys.readouterr().out == f"{egg_dir_path}...OK\n"
+    assert output == f"{egg_dir_path}...OK\n"
 
 
 def test_convert_bdist_wininst(
@@ -247,9 +246,8 @@ def test_convert_bdist_wininst(
     arch: str,
     expected_wheelfile: bytes,
     expected_wheel_filename: str,
-    capsys: CaptureFixture,
 ) -> None:
-    convert([bdist_wininst_path], str(tmp_path), verbose=True)
+    output = run_command("convert", "-v", "--dest", tmp_path, bdist_wininst_path)
     wheel_path = next(path for path in tmp_path.iterdir() if path.suffix == ".whl")
     assert wheel_path.name == expected_wheel_filename
     with WheelFile(wheel_path) as wf:
@@ -261,4 +259,33 @@ def test_convert_bdist_wininst(
         assert wf.read("sampledist-1.0.0.dist-info/WHEEL") == expected_wheelfile
         assert wf.read("sampledist-1.0.0.dist-info/entry_points.txt") == b""
 
-    assert capsys.readouterr().out == f"{bdist_wininst_path}...OK\n"
+    assert output == f"{bdist_wininst_path}...OK\n"
+
+
+def test_convert_pkg_info_with_empty_description() -> None:
+    # Regression test for https://github.com/pypa/wheel/issues/645
+    pkginfo = """\
+Metadata-Version: 2.1
+Name: Sampledist
+Version: 1.0.0
+Home-page: https://example.com
+Download-URL: https://example.com/sampledist
+Description:"""
+    message = Message()
+    convert_pkg_info(pkginfo, message)
+    assert message.get_all("Name") == ["Sampledist"]
+    assert message.get_payload() == "\n"
+
+
+def test_convert_pkg_info_with_one_line_description() -> None:
+    pkginfo = """\
+Metadata-Version: 2.1
+Name: Sampledist
+Version: 1.0.0
+Home-page: https://example.com
+Download-URL: https://example.com/sampledist
+Description:    My cool package"""
+    message = Message()
+    convert_pkg_info(pkginfo, message)
+    assert message.get_all("Name") == ["Sampledist"]
+    assert message.get_payload() == "My cool package\n\n\n"
diff --git a/tests/commands/test_info.py b/tests/commands/test_info.py
new file mode 100644
index 000000000..7c0d31d29
--- /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
diff --git a/tests/cli/test_pack.py b/tests/commands/test_pack.py
similarity index 73%
rename from tests/cli/test_pack.py
rename to tests/commands/test_pack.py
index 31f28de1f..70f62ee74 100644
--- a/tests/cli/test_pack.py
+++ b/tests/commands/test_pack.py
@@ -4,11 +4,12 @@
 import os
 from email.message import Message
 from email.parser import BytesParser
-from zipfile import ZipFile
+from zipfile import Path, ZipFile
 
 import pytest
+from pytest import TempPathFactory
 
-from wheel.cli.pack import pack
+from .util import run_command
 
 THISDIR = os.path.dirname(__file__)
 TESTWHEEL_NAME = "test-1.0-py2.py3-none-any.whl"
@@ -19,14 +20,19 @@
 @pytest.mark.parametrize(
     "build_tag_arg, existing_build_tag, filename",
     [
-        (None, None, "test-1.0-py2.py3-none-any.whl"),
-        ("2b", None, "test-1.0-2b-py2.py3-none-any.whl"),
-        (None, "3", "test-1.0-3-py2.py3-none-any.whl"),
-        ("", "3", "test-1.0-py2.py3-none-any.whl"),
+        pytest.param(None, None, "test-1.0-py2.py3-none-any.whl", id="nobuildnum"),
+        pytest.param("2b", None, "test-1.0-2b-py2.py3-none-any.whl", id="newbuildarg"),
+        pytest.param(None, "3", "test-1.0-3-py2.py3-none-any.whl", id="oldbuildnum"),
+        pytest.param("", "3", "test-1.0-py2.py3-none-any.whl", id="erasebuildnum"),
     ],
-    ids=["nobuildnum", "newbuildarg", "oldbuildnum", "erasebuildnum"],
 )
-def test_pack(tmp_path_factory, tmp_path, build_tag_arg, existing_build_tag, filename):
+def test_pack(
+    tmp_path_factory: TempPathFactory,
+    tmp_path: Path,
+    build_tag_arg: str | None,
+    existing_build_tag: str | None,
+    filename: str,
+) -> None:
     unpack_dir = tmp_path_factory.mktemp("wheeldir")
     with ZipFile(TESTWHEEL_PATH) as zf:
         old_record = zf.read("test-1.0.dist-info/RECORD")
@@ -35,7 +41,7 @@ def test_pack(tmp_path_factory, tmp_path, build_tag_arg, existing_build_tag, fil
             for line in old_record.split(b"\n")
             if line and not line.startswith(b"test-1.0.dist-info/WHEEL,")
         )
-        zf.extractall(str(unpack_dir))
+        zf.extractall(unpack_dir)
 
     if existing_build_tag:
         # Add the build number to WHEEL
@@ -45,11 +51,16 @@ def test_pack(tmp_path_factory, tmp_path, build_tag_arg, existing_build_tag, fil
         wheel_file_content += b"Build: 3\r\n"
         wheel_file_path.write_bytes(wheel_file_content)
 
-    pack(str(unpack_dir), str(tmp_path), build_tag_arg)
+    args = ["--dest", tmp_path, unpack_dir]
+    if build_tag_arg is not None:
+        (args.insert(3, "--build"),)
+        args.insert(4, build_tag_arg)
+
+    run_command("pack", *args)
     new_wheel_path = tmp_path.joinpath(filename)
     assert new_wheel_path.is_file()
 
-    with ZipFile(str(new_wheel_path)) as zf:
+    with ZipFile(new_wheel_path) as zf:
         new_record = zf.read("test-1.0.dist-info/RECORD")
         new_record_lines = sorted(
             line.rstrip()
diff --git a/tests/commands/test_tags.py b/tests/commands/test_tags.py
new file mode 100644
index 000000000..f73b1175a
--- /dev/null
+++ b/tests/commands/test_tags.py
@@ -0,0 +1,232 @@
+from __future__ import annotations
+
+import shutil
+from pathlib import Path
+from subprocess import CalledProcessError
+from zipfile import ZipFile
+
+import pytest
+
+from wheel.wheelfile import WheelFile
+
+from .util import run_command
+
+TESTWHEEL_NAME = "test-1.0-py2.py3-none-any.whl"
+TESTWHEEL_PATH = Path(__file__).parent.parent / "testdata" / TESTWHEEL_NAME
+
+
+@pytest.fixture
+def wheelpath(tmp_path: Path) -> Path:
+    wheels_dir = tmp_path / "wheels"
+    wheels_dir.mkdir()
+    fn = wheels_dir / TESTWHEEL_NAME
+    shutil.copy(TESTWHEEL_PATH, fn)
+    return fn
+
+
+def test_tags_no_args(wheelpath: Path) -> None:
+    newname = run_command("tags", wheelpath).strip()
+    assert newname == TESTWHEEL_NAME
+    assert wheelpath.exists()
+
+
+def test_python_tags(wheelpath: Path) -> None:
+    newname = run_command("tags", "--python-tag", "py3", wheelpath).strip()
+    assert newname == TESTWHEEL_NAME.replace("py2.py3", "py3")
+    output_file = wheelpath.parent / newname
+    with WheelFile(output_file) as f:
+        output = f.read(f.dist_info_path + "/WHEEL")
+
+    assert (
+        output == b"Wheel-Version: 1.0\nGenerator: bdist_wheel (0.30.0)"
+        b"\nRoot-Is-Purelib: false\nTag: py3-none-any\n\n"
+    )
+    output_file.unlink()
+
+    newname = run_command("tags", wheelpath, "--python-tag", "py2.py3").strip()
+    assert newname == TESTWHEEL_NAME
+
+    newname = run_command("tags", "--remove", "--python-tag", "+py4", wheelpath).strip()
+    assert not wheelpath.exists()
+    assert newname == TESTWHEEL_NAME.replace("py2.py3", "py2.py3.py4")
+    output_file = wheelpath.parent / newname
+    output_file.unlink()
+
+
+def test_abi_tags(wheelpath: Path) -> None:
+    newname = run_command("tags", wheelpath, "--abi-tag", "cp33m").strip()
+    assert newname == TESTWHEEL_NAME.replace("none", "cp33m")
+    output_file = wheelpath.parent / newname
+    output_file.unlink()
+
+    newname = run_command("tags", wheelpath, "--abi-tag", "cp33m.abi3").strip()
+    assert newname == TESTWHEEL_NAME.replace("none", "abi3.cp33m")
+    output_file = wheelpath.parent / newname
+    output_file.unlink()
+
+    newname = run_command("tags", wheelpath, "--abi-tag", "none").strip()
+    assert newname == TESTWHEEL_NAME
+
+    newname = run_command(
+        "tags", wheelpath, "--remove", "--abi-tag", "+abi3.cp33m"
+    ).strip()
+    assert not wheelpath.exists()
+    assert newname == TESTWHEEL_NAME.replace("none", "abi3.cp33m.none")
+    output_file = wheelpath.parent / newname
+    output_file.unlink()
+
+
+def test_plat_tags(wheelpath: Path) -> None:
+    newname = run_command("tags", "--platform-tag", "linux_x86_64", wheelpath).strip()
+    assert newname == TESTWHEEL_NAME.replace("any", "linux_x86_64")
+    output_file = wheelpath.parent / newname
+    assert output_file.exists()
+    output_file.unlink()
+
+    newname = run_command(
+        "tags", "--platform-tag", "linux_x86_64.win32", wheelpath
+    ).strip()
+    assert newname == TESTWHEEL_NAME.replace("any", "linux_x86_64.win32")
+    output_file = wheelpath.parent / newname
+    assert output_file.exists()
+    output_file.unlink()
+
+    newname = run_command(
+        "tags", "--platform-tag", "+linux_x86_64.win32", wheelpath
+    ).strip()
+    assert newname == TESTWHEEL_NAME.replace("any", "any.linux_x86_64.win32")
+    output_file = wheelpath.parent / newname
+    assert output_file.exists()
+    output_file.unlink()
+
+    newname = run_command(
+        "tags", "--platform-tag", "+linux_x86_64.win32", wheelpath
+    ).strip()
+    assert newname == TESTWHEEL_NAME.replace("any", "any.linux_x86_64.win32")
+    output_file = wheelpath.parent / newname
+    assert output_file.exists()
+
+    newname2 = run_command("tags", "--platform-tag=-any", output_file).strip()
+    output_file.unlink()
+
+    assert newname2 == TESTWHEEL_NAME.replace("any", "linux_x86_64.win32")
+    output_file2 = wheelpath.parent / newname2
+    assert output_file2.exists()
+    output_file2.unlink()
+
+    newname = run_command("tags", "--platform-tag", "any", wheelpath).strip()
+    assert newname == TESTWHEEL_NAME
+
+
+def test_build_tag(wheelpath: Path) -> None:
+    newname = run_command("tags", "--build", "1bah", wheelpath).strip()
+    assert newname == TESTWHEEL_NAME.replace("-py2", "-1bah-py2")
+    output_file = wheelpath.parent / newname
+    assert output_file.exists()
+
+    newname = run_command("tags", "--build", "", wheelpath).strip()
+    assert newname == TESTWHEEL_NAME
+    output_file.unlink()
+
+
+@pytest.mark.parametrize(
+    "build_tag, error",
+    [
+        pytest.param("foo", "build tag must begin with a digit", id="digitstart"),
+        pytest.param("1-f", "invalid character ('-') in build tag", id="hyphen"),
+    ],
+)
+def test_invalid_build_tag(wheelpath: Path, build_tag: str, error: str) -> None:
+    with pytest.raises(CalledProcessError) as exc_info:
+        run_command("tags", "--build", build_tag, wheelpath, catch_systemexit=False)
+
+    exc = exc_info.value
+    assert exc.returncode == 2
+    assert f"error: argument --build: {error}" in exc.stderr
+
+
+def test_multi_tags(wheelpath: Path) -> None:
+    newname = run_command(
+        "tags",
+        "--platform-tag",
+        "linux_x86_64",
+        "--python-tag",
+        "+py4",
+        "--build",
+        "1",
+        wheelpath,
+    ).strip()
+    assert newname == "test-1.0-1-py2.py3.py4-none-linux_x86_64.whl"
+
+    output_file = wheelpath.parent / newname
+    assert output_file.exists()
+    with WheelFile(output_file) as f:
+        output = f.read(f.dist_info_path + "/WHEEL")
+
+    assert output == (
+        b"Wheel-Version: 1.0\n"
+        b"Generator: bdist_wheel (0.30.0)\nRoot-Is-Purelib: false\n"
+        b"Tag: py2-none-linux_x86_64\n"
+        b"Tag: py3-none-linux_x86_64\n"
+        b"Tag: py4-none-linux_x86_64\n"
+        b"Build: 1\n\n"
+    )
+    output_file.unlink()
+
+
+def test_tags_command(wheelpath: Path) -> None:
+    newname = run_command(
+        "tags",
+        "--python-tag",
+        "py3",
+        "--abi-tag",
+        "cp33m",
+        "--platform-tag",
+        "linux_x86_64",
+        "--build",
+        "7",
+        wheelpath,
+    ).strip()
+    assert "test-1.0-7-py3-cp33m-linux_x86_64.whl" == newname
+    output_file = wheelpath.parent / newname
+    output_file.unlink()
+
+
+def test_tags_command_del(wheelpath: Path) -> None:
+    newname = run_command(
+        "tags",
+        "--python-tag",
+        "+py4",
+        "--abi-tag",
+        "cp33m",
+        "--platform-tag",
+        "linux_x86_64",
+        "--remove",
+        wheelpath,
+    ).strip()
+    assert "test-1.0-py2.py3.py4-cp33m-linux_x86_64.whl" == newname
+    output_file = wheelpath.parent / newname
+    output_file.unlink()
+
+
+def test_permission_bits(wheelpath: Path) -> None:
+    newname = run_command("tags", "--python-tag", "+py4", wheelpath).strip()
+    assert "test-1.0-py2.py3.py4-none-any.whl" == newname
+    output_file = wheelpath.parent / newname
+
+    with ZipFile(output_file, "r") as outf, ZipFile(wheelpath, "r") as inf:
+        for member in inf.namelist():
+            member_info = inf.getinfo(member)
+            if member_info.is_dir():
+                continue
+
+            if member_info.filename.endswith("/RECORD"):
+                continue
+
+            out_attr = outf.getinfo(member).external_attr
+            inf_attr = member_info.external_attr
+            assert out_attr == inf_attr, (
+                f"{member} 0x{out_attr:012o} != 0x{inf_attr:012o}"
+            )
+
+    output_file.unlink()
diff --git a/tests/commands/test_unpack.py b/tests/commands/test_unpack.py
new file mode 100644
index 000000000..38cfb4139
--- /dev/null
+++ b/tests/commands/test_unpack.py
@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+import platform
+import stat
+from pathlib import Path
+
+import pytest
+from pytest import TempPathFactory
+
+from wheel.wheelfile import WheelFile
+
+from .util import run_command
+
+
+def test_unpack(tmp_path_factory: TempPathFactory) -> None:
+    wheel_path = tmp_path_factory.mktemp("build") / "test-1.0-py3-none-any.whl"
+    with WheelFile(wheel_path, "w") as wf:
+        wf.writestr(
+            "test-1.0.dist-info/METADATA",
+            "Metadata-Version: 2.4\nName: test\nVersion: 1.0\n",
+        )
+        wf.writestr("test-1.0/package/__init__.py", "")
+        wf.writestr("test-1.0/package/module.py", "print('hello world')\n")
+
+    extract_path = tmp_path_factory.mktemp("extract")
+    run_command("unpack", "--dest", extract_path, wheel_path)
+
+    extract_path /= "test-1.0"
+    assert extract_path.joinpath("test-1.0.dist-info", "METADATA").read_text(
+        "utf-8"
+    ) == ("Metadata-Version: 2.4\nName: test\nVersion: 1.0\n")
+    assert (
+        extract_path.joinpath("test-1.0", "package", "__init__.py").read_text("utf-8")
+        == ""
+    )
+    assert extract_path.joinpath("test-1.0", "package", "module.py").read_text(
+        "utf-8"
+    ) == ("print('hello world')\n")
+
+
+@pytest.mark.skipif(
+    platform.system() == "Windows", reason="Windows does not support the executable bit"
+)
+def test_unpack_executable_bit(tmp_path: Path) -> None:
+    wheel_path = tmp_path / "test-1.0-py3-none-any.whl"
+    script_path = tmp_path / "script"
+    script_path.write_bytes(b"test script")
+    script_path.chmod(0o755)
+    with WheelFile(wheel_path, "w") as wf:
+        wf.write(str(script_path), "nested/script")
+
+    script_path.unlink()
+    script_path = tmp_path / "test-1.0" / "nested" / "script"
+    run_command("unpack", "--dest", tmp_path, wheel_path)
+    assert not script_path.is_dir()
+    assert stat.S_IMODE(script_path.stat().st_mode) == 0o755
+
+
+@pytest.mark.skipif(
+    platform.system() == "Windows", reason="Windows does not support chmod()"
+)
+def test_chmod_outside_unpack_tree(tmp_path_factory: TempPathFactory) -> None:
+    wheel_path = tmp_path_factory.mktemp("build") / "test-1.0-py3-none-any.whl"
+    with WheelFile(wheel_path, "w") as wf:
+        wf.writestr(
+            "test-1.0.dist-info/METADATA",
+            "Metadata-Version: 2.4\nName: test\nVersion: 1.0\n",
+        )
+        wf.writestr("../../system-file", b"malicious data")
+
+    extract_root_path = tmp_path_factory.mktemp("extract")
+    system_file = extract_root_path / "system-file"
+    extract_path = extract_root_path / "subdir"
+    system_file.write_bytes(b"important data")
+    system_file.chmod(0o755)
+    run_command("unpack", "--dest", extract_path, wheel_path)
+
+    assert system_file.read_bytes() == b"important data"
+    assert stat.S_IMODE(system_file.stat().st_mode) == 0o755
diff --git a/tests/commands/util.py b/tests/commands/util.py
new file mode 100644
index 000000000..5d139d945
--- /dev/null
+++ b/tests/commands/util.py
@@ -0,0 +1,43 @@
+from __future__ import annotations
+
+import sys
+from io import StringIO
+from os import PathLike
+from subprocess import CalledProcessError
+from unittest.mock import patch
+
+import pytest
+
+from wheel._commands import main
+
+
+def run_command(
+    command: str, *args: str | PathLike, catch_systemexit: bool = True
+) -> str:
+    returncode = 0
+    stdout = StringIO()
+    stderr = StringIO()
+    args = ("wheel", command) + tuple(str(arg) for arg in args)
+    with (
+        patch.object(sys, "argv", args),
+        patch.object(sys, "stdout", stdout),
+        patch.object(sys, "stderr", stderr),
+    ):
+        try:
+            main()
+        except SystemExit as exc:
+            if not catch_systemexit:
+                raise CalledProcessError(
+                    exc.code, args, stdout.getvalue(), stderr.getvalue()
+                ) from exc
+
+            returncode = exc.code
+
+    if returncode:
+        pytest.fail(
+            f"'wheel {command}' exited with return code {returncode}\n"
+            f"arguments: {args}\n"
+            f"error output:\n{stderr.getvalue()}"
+        )
+
+    return stdout.getvalue()
diff --git a/tests/conftest.py b/tests/conftest.py
deleted file mode 100644
index d5bd356f3..000000000
--- a/tests/conftest.py
+++ /dev/null
@@ -1,66 +0,0 @@
-"""
-pytest local configuration plug-in
-"""
-
-from __future__ import annotations
-
-import os.path
-import subprocess
-import sys
-import sysconfig
-
-import pytest
-
-
-@pytest.fixture(scope="session")
-def wheels_and_eggs(tmp_path_factory):
-    """Build wheels and eggs from test distributions."""
-    test_distributions = (
-        "complex-dist",
-        "simple.dist",
-        "headers.dist",
-        "commasinfilenames.dist",
-        "unicode.dist",
-    )
-
-    if sys.platform != "win32" and sysconfig.get_config_var("Py_GIL_DISABLED") != 1:
-        # ABI3 extensions don't really work on Windows
-        test_distributions += ("abi3extension.dist",)
-
-    pwd = os.path.abspath(os.curdir)
-    this_dir = os.path.dirname(__file__)
-    build_dir = tmp_path_factory.mktemp("build")
-    dist_dir = tmp_path_factory.mktemp("dist")
-    for dist in test_distributions:
-        os.chdir(os.path.join(this_dir, "testdata", dist))
-        subprocess.check_call(
-            [
-                sys.executable,
-                "setup.py",
-                "bdist_egg",
-                "-b",
-                str(build_dir),
-                "-d",
-                str(dist_dir),
-                "bdist_wheel",
-                "-b",
-                str(build_dir),
-                "-d",
-                str(dist_dir),
-            ]
-        )
-
-    os.chdir(pwd)
-    return sorted(
-        str(fname) for fname in dist_dir.iterdir() if fname.suffix in (".whl", ".egg")
-    )
-
-
-@pytest.fixture(scope="session")
-def wheel_paths(wheels_and_eggs):
-    return [fname for fname in wheels_and_eggs if fname.endswith(".whl")]
-
-
-@pytest.fixture(scope="session")
-def egg_paths(wheels_and_eggs):
-    return [fname for fname in wheels_and_eggs if fname.endswith(".egg")]
diff --git a/tests/test_bdist_wheel.py b/tests/test_bdist_wheel.py
index fcb2dfc4d..4a0ebc98b 100644
--- a/tests/test_bdist_wheel.py
+++ b/tests/test_bdist_wheel.py
@@ -1,452 +1,6 @@
-from __future__ import annotations
-
-import builtins
-import importlib
-import os.path
-import platform
-import shutil
-import stat
-import struct
-import subprocess
-import sys
-import sysconfig
-from inspect import cleandoc
-from unittest.mock import Mock
-from zipfile import ZipFile
-
 import pytest
-import setuptools
-
-from wheel._bdist_wheel import (
-    bdist_wheel,
-    get_abi_tag,
-    remove_readonly,
-    remove_readonly_exc,
-)
-from wheel.vendored.packaging import tags
-from wheel.wheelfile import WheelFile
-
-DEFAULT_FILES = {
-    "dummy_dist-1.0.dist-info/top_level.txt",
-    "dummy_dist-1.0.dist-info/METADATA",
-    "dummy_dist-1.0.dist-info/WHEEL",
-    "dummy_dist-1.0.dist-info/RECORD",
-}
-DEFAULT_LICENSE_FILES = {
-    "LICENSE",
-    "LICENSE.txt",
-    "LICENCE",
-    "LICENCE.txt",
-    "COPYING",
-    "COPYING.md",
-    "NOTICE",
-    "NOTICE.rst",
-    "AUTHORS",
-    "AUTHORS.txt",
-}
-OTHER_IGNORED_FILES = {
-    "LICENSE~",
-    "AUTHORS~",
-}
-SETUPPY_EXAMPLE = """\
-from setuptools import setup
-
-setup(
-    name='dummy_dist',
-    version='1.0',
-)
-"""
-
-
-@pytest.fixture
-def dummy_dist(tmp_path_factory):
-    basedir = tmp_path_factory.mktemp("dummy_dist")
-    basedir.joinpath("setup.py").write_text(SETUPPY_EXAMPLE, encoding="utf-8")
-    for fname in DEFAULT_LICENSE_FILES | OTHER_IGNORED_FILES:
-        basedir.joinpath(fname).write_text("", encoding="utf-8")
-
-    licensedir = basedir.joinpath("licenses")
-    licensedir.mkdir()
-    licensedir.joinpath("DUMMYFILE").write_text("", encoding="utf-8")
-    return basedir
-
-
-def test_no_scripts(wheel_paths):
-    """Make sure entry point scripts are not generated."""
-    path = next(path for path in wheel_paths if "complex_dist" in path)
-    for entry in ZipFile(path).infolist():
-        assert ".data/scripts/" not in entry.filename
-
-
-def test_unicode_record(wheel_paths):
-    path = next(path for path in wheel_paths if "unicode.dist" in path)
-    with ZipFile(path) as zf:
-        record = zf.read("unicode.dist-0.1.dist-info/RECORD")
-
-    assert "åäö_日本語.py".encode() in record
-
-
-UTF8_PKG_INFO = """\
-Metadata-Version: 2.1
-Name: helloworld
-Version: 42
-Author-email: "John X. Ãørçeč" , Γαμα קּ 東 
-
-
-UTF-8 描述 説明
-"""
-
-
-def test_preserve_unicode_metadata(monkeypatch, tmp_path):
-    monkeypatch.chdir(tmp_path)
-    egginfo = tmp_path / "dummy_dist.egg-info"
-    distinfo = tmp_path / "dummy_dist.dist-info"
-
-    egginfo.mkdir()
-    (egginfo / "PKG-INFO").write_text(UTF8_PKG_INFO, encoding="utf-8")
-    (egginfo / "dependency_links.txt").touch()
-
-    class simpler_bdist_wheel(bdist_wheel):
-        """Avoid messing with setuptools/distutils internals"""
-
-        def __init__(self):
-            pass
-
-        @property
-        def license_paths(self):
-            return []
-
-    cmd_obj = simpler_bdist_wheel()
-    cmd_obj.egg2dist(egginfo, distinfo)
-
-    metadata = (distinfo / "METADATA").read_text(encoding="utf-8")
-    assert 'Author-email: "John X. Ãørçeč"' in metadata
-    assert "Γαμα קּ 東 " in metadata
-    assert "UTF-8 描述 説明" in metadata
-
-
-def test_licenses_default(dummy_dist, monkeypatch, tmp_path):
-    monkeypatch.chdir(dummy_dist)
-    subprocess.check_call(
-        [sys.executable, "setup.py", "bdist_wheel", "-b", str(tmp_path), "--universal"]
-    )
-    with WheelFile("dist/dummy_dist-1.0-py2.py3-none-any.whl") as wf:
-        license_files = {
-            "dummy_dist-1.0.dist-info/" + fname for fname in DEFAULT_LICENSE_FILES
-        }
-        assert set(wf.namelist()) == DEFAULT_FILES | license_files
-
-
-def test_licenses_deprecated(dummy_dist, monkeypatch, tmp_path):
-    dummy_dist.joinpath("setup.cfg").write_text(
-        "[metadata]\nlicense_file=licenses/DUMMYFILE", encoding="utf-8"
-    )
-    monkeypatch.chdir(dummy_dist)
-    subprocess.check_call(
-        [sys.executable, "setup.py", "bdist_wheel", "-b", str(tmp_path), "--universal"]
-    )
-    with WheelFile("dist/dummy_dist-1.0-py2.py3-none-any.whl") as wf:
-        license_files = {"dummy_dist-1.0.dist-info/DUMMYFILE"}
-        assert set(wf.namelist()) == DEFAULT_FILES | license_files
-
-
-@pytest.mark.parametrize(
-    "config_file, config",
-    [
-        ("setup.cfg", "[metadata]\nlicense_files=licenses/*\n  LICENSE"),
-        ("setup.cfg", "[metadata]\nlicense_files=licenses/*, LICENSE"),
-        (
-            "setup.py",
-            SETUPPY_EXAMPLE.replace(
-                ")", "  license_files=['licenses/DUMMYFILE', 'LICENSE'])"
-            ),
-        ),
-    ],
-)
-def test_licenses_override(dummy_dist, monkeypatch, tmp_path, config_file, config):
-    dummy_dist.joinpath(config_file).write_text(config, encoding="utf-8")
-    monkeypatch.chdir(dummy_dist)
-    subprocess.check_call(
-        [sys.executable, "setup.py", "bdist_wheel", "-b", str(tmp_path), "--universal"]
-    )
-    with WheelFile("dist/dummy_dist-1.0-py2.py3-none-any.whl") as wf:
-        license_files = {
-            "dummy_dist-1.0.dist-info/" + fname for fname in {"DUMMYFILE", "LICENSE"}
-        }
-        assert set(wf.namelist()) == DEFAULT_FILES | license_files
-
-
-def test_licenses_disabled(dummy_dist, monkeypatch, tmp_path):
-    dummy_dist.joinpath("setup.cfg").write_text(
-        "[metadata]\nlicense_files=\n", encoding="utf-8"
-    )
-    monkeypatch.chdir(dummy_dist)
-    subprocess.check_call(
-        [sys.executable, "setup.py", "bdist_wheel", "-b", str(tmp_path), "--universal"]
-    )
-    with WheelFile("dist/dummy_dist-1.0-py2.py3-none-any.whl") as wf:
-        assert set(wf.namelist()) == DEFAULT_FILES
-
-
-def test_build_number(dummy_dist, monkeypatch, tmp_path):
-    monkeypatch.chdir(dummy_dist)
-    subprocess.check_call(
-        [
-            sys.executable,
-            "setup.py",
-            "bdist_wheel",
-            "-b",
-            str(tmp_path),
-            "--universal",
-            "--build-number=2",
-        ]
-    )
-    with WheelFile("dist/dummy_dist-1.0-2-py2.py3-none-any.whl") as wf:
-        filenames = set(wf.namelist())
-        assert "dummy_dist-1.0.dist-info/RECORD" in filenames
-        assert "dummy_dist-1.0.dist-info/METADATA" in filenames
-
-
-def test_limited_abi(monkeypatch, tmp_path):
-    """Test that building a binary wheel with the limited ABI works."""
-    this_dir = os.path.dirname(__file__)
-    source_dir = os.path.join(this_dir, "testdata", "extension.dist")
-    build_dir = tmp_path.joinpath("build")
-    dist_dir = tmp_path.joinpath("dist")
-    monkeypatch.chdir(source_dir)
-    subprocess.check_call(
-        [
-            sys.executable,
-            "setup.py",
-            "bdist_wheel",
-            "-b",
-            str(build_dir),
-            "-d",
-            str(dist_dir),
-        ]
-    )
-
-
-def test_build_from_readonly_tree(dummy_dist, monkeypatch, tmp_path):
-    basedir = str(tmp_path.joinpath("dummy"))
-    shutil.copytree(str(dummy_dist), basedir)
-    monkeypatch.chdir(basedir)
-
-    # Make the tree read-only
-    for root, _dirs, files in os.walk(basedir):
-        for fname in files:
-            os.chmod(os.path.join(root, fname), stat.S_IREAD)
-
-    subprocess.check_call([sys.executable, "setup.py", "bdist_wheel"])
-
-
-@pytest.mark.parametrize(
-    "option, compress_type",
-    list(bdist_wheel.supported_compressions.items()),
-    ids=list(bdist_wheel.supported_compressions),
-)
-def test_compression(dummy_dist, monkeypatch, tmp_path, option, compress_type):
-    monkeypatch.chdir(dummy_dist)
-    subprocess.check_call(
-        [
-            sys.executable,
-            "setup.py",
-            "bdist_wheel",
-            "-b",
-            str(tmp_path),
-            "--universal",
-            f"--compression={option}",
-        ]
-    )
-    with WheelFile("dist/dummy_dist-1.0-py2.py3-none-any.whl") as wf:
-        filenames = set(wf.namelist())
-        assert "dummy_dist-1.0.dist-info/RECORD" in filenames
-        assert "dummy_dist-1.0.dist-info/METADATA" in filenames
-        for zinfo in wf.filelist:
-            assert zinfo.compress_type == compress_type
-
-
-def test_wheelfile_line_endings(wheel_paths):
-    for path in wheel_paths:
-        with WheelFile(path) as wf:
-            wheelfile = next(fn for fn in wf.filelist if fn.filename.endswith("WHEEL"))
-            wheelfile_contents = wf.read(wheelfile)
-            assert b"\r" not in wheelfile_contents
-
-
-def test_unix_epoch_timestamps(dummy_dist, monkeypatch, tmp_path):
-    monkeypatch.setenv("SOURCE_DATE_EPOCH", "0")
-    monkeypatch.chdir(dummy_dist)
-    subprocess.check_call(
-        [
-            sys.executable,
-            "setup.py",
-            "bdist_wheel",
-            "-b",
-            str(tmp_path),
-            "--universal",
-            "--build-number=2",
-        ]
-    )
-
-
-def test_get_abi_tag_windows(monkeypatch):
-    monkeypatch.setattr(tags, "interpreter_name", lambda: "cp")
-    monkeypatch.setattr(sysconfig, "get_config_var", lambda x: "cp313-win_amd64")
-    assert get_abi_tag() == "cp313"
-
-
-def test_get_abi_tag_pypy_old(monkeypatch):
-    monkeypatch.setattr(tags, "interpreter_name", lambda: "pp")
-    monkeypatch.setattr(sysconfig, "get_config_var", lambda x: "pypy36-pp73")
-    assert get_abi_tag() == "pypy36_pp73"
-
-
-def test_get_abi_tag_pypy_new(monkeypatch):
-    monkeypatch.setattr(sysconfig, "get_config_var", lambda x: "pypy37-pp73-darwin")
-    monkeypatch.setattr(tags, "interpreter_name", lambda: "pp")
-    assert get_abi_tag() == "pypy37_pp73"
-
-
-def test_get_abi_tag_graalpy(monkeypatch):
-    monkeypatch.setattr(
-        sysconfig, "get_config_var", lambda x: "graalpy231-310-native-x86_64-linux"
-    )
-    monkeypatch.setattr(tags, "interpreter_name", lambda: "graalpy")
-    assert get_abi_tag() == "graalpy231_310_native"
-
-
-def test_get_abi_tag_fallback(monkeypatch):
-    monkeypatch.setattr(sysconfig, "get_config_var", lambda x: "unknown-python-310")
-    monkeypatch.setattr(tags, "interpreter_name", lambda: "unknown-python")
-    assert get_abi_tag() == "unknown_python_310"
-
-
-def test_platform_with_space(dummy_dist, monkeypatch):
-    """Ensure building on platforms with a space in the name succeed."""
-    monkeypatch.chdir(dummy_dist)
-    subprocess.check_call(
-        [sys.executable, "setup.py", "bdist_wheel", "--plat-name", "isilon onefs"]
-    )
-
-
-def test_rmtree_readonly(monkeypatch, tmp_path):
-    """Verify onerr works as expected"""
-
-    bdist_dir = tmp_path / "with_readonly"
-    bdist_dir.mkdir()
-    some_file = bdist_dir.joinpath("file.txt")
-    some_file.touch()
-    some_file.chmod(stat.S_IREAD)
-
-    expected_count = 1 if sys.platform.startswith("win") else 0
-
-    if sys.version_info < (3, 12):
-        count_remove_readonly = Mock(side_effect=remove_readonly)
-        shutil.rmtree(bdist_dir, onerror=count_remove_readonly)
-        assert count_remove_readonly.call_count == expected_count
-    else:
-        count_remove_readonly_exc = Mock(side_effect=remove_readonly_exc)
-        shutil.rmtree(bdist_dir, onexc=count_remove_readonly_exc)
-        assert count_remove_readonly_exc.call_count == expected_count
-
-    assert not bdist_dir.is_dir()
-
-
-def test_data_dir_with_tag_build(monkeypatch, tmp_path):
-    """
-    Setuptools allow authors to set PEP 440's local version segments
-    using ``egg_info.tag_build``. This should be reflected not only in the
-    ``.whl`` file name, but also in the ``.dist-info`` and ``.data`` dirs.
-    See pypa/setuptools#3997.
-    """
-    monkeypatch.chdir(tmp_path)
-    files = {
-        "setup.py": """
-            from setuptools import setup
-            setup(headers=["hello.h"])
-            """,
-        "setup.cfg": """
-            [metadata]
-            name = test
-            version = 1.0
-
-            [options.data_files]
-            hello/world = file.txt
-
-            [egg_info]
-            tag_build = +what
-            tag_date = 0
-            """,
-        "file.txt": "",
-        "hello.h": "",
-    }
-    for file, content in files.items():
-        with open(file, "w", encoding="utf-8") as fh:
-            fh.write(cleandoc(content))
-
-    subprocess.check_call([sys.executable, "setup.py", "bdist_wheel"])
-
-    # Ensure .whl, .dist-info and .data contain the local segment
-    wheel_path = "dist/test-1.0+what-py3-none-any.whl"
-    assert os.path.exists(wheel_path)
-    entries = set(ZipFile(wheel_path).namelist())
-    for expected in (
-        "test-1.0+what.data/headers/hello.h",
-        "test-1.0+what.data/data/hello/world/file.txt",
-        "test-1.0+what.dist-info/METADATA",
-        "test-1.0+what.dist-info/WHEEL",
-    ):
-        assert expected in entries
-
-    for not_expected in (
-        "test.data/headers/hello.h",
-        "test-1.0.data/data/hello/world/file.txt",
-        "test.dist-info/METADATA",
-        "test-1.0.dist-info/WHEEL",
-    ):
-        assert not_expected not in entries
-
-
-@pytest.mark.parametrize(
-    "reported,expected",
-    [("linux-x86_64", "linux_i686"), ("linux-aarch64", "linux_armv7l")],
-)
-@pytest.mark.skipif(
-    platform.system() != "Linux", reason="Only makes sense to test on Linux"
-)
-def test_platform_linux32(reported, expected, monkeypatch):
-    monkeypatch.setattr(struct, "calcsize", lambda x: 4)
-    dist = setuptools.Distribution()
-    cmd = bdist_wheel(dist)
-    cmd.plat_name = reported
-    cmd.root_is_pure = False
-    _, _, actual = cmd.get_tag()
-    assert actual == expected
-
-
-def test_no_ctypes(monkeypatch) -> None:
-    def _fake_import(name: str, *args, **kwargs):
-        if name == "ctypes":
-            raise ModuleNotFoundError(f"No module named {name}")
-
-        return importlib.__import__(name, *args, **kwargs)
-
-    # Install an importer shim that refuses to load ctypes
-    monkeypatch.setattr(builtins, "__import__", _fake_import)
-
-    # Unload all wheel modules
-    for module in list(sys.modules):
-        if module.startswith("wheel"):
-            monkeypatch.delitem(sys.modules, module)
-
-    from wheel import _bdist_wheel
-
-    assert _bdist_wheel
-
 
-def test_deprecated_import() -> None:
-    with pytest.warns(DeprecationWarning):
-        from wheel import bdist_wheel
 
-    assert issubclass(bdist_wheel.bdist_wheel, setuptools.Command)
+def test_import_bdist_wheel() -> None:
+    with pytest.warns(FutureWarning, match="no longer the canonical location"):
+        from wheel.bdist_wheel import bdist_wheel  # noqa: F401
diff --git a/tests/test_macosx_libfile.py b/tests/test_macosx_libfile.py
deleted file mode 100644
index 6e5a8165b..000000000
--- a/tests/test_macosx_libfile.py
+++ /dev/null
@@ -1,226 +0,0 @@
-from __future__ import annotations
-
-import os
-import struct
-import sysconfig
-
-import pytest
-
-from wheel._bdist_wheel import get_platform
-from wheel.macosx_libfile import extract_macosx_min_system_version
-
-
-def test_read_from_dylib():
-    dirname = os.path.dirname(__file__)
-    dylib_dir = os.path.join(dirname, "testdata", "macosx_minimal_system_version")
-    versions = [
-        ("test_lib_10_6_fat.dylib", "10.6.0"),
-        ("test_lib_10_10_fat.dylib", "10.10.0"),
-        ("test_lib_10_14_fat.dylib", "10.14.0"),
-        ("test_lib_10_6.dylib", "10.6.0"),
-        ("test_lib_10_10.dylib", "10.10.0"),
-        ("test_lib_10_14.dylib", "10.14.0"),
-        ("test_lib_10_6_386.dylib", "10.6.0"),
-        ("test_lib_10_10_386.dylib", "10.10.0"),
-        ("test_lib_10_14_386.dylib", "10.14.0"),
-        ("test_lib_multiple_fat.dylib", "10.14.0"),
-        ("test_lib_10_10_10.dylib", "10.10.10"),
-        ("test_lib_11.dylib", "11.0.0"),
-        ("test_lib_10_9_universal2.dylib", "10.9.0"),
-    ]
-    for file_name, ver in versions:
-        extracted = extract_macosx_min_system_version(
-            os.path.join(dylib_dir, file_name)
-        )
-        str_ver = ".".join([str(x) for x in extracted])
-        assert str_ver == ver
-    assert (
-        extract_macosx_min_system_version(os.path.join(dylib_dir, "test_lib.c")) is None
-    )
-    assert (
-        extract_macosx_min_system_version(os.path.join(dylib_dir, "libb.dylib")) is None
-    )
-
-
-def return_factory(return_val):
-    def fun(*args, **kwargs):
-        return return_val
-
-    return fun
-
-
-class TestGetPlatformMacosx:
-    def test_simple(self, monkeypatch):
-        dirname = os.path.dirname(__file__)
-        dylib_dir = os.path.join(dirname, "testdata", "macosx_minimal_system_version")
-        monkeypatch.setattr(
-            sysconfig, "get_platform", return_factory("macosx-11.0-x86_64")
-        )
-        assert get_platform(dylib_dir) == "macosx_11_0_x86_64"
-
-    def test_version_bump(self, monkeypatch, capsys):
-        dirname = os.path.dirname(__file__)
-        dylib_dir = os.path.join(dirname, "testdata", "macosx_minimal_system_version")
-        monkeypatch.setattr(
-            sysconfig, "get_platform", return_factory("macosx-10.9-x86_64")
-        )
-        assert get_platform(dylib_dir) == "macosx_11_0_x86_64"
-        captured = capsys.readouterr()
-        assert "[WARNING] This wheel needs a higher macOS version than" in captured.err
-
-    def test_information_about_problematic_files_python_version(
-        self, monkeypatch, capsys
-    ):
-        dirname = os.path.dirname(__file__)
-        dylib_dir = os.path.join(dirname, "testdata", "macosx_minimal_system_version")
-        monkeypatch.setattr(
-            sysconfig, "get_platform", return_factory("macosx-10.9-x86_64")
-        )
-        monkeypatch.setattr(
-            os,
-            "walk",
-            return_factory(
-                [(dylib_dir, [], ["test_lib_10_6.dylib", "test_lib_10_10_fat.dylib"])]
-            ),
-        )
-        assert get_platform(dylib_dir) == "macosx_10_10_x86_64"
-        captured = capsys.readouterr()
-        assert "[WARNING] This wheel needs a higher macOS version than" in captured.err
-        assert (
-            "the version your Python interpreter is compiled against." in captured.err
-        )
-        assert "test_lib_10_10_fat.dylib" in captured.err
-
-    def test_information_about_problematic_files_env_variable(
-        self, monkeypatch, capsys
-    ):
-        dirname = os.path.dirname(__file__)
-        dylib_dir = os.path.join(dirname, "testdata", "macosx_minimal_system_version")
-        monkeypatch.setattr(
-            sysconfig, "get_platform", return_factory("macosx-10.9-x86_64")
-        )
-        monkeypatch.setenv("MACOSX_DEPLOYMENT_TARGET", "10.8")
-        monkeypatch.setattr(
-            os,
-            "walk",
-            return_factory(
-                [(dylib_dir, [], ["test_lib_10_6.dylib", "test_lib_10_10_fat.dylib"])]
-            ),
-        )
-        assert get_platform(dylib_dir) == "macosx_10_10_x86_64"
-        captured = capsys.readouterr()
-        assert "[WARNING] This wheel needs a higher macOS version than" in captured.err
-        assert "is set in MACOSX_DEPLOYMENT_TARGET variable." in captured.err
-        assert "test_lib_10_10_fat.dylib" in captured.err
-
-    def test_bump_platform_tag_by_env_variable(self, monkeypatch, capsys):
-        dirname = os.path.dirname(__file__)
-        dylib_dir = os.path.join(dirname, "testdata", "macosx_minimal_system_version")
-        monkeypatch.setattr(
-            sysconfig, "get_platform", return_factory("macosx-10.9-x86_64")
-        )
-        monkeypatch.setattr(
-            os,
-            "walk",
-            return_factory(
-                [(dylib_dir, [], ["test_lib_10_6.dylib", "test_lib_10_6_fat.dylib"])]
-            ),
-        )
-        assert get_platform(dylib_dir) == "macosx_10_9_x86_64"
-        monkeypatch.setenv("MACOSX_DEPLOYMENT_TARGET", "10.10")
-        assert get_platform(dylib_dir) == "macosx_10_10_x86_64"
-        captured = capsys.readouterr()
-        assert captured.err == ""
-
-    def test_bugfix_release_platform_tag(self, monkeypatch, capsys):
-        dirname = os.path.dirname(__file__)
-        dylib_dir = os.path.join(dirname, "testdata", "macosx_minimal_system_version")
-        monkeypatch.setattr(
-            sysconfig, "get_platform", return_factory("macosx-10.9-x86_64")
-        )
-        monkeypatch.setattr(
-            os,
-            "walk",
-            return_factory(
-                [
-                    (
-                        dylib_dir,
-                        [],
-                        [
-                            "test_lib_10_6.dylib",
-                            "test_lib_10_6_fat.dylib",
-                            "test_lib_10_10_10.dylib",
-                        ],
-                    )
-                ]
-            ),
-        )
-        assert get_platform(dylib_dir) == "macosx_10_10_x86_64"
-        captured = capsys.readouterr()
-        assert "This wheel needs a higher macOS version than" in captured.err
-        monkeypatch.setenv("MACOSX_DEPLOYMENT_TARGET", "10.9")
-        assert get_platform(dylib_dir) == "macosx_10_10_x86_64"
-        captured = capsys.readouterr()
-        assert "This wheel needs a higher macOS version than" in captured.err
-
-    def test_warning_on_to_low_env_variable(self, monkeypatch, capsys):
-        dirname = os.path.dirname(__file__)
-        dylib_dir = os.path.join(dirname, "testdata", "macosx_minimal_system_version")
-        monkeypatch.setattr(
-            sysconfig, "get_platform", return_factory("macosx-10.9-x86_64")
-        )
-        monkeypatch.setenv("MACOSX_DEPLOYMENT_TARGET", "10.8")
-        monkeypatch.setattr(
-            os,
-            "walk",
-            return_factory(
-                [(dylib_dir, [], ["test_lib_10_6.dylib", "test_lib_10_6_fat.dylib"])]
-            ),
-        )
-        assert get_platform(dylib_dir) == "macosx_10_9_x86_64"
-        captured = capsys.readouterr()
-        assert (
-            "MACOSX_DEPLOYMENT_TARGET is set to a lower value (10.8) than the"
-            in captured.err
-        )
-
-    def test_get_platform_bigsur_env(self, monkeypatch):
-        dirname = os.path.dirname(__file__)
-        dylib_dir = os.path.join(dirname, "testdata", "macosx_minimal_system_version")
-        monkeypatch.setattr(
-            sysconfig, "get_platform", return_factory("macosx-10.9-x86_64")
-        )
-        monkeypatch.setenv("MACOSX_DEPLOYMENT_TARGET", "11")
-        monkeypatch.setattr(
-            os,
-            "walk",
-            return_factory(
-                [(dylib_dir, [], ["test_lib_10_6.dylib", "test_lib_10_10_fat.dylib"])]
-            ),
-        )
-        assert get_platform(dylib_dir) == "macosx_11_0_x86_64"
-
-    def test_get_platform_bigsur_platform(self, monkeypatch):
-        dirname = os.path.dirname(__file__)
-        dylib_dir = os.path.join(dirname, "testdata", "macosx_minimal_system_version")
-        monkeypatch.setattr(
-            sysconfig, "get_platform", return_factory("macosx-11-x86_64")
-        )
-        monkeypatch.setattr(
-            os,
-            "walk",
-            return_factory(
-                [(dylib_dir, [], ["test_lib_10_6.dylib", "test_lib_10_10_fat.dylib"])]
-            ),
-        )
-        assert get_platform(dylib_dir) == "macosx_11_0_x86_64"
-
-
-@pytest.mark.parametrize(
-    "reported,expected",
-    [("linux-x86_64", "linux_i686"), ("linux-aarch64", "linux_armv7l")],
-)
-def test_get_platform_linux32(reported, expected, monkeypatch):
-    monkeypatch.setattr(sysconfig, "get_platform", return_factory(reported))
-    monkeypatch.setattr(struct, "calcsize", lambda x: 4)
-    assert get_platform(None) == expected
diff --git a/tests/test_metadata.py b/tests/test_metadata.py
index ff7e7ccd7..364d95068 100644
--- a/tests/test_metadata.py
+++ b/tests/test_metadata.py
@@ -1,16 +1,20 @@
 from __future__ import annotations
 
-from wheel.metadata import pkginfo_to_metadata
+from pathlib import Path
 
+import pytest
 
-def test_pkginfo_to_metadata(tmp_path):
+from wheel._metadata import pkginfo_to_metadata
+
+
+def test_pkginfo_to_metadata(tmp_path: Path) -> None:
     expected_metadata = [
         ("Metadata-Version", "2.1"),
         ("Name", "spam"),
         ("Version", "0.1"),
-        ("Requires-Dist", "pip@ https://github.com/pypa/pip/archive/1.3.1.zip"),
+        ("Requires-Dist", "pip @ https://github.com/pypa/pip/archive/1.3.1.zip"),
         ("Requires-Dist", 'pywin32; sys_platform == "win32"'),
-        ("Requires-Dist", 'foo@ http://host/foo.zip ; sys_platform == "win32"'),
+        ("Requires-Dist", 'foo @ http://host/foo.zip ; sys_platform == "win32"'),
         ("Provides-Extra", "signatures"),
         (
             "Requires-Dist",
@@ -18,7 +22,7 @@ def test_pkginfo_to_metadata(tmp_path):
         ),
         ("Provides-Extra", "empty_extra"),
         ("Provides-Extra", "extra"),
-        ("Requires-Dist", 'bar@ http://host/bar.zip ; extra == "extra"'),
+        ("Requires-Dist", 'bar @ http://host/bar.zip ; extra == "extra"'),
         ("Provides-Extra", "faster-signatures"),
         ("Requires-Dist", 'ed25519ll; extra == "faster-signatures"'),
         ("Provides-Extra", "rest"),
@@ -58,7 +62,7 @@ def test_pkginfo_to_metadata(tmp_path):
 
 [:sys_platform=="win32"]
 pywin32
-foo @http://host/foo.zip
+foo @ http://host/foo.zip
 
 [faster-signatures]
 ed25519ll
@@ -83,3 +87,10 @@ def test_pkginfo_to_metadata(tmp_path):
         egg_info_path=str(egg_info_dir), pkginfo_path=str(pkg_info)
     )
     assert message.items() == expected_metadata
+
+
+def test_metadata_deprecated() -> None:
+    with pytest.warns(DeprecationWarning, match="has been made private"):
+        from wheel import metadata
+
+        assert hasattr(metadata, "pkginfo_to_metadata")
diff --git a/tests/test_sdist.py b/tests/test_sdist.py
deleted file mode 100644
index cc253b720..000000000
--- a/tests/test_sdist.py
+++ /dev/null
@@ -1,50 +0,0 @@
-import subprocess
-import sys
-import tarfile
-from pathlib import Path
-
-import pytest
-
-pytest.importorskip("flit")
-pytest.importorskip("build")
-
-# This test must be run from the source directory - okay to skip if not
-DIR = Path(__file__).parent.resolve()
-MAIN_DIR = DIR.parent
-
-
-def test_compare_sdists(monkeypatch, tmp_path):
-    monkeypatch.chdir(MAIN_DIR)
-
-    sdist_build_dir = tmp_path / "bdir"
-
-    subprocess.run(
-        [
-            sys.executable,
-            "-m",
-            "build",
-            "--sdist",
-            "--no-isolation",
-            f"--outdir={sdist_build_dir}",
-        ],
-        check=True,
-    )
-
-    (sdist_build,) = sdist_build_dir.glob("*.tar.gz")
-
-    # Flit doesn't allow targeting directories, as far as I can tell
-    process = subprocess.run(
-        [sys.executable, "-m", "flit", "build", "--format=sdist"],
-        stderr=subprocess.PIPE,
-    )
-    if process.returncode != 0:
-        pytest.fail(process.stderr.decode("utf-8"))
-
-    (sdist_flit,) = Path("dist").glob("*.tar.gz")
-
-    out = [set(), set()]
-    for i, sdist in enumerate([sdist_build, sdist_flit]):
-        with tarfile.open(str(sdist), "r:gz") as tar:
-            out[i] = set(tar.getnames())
-
-    assert out[0] == (out[1] - {"setup.py"})
diff --git a/tests/test_tagopt.py b/tests/test_tagopt.py
deleted file mode 100644
index 5335af44a..000000000
--- a/tests/test_tagopt.py
+++ /dev/null
@@ -1,221 +0,0 @@
-"""
-Tests for the bdist_wheel tag options (--python-tag, --universal, and
---plat-name)
-"""
-
-from __future__ import annotations
-
-import subprocess
-import sys
-
-import pytest
-
-SETUP_PY = """\
-from setuptools import setup, Extension
-
-setup(
-    name="Test",
-    version="1.0",
-    author_email="author@example.com",
-    py_modules=["test"],
-    {ext_modules}
-)
-"""
-
-EXT_MODULES = "ext_modules=[Extension('_test', sources=['test.c'])],"
-
-
-@pytest.fixture
-def temp_pkg(request, tmp_path):
-    tmp_path.joinpath("test.py").write_text('print("Hello, world")', encoding="utf-8")
-
-    ext = getattr(request, "param", [False, ""])
-    if ext[0]:
-        # if ext[1] is not '', it will write a bad header and fail to compile
-        tmp_path.joinpath("test.c").write_text(
-            f"#include ", encoding="utf-8"
-        )
-        setup_py = SETUP_PY.format(ext_modules=EXT_MODULES)
-    else:
-        setup_py = SETUP_PY.format(ext_modules="")
-
-    tmp_path.joinpath("setup.py").write_text(setup_py, encoding="utf-8")
-    if ext[0]:
-        try:
-            subprocess.check_call(
-                [sys.executable, "setup.py", "build_ext"], cwd=str(tmp_path)
-            )
-        except subprocess.CalledProcessError:
-            pytest.skip("Cannot compile C extensions")
-    return tmp_path
-
-
-@pytest.mark.parametrize("temp_pkg", [[True, "xxx"]], indirect=["temp_pkg"])
-def test_nocompile_skips(temp_pkg):
-    assert False  # noqa: B011 - should have skipped with a "Cannot compile" message
-
-
-def test_default_tag(temp_pkg):
-    subprocess.check_call(
-        [sys.executable, "setup.py", "bdist_wheel"], cwd=str(temp_pkg)
-    )
-    dist_dir = temp_pkg.joinpath("dist")
-    assert dist_dir.is_dir()
-    wheels = list(dist_dir.iterdir())
-    assert len(wheels) == 1
-    assert wheels[0].name == f"Test-1.0-py{sys.version_info[0]}-none-any.whl"
-    assert wheels[0].suffix == ".whl"
-
-
-def test_build_number(temp_pkg):
-    subprocess.check_call(
-        [sys.executable, "setup.py", "bdist_wheel", "--build-number=1"],
-        cwd=str(temp_pkg),
-    )
-    dist_dir = temp_pkg.joinpath("dist")
-    assert dist_dir.is_dir()
-    wheels = list(dist_dir.iterdir())
-    assert len(wheels) == 1
-    assert wheels[0].name == f"Test-1.0-1-py{sys.version_info[0]}-none-any.whl"
-    assert wheels[0].suffix == ".whl"
-
-
-def test_explicit_tag(temp_pkg):
-    subprocess.check_call(
-        [sys.executable, "setup.py", "bdist_wheel", "--python-tag=py32"],
-        cwd=str(temp_pkg),
-    )
-    dist_dir = temp_pkg.joinpath("dist")
-    assert dist_dir.is_dir()
-    wheels = list(dist_dir.iterdir())
-    assert len(wheels) == 1
-    assert wheels[0].name.startswith("Test-1.0-py32-")
-    assert wheels[0].suffix == ".whl"
-
-
-def test_universal_tag(temp_pkg):
-    subprocess.check_call(
-        [sys.executable, "setup.py", "bdist_wheel", "--universal"], cwd=str(temp_pkg)
-    )
-    dist_dir = temp_pkg.joinpath("dist")
-    assert dist_dir.is_dir()
-    wheels = list(dist_dir.iterdir())
-    assert len(wheels) == 1
-    assert wheels[0].name.startswith("Test-1.0-py2.py3-")
-    assert wheels[0].suffix == ".whl"
-
-
-def test_universal_beats_explicit_tag(temp_pkg):
-    subprocess.check_call(
-        [sys.executable, "setup.py", "bdist_wheel", "--universal", "--python-tag=py32"],
-        cwd=str(temp_pkg),
-    )
-    dist_dir = temp_pkg.joinpath("dist")
-    assert dist_dir.is_dir()
-    wheels = list(dist_dir.iterdir())
-    assert len(wheels) == 1
-    assert wheels[0].name.startswith("Test-1.0-py2.py3-")
-    assert wheels[0].suffix == ".whl"
-
-
-def test_universal_in_setup_cfg(temp_pkg):
-    temp_pkg.joinpath("setup.cfg").write_text(
-        "[bdist_wheel]\nuniversal=1", encoding="utf-8"
-    )
-    subprocess.check_call(
-        [sys.executable, "setup.py", "bdist_wheel"], cwd=str(temp_pkg)
-    )
-    dist_dir = temp_pkg.joinpath("dist")
-    assert dist_dir.is_dir()
-    wheels = list(dist_dir.iterdir())
-    assert len(wheels) == 1
-    assert wheels[0].name.startswith("Test-1.0-py2.py3-")
-    assert wheels[0].suffix == ".whl"
-
-
-def test_pythontag_in_setup_cfg(temp_pkg):
-    temp_pkg.joinpath("setup.cfg").write_text(
-        "[bdist_wheel]\npython_tag=py32", encoding="utf-8"
-    )
-    subprocess.check_call(
-        [sys.executable, "setup.py", "bdist_wheel"], cwd=str(temp_pkg)
-    )
-    dist_dir = temp_pkg.joinpath("dist")
-    assert dist_dir.is_dir()
-    wheels = list(dist_dir.iterdir())
-    assert len(wheels) == 1
-    assert wheels[0].name.startswith("Test-1.0-py32-")
-    assert wheels[0].suffix == ".whl"
-
-
-def test_legacy_wheel_section_in_setup_cfg(temp_pkg):
-    temp_pkg.joinpath("setup.cfg").write_text("[wheel]\nuniversal=1", encoding="utf-8")
-    subprocess.check_call(
-        [sys.executable, "setup.py", "bdist_wheel"], cwd=str(temp_pkg)
-    )
-    dist_dir = temp_pkg.joinpath("dist")
-    assert dist_dir.is_dir()
-    wheels = list(dist_dir.iterdir())
-    assert len(wheels) == 1
-    assert wheels[0].name.startswith("Test-1.0-py2.py3-")
-    assert wheels[0].suffix == ".whl"
-
-
-def test_plat_name_purepy(temp_pkg):
-    subprocess.check_call(
-        [sys.executable, "setup.py", "bdist_wheel", "--plat-name=testplat.pure"],
-        cwd=str(temp_pkg),
-    )
-    dist_dir = temp_pkg.joinpath("dist")
-    assert dist_dir.is_dir()
-    wheels = list(dist_dir.iterdir())
-    assert len(wheels) == 1
-    assert wheels[0].name.endswith("-testplat_pure.whl")
-    assert wheels[0].suffix == ".whl"
-
-
-@pytest.mark.parametrize("temp_pkg", [[True, ""]], indirect=["temp_pkg"])
-def test_plat_name_ext(temp_pkg):
-    subprocess.check_call(
-        [sys.executable, "setup.py", "bdist_wheel", "--plat-name=testplat.arch"],
-        cwd=str(temp_pkg),
-    )
-
-    dist_dir = temp_pkg.joinpath("dist")
-    assert dist_dir.is_dir()
-    wheels = list(dist_dir.iterdir())
-    assert len(wheels) == 1
-    assert wheels[0].name.endswith("-testplat_arch.whl")
-    assert wheels[0].suffix == ".whl"
-
-
-def test_plat_name_purepy_in_setupcfg(temp_pkg):
-    temp_pkg.joinpath("setup.cfg").write_text(
-        "[bdist_wheel]\nplat_name=testplat.pure", encoding="utf-8"
-    )
-    subprocess.check_call(
-        [sys.executable, "setup.py", "bdist_wheel"], cwd=str(temp_pkg)
-    )
-    dist_dir = temp_pkg.joinpath("dist")
-    assert dist_dir.is_dir()
-    wheels = list(dist_dir.iterdir())
-    assert len(wheels) == 1
-    assert wheels[0].name.endswith("-testplat_pure.whl")
-    assert wheels[0].suffix == ".whl"
-
-
-@pytest.mark.parametrize("temp_pkg", [[True, ""]], indirect=["temp_pkg"])
-def test_plat_name_ext_in_setupcfg(temp_pkg):
-    temp_pkg.joinpath("setup.cfg").write_text(
-        "[bdist_wheel]\nplat_name=testplat.arch", encoding="utf-8"
-    )
-    subprocess.check_call(
-        [sys.executable, "setup.py", "bdist_wheel"], cwd=str(temp_pkg)
-    )
-
-    dist_dir = temp_pkg.joinpath("dist")
-    assert dist_dir.is_dir()
-    wheels = list(dist_dir.iterdir())
-    assert len(wheels) == 1
-    assert wheels[0].name.endswith("-testplat_arch.whl")
-    assert wheels[0].suffix == ".whl"
diff --git a/tests/test_wheelfile.py b/tests/test_wheelfile.py
index b58782144..d7fa45bcb 100644
--- a/tests/test_wheelfile.py
+++ b/tests/test_wheelfile.py
@@ -2,17 +2,18 @@
 
 import stat
 import sys
+from pathlib import Path
 from zipfile import ZIP_DEFLATED, ZipFile
 
 import pytest
+from pytest import MonkeyPatch, TempPathFactory
 
-from wheel.cli import WheelError
-from wheel.wheelfile import WheelFile
+from wheel.wheelfile import WheelError, WheelFile
 
 
 @pytest.fixture
-def wheel_path(tmp_path):
-    return str(tmp_path.joinpath("test-1.0-py2.py3-none-any.whl"))
+def wheel_path(tmp_path: Path) -> Path:
+    return tmp_path.joinpath("test-1.0-py2.py3-none-any.whl")
 
 
 @pytest.mark.parametrize(
@@ -22,10 +23,10 @@ def wheel_path(tmp_path):
         "foo-2-py2.py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
     ],
 )
-def test_wheelfile_re(filename, tmp_path):
+def test_wheelfile_re(filename: str, tmp_path: Path) -> None:
     # Regression test for #208 and #485
-    path = tmp_path.joinpath(filename)
-    with WheelFile(str(path), "w") as wf:
+    path = tmp_path / filename
+    with WheelFile(path, "w") as wf:
         assert wf.parsed_filename.group("namever") == "foo-2"
 
 
@@ -40,12 +41,12 @@ def test_wheelfile_re(filename, tmp_path):
         "test-1.0-py 2-none-any.whl",
     ],
 )
-def test_bad_wheel_filename(filename):
+def test_bad_wheel_filename(filename: str) -> None:
     exc = pytest.raises(WheelError, WheelFile, filename)
     exc.match(f"^Bad wheel filename {filename!r}$")
 
 
-def test_missing_record(wheel_path):
+def test_missing_record(wheel_path: Path) -> None:
     with ZipFile(wheel_path, "w") as zf:
         zf.writestr("hello/héllö.py", 'print("Héllö, w0rld!")\n')
 
@@ -53,7 +54,34 @@ def test_missing_record(wheel_path):
     exc.match("^Missing test-1.0.dist-info/RECORD file$")
 
 
-def test_unsupported_hash_algorithm(wheel_path):
+def test_mixed_case_dist_info(tmp_path: Path) -> None:
+    """Regression test: wheel filename has uppercase but .dist-info dir is lowercase.
+
+    A wheel named ``Django-3.2.5.whl`` may contain ``django-3.2.5.dist-info/``
+    inside (normalized). WheelFile should find RECORD case-insensitively.
+    See `#411 `_.
+    """
+    wheel_path = tmp_path / "MixedCase-1.0-py3-none-any.whl"
+    with ZipFile(wheel_path, "w", ZIP_DEFLATED) as zf:
+        zf.writestr("mixedcase/__init__.py", "")
+        # Use lowercase dist-info (as pip/build tools produce)
+        zf.writestr("mixedcase-1.0.dist-info/WHEEL", "Wheel-Version: 1.0\n")
+        zf.writestr(
+            "mixedcase-1.0.dist-info/METADATA",
+            "Metadata-Version: 2.1\nName: MixedCase\nVersion: 1.0\n",
+        )
+        zf.writestr(
+            "mixedcase-1.0.dist-info/RECORD",
+            "mixedcase/__init__.py,,\n",
+        )
+
+    # Should not raise — this is the fix
+    with WheelFile(wheel_path) as wf:
+        assert wf.dist_info_path == "mixedcase-1.0.dist-info"
+        assert wf.record_path == "mixedcase-1.0.dist-info/RECORD"
+
+
+def test_unsupported_hash_algorithm(wheel_path: Path) -> None:
     with ZipFile(wheel_path, "w") as zf:
         zf.writestr("hello/héllö.py", 'print("Héllö, w0rld!")\n')
         zf.writestr(
@@ -67,10 +95,12 @@ def test_unsupported_hash_algorithm(wheel_path):
 
 @pytest.mark.parametrize(
     "algorithm, digest",
-    [("md5", "4J-scNa2qvSgy07rS4at-Q"), ("sha1", "QjCnGu5Qucb6-vir1a6BVptvOA4")],
-    ids=["md5", "sha1"],
+    [
+        pytest.param("md5", "4J-scNa2qvSgy07rS4at-Q", id="md5"),
+        pytest.param("sha1", "QjCnGu5Qucb6-vir1a6BVptvOA4", id="sha1"),
+    ],
 )
-def test_weak_hash_algorithm(wheel_path, algorithm, digest):
+def test_weak_hash_algorithm(wheel_path: Path, algorithm: str, digest: str) -> None:
     hash_string = f"{algorithm}={digest}"
     with ZipFile(wheel_path, "w") as zf:
         zf.writestr("hello/héllö.py", 'print("Héllö, w0rld!")\n')
@@ -83,17 +113,23 @@ def test_weak_hash_algorithm(wheel_path, algorithm, digest):
 @pytest.mark.parametrize(
     "algorithm, digest",
     [
-        ("sha256", "bv-QV3RciQC2v3zL8Uvhd_arp40J5A9xmyubN34OVwo"),
-        ("sha384", "cDXriAy_7i02kBeDkN0m2RIDz85w6pwuHkt2PZ4VmT2PQc1TZs8Ebvf6eKDFcD_S"),
-        (
+        pytest.param(
+            "sha256", "bv-QV3RciQC2v3zL8Uvhd_arp40J5A9xmyubN34OVwo", id="sha256"
+        ),
+        pytest.param(
+            "sha384",
+            "cDXriAy_7i02kBeDkN0m2RIDz85w6pwuHkt2PZ4VmT2PQc1TZs8Ebvf6eKDFcD_S",
+            id="sha384",
+        ),
+        pytest.param(
             "sha512",
             "kdX9CQlwNt4FfOpOKO_X0pn_v1opQuksE40SrWtMyP1NqooWVWpzCE3myZTfpy8g2azZON_"
             "iLNpWVxTwuDWqBQ",
+            id="sha512",
         ),
     ],
-    ids=["sha256", "sha384", "sha512"],
 )
-def test_testzip(wheel_path, algorithm, digest):
+def test_testzip(wheel_path: Path, algorithm: str, digest: str) -> None:
     hash_string = f"{algorithm}={digest}"
     with ZipFile(wheel_path, "w") as zf:
         zf.writestr("hello/héllö.py", 'print("Héllö, world!")\n')
@@ -103,7 +139,7 @@ def test_testzip(wheel_path, algorithm, digest):
         wf.testzip()
 
 
-def test_testzip_missing_hash(wheel_path):
+def test_testzip_missing_hash(wheel_path: Path) -> None:
     with ZipFile(wheel_path, "w") as zf:
         zf.writestr("hello/héllö.py", 'print("Héllö, world!")\n')
         zf.writestr("test-1.0.dist-info/RECORD", "")
@@ -113,7 +149,7 @@ def test_testzip_missing_hash(wheel_path):
         exc.match("^No hash found for file 'hello/héllö.py'$")
 
 
-def test_testzip_bad_hash(wheel_path):
+def test_testzip_bad_hash(wheel_path: Path) -> None:
     with ZipFile(wheel_path, "w") as zf:
         zf.writestr("hello/héllö.py", 'print("Héllö, w0rld!")\n')
         zf.writestr(
@@ -126,7 +162,7 @@ def test_testzip_bad_hash(wheel_path):
         exc.match("^Hash mismatch for file 'hello/héllö.py'$")
 
 
-def test_write_str(wheel_path):
+def test_write_str(wheel_path: Path) -> None:
     with WheelFile(wheel_path, "w") as wf:
         wf.writestr("hello/héllö.py", 'print("Héllö, world!")\n')
         wf.writestr("hello/h,ll,.py", 'print("Héllö, world!")\n')
@@ -148,7 +184,9 @@ def test_write_str(wheel_path):
         )
 
 
-def test_timestamp(tmp_path_factory, wheel_path, monkeypatch):
+def test_timestamp(
+    tmp_path_factory: TempPathFactory, wheel_path: Path, monkeypatch: MonkeyPatch
+) -> None:
     # An environment variable can be used to influence the timestamp on
     # TarInfo objects inside the zip.  See issue #143.
     build_dir = tmp_path_factory.mktemp("build")
@@ -170,7 +208,7 @@ def test_timestamp(tmp_path_factory, wheel_path, monkeypatch):
 @pytest.mark.skipif(
     sys.platform == "win32", reason="Windows does not support UNIX-like permissions"
 )
-def test_attributes(tmp_path_factory, wheel_path):
+def test_attributes(tmp_path_factory: TempPathFactory, wheel_path: Path) -> None:
     # With the change from ZipFile.write() to .writestr(), we need to manually
     # set member attributes.
     build_dir = tmp_path_factory.mktemp("build")
diff --git a/tests/testdata/abi3extension.dist/extension.c b/tests/testdata/abi3extension.dist/extension.c
deleted file mode 100644
index a37c3fa2d..000000000
--- a/tests/testdata/abi3extension.dist/extension.c
+++ /dev/null
@@ -1,2 +0,0 @@
-#define Py_LIMITED_API 0x03020000
-#include 
diff --git a/tests/testdata/abi3extension.dist/setup.cfg b/tests/testdata/abi3extension.dist/setup.cfg
deleted file mode 100644
index 9f6ff39a0..000000000
--- a/tests/testdata/abi3extension.dist/setup.cfg
+++ /dev/null
@@ -1,2 +0,0 @@
-[bdist_wheel]
-py_limited_api=cp32
diff --git a/tests/testdata/abi3extension.dist/setup.py b/tests/testdata/abi3extension.dist/setup.py
deleted file mode 100644
index 5962bd155..000000000
--- a/tests/testdata/abi3extension.dist/setup.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from __future__ import annotations
-
-from setuptools import Extension, setup
-
-setup(
-    name="extension.dist",
-    version="0.1",
-    description="A testing distribution \N{SNOWMAN}",
-    ext_modules=[
-        Extension(name="extension", sources=["extension.c"], py_limited_api=True)
-    ],
-)
diff --git a/tests/testdata/commasinfilenames.dist/mypackage/__init__.py b/tests/testdata/commasinfilenames.dist/mypackage/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/tests/testdata/commasinfilenames.dist/mypackage/data/1,2,3.txt b/tests/testdata/commasinfilenames.dist/mypackage/data/1,2,3.txt
deleted file mode 100644
index e69de29bb..000000000
diff --git a/tests/testdata/commasinfilenames.dist/mypackage/data/__init__.py b/tests/testdata/commasinfilenames.dist/mypackage/data/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/tests/testdata/commasinfilenames.dist/setup.py b/tests/testdata/commasinfilenames.dist/setup.py
deleted file mode 100644
index a2783a3b6..000000000
--- a/tests/testdata/commasinfilenames.dist/setup.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from __future__ import annotations
-
-from setuptools import setup
-
-setup(
-    name="testrepo",
-    version="0.1",
-    packages=["mypackage"],
-    description="A test package with commas in file names",
-    include_package_data=True,
-    package_data={"mypackage.data": ["*"]},
-)
diff --git a/tests/testdata/commasinfilenames.dist/testrepo-0.1.0/mypackage/__init__.py b/tests/testdata/commasinfilenames.dist/testrepo-0.1.0/mypackage/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/tests/testdata/complex-dist/complexdist/__init__.py b/tests/testdata/complex-dist/complexdist/__init__.py
deleted file mode 100644
index 88aa7b76a..000000000
--- a/tests/testdata/complex-dist/complexdist/__init__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-from __future__ import annotations
-
-
-def main():
-    return
diff --git a/tests/testdata/complex-dist/setup.py b/tests/testdata/complex-dist/setup.py
deleted file mode 100644
index e0439d9ef..000000000
--- a/tests/testdata/complex-dist/setup.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from __future__ import annotations
-
-from setuptools import setup
-
-setup(
-    name="complex-dist",
-    version="0.1",
-    description="Another testing distribution \N{SNOWMAN}",
-    long_description="Another testing distribution \N{SNOWMAN}",
-    author="Illustrious Author",
-    author_email="illustrious@example.org",
-    url="http://example.org/exemplary",
-    packages=["complexdist"],
-    setup_requires=["wheel", "setuptools"],
-    install_requires=["quux", "splort"],
-    extras_require={"simple": ["simple.dist"]},
-    tests_require=["foo", "bar>=10.0.0"],
-    entry_points={
-        "console_scripts": [
-            "complex-dist=complexdist:main",
-            "complex-dist2=complexdist:main",
-        ],
-    },
-)
diff --git a/tests/cli/eggnames.txt b/tests/testdata/eggnames.txt
similarity index 100%
rename from tests/cli/eggnames.txt
rename to tests/testdata/eggnames.txt
diff --git a/tests/testdata/extension.dist/extension.c b/tests/testdata/extension.dist/extension.c
deleted file mode 100644
index 26403efa8..000000000
--- a/tests/testdata/extension.dist/extension.c
+++ /dev/null
@@ -1,17 +0,0 @@
-#include 
-
-static PyMethodDef methods[] = {
-	{ NULL, NULL, 0, NULL }
-};
-
-static struct PyModuleDef module_def = {
-	PyModuleDef_HEAD_INIT,
-	"extension",
-	"Dummy extension module",
-	-1,
-	methods
-};
-
-PyMODINIT_FUNC PyInit_extension(void) {
-	return PyModule_Create(&module_def);
-}
diff --git a/tests/testdata/extension.dist/setup.py b/tests/testdata/extension.dist/setup.py
deleted file mode 100644
index 9a6eed8cf..000000000
--- a/tests/testdata/extension.dist/setup.py
+++ /dev/null
@@ -1,10 +0,0 @@
-from __future__ import annotations
-
-from setuptools import Extension, setup
-
-setup(
-    name="extension.dist",
-    version="0.1",
-    description="A testing distribution \N{SNOWMAN}",
-    ext_modules=[Extension(name="extension", sources=["extension.c"])],
-)
diff --git a/tests/testdata/headers.dist/header.h b/tests/testdata/headers.dist/header.h
deleted file mode 100644
index e69de29bb..000000000
diff --git a/tests/testdata/headers.dist/headersdist.py b/tests/testdata/headers.dist/headersdist.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/tests/testdata/headers.dist/setup.cfg b/tests/testdata/headers.dist/setup.cfg
deleted file mode 100644
index 3c6e79cf3..000000000
--- a/tests/testdata/headers.dist/setup.cfg
+++ /dev/null
@@ -1,2 +0,0 @@
-[bdist_wheel]
-universal=1
diff --git a/tests/testdata/headers.dist/setup.py b/tests/testdata/headers.dist/setup.py
deleted file mode 100644
index 6cf9b46fa..000000000
--- a/tests/testdata/headers.dist/setup.py
+++ /dev/null
@@ -1,10 +0,0 @@
-from __future__ import annotations
-
-from setuptools import setup
-
-setup(
-    name="headers.dist",
-    version="0.1",
-    description="A distribution with headers",
-    headers=["header.h"],
-)
diff --git a/tests/testdata/macosx_minimal_system_version/libb.dylib b/tests/testdata/macosx_minimal_system_version/libb.dylib
deleted file mode 100644
index 25c954656..000000000
Binary files a/tests/testdata/macosx_minimal_system_version/libb.dylib and /dev/null differ
diff --git a/tests/testdata/macosx_minimal_system_version/test_lib.c b/tests/testdata/macosx_minimal_system_version/test_lib.c
deleted file mode 100644
index dfa226816..000000000
--- a/tests/testdata/macosx_minimal_system_version/test_lib.c
+++ /dev/null
@@ -1,13 +0,0 @@
-int num_of_letters(char* text){
-    int num = 0;
-    char * lett = text;
-    while (lett != 0){
-        if (*lett >= 'a' && *lett <= 'z'){
-            num += 1;
-        } else if (*lett >= 'A' && *lett <= 'Z'){
-            num += 1;
-        }
-        lett += 1;
-    }
-    return num;
-}
diff --git a/tests/testdata/macosx_minimal_system_version/test_lib_10_10.dylib b/tests/testdata/macosx_minimal_system_version/test_lib_10_10.dylib
deleted file mode 100644
index eaf1a94e5..000000000
Binary files a/tests/testdata/macosx_minimal_system_version/test_lib_10_10.dylib and /dev/null differ
diff --git a/tests/testdata/macosx_minimal_system_version/test_lib_10_10_10.dylib b/tests/testdata/macosx_minimal_system_version/test_lib_10_10_10.dylib
deleted file mode 100644
index 229d115f8..000000000
Binary files a/tests/testdata/macosx_minimal_system_version/test_lib_10_10_10.dylib and /dev/null differ
diff --git a/tests/testdata/macosx_minimal_system_version/test_lib_10_10_386.dylib b/tests/testdata/macosx_minimal_system_version/test_lib_10_10_386.dylib
deleted file mode 100644
index 8f543875d..000000000
Binary files a/tests/testdata/macosx_minimal_system_version/test_lib_10_10_386.dylib and /dev/null differ
diff --git a/tests/testdata/macosx_minimal_system_version/test_lib_10_10_fat.dylib b/tests/testdata/macosx_minimal_system_version/test_lib_10_10_fat.dylib
deleted file mode 100644
index 6c095127d..000000000
Binary files a/tests/testdata/macosx_minimal_system_version/test_lib_10_10_fat.dylib and /dev/null differ
diff --git a/tests/testdata/macosx_minimal_system_version/test_lib_10_14.dylib b/tests/testdata/macosx_minimal_system_version/test_lib_10_14.dylib
deleted file mode 100644
index c9024ccc4..000000000
Binary files a/tests/testdata/macosx_minimal_system_version/test_lib_10_14.dylib and /dev/null differ
diff --git a/tests/testdata/macosx_minimal_system_version/test_lib_10_14_386.dylib b/tests/testdata/macosx_minimal_system_version/test_lib_10_14_386.dylib
deleted file mode 100644
index c85b71691..000000000
Binary files a/tests/testdata/macosx_minimal_system_version/test_lib_10_14_386.dylib and /dev/null differ
diff --git a/tests/testdata/macosx_minimal_system_version/test_lib_10_14_fat.dylib b/tests/testdata/macosx_minimal_system_version/test_lib_10_14_fat.dylib
deleted file mode 100644
index 4bb094073..000000000
Binary files a/tests/testdata/macosx_minimal_system_version/test_lib_10_14_fat.dylib and /dev/null differ
diff --git a/tests/testdata/macosx_minimal_system_version/test_lib_10_6.dylib b/tests/testdata/macosx_minimal_system_version/test_lib_10_6.dylib
deleted file mode 100644
index 80401eed5..000000000
Binary files a/tests/testdata/macosx_minimal_system_version/test_lib_10_6.dylib and /dev/null differ
diff --git a/tests/testdata/macosx_minimal_system_version/test_lib_10_6_386.dylib b/tests/testdata/macosx_minimal_system_version/test_lib_10_6_386.dylib
deleted file mode 100644
index 1e48cd853..000000000
Binary files a/tests/testdata/macosx_minimal_system_version/test_lib_10_6_386.dylib and /dev/null differ
diff --git a/tests/testdata/macosx_minimal_system_version/test_lib_10_6_fat.dylib b/tests/testdata/macosx_minimal_system_version/test_lib_10_6_fat.dylib
deleted file mode 100644
index f4ffaeec2..000000000
Binary files a/tests/testdata/macosx_minimal_system_version/test_lib_10_6_fat.dylib and /dev/null differ
diff --git a/tests/testdata/macosx_minimal_system_version/test_lib_10_9_universal2.dylib b/tests/testdata/macosx_minimal_system_version/test_lib_10_9_universal2.dylib
deleted file mode 100755
index 26ab109c9..000000000
Binary files a/tests/testdata/macosx_minimal_system_version/test_lib_10_9_universal2.dylib and /dev/null differ
diff --git a/tests/testdata/macosx_minimal_system_version/test_lib_11.dylib b/tests/testdata/macosx_minimal_system_version/test_lib_11.dylib
deleted file mode 100644
index 80202c11b..000000000
Binary files a/tests/testdata/macosx_minimal_system_version/test_lib_11.dylib and /dev/null differ
diff --git a/tests/testdata/macosx_minimal_system_version/test_lib_multiple_fat.dylib b/tests/testdata/macosx_minimal_system_version/test_lib_multiple_fat.dylib
deleted file mode 100644
index 5f7fd5091..000000000
Binary files a/tests/testdata/macosx_minimal_system_version/test_lib_multiple_fat.dylib and /dev/null differ
diff --git a/tests/testdata/simple.dist/setup.py b/tests/testdata/simple.dist/setup.py
deleted file mode 100644
index 1e7a78a22..000000000
--- a/tests/testdata/simple.dist/setup.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from __future__ import annotations
-
-from setuptools import setup
-
-setup(
-    name="simple.dist",
-    version="0.1",
-    description="A testing distribution \N{SNOWMAN}",
-    packages=["simpledist"],
-    extras_require={"voting": ["beaglevote"]},
-)
diff --git a/tests/testdata/simple.dist/simpledist/__init__.py b/tests/testdata/simple.dist/simpledist/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/tests/testdata/unicode.dist/setup.py b/tests/testdata/unicode.dist/setup.py
deleted file mode 100644
index ec66d1e6a..000000000
--- a/tests/testdata/unicode.dist/setup.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from __future__ import annotations
-
-from setuptools import setup
-
-setup(
-    name="unicode.dist",
-    version="0.1",
-    description="A testing distribution \N{SNOWMAN}",
-    packages=["unicodedist"],
-    zip_safe=True,
-)
diff --git a/tests/testdata/unicode.dist/unicodedist/__init__.py b/tests/testdata/unicode.dist/unicodedist/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git "a/tests/testdata/unicode.dist/unicodedist/\303\245\303\244\303\266_\346\227\245\346\234\254\350\252\236.py" "b/tests/testdata/unicode.dist/unicodedist/\303\245\303\244\303\266_\346\227\245\346\234\254\350\252\236.py"
deleted file mode 100644
index e69de29bb..000000000