From d5969f02fabbcbc9938390a0df6b818661a97210 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Tue, 9 Jun 2020 21:06:28 +0300 Subject: [PATCH 01/59] Replaced old WheelFile with the proposed new one --- src/wheel/wheelfile.py | 407 +++++++++++++++++++++++++++-------------- 1 file changed, 270 insertions(+), 137 deletions(-) diff --git a/src/wheel/wheelfile.py b/src/wheel/wheelfile.py index 3ee97ddd..7c6cfa9b 100644 --- a/src/wheel/wheelfile.py +++ b/src/wheel/wheelfile.py @@ -1,169 +1,302 @@ -from __future__ import print_function - 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 distutils import log as logger +from email.generator import Generator +from email.message import Message +from io import StringIO +from os import PathLike +from pathlib import Path +from typing import Optional, Union, Dict, Iterable, NamedTuple, Tuple from zipfile import ZIP_DEFLATED, ZipInfo, ZipFile -from wheel.cli import WheelError -from wheel.util import urlsafe_b64decode, as_unicode, native, urlsafe_b64encode, as_bytes, StringIO +from . import __version__ as wheel_version -# 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( +_WHEEL_INFO_RE = re.compile( r"""^(?P(?P.+?)-(?P.+?))(-(?P\d[^-]*))? -(?P.+?)-(?P.+?)-(?P.+?)\.whl$""", re.VERBOSE) +WheelMetadata = NamedTuple('WheelMetadata', [ + ('name', str), + ('version', str), + ('build_tag', Optional[str]), + ('implementation', str), + ('abi', str), + ('platform', str) +]) + +WheelRecordEntry = NamedTuple('_WheelRecordEntry', [ + ('hash_algorithm', str), + ('hash_value', bytes), + ('filesize', int) +]) + + +def parse_filename(filename: str) -> WheelMetadata: + parsed_filename = _WHEEL_INFO_RE.match(filename) + if parsed_filename is None: + raise WheelError('Bad wheel filename {!r}'.format(filename)) + + return WheelMetadata(*parsed_filename.groups()[1:]) + -def get_zipinfo_datetime(timestamp=None): - # Some applications need reproducible .whl files, but they can't do this without forcing - # the timestamp of the individual ZipInfo objects. See issue #143. - timestamp = int(os.environ.get('SOURCE_DATE_EPOCH', timestamp or time.time())) - return time.gmtime(timestamp)[0:6] +def make_filename(name: str, version: str, build_tag: Union[str, int, None] = None, + impl_tag: str = 'py3', abi_tag: str = 'none', plat_tag: str = 'any') -> str: + filename = '{}-{}'.format(name, version) + if build_tag is not None: + filename = '{}-{}'.format(filename, build_tag) + return '{}-{}-{}-{}.whl'.format(filename, impl_tag, abi_tag, plat_tag) -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 WheelError(Exception): + pass - 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("Bad wheel filename {!r}".format(basename)) - ZipFile.__init__(self, file, mode, compression=compression, allowZip64=True) +class WheelFile: + __slots__ = ('generator', 'root_is_purelib', '_metadata', '_mode', '_zip', '_data_path', + '_dist_info_path', '_record_path', '_record_entries', '_exclude_archive_names') + + # dist-info file names ignored for hash checking/recording + _exclude_filenames = ('RECORD', 'RECORD.jws', 'RECORD.p7s') + _default_hash_algorithm = 'sha256' + + def __init__(self, path: Union[str, PathLike], mode: str = 'r', *, + compression: int = ZIP_DEFLATED, generator: Optional[str] = None, + root_is_purelib: bool = True): + path = str(path) + self.generator = generator or 'Wheel {}'.format(wheel_version) + self.root_is_purelib = root_is_purelib + self._mode = mode + self._metadata = parse_filename(path) + self._data_path = '{meta.name}-{meta.version}.data'.format(meta=self._metadata) + self._dist_info_path = '{meta.name}-{meta.version}.dist-info'.format(meta=self._metadata) + self._record_path = self._dist_info_path + '/RECORD' + self._exclude_archive_names = frozenset(self._dist_info_path + '/' + fname + for fname in self._exclude_filenames) + self._zip = ZipFile(path, mode, compression=compression) + self._record_entries = OrderedDict() # type: Dict[str, WheelRecordEntry] - 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 = {} 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 + self._read_record() + elif mode != 'w': + raise ValueError("mode must be either 'r' or 'w'") + + @property + def path(self) -> Path: + return Path(self._zip.filename) + + @property + def mode(self) -> str: + return self._mode + + @property + def metadata(self) -> WheelMetadata: + return self._metadata - # Fill in the expected hashes by reading them from RECORD + def close(self) -> None: + try: + if self.mode == 'w': + self._write_wheelfile() + self._write_record() + except BaseException: + self._zip.close() + if self.mode == 'w': + os.unlink(self._zip.filename) + + raise + finally: try: - record = self.open(self.record_path) - except KeyError: - raise WheelError('Missing {} file'.format(self.record_path)) - - with record: - for line in record: - line = line.decode('utf-8') - path, hash_sum, size = line.rsplit(u',', 2) - if hash_sum: - algorithm, hash_sum = hash_sum.split(u'=') - try: - hashlib.new(algorithm) - except ValueError: - raise WheelError('Unsupported hash algorithm: {}'.format(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=None): - if eof is None: - eof = ef._eof - update_crc_orig(newdata) - else: # Python 2 - update_crc_orig(newdata, eof) - - running_hash.update(newdata) - if eof and running_hash.digest() != expected_hash: - raise WheelError("Hash mismatch for file '{}'".format(native(ef_name))) - - ef_name = as_unicode(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("No hash found for file '{}'".format(native(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): - logger.info("creating '%s' and adding '%s' to it", self.filename, base_dir) + self._zip.close() + finally: + self._record_entries.clear() + + def __enter__(self) -> 'WheelFile': + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.close() + + @staticmethod + def _get_zipinfo_datetime(timestamp: float) -> Tuple[int, int, int, int, int, int]: + # Some applications need reproducible .whl files, but they can't do this without forcing + # the timestamp of the individual ZipInfo objects. See issue #143. + timestamp = int(os.environ.get('SOURCE_DATE_EPOCH', timestamp)) + return time.gmtime(timestamp)[0:6] + + def write_file(self, archive_name: str, contents: Union[bytes, str, PathLike]) -> None: + timestamp = time.time() + if isinstance(contents, bytes): + data = contents + elif isinstance(contents, str): + data = contents.encode('utf-8') + elif isinstance(contents, PathLike): + path = Path(contents) + timestamp = path.stat().st_mtime + data = path.read_bytes() + else: + raise TypeError('contents must be a str, bytes or a path-like object') + + if archive_name not in self._exclude_archive_names: + hash_digest = hashlib.new(self._default_hash_algorithm, data).digest() + self._record_entries[archive_name] = WheelRecordEntry( + self._default_hash_algorithm, hash_digest, len(data)) + + zinfo = ZipInfo(archive_name, date_time=self._get_zipinfo_datetime(timestamp)) + zinfo.external_attr = 0o664 << 16 + self._zip.writestr(zinfo, data) + + def write_data_file(self, archive_name: str, contents: Union[bytes, str, PathLike]) -> None: + archive_path = self._data_path + '/' + archive_name + self.write_file(archive_path, contents) + + def write_metadata_file(self, archive_name: str, + contents: Union[bytes, str, PathLike]) -> None: + archive_path = self._dist_info_path + '/' + archive_name + self.write_file(archive_path, contents) + + def write_files_from_directory(self, base_path: Union[str, PathLike]) -> None: + base_path = Path(base_path) + if not base_path.is_dir(): + raise WheelError('{} is not a directory'.format(base_path)) + deferred = [] - for root, dirnames, filenames in os.walk(base_dir): + for root, dirnames, filenames in os.walk(str(base_path)): # Sort the directory names so that `os.walk` will walk them in a # defined order on the next iteration. dirnames.sort() + root_path = base_path / root 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: + path = root_path / name + if path.is_file(): + archive_name = str(path.relative_to(base_path)) + if archive_name in self._exclude_filenames: pass elif root.endswith('.dist-info'): - deferred.append((path, arcname)) + deferred.append((path, archive_name)) 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, bytes, compress_type=None): - ZipFile.writestr(self, zinfo_or_arcname, bytes, compress_type) - fname = (zinfo_or_arcname.filename if isinstance(zinfo_or_arcname, ZipInfo) - else zinfo_or_arcname) - logger.info("adding '%s'", fname) - if fname != self.record_path: - hash_ = self._default_algorithm(bytes) - self._file_hashes[fname] = hash_.name, native(urlsafe_b64encode(hash_.digest())) - self._file_sizes[fname] = len(bytes) - - 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] + self.write_file(archive_name, path) + + for path, archive_name in sorted(deferred): + self.write_file(archive_name, path) + + def read_file(self, archive_name: str) -> bytes: + try: + contents = self._zip.read(archive_name) + except KeyError: + raise WheelError('File {} not found'.format(archive_name)) from None + + if archive_name in self._record_entries: + entry = self._record_entries[archive_name] + if len(contents) != entry.filesize: + raise WheelError('{}: file size mismatch: {} bytes in RECORD, {} bytes in archive' + .format(archive_name, entry.filesize, len(contents))) + + computed_hash = hashlib.new(entry.hash_algorithm, contents).digest() + if computed_hash != entry.hash_value: + raise WheelError( + '{}: hash mismatch: {} in RECORD, {} computed from current file contents' + .format(archive_name, urlsafe_b64encode(entry.hash_value).decode('ascii'), + urlsafe_b64encode(computed_hash).decode('ascii'))) + + return contents + + def read_data_file(self, filename: str) -> bytes: + return self.read_file(self._data_path + '/' + filename) + + def read_metadata_file(self, filename: str) -> bytes: + return self.read_file(self._dist_info_path + '/' + filename) + + def unpack(self, dest_dir: Union[str, PathLike], + archive_names: Union[str, Iterable[str], None] = None) -> None: + base_path = Path(dest_dir) + if not base_path.is_dir(): + raise WheelError('{} is not a directory'.format(base_path)) + + if archive_names is None: + filenames = self._zip.infolist() + elif isinstance(archive_names, str): + filenames = [self._zip.getinfo(archive_names)] + else: + filenames = [self._zip.getinfo(fname) for fname in archive_names] + + for zinfo in filenames: + entry = None # type: Optional[WheelRecordEntry] + if zinfo.filename in self._record_entries: + entry = self._record_entries[zinfo.filename] + + path = base_path.joinpath(zinfo.filename) + with self._zip.open(zinfo) as infile, path.open('wb') as outfile: + hash_ = hashlib.new(entry.hash_algorithm) if entry else None + while True: + data = infile.read(1024 * 1024) + if data: + if hash_: + hash_.update(data) + + outfile.write(data) + else: + break + + if hash_ is not None and entry is not None and hash_.digest() != entry.hash_value: + raise WheelError( + '{}: hash mismatch: {} in RECORD, {} computed from current file contents' + .format(zinfo.filename, urlsafe_b64encode(entry.hash_value).decode('ascii'), + urlsafe_b64encode(hash_.digest()).decode('ascii')) ) - for fname, (algorithm, hash_) in self._file_hashes.items() - )) - writer.writerow((format(self.record_path), "", "")) - zinfo = ZipInfo(native(self.record_path), date_time=get_zipinfo_datetime()) - zinfo.compress_type = self.compression - zinfo.external_attr = 0o664 << 16 - self.writestr(zinfo, as_bytes(data.getvalue())) - - ZipFile.close(self) + + def _read_record(self) -> None: + self._record_entries.clear() + contents = self.read_metadata_file('RECORD').decode('utf-8') + for line in contents.split('\n'): + path, hash_digest, filesize = line.rsplit(',', 2) + if hash_digest: + algorithm, hash_digest = hash_digest.split('=') + try: + hashlib.new(algorithm) + except ValueError: + raise WheelError('Unsupported hash algorithm: {}'.format(algorithm)) + + if algorithm.lower() in {'md5', 'sha1'}: + raise WheelError( + 'Weak hash algorithm ({}) is not permitted by PEP 427' + .format(algorithm)) + + self._record_entries[path] = WheelRecordEntry( + algorithm, urlsafe_b64decode(hash_digest), int(filesize)) + + def _write_record(self) -> None: + data = StringIO() + writer = csv.writer(data, delimiter=',', quotechar='"', lineterminator='\n') + writer.writerows([ + (fname, + entry.hash_algorithm + "=" + urlsafe_b64encode(entry.hash_value).decode('ascii'), + entry.filesize) + for fname, entry in self._record_entries.items() + ]) + writer.writerow((self._record_path, "", "")) + self.write_metadata_file('RECORD', data.getvalue()) + + def _write_wheelfile(self) -> None: + msg = Message() + msg['Wheel-Version'] = '1.0' # of the spec + msg['Generator'] = self.generator + msg['Root-Is-Purelib'] = str(self.root_is_purelib).lower() + if self.metadata.build_tag is not None: + msg['Build'] = self.metadata.build_tag + + for impl in self.metadata.implementation.split('.'): + for abi in self.metadata.abi.split('.'): + for plat in self.metadata.platform.split('.'): + msg['Tag'] = '-'.join((impl, abi, plat)) + + buffer = StringIO() + Generator(buffer, maxheaderlen=0).flatten(msg) + self.write_metadata_file('WHEEL', buffer.getvalue()) + + def __repr__(self): + return '{}({!r}, {!r})'.format(self.__class__.__name__, self.path, self.mode) From a38821205fa9356251ff299d29a6b04d7de91fab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Wed, 10 Jun 2020 15:39:03 +0300 Subject: [PATCH 02/59] Added support for using WheelFile with existing file handles --- src/wheel/wheelfile.py | 37 +++++++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/src/wheel/wheelfile.py b/src/wheel/wheelfile.py index 7c6cfa9b..1d5916e9 100644 --- a/src/wheel/wheelfile.py +++ b/src/wheel/wheelfile.py @@ -7,10 +7,10 @@ from collections import OrderedDict from email.generator import Generator from email.message import Message -from io import StringIO +from io import StringIO, FileIO from os import PathLike from pathlib import Path -from typing import Optional, Union, Dict, Iterable, NamedTuple, Tuple +from typing import Optional, Union, Dict, Iterable, NamedTuple, Tuple, IO from zipfile import ZIP_DEFLATED, ZipInfo, ZipFile from . import __version__ as wheel_version @@ -58,37 +58,46 @@ class WheelError(Exception): class WheelFile: - __slots__ = ('generator', 'root_is_purelib', '_metadata', '_mode', '_zip', '_data_path', + __slots__ = ('generator', 'root_is_purelib', '_mode', '_metadata', '_zip', '_data_path', '_dist_info_path', '_record_path', '_record_entries', '_exclude_archive_names') # dist-info file names ignored for hash checking/recording _exclude_filenames = ('RECORD', 'RECORD.jws', 'RECORD.p7s') _default_hash_algorithm = 'sha256' - def __init__(self, path: Union[str, PathLike], mode: str = 'r', *, - compression: int = ZIP_DEFLATED, generator: Optional[str] = None, - root_is_purelib: bool = True): - path = str(path) + def __init__(self, path_or_fd: Union[str, PathLike, IO[bytes]], mode: str = 'r', *, + metadata: Optional[WheelMetadata] = None, compression: int = ZIP_DEFLATED, + generator: Optional[str] = None, root_is_purelib: bool = True): + if mode not in ('r', 'w'): + raise ValueError("mode must be either 'r' or 'w'") + + if isinstance(path_or_fd, (str, PathLike)): + path_or_fd = Path(path_or_fd).open(mode + 'b') + + if metadata is None: + if isinstance(path_or_fd, FileIO): + metadata = parse_filename(path_or_fd.name) + else: + raise WheelError('No file name or metadata provided') + self.generator = generator or 'Wheel {}'.format(wheel_version) self.root_is_purelib = root_is_purelib self._mode = mode - self._metadata = parse_filename(path) + self._metadata = metadata self._data_path = '{meta.name}-{meta.version}.data'.format(meta=self._metadata) self._dist_info_path = '{meta.name}-{meta.version}.dist-info'.format(meta=self._metadata) self._record_path = self._dist_info_path + '/RECORD' self._exclude_archive_names = frozenset(self._dist_info_path + '/' + fname for fname in self._exclude_filenames) - self._zip = ZipFile(path, mode, compression=compression) + self._zip = ZipFile(path_or_fd, mode, compression=compression) self._record_entries = OrderedDict() # type: Dict[str, WheelRecordEntry] if mode == 'r': self._read_record() - elif mode != 'w': - raise ValueError("mode must be either 'r' or 'w'") @property - def path(self) -> Path: - return Path(self._zip.filename) + def path(self) -> Optional[Path]: + return Path(self._zip.filename) if self._zip.filename else None @property def mode(self) -> str: @@ -105,7 +114,7 @@ def close(self) -> None: self._write_record() except BaseException: self._zip.close() - if self.mode == 'w': + if self.mode == 'w' and self._zip.filename: os.unlink(self._zip.filename) raise From 376cf9999a6e73eece54ed28db999a6e53ff321b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Thu, 18 Jun 2020 11:36:36 +0300 Subject: [PATCH 03/59] Use posixpath.join() to put together archive names --- src/wheel/wheelfile.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/wheel/wheelfile.py b/src/wheel/wheelfile.py index 1d5916e9..025db2fe 100644 --- a/src/wheel/wheelfile.py +++ b/src/wheel/wheelfile.py @@ -1,6 +1,7 @@ import csv import hashlib import os.path +import posixpath import re import time from base64 import urlsafe_b64decode, urlsafe_b64encode @@ -86,8 +87,8 @@ def __init__(self, path_or_fd: Union[str, PathLike, IO[bytes]], mode: str = 'r', self._metadata = metadata self._data_path = '{meta.name}-{meta.version}.data'.format(meta=self._metadata) self._dist_info_path = '{meta.name}-{meta.version}.dist-info'.format(meta=self._metadata) - self._record_path = self._dist_info_path + '/RECORD' - self._exclude_archive_names = frozenset(self._dist_info_path + '/' + fname + self._record_path = posixpath.join(self._dist_info_path + 'RECORD') + self._exclude_archive_names = frozenset(posixpath.join(self._dist_info_path, fname) for fname in self._exclude_filenames) self._zip = ZipFile(path_or_fd, mode, compression=compression) self._record_entries = OrderedDict() # type: Dict[str, WheelRecordEntry] @@ -160,12 +161,12 @@ def write_file(self, archive_name: str, contents: Union[bytes, str, PathLike]) - self._zip.writestr(zinfo, data) def write_data_file(self, archive_name: str, contents: Union[bytes, str, PathLike]) -> None: - archive_path = self._data_path + '/' + archive_name + archive_path = posixpath.join(self._data_path, archive_name) self.write_file(archive_path, contents) def write_metadata_file(self, archive_name: str, contents: Union[bytes, str, PathLike]) -> None: - archive_path = self._dist_info_path + '/' + archive_name + archive_path = posixpath.join(self._dist_info_path, archive_name) self.write_file(archive_path, contents) def write_files_from_directory(self, base_path: Union[str, PathLike]) -> None: @@ -215,10 +216,12 @@ def read_file(self, archive_name: str) -> bytes: return contents def read_data_file(self, filename: str) -> bytes: - return self.read_file(self._data_path + '/' + filename) + archive_name = posixpath.join(self._data_path, filename) + return self.read_file(archive_name) def read_metadata_file(self, filename: str) -> bytes: - return self.read_file(self._dist_info_path + '/' + filename) + archive_name = posixpath.join(self._dist_info_path, filename) + return self.read_file(archive_name) def unpack(self, dest_dir: Union[str, PathLike], archive_names: Union[str, Iterable[str], None] = None) -> None: From b03dde95bc00e5a7fc35b893d015c66aa47fe0b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Thu, 18 Jun 2020 12:01:48 +0300 Subject: [PATCH 04/59] Removed PathLike support from write_file and added the timestamp argument --- src/wheel/wheelfile.py | 54 +++++++++++++++++++----------------------- 1 file changed, 25 insertions(+), 29 deletions(-) diff --git a/src/wheel/wheelfile.py b/src/wheel/wheelfile.py index 025db2fe..60f829f7 100644 --- a/src/wheel/wheelfile.py +++ b/src/wheel/wheelfile.py @@ -6,12 +6,13 @@ import time from base64 import urlsafe_b64decode, urlsafe_b64encode from collections import OrderedDict +from datetime import datetime from email.generator import Generator from email.message import Message from io import StringIO, FileIO from os import PathLike from pathlib import Path -from typing import Optional, Union, Dict, Iterable, NamedTuple, Tuple, IO +from typing import Optional, Union, Dict, Iterable, NamedTuple, IO from zipfile import ZIP_DEFLATED, ZipInfo, ZipFile from . import __version__ as wheel_version @@ -131,43 +132,38 @@ def __enter__(self) -> 'WheelFile': def __exit__(self, exc_type, exc_val, exc_tb) -> None: self.close() - @staticmethod - def _get_zipinfo_datetime(timestamp: float) -> Tuple[int, int, int, int, int, int]: - # Some applications need reproducible .whl files, but they can't do this without forcing - # the timestamp of the individual ZipInfo objects. See issue #143. - timestamp = int(os.environ.get('SOURCE_DATE_EPOCH', timestamp)) - return time.gmtime(timestamp)[0:6] - - def write_file(self, archive_name: str, contents: Union[bytes, str, PathLike]) -> None: - timestamp = time.time() - if isinstance(contents, bytes): - data = contents - elif isinstance(contents, str): - data = contents.encode('utf-8') - elif isinstance(contents, PathLike): - path = Path(contents) - timestamp = path.stat().st_mtime - data = path.read_bytes() - else: - raise TypeError('contents must be a str, bytes or a path-like object') + def write_file(self, archive_name: str, contents: Union[bytes, str], + timestamp: Union[datetime, int] = None) -> None: + if isinstance(contents, str): + contents = contents.encode('utf-8') + elif not isinstance(contents, bytes): + raise TypeError('contents must be str or bytes') + + if timestamp is None: + timestamp = time.time() + elif isinstance(timestamp, datetime): + timestamp = timestamp.timestamp() + elif not isinstance(timestamp, int): + raise TypeError('timestamp must be int or datetime (or None to use current time') if archive_name not in self._exclude_archive_names: - hash_digest = hashlib.new(self._default_hash_algorithm, data).digest() + hash_digest = hashlib.new(self._default_hash_algorithm, contents).digest() self._record_entries[archive_name] = WheelRecordEntry( - self._default_hash_algorithm, hash_digest, len(data)) + self._default_hash_algorithm, hash_digest, len(contents)) - zinfo = ZipInfo(archive_name, date_time=self._get_zipinfo_datetime(timestamp)) + zinfo = ZipInfo(archive_name, date_time=time.gmtime(timestamp)[0:6]) zinfo.external_attr = 0o664 << 16 - self._zip.writestr(zinfo, data) + self._zip.writestr(zinfo, contents) - def write_data_file(self, archive_name: str, contents: Union[bytes, str, PathLike]) -> None: + def write_data_file(self, archive_name: str, contents: Union[bytes, str], + timestamp: Union[datetime, int] = None) -> None: archive_path = posixpath.join(self._data_path, archive_name) - self.write_file(archive_path, contents) + self.write_file(archive_path, contents, timestamp) - def write_metadata_file(self, archive_name: str, - contents: Union[bytes, str, PathLike]) -> None: + def write_metadata_file(self, archive_name: str, contents: Union[bytes, str], + timestamp: Union[datetime, int] = None) -> None: archive_path = posixpath.join(self._dist_info_path, archive_name) - self.write_file(archive_path, contents) + self.write_file(archive_path, contents, timestamp) def write_files_from_directory(self, base_path: Union[str, PathLike]) -> None: base_path = Path(base_path) From 0fd872dd7ab97a4d10db1218f219ed6b2b3bc9b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Thu, 18 Jun 2020 14:01:03 +0300 Subject: [PATCH 05/59] Don't test on Python 2.7 --- .github/workflows/codeqa-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeqa-test.yml b/.github/workflows/codeqa-test.yml index a84594b6..caa95f64 100644 --- a/.github/workflows/codeqa-test.yml +++ b/.github/workflows/codeqa-test.yml @@ -22,7 +22,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: [2.7, 3.5, 3.6, 3.8, pypy3] + python-version: [3.5, 3.6, 3.8, pypy3] exclude: - os: ubuntu-latest python-version: 3.6 From eb0e459ab073e826f2527238104eab22b4af5e5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Thu, 18 Jun 2020 14:06:13 +0300 Subject: [PATCH 06/59] Removed the write_files_from_directory() method --- src/wheel/wheelfile.py | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/src/wheel/wheelfile.py b/src/wheel/wheelfile.py index 60f829f7..ec2b0a12 100644 --- a/src/wheel/wheelfile.py +++ b/src/wheel/wheelfile.py @@ -165,31 +165,6 @@ def write_metadata_file(self, archive_name: str, contents: Union[bytes, str], archive_path = posixpath.join(self._dist_info_path, archive_name) self.write_file(archive_path, contents, timestamp) - def write_files_from_directory(self, base_path: Union[str, PathLike]) -> None: - base_path = Path(base_path) - if not base_path.is_dir(): - raise WheelError('{} is not a directory'.format(base_path)) - - deferred = [] - for root, dirnames, filenames in os.walk(str(base_path)): - # Sort the directory names so that `os.walk` will walk them in a - # defined order on the next iteration. - dirnames.sort() - root_path = base_path / root - for name in sorted(filenames): - path = root_path / name - if path.is_file(): - archive_name = str(path.relative_to(base_path)) - if archive_name in self._exclude_filenames: - pass - elif root.endswith('.dist-info'): - deferred.append((path, archive_name)) - else: - self.write_file(archive_name, path) - - for path, archive_name in sorted(deferred): - self.write_file(archive_name, path) - def read_file(self, archive_name: str) -> bytes: try: contents = self._zip.read(archive_name) From fac7e16bff5a3a0022a02799a5f2f33d15df883f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Thu, 18 Jun 2020 14:29:07 +0300 Subject: [PATCH 07/59] Renamed write_metadata_file() to write_distinfo_file() --- src/wheel/wheelfile.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/wheel/wheelfile.py b/src/wheel/wheelfile.py index ec2b0a12..7ae95055 100644 --- a/src/wheel/wheelfile.py +++ b/src/wheel/wheelfile.py @@ -160,7 +160,7 @@ def write_data_file(self, archive_name: str, contents: Union[bytes, str], archive_path = posixpath.join(self._data_path, archive_name) self.write_file(archive_path, contents, timestamp) - def write_metadata_file(self, archive_name: str, contents: Union[bytes, str], + def write_distinfo_file(self, archive_name: str, contents: Union[bytes, str], timestamp: Union[datetime, int] = None) -> None: archive_path = posixpath.join(self._dist_info_path, archive_name) self.write_file(archive_path, contents, timestamp) @@ -262,7 +262,7 @@ def _write_record(self) -> None: for fname, entry in self._record_entries.items() ]) writer.writerow((self._record_path, "", "")) - self.write_metadata_file('RECORD', data.getvalue()) + self.write_distinfo_file('RECORD', data.getvalue()) def _write_wheelfile(self) -> None: msg = Message() @@ -279,7 +279,7 @@ def _write_wheelfile(self) -> None: buffer = StringIO() Generator(buffer, maxheaderlen=0).flatten(msg) - self.write_metadata_file('WHEEL', buffer.getvalue()) + self.write_distinfo_file('WHEEL', buffer.getvalue()) def __repr__(self): return '{}({!r}, {!r})'.format(self.__class__.__name__, self.path, self.mode) From fe393299c445cd463f6048d3a91d388dc3e08620 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Thu, 18 Jun 2020 14:32:21 +0300 Subject: [PATCH 08/59] Renamed read_metadata_file() to read_distinfo_file() --- src/wheel/wheelfile.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wheel/wheelfile.py b/src/wheel/wheelfile.py index 7ae95055..81ebb3fd 100644 --- a/src/wheel/wheelfile.py +++ b/src/wheel/wheelfile.py @@ -190,7 +190,7 @@ def read_data_file(self, filename: str) -> bytes: archive_name = posixpath.join(self._data_path, filename) return self.read_file(archive_name) - def read_metadata_file(self, filename: str) -> bytes: + def read_distinfo_file(self, filename: str) -> bytes: archive_name = posixpath.join(self._dist_info_path, filename) return self.read_file(archive_name) @@ -234,7 +234,7 @@ def unpack(self, dest_dir: Union[str, PathLike], def _read_record(self) -> None: self._record_entries.clear() - contents = self.read_metadata_file('RECORD').decode('utf-8') + contents = self.read_distinfo_file('RECORD').decode('utf-8') for line in contents.split('\n'): path, hash_digest, filesize = line.rsplit(',', 2) if hash_digest: From 96b88b527a8f2555a0ca29c9fd3b0690495e41c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Thu, 18 Jun 2020 15:15:37 +0300 Subject: [PATCH 09/59] Reverted the use of posixpath.join() --- src/wheel/wheelfile.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/wheel/wheelfile.py b/src/wheel/wheelfile.py index 81ebb3fd..8e2a853c 100644 --- a/src/wheel/wheelfile.py +++ b/src/wheel/wheelfile.py @@ -1,7 +1,6 @@ import csv import hashlib import os.path -import posixpath import re import time from base64 import urlsafe_b64decode, urlsafe_b64encode @@ -88,8 +87,8 @@ def __init__(self, path_or_fd: Union[str, PathLike, IO[bytes]], mode: str = 'r', self._metadata = metadata self._data_path = '{meta.name}-{meta.version}.data'.format(meta=self._metadata) self._dist_info_path = '{meta.name}-{meta.version}.dist-info'.format(meta=self._metadata) - self._record_path = posixpath.join(self._dist_info_path + 'RECORD') - self._exclude_archive_names = frozenset(posixpath.join(self._dist_info_path, fname) + self._record_path = self._dist_info_path + '/RECORD' + self._exclude_archive_names = frozenset(self._dist_info_path + '/' + fname for fname in self._exclude_filenames) self._zip = ZipFile(path_or_fd, mode, compression=compression) self._record_entries = OrderedDict() # type: Dict[str, WheelRecordEntry] @@ -155,14 +154,14 @@ def write_file(self, archive_name: str, contents: Union[bytes, str], zinfo.external_attr = 0o664 << 16 self._zip.writestr(zinfo, contents) - def write_data_file(self, archive_name: str, contents: Union[bytes, str], + def write_data_file(self, filename: str, contents: Union[bytes, str], timestamp: Union[datetime, int] = None) -> None: - archive_path = posixpath.join(self._data_path, archive_name) + archive_path = self._data_path + '/' + filename.strip('/') self.write_file(archive_path, contents, timestamp) - def write_distinfo_file(self, archive_name: str, contents: Union[bytes, str], + def write_distinfo_file(self, filename: str, contents: Union[bytes, str], timestamp: Union[datetime, int] = None) -> None: - archive_path = posixpath.join(self._dist_info_path, archive_name) + archive_path = self._dist_info_path + '/' + filename.strip() self.write_file(archive_path, contents, timestamp) def read_file(self, archive_name: str) -> bytes: @@ -187,12 +186,12 @@ def read_file(self, archive_name: str) -> bytes: return contents def read_data_file(self, filename: str) -> bytes: - archive_name = posixpath.join(self._data_path, filename) - return self.read_file(archive_name) + archive_path = self._data_path + '/' + filename.strip('/') + return self.read_file(archive_path) def read_distinfo_file(self, filename: str) -> bytes: - archive_name = posixpath.join(self._dist_info_path, filename) - return self.read_file(archive_name) + archive_path = self._dist_info_path + '/' + filename.strip('/') + return self.read_file(archive_path) def unpack(self, dest_dir: Union[str, PathLike], archive_names: Union[str, Iterable[str], None] = None) -> None: From 3a3d7316004b1d4e398a71514dd9f0ea56008770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Thu, 18 Jun 2020 16:04:26 +0300 Subject: [PATCH 10/59] Removed more Python 2 configuration --- setup.cfg | 2 -- tox.ini | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/setup.cfg b/setup.cfg index c6e4e7aa..bf044fa4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -9,8 +9,6 @@ classifiers = Topic :: System :: Archiving :: Packaging License :: OSI Approved :: MIT License Programming Language :: Python - Programming Language :: Python :: 2 - Programming Language :: Python :: 2.7 Programming Language :: Python :: 3 Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.6 diff --git a/tox.ini b/tox.ini index 4a289ad8..301ee9fe 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,7 @@ # and then run "tox" from this directory. [tox] -envlist = py27, py35, py36, py37, py38, pypy, pypy3, flake8 +envlist = py35, py36, py37, py38, pypy, pypy3, flake8 minversion = 3.3.0 skip_missing_interpreters = true From a975348ac5b76c7fcc7edb019b36f7a20aafe8c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Thu, 18 Jun 2020 16:05:01 +0300 Subject: [PATCH 11/59] Added special support for the METADATA file --- src/wheel/wheelfile.py | 44 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/wheel/wheelfile.py b/src/wheel/wheelfile.py index 8e2a853c..ac5f686b 100644 --- a/src/wheel/wheelfile.py +++ b/src/wheel/wheelfile.py @@ -8,10 +8,11 @@ from datetime import datetime from email.generator import Generator from email.message import Message +from email.parser import Parser from io import StringIO, FileIO from os import PathLike from pathlib import Path -from typing import Optional, Union, Dict, Iterable, NamedTuple, IO +from typing import Optional, Union, Dict, Iterable, NamedTuple, IO, Tuple, List from zipfile import ZIP_DEFLATED, ZipInfo, ZipFile from . import __version__ as wheel_version @@ -111,7 +112,16 @@ def metadata(self) -> WheelMetadata: def close(self) -> None: try: if self.mode == 'w': - self._write_wheelfile() + filenames = set(self._zip.namelist()) + + metadata_path = self._dist_info_path + '/METADATA' + if metadata_path not in filenames: + self.write_metadata([]) + + wheel_path = self._dist_info_path + '/WHEEL' + if wheel_path not in filenames: + self._write_wheelfile() + self._write_record() except BaseException: self._zip.close() @@ -280,5 +290,35 @@ def _write_wheelfile(self) -> None: Generator(buffer, maxheaderlen=0).flatten(msg) self.write_distinfo_file('WHEEL', buffer.getvalue()) + def read_metadata(self) -> List[Tuple[str, str]]: + contents = self.read_distinfo_file('METADATA').decode('utf-8') + msg = Parser().parsestr(contents) + items = [(key, str(value)) for key, value in msg.items()] + payload = msg.get_payload(0, True) + if payload: + items.append(('Description', payload)) + + return items + + def write_metadata(self, items: Iterable[Tuple[str, str]]) -> None: + msg = Message() + 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'] = self._metadata.version + + buffer = StringIO() + Generator(buffer, maxheaderlen=0).flatten(msg) + self.write_distinfo_file('METADATA', buffer.getvalue()) + def __repr__(self): return '{}({!r}, {!r})'.format(self.__class__.__name__, self.path, self.mode) From f79c25ec33de360fbeae624487bf84f14400247a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 21 Jun 2020 17:12:03 +0300 Subject: [PATCH 12/59] Removed the test workflow trigger for PRs It was always triggered twice on PRs, so I hope it will work as intended now. --- .github/workflows/codeqa-test.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/codeqa-test.yml b/.github/workflows/codeqa-test.yml index caa95f64..32ad79fd 100644 --- a/.github/workflows/codeqa-test.yml +++ b/.github/workflows/codeqa-test.yml @@ -1,8 +1,6 @@ name: Python codeqa/test on: - pull_request: - branches: "*" push: branches: "*" From b2d7029fcbb41d79d281e189ec021025677dcfca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 21 Jun 2020 17:15:47 +0300 Subject: [PATCH 13/59] Refactored bdist_wheel to use the new WheelFile API --- src/wheel/bdist_wheel.py | 232 ++++++++++++++++++++------------------ src/wheel/metadata.py | 33 +++--- src/wheel/wheelfile.py | 52 ++++++--- tests/test_bdist_wheel.py | 30 ++--- tests/test_wheelfile.py | 45 ++++---- 5 files changed, 211 insertions(+), 181 deletions(-) diff --git a/src/wheel/bdist_wheel.py b/src/wheel/bdist_wheel.py index bc29a06f..413939c6 100644 --- a/src/wheel/bdist_wheel.py +++ b/src/wheel/bdist_wheel.py @@ -12,21 +12,20 @@ import re import warnings from collections import OrderedDict -from email.generator import Generator from distutils.core import Command from distutils.sysconfig import get_config_var from distutils import log as logger -from glob import iglob +from pathlib import Path from shutil import rmtree +from typing import Set from zipfile import ZIP_DEFLATED, ZIP_STORED from packaging import tags import pkg_resources -from .pkginfo import write_pkg_info from .macosx_libfile import calculate_macosx_platform_tag from .metadata import pkginfo_to_metadata -from .wheelfile import WheelFile +from .wheelfile import WheelFile, make_filename from . import __version__ as wheel_version @@ -49,6 +48,7 @@ def get_platform(archive_root): if result == "linux_x86_64" and sys.maxsize == 2147483647: # pip pull request #3497 result = "linux_i686" + return result @@ -60,7 +60,9 @@ def get_flag(var, fallback, expected=True, warn=True): if warn: warnings.warn("Config variable '{0}' is unset, Python ABI tag may " "be incorrect".format(var), RuntimeWarning, 2) + return fallback + return val == expected @@ -97,6 +99,7 @@ def get_abi_tag(): abi = soabi.replace('.', '_').replace('-', '_') else: abi = None + return abi @@ -322,68 +325,79 @@ def run(self): basedir_observed) logger.info("installing to %s", self.bdist_dir) - self.run_command('install') impl_tag, abi_tag, plat_tag = self.get_tag() - archive_basename = "{}-{}-{}-{}".format(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())) - distinfo_dir = os.path.join(self.bdist_dir, distinfo_dirname) - self.egg2dist(self.egginfo_dir, distinfo_dir) - - self.write_wheelfile(distinfo_dir) + archive_basename = make_filename( + self.distribution.get_name(), self.distribution.get_version(), + self.build_number, impl_tag, abi_tag, plat_tag + ) + print('basename:', archive_basename) + archive_root = Path(self.bdist_dir) + if self.relative: + archive_root /= self._ensure_relative(install.install_base) # 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 WheelFile(wheel_path, 'w', compression=self.compression, + generator='bdist_wheel (' + wheel_version + ')', + root_is_purelib=self.root_is_pure) as wf: + deferred = [] + for root, dirnames, filenames in os.walk(str(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 = archive_root / root + if root_path.name.endswith('.egg-info'): + continue + + for name in sorted(filenames): + path = root_path / name + if path.is_file(): + archive_name = str(path.relative_to(archive_root)) + 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 + for license_path in self.license_paths: + logger.info("adding '%s'", license_path) + wf.write_distinfo_file(os.path.basename(license_path), 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( ('bdist_wheel', '{}.{}'.format(*sys.version_info[:2]), # like 3.7 - wheel_path)) + str(wheel_path))) if not self.keep_temp: logger.info('removing %s', self.bdist_dir) if not self.dry_run: 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') - logger.info('creating %s', wheelfile_path) - with open(wheelfile_path, 'w') as f: - Generator(f, maxheaderlen=0).flatten(msg) - - 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: @@ -391,9 +405,9 @@ def _ensure_relative(self, path): return path @property - def license_paths(self): + def license_paths(self) -> Set[Path]: metadata = self.distribution.get_option_dict('metadata') - files = set() + files = set() # type: Set[Path] patterns = sorted({ option for option in metadata.get('license_files', ('', ''))[1].split() }) @@ -401,76 +415,76 @@ def license_paths(self): if 'license_file' in metadata: warnings.warn('The "license_file" option is deprecated. Use ' '"license_files" instead.', DeprecationWarning) - files.add(metadata['license_file'][1]) + files.add(Path(metadata['license_file'][1])) if 'license_file' not in metadata and 'license_files' not in metadata: patterns = ('LICEN[CS]E*', 'COPYING*', 'NOTICE*', 'AUTHORS*') for pattern in patterns: - for path in iglob(pattern): - if path.endswith('~'): + for path in Path().glob(pattern): + if path.name.endswith('~'): logger.debug('ignoring license file "%s" as it looks like a backup', path) continue - if path not in files and os.path.isfile(path): + if path not in files and path.is_file(): logger.info('adding license file "%s" (matched pattern "%s")', path, 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 = "Egg metadata expected at %s but not found" % (egginfo_path,) - if possible: - alt = os.path.basename(possible[0]) - err += " (%s found - possible misnamed archive file?)" % (alt,) - - 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, 'r') as dependency_links_file: - dependency_links = dependency_links_file.read().strip() - if not dependency_links: - adios(dependency_links_path) - - write_pkg_info(os.path.join(distinfo_path, 'METADATA'), 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) + # 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 = "Egg metadata expected at %s but not found" % (egginfo_path,) + # if possible: + # alt = os.path.basename(possible[0]) + # err += " (%s found - possible misnamed archive file?)" % (alt,) + # + # 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) + # 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) + # + # # 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, 'r') as dependency_links_file: + # dependency_links = dependency_links_file.read().strip() + # if not dependency_links: + # adios(dependency_links_path) + # + # write_pkg_info(os.path.join(distinfo_path, 'METADATA'), 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/metadata.py b/src/wheel/metadata.py index 37efa743..13e3e155 100644 --- a/src/wheel/metadata.py +++ b/src/wheel/metadata.py @@ -2,13 +2,13 @@ Tools for converting old- to new-style metadata. """ -import os.path import textwrap +from pathlib import Path +from email.parser import HeaderParser +from typing import List, Tuple import pkg_resources -from .pkginfo import read_pkg_info - def requires_to_requires_dist(requirement): """Return the version specifier for a requirement in PEP 345/566 fashion.""" @@ -62,20 +62,20 @@ def generate_requirements(extras_require): yield 'Requires-Dist', new_req + condition -def pkginfo_to_metadata(egg_info_path, pkginfo_path): - """ - Convert .egg-info directory with PKG-INFO to the Metadata 2.1 format - """ - pkg_info = read_pkg_info(pkginfo_path) +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(pkg_resources.split_sections(requires), key=lambda x: x[0] or '') for extra, reqs in parsed_requirements: @@ -83,12 +83,7 @@ def pkginfo_to_metadata(egg_info_path, pkginfo_path): if (key, value) not in pkg_info.items(): pkg_info[key] = value - description = pkg_info['Description'] - if description: - pkg_info.set_payload(dedent_description(pkg_info)) - del pkg_info['Description'] - - return pkg_info + return list(pkg_info.items()) def pkginfo_unicode(pkg_info, field): diff --git a/src/wheel/wheelfile.py b/src/wheel/wheelfile.py index ac5f686b..5462784b 100644 --- a/src/wheel/wheelfile.py +++ b/src/wheel/wheelfile.py @@ -9,7 +9,7 @@ from email.generator import Generator from email.message import Message from email.parser import Parser -from io import StringIO, FileIO +from io import StringIO from os import PathLike from pathlib import Path from typing import Optional, Union, Dict, Iterable, NamedTuple, IO, Tuple, List @@ -17,8 +17,9 @@ from . import __version__ as wheel_version +_DIST_NAME_RE = re.compile(r'[^A-Za-z0-9.]+') _WHEEL_INFO_RE = re.compile( - r"""^(?P(?P.+?)-(?P.+?))(-(?P\d[^-]*))? + r"""^(?P(?P.+?)-(?P.+?))(?:-(?P\d[^-]*))? -(?P.+?)-(?P.+?)-(?P.+?)\.whl$""", re.VERBOSE) @@ -38,6 +39,15 @@ ]) +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 parse_filename(filename: str) -> WheelMetadata: parsed_filename = _WHEEL_INFO_RE.match(filename) if parsed_filename is None: @@ -48,6 +58,8 @@ def parse_filename(filename: str) -> WheelMetadata: def make_filename(name: str, version: str, build_tag: Union[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 = '{}-{}'.format(name, version) if build_tag is not None: filename = '{}-{}'.format(filename, build_tag) @@ -60,8 +72,9 @@ class WheelError(Exception): class WheelFile: - __slots__ = ('generator', 'root_is_purelib', '_mode', '_metadata', '_zip', '_data_path', - '_dist_info_path', '_record_path', '_record_entries', '_exclude_archive_names') + __slots__ = ('generator', 'root_is_purelib', '_mode', '_metadata', '_compression', '_zip', + '_data_path', '_dist_info_path', '_record_path', '_record_entries', + '_exclude_archive_names') # dist-info file names ignored for hash checking/recording _exclude_filenames = ('RECORD', 'RECORD.jws', 'RECORD.p7s') @@ -77,8 +90,9 @@ def __init__(self, path_or_fd: Union[str, PathLike, IO[bytes]], mode: str = 'r', path_or_fd = Path(path_or_fd).open(mode + 'b') if metadata is None: - if isinstance(path_or_fd, FileIO): - metadata = parse_filename(path_or_fd.name) + filename = getattr(path_or_fd, 'name', None) + if filename: + metadata = parse_filename(os.path.basename(filename)) else: raise WheelError('No file name or metadata provided') @@ -86,12 +100,13 @@ def __init__(self, path_or_fd: Union[str, PathLike, IO[bytes]], mode: str = 'r', self.root_is_purelib = root_is_purelib self._mode = mode self._metadata = metadata + self._compression = compression self._data_path = '{meta.name}-{meta.version}.data'.format(meta=self._metadata) self._dist_info_path = '{meta.name}-{meta.version}.dist-info'.format(meta=self._metadata) self._record_path = self._dist_info_path + '/RECORD' self._exclude_archive_names = frozenset(self._dist_info_path + '/' + fname for fname in self._exclude_filenames) - self._zip = ZipFile(path_or_fd, mode, compression=compression) + self._zip = ZipFile(path_or_fd, mode) self._record_entries = OrderedDict() # type: Dict[str, WheelRecordEntry] if mode == 'r': @@ -109,6 +124,14 @@ def mode(self) -> str: def metadata(self) -> WheelMetadata: return self._metadata + @property + def record_entries(self) -> Dict[str, WheelRecordEntry]: + return self._record_entries.copy() + + @property + def filenames(self) -> List[str]: + return self._zip.namelist() + def close(self) -> None: try: if self.mode == 'w': @@ -161,6 +184,7 @@ def write_file(self, archive_name: str, contents: Union[bytes, str], self._default_hash_algorithm, hash_digest, len(contents)) zinfo = ZipInfo(archive_name, date_time=time.gmtime(timestamp)[0:6]) + zinfo.compress_type = self._compression zinfo.external_attr = 0o664 << 16 self._zip.writestr(zinfo, contents) @@ -190,8 +214,8 @@ def read_file(self, archive_name: str) -> bytes: if computed_hash != entry.hash_value: raise WheelError( '{}: hash mismatch: {} in RECORD, {} computed from current file contents' - .format(archive_name, urlsafe_b64encode(entry.hash_value).decode('ascii'), - urlsafe_b64encode(computed_hash).decode('ascii'))) + .format(archive_name, _encode_hash_value(entry.hash_value), + _encode_hash_value(computed_hash))) return contents @@ -237,14 +261,14 @@ def unpack(self, dest_dir: Union[str, PathLike], if hash_ is not None and entry is not None and hash_.digest() != entry.hash_value: raise WheelError( '{}: hash mismatch: {} in RECORD, {} computed from current file contents' - .format(zinfo.filename, urlsafe_b64encode(entry.hash_value).decode('ascii'), - urlsafe_b64encode(hash_.digest()).decode('ascii')) + .format(zinfo.filename, _encode_hash_value(entry.hash_value), + _encode_hash_value(hash_.digest())) ) def _read_record(self) -> None: self._record_entries.clear() contents = self.read_distinfo_file('RECORD').decode('utf-8') - for line in contents.split('\n'): + for line in contents.strip().split('\n'): path, hash_digest, filesize = line.rsplit(',', 2) if hash_digest: algorithm, hash_digest = hash_digest.split('=') @@ -259,14 +283,14 @@ def _read_record(self) -> None: .format(algorithm)) self._record_entries[path] = WheelRecordEntry( - algorithm, urlsafe_b64decode(hash_digest), int(filesize)) + algorithm, _decode_hash_value(hash_digest), int(filesize)) def _write_record(self) -> None: data = StringIO() writer = csv.writer(data, delimiter=',', quotechar='"', lineterminator='\n') writer.writerows([ (fname, - entry.hash_algorithm + "=" + urlsafe_b64encode(entry.hash_value).decode('ascii'), + entry.hash_algorithm + "=" + _encode_hash_value(entry.hash_value), entry.filesize) for fname, entry in self._record_entries.items() ]) diff --git a/tests/test_bdist_wheel.py b/tests/test_bdist_wheel.py index 91eb8c12..2eea587f 100644 --- a/tests/test_bdist_wheel.py +++ b/tests/test_bdist_wheel.py @@ -1,4 +1,3 @@ -# coding: utf-8 import os.path import shutil import stat @@ -47,18 +46,21 @@ def dummy_dist(tmpdir_factory): def test_no_scripts(wheel_paths): """Make sure entry point scripts are not generated.""" path = next(path for path in wheel_paths if 'complex_dist' in path) - for entry in ZipFile(path).infolist(): - assert '.data/scripts/' not in entry.filename + with WheelFile(path) as wf: + filenames = set(wf.filenames) + + for filename in filenames: + assert '.data/scripts/' not in filename @pytest.mark.skipif(sys.version_info < (3, 6), reason='Packaging unicode file names only works reliably on Python 3.6+') 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') + with WheelFile(path) as wf: + filenames = set(wf.record_entries) - assert u'åäö_日本語.py'.encode('utf-8') in record + assert 'unicodedist/åäö_日本語.py' in filenames def test_licenses_default(dummy_dist, monkeypatch, tmpdir): @@ -67,7 +69,7 @@ def test_licenses_default(dummy_dist, monkeypatch, tmpdir): '--universal']) with WheelFile('dist/dummy_dist-1.0-py2.py3-none-any.whl') as wf: license_files = {'dummy_dist-1.0.dist-info/' + fname for fname in DEFAULT_LICENSE_FILES} - assert set(wf.namelist()) == DEFAULT_FILES | license_files + assert set(wf.filenames) == DEFAULT_FILES | license_files def test_licenses_deprecated(dummy_dist, monkeypatch, tmpdir): @@ -77,7 +79,7 @@ def test_licenses_deprecated(dummy_dist, monkeypatch, 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 def test_licenses_override(dummy_dist, monkeypatch, tmpdir): @@ -87,7 +89,7 @@ def test_licenses_override(dummy_dist, monkeypatch, tmpdir): '--universal']) with WheelFile('dist/dummy_dist-1.0-py2.py3-none-any.whl') as wf: license_files = {'dummy_dist-1.0.dist-info/' + fname for fname in {'DUMMYFILE', 'LICENSE'}} - assert set(wf.namelist()) == DEFAULT_FILES | license_files + assert set(wf.filenames) == DEFAULT_FILES | license_files def test_licenses_disabled(dummy_dist, monkeypatch, tmpdir): @@ -96,7 +98,7 @@ def test_licenses_disabled(dummy_dist, monkeypatch, tmpdir): 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: - assert set(wf.namelist()) == DEFAULT_FILES + assert set(wf.filenames) == DEFAULT_FILES def test_build_number(dummy_dist, monkeypatch, tmpdir): @@ -104,7 +106,7 @@ def test_build_number(dummy_dist, monkeypatch, tmpdir): subprocess.check_call([sys.executable, 'setup.py', 'bdist_wheel', '-b', str(tmpdir), '--universal', '--build-number=2']) with WheelFile('dist/dummy_dist-1.0-2-py2.py3-none-any.whl') as wf: - filenames = set(wf.namelist()) + filenames = set(wf.filenames) assert 'dummy_dist-1.0.dist-info/RECORD' in filenames assert 'dummy_dist-1.0.dist-info/METADATA' in filenames @@ -140,9 +142,9 @@ def test_compression(dummy_dist, monkeypatch, tmpdir, option, compress_type): monkeypatch.chdir(dummy_dist) subprocess.check_call([sys.executable, 'setup.py', 'bdist_wheel', '-b', str(tmpdir), '--universal', '--compression={}'.format(option)]) - with WheelFile('dist/dummy_dist-1.0-py2.py3-none-any.whl') as wf: - filenames = set(wf.namelist()) + with ZipFile('dist/dummy_dist-1.0-py2.py3-none-any.whl') as zf: + filenames = set(zf.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: + for zinfo in zf.infolist(): assert zinfo.compress_type == compress_type diff --git a/tests/test_wheelfile.py b/tests/test_wheelfile.py index db11bcd2..6dd72586 100644 --- a/tests/test_wheelfile.py +++ b/tests/test_wheelfile.py @@ -1,13 +1,9 @@ -# coding: utf-8 -from __future__ import unicode_literals - import sys from zipfile import ZipFile, ZIP_DEFLATED import pytest from wheel.cli import WheelError -from wheel.util import native, as_bytes from wheel.wheelfile import WheelFile @@ -20,7 +16,8 @@ def test_wheelfile_re(tmpdir): # Regression test for #208 path = tmpdir.join('foo-2-py3-none-any.whl') with WheelFile(str(path), 'w') as wf: - assert wf.parsed_filename.group('namever') == 'foo-2' + assert wf.metadata.name == 'foo' + assert wf.metadata.version == '2' @pytest.mark.parametrize('filename', [ @@ -37,7 +34,7 @@ def test_bad_wheel_filename(filename): def test_missing_record(wheel_path): with ZipFile(wheel_path, 'w') as zf: - zf.writestr(native('hello/héllö.py'), as_bytes('print("Héllö, w0rld!")\n')) + 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$") @@ -45,10 +42,10 @@ def test_missing_record(wheel_path): def test_unsupported_hash_algorithm(wheel_path): with ZipFile(wheel_path, 'w') as zf: - zf.writestr(native('hello/héllö.py'), as_bytes('print("Héllö, w0rld!")\n')) + zf.writestr('hello/héllö.py', 'print("Héllö, w0rld!")\n') zf.writestr( 'test-1.0.dist-info/RECORD', - as_bytes('hello/héllö.py,sha000=bv-QV3RciQC2v3zL8Uvhd_arp40J5A9xmyubN34OVwo,25')) + 'hello/héllö.py,sha000=bv-QV3RciQC2v3zL8Uvhd_arp40J5A9xmyubN34OVwo,25') exc = pytest.raises(WheelError, WheelFile, wheel_path) exc.match("^Unsupported hash algorithm: sha000$") @@ -61,9 +58,8 @@ def test_unsupported_hash_algorithm(wheel_path): def test_weak_hash_algorithm(wheel_path, algorithm, digest): hash_string = '{}={}'.format(algorithm, digest) with ZipFile(wheel_path, 'w') as zf: - zf.writestr(native('hello/héllö.py'), as_bytes('print("Héllö, w0rld!")\n')) - zf.writestr('test-1.0.dist-info/RECORD', - as_bytes('hello/héllö.py,{},25'.format(hash_string))) + zf.writestr('hello/héllö.py', 'print("Héllö, w0rld!")\n') + zf.writestr('test-1.0.dist-info/RECORD', 'hello/héllö.py,{},25'.format(hash_string)) exc = pytest.raises(WheelError, WheelFile, wheel_path) exc.match(r"^Weak hash algorithm \({}\) is not permitted by PEP 427$".format(algorithm)) @@ -78,9 +74,8 @@ def test_weak_hash_algorithm(wheel_path, algorithm, digest): def test_testzip(wheel_path, algorithm, digest): hash_string = '{}={}'.format(algorithm, digest) with ZipFile(wheel_path, 'w') as zf: - zf.writestr(native('hello/héllö.py'), as_bytes('print("Héllö, world!")\n')) - zf.writestr('test-1.0.dist-info/RECORD', - as_bytes('hello/héllö.py,{},25'.format(hash_string))) + zf.writestr('hello/héllö.py', 'print("Héllö, world!")\n') + zf.writestr('test-1.0.dist-info/RECORD', 'hello/héllö.py,{},25'.format(hash_string)) with WheelFile(wheel_path) as wf: wf.testzip() @@ -88,42 +83,42 @@ def test_testzip(wheel_path, algorithm, digest): def test_testzip_missing_hash(wheel_path): with ZipFile(wheel_path, 'w') as zf: - zf.writestr(native('hello/héllö.py'), as_bytes('print("Héllö, world!")\n')) + 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) - exc.match(native("^No hash found for file 'hello/héllö.py'$")) + exc.match("^No hash found for file 'hello/héllö.py'$") def test_testzip_bad_hash(wheel_path): with ZipFile(wheel_path, 'w') as zf: - zf.writestr(native('hello/héllö.py'), as_bytes('print("Héllö, w0rld!")\n')) + zf.writestr('hello/héllö.py', 'print("Héllö, w0rld!")\n') zf.writestr( 'test-1.0.dist-info/RECORD', - as_bytes('hello/héllö.py,sha256=bv-QV3RciQC2v3zL8Uvhd_arp40J5A9xmyubN34OVwo,25')) + 'hello/héllö.py,sha256=bv-QV3RciQC2v3zL8Uvhd_arp40J5A9xmyubN34OVwo,25') with WheelFile(wheel_path) as wf: exc = pytest.raises(WheelError, wf.testzip) - exc.match(native("^Hash mismatch for file 'hello/héllö.py'$")) + 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(native('hello/héllö.py'), as_bytes('print("Héllö, world!")\n')) - wf.writestr(native('hello/h,ll,.py'), as_bytes('print("Héllö, world!")\n')) + 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 infolist[0].filename == native('hello/héllö.py') + assert infolist[0].filename == 'hello/héllö.py' assert infolist[0].file_size == 25 - assert infolist[1].filename == native('hello/h,ll,.py') + assert infolist[1].filename == 'hello/h,ll,.py' assert infolist[1].file_size == 25 assert infolist[2].filename == 'test-1.0.dist-info/RECORD' record = zf.read('test-1.0.dist-info/RECORD') - assert record == as_bytes( + 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/RECORD,,\n') @@ -137,7 +132,7 @@ def test_timestamp(tmpdir_factory, wheel_path, monkeypatch): build_dir.join(filename).write(filename + '\n') # The earliest date representable in TarInfos, 1980-01-01 - monkeypatch.setenv(native('SOURCE_DATE_EPOCH'), native('315576060')) + monkeypatch.setenv('SOURCE_DATE_EPOCH', '315576060') with WheelFile(wheel_path, 'w') as wf: wf.write_files(str(build_dir)) From e7fe9bb74ed75f9142badb6e9bec79dc759c9400 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 21 Jun 2020 17:35:04 +0300 Subject: [PATCH 14/59] Refactored test suite and CLI tools to use tmp_path The tmp_path and tmp_path_factory fixtures are based on pathlib while tmpdir/tmpdir_factory are based on the third party py.path.local library. --- src/wheel/cli/convert.py | 90 ++++++++++++------------ src/wheel/cli/pack.py | 55 ++++++++------- src/wheel/cli/unpack.py | 19 ++++-- tests/cli/test_convert.py | 10 +-- tests/cli/test_pack.py | 14 ++-- tests/cli/test_unpack.py | 4 +- tests/conftest.py | 21 +++--- tests/test_bdist_wheel.py | 56 +++++++-------- tests/test_metadata.py | 14 ++-- tests/test_pkginfo.py | 6 +- tests/test_tagopt.py | 140 +++++++++++++++++++------------------- tests/test_wheelfile.py | 22 +++--- 12 files changed, 235 insertions(+), 216 deletions(-) diff --git a/src/wheel/cli/convert.py b/src/wheel/cli/convert.py index 154f1b1e..34673a37 100755 --- a/src/wheel/cli/convert.py +++ b/src/wheel/cli/convert.py @@ -1,16 +1,23 @@ -import os.path import re import shutil import sys import tempfile import zipfile from distutils import dist -from glob import iglob +from pathlib import Path +from typing import Union, Dict, Iterable + +from wheel.wheelfile import make_filename from ..bdist_wheel import bdist_wheel from ..wheelfile import WheelFile from . import WheelError, require_pkgresources +if sys.version_info >= (3, 6): + from os import PathLike +else: + PathLike = Path + egg_info_re = re.compile(r''' (?P.+?)-(?P.+?) (-(?Ppy\d\.\d+) @@ -34,26 +41,26 @@ def get_tag(self): return bdist_wheel.get_tag(self) -def egg2wheel(egg_path, dest_dir): - filename = os.path.basename(egg_path) - match = egg_info_re.match(filename) +def egg2wheel(egg_path: Union[str, PathLike], dest_dir: Union[str, PathLike]) -> None: + egg_path = Path(egg_path) + dest_dir = Path(dest_dir) + match = egg_info_re.match(egg_path.name) if not match: - raise WheelError('Invalid egg file name: {}'.format(filename)) + raise WheelError('Invalid egg file name: {}'.format(egg_path.name)) egg_info = match.groupdict() - dir = tempfile.mkdtemp(suffix="_e2w") - if os.path.isfile(egg_path): + tmp_path = Path(tempfile.mkdtemp(suffix="_e2w")) + if egg_path.is_file(): # assume we have a bdist_egg otherwise - with zipfile.ZipFile(egg_path) as egg: - egg.extractall(dir) + with zipfile.ZipFile(str(egg_path)) as egg: + egg.extractall(str(tmp_path)) 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) + for pth in egg_path.iterdir(): + if pth.is_file(): + shutil.copy2(str(pth), tmp_path) else: - shutil.copytree(src, os.path.join(dir, pth)) + shutil.copytree(str(pth), tmp_path / pth.name) pyver = egg_info['pyver'] if pyver: @@ -78,17 +85,17 @@ def egg2wheel(egg_path, dest_dir): 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) + dist_info_dir = tmp_path / '{name}-{ver}.dist-info'.format(**egg_info) + bw.egg2dist(tmp_path / 'EGG-INFO', dist_info_dir) + wheel_name = make_filename(egg_info['name'], egg_info['ver'], impl_tag=pyver, + abi_tag=abi, plat_tag=arch) + with WheelFile(dest_dir / wheel_name, 'w', generator='egg2wheel') as wf: + wf.write_files(tmp_path) - shutil.rmtree(dir) + shutil.rmtree(str(tmp_path)) -def parse_wininst_info(wininfo_name, egginfo_name): +def parse_wininst_info(wininfo_name: str, egginfo_name: str) -> Dict[str, str]: """Extract metadata from filenames. Extracts the 4 metadataitems needed (name, version, pyversion, arch) from @@ -159,8 +166,8 @@ def parse_wininst_info(wininfo_name, egginfo_name): return {'name': w_name, 'ver': w_ver, 'arch': w_arch, 'pyver': w_pyver} -def wininst2wheel(path, dest_dir): - with zipfile.ZipFile(path) as bdw: +def wininst2wheel(path: Union[str, PathLike], dest_dir: Union[str, PathLike]) -> None: + with zipfile.ZipFile(str(path)) as bdw: # Search for egg-info in the archive egginfo_name = None for filename in bdw.namelist(): @@ -168,7 +175,7 @@ def wininst2wheel(path, dest_dir): egginfo_name = filename break - info = parse_wininst_info(os.path.basename(path), egginfo_name) + info = parse_wininst_info(path.name, egginfo_name) root_is_purelib = True for zipinfo in bdw.infolist(): @@ -207,8 +214,8 @@ def wininst2wheel(path, dest_dir): egginfo_name = newname elif '.egg-info/' in newname: egginfo_name, sep, _ = newname.rpartition('/') - dir = tempfile.mkdtemp(suffix="_b2w") - bdw.extractall(dir, members) + tmp_path = Path(tempfile.mkdtemp(suffix="_b2w")) + bdw.extractall(tmp_path, members) # egg2wheel abi = 'none' @@ -223,7 +230,8 @@ def wininst2wheel(path, dest_dir): # CPython-specific. if arch != 'any': pyver = pyver.replace('py', 'cp') - wheel_name = '-'.join((dist_info, pyver, abi, arch)) + + wheel_name = make_filename(info['name'], info['ver'], None, pyver, abi, arch) if root_is_purelib: bw = bdist_wheel(dist.Distribution()) else: @@ -238,32 +246,30 @@ def wininst2wheel(path, dest_dir): 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') + dist_info_dir = tmp_path / '%s.dist-info' % dist_info + bw.egg2dist(tmp_path / egginfo_name, dist_info_dir) - wheel_path = os.path.join(dest_dir, wheel_name) - with WheelFile(wheel_path, 'w') as wf: - wf.write_files(dir) + with WheelFile(dest_dir / wheel_name, 'w', generator='wininst2wheel') as wf: + wf.write_files(tmp_path) - shutil.rmtree(dir) + shutil.rmtree(str(tmp_path)) -def convert(files, dest_dir, verbose): +def convert(files: Iterable[str], dest_dir: Union[str, PathLike], verbose: bool) -> None: # Only support wheel convert if pkg_resources is present require_pkgresources('wheel convert') - for pat in files: - for installer in iglob(pat): - if os.path.splitext(installer)[1] == '.egg': + for pattern in files: + for installer_path in Path.cwd().glob(pattern): + if installer_path.suffix == '.egg': conv = egg2wheel else: conv = wininst2wheel if verbose: - print("{}... ".format(installer)) + print("{}... ".format(installer_path)) sys.stdout.flush() - conv(installer, dest_dir) + conv(installer_path, dest_dir) if verbose: print("OK") diff --git a/src/wheel/cli/pack.py b/src/wheel/cli/pack.py index 1e77fdbd..7659cc16 100644 --- a/src/wheel/cli/pack.py +++ b/src/wheel/cli/pack.py @@ -1,17 +1,22 @@ -from __future__ import print_function - -import os.path import re import sys +from pathlib import Path +from typing import Optional, Union from wheel.cli import WheelError -from wheel.wheelfile import WheelFile +from wheel.wheelfile import WheelFile, make_filename + +if sys.version_info >= (3, 6): + from os import PathLike +else: + PathLike = Path -DIST_INFO_RE = re.compile(r"^(?P(?P.+?)-(?P\d.*?))\.dist-info$") +DIST_INFO_RE = re.compile(r"^(?P.+?)-(?P\d.*?)\.dist-info$") BUILD_NUM_RE = re.compile(br'Build: (\d\w*)$') -def pack(directory, dest_dir, build_number): +def pack(directory: Union[str, PathLike], dest_dir: Union[str, PathLike], + build_number: Optional[str] = None) -> None: """Repack a previously unpacked wheel directory into a new wheel file. The .dist-info/WHEEL file must contain one or more tags so that the target @@ -19,10 +24,13 @@ def pack(directory, dest_dir, build_number): :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 - 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)] + directory = Path(directory) + dist_info_dirs = [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('Multiple .dist-info directories found in {}'.format(directory)) elif not dist_info_dirs: @@ -30,12 +38,12 @@ def pack(directory, dest_dir, build_number): # Determine the target wheel filename dist_info_dir = dist_info_dirs[0] - name_version = DIST_INFO_RE.match(dist_info_dir).group('namever') + name, version = DIST_INFO_RE.match(dist_info_dir.name).groups() # 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: '): @@ -49,28 +57,25 @@ def pack(directory, dest_dir, build_number): # 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() - if not BUILD_NUM_RE.subn(replacement, wheel_file_content)[1]: - wheel_file_content += replacement + if build_number is not None and build_number != existing_build_number: + replacement = ('Build: %s\r\n' % build_number).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.truncate() - f.write(wheel_file_content) + 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, '{}-{}.whl'.format(name_version, tagline)) + filename = make_filename(name, version, build_number, '.'.join(impls), '.'.join(abivers), + '.'.join(platforms)) + wheel_path = Path(dest_dir) / filename with WheelFile(wheel_path, 'w') as wf: print("Repacking wheel as {}...".format(wheel_path), end='') sys.stdout.flush() diff --git a/src/wheel/cli/unpack.py b/src/wheel/cli/unpack.py index 2e9857a3..d6780ee5 100644 --- a/src/wheel/cli/unpack.py +++ b/src/wheel/cli/unpack.py @@ -1,12 +1,16 @@ -from __future__ import print_function - -import os.path import sys +from pathlib import Path +from typing import Union from ..wheelfile import WheelFile +if sys.version_info >= (3, 6): + from os import PathLike +else: + from pathlib import Path as PathLike + -def unpack(path, dest='.'): +def unpack(path: Union[str, PathLike], dest: Union[str, PathLike] = '.') -> None: """Unpack a wheel. Wheel content will be unpacked to {dest}/{name}-{ver}, where {name} @@ -16,10 +20,11 @@ def unpack(path, dest='.'): :param dest: Destination directory (default to current directory). """ with WheelFile(path) as wf: - namever = wf.parsed_filename.group('namever') - destination = os.path.join(dest, namever) + namever = wf.metadata.name + '.' + wf.metadata.version + destination = Path(dest) / namever + destination.mkdir(exist_ok=True) print("Unpacking to: {}...".format(destination), end='') sys.stdout.flush() - wf.extractall(destination) + wf.unpack(destination) print('OK') diff --git a/tests/cli/test_convert.py b/tests/cli/test_convert.py index 7d711aa8..2237f6c0 100644 --- a/tests/cli/test_convert.py +++ b/tests/cli/test_convert.py @@ -2,7 +2,7 @@ import re from wheel.cli.convert import convert, egg_info_re -from wheel.wheelfile import WHEEL_INFO_RE +from wheel.wheelfile import _WHEEL_INFO_RE def test_egg_re(): @@ -15,10 +15,10 @@ 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, tmp_path): + convert(egg_paths, str(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(_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) diff --git a/tests/cli/test_pack.py b/tests/cli/test_pack.py index ff36d6f7..ce70ddd9 100644 --- a/tests/cli/test_pack.py +++ b/tests/cli/test_pack.py @@ -17,8 +17,8 @@ (None, '3', 'test-1.0-3-py2.py3-none-any.whl'), ('', '3', 'test-1.0-py2.py3-none-any.whl'), ], 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, tmp_path, build_tag_arg, existing_build_tag, filename): + 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(line.rstrip() for line in old_record.split(b'\n') @@ -27,14 +27,14 @@ 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) + pack(str(unpack_dir), str(tmp_path), build_tag_arg) + new_wheel_path = tmp_path / filename assert new_wheel_path.isfile() with ZipFile(str(new_wheel_path)) as zf: diff --git a/tests/cli/test_unpack.py b/tests/cli/test_unpack.py index 531ca7de..7f622028 100644 --- a/tests/cli/test_unpack.py +++ b/tests/cli/test_unpack.py @@ -1,10 +1,10 @@ from wheel.cli.unpack import unpack -def test_unpack(wheel_paths, tmpdir): +def test_unpack(wheel_paths, tmp_path): """ Make sure 'wheel unpack' works. This also verifies the integrity of our testing wheel files. """ for wheel_path in wheel_paths: - unpack(wheel_path, str(tmpdir)) + unpack(wheel_path, str(tmp_path)) diff --git a/tests/conftest.py b/tests/conftest.py index 7c3698c6..51a84fd2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,12 +5,13 @@ import os.path import subprocess import sys +from pathlib import Path import pytest @pytest.fixture(scope='session') -def wheels_and_eggs(tmpdir_factory): +def wheels_and_eggs(tmp_path_factory): """Build wheels and eggs from test distributions.""" test_distributions = "complex-dist", "simple.dist", "headers.dist" if sys.version_info >= (3, 6): @@ -22,25 +23,25 @@ def wheels_and_eggs(tmpdir_factory): # ABI3 extensions don't really work on Windows test_distributions += ("abi3extension.dist",) - pwd = os.path.abspath(os.curdir) - this_dir = os.path.dirname(__file__) - build_dir = 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)) + working_dir = this_dir / 'testdata' / dist subprocess.check_call([sys.executable, 'setup.py', 'bdist_egg', '-b', str(build_dir), '-d', str(dist_dir), - 'bdist_wheel', '-b', str(build_dir), '-d', str(dist_dir)]) + 'bdist_wheel', '-b', str(build_dir), '-d', str(dist_dir)], + cwd=str(working_dir)) - os.chdir(pwd) - return sorted(str(fname) for fname in dist_dir.listdir() if fname.ext in ('.whl', '.egg')) + return sorted(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')] + 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')] + 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 2eea587f..c7e030a5 100644 --- a/tests/test_bdist_wheel.py +++ b/tests/test_bdist_wheel.py @@ -26,9 +26,9 @@ @pytest.fixture -def dummy_dist(tmpdir_factory): - basedir = tmpdir_factory.mktemp('dummy_dist') - basedir.join('setup.py').write("""\ +def dummy_dist(tmp_path_factory): + basedir = tmp_path_factory.mktemp('dummy_dist') + basedir.joinpath('setup.py').write_text("""\ from setuptools import setup setup( @@ -37,15 +37,17 @@ def dummy_dist(tmpdir_factory): ) """) 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): """Make sure entry point scripts are not generated.""" - path = next(path for path in wheel_paths if 'complex_dist' in path) + path = next(path for path in wheel_paths if 'complex_dist' in path.name) with WheelFile(path) as wf: filenames = set(wf.filenames) @@ -56,54 +58,54 @@ def test_no_scripts(wheel_paths): @pytest.mark.skipif(sys.version_info < (3, 6), reason='Packaging unicode file names only works reliably on Python 3.6+') def test_unicode_record(wheel_paths): - path = next(path for path in wheel_paths if 'unicode.dist' in path) + path = next(path for path in wheel_paths if 'unicode.dist' in path.name) with WheelFile(path) as wf: filenames = set(wf.record_entries) assert 'unicodedist/åäö_日本語.py' in filenames -def test_licenses_default(dummy_dist, monkeypatch, tmpdir): +def test_licenses_default(dummy_dist, monkeypatch, tmp_path): monkeypatch.chdir(dummy_dist) - subprocess.check_call([sys.executable, 'setup.py', 'bdist_wheel', '-b', str(tmpdir), + subprocess.check_call([sys.executable, 'setup.py', 'bdist_wheel', '-b', str(tmp_path), '--universal']) with WheelFile('dist/dummy_dist-1.0-py2.py3-none-any.whl') as wf: license_files = {'dummy_dist-1.0.dist-info/' + fname for fname in DEFAULT_LICENSE_FILES} assert set(wf.filenames) == DEFAULT_FILES | license_files -def test_licenses_deprecated(dummy_dist, monkeypatch, tmpdir): - dummy_dist.join('setup.cfg').write('[metadata]\nlicense_file=licenses/DUMMYFILE') +def test_licenses_deprecated(dummy_dist, monkeypatch, tmp_path): + dummy_dist.joinpath('setup.cfg').write_text('[metadata]\nlicense_file=licenses/DUMMYFILE') monkeypatch.chdir(dummy_dist) - subprocess.check_call([sys.executable, 'setup.py', 'bdist_wheel', '-b', str(tmpdir), + subprocess.check_call([sys.executable, 'setup.py', 'bdist_wheel', '-b', str(tmp_path), '--universal']) with WheelFile('dist/dummy_dist-1.0-py2.py3-none-any.whl') as wf: license_files = {'dummy_dist-1.0.dist-info/DUMMYFILE'} assert set(wf.filenames) == DEFAULT_FILES | license_files -def test_licenses_override(dummy_dist, monkeypatch, tmpdir): - dummy_dist.join('setup.cfg').write('[metadata]\nlicense_files=licenses/*\n LICENSE') +def test_licenses_override(dummy_dist, monkeypatch, tmp_path): + dummy_dist.joinpath('setup.cfg').write_text('[metadata]\nlicense_files=licenses/*\n LICENSE') monkeypatch.chdir(dummy_dist) - subprocess.check_call([sys.executable, 'setup.py', 'bdist_wheel', '-b', str(tmpdir), + subprocess.check_call([sys.executable, 'setup.py', 'bdist_wheel', '-b', str(tmp_path), '--universal']) with WheelFile('dist/dummy_dist-1.0-py2.py3-none-any.whl') as wf: license_files = {'dummy_dist-1.0.dist-info/' + fname for fname in {'DUMMYFILE', 'LICENSE'}} assert set(wf.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, monkeypatch, tmp_path): + 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), + subprocess.check_call([sys.executable, 'setup.py', 'bdist_wheel', '-b', str(tmp_path), '--universal']) with WheelFile('dist/dummy_dist-1.0-py2.py3-none-any.whl') as wf: assert set(wf.filenames) == DEFAULT_FILES -def test_build_number(dummy_dist, monkeypatch, tmpdir): +def test_build_number(dummy_dist, monkeypatch, tmp_path): monkeypatch.chdir(dummy_dist) - subprocess.check_call([sys.executable, 'setup.py', 'bdist_wheel', '-b', str(tmpdir), + subprocess.check_call([sys.executable, 'setup.py', 'bdist_wheel', '-b', str(tmp_path), '--universal', '--build-number=2']) with WheelFile('dist/dummy_dist-1.0-2-py2.py3-none-any.whl') as wf: filenames = set(wf.filenames) @@ -112,19 +114,19 @@ def test_build_number(dummy_dist, monkeypatch, tmpdir): @pytest.mark.skipif(sys.version_info[0] < 3, reason='The limited ABI only works on Python 3+') -def test_limited_abi(monkeypatch, tmpdir): +def test_limited_abi(monkeypatch, tmp_path): """Test that building a binary wheel with the limited ABI works.""" this_dir = os.path.dirname(__file__) source_dir = os.path.join(this_dir, 'testdata', 'extension.dist') - build_dir = 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([sys.executable, 'setup.py', 'bdist_wheel', '-b', str(build_dir), '-d', str(dist_dir)]) -def test_build_from_readonly_tree(dummy_dist, monkeypatch, tmpdir): - basedir = str(tmpdir.join('dummy')) +def test_build_from_readonly_tree(dummy_dist, monkeypatch, tmp_path): + basedir = str(tmp_path / 'dummy') shutil.copytree(str(dummy_dist), basedir) monkeypatch.chdir(basedir) @@ -138,9 +140,9 @@ 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)) -def test_compression(dummy_dist, monkeypatch, tmpdir, option, compress_type): +def test_compression(dummy_dist, monkeypatch, tmp_path, option, compress_type): monkeypatch.chdir(dummy_dist) - subprocess.check_call([sys.executable, 'setup.py', 'bdist_wheel', '-b', str(tmpdir), + subprocess.check_call([sys.executable, 'setup.py', 'bdist_wheel', '-b', str(tmp_path), '--universal', '--compression={}'.format(option)]) with ZipFile('dist/dummy_dist-1.0-py2.py3-none-any.whl') as zf: filenames = set(zf.namelist()) diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 2e4f24c8..24a73278 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -1,7 +1,7 @@ from wheel.metadata import pkginfo_to_metadata -def test_pkginfo_to_metadata(tmpdir): +def test_pkginfo_to_metadata(tmp_path): expected_metadata = [ ('Metadata-Version', '2.1'), ('Name', 'spam'), @@ -25,8 +25,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 Version: 0.1 @@ -37,8 +37,8 @@ def test_pkginfo_to_metadata(tmpdir): Provides-Extra: Signatures 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 [extra] @@ -67,5 +67,5 @@ def test_pkginfo_to_metadata(tmpdir): pytest>=3.0.0 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_pkginfo.py b/tests/test_pkginfo.py index a7e07a8f..1b3a288a 100644 --- a/tests/test_pkginfo.py +++ b/tests/test_pkginfo.py @@ -3,7 +3,7 @@ from wheel.pkginfo import write_pkg_info -def test_pkginfo_mangle_from(tmpdir): +def test_pkginfo_mangle_from(tmp_path): """Test that write_pkginfo() will not prepend a ">" to a line starting with "From".""" metadata = """\ Metadata-Version: 2.1 @@ -17,6 +17,6 @@ def test_pkginfo_mangle_from(tmpdir): """ message = Parser().parsestr(metadata) - pkginfo_file = tmpdir.join('PKGINFO') - write_pkg_info(str(pkginfo_file), message) + pkginfo_file = tmp_path / 'PKGINFO' + write_pkg_info(pkginfo_file, message) assert pkginfo_file.read_text('ascii') == metadata diff --git a/tests/test_tagopt.py b/tests/test_tagopt.py index 4eb2fea3..b21225bd 100644 --- a/tests/test_tagopt.py +++ b/tests/test_tagopt.py @@ -24,126 +24,126 @@ @pytest.fixture -def temp_pkg(request, tmpdir): - tmpdir.join('test.py').write('print("Hello, world")') +def temp_pkg(request, tmp_path): + tmp_path.joinpath('test.py').write_text('print("Hello, world")') ext = getattr(request, 'param', False) if ext: - tmpdir.join('test.c').write('#include ') + tmp_path.joinpath('test.c').write_text('#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) - return tmpdir + tmp_path.joinpath('setup.py').write_text(setup_py) + return tmp_path def test_default_tag(temp_pkg): 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 == 'Test-1.0-py%s-none-any.whl' % (sys.version_info[0],) - assert wheels[0].ext == '.whl' + assert wheels[0].name == 'Test-1.0-py%s-none-any.whl' % (sys.version_info[0],) + assert wheels[0].suffix == '.whl' def test_build_number(temp_pkg): subprocess.check_call([sys.executable, 'setup.py', 'bdist_wheel', '--build-number=1'], cwd=str(temp_pkg)) - dist_dir = temp_pkg.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 == 'Test-1.0-1-py%s-none-any.whl' % (sys.version_info[0],)) - assert wheels[0].ext == '.whl' + assert (wheels[0].name == 'Test-1.0-1-py%s-none-any.whl' % (sys.version_info[0],)) + assert wheels[0].suffix == '.whl' def test_explicit_tag(temp_pkg): subprocess.check_call( [sys.executable, 'setup.py', 'bdist_wheel', '--python-tag=py32'], cwd=str(temp_pkg)) - dist_dir = temp_pkg.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): 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): 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') + 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') + 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') + 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): 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']) @@ -155,30 +155,30 @@ def test_plat_name_ext(temp_pkg): except subprocess.CalledProcessError: pytest.skip("Cannot compile C Extensions") - 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') + 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') + temp_pkg.joinpath('setup.cfg').write_text('[bdist_wheel]\nplat_name=testplat.arch') try: subprocess.check_call( [sys.executable, 'setup.py', 'bdist_wheel'], @@ -186,9 +186,9 @@ def test_plat_name_ext_in_setupcfg(temp_pkg): except subprocess.CalledProcessError: pytest.skip("Cannot compile C Extensions") - 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' diff --git a/tests/test_wheelfile.py b/tests/test_wheelfile.py index 6dd72586..7209de49 100644 --- a/tests/test_wheelfile.py +++ b/tests/test_wheelfile.py @@ -8,13 +8,13 @@ @pytest.fixture -def wheel_path(tmpdir): - return str(tmpdir.join('test-1.0-py2.py3-none-any.whl')) +def wheel_path(tmp_path): + return str(tmp_path / 'test-1.0-py2.py3-none-any.whl') -def test_wheelfile_re(tmpdir): +def test_wheelfile_re(tmp_path): # Regression test for #208 - path = tmpdir.join('foo-2-py3-none-any.whl') + path = tmp_path / 'foo-2-py3-none-any.whl' with WheelFile(str(path), 'w') as wf: assert wf.metadata.name == 'foo' assert wf.metadata.version == '2' @@ -124,12 +124,12 @@ def test_write_str(wheel_path): 'test-1.0.dist-info/RECORD,,\n') -def test_timestamp(tmpdir_factory, wheel_path, monkeypatch): +def test_timestamp(tmp_path_factory, wheel_path, monkeypatch): # 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') @@ -145,14 +145,14 @@ def test_timestamp(tmpdir_factory, wheel_path, monkeypatch): @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, wheel_path): # 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: From d7b1f1d5c2ed3d1b3b2cfc8d8054ebc93504b8ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 21 Jun 2020 22:05:57 +0300 Subject: [PATCH 15/59] Added the py.typed marker This lets MyPy use the type definitions in this library from other projects too. --- src/wheel/py.typed | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/wheel/py.typed diff --git a/src/wheel/py.typed b/src/wheel/py.typed new file mode 100644 index 00000000..e69de29b From 4654492b0b7650d5315ac49854065cbe159788ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 21 Jun 2020 22:07:34 +0300 Subject: [PATCH 16/59] Removed obsolete modules --- src/wheel/pkginfo.py | 43 ---------------------------------------- src/wheel/util.py | 46 ------------------------------------------- tests/test_pkginfo.py | 22 --------------------- 3 files changed, 111 deletions(-) delete mode 100644 src/wheel/pkginfo.py delete mode 100644 src/wheel/util.py delete mode 100644 tests/test_pkginfo.py diff --git a/src/wheel/pkginfo.py b/src/wheel/pkginfo.py deleted file mode 100644 index 115be45b..00000000 --- a/src/wheel/pkginfo.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Tools for reading and writing PKG-INFO / METADATA without caring -about the encoding.""" - -from email.parser import Parser - -try: - unicode - _PY3 = False -except NameError: - _PY3 = True - -if not _PY3: - from email.generator import Generator - - def read_pkg_info_bytes(bytestr): - return Parser().parsestr(bytestr) - - def read_pkg_info(path): - with open(path, "r") as headers: - message = Parser().parse(headers) - return message - - def write_pkg_info(path, message): - with open(path, 'w') as metadata: - Generator(metadata, mangle_from_=False, maxheaderlen=0).flatten(message) -else: - from email.generator import BytesGenerator - - def read_pkg_info_bytes(bytestr): - headers = bytestr.decode(encoding="ascii", errors="surrogateescape") - message = Parser().parsestr(headers) - return message - - def read_pkg_info(path): - with open(path, "r", - encoding="ascii", - errors="surrogateescape") as headers: - message = Parser().parse(headers) - return message - - def write_pkg_info(path, message): - with open(path, "wb") as out: - BytesGenerator(out, mangle_from_=False, maxheaderlen=0).flatten(message) diff --git a/src/wheel/util.py b/src/wheel/util.py deleted file mode 100644 index 3ae2b445..00000000 --- a/src/wheel/util.py +++ /dev/null @@ -1,46 +0,0 @@ -import base64 -import io -import sys - - -if sys.version_info[0] < 3: - text_type = unicode # noqa: F821 - - StringIO = io.BytesIO - - def native(s, encoding='utf-8'): - if isinstance(s, unicode): # noqa: F821 - return s.encode(encoding) - return s -else: - text_type = str - - StringIO = io.StringIO - - def native(s, encoding='utf-8'): - if isinstance(s, bytes): - return s.decode(encoding) - return s - - -def urlsafe_b64encode(data): - """urlsafe_b64encode without padding""" - return base64.urlsafe_b64encode(data).rstrip(b'=') - - -def urlsafe_b64decode(data): - """urlsafe_b64decode without padding""" - pad = b'=' * (4 - (len(data) & 3)) - return base64.urlsafe_b64decode(data + pad) - - -def as_unicode(s): - if isinstance(s, bytes): - return s.decode('utf-8') - return s - - -def as_bytes(s): - if isinstance(s, text_type): - return s.encode('utf-8') - return s diff --git a/tests/test_pkginfo.py b/tests/test_pkginfo.py deleted file mode 100644 index 1b3a288a..00000000 --- a/tests/test_pkginfo.py +++ /dev/null @@ -1,22 +0,0 @@ -from email.parser import Parser - -from wheel.pkginfo import write_pkg_info - - -def test_pkginfo_mangle_from(tmp_path): - """Test that write_pkginfo() will not prepend a ">" to a line starting with "From".""" - metadata = """\ -Metadata-Version: 2.1 -Name: foo - -From blahblah - -==== -Test -==== - -""" - message = Parser().parsestr(metadata) - pkginfo_file = tmp_path / 'PKGINFO' - write_pkg_info(pkginfo_file, message) - assert pkginfo_file.read_text('ascii') == metadata From 65911551246d06bf9da024e27faf83cadfa4aa57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 21 Jun 2020 22:26:14 +0300 Subject: [PATCH 17/59] Added type annotations to macosx_libfile --- src/wheel/macosx_libfile.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/wheel/macosx_libfile.py b/src/wheel/macosx_libfile.py index 9141f269..12cd42ee 100644 --- a/src/wheel/macosx_libfile.py +++ b/src/wheel/macosx_libfile.py @@ -38,6 +38,7 @@ import ctypes import os import sys +from typing import Tuple, Optional, BinaryIO, Type """here the needed const and struct from mach-o header files""" @@ -209,14 +210,15 @@ """ -def swap32(x): +def swap32(x: int) -> int: return (((x << 24) & 0xFF000000) | ((x << 8) & 0x00FF0000) | ((x >> 8) & 0x0000FF00) | ((x >> 24) & 0x000000FF)) -def get_base_class_and_magic_number(lib_file, seek=None): +def get_base_class_and_magic_number( + lib_file: BinaryIO, seek: Optional[int] = None) -> Tuple[Type[ctypes.Structure], int]: if seek is None: seek = lib_file.tell() else: @@ -239,12 +241,13 @@ def get_base_class_and_magic_number(lib_file, seek=None): return BaseClass, magic_number -def read_data(struct_class, lib_file): - return struct_class.from_buffer_copy(lib_file.read( - ctypes.sizeof(struct_class))) +def read_data(struct_class: Type[ctypes.Structure], lib_file: BinaryIO): + 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) -> Optional[Tuple[int, int, int]]: 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]: @@ -288,7 +291,8 @@ class FatArch(BaseClass): return None -def read_mach_header(lib_file, seek=None): +def read_mach_header(lib_file: BinaryIO, + seek: Optional[int] = None) -> Optional[Tuple[int, int, int]]: """ This funcition parse mach-O header and extract information about minimal system version @@ -335,14 +339,14 @@ class VersionBuild(base_class): continue -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 @@ -406,5 +410,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 From 3667709aaf193bf2a943e08df53ea3e678adbdac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 21 Jun 2020 22:38:27 +0300 Subject: [PATCH 18/59] Fixed a few WheelFile tests --- tests/test_wheelfile.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_wheelfile.py b/tests/test_wheelfile.py index 7209de49..c60ef7e4 100644 --- a/tests/test_wheelfile.py +++ b/tests/test_wheelfile.py @@ -3,8 +3,7 @@ import pytest -from wheel.cli import WheelError -from wheel.wheelfile import WheelFile +from wheel.wheelfile import WheelFile, WheelError @pytest.fixture @@ -37,7 +36,7 @@ def test_missing_record(wheel_path): 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$") + exc.match("^File test-1.0.dist-info/RECORD not found$") def test_unsupported_hash_algorithm(wheel_path): From 9e63bec75a35f45f83ae12cc379fb1ec3a97d7c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 21 Jun 2020 22:38:40 +0300 Subject: [PATCH 19/59] Restored Python 3.5 compatibility to WheelFile --- src/wheel/wheelfile.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/wheel/wheelfile.py b/src/wheel/wheelfile.py index 5462784b..a5c88628 100644 --- a/src/wheel/wheelfile.py +++ b/src/wheel/wheelfile.py @@ -2,6 +2,7 @@ import hashlib import os.path import re +import sys import time from base64 import urlsafe_b64decode, urlsafe_b64encode from collections import OrderedDict @@ -10,13 +11,17 @@ from email.message import Message from email.parser import Parser from io import StringIO -from os import PathLike from pathlib import Path from typing import Optional, Union, Dict, Iterable, NamedTuple, IO, Tuple, List from zipfile import ZIP_DEFLATED, ZipInfo, ZipFile from . import __version__ as wheel_version +if sys.version_info >= (3, 6): + from os import PathLike +else: + PathLike = Path + _DIST_NAME_RE = re.compile(r'[^A-Za-z0-9.]+') _WHEEL_INFO_RE = re.compile( r"""^(?P(?P.+?)-(?P.+?))(?:-(?P\d[^-]*))? From cbca67efff757d2b80b0d18d6d557fcfc60c57d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 21 Jun 2020 23:33:24 +0300 Subject: [PATCH 20/59] Added Python 3.7 to the testing matrix --- .github/workflows/codeqa-test.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/codeqa-test.yml b/.github/workflows/codeqa-test.yml index 32ad79fd..46635db4 100644 --- a/.github/workflows/codeqa-test.yml +++ b/.github/workflows/codeqa-test.yml @@ -20,18 +20,18 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: [3.5, 3.6, 3.8, pypy3] + python-version: [3.5, 3.6, 3.7, 3.8, pypy3] exclude: - - os: ubuntu-latest - python-version: 3.6 - os: macos-latest python-version: 3.6 + - os: macos-latest + python-version: 3.7 - os: macos-latest python-version: pypy3 - os: windows-latest - python-version: 2.7 + python-version: 3.6 - os: windows-latest - python-version: 3.5 + python-version: 3.7 - os: windows-latest python-version: pypy3 runs-on: ${{ matrix.os }} From bf0afcaf6897e5014653ce8f9d314d42c65de26f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Mon, 22 Jun 2020 09:13:20 +0300 Subject: [PATCH 21/59] Updated the Github publish workflow --- .github/workflows/publish.yml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 464988d1..d519ef0f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,18 +2,20 @@ 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@v1 + - uses: actions/checkout@v2 - name: Set up Python - uses: actions/setup-python@v1 + uses: actions/setup-python@v2 with: - python-version: 3.7 + python-version: 3.x - name: Install dependencies run: | pip install "setuptools >= 40.9" @@ -21,6 +23,7 @@ jobs: - name: Create packages run: python setup.py sdist bdist_wheel - name: Upload packages - uses: pypa/gh-action-pypi-publish@v1.0.0a0 + uses: pypa/gh-action-pypi-publish@master with: + user: __token__ password: ${{ secrets.pypi_password }} From 71474a5ac9dc432405c3f9b847504280339bc541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Wed, 24 Jun 2020 19:29:53 +0300 Subject: [PATCH 22/59] Updated the Github codeqa/test workflow --- .github/workflows/codeqa-test.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeqa-test.yml b/.github/workflows/codeqa-test.yml index 46635db4..041bc37d 100644 --- a/.github/workflows/codeqa-test.yml +++ b/.github/workflows/codeqa-test.yml @@ -8,7 +8,7 @@ jobs: flake8: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - name: Check code style with Flake8 uses: TrueBrain/actions-flake8@v1.2 with: @@ -36,9 +36,9 @@ jobs: python-version: pypy3 runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 + uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Upgrade setuptools From ec0aded9992debadaf5e43b7d767407ce2e3d64c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Mon, 8 Feb 2021 23:17:37 +0200 Subject: [PATCH 23/59] Removed Python 3.5 support --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index a7df4316..8b37f288 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,7 @@ # and then run "tox" from this directory. [tox] -envlist = py35, py36, py37, py38, py39, pypy, pypy3, flake8 +envlist = py36, py37, py38, py39, pypy3, flake8 minversion = 3.3.0 skip_missing_interpreters = true From f1a5fd49482bfdd9a1e803e90330a4be7f3d4b2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Mon, 8 Feb 2021 23:17:53 +0200 Subject: [PATCH 24/59] WIP refactored the Wheel API --- src/wheel/wheelfile.py | 239 ++++++++++++++++++++++++++++++----------- 1 file changed, 178 insertions(+), 61 deletions(-) diff --git a/src/wheel/wheelfile.py b/src/wheel/wheelfile.py index a5c88628..d8fd6785 100644 --- a/src/wheel/wheelfile.py +++ b/src/wheel/wheelfile.py @@ -2,8 +2,10 @@ import hashlib import os.path import re +import shutil import sys import time +from abc import abstractmethod, ABCMeta from base64 import urlsafe_b64decode, urlsafe_b64encode from collections import OrderedDict from datetime import datetime @@ -11,8 +13,8 @@ from email.message import Message from email.parser import Parser from io import StringIO -from pathlib import Path -from typing import Optional, Union, Dict, Iterable, NamedTuple, IO, Tuple, List +from pathlib import Path, PurePath +from typing import Optional, Union, Dict, Iterable, NamedTuple, IO, Tuple, List, BinaryIO, Iterator from zipfile import ZIP_DEFLATED, ZipInfo, ZipFile from . import __version__ as wheel_version @@ -76,6 +78,81 @@ class WheelError(Exception): pass +WheelContentElement = Tuple[Tuple[PurePath, str, str], BinaryIO] + + +class WheelReader: + def __init__(self, file: IO[bytes], distribution: str, version: str): + self._zip = ZipFile(file) + self.distribution = distribution + self.version = version + self._dist_info_dir = f'{distribution}-{version}.dist-info' + self._data_dir = f'{distribution}-{version}.data' + self._record_entries = self._read_record() + + def _read_record(self) -> OrderedDict[str, WheelRecordEntry]: + entries = OrderedDict() + contents = self.read_dist_info('RECORD') + reader = csv.reader(contents.strip().split('\n'), delimiter=',', quotechar='"', + lineterminator='\n') + for row in reader: + 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, hash_digest, int(filesize)) + + return entries + + @property + def dist_info_dir(self): + return self._dist_info_dir + + @property + def data_dir(self): + 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)] + + def read_dist_info(self, filename: str) -> str: + return self._zip.read(f'{self.dist_info_dir}/{filename}').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 (fname, entry.hash_value, entry.filesize), stream + + def __repr__(self): + return (f'{self.__class__.__name__}({self._zip.fp!r}, {self.distribution!r}, ' + f'{self.version!r})') + + +class WheelFileReader(WheelReader): + def __init__(self, path: Union[str, PathLike]): + self.path = Path(path) + parsed_filename = _WHEEL_INFO_RE.match(self.path.name) + if parsed_filename is None: + raise WheelError(f'Bad wheel filename {self.path.name!r}') + + name, version = parsed_filename.groups()[1:] + super().__init__(open(path, 'rb'), name, version) + + def __repr__(self): + return f'{self.__class__.__name__}({self.path!r})' + + class WheelFile: __slots__ = ('generator', 'root_is_purelib', '_mode', '_metadata', '_compression', '_zip', '_data_path', '_dist_info_path', '_record_path', '_record_entries', @@ -169,6 +246,105 @@ def __enter__(self) -> 'WheelFile': def __exit__(self, exc_type, exc_val, exc_tb) -> None: self.close() + +class SourceFile(metaclass=ABCMeta): + @property + @abstractmethod + def archive_name(self) -> str: + pass + + @abstractmethod + def open(self) -> IO[bytes]: + pass + + +class SourceFileReader: + def __init__(self, file: IO[bytes], hash_algorithm): + self._file = file + self._hash = hashlib.new(hash_algorithm) + + def read(self, n): + data = self._file.read(n) + self._hash.update(data) + return data + + +class WheelWriter: + def __init__(self, path_or_fd, distribution: str, version: str, + build_tag: Optional[str] = None): + self.distribution = distribution + self.version = version + self.build_tag = build_tag + self._dist_info_dir = f'{distribution}-{version}.dist-info' + self._data_dir = f'{distribution}-{version}.data' + self._zip = ZipFile(path_or_fd, 'w') + self._record_entries: Dict[str, WheelRecordEntry] = OrderedDict() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if not exc_type: + self._write_record() + + def write_distinfo_file(self, arcname: str, data: str) -> None: + self._zip.writestr(arcname, data) + + 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: + msg = Message() + msg['Wheel-Version'] = '1.0' # of the spec + msg['Generator'] = self.generator + msg['Root-Is-Purelib'] = str(self.root_is_purelib).lower() + if self.build_tag is not None: + msg['Build'] = self.build_tag + + for impl in self.metadata.implementation.split('.'): + for abi in self.metadata.abi.split('.'): + for plat in self.metadata.platform.split('.'): + msg['Tag'] = '-'.join((impl, abi, plat)) + + buffer = StringIO() + Generator(buffer, maxheaderlen=0).flatten(msg) + self.write_distinfo_file('WHEEL', buffer.getvalue()) + + def write_metadata(self, items: Iterable[Tuple[str, str]]) -> None: + msg = Message() + 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.distribution + if 'Version' not in msg: + msg['Version'] = self.version + + buffer = StringIO() + Generator(buffer, maxheaderlen=0).flatten(msg) + self.write_distinfo_file('METADATA', buffer.getvalue()) + + def write_files(self, files: Iterator[SourceFile]) -> None: + for file in files: + with file.open() as dest, self._zip.open(file.archive_name, 'w') as src: + wrapper = SourceFileReader(src, 'sha256') + shutil.copyfileobj(wrapper, dest, 1024 * 8) + def write_file(self, archive_name: str, contents: Union[bytes, str], timestamp: Union[datetime, int] = None) -> None: if isinstance(contents, str): @@ -270,65 +446,6 @@ def unpack(self, dest_dir: Union[str, PathLike], _encode_hash_value(hash_.digest())) ) - def _read_record(self) -> None: - self._record_entries.clear() - contents = self.read_distinfo_file('RECORD').decode('utf-8') - for line in contents.strip().split('\n'): - path, hash_digest, filesize = line.rsplit(',', 2) - if hash_digest: - algorithm, hash_digest = hash_digest.split('=') - try: - hashlib.new(algorithm) - except ValueError: - raise WheelError('Unsupported hash algorithm: {}'.format(algorithm)) - - if algorithm.lower() in {'md5', 'sha1'}: - raise WheelError( - 'Weak hash algorithm ({}) is not permitted by PEP 427' - .format(algorithm)) - - self._record_entries[path] = WheelRecordEntry( - algorithm, _decode_hash_value(hash_digest), int(filesize)) - - 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: - msg = Message() - msg['Wheel-Version'] = '1.0' # of the spec - msg['Generator'] = self.generator - msg['Root-Is-Purelib'] = str(self.root_is_purelib).lower() - if self.metadata.build_tag is not None: - msg['Build'] = self.metadata.build_tag - - for impl in self.metadata.implementation.split('.'): - for abi in self.metadata.abi.split('.'): - for plat in self.metadata.platform.split('.'): - msg['Tag'] = '-'.join((impl, abi, plat)) - - buffer = StringIO() - Generator(buffer, maxheaderlen=0).flatten(msg) - self.write_distinfo_file('WHEEL', buffer.getvalue()) - - def read_metadata(self) -> List[Tuple[str, str]]: - contents = self.read_distinfo_file('METADATA').decode('utf-8') - msg = Parser().parsestr(contents) - items = [(key, str(value)) for key, value in msg.items()] - payload = msg.get_payload(0, True) - if payload: - items.append(('Description', payload)) - - return items - def write_metadata(self, items: Iterable[Tuple[str, str]]) -> None: msg = Message() for key, value in items: From a2a53de59e99d5eb7f979fc66ab21c71044f9359 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Mon, 24 Oct 2022 13:12:12 +0300 Subject: [PATCH 25/59] Improved the API and dropped bdist_wininst convert support --- docs/news.rst | 5 + setup.cfg | 1 + src/wheel/bdist_wheel.py | 191 ++--- src/wheel/cli/convert.py | 337 +++------ src/wheel/cli/pack.py | 44 +- src/wheel/cli/unpack.py | 19 +- src/wheel/macosx_libfile.py | 10 +- src/wheel/metadata.py | 16 +- src/wheel/vendored/packaging/_structures.py | 61 ++ src/wheel/vendored/packaging/utils.py | 136 ++++ src/wheel/vendored/packaging/version.py | 504 +++++++++++++ src/wheel/wheelfile.py | 743 ++++++++++---------- tests/cli/test_convert.py | 15 +- tests/cli/test_pack.py | 20 +- tests/conftest.py | 13 +- tests/test_bdist_wheel.py | 139 ++-- tests/test_macosx_libfile.py | 43 +- tests/test_metadata.py | 8 +- tests/test_tagopt.py | 98 ++- tests/test_wheelfile.py | 105 +-- 20 files changed, 1534 insertions(+), 974 deletions(-) create mode 100644 src/wheel/vendored/packaging/_structures.py create mode 100644 src/wheel/vendored/packaging/utils.py create mode 100644 src/wheel/vendored/packaging/version.py diff --git a/docs/news.rst b/docs/news.rst index 8c4ce091..97bc8c21 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -1,6 +1,11 @@ Release Notes ============= +**UNRELEASED** + +- Added a public API +- Dropped support for converting ``bdist_wininst`` based installers into wheels + **0.38.0 (2022-10-21)** - Dropped support for Python < 3.7 diff --git a/setup.cfg b/setup.cfg index f83f17a2..e90c4789 100644 --- a/setup.cfg +++ b/setup.cfg @@ -58,6 +58,7 @@ max-line-length = 88 [tool:pytest] testpaths = tests +addopts = --tb=short [coverage:run] source = wheel diff --git a/src/wheel/bdist_wheel.py b/src/wheel/bdist_wheel.py index 2a1faf3a..0d675a3e 100644 --- a/src/wheel/bdist_wheel.py +++ b/src/wheel/bdist_wheel.py @@ -13,23 +13,22 @@ import sys import sysconfig import warnings -from collections import OrderedDict +from logging import getLogger +from pathlib import Path from shutil import rmtree -from zipfile import ZIP_DEFLATED, ZIP_STORED +from sysconfig import get_config_var import pkg_resources from setuptools import Command from .macosx_libfile import calculate_macosx_platform_tag from .metadata import pkginfo_to_metadata -from .util import log from .vendored.packaging import tags -from .wheelfile import WheelFile, make_filename -from . import __version__ as wheel_version - +from .wheelfile import WheelWriter, make_filename safe_name = pkg_resources.safe_name safe_version = pkg_resources.safe_version +logger = getLogger("wheel") PY_LIMITED_API_PATTERN = r"cp3\d" @@ -38,7 +37,7 @@ def python_tag() -> str: return f"py{sys.version_info[0]}" -def get_platform(archive_root) -> str: +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: @@ -50,21 +49,26 @@ def get_platform(archive_root) -> str: 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 = get_config_var(var) if val is None: if warn: - warnings.warn(f"Config variable '{var}' is unset, Python ABI tag may " - "be incorrect", RuntimeWarning, 2) + warnings.warn( + 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 (PyPy).""" soabi = get_config_var("SOABI") impl = tags.interpreter_name() @@ -97,15 +101,15 @@ 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, path, excinfo) -> None: print(str(excinfo[1])) os.chmod(path, stat.S_IWRITE) func(path) @@ -115,9 +119,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"), @@ -200,7 +202,7 @@ def initialize_options(self): self.py_limited_api = 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 self.bdist_dir = os.path.join(bdist_base, "wheel") @@ -208,9 +210,7 @@ def finalize_options(self): 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") @@ -224,13 +224,13 @@ def finalize_options(self): 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") 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() @@ -241,7 +241,7 @@ 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()), @@ -251,7 +251,7 @@ def wheel_dist_name(self): 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: @@ -305,7 +305,7 @@ 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 @@ -348,14 +348,17 @@ def run(self): ) logger.info("installing to %s", self.bdist_dir) - self.run_command('install') + self.run_command("install") impl_tag, abi_tag, plat_tag = self.get_tag() archive_basename = make_filename( - self.distribution.get_name(), self.distribution.get_version(), - self.build_number, impl_tag, abi_tag, plat_tag + self.distribution.get_name(), + self.distribution.get_version(), + self.build_number, + impl_tag, + abi_tag, + plat_tag, ) - print('basename:', archive_basename) archive_root = Path(self.bdist_dir) if self.relative: archive_root /= self._ensure_relative(install.install_base) @@ -366,23 +369,25 @@ def run(self): wheel_path = Path(self.dist_dir) / archive_basename logger.info("creating '%s' and adding '%s' to it", wheel_path, archive_root) - with WheelFile(wheel_path, 'w', compression=self.compression, - generator='bdist_wheel (' + wheel_version + ')', - root_is_purelib=self.root_is_pure) as wf: + 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(str(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 = archive_root / root - if root_path.name.endswith('.egg-info'): + if root_path.name.endswith(".egg-info"): continue for name in sorted(filenames): path = root_path / name if path.is_file(): archive_name = str(path.relative_to(archive_root)) - if root.endswith('.dist-info'): + if root.endswith(".dist-info"): deferred.append((path, archive_name)) else: logger.info("adding '%s'", archive_name) @@ -395,28 +400,37 @@ def run(self): # Write the license files for license_path in self.license_paths: logger.info("adding '%s'", license_path) - wf.write_distinfo_file(os.path.basename(license_path), license_path.read_bytes()) + wf.write_distinfo_file( + os.path.basename(license_path), license_path.read_bytes() + ) # Write the metadata files from the .egg-info directory - self.set_undefined_options('install_egg_info', ('target', 'egginfo_dir')) + self.set_undefined_options("install_egg_info", ("target", "egginfo_dir")) for path in Path(self.egginfo_dir).iterdir(): - if path.name == 'PKG-INFO': + 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'}: + 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( - ('bdist_wheel', - '{}.{}'.format(*sys.version_info[:2]), # like 3.7 - str(wheel_path))) + getattr(self.distribution, "dist_files", []).append( + ( + "bdist_wheel", + "{}.{}".format(*sys.version_info[:2]), # like 3.7 + str(wheel_path), + ) + ) if not self.keep_temp: - log.info(f"removing {self.bdist_dir}") + logger.info(f"removing {self.bdist_dir}") if not self.dry_run: rmtree(self.bdist_dir, onerror=remove_readonly) @@ -428,86 +442,7 @@ def _ensure_relative(self, path: str) -> str: return path @property - def license_paths(self) -> Set[Path]: - metadata = self.distribution.get_option_dict('metadata') - files = set() # type: Set[Path] - patterns = sorted({ - option for option in metadata.get('license_files', ('', ''))[1].split() - }) - - if 'license_file' in metadata: - warnings.warn('The "license_file" option is deprecated. Use ' - '"license_files" instead.', DeprecationWarning) - files.add(Path(metadata['license_file'][1])) - - if 'license_file' not in metadata and 'license_files' not in metadata: - patterns = ('LICEN[CS]E*', 'COPYING*', 'NOTICE*', 'AUTHORS*') - - for pattern in patterns: - for path in Path().glob(pattern): - if path.name.endswith('~'): - logger.debug('ignoring license file "%s" as it looks like a backup', path) - continue - - if path not in files and path.is_file(): - logger.info('adding license file "%s" (matched pattern "%s")', path, 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 = "Egg metadata expected at %s but not found" % (egginfo_path,) - # if possible: - # alt = os.path.basename(possible[0]) - # err += " (%s found - possible misnamed archive file?)" % (alt,) - # - # 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) - # 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) - # - # # 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, 'r') as dependency_links_file: - # dependency_links = dependency_links_file.read().strip() - # if not dependency_links: - # adios(dependency_links_path) - # - # write_pkg_info(os.path.join(distinfo_path, 'METADATA'), 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) + def license_paths(self) -> list[Path]: + metadata = self.distribution.metadata + files = sorted(metadata.license_files or []) + return [Path(path) for path in files] diff --git a/src/wheel/cli/convert.py b/src/wheel/cli/convert.py index fd2c3435..eb276d83 100755 --- a/src/wheel/cli/convert.py +++ b/src/wheel/cli/convert.py @@ -1,18 +1,17 @@ from __future__ import annotations +import os import re -import shutil -import tempfile import zipfile -from collections.abc import Iterable +from collections.abc import Generator, Iterable +from email.message import Message +from email.parser import HeaderParser from os import PathLike -from pathlib import Path +from pathlib import Path, PurePath +from typing import IO, Any -from setuptools.dist import Distribution - -from ..bdist_wheel import bdist_wheel -from ..wheelfile import WheelFile, make_filename -from . import WheelError, require_pkgresources +from ..wheelfile import WheelWriter, make_filename +from . import WheelError egg_info_re = re.compile( r""" @@ -24,254 +23,104 @@ ) -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: 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 -def egg2wheel(egg_path: str | PathLike, dest_dir: str | PathLike) -> None: - egg_path = Path(egg_path) - dest_dir = Path(dest_dir) 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() - tmp_path = Path(tempfile.mkdtemp(suffix="_e2w")) - if egg_path.is_file(): - # assume we have a bdist_egg otherwise - with zipfile.ZipFile(str(egg_path)) as egg: - egg.extractall(str(tmp_path)) - else: - # support buildout-style installed eggs directories - for pth in egg_path.iterdir(): - if pth.is_file(): - shutil.copy2(str(pth), tmp_path) - else: - shutil.copytree(str(pth), tmp_path / pth.name) - - pyver = egg_info["pyver"] - if pyver: - pyver = egg_info["pyver"] = pyver.replace(".", "") - + pyver = egg_info["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 = arch is 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 = tmp_path / '{name}-{ver}.dist-info'.format(**egg_info) - bw.egg2dist(tmp_path / 'EGG-INFO', dist_info_dir) - wheel_name = make_filename(egg_info['name'], egg_info['ver'], impl_tag=pyver, - abi_tag=abi, plat_tag=arch) - with WheelFile(dest_dir / wheel_name, 'w', generator='egg2wheel') as wf: - wf.write_files(tmp_path) - - shutil.rmtree(str(tmp_path)) - - -def parse_wininst_info(wininfo_name: str, egginfo_name: str) -> dict[str, str]: - """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: str | PathLike, dest_dir: str | PathLike) -> None: - with zipfile.ZipFile(str(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(path.name, 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("/") - tmp_path = Path(tempfile.mkdtemp(suffix="_b2w")) - bdw.extractall(tmp_path, 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 = make_filename(info['name'], info['ver'], None, pyver, abi, arch) - if root_is_purelib: - bw = bdist_wheel(Distribution()) + if egg_path.is_dir(): + # buildout-style installed eggs directory + source = egg_dir_source() 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 = tmp_path / '%s.dist-info' % dist_info - bw.egg2dist(tmp_path / egginfo_name, dist_info_dir) - - with WheelFile(dest_dir / wheel_name, 'w', generator='wininst2wheel') as wf: - wf.write_files(tmp_path) + 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) - shutil.rmtree(str(tmp_path)) + if metadata: + wf.write_metadata(metadata.items()) def convert( - files: Iterable[str], - dest_dir: str | PathLike, - verbose: bool + files: Iterable[str | PathLike], dest_dir: str | PathLike, verbose: bool ) -> None: - # Only support wheel convert if pkg_resources is present - require_pkgresources('wheel convert') - - for pattern in files: - for installer_path in Path.cwd().glob(pattern): - if installer_path.suffix == ".egg": - conv = egg2wheel - else: - conv = wininst2wheel - - if verbose: - print(f"{installer_path}... ", flush=True) - - conv(installer_path, dest_dir) - if verbose: - print("OK") + 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 index 2a4fab4d..f272e90e 100644 --- a/src/wheel/cli/pack.py +++ b/src/wheel/cli/pack.py @@ -1,22 +1,18 @@ from __future__ import annotations -import os.path import re -import sys from os import PathLike from pathlib import Path from wheel.cli import WheelError -from wheel.wheelfile import WheelFile, make_filename +from wheel.wheelfile import WheelWriter, make_filename DIST_INFO_RE = re.compile(r"^(?P(?P[^-]+)-(?P\d.*?))\.dist-info$") -BUILD_NUM_RE = re.compile(br'Build: (\d\w*)$') +BUILD_NUM_RE = re.compile(rb"Build: (\d\w*)$") def pack( - directory: str | PathLike, - dest_dir: str | PathLike, - build_number: str | None = None + directory: str | PathLike, dest_dir: str | PathLike, build_number: str | None = None ) -> None: """Repack a previously unpacked wheel directory into a new wheel file. @@ -30,8 +26,11 @@ def pack( """ # Find the .dist-info directory directory = Path(directory) - dist_info_dirs = [path for path in directory.iterdir() - if path.is_dir() and DIST_INFO_RE.match(path.name)] + dist_info_dirs = [ + 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}") elif not dist_info_dirs: @@ -39,11 +38,11 @@ def pack( # Determine the target wheel filename dist_info_dir = dist_info_dirs[0] - name, version = DIST_INFO_RE.match(dist_info_dir.name).groups() + name, version = DIST_INFO_RE.match(dist_info_dir.name).groups()[1:] # Read the tags and the existing build number from .dist-info/WHEEL existing_build_number = None - wheel_file_path = dist_info_dir / 'WHEEL' + wheel_file_path = dist_info_dir / "WHEEL" with wheel_file_path.open() as f: tags = [] for line in f: @@ -61,8 +60,10 @@ def pack( # 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 and build_number != existing_build_number: - replacement = ('Build: %s\r\n' % build_number).encode('ascii') if build_number else b'' - with wheel_file_path.open('rb+') as f: + 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 @@ -77,12 +78,17 @@ def pack( platforms = sorted({tag.split("-")[2] for tag in tags}) # Repack the wheel - filename = make_filename(name, version, build_number, '.'.join(impls), '.'.join(abivers), - '.'.join(platforms)) + filename = make_filename( + name, + version, + build_number, + ".".join(impls), + ".".join(abivers), + ".".join(platforms), + ) wheel_path = Path(dest_dir) / filename - with WheelFile(wheel_path, 'w') as wf: - print("Repacking wheel as {}...".format(wheel_path), end='') - sys.stdout.flush() - wf.write_files(directory) + with WheelWriter(wheel_path) as wf: + print(f"Repacking wheel as {wheel_path}...", end="", flush=True) + wf.write_files_from_directory(directory) print("OK") diff --git a/src/wheel/cli/unpack.py b/src/wheel/cli/unpack.py index 8f16e5f3..f0dae6a2 100644 --- a/src/wheel/cli/unpack.py +++ b/src/wheel/cli/unpack.py @@ -1,14 +1,12 @@ from __future__ import annotations -import sys -from pathlib import Path -from typing import Union from os import PathLike +from pathlib import Path -from ..wheelfile import WheelFile +from ..wheelfile import WheelReader -def unpack(path: Union[str, PathLike], dest: Union[str, PathLike] = '.') -> None: +def unpack(path: str | PathLike, dest: str | PathLike = ".") -> None: """Unpack a wheel. Wheel content will be unpacked to {dest}/{name}-{ver}, where {name} @@ -17,12 +15,11 @@ def unpack(path: Union[str, PathLike], dest: Union[str, PathLike] = '.') -> None :param path: The path to the wheel. :param dest: Destination directory (default to current directory). """ - with WheelFile(path) as wf: - namever = wf.metadata.name + '.' + wf.metadata.version + with WheelReader(path) as wf: + namever = f"{wf.name}.{wf.version}" destination = Path(dest) / namever destination.mkdir(exist_ok=True) - print("Unpacking to: {}...".format(destination), end='') - sys.stdout.flush() - wf.unpack(destination) + print(f"Unpacking to: {destination}...", end="", flush=True) + wf.extractall(destination) - print('OK') + print("OK") diff --git a/src/wheel/macosx_libfile.py b/src/wheel/macosx_libfile.py index 4465726d..b62defe6 100644 --- a/src/wheel/macosx_libfile.py +++ b/src/wheel/macosx_libfile.py @@ -249,8 +249,7 @@ def swap32(x: int) -> int: def get_base_class_and_magic_number( - lib_file: BinaryIO, - seek: int | None = None + lib_file: BinaryIO, seek: int | None = None ) -> tuple[type[ctypes.Structure], int]: if seek is None: seek = lib_file.tell() @@ -276,9 +275,7 @@ def get_base_class_and_magic_number( def read_data(struct_class: type[ctypes.Structure], lib_file: BinaryIO): - return struct_class.from_buffer_copy( - lib_file.read(ctypes.sizeof(struct_class)) - ) + return struct_class.from_buffer_copy(lib_file.read(ctypes.sizeof(struct_class))) def extract_macosx_min_system_version(path_to_lib: str) -> tuple[int, int, int] | None: @@ -340,8 +337,7 @@ class FatArch(BaseClass): def read_mach_header( - lib_file: BinaryIO, - seek: int | None = None + lib_file: BinaryIO, seek: int | None = None ) -> tuple[int, int, int] | None: """ This funcition parse mach-O header and extract diff --git a/src/wheel/metadata.py b/src/wheel/metadata.py index 6dd6a571..dc4239fe 100644 --- a/src/wheel/metadata.py +++ b/src/wheel/metadata.py @@ -3,12 +3,11 @@ """ from __future__ import annotations -import textwrap from collections.abc import Iterator -from pathlib import Path from email.parser import HeaderParser +from pathlib import Path -from pkg_resources import Requirement, safe_extra +from pkg_resources import Requirement, safe_extra, split_sections def requires_to_requires_dist(requirement: Requirement) -> str: @@ -74,16 +73,15 @@ def pkginfo_to_metadata(pkginfo_path: Path) -> list[tuple[str, str]]: with pkginfo_path.open() as fp: pkg_info = HeaderParser().parse(fp) - pkg_info.replace_header('Metadata-Version', '2.1') + 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 = pkginfo_path.parent / 'requires.txt' + del pkg_info["Provides-Extra"] + del pkg_info["Requires-Dist"] + requires_path = pkginfo_path.parent / "requires.txt" if requires_path.exists(): requires = requires_path.read_text() - parsed_requirements = sorted(pkg_resources.split_sections(requires), - key=lambda x: x[0] or '') + parsed_requirements = sorted(split_sections(requires), key=lambda x: x[0] or "") for extra, reqs in parsed_requirements: for key, value in generate_requirements({extra: reqs}): if (key, value) not in pkg_info.items(): 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..bab11b80 --- /dev/null +++ b/src/wheel/vendored/packaging/utils.py @@ -0,0 +1,136 @@ +# 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]) -> 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 + # NB: This strips trailing '.0's to normalize + parts.append(re.sub(r"(\.0)+$", "", ".".join(str(x) for x in parsed.release))) + + # 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..de9a09a4 --- /dev/null +++ b/src/wheel/vendored/packaging/version.py @@ -0,0 +1,504 @@ +# 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 collections +import itertools +import re +import warnings +from typing import Callable, Iterator, List, Optional, SupportsInt, Tuple, Union + +from ._structures import Infinity, InfinityType, NegativeInfinity, NegativeInfinityType + +__all__ = ["parse", "Version", "LegacyVersion", "InvalidVersion", "VERSION_PATTERN"] + +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 +] +LegacyCmpKey = Tuple[int, Tuple[str, ...]] +VersionComparisonMethod = Callable[ + [Union[CmpKey, LegacyCmpKey], Union[CmpKey, LegacyCmpKey]], bool +] + +_Version = collections.namedtuple( + "_Version", ["epoch", "release", "dev", "pre", "post", "local"] +) + + +def parse(version: str) -> Union["LegacyVersion", "Version"]: + """ + Parse the given version string and return either a :class:`Version` object + or a :class:`LegacyVersion` object depending on if the given version is + a valid PEP 440 version or a legacy version. + """ + try: + return Version(version) + except InvalidVersion: + return LegacyVersion(version) + + +class InvalidVersion(ValueError): + """ + An invalid version was found, users should refer to PEP 440. + """ + + +class _BaseVersion: + _key: Union[CmpKey, LegacyCmpKey] + + 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 + + +class LegacyVersion(_BaseVersion): + def __init__(self, version: str) -> None: + self._version = str(version) + self._key = _legacy_cmpkey(self._version) + + warnings.warn( + "Creating a LegacyVersion has been deprecated and will be " + "removed in the next major release", + DeprecationWarning, + ) + + def __str__(self) -> str: + return self._version + + def __repr__(self) -> str: + return f"" + + @property + def public(self) -> str: + return self._version + + @property + def base_version(self) -> str: + return self._version + + @property + def epoch(self) -> int: + return -1 + + @property + def release(self) -> None: + return None + + @property + def pre(self) -> None: + return None + + @property + def post(self) -> None: + return None + + @property + def dev(self) -> None: + return None + + @property + def local(self) -> None: + return None + + @property + def is_prerelease(self) -> bool: + return False + + @property + def is_postrelease(self) -> bool: + return False + + @property + def is_devrelease(self) -> bool: + return False + + +_legacy_version_component_re = re.compile(r"(\d+ | [a-z]+ | \.| -)", re.VERBOSE) + +_legacy_version_replacement_map = { + "pre": "c", + "preview": "c", + "-": "final-", + "rc": "c", + "dev": "@", +} + + +def _parse_version_parts(s: str) -> Iterator[str]: + for part in _legacy_version_component_re.split(s): + part = _legacy_version_replacement_map.get(part, part) + + if not part or part == ".": + continue + + if part[:1] in "0123456789": + # pad for numeric comparison + yield part.zfill(8) + else: + yield "*" + part + + # ensure that alpha/beta/candidate are before final + yield "*final" + + +def _legacy_cmpkey(version: str) -> LegacyCmpKey: + + # We hardcode an epoch of -1 here. A PEP 440 version can only have a epoch + # greater than or equal to 0. This will effectively put the LegacyVersion, + # which uses the defacto standard originally implemented by setuptools, + # as before all PEP 440 versions. + epoch = -1 + + # This scheme is taken from pkg_resources.parse_version setuptools prior to + # it's adoption of the packaging library. + parts: List[str] = [] + for part in _parse_version_parts(version.lower()): + if part.startswith("*"): + # remove "-" before a prerelease tag + if part < "*final": + while parts and parts[-1] == "*final-": + parts.pop() + + # remove trailing zeros from each series of numeric parts + while parts and parts[-1] == "00000000": + parts.pop() + + parts.append(part) + + return epoch, tuple(parts) + + +# 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
+"""
+
+
+class Version(_BaseVersion):
+
+    _regex = re.compile(r"^\s*" + VERSION_PATTERN + r"\s*$", re.VERBOSE | re.IGNORECASE)
+
+    def __init__(self, version: str) -> None:
+
+        # 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:
+        return f""
+
+    def __str__(self) -> str:
+        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:
+        _epoch: int = self._version.epoch
+        return _epoch
+
+    @property
+    def release(self) -> Tuple[int, ...]:
+        _release: Tuple[int, ...] = self._version.release
+        return _release
+
+    @property
+    def pre(self) -> Optional[Tuple[str, int]]:
+        _pre: Optional[Tuple[str, int]] = self._version.pre
+        return _pre
+
+    @property
+    def post(self) -> Optional[int]:
+        return self._version.post[1] if self._version.post else None
+
+    @property
+    def dev(self) -> Optional[int]:
+        return self._version.dev[1] if self._version.dev else None
+
+    @property
+    def local(self) -> Optional[str]:
+        if self._version.local:
+            return ".".join(str(x) for x in self._version.local)
+        else:
+            return None
+
+    @property
+    def public(self) -> str:
+        return str(self).split("+", 1)[0]
+
+    @property
+    def base_version(self) -> str:
+        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:
+        return self.dev is not None or self.pre is not None
+
+    @property
+    def is_postrelease(self) -> bool:
+        return self.post is not None
+
+    @property
+    def is_devrelease(self) -> bool:
+        return self.dev is not None
+
+    @property
+    def major(self) -> int:
+        return self.release[0] if len(self.release) >= 1 else 0
+
+    @property
+    def minor(self) -> int:
+        return self.release[1] if len(self.release) >= 2 else 0
+
+    @property
+    def micro(self) -> int:
+        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 28323f0f..4051f49b 100644
--- a/src/wheel/wheelfile.py
+++ b/src/wheel/wheelfile.py
@@ -4,111 +4,209 @@
 import hashlib
 import os.path
 import re
-import shutil
-import sys
+import stat
 import time
-from abc import abstractmethod, ABCMeta
 from base64 import urlsafe_b64decode, urlsafe_b64encode
 from collections import OrderedDict
-from datetime import datetime
+from collections.abc import Iterable, Iterator
+from contextlib import ExitStack
+from datetime import datetime, timezone
 from email.generator import Generator
 from email.message import Message
-from email.parser import Parser
-from io import StringIO
+from io import StringIO, UnsupportedOperation
 from os import PathLike
 from pathlib import Path, PurePath
-from typing import Optional, Union, Dict, Iterable, NamedTuple, IO, Tuple, List, BinaryIO, Iterator
-from zipfile import ZIP_DEFLATED, ZipInfo, ZipFile
+from types import TracebackType
+from typing import IO, TYPE_CHECKING, BinaryIO, NamedTuple, cast
+from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile, ZipInfo
 
 from . import __version__ as wheel_version
+from .vendored.packaging.utils import InvalidWheelFilename, parse_wheel_filename
 
-_DIST_NAME_RE = re.compile(r'[^A-Za-z0-9.]+')
-WHEEL_INFO_RE = re.compile(
-    r"""^(?P(?P[^-]+?)-(?P[^-]+?))(-(?P\d[^-]*))?
-     -(?P[^-]+?)-(?P[^-]+?)-(?P[^.]+?)\.whl$""",
-    re.VERBOSE,
-)
-
-WheelMetadata = NamedTuple('WheelMetadata', [
-    ('name', str),
-    ('version', str),
-    ('build_tag', Optional[str]),
-    ('implementation', str),
-    ('abi', str),
-    ('platform', str)
-])
-
-WheelRecordEntry = NamedTuple('_WheelRecordEntry', [
-    ('hash_algorithm', str),
-    ('hash_value', bytes),
-    ('filesize', int)
-])
+if TYPE_CHECKING:
+    from packaging.tags import Tag
+    from packaging.utils import NormalizedName, Version
 
+    WheelContentElement = tuple[tuple[PurePath, str, str], BinaryIO]
 
-def _encode_hash_value(hash_value: bytes) -> str:
-    return urlsafe_b64encode(hash_value).rstrip(b'=').decode('ascii')
+_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)
 
 
-def _decode_hash_value(encoded_hash: str) -> bytes:
-    pad = b'=' * (4 - (len(encoded_hash) & 3))
-    return urlsafe_b64decode(encoded_hash.encode('ascii') + pad)
+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(cast("NormalizedName", name), version, build, tags)
+
+
+class WheelRecordEntry(NamedTuple):
+    hash_algorithm: str
+    hash_value: bytes
+    filesize: int
+
 
+def _encode_hash_value(hash_value: bytes) -> str:
+    return urlsafe_b64encode(hash_value).rstrip(b"=").decode("ascii")
 
-def parse_filename(filename: str) -> WheelMetadata:
-    parsed_filename = _WHEEL_INFO_RE.match(filename)
-    if parsed_filename is None:
-        raise WheelError('Bad wheel filename {!r}'.format(filename))
 
-    return WheelMetadata(*parsed_filename.groups()[1:])
+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: Union[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 = '{}-{}'.format(name, version)
-    if build_tag is not None:
-        filename = '{}-{}'.format(filename, build_tag)
+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 '{}-{}-{}-{}.whl'.format(filename, impl_tag, abi_tag, plat_tag)
+    return f"{filename}-{impl_tag}-{abi_tag}-{plat_tag}.whl"
 
 
 class WheelError(Exception):
     pass
 
 
-WheelContentElement = Tuple[Tuple[PurePath, str, str], BinaryIO]
+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 | None = None) -> 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):
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        self._fp.close()
+
+    def __repr__(self) -> str:
+        return f"{self.__class__.__name__}({self._fp!r}, {self._arcname!r})"
 
 
 class WheelReader:
-    def __init__(self, file: IO[bytes], distribution: str, version: str):
-        self._zip = ZipFile(file)
-        self.distribution = distribution
-        self.version = version
-        self._dist_info_dir = f'{distribution}-{version}.dist-info'
-        self._data_dir = f'{distribution}-{version}.data'
+    name: NormalizedName
+    version: Version
+    _zip: ZipFile
+    _record_entries: OrderedDict[str, WheelRecordEntry]
+
+    def __init__(self, path_or_fd: str | PathLike | 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")
+        try:
+            if not hasattr(self, "name"):
+                for zinfo in reversed(self._zip.infolist()):
+                    if zinfo.is_dir() and zinfo.filename.endswith(".dist-info"):
+                        match = _DIST_NAME_RE.match(zinfo.filename)
+                        if match:
+                            self.name, self.version = match.groups()
+                            break
+                else:
+                    raise WheelError(
+                        "Cannot find a .dist-info directory. Is this really a wheel "
+                        "file?"
+                    )
+        except BaseException:
+            self._zip.close()
+            raise
+
+        self._dist_info_dir = f"{self.name}-{self.version}.dist-info"
+        self._data_dir = f"{self.name}-{self.version}.data"
         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()
 
     def _read_record(self) -> OrderedDict[str, WheelRecordEntry]:
         entries = OrderedDict()
-        contents = self.read_dist_info('RECORD')
-        reader = csv.reader(contents.strip().split('\n'), delimiter=',', quotechar='"',
-                            lineterminator='\n')
+        contents = self.read_dist_info("RECORD")
+        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('=')
+                algorithm, hash_digest = hash_digest.split("=")
                 try:
                     hashlib.new(algorithm)
                 except ValueError:
-                    raise WheelError(f'Unsupported hash algorithm: {algorithm}') from None
+                    raise WheelError(
+                        f"Unsupported hash algorithm: {algorithm}"
+                    ) from None
 
-                if algorithm.lower() in {'md5', 'sha1'}:
+                if algorithm.lower() in {"md5", "sha1"}:
                     raise WheelError(
-                        f'Weak hash algorithm ({algorithm}) is not permitted by PEP 427')
+                        f"Weak hash algorithm ({algorithm}) is not permitted by PEP 427"
+                    )
 
                 entries[path] = WheelRecordEntry(
-                    algorithm, hash_digest, int(filesize))
+                    algorithm, _decode_hash_value(hash_digest), int(filesize)
+                )
 
         return entries
 
@@ -121,349 +219,282 @@ def data_dir(self):
         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)]
+    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:
-        return self._zip.read(f'{self.dist_info_dir}/{filename}').decode('utf-8')
+        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:
+            with self._zip.open(fname, "r") as stream:
                 yield (fname, entry.hash_value, entry.filesize), stream
 
-    def __repr__(self):
-        return (f'{self.__class__.__name__}({self._zip.fp!r}, {self.distribution!r}, '
-                f'{self.version!r})')
-
+    def test(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
 
-class WheelFileReader(WheelReader):
-    def __init__(self, path: Union[str, PathLike]):
-        self.path = Path(path)
-        parsed_filename = _WHEEL_INFO_RE.match(self.path.name)
-        if parsed_filename is None:
-            raise WheelError(f'Bad wheel filename {self.path.name!r}')
+            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) -> 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
 
-        name, version = parsed_filename.groups()[1:]
-        super().__init__(open(path, 'rb'), name, version)
+                    outfile.write(data)
 
-    def __repr__(self):
-        return f'{self.__class__.__name__}({self.path!r})'
+    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
+        )
 
-class WheelFile:
-    __slots__ = ('generator', 'root_is_purelib', '_mode', '_metadata', '_compression', '_zip',
-                 '_data_path', '_dist_info_path', '_record_path', '_record_entries',
-                 '_exclude_archive_names')
+    def _read_file(self, archive_name: str) -> bytes:
+        with self._open_file(archive_name) as fp:
+            return fp.read()
 
-    # dist-info file names ignored for hash checking/recording
-    _exclude_filenames = ('RECORD', 'RECORD.jws', 'RECORD.p7s')
-    _default_hash_algorithm = 'sha256'
+    def read_data_file(self, filename: str) -> bytes:
+        archive_path = self._data_dir + "/" + filename.strip("/")
+        return self._read_file(archive_path)
 
-    def __init__(self, path_or_fd: Union[str, PathLike, IO[bytes]], mode: str = 'r', *,
-                 metadata: Optional[WheelMetadata] = None, compression: int = ZIP_DEFLATED,
-                 generator: Optional[str] = None, root_is_purelib: bool = True):
-        if mode not in ('r', 'w'):
-            raise ValueError("mode must be either 'r' or 'w'")
+    def read_distinfo_file(self, filename: str) -> bytes:
+        archive_path = self._dist_info_dir + "/" + filename.strip("/")
+        return self._read_file(archive_path)
 
-        if isinstance(path_or_fd, (str, PathLike)):
-            path_or_fd = Path(path_or_fd).open(mode + 'b')
+    def __repr__(self):
+        return f"{self.__class__.__name__}({self.path_or_fd})"
 
-        if metadata is None:
-            filename = getattr(path_or_fd, 'name', None)
-            if filename:
-                metadata = parse_filename(os.path.basename(filename))
-            else:
-                raise WheelError('No file name or metadata provided')
 
-        self.generator = generator or 'Wheel {}'.format(wheel_version)
+class WheelWriter:
+    def __init__(
+        self,
+        path_or_fd: str | PathLike | 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._mode = mode
-        self._metadata = metadata
-        self._compression = compression
-        self._data_path = '{meta.name}-{meta.version}.data'.format(meta=self._metadata)
-        self._dist_info_path = '{meta.name}-{meta.version}.dist-info'.format(meta=self._metadata)
-        self._record_path = self._dist_info_path + '/RECORD'
-        self._exclude_archive_names = frozenset(self._dist_info_path + '/' + fname
-                                                for fname in self._exclude_filenames)
-        self._zip = ZipFile(path_or_fd, mode)
-        self._record_entries = OrderedDict()  # type: Dict[str, WheelRecordEntry]
-
-        if mode == 'r':
-            self._read_record()
-
-    @property
-    def path(self) -> Optional[Path]:
-        return Path(self._zip.filename) if self._zip.filename else None
-
-    @property
-    def mode(self) -> str:
-        return self._mode
-
-    @property
-    def metadata(self) -> WheelMetadata:
-        return self._metadata
-
-    @property
-    def record_entries(self) -> Dict[str, WheelRecordEntry]:
-        return self._record_entries.copy()
-
-    @property
-    def filenames(self) -> List[str]:
-        return self._zip.namelist()
+        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 close(self) -> None:
+    def __exit__(
+        self,
+        exc_type: type[BaseException],
+        exc_val: BaseException,
+        exc_tb: TracebackType,
+    ) -> None:
         try:
-            if self.mode == 'w':
-                filenames = set(self._zip.namelist())
-
-                metadata_path = self._dist_info_path + '/METADATA'
-                if metadata_path not in filenames:
-                    self.write_metadata([])
-
-                wheel_path = self._dist_info_path + '/WHEEL'
-                if wheel_path not in filenames:
+            if not exc_type:
+                if f"{self._dist_info_dir}/WHEEL" not in self._record_entries:
                     self._write_wheelfile()
 
                 self._write_record()
-        except BaseException:
-            self._zip.close()
-            if self.mode == 'w' and self._zip.filename:
-                os.unlink(self._zip.filename)
-
-            raise
         finally:
-            try:
-                self._zip.close()
-            finally:
-                self._record_entries.clear()
-
-    def __enter__(self) -> 'WheelFile':
-        return self
-
-    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
-        self.close()
-
-
-class SourceFile(metaclass=ABCMeta):
-    @property
-    @abstractmethod
-    def archive_name(self) -> str:
-        pass
-
-    @abstractmethod
-    def open(self) -> IO[bytes]:
-        pass
-
-
-class SourceFileReader:
-    def __init__(self, file: IO[bytes], hash_algorithm):
-        self._file = file
-        self._hash = hashlib.new(hash_algorithm)
-
-    def read(self, n):
-        data = self._file.read(n)
-        self._hash.update(data)
-        return data
-
-
-class WheelWriter:
-    def __init__(self, path_or_fd, distribution: str, version: str,
-                 build_tag: Optional[str] = None):
-        self.distribution = distribution
-        self.version = version
-        self.build_tag = build_tag
-        self._dist_info_dir = f'{distribution}-{version}.dist-info'
-        self._data_dir = f'{distribution}-{version}.data'
-        self._zip = ZipFile(path_or_fd, 'w')
-        self._record_entries: Dict[str, WheelRecordEntry] = OrderedDict()
-
-    def __enter__(self):
-        return self
-
-    def __exit__(self, exc_type, exc_val, exc_tb):
-        if not exc_type:
-            self._write_record()
-
-    def write_distinfo_file(self, arcname: str, data: str) -> None:
-        self._zip.writestr(arcname, data)
+            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 = 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())
+        self.write_distinfo_file("RECORD", data.getvalue())
 
     def _write_wheelfile(self) -> None:
         msg = Message()
-        msg['Wheel-Version'] = '1.0'  # of the spec
-        msg['Generator'] = self.generator
-        msg['Root-Is-Purelib'] = str(self.root_is_purelib).lower()
-        if self.build_tag is not None:
-            msg['Build'] = self.build_tag
+        msg["Wheel-Version"] = "1.0"  # of the spec
+        msg["Generator"] = self.generator
+        msg["Root-Is-Purelib"] = str(self.root_is_purelib).lower()
+        if self.metadata.build_tag:
+            msg["Build"] = str(self.metadata.build_tag[0]) + self.metadata.build_tag[1]
 
-        for impl in self.metadata.implementation.split('.'):
-            for abi in self.metadata.abi.split('.'):
-                for plat in self.metadata.platform.split('.'):
-                    msg['Tag'] = '-'.join((impl, abi, plat))
+        for tag in sorted(
+            self.metadata.tags, key=lambda t: (t.interpreter, t.abi, t.platform)
+        ):
+            msg["Tag"] = f"{tag.interpreter}-{tag.abi}-{tag.platform}"
 
         buffer = StringIO()
         Generator(buffer, maxheaderlen=0).flatten(msg)
-        self.write_distinfo_file('WHEEL', buffer.getvalue())
+        self.write_distinfo_file("WHEEL", buffer.getvalue())
 
-    def write_metadata(self, items: Iterable[Tuple[str, str]]) -> None:
+    def write_metadata(self, items: Iterable[tuple[str, str]]) -> None:
         msg = Message()
         for key, value in items:
             key = key.title()
-            if key == 'Description':
-                msg.set_payload(value, 'utf-8')
+            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.distribution
-        if 'Version' not in msg:
-            msg['Version'] = self.version
+        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)
 
         buffer = StringIO()
         Generator(buffer, maxheaderlen=0).flatten(msg)
-        self.write_distinfo_file('METADATA', buffer.getvalue())
-
-    def write_files(self, files: Iterator[SourceFile]) -> None:
-        for file in files:
-            with file.open() as dest, self._zip.open(file.archive_name, 'w') as src:
-                wrapper = SourceFileReader(src, 'sha256')
-                shutil.copyfileobj(wrapper, dest, 1024 * 8)
-
-    def write_file(self, archive_name: str, contents: Union[bytes, str],
-                   timestamp: Union[datetime, int] = None) -> None:
-        if isinstance(contents, str):
-            contents = contents.encode('utf-8')
-        elif not isinstance(contents, bytes):
-            raise TypeError('contents must be str or bytes')
-
-        if timestamp is None:
-            timestamp = time.time()
-        elif isinstance(timestamp, datetime):
-            timestamp = timestamp.timestamp()
-        elif not isinstance(timestamp, int):
-            raise TypeError('timestamp must be int or datetime (or None to use current time')
-
-        if archive_name not in self._exclude_archive_names:
-            hash_digest = hashlib.new(self._default_hash_algorithm, contents).digest()
-            self._record_entries[archive_name] = WheelRecordEntry(
-                self._default_hash_algorithm, hash_digest, len(contents))
-
-        zinfo = ZipInfo(archive_name, date_time=time.gmtime(timestamp)[0:6])
-        zinfo.compress_type = self._compression
+        self.write_distinfo_file("METADATA", buffer.getvalue())
+
+    def write_file(
+        self,
+        name: str | PurePath,
+        contents: bytes | str | PathLike | IO[bytes],
+        timestamp: datetime = DEFAULT_TIMESTAMP,
+    ) -> None:
+        arcname = PurePath(name).as_posix()
+        gmtime = time.gmtime(timestamp.timestamp())
+        zinfo = ZipInfo(arcname, gmtime)
+        zinfo.compress_type = self._compress_type
         zinfo.external_attr = 0o664 << 16
-        self._zip.writestr(zinfo, contents)
-
-    def write_data_file(self, filename: str, contents: Union[bytes, str],
-                        timestamp: Union[datetime, int] = None) -> None:
-        archive_path = self._data_path + '/' + filename.strip('/')
-        self.write_file(archive_path, contents, timestamp)
-
-    def write_distinfo_file(self, filename: str, contents: Union[bytes, str],
-                            timestamp: Union[datetime, int] = None) -> None:
-        archive_path = self._dist_info_path + '/' + filename.strip()
-        self.write_file(archive_path, contents, timestamp)
-
-    def read_file(self, archive_name: str) -> bytes:
-        try:
-            contents = self._zip.read(archive_name)
-        except KeyError:
-            raise WheelError('File {} not found'.format(archive_name)) from None
-
-        if archive_name in self._record_entries:
-            entry = self._record_entries[archive_name]
-            if len(contents) != entry.filesize:
-                raise WheelError('{}: file size mismatch: {} bytes in RECORD, {} bytes in archive'
-                                 .format(archive_name, entry.filesize, len(contents)))
-
-            computed_hash = hashlib.new(entry.hash_algorithm, contents).digest()
-            if computed_hash != entry.hash_value:
-                raise WheelError(
-                    '{}: hash mismatch: {} in RECORD, {} computed from current file contents'
-                    .format(archive_name, _encode_hash_value(entry.hash_value),
-                            _encode_hash_value(computed_hash)))
-
-        return contents
-
-    def read_data_file(self, filename: str) -> bytes:
-        archive_path = self._data_path + '/' + filename.strip('/')
-        return self.read_file(archive_path)
-
-    def read_distinfo_file(self, filename: str) -> bytes:
-        archive_path = self._dist_info_path + '/' + filename.strip('/')
-        return self.read_file(archive_path)
-
-    def unpack(self, dest_dir: Union[str, PathLike],
-               archive_names: Union[str, Iterable[str], None] = None) -> None:
-        base_path = Path(dest_dir)
-        if not base_path.is_dir():
-            raise WheelError('{} is not a directory'.format(base_path))
-
-        if archive_names is None:
-            filenames = self._zip.infolist()
-        elif isinstance(archive_names, str):
-            filenames = [self._zip.getinfo(archive_names)]
-        else:
-            filenames = [self._zip.getinfo(fname) for fname in archive_names]
-
-        for zinfo in filenames:
-            entry = None  # type: Optional[WheelRecordEntry]
-            if zinfo.filename in self._record_entries:
-                entry = self._record_entries[zinfo.filename]
-
-            path = base_path.joinpath(zinfo.filename)
-            with self._zip.open(zinfo) as infile, path.open('wb') as outfile:
-                hash_ = hashlib.new(entry.hash_algorithm) if entry else None
+        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:
-                    data = infile.read(1024 * 1024)
-                    if data:
-                        if hash_:
-                            hash_.update(data)
-
-                        outfile.write(data)
-                    else:
+                    buffer = contents.read(65536)
+                    if not buffer:
+                        file_size = contents.tell()
                         break
 
-            if hash_ is not None and entry is not None and hash_.digest() != entry.hash_value:
-                raise WheelError(
-                    '{}: hash mismatch: {} in RECORD, {} computed from current file contents'
-                    .format(zinfo.filename, _encode_hash_value(entry.hash_value),
-                            _encode_hash_value(hash_.digest()))
-                )
-
-    def write_metadata(self, items: Iterable[Tuple[str, str]]) -> None:
-        msg = Message()
-        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'] = self._metadata.version
+                    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) -> 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 | IO[bytes],
+        timestamp: datetime = DEFAULT_TIMESTAMP,
+    ) -> None:
+        archive_path = self._data_dir + "/" + filename.strip("/")
+        self.write_file(archive_path, contents, timestamp)
 
-        buffer = StringIO()
-        Generator(buffer, maxheaderlen=0).flatten(msg)
-        self.write_distinfo_file('METADATA', buffer.getvalue())
+    def write_distinfo_file(
+        self,
+        filename: str,
+        contents: bytes | str | IO[bytes],
+        timestamp: datetime | int = DEFAULT_TIMESTAMP,
+    ) -> None:
+        archive_path = self._dist_info_dir + "/" + filename.strip()
+        self.write_file(archive_path, contents, timestamp)
 
     def __repr__(self):
-        return '{}({!r}, {!r})'.format(self.__class__.__name__, self.path, self.mode)
+        return f"{self.__class__.__name__}({self.path_or_fd!r})"
diff --git a/tests/cli/test_convert.py b/tests/cli/test_convert.py
index 87ca6f6e..a886505f 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
 
 
-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, tmp_path):
-    convert(egg_paths, str(tmp_path), verbose=False)
+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 8cff33a0..e0f48064 100644
--- a/tests/cli/test_pack.py
+++ b/tests/cli/test_pack.py
@@ -1,10 +1,12 @@
 from __future__ import annotations
 
 import os
+from pathlib import Path
 from textwrap import dedent
 from zipfile import ZipFile
 
 import pytest
+from _pytest.tmpdir import TempPathFactory
 
 from wheel.cli.pack import pack
 
@@ -23,7 +25,13 @@
         pytest.param("", "3", "test-1.0-py2.py3-none-any.whl", id="erasebuildnum"),
     ],
 )
-def test_pack(tmp_path_factory, tmp_path, build_tag_arg, existing_build_tag, filename):
+def test_pack(
+    tmp_path_factory: TempPathFactory,
+    tmp_path: Path,
+    build_tag_arg: str | None,
+    existing_build_tag: str | None,
+    filename: str,
+) -> None:
     unpack_dir = tmp_path_factory.mktemp("wheeldir")
     with ZipFile(TESTWHEEL_PATH) as zf:
         old_record = zf.read("test-1.0.dist-info/RECORD")
@@ -36,15 +44,15 @@ def test_pack(tmp_path_factory, tmp_path, build_tag_arg, existing_build_tag, fil
 
     if existing_build_tag:
         # Add the build number to WHEEL
-        wheel_file_path = unpack_dir / 'test-1.0.dist-info' / 'WHEEL'
+        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'
+        assert b"Build" not in wheel_file_content
+        wheel_file_content += b"Build: 3\r\n"
         wheel_file_path.write_bytes(wheel_file_content)
 
-    pack(str(unpack_dir), str(tmp_path), build_tag_arg)
+    pack(unpack_dir, tmp_path, build_tag_arg)
     new_wheel_path = tmp_path / filename
-    assert new_wheel_path.isfile()
+    assert new_wheel_path.is_file()
 
     with ZipFile(str(new_wheel_path)) as zf:
         new_record = zf.read("test-1.0.dist-info/RECORD")
diff --git a/tests/conftest.py b/tests/conftest.py
index 1b52ff5a..cd87ad2d 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -10,10 +10,11 @@
 from pathlib import Path
 
 import pytest
+from _pytest.tmpdir import TempPathFactory
 
 
 @pytest.fixture(scope="session")
-def wheels_and_eggs(tmp_path_factory):
+def wheels_and_eggs(tmp_path_factory: TempPathFactory) -> list[Path]:
     """Build wheels and eggs from test distributions."""
     test_distributions = (
         "complex-dist",
@@ -50,15 +51,15 @@ def wheels_and_eggs(tmp_path_factory):
         )
 
     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 [path for path in wheels_and_eggs if path.suffix == '.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 [path for path in wheels_and_eggs if path.suffix == '.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 1825feaa..27bff7dd 100644
--- a/tests/test_bdist_wheel.py
+++ b/tests/test_bdist_wheel.py
@@ -5,37 +5,37 @@
 import stat
 import subprocess
 import sys
-from pathlib import Path
+import zipfile
+from pathlib import Path, PurePath
 from zipfile import ZipFile
 
 import pytest
 from _pytest.monkeypatch import MonkeyPatch
 from _pytest.tmpdir import TempPathFactory
 
-from wheel.bdist_wheel import bdist_wheel
-from wheel.wheelfile import WheelFile
+from wheel.wheelfile import WheelReader
 
 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
@@ -49,8 +49,8 @@
 
 @pytest.fixture
 def dummy_dist(tmp_path_factory: TempPathFactory) -> Path:
-    basedir = tmp_path_factory.mktemp('dummy_dist')
-    basedir.joinpath('setup.py').write_text(SETUPPY_EXAMPLE)
+    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.joinpath(fname).write_text("")
 
@@ -60,58 +60,69 @@ def dummy_dist(tmp_path_factory: TempPathFactory) -> Path:
     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.name)
-    with WheelFile(path) as wf:
+    with WheelReader(path) as wf:
         filenames = set(wf.filenames)
 
     for filename in filenames:
-        assert ".data/scripts/" not in filename
+        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")
+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".encode() in record
+    assert "åäö_日本語.py" in record
 
 
-def test_licenses_default(dummy_dist, monkeypatch: MonkeyPatch, tmp_path: Path) -> None:
+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(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.filenames) == DEFAULT_FILES | license_files
 
 
-def test_licenses_deprecated(dummy_dist, monkeypatch: MonkeyPatch, tmp_path: Path) -> None:
-    dummy_dist.joinpath('setup.cfg').write_text('[metadata]\nlicense_file=licenses/DUMMYFILE')
+def test_licenses_deprecated(
+    dummy_dist: Path, monkeypatch: MonkeyPatch, tmp_path: Path
+) -> None:
+    dummy_dist.joinpath("setup.cfg").write_text(
+        "[metadata]\nlicense_file=licenses/DUMMYFILE"
+    )
     monkeypatch.chdir(dummy_dist)
     subprocess.check_call(
         [sys.executable, "setup.py", "bdist_wheel", "-b", str(tmp_path), "--universal"]
     )
-    with WheelFile("dist/dummy_dist-1.0-py2.py3-none-any.whl") as wf:
-        license_files = {'dummy_dist-1.0.dist-info/DUMMYFILE'}
+    with WheelReader("dist/dummy_dist-1.0-py2.py3-none-any.whl") as wf:
+        license_files = {PurePath("dummy-dist-1.0.dist-info/DUMMYFILE")}
         assert set(wf.filenames) == DEFAULT_FILES | license_files
 
 
-def test_licenses_disabled(dummy_dist, monkeypatch: MonkeyPatch, tmp_path: Path) -> None:
+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(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:
         assert set(wf.filenames) == DEFAULT_FILES
 
 
-def test_build_number(dummy_dist, monkeypatch: MonkeyPatch, tmp_path: Path) -> None:
+def test_build_number(
+    dummy_dist: Path, monkeypatch: MonkeyPatch, tmp_path: Path
+) -> None:
     monkeypatch.chdir(dummy_dist)
     subprocess.check_call(
         [
@@ -124,18 +135,18 @@ def test_build_number(dummy_dist, monkeypatch: MonkeyPatch, tmp_path: Path) -> N
             "--build-number=2",
         ]
     )
-    with WheelFile("dist/dummy_dist-1.0-2-py2.py3-none-any.whl") as wf:
+    with WheelReader("dist/dummy_dist-1.0-2-py2.py3-none-any.whl") as wf:
         filenames = set(wf.filenames)
-        assert "dummy_dist-1.0.dist-info/RECORD" in filenames
-        assert "dummy_dist-1.0.dist-info/METADATA" in 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: 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 = tmp_path / 'build'
-    dist_dir = tmp_path / 'dist'
+    source_dir = os.path.join(this_dir, "testdata", "extension.dist")
+    build_dir = tmp_path / "build"
+    dist_dir = tmp_path / "dist"
     monkeypatch.chdir(source_dir)
     subprocess.check_call(
         [
@@ -150,8 +161,10 @@ def test_limited_abi(monkeypatch: MonkeyPatch, tmp_path: Path) -> None:
     )
 
 
-def test_build_from_readonly_tree(dummy_dist, monkeypatch: MonkeyPatch, tmp_path: Path) -> None:
-    basedir = str(tmp_path / '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)
 
@@ -165,10 +178,18 @@ def test_build_from_readonly_tree(dummy_dist, monkeypatch: MonkeyPatch, tmp_path
 
 @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: MonkeyPatch, tmp_path: Path, 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(
         [
@@ -182,22 +203,20 @@ def test_compression(dummy_dist, monkeypatch: MonkeyPatch, tmp_path: Path, optio
         ]
     )
     with ZipFile("dist/dummy_dist-1.0-py2.py3-none-any.whl") as zf:
-        filenames = set(zf.namelist())
-        assert "dummy_dist-1.0.dist-info/RECORD" in filenames
-        assert "dummy_dist-1.0.dist-info/METADATA" in filenames
         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(
@@ -206,7 +225,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_macosx_libfile.py b/tests/test_macosx_libfile.py
index fed3ebbe..d4083968 100644
--- a/tests/test_macosx_libfile.py
+++ b/tests/test_macosx_libfile.py
@@ -3,12 +3,17 @@
 import os
 import sys
 import sysconfig
+from collections.abc import Callable
+from typing import Any
+
+from _pytest.capture import CaptureFixture
+from _pytest.monkeypatch import MonkeyPatch
 
 from wheel.bdist_wheel import get_platform
 from wheel.macosx_libfile import extract_macosx_min_system_version
 
 
-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 = [
@@ -40,15 +45,15 @@ def test_read_from_dylib():
     )
 
 
-def return_factory(return_val):
-    def fun(*args, **kwargs):
+def return_factory(return_val) -> Callable:
+    def fun(*args: Any, **kwargs: Any):
         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 +61,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
+    ) -> None:
         dirname = os.path.dirname(__file__)
         dylib_dir = os.path.join(dirname, "testdata", "macosx_minimal_system_version")
         monkeypatch.setattr(
@@ -67,8 +74,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
+    ) -> None:
         dirname = os.path.dirname(__file__)
         dylib_dir = os.path.join(dirname, "testdata", "macosx_minimal_system_version")
         monkeypatch.setattr(
@@ -90,8 +97,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
+    ) -> None:
         dirname = os.path.dirname(__file__)
         dylib_dir = os.path.join(dirname, "testdata", "macosx_minimal_system_version")
         monkeypatch.setattr(
@@ -111,7 +118,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
+    ) -> None:
         dirname = os.path.dirname(__file__)
         dylib_dir = os.path.join(dirname, "testdata", "macosx_minimal_system_version")
         monkeypatch.setattr(
@@ -130,7 +139,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
+    ) -> None:
         dirname = os.path.dirname(__file__)
         dylib_dir = os.path.join(dirname, "testdata", "macosx_minimal_system_version")
         monkeypatch.setattr(
@@ -161,7 +172,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
+    ) -> None:
         dirname = os.path.dirname(__file__)
         dylib_dir = os.path.join(dirname, "testdata", "macosx_minimal_system_version")
         monkeypatch.setattr(
@@ -182,7 +195,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 +211,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 +227,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 6356b2e9..f6e2ee4e 100644
--- a/tests/test_metadata.py
+++ b/tests/test_metadata.py
@@ -31,7 +31,8 @@ def test_pkginfo_to_metadata(tmp_path):
     ]
 
     pkg_info_path = tmp_path / "PKG-INFO"
-    pkg_info_path.write_text("""\
+    pkg_info_path.write_text(
+        """\
 Metadata-Version: 0.0
 Name: spam
 Version: 0.1
@@ -43,8 +44,9 @@ def test_pkginfo_to_metadata(tmp_path):
 Provides-Extra: faster-signatures"""
     )
 
