diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b3e521d7..0bd6b4d2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,12 +2,14 @@ name: Publish packages to PyPI on: create: - tags: "*" + tags: + - "[0-9]+.[0-9]+.[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+[a-b][0-9]+" + - "[0-9]+.[0-9]+.[0-9]+rc[0-9]+" jobs: publish: runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/') steps: - uses: actions/checkout@v3 - name: Set up Python diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c1bca612..7ed96f6d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,19 +35,24 @@ jobs: uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - - uses: actions/cache@v3 - with: - path: ~/.cache/pip - key: pip-test-${{ matrix.python-version }}-${{ matrix.os }} - - name: Install the project - run: "pip install --no-binary=:all: ." - - name: Install test dependencies - run: pip install .[test] coverage[toml] + cache: pip + cache-dependency-path: setup.cfg + - name: Install the project and its test dependencies + run: pip install --no-binary=setuptools,wheel .[test] coverage[toml] - name: Test with pytest run: | - coverage run -m pytest -W always - coverage xml + python -m coverage run -m pytest -W always + python -m coverage xml - name: Send coverage data to Codecov uses: codecov/codecov-action@v3 with: file: coverage.xml + + mypy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Install the project and its Mypy dependencies + run: pip install --no-binary=setuptools,wheel .[types] + - name: Test with Mypy + run: mypy diff --git a/docs/news.rst b/docs/news.rst index ab0f3c9c..d99d0b06 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -3,6 +3,8 @@ Release Notes **UNRELEASED** +- Added a public API +- Dropped support for converting ``bdist_wininst`` based installers into wheels - Updated vendored ``packaging`` to 22.0 **0.38.4 (2022-11-09)** diff --git a/docs/reference/wheel_convert.rst b/docs/reference/wheel_convert.rst index ca625b53..82bdf148 100644 --- a/docs/reference/wheel_convert.rst +++ b/docs/reference/wheel_convert.rst @@ -12,14 +12,15 @@ Usage Description ----------- -Convert one or more eggs (``.egg``; made with ``bdist_egg``) or Windows -installers (``.exe``; made with ``bdist_wininst``) into wheels. +Convert one or more eggs (``.egg``; made with ``bdist_egg``) into wheels. Egg names must match the standard format: * ``--pyX.Y`` for pure Python wheels * ``--pyX.Y-`` for binary wheels +Each argument can be either an ``.egg`` file, an unpacked egg directory or a directory +containing eggs (packed or unpacked). Options ------- diff --git a/setup.cfg b/setup.cfg index 01ccda33..25ec451a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -39,11 +39,16 @@ where = src [options.extras_require] test = - pytest >= 3.0.0 + pytest >= 6.2.0 + setuptools >= 57 +types = + mypy >= 0.982 + pytest >= 6.2 + types-setuptools >= 65.5.0.2 [options.entry_points] console_scripts = - wheel = wheel.cli:main + wheel = wheel._cli:main distutils.commands = bdist_wheel = wheel.bdist_wheel:bdist_wheel @@ -57,6 +62,9 @@ max-line-length = 88 [tool:pytest] testpaths = tests +addopts = --tb=short +filterwarnings = + ignore::DeprecationWarning:wheel.wheelfile [coverage:run] source = wheel @@ -64,3 +72,11 @@ omit = */vendored/* [coverage:report] show_missing = true + +[mypy] +files = src,tests +python_version = 3.8 +exclude = testdata/ + +[mypy-wheel.vendored.*] +ignore_errors = True diff --git a/src/wheel/__init__.py b/src/wheel/__init__.py index ace3fc60..08132a2e 100644 --- a/src/wheel/__init__.py +++ b/src/wheel/__init__.py @@ -1,3 +1,18 @@ from __future__ import annotations -__version__ = "0.38.4" +__all__ = [ + "WheelError", + "WheelReader", + "WheelWriter", + "make_filename", + "write_wheelfile", +] +__version__ = "1.0.0a1" + +from ._wheelfile import ( + WheelError, + WheelReader, + WheelWriter, + make_filename, + write_wheelfile, +) diff --git a/src/wheel/__main__.py b/src/wheel/__main__.py index 0be74537..a4b5e0d9 100644 --- a/src/wheel/__main__.py +++ b/src/wheel/__main__.py @@ -5,19 +5,20 @@ from __future__ import annotations import sys +from typing import NoReturn -def main(): # needed for console script +def main() -> NoReturn: # needed for console script if __package__ == "": # 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 + import wheel._cli - sys.exit(wheel.cli.main()) + sys.exit(wheel._cli.main()) if __name__ == "__main__": - sys.exit(main()) + main() diff --git a/src/wheel/cli/__init__.py b/src/wheel/_cli/__init__.py similarity index 80% rename from src/wheel/cli/__init__.py rename to src/wheel/_cli/__init__.py index c0fb8c44..957da241 100644 --- a/src/wheel/cli/__init__.py +++ b/src/wheel/_cli/__init__.py @@ -4,41 +4,41 @@ from __future__ import annotations -import argparse import os import sys +from argparse import ArgumentParser, Namespace class WheelError(Exception): pass -def unpack_f(args): +def unpack_f(args: Namespace) -> None: from .unpack import unpack unpack(args.wheelfile, args.dest) -def pack_f(args): +def pack_f(args: Namespace) -> None: from .pack import pack pack(args.directory, args.dest_dir, args.build_number) -def convert_f(args): +def convert_f(args: Namespace) -> None: from .convert import convert convert(args.files, args.dest_dir, args.verbose) -def version_f(args): +def version_f(args: Namespace) -> None: from .. import __version__ - print("wheel %s" % __version__) + print(f"wheel {__version__}") -def parser(): - p = argparse.ArgumentParser() +def parser() -> ArgumentParser: + p = ArgumentParser() s = p.add_subparsers(help="commands") unpack_parser = s.add_parser("unpack", help="Unpack wheel") @@ -61,8 +61,10 @@ def parser(): ) repack_parser.set_defaults(func=pack_f) - convert_parser = s.add_parser("convert", help="Convert egg or wininst to wheel") - convert_parser.add_argument("files", nargs="*", help="Files to convert") + convert_parser = s.add_parser("convert", help="Convert eggs to wheels") + convert_parser.add_argument( + "files", nargs="*", help=".egg files or directories to convert" + ) convert_parser.add_argument( "--dest-dir", "-d", @@ -81,7 +83,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/_cli/convert.py new file mode 100755 index 00000000..bdcf6a86 --- /dev/null +++ b/src/wheel/_cli/convert.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import os +import re +import zipfile +from collections.abc import Generator, Iterable +from email.message import Message +from email.parser import HeaderParser +from os import PathLike +from pathlib import Path, PurePath +from typing import IO, Any + +from .. import WheelWriter, make_filename +from . import WheelError + +egg_info_re = re.compile( + r""" + (?P.+?)-(?P.+?) + (-(?Ppy\d\.\d+) + (-(?P.+?))? + )?.egg$""", + re.VERBOSE, +) + + +def egg2wheel(egg_path: Path, dest_dir: Path) -> None: + def egg_file_source() -> Generator[tuple[PurePath, IO[bytes]], Any, None]: + with zipfile.ZipFile(egg_path) as zf: + for zinfo in zf.infolist(): + with zf.open(zinfo) as fp: + yield PurePath(zinfo.filename), fp + + def egg_dir_source() -> Generator[tuple[PurePath, IO[bytes]], Any, None]: + for root, _dirs, files in os.walk(egg_path, followlinks=False): + root_path = Path(root) + for fname in files: + file_path = root_path / fname + with file_path.open("rb") as fp: + yield file_path, fp + + match = egg_info_re.match(egg_path.name) + if not match: + raise WheelError(f"Invalid egg file name: {egg_path.name}") + + # Assume pure Python if there is no specified architecture + # Assume all binary eggs are for CPython + egg_info = match.groupdict() + pyver = egg_info["pyver"].replace(".", "") + arch = (egg_info["arch"] or "any").replace(".", "_").replace("-", "_") + abi = "cp" + pyver[2:] if arch != "any" else "none" + root_is_purelib = arch is None + + if egg_path.is_dir(): + # buildout-style installed eggs directory + source = egg_dir_source() + else: + source = egg_file_source() + + wheel_name = make_filename( + egg_info["name"], egg_info["ver"], impl_tag=pyver, abi_tag=abi, plat_tag=arch + ) + metadata = Message() + with WheelWriter( + dest_dir / wheel_name, generator="egg2wheel", root_is_purelib=root_is_purelib + ) as wf: + for path, fp in source: + if path.parts[0] == "EGG-INFO": + if path.parts[1] == "requires.txt": + requires = fp.read().decode("utf-8") + extra = specifier = "" + for line in requires.splitlines(): + line = line.strip() + if line.startswith("[") and line.endswith("]"): + extra, _, specifier = line[1:-1].strip().partition(":") + metadata["Provides-Extra"] = extra + elif line: + specifiers: list[str] = [] + if extra: + specifiers += f"extra == {extra!r}" + + if specifier: + specifiers += specifier + + if specifiers: + line = line + " ; " + " and ".join(specifiers) + + metadata["Requires-Dist"] = line + elif path.parts[1] in ("entry_points.txt", "top_level.txt"): + wf.write_distinfo_file(path.parts[1], fp) + elif path.parts[1] == "PKG-INFO": + pkg_info = HeaderParser().parsestr(fp.read().decode("utf-8")) + pkg_info.replace_header("Metadata-Version", "2.1") + del pkg_info["Provides-Extra"] + del pkg_info["Requires-Dist"] + for header, value in pkg_info.items(): + metadata[header] = value + else: + wf.write_file(path, fp) + + if metadata: + wf.write_metadata(metadata.items()) + + +def convert( + files: Iterable[str | PathLike[str]], dest_dir: str | PathLike[str], verbose: bool +) -> None: + dest_path = Path(dest_dir) + paths: list[Path] = [] + for fname in files: + path = Path(fname) + if path.is_file(): + paths.append(path) + elif path.is_dir(): + paths.extend(path.iterdir()) + + for path in paths: + if path.suffix != ".egg": + continue + + if verbose: + print(f"{path}... ", flush=True) + + egg2wheel(path, dest_path) + + if verbose: + print("OK") diff --git a/src/wheel/cli/pack.py b/src/wheel/_cli/pack.py similarity index 54% rename from src/wheel/cli/pack.py rename to src/wheel/_cli/pack.py index 1949d4cf..eb120569 100644 --- a/src/wheel/cli/pack.py +++ b/src/wheel/_cli/pack.py @@ -1,16 +1,21 @@ from __future__ import annotations -import os.path import re +from os import PathLike +from pathlib import Path -from wheel.cli import WheelError -from wheel.wheelfile import WheelFile +from .. import WheelWriter, make_filename +from . import WheelError -DIST_INFO_RE = re.compile(r"^(?P(?P.+?)-(?P\d.*?))\.dist-info$") +DIST_INFO_RE = re.compile(r"^(?P(?P[^-]+)-(?P\d.*?))\.dist-info$") BUILD_NUM_RE = re.compile(rb"Build: (\d\w*)$") -def pack(directory: str, dest_dir: str, build_number: str | None): +def pack( + directory: str | PathLike[str], + dest_dir: str | PathLike[str], + build_number: str | None = None, +) -> None: """Repack a previously unpacked wheel directory into a new wheel file. The .dist-info/WHEEL file must contain one or more tags so that the target @@ -18,12 +23,15 @@ def pack(directory: str, dest_dir: str, build_number: str | None): :param directory: The unpacked wheel directory :param dest_dir: Destination directory (defaults to the current directory) + :param build_number: Build tag to use, if any + """ # Find the .dist-info directory + directory = Path(directory) dist_info_dirs = [ - fn - for fn in os.listdir(directory) - if os.path.isdir(os.path.join(directory, fn)) and DIST_INFO_RE.match(fn) + path + for path in directory.iterdir() + if path.is_dir() and DIST_INFO_RE.match(path.name) ] if len(dist_info_dirs) > 1: raise WheelError(f"Multiple .dist-info directories found in {directory}") @@ -32,12 +40,14 @@ def pack(directory: str, dest_dir: str, build_number: str | None): # Determine the target wheel filename dist_info_dir = dist_info_dirs[0] - name_version = DIST_INFO_RE.match(dist_info_dir).group("namever") + match = DIST_INFO_RE.match(dist_info_dir.name) + assert match + name, version = match.groups()[1:] # Read the tags and the existing build number from .dist-info/WHEEL existing_build_number = None - wheel_file_path = os.path.join(directory, dist_info_dir, "WHEEL") - with open(wheel_file_path) as f: + wheel_file_path = dist_info_dir / "WHEEL" + with wheel_file_path.open() as f: tags = [] for line in f: if line.startswith("Tag: "): @@ -53,38 +63,36 @@ def pack(directory: str, dest_dir: str, build_number: str | None): # Set the wheel file name and add/replace/remove the Build tag in .dist-info/WHEEL build_number = build_number if build_number is not None else existing_build_number - if build_number is not None: - if build_number: - name_version += "-" + build_number - - if build_number != existing_build_number: - replacement = ( - ("Build: %s\r\n" % build_number).encode("ascii") - if build_number - else b"" - ) - with open(wheel_file_path, "rb+") as f: - wheel_file_content = f.read() - wheel_file_content, num_replaced = BUILD_NUM_RE.subn( - replacement, wheel_file_content - ) - if not num_replaced: - wheel_file_content += replacement - - f.seek(0) - f.truncate() - f.write(wheel_file_content) + if build_number is not None and build_number != existing_build_number: + replacement = ( + f"Build: {build_number}\r\n".encode("ascii") if build_number else b"" + ) + with wheel_file_path.open("rb+") as f: + wheel_file_content = f.read() + if not BUILD_NUM_RE.subn(replacement, wheel_file_content)[1]: + wheel_file_content += replacement + + f.seek(0) + f.truncate() + f.write(wheel_file_content) # Reassemble the tags for the wheel file impls = sorted({tag.split("-")[0] for tag in tags}) abivers = sorted({tag.split("-")[1] for tag in tags}) platforms = sorted({tag.split("-")[2] for tag in tags}) - tagline = "-".join([".".join(impls), ".".join(abivers), ".".join(platforms)]) # Repack the wheel - wheel_path = os.path.join(dest_dir, f"{name_version}-{tagline}.whl") - with WheelFile(wheel_path, "w") as wf: + filename = make_filename( + name, + version, + build_number, + ".".join(impls), + ".".join(abivers), + ".".join(platforms), + ) + wheel_path = Path(dest_dir) / filename + with WheelWriter(wheel_path) as wf: print(f"Repacking wheel as {wheel_path}...", end="", flush=True) - wf.write_files(directory) + wf.write_files_from_directory(directory) print("OK") diff --git a/src/wheel/cli/unpack.py b/src/wheel/_cli/unpack.py similarity index 66% rename from src/wheel/cli/unpack.py rename to src/wheel/_cli/unpack.py index c6409d4b..9b14b49d 100644 --- a/src/wheel/cli/unpack.py +++ b/src/wheel/_cli/unpack.py @@ -1,11 +1,12 @@ from __future__ import annotations +from os import PathLike from pathlib import Path -from ..wheelfile import WheelFile +from .. import WheelReader -def unpack(path: str, dest: str = ".") -> None: +def unpack(path: str | PathLike[str], dest: str | PathLike[str] = ".") -> None: """Unpack a wheel. Wheel content will be unpacked to {dest}/{name}-{ver}, where {name} @@ -14,9 +15,10 @@ def unpack(path: str, dest: str = ".") -> None: :param path: The path to the wheel. :param dest: Destination directory (default to current directory). """ - with WheelFile(path) as wf: - namever = wf.parsed_filename.group("namever") + with WheelReader(path) as wf: + namever = f"{wf.name}.{wf.version}" destination = Path(dest) / namever + destination.mkdir(exist_ok=True) print(f"Unpacking to: {destination}...", end="", flush=True) wf.extractall(destination) diff --git a/src/wheel/macosx_libfile.py b/src/wheel/_macosx_libfile.py similarity index 89% rename from src/wheel/macosx_libfile.py rename to src/wheel/_macosx_libfile.py index 4d085742..2b9e6369 100644 --- a/src/wheel/macosx_libfile.py +++ b/src/wheel/_macosx_libfile.py @@ -43,6 +43,7 @@ import ctypes import os import sys +from typing import BinaryIO """here the needed const and struct from mach-o header files""" @@ -238,7 +239,7 @@ """ -def swap32(x): +def swap32(x: int) -> int: return ( ((x << 24) & 0xFF000000) | ((x << 8) & 0x00FF0000) @@ -247,7 +248,9 @@ def swap32(x): ) -def get_base_class_and_magic_number(lib_file, seek=None): +def get_base_class_and_magic_number( + lib_file: BinaryIO, seek: int | None = None +) -> tuple[type[ctypes.Structure], int]: if seek is None: seek = lib_file.tell() else: @@ -256,6 +259,7 @@ def get_base_class_and_magic_number(lib_file, seek=None): lib_file.read(ctypes.sizeof(ctypes.c_uint32)) ).value + BaseClass = ctypes.Structure # Handle wrong byte order if magic_number in [FAT_CIGAM, FAT_CIGAM_64, MH_CIGAM, MH_CIGAM_64]: if sys.byteorder == "little": @@ -264,37 +268,37 @@ def get_base_class_and_magic_number(lib_file, seek=None): BaseClass = ctypes.LittleEndianStructure magic_number = swap32(magic_number) - else: - BaseClass = ctypes.Structure lib_file.seek(seek) return BaseClass, magic_number -def read_data(struct_class, lib_file): +def read_data( + struct_class: type[ctypes.Structure], lib_file: BinaryIO +) -> ctypes.Structure: return struct_class.from_buffer_copy(lib_file.read(ctypes.sizeof(struct_class))) -def extract_macosx_min_system_version(path_to_lib): +def extract_macosx_min_system_version(path_to_lib: str) -> tuple[int, int, int] | None: with open(path_to_lib, "rb") as lib_file: BaseClass, magic_number = get_base_class_and_magic_number(lib_file, 0) if magic_number not in [FAT_MAGIC, FAT_MAGIC_64, MH_MAGIC, MH_MAGIC_64]: - return + return None if magic_number in [FAT_MAGIC, FAT_CIGAM_64]: - class FatHeader(BaseClass): + class FatHeader(BaseClass): # type: ignore[valid-type,misc] _fields_ = fat_header_fields fat_header = read_data(FatHeader, lib_file) if magic_number == FAT_MAGIC: - class FatArch(BaseClass): + class FatArch(BaseClass): # type: ignore[valid-type,misc] _fields_ = fat_arch_fields else: - class FatArch(BaseClass): + class FatArch(BaseClass): # type: ignore[valid-type,misc,no-redef] _fields_ = fat_arch_64_fields fat_arch_list = [ @@ -333,7 +337,9 @@ class FatArch(BaseClass): return None -def read_mach_header(lib_file, seek=None): +def read_mach_header( + lib_file: BinaryIO, seek: int | None = None +) -> tuple[int, int, int] | None: """ This funcition parse mach-O header and extract information about minimal system version @@ -345,17 +351,17 @@ def read_mach_header(lib_file, seek=None): base_class, magic_number = get_base_class_and_magic_number(lib_file) arch = "32" if magic_number == MH_MAGIC else "64" - class SegmentBase(base_class): + class SegmentBase(base_class): # type: ignore[valid-type,misc] _fields_ = segment_base_fields if arch == "32": - class MachHeader(base_class): + class MachHeader(base_class): # type: ignore[valid-type,misc] _fields_ = mach_header_fields else: - class MachHeader(base_class): + class MachHeader(base_class): # type: ignore[valid-type,misc,no-redef] _fields_ = mach_header_fields_64 mach_header = read_data(MachHeader, lib_file) @@ -365,14 +371,14 @@ class MachHeader(base_class): lib_file.seek(pos) if segment_base.cmd == LC_VERSION_MIN_MACOSX: - class VersionMinCommand(base_class): + class VersionMinCommand(base_class): # type: ignore[valid-type,misc] _fields_ = version_min_command_fields version_info = read_data(VersionMinCommand, lib_file) return parse_version(version_info.version) elif segment_base.cmd == LC_BUILD_VERSION: - class VersionBuild(base_class): + class VersionBuild(base_class): # type: ignore[valid-type,misc] _fields_ = build_version_command_fields version_info = read_data(VersionBuild, lib_file) @@ -381,22 +387,24 @@ class VersionBuild(base_class): lib_file.seek(pos + segment_base.cmdsize) continue + return None + -def parse_version(version): +def parse_version(version: int) -> tuple[int, int, int]: x = (version & 0xFFFF0000) >> 16 y = (version & 0x0000FF00) >> 8 z = version & 0x000000FF return x, y, z -def calculate_macosx_platform_tag(archive_root, platform_tag): +def calculate_macosx_platform_tag(archive_root: str, platform_tag: str) -> str: """ Calculate proper macosx platform tag basing on files which are included to wheel Example platform tag `macosx-10.14-x86_64` """ - prefix, base_version, suffix = platform_tag.split("-") - base_version = tuple(int(x) for x in base_version.split(".")) + prefix, base_version_str, suffix = platform_tag.split("-") + base_version = tuple(int(x) for x in base_version_str.split(".")) base_version = base_version[:2] if base_version[0] > 10: base_version = (base_version[0], 0) @@ -427,6 +435,7 @@ def calculate_macosx_platform_tag(archive_root, platform_tag): for filename in filenames: if filename.endswith(".dylib") or filename.endswith(".so"): lib_path = os.path.join(dirpath, filename) + min_ver: tuple[int, ...] | None min_ver = extract_macosx_min_system_version(lib_path) if min_ver is not None: min_ver = min_ver[0:2] @@ -441,19 +450,16 @@ def calculate_macosx_platform_tag(archive_root, platform_tag): fin_base_version = "_".join([str(x) for x in base_version]) if start_version < base_version: problematic_files = [k for k, v in versions_dict.items() if v > start_version] - problematic_files = "\n".join(problematic_files) + problematic_files_str = "\n".join(problematic_files) if len(problematic_files) == 1: files_form = "this file" else: files_form = "these files" error_message = ( - "[WARNING] This wheel needs a higher macOS version than {} " + "[WARNING] This wheel needs a higher macOS version than {} " "To silence this warning, set MACOSX_DEPLOYMENT_TARGET to at least " - + fin_base_version - + " or recreate " - + files_form - + " with lower " - "MACOSX_DEPLOYMENT_TARGET: \n" + problematic_files + f"{fin_base_version} or recreate {files_form} with lower " + f"MACOSX_DEPLOYMENT_TARGET: \n{problematic_files_str}" ) if "MACOSX_DEPLOYMENT_TARGET" in os.environ: @@ -467,5 +473,4 @@ def calculate_macosx_platform_tag(archive_root, platform_tag): sys.stderr.write(error_message) - platform_tag = prefix + "_" + fin_base_version + "_" + suffix - return platform_tag + return prefix + "_" + fin_base_version + "_" + suffix diff --git a/src/wheel/metadata.py b/src/wheel/_metadata.py similarity index 66% rename from src/wheel/metadata.py rename to src/wheel/_metadata.py index 159ff0aa..a6a6bb51 100644 --- a/src/wheel/metadata.py +++ b/src/wheel/_metadata.py @@ -3,11 +3,9 @@ """ from __future__ import annotations -import os.path -import textwrap -from email.message import Message -from email.parser import Parser -from typing import Iterator +from collections.abc import Iterator +from email.parser import HeaderParser +from pathlib import Path from pkg_resources import Requirement, safe_extra, split_sections @@ -15,7 +13,7 @@ def requires_to_requires_dist(requirement: Requirement) -> str: """Return the version specifier for a requirement in PEP 345/566 fashion.""" if getattr(requirement, "url", None): - return " @ " + requirement.url + return f" @ {requirement.url}" # type: ignore[attr-defined] requires_dist = [] for op, ver in requirement.specs: @@ -69,41 +67,24 @@ def generate_requirements( yield "Requires-Dist", new_req + condition -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) +def pkginfo_to_metadata(pkginfo_path: Path) -> list[tuple[str, str]]: + """Convert an .egg-info/PKG-INFO file to the Metadata 2.1 format.""" + + with pkginfo_path.open() as fp: + pkg_info = HeaderParser().parse(fp) 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) as requires_file: - requires = requires_file.read() - + requires_path = pkginfo_path.parent / "requires.txt" + if requires_path.exists(): + requires = requires_path.read_text() 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}): + for key, value in generate_requirements({extra or "": 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 + return list(pkg_info.items()) diff --git a/src/wheel/_setuptools_logging.py b/src/wheel/_setuptools_logging.py deleted file mode 100644 index 006c0985..00000000 --- a/src/wheel/_setuptools_logging.py +++ /dev/null @@ -1,26 +0,0 @@ -# copied from setuptools.logging, omitting monkeypatching -from __future__ import annotations - -import logging -import sys - - -def _not_warning(record): - return record.levelno < logging.WARNING - - -def configure(): - """ - Configure logging to emit warning and above to stderr - and everything else to stdout. This behavior is provided - for compatibility with distutils.log but may change in - the future. - """ - err_handler = logging.StreamHandler() - err_handler.setLevel(logging.WARNING) - out_handler = logging.StreamHandler(sys.stdout) - out_handler.addFilter(_not_warning) - handlers = err_handler, out_handler - logging.basicConfig( - format="{message}", style="{", handlers=handlers, level=logging.DEBUG - ) diff --git a/src/wheel/_wheelfile.py b/src/wheel/_wheelfile.py new file mode 100644 index 00000000..c25acae8 --- /dev/null +++ b/src/wheel/_wheelfile.py @@ -0,0 +1,547 @@ +from __future__ import annotations + +import csv +import hashlib +import os.path +import re +import stat +import time +from base64 import urlsafe_b64decode, urlsafe_b64encode +from collections import OrderedDict +from collections.abc import Iterable, Iterator +from contextlib import ExitStack +from datetime import datetime, timezone +from email.message import Message +from email.policy import EmailPolicy +from io import BytesIO, StringIO, UnsupportedOperation +from os import PathLike +from pathlib import Path, PurePath +from types import TracebackType +from typing import IO, NamedTuple +from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile, ZipInfo + +from . import __version__ as wheel_version +from .vendored.packaging.tags import Tag +from .vendored.packaging.utils import ( + InvalidWheelFilename, + NormalizedName, + parse_wheel_filename, +) +from .vendored.packaging.version import Version + +_DIST_NAME_RE = re.compile(r"[^A-Za-z0-9.]+") +_EXCLUDE_FILENAMES = ("RECORD", "RECORD.jws", "RECORD.p7s") +DEFAULT_TIMESTAMP = datetime(1980, 1, 1, tzinfo=timezone.utc) +EMAIL_POLICY = EmailPolicy(max_line_length=0, mangle_from_=False, utf8=True) + + +class WheelMetadata(NamedTuple): + name: NormalizedName + version: Version + build_tag: tuple[int, str] | tuple[()] + tags: frozenset[Tag] + + @classmethod + def from_filename(cls, fname: str) -> WheelMetadata: + try: + name, version, build, tags = parse_wheel_filename(fname) + except InvalidWheelFilename as exc: + raise WheelError(f"Bad wheel filename {fname!r}") from exc + + return cls(name, version, build, tags) + + +class WheelRecordEntry(NamedTuple): + hash_algorithm: str + hash_value: bytes + filesize: int + + +class WheelContentElement(NamedTuple): + path: PurePath + hash_value: bytes + size: int + stream: IO[bytes] + + +def _encode_hash_value(hash_value: bytes) -> str: + return urlsafe_b64encode(hash_value).rstrip(b"=").decode("ascii") + + +def _decode_hash_value(encoded_hash: str) -> bytes: + pad = b"=" * (4 - (len(encoded_hash) & 3)) + return urlsafe_b64decode(encoded_hash.encode("ascii") + pad) + + +def make_filename( + name: str, + version: str, + build_tag: str | int | None = None, + impl_tag: str = "py3", + abi_tag: str = "none", + plat_tag: str = "any", +) -> str: + name = _DIST_NAME_RE.sub("_", name) + version = _DIST_NAME_RE.sub("_", version) + filename = f"{name}-{version}" + if build_tag: + filename = f"{filename}-{build_tag}" + + return f"{filename}-{impl_tag}-{abi_tag}-{plat_tag}.whl" + + +class WheelError(Exception): + pass + + +class WheelArchiveFile: + def __init__( + self, fp: IO[bytes], arcname: str, record_entry: WheelRecordEntry | None + ): + self._fp = fp + self._arcname = arcname + self._record_entry = record_entry + if record_entry: + self._hash = hashlib.new(record_entry.hash_algorithm) + self._num_bytes_read = 0 + + def read(self, amount: int = -1) -> bytes: + data = self._fp.read(amount) + if amount and self._record_entry is not None: + if data: + self._hash.update(data) + self._num_bytes_read += len(data) + elif self._record_entry: + # The file has been read in full – check that hash and file size match + # with the entry in RECORD + if self._hash.digest() != self._record_entry.hash_value: + raise WheelError(f"Hash mismatch for file {self._arcname!r}") + elif self._num_bytes_read != self._record_entry.filesize: + raise WheelError( + f"{self._arcname}: file size mismatch: " + f"{self._record_entry.filesize} bytes in RECORD, " + f"{self._num_bytes_read} bytes in archive" + ) + + return data + + def __enter__(self) -> WheelArchiveFile: + return self + + def __exit__( + self, + exc_type: type[BaseException], + exc_val: BaseException, + exc_tb: TracebackType, + ) -> None: + self._fp.close() + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self._fp!r}, {self._arcname!r})" + + +class WheelReader: + name: NormalizedName + version: Version + _zip: ZipFile + _dist_info_dir: str + _data_dir: str + _record_entries: OrderedDict[str, WheelRecordEntry] + + def __init__(self, path_or_fd: str | PathLike[str] | IO[bytes]): + self.path_or_fd = path_or_fd + + if isinstance(path_or_fd, (str, PathLike)): + fname = Path(path_or_fd).name + try: + self.name, self.version = parse_wheel_filename(fname)[:2] + except InvalidWheelFilename as exc: + raise WheelError(str(exc)) from None + + def __enter__(self) -> WheelReader: + self._zip = ZipFile(self.path_or_fd, "r") + + # See if the expected .dist-info directory is in place by searching for RECORD + # in the expected directory. Wheels made with older versions of "wheel" did not + # properly normalize the names, so the name of the .dist-info directory does not + # match the expectation there. + dist_info_dir: str | None = None + if hasattr(self, "name"): + dist_info_dir = f"{self.name}-{self.version}.dist-info" + try: + self._zip.getinfo(f"{dist_info_dir}/RECORD") + except KeyError: + dist_info_dir = None + else: + self._dist_info_dir = dist_info_dir + self._data_dir = f"{self.name}-{self.version}.data" + + # If no .dist-info directory could not be found yet, resort to scanning the + # archive's file names for any .dist-info directory containing a RECORD file. + if dist_info_dir is None: + try: + for zinfo in reversed(self._zip.infolist()): + if zinfo.filename.endswith(".dist-info/RECORD"): + dist_info_dir = zinfo.filename.rsplit("/", 1)[0] + namever = dist_info_dir.rsplit(".", 1)[0] + name, version = namever.rpartition("-")[::2] + if name and version: + self.name = NormalizedName(name) + self.version = Version(version) + self._dist_info_dir = dist_info_dir + self._data_dir = dist_info_dir.replace( + ".dist-info", ".data" + ) + break + else: + raise WheelError( + "Cannot find a valid .dist-info directory. " + "Is this really a wheel file?" + ) + except BaseException: + self._zip.close() + raise + + self._record_entries = self._read_record() + return self + + def __exit__( + self, + exc_type: type[BaseException], + exc_val: BaseException, + exc_tb: TracebackType, + ) -> None: + self._zip.close() + self._record_entries.clear() + del self._zip + + def _read_record(self) -> OrderedDict[str, WheelRecordEntry]: + entries = OrderedDict() + try: + contents = self.read_dist_info("RECORD") + except WheelError: + raise WheelError(f"Missing {self._dist_info_dir}/RECORD file") from None + + reader = csv.reader( + contents.strip().split("\n"), + delimiter=",", + quotechar='"', + lineterminator="\n", + ) + for row in reader: + if not row: + break + + path, hash_digest, filesize = row + if hash_digest: + algorithm, hash_digest = hash_digest.split("=") + try: + hashlib.new(algorithm) + except ValueError: + raise WheelError( + f"Unsupported hash algorithm: {algorithm}" + ) from None + + if algorithm.lower() in {"md5", "sha1"}: + raise WheelError( + f"Weak hash algorithm ({algorithm}) is not permitted by PEP 427" + ) + + entries[path] = WheelRecordEntry( + algorithm, _decode_hash_value(hash_digest), int(filesize) + ) + + return entries + + @property + def dist_info_dir(self) -> str: + return self._dist_info_dir + + @property + def data_dir(self) -> str: + return self._data_dir + + @property + def dist_info_filenames(self) -> list[PurePath]: + return [ + PurePath(fname) + for fname in self._zip.namelist() + if fname.startswith(self._dist_info_dir) + ] + + @property + def filenames(self) -> list[PurePath]: + return [PurePath(fname) for fname in self._zip.namelist()] + + def read_dist_info(self, filename: str) -> str: + filename = self.dist_info_dir + "/" + filename + try: + contents = self._zip.read(filename) + except KeyError: + raise WheelError(f"File {filename!r} not found") from None + + return contents.decode("utf-8") + + def get_contents(self) -> Iterator[WheelContentElement]: + for fname, entry in self._record_entries.items(): + with self._zip.open(fname, "r") as stream: + yield WheelContentElement( + PurePath(fname), entry.hash_value, entry.filesize, stream + ) + + def validate_record(self) -> None: + """Verify the integrity of the contained files.""" + for zinfo in self._zip.infolist(): + # Ignore signature files + basename = os.path.basename(zinfo.filename) + if basename in _EXCLUDE_FILENAMES: + continue + + try: + record = self._record_entries[zinfo.filename] + except KeyError: + raise WheelError(f"No hash found for file {zinfo.filename!r}") from None + + hash_ = hashlib.new(record.hash_algorithm) + with self._zip.open(zinfo) as fp: + hash_.update(fp.read(65536)) + + if hash_.digest() != record.hash_value: + raise WheelError(f"Hash mismatch for file {zinfo.filename!r}") + + def extractall(self, base_path: str | PathLike[str]) -> None: + basedir = Path(base_path) + if not basedir.exists(): + raise WheelError(f"{basedir} does not exist") + elif not basedir.is_dir(): + raise WheelError(f"{basedir} is not a directory") + + for fname in self._zip.namelist(): + target_path = basedir.joinpath(fname) + target_path.parent.mkdir(0o755, True, True) + with self._open_file(fname) as infile, target_path.open("wb") as outfile: + while True: + data = infile.read(65536) + if not data: + break + + outfile.write(data) + + def _open_file(self, archive_name: str) -> WheelArchiveFile: + basename = os.path.basename(archive_name) + if basename in _EXCLUDE_FILENAMES: + record_entry = None + else: + record_entry = self._record_entries[archive_name] + + return WheelArchiveFile( + self._zip.open(archive_name), archive_name, record_entry + ) + + def read_file(self, archive_name: str) -> bytes: + with self._open_file(archive_name) as fp: + return fp.read() + + def read_data_file(self, filename: str) -> bytes: + archive_path = self._data_dir + "/" + filename.strip("/") + return self.read_file(archive_path) + + def read_distinfo_file(self, filename: str) -> bytes: + archive_path = self._dist_info_dir + "/" + filename.strip("/") + return self.read_file(archive_path) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.path_or_fd})" + + +def write_wheelfile( + fp: IO[bytes], metadata: WheelMetadata, generator: str, root_is_purelib: bool +) -> None: + msg = Message(policy=EMAIL_POLICY) + msg["Wheel-Version"] = "1.0" # of the spec + msg["Generator"] = generator + msg["Root-Is-Purelib"] = str(root_is_purelib).lower() + if metadata.build_tag: + msg["Build"] = str(metadata.build_tag[0]) + metadata.build_tag[1] + + for tag in sorted(metadata.tags, key=lambda t: (t.interpreter, t.abi, t.platform)): + msg["Tag"] = f"{tag.interpreter}-{tag.abi}-{tag.platform}" + + fp.write(msg.as_bytes()) + + +class WheelWriter: + def __init__( + self, + path_or_fd: str | PathLike[str] | IO[bytes], + metadata: WheelMetadata | None = None, + *, + generator: str | None = None, + root_is_purelib: bool = True, + compress: bool = True, + hash_algorithm: str = "sha256", + ): + self.path_or_fd = path_or_fd + self.generator = generator or f"Wheel ({wheel_version})" + self.root_is_purelib = root_is_purelib + self.hash_algorithm = hash_algorithm + self._compress_type = ZIP_DEFLATED if compress else ZIP_STORED + + if metadata: + self.metadata = metadata + elif isinstance(path_or_fd, (str, PathLike)): + filename = Path(path_or_fd).name + self.metadata = WheelMetadata.from_filename(filename) + else: + raise WheelError("path_or_fd is not a path, and metadata was not provided") + + if hash_algorithm not in hashlib.algorithms_available: + raise ValueError(f"Hash algorithm {hash_algorithm!r} is not available") + elif hash_algorithm in ("md5", "sha1"): + raise ValueError( + f"Weak hash algorithm ({hash_algorithm}) is not permitted by PEP 427" + ) + + self._dist_info_dir = f"{self.metadata.name}-{self.metadata.version}.dist-info" + self._data_dir = f"{self.metadata.name}-{self.metadata.version}.data" + self._record_path = f"{self._dist_info_dir}/RECORD" + self._record_entries: dict[str, WheelRecordEntry] = OrderedDict() + + def __enter__(self) -> WheelWriter: + self._zip = ZipFile(self.path_or_fd, "w", compression=self._compress_type) + return self + + def __exit__( + self, + exc_type: type[BaseException], + exc_val: BaseException, + exc_tb: TracebackType, + ) -> None: + try: + if not exc_type: + if f"{self._dist_info_dir}/WHEEL" not in self._record_entries: + self._write_wheelfile() + + self._write_record() + finally: + self._zip.close() + + def _write_record(self) -> None: + data = StringIO() + writer = csv.writer(data, delimiter=",", quotechar='"', lineterminator="\n") + writer.writerows( + [ + ( + fname, + entry.hash_algorithm + "=" + _encode_hash_value(entry.hash_value), + entry.filesize, + ) + for fname, entry in self._record_entries.items() + ] + ) + writer.writerow((self._record_path, "", "")) + self.write_distinfo_file("RECORD", data.getvalue()) + + def _write_wheelfile(self) -> None: + buffer = BytesIO() + write_wheelfile(buffer, self.metadata, self.generator, self.root_is_purelib) + self.write_distinfo_file("WHEEL", buffer.getvalue()) + + def write_metadata(self, items: Iterable[tuple[str, str]]) -> None: + msg = Message(policy=EMAIL_POLICY) + for key, value in items: + key = key.title() + if key == "Description": + msg.set_payload(value, "utf-8") + else: + msg.add_header(key, value) + + if "Metadata-Version" not in msg: + msg["Metadata-Version"] = "2.1" + if "Name" not in msg: + msg["Name"] = self.metadata.name + if "Version" not in msg: + msg["Version"] = str(self.metadata.version) + + self.write_distinfo_file("METADATA", msg.as_bytes()) + + def write_file( + self, + name: str | PurePath, + contents: bytes | str | PathLike[str] | IO[bytes], + timestamp: datetime = DEFAULT_TIMESTAMP, + ) -> None: + arcname = PurePath(name).as_posix() + gmtime = time.gmtime(timestamp.timestamp()) + zinfo = ZipInfo(arcname, gmtime[:6]) + zinfo.compress_type = self._compress_type + zinfo.external_attr = 0o664 << 16 + with ExitStack() as exit_stack: + fp = exit_stack.enter_context(self._zip.open(zinfo, "w")) + if isinstance(contents, str): + contents = contents.encode("utf-8") + elif isinstance(contents, PathLike): + contents = exit_stack.enter_context(Path(contents).open("rb")) + + if isinstance(contents, bytes): + file_size = len(contents) + fp.write(contents) + hash_ = hashlib.new(self.hash_algorithm, contents) + else: + try: + st = os.stat(contents.fileno()) + except (AttributeError, UnsupportedOperation): + pass + else: + zinfo.external_attr = ( + stat.S_IMODE(st.st_mode) | stat.S_IFMT(st.st_mode) + ) << 16 + + hash_ = hashlib.new(self.hash_algorithm) + while True: + buffer = contents.read(65536) + if not buffer: + file_size = contents.tell() + break + + hash_.update(buffer) + fp.write(buffer) + + self._record_entries[arcname] = WheelRecordEntry( + self.hash_algorithm, hash_.digest(), file_size + ) + + def write_files_from_directory(self, directory: str | PathLike[str]) -> None: + basedir = Path(directory) + if not basedir.exists(): + raise WheelError(f"{basedir} does not exist") + elif not basedir.is_dir(): + raise WheelError(f"{basedir} is not a directory") + + for root, _dirs, files in os.walk(basedir): + for fname in files: + path = Path(root) / fname + relative = path.relative_to(basedir) + if relative.as_posix() != self._record_path: + self.write_file(relative, path) + + def write_data_file( + self, + filename: str, + contents: bytes | str | PathLike[str] | IO[bytes], + timestamp: datetime = DEFAULT_TIMESTAMP, + ) -> None: + archive_path = self._data_dir + "/" + filename.strip("/") + self.write_file(archive_path, contents, timestamp) + + def write_distinfo_file( + self, + filename: str, + contents: bytes | str | IO[bytes], + timestamp: datetime = DEFAULT_TIMESTAMP, + ) -> None: + archive_path = self._dist_info_dir + "/" + filename.strip() + self.write_file(archive_path, contents, timestamp) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.path_or_fd!r})" diff --git a/src/wheel/bdist_wheel.py b/src/wheel/bdist_wheel.py index 7fcf4a37..a8171ff6 100644 --- a/src/wheel/bdist_wheel.py +++ b/src/wheel/bdist_wheel.py @@ -13,38 +13,30 @@ import sys import sysconfig import warnings -from collections import OrderedDict -from email.generator import BytesGenerator, Generator -from email.policy import EmailPolicy -from glob import iglob -from io import BytesIO +from logging import getLogger +from pathlib import Path from shutil import rmtree -from zipfile import ZIP_DEFLATED, ZIP_STORED +from types import TracebackType +from typing import Any, Callable -import pkg_resources +from pkg_resources import safe_name, safe_version from setuptools import Command -from . import __version__ as wheel_version -from .macosx_libfile import calculate_macosx_platform_tag -from .metadata import pkginfo_to_metadata -from .util import log +from ._macosx_libfile import calculate_macosx_platform_tag +from ._metadata import pkginfo_to_metadata +from ._wheelfile import WheelWriter, make_filename from .vendored.packaging import tags -from .wheelfile import WheelFile -safe_name = pkg_resources.safe_name -safe_version = pkg_resources.safe_version -setuptools_major_version = int( - pkg_resources.get_distribution("setuptools").version.split(".")[0] -) +logger = getLogger("wheel") PY_LIMITED_API_PATTERN = r"cp3\d" -def python_tag(): +def python_tag() -> str: return f"py{sys.version_info[0]}" -def get_platform(archive_root): +def get_platform(archive_root: str | None) -> str: """Return our platform name 'win32', 'linux_x86_64'""" result = sysconfig.get_platform() if result.startswith("macosx") and archive_root is not None: @@ -56,23 +48,26 @@ def get_platform(archive_root): return result.replace("-", "_") -def get_flag(var, fallback, expected=True, warn=True): +def get_flag( + var: str, fallback: bool, expected: bool = True, warn: bool = True +) -> bool: """Use a fallback value for determining SOABI flags if the needed config var is unset or unavailable.""" val = sysconfig.get_config_var(var) if val is None: if warn: warnings.warn( - "Config variable '{}' is unset, Python ABI tag may " - "be incorrect".format(var), + f"Config variable '{var}' is unset, Python ABI tag may " "be incorrect", RuntimeWarning, 2, ) + return fallback + return val == expected -def get_abi_tag(): +def get_abi_tag() -> str | None: """Return the ABI tag based on SOABI (if available) or emulate SOABI (PyPy2).""" soabi = sysconfig.get_config_var("SOABI") impl = tags.interpreter_name() @@ -105,15 +100,19 @@ def get_abi_tag(): return abi -def safer_name(name): +def safer_name(name: str) -> str: return safe_name(name).replace("-", "_") -def safer_version(version): +def safer_version(version: str) -> str: return safe_version(version).replace("-", "_") -def remove_readonly(func, path, excinfo): +def remove_readonly( + func: Callable[..., Any], + path: Any, + excinfo: tuple[type[BaseException], BaseException, TracebackType], +) -> None: print(str(excinfo[1])) os.chmod(path, stat.S_IWRITE) func(path) @@ -123,9 +122,7 @@ class bdist_wheel(Command): description = "create a wheel distribution" - supported_compressions = OrderedDict( - [("stored", ZIP_STORED), ("deflated", ZIP_DEFLATED)] - ) + supported_compressions = ("stored", "deflated") user_options = [ ("bdist-dir=", "b", "temporary directory for creating the distribution"), @@ -148,16 +145,6 @@ class bdist_wheel(Command): None, "build the archive using relative paths " "(default: false)", ), - ( - "owner=", - "u", - "Owner name used when creating a tar file" " [default: current user]", - ), - ( - "group=", - "g", - "Group name used when creating a tar file" " [default: current group]", - ), ("universal", None, "make a universal wheel" " (default: false)"), ( "compression=", @@ -187,38 +174,35 @@ class bdist_wheel(Command): boolean_options = ["keep-temp", "skip-build", "relative", "universal"] - def initialize_options(self): - self.bdist_dir = None - self.data_dir = None - self.plat_name = None - self.plat_tag = None - self.format = "zip" + def initialize_options(self) -> None: + self.bdist_dir: Any = None + self.data_dir: Any = None + self.plat_name: Any = None + self.plat_tag: Any = None self.keep_temp = False - self.dist_dir = None - self.egginfo_dir = None - self.root_is_pure = None - self.skip_build = None + self.dist_dir: Any = None + self.egginfo_dir: Any = None + self.root_is_pure: Any = None + self.skip_build: None | bool = None self.relative = False - self.owner = None - self.group = None self.universal = False self.compression = "deflated" self.python_tag = python_tag() self.build_number = None - self.py_limited_api = False + self.py_limited_api: Any = False self.plat_name_supplied = False - def finalize_options(self): + def finalize_options(self) -> None: if self.bdist_dir is None: - bdist_base = self.get_finalized_command("bdist").bdist_base + bdist_base = self.get_finalized_command( # type: ignore[attr-defined] + "bdist" + ).bdist_base self.bdist_dir = os.path.join(bdist_base, "wheel") self.data_dir = self.wheel_dist_name + ".data" self.plat_name_supplied = self.plat_name is not None - try: - self.compression = self.supported_compressions[self.compression] - except KeyError: + if self.compression not in self.supported_compressions: raise ValueError(f"Unsupported compression: {self.compression}") need_options = ("dist_dir", "plat_name", "skip_build") @@ -226,19 +210,20 @@ def finalize_options(self): self.set_undefined_options("bdist", *zip(need_options, need_options)) self.root_is_pure = not ( - self.distribution.has_ext_modules() or self.distribution.has_c_libraries() + self.distribution.has_ext_modules() # type: ignore[attr-defined] + or self.distribution.has_c_libraries() # type: ignore[attr-defined] ) if self.py_limited_api and not re.match( PY_LIMITED_API_PATTERN, self.py_limited_api ): - raise ValueError("py-limited-api must match '%s'" % PY_LIMITED_API_PATTERN) + raise ValueError(f"py-limited-api must match {PY_LIMITED_API_PATTERN!r}") # Support legacy [wheel] section for setting universal - wheel = self.distribution.get_option_dict("wheel") + wheel = self.distribution.get_option_dict("wheel") # type: ignore[attr-defined] if "universal" in wheel: # please don't define this in your global configs - log.warning( + logger.warning( "The [wheel] section is deprecated. Use [bdist_wheel] instead.", ) val = wheel["universal"][1].strip() @@ -249,17 +234,19 @@ def finalize_options(self): raise ValueError("Build tag (build-number) must start with a digit.") @property - def wheel_dist_name(self): + def wheel_dist_name(self) -> str: """Return distribution full name with - replaced with _""" - components = ( - safer_name(self.distribution.get_name()), - safer_version(self.distribution.get_version()), + components: tuple[str, ...] = ( + safer_name(self.distribution.get_name()), # type: ignore[attr-defined] + safer_version( + self.distribution.get_version() # type: ignore[attr-defined] + ), ) if self.build_number: components += (self.build_number,) return "-".join(components) - def get_tag(self): + def get_tag(self) -> tuple[str, str, str]: # bdist sets self.plat_name if unset, we should only use it for purepy # wheels if the user supplied it. if self.plat_name_supplied: @@ -313,28 +300,28 @@ def get_tag(self): ), f"would build wheel with unsupported tag {tag}" return tag - def run(self): + def run(self) -> None: build_scripts = self.reinitialize_command("build_scripts") - build_scripts.executable = "python" - build_scripts.force = True + build_scripts.executable = "python" # type: ignore[attr-defined] + build_scripts.force = True # type: ignore[attr-defined] build_ext = self.reinitialize_command("build_ext") - build_ext.inplace = False + build_ext.inplace = False # type: ignore[attr-defined] if not self.skip_build: self.run_command("build") install = self.reinitialize_command("install", reinit_subcommands=True) - install.root = self.bdist_dir - install.compile = False - install.skip_build = self.skip_build - install.warn_dir = False + install.root = self.bdist_dir # type: ignore[attr-defined] + install.compile = False # type: ignore[attr-defined] + install.skip_build = self.skip_build # type: ignore[attr-defined] + install.warn_dir = False # type: ignore[attr-defined] # A wheel without setuptools scripts is more cross-platform. # Use the (undocumented) `no_ep` option to setuptools' # install_scripts command to avoid creating entry point scripts. install_scripts = self.reinitialize_command("install_scripts") - install_scripts.no_ep = True + install_scripts.no_ep = True # type: ignore[attr-defined] # Use a custom scheme for the archive, because we have to decide # at installation time which scheme to use. @@ -355,196 +342,101 @@ def run(self): basedir_observed, ) - log.info(f"installing to {self.bdist_dir}") - + logger.info("installing to %s", self.bdist_dir) self.run_command("install") impl_tag, abi_tag, plat_tag = self.get_tag() - archive_basename = f"{self.wheel_dist_name}-{impl_tag}-{abi_tag}-{plat_tag}" - if not self.relative: - archive_root = self.bdist_dir - else: - archive_root = os.path.join( - self.bdist_dir, self._ensure_relative(install.install_base) - ) - - self.set_undefined_options("install_egg_info", ("target", "egginfo_dir")) - distinfo_dirname = "{}-{}.dist-info".format( - safer_name(self.distribution.get_name()), - safer_version(self.distribution.get_version()), + archive_basename = make_filename( + self.distribution.get_name(), # type: ignore[attr-defined] + self.distribution.get_version(), # type: ignore[attr-defined] + self.build_number, + impl_tag, + abi_tag, + plat_tag, ) - distinfo_dir = os.path.join(self.bdist_dir, distinfo_dirname) - self.egg2dist(self.egginfo_dir, distinfo_dir) - - self.write_wheelfile(distinfo_dir) + archive_root = Path(self.bdist_dir) + if self.relative: + archive_root /= self._ensure_relative( + install.install_base # type: ignore[attr-defined] + ) # Make the archive if not os.path.exists(self.dist_dir): os.makedirs(self.dist_dir) - wheel_path = os.path.join(self.dist_dir, archive_basename + ".whl") - with WheelFile(wheel_path, "w", self.compression) as wf: - wf.write_files(archive_root) + wheel_path = Path(self.dist_dir) / archive_basename + logger.info("creating '%s' and adding '%s' to it", wheel_path, archive_root) + with WheelWriter( + wheel_path, + compress=self.compression == "deflated", + root_is_purelib=self.root_is_pure, + ) as wf: + deferred = [] + for root, dirnames, filenames in os.walk(archive_root): + # Sort the directory names so that `os.walk` will walk them in a + # defined order on the next iteration. + dirnames.sort() + root_path = Path(root) + if root_path.name.endswith(".egg-info"): + continue + + for name in sorted(filenames): + path = root_path / name + if path.is_file(): + archive_name = path.relative_to(archive_root).as_posix() + if root.endswith(".dist-info"): + deferred.append((path, archive_name)) + else: + logger.info("adding '%s'", archive_name) + wf.write_file(archive_name, path.read_bytes()) + + for path, archive_name in sorted(deferred): + logger.info("adding '%s'", archive_name) + wf.write_file(archive_name, path.read_bytes()) + + # Write the license files + metadata = self.distribution.metadata # type: ignore[attr-defined] + files = sorted(metadata.license_files or []) + license_paths = [Path(path) for path in files] + for license_path in license_paths: + logger.info("adding '%s'", license_path.name) + wf.write_distinfo_file(license_path.name, license_path.read_bytes()) + + # Write the metadata files from the .egg-info directory + self.set_undefined_options("install_egg_info", ("target", "egginfo_dir")) + for path in Path(self.egginfo_dir).iterdir(): + if path.name == "PKG-INFO": + items = pkginfo_to_metadata(path) + wf.write_metadata(items) + elif path.name not in { + "requires.txt", + "SOURCES.txt", + "not-zip-safe", + "dependency_links.txt", + }: + wf.write_distinfo_file(path.name, path.read_bytes()) + + shutil.rmtree(self.egginfo_dir) # Add to 'Distribution.dist_files' so that the "upload" command works - getattr(self.distribution, "dist_files", []).append( + getattr( + self.distribution, "dist_files", [] # type: ignore[attr-defined] + ).append( ( "bdist_wheel", "{}.{}".format(*sys.version_info[:2]), # like 3.7 - wheel_path, + str(wheel_path), ) ) if not self.keep_temp: - log.info(f"removing {self.bdist_dir}") - if not self.dry_run: + logger.info(f"removing {self.bdist_dir}") + if not self.dry_run: # type: ignore[attr-defined] rmtree(self.bdist_dir, onerror=remove_readonly) - def write_wheelfile( - self, wheelfile_base, generator="bdist_wheel (" + wheel_version + ")" - ): - from email.message import Message - - msg = Message() - msg["Wheel-Version"] = "1.0" # of the spec - msg["Generator"] = generator - msg["Root-Is-Purelib"] = str(self.root_is_pure).lower() - if self.build_number is not None: - msg["Build"] = self.build_number - - # Doesn't work for bdist_wininst - impl_tag, abi_tag, plat_tag = self.get_tag() - for impl in impl_tag.split("."): - for abi in abi_tag.split("."): - for plat in plat_tag.split("."): - msg["Tag"] = "-".join((impl, abi, plat)) - - wheelfile_path = os.path.join(wheelfile_base, "WHEEL") - log.info(f"creating {wheelfile_path}") - buffer = BytesIO() - BytesGenerator(buffer, maxheaderlen=0).flatten(msg) - with open(wheelfile_path, "wb") as f: - f.write(buffer.getvalue().replace(b"\r\n", b"\r")) - - def _ensure_relative(self, path): + def _ensure_relative(self, path: str) -> str: # copied from dir_util, deleted drive, path = os.path.splitdrive(path) if path[0:1] == os.sep: path = drive + path[1:] return path - - @property - def license_paths(self): - if setuptools_major_version >= 57: - # Setuptools has resolved any patterns to actual file names - return self.distribution.metadata.license_files or () - - files = set() - metadata = self.distribution.get_option_dict("metadata") - if setuptools_major_version >= 42: - # Setuptools recognizes the license_files option but does not do globbing - patterns = self.distribution.metadata.license_files - else: - # Prior to those, wheel is entirely responsible for handling license files - if "license_files" in metadata: - patterns = metadata["license_files"][1].split() - else: - patterns = () - - if "license_file" in metadata: - warnings.warn( - 'The "license_file" option is deprecated. Use "license_files" instead.', - DeprecationWarning, - ) - files.add(metadata["license_file"][1]) - - if not files and not patterns and not isinstance(patterns, list): - patterns = ("LICEN[CS]E*", "COPYING*", "NOTICE*", "AUTHORS*") - - for pattern in patterns: - for path in iglob(pattern): - if path.endswith("~"): - log.debug( - f'ignoring license file "{path}" as it looks like a backup' - ) - continue - - if path not in files and os.path.isfile(path): - log.info( - f'adding license file "{path}" (matched pattern "{pattern}")' - ) - files.add(path) - - return files - - def egg2dist(self, egginfo_path, distinfo_path): - """Convert an .egg-info directory into a .dist-info directory""" - - def adios(p): - """Appropriately delete directory, file or link.""" - if os.path.exists(p) and not os.path.islink(p) and os.path.isdir(p): - shutil.rmtree(p) - elif os.path.exists(p): - os.unlink(p) - - adios(distinfo_path) - - if not os.path.exists(egginfo_path): - # There is no egg-info. This is probably because the egg-info - # file/directory is not named matching the distribution name used - # to name the archive file. Check for this case and report - # accordingly. - import glob - - pat = os.path.join(os.path.dirname(egginfo_path), "*.egg-info") - possible = glob.glob(pat) - err = f"Egg metadata expected at {egginfo_path} but not found" - if possible: - alt = os.path.basename(possible[0]) - err += f" ({alt} found - possible misnamed archive file?)" - - raise ValueError(err) - - if os.path.isfile(egginfo_path): - # .egg-info is a single file - pkginfo_path = egginfo_path - pkg_info = pkginfo_to_metadata(egginfo_path, egginfo_path) - os.mkdir(distinfo_path) - else: - # .egg-info is a directory - pkginfo_path = os.path.join(egginfo_path, "PKG-INFO") - pkg_info = pkginfo_to_metadata(egginfo_path, pkginfo_path) - - # ignore common egg metadata that is useless to wheel - shutil.copytree( - egginfo_path, - distinfo_path, - ignore=lambda x, y: { - "PKG-INFO", - "requires.txt", - "SOURCES.txt", - "not-zip-safe", - }, - ) - - # delete dependency_links if it is only whitespace - dependency_links_path = os.path.join(distinfo_path, "dependency_links.txt") - with open(dependency_links_path) as dependency_links_file: - dependency_links = dependency_links_file.read().strip() - if not dependency_links: - adios(dependency_links_path) - - pkg_info_path = os.path.join(distinfo_path, "METADATA") - serialization_policy = EmailPolicy( - utf8=True, - mangle_from_=False, - max_line_length=0, - ) - with open(pkg_info_path, "w", encoding="utf-8") as out: - Generator(out, policy=serialization_policy).flatten(pkg_info) - - for license_path in self.license_paths: - filename = os.path.basename(license_path) - shutil.copy(license_path, os.path.join(distinfo_path, filename)) - - adios(egginfo_path) diff --git a/src/wheel/cli/convert.py b/src/wheel/cli/convert.py deleted file mode 100755 index 1287059d..00000000 --- a/src/wheel/cli/convert.py +++ /dev/null @@ -1,273 +0,0 @@ -from __future__ import annotations - -import os.path -import re -import shutil -import tempfile -import zipfile -from glob import iglob - -from ..bdist_wheel import bdist_wheel -from ..wheelfile import WheelFile -from . import WheelError - -try: - from setuptools import Distribution -except ImportError: - from distutils.dist import Distribution - -egg_info_re = re.compile( - r""" - (?P.+?)-(?P.+?) - (-(?Ppy\d\.\d+) - (-(?P.+?))? - )?.egg$""", - re.VERBOSE, -) - - -class _bdist_wheel_tag(bdist_wheel): - # allow the client to override the default generated wheel tag - # The default bdist_wheel implementation uses python and abi tags - # of the running python process. This is not suitable for - # generating/repackaging prebuild binaries. - - full_tag_supplied = False - full_tag = None # None or a (pytag, soabitag, plattag) triple - - def get_tag(self): - if self.full_tag_supplied and self.full_tag is not None: - return self.full_tag - else: - return bdist_wheel.get_tag(self) - - -def egg2wheel(egg_path: str, dest_dir: str): - filename = os.path.basename(egg_path) - match = egg_info_re.match(filename) - if not match: - raise WheelError(f"Invalid egg file name: {filename}") - - egg_info = match.groupdict() - dir = tempfile.mkdtemp(suffix="_e2w") - if os.path.isfile(egg_path): - # assume we have a bdist_egg otherwise - with zipfile.ZipFile(egg_path) as egg: - egg.extractall(dir) - else: - # support buildout-style installed eggs directories - for pth in os.listdir(egg_path): - src = os.path.join(egg_path, pth) - if os.path.isfile(src): - shutil.copy2(src, dir) - else: - shutil.copytree(src, os.path.join(dir, pth)) - - pyver = egg_info["pyver"] - if pyver: - pyver = egg_info["pyver"] = pyver.replace(".", "") - - arch = (egg_info["arch"] or "any").replace(".", "_").replace("-", "_") - - # assume all binary eggs are for CPython - abi = "cp" + pyver[2:] if arch != "any" else "none" - - root_is_purelib = egg_info["arch"] is None - if root_is_purelib: - bw = bdist_wheel(Distribution()) - else: - bw = _bdist_wheel_tag(Distribution()) - - bw.root_is_pure = root_is_purelib - bw.python_tag = pyver - bw.plat_name_supplied = True - bw.plat_name = egg_info["arch"] or "any" - if not root_is_purelib: - bw.full_tag_supplied = True - bw.full_tag = (pyver, abi, arch) - - dist_info_dir = os.path.join(dir, "{name}-{ver}.dist-info".format(**egg_info)) - bw.egg2dist(os.path.join(dir, "EGG-INFO"), dist_info_dir) - bw.write_wheelfile(dist_info_dir, generator="egg2wheel") - wheel_name = "{name}-{ver}-{pyver}-{}-{}.whl".format(abi, arch, **egg_info) - with WheelFile(os.path.join(dest_dir, wheel_name), "w") as wf: - wf.write_files(dir) - - shutil.rmtree(dir) - - -def parse_wininst_info(wininfo_name, egginfo_name): - """Extract metadata from filenames. - - Extracts the 4 metadataitems needed (name, version, pyversion, arch) from - the installer filename and the name of the egg-info directory embedded in - the zipfile (if any). - - The egginfo filename has the format:: - - name-ver(-pyver)(-arch).egg-info - - The installer filename has the format:: - - name-ver.arch(-pyver).exe - - Some things to note: - - 1. The installer filename is not definitive. An installer can be renamed - and work perfectly well as an installer. So more reliable data should - be used whenever possible. - 2. The egg-info data should be preferred for the name and version, because - these come straight from the distutils metadata, and are mandatory. - 3. The pyver from the egg-info data should be ignored, as it is - constructed from the version of Python used to build the installer, - which is irrelevant - the installer filename is correct here (even to - the point that when it's not there, any version is implied). - 4. The architecture must be taken from the installer filename, as it is - not included in the egg-info data. - 5. Architecture-neutral installers still have an architecture because the - installer format itself (being executable) is architecture-specific. We - should therefore ignore the architecture if the content is pure-python. - """ - - egginfo = None - if egginfo_name: - egginfo = egg_info_re.search(egginfo_name) - if not egginfo: - raise ValueError(f"Egg info filename {egginfo_name} is not valid") - - # Parse the wininst filename - # 1. Distribution name (up to the first '-') - w_name, sep, rest = wininfo_name.partition("-") - if not sep: - raise ValueError(f"Installer filename {wininfo_name} is not valid") - - # Strip '.exe' - rest = rest[:-4] - # 2. Python version (from the last '-', must start with 'py') - rest2, sep, w_pyver = rest.rpartition("-") - if sep and w_pyver.startswith("py"): - rest = rest2 - w_pyver = w_pyver.replace(".", "") - else: - # Not version specific - use py2.py3. While it is possible that - # pure-Python code is not compatible with both Python 2 and 3, there - # is no way of knowing from the wininst format, so we assume the best - # here (the user can always manually rename the wheel to be more - # restrictive if needed). - w_pyver = "py2.py3" - # 3. Version and architecture - w_ver, sep, w_arch = rest.rpartition(".") - if not sep: - raise ValueError(f"Installer filename {wininfo_name} is not valid") - - if egginfo: - w_name = egginfo.group("name") - w_ver = egginfo.group("ver") - - return {"name": w_name, "ver": w_ver, "arch": w_arch, "pyver": w_pyver} - - -def wininst2wheel(path, dest_dir): - with zipfile.ZipFile(path) as bdw: - # Search for egg-info in the archive - egginfo_name = None - for filename in bdw.namelist(): - if ".egg-info" in filename: - egginfo_name = filename - break - - info = parse_wininst_info(os.path.basename(path), egginfo_name) - - root_is_purelib = True - for zipinfo in bdw.infolist(): - if zipinfo.filename.startswith("PLATLIB"): - root_is_purelib = False - break - if root_is_purelib: - paths = {"purelib": ""} - else: - paths = {"platlib": ""} - - dist_info = "%(name)s-%(ver)s" % info - datadir = "%s.data/" % dist_info - - # rewrite paths to trick ZipFile into extracting an egg - # XXX grab wininst .ini - between .exe, padding, and first zip file. - members = [] - egginfo_name = "" - for zipinfo in bdw.infolist(): - key, basename = zipinfo.filename.split("/", 1) - key = key.lower() - basepath = paths.get(key, None) - if basepath is None: - basepath = datadir + key.lower() + "/" - oldname = zipinfo.filename - newname = basepath + basename - zipinfo.filename = newname - del bdw.NameToInfo[oldname] - bdw.NameToInfo[newname] = zipinfo - # Collect member names, but omit '' (from an entry like "PLATLIB/" - if newname: - members.append(newname) - # Remember egg-info name for the egg2dist call below - if not egginfo_name: - if newname.endswith(".egg-info"): - egginfo_name = newname - elif ".egg-info/" in newname: - egginfo_name, sep, _ = newname.rpartition("/") - dir = tempfile.mkdtemp(suffix="_b2w") - bdw.extractall(dir, members) - - # egg2wheel - abi = "none" - pyver = info["pyver"] - arch = (info["arch"] or "any").replace(".", "_").replace("-", "_") - # Wininst installers always have arch even if they are not - # architecture-specific (because the format itself is). - # So, assume the content is architecture-neutral if root is purelib. - if root_is_purelib: - arch = "any" - # If the installer is architecture-specific, it's almost certainly also - # CPython-specific. - if arch != "any": - pyver = pyver.replace("py", "cp") - wheel_name = "-".join((dist_info, pyver, abi, arch)) - if root_is_purelib: - bw = bdist_wheel(Distribution()) - else: - bw = _bdist_wheel_tag(Distribution()) - - bw.root_is_pure = root_is_purelib - bw.python_tag = pyver - bw.plat_name_supplied = True - bw.plat_name = info["arch"] or "any" - - if not root_is_purelib: - bw.full_tag_supplied = True - bw.full_tag = (pyver, abi, arch) - - dist_info_dir = os.path.join(dir, "%s.dist-info" % dist_info) - bw.egg2dist(os.path.join(dir, egginfo_name), dist_info_dir) - bw.write_wheelfile(dist_info_dir, generator="wininst2wheel") - - wheel_path = os.path.join(dest_dir, wheel_name) - with WheelFile(wheel_path, "w") as wf: - wf.write_files(dir) - - shutil.rmtree(dir) - - -def convert(files, dest_dir, verbose): - for pat in files: - for installer in iglob(pat): - if os.path.splitext(installer)[1] == ".egg": - conv = egg2wheel - else: - conv = wininst2wheel - - if verbose: - print(f"{installer}... ", flush=True) - - conv(installer, dest_dir) - if verbose: - print("OK") diff --git a/src/wheel/py.typed b/src/wheel/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/src/wheel/util.py b/src/wheel/util.py deleted file mode 100644 index d98d98cb..00000000 --- a/src/wheel/util.py +++ /dev/null @@ -1,26 +0,0 @@ -from __future__ import annotations - -import base64 -import logging - -log = logging.getLogger("wheel") - -# ensure Python logging is configured -try: - __import__("setuptools.logging") -except ImportError: - # setuptools < ?? - from . import _setuptools_logging - - _setuptools_logging.configure() - - -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/_structures.py b/src/wheel/vendored/packaging/_structures.py new file mode 100644 index 00000000..90a6465f --- /dev/null +++ b/src/wheel/vendored/packaging/_structures.py @@ -0,0 +1,61 @@ +# 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/utils.py b/src/wheel/vendored/packaging/utils.py new file mode 100644 index 00000000..33c613b7 --- /dev/null +++ b/src/wheel/vendored/packaging/utils.py @@ -0,0 +1,141 @@ +# 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 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. + """ + + +_canonicalize_regex = re.compile(r"[-_.]+") +# PEP 427: The build number must start with a digit. +_build_tag_regex = re.compile(r"(\d+)(.*)") + + +def canonicalize_name(name: str) -> NormalizedName: + # This is taken from PEP 503. + value = _canonicalize_regex.sub("-", name).lower() + return cast(NormalizedName, value) + + +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) + version = Version(parts[1]) + 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) + version = Version(version_part) + return (name, version) diff --git a/src/wheel/vendored/packaging/version.py b/src/wheel/vendored/packaging/version.py new file mode 100644 index 00000000..e5c738cf --- /dev/null +++ b/src/wheel/vendored/packaging/version.py @@ -0,0 +1,563 @@ +# 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 collections +import itertools +import re +from typing import Callable, Optional, SupportsInt, Tuple, Union + +from ._structures import Infinity, InfinityType, NegativeInfinity, NegativeInfinityType + +__all__ = ["VERSION_PATTERN", "parse", "Version", "InvalidVersion"] + +InfiniteTypes = Union[InfinityType, NegativeInfinityType] +PrePostDevType = Union[InfiniteTypes, Tuple[str, int]] +SubLocalType = Union[InfiniteTypes, int, str] +LocalType = Union[ + NegativeInfinityType, + Tuple[ + Union[ + SubLocalType, + Tuple[SubLocalType, str], + Tuple[NegativeInfinityType, SubLocalType], + ], + ..., + ], +] +CmpKey = Tuple[ + int, Tuple[int, ...], PrePostDevType, PrePostDevType, PrePostDevType, LocalType +] +VersionComparisonMethod = Callable[[CmpKey, CmpKey], bool] + +_Version = collections.namedtuple( + "_Version", ["epoch", "release", "dev", "pre", "post", "local"] +) + + +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: CmpKey + + 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
+            [-_\.]?
+            (?P(a|b|c|rc|alpha|beta|pre|preview))
+            [-_\.]?
+            (?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)
+
+    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
+        """
+        _epoch: int = self._version.epoch
+        return _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.
+        """
+        _release: Tuple[int, ...] = self._version.release
+        return _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)
+        """
+        _pre: Optional[Tuple[str, int]] = self._version.pre
+        return _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: str, number: Union[str, bytes, SupportsInt]
+) -> 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: 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[Tuple[SubLocalType]],
+) -> 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: PrePostDevType = 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: PrePostDevType = NegativeInfinity
+
+    else:
+        _post = post
+
+    # Versions without a development segment should sort after those with one.
+    if dev is None:
+        _dev: PrePostDevType = Infinity
+
+    else:
+        _dev = dev
+
+    if local is None:
+        # Versions without a local segment should sort before those with one.
+        _local: LocalType = 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/wheelfile.py b/src/wheel/wheelfile.py
index 8ae97336..7a28e691 100644
--- a/src/wheel/wheelfile.py
+++ b/src/wheel/wheelfile.py
@@ -1,20 +1,29 @@
 from __future__ import annotations
 
-import csv
-import hashlib
 import os.path
 import re
-import stat
 import time
-from collections import OrderedDict
-from io import StringIO, TextIOWrapper
-from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
-
-from wheel.cli import WheelError
-from wheel.util import log, urlsafe_b64decode, urlsafe_b64encode
+from datetime import datetime
+from os import PathLike
+from pathlib import Path, PurePath
+from types import TracebackType
+from typing import TYPE_CHECKING
+from warnings import warn
+from zipfile import ZipInfo
+
+from . import WheelWriter
+from ._wheelfile import DEFAULT_TIMESTAMP, WheelError, WheelReader
+
+if TYPE_CHECKING:
+    from typing import Literal
+
+warn(
+    DeprecationWarning(
+        f"The {__name__} module has been deprecated in favor of a supported public "
+        "API, and will be removed in a future release."
+    )
+)
 
-# Non-greedy matching of an optional build number may be too clever (more
-# invalid wheel filenames will match). Separate regex for .dist-info?
 WHEEL_INFO_RE = re.compile(
     r"""^(?P(?P[^\s-]+?)-(?P[^\s-]+?))(-(?P\d[^\s-]*))?
      -(?P[^\s-]+?)-(?P[^\s-]+?)-(?P\S+)\.whl$""",
@@ -31,161 +40,89 @@ def get_zipinfo_datetime(timestamp=None):
     return time.gmtime(timestamp)[0:6]
 
 
-class WheelFile(ZipFile):
-    """A ZipFile derivative class that also reads SHA-256 hashes from
-    .dist-info/RECORD and checks any read files against those.
-    """
-
-    _default_algorithm = hashlib.sha256
+class WheelFile:
+    """Compatibility shim for WheelReader and WheelWriter."""
 
-    def __init__(self, file, mode="r", compression=ZIP_DEFLATED):
-        basename = os.path.basename(file)
-        self.parsed_filename = WHEEL_INFO_RE.match(basename)
-        if not basename.endswith(".whl") or self.parsed_filename is None:
-            raise WheelError(f"Bad wheel filename {basename!r}")
+    _reader: WheelReader
+    _writer: WheelWriter
 
-        ZipFile.__init__(self, file, mode, compression=compression, allowZip64=True)
-
-        self.dist_info_path = "{}.dist-info".format(
-            self.parsed_filename.group("namever")
-        )
-        self.record_path = self.dist_info_path + "/RECORD"
-        self._file_hashes = OrderedDict()
-        self._file_sizes = {}
+    def __init__(self, path: str | PathLike[str], mode: Literal["r", "w"] = "r"):
         if mode == "r":
-            # Ignore RECORD and any embedded wheel signatures
-            self._file_hashes[self.record_path] = None, None
-            self._file_hashes[self.record_path + ".jws"] = None, None
-            self._file_hashes[self.record_path + ".p7s"] = None, None
-
-            # Fill in the expected hashes by reading them from RECORD
-            try:
-                record = self.open(self.record_path)
-            except KeyError:
-                raise WheelError(f"Missing {self.record_path} file")
-
-            with record:
-                for line in csv.reader(
-                    TextIOWrapper(record, newline="", encoding="utf-8")
-                ):
-                    path, hash_sum, size = line
-                    if not hash_sum:
-                        continue
-
-                    algorithm, hash_sum = hash_sum.split("=")
-                    try:
-                        hashlib.new(algorithm)
-                    except ValueError:
-                        raise WheelError(f"Unsupported hash algorithm: {algorithm}")
-
-                    if algorithm.lower() in {"md5", "sha1"}:
-                        raise WheelError(
-                            "Weak hash algorithm ({}) is not permitted by PEP "
-                            "427".format(algorithm)
-                        )
-
-                    self._file_hashes[path] = (
-                        algorithm,
-                        urlsafe_b64decode(hash_sum.encode("ascii")),
-                    )
-
-    def open(self, name_or_info, mode="r", pwd=None):
-        def _update_crc(newdata):
-            eof = ef._eof
-            update_crc_orig(newdata)
-            running_hash.update(newdata)
-            if eof and running_hash.digest() != expected_hash:
-                raise WheelError(f"Hash mismatch for file '{ef_name}'")
-
-        ef_name = (
-            name_or_info.filename if isinstance(name_or_info, ZipInfo) else name_or_info
-        )
-        if (
-            mode == "r"
-            and not ef_name.endswith("/")
-            and ef_name not in self._file_hashes
-        ):
-            raise WheelError(f"No hash found for file '{ef_name}'")
-
-        ef = ZipFile.open(self, name_or_info, mode, pwd)
-        if mode == "r" and not ef_name.endswith("/"):
-            algorithm, expected_hash = self._file_hashes[ef_name]
-            if expected_hash is not None:
-                # Monkey patch the _update_crc method to also check for the hash from
-                # RECORD
-                running_hash = hashlib.new(algorithm)
-                update_crc_orig, ef._update_crc = ef._update_crc, _update_crc
-
-        return ef
-
-    def write_files(self, base_dir):
-        log.info(f"creating '{self.filename}' and adding '{base_dir}' to it")
-        deferred = []
-        for root, dirnames, filenames in os.walk(base_dir):
-            # Sort the directory names so that `os.walk` will walk them in a
-            # defined order on the next iteration.
-            dirnames.sort()
-            for name in sorted(filenames):
-                path = os.path.normpath(os.path.join(root, name))
-                if os.path.isfile(path):
-                    arcname = os.path.relpath(path, base_dir).replace(os.path.sep, "/")
-                    if arcname == self.record_path:
-                        pass
-                    elif root.endswith(".dist-info"):
-                        deferred.append((path, arcname))
-                    else:
-                        self.write(path, arcname)
-
-        deferred.sort()
-        for path, arcname in deferred:
-            self.write(path, arcname)
-
-    def write(self, filename, arcname=None, compress_type=None):
-        with open(filename, "rb") as f:
-            st = os.fstat(f.fileno())
-            data = f.read()
-
-        zinfo = ZipInfo(
-            arcname or filename, date_time=get_zipinfo_datetime(st.st_mtime)
-        )
-        zinfo.external_attr = (stat.S_IMODE(st.st_mode) | stat.S_IFMT(st.st_mode)) << 16
-        zinfo.compress_type = compress_type or self.compression
-        self.writestr(zinfo, data, compress_type)
-
-    def writestr(self, zinfo_or_arcname, data, compress_type=None):
+            self._reader = WheelReader(path)
+        elif mode == "w":
+            self._writer = WheelWriter(path)
+        else:
+            raise ValueError(f"Invalid mode: {mode}")
+
+        self.filename = str(path)
+        parsed_filename = WHEEL_INFO_RE.match(os.path.basename(self.filename))
+        if parsed_filename is None:
+            raise WheelError("Cannot parse wheel file name")
+
+        self.parsed_filename = parsed_filename
+
+    @property
+    def dist_info_path(self) -> str:
+        if hasattr(self, "_reader"):
+            return self._reader._dist_info_dir
+        else:
+            return self._writer._dist_info_dir
+
+    def __enter__(self) -> WheelFile:
+        if hasattr(self, "_reader"):
+            self._reader.__enter__()
+        else:
+            self._writer.__enter__()
+
+        return self
+
+    def __exit__(
+        self,
+        exc_type: type[BaseException],
+        exc_val: BaseException,
+        exc_tb: TracebackType,
+    ) -> None:
+        if hasattr(self, "_reader"):
+            self._reader.__exit__(exc_type, exc_val, exc_tb)
+        else:
+            self._writer.__exit__(exc_type, exc_val, exc_tb)
+
+    def read(self, name: str) -> bytes:
+        return self._reader.read_file(name)
+
+    def extractall(self, base_path: str | PathLike[str] | None = None) -> None:
+        self._reader.extractall(base_path or os.getcwd())
+
+    def write_files(self, base_dir: PathLike[str] | str) -> None:
+        self._writer.write_files_from_directory(base_dir)
+
+    def write(
+        self,
+        filename: str | PathLike[str],
+        arcname: str | None = None,
+        compress_type: int | None = None,
+    ):
+        fname = PurePath(arcname or filename)
+        self._writer.write_file(fname, Path(filename))
+
+    def writestr(
+        self,
+        zinfo_or_arcname: str | ZipInfo,
+        data: bytes | str,
+        compress_type: int | None = None,
+    ):
         if isinstance(data, str):
             data = data.encode("utf-8")
 
-        ZipFile.writestr(self, zinfo_or_arcname, data, compress_type)
-        fname = (
-            zinfo_or_arcname.filename
-            if isinstance(zinfo_or_arcname, ZipInfo)
-            else zinfo_or_arcname
-        )
-        log.info(f"adding '{fname}'")
-        if fname != self.record_path:
-            hash_ = self._default_algorithm(data)
-            self._file_hashes[fname] = (
-                hash_.name,
-                urlsafe_b64encode(hash_.digest()).decode("ascii"),
-            )
-            self._file_sizes[fname] = len(data)
-
-    def close(self):
-        # Write RECORD
-        if self.fp is not None and self.mode == "w" and self._file_hashes:
-            data = StringIO()
-            writer = csv.writer(data, delimiter=",", quotechar='"', lineterminator="\n")
-            writer.writerows(
-                (
-                    (fname, algorithm + "=" + hash_, self._file_sizes[fname])
-                    for fname, (algorithm, hash_) in self._file_hashes.items()
-                )
+        if isinstance(zinfo_or_arcname, ZipInfo):
+            arcname = zinfo_or_arcname.filename
+            timestamp = datetime(*zinfo_or_arcname.date_time[:6])
+        elif isinstance(zinfo_or_arcname, str):
+            arcname = zinfo_or_arcname
+            timestamp = DEFAULT_TIMESTAMP
+        else:
+            raise TypeError(
+                f"Invalid type for zinfo_or_arcname: {type(zinfo_or_arcname)}"
             )
-            writer.writerow((format(self.record_path), "", ""))
-            zinfo = ZipInfo(self.record_path, date_time=get_zipinfo_datetime())
-            zinfo.compress_type = self.compression
-            zinfo.external_attr = 0o664 << 16
-            self.writestr(zinfo, data.getvalue())
 
-        ZipFile.close(self)
+        self._writer.write_file(arcname, data, timestamp=timestamp)
diff --git a/tests/cli/test_convert.py b/tests/cli/test_convert.py
index 1dc86c0d..950ceb02 100644
--- a/tests/cli/test_convert.py
+++ b/tests/cli/test_convert.py
@@ -2,12 +2,12 @@
 
 import os.path
 import re
+from pathlib import Path
 
-from wheel.cli.convert import convert, egg_info_re
-from wheel.wheelfile import WHEEL_INFO_RE
+from wheel._cli.convert import convert, egg_info_re
 
 
-def test_egg_re():
+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) as egg_names:
@@ -17,12 +17,9 @@ def test_egg_re():
                 assert egg_info_re.match(line), line
 
 
-def test_convert_egg(egg_paths, tmpdir):
-    convert(egg_paths, str(tmpdir), verbose=False)
-    wheel_names = [path.basename for path in tmpdir.listdir()]
+def test_convert_egg(egg_paths: list[Path], tmp_path: Path) -> None:
+    convert(egg_paths, tmp_path, verbose=False)
+    wheel_names = [path.name for path in tmp_path.iterdir()]
     assert len(wheel_names) == len(egg_paths)
-    assert all(WHEEL_INFO_RE.match(filename) for filename in wheel_names)
-    assert all(
-        re.match(r"^[\w\d.]+-\d\.\d-\w+\d+-[\w\d]+-[\w\d]+\.whl$", fname)
-        for fname in wheel_names
-    )
+    for fname in wheel_names:
+        assert re.match(r"^[\w\d.]+-\d\.\d-\w+\d+-[\w\d]+-[\w\d]+\.whl$", fname)
diff --git a/tests/cli/test_pack.py b/tests/cli/test_pack.py
index 9264eb9e..74fd9374 100644
--- a/tests/cli/test_pack.py
+++ b/tests/cli/test_pack.py
@@ -1,12 +1,14 @@
 from __future__ import annotations
 
 import os
+from pathlib import Path
 from textwrap import dedent
 from zipfile import ZipFile
 
 import pytest
+from pytest import TempPathFactory
 
-from wheel.cli.pack import pack
+from wheel._cli.pack import pack
 
 THISDIR = os.path.dirname(__file__)
 TESTWHEEL_NAME = "test-1.0-py2.py3-none-any.whl"
@@ -17,15 +19,20 @@
 @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(tmpdir_factory, tmpdir, build_tag_arg, existing_build_tag, filename):
-    unpack_dir = tmpdir_factory.mktemp("wheeldir")
+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")
         old_record_lines = sorted(
@@ -37,15 +44,15 @@ def test_pack(tmpdir_factory, tmpdir, build_tag_arg, existing_build_tag, filenam
 
     if existing_build_tag:
         # Add the build number to WHEEL
-        wheel_file_path = unpack_dir.join("test-1.0.dist-info").join("WHEEL")
-        wheel_file_content = wheel_file_path.read_binary()
+        wheel_file_path = unpack_dir / "test-1.0.dist-info" / "WHEEL"
+        wheel_file_content = wheel_file_path.read_bytes()
         assert b"Build" not in wheel_file_content
         wheel_file_content += b"Build: 3\r\n"
-        wheel_file_path.write_binary(wheel_file_content)
+        wheel_file_path.write_bytes(wheel_file_content)
 
-    pack(str(unpack_dir), str(tmpdir), build_tag_arg)
-    new_wheel_path = tmpdir.join(filename)
-    assert new_wheel_path.isfile()
+    pack(unpack_dir, tmp_path, build_tag_arg)
+    new_wheel_path = tmp_path / filename
+    assert new_wheel_path.is_file()
 
     with ZipFile(str(new_wheel_path)) as zf:
         new_record = zf.read("test-1.0.dist-info/RECORD")
@@ -74,5 +81,5 @@ def test_pack(tmpdir_factory, tmpdir, build_tag_arg, existing_build_tag, filenam
     if expected_build_num:
         expected_wheel_content += "Build: %s\r\n" % expected_build_num
 
-    expected_wheel_content = expected_wheel_content.encode("ascii")
-    assert new_wheel_file_content == expected_wheel_content
+    expected_wheel_content_bytes = expected_wheel_content.encode("ascii")
+    assert new_wheel_file_content == expected_wheel_content_bytes
diff --git a/tests/cli/test_unpack.py b/tests/cli/test_unpack.py
index 9505caea..ce28ff03 100644
--- a/tests/cli/test_unpack.py
+++ b/tests/cli/test_unpack.py
@@ -1,12 +1,14 @@
 from __future__ import annotations
 
-from wheel.cli.unpack import unpack
+from pathlib import Path
 
+from wheel._cli.unpack import unpack
 
-def test_unpack(wheel_paths, tmpdir):
+
+def test_unpack(wheel_paths: list[Path], tmp_path: Path) -> None:
     """
     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(tmpdir))