-    requires_txt_path = tmp_path / 'requires.txt'
-    requires_txt_path.write_text("""\
+    requires_txt_path = tmp_path / "requires.txt"
+    requires_txt_path.write_text(
+        """\
 pip@https://github.com/pypa/pip/archive/1.3.1.zip
 
 [extra]
diff --git a/tests/test_tagopt.py b/tests/test_tagopt.py
index 89984b45..3d8658fe 100644
--- a/tests/test_tagopt.py
+++ b/tests/test_tagopt.py
@@ -43,8 +43,7 @@ def temp_pkg(request: FixtureRequest, tmp_path: Path) -> Path:
     if ext[0]:
         try:
             subprocess.check_call(
-                [sys.executable, "setup.py", "build_ext"],
-                cwd=str(tmp_path)
+                [sys.executable, "setup.py", "build_ext"], cwd=str(tmp_path)
             )
         except subprocess.CalledProcessError:
             pytest.skip("Cannot compile C extensions")
@@ -74,12 +73,12 @@ def test_build_number(temp_pkg: Path) -> None:
         [sys.executable, "setup.py", "bdist_wheel", "--build-number=1"],
         cwd=str(temp_pkg),
     )
-    dist_dir = temp_pkg / 'dist'
+    dist_dir = temp_pkg / "dist"
     assert dist_dir.is_dir()
     wheels = list(dist_dir.iterdir())
     assert len(wheels) == 1
     assert wheels[0].name == f"Test-1.0-1-py{sys.version_info[0]}-none-any.whl"
-    assert wheels[0].suffix == '.whl'
+    assert wheels[0].suffix == ".whl"
 
 
 def test_explicit_tag(temp_pkg: Path) -> None:
@@ -87,121 +86,116 @@ def test_explicit_tag(temp_pkg: Path) -> None:
         [sys.executable, "setup.py", "bdist_wheel", "--python-tag=py32"],
         cwd=str(temp_pkg),
     )
-    dist_dir = temp_pkg / 'dist'
+    dist_dir = temp_pkg / "dist"
     assert dist_dir.is_dir()
     wheels = list(dist_dir.iterdir())
     assert len(wheels) == 1
-    assert wheels[0].name.startswith('Test-1.0-py32-')
-    assert wheels[0].suffix == '.whl'
+    assert wheels[0].name.startswith("Test-1.0-py32-")
+    assert wheels[0].suffix == ".whl"
 
 
 def test_universal_tag(temp_pkg: Path) -> None:
     subprocess.check_call(
-        [sys.executable, 'setup.py', 'bdist_wheel', '--universal'],
-        cwd=str(temp_pkg)
+        [sys.executable, "setup.py", "bdist_wheel", "--universal"], cwd=str(temp_pkg)
     )
-    dist_dir = temp_pkg / 'dist'
+    dist_dir = temp_pkg / "dist"
     assert dist_dir.is_dir()
     wheels = list(dist_dir.iterdir())
     assert len(wheels) == 1