+        unpack(wheel_path, tmp_path)
diff --git a/tests/conftest.py b/tests/conftest.py
index d5a99d85..546706a7 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -7,29 +7,30 @@
 import os.path
 import subprocess
 import sys
+from pathlib import Path
 
 import pytest
+from pytest import TempPathFactory
 
 
 @pytest.fixture(scope="session")
-def wheels_and_eggs(tmpdir_factory):
+def wheels_and_eggs(tmp_path_factory: TempPathFactory) -> list[Path]:
     """Build wheels and eggs from test distributions."""
-    test_distributions = (
+    test_distributions = [
         "complex-dist",
         "simple.dist",
         "headers.dist",
         "commasinfilenames.dist",
         "unicode.dist",
-    )
+    ]
 
     if sys.platform != "win32":
         # ABI3 extensions don't really work on Windows
-        test_distributions += ("abi3extension.dist",)
+        test_distributions.append("abi3extension.dist")
 
-    pwd = os.path.abspath(os.curdir)
-    this_dir = os.path.dirname(__file__)
-    build_dir = tmpdir_factory.mktemp("build")
-    dist_dir = tmpdir_factory.mktemp("dist")
+    this_dir = Path(__file__).parent
+    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(
@@ -49,17 +50,16 @@ def wheels_and_eggs(tmpdir_factory):
             ]
         )
 