-    assert wheels[0].name.startswith('Test-1.0-py2.py3-')
-    assert wheels[0].suffix == '.whl'
+    assert wheels[0].name.startswith("Test-1.0-py2.py3-")
+    assert wheels[0].suffix == ".whl"
 
 
 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)
+        [sys.executable, "setup.py", "bdist_wheel", "--universal", "--python-tag=py32"],
+        cwd=str(temp_pkg),
     )
-    dist_dir = temp_pkg / 'dist'
+    dist_dir = temp_pkg / "dist"
     assert dist_dir.is_dir()
     wheels = list(dist_dir.iterdir())
     assert len(wheels) == 1
-    assert wheels[0].name.startswith('Test-1.0-py2.py3-')
-    assert wheels[0].suffix == '.whl'
+    assert wheels[0].name.startswith("Test-1.0-py2.py3-")
+    assert wheels[0].suffix == ".whl"
 
 
 def test_universal_in_setup_cfg(temp_pkg: Path) -> None:
-    temp_pkg.joinpath('setup.cfg').write_text('[bdist_wheel]\nuniversal=1')
+    temp_pkg.joinpath("setup.cfg").write_text("[bdist_wheel]\nuniversal=1")
     subprocess.check_call(
-        [sys.executable, 'setup.py', 'bdist_wheel'],
-        cwd=str(temp_pkg)
+        [sys.executable, "setup.py", "bdist_wheel"], cwd=str(temp_pkg)
     )
-    dist_dir = temp_pkg / 'dist'
+    dist_dir = temp_pkg / "dist"
     assert dist_dir.is_dir()
     wheels = list(dist_dir.iterdir())
     assert len(wheels) == 1
-    assert wheels[0].name.startswith('Test-1.0-py2.py3-')
-    assert wheels[0].suffix == '.whl'
+    assert wheels[0].name.startswith("Test-1.0-py2.py3-")
+    assert wheels[0].suffix == ".whl"
 
 
 def test_pythontag_in_setup_cfg(temp_pkg: Path) -> None:
-    temp_pkg.joinpath('setup.cfg').write_text('[bdist_wheel]\npython_tag=py32')
+    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)
+        [sys.executable, "setup.py", "bdist_wheel"], cwd=str(temp_pkg)
     )
-    dist_dir = temp_pkg / 'dist'
+    dist_dir = temp_pkg / "dist"
     assert dist_dir.is_dir()
     wheels = list(dist_dir.iterdir())
     assert len(wheels) == 1
-    assert wheels[0].name.startswith('Test-1.0-py32-')
-    assert wheels[0].suffix == '.whl'
+    assert wheels[0].name.startswith("Test-1.0-py32-")
+    assert wheels[0].suffix == ".whl"
 
 
 def test_legacy_wheel_section_in_setup_cfg(temp_pkg: Path) -> None:
-    temp_pkg.joinpath('setup.cfg').write_text('[wheel]\nuniversal=1')
+    temp_pkg.joinpath("setup.cfg").write_text("[wheel]\nuniversal=1")
     subprocess.check_call(
-        [sys.executable, 'setup.py', 'bdist_wheel'],
-        cwd=str(temp_pkg)
+        [sys.executable, "setup.py", "bdist_wheel"], cwd=str(temp_pkg)
     )
-    dist_dir = temp_pkg / 'dist'
+    dist_dir = temp_pkg / "dist"
     assert dist_dir.is_dir()
     wheels = list(dist_dir.iterdir())
     assert len(wheels) == 1
-    assert wheels[0].name.startswith('Test-1.0-py2.py3-')
-    assert wheels[0].suffix == '.whl'
+    assert wheels[0].name.startswith("Test-1.0-py2.py3-")
+    assert wheels[0].suffix == ".whl"
 
 
 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)
+        [sys.executable, "setup.py", "bdist_wheel", "--plat-name=testplat.pure"],
+        cwd=str(temp_pkg),
     )
-    dist_dir = temp_pkg / 'dist'
+    dist_dir = temp_pkg / "dist"
     assert dist_dir.is_dir()
     wheels = list(dist_dir.iterdir())
     assert len(wheels) == 1
-    assert wheels[0].name.endswith('-testplat_pure.whl')
-    assert wheels[0].suffix == '.whl'
+    assert wheels[0].name.endswith("-testplat_pure.whl")
+    assert wheels[0].suffix == ".whl"
 
 
-@pytest.mark.parametrize('temp_pkg', [[True, '']], indirect=['temp_pkg'])
+@pytest.mark.parametrize("temp_pkg", [[True, ""]], indirect=["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)
+        [sys.executable, "setup.py", "bdist_wheel", "--plat-name=testplat.arch"],
+        cwd=str(temp_pkg),
     )
-    dist_dir = temp_pkg / 'dist'
+    dist_dir = temp_pkg / "dist"
     assert dist_dir.is_dir()
     wheels = list(dist_dir.iterdir())
     assert len(wheels) == 1
-    assert wheels[0].name.endswith('-testplat_arch.whl')
-    assert wheels[0].suffix == '.whl'
+    assert wheels[0].name.endswith("-testplat_arch.whl")
+    assert wheels[0].suffix == ".whl"
 
 
 def test_plat_name_purepy_in_setupcfg(temp_pkg: Path) -> None:
-    temp_pkg.joinpath('setup.cfg').write_text('[bdist_wheel]\nplat_name=testplat.pure')
+    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)
+        [sys.executable, "setup.py", "bdist_wheel"], cwd=str(temp_pkg)
     )
-    dist_dir = temp_pkg / 'dist'
+    dist_dir = temp_pkg / "dist"
     assert dist_dir.is_dir()
     wheels = list(dist_dir.iterdir())
     assert len(wheels) == 1
-    assert wheels[0].name.endswith('-testplat_pure.whl')
-    assert wheels[0].suffix == '.whl'
+    assert wheels[0].name.endswith("-testplat_pure.whl")
+    assert wheels[0].suffix == ".whl"
 
 
 @pytest.mark.parametrize("temp_pkg", [[True, ""]], indirect=["temp_pkg"])
diff --git a/tests/test_wheelfile.py b/tests/test_wheelfile.py
index d8860d23..dd35da6d 100644
--- a/tests/test_wheelfile.py
+++ b/tests/test_wheelfile.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import os.path
 import sys
 from pathlib import Path
 from zipfile import ZIP_DEFLATED, ZipFile
@@ -8,43 +9,39 @@
 from _pytest.monkeypatch import MonkeyPatch
 from _pytest.tmpdir import TempPathFactory
 
-from wheel.wheelfile import WheelFile, WheelError
+from wheel.wheelfile import WheelError, WheelReader, WheelWriter
 
 
 @pytest.fixture
 def wheel_path(tmp_path: Path) -> Path:
-    return tmp_path / 'test-1.0-py2.py3-none-any.whl'
-
-
-def test_wheelfile_re(tmp_path: Path) -> None:
-    # Regression test for #208
-    path = tmp_path / 'foo-2-py3-none-any.whl'
-    with WheelFile(str(path), 'w') as wf:
-        assert wf.metadata.name == 'foo'
-        assert wf.metadata.version == '2'
+    return tmp_path / "test-1.0-py2.py3-none-any.whl"
 
 
 @pytest.mark.parametrize(
-    "filename",
+    "filename, reason",
     [
-        "test.whl",
-        "test-1.0.whl",
-        "test-1.0-py2.whl",
-        "test-1.0-py2-none.whl",
-        "test-1.0-py2-none-any",
+        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'"),
     ],
 )
-def test_bad_wheel_filename(filename: str) -> None:
-    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: 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("^File test-1.0.dist-info/RECORD not found$")
+    with pytest.raises(
+        WheelError, match="^File 'test-1.0.dist-info/RECORD' not found$"
+    ):
+        with WheelReader(wheel_path):
+            pass
 
 
 def test_unsupported_hash_algorithm(wheel_path: Path) -> None:
@@ -55,15 +52,16 @@ def test_unsupported_hash_algorithm(wheel_path: Path) -> None:
             "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",
     [
         pytest.param("md5", "4J-scNa2qvSgy07rS4at-Q", id="md5"),
-        pytest.param("sha1", "QjCnGu5Qucb6-vir1a6BVptvOA4", id="sha1")
+        pytest.param("sha1", "QjCnGu5Qucb6-vir1a6BVptvOA4", id="sha1"),
     ],
 )
 def test_weak_hash_algorithm(wheel_path: Path, algorithm: str, digest: str) -> None:
@@ -72,8 +70,12 @@ def test_weak_hash_algorithm(wheel_path: Path, algorithm: str, digest: str) -> N
         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(
@@ -89,23 +91,23 @@ def test_weak_hash_algorithm(wheel_path: Path, algorithm: str, digest: str) -> N
     ],
     ids=["sha256", "sha384", "sha512"],
 )
-def test_testzip(wheel_path: Path, algorithm: str, digest: str) -> None:
+def test_test(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.test()
 
 
-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.test)
         exc.match("^No hash found for file 'hello/héllö.py'$")
 
 
@@ -117,49 +119,54 @@ def test_testzip_bad_hash(wheel_path: Path) -> None:
             "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.test)
         exc.match("^Hash mismatch for file 'hello/héllö.py'$")
 
 
-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')
+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=kvhmsZiU3PgVzMvq_TOoVFCHYjYJCCV7GTSzCS6nYtQ,104\n"
             "test-1.0.dist-info/RECORD,,\n"
         )
 
 
-def test_timestamp(tmp_path_factory: TempPathFactory, wheel_path: Path, monkeypatch: MonkeyPatch) -> None:
+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')
+    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 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
 
 
@@ -176,8 +183,8 @@ def test_attributes(tmp_path_factory: TempPathFactory, wheel_path: Path) -> None
         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:

From 34f05ae94082f83f38770448ecb4e8058cf87682 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= 
Date: Mon, 24 Oct 2022 13:31:04 +0300
Subject: [PATCH 26/59] Removed install dependency on setuptools

---
 setup.cfg | 1 -
 1 file changed, 1 deletion(-)

diff --git a/setup.cfg b/setup.cfg
index e90c4789..8811447c 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -32,7 +32,6 @@ package_dir=
     = src
 packages = find:
 python_requires = >=3.7
-install_requires = setuptools >= 57.0.0
 zip_safe = False
 
 [options.packages.find]

From 85ac7c51bffcb1c62f55696647b126fb426af9d2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= 
Date: Mon, 24 Oct 2022 13:32:58 +0300
Subject: [PATCH 27/59] Updated version number

---
 src/wheel/__init__.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/wheel/__init__.py b/src/wheel/__init__.py
index e738531f..6f51fa8f 100644
--- a/src/wheel/__init__.py
+++ b/src/wheel/__init__.py
@@ -1,3 +1,3 @@
 from __future__ import annotations
 
-__version__ = "0.38.0"
+__version__ = "1.0.0a1"

From e3934a90c8d6d3c22bb5ba2845471ec710b7d204 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= 
Date: Mon, 24 Oct 2022 14:44:19 +0300
Subject: [PATCH 28/59] Switched to running coverage using "python -m"

---
 .github/workflows/test.yml | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index aad32604..047bc467 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -45,8 +45,8 @@ jobs:
       run: pip install .[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:

From 946f7f97d7b6c29b4d03c1f305ae98604ef7b18c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= 
Date: Mon, 24 Oct 2022 15:22:30 +0300
Subject: [PATCH 29/59] Fixed no files beside .egg-info being added by
 bdist_wheel

---
 src/wheel/bdist_wheel.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/src/wheel/bdist_wheel.py b/src/wheel/bdist_wheel.py
index 0d675a3e..d1430fa0 100644
--- a/src/wheel/bdist_wheel.py
+++ b/src/wheel/bdist_wheel.py
@@ -375,18 +375,18 @@ def run(self) -> None:
             root_is_purelib=self.root_is_pure,
         ) as wf:
             deferred = []
-            for root, dirnames, filenames in os.walk(str(archive_root)):
+            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 = archive_root / root
+                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 = str(path.relative_to(archive_root))
+                        archive_name = path.relative_to(archive_root).as_posix()
                         if root.endswith(".dist-info"):
                             deferred.append((path, archive_name))
                         else:

From b69c2bc2e930eafaa67515631a8bf2067991b055 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= 
Date: Mon, 24 Oct 2022 15:29:43 +0300
Subject: [PATCH 30/59] Fixed test_write_file

---
 tests/test_wheelfile.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tests/test_wheelfile.py b/tests/test_wheelfile.py
index dd35da6d..d2a54461 100644
--- a/tests/test_wheelfile.py
+++ b/tests/test_wheelfile.py
@@ -144,7 +144,7 @@ def test_write_file(wheel_path: Path) -> None:
             "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=kvhmsZiU3PgVzMvq_TOoVFCHYjYJCCV7GTSzCS6nYtQ,104\n"
+            "sha256=xn45MTtJwj1QxDHLE3DKYDjLqYLb8DHEh5F6k8vFf5o,105\n"
             "test-1.0.dist-info/RECORD,,\n"
         )
 

From a2ca1780a72167baccb275aa1eef6427de3e6c83 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= 
Date: Mon, 24 Oct 2022 15:40:46 +0300
Subject: [PATCH 31/59] Added setuptools as test dependency

---
 .github/workflows/test.yml | 6 ++----
 setup.cfg                  | 1 +
 2 files changed, 3 insertions(+), 4 deletions(-)

diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 047bc467..eb9daeb6 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -39,10 +39,8 @@ jobs:
       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]
+    - name: Install the project and its test dependencies
+      run: pip install --no-binary=setuptools,wheel .[test] coverage[toml]
     - name: Test with pytest
       run: |
         python -m coverage run -m pytest -W always
diff --git a/setup.cfg b/setup.cfg
index 8811447c..27f9274e 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -40,6 +40,7 @@ where = src
 [options.extras_require]
 test =
     pytest >= 3.0.0
+    setuptools >= 57
 
 [options.entry_points]
 console_scripts =

From c7167fe264fe1ca48351ecc47424ceed8741064f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= 
Date: Mon, 24 Oct 2022 15:51:27 +0300
Subject: [PATCH 32/59] Fixed deprecated pytest imports

---
 setup.cfg                    | 2 +-
 tests/cli/test_pack.py       | 2 +-
 tests/conftest.py            | 2 +-
 tests/test_bdist_wheel.py    | 3 +--
 tests/test_macosx_libfile.py | 3 +--
 tests/test_tagopt.py         | 2 +-
 tests/test_wheelfile.py      | 3 +--
 7 files changed, 7 insertions(+), 10 deletions(-)

diff --git a/setup.cfg b/setup.cfg
index 27f9274e..13e025da 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -39,7 +39,7 @@ where = src
 
 [options.extras_require]
 test =
-    pytest >= 3.0.0
+    pytest >= 6.2.0
     setuptools >= 57
 
 [options.entry_points]
diff --git a/tests/cli/test_pack.py b/tests/cli/test_pack.py
index e0f48064..7d6390f0 100644
--- a/tests/cli/test_pack.py
+++ b/tests/cli/test_pack.py
@@ -6,7 +6,7 @@
 from zipfile import ZipFile
 
 import pytest
-from _pytest.tmpdir import TempPathFactory
+from pytest import TempPathFactory
 
 from wheel.cli.pack import pack
 
diff --git a/tests/conftest.py b/tests/conftest.py
index cd87ad2d..af1d1fd6 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -10,7 +10,7 @@
 from pathlib import Path
 
 import pytest
-from _pytest.tmpdir import TempPathFactory
+from pytest import TempPathFactory
 
 
 @pytest.fixture(scope="session")
diff --git a/tests/test_bdist_wheel.py b/tests/test_bdist_wheel.py
index 27bff7dd..54707eb5 100644
--- a/tests/test_bdist_wheel.py
+++ b/tests/test_bdist_wheel.py
@@ -10,8 +10,7 @@
 from zipfile import ZipFile
 
 import pytest
-from _pytest.monkeypatch import MonkeyPatch
-from _pytest.tmpdir import TempPathFactory
+from pytest import MonkeyPatch, TempPathFactory
 
 from wheel.wheelfile import WheelReader
 
diff --git a/tests/test_macosx_libfile.py b/tests/test_macosx_libfile.py
index d4083968..0d27f30d 100644
--- a/tests/test_macosx_libfile.py
+++ b/tests/test_macosx_libfile.py
@@ -6,8 +6,7 @@
 from collections.abc import Callable
 from typing import Any
 
-from _pytest.capture import CaptureFixture
-from _pytest.monkeypatch import MonkeyPatch
+from pytest import CaptureFixture, MonkeyPatch
 
 from wheel.bdist_wheel import get_platform
 from wheel.macosx_libfile import extract_macosx_min_system_version
diff --git a/tests/test_tagopt.py b/tests/test_tagopt.py
index 3d8658fe..2e132ab2 100644
--- a/tests/test_tagopt.py
+++ b/tests/test_tagopt.py
@@ -10,7 +10,7 @@
 from pathlib import Path
 
 import pytest