-    os.chdir(pwd)
     return sorted(
-        str(fname) for fname in dist_dir.listdir() if fname.ext in (".whl", ".egg")
+        path for path in dist_dir.iterdir() if path.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")]
+def wheel_paths(wheels_and_eggs: list[Path]) -> list[Path]:
+    return [path for path in wheels_and_eggs if path.suffix == ".whl"]
 
 
 @pytest.fixture(scope="session")
-def egg_paths(wheels_and_eggs):
-    return [fname for fname in wheels_and_eggs if fname.endswith(".egg")]
+def egg_paths(wheels_and_eggs: list[Path]) -> list[Path]:
+    return [path for path in wheels_and_eggs if path.suffix == ".egg"]
diff --git a/tests/test_bdist_wheel.py b/tests/test_bdist_wheel.py
index 5a6db16e..371c3c11 100644
--- a/tests/test_bdist_wheel.py
+++ b/tests/test_bdist_wheel.py
@@ -6,35 +6,38 @@
 import subprocess
 import sys
 import sysconfig
+import zipfile
+from pathlib import Path, PurePath
 from zipfile import ZipFile
 
 import pytest
+from pytest import MonkeyPatch, TempPathFactory
 
-from wheel.bdist_wheel import bdist_wheel, get_abi_tag
+from wheel import WheelReader
+from wheel.bdist_wheel import get_abi_tag
 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",
+    PurePath("dummy-dist-1.0.dist-info/top_level.txt"),
+    PurePath("dummy-dist-1.0.dist-info/METADATA"),
+    PurePath("dummy-dist-1.0.dist-info/WHEEL"),
+    PurePath("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",
+    PurePath("LICENSE"),
+    PurePath("LICENSE.txt"),
+    PurePath("LICENCE"),
+    PurePath("LICENCE.txt"),
+    PurePath("COPYING"),
+    PurePath("COPYING.md"),
+    PurePath("NOTICE"),
+    PurePath("NOTICE.rst"),
+    PurePath("AUTHORS"),
+    PurePath("AUTHORS.txt"),
 }
 OTHER_IGNORED_FILES = {
-    "LICENSE~",
-    "AUTHORS~",
+    PurePath("LICENSE~"),
+    PurePath("AUTHORS~"),
 }
 SETUPPY_EXAMPLE = """\
 from setuptools import setup
@@ -47,91 +50,57 @@
 
 
 @pytest.fixture
-def dummy_dist(tmpdir_factory):
-    basedir = tmpdir_factory.mktemp("dummy_dist")
-    basedir.join("setup.py").write(SETUPPY_EXAMPLE)
+def dummy_dist(tmp_path_factory: TempPathFactory) -> Path:
+    basedir = tmp_path_factory.mktemp("dummy_dist")
+    basedir.joinpath("setup.py").write_text(SETUPPY_EXAMPLE)
     for fname in DEFAULT_LICENSE_FILES | OTHER_IGNORED_FILES:
-        basedir.join(fname).write("")
+        basedir.joinpath(fname).write_text("")
 
-    basedir.join("licenses").mkdir().join("DUMMYFILE").write("")
+    licenses_path = basedir.joinpath("licenses")
+    licenses_path.mkdir()
+    licenses_path.joinpath("DUMMYFILE").write_text("")
     return basedir
 
 
-def test_no_scripts(wheel_paths):
+def test_no_scripts(wheel_paths: list[Path]) -> None:
     """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
+    path = next(path for path in wheel_paths if "complex_dist" in path.name)
+    with WheelReader(path) as wf:
+        filenames = set(wf.filenames)
 
+    for filename in filenames:
+        assert ".data/scripts/" not in filename.name
 
-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
+def test_unicode_record(wheel_paths: list[Path]) -> None:
+    path = next(path for path in wheel_paths if "unicode.dist" in path.name)
+    with WheelReader(path) as wf:
+        record = wf.read_dist_info("RECORD")
 
+    assert "åäö_日本語.py" in record
 
-UTF8_PKG_INFO = """\
-Metadata-Version: 2.1
-Name: helloworld
-Version: 42
-Author-email: "John X. Ãørçeč" , Γαμα קּ 東 
 
+def test_unicode_metadata(wheel_paths: list[Path]) -> None:
+    path = next(path for path in wheel_paths if "unicode.dist" in path.name)
+    with WheelReader(path) as wf:
+        metadata = wf.read_dist_info("METADATA")
 
-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)
+    assert "Summary: A testing distribution ☃" in metadata
 
-    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, tmpdir):
+def test_licenses_default(
+    dummy_dist: Path, monkeypatch: MonkeyPatch, tmp_path: Path
+) -> None:
     monkeypatch.chdir(dummy_dist)
     subprocess.check_call(
-        [sys.executable, "setup.py", "bdist_wheel", "-b", str(tmpdir), "--universal"]
+        [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:
+    with WheelReader("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
+            PurePath("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, tmpdir):
-    dummy_dist.join("setup.cfg").write("[metadata]\nlicense_file=licenses/DUMMYFILE")
-    monkeypatch.chdir(dummy_dist)
-    subprocess.check_call(
-        [sys.executable, "setup.py", "bdist_wheel", "-b", str(tmpdir), "--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
+        assert set(wf.filenames) == DEFAULT_FILES | license_files
 
 
 @pytest.mark.parametrize(
@@ -147,30 +116,41 @@ def test_licenses_deprecated(dummy_dist, monkeypatch, tmpdir):
         ),
     ],
 )
-def test_licenses_override(dummy_dist, monkeypatch, tmpdir, config_file, config):
-    dummy_dist.join(config_file).write(config)
+def test_licenses_override(
+    dummy_dist: Path,
+    monkeypatch: MonkeyPatch,
+    tmp_path: Path,
+    config_file: str,
+    config: str,
+) -> None:
+    dummy_dist.joinpath(config_file).write_text(config)
     monkeypatch.chdir(dummy_dist)
     subprocess.check_call(
-        [sys.executable, "setup.py", "bdist_wheel", "-b", str(tmpdir), "--universal"]
+        [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:
+    with WheelReader("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"}
+            PurePath("dummy-dist-1.0.dist-info") / fname
+            for fname in {"DUMMYFILE", "LICENSE"}
         }
-        assert set(wf.namelist()) == DEFAULT_FILES | license_files
+        assert set(wf.filenames) == DEFAULT_FILES | license_files
 
 
-def test_licenses_disabled(dummy_dist, monkeypatch, tmpdir):
-    dummy_dist.join("setup.cfg").write("[metadata]\nlicense_files=\n")
+def test_licenses_disabled(
+    dummy_dist: Path, monkeypatch: MonkeyPatch, tmp_path: Path
+) -> None:
+    dummy_dist.joinpath("setup.cfg").write_text("[metadata]\nlicense_files=\n")
     monkeypatch.chdir(dummy_dist)
     subprocess.check_call(
-        [sys.executable, "setup.py", "bdist_wheel", "-b", str(tmpdir), "--universal"]
+        [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
+    with WheelReader("dist/dummy_dist-1.0-py2.py3-none-any.whl") as wf:
+        assert set(wf.filenames) == DEFAULT_FILES
 
 
-def test_build_number(dummy_dist, monkeypatch, tmpdir):
+def test_build_number(
+    dummy_dist: Path, monkeypatch: MonkeyPatch, tmp_path: Path
+) -> None:
     monkeypatch.chdir(dummy_dist)
     subprocess.check_call(
         [
@@ -178,23 +158,23 @@ def test_build_number(dummy_dist, monkeypatch, tmpdir):
             "setup.py",
             "bdist_wheel",
             "-b",
-            str(tmpdir),
+            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
+    with WheelReader("dist/dummy_dist-1.0-2-py2.py3-none-any.whl") as wf:
+        filenames = set(wf.filenames)
+        assert PurePath("dummy-dist-1.0.dist-info/RECORD") in filenames
+        assert PurePath("dummy-dist-1.0.dist-info/METADATA") in filenames
 
 
-def test_limited_abi(monkeypatch, tmpdir):
+def test_limited_abi(monkeypatch: MonkeyPatch, tmp_path: Path) -> None:
     """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 = tmpdir.join("build")
-    dist_dir = tmpdir.join("dist")
+    build_dir = tmp_path / "build"
+    dist_dir = tmp_path / "dist"
     monkeypatch.chdir(source_dir)
     subprocess.check_call(
         [
@@ -209,8 +189,10 @@ def test_limited_abi(monkeypatch, tmpdir):
     )
 
 
-def test_build_from_readonly_tree(dummy_dist, monkeypatch, tmpdir):
-    basedir = str(tmpdir.join("dummy"))
+def test_build_from_readonly_tree(
+    dummy_dist: Path, monkeypatch: MonkeyPatch, tmp_path: Path
+) -> None:
+    basedir = str(tmp_path / "dummy")
     shutil.copytree(str(dummy_dist), basedir)
     monkeypatch.chdir(basedir)
 
@@ -224,10 +206,18 @@ def test_build_from_readonly_tree(dummy_dist, monkeypatch, tmpdir):
 
 @pytest.mark.parametrize(
     "option, compress_type",
-    list(bdist_wheel.supported_compressions.items()),
-    ids=list(bdist_wheel.supported_compressions),
+    [
+        pytest.param("stored", zipfile.ZIP_STORED, id="stored"),
+        pytest.param("deflated", zipfile.ZIP_DEFLATED, id="deflated"),
+    ],
 )
-def test_compression(dummy_dist, monkeypatch, tmpdir, option, compress_type):
+def test_compression(
+    dummy_dist: Path,
+    monkeypatch: MonkeyPatch,
+    tmp_path: Path,
+    option: str,
+    compress_type: int,
+) -> None:
     monkeypatch.chdir(dummy_dist)
     subprocess.check_call(
         [
@@ -235,28 +225,26 @@ def test_compression(dummy_dist, monkeypatch, tmpdir, option, compress_type):
             "setup.py",
             "bdist_wheel",
             "-b",
-            str(tmpdir),
+            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:
+    with ZipFile("dist/dummy_dist-1.0-py2.py3-none-any.whl") as zf:
+        for zinfo in zf.infolist():
             assert zinfo.compress_type == compress_type
 
 
-def test_wheelfile_line_endings(wheel_paths):
+def test_wheelfile_line_endings(wheel_paths: list[Path]) -> None:
     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
+        with WheelReader(path) as wf:
+            wheelfile_contents = wf.read_dist_info("WHEEL")
+            assert "\r" not in wheelfile_contents
 
 
-def test_unix_epoch_timestamps(dummy_dist, monkeypatch, tmpdir):
+def test_unix_epoch_timestamps(
+    dummy_dist: Path, monkeypatch: MonkeyPatch, tmp_path: Path
+) -> None:
     monkeypatch.setenv("SOURCE_DATE_EPOCH", "0")
     monkeypatch.chdir(dummy_dist)
     subprocess.check_call(
@@ -265,7 +253,7 @@ def test_unix_epoch_timestamps(dummy_dist, monkeypatch, tmpdir):
             "setup.py",
             "bdist_wheel",
             "-b",
-            str(tmpdir),
+            str(tmp_path),
             "--universal",
             "--build-number=2",
         ]
diff --git a/tests/test_deprecated_wheelfile.py b/tests/test_deprecated_wheelfile.py
new file mode 100644
index 00000000..bb5bcdfc
--- /dev/null
+++ b/tests/test_deprecated_wheelfile.py
@@ -0,0 +1,151 @@
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+from zipfile import ZIP_DEFLATED, ZipFile
+
+import pytest
+from pytest import MonkeyPatch, TempPathFactory
+
+from wheel import WheelError
+from wheel.wheelfile import WheelFile
+
+
+@pytest.fixture
+def wheel_path(tmp_path: Path) -> Path:
+    return tmp_path / "test-1.0-py2.py3-none-any.whl"
+
+
+@pytest.mark.parametrize(
+    "filename",
+    [
+        "foo-2-py3-none-any.whl",
+        "foo-2-py2.py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
+    ],
+)
+def test_wheelfile_re(filename: str, tmp_path: Path) -> None:
+    # Regression test for #208 and #485
+    path = tmp_path / filename
+    with WheelFile(path, "w") as wf:
+        assert wf.parsed_filename.group("namever") == "foo-2"
+
+
+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')
+
+    with pytest.raises(
+        WheelError,
+        match=(
+            "^Cannot find a valid .dist-info directory. Is this really a wheel file\\?$"
+        ),
+    ):
+        with WheelFile(wheel_path):
+            pass
+
+
+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(
+            "test-1.0.dist-info/RECORD",
+            "hello/héllö.py,sha000=bv-QV3RciQC2v3zL8Uvhd_arp40J5A9xmyubN34OVwo,25",
+        )
+
+    with pytest.raises(WheelError, match="^Unsupported hash algorithm: sha000$"):
+        with WheelFile(wheel_path):
+            pass
+
+
+@pytest.mark.parametrize(
+    "algorithm, digest",
+    [
+        pytest.param("md5", "4J-scNa2qvSgy07rS4at-Q", id="md5"),
+        pytest.param("sha1", "QjCnGu5Qucb6-vir1a6BVptvOA4", id="sha1"),
+    ],
+)
+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')
+        zf.writestr("test-1.0.dist-info/RECORD", f"hello/héllö.py,{hash_string},25")
+
+    with pytest.raises(
+        WheelError,
+        match=rf"^Weak hash algorithm \({algorithm}\) is not permitted by PEP 427$",
+    ):
+        with WheelFile(wheel_path):
+            pass
+
+
+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')
+
+    with ZipFile(wheel_path, "r") as zf:
+        infolist = zf.infolist()
+        assert len(infolist) == 4
+        assert infolist[0].filename == "hello/héllö.py"
+        assert infolist[0].file_size == 25
+        assert infolist[1].filename == "hello/h,ll,.py"
+        assert infolist[1].file_size == 25
+        assert infolist[2].filename == "test-1.0.dist-info/WHEEL"
+        assert infolist[3].filename == "test-1.0.dist-info/RECORD"
+
+        record = zf.read("test-1.0.dist-info/RECORD")
+        assert record.decode("utf-8") == (
+            "hello/héllö.py,sha256=bv-QV3RciQC2v3zL8Uvhd_arp40J5A9xmyubN34OVwo,25\n"
+            '"hello/h,ll,.py",sha256=bv-QV3RciQC2v3zL8Uvhd_arp40J5A9xmyubN34OVwo,25\n'
+            "test-1.0.dist-info/WHEEL,"
+            "sha256=xn45MTtJwj1QxDHLE3DKYDjLqYLb8DHEh5F6k8vFf5o,105\n"
+            "test-1.0.dist-info/RECORD,,\n"
+        )
+
+
+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")
+    for filename in ("one", "two", "three"):
+        build_dir.joinpath(filename).write_text(filename + "\n")
+
+    # The earliest date representable in TarInfos, 1980-01-01
+    monkeypatch.setenv("SOURCE_DATE_EPOCH", "315576060")
+
+    with WheelFile(wheel_path, "w") as wf:
+        wf.write_files(str(build_dir))
+
+    with ZipFile(wheel_path, "r") as zf:
+        for info in zf.infolist():
+            assert info.date_time[:3] == (1980, 1, 1)
+            assert info.compress_type == ZIP_DEFLATED
+
+
+@pytest.mark.skipif(
+    sys.platform == "win32", reason="Windows does not support UNIX-like permissions"
+)
+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")
+    files = (("foo", 0o644), ("bar", 0o755))
+    for filename, mode in files:
+        path = build_dir / filename
+        path.write_text(filename + "\n")
+        path.chmod(mode)
+
+    with WheelFile(wheel_path, "w") as wf:
+        wf.write_files(str(build_dir))
+
+    with ZipFile(wheel_path, "r") as zf:
+        for filename, mode in files:
+            info = zf.getinfo(filename)
+            assert info.external_attr == (mode | 0o100000) << 16
+            assert info.compress_type == ZIP_DEFLATED
+
+        info = zf.getinfo("test-1.0.dist-info/RECORD")
+        permissions = (info.external_attr >> 16) & 0o777
+        assert permissions == 0o664
diff --git a/tests/test_macosx_libfile.py b/tests/test_macosx_libfile.py
index fed3ebbe..6086618f 100644
--- a/tests/test_macosx_libfile.py
+++ b/tests/test_macosx_libfile.py
@@ -3,12 +3,18 @@
 import os
 import sys
 import sysconfig
+from collections.abc import Callable
+from typing import Any, TypeVar
 
+from pytest import CaptureFixture, MonkeyPatch
+
+from wheel._macosx_libfile import extract_macosx_min_system_version
 from wheel.bdist_wheel import get_platform
-from wheel.macosx_libfile import extract_macosx_min_system_version
+
+T = TypeVar("T")
 
 
-def test_read_from_dylib():
+def test_read_from_dylib() -> None:
     dirname = os.path.dirname(__file__)
     dylib_dir = os.path.join(dirname, "testdata", "macosx_minimal_system_version")
     versions = [
@@ -30,6 +36,7 @@ def test_read_from_dylib():
         extracted = extract_macosx_min_system_version(
             os.path.join(dylib_dir, file_name)
         )
+        assert extracted
         str_ver = ".".join([str(x) for x in extracted])
         assert str_ver == ver
     assert (
@@ -40,15 +47,15 @@ def test_read_from_dylib():
     )
 
 
-def return_factory(return_val):
-    def fun(*args, **kwargs):
+def return_factory(return_val: T) -> Callable[..., T]:
+    def fun(*args: Any, **kwargs: Any) -> T:
         return return_val
 
     return fun
 
 
 class TestGetPlatformMacosx:
-    def test_simple(self, monkeypatch):
+    def test_simple(self, monkeypatch: MonkeyPatch) -> None:
         dirname = os.path.dirname(__file__)
         dylib_dir = os.path.join(dirname, "testdata", "macosx_minimal_system_version")
         monkeypatch.setattr(
@@ -56,7 +63,9 @@ def test_simple(self, monkeypatch):
         )
         assert get_platform(dylib_dir) == "macosx_11_0_x86_64"
 
-    def test_version_bump(self, monkeypatch, capsys):
+    def test_version_bump(
+        self, monkeypatch: MonkeyPatch, capsys: CaptureFixture[str]
+    ) -> None:
         dirname = os.path.dirname(__file__)
         dylib_dir = os.path.join(dirname, "testdata", "macosx_minimal_system_version")
         monkeypatch.setattr(
@@ -67,8 +76,8 @@ def test_version_bump(self, monkeypatch, capsys):
         assert "[WARNING] This wheel needs a higher macOS version than" in captured.err
 
     def test_information_about_problematic_files_python_version(
-        self, monkeypatch, capsys
-    ):
+        self, monkeypatch: MonkeyPatch, capsys: CaptureFixture[str]
+    ) -> None:
         dirname = os.path.dirname(__file__)
         dylib_dir = os.path.join(dirname, "testdata", "macosx_minimal_system_version")
         monkeypatch.setattr(
@@ -90,8 +99,8 @@ def test_information_about_problematic_files_python_version(
         assert "test_lib_10_10_fat.dylib" in captured.err
 
     def test_information_about_problematic_files_env_variable(
-        self, monkeypatch, capsys
-    ):
+        self, monkeypatch: MonkeyPatch, capsys: CaptureFixture[str]
+    ) -> None:
         dirname = os.path.dirname(__file__)
         dylib_dir = os.path.join(dirname, "testdata", "macosx_minimal_system_version")
         monkeypatch.setattr(
@@ -111,7 +120,9 @@ def test_information_about_problematic_files_env_variable(
         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):
+    def test_bump_platform_tag_by_env_variable(
+        self, monkeypatch: MonkeyPatch, capsys: CaptureFixture[str]
+    ) -> None:
         dirname = os.path.dirname(__file__)
         dylib_dir = os.path.join(dirname, "testdata", "macosx_minimal_system_version")
         monkeypatch.setattr(
@@ -130,7 +141,9 @@ def test_bump_platform_tag_by_env_variable(self, monkeypatch, capsys):
         captured = capsys.readouterr()
         assert captured.err == ""
 
-    def test_bugfix_release_platform_tag(self, monkeypatch, capsys):
+    def test_bugfix_release_platform_tag(
+        self, monkeypatch: MonkeyPatch, capsys: CaptureFixture[str]
+    ) -> None:
         dirname = os.path.dirname(__file__)
         dylib_dir = os.path.join(dirname, "testdata", "macosx_minimal_system_version")
         monkeypatch.setattr(
@@ -161,7 +174,9 @@ def test_bugfix_release_platform_tag(self, monkeypatch, capsys):
         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):
+    def test_warning_on_to_low_env_variable(
+        self, monkeypatch: MonkeyPatch, capsys: CaptureFixture[str]
+    ) -> None:
         dirname = os.path.dirname(__file__)
         dylib_dir = os.path.join(dirname, "testdata", "macosx_minimal_system_version")
         monkeypatch.setattr(
@@ -182,7 +197,7 @@ def test_warning_on_to_low_env_variable(self, monkeypatch, capsys):
             in captured.err
         )
 
-    def test_get_platform_bigsur_env(self, monkeypatch):
+    def test_get_platform_bigsur_env(self, monkeypatch: MonkeyPatch) -> None:
         dirname = os.path.dirname(__file__)
         dylib_dir = os.path.join(dirname, "testdata", "macosx_minimal_system_version")
         monkeypatch.setattr(
@@ -198,7 +213,7 @@ def test_get_platform_bigsur_env(self, monkeypatch):
         )
         assert get_platform(dylib_dir) == "macosx_11_0_x86_64"
 
-    def test_get_platform_bigsur_platform(self, monkeypatch):
+    def test_get_platform_bigsur_platform(self, monkeypatch: MonkeyPatch) -> None:
         dirname = os.path.dirname(__file__)
         dylib_dir = os.path.join(dirname, "testdata", "macosx_minimal_system_version")
         monkeypatch.setattr(
@@ -214,7 +229,7 @@ def test_get_platform_bigsur_platform(self, monkeypatch):
         assert get_platform(dylib_dir) == "macosx_11_0_x86_64"
 
 
-def test_get_platform_linux(monkeypatch):
+def test_get_platform_linux(monkeypatch: MonkeyPatch) -> None:
     monkeypatch.setattr(sysconfig, "get_platform", return_factory("linux-x86_64"))
     monkeypatch.setattr(sys, "maxsize", 2147483647)
     assert get_platform(None) == "linux_i686"
diff --git a/tests/test_metadata.py b/tests/test_metadata.py
index 96461c40..249c741d 100644
--- a/tests/test_metadata.py
+++ b/tests/test_metadata.py
@@ -1,9 +1,11 @@
 from __future__ import annotations
 
-from wheel.metadata import pkginfo_to_metadata
+from pathlib import Path
 
+from wheel._metadata import pkginfo_to_metadata
 
-def test_pkginfo_to_metadata(tmpdir):
+
+def test_pkginfo_to_metadata(tmp_path: Path) -> None:
     expected_metadata = [
         ("Metadata-Version", "2.1"),
         ("Name", "spam"),
@@ -30,8 +32,8 @@ def test_pkginfo_to_metadata(tmpdir):
         ("Requires-Dist", "pytest-cov ; extra == 'test'"),
     ]
 
-    pkg_info = tmpdir.join("PKG-INFO")
-    pkg_info.write(
+    pkg_info_path = tmp_path / "PKG-INFO"
+    pkg_info_path.write_text(
         """\
 Metadata-Version: 0.0
 Name: spam
@@ -44,8 +46,8 @@ def test_pkginfo_to_metadata(tmpdir):
 Provides-Extra: faster-signatures"""
     )
 
-    egg_info_dir = tmpdir.ensure_dir("test.egg-info")
-    egg_info_dir.join("requires.txt").write(
+    requires_txt_path = tmp_path / "requires.txt"
+    requires_txt_path.write_text(
         """\
 pip@https://github.com/pypa/pip/archive/1.3.1.zip
 
@@ -76,7 +78,5 @@ def test_pkginfo_to_metadata(tmpdir):
 pytest-cov"""
     )
 
-    message = pkginfo_to_metadata(
-        egg_info_path=str(egg_info_dir), pkginfo_path=str(pkg_info)
-    )
-    assert message.items() == expected_metadata
+    items = pkginfo_to_metadata(pkg_info_path)
+    assert items == expected_metadata
diff --git a/tests/test_tagopt.py b/tests/test_tagopt.py
index 59a18b0f..2e132ab2 100644
--- a/tests/test_tagopt.py
+++ b/tests/test_tagopt.py
@@ -7,8 +7,10 @@
 
 import subprocess
 import sys
+from pathlib import Path
 
 import pytest
+from pytest import FixtureRequest
 
 SETUP_PY = """\
 from setuptools import setup, Extension
@@ -26,186 +28,185 @@
 
 
 @pytest.fixture
-def temp_pkg(request, tmpdir):
-    tmpdir.join("test.py").write('print("Hello, world")')
+def temp_pkg(request: FixtureRequest, tmp_path: Path) -> Path:
+    tmp_path.joinpath("test.py").write_text('print("Hello, world")')
 
     ext = getattr(request, "param", [False, ""])
     if ext[0]:
         # if ext[1] is not '', it will write a bad header and fail to compile
-        tmpdir.join("test.c").write("#include " % ext[1])
+        tmp_path.joinpath("test.c").write_text(f"#include ")
         setup_py = SETUP_PY.format(ext_modules=EXT_MODULES)
     else:
         setup_py = SETUP_PY.format(ext_modules="")
 
-    tmpdir.join("setup.py").write(setup_py)
+    tmp_path.joinpath("setup.py").write_text(setup_py)
     if ext[0]:
         try:
             subprocess.check_call(
-                [sys.executable, "setup.py", "build_ext"], cwd=str(tmpdir)
+                [sys.executable, "setup.py", "build_ext"], cwd=str(tmp_path)
             )
         except subprocess.CalledProcessError:
             pytest.skip("Cannot compile C extensions")
-    return tmpdir
+
+    return tmp_path
 
 
 @pytest.mark.parametrize("temp_pkg", [[True, "xxx"]], indirect=["temp_pkg"])
-def test_nocompile_skips(temp_pkg):
+def test_nocompile_skips(temp_pkg: Path) -> None:
     assert False  # noqa: B011 - should have skipped with a "Cannot compile" message
 
 
-def test_default_tag(temp_pkg):
+def test_default_tag(temp_pkg: Path) -> None:
     subprocess.check_call(
         [sys.executable, "setup.py", "bdist_wheel"], cwd=str(temp_pkg)
     )
-    dist_dir = temp_pkg.join("dist")
-    assert dist_dir.check(dir=1)
-    wheels = dist_dir.listdir()
+    dist_dir = temp_pkg.joinpath("dist")
+    assert dist_dir.is_dir()
+    wheels = list(dist_dir.iterdir())
     assert len(wheels) == 1
-    assert wheels[0].basename == f"Test-1.0-py{sys.version_info[0]}-none-any.whl"
-    assert wheels[0].ext == ".whl"
+    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):
+def test_build_number(temp_pkg: Path) -> None:
     subprocess.check_call(
         [sys.executable, "setup.py", "bdist_wheel", "--build-number=1"],
         cwd=str(temp_pkg),
     )
-    dist_dir = temp_pkg.join("dist")
-    assert dist_dir.check(dir=1)
-    wheels = dist_dir.listdir()
+    dist_dir = temp_pkg / "dist"
+    assert dist_dir.is_dir()
+    wheels = list(dist_dir.iterdir())
     assert len(wheels) == 1
-    assert wheels[0].basename == f"Test-1.0-1-py{sys.version_info[0]}-none-any.whl"
-    assert wheels[0].ext == ".whl"
+    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):
+def test_explicit_tag(temp_pkg: Path) -> None:
     subprocess.check_call(
         [sys.executable, "setup.py", "bdist_wheel", "--python-tag=py32"],
         cwd=str(temp_pkg),
     )
-    dist_dir = temp_pkg.join("dist")
-    assert dist_dir.check(dir=1)
-    wheels = dist_dir.listdir()
+    dist_dir = temp_pkg / "dist"
+    assert dist_dir.is_dir()
+    wheels = list(dist_dir.iterdir())
     assert len(wheels) == 1
-    assert wheels[0].basename.startswith("Test-1.0-py32-")
-    assert wheels[0].ext == ".whl"
+    assert wheels[0].name.startswith("Test-1.0-py32-")
+    assert wheels[0].suffix == ".whl"
 
 
-def test_universal_tag(temp_pkg):
+def test_universal_tag(temp_pkg: Path) -> None:
     subprocess.check_call(
         [sys.executable, "setup.py", "bdist_wheel", "--universal"], cwd=str(temp_pkg)
     )
-    dist_dir = temp_pkg.join("dist")
-    assert dist_dir.check(dir=1)
-    wheels = dist_dir.listdir()
+    dist_dir = temp_pkg / "dist"
+    assert dist_dir.is_dir()
+    wheels = list(dist_dir.iterdir())
     assert len(wheels) == 1
-    assert wheels[0].basename.startswith("Test-1.0-py2.py3-")
-    assert wheels[0].ext == ".whl"
+    assert wheels[0].name.startswith("Test-1.0-py2.py3-")
+    assert wheels[0].suffix == ".whl"
 
 
-def test_universal_beats_explicit_tag(temp_pkg):
+def test_universal_beats_explicit_tag(temp_pkg: Path) -> None:
     subprocess.check_call(
         [sys.executable, "setup.py", "bdist_wheel", "--universal", "--python-tag=py32"],
         cwd=str(temp_pkg),
     )
-    dist_dir = temp_pkg.join("dist")
-    assert dist_dir.check(dir=1)
-    wheels = dist_dir.listdir()
+    dist_dir = temp_pkg / "dist"
+    assert dist_dir.is_dir()
+    wheels = list(dist_dir.iterdir())
     assert len(wheels) == 1
-    assert wheels[0].basename.startswith("Test-1.0-py2.py3-")
-    assert wheels[0].ext == ".whl"
+    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.join("setup.cfg").write("[bdist_wheel]\nuniversal=1")
+def test_universal_in_setup_cfg(temp_pkg: Path) -> None:
+    temp_pkg.joinpath("setup.cfg").write_text("[bdist_wheel]\nuniversal=1")
     subprocess.check_call(
         [sys.executable, "setup.py", "bdist_wheel"], cwd=str(temp_pkg)
     )
-    dist_dir = temp_pkg.join("dist")
-    assert dist_dir.check(dir=1)
-    wheels = dist_dir.listdir()
+    dist_dir = temp_pkg / "dist"
+    assert dist_dir.is_dir()
+    wheels = list(dist_dir.iterdir())
     assert len(wheels) == 1
-    assert wheels[0].basename.startswith("Test-1.0-py2.py3-")
-    assert wheels[0].ext == ".whl"
+    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.join("setup.cfg").write("[bdist_wheel]\npython_tag=py32")
+def test_pythontag_in_setup_cfg(temp_pkg: Path) -> None:
+    temp_pkg.joinpath("setup.cfg").write_text("[bdist_wheel]\npython_tag=py32")
     subprocess.check_call(
         [sys.executable, "setup.py", "bdist_wheel"], cwd=str(temp_pkg)
     )
-    dist_dir = temp_pkg.join("dist")
-    assert dist_dir.check(dir=1)
-    wheels = dist_dir.listdir()
+    dist_dir = temp_pkg / "dist"
+    assert dist_dir.is_dir()
+    wheels = list(dist_dir.iterdir())
     assert len(wheels) == 1
-    assert wheels[0].basename.startswith("Test-1.0-py32-")
-    assert wheels[0].ext == ".whl"
+    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.join("setup.cfg").write("[wheel]\nuniversal=1")
+def test_legacy_wheel_section_in_setup_cfg(temp_pkg: Path) -> None:
+    temp_pkg.joinpath("setup.cfg").write_text("[wheel]\nuniversal=1")
     subprocess.check_call(
         [sys.executable, "setup.py", "bdist_wheel"], cwd=str(temp_pkg)
     )
-    dist_dir = temp_pkg.join("dist")
-    assert dist_dir.check(dir=1)
-    wheels = dist_dir.listdir()
+    dist_dir = temp_pkg / "dist"
+    assert dist_dir.is_dir()
+    wheels = list(dist_dir.iterdir())
     assert len(wheels) == 1
-    assert wheels[0].basename.startswith("Test-1.0-py2.py3-")
-    assert wheels[0].ext == ".whl"
+    assert wheels[0].name.startswith("Test-1.0-py2.py3-")
+    assert wheels[0].suffix == ".whl"
 
 
-def test_plat_name_purepy(temp_pkg):
+def test_plat_name_purepy(temp_pkg: Path) -> None:
     subprocess.check_call(
         [sys.executable, "setup.py", "bdist_wheel", "--plat-name=testplat.pure"],
         cwd=str(temp_pkg),
     )
-    dist_dir = temp_pkg.join("dist")
-    assert dist_dir.check(dir=1)
-    wheels = dist_dir.listdir()
+    dist_dir = temp_pkg / "dist"
+    assert dist_dir.is_dir()
+    wheels = list(dist_dir.iterdir())
     assert len(wheels) == 1
-    assert wheels[0].basename.endswith("-testplat_pure.whl")
-    assert wheels[0].ext == ".whl"
+    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):
+def test_plat_name_ext(temp_pkg: Path) -> None:
     subprocess.check_call(
         [sys.executable, "setup.py", "bdist_wheel", "--plat-name=testplat.arch"],
         cwd=str(temp_pkg),
     )
-
-    dist_dir = temp_pkg.join("dist")
-    assert dist_dir.check(dir=1)
-    wheels = dist_dir.listdir()
+    dist_dir = temp_pkg / "dist"
+    assert dist_dir.is_dir()
+    wheels = list(dist_dir.iterdir())
     assert len(wheels) == 1
-    assert wheels[0].basename.endswith("-testplat_arch.whl")
-    assert wheels[0].ext == ".whl"
+    assert wheels[0].name.endswith("-testplat_arch.whl")
+    assert wheels[0].suffix == ".whl"
 
 
-def test_plat_name_purepy_in_setupcfg(temp_pkg):
-    temp_pkg.join("setup.cfg").write("[bdist_wheel]\nplat_name=testplat.pure")
+def test_plat_name_purepy_in_setupcfg(temp_pkg: Path) -> None:
+    temp_pkg.joinpath("setup.cfg").write_text("[bdist_wheel]\nplat_name=testplat.pure")
     subprocess.check_call(
         [sys.executable, "setup.py", "bdist_wheel"], cwd=str(temp_pkg)
     )
-    dist_dir = temp_pkg.join("dist")
-    assert dist_dir.check(dir=1)
-    wheels = dist_dir.listdir()
+    dist_dir = temp_pkg / "dist"
+    assert dist_dir.is_dir()
+    wheels = list(dist_dir.iterdir())
     assert len(wheels) == 1
-    assert wheels[0].basename.endswith("-testplat_pure.whl")
-    assert wheels[0].ext == ".whl"
+    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.join("setup.cfg").write("[bdist_wheel]\nplat_name=testplat.arch")
+def test_plat_name_ext_in_setupcfg(temp_pkg: Path) -> None:
+    temp_pkg.joinpath("setup.cfg").write_text("[bdist_wheel]\nplat_name=testplat.arch")
     subprocess.check_call(
         [sys.executable, "setup.py", "bdist_wheel"], cwd=str(temp_pkg)
     )
-
-    dist_dir = temp_pkg.join("dist")
-    assert dist_dir.check(dir=1)
-    wheels = dist_dir.listdir()
+    dist_dir = temp_pkg.joinpath("dist")
+    assert dist_dir.is_dir()
+    wheels = list(dist_dir.iterdir())
     assert len(wheels) == 1
-    assert wheels[0].basename.endswith("-testplat_arch.whl")
-    assert wheels[0].ext == ".whl"
+    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 ce134f9d..90728cde 100644
--- a/tests/test_wheelfile.py
+++ b/tests/test_wheelfile.py
@@ -1,58 +1,57 @@
 from __future__ import annotations
 
+import os.path
 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 import WheelError, WheelReader, WheelWriter
 
 
 @pytest.fixture
-def wheel_path(tmpdir):
-    return str(tmpdir.join("test-1.0-py2.py3-none-any.whl"))
+def wheel_path(tmp_path: Path) -> Path:
+    return tmp_path / "test-1.0-py2.py3-none-any.whl"
 
 
 @pytest.mark.parametrize(
-    "filename",
+    "filename, reason",
     [
-        "foo-2-py3-none-any.whl",
-        "foo-2-py2.py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
-    ],
-)
-def test_wheelfile_re(filename, tmpdir):
-    # Regression test for #208 and #485
-    path = tmpdir.join(filename)
-    with WheelFile(str(path), "w") as wf:
-        assert wf.parsed_filename.group("namever") == "foo-2"
-
-
-@pytest.mark.parametrize(
-    "filename",
-    [
-        "test.whl",
-        "test-1.0.whl",
-        "test-1.0-py2.whl",
-        "test-1.0-py2-none.whl",
-        "test-1.0-py2-none-any",
-        "test-1.0-py 2-none-any.whl",
+        pytest.param("test.whl", "wrong number of parts"),
+        pytest.param("test-1.0.whl", "wrong number of parts"),
+        pytest.param("test-1.0-py2.whl", "wrong number of parts"),
+        pytest.param("test-1.0-py2-none.whl", "wrong number of parts"),
+        pytest.param("test-1.0-py2-none-any", "extension must be '.whl'"),
+        pytest.param(
+            "test-1.0-py 2-none-any.whl",
+            "bad file name",
+            marks=[pytest.mark.xfail(reason="packaging does not fail this yet")],
+        ),
     ],
 )
-def test_bad_wheel_filename(filename):
-    exc = pytest.raises(WheelError, WheelFile, filename)
-    exc.match(f"^Bad wheel filename {filename!r}$")
+def test_bad_wheel_filename(filename: str, reason: str) -> None:
+    basename = os.path.splitext(filename)[0] if filename.endswith(".whl") else filename
+    exc = pytest.raises(WheelError, WheelReader, filename)
+    exc.match(rf"^Invalid wheel filename \({reason}\): {basename}$")
 
 
-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')
 
-    exc = pytest.raises(WheelError, WheelFile, wheel_path)
-    exc.match("^Missing test-1.0.dist-info/RECORD file$")
+    with pytest.raises(
+        WheelError,
+        match=(
+            "^Cannot find a valid .dist-info directory. Is this really a wheel file\\?$"
+        ),
+    ):
+        with WheelReader(wheel_path):
+            pass
 
 
-def test_unsupported_hash_algorithm(wheel_path):
+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(
@@ -60,23 +59,30 @@ def test_unsupported_hash_algorithm(wheel_path):
             "hello/héllö.py,sha000=bv-QV3RciQC2v3zL8Uvhd_arp40J5A9xmyubN34OVwo,25",
         )
 
-    exc = pytest.raises(WheelError, WheelFile, wheel_path)
-    exc.match("^Unsupported hash algorithm: sha000$")
+    with pytest.raises(WheelError, match="^Unsupported hash algorithm: sha000$"):
+        with WheelReader(wheel_path):
+            pass
 
 
 @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')
         zf.writestr("test-1.0.dist-info/RECORD", f"hello/héllö.py,{hash_string},25")
 
-    exc = pytest.raises(WheelError, WheelFile, wheel_path)
-    exc.match(rf"^Weak hash algorithm \({algorithm}\) is not permitted by PEP 427$")
+    with pytest.raises(
+        WheelError,
+        match=rf"^Weak hash algorithm \({algorithm}\) is not permitted by PEP 427$",
+    ):
+        with WheelReader(wheel_path):
+            pass
 
 
 @pytest.mark.parametrize(
@@ -92,27 +98,27 @@ def test_weak_hash_algorithm(wheel_path, algorithm, digest):
     ],
     ids=["sha256", "sha384", "sha512"],
 )
-def test_testzip(wheel_path, algorithm, digest):
+def test_validate_record(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')
         zf.writestr("test-1.0.dist-info/RECORD", f"hello/héllö.py,{hash_string},25")
 
-    with WheelFile(wheel_path) as wf:
-        wf.testzip()
+    with WheelReader(wheel_path) as wf:
+        wf.validate_record()
 
 
-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", "")
 
-    with WheelFile(wheel_path) as wf:
-        exc = pytest.raises(WheelError, wf.testzip)
+    with WheelReader(wheel_path) as wf:
+        exc = pytest.raises(WheelError, wf.validate_record)
         exc.match("^No hash found for file 'hello/héllö.py'$")
 
 
-def test_testzip_bad_hash(wheel_path):
+def test_validate_record_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(
@@ -120,67 +126,72 @@ def test_testzip_bad_hash(wheel_path):
             "hello/héllö.py,sha256=bv-QV3RciQC2v3zL8Uvhd_arp40J5A9xmyubN34OVwo,25",
         )
 
-    with WheelFile(wheel_path) as wf:
-        exc = pytest.raises(WheelError, wf.testzip)
+    with WheelReader(wheel_path) as wf:
+        exc = pytest.raises(WheelError, wf.validate_record)
         exc.match("^Hash mismatch for file 'hello/héllö.py'$")
 
 
-def test_write_str(wheel_path):
-    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')
+def test_write_file(wheel_path: Path) -> None:
+    with WheelWriter(wheel_path) as wf:
+        wf.write_file("hello/héllö.py", 'print("Héllö, world!")\n')
+        wf.write_file("hello/h,ll,.py", 'print("Héllö, world!")\n')
 
     with ZipFile(wheel_path, "r") as zf:
         infolist = zf.infolist()
-        assert len(infolist) == 3
+        assert len(infolist) == 4
         assert infolist[0].filename == "hello/héllö.py"
         assert infolist[0].file_size == 25
         assert infolist[1].filename == "hello/h,ll,.py"
         assert infolist[1].file_size == 25
-        assert infolist[2].filename == "test-1.0.dist-info/RECORD"
+        assert infolist[2].filename == "test-1.0.dist-info/WHEEL"
+        assert infolist[3].filename == "test-1.0.dist-info/RECORD"
 
         record = zf.read("test-1.0.dist-info/RECORD")
         assert record.decode("utf-8") == (
             "hello/héllö.py,sha256=bv-QV3RciQC2v3zL8Uvhd_arp40J5A9xmyubN34OVwo,25\n"
             '"hello/h,ll,.py",sha256=bv-QV3RciQC2v3zL8Uvhd_arp40J5A9xmyubN34OVwo,25\n'
+            "test-1.0.dist-info/WHEEL,"
+            "sha256=xn45MTtJwj1QxDHLE3DKYDjLqYLb8DHEh5F6k8vFf5o,105\n"
             "test-1.0.dist-info/RECORD,,\n"
         )
 
 
-def test_timestamp(tmpdir_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 = tmpdir_factory.mktemp("build")
+    build_dir = tmp_path_factory.mktemp("build")
     for filename in ("one", "two", "three"):
-        build_dir.join(filename).write(filename + "\n")
+        build_dir.joinpath(filename).write_text(filename + "\n")
 
     # The earliest date representable in TarInfos, 1980-01-01
     monkeypatch.setenv("SOURCE_DATE_EPOCH", "315576060")
 
-    with WheelFile(wheel_path, "w") as wf:
-        wf.write_files(str(build_dir))
+    with WheelWriter(wheel_path) as wf:
+        wf.write_files_from_directory(build_dir)
 
     with ZipFile(wheel_path, "r") as zf:
         for info in zf.infolist():
-            assert info.date_time[:3] == (1980, 1, 1)
+            assert info.date_time == (1980, 1, 1, 0, 0, 0)
             assert info.compress_type == ZIP_DEFLATED
 
 
 @pytest.mark.skipif(
     sys.platform == "win32", reason="Windows does not support UNIX-like permissions"
 )
-def test_attributes(tmpdir_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 = tmpdir_factory.mktemp("build")
+    build_dir = tmp_path_factory.mktemp("build")
     files = (("foo", 0o644), ("bar", 0o755))
     for filename, mode in files:
-        path = build_dir.join(filename)
-        path.write(filename + "\n")
+        path = build_dir / filename
+        path.write_text(filename + "\n")
         path.chmod(mode)
 
-    with WheelFile(wheel_path, "w") as wf:
-        wf.write_files(str(build_dir))
+    with WheelWriter(wheel_path) as wf:
+        wf.write_files_from_directory(build_dir)
 
     with ZipFile(wheel_path, "r") as zf:
         for filename, mode in files:
@@ -191,3 +202,17 @@ def test_attributes(tmpdir_factory, wheel_path):
         info = zf.getinfo("test-1.0.dist-info/RECORD")
         permissions = (info.external_attr >> 16) & 0o777
         assert permissions == 0o664
+
+
+def test_unnormalized_wheel(tmp_path: Path) -> None:
+    # Previous versions of "wheel" did not correctly normalize the names; test that we
+    # can still read such wheels
+    wheel_path = tmp_path / "Test_foo_bar-1.0.0-py3-none-any.whl"
+    with ZipFile(wheel_path, "w") as zf:
+        zf.writestr(
+            "Test_foo_bar-1.0.0.dist-info/RECORD",
+            "Test_foo_bar-1.0.0.dist-info/RECORD,,\n",
+        )
+
+    with WheelReader(wheel_path):
+        pass
diff --git a/tox.ini b/tox.ini
index 1d170e60..81fbf2ee 100644
--- a/tox.ini
+++ b/tox.ini
@@ -16,6 +16,6 @@ extras = test
 [testenv:lint]
 depends =
 basepython = python3
-deps = pre-commit
-commands = pre-commit run --all-files --show-diff-on-failure
+deps = flake8
+commands = flake8 src tests
 skip_install = true