-from _pytest.fixtures import FixtureRequest
+from pytest import FixtureRequest
 
 SETUP_PY = """\
 from setuptools import setup, Extension
diff --git a/tests/test_wheelfile.py b/tests/test_wheelfile.py
index d2a54461..4374c12a 100644
--- a/tests/test_wheelfile.py
+++ b/tests/test_wheelfile.py
@@ -6,8 +6,7 @@
 from zipfile import ZIP_DEFLATED, ZipFile
 
 import pytest
-from _pytest.monkeypatch import MonkeyPatch
-from _pytest.tmpdir import TempPathFactory
+from pytest import MonkeyPatch, TempPathFactory
 
 from wheel.wheelfile import WheelError, WheelReader, WheelWriter
 

From f4aff153b20b2d4621a145f8bcb1a15aee8f3899 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= 
Date: Mon, 24 Oct 2022 16:41:28 +0300
Subject: [PATCH 33/59] Made most wheel modules private

---
 src/wheel/__init__.py                               | 3 +++
 src/wheel/{macosx_libfile.py => _macosx_libfile.py} | 0
 src/wheel/{metadata.py => _metadata.py}             | 0
 src/wheel/{wheelfile.py => _wheelfile.py}           | 0
 src/wheel/bdist_wheel.py                            | 6 +++---
 src/wheel/cli/convert.py                            | 2 +-
 src/wheel/cli/pack.py                               | 4 ++--
 src/wheel/cli/unpack.py                             | 2 +-
 tests/test_bdist_wheel.py                           | 2 +-
 tests/test_macosx_libfile.py                        | 2 +-
 tests/test_metadata.py                              | 2 +-
 tests/test_wheelfile.py                             | 2 +-
 12 files changed, 14 insertions(+), 11 deletions(-)
 rename src/wheel/{macosx_libfile.py => _macosx_libfile.py} (100%)
 rename src/wheel/{metadata.py => _metadata.py} (100%)
 rename src/wheel/{wheelfile.py => _wheelfile.py} (100%)

diff --git a/src/wheel/__init__.py b/src/wheel/__init__.py
index 6f51fa8f..0415c70f 100644
--- a/src/wheel/__init__.py
+++ b/src/wheel/__init__.py
@@ -1,3 +1,6 @@
 from __future__ import annotations
 
+__all__ = ["WheelError", "WheelReader", "WheelWriter", "make_filename"]
 __version__ = "1.0.0a1"
+
+from ._wheelfile import WheelError, WheelReader, WheelWriter, make_filename
diff --git a/src/wheel/macosx_libfile.py b/src/wheel/_macosx_libfile.py
similarity index 100%
rename from src/wheel/macosx_libfile.py
rename to src/wheel/_macosx_libfile.py
diff --git a/src/wheel/metadata.py b/src/wheel/_metadata.py
similarity index 100%
rename from src/wheel/metadata.py
rename to src/wheel/_metadata.py
diff --git a/src/wheel/wheelfile.py b/src/wheel/_wheelfile.py
similarity index 100%
rename from src/wheel/wheelfile.py
rename to src/wheel/_wheelfile.py
diff --git a/src/wheel/bdist_wheel.py b/src/wheel/bdist_wheel.py
index d1430fa0..71ec6ebe 100644
--- a/src/wheel/bdist_wheel.py
+++ b/src/wheel/bdist_wheel.py
@@ -21,10 +21,10 @@
 import pkg_resources
 from setuptools import Command
 
-from .macosx_libfile import calculate_macosx_platform_tag
-from .metadata import pkginfo_to_metadata
+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 WheelWriter, make_filename
 
 safe_name = pkg_resources.safe_name
 safe_version = pkg_resources.safe_version
diff --git a/src/wheel/cli/convert.py b/src/wheel/cli/convert.py
index eb276d83..1b1bf7f6 100755
--- a/src/wheel/cli/convert.py
+++ b/src/wheel/cli/convert.py
@@ -10,7 +10,7 @@
 from pathlib import Path, PurePath
 from typing import IO, Any
 
-from ..wheelfile import WheelWriter, make_filename
+from .. import WheelWriter, make_filename
 from . import WheelError
 
 egg_info_re = re.compile(
diff --git a/src/wheel/cli/pack.py b/src/wheel/cli/pack.py
index f272e90e..37d65ea3 100644
--- a/src/wheel/cli/pack.py
+++ b/src/wheel/cli/pack.py
@@ -4,8 +4,8 @@
 from os import PathLike
 from pathlib import Path
 
-from wheel.cli import WheelError
-from wheel.wheelfile import WheelWriter, make_filename
+from .. import WheelWriter, make_filename
+from . import WheelError
 
 DIST_INFO_RE = re.compile(r"^(?P(?P[^-]+)-(?P\d.*?))\.dist-info$")
 BUILD_NUM_RE = re.compile(rb"Build: (\d\w*)$")
diff --git a/src/wheel/cli/unpack.py b/src/wheel/cli/unpack.py
index f0dae6a2..530179a5 100644
--- a/src/wheel/cli/unpack.py
+++ b/src/wheel/cli/unpack.py
@@ -3,7 +3,7 @@
 from os import PathLike
 from pathlib import Path
 
-from ..wheelfile import WheelReader
+from .. import WheelReader
 
 
 def unpack(path: str | PathLike, dest: str | PathLike = ".") -> None:
diff --git a/tests/test_bdist_wheel.py b/tests/test_bdist_wheel.py
index 54707eb5..cf0adf1d 100644
--- a/tests/test_bdist_wheel.py
+++ b/tests/test_bdist_wheel.py
@@ -12,7 +12,7 @@
 import pytest
 from pytest import MonkeyPatch, TempPathFactory
 
-from wheel.wheelfile import WheelReader
+from wheel import WheelReader
 
 DEFAULT_FILES = {
     PurePath("dummy-dist-1.0.dist-info/top_level.txt"),
diff --git a/tests/test_macosx_libfile.py b/tests/test_macosx_libfile.py
index 0d27f30d..55724244 100644
--- a/tests/test_macosx_libfile.py
+++ b/tests/test_macosx_libfile.py
@@ -8,8 +8,8 @@
 
 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
 
 
 def test_read_from_dylib() -> None:
diff --git a/tests/test_metadata.py b/tests/test_metadata.py
index f6e2ee4e..c4639535 100644
--- a/tests/test_metadata.py
+++ b/tests/test_metadata.py
@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from wheel.metadata import pkginfo_to_metadata
+from wheel._metadata import pkginfo_to_metadata
 
 
 def test_pkginfo_to_metadata(tmp_path):
diff --git a/tests/test_wheelfile.py b/tests/test_wheelfile.py
index 4374c12a..649b4518 100644
--- a/tests/test_wheelfile.py
+++ b/tests/test_wheelfile.py
@@ -8,7 +8,7 @@
 import pytest
 from pytest import MonkeyPatch, TempPathFactory
 
-from wheel.wheelfile import WheelError, WheelReader, WheelWriter
+from wheel import WheelError, WheelReader, WheelWriter
 
 
 @pytest.fixture

From 29a5dfcf96bd5f90d8b7fda78181a096ab0f6713 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= 
Date: Mon, 24 Oct 2022 16:54:15 +0300
Subject: [PATCH 34/59] Made all remaining modules private

---
 setup.cfg                                     | 4 ++--
 src/wheel/{bdist_wheel.py => _bdist_wheel.py} | 0
 src/wheel/{cli => _cli}/__init__.py           | 0
 src/wheel/{cli => _cli}/convert.py            | 0
 src/wheel/{cli => _cli}/pack.py               | 0
 src/wheel/{cli => _cli}/unpack.py             | 0
 tests/cli/test_convert.py                     | 2 +-
 tests/cli/test_pack.py                        | 2 +-
 tests/cli/test_unpack.py                      | 2 +-
 tests/test_macosx_libfile.py                  | 2 +-
 10 files changed, 6 insertions(+), 6 deletions(-)
 rename src/wheel/{bdist_wheel.py => _bdist_wheel.py} (100%)
 rename src/wheel/{cli => _cli}/__init__.py (100%)
 rename src/wheel/{cli => _cli}/convert.py (100%)
 rename src/wheel/{cli => _cli}/pack.py (100%)
 rename src/wheel/{cli => _cli}/unpack.py (100%)

diff --git a/setup.cfg b/setup.cfg
index 13e025da..99a71a15 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -44,9 +44,9 @@ test =
 
 [options.entry_points]
 console_scripts =
-    wheel = wheel.cli:main
+    wheel = wheel._cli:main
 distutils.commands =
-    bdist_wheel = wheel.bdist_wheel:bdist_wheel
+    bdist_wheel = wheel._bdist_wheel:bdist_wheel
 
 [tool:isort]
 src_paths = src
diff --git a/src/wheel/bdist_wheel.py b/src/wheel/_bdist_wheel.py
similarity index 100%
rename from src/wheel/bdist_wheel.py
rename to src/wheel/_bdist_wheel.py
diff --git a/src/wheel/cli/__init__.py b/src/wheel/_cli/__init__.py
similarity index 100%
rename from src/wheel/cli/__init__.py
rename to src/wheel/_cli/__init__.py
diff --git a/src/wheel/cli/convert.py b/src/wheel/_cli/convert.py
similarity index 100%
rename from src/wheel/cli/convert.py
rename to src/wheel/_cli/convert.py
diff --git a/src/wheel/cli/pack.py b/src/wheel/_cli/pack.py
similarity index 100%
rename from src/wheel/cli/pack.py
rename to src/wheel/_cli/pack.py
diff --git a/src/wheel/cli/unpack.py b/src/wheel/_cli/unpack.py
similarity index 100%
rename from src/wheel/cli/unpack.py
rename to src/wheel/_cli/unpack.py
diff --git a/tests/cli/test_convert.py b/tests/cli/test_convert.py
index a886505f..950ceb02 100644
--- a/tests/cli/test_convert.py
+++ b/tests/cli/test_convert.py
@@ -4,7 +4,7 @@
 import re
 from pathlib import Path
 
-from wheel.cli.convert import convert, egg_info_re
+from wheel._cli.convert import convert, egg_info_re
 
 
 def test_egg_re() -> None:
diff --git a/tests/cli/test_pack.py b/tests/cli/test_pack.py
index 7d6390f0..67db32cd 100644
--- a/tests/cli/test_pack.py
+++ b/tests/cli/test_pack.py
@@ -8,7 +8,7 @@
 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"
diff --git a/tests/cli/test_unpack.py b/tests/cli/test_unpack.py
index e6a38bb9..dcbc4c15 100644
--- a/tests/cli/test_unpack.py
+++ b/tests/cli/test_unpack.py
@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from wheel.cli.unpack import unpack
+from wheel._cli.unpack import unpack
 
 
 def test_unpack(wheel_paths, tmp_path):
diff --git a/tests/test_macosx_libfile.py b/tests/test_macosx_libfile.py
index 55724244..b122928e 100644
--- a/tests/test_macosx_libfile.py
+++ b/tests/test_macosx_libfile.py
@@ -8,8 +8,8 @@
 
 from pytest import CaptureFixture, MonkeyPatch
 
+from wheel._bdist_wheel import get_platform
 from wheel._macosx_libfile import extract_macosx_min_system_version
-from wheel.bdist_wheel import get_platform
 
 
 def test_read_from_dylib() -> None:

From 9f268c3acc3ad645ed9feac6bcb66f60d8bc9ada Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= 
Date: Mon, 24 Oct 2022 16:55:29 +0300
Subject: [PATCH 35/59] Changed the default value for amount in
 WheelArchiveFile.read() to -1

---
 src/wheel/_wheelfile.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/wheel/_wheelfile.py b/src/wheel/_wheelfile.py
index 4051f49b..6bb95933 100644
--- a/src/wheel/_wheelfile.py
+++ b/src/wheel/_wheelfile.py
@@ -97,7 +97,7 @@ def __init__(
             self._hash = hashlib.new(record_entry.hash_algorithm)
             self._num_bytes_read = 0
 
-    def read(self, amount: int | None = None) -> bytes:
+    def read(self, amount: int = -1) -> bytes:
         data = self._fp.read(amount)
         if amount and self._record_entry is not None:
             if data:

From c85a3333f263dcc285766064b337aa8174f72b1c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= 
Date: Mon, 24 Oct 2022 17:51:18 +0300
Subject: [PATCH 36/59] Fixed import in wheel.__main__

---
 src/wheel/__main__.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/wheel/__main__.py b/src/wheel/__main__.py
index 0be74537..2371845d 100644
--- a/src/wheel/__main__.py
+++ b/src/wheel/__main__.py
@@ -14,9 +14,9 @@ def main():  # needed for console script
 
         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__":

From 31d0dc9497524e578b4de5188080cf10513a4c6d Mon Sep 17 00:00:00 2001
From: Kyle Benesch <4b796c65+github@gmail.com>
Date: Tue, 25 Oct 2022 03:44:49 -0700
Subject: [PATCH 37/59] Fixed the most critical type issues and added a MyPy
 check to CI (#473)

* Explicitly mark missing setuptools attributes.

Suppresses Mypy errors.  Mypy will note if these ever become type hinted.

* Fix inconsistent timestamp hint.

This could be `int` and was passed to a non `int` sub-function.

For now I choose to make this more strict.
Alternatively `int` can be added to all timestamp hints.

* Fix BaseClass type hint.

Mypy got confused with the order and thought this was a type error.

All that matters is the variable gets declared with
`Type[ctypes.Structure]` first.  Since the other two are subclasses.

* Fix types for reused names.

More names are used or names are annotated with a union of the types
they'll have.

Fixes a minor bug in calculate_macosx_platform_tag.
`len(problematic_files)` was being called on the string, not the list.

* Use vendored types for vendored function calls.

Otherwise the types won't match, Mypy doesn't see them as the same.

This means a third party can't mix these types with a non-vendored
version, but that wouldn't be type safe anyway.

* Fix type alias to use compatible types.

This style was too new for early versions of Python.
It can and should be fixed without resorting to a TYPE_CHECKING branch.

* Work better with packaging types.

Removed cast as the variable was already the correct type.

Cast strings to their types using the class for those types.
The code looks suspect, I'm not sure if the regular expression is correct.

TYPE_CHECKING is no longer used.

* Add ignore for requirement.url attribute.

* Cast ZipFile IO[bytes] to BinaryIO.

This seems to be common problem with ZipFile that it uses the incorrect
type here.  This cast seems like the best way to fix it.

* Fix WheelContentElement types to match what's used in code.

If this is for the public API then it might be better to use a NamedTuple.

* ZipInfo wants an exact tuple size.

* Assert that dist-info match is correct.

Mypy does not like unchecked matching.

* Fix return statements.

If a function returns values then it can't implicitly return None. PEP 8

Functions returning values must have returns at the ends of all
branches.

* Exclude vendored sources from Mypy type checking.

* Add ignores for ctypes class magic.

I can think of better ways to do this, but not any which wouldn't
add a dependency.  I don't know enough about ctypes to suggest an
alternative method.  The Python struct module might work, but it'd be
some effort to convert to it.

* Add workaround for a bad upstream type hint.
---
 .github/workflows/test.yml   |  9 +++++++
 setup.cfg                    | 10 ++++++++
 src/wheel/_bdist_wheel.py    | 49 +++++++++++++++++++++---------------
 src/wheel/_cli/pack.py       |  4 ++-
 src/wheel/_macosx_libfile.py | 39 ++++++++++++++--------------
 src/wheel/_metadata.py       |  6 +++--
 src/wheel/_wheelfile.py      | 29 ++++++++++++---------
 7 files changed, 91 insertions(+), 55 deletions(-)

diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index eb9daeb6..682dc4a1 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -49,3 +49,12 @@ jobs:
       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/setup.cfg b/setup.cfg
index 99a71a15..47d5a4f6 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -41,6 +41,9 @@ where = src
 test =
     pytest >= 6.2.0
     setuptools >= 57
+types =
+    mypy >= 0.982
+    types-setuptools >= 65.5.0.1
 
 [options.entry_points]
 console_scripts =
@@ -66,3 +69,10 @@ omit = */vendored/*
 
 [coverage:report]
 show_missing = true
+
+[mypy]
+files = src
+python_version = 3.7
+
+[mypy-wheel.vendored.*]
+ignore_errors = True
diff --git a/src/wheel/_bdist_wheel.py b/src/wheel/_bdist_wheel.py
index 71ec6ebe..50e548b5 100644
--- a/src/wheel/_bdist_wheel.py
+++ b/src/wheel/_bdist_wheel.py
@@ -204,7 +204,9 @@ def initialize_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(
+                "bdist"
+            ).bdist_base  # type: ignore[attr-defined]
             self.bdist_dir = os.path.join(bdist_base, "wheel")
 
         self.data_dir = self.wheel_dist_name + ".data"
@@ -218,7 +220,8 @@ def finalize_options(self) -> None:
         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(
@@ -227,7 +230,7 @@ def finalize_options(self) -> None:
             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
             logger.warning(
@@ -243,9 +246,11 @@ def finalize_options(self) -> None:
     @property
     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,)
@@ -307,26 +312,26 @@ def get_tag(self) -> tuple[str, str, str]:
 
     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.
@@ -352,8 +357,8 @@ def run(self) -> None:
 
         impl_tag, abi_tag, plat_tag = self.get_tag()
         archive_basename = make_filename(
-            self.distribution.get_name(),
-            self.distribution.get_version(),
+            self.distribution.get_name(),  # type: ignore[attr-defined]
+            self.distribution.get_version(),  # type: ignore[attr-defined]
             self.build_number,
             impl_tag,
             abi_tag,
@@ -361,7 +366,9 @@ def run(self) -> None:
         )
         archive_root = Path(self.bdist_dir)
         if self.relative:
-            archive_root /= self._ensure_relative(install.install_base)
+            archive_root /= self._ensure_relative(
+                install.install_base  # type: ignore[attr-defined]
+            )
 
         # Make the archive
         if not os.path.exists(self.dist_dir):
@@ -421,7 +428,9 @@ def run(self) -> None:
         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
@@ -431,7 +440,7 @@ def run(self) -> None:
 
         if not self.keep_temp:
             logger.info(f"removing {self.bdist_dir}")
-            if not self.dry_run:
+            if not self.dry_run:  # type: ignore[attr-defined]
                 rmtree(self.bdist_dir, onerror=remove_readonly)
 
     def _ensure_relative(self, path: str) -> str:
@@ -443,6 +452,6 @@ def _ensure_relative(self, path: str) -> str:
 
     @property
     def license_paths(self) -> list[Path]:
-        metadata = self.distribution.metadata
+        metadata = self.distribution.metadata  # type: ignore[attr-defined]
         files = sorted(metadata.license_files or [])
         return [Path(path) for path in files]
diff --git a/src/wheel/_cli/pack.py b/src/wheel/_cli/pack.py
index 37d65ea3..0bc14b6a 100644
--- a/src/wheel/_cli/pack.py
+++ b/src/wheel/_cli/pack.py
@@ -38,7 +38,9 @@ def pack(
 
     # Determine the target wheel filename
     dist_info_dir = dist_info_dirs[0]
-    name, version = DIST_INFO_RE.match(dist_info_dir.name).groups()[1:]
+    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
diff --git a/src/wheel/_macosx_libfile.py b/src/wheel/_macosx_libfile.py
index b62defe6..348d6734 100644
--- a/src/wheel/_macosx_libfile.py
+++ b/src/wheel/_macosx_libfile.py
@@ -259,6 +259,7 @@ def get_base_class_and_magic_number(
         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":
@@ -267,8 +268,6 @@ def get_base_class_and_magic_number(
             BaseClass = ctypes.LittleEndianStructure
 
         magic_number = swap32(magic_number)
-    else:
-        BaseClass = ctypes.Structure
 
     lib_file.seek(seek)
     return BaseClass, magic_number
@@ -282,22 +281,22 @@ def extract_macosx_min_system_version(path_to_lib: str) -> tuple[int, int, int]
     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 = [
@@ -350,17 +349,17 @@ def read_mach_header(
     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)
@@ -370,14 +369,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)
@@ -386,6 +385,8 @@ class VersionBuild(base_class):
             lib_file.seek(pos + segment_base.cmdsize)
             continue
 
+    return None
+
 
 def parse_version(version: int) -> tuple[int, int, int]:
     x = (version & 0xFFFF0000) >> 16
@@ -400,8 +401,8 @@ def calculate_macosx_platform_tag(archive_root: str, platform_tag: str) -> str:
 
     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)
@@ -432,6 +433,7 @@ def calculate_macosx_platform_tag(archive_root: str, platform_tag: str) -> str:
         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]
@@ -446,19 +448,16 @@ def calculate_macosx_platform_tag(archive_root: str, platform_tag: str) -> str:
     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:
diff --git a/src/wheel/_metadata.py b/src/wheel/_metadata.py
index dc4239fe..980c963d 100644
--- a/src/wheel/_metadata.py
+++ b/src/wheel/_metadata.py
@@ -13,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:
@@ -83,7 +83,9 @@ def pkginfo_to_metadata(pkginfo_path: Path) -> list[tuple[str, str]]:
         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}):
+            # Remove assert when https://github.com/python/typeshed/pull/8975 is merged.
+            assert isinstance(reqs, list)
+            for key, value in generate_requirements({extra or "": reqs}):
                 if (key, value) not in pkg_info.items():
                     pkg_info[key] = value
 
diff --git a/src/wheel/_wheelfile.py b/src/wheel/_wheelfile.py
index 6bb95933..1c50c39b 100644
--- a/src/wheel/_wheelfile.py
+++ b/src/wheel/_wheelfile.py
@@ -17,17 +17,19 @@
 from os import PathLike
 from pathlib import Path, PurePath
 from types import TracebackType
-from typing import IO, TYPE_CHECKING, BinaryIO, NamedTuple, cast
+from typing import IO, BinaryIO, NamedTuple, Tuple, cast
 from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile, ZipInfo
 
 from . import __version__ as wheel_version
-from .vendored.packaging.utils import InvalidWheelFilename, parse_wheel_filename
+from .vendored.packaging.tags import Tag
+from .vendored.packaging.utils import (
+    InvalidWheelFilename,
+    NormalizedName,
+    Version,
+    parse_wheel_filename,
+)
 
-if TYPE_CHECKING:
-    from packaging.tags import Tag
-    from packaging.utils import NormalizedName, Version
-
-    WheelContentElement = tuple[tuple[PurePath, str, str], BinaryIO]
+WheelContentElement = Tuple[Tuple[PurePath, bytes, int], BinaryIO]
 
 _DIST_NAME_RE = re.compile(r"[^A-Za-z0-9.]+")
 _EXCLUDE_FILENAMES = ("RECORD", "RECORD.jws", "RECORD.p7s")
@@ -47,7 +49,7 @@ def from_filename(cls, fname: str) -> WheelMetadata:
         except InvalidWheelFilename as exc:
             raise WheelError(f"Bad wheel filename {fname!r}") from exc
 
-        return cls(cast("NormalizedName", name), version, build, tags)
+        return cls(name, version, build, tags)
 
 
 class WheelRecordEntry(NamedTuple):
@@ -151,7 +153,8 @@ def __enter__(self) -> WheelReader:
                     if zinfo.is_dir() and zinfo.filename.endswith(".dist-info"):
                         match = _DIST_NAME_RE.match(zinfo.filename)
                         if match:
-                            self.name, self.version = match.groups()
+                            self.name = NormalizedName(match[1])
+                            self.version = Version(match[2])
                             break
                 else:
                     raise WheelError(
@@ -242,7 +245,9 @@ def read_dist_info(self, filename: str) -> str:
     def get_contents(self) -> Iterator[WheelContentElement]:
         for fname, entry in self._record_entries.items():
             with self._zip.open(fname, "r") as stream:
-                yield (fname, entry.hash_value, entry.filesize), stream
+                yield (PurePath(fname), entry.hash_value, entry.filesize), cast(
+                    BinaryIO, stream
+                )
 
     def test(self) -> None:
         """Verify the integrity of the contained files."""
@@ -426,7 +431,7 @@ def write_file(
     ) -> None:
         arcname = PurePath(name).as_posix()
         gmtime = time.gmtime(timestamp.timestamp())
-        zinfo = ZipInfo(arcname, gmtime)
+        zinfo = ZipInfo(arcname, gmtime[:6])
         zinfo.compress_type = self._compress_type
         zinfo.external_attr = 0o664 << 16
         with ExitStack() as exit_stack:
@@ -491,7 +496,7 @@ def write_distinfo_file(
         self,
         filename: str,
         contents: bytes | str | IO[bytes],
-        timestamp: datetime | int = DEFAULT_TIMESTAMP,
+        timestamp: datetime = DEFAULT_TIMESTAMP,
     ) -> None:
         archive_path = self._dist_info_dir + "/" + filename.strip()
         self.write_file(archive_path, contents, timestamp)

From 81471ec0daeae2aa8c901d6ef38207d285108c10 Mon Sep 17 00:00:00 2001
From: "pre-commit-ci[bot]"
 <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date: Tue, 25 Oct 2022 10:45:10 +0000
Subject: [PATCH 38/59] [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci
---
 src/wheel/_bdist_wheel.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/wheel/_bdist_wheel.py b/src/wheel/_bdist_wheel.py
index 50e548b5..1ad43fb1 100644
--- a/src/wheel/_bdist_wheel.py
+++ b/src/wheel/_bdist_wheel.py
@@ -250,7 +250,7 @@ def wheel_dist_name(self) -> 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,)

From ef1425da460bf15ea13b5125a2d805b4d7436fe0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= 
Date: Tue, 25 Oct 2022 18:19:50 +0300
Subject: [PATCH 39/59] Removed unused module

---
 src/wheel/_setuptools_logging.py | 26 --------------------------
 1 file changed, 26 deletions(-)
 delete mode 100644 src/wheel/_setuptools_logging.py

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
-    )

From 71efa9ba9147727668ca6ab0ae2c7fc0622bfc5c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= 
Date: Tue, 25 Oct 2022 18:22:42 +0300
Subject: [PATCH 40/59] Updated the docs for "wheel convert"

---
 docs/reference/wheel_convert.rst | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

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
 -------

From c2171c259a5f274ae2a4f184a8e47042294d55b9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= 
Date: Tue, 25 Oct 2022 18:25:45 +0300
Subject: [PATCH 41/59] Updated CLI help, type annotations and string
 interpolation

---
 src/wheel/_cli/__init__.py | 10 ++++++----
 1 file changed, 6 insertions(+), 4 deletions(-)

diff --git a/src/wheel/_cli/__init__.py b/src/wheel/_cli/__init__.py
index c0fb8c44..d8dfda24 100644
--- a/src/wheel/_cli/__init__.py
+++ b/src/wheel/_cli/__init__.py
@@ -34,7 +34,7 @@ def convert_f(args):
 def version_f(args):
     from .. import __version__
 
-    print("wheel %s" % __version__)
+    print(f"wheel {__version__}")
 
 
 def parser():
@@ -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"):

From 122f4568fb9831a6d8466c01cb795ae657d90a44 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= 
Date: Tue, 25 Oct 2022 19:37:35 +0300
Subject: [PATCH 42/59] Made WheelContentElement into a named tuple

---
 src/wheel/_wheelfile.py | 15 ++++++++++-----
 1 file changed, 10 insertions(+), 5 deletions(-)

diff --git a/src/wheel/_wheelfile.py b/src/wheel/_wheelfile.py
index 1c50c39b..c34d2e64 100644
--- a/src/wheel/_wheelfile.py
+++ b/src/wheel/_wheelfile.py
@@ -17,7 +17,7 @@
 from os import PathLike
 from pathlib import Path, PurePath
 from types import TracebackType
-from typing import IO, BinaryIO, NamedTuple, Tuple, cast
+from typing import IO, NamedTuple
 from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile, ZipInfo
 
 from . import __version__ as wheel_version
@@ -29,8 +29,6 @@
     parse_wheel_filename,
 )
 
-WheelContentElement = Tuple[Tuple[PurePath, bytes, int], BinaryIO]
-
 _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)
@@ -58,6 +56,13 @@ class WheelRecordEntry(NamedTuple):
     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")
 
@@ -245,8 +250,8 @@ def read_dist_info(self, filename: str) -> str:
     def get_contents(self) -> Iterator[WheelContentElement]:
         for fname, entry in self._record_entries.items():
             with self._zip.open(fname, "r") as stream:
-                yield (PurePath(fname), entry.hash_value, entry.filesize), cast(
-                    BinaryIO, stream
+                yield WheelContentElement(
+                    PurePath(fname), entry.hash_value, entry.filesize, stream
                 )
 
     def test(self) -> None:

From d14b5fc651aaacb036528feaeedd039383771dbc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= 
Date: Tue, 25 Oct 2022 20:00:33 +0300
Subject: [PATCH 43/59] Removed obsolete options

Wheel is a zip based format, so tar related options don't make sense, and they weren't even used in the code.
---
 src/wheel/_bdist_wheel.py | 13 -------------
 1 file changed, 13 deletions(-)

diff --git a/src/wheel/_bdist_wheel.py b/src/wheel/_bdist_wheel.py
index 1ad43fb1..e0ad96f0 100644
--- a/src/wheel/_bdist_wheel.py
+++ b/src/wheel/_bdist_wheel.py
@@ -142,16 +142,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=",
@@ -186,15 +176,12 @@ def initialize_options(self):
         self.data_dir = None
         self.plat_name = None
         self.plat_tag = None
-        self.format = "zip"
         self.keep_temp = False
         self.dist_dir = None
         self.egginfo_dir = None
         self.root_is_pure = None
         self.skip_build = None
         self.relative = False
-        self.owner = None
-        self.group = None
         self.universal = False
         self.compression = "deflated"
         self.python_tag = python_tag()

From 9f2bfc62c76a5e646b533b8cc3ae7e4a67f68445 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= 
Date: Tue, 25 Oct 2022 20:01:17 +0300
Subject: [PATCH 44/59] Fixed mypy error

---
 src/wheel/_bdist_wheel.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/wheel/_bdist_wheel.py b/src/wheel/_bdist_wheel.py
index e0ad96f0..adc7736d 100644
--- a/src/wheel/_bdist_wheel.py
+++ b/src/wheel/_bdist_wheel.py
@@ -191,9 +191,9 @@ def initialize_options(self):
 
     def finalize_options(self) -> None:
         if self.bdist_dir is None:
-            bdist_base = self.get_finalized_command(
+            bdist_base = self.get_finalized_command(  # type: ignore[attr-defined]
                 "bdist"
-            ).bdist_base  # type: ignore[attr-defined]
+            ).bdist_base
             self.bdist_dir = os.path.join(bdist_base, "wheel")
 
         self.data_dir = self.wheel_dist_name + ".data"

From edcb32592a057012f7116f8ebdba530c44ced7d8 Mon Sep 17 00:00:00 2001
From: Kyle Benesch <4b796c65+github@gmail.com>
Date: Wed, 26 Oct 2022 12:23:10 -0700
Subject: [PATCH 45/59] Added tests to type checking and finish annotations.
 (#477)

---
 setup.cfg                    |  6 ++++--
 src/wheel/__main__.py        |  5 +++--
 src/wheel/_bdist_wheel.py    | 28 +++++++++++++++++-----------
 src/wheel/_cli/__init__.py   | 14 +++++++-------
 src/wheel/_cli/convert.py    |  2 +-
 src/wheel/_cli/pack.py       |  4 +++-
 src/wheel/_cli/unpack.py     |  2 +-
 src/wheel/_macosx_libfile.py |  4 +++-
 src/wheel/_metadata.py       |  2 --
 src/wheel/_wheelfile.py      | 31 ++++++++++++++++++-------------
 tests/cli/test_pack.py       |  4 ++--
 tests/cli/test_unpack.py     |  6 ++++--
 tests/conftest.py            |  6 +++---
 tests/test_macosx_libfile.py | 21 ++++++++++++---------
 tests/test_metadata.py       |  4 +++-
 15 files changed, 81 insertions(+), 58 deletions(-)

diff --git a/setup.cfg b/setup.cfg
index 47d5a4f6..966d5391 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -43,7 +43,8 @@ test =
     setuptools >= 57
 types =
     mypy >= 0.982
-    types-setuptools >= 65.5.0.1
+    pytest >= 6.2
+    types-setuptools >= 65.5.0.2
 
 [options.entry_points]
 console_scripts =
@@ -71,8 +72,9 @@ omit = */vendored/*
 show_missing = true
 
 [mypy]
-files = src
+files = src,tests
 python_version = 3.7
+exclude = testdata/
 
 [mypy-wheel.vendored.*]
 ignore_errors = True
diff --git a/src/wheel/__main__.py b/src/wheel/__main__.py
index 2371845d..a4b5e0d9 100644
--- a/src/wheel/__main__.py
+++ b/src/wheel/__main__.py
@@ -5,9 +5,10 @@
 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
@@ -20,4 +21,4 @@ def main():  # needed for console script
 
 
 if __name__ == "__main__":
-    sys.exit(main())
+    main()
diff --git a/src/wheel/_bdist_wheel.py b/src/wheel/_bdist_wheel.py
index adc7736d..b2e9ea3d 100644
--- a/src/wheel/_bdist_wheel.py
+++ b/src/wheel/_bdist_wheel.py
@@ -17,6 +17,8 @@
 from pathlib import Path
 from shutil import rmtree
 from sysconfig import get_config_var
+from types import TracebackType
+from typing import Any, Callable
 
 import pkg_resources
 from setuptools import Command
@@ -109,7 +111,11 @@ def safer_version(version: str) -> str:
     return safe_version(version).replace("-", "_")
 
 
-def remove_readonly(func, path, excinfo) -> None:
+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)
@@ -171,22 +177,22 @@ 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
+    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.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) -> None:
diff --git a/src/wheel/_cli/__init__.py b/src/wheel/_cli/__init__.py
index d8dfda24..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(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")
diff --git a/src/wheel/_cli/convert.py b/src/wheel/_cli/convert.py
index 1b1bf7f6..bdcf6a86 100755
--- a/src/wheel/_cli/convert.py
+++ b/src/wheel/_cli/convert.py
@@ -102,7 +102,7 @@ def egg_dir_source() -> Generator[tuple[PurePath, IO[bytes]], Any, None]:
 
 
 def convert(
-    files: Iterable[str | PathLike], dest_dir: str | PathLike, verbose: bool
+    files: Iterable[str | PathLike[str]], dest_dir: str | PathLike[str], verbose: bool
 ) -> None:
     dest_path = Path(dest_dir)
     paths: list[Path] = []
diff --git a/src/wheel/_cli/pack.py b/src/wheel/_cli/pack.py
index 0bc14b6a..eb120569 100644
--- a/src/wheel/_cli/pack.py
+++ b/src/wheel/_cli/pack.py
@@ -12,7 +12,9 @@
 
 
 def pack(
-    directory: str | PathLike, dest_dir: str | PathLike, build_number: str | None = None
+    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.
 
diff --git a/src/wheel/_cli/unpack.py b/src/wheel/_cli/unpack.py
index 530179a5..9b14b49d 100644
--- a/src/wheel/_cli/unpack.py
+++ b/src/wheel/_cli/unpack.py
@@ -6,7 +6,7 @@
 from .. import WheelReader
 
 
-def unpack(path: str | PathLike, dest: str | PathLike = ".") -> 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}
diff --git a/src/wheel/_macosx_libfile.py b/src/wheel/_macosx_libfile.py
index 348d6734..2b9e6369 100644
--- a/src/wheel/_macosx_libfile.py
+++ b/src/wheel/_macosx_libfile.py
@@ -273,7 +273,9 @@ def get_base_class_and_magic_number(
     return BaseClass, magic_number
 
 
-def read_data(struct_class: type[ctypes.Structure], lib_file: BinaryIO):
+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)))
 
 
diff --git a/src/wheel/_metadata.py b/src/wheel/_metadata.py
index 980c963d..a6a6bb51 100644
--- a/src/wheel/_metadata.py
+++ b/src/wheel/_metadata.py
@@ -83,8 +83,6 @@ def pkginfo_to_metadata(pkginfo_path: Path) -> list[tuple[str, str]]:
         requires = requires_path.read_text()
         parsed_requirements = sorted(split_sections(requires), key=lambda x: x[0] or "")
         for extra, reqs in parsed_requirements:
-            # Remove assert when https://github.com/python/typeshed/pull/8975 is merged.
-            assert isinstance(reqs, list)
             for key, value in generate_requirements({extra or "": reqs}):
                 if (key, value) not in pkg_info.items():
                     pkg_info[key] = value
diff --git a/src/wheel/_wheelfile.py b/src/wheel/_wheelfile.py
index c34d2e64..63ef550e 100644
--- a/src/wheel/_wheelfile.py
+++ b/src/wheel/_wheelfile.py
@@ -25,9 +25,9 @@
 from .vendored.packaging.utils import (
     InvalidWheelFilename,
     NormalizedName,
-    Version,
     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")
@@ -124,10 +124,15 @@ def read(self, amount: int = -1) -> bytes:
 
         return data
 
-    def __enter__(self):
+    def __enter__(self) -> WheelArchiveFile:
         return self
 
-    def __exit__(self, exc_type, exc_val, exc_tb):
+    def __exit__(
+        self,
+        exc_type: type[BaseException],
+        exc_val: BaseException,
+        exc_tb: TracebackType,
+    ) -> None:
         self._fp.close()
 
     def __repr__(self) -> str:
@@ -140,7 +145,7 @@ class WheelReader:
     _zip: ZipFile
     _record_entries: OrderedDict[str, WheelRecordEntry]
 
-    def __init__(self, path_or_fd: str | PathLike | IO[bytes]):
+    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)):
@@ -219,11 +224,11 @@ def _read_record(self) -> OrderedDict[str, WheelRecordEntry]:
         return entries
 
     @property
-    def dist_info_dir(self):
+    def dist_info_dir(self) -> str:
         return self._dist_info_dir
 
     @property
-    def data_dir(self):
+    def data_dir(self) -> str:
         return self._data_dir
 
     @property
@@ -274,7 +279,7 @@ def test(self) -> None:
             if hash_.digest() != record.hash_value:
                 raise WheelError(f"Hash mismatch for file {zinfo.filename!r}")
 
-    def extractall(self, base_path: str | PathLike) -> None:
+    def extractall(self, base_path: str | PathLike[str]) -> None:
         basedir = Path(base_path)
         if not basedir.exists():
             raise WheelError(f"{basedir} does not exist")
@@ -315,14 +320,14 @@ 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):
+    def __repr__(self) -> str:
         return f"{self.__class__.__name__}({self.path_or_fd})"
 
 
 class WheelWriter:
     def __init__(
         self,
-        path_or_fd: str | PathLike | IO[bytes],
+        path_or_fd: str | PathLike[str] | IO[bytes],
         metadata: WheelMetadata | None = None,
         *,
         generator: str | None = None,
@@ -431,7 +436,7 @@ def write_metadata(self, items: Iterable[tuple[str, str]]) -> None:
     def write_file(
         self,
         name: str | PurePath,
-        contents: bytes | str | PathLike | IO[bytes],
+        contents: bytes | str | PathLike[str] | IO[bytes],
         timestamp: datetime = DEFAULT_TIMESTAMP,
     ) -> None:
         arcname = PurePath(name).as_posix()
@@ -474,7 +479,7 @@ def write_file(
             self.hash_algorithm, hash_.digest(), file_size
         )
 
-    def write_files_from_directory(self, directory: str | PathLike) -> None:
+    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")
@@ -491,7 +496,7 @@ def write_files_from_directory(self, directory: str | PathLike) -> None:
     def write_data_file(
         self,
         filename: str,
-        contents: bytes | str | PathLike | IO[bytes],
+        contents: bytes | str | PathLike[str] | IO[bytes],
         timestamp: datetime = DEFAULT_TIMESTAMP,
     ) -> None:
         archive_path = self._data_dir + "/" + filename.strip("/")
@@ -506,5 +511,5 @@ def write_distinfo_file(
         archive_path = self._dist_info_dir + "/" + filename.strip()
         self.write_file(archive_path, contents, timestamp)
 
-    def __repr__(self):
+    def __repr__(self) -> str:
         return f"{self.__class__.__name__}({self.path_or_fd!r})"
diff --git a/tests/cli/test_pack.py b/tests/cli/test_pack.py
index 67db32cd..74fd9374 100644
--- a/tests/cli/test_pack.py
+++ b/tests/cli/test_pack.py
@@ -81,5 +81,5 @@ def test_pack(
     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 dcbc4c15..ce28ff03 100644
--- a/tests/cli/test_unpack.py
+++ b/tests/cli/test_unpack.py
@@ -1,12 +1,14 @@
 from __future__ import annotations
 
+from pathlib import Path
+
 from wheel._cli.unpack import unpack
 
 
-def test_unpack(wheel_paths, tmp_path):
+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(tmp_path))
+        unpack(wheel_path, tmp_path)
diff --git a/tests/conftest.py b/tests/conftest.py
index af1d1fd6..546706a7 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -16,17 +16,17 @@
 @pytest.fixture(scope="session")
 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")
 
     this_dir = Path(__file__).parent
     build_dir = tmp_path_factory.mktemp("build")
diff --git a/tests/test_macosx_libfile.py b/tests/test_macosx_libfile.py
index b122928e..a49e7201 100644
--- a/tests/test_macosx_libfile.py
+++ b/tests/test_macosx_libfile.py
@@ -4,13 +4,15 @@
 import sys
 import sysconfig
 from collections.abc import Callable
-from typing import Any
+from typing import Any, TypeVar
 
 from pytest import CaptureFixture, MonkeyPatch
 
 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() -> None:
     dirname = os.path.dirname(__file__)
@@ -34,6 +36,7 @@ def test_read_from_dylib() -> None:
         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 (
@@ -44,8 +47,8 @@ def test_read_from_dylib() -> None:
     )
 
 
-def return_factory(return_val) -> Callable:
-    def fun(*args: Any, **kwargs: Any):
+def return_factory(return_val: T) -> Callable[..., T]:
+    def fun(*args: Any, **kwargs: Any) -> T:
         return return_val
 
     return fun
@@ -61,7 +64,7 @@ def test_simple(self, monkeypatch: MonkeyPatch) -> None:
         assert get_platform(dylib_dir) == "macosx_11_0_x86_64"
 
     def test_version_bump(
-        self, monkeypatch: MonkeyPatch, capsys: CaptureFixture
+        self, monkeypatch: MonkeyPatch, capsys: CaptureFixture[str]
     ) -> None:
         dirname = os.path.dirname(__file__)
         dylib_dir = os.path.join(dirname, "testdata", "macosx_minimal_system_version")
@@ -73,7 +76,7 @@ def test_version_bump(
         assert "[WARNING] This wheel needs a higher macOS version than" in captured.err
 
     def test_information_about_problematic_files_python_version(
-        self, monkeypatch: MonkeyPatch, capsys: CaptureFixture
+        self, monkeypatch: MonkeyPatch, capsys: CaptureFixture[str]
     ) -> None:
         dirname = os.path.dirname(__file__)
         dylib_dir = os.path.join(dirname, "testdata", "macosx_minimal_system_version")
@@ -96,7 +99,7 @@ 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: MonkeyPatch, capsys: CaptureFixture
+        self, monkeypatch: MonkeyPatch, capsys: CaptureFixture[str]
     ) -> None:
         dirname = os.path.dirname(__file__)
         dylib_dir = os.path.join(dirname, "testdata", "macosx_minimal_system_version")
@@ -118,7 +121,7 @@ def test_information_about_problematic_files_env_variable(
         assert "test_lib_10_10_fat.dylib" in captured.err
 
     def test_bump_platform_tag_by_env_variable(
-        self, monkeypatch: MonkeyPatch, capsys: CaptureFixture
+        self, monkeypatch: MonkeyPatch, capsys: CaptureFixture[str]
     ) -> None:
         dirname = os.path.dirname(__file__)
         dylib_dir = os.path.join(dirname, "testdata", "macosx_minimal_system_version")
@@ -139,7 +142,7 @@ def test_bump_platform_tag_by_env_variable(
         assert captured.err == ""
 
     def test_bugfix_release_platform_tag(
-        self, monkeypatch: MonkeyPatch, capsys: CaptureFixture
+        self, monkeypatch: MonkeyPatch, capsys: CaptureFixture[str]
     ) -> None:
         dirname = os.path.dirname(__file__)
         dylib_dir = os.path.join(dirname, "testdata", "macosx_minimal_system_version")
@@ -172,7 +175,7 @@ def test_bugfix_release_platform_tag(
         assert "This wheel needs a higher macOS version than" in captured.err
 
     def test_warning_on_to_low_env_variable(
-        self, monkeypatch: MonkeyPatch, capsys: CaptureFixture
+        self, monkeypatch: MonkeyPatch, capsys: CaptureFixture[str]
     ) -> None:
         dirname = os.path.dirname(__file__)
         dylib_dir = os.path.join(dirname, "testdata", "macosx_minimal_system_version")
diff --git a/tests/test_metadata.py b/tests/test_metadata.py
index c4639535..249c741d 100644
--- a/tests/test_metadata.py
+++ b/tests/test_metadata.py
@@ -1,9 +1,11 @@
 from __future__ import annotations
 
+from pathlib import Path
+
 from wheel._metadata import pkginfo_to_metadata
 
 
-def test_pkginfo_to_metadata(tmp_path):
+def test_pkginfo_to_metadata(tmp_path: Path) -> None:
     expected_metadata = [
         ("Metadata-Version", "2.1"),
         ("Name", "spam"),

From 914cc81f3cdf25004768d789c11eb06f3bf851e3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= 
Date: Tue, 25 Oct 2022 23:42:46 +0300
Subject: [PATCH 46/59] Merged the license_paths property back to run()

---
 src/wheel/_bdist_wheel.py | 17 ++++++-----------
 1 file changed, 6 insertions(+), 11 deletions(-)

diff --git a/src/wheel/_bdist_wheel.py b/src/wheel/_bdist_wheel.py
index b2e9ea3d..a95ae5fc 100644
--- a/src/wheel/_bdist_wheel.py
+++ b/src/wheel/_bdist_wheel.py
@@ -398,11 +398,12 @@ def run(self) -> None:
                 wf.write_file(archive_name, path.read_bytes())
 
             # Write the license files
-            for license_path in self.license_paths:
-                logger.info("adding '%s'", license_path)
-                wf.write_distinfo_file(
-                    os.path.basename(license_path), license_path.read_bytes()
-                )
+            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"))
@@ -442,9 +443,3 @@ def _ensure_relative(self, path: str) -> str:
         if path[0:1] == os.sep:
             path = drive + path[1:]
         return path
-
-    @property
-    def license_paths(self) -> list[Path]:
-        metadata = self.distribution.metadata  # type: ignore[attr-defined]
-        files = sorted(metadata.license_files or [])
-        return [Path(path) for path in files]

From ea9086a06c6653af0863d96b1d58e07824fb6da3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= 
Date: Thu, 27 Oct 2022 17:47:23 +0300
Subject: [PATCH 47/59] Removed obsolete test

---
 tests/test_bdist_wheel.py | 15 ---------------
 1 file changed, 15 deletions(-)

diff --git a/tests/test_bdist_wheel.py b/tests/test_bdist_wheel.py
index cf0adf1d..8927c91d 100644
--- a/tests/test_bdist_wheel.py
+++ b/tests/test_bdist_wheel.py
@@ -92,21 +92,6 @@ def test_licenses_default(
         assert set(wf.filenames) == DEFAULT_FILES | license_files
 
 
-def test_licenses_deprecated(
-    dummy_dist: Path, monkeypatch: MonkeyPatch, tmp_path: Path
-) -> None:
-    dummy_dist.joinpath("setup.cfg").write_text(
-        "[metadata]\nlicense_file=licenses/DUMMYFILE"
-    )
-    monkeypatch.chdir(dummy_dist)
-    subprocess.check_call(
-        [sys.executable, "setup.py", "bdist_wheel", "-b", str(tmp_path), "--universal"]
-    )
-    with WheelReader("dist/dummy_dist-1.0-py2.py3-none-any.whl") as wf:
-        license_files = {PurePath("dummy-dist-1.0.dist-info/DUMMYFILE")}
-        assert set(wf.filenames) == DEFAULT_FILES | license_files
-
-
 def test_licenses_disabled(
     dummy_dist: Path, monkeypatch: MonkeyPatch, tmp_path: Path
 ) -> None:

From 13ae2b61e4a7c898f34e1428502dffc604ad9026 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= 
Date: Thu, 27 Oct 2022 17:56:59 +0300
Subject: [PATCH 48/59] Re-added test accidentally removed during merge

---
 tests/test_bdist_wheel.py | 33 +++++++++++++++++++++++++++++++++
 1 file changed, 33 insertions(+)

diff --git a/tests/test_bdist_wheel.py b/tests/test_bdist_wheel.py
index 8927c91d..8b9b37f1 100644
--- a/tests/test_bdist_wheel.py
+++ b/tests/test_bdist_wheel.py
@@ -92,6 +92,39 @@ def test_licenses_default(
         assert set(wf.filenames) == DEFAULT_FILES | license_files
 
 
+@pytest.mark.parametrize(
+    "config_file, config",
+    [
+        ("setup.cfg", "[metadata]\nlicense_files=licenses/*\n  LICENSE"),
+        ("setup.cfg", "[metadata]\nlicense_files=licenses/*, LICENSE"),
+        (
+            "setup.py",
+            SETUPPY_EXAMPLE.replace(
+                ")", "  license_files=['licenses/DUMMYFILE', 'LICENSE'])"
+            ),
+        ),
+    ],
+)
+def test_licenses_override(
+    dummy_dist: 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(tmp_path), "--universal"]
+    )
+    with WheelReader("dist/dummy_dist-1.0-py2.py3-none-any.whl") as wf:
+        license_files = {
+            PurePath("dummy-dist-1.0.dist-info") / fname
+            for fname in {"DUMMYFILE", "LICENSE"}
+        }
+        assert set(wf.filenames) == DEFAULT_FILES | license_files
+
+
 def test_licenses_disabled(
     dummy_dist: Path, monkeypatch: MonkeyPatch, tmp_path: Path
 ) -> None:

From b43297770d22b2c4fbb062b0fb0ecdb7eb816f43 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= 
Date: Mon, 7 Nov 2022 23:07:29 +0200
Subject: [PATCH 49/59] Moved the bdist_wheel module back to its original
 location

---
 setup.cfg                                     | 2 +-
 src/wheel/{_bdist_wheel.py => bdist_wheel.py} | 0
 tests/test_bdist_wheel.py                     | 2 +-
 tests/test_macosx_libfile.py                  | 2 +-
 4 files changed, 3 insertions(+), 3 deletions(-)
 rename src/wheel/{_bdist_wheel.py => bdist_wheel.py} (100%)

diff --git a/setup.cfg b/setup.cfg
index 966d5391..fc822014 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -50,7 +50,7 @@ types =
 console_scripts =
     wheel = wheel._cli:main
 distutils.commands =
-    bdist_wheel = wheel._bdist_wheel:bdist_wheel
+    bdist_wheel = wheel.bdist_wheel:bdist_wheel
 
 [tool:isort]
 src_paths = src
diff --git a/src/wheel/_bdist_wheel.py b/src/wheel/bdist_wheel.py
similarity index 100%
rename from src/wheel/_bdist_wheel.py
rename to src/wheel/bdist_wheel.py
diff --git a/tests/test_bdist_wheel.py b/tests/test_bdist_wheel.py
index 1146e5f4..b8da7fb8 100644
--- a/tests/test_bdist_wheel.py
+++ b/tests/test_bdist_wheel.py
@@ -14,7 +14,7 @@
 from pytest import MonkeyPatch, TempPathFactory
 
 from wheel import WheelReader
-from wheel._bdist_wheel import get_abi_tag
+from wheel.bdist_wheel import get_abi_tag
 from wheel.vendored.packaging import tags
 
 DEFAULT_FILES = {
diff --git a/tests/test_macosx_libfile.py b/tests/test_macosx_libfile.py
index a49e7201..6086618f 100644
--- a/tests/test_macosx_libfile.py
+++ b/tests/test_macosx_libfile.py
@@ -8,8 +8,8 @@
 
 from pytest import CaptureFixture, MonkeyPatch
 
-from wheel._bdist_wheel import get_platform
 from wheel._macosx_libfile import extract_macosx_min_system_version
+from wheel.bdist_wheel import get_platform
 
 T = TypeVar("T")
 

From 14ca7debb18c2a6943c6fd77aed36fc0f139c438 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= 
Date: Sun, 27 Nov 2022 00:28:25 +0200
Subject: [PATCH 50/59] Renamed test() to validate_record()

Co-authored-by: Pradyun Gedam 
---
 src/wheel/_wheelfile.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/wheel/_wheelfile.py b/src/wheel/_wheelfile.py
index 63ef550e..61f4b771 100644
--- a/src/wheel/_wheelfile.py
+++ b/src/wheel/_wheelfile.py
@@ -259,7 +259,7 @@ def get_contents(self) -> Iterator[WheelContentElement]:
                     PurePath(fname), entry.hash_value, entry.filesize, stream
                 )
 
-    def test(self) -> None:
+    def validate_record(self) -> None:
         """Verify the integrity of the contained files."""
         for zinfo in self._zip.infolist():
             # Ignore signature files

From d5c7dc371a29c1cff57386ef4f771f8ecc450b57 Mon Sep 17 00:00:00 2001
From: "pre-commit-ci[bot]"
 <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date: Sat, 26 Nov 2022 22:31:55 +0000
Subject: [PATCH 51/59] [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci
---
 tests/test_bdist_wheel.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/tests/test_bdist_wheel.py b/tests/test_bdist_wheel.py
index 002e66d0..a394e11f 100644
--- a/tests/test_bdist_wheel.py
+++ b/tests/test_bdist_wheel.py
@@ -79,6 +79,7 @@ def test_unicode_record(wheel_paths: list[Path]) -> None:
 
     assert "åäö_日本語.py" in record
 
+
 UTF8_PKG_INFO = """\
 Metadata-Version: 2.1
 Name: helloworld

From eb12145398c29f729fae5d9ced49167dbc8cce25 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= 
Date: Sun, 27 Nov 2022 01:03:41 +0200
Subject: [PATCH 52/59] Added a fix for #489

---
 src/wheel/_wheelfile.py   |  6 ++++--
 tests/test_bdist_wheel.py | 41 +++++----------------------------------
 tests/test_wheelfile.py   | 10 +++++-----
 3 files changed, 14 insertions(+), 43 deletions(-)

diff --git a/src/wheel/_wheelfile.py b/src/wheel/_wheelfile.py
index 61f4b771..e1c263fb 100644
--- a/src/wheel/_wheelfile.py
+++ b/src/wheel/_wheelfile.py
@@ -13,6 +13,7 @@
 from datetime import datetime, timezone
 from email.generator import Generator
 from email.message import Message
+from email.policy import EmailPolicy
 from io import StringIO, UnsupportedOperation
 from os import PathLike
 from pathlib import Path, PurePath
@@ -32,6 +33,7 @@
 _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):
@@ -410,7 +412,7 @@ def _write_wheelfile(self) -> None:
             msg["Tag"] = f"{tag.interpreter}-{tag.abi}-{tag.platform}"
 
         buffer = StringIO()
-        Generator(buffer, maxheaderlen=0).flatten(msg)
+        Generator(buffer, maxheaderlen=0, policy=EMAIL_POLICY).flatten(msg)
         self.write_distinfo_file("WHEEL", buffer.getvalue())
 
     def write_metadata(self, items: Iterable[tuple[str, str]]) -> None:
@@ -430,7 +432,7 @@ def write_metadata(self, items: Iterable[tuple[str, str]]) -> None:
             msg["Version"] = str(self.metadata.version)
 
         buffer = StringIO()
-        Generator(buffer, maxheaderlen=0).flatten(msg)
+        Generator(buffer, maxheaderlen=0, policy=EMAIL_POLICY).flatten(msg)
         self.write_distinfo_file("METADATA", buffer.getvalue())
 
     def write_file(
diff --git a/tests/test_bdist_wheel.py b/tests/test_bdist_wheel.py
index a394e11f..371c3c11 100644
--- a/tests/test_bdist_wheel.py
+++ b/tests/test_bdist_wheel.py
@@ -80,43 +80,12 @@ def test_unicode_record(wheel_paths: list[Path]) -> None:
     assert "åäö_日本語.py" in record
 
 
-UTF8_PKG_INFO = """\
-Metadata-Version: 2.1
-Name: helloworld
-Version: 42
-Author-email: "John X. Ãørçeč" , Γαμα קּ 東 
-
-
-UTF-8 描述 説明
-"""
-
-
-def test_preserve_unicode_metadata(monkeypatch, tmp_path):
-    monkeypatch.chdir(tmp_path)
-    egginfo = tmp_path / "dummy_dist.egg-info"
-    distinfo = tmp_path / "dummy_dist.dist-info"
-
-    egginfo.mkdir()
-    (egginfo / "PKG-INFO").write_text(UTF8_PKG_INFO, encoding="utf-8")
-    (egginfo / "dependency_links.txt").touch()
-
-    class simpler_bdist_wheel(bdist_wheel):
-        """Avoid messing with setuptools/distutils internals"""
-
-        def __init__(self):
-            pass
-
-        @property
-        def license_paths(self):
-            return []
-
-    cmd_obj = simpler_bdist_wheel()
-    cmd_obj.egg2dist(egginfo, distinfo)
+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")
 
-    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
+    assert "Summary: A testing distribution ☃" in metadata
 
 
 def test_licenses_default(
diff --git a/tests/test_wheelfile.py b/tests/test_wheelfile.py
index 3e6437b6..f05487e2 100644
--- a/tests/test_wheelfile.py
+++ b/tests/test_wheelfile.py
@@ -95,14 +95,14 @@ def test_weak_hash_algorithm(wheel_path: Path, algorithm: str, digest: str) -> N
     ],
     ids=["sha256", "sha384", "sha512"],
 )
-def test_test(wheel_path: Path, algorithm: str, digest: str) -> None:
+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 WheelReader(wheel_path) as wf:
-        wf.test()
+        wf.validate_record()
 
 
 def test_testzip_missing_hash(wheel_path: Path) -> None:
@@ -111,11 +111,11 @@ def test_testzip_missing_hash(wheel_path: Path) -> None:
         zf.writestr("test-1.0.dist-info/RECORD", "")
 
     with WheelReader(wheel_path) as wf:
-        exc = pytest.raises(WheelError, wf.test)
+        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: Path) -> None:
+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(
@@ -124,7 +124,7 @@ def test_testzip_bad_hash(wheel_path: Path) -> None:
         )
 
     with WheelReader(wheel_path) as wf:
-        exc = pytest.raises(WheelError, wf.test)
+        exc = pytest.raises(WheelError, wf.validate_record)
         exc.match("^Hash mismatch for file 'hello/héllö.py'$")
 
 

From 91d0e2ea5debea458eaae3e85719316a2a4941c2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= 
Date: Sun, 27 Nov 2022 01:07:31 +0200
Subject: [PATCH 53/59] Fixed cache not being used on Windows and macOS in the
 test workflow

---
 .github/workflows/test.yml | 6 ++----
 1 file changed, 2 insertions(+), 4 deletions(-)

diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index d7da5979..7ed96f6d 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -35,10 +35,8 @@ 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 }}
+        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

From d783f6bb604a1a8bda672293de4892ee7e694261 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= 
Date: Sat, 10 Dec 2022 22:54:43 +0200
Subject: [PATCH 54/59] Added compatibility shim for wheel.wheelfile

---
 setup.cfg                          |   2 +
 src/wheel/_wheelfile.py            |  12 ++-
 src/wheel/wheelfile.py             | 116 +++++++++++++++++++++++
 tests/test_deprecated_wheelfile.py | 146 +++++++++++++++++++++++++++++
 tests/test_wheelfile.py            |   4 +-
 5 files changed, 273 insertions(+), 7 deletions(-)
 create mode 100644 src/wheel/wheelfile.py
 create mode 100644 tests/test_deprecated_wheelfile.py

diff --git a/setup.cfg b/setup.cfg
index fc822014..c0610b25 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -63,6 +63,8 @@ max-line-length = 88
 [tool:pytest]
 testpaths = tests
 addopts = --tb=short
+filterwarnings =
+    ignore::DeprecationWarning:wheel.wheelfile
 
 [coverage:run]
 source = wheel
diff --git a/src/wheel/_wheelfile.py b/src/wheel/_wheelfile.py
index e1c263fb..4a3190bf 100644
--- a/src/wheel/_wheelfile.py
+++ b/src/wheel/_wheelfile.py
@@ -193,7 +193,11 @@ def __exit__(
 
     def _read_record(self) -> OrderedDict[str, WheelRecordEntry]:
         entries = OrderedDict()
-        contents = self.read_dist_info("RECORD")
+        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=",",
@@ -310,17 +314,17 @@ def _open_file(self, archive_name: str) -> WheelArchiveFile:
             self._zip.open(archive_name), archive_name, record_entry
         )
 
-    def _read_file(self, archive_name: str) -> bytes:
+    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)
+        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)
+        return self.read_file(archive_path)
 
     def __repr__(self) -> str:
         return f"{self.__class__.__name__}({self.path_or_fd})"
diff --git a/src/wheel/wheelfile.py b/src/wheel/wheelfile.py
new file mode 100644
index 00000000..22c4d841
--- /dev/null
+++ b/src/wheel/wheelfile.py
@@ -0,0 +1,116 @@
+from __future__ import annotations
+
+import os.path
+import re
+import time
+from os import PathLike
+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, 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."
+    )
+)
+
+WHEEL_INFO_RE = re.compile(
+    r"""^(?P(?P[^\s-]+?)-(?P[^\s-]+?))(-(?P\d[^\s-]*))?
+     -(?P[^\s-]+?)-(?P[^\s-]+?)-(?P\S+)\.whl$""",
+    re.VERBOSE,
+)
+MINIMUM_TIMESTAMP = 315532800  # 1980-01-01 00:00:00 UTC
+
+
+def get_zipinfo_datetime(timestamp=None):
+    # Some applications need reproducible .whl files, but they can't do this without
+    # forcing the timestamp of the individual ZipInfo objects. See issue #143.
+    timestamp = int(os.environ.get("SOURCE_DATE_EPOCH", timestamp or time.time()))
+    timestamp = max(timestamp, MINIMUM_TIMESTAMP)
+    return time.gmtime(timestamp)[0:6]
+
+
+class WheelFile:
+    """Compatibility shim for WheelReader and WheelWriter."""
+
+    _reader: WheelReader
+    _writer: WheelWriter
+
+    def __init__(self, path: str | PathLike[str], mode: Literal["r", "w"] = "r"):
+        if mode == "r":
+            self._reader = WheelReader(path)
+        elif mode == "w":
+            self._writer = WheelWriter(path)
+        else:
+            raise ValueError(f"Invalid mode: {mode}")
+
+        self.filename = str(path)
+        self.parsed_filename = WHEEL_INFO_RE.match(os.path.basename(self.filename))
+        self.dist_info_path = f"{self.parsed_filename.group('namever')}.dist-info"
+
+    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)
+
+    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,
+    ):
+        arcname = arcname or filename
+        self._writer.write_file(arcname, 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")
+
+        if isinstance(zinfo_or_arcname, ZipInfo):
+            arcname = zinfo_or_arcname.filename
+            timestamp = zinfo_or_arcname.date_time
+        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)}"
+            )
+
+        self._writer.write_file(arcname, data, timestamp=timestamp)
diff --git a/tests/test_deprecated_wheelfile.py b/tests/test_deprecated_wheelfile.py
new file mode 100644
index 00000000..e5230e14
--- /dev/null
+++ b/tests/test_deprecated_wheelfile.py
@@ -0,0 +1,146 @@
+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="^Missing test-1.0.dist-info/RECORD 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(tmpdir_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")
+    files = (("foo", 0o644), ("bar", 0o755))
+    for filename, mode in files:
+        path = build_dir.join(filename)
+        path.write(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_wheelfile.py b/tests/test_wheelfile.py
index f05487e2..91e1797f 100644
--- a/tests/test_wheelfile.py
+++ b/tests/test_wheelfile.py
@@ -41,9 +41,7 @@ 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="^File 'test-1.0.dist-info/RECORD' not found$"
-    ):
+    with pytest.raises(WheelError, match="^Missing test-1.0.dist-info/RECORD file$"):
         with WheelReader(wheel_path):
             pass
 

From ff85f76150f6de4e54481ad2e869c8258dc90c76 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= 
Date: Sun, 11 Dec 2022 15:07:57 +0200
Subject: [PATCH 55/59] Added automatic detection of .dist-info directory

---
 src/wheel/_wheelfile.py            | 52 ++++++++++++++++++++++--------
 tests/test_deprecated_wheelfile.py | 15 ++++++---
 tests/test_wheelfile.py            | 21 +++++++++++-
 3 files changed, 68 insertions(+), 20 deletions(-)

diff --git a/src/wheel/_wheelfile.py b/src/wheel/_wheelfile.py
index 4a3190bf..3c2fa8af 100644
--- a/src/wheel/_wheelfile.py
+++ b/src/wheel/_wheelfile.py
@@ -145,6 +145,8 @@ 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]):
@@ -159,26 +161,48 @@ def __init__(self, path_or_fd: str | PathLike[str] | IO[bytes]):
 
     def __enter__(self) -> WheelReader:
         self._zip = ZipFile(self.path_or_fd, "r")
-        try:
-            if not hasattr(self, "name"):
+
+        # 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.is_dir() and zinfo.filename.endswith(".dist-info"):
-                        match = _DIST_NAME_RE.match(zinfo.filename)
-                        if match:
-                            self.name = NormalizedName(match[1])
-                            self.version = Version(match[2])
+                    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 .dist-info directory. Is this really a wheel "
-                        "file?"
+                        "Cannot find a valid .dist-info directory. "
+                        "Is this really a wheel file?"
                     )
-        except BaseException:
-            self._zip.close()
-            raise
+            except BaseException:
+                self._zip.close()
+                raise
 
-        self._dist_info_dir = f"{self.name}-{self.version}.dist-info"
-        self._data_dir = f"{self.name}-{self.version}.data"
         self._record_entries = self._read_record()
         return self
 
diff --git a/tests/test_deprecated_wheelfile.py b/tests/test_deprecated_wheelfile.py
index e5230e14..bb5bcdfc 100644
--- a/tests/test_deprecated_wheelfile.py
+++ b/tests/test_deprecated_wheelfile.py
@@ -34,7 +34,12 @@ 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="^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 WheelFile(wheel_path):
             pass
 
@@ -122,14 +127,14 @@ def test_timestamp(
 @pytest.mark.skipif(
     sys.platform == "win32", reason="Windows does not support UNIX-like permissions"
 )
-def test_attributes(tmpdir_factory: TempPathFactory, wheel_path: Path) -> None:
+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:
diff --git a/tests/test_wheelfile.py b/tests/test_wheelfile.py
index 91e1797f..90728cde 100644
--- a/tests/test_wheelfile.py
+++ b/tests/test_wheelfile.py
@@ -41,7 +41,12 @@ 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="^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
 
@@ -197,3 +202,17 @@ def test_attributes(tmp_path_factory: TempPathFactory, wheel_path: Path) -> None
         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

From 6892de43d86436e68dcc5edbaa500719546822be Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= 
Date: Sun, 11 Dec 2022 15:42:47 +0200
Subject: [PATCH 56/59] Refactored code to make mypy happy

---
 setup.cfg               |  2 +-
 src/wheel/_wheelfile.py |  1 +
 src/wheel/wheelfile.py  | 18 +++++++++++++-----
 3 files changed, 15 insertions(+), 6 deletions(-)

diff --git a/setup.cfg b/setup.cfg
index c0610b25..25ec451a 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -75,7 +75,7 @@ show_missing = true
 
 [mypy]
 files = src,tests
-python_version = 3.7
+python_version = 3.8
 exclude = testdata/
 
 [mypy-wheel.vendored.*]
diff --git a/src/wheel/_wheelfile.py b/src/wheel/_wheelfile.py
index 3c2fa8af..3c2f6d84 100644
--- a/src/wheel/_wheelfile.py
+++ b/src/wheel/_wheelfile.py
@@ -214,6 +214,7 @@ def __exit__(
     ) -> None:
         self._zip.close()
         self._record_entries.clear()
+        del self._zip
 
     def _read_record(self) -> OrderedDict[str, WheelRecordEntry]:
         entries = OrderedDict()
diff --git a/src/wheel/wheelfile.py b/src/wheel/wheelfile.py
index 22c4d841..d60800c9 100644
--- a/src/wheel/wheelfile.py
+++ b/src/wheel/wheelfile.py
@@ -3,7 +3,9 @@
 import os.path
 import re
 import time
+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
@@ -54,7 +56,13 @@ def __init__(self, path: str | PathLike[str], mode: Literal["r", "w"] = "r"):
 
         self.filename = str(path)
         self.parsed_filename = WHEEL_INFO_RE.match(os.path.basename(self.filename))
-        self.dist_info_path = f"{self.parsed_filename.group('namever')}.dist-info"
+
+    @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"):
@@ -79,7 +87,7 @@ 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)
+        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)
@@ -90,8 +98,8 @@ def write(
         arcname: str | None = None,
         compress_type: int | None = None,
     ):
-        arcname = arcname or filename
-        self._writer.write_file(arcname, filename)
+        fname = PurePath(arcname or filename)
+        self._writer.write_file(fname, Path(filename))
 
     def writestr(
         self,
@@ -104,7 +112,7 @@ def writestr(
 
         if isinstance(zinfo_or_arcname, ZipInfo):
             arcname = zinfo_or_arcname.filename
-            timestamp = zinfo_or_arcname.date_time
+            timestamp = datetime(*zinfo_or_arcname.date_time[:6])
         elif isinstance(zinfo_or_arcname, str):
             arcname = zinfo_or_arcname
             timestamp = DEFAULT_TIMESTAMP

From 513f1f7e5eeb00db1ca155894f700238f95e231a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= 
Date: Sun, 11 Dec 2022 16:07:18 +0200
Subject: [PATCH 57/59] Refactored pkg_resources imports

---
 src/wheel/bdist_wheel.py | 4 +---
 1 file changed, 1 insertion(+), 3 deletions(-)

diff --git a/src/wheel/bdist_wheel.py b/src/wheel/bdist_wheel.py
index a56318b2..a8171ff6 100644
--- a/src/wheel/bdist_wheel.py
+++ b/src/wheel/bdist_wheel.py
@@ -19,7 +19,7 @@
 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 ._macosx_libfile import calculate_macosx_platform_tag
@@ -27,8 +27,6 @@
 from ._wheelfile import WheelWriter, make_filename
 from .vendored.packaging import tags
 
-safe_name = pkg_resources.safe_name
-safe_version = pkg_resources.safe_version
 logger = getLogger("wheel")
 
 PY_LIMITED_API_PATTERN = r"cp3\d"

From 09c5f6482fc609a4a02246fac9ccac722f51af7e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= 
Date: Sun, 11 Dec 2022 16:08:03 +0200
Subject: [PATCH 58/59] Fixed the rest of mypy errors

---
 src/wheel/wheelfile.py | 8 ++++++--
 1 file changed, 6 insertions(+), 2 deletions(-)

diff --git a/src/wheel/wheelfile.py b/src/wheel/wheelfile.py
index d60800c9..7a28e691 100644
--- a/src/wheel/wheelfile.py
+++ b/src/wheel/wheelfile.py
@@ -12,7 +12,7 @@
 from zipfile import ZipInfo
 
 from . import WheelWriter
-from ._wheelfile import DEFAULT_TIMESTAMP, WheelReader
+from ._wheelfile import DEFAULT_TIMESTAMP, WheelError, WheelReader
 
 if TYPE_CHECKING:
     from typing import Literal
@@ -55,7 +55,11 @@ def __init__(self, path: str | PathLike[str], mode: Literal["r", "w"] = "r"):
             raise ValueError(f"Invalid mode: {mode}")
 
         self.filename = str(path)
-        self.parsed_filename = WHEEL_INFO_RE.match(os.path.basename(self.filename))
+        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:

From 59fd006757488aa38a655bb9c8a2904575a2d416 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= 
Date: Mon, 12 Dec 2022 01:07:45 +0200
Subject: [PATCH 59/59] Added a standalone function for writing a WHEEL file

---
 src/wheel/__init__.py   | 16 ++++++++++++++--
 src/wheel/_wheelfile.py | 41 +++++++++++++++++++++--------------------
 2 files changed, 35 insertions(+), 22 deletions(-)

diff --git a/src/wheel/__init__.py b/src/wheel/__init__.py
index 0415c70f..08132a2e 100644
--- a/src/wheel/__init__.py
+++ b/src/wheel/__init__.py
@@ -1,6 +1,18 @@
 from __future__ import annotations
 
-__all__ = ["WheelError", "WheelReader", "WheelWriter", "make_filename"]
+__all__ = [
+    "WheelError",
+    "WheelReader",
+    "WheelWriter",
+    "make_filename",
+    "write_wheelfile",
+]
 __version__ = "1.0.0a1"
 
-from ._wheelfile import WheelError, WheelReader, WheelWriter, make_filename
+from ._wheelfile import (
+    WheelError,
+    WheelReader,
+    WheelWriter,
+    make_filename,
+    write_wheelfile,
+)
diff --git a/src/wheel/_wheelfile.py b/src/wheel/_wheelfile.py
index 3c2f6d84..c25acae8 100644
--- a/src/wheel/_wheelfile.py
+++ b/src/wheel/_wheelfile.py
@@ -11,10 +11,9 @@
 from collections.abc import Iterable, Iterator
 from contextlib import ExitStack
 from datetime import datetime, timezone
-from email.generator import Generator
 from email.message import Message
 from email.policy import EmailPolicy
-from io import StringIO, UnsupportedOperation
+from io import BytesIO, StringIO, UnsupportedOperation
 from os import PathLike
 from pathlib import Path, PurePath
 from types import TracebackType
@@ -355,6 +354,22 @@ 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,
@@ -428,24 +443,12 @@ def _write_record(self) -> None:
         self.write_distinfo_file("RECORD", data.getvalue())
 
     def _write_wheelfile(self) -> None:
-        msg = Message()
-        msg["Wheel-Version"] = "1.0"  # of the spec
-        msg["Generator"] = self.generator
-        msg["Root-Is-Purelib"] = str(self.root_is_purelib).lower()
-        if self.metadata.build_tag:
-            msg["Build"] = str(self.metadata.build_tag[0]) + self.metadata.build_tag[1]
-
-        for tag in sorted(
-            self.metadata.tags, key=lambda t: (t.interpreter, t.abi, t.platform)
-        ):
-            msg["Tag"] = f"{tag.interpreter}-{tag.abi}-{tag.platform}"
-
-        buffer = StringIO()
-        Generator(buffer, maxheaderlen=0, policy=EMAIL_POLICY).flatten(msg)
+        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()
+        msg = Message(policy=EMAIL_POLICY)
         for key, value in items:
             key = key.title()
             if key == "Description":
@@ -460,9 +463,7 @@ def write_metadata(self, items: Iterable[tuple[str, str]]) -> None:
         if "Version" not in msg:
             msg["Version"] = str(self.metadata.version)
 
-        buffer = StringIO()
-        Generator(buffer, maxheaderlen=0, policy=EMAIL_POLICY).flatten(msg)
-        self.write_distinfo_file("METADATA", buffer.getvalue())
+        self.write_distinfo_file("METADATA", msg.as_bytes())
 
     def write_file(
         self,