diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..191b6805 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,17 @@ +[run] +omit = + # leading `*/` for pytest-dev/pytest-cov#456 + */.tox/* + + # local + */compat/* + */distutils/_vendor/* +disable_warnings = + couldnt-parse + +[report] +show_missing = True +exclude_also = + # jaraco/skeleton#97 + @overload + if TYPE_CHECKING: diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..304196f8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +root = true + +[*] +charset = utf-8 +indent_style = tab +indent_size = 4 +insert_final_newline = true +end_of_line = lf + +[*.py] +indent_style = space +max_line_length = 88 + +[*.{yml,yaml}] +indent_style = space +indent_size = 2 + +[*.rst] +indent_style = space diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..89ff3396 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "daily" + allow: + - dependency-type: "all" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 60801ace..b6b757db 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,42 +1,99 @@ name: tests -on: [push, pull_request] +on: + merge_group: + push: + branches-ignore: + # temporary GH branches relating to merge queues (jaraco/skeleton#93) + - gh-readonly-queue/** + tags: + # required if branches-ignore is supplied (jaraco/skeleton#103) + - '**' + pull_request: concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + group: >- + ${{ github.workflow }}- + ${{ github.ref_type }}- + ${{ github.event.pull_request.number || github.sha }} cancel-in-progress: true +permissions: + contents: read + +env: + # Environment variable to support color support (jaraco/skeleton#66) + FORCE_COLOR: 1 + + # Suppress noisy pip warnings + PIP_DISABLE_PIP_VERSION_CHECK: 'true' + PIP_NO_PYTHON_VERSION_WARNING: 'true' + PIP_NO_WARN_SCRIPT_LOCATION: 'true' + + # Ensure tests can sense settings about the environment + TOX_OVERRIDE: >- + testenv.pass_env+=GITHUB_*,FORCE_COLOR + jobs: test: strategy: + # https://blog.jaraco.com/efficient-use-of-ci-resources/ matrix: python: - # Build on pre-releases until stable, then stable releases. - # actions/setup-python#213 - - ~3.7.0-0 - - ~3.10.0-0 - - ~3.11.0-0 + - "3.8" + - "3.12" platform: - ubuntu-latest - macos-latest - windows-latest include: - - platform: ubuntu-latest - python: 'pypy3.9' + - python: "3.9" + platform: ubuntu-latest + - python: "3.10" + platform: ubuntu-latest + - python: "3.11" + platform: ubuntu-latest + - python: pypy3.10 + platform: ubuntu-latest runs-on: ${{ matrix.platform }} + continue-on-error: ${{ matrix.python == '3.13' }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup Python uses: actions/setup-python@v4 with: python-version: ${{ matrix.python }} + allow-prereleases: true - name: Install tox - run: | - python -m pip install tox - - name: Run tests + run: python -m pip install tox + - name: Run run: tox + collateral: + strategy: + fail-fast: false + matrix: + job: + - diffcov + - docs + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.python == '3.12' }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: 3.x + - name: Install tox + run: python -m pip install tox + - name: Eval ${{ matrix.job }} + run: tox -e ${{ matrix.job }} + test_cygwin: + # disabled due to lack of Rust support pypa/setuptools#3921 + if: ${{ false }} strategy: matrix: python: @@ -58,10 +115,53 @@ jobs: gcc-core, gcc-g++, ncompress + git - name: Run tests shell: C:\cygwin\bin\env.exe CYGWIN_NOWINPATH=1 CHERE_INVOKING=1 C:\cygwin\bin\bash.exe -leo pipefail -o igncr {0} run: tox + test_msys2_mingw: + strategy: + matrix: + include: + - { sys: mingw64, env: x86_64, cc: gcc, cxx: g++ } + - { sys: mingw32, env: i686, cc: gcc, cxx: g++ } + - { sys: ucrt64, env: ucrt-x86_64, cc: gcc, cxx: g++ } + - { sys: clang64, env: clang-x86_64, cc: clang, cxx: clang++} + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: msys2/setup-msys2@v2 + with: + msystem: ${{matrix.sys}} + install: | + mingw-w64-${{matrix.env}}-toolchain + mingw-w64-${{matrix.env}}-python + mingw-w64-${{matrix.env}}-python-pip + mingw-w64-${{matrix.env}}-python-virtualenv + mingw-w64-${{matrix.env}}-cc + git + - name: Install Dependencies + shell: msys2 {0} + run: | + export VIRTUALENV_NO_SETUPTOOLS=1 + + python -m virtualenv /tmp/venv + source /tmp/venv/bin/activate + + # python-ruff doesn't work without rust + sed -i '/pytest-ruff/d' pyproject.toml + + pip install -e .[test] + - name: Run tests + shell: msys2 {0} + env: + CC: ${{ matrix.cc }} + CXX: ${{ matrix.cxx }} + run: | + source /tmp/venv/bin/activate + pytest + ci_setuptools: # Integration testing with setuptools strategy: @@ -100,21 +200,40 @@ jobs: env: VIRTUALENV_NO_SETUPTOOLS: null + check: # This job does nothing and is only used for the branch protection + if: always() + + needs: + - test + - collateral + # disabled due to disabled job + # - test_cygwin + + runs-on: ubuntu-latest + + steps: + - name: Decide whether the needed jobs succeeded or failed + uses: re-actors/alls-green@release/v1 + with: + jobs: ${{ toJSON(needs) }} + release: - needs: test + permissions: + contents: write + needs: + - check if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup Python uses: actions/setup-python@v4 with: - python-version: "3.10" + python-version: 3.11-dev - name: Install tox - run: | - python -m pip install tox - - name: Release + run: python -m pip install tox + - name: Run run: tox -e release env: TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..5a4a7e91 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,6 @@ +repos: +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.8 + hooks: + - id: ruff + - id: ruff-format diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..dc8516ac --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,16 @@ +version: 2 +python: + install: + - path: . + extra_requirements: + - doc + +# required boilerplate readthedocs/readthedocs.org#10401 +build: + os: ubuntu-lts-latest + tools: + python: latest + # post-checkout job to ensure the clone isn't shallow jaraco/skeleton#114 + jobs: + post_checkout: + - git fetch --unshallow || true diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..1bb5a443 --- /dev/null +++ b/LICENSE @@ -0,0 +1,17 @@ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +IN THE SOFTWARE. diff --git a/NEWS.rst b/NEWS.rst new file mode 100644 index 00000000..e69de29b diff --git a/README.rst b/README.rst index 588898bb..aa3b65f1 100644 --- a/README.rst +++ b/README.rst @@ -1,11 +1,27 @@ -Python Module Distribution Utilities extracted from the Python Standard Library +.. image:: https://img.shields.io/pypi/v/distutils.svg + :target: https://pypi.org/project/distutils + +.. image:: https://img.shields.io/pypi/pyversions/distutils.svg + +.. image:: https://github.com/pypa/distutils/actions/workflows/main.yml/badge.svg + :target: https://github.com/pypa/distutils/actions?query=workflow%3A%22tests%22 + :alt: tests + +.. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v2.json + :target: https://github.com/astral-sh/ruff + :alt: Ruff -Synchronizing -============= +.. .. image:: https://readthedocs.org/projects/PROJECT_RTD/badge/?version=latest +.. :target: https://PROJECT_RTD.readthedocs.io/en/latest/?badge=latest + +.. image:: https://img.shields.io/badge/skeleton-2024-informational + :target: https://blog.jaraco.com/skeleton + +Python Module Distribution Utilities extracted from the Python Standard Library -This project is no longer kept in sync with the code still in stdlib, which is deprecated and scheduled for removal. +This package is unsupported except as integrated into and exposed by Setuptools. -To Setuptools -------------- +Integration +----------- Simply merge the changes directly into setuptools' repo. diff --git a/conftest.py b/conftest.py index b01b3130..6639aa65 100644 --- a/conftest.py +++ b/conftest.py @@ -1,22 +1,24 @@ +import logging import os -import sys -import platform import pathlib -import logging +import platform +import sys -import pytest import path - +import pytest collect_ignore = [] if platform.system() != 'Windows': - collect_ignore.extend( - [ - 'distutils/msvc9compiler.py', - ] - ) + collect_ignore.extend([ + 'distutils/msvc9compiler.py', + ]) + + +collect_ignore_glob = [ + 'distutils/_vendor/**/*', +] @pytest.fixture @@ -59,7 +61,7 @@ def _save_cwd(): @pytest.fixture def distutils_managed_tempdir(request): - from distutils.tests import py38compat as os_helper + from distutils.tests.compat import py38 as os_helper self = request.instance self.tempdirs = [] @@ -95,8 +97,7 @@ def temp_cwd(tmp_path): @pytest.fixture def pypirc(request, save_env, distutils_managed_tempdir): - from distutils.core import PyPIRCCommand - from distutils.core import Distribution + from distutils.core import Distribution, PyPIRCCommand self = request.instance self.tmp_dir = self.mkdtemp() @@ -154,3 +155,10 @@ def temp_home(tmp_path, monkeypatch): def fake_home(fs, monkeypatch): home = fs.create_dir('/fakehome') return _set_home(monkeypatch, pathlib.Path(home.path)) + + +@pytest.fixture +def disable_macos_customization(monkeypatch): + from distutils import sysconfig + + monkeypatch.setattr(sysconfig, '_customize_macos', lambda: None) diff --git a/distutils/__init__.py b/distutils/__init__.py index 1a188c35..e374d5c5 100644 --- a/distutils/__init__.py +++ b/distutils/__init__.py @@ -1,5 +1,5 @@ -import sys import importlib +import sys __version__, _, _ = sys.version.partition(' ') diff --git a/distutils/_collections.py b/distutils/_collections.py index 5ad21cc7..d11a8346 100644 --- a/distutils/_collections.py +++ b/distutils/_collections.py @@ -1,7 +1,11 @@ +from __future__ import annotations + import collections import functools import itertools import operator +from collections.abc import Mapping +from typing import Any # from jaraco.collections 3.5.1 @@ -58,7 +62,7 @@ def __len__(self): return len(list(iter(self))) -# from jaraco.collections 3.7 +# from jaraco.collections 5.0.1 class RangeMap(dict): """ A dictionary-like object that uses the keys as bounds for a range. @@ -70,7 +74,7 @@ class RangeMap(dict): One may supply keyword parameters to be passed to the sort function used to sort keys (i.e. key, reverse) as sort_params. - Let's create a map that maps 1-3 -> 'a', 4-6 -> 'b' + Create a map that maps 1-3 -> 'a', 4-6 -> 'b' >>> r = RangeMap({3: 'a', 6: 'b'}) # boy, that was easy >>> r[1], r[2], r[3], r[4], r[5], r[6] @@ -82,7 +86,7 @@ class RangeMap(dict): >>> r[4.5] 'b' - But you'll notice that the way rangemap is defined, it must be open-ended + Notice that the way rangemap is defined, it must be open-ended on one side. >>> r[0] @@ -140,7 +144,12 @@ class RangeMap(dict): """ - def __init__(self, source, sort_params={}, key_match_comparator=operator.le): + def __init__( + self, + source, + sort_params: Mapping[str, Any] = {}, + key_match_comparator=operator.le, + ): dict.__init__(self, source) self.sort_params = sort_params self.match = key_match_comparator diff --git a/distutils/_functools.py b/distutils/_functools.py index e7053bac..e03365ea 100644 --- a/distutils/_functools.py +++ b/distutils/_functools.py @@ -1,3 +1,4 @@ +import collections.abc import functools @@ -18,3 +19,55 @@ def wrapper(param, *args, **kwargs): return func(param, *args, **kwargs) return wrapper + + +# from jaraco.functools 4.0 +@functools.singledispatch +def _splat_inner(args, func): + """Splat args to func.""" + return func(*args) + + +@_splat_inner.register +def _(args: collections.abc.Mapping, func): + """Splat kargs to func as kwargs.""" + return func(**args) + + +def splat(func): + """ + Wrap func to expect its parameters to be passed positionally in a tuple. + + Has a similar effect to that of ``itertools.starmap`` over + simple ``map``. + + >>> import itertools, operator + >>> pairs = [(-1, 1), (0, 2)] + >>> _ = tuple(itertools.starmap(print, pairs)) + -1 1 + 0 2 + >>> _ = tuple(map(splat(print), pairs)) + -1 1 + 0 2 + + The approach generalizes to other iterators that don't have a "star" + equivalent, such as a "starfilter". + + >>> list(filter(splat(operator.add), pairs)) + [(0, 2)] + + Splat also accepts a mapping argument. + + >>> def is_nice(msg, code): + ... return "smile" in msg or code == 0 + >>> msgs = [ + ... dict(msg='smile!', code=20), + ... dict(msg='error :(', code=1), + ... dict(msg='unknown', code=0), + ... ] + >>> for msg in filter(splat(is_nice), msgs): + ... print(msg) + {'msg': 'smile!', 'code': 20} + {'msg': 'unknown', 'code': 0} + """ + return functools.wraps(func)(functools.partial(_splat_inner, func=func)) diff --git a/distutils/_itertools.py b/distutils/_itertools.py new file mode 100644 index 00000000..85b29511 --- /dev/null +++ b/distutils/_itertools.py @@ -0,0 +1,52 @@ +# from more_itertools 10.2 +def always_iterable(obj, base_type=(str, bytes)): + """If *obj* is iterable, return an iterator over its items:: + + >>> obj = (1, 2, 3) + >>> list(always_iterable(obj)) + [1, 2, 3] + + If *obj* is not iterable, return a one-item iterable containing *obj*:: + + >>> obj = 1 + >>> list(always_iterable(obj)) + [1] + + If *obj* is ``None``, return an empty iterable: + + >>> obj = None + >>> list(always_iterable(None)) + [] + + By default, binary and text strings are not considered iterable:: + + >>> obj = 'foo' + >>> list(always_iterable(obj)) + ['foo'] + + If *base_type* is set, objects for which ``isinstance(obj, base_type)`` + returns ``True`` won't be considered iterable. + + >>> obj = {'a': 1} + >>> list(always_iterable(obj)) # Iterate over the dict's keys + ['a'] + >>> list(always_iterable(obj, base_type=dict)) # Treat dicts as a unit + [{'a': 1}] + + Set *base_type* to ``None`` to avoid any special handling and treat objects + Python considers iterable as iterable: + + >>> obj = 'foo' + >>> list(always_iterable(obj, base_type=None)) + ['f', 'o', 'o'] + """ + if obj is None: + return iter(()) + + if (base_type is not None) and isinstance(obj, base_type): + return iter((obj,)) + + try: + return iter(obj) + except TypeError: + return iter((obj,)) diff --git a/distutils/_log.py b/distutils/_log.py index 4a2ae0ac..0148f157 100644 --- a/distutils/_log.py +++ b/distutils/_log.py @@ -1,4 +1,3 @@ import logging - log = logging.getLogger() diff --git a/distutils/_macos_compat.py b/distutils/_macos_compat.py index 17769e91..76ecb96a 100644 --- a/distutils/_macos_compat.py +++ b/distutils/_macos_compat.py @@ -1,5 +1,5 @@ -import sys import importlib +import sys def bypass_compiler_fixup(cmd, args): diff --git a/distutils/_modified.py b/distutils/_modified.py new file mode 100644 index 00000000..6532aa10 --- /dev/null +++ b/distutils/_modified.py @@ -0,0 +1,72 @@ +"""Timestamp comparison of files and groups of files.""" + +import functools +import os.path + +from ._functools import splat +from .compat.py39 import zip_strict +from .errors import DistutilsFileError + + +def _newer(source, target): + return not os.path.exists(target) or ( + os.path.getmtime(source) > os.path.getmtime(target) + ) + + +def newer(source, target): + """ + Is source modified more recently than target. + + Returns True if 'source' is modified more recently than + 'target' or if 'target' does not exist. + + Raises DistutilsFileError if 'source' does not exist. + """ + if not os.path.exists(source): + raise DistutilsFileError(f"file '{os.path.abspath(source)}' does not exist") + + return _newer(source, target) + + +def newer_pairwise(sources, targets, newer=newer): + """ + Filter filenames where sources are newer than targets. + + Walk two filename iterables in parallel, testing if each source is newer + than its corresponding target. Returns a pair of lists (sources, + targets) where source is newer than target, according to the semantics + of 'newer()'. + """ + newer_pairs = filter(splat(newer), zip_strict(sources, targets)) + return tuple(map(list, zip(*newer_pairs))) or ([], []) + + +def newer_group(sources, target, missing='error'): + """ + Is target out-of-date with respect to any file in sources. + + Return True if 'target' is out-of-date with respect to any file + listed in 'sources'. In other words, if 'target' exists and is newer + than every file in 'sources', return False; otherwise return True. + ``missing`` controls how to handle a missing source file: + + - error (default): allow the ``stat()`` call to fail. + - ignore: silently disregard any missing source files. + - newer: treat missing source files as "target out of date". This + mode is handy in "dry-run" mode: it will pretend to carry out + commands that wouldn't work because inputs are missing, but + that doesn't matter because dry-run won't run the commands. + """ + + def missing_as_newer(source): + return missing == 'newer' and not os.path.exists(source) + + ignored = os.path.exists if missing == 'ignore' else None + return any( + missing_as_newer(source) or _newer(source, target) + for source in filter(ignored, sources) + ) + + +newer_pairwise_group = functools.partial(newer_pairwise, newer=newer_group) diff --git a/distutils/_msvccompiler.py b/distutils/_msvccompiler.py index 4f081c7e..b0322410 100644 --- a/distutils/_msvccompiler.py +++ b/distutils/_msvccompiler.py @@ -13,28 +13,28 @@ # ported to VS 2005 and VS 2008 by Christian Heimes # ported to VS 2015 by Steve Dower +import contextlib import os import subprocess -import contextlib -import warnings import unittest.mock as mock +import warnings with contextlib.suppress(ImportError): import winreg +from itertools import count + +from ._log import log +from .ccompiler import CCompiler, gen_lib_options from .errors import ( + CompileError, DistutilsExecError, DistutilsPlatformError, - CompileError, LibError, LinkError, ) -from .ccompiler import CCompiler, gen_lib_options -from ._log import log from .util import get_platform -from itertools import count - def _find_vc2015(): try: @@ -218,7 +218,7 @@ class MSVCCompiler(CCompiler): static_lib_format = shared_lib_format = '%s%s' exe_extension = '.exe' - def __init__(self, verbose=0, dry_run=0, force=0): + def __init__(self, verbose=False, dry_run=False, force=False): super().__init__(verbose, dry_run, force) # target platform (.plat_name is consistent with 'bdist') self.plat_name = None @@ -253,7 +253,7 @@ def initialize(self, plat_name=None): vc_env = _get_vc_env(plat_spec) if not vc_env: raise DistutilsPlatformError( - "Unable to find a compatible " "Visual Studio installation." + "Unable to find a compatible Visual Studio installation." ) self._configure(vc_env) @@ -334,7 +334,7 @@ def compile( # noqa: C901 output_dir=None, macros=None, include_dirs=None, - debug=0, + debug=False, extra_preargs=None, extra_postargs=None, depends=None, @@ -423,7 +423,7 @@ def compile( # noqa: C901 return objects def create_static_lib( - self, objects, output_libname, output_dir=None, debug=0, target_lang=None + self, objects, output_libname, output_dir=None, debug=False, target_lang=None ): if not self.initialized: self.initialize() @@ -452,7 +452,7 @@ def link( library_dirs=None, runtime_library_dirs=None, export_symbols=None, - debug=0, + debug=False, extra_preargs=None, extra_postargs=None, build_temp=None, @@ -551,7 +551,7 @@ def runtime_library_dir_option(self, dir): def library_option(self, lib): return self.library_filename(lib) - def find_library_file(self, dirs, lib, debug=0): + def find_library_file(self, dirs, lib, debug=False): # Prefer a debugging library if found (and requested), but deal # with it if we don't have one. if debug: diff --git a/distutils/_vendor/__init__.py b/distutils/_vendor/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/distutils/_vendor/packaging-24.0.dist-info/INSTALLER b/distutils/_vendor/packaging-24.0.dist-info/INSTALLER new file mode 100644 index 00000000..a1b589e3 --- /dev/null +++ b/distutils/_vendor/packaging-24.0.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/distutils/_vendor/packaging-24.0.dist-info/LICENSE b/distutils/_vendor/packaging-24.0.dist-info/LICENSE new file mode 100644 index 00000000..6f62d44e --- /dev/null +++ b/distutils/_vendor/packaging-24.0.dist-info/LICENSE @@ -0,0 +1,3 @@ +This software is made available under the terms of *either* of the licenses +found in LICENSE.APACHE or LICENSE.BSD. Contributions to this software is made +under the terms of *both* these licenses. diff --git a/distutils/_vendor/packaging-24.0.dist-info/LICENSE.APACHE b/distutils/_vendor/packaging-24.0.dist-info/LICENSE.APACHE new file mode 100644 index 00000000..f433b1a5 --- /dev/null +++ b/distutils/_vendor/packaging-24.0.dist-info/LICENSE.APACHE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/distutils/_vendor/packaging-24.0.dist-info/LICENSE.BSD b/distutils/_vendor/packaging-24.0.dist-info/LICENSE.BSD new file mode 100644 index 00000000..42ce7b75 --- /dev/null +++ b/distutils/_vendor/packaging-24.0.dist-info/LICENSE.BSD @@ -0,0 +1,23 @@ +Copyright (c) Donald Stufft and individual contributors. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/distutils/_vendor/packaging-24.0.dist-info/METADATA b/distutils/_vendor/packaging-24.0.dist-info/METADATA new file mode 100644 index 00000000..10ab4390 --- /dev/null +++ b/distutils/_vendor/packaging-24.0.dist-info/METADATA @@ -0,0 +1,102 @@ +Metadata-Version: 2.1 +Name: packaging +Version: 24.0 +Summary: Core utilities for Python packages +Author-email: Donald Stufft +Requires-Python: >=3.7 +Description-Content-Type: text/x-rst +Classifier: Development Status :: 5 - Production/Stable +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: Apache Software License +Classifier: License :: OSI Approved :: BSD License +Classifier: Programming Language :: Python +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3 :: Only +Classifier: Programming Language :: Python :: 3.7 +Classifier: Programming Language :: Python :: 3.8 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Programming Language :: Python :: Implementation :: CPython +Classifier: Programming Language :: Python :: Implementation :: PyPy +Classifier: Typing :: Typed +Project-URL: Documentation, https://packaging.pypa.io/ +Project-URL: Source, https://github.com/pypa/packaging + +packaging +========= + +.. start-intro + +Reusable core utilities for various Python Packaging +`interoperability specifications `_. + +This library provides utilities that implement the interoperability +specifications which have clearly one correct behaviour (eg: :pep:`440`) +or benefit greatly from having a single shared implementation (eg: :pep:`425`). + +.. end-intro + +The ``packaging`` project includes the following: version handling, specifiers, +markers, requirements, tags, utilities. + +Documentation +------------- + +The `documentation`_ provides information and the API for the following: + +- Version Handling +- Specifiers +- Markers +- Requirements +- Tags +- Utilities + +Installation +------------ + +Use ``pip`` to install these utilities:: + + pip install packaging + +The ``packaging`` library uses calendar-based versioning (``YY.N``). + +Discussion +---------- + +If you run into bugs, you can file them in our `issue tracker`_. + +You can also join ``#pypa`` on Freenode to ask questions or get involved. + + +.. _`documentation`: https://packaging.pypa.io/ +.. _`issue tracker`: https://github.com/pypa/packaging/issues + + +Code of Conduct +--------------- + +Everyone interacting in the packaging project's codebases, issue trackers, chat +rooms, and mailing lists is expected to follow the `PSF Code of Conduct`_. + +.. _PSF Code of Conduct: https://github.com/pypa/.github/blob/main/CODE_OF_CONDUCT.md + +Contributing +------------ + +The ``CONTRIBUTING.rst`` file outlines how to contribute to this project as +well as how to report a potential security issue. The documentation for this +project also covers information about `project development`_ and `security`_. + +.. _`project development`: https://packaging.pypa.io/en/latest/development/ +.. _`security`: https://packaging.pypa.io/en/latest/security/ + +Project History +--------------- + +Please review the ``CHANGELOG.rst`` file or the `Changelog documentation`_ for +recent changes and project history. + +.. _`Changelog documentation`: https://packaging.pypa.io/en/latest/changelog/ + diff --git a/distutils/_vendor/packaging-24.0.dist-info/RECORD b/distutils/_vendor/packaging-24.0.dist-info/RECORD new file mode 100644 index 00000000..bcf796c2 --- /dev/null +++ b/distutils/_vendor/packaging-24.0.dist-info/RECORD @@ -0,0 +1,37 @@ +packaging-24.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +packaging-24.0.dist-info/LICENSE,sha256=ytHvW9NA1z4HS6YU0m996spceUDD2MNIUuZcSQlobEg,197 +packaging-24.0.dist-info/LICENSE.APACHE,sha256=DVQuDIgE45qn836wDaWnYhSdxoLXgpRRKH4RuTjpRZQ,10174 +packaging-24.0.dist-info/LICENSE.BSD,sha256=tw5-m3QvHMb5SLNMFqo5_-zpQZY2S8iP8NIYDwAo-sU,1344 +packaging-24.0.dist-info/METADATA,sha256=0dESdhY_wHValuOrbgdebiEw04EbX4dkujlxPdEsFus,3203 +packaging-24.0.dist-info/RECORD,, +packaging-24.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +packaging-24.0.dist-info/WHEEL,sha256=EZbGkh7Ie4PoZfRQ8I0ZuP9VklN_TvcZ6DSE5Uar4z4,81 +packaging/__init__.py,sha256=UzotcV07p8vcJzd80S-W0srhgY8NMVD_XvJcZ7JN-tA,496 +packaging/__pycache__/__init__.cpython-312.pyc,, +packaging/__pycache__/_elffile.cpython-312.pyc,, +packaging/__pycache__/_manylinux.cpython-312.pyc,, +packaging/__pycache__/_musllinux.cpython-312.pyc,, +packaging/__pycache__/_parser.cpython-312.pyc,, +packaging/__pycache__/_structures.cpython-312.pyc,, +packaging/__pycache__/_tokenizer.cpython-312.pyc,, +packaging/__pycache__/markers.cpython-312.pyc,, +packaging/__pycache__/metadata.cpython-312.pyc,, +packaging/__pycache__/requirements.cpython-312.pyc,, +packaging/__pycache__/specifiers.cpython-312.pyc,, +packaging/__pycache__/tags.cpython-312.pyc,, +packaging/__pycache__/utils.cpython-312.pyc,, +packaging/__pycache__/version.cpython-312.pyc,, +packaging/_elffile.py,sha256=hbmK8OD6Z7fY6hwinHEUcD1by7czkGiNYu7ShnFEk2k,3266 +packaging/_manylinux.py,sha256=1ng_TqyH49hY6s3W_zVHyoJIaogbJqbIF1jJ0fAehc4,9590 +packaging/_musllinux.py,sha256=kgmBGLFybpy8609-KTvzmt2zChCPWYvhp5BWP4JX7dE,2676 +packaging/_parser.py,sha256=zlsFB1FpMRjkUdQb6WLq7xON52ruQadxFpYsDXWhLb4,10347 +packaging/_structures.py,sha256=q3eVNmbWJGG_S0Dit_S3Ao8qQqz_5PYTXFAKBZe5yr4,1431 +packaging/_tokenizer.py,sha256=alCtbwXhOFAmFGZ6BQ-wCTSFoRAJ2z-ysIf7__MTJ_k,5292 +packaging/markers.py,sha256=eH-txS2zq1HdNpTd9LcZUcVIwewAiNU0grmq5wjKnOk,8208 +packaging/metadata.py,sha256=w7jPEg6mDf1FTZMn79aFxFuk4SKtynUJtxr2InTxlV4,33036 +packaging/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +packaging/requirements.py,sha256=dgoBeVprPu2YE6Q8nGfwOPTjATHbRa_ZGLyXhFEln6Q,2933 +packaging/specifiers.py,sha256=dB2DwbmvSbEuVilEyiIQ382YfW5JfwzXTfRRPVtaENY,39784 +packaging/tags.py,sha256=fedHXiOHkBxNZTXotXv8uXPmMFU9ae-TKBujgYHigcA,18950 +packaging/utils.py,sha256=XgdmP3yx9-wQEFjO7OvMj9RjEf5JlR5HFFR69v7SQ9E,5268 +packaging/version.py,sha256=XjRBLNK17UMDgLeP8UHnqwiY3TdSi03xFQURtec211A,16236 diff --git a/distutils/_vendor/packaging-24.0.dist-info/REQUESTED b/distutils/_vendor/packaging-24.0.dist-info/REQUESTED new file mode 100644 index 00000000..e69de29b diff --git a/distutils/_vendor/packaging-24.0.dist-info/WHEEL b/distutils/_vendor/packaging-24.0.dist-info/WHEEL new file mode 100644 index 00000000..3b5e64b5 --- /dev/null +++ b/distutils/_vendor/packaging-24.0.dist-info/WHEEL @@ -0,0 +1,4 @@ +Wheel-Version: 1.0 +Generator: flit 3.9.0 +Root-Is-Purelib: true +Tag: py3-none-any diff --git a/distutils/_vendor/packaging/__init__.py b/distutils/_vendor/packaging/__init__.py new file mode 100644 index 00000000..e7c0aa12 --- /dev/null +++ b/distutils/_vendor/packaging/__init__.py @@ -0,0 +1,15 @@ +# 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. + +__title__ = "packaging" +__summary__ = "Core utilities for Python packages" +__uri__ = "https://github.com/pypa/packaging" + +__version__ = "24.0" + +__author__ = "Donald Stufft and individual contributors" +__email__ = "donald@stufft.io" + +__license__ = "BSD-2-Clause or Apache-2.0" +__copyright__ = "2014 %s" % __author__ diff --git a/distutils/_vendor/packaging/_elffile.py b/distutils/_vendor/packaging/_elffile.py new file mode 100644 index 00000000..6fb19b30 --- /dev/null +++ b/distutils/_vendor/packaging/_elffile.py @@ -0,0 +1,108 @@ +""" +ELF file parser. + +This provides a class ``ELFFile`` that parses an ELF executable in a similar +interface to ``ZipFile``. Only the read interface is implemented. + +Based on: https://gist.github.com/lyssdod/f51579ae8d93c8657a5564aefc2ffbca +ELF header: https://refspecs.linuxfoundation.org/elf/gabi4+/ch4.eheader.html +""" + +import enum +import os +import struct +from typing import IO, Optional, Tuple + + +class ELFInvalid(ValueError): + pass + + +class EIClass(enum.IntEnum): + C32 = 1 + C64 = 2 + + +class EIData(enum.IntEnum): + Lsb = 1 + Msb = 2 + + +class EMachine(enum.IntEnum): + I386 = 3 + S390 = 22 + Arm = 40 + X8664 = 62 + AArc64 = 183 + + +class ELFFile: + """ + Representation of an ELF executable. + """ + + def __init__(self, f: IO[bytes]) -> None: + self._f = f + + try: + ident = self._read("16B") + except struct.error: + raise ELFInvalid("unable to parse identification") + magic = bytes(ident[:4]) + if magic != b"\x7fELF": + raise ELFInvalid(f"invalid magic: {magic!r}") + + self.capacity = ident[4] # Format for program header (bitness). + self.encoding = ident[5] # Data structure encoding (endianness). + + try: + # e_fmt: Format for program header. + # p_fmt: Format for section header. + # p_idx: Indexes to find p_type, p_offset, and p_filesz. + e_fmt, self._p_fmt, self._p_idx = { + (1, 1): ("HHIIIIIHHH", ">IIIIIIII", (0, 1, 4)), # 32-bit MSB. + (2, 1): ("HHIQQQIHHH", ">IIQQQQQQ", (0, 2, 5)), # 64-bit MSB. + }[(self.capacity, self.encoding)] + except KeyError: + raise ELFInvalid( + f"unrecognized capacity ({self.capacity}) or " + f"encoding ({self.encoding})" + ) + + try: + ( + _, + self.machine, # Architecture type. + _, + _, + self._e_phoff, # Offset of program header. + _, + self.flags, # Processor-specific flags. + _, + self._e_phentsize, # Size of section. + self._e_phnum, # Number of sections. + ) = self._read(e_fmt) + except struct.error as e: + raise ELFInvalid("unable to parse machine and section information") from e + + def _read(self, fmt: str) -> Tuple[int, ...]: + return struct.unpack(fmt, self._f.read(struct.calcsize(fmt))) + + @property + def interpreter(self) -> Optional[str]: + """ + The path recorded in the ``PT_INTERP`` section header. + """ + for index in range(self._e_phnum): + self._f.seek(self._e_phoff + self._e_phentsize * index) + try: + data = self._read(self._p_fmt) + except struct.error: + continue + if data[self._p_idx[0]] != 3: # Not PT_INTERP. + continue + self._f.seek(data[self._p_idx[1]]) + return os.fsdecode(self._f.read(data[self._p_idx[2]])).strip("\0") + return None diff --git a/distutils/_vendor/packaging/_manylinux.py b/distutils/_vendor/packaging/_manylinux.py new file mode 100644 index 00000000..ad62505f --- /dev/null +++ b/distutils/_vendor/packaging/_manylinux.py @@ -0,0 +1,260 @@ +import collections +import contextlib +import functools +import os +import re +import sys +import warnings +from typing import Dict, Generator, Iterator, NamedTuple, Optional, Sequence, Tuple + +from ._elffile import EIClass, EIData, ELFFile, EMachine + +EF_ARM_ABIMASK = 0xFF000000 +EF_ARM_ABI_VER5 = 0x05000000 +EF_ARM_ABI_FLOAT_HARD = 0x00000400 + + +# `os.PathLike` not a generic type until Python 3.9, so sticking with `str` +# as the type for `path` until then. +@contextlib.contextmanager +def _parse_elf(path: str) -> Generator[Optional[ELFFile], None, None]: + try: + with open(path, "rb") as f: + yield ELFFile(f) + except (OSError, TypeError, ValueError): + yield None + + +def _is_linux_armhf(executable: str) -> bool: + # hard-float ABI can be detected from the ELF header of the running + # process + # https://static.docs.arm.com/ihi0044/g/aaelf32.pdf + with _parse_elf(executable) as f: + return ( + f is not None + and f.capacity == EIClass.C32 + and f.encoding == EIData.Lsb + and f.machine == EMachine.Arm + and f.flags & EF_ARM_ABIMASK == EF_ARM_ABI_VER5 + and f.flags & EF_ARM_ABI_FLOAT_HARD == EF_ARM_ABI_FLOAT_HARD + ) + + +def _is_linux_i686(executable: str) -> bool: + with _parse_elf(executable) as f: + return ( + f is not None + and f.capacity == EIClass.C32 + and f.encoding == EIData.Lsb + and f.machine == EMachine.I386 + ) + + +def _have_compatible_abi(executable: str, archs: Sequence[str]) -> bool: + if "armv7l" in archs: + return _is_linux_armhf(executable) + if "i686" in archs: + return _is_linux_i686(executable) + allowed_archs = { + "x86_64", + "aarch64", + "ppc64", + "ppc64le", + "s390x", + "loongarch64", + "riscv64", + } + return any(arch in allowed_archs for arch in archs) + + +# If glibc ever changes its major version, we need to know what the last +# minor version was, so we can build the complete list of all versions. +# For now, guess what the highest minor version might be, assume it will +# be 50 for testing. Once this actually happens, update the dictionary +# with the actual value. +_LAST_GLIBC_MINOR: Dict[int, int] = collections.defaultdict(lambda: 50) + + +class _GLibCVersion(NamedTuple): + major: int + minor: int + + +def _glibc_version_string_confstr() -> Optional[str]: + """ + Primary implementation of glibc_version_string using os.confstr. + """ + # os.confstr is quite a bit faster than ctypes.DLL. It's also less likely + # to be broken or missing. This strategy is used in the standard library + # platform module. + # https://github.com/python/cpython/blob/fcf1d003bf4f0100c/Lib/platform.py#L175-L183 + try: + # Should be a string like "glibc 2.17". + version_string: Optional[str] = os.confstr("CS_GNU_LIBC_VERSION") + assert version_string is not None + _, version = version_string.rsplit() + except (AssertionError, AttributeError, OSError, ValueError): + # os.confstr() or CS_GNU_LIBC_VERSION not available (or a bad value)... + return None + return version + + +def _glibc_version_string_ctypes() -> Optional[str]: + """ + Fallback implementation of glibc_version_string using ctypes. + """ + try: + import ctypes + except ImportError: + return None + + # ctypes.CDLL(None) internally calls dlopen(NULL), and as the dlopen + # manpage says, "If filename is NULL, then the returned handle is for the + # main program". This way we can let the linker do the work to figure out + # which libc our process is actually using. + # + # We must also handle the special case where the executable is not a + # dynamically linked executable. This can occur when using musl libc, + # for example. In this situation, dlopen() will error, leading to an + # OSError. Interestingly, at least in the case of musl, there is no + # errno set on the OSError. The single string argument used to construct + # OSError comes from libc itself and is therefore not portable to + # hard code here. In any case, failure to call dlopen() means we + # can proceed, so we bail on our attempt. + try: + process_namespace = ctypes.CDLL(None) + except OSError: + return None + + try: + gnu_get_libc_version = process_namespace.gnu_get_libc_version + except AttributeError: + # Symbol doesn't exist -> therefore, we are not linked to + # glibc. + return None + + # Call gnu_get_libc_version, which returns a string like "2.5" + gnu_get_libc_version.restype = ctypes.c_char_p + version_str: str = gnu_get_libc_version() + # py2 / py3 compatibility: + if not isinstance(version_str, str): + version_str = version_str.decode("ascii") + + return version_str + + +def _glibc_version_string() -> Optional[str]: + """Returns glibc version string, or None if not using glibc.""" + return _glibc_version_string_confstr() or _glibc_version_string_ctypes() + + +def _parse_glibc_version(version_str: str) -> Tuple[int, int]: + """Parse glibc version. + + We use a regexp instead of str.split because we want to discard any + random junk that might come after the minor version -- this might happen + in patched/forked versions of glibc (e.g. Linaro's version of glibc + uses version strings like "2.20-2014.11"). See gh-3588. + """ + m = re.match(r"(?P[0-9]+)\.(?P[0-9]+)", version_str) + if not m: + warnings.warn( + f"Expected glibc version with 2 components major.minor," + f" got: {version_str}", + RuntimeWarning, + ) + return -1, -1 + return int(m.group("major")), int(m.group("minor")) + + +@functools.lru_cache() +def _get_glibc_version() -> Tuple[int, int]: + version_str = _glibc_version_string() + if version_str is None: + return (-1, -1) + return _parse_glibc_version(version_str) + + +# From PEP 513, PEP 600 +def _is_compatible(arch: str, version: _GLibCVersion) -> bool: + sys_glibc = _get_glibc_version() + if sys_glibc < version: + return False + # Check for presence of _manylinux module. + try: + import _manylinux + except ImportError: + return True + if hasattr(_manylinux, "manylinux_compatible"): + result = _manylinux.manylinux_compatible(version[0], version[1], arch) + if result is not None: + return bool(result) + return True + if version == _GLibCVersion(2, 5): + if hasattr(_manylinux, "manylinux1_compatible"): + return bool(_manylinux.manylinux1_compatible) + if version == _GLibCVersion(2, 12): + if hasattr(_manylinux, "manylinux2010_compatible"): + return bool(_manylinux.manylinux2010_compatible) + if version == _GLibCVersion(2, 17): + if hasattr(_manylinux, "manylinux2014_compatible"): + return bool(_manylinux.manylinux2014_compatible) + return True + + +_LEGACY_MANYLINUX_MAP = { + # CentOS 7 w/ glibc 2.17 (PEP 599) + (2, 17): "manylinux2014", + # CentOS 6 w/ glibc 2.12 (PEP 571) + (2, 12): "manylinux2010", + # CentOS 5 w/ glibc 2.5 (PEP 513) + (2, 5): "manylinux1", +} + + +def platform_tags(archs: Sequence[str]) -> Iterator[str]: + """Generate manylinux tags compatible to the current platform. + + :param archs: Sequence of compatible architectures. + The first one shall be the closest to the actual architecture and be the part of + platform tag after the ``linux_`` prefix, e.g. ``x86_64``. + The ``linux_`` prefix is assumed as a prerequisite for the current platform to + be manylinux-compatible. + + :returns: An iterator of compatible manylinux tags. + """ + if not _have_compatible_abi(sys.executable, archs): + return + # Oldest glibc to be supported regardless of architecture is (2, 17). + too_old_glibc2 = _GLibCVersion(2, 16) + if set(archs) & {"x86_64", "i686"}: + # On x86/i686 also oldest glibc to be supported is (2, 5). + too_old_glibc2 = _GLibCVersion(2, 4) + current_glibc = _GLibCVersion(*_get_glibc_version()) + glibc_max_list = [current_glibc] + # We can assume compatibility across glibc major versions. + # https://sourceware.org/bugzilla/show_bug.cgi?id=24636 + # + # Build a list of maximum glibc versions so that we can + # output the canonical list of all glibc from current_glibc + # down to too_old_glibc2, including all intermediary versions. + for glibc_major in range(current_glibc.major - 1, 1, -1): + glibc_minor = _LAST_GLIBC_MINOR[glibc_major] + glibc_max_list.append(_GLibCVersion(glibc_major, glibc_minor)) + for arch in archs: + for glibc_max in glibc_max_list: + if glibc_max.major == too_old_glibc2.major: + min_minor = too_old_glibc2.minor + else: + # For other glibc major versions oldest supported is (x, 0). + min_minor = -1 + for glibc_minor in range(glibc_max.minor, min_minor, -1): + glibc_version = _GLibCVersion(glibc_max.major, glibc_minor) + tag = "manylinux_{}_{}".format(*glibc_version) + if _is_compatible(arch, glibc_version): + yield f"{tag}_{arch}" + # Handle the legacy manylinux1, manylinux2010, manylinux2014 tags. + if glibc_version in _LEGACY_MANYLINUX_MAP: + legacy_tag = _LEGACY_MANYLINUX_MAP[glibc_version] + if _is_compatible(arch, glibc_version): + yield f"{legacy_tag}_{arch}" diff --git a/distutils/_vendor/packaging/_musllinux.py b/distutils/_vendor/packaging/_musllinux.py new file mode 100644 index 00000000..86419df9 --- /dev/null +++ b/distutils/_vendor/packaging/_musllinux.py @@ -0,0 +1,83 @@ +"""PEP 656 support. + +This module implements logic to detect if the currently running Python is +linked against musl, and what musl version is used. +""" + +import functools +import re +import subprocess +import sys +from typing import Iterator, NamedTuple, Optional, Sequence + +from ._elffile import ELFFile + + +class _MuslVersion(NamedTuple): + major: int + minor: int + + +def _parse_musl_version(output: str) -> Optional[_MuslVersion]: + lines = [n for n in (n.strip() for n in output.splitlines()) if n] + if len(lines) < 2 or lines[0][:4] != "musl": + return None + m = re.match(r"Version (\d+)\.(\d+)", lines[1]) + if not m: + return None + return _MuslVersion(major=int(m.group(1)), minor=int(m.group(2))) + + +@functools.lru_cache() +def _get_musl_version(executable: str) -> Optional[_MuslVersion]: + """Detect currently-running musl runtime version. + + This is done by checking the specified executable's dynamic linking + information, and invoking the loader to parse its output for a version + string. If the loader is musl, the output would be something like:: + + musl libc (x86_64) + Version 1.2.2 + Dynamic Program Loader + """ + try: + with open(executable, "rb") as f: + ld = ELFFile(f).interpreter + except (OSError, TypeError, ValueError): + return None + if ld is None or "musl" not in ld: + return None + proc = subprocess.run([ld], stderr=subprocess.PIPE, text=True) + return _parse_musl_version(proc.stderr) + + +def platform_tags(archs: Sequence[str]) -> Iterator[str]: + """Generate musllinux tags compatible to the current platform. + + :param archs: Sequence of compatible architectures. + The first one shall be the closest to the actual architecture and be the part of + platform tag after the ``linux_`` prefix, e.g. ``x86_64``. + The ``linux_`` prefix is assumed as a prerequisite for the current platform to + be musllinux-compatible. + + :returns: An iterator of compatible musllinux tags. + """ + sys_musl = _get_musl_version(sys.executable) + if sys_musl is None: # Python not dynamically linked against musl. + return + for arch in archs: + for minor in range(sys_musl.minor, -1, -1): + yield f"musllinux_{sys_musl.major}_{minor}_{arch}" + + +if __name__ == "__main__": # pragma: no cover + import sysconfig + + plat = sysconfig.get_platform() + assert plat.startswith("linux-"), "not linux" + + print("plat:", plat) + print("musl:", _get_musl_version(sys.executable)) + print("tags:", end=" ") + for t in platform_tags(re.sub(r"[.-]", "_", plat.split("-", 1)[-1])): + print(t, end="\n ") diff --git a/distutils/_vendor/packaging/_parser.py b/distutils/_vendor/packaging/_parser.py new file mode 100644 index 00000000..684df754 --- /dev/null +++ b/distutils/_vendor/packaging/_parser.py @@ -0,0 +1,356 @@ +"""Handwritten parser of dependency specifiers. + +The docstring for each __parse_* function contains ENBF-inspired grammar representing +the implementation. +""" + +import ast +from typing import Any, List, NamedTuple, Optional, Tuple, Union + +from ._tokenizer import DEFAULT_RULES, Tokenizer + + +class Node: + def __init__(self, value: str) -> None: + self.value = value + + def __str__(self) -> str: + return self.value + + def __repr__(self) -> str: + return f"<{self.__class__.__name__}('{self}')>" + + def serialize(self) -> str: + raise NotImplementedError + + +class Variable(Node): + def serialize(self) -> str: + return str(self) + + +class Value(Node): + def serialize(self) -> str: + return f'"{self}"' + + +class Op(Node): + def serialize(self) -> str: + return str(self) + + +MarkerVar = Union[Variable, Value] +MarkerItem = Tuple[MarkerVar, Op, MarkerVar] +# MarkerAtom = Union[MarkerItem, List["MarkerAtom"]] +# MarkerList = List[Union["MarkerList", MarkerAtom, str]] +# mypy does not support recursive type definition +# https://github.com/python/mypy/issues/731 +MarkerAtom = Any +MarkerList = List[Any] + + +class ParsedRequirement(NamedTuple): + name: str + url: str + extras: List[str] + specifier: str + marker: Optional[MarkerList] + + +# -------------------------------------------------------------------------------------- +# Recursive descent parser for dependency specifier +# -------------------------------------------------------------------------------------- +def parse_requirement(source: str) -> ParsedRequirement: + return _parse_requirement(Tokenizer(source, rules=DEFAULT_RULES)) + + +def _parse_requirement(tokenizer: Tokenizer) -> ParsedRequirement: + """ + requirement = WS? IDENTIFIER WS? extras WS? requirement_details + """ + tokenizer.consume("WS") + + name_token = tokenizer.expect( + "IDENTIFIER", expected="package name at the start of dependency specifier" + ) + name = name_token.text + tokenizer.consume("WS") + + extras = _parse_extras(tokenizer) + tokenizer.consume("WS") + + url, specifier, marker = _parse_requirement_details(tokenizer) + tokenizer.expect("END", expected="end of dependency specifier") + + return ParsedRequirement(name, url, extras, specifier, marker) + + +def _parse_requirement_details( + tokenizer: Tokenizer, +) -> Tuple[str, str, Optional[MarkerList]]: + """ + requirement_details = AT URL (WS requirement_marker?)? + | specifier WS? (requirement_marker)? + """ + + specifier = "" + url = "" + marker = None + + if tokenizer.check("AT"): + tokenizer.read() + tokenizer.consume("WS") + + url_start = tokenizer.position + url = tokenizer.expect("URL", expected="URL after @").text + if tokenizer.check("END", peek=True): + return (url, specifier, marker) + + tokenizer.expect("WS", expected="whitespace after URL") + + # The input might end after whitespace. + if tokenizer.check("END", peek=True): + return (url, specifier, marker) + + marker = _parse_requirement_marker( + tokenizer, span_start=url_start, after="URL and whitespace" + ) + else: + specifier_start = tokenizer.position + specifier = _parse_specifier(tokenizer) + tokenizer.consume("WS") + + if tokenizer.check("END", peek=True): + return (url, specifier, marker) + + marker = _parse_requirement_marker( + tokenizer, + span_start=specifier_start, + after=( + "version specifier" + if specifier + else "name and no valid version specifier" + ), + ) + + return (url, specifier, marker) + + +def _parse_requirement_marker( + tokenizer: Tokenizer, *, span_start: int, after: str +) -> MarkerList: + """ + requirement_marker = SEMICOLON marker WS? + """ + + if not tokenizer.check("SEMICOLON"): + tokenizer.raise_syntax_error( + f"Expected end or semicolon (after {after})", + span_start=span_start, + ) + tokenizer.read() + + marker = _parse_marker(tokenizer) + tokenizer.consume("WS") + + return marker + + +def _parse_extras(tokenizer: Tokenizer) -> List[str]: + """ + extras = (LEFT_BRACKET wsp* extras_list? wsp* RIGHT_BRACKET)? + """ + if not tokenizer.check("LEFT_BRACKET", peek=True): + return [] + + with tokenizer.enclosing_tokens( + "LEFT_BRACKET", + "RIGHT_BRACKET", + around="extras", + ): + tokenizer.consume("WS") + extras = _parse_extras_list(tokenizer) + tokenizer.consume("WS") + + return extras + + +def _parse_extras_list(tokenizer: Tokenizer) -> List[str]: + """ + extras_list = identifier (wsp* ',' wsp* identifier)* + """ + extras: List[str] = [] + + if not tokenizer.check("IDENTIFIER"): + return extras + + extras.append(tokenizer.read().text) + + while True: + tokenizer.consume("WS") + if tokenizer.check("IDENTIFIER", peek=True): + tokenizer.raise_syntax_error("Expected comma between extra names") + elif not tokenizer.check("COMMA"): + break + + tokenizer.read() + tokenizer.consume("WS") + + extra_token = tokenizer.expect("IDENTIFIER", expected="extra name after comma") + extras.append(extra_token.text) + + return extras + + +def _parse_specifier(tokenizer: Tokenizer) -> str: + """ + specifier = LEFT_PARENTHESIS WS? version_many WS? RIGHT_PARENTHESIS + | WS? version_many WS? + """ + with tokenizer.enclosing_tokens( + "LEFT_PARENTHESIS", + "RIGHT_PARENTHESIS", + around="version specifier", + ): + tokenizer.consume("WS") + parsed_specifiers = _parse_version_many(tokenizer) + tokenizer.consume("WS") + + return parsed_specifiers + + +def _parse_version_many(tokenizer: Tokenizer) -> str: + """ + version_many = (SPECIFIER (WS? COMMA WS? SPECIFIER)*)? + """ + parsed_specifiers = "" + while tokenizer.check("SPECIFIER"): + span_start = tokenizer.position + parsed_specifiers += tokenizer.read().text + if tokenizer.check("VERSION_PREFIX_TRAIL", peek=True): + tokenizer.raise_syntax_error( + ".* suffix can only be used with `==` or `!=` operators", + span_start=span_start, + span_end=tokenizer.position + 1, + ) + if tokenizer.check("VERSION_LOCAL_LABEL_TRAIL", peek=True): + tokenizer.raise_syntax_error( + "Local version label can only be used with `==` or `!=` operators", + span_start=span_start, + span_end=tokenizer.position, + ) + tokenizer.consume("WS") + if not tokenizer.check("COMMA"): + break + parsed_specifiers += tokenizer.read().text + tokenizer.consume("WS") + + return parsed_specifiers + + +# -------------------------------------------------------------------------------------- +# Recursive descent parser for marker expression +# -------------------------------------------------------------------------------------- +def parse_marker(source: str) -> MarkerList: + return _parse_full_marker(Tokenizer(source, rules=DEFAULT_RULES)) + + +def _parse_full_marker(tokenizer: Tokenizer) -> MarkerList: + retval = _parse_marker(tokenizer) + tokenizer.expect("END", expected="end of marker expression") + return retval + + +def _parse_marker(tokenizer: Tokenizer) -> MarkerList: + """ + marker = marker_atom (BOOLOP marker_atom)+ + """ + expression = [_parse_marker_atom(tokenizer)] + while tokenizer.check("BOOLOP"): + token = tokenizer.read() + expr_right = _parse_marker_atom(tokenizer) + expression.extend((token.text, expr_right)) + return expression + + +def _parse_marker_atom(tokenizer: Tokenizer) -> MarkerAtom: + """ + marker_atom = WS? LEFT_PARENTHESIS WS? marker WS? RIGHT_PARENTHESIS WS? + | WS? marker_item WS? + """ + + tokenizer.consume("WS") + if tokenizer.check("LEFT_PARENTHESIS", peek=True): + with tokenizer.enclosing_tokens( + "LEFT_PARENTHESIS", + "RIGHT_PARENTHESIS", + around="marker expression", + ): + tokenizer.consume("WS") + marker: MarkerAtom = _parse_marker(tokenizer) + tokenizer.consume("WS") + else: + marker = _parse_marker_item(tokenizer) + tokenizer.consume("WS") + return marker + + +def _parse_marker_item(tokenizer: Tokenizer) -> MarkerItem: + """ + marker_item = WS? marker_var WS? marker_op WS? marker_var WS? + """ + tokenizer.consume("WS") + marker_var_left = _parse_marker_var(tokenizer) + tokenizer.consume("WS") + marker_op = _parse_marker_op(tokenizer) + tokenizer.consume("WS") + marker_var_right = _parse_marker_var(tokenizer) + tokenizer.consume("WS") + return (marker_var_left, marker_op, marker_var_right) + + +def _parse_marker_var(tokenizer: Tokenizer) -> MarkerVar: + """ + marker_var = VARIABLE | QUOTED_STRING + """ + if tokenizer.check("VARIABLE"): + return process_env_var(tokenizer.read().text.replace(".", "_")) + elif tokenizer.check("QUOTED_STRING"): + return process_python_str(tokenizer.read().text) + else: + tokenizer.raise_syntax_error( + message="Expected a marker variable or quoted string" + ) + + +def process_env_var(env_var: str) -> Variable: + if env_var in ("platform_python_implementation", "python_implementation"): + return Variable("platform_python_implementation") + else: + return Variable(env_var) + + +def process_python_str(python_str: str) -> Value: + value = ast.literal_eval(python_str) + return Value(str(value)) + + +def _parse_marker_op(tokenizer: Tokenizer) -> Op: + """ + marker_op = IN | NOT IN | OP + """ + if tokenizer.check("IN"): + tokenizer.read() + return Op("in") + elif tokenizer.check("NOT"): + tokenizer.read() + tokenizer.expect("WS", expected="whitespace after 'not'") + tokenizer.expect("IN", expected="'in' after 'not'") + return Op("not in") + elif tokenizer.check("OP"): + return Op(tokenizer.read().text) + else: + return tokenizer.raise_syntax_error( + "Expected marker operator, one of " + "<=, <, !=, ==, >=, >, ~=, ===, in, not in" + ) diff --git a/distutils/_vendor/packaging/_structures.py b/distutils/_vendor/packaging/_structures.py new file mode 100644 index 00000000..90a6465f --- /dev/null +++ b/distutils/_vendor/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/distutils/_vendor/packaging/_tokenizer.py b/distutils/_vendor/packaging/_tokenizer.py new file mode 100644 index 00000000..dd0d648d --- /dev/null +++ b/distutils/_vendor/packaging/_tokenizer.py @@ -0,0 +1,192 @@ +import contextlib +import re +from dataclasses import dataclass +from typing import Dict, Iterator, NoReturn, Optional, Tuple, Union + +from .specifiers import Specifier + + +@dataclass +class Token: + name: str + text: str + position: int + + +class ParserSyntaxError(Exception): + """The provided source text could not be parsed correctly.""" + + def __init__( + self, + message: str, + *, + source: str, + span: Tuple[int, int], + ) -> None: + self.span = span + self.message = message + self.source = source + + super().__init__() + + def __str__(self) -> str: + marker = " " * self.span[0] + "~" * (self.span[1] - self.span[0]) + "^" + return "\n ".join([self.message, self.source, marker]) + + +DEFAULT_RULES: "Dict[str, Union[str, re.Pattern[str]]]" = { + "LEFT_PARENTHESIS": r"\(", + "RIGHT_PARENTHESIS": r"\)", + "LEFT_BRACKET": r"\[", + "RIGHT_BRACKET": r"\]", + "SEMICOLON": r";", + "COMMA": r",", + "QUOTED_STRING": re.compile( + r""" + ( + ('[^']*') + | + ("[^"]*") + ) + """, + re.VERBOSE, + ), + "OP": r"(===|==|~=|!=|<=|>=|<|>)", + "BOOLOP": r"\b(or|and)\b", + "IN": r"\bin\b", + "NOT": r"\bnot\b", + "VARIABLE": re.compile( + r""" + \b( + python_version + |python_full_version + |os[._]name + |sys[._]platform + |platform_(release|system) + |platform[._](version|machine|python_implementation) + |python_implementation + |implementation_(name|version) + |extra + )\b + """, + re.VERBOSE, + ), + "SPECIFIER": re.compile( + Specifier._operator_regex_str + Specifier._version_regex_str, + re.VERBOSE | re.IGNORECASE, + ), + "AT": r"\@", + "URL": r"[^ \t]+", + "IDENTIFIER": r"\b[a-zA-Z0-9][a-zA-Z0-9._-]*\b", + "VERSION_PREFIX_TRAIL": r"\.\*", + "VERSION_LOCAL_LABEL_TRAIL": r"\+[a-z0-9]+(?:[-_\.][a-z0-9]+)*", + "WS": r"[ \t]+", + "END": r"$", +} + + +class Tokenizer: + """Context-sensitive token parsing. + + Provides methods to examine the input stream to check whether the next token + matches. + """ + + def __init__( + self, + source: str, + *, + rules: "Dict[str, Union[str, re.Pattern[str]]]", + ) -> None: + self.source = source + self.rules: Dict[str, re.Pattern[str]] = { + name: re.compile(pattern) for name, pattern in rules.items() + } + self.next_token: Optional[Token] = None + self.position = 0 + + def consume(self, name: str) -> None: + """Move beyond provided token name, if at current position.""" + if self.check(name): + self.read() + + def check(self, name: str, *, peek: bool = False) -> bool: + """Check whether the next token has the provided name. + + By default, if the check succeeds, the token *must* be read before + another check. If `peek` is set to `True`, the token is not loaded and + would need to be checked again. + """ + assert ( + self.next_token is None + ), f"Cannot check for {name!r}, already have {self.next_token!r}" + assert name in self.rules, f"Unknown token name: {name!r}" + + expression = self.rules[name] + + match = expression.match(self.source, self.position) + if match is None: + return False + if not peek: + self.next_token = Token(name, match[0], self.position) + return True + + def expect(self, name: str, *, expected: str) -> Token: + """Expect a certain token name next, failing with a syntax error otherwise. + + The token is *not* read. + """ + if not self.check(name): + raise self.raise_syntax_error(f"Expected {expected}") + return self.read() + + def read(self) -> Token: + """Consume the next token and return it.""" + token = self.next_token + assert token is not None + + self.position += len(token.text) + self.next_token = None + + return token + + def raise_syntax_error( + self, + message: str, + *, + span_start: Optional[int] = None, + span_end: Optional[int] = None, + ) -> NoReturn: + """Raise ParserSyntaxError at the given position.""" + span = ( + self.position if span_start is None else span_start, + self.position if span_end is None else span_end, + ) + raise ParserSyntaxError( + message, + source=self.source, + span=span, + ) + + @contextlib.contextmanager + def enclosing_tokens( + self, open_token: str, close_token: str, *, around: str + ) -> Iterator[None]: + if self.check(open_token): + open_position = self.position + self.read() + else: + open_position = None + + yield + + if open_position is None: + return + + if not self.check(close_token): + self.raise_syntax_error( + f"Expected matching {close_token} for {open_token}, after {around}", + span_start=open_position, + ) + + self.read() diff --git a/distutils/_vendor/packaging/markers.py b/distutils/_vendor/packaging/markers.py new file mode 100644 index 00000000..8b98fca7 --- /dev/null +++ b/distutils/_vendor/packaging/markers.py @@ -0,0 +1,252 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. + +import operator +import os +import platform +import sys +from typing import Any, Callable, Dict, List, Optional, Tuple, Union + +from ._parser import ( + MarkerAtom, + MarkerList, + Op, + Value, + Variable, + parse_marker as _parse_marker, +) +from ._tokenizer import ParserSyntaxError +from .specifiers import InvalidSpecifier, Specifier +from .utils import canonicalize_name + +__all__ = [ + "InvalidMarker", + "UndefinedComparison", + "UndefinedEnvironmentName", + "Marker", + "default_environment", +] + +Operator = Callable[[str, str], bool] + + +class InvalidMarker(ValueError): + """ + An invalid marker was found, users should refer to PEP 508. + """ + + +class UndefinedComparison(ValueError): + """ + An invalid operation was attempted on a value that doesn't support it. + """ + + +class UndefinedEnvironmentName(ValueError): + """ + A name was attempted to be used that does not exist inside of the + environment. + """ + + +def _normalize_extra_values(results: Any) -> Any: + """ + Normalize extra values. + """ + if isinstance(results[0], tuple): + lhs, op, rhs = results[0] + if isinstance(lhs, Variable) and lhs.value == "extra": + normalized_extra = canonicalize_name(rhs.value) + rhs = Value(normalized_extra) + elif isinstance(rhs, Variable) and rhs.value == "extra": + normalized_extra = canonicalize_name(lhs.value) + lhs = Value(normalized_extra) + results[0] = lhs, op, rhs + return results + + +def _format_marker( + marker: Union[List[str], MarkerAtom, str], first: Optional[bool] = True +) -> str: + + assert isinstance(marker, (list, tuple, str)) + + # Sometimes we have a structure like [[...]] which is a single item list + # where the single item is itself it's own list. In that case we want skip + # the rest of this function so that we don't get extraneous () on the + # outside. + if ( + isinstance(marker, list) + and len(marker) == 1 + and isinstance(marker[0], (list, tuple)) + ): + return _format_marker(marker[0]) + + if isinstance(marker, list): + inner = (_format_marker(m, first=False) for m in marker) + if first: + return " ".join(inner) + else: + return "(" + " ".join(inner) + ")" + elif isinstance(marker, tuple): + return " ".join([m.serialize() for m in marker]) + else: + return marker + + +_operators: Dict[str, Operator] = { + "in": lambda lhs, rhs: lhs in rhs, + "not in": lambda lhs, rhs: lhs not in rhs, + "<": operator.lt, + "<=": operator.le, + "==": operator.eq, + "!=": operator.ne, + ">=": operator.ge, + ">": operator.gt, +} + + +def _eval_op(lhs: str, op: Op, rhs: str) -> bool: + try: + spec = Specifier("".join([op.serialize(), rhs])) + except InvalidSpecifier: + pass + else: + return spec.contains(lhs, prereleases=True) + + oper: Optional[Operator] = _operators.get(op.serialize()) + if oper is None: + raise UndefinedComparison(f"Undefined {op!r} on {lhs!r} and {rhs!r}.") + + return oper(lhs, rhs) + + +def _normalize(*values: str, key: str) -> Tuple[str, ...]: + # PEP 685 – Comparison of extra names for optional distribution dependencies + # https://peps.python.org/pep-0685/ + # > When comparing extra names, tools MUST normalize the names being + # > compared using the semantics outlined in PEP 503 for names + if key == "extra": + return tuple(canonicalize_name(v) for v in values) + + # other environment markers don't have such standards + return values + + +def _evaluate_markers(markers: MarkerList, environment: Dict[str, str]) -> bool: + groups: List[List[bool]] = [[]] + + for marker in markers: + assert isinstance(marker, (list, tuple, str)) + + if isinstance(marker, list): + groups[-1].append(_evaluate_markers(marker, environment)) + elif isinstance(marker, tuple): + lhs, op, rhs = marker + + if isinstance(lhs, Variable): + environment_key = lhs.value + lhs_value = environment[environment_key] + rhs_value = rhs.value + else: + lhs_value = lhs.value + environment_key = rhs.value + rhs_value = environment[environment_key] + + lhs_value, rhs_value = _normalize(lhs_value, rhs_value, key=environment_key) + groups[-1].append(_eval_op(lhs_value, op, rhs_value)) + else: + assert marker in ["and", "or"] + if marker == "or": + groups.append([]) + + return any(all(item) for item in groups) + + +def format_full_version(info: "sys._version_info") -> str: + version = "{0.major}.{0.minor}.{0.micro}".format(info) + kind = info.releaselevel + if kind != "final": + version += kind[0] + str(info.serial) + return version + + +def default_environment() -> Dict[str, str]: + iver = format_full_version(sys.implementation.version) + implementation_name = sys.implementation.name + return { + "implementation_name": implementation_name, + "implementation_version": iver, + "os_name": os.name, + "platform_machine": platform.machine(), + "platform_release": platform.release(), + "platform_system": platform.system(), + "platform_version": platform.version(), + "python_full_version": platform.python_version(), + "platform_python_implementation": platform.python_implementation(), + "python_version": ".".join(platform.python_version_tuple()[:2]), + "sys_platform": sys.platform, + } + + +class Marker: + def __init__(self, marker: str) -> None: + # Note: We create a Marker object without calling this constructor in + # packaging.requirements.Requirement. If any additional logic is + # added here, make sure to mirror/adapt Requirement. + try: + self._markers = _normalize_extra_values(_parse_marker(marker)) + # The attribute `_markers` can be described in terms of a recursive type: + # MarkerList = List[Union[Tuple[Node, ...], str, MarkerList]] + # + # For example, the following expression: + # python_version > "3.6" or (python_version == "3.6" and os_name == "unix") + # + # is parsed into: + # [ + # (, ')>, ), + # 'and', + # [ + # (, , ), + # 'or', + # (, , ) + # ] + # ] + except ParserSyntaxError as e: + raise InvalidMarker(str(e)) from e + + def __str__(self) -> str: + return _format_marker(self._markers) + + def __repr__(self) -> str: + return f"" + + def __hash__(self) -> int: + return hash((self.__class__.__name__, str(self))) + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, Marker): + return NotImplemented + + return str(self) == str(other) + + def evaluate(self, environment: Optional[Dict[str, str]] = None) -> bool: + """Evaluate a marker. + + Return the boolean from evaluating the given marker against the + environment. environment is an optional argument to override all or + part of the determined environment. + + The environment is determined from the current Python process. + """ + current_environment = default_environment() + current_environment["extra"] = "" + if environment is not None: + current_environment.update(environment) + # The API used to allow setting extra to None. We need to handle this + # case for backwards compatibility. + if current_environment["extra"] is None: + current_environment["extra"] = "" + + return _evaluate_markers(self._markers, current_environment) diff --git a/distutils/_vendor/packaging/metadata.py b/distutils/_vendor/packaging/metadata.py new file mode 100644 index 00000000..fb274930 --- /dev/null +++ b/distutils/_vendor/packaging/metadata.py @@ -0,0 +1,825 @@ +import email.feedparser +import email.header +import email.message +import email.parser +import email.policy +import sys +import typing +from typing import ( + Any, + Callable, + Dict, + Generic, + List, + Optional, + Tuple, + Type, + Union, + cast, +) + +from . import requirements, specifiers, utils, version as version_module + +T = typing.TypeVar("T") +if sys.version_info[:2] >= (3, 8): # pragma: no cover + from typing import Literal, TypedDict +else: # pragma: no cover + if typing.TYPE_CHECKING: + from typing_extensions import Literal, TypedDict + else: + try: + from typing_extensions import Literal, TypedDict + except ImportError: + + class Literal: + def __init_subclass__(*_args, **_kwargs): + pass + + class TypedDict: + def __init_subclass__(*_args, **_kwargs): + pass + + +try: + ExceptionGroup +except NameError: # pragma: no cover + + class ExceptionGroup(Exception): # noqa: N818 + """A minimal implementation of :external:exc:`ExceptionGroup` from Python 3.11. + + If :external:exc:`ExceptionGroup` is already defined by Python itself, + that version is used instead. + """ + + message: str + exceptions: List[Exception] + + def __init__(self, message: str, exceptions: List[Exception]) -> None: + self.message = message + self.exceptions = exceptions + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.message!r}, {self.exceptions!r})" + +else: # pragma: no cover + ExceptionGroup = ExceptionGroup + + +class InvalidMetadata(ValueError): + """A metadata field contains invalid data.""" + + field: str + """The name of the field that contains invalid data.""" + + def __init__(self, field: str, message: str) -> None: + self.field = field + super().__init__(message) + + +# The RawMetadata class attempts to make as few assumptions about the underlying +# serialization formats as possible. The idea is that as long as a serialization +# formats offer some very basic primitives in *some* way then we can support +# serializing to and from that format. +class RawMetadata(TypedDict, total=False): + """A dictionary of raw core metadata. + + Each field in core metadata maps to a key of this dictionary (when data is + provided). The key is lower-case and underscores are used instead of dashes + compared to the equivalent core metadata field. Any core metadata field that + can be specified multiple times or can hold multiple values in a single + field have a key with a plural name. See :class:`Metadata` whose attributes + match the keys of this dictionary. + + Core metadata fields that can be specified multiple times are stored as a + list or dict depending on which is appropriate for the field. Any fields + which hold multiple values in a single field are stored as a list. + + """ + + # Metadata 1.0 - PEP 241 + metadata_version: str + name: str + version: str + platforms: List[str] + summary: str + description: str + keywords: List[str] + home_page: str + author: str + author_email: str + license: str + + # Metadata 1.1 - PEP 314 + supported_platforms: List[str] + download_url: str + classifiers: List[str] + requires: List[str] + provides: List[str] + obsoletes: List[str] + + # Metadata 1.2 - PEP 345 + maintainer: str + maintainer_email: str + requires_dist: List[str] + provides_dist: List[str] + obsoletes_dist: List[str] + requires_python: str + requires_external: List[str] + project_urls: Dict[str, str] + + # Metadata 2.0 + # PEP 426 attempted to completely revamp the metadata format + # but got stuck without ever being able to build consensus on + # it and ultimately ended up withdrawn. + # + # However, a number of tools had started emitting METADATA with + # `2.0` Metadata-Version, so for historical reasons, this version + # was skipped. + + # Metadata 2.1 - PEP 566 + description_content_type: str + provides_extra: List[str] + + # Metadata 2.2 - PEP 643 + dynamic: List[str] + + # Metadata 2.3 - PEP 685 + # No new fields were added in PEP 685, just some edge case were + # tightened up to provide better interoptability. + + +_STRING_FIELDS = { + "author", + "author_email", + "description", + "description_content_type", + "download_url", + "home_page", + "license", + "maintainer", + "maintainer_email", + "metadata_version", + "name", + "requires_python", + "summary", + "version", +} + +_LIST_FIELDS = { + "classifiers", + "dynamic", + "obsoletes", + "obsoletes_dist", + "platforms", + "provides", + "provides_dist", + "provides_extra", + "requires", + "requires_dist", + "requires_external", + "supported_platforms", +} + +_DICT_FIELDS = { + "project_urls", +} + + +def _parse_keywords(data: str) -> List[str]: + """Split a string of comma-separate keyboards into a list of keywords.""" + return [k.strip() for k in data.split(",")] + + +def _parse_project_urls(data: List[str]) -> Dict[str, str]: + """Parse a list of label/URL string pairings separated by a comma.""" + urls = {} + for pair in data: + # Our logic is slightly tricky here as we want to try and do + # *something* reasonable with malformed data. + # + # The main thing that we have to worry about, is data that does + # not have a ',' at all to split the label from the Value. There + # isn't a singular right answer here, and we will fail validation + # later on (if the caller is validating) so it doesn't *really* + # matter, but since the missing value has to be an empty str + # and our return value is dict[str, str], if we let the key + # be the missing value, then they'd have multiple '' values that + # overwrite each other in a accumulating dict. + # + # The other potentional issue is that it's possible to have the + # same label multiple times in the metadata, with no solid "right" + # answer with what to do in that case. As such, we'll do the only + # thing we can, which is treat the field as unparseable and add it + # to our list of unparsed fields. + parts = [p.strip() for p in pair.split(",", 1)] + parts.extend([""] * (max(0, 2 - len(parts)))) # Ensure 2 items + + # TODO: The spec doesn't say anything about if the keys should be + # considered case sensitive or not... logically they should + # be case-preserving and case-insensitive, but doing that + # would open up more cases where we might have duplicate + # entries. + label, url = parts + if label in urls: + # The label already exists in our set of urls, so this field + # is unparseable, and we can just add the whole thing to our + # unparseable data and stop processing it. + raise KeyError("duplicate labels in project urls") + urls[label] = url + + return urls + + +def _get_payload(msg: email.message.Message, source: Union[bytes, str]) -> str: + """Get the body of the message.""" + # If our source is a str, then our caller has managed encodings for us, + # and we don't need to deal with it. + if isinstance(source, str): + payload: str = msg.get_payload() + return payload + # If our source is a bytes, then we're managing the encoding and we need + # to deal with it. + else: + bpayload: bytes = msg.get_payload(decode=True) + try: + return bpayload.decode("utf8", "strict") + except UnicodeDecodeError: + raise ValueError("payload in an invalid encoding") + + +# The various parse_FORMAT functions here are intended to be as lenient as +# possible in their parsing, while still returning a correctly typed +# RawMetadata. +# +# To aid in this, we also generally want to do as little touching of the +# data as possible, except where there are possibly some historic holdovers +# that make valid data awkward to work with. +# +# While this is a lower level, intermediate format than our ``Metadata`` +# class, some light touch ups can make a massive difference in usability. + +# Map METADATA fields to RawMetadata. +_EMAIL_TO_RAW_MAPPING = { + "author": "author", + "author-email": "author_email", + "classifier": "classifiers", + "description": "description", + "description-content-type": "description_content_type", + "download-url": "download_url", + "dynamic": "dynamic", + "home-page": "home_page", + "keywords": "keywords", + "license": "license", + "maintainer": "maintainer", + "maintainer-email": "maintainer_email", + "metadata-version": "metadata_version", + "name": "name", + "obsoletes": "obsoletes", + "obsoletes-dist": "obsoletes_dist", + "platform": "platforms", + "project-url": "project_urls", + "provides": "provides", + "provides-dist": "provides_dist", + "provides-extra": "provides_extra", + "requires": "requires", + "requires-dist": "requires_dist", + "requires-external": "requires_external", + "requires-python": "requires_python", + "summary": "summary", + "supported-platform": "supported_platforms", + "version": "version", +} +_RAW_TO_EMAIL_MAPPING = {raw: email for email, raw in _EMAIL_TO_RAW_MAPPING.items()} + + +def parse_email(data: Union[bytes, str]) -> Tuple[RawMetadata, Dict[str, List[str]]]: + """Parse a distribution's metadata stored as email headers (e.g. from ``METADATA``). + + This function returns a two-item tuple of dicts. The first dict is of + recognized fields from the core metadata specification. Fields that can be + parsed and translated into Python's built-in types are converted + appropriately. All other fields are left as-is. Fields that are allowed to + appear multiple times are stored as lists. + + The second dict contains all other fields from the metadata. This includes + any unrecognized fields. It also includes any fields which are expected to + be parsed into a built-in type but were not formatted appropriately. Finally, + any fields that are expected to appear only once but are repeated are + included in this dict. + + """ + raw: Dict[str, Union[str, List[str], Dict[str, str]]] = {} + unparsed: Dict[str, List[str]] = {} + + if isinstance(data, str): + parsed = email.parser.Parser(policy=email.policy.compat32).parsestr(data) + else: + parsed = email.parser.BytesParser(policy=email.policy.compat32).parsebytes(data) + + # We have to wrap parsed.keys() in a set, because in the case of multiple + # values for a key (a list), the key will appear multiple times in the + # list of keys, but we're avoiding that by using get_all(). + for name in frozenset(parsed.keys()): + # Header names in RFC are case insensitive, so we'll normalize to all + # lower case to make comparisons easier. + name = name.lower() + + # We use get_all() here, even for fields that aren't multiple use, + # because otherwise someone could have e.g. two Name fields, and we + # would just silently ignore it rather than doing something about it. + headers = parsed.get_all(name) or [] + + # The way the email module works when parsing bytes is that it + # unconditionally decodes the bytes as ascii using the surrogateescape + # handler. When you pull that data back out (such as with get_all() ), + # it looks to see if the str has any surrogate escapes, and if it does + # it wraps it in a Header object instead of returning the string. + # + # As such, we'll look for those Header objects, and fix up the encoding. + value = [] + # Flag if we have run into any issues processing the headers, thus + # signalling that the data belongs in 'unparsed'. + valid_encoding = True + for h in headers: + # It's unclear if this can return more types than just a Header or + # a str, so we'll just assert here to make sure. + assert isinstance(h, (email.header.Header, str)) + + # If it's a header object, we need to do our little dance to get + # the real data out of it. In cases where there is invalid data + # we're going to end up with mojibake, but there's no obvious, good + # way around that without reimplementing parts of the Header object + # ourselves. + # + # That should be fine since, if mojibacked happens, this key is + # going into the unparsed dict anyways. + if isinstance(h, email.header.Header): + # The Header object stores it's data as chunks, and each chunk + # can be independently encoded, so we'll need to check each + # of them. + chunks: List[Tuple[bytes, Optional[str]]] = [] + for bin, encoding in email.header.decode_header(h): + try: + bin.decode("utf8", "strict") + except UnicodeDecodeError: + # Enable mojibake. + encoding = "latin1" + valid_encoding = False + else: + encoding = "utf8" + chunks.append((bin, encoding)) + + # Turn our chunks back into a Header object, then let that + # Header object do the right thing to turn them into a + # string for us. + value.append(str(email.header.make_header(chunks))) + # This is already a string, so just add it. + else: + value.append(h) + + # We've processed all of our values to get them into a list of str, + # but we may have mojibake data, in which case this is an unparsed + # field. + if not valid_encoding: + unparsed[name] = value + continue + + raw_name = _EMAIL_TO_RAW_MAPPING.get(name) + if raw_name is None: + # This is a bit of a weird situation, we've encountered a key that + # we don't know what it means, so we don't know whether it's meant + # to be a list or not. + # + # Since we can't really tell one way or another, we'll just leave it + # as a list, even though it may be a single item list, because that's + # what makes the most sense for email headers. + unparsed[name] = value + continue + + # If this is one of our string fields, then we'll check to see if our + # value is a list of a single item. If it is then we'll assume that + # it was emitted as a single string, and unwrap the str from inside + # the list. + # + # If it's any other kind of data, then we haven't the faintest clue + # what we should parse it as, and we have to just add it to our list + # of unparsed stuff. + if raw_name in _STRING_FIELDS and len(value) == 1: + raw[raw_name] = value[0] + # If this is one of our list of string fields, then we can just assign + # the value, since email *only* has strings, and our get_all() call + # above ensures that this is a list. + elif raw_name in _LIST_FIELDS: + raw[raw_name] = value + # Special Case: Keywords + # The keywords field is implemented in the metadata spec as a str, + # but it conceptually is a list of strings, and is serialized using + # ", ".join(keywords), so we'll do some light data massaging to turn + # this into what it logically is. + elif raw_name == "keywords" and len(value) == 1: + raw[raw_name] = _parse_keywords(value[0]) + # Special Case: Project-URL + # The project urls is implemented in the metadata spec as a list of + # specially-formatted strings that represent a key and a value, which + # is fundamentally a mapping, however the email format doesn't support + # mappings in a sane way, so it was crammed into a list of strings + # instead. + # + # We will do a little light data massaging to turn this into a map as + # it logically should be. + elif raw_name == "project_urls": + try: + raw[raw_name] = _parse_project_urls(value) + except KeyError: + unparsed[name] = value + # Nothing that we've done has managed to parse this, so it'll just + # throw it in our unparseable data and move on. + else: + unparsed[name] = value + + # We need to support getting the Description from the message payload in + # addition to getting it from the the headers. This does mean, though, there + # is the possibility of it being set both ways, in which case we put both + # in 'unparsed' since we don't know which is right. + try: + payload = _get_payload(parsed, data) + except ValueError: + unparsed.setdefault("description", []).append( + parsed.get_payload(decode=isinstance(data, bytes)) + ) + else: + if payload: + # Check to see if we've already got a description, if so then both + # it, and this body move to unparseable. + if "description" in raw: + description_header = cast(str, raw.pop("description")) + unparsed.setdefault("description", []).extend( + [description_header, payload] + ) + elif "description" in unparsed: + unparsed["description"].append(payload) + else: + raw["description"] = payload + + # We need to cast our `raw` to a metadata, because a TypedDict only support + # literal key names, but we're computing our key names on purpose, but the + # way this function is implemented, our `TypedDict` can only have valid key + # names. + return cast(RawMetadata, raw), unparsed + + +_NOT_FOUND = object() + + +# Keep the two values in sync. +_VALID_METADATA_VERSIONS = ["1.0", "1.1", "1.2", "2.1", "2.2", "2.3"] +_MetadataVersion = Literal["1.0", "1.1", "1.2", "2.1", "2.2", "2.3"] + +_REQUIRED_ATTRS = frozenset(["metadata_version", "name", "version"]) + + +class _Validator(Generic[T]): + """Validate a metadata field. + + All _process_*() methods correspond to a core metadata field. The method is + called with the field's raw value. If the raw value is valid it is returned + in its "enriched" form (e.g. ``version.Version`` for the ``Version`` field). + If the raw value is invalid, :exc:`InvalidMetadata` is raised (with a cause + as appropriate). + """ + + name: str + raw_name: str + added: _MetadataVersion + + def __init__( + self, + *, + added: _MetadataVersion = "1.0", + ) -> None: + self.added = added + + def __set_name__(self, _owner: "Metadata", name: str) -> None: + self.name = name + self.raw_name = _RAW_TO_EMAIL_MAPPING[name] + + def __get__(self, instance: "Metadata", _owner: Type["Metadata"]) -> T: + # With Python 3.8, the caching can be replaced with functools.cached_property(). + # No need to check the cache as attribute lookup will resolve into the + # instance's __dict__ before __get__ is called. + cache = instance.__dict__ + value = instance._raw.get(self.name) + + # To make the _process_* methods easier, we'll check if the value is None + # and if this field is NOT a required attribute, and if both of those + # things are true, we'll skip the the converter. This will mean that the + # converters never have to deal with the None union. + if self.name in _REQUIRED_ATTRS or value is not None: + try: + converter: Callable[[Any], T] = getattr(self, f"_process_{self.name}") + except AttributeError: + pass + else: + value = converter(value) + + cache[self.name] = value + try: + del instance._raw[self.name] # type: ignore[misc] + except KeyError: + pass + + return cast(T, value) + + def _invalid_metadata( + self, msg: str, cause: Optional[Exception] = None + ) -> InvalidMetadata: + exc = InvalidMetadata( + self.raw_name, msg.format_map({"field": repr(self.raw_name)}) + ) + exc.__cause__ = cause + return exc + + def _process_metadata_version(self, value: str) -> _MetadataVersion: + # Implicitly makes Metadata-Version required. + if value not in _VALID_METADATA_VERSIONS: + raise self._invalid_metadata(f"{value!r} is not a valid metadata version") + return cast(_MetadataVersion, value) + + def _process_name(self, value: str) -> str: + if not value: + raise self._invalid_metadata("{field} is a required field") + # Validate the name as a side-effect. + try: + utils.canonicalize_name(value, validate=True) + except utils.InvalidName as exc: + raise self._invalid_metadata( + f"{value!r} is invalid for {{field}}", cause=exc + ) + else: + return value + + def _process_version(self, value: str) -> version_module.Version: + if not value: + raise self._invalid_metadata("{field} is a required field") + try: + return version_module.parse(value) + except version_module.InvalidVersion as exc: + raise self._invalid_metadata( + f"{value!r} is invalid for {{field}}", cause=exc + ) + + def _process_summary(self, value: str) -> str: + """Check the field contains no newlines.""" + if "\n" in value: + raise self._invalid_metadata("{field} must be a single line") + return value + + def _process_description_content_type(self, value: str) -> str: + content_types = {"text/plain", "text/x-rst", "text/markdown"} + message = email.message.EmailMessage() + message["content-type"] = value + + content_type, parameters = ( + # Defaults to `text/plain` if parsing failed. + message.get_content_type().lower(), + message["content-type"].params, + ) + # Check if content-type is valid or defaulted to `text/plain` and thus was + # not parseable. + if content_type not in content_types or content_type not in value.lower(): + raise self._invalid_metadata( + f"{{field}} must be one of {list(content_types)}, not {value!r}" + ) + + charset = parameters.get("charset", "UTF-8") + if charset != "UTF-8": + raise self._invalid_metadata( + f"{{field}} can only specify the UTF-8 charset, not {list(charset)}" + ) + + markdown_variants = {"GFM", "CommonMark"} + variant = parameters.get("variant", "GFM") # Use an acceptable default. + if content_type == "text/markdown" and variant not in markdown_variants: + raise self._invalid_metadata( + f"valid Markdown variants for {{field}} are {list(markdown_variants)}, " + f"not {variant!r}", + ) + return value + + def _process_dynamic(self, value: List[str]) -> List[str]: + for dynamic_field in map(str.lower, value): + if dynamic_field in {"name", "version", "metadata-version"}: + raise self._invalid_metadata( + f"{value!r} is not allowed as a dynamic field" + ) + elif dynamic_field not in _EMAIL_TO_RAW_MAPPING: + raise self._invalid_metadata(f"{value!r} is not a valid dynamic field") + return list(map(str.lower, value)) + + def _process_provides_extra( + self, + value: List[str], + ) -> List[utils.NormalizedName]: + normalized_names = [] + try: + for name in value: + normalized_names.append(utils.canonicalize_name(name, validate=True)) + except utils.InvalidName as exc: + raise self._invalid_metadata( + f"{name!r} is invalid for {{field}}", cause=exc + ) + else: + return normalized_names + + def _process_requires_python(self, value: str) -> specifiers.SpecifierSet: + try: + return specifiers.SpecifierSet(value) + except specifiers.InvalidSpecifier as exc: + raise self._invalid_metadata( + f"{value!r} is invalid for {{field}}", cause=exc + ) + + def _process_requires_dist( + self, + value: List[str], + ) -> List[requirements.Requirement]: + reqs = [] + try: + for req in value: + reqs.append(requirements.Requirement(req)) + except requirements.InvalidRequirement as exc: + raise self._invalid_metadata(f"{req!r} is invalid for {{field}}", cause=exc) + else: + return reqs + + +class Metadata: + """Representation of distribution metadata. + + Compared to :class:`RawMetadata`, this class provides objects representing + metadata fields instead of only using built-in types. Any invalid metadata + will cause :exc:`InvalidMetadata` to be raised (with a + :py:attr:`~BaseException.__cause__` attribute as appropriate). + """ + + _raw: RawMetadata + + @classmethod + def from_raw(cls, data: RawMetadata, *, validate: bool = True) -> "Metadata": + """Create an instance from :class:`RawMetadata`. + + If *validate* is true, all metadata will be validated. All exceptions + related to validation will be gathered and raised as an :class:`ExceptionGroup`. + """ + ins = cls() + ins._raw = data.copy() # Mutations occur due to caching enriched values. + + if validate: + exceptions: List[Exception] = [] + try: + metadata_version = ins.metadata_version + metadata_age = _VALID_METADATA_VERSIONS.index(metadata_version) + except InvalidMetadata as metadata_version_exc: + exceptions.append(metadata_version_exc) + metadata_version = None + + # Make sure to check for the fields that are present, the required + # fields (so their absence can be reported). + fields_to_check = frozenset(ins._raw) | _REQUIRED_ATTRS + # Remove fields that have already been checked. + fields_to_check -= {"metadata_version"} + + for key in fields_to_check: + try: + if metadata_version: + # Can't use getattr() as that triggers descriptor protocol which + # will fail due to no value for the instance argument. + try: + field_metadata_version = cls.__dict__[key].added + except KeyError: + exc = InvalidMetadata(key, f"unrecognized field: {key!r}") + exceptions.append(exc) + continue + field_age = _VALID_METADATA_VERSIONS.index( + field_metadata_version + ) + if field_age > metadata_age: + field = _RAW_TO_EMAIL_MAPPING[key] + exc = InvalidMetadata( + field, + "{field} introduced in metadata version " + "{field_metadata_version}, not {metadata_version}", + ) + exceptions.append(exc) + continue + getattr(ins, key) + except InvalidMetadata as exc: + exceptions.append(exc) + + if exceptions: + raise ExceptionGroup("invalid metadata", exceptions) + + return ins + + @classmethod + def from_email( + cls, data: Union[bytes, str], *, validate: bool = True + ) -> "Metadata": + """Parse metadata from email headers. + + If *validate* is true, the metadata will be validated. All exceptions + related to validation will be gathered and raised as an :class:`ExceptionGroup`. + """ + raw, unparsed = parse_email(data) + + if validate: + exceptions: list[Exception] = [] + for unparsed_key in unparsed: + if unparsed_key in _EMAIL_TO_RAW_MAPPING: + message = f"{unparsed_key!r} has invalid data" + else: + message = f"unrecognized field: {unparsed_key!r}" + exceptions.append(InvalidMetadata(unparsed_key, message)) + + if exceptions: + raise ExceptionGroup("unparsed", exceptions) + + try: + return cls.from_raw(raw, validate=validate) + except ExceptionGroup as exc_group: + raise ExceptionGroup( + "invalid or unparsed metadata", exc_group.exceptions + ) from None + + metadata_version: _Validator[_MetadataVersion] = _Validator() + """:external:ref:`core-metadata-metadata-version` + (required; validated to be a valid metadata version)""" + name: _Validator[str] = _Validator() + """:external:ref:`core-metadata-name` + (required; validated using :func:`~packaging.utils.canonicalize_name` and its + *validate* parameter)""" + version: _Validator[version_module.Version] = _Validator() + """:external:ref:`core-metadata-version` (required)""" + dynamic: _Validator[Optional[List[str]]] = _Validator( + added="2.2", + ) + """:external:ref:`core-metadata-dynamic` + (validated against core metadata field names and lowercased)""" + platforms: _Validator[Optional[List[str]]] = _Validator() + """:external:ref:`core-metadata-platform`""" + supported_platforms: _Validator[Optional[List[str]]] = _Validator(added="1.1") + """:external:ref:`core-metadata-supported-platform`""" + summary: _Validator[Optional[str]] = _Validator() + """:external:ref:`core-metadata-summary` (validated to contain no newlines)""" + description: _Validator[Optional[str]] = _Validator() # TODO 2.1: can be in body + """:external:ref:`core-metadata-description`""" + description_content_type: _Validator[Optional[str]] = _Validator(added="2.1") + """:external:ref:`core-metadata-description-content-type` (validated)""" + keywords: _Validator[Optional[List[str]]] = _Validator() + """:external:ref:`core-metadata-keywords`""" + home_page: _Validator[Optional[str]] = _Validator() + """:external:ref:`core-metadata-home-page`""" + download_url: _Validator[Optional[str]] = _Validator(added="1.1") + """:external:ref:`core-metadata-download-url`""" + author: _Validator[Optional[str]] = _Validator() + """:external:ref:`core-metadata-author`""" + author_email: _Validator[Optional[str]] = _Validator() + """:external:ref:`core-metadata-author-email`""" + maintainer: _Validator[Optional[str]] = _Validator(added="1.2") + """:external:ref:`core-metadata-maintainer`""" + maintainer_email: _Validator[Optional[str]] = _Validator(added="1.2") + """:external:ref:`core-metadata-maintainer-email`""" + license: _Validator[Optional[str]] = _Validator() + """:external:ref:`core-metadata-license`""" + classifiers: _Validator[Optional[List[str]]] = _Validator(added="1.1") + """:external:ref:`core-metadata-classifier`""" + requires_dist: _Validator[Optional[List[requirements.Requirement]]] = _Validator( + added="1.2" + ) + """:external:ref:`core-metadata-requires-dist`""" + requires_python: _Validator[Optional[specifiers.SpecifierSet]] = _Validator( + added="1.2" + ) + """:external:ref:`core-metadata-requires-python`""" + # Because `Requires-External` allows for non-PEP 440 version specifiers, we + # don't do any processing on the values. + requires_external: _Validator[Optional[List[str]]] = _Validator(added="1.2") + """:external:ref:`core-metadata-requires-external`""" + project_urls: _Validator[Optional[Dict[str, str]]] = _Validator(added="1.2") + """:external:ref:`core-metadata-project-url`""" + # PEP 685 lets us raise an error if an extra doesn't pass `Name` validation + # regardless of metadata version. + provides_extra: _Validator[Optional[List[utils.NormalizedName]]] = _Validator( + added="2.1", + ) + """:external:ref:`core-metadata-provides-extra`""" + provides_dist: _Validator[Optional[List[str]]] = _Validator(added="1.2") + """:external:ref:`core-metadata-provides-dist`""" + obsoletes_dist: _Validator[Optional[List[str]]] = _Validator(added="1.2") + """:external:ref:`core-metadata-obsoletes-dist`""" + requires: _Validator[Optional[List[str]]] = _Validator(added="1.1") + """``Requires`` (deprecated)""" + provides: _Validator[Optional[List[str]]] = _Validator(added="1.1") + """``Provides`` (deprecated)""" + obsoletes: _Validator[Optional[List[str]]] = _Validator(added="1.1") + """``Obsoletes`` (deprecated)""" diff --git a/distutils/_vendor/packaging/py.typed b/distutils/_vendor/packaging/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/distutils/_vendor/packaging/requirements.py b/distutils/_vendor/packaging/requirements.py new file mode 100644 index 00000000..bdc43a7e --- /dev/null +++ b/distutils/_vendor/packaging/requirements.py @@ -0,0 +1,90 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. + +from typing import Any, Iterator, Optional, Set + +from ._parser import parse_requirement as _parse_requirement +from ._tokenizer import ParserSyntaxError +from .markers import Marker, _normalize_extra_values +from .specifiers import SpecifierSet +from .utils import canonicalize_name + + +class InvalidRequirement(ValueError): + """ + An invalid requirement was found, users should refer to PEP 508. + """ + + +class Requirement: + """Parse a requirement. + + Parse a given requirement string into its parts, such as name, specifier, + URL, and extras. Raises InvalidRequirement on a badly-formed requirement + string. + """ + + # TODO: Can we test whether something is contained within a requirement? + # If so how do we do that? Do we need to test against the _name_ of + # the thing as well as the version? What about the markers? + # TODO: Can we normalize the name and extra name? + + def __init__(self, requirement_string: str) -> None: + try: + parsed = _parse_requirement(requirement_string) + except ParserSyntaxError as e: + raise InvalidRequirement(str(e)) from e + + self.name: str = parsed.name + self.url: Optional[str] = parsed.url or None + self.extras: Set[str] = set(parsed.extras or []) + self.specifier: SpecifierSet = SpecifierSet(parsed.specifier) + self.marker: Optional[Marker] = None + if parsed.marker is not None: + self.marker = Marker.__new__(Marker) + self.marker._markers = _normalize_extra_values(parsed.marker) + + def _iter_parts(self, name: str) -> Iterator[str]: + yield name + + if self.extras: + formatted_extras = ",".join(sorted(self.extras)) + yield f"[{formatted_extras}]" + + if self.specifier: + yield str(self.specifier) + + if self.url: + yield f"@ {self.url}" + if self.marker: + yield " " + + if self.marker: + yield f"; {self.marker}" + + def __str__(self) -> str: + return "".join(self._iter_parts(self.name)) + + def __repr__(self) -> str: + return f"" + + def __hash__(self) -> int: + return hash( + ( + self.__class__.__name__, + *self._iter_parts(canonicalize_name(self.name)), + ) + ) + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, Requirement): + return NotImplemented + + return ( + canonicalize_name(self.name) == canonicalize_name(other.name) + and self.extras == other.extras + and self.specifier == other.specifier + and self.url == other.url + and self.marker == other.marker + ) diff --git a/distutils/_vendor/packaging/specifiers.py b/distutils/_vendor/packaging/specifiers.py new file mode 100644 index 00000000..2d015bab --- /dev/null +++ b/distutils/_vendor/packaging/specifiers.py @@ -0,0 +1,1017 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. +""" +.. testsetup:: + + from packaging.specifiers import Specifier, SpecifierSet, InvalidSpecifier + from packaging.version import Version +""" + +import abc +import itertools +import re +from typing import Callable, Iterable, Iterator, List, Optional, Tuple, TypeVar, Union + +from .utils import canonicalize_version +from .version import Version + +UnparsedVersion = Union[Version, str] +UnparsedVersionVar = TypeVar("UnparsedVersionVar", bound=UnparsedVersion) +CallableOperator = Callable[[Version, str], bool] + + +def _coerce_version(version: UnparsedVersion) -> Version: + if not isinstance(version, Version): + version = Version(version) + return version + + +class InvalidSpecifier(ValueError): + """ + Raised when attempting to create a :class:`Specifier` with a specifier + string that is invalid. + + >>> Specifier("lolwat") + Traceback (most recent call last): + ... + packaging.specifiers.InvalidSpecifier: Invalid specifier: 'lolwat' + """ + + +class BaseSpecifier(metaclass=abc.ABCMeta): + @abc.abstractmethod + def __str__(self) -> str: + """ + Returns the str representation of this Specifier-like object. This + should be representative of the Specifier itself. + """ + + @abc.abstractmethod + def __hash__(self) -> int: + """ + Returns a hash value for this Specifier-like object. + """ + + @abc.abstractmethod + def __eq__(self, other: object) -> bool: + """ + Returns a boolean representing whether or not the two Specifier-like + objects are equal. + + :param other: The other object to check against. + """ + + @property + @abc.abstractmethod + def prereleases(self) -> Optional[bool]: + """Whether or not pre-releases as a whole are allowed. + + This can be set to either ``True`` or ``False`` to explicitly enable or disable + prereleases or it can be set to ``None`` (the default) to use default semantics. + """ + + @prereleases.setter + def prereleases(self, value: bool) -> None: + """Setter for :attr:`prereleases`. + + :param value: The value to set. + """ + + @abc.abstractmethod + def contains(self, item: str, prereleases: Optional[bool] = None) -> bool: + """ + Determines if the given item is contained within this specifier. + """ + + @abc.abstractmethod + def filter( + self, iterable: Iterable[UnparsedVersionVar], prereleases: Optional[bool] = None + ) -> Iterator[UnparsedVersionVar]: + """ + Takes an iterable of items and filters them so that only items which + are contained within this specifier are allowed in it. + """ + + +class Specifier(BaseSpecifier): + """This class abstracts handling of version specifiers. + + .. tip:: + + It is generally not required to instantiate this manually. You should instead + prefer to work with :class:`SpecifierSet` instead, which can parse + comma-separated version specifiers (which is what package metadata contains). + """ + + _operator_regex_str = r""" + (?P(~=|==|!=|<=|>=|<|>|===)) + """ + _version_regex_str = r""" + (?P + (?: + # The identity operators allow for an escape hatch that will + # do an exact string match of the version you wish to install. + # This will not be parsed by PEP 440 and we cannot determine + # any semantic meaning from it. This operator is discouraged + # but included entirely as an escape hatch. + (?<====) # Only match for the identity operator + \s* + [^\s;)]* # The arbitrary version can be just about anything, + # we match everything except for whitespace, a + # semi-colon for marker support, and a closing paren + # since versions can be enclosed in them. + ) + | + (?: + # The (non)equality operators allow for wild card and local + # versions to be specified so we have to define these two + # operators separately to enable that. + (?<===|!=) # Only match for equals and not equals + + \s* + v? + (?:[0-9]+!)? # epoch + [0-9]+(?:\.[0-9]+)* # release + + # You cannot use a wild card and a pre-release, post-release, a dev or + # local version together so group them with a | and make them optional. + (?: + \.\* # Wild card syntax of .* + | + (?: # pre release + [-_\.]? + (alpha|beta|preview|pre|a|b|c|rc) + [-_\.]? + [0-9]* + )? + (?: # post release + (?:-[0-9]+)|(?:[-_\.]?(post|rev|r)[-_\.]?[0-9]*) + )? + (?:[-_\.]?dev[-_\.]?[0-9]*)? # dev release + (?:\+[a-z0-9]+(?:[-_\.][a-z0-9]+)*)? # local + )? + ) + | + (?: + # The compatible operator requires at least two digits in the + # release segment. + (?<=~=) # Only match for the compatible operator + + \s* + v? + (?:[0-9]+!)? # epoch + [0-9]+(?:\.[0-9]+)+ # release (We have a + instead of a *) + (?: # pre release + [-_\.]? + (alpha|beta|preview|pre|a|b|c|rc) + [-_\.]? + [0-9]* + )? + (?: # post release + (?:-[0-9]+)|(?:[-_\.]?(post|rev|r)[-_\.]?[0-9]*) + )? + (?:[-_\.]?dev[-_\.]?[0-9]*)? # dev release + ) + | + (?: + # All other operators only allow a sub set of what the + # (non)equality operators do. Specifically they do not allow + # local versions to be specified nor do they allow the prefix + # matching wild cards. + (?=": "greater_than_equal", + "<": "less_than", + ">": "greater_than", + "===": "arbitrary", + } + + def __init__(self, spec: str = "", prereleases: Optional[bool] = None) -> None: + """Initialize a Specifier instance. + + :param spec: + The string representation of a specifier which will be parsed and + normalized before use. + :param prereleases: + This tells the specifier if it should accept prerelease versions if + applicable or not. The default of ``None`` will autodetect it from the + given specifiers. + :raises InvalidSpecifier: + If the given specifier is invalid (i.e. bad syntax). + """ + match = self._regex.search(spec) + if not match: + raise InvalidSpecifier(f"Invalid specifier: '{spec}'") + + self._spec: Tuple[str, str] = ( + match.group("operator").strip(), + match.group("version").strip(), + ) + + # Store whether or not this Specifier should accept prereleases + self._prereleases = prereleases + + # https://github.com/python/mypy/pull/13475#pullrequestreview-1079784515 + @property # type: ignore[override] + def prereleases(self) -> bool: + # If there is an explicit prereleases set for this, then we'll just + # blindly use that. + if self._prereleases is not None: + return self._prereleases + + # Look at all of our specifiers and determine if they are inclusive + # operators, and if they are if they are including an explicit + # prerelease. + operator, version = self._spec + if operator in ["==", ">=", "<=", "~=", "==="]: + # The == specifier can include a trailing .*, if it does we + # want to remove before parsing. + if operator == "==" and version.endswith(".*"): + version = version[:-2] + + # Parse the version, and if it is a pre-release than this + # specifier allows pre-releases. + if Version(version).is_prerelease: + return True + + return False + + @prereleases.setter + def prereleases(self, value: bool) -> None: + self._prereleases = value + + @property + def operator(self) -> str: + """The operator of this specifier. + + >>> Specifier("==1.2.3").operator + '==' + """ + return self._spec[0] + + @property + def version(self) -> str: + """The version of this specifier. + + >>> Specifier("==1.2.3").version + '1.2.3' + """ + return self._spec[1] + + def __repr__(self) -> str: + """A representation of the Specifier that shows all internal state. + + >>> Specifier('>=1.0.0') + =1.0.0')> + >>> Specifier('>=1.0.0', prereleases=False) + =1.0.0', prereleases=False)> + >>> Specifier('>=1.0.0', prereleases=True) + =1.0.0', prereleases=True)> + """ + pre = ( + f", prereleases={self.prereleases!r}" + if self._prereleases is not None + else "" + ) + + return f"<{self.__class__.__name__}({str(self)!r}{pre})>" + + def __str__(self) -> str: + """A string representation of the Specifier that can be round-tripped. + + >>> str(Specifier('>=1.0.0')) + '>=1.0.0' + >>> str(Specifier('>=1.0.0', prereleases=False)) + '>=1.0.0' + """ + return "{}{}".format(*self._spec) + + @property + def _canonical_spec(self) -> Tuple[str, str]: + canonical_version = canonicalize_version( + self._spec[1], + strip_trailing_zero=(self._spec[0] != "~="), + ) + return self._spec[0], canonical_version + + def __hash__(self) -> int: + return hash(self._canonical_spec) + + def __eq__(self, other: object) -> bool: + """Whether or not the two Specifier-like objects are equal. + + :param other: The other object to check against. + + The value of :attr:`prereleases` is ignored. + + >>> Specifier("==1.2.3") == Specifier("== 1.2.3.0") + True + >>> (Specifier("==1.2.3", prereleases=False) == + ... Specifier("==1.2.3", prereleases=True)) + True + >>> Specifier("==1.2.3") == "==1.2.3" + True + >>> Specifier("==1.2.3") == Specifier("==1.2.4") + False + >>> Specifier("==1.2.3") == Specifier("~=1.2.3") + False + """ + if isinstance(other, str): + try: + other = self.__class__(str(other)) + except InvalidSpecifier: + return NotImplemented + elif not isinstance(other, self.__class__): + return NotImplemented + + return self._canonical_spec == other._canonical_spec + + def _get_operator(self, op: str) -> CallableOperator: + operator_callable: CallableOperator = getattr( + self, f"_compare_{self._operators[op]}" + ) + return operator_callable + + def _compare_compatible(self, prospective: Version, spec: str) -> bool: + + # Compatible releases have an equivalent combination of >= and ==. That + # is that ~=2.2 is equivalent to >=2.2,==2.*. This allows us to + # implement this in terms of the other specifiers instead of + # implementing it ourselves. The only thing we need to do is construct + # the other specifiers. + + # We want everything but the last item in the version, but we want to + # ignore suffix segments. + prefix = _version_join( + list(itertools.takewhile(_is_not_suffix, _version_split(spec)))[:-1] + ) + + # Add the prefix notation to the end of our string + prefix += ".*" + + return self._get_operator(">=")(prospective, spec) and self._get_operator("==")( + prospective, prefix + ) + + def _compare_equal(self, prospective: Version, spec: str) -> bool: + + # We need special logic to handle prefix matching + if spec.endswith(".*"): + # In the case of prefix matching we want to ignore local segment. + normalized_prospective = canonicalize_version( + prospective.public, strip_trailing_zero=False + ) + # Get the normalized version string ignoring the trailing .* + normalized_spec = canonicalize_version(spec[:-2], strip_trailing_zero=False) + # Split the spec out by bangs and dots, and pretend that there is + # an implicit dot in between a release segment and a pre-release segment. + split_spec = _version_split(normalized_spec) + + # Split the prospective version out by bangs and dots, and pretend + # that there is an implicit dot in between a release segment and + # a pre-release segment. + split_prospective = _version_split(normalized_prospective) + + # 0-pad the prospective version before shortening it to get the correct + # shortened version. + padded_prospective, _ = _pad_version(split_prospective, split_spec) + + # Shorten the prospective version to be the same length as the spec + # so that we can determine if the specifier is a prefix of the + # prospective version or not. + shortened_prospective = padded_prospective[: len(split_spec)] + + return shortened_prospective == split_spec + else: + # Convert our spec string into a Version + spec_version = Version(spec) + + # If the specifier does not have a local segment, then we want to + # act as if the prospective version also does not have a local + # segment. + if not spec_version.local: + prospective = Version(prospective.public) + + return prospective == spec_version + + def _compare_not_equal(self, prospective: Version, spec: str) -> bool: + return not self._compare_equal(prospective, spec) + + def _compare_less_than_equal(self, prospective: Version, spec: str) -> bool: + + # NB: Local version identifiers are NOT permitted in the version + # specifier, so local version labels can be universally removed from + # the prospective version. + return Version(prospective.public) <= Version(spec) + + def _compare_greater_than_equal(self, prospective: Version, spec: str) -> bool: + + # NB: Local version identifiers are NOT permitted in the version + # specifier, so local version labels can be universally removed from + # the prospective version. + return Version(prospective.public) >= Version(spec) + + def _compare_less_than(self, prospective: Version, spec_str: str) -> bool: + + # Convert our spec to a Version instance, since we'll want to work with + # it as a version. + spec = Version(spec_str) + + # Check to see if the prospective version is less than the spec + # version. If it's not we can short circuit and just return False now + # instead of doing extra unneeded work. + if not prospective < spec: + return False + + # This special case is here so that, unless the specifier itself + # includes is a pre-release version, that we do not accept pre-release + # versions for the version mentioned in the specifier (e.g. <3.1 should + # not match 3.1.dev0, but should match 3.0.dev0). + if not spec.is_prerelease and prospective.is_prerelease: + if Version(prospective.base_version) == Version(spec.base_version): + return False + + # If we've gotten to here, it means that prospective version is both + # less than the spec version *and* it's not a pre-release of the same + # version in the spec. + return True + + def _compare_greater_than(self, prospective: Version, spec_str: str) -> bool: + + # Convert our spec to a Version instance, since we'll want to work with + # it as a version. + spec = Version(spec_str) + + # Check to see if the prospective version is greater than the spec + # version. If it's not we can short circuit and just return False now + # instead of doing extra unneeded work. + if not prospective > spec: + return False + + # This special case is here so that, unless the specifier itself + # includes is a post-release version, that we do not accept + # post-release versions for the version mentioned in the specifier + # (e.g. >3.1 should not match 3.0.post0, but should match 3.2.post0). + if not spec.is_postrelease and prospective.is_postrelease: + if Version(prospective.base_version) == Version(spec.base_version): + return False + + # Ensure that we do not allow a local version of the version mentioned + # in the specifier, which is technically greater than, to match. + if prospective.local is not None: + if Version(prospective.base_version) == Version(spec.base_version): + return False + + # If we've gotten to here, it means that prospective version is both + # greater than the spec version *and* it's not a pre-release of the + # same version in the spec. + return True + + def _compare_arbitrary(self, prospective: Version, spec: str) -> bool: + return str(prospective).lower() == str(spec).lower() + + def __contains__(self, item: Union[str, Version]) -> bool: + """Return whether or not the item is contained in this specifier. + + :param item: The item to check for. + + This is used for the ``in`` operator and behaves the same as + :meth:`contains` with no ``prereleases`` argument passed. + + >>> "1.2.3" in Specifier(">=1.2.3") + True + >>> Version("1.2.3") in Specifier(">=1.2.3") + True + >>> "1.0.0" in Specifier(">=1.2.3") + False + >>> "1.3.0a1" in Specifier(">=1.2.3") + False + >>> "1.3.0a1" in Specifier(">=1.2.3", prereleases=True) + True + """ + return self.contains(item) + + def contains( + self, item: UnparsedVersion, prereleases: Optional[bool] = None + ) -> bool: + """Return whether or not the item is contained in this specifier. + + :param item: + The item to check for, which can be a version string or a + :class:`Version` instance. + :param prereleases: + Whether or not to match prereleases with this Specifier. If set to + ``None`` (the default), it uses :attr:`prereleases` to determine + whether or not prereleases are allowed. + + >>> Specifier(">=1.2.3").contains("1.2.3") + True + >>> Specifier(">=1.2.3").contains(Version("1.2.3")) + True + >>> Specifier(">=1.2.3").contains("1.0.0") + False + >>> Specifier(">=1.2.3").contains("1.3.0a1") + False + >>> Specifier(">=1.2.3", prereleases=True).contains("1.3.0a1") + True + >>> Specifier(">=1.2.3").contains("1.3.0a1", prereleases=True) + True + """ + + # Determine if prereleases are to be allowed or not. + if prereleases is None: + prereleases = self.prereleases + + # Normalize item to a Version, this allows us to have a shortcut for + # "2.0" in Specifier(">=2") + normalized_item = _coerce_version(item) + + # Determine if we should be supporting prereleases in this specifier + # or not, if we do not support prereleases than we can short circuit + # logic if this version is a prereleases. + if normalized_item.is_prerelease and not prereleases: + return False + + # Actually do the comparison to determine if this item is contained + # within this Specifier or not. + operator_callable: CallableOperator = self._get_operator(self.operator) + return operator_callable(normalized_item, self.version) + + def filter( + self, iterable: Iterable[UnparsedVersionVar], prereleases: Optional[bool] = None + ) -> Iterator[UnparsedVersionVar]: + """Filter items in the given iterable, that match the specifier. + + :param iterable: + An iterable that can contain version strings and :class:`Version` instances. + The items in the iterable will be filtered according to the specifier. + :param prereleases: + Whether or not to allow prereleases in the returned iterator. If set to + ``None`` (the default), it will be intelligently decide whether to allow + prereleases or not (based on the :attr:`prereleases` attribute, and + whether the only versions matching are prereleases). + + This method is smarter than just ``filter(Specifier().contains, [...])`` + because it implements the rule from :pep:`440` that a prerelease item + SHOULD be accepted if no other versions match the given specifier. + + >>> list(Specifier(">=1.2.3").filter(["1.2", "1.3", "1.5a1"])) + ['1.3'] + >>> list(Specifier(">=1.2.3").filter(["1.2", "1.2.3", "1.3", Version("1.4")])) + ['1.2.3', '1.3', ] + >>> list(Specifier(">=1.2.3").filter(["1.2", "1.5a1"])) + ['1.5a1'] + >>> list(Specifier(">=1.2.3").filter(["1.3", "1.5a1"], prereleases=True)) + ['1.3', '1.5a1'] + >>> list(Specifier(">=1.2.3", prereleases=True).filter(["1.3", "1.5a1"])) + ['1.3', '1.5a1'] + """ + + yielded = False + found_prereleases = [] + + kw = {"prereleases": prereleases if prereleases is not None else True} + + # Attempt to iterate over all the values in the iterable and if any of + # them match, yield them. + for version in iterable: + parsed_version = _coerce_version(version) + + if self.contains(parsed_version, **kw): + # If our version is a prerelease, and we were not set to allow + # prereleases, then we'll store it for later in case nothing + # else matches this specifier. + if parsed_version.is_prerelease and not ( + prereleases or self.prereleases + ): + found_prereleases.append(version) + # Either this is not a prerelease, or we should have been + # accepting prereleases from the beginning. + else: + yielded = True + yield version + + # Now that we've iterated over everything, determine if we've yielded + # any values, and if we have not and we have any prereleases stored up + # then we will go ahead and yield the prereleases. + if not yielded and found_prereleases: + for version in found_prereleases: + yield version + + +_prefix_regex = re.compile(r"^([0-9]+)((?:a|b|c|rc)[0-9]+)$") + + +def _version_split(version: str) -> List[str]: + """Split version into components. + + The split components are intended for version comparison. The logic does + not attempt to retain the original version string, so joining the + components back with :func:`_version_join` may not produce the original + version string. + """ + result: List[str] = [] + + epoch, _, rest = version.rpartition("!") + result.append(epoch or "0") + + for item in rest.split("."): + match = _prefix_regex.search(item) + if match: + result.extend(match.groups()) + else: + result.append(item) + return result + + +def _version_join(components: List[str]) -> str: + """Join split version components into a version string. + + This function assumes the input came from :func:`_version_split`, where the + first component must be the epoch (either empty or numeric), and all other + components numeric. + """ + epoch, *rest = components + return f"{epoch}!{'.'.join(rest)}" + + +def _is_not_suffix(segment: str) -> bool: + return not any( + segment.startswith(prefix) for prefix in ("dev", "a", "b", "rc", "post") + ) + + +def _pad_version(left: List[str], right: List[str]) -> Tuple[List[str], List[str]]: + left_split, right_split = [], [] + + # Get the release segment of our versions + left_split.append(list(itertools.takewhile(lambda x: x.isdigit(), left))) + right_split.append(list(itertools.takewhile(lambda x: x.isdigit(), right))) + + # Get the rest of our versions + left_split.append(left[len(left_split[0]) :]) + right_split.append(right[len(right_split[0]) :]) + + # Insert our padding + left_split.insert(1, ["0"] * max(0, len(right_split[0]) - len(left_split[0]))) + right_split.insert(1, ["0"] * max(0, len(left_split[0]) - len(right_split[0]))) + + return ( + list(itertools.chain.from_iterable(left_split)), + list(itertools.chain.from_iterable(right_split)), + ) + + +class SpecifierSet(BaseSpecifier): + """This class abstracts handling of a set of version specifiers. + + It can be passed a single specifier (``>=3.0``), a comma-separated list of + specifiers (``>=3.0,!=3.1``), or no specifier at all. + """ + + def __init__( + self, specifiers: str = "", prereleases: Optional[bool] = None + ) -> None: + """Initialize a SpecifierSet instance. + + :param specifiers: + The string representation of a specifier or a comma-separated list of + specifiers which will be parsed and normalized before use. + :param prereleases: + This tells the SpecifierSet if it should accept prerelease versions if + applicable or not. The default of ``None`` will autodetect it from the + given specifiers. + + :raises InvalidSpecifier: + If the given ``specifiers`` are not parseable than this exception will be + raised. + """ + + # Split on `,` to break each individual specifier into it's own item, and + # strip each item to remove leading/trailing whitespace. + split_specifiers = [s.strip() for s in specifiers.split(",") if s.strip()] + + # Make each individual specifier a Specifier and save in a frozen set for later. + self._specs = frozenset(map(Specifier, split_specifiers)) + + # Store our prereleases value so we can use it later to determine if + # we accept prereleases or not. + self._prereleases = prereleases + + @property + def prereleases(self) -> Optional[bool]: + # If we have been given an explicit prerelease modifier, then we'll + # pass that through here. + if self._prereleases is not None: + return self._prereleases + + # If we don't have any specifiers, and we don't have a forced value, + # then we'll just return None since we don't know if this should have + # pre-releases or not. + if not self._specs: + return None + + # Otherwise we'll see if any of the given specifiers accept + # prereleases, if any of them do we'll return True, otherwise False. + return any(s.prereleases for s in self._specs) + + @prereleases.setter + def prereleases(self, value: bool) -> None: + self._prereleases = value + + def __repr__(self) -> str: + """A representation of the specifier set that shows all internal state. + + Note that the ordering of the individual specifiers within the set may not + match the input string. + + >>> SpecifierSet('>=1.0.0,!=2.0.0') + =1.0.0')> + >>> SpecifierSet('>=1.0.0,!=2.0.0', prereleases=False) + =1.0.0', prereleases=False)> + >>> SpecifierSet('>=1.0.0,!=2.0.0', prereleases=True) + =1.0.0', prereleases=True)> + """ + pre = ( + f", prereleases={self.prereleases!r}" + if self._prereleases is not None + else "" + ) + + return f"" + + def __str__(self) -> str: + """A string representation of the specifier set that can be round-tripped. + + Note that the ordering of the individual specifiers within the set may not + match the input string. + + >>> str(SpecifierSet(">=1.0.0,!=1.0.1")) + '!=1.0.1,>=1.0.0' + >>> str(SpecifierSet(">=1.0.0,!=1.0.1", prereleases=False)) + '!=1.0.1,>=1.0.0' + """ + return ",".join(sorted(str(s) for s in self._specs)) + + def __hash__(self) -> int: + return hash(self._specs) + + def __and__(self, other: Union["SpecifierSet", str]) -> "SpecifierSet": + """Return a SpecifierSet which is a combination of the two sets. + + :param other: The other object to combine with. + + >>> SpecifierSet(">=1.0.0,!=1.0.1") & '<=2.0.0,!=2.0.1' + =1.0.0')> + >>> SpecifierSet(">=1.0.0,!=1.0.1") & SpecifierSet('<=2.0.0,!=2.0.1') + =1.0.0')> + """ + if isinstance(other, str): + other = SpecifierSet(other) + elif not isinstance(other, SpecifierSet): + return NotImplemented + + specifier = SpecifierSet() + specifier._specs = frozenset(self._specs | other._specs) + + if self._prereleases is None and other._prereleases is not None: + specifier._prereleases = other._prereleases + elif self._prereleases is not None and other._prereleases is None: + specifier._prereleases = self._prereleases + elif self._prereleases == other._prereleases: + specifier._prereleases = self._prereleases + else: + raise ValueError( + "Cannot combine SpecifierSets with True and False prerelease " + "overrides." + ) + + return specifier + + def __eq__(self, other: object) -> bool: + """Whether or not the two SpecifierSet-like objects are equal. + + :param other: The other object to check against. + + The value of :attr:`prereleases` is ignored. + + >>> SpecifierSet(">=1.0.0,!=1.0.1") == SpecifierSet(">=1.0.0,!=1.0.1") + True + >>> (SpecifierSet(">=1.0.0,!=1.0.1", prereleases=False) == + ... SpecifierSet(">=1.0.0,!=1.0.1", prereleases=True)) + True + >>> SpecifierSet(">=1.0.0,!=1.0.1") == ">=1.0.0,!=1.0.1" + True + >>> SpecifierSet(">=1.0.0,!=1.0.1") == SpecifierSet(">=1.0.0") + False + >>> SpecifierSet(">=1.0.0,!=1.0.1") == SpecifierSet(">=1.0.0,!=1.0.2") + False + """ + if isinstance(other, (str, Specifier)): + other = SpecifierSet(str(other)) + elif not isinstance(other, SpecifierSet): + return NotImplemented + + return self._specs == other._specs + + def __len__(self) -> int: + """Returns the number of specifiers in this specifier set.""" + return len(self._specs) + + def __iter__(self) -> Iterator[Specifier]: + """ + Returns an iterator over all the underlying :class:`Specifier` instances + in this specifier set. + + >>> sorted(SpecifierSet(">=1.0.0,!=1.0.1"), key=str) + [, =1.0.0')>] + """ + return iter(self._specs) + + def __contains__(self, item: UnparsedVersion) -> bool: + """Return whether or not the item is contained in this specifier. + + :param item: The item to check for. + + This is used for the ``in`` operator and behaves the same as + :meth:`contains` with no ``prereleases`` argument passed. + + >>> "1.2.3" in SpecifierSet(">=1.0.0,!=1.0.1") + True + >>> Version("1.2.3") in SpecifierSet(">=1.0.0,!=1.0.1") + True + >>> "1.0.1" in SpecifierSet(">=1.0.0,!=1.0.1") + False + >>> "1.3.0a1" in SpecifierSet(">=1.0.0,!=1.0.1") + False + >>> "1.3.0a1" in SpecifierSet(">=1.0.0,!=1.0.1", prereleases=True) + True + """ + return self.contains(item) + + def contains( + self, + item: UnparsedVersion, + prereleases: Optional[bool] = None, + installed: Optional[bool] = None, + ) -> bool: + """Return whether or not the item is contained in this SpecifierSet. + + :param item: + The item to check for, which can be a version string or a + :class:`Version` instance. + :param prereleases: + Whether or not to match prereleases with this SpecifierSet. If set to + ``None`` (the default), it uses :attr:`prereleases` to determine + whether or not prereleases are allowed. + + >>> SpecifierSet(">=1.0.0,!=1.0.1").contains("1.2.3") + True + >>> SpecifierSet(">=1.0.0,!=1.0.1").contains(Version("1.2.3")) + True + >>> SpecifierSet(">=1.0.0,!=1.0.1").contains("1.0.1") + False + >>> SpecifierSet(">=1.0.0,!=1.0.1").contains("1.3.0a1") + False + >>> SpecifierSet(">=1.0.0,!=1.0.1", prereleases=True).contains("1.3.0a1") + True + >>> SpecifierSet(">=1.0.0,!=1.0.1").contains("1.3.0a1", prereleases=True) + True + """ + # Ensure that our item is a Version instance. + if not isinstance(item, Version): + item = Version(item) + + # Determine if we're forcing a prerelease or not, if we're not forcing + # one for this particular filter call, then we'll use whatever the + # SpecifierSet thinks for whether or not we should support prereleases. + if prereleases is None: + prereleases = self.prereleases + + # We can determine if we're going to allow pre-releases by looking to + # see if any of the underlying items supports them. If none of them do + # and this item is a pre-release then we do not allow it and we can + # short circuit that here. + # Note: This means that 1.0.dev1 would not be contained in something + # like >=1.0.devabc however it would be in >=1.0.debabc,>0.0.dev0 + if not prereleases and item.is_prerelease: + return False + + if installed and item.is_prerelease: + item = Version(item.base_version) + + # We simply dispatch to the underlying specs here to make sure that the + # given version is contained within all of them. + # Note: This use of all() here means that an empty set of specifiers + # will always return True, this is an explicit design decision. + return all(s.contains(item, prereleases=prereleases) for s in self._specs) + + def filter( + self, iterable: Iterable[UnparsedVersionVar], prereleases: Optional[bool] = None + ) -> Iterator[UnparsedVersionVar]: + """Filter items in the given iterable, that match the specifiers in this set. + + :param iterable: + An iterable that can contain version strings and :class:`Version` instances. + The items in the iterable will be filtered according to the specifier. + :param prereleases: + Whether or not to allow prereleases in the returned iterator. If set to + ``None`` (the default), it will be intelligently decide whether to allow + prereleases or not (based on the :attr:`prereleases` attribute, and + whether the only versions matching are prereleases). + + This method is smarter than just ``filter(SpecifierSet(...).contains, [...])`` + because it implements the rule from :pep:`440` that a prerelease item + SHOULD be accepted if no other versions match the given specifier. + + >>> list(SpecifierSet(">=1.2.3").filter(["1.2", "1.3", "1.5a1"])) + ['1.3'] + >>> list(SpecifierSet(">=1.2.3").filter(["1.2", "1.3", Version("1.4")])) + ['1.3', ] + >>> list(SpecifierSet(">=1.2.3").filter(["1.2", "1.5a1"])) + [] + >>> list(SpecifierSet(">=1.2.3").filter(["1.3", "1.5a1"], prereleases=True)) + ['1.3', '1.5a1'] + >>> list(SpecifierSet(">=1.2.3", prereleases=True).filter(["1.3", "1.5a1"])) + ['1.3', '1.5a1'] + + An "empty" SpecifierSet will filter items based on the presence of prerelease + versions in the set. + + >>> list(SpecifierSet("").filter(["1.3", "1.5a1"])) + ['1.3'] + >>> list(SpecifierSet("").filter(["1.5a1"])) + ['1.5a1'] + >>> list(SpecifierSet("", prereleases=True).filter(["1.3", "1.5a1"])) + ['1.3', '1.5a1'] + >>> list(SpecifierSet("").filter(["1.3", "1.5a1"], prereleases=True)) + ['1.3', '1.5a1'] + """ + # Determine if we're forcing a prerelease or not, if we're not forcing + # one for this particular filter call, then we'll use whatever the + # SpecifierSet thinks for whether or not we should support prereleases. + if prereleases is None: + prereleases = self.prereleases + + # If we have any specifiers, then we want to wrap our iterable in the + # filter method for each one, this will act as a logical AND amongst + # each specifier. + if self._specs: + for spec in self._specs: + iterable = spec.filter(iterable, prereleases=bool(prereleases)) + return iter(iterable) + # If we do not have any specifiers, then we need to have a rough filter + # which will filter out any pre-releases, unless there are no final + # releases. + else: + filtered: List[UnparsedVersionVar] = [] + found_prereleases: List[UnparsedVersionVar] = [] + + for item in iterable: + parsed_version = _coerce_version(item) + + # Store any item which is a pre-release for later unless we've + # already found a final version or we are accepting prereleases + if parsed_version.is_prerelease and not prereleases: + if not filtered: + found_prereleases.append(item) + else: + filtered.append(item) + + # If we've found no items except for pre-releases, then we'll go + # ahead and use the pre-releases + if not filtered and found_prereleases and prereleases is None: + return iter(found_prereleases) + + return iter(filtered) diff --git a/distutils/_vendor/packaging/tags.py b/distutils/_vendor/packaging/tags.py new file mode 100644 index 00000000..89f19261 --- /dev/null +++ b/distutils/_vendor/packaging/tags.py @@ -0,0 +1,571 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. + +import logging +import platform +import re +import struct +import subprocess +import sys +import sysconfig +from importlib.machinery import EXTENSION_SUFFIXES +from typing import ( + Dict, + FrozenSet, + Iterable, + Iterator, + List, + Optional, + Sequence, + Tuple, + Union, + cast, +) + +from . import _manylinux, _musllinux + +logger = logging.getLogger(__name__) + +PythonVersion = Sequence[int] +MacVersion = Tuple[int, int] + +INTERPRETER_SHORT_NAMES: Dict[str, str] = { + "python": "py", # Generic. + "cpython": "cp", + "pypy": "pp", + "ironpython": "ip", + "jython": "jy", +} + + +_32_BIT_INTERPRETER = struct.calcsize("P") == 4 + + +class Tag: + """ + A representation of the tag triple for a wheel. + + Instances are considered immutable and thus are hashable. Equality checking + is also supported. + """ + + __slots__ = ["_interpreter", "_abi", "_platform", "_hash"] + + def __init__(self, interpreter: str, abi: str, platform: str) -> None: + self._interpreter = interpreter.lower() + self._abi = abi.lower() + self._platform = platform.lower() + # The __hash__ of every single element in a Set[Tag] will be evaluated each time + # that a set calls its `.disjoint()` method, which may be called hundreds of + # times when scanning a page of links for packages with tags matching that + # Set[Tag]. Pre-computing the value here produces significant speedups for + # downstream consumers. + self._hash = hash((self._interpreter, self._abi, self._platform)) + + @property + def interpreter(self) -> str: + return self._interpreter + + @property + def abi(self) -> str: + return self._abi + + @property + def platform(self) -> str: + return self._platform + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Tag): + return NotImplemented + + return ( + (self._hash == other._hash) # Short-circuit ASAP for perf reasons. + and (self._platform == other._platform) + and (self._abi == other._abi) + and (self._interpreter == other._interpreter) + ) + + def __hash__(self) -> int: + return self._hash + + def __str__(self) -> str: + return f"{self._interpreter}-{self._abi}-{self._platform}" + + def __repr__(self) -> str: + return f"<{self} @ {id(self)}>" + + +def parse_tag(tag: str) -> FrozenSet[Tag]: + """ + Parses the provided tag (e.g. `py3-none-any`) into a frozenset of Tag instances. + + Returning a set is required due to the possibility that the tag is a + compressed tag set. + """ + tags = set() + interpreters, abis, platforms = tag.split("-") + for interpreter in interpreters.split("."): + for abi in abis.split("."): + for platform_ in platforms.split("."): + tags.add(Tag(interpreter, abi, platform_)) + return frozenset(tags) + + +def _get_config_var(name: str, warn: bool = False) -> Union[int, str, None]: + value: Union[int, str, None] = sysconfig.get_config_var(name) + if value is None and warn: + logger.debug( + "Config variable '%s' is unset, Python ABI tag may be incorrect", name + ) + return value + + +def _normalize_string(string: str) -> str: + return string.replace(".", "_").replace("-", "_").replace(" ", "_") + + +def _is_threaded_cpython(abis: List[str]) -> bool: + """ + Determine if the ABI corresponds to a threaded (`--disable-gil`) build. + + The threaded builds are indicated by a "t" in the abiflags. + """ + if len(abis) == 0: + return False + # expect e.g., cp313 + m = re.match(r"cp\d+(.*)", abis[0]) + if not m: + return False + abiflags = m.group(1) + return "t" in abiflags + + +def _abi3_applies(python_version: PythonVersion, threading: bool) -> bool: + """ + Determine if the Python version supports abi3. + + PEP 384 was first implemented in Python 3.2. The threaded (`--disable-gil`) + builds do not support abi3. + """ + return len(python_version) > 1 and tuple(python_version) >= (3, 2) and not threading + + +def _cpython_abis(py_version: PythonVersion, warn: bool = False) -> List[str]: + py_version = tuple(py_version) # To allow for version comparison. + abis = [] + version = _version_nodot(py_version[:2]) + threading = debug = pymalloc = ucs4 = "" + with_debug = _get_config_var("Py_DEBUG", warn) + has_refcount = hasattr(sys, "gettotalrefcount") + # Windows doesn't set Py_DEBUG, so checking for support of debug-compiled + # extension modules is the best option. + # https://github.com/pypa/pip/issues/3383#issuecomment-173267692 + has_ext = "_d.pyd" in EXTENSION_SUFFIXES + if with_debug or (with_debug is None and (has_refcount or has_ext)): + debug = "d" + if py_version >= (3, 13) and _get_config_var("Py_GIL_DISABLED", warn): + threading = "t" + if py_version < (3, 8): + with_pymalloc = _get_config_var("WITH_PYMALLOC", warn) + if with_pymalloc or with_pymalloc is None: + pymalloc = "m" + if py_version < (3, 3): + unicode_size = _get_config_var("Py_UNICODE_SIZE", warn) + if unicode_size == 4 or ( + unicode_size is None and sys.maxunicode == 0x10FFFF + ): + ucs4 = "u" + elif debug: + # Debug builds can also load "normal" extension modules. + # We can also assume no UCS-4 or pymalloc requirement. + abis.append(f"cp{version}{threading}") + abis.insert(0, f"cp{version}{threading}{debug}{pymalloc}{ucs4}") + return abis + + +def cpython_tags( + python_version: Optional[PythonVersion] = None, + abis: Optional[Iterable[str]] = None, + platforms: Optional[Iterable[str]] = None, + *, + warn: bool = False, +) -> Iterator[Tag]: + """ + Yields the tags for a CPython interpreter. + + The tags consist of: + - cp-- + - cp-abi3- + - cp-none- + - cp-abi3- # Older Python versions down to 3.2. + + If python_version only specifies a major version then user-provided ABIs and + the 'none' ABItag will be used. + + If 'abi3' or 'none' are specified in 'abis' then they will be yielded at + their normal position and not at the beginning. + """ + if not python_version: + python_version = sys.version_info[:2] + + interpreter = f"cp{_version_nodot(python_version[:2])}" + + if abis is None: + if len(python_version) > 1: + abis = _cpython_abis(python_version, warn) + else: + abis = [] + abis = list(abis) + # 'abi3' and 'none' are explicitly handled later. + for explicit_abi in ("abi3", "none"): + try: + abis.remove(explicit_abi) + except ValueError: + pass + + platforms = list(platforms or platform_tags()) + for abi in abis: + for platform_ in platforms: + yield Tag(interpreter, abi, platform_) + + threading = _is_threaded_cpython(abis) + use_abi3 = _abi3_applies(python_version, threading) + if use_abi3: + yield from (Tag(interpreter, "abi3", platform_) for platform_ in platforms) + yield from (Tag(interpreter, "none", platform_) for platform_ in platforms) + + if use_abi3: + for minor_version in range(python_version[1] - 1, 1, -1): + for platform_ in platforms: + interpreter = "cp{version}".format( + version=_version_nodot((python_version[0], minor_version)) + ) + yield Tag(interpreter, "abi3", platform_) + + +def _generic_abi() -> List[str]: + """ + Return the ABI tag based on EXT_SUFFIX. + """ + # The following are examples of `EXT_SUFFIX`. + # We want to keep the parts which are related to the ABI and remove the + # parts which are related to the platform: + # - linux: '.cpython-310-x86_64-linux-gnu.so' => cp310 + # - mac: '.cpython-310-darwin.so' => cp310 + # - win: '.cp310-win_amd64.pyd' => cp310 + # - win: '.pyd' => cp37 (uses _cpython_abis()) + # - pypy: '.pypy38-pp73-x86_64-linux-gnu.so' => pypy38_pp73 + # - graalpy: '.graalpy-38-native-x86_64-darwin.dylib' + # => graalpy_38_native + + ext_suffix = _get_config_var("EXT_SUFFIX", warn=True) + if not isinstance(ext_suffix, str) or ext_suffix[0] != ".": + raise SystemError("invalid sysconfig.get_config_var('EXT_SUFFIX')") + parts = ext_suffix.split(".") + if len(parts) < 3: + # CPython3.7 and earlier uses ".pyd" on Windows. + return _cpython_abis(sys.version_info[:2]) + soabi = parts[1] + if soabi.startswith("cpython"): + # non-windows + abi = "cp" + soabi.split("-")[1] + elif soabi.startswith("cp"): + # windows + abi = soabi.split("-")[0] + elif soabi.startswith("pypy"): + abi = "-".join(soabi.split("-")[:2]) + elif soabi.startswith("graalpy"): + abi = "-".join(soabi.split("-")[:3]) + elif soabi: + # pyston, ironpython, others? + abi = soabi + else: + return [] + return [_normalize_string(abi)] + + +def generic_tags( + interpreter: Optional[str] = None, + abis: Optional[Iterable[str]] = None, + platforms: Optional[Iterable[str]] = None, + *, + warn: bool = False, +) -> Iterator[Tag]: + """ + Yields the tags for a generic interpreter. + + The tags consist of: + - -- + + The "none" ABI will be added if it was not explicitly provided. + """ + if not interpreter: + interp_name = interpreter_name() + interp_version = interpreter_version(warn=warn) + interpreter = "".join([interp_name, interp_version]) + if abis is None: + abis = _generic_abi() + else: + abis = list(abis) + platforms = list(platforms or platform_tags()) + if "none" not in abis: + abis.append("none") + for abi in abis: + for platform_ in platforms: + yield Tag(interpreter, abi, platform_) + + +def _py_interpreter_range(py_version: PythonVersion) -> Iterator[str]: + """ + Yields Python versions in descending order. + + After the latest version, the major-only version will be yielded, and then + all previous versions of that major version. + """ + if len(py_version) > 1: + yield f"py{_version_nodot(py_version[:2])}" + yield f"py{py_version[0]}" + if len(py_version) > 1: + for minor in range(py_version[1] - 1, -1, -1): + yield f"py{_version_nodot((py_version[0], minor))}" + + +def compatible_tags( + python_version: Optional[PythonVersion] = None, + interpreter: Optional[str] = None, + platforms: Optional[Iterable[str]] = None, +) -> Iterator[Tag]: + """ + Yields the sequence of tags that are compatible with a specific version of Python. + + The tags consist of: + - py*-none- + - -none-any # ... if `interpreter` is provided. + - py*-none-any + """ + if not python_version: + python_version = sys.version_info[:2] + platforms = list(platforms or platform_tags()) + for version in _py_interpreter_range(python_version): + for platform_ in platforms: + yield Tag(version, "none", platform_) + if interpreter: + yield Tag(interpreter, "none", "any") + for version in _py_interpreter_range(python_version): + yield Tag(version, "none", "any") + + +def _mac_arch(arch: str, is_32bit: bool = _32_BIT_INTERPRETER) -> str: + if not is_32bit: + return arch + + if arch.startswith("ppc"): + return "ppc" + + return "i386" + + +def _mac_binary_formats(version: MacVersion, cpu_arch: str) -> List[str]: + formats = [cpu_arch] + if cpu_arch == "x86_64": + if version < (10, 4): + return [] + formats.extend(["intel", "fat64", "fat32"]) + + elif cpu_arch == "i386": + if version < (10, 4): + return [] + formats.extend(["intel", "fat32", "fat"]) + + elif cpu_arch == "ppc64": + # TODO: Need to care about 32-bit PPC for ppc64 through 10.2? + if version > (10, 5) or version < (10, 4): + return [] + formats.append("fat64") + + elif cpu_arch == "ppc": + if version > (10, 6): + return [] + formats.extend(["fat32", "fat"]) + + if cpu_arch in {"arm64", "x86_64"}: + formats.append("universal2") + + if cpu_arch in {"x86_64", "i386", "ppc64", "ppc", "intel"}: + formats.append("universal") + + return formats + + +def mac_platforms( + version: Optional[MacVersion] = None, arch: Optional[str] = None +) -> Iterator[str]: + """ + Yields the platform tags for a macOS system. + + The `version` parameter is a two-item tuple specifying the macOS version to + generate platform tags for. The `arch` parameter is the CPU architecture to + generate platform tags for. Both parameters default to the appropriate value + for the current system. + """ + version_str, _, cpu_arch = platform.mac_ver() + if version is None: + version = cast("MacVersion", tuple(map(int, version_str.split(".")[:2]))) + if version == (10, 16): + # When built against an older macOS SDK, Python will report macOS 10.16 + # instead of the real version. + version_str = subprocess.run( + [ + sys.executable, + "-sS", + "-c", + "import platform; print(platform.mac_ver()[0])", + ], + check=True, + env={"SYSTEM_VERSION_COMPAT": "0"}, + stdout=subprocess.PIPE, + text=True, + ).stdout + version = cast("MacVersion", tuple(map(int, version_str.split(".")[:2]))) + else: + version = version + if arch is None: + arch = _mac_arch(cpu_arch) + else: + arch = arch + + if (10, 0) <= version and version < (11, 0): + # Prior to Mac OS 11, each yearly release of Mac OS bumped the + # "minor" version number. The major version was always 10. + for minor_version in range(version[1], -1, -1): + compat_version = 10, minor_version + binary_formats = _mac_binary_formats(compat_version, arch) + for binary_format in binary_formats: + yield "macosx_{major}_{minor}_{binary_format}".format( + major=10, minor=minor_version, binary_format=binary_format + ) + + if version >= (11, 0): + # Starting with Mac OS 11, each yearly release bumps the major version + # number. The minor versions are now the midyear updates. + for major_version in range(version[0], 10, -1): + compat_version = major_version, 0 + binary_formats = _mac_binary_formats(compat_version, arch) + for binary_format in binary_formats: + yield "macosx_{major}_{minor}_{binary_format}".format( + major=major_version, minor=0, binary_format=binary_format + ) + + if version >= (11, 0): + # Mac OS 11 on x86_64 is compatible with binaries from previous releases. + # Arm64 support was introduced in 11.0, so no Arm binaries from previous + # releases exist. + # + # However, the "universal2" binary format can have a + # macOS version earlier than 11.0 when the x86_64 part of the binary supports + # that version of macOS. + if arch == "x86_64": + for minor_version in range(16, 3, -1): + compat_version = 10, minor_version + binary_formats = _mac_binary_formats(compat_version, arch) + for binary_format in binary_formats: + yield "macosx_{major}_{minor}_{binary_format}".format( + major=compat_version[0], + minor=compat_version[1], + binary_format=binary_format, + ) + else: + for minor_version in range(16, 3, -1): + compat_version = 10, minor_version + binary_format = "universal2" + yield "macosx_{major}_{minor}_{binary_format}".format( + major=compat_version[0], + minor=compat_version[1], + binary_format=binary_format, + ) + + +def _linux_platforms(is_32bit: bool = _32_BIT_INTERPRETER) -> Iterator[str]: + linux = _normalize_string(sysconfig.get_platform()) + if not linux.startswith("linux_"): + # we should never be here, just yield the sysconfig one and return + yield linux + return + if is_32bit: + if linux == "linux_x86_64": + linux = "linux_i686" + elif linux == "linux_aarch64": + linux = "linux_armv8l" + _, arch = linux.split("_", 1) + archs = {"armv8l": ["armv8l", "armv7l"]}.get(arch, [arch]) + yield from _manylinux.platform_tags(archs) + yield from _musllinux.platform_tags(archs) + for arch in archs: + yield f"linux_{arch}" + + +def _generic_platforms() -> Iterator[str]: + yield _normalize_string(sysconfig.get_platform()) + + +def platform_tags() -> Iterator[str]: + """ + Provides the platform tags for this installation. + """ + if platform.system() == "Darwin": + return mac_platforms() + elif platform.system() == "Linux": + return _linux_platforms() + else: + return _generic_platforms() + + +def interpreter_name() -> str: + """ + Returns the name of the running interpreter. + + Some implementations have a reserved, two-letter abbreviation which will + be returned when appropriate. + """ + name = sys.implementation.name + return INTERPRETER_SHORT_NAMES.get(name) or name + + +def interpreter_version(*, warn: bool = False) -> str: + """ + Returns the version of the running interpreter. + """ + version = _get_config_var("py_version_nodot", warn=warn) + if version: + version = str(version) + else: + version = _version_nodot(sys.version_info[:2]) + return version + + +def _version_nodot(version: PythonVersion) -> str: + return "".join(map(str, version)) + + +def sys_tags(*, warn: bool = False) -> Iterator[Tag]: + """ + Returns the sequence of tag triples for the running interpreter. + + The order of the sequence corresponds to priority order for the + interpreter, from most to least important. + """ + + interp_name = interpreter_name() + if interp_name == "cp": + yield from cpython_tags(warn=warn) + else: + yield from generic_tags() + + if interp_name == "pp": + interp = "pp3" + elif interp_name == "cp": + interp = "cp" + interpreter_version(warn=warn) + else: + interp = None + yield from compatible_tags(interpreter=interp) diff --git a/distutils/_vendor/packaging/utils.py b/distutils/_vendor/packaging/utils.py new file mode 100644 index 00000000..c2c2f75a --- /dev/null +++ b/distutils/_vendor/packaging/utils.py @@ -0,0 +1,172 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. + +import re +from typing import FrozenSet, NewType, Tuple, Union, cast + +from .tags import Tag, parse_tag +from .version import InvalidVersion, Version + +BuildTag = Union[Tuple[()], Tuple[int, str]] +NormalizedName = NewType("NormalizedName", str) + + +class InvalidName(ValueError): + """ + An invalid distribution name; users should refer to the packaging user guide. + """ + + +class InvalidWheelFilename(ValueError): + """ + An invalid wheel filename was found, users should refer to PEP 427. + """ + + +class InvalidSdistFilename(ValueError): + """ + An invalid sdist filename was found, users should refer to the packaging user guide. + """ + + +# Core metadata spec for `Name` +_validate_regex = re.compile( + r"^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$", re.IGNORECASE +) +_canonicalize_regex = re.compile(r"[-_.]+") +_normalized_regex = re.compile(r"^([a-z0-9]|[a-z0-9]([a-z0-9-](?!--))*[a-z0-9])$") +# PEP 427: The build number must start with a digit. +_build_tag_regex = re.compile(r"(\d+)(.*)") + + +def canonicalize_name(name: str, *, validate: bool = False) -> NormalizedName: + if validate and not _validate_regex.match(name): + raise InvalidName(f"name is invalid: {name!r}") + # This is taken from PEP 503. + value = _canonicalize_regex.sub("-", name).lower() + return cast(NormalizedName, value) + + +def is_normalized_name(name: str) -> bool: + return _normalized_regex.match(name) is not None + + +def canonicalize_version( + version: Union[Version, str], *, strip_trailing_zero: bool = True +) -> str: + """ + This is very similar to Version.__str__, but has one subtle difference + with the way it handles the release segment. + """ + if isinstance(version, str): + try: + parsed = Version(version) + except InvalidVersion: + # Legacy versions cannot be normalized + return version + else: + parsed = version + + parts = [] + + # Epoch + if parsed.epoch != 0: + parts.append(f"{parsed.epoch}!") + + # Release segment + release_segment = ".".join(str(x) for x in parsed.release) + if strip_trailing_zero: + # NB: This strips trailing '.0's to normalize + release_segment = re.sub(r"(\.0)+$", "", release_segment) + parts.append(release_segment) + + # Pre-release + if parsed.pre is not None: + parts.append("".join(str(x) for x in parsed.pre)) + + # Post-release + if parsed.post is not None: + parts.append(f".post{parsed.post}") + + # Development release + if parsed.dev is not None: + parts.append(f".dev{parsed.dev}") + + # Local version segment + if parsed.local is not None: + parts.append(f"+{parsed.local}") + + return "".join(parts) + + +def parse_wheel_filename( + filename: str, +) -> Tuple[NormalizedName, Version, BuildTag, FrozenSet[Tag]]: + if not filename.endswith(".whl"): + raise InvalidWheelFilename( + f"Invalid wheel filename (extension must be '.whl'): {filename}" + ) + + filename = filename[:-4] + dashes = filename.count("-") + if dashes not in (4, 5): + raise InvalidWheelFilename( + f"Invalid wheel filename (wrong number of parts): {filename}" + ) + + parts = filename.split("-", dashes - 2) + name_part = parts[0] + # See PEP 427 for the rules on escaping the project name. + if "__" in name_part or re.match(r"^[\w\d._]*$", name_part, re.UNICODE) is None: + raise InvalidWheelFilename(f"Invalid project name: {filename}") + name = canonicalize_name(name_part) + + try: + version = Version(parts[1]) + except InvalidVersion as e: + raise InvalidWheelFilename( + f"Invalid wheel filename (invalid version): {filename}" + ) from e + + if dashes == 5: + build_part = parts[2] + build_match = _build_tag_regex.match(build_part) + if build_match is None: + raise InvalidWheelFilename( + f"Invalid build number: {build_part} in '{filename}'" + ) + build = cast(BuildTag, (int(build_match.group(1)), build_match.group(2))) + else: + build = () + tags = parse_tag(parts[-1]) + return (name, version, build, tags) + + +def parse_sdist_filename(filename: str) -> Tuple[NormalizedName, Version]: + if filename.endswith(".tar.gz"): + file_stem = filename[: -len(".tar.gz")] + elif filename.endswith(".zip"): + file_stem = filename[: -len(".zip")] + else: + raise InvalidSdistFilename( + f"Invalid sdist filename (extension must be '.tar.gz' or '.zip'):" + f" {filename}" + ) + + # We are requiring a PEP 440 version, which cannot contain dashes, + # so we split on the last dash. + name_part, sep, version_part = file_stem.rpartition("-") + if not sep: + raise InvalidSdistFilename(f"Invalid sdist filename: {filename}") + + name = canonicalize_name(name_part) + + try: + version = Version(version_part) + except InvalidVersion as e: + raise InvalidSdistFilename( + f"Invalid sdist filename (invalid version): {filename}" + ) from e + + return (name, version) diff --git a/distutils/_vendor/packaging/version.py b/distutils/_vendor/packaging/version.py new file mode 100644 index 00000000..5faab9bd --- /dev/null +++ b/distutils/_vendor/packaging/version.py @@ -0,0 +1,563 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. +""" +.. testsetup:: + + from packaging.version import parse, Version +""" + +import itertools +import re +from typing import Any, Callable, NamedTuple, Optional, SupportsInt, Tuple, Union + +from ._structures import Infinity, InfinityType, NegativeInfinity, NegativeInfinityType + +__all__ = ["VERSION_PATTERN", "parse", "Version", "InvalidVersion"] + +LocalType = Tuple[Union[int, str], ...] + +CmpPrePostDevType = Union[InfinityType, NegativeInfinityType, Tuple[str, int]] +CmpLocalType = Union[ + NegativeInfinityType, + Tuple[Union[Tuple[int, str], Tuple[NegativeInfinityType, Union[int, str]]], ...], +] +CmpKey = Tuple[ + int, + Tuple[int, ...], + CmpPrePostDevType, + CmpPrePostDevType, + CmpPrePostDevType, + CmpLocalType, +] +VersionComparisonMethod = Callable[[CmpKey, CmpKey], bool] + + +class _Version(NamedTuple): + epoch: int + release: Tuple[int, ...] + dev: Optional[Tuple[str, int]] + pre: Optional[Tuple[str, int]] + post: Optional[Tuple[str, int]] + local: Optional[LocalType] + + +def parse(version: str) -> "Version": + """Parse the given version string. + + >>> parse('1.0.dev1') + + + :param version: The version string to parse. + :raises InvalidVersion: When the version string is not a valid version. + """ + return Version(version) + + +class InvalidVersion(ValueError): + """Raised when a version string is not a valid version. + + >>> Version("invalid") + Traceback (most recent call last): + ... + packaging.version.InvalidVersion: Invalid version: 'invalid' + """ + + +class _BaseVersion: + _key: Tuple[Any, ...] + + def __hash__(self) -> int: + return hash(self._key) + + # Please keep the duplicated `isinstance` check + # in the six comparisons hereunder + # unless you find a way to avoid adding overhead function calls. + def __lt__(self, other: "_BaseVersion") -> bool: + if not isinstance(other, _BaseVersion): + return NotImplemented + + return self._key < other._key + + def __le__(self, other: "_BaseVersion") -> bool: + if not isinstance(other, _BaseVersion): + return NotImplemented + + return self._key <= other._key + + def __eq__(self, other: object) -> bool: + if not isinstance(other, _BaseVersion): + return NotImplemented + + return self._key == other._key + + def __ge__(self, other: "_BaseVersion") -> bool: + if not isinstance(other, _BaseVersion): + return NotImplemented + + return self._key >= other._key + + def __gt__(self, other: "_BaseVersion") -> bool: + if not isinstance(other, _BaseVersion): + return NotImplemented + + return self._key > other._key + + def __ne__(self, other: object) -> bool: + if not isinstance(other, _BaseVersion): + return NotImplemented + + return self._key != other._key + + +# Deliberately not anchored to the start and end of the string, to make it +# easier for 3rd party code to reuse +_VERSION_PATTERN = r""" + v? + (?: + (?:(?P[0-9]+)!)? # epoch + (?P[0-9]+(?:\.[0-9]+)*) # release segment + (?P
                                          # pre-release
+            [-_\.]?
+            (?Palpha|a|beta|b|preview|pre|c|rc)
+            [-_\.]?
+            (?P[0-9]+)?
+        )?
+        (?P                                         # post release
+            (?:-(?P[0-9]+))
+            |
+            (?:
+                [-_\.]?
+                (?Ppost|rev|r)
+                [-_\.]?
+                (?P[0-9]+)?
+            )
+        )?
+        (?P                                          # dev release
+            [-_\.]?
+            (?Pdev)
+            [-_\.]?
+            (?P[0-9]+)?
+        )?
+    )
+    (?:\+(?P[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?       # local version
+"""
+
+VERSION_PATTERN = _VERSION_PATTERN
+"""
+A string containing the regular expression used to match a valid version.
+
+The pattern is not anchored at either end, and is intended for embedding in larger
+expressions (for example, matching a version number as part of a file name). The
+regular expression should be compiled with the ``re.VERBOSE`` and ``re.IGNORECASE``
+flags set.
+
+:meta hide-value:
+"""
+
+
+class Version(_BaseVersion):
+    """This class abstracts handling of a project's versions.
+
+    A :class:`Version` instance is comparison aware and can be compared and
+    sorted using the standard Python interfaces.
+
+    >>> v1 = Version("1.0a5")
+    >>> v2 = Version("1.0")
+    >>> v1
+    
+    >>> v2
+    
+    >>> v1 < v2
+    True
+    >>> v1 == v2
+    False
+    >>> v1 > v2
+    False
+    >>> v1 >= v2
+    False
+    >>> v1 <= v2
+    True
+    """
+
+    _regex = re.compile(r"^\s*" + VERSION_PATTERN + r"\s*$", re.VERBOSE | re.IGNORECASE)
+    _key: CmpKey
+
+    def __init__(self, version: str) -> None:
+        """Initialize a Version object.
+
+        :param version:
+            The string representation of a version which will be parsed and normalized
+            before use.
+        :raises InvalidVersion:
+            If the ``version`` does not conform to PEP 440 in any way then this
+            exception will be raised.
+        """
+
+        # Validate the version and parse it into pieces
+        match = self._regex.search(version)
+        if not match:
+            raise InvalidVersion(f"Invalid version: '{version}'")
+
+        # Store the parsed out pieces of the version
+        self._version = _Version(
+            epoch=int(match.group("epoch")) if match.group("epoch") else 0,
+            release=tuple(int(i) for i in match.group("release").split(".")),
+            pre=_parse_letter_version(match.group("pre_l"), match.group("pre_n")),
+            post=_parse_letter_version(
+                match.group("post_l"), match.group("post_n1") or match.group("post_n2")
+            ),
+            dev=_parse_letter_version(match.group("dev_l"), match.group("dev_n")),
+            local=_parse_local_version(match.group("local")),
+        )
+
+        # Generate a key which will be used for sorting
+        self._key = _cmpkey(
+            self._version.epoch,
+            self._version.release,
+            self._version.pre,
+            self._version.post,
+            self._version.dev,
+            self._version.local,
+        )
+
+    def __repr__(self) -> str:
+        """A representation of the Version that shows all internal state.
+
+        >>> Version('1.0.0')
+        
+        """
+        return f""
+
+    def __str__(self) -> str:
+        """A string representation of the version that can be rounded-tripped.
+
+        >>> str(Version("1.0a5"))
+        '1.0a5'
+        """
+        parts = []
+
+        # Epoch
+        if self.epoch != 0:
+            parts.append(f"{self.epoch}!")
+
+        # Release segment
+        parts.append(".".join(str(x) for x in self.release))
+
+        # Pre-release
+        if self.pre is not None:
+            parts.append("".join(str(x) for x in self.pre))
+
+        # Post-release
+        if self.post is not None:
+            parts.append(f".post{self.post}")
+
+        # Development release
+        if self.dev is not None:
+            parts.append(f".dev{self.dev}")
+
+        # Local version segment
+        if self.local is not None:
+            parts.append(f"+{self.local}")
+
+        return "".join(parts)
+
+    @property
+    def epoch(self) -> int:
+        """The epoch of the version.
+
+        >>> Version("2.0.0").epoch
+        0
+        >>> Version("1!2.0.0").epoch
+        1
+        """
+        return self._version.epoch
+
+    @property
+    def release(self) -> Tuple[int, ...]:
+        """The components of the "release" segment of the version.
+
+        >>> Version("1.2.3").release
+        (1, 2, 3)
+        >>> Version("2.0.0").release
+        (2, 0, 0)
+        >>> Version("1!2.0.0.post0").release
+        (2, 0, 0)
+
+        Includes trailing zeroes but not the epoch or any pre-release / development /
+        post-release suffixes.
+        """
+        return self._version.release
+
+    @property
+    def pre(self) -> Optional[Tuple[str, int]]:
+        """The pre-release segment of the version.
+
+        >>> print(Version("1.2.3").pre)
+        None
+        >>> Version("1.2.3a1").pre
+        ('a', 1)
+        >>> Version("1.2.3b1").pre
+        ('b', 1)
+        >>> Version("1.2.3rc1").pre
+        ('rc', 1)
+        """
+        return self._version.pre
+
+    @property
+    def post(self) -> Optional[int]:
+        """The post-release number of the version.
+
+        >>> print(Version("1.2.3").post)
+        None
+        >>> Version("1.2.3.post1").post
+        1
+        """
+        return self._version.post[1] if self._version.post else None
+
+    @property
+    def dev(self) -> Optional[int]:
+        """The development number of the version.
+
+        >>> print(Version("1.2.3").dev)
+        None
+        >>> Version("1.2.3.dev1").dev
+        1
+        """
+        return self._version.dev[1] if self._version.dev else None
+
+    @property
+    def local(self) -> Optional[str]:
+        """The local version segment of the version.
+
+        >>> print(Version("1.2.3").local)
+        None
+        >>> Version("1.2.3+abc").local
+        'abc'
+        """
+        if self._version.local:
+            return ".".join(str(x) for x in self._version.local)
+        else:
+            return None
+
+    @property
+    def public(self) -> str:
+        """The public portion of the version.
+
+        >>> Version("1.2.3").public
+        '1.2.3'
+        >>> Version("1.2.3+abc").public
+        '1.2.3'
+        >>> Version("1.2.3+abc.dev1").public
+        '1.2.3'
+        """
+        return str(self).split("+", 1)[0]
+
+    @property
+    def base_version(self) -> str:
+        """The "base version" of the version.
+
+        >>> Version("1.2.3").base_version
+        '1.2.3'
+        >>> Version("1.2.3+abc").base_version
+        '1.2.3'
+        >>> Version("1!1.2.3+abc.dev1").base_version
+        '1!1.2.3'
+
+        The "base version" is the public version of the project without any pre or post
+        release markers.
+        """
+        parts = []
+
+        # Epoch
+        if self.epoch != 0:
+            parts.append(f"{self.epoch}!")
+
+        # Release segment
+        parts.append(".".join(str(x) for x in self.release))
+
+        return "".join(parts)
+
+    @property
+    def is_prerelease(self) -> bool:
+        """Whether this version is a pre-release.
+
+        >>> Version("1.2.3").is_prerelease
+        False
+        >>> Version("1.2.3a1").is_prerelease
+        True
+        >>> Version("1.2.3b1").is_prerelease
+        True
+        >>> Version("1.2.3rc1").is_prerelease
+        True
+        >>> Version("1.2.3dev1").is_prerelease
+        True
+        """
+        return self.dev is not None or self.pre is not None
+
+    @property
+    def is_postrelease(self) -> bool:
+        """Whether this version is a post-release.
+
+        >>> Version("1.2.3").is_postrelease
+        False
+        >>> Version("1.2.3.post1").is_postrelease
+        True
+        """
+        return self.post is not None
+
+    @property
+    def is_devrelease(self) -> bool:
+        """Whether this version is a development release.
+
+        >>> Version("1.2.3").is_devrelease
+        False
+        >>> Version("1.2.3.dev1").is_devrelease
+        True
+        """
+        return self.dev is not None
+
+    @property
+    def major(self) -> int:
+        """The first item of :attr:`release` or ``0`` if unavailable.
+
+        >>> Version("1.2.3").major
+        1
+        """
+        return self.release[0] if len(self.release) >= 1 else 0
+
+    @property
+    def minor(self) -> int:
+        """The second item of :attr:`release` or ``0`` if unavailable.
+
+        >>> Version("1.2.3").minor
+        2
+        >>> Version("1").minor
+        0
+        """
+        return self.release[1] if len(self.release) >= 2 else 0
+
+    @property
+    def micro(self) -> int:
+        """The third item of :attr:`release` or ``0`` if unavailable.
+
+        >>> Version("1.2.3").micro
+        3
+        >>> Version("1").micro
+        0
+        """
+        return self.release[2] if len(self.release) >= 3 else 0
+
+
+def _parse_letter_version(
+    letter: Optional[str], number: Union[str, bytes, SupportsInt, None]
+) -> Optional[Tuple[str, int]]:
+
+    if letter:
+        # We consider there to be an implicit 0 in a pre-release if there is
+        # not a numeral associated with it.
+        if number is None:
+            number = 0
+
+        # We normalize any letters to their lower case form
+        letter = letter.lower()
+
+        # We consider some words to be alternate spellings of other words and
+        # in those cases we want to normalize the spellings to our preferred
+        # spelling.
+        if letter == "alpha":
+            letter = "a"
+        elif letter == "beta":
+            letter = "b"
+        elif letter in ["c", "pre", "preview"]:
+            letter = "rc"
+        elif letter in ["rev", "r"]:
+            letter = "post"
+
+        return letter, int(number)
+    if not letter and number:
+        # We assume if we are given a number, but we are not given a letter
+        # then this is using the implicit post release syntax (e.g. 1.0-1)
+        letter = "post"
+
+        return letter, int(number)
+
+    return None
+
+
+_local_version_separators = re.compile(r"[\._-]")
+
+
+def _parse_local_version(local: Optional[str]) -> Optional[LocalType]:
+    """
+    Takes a string like abc.1.twelve and turns it into ("abc", 1, "twelve").
+    """
+    if local is not None:
+        return tuple(
+            part.lower() if not part.isdigit() else int(part)
+            for part in _local_version_separators.split(local)
+        )
+    return None
+
+
+def _cmpkey(
+    epoch: int,
+    release: Tuple[int, ...],
+    pre: Optional[Tuple[str, int]],
+    post: Optional[Tuple[str, int]],
+    dev: Optional[Tuple[str, int]],
+    local: Optional[LocalType],
+) -> CmpKey:
+
+    # When we compare a release version, we want to compare it with all of the
+    # trailing zeros removed. So we'll use a reverse the list, drop all the now
+    # leading zeros until we come to something non zero, then take the rest
+    # re-reverse it back into the correct order and make it a tuple and use
+    # that for our sorting key.
+    _release = tuple(
+        reversed(list(itertools.dropwhile(lambda x: x == 0, reversed(release))))
+    )
+
+    # We need to "trick" the sorting algorithm to put 1.0.dev0 before 1.0a0.
+    # We'll do this by abusing the pre segment, but we _only_ want to do this
+    # if there is not a pre or a post segment. If we have one of those then
+    # the normal sorting rules will handle this case correctly.
+    if pre is None and post is None and dev is not None:
+        _pre: CmpPrePostDevType = NegativeInfinity
+    # Versions without a pre-release (except as noted above) should sort after
+    # those with one.
+    elif pre is None:
+        _pre = Infinity
+    else:
+        _pre = pre
+
+    # Versions without a post segment should sort before those with one.
+    if post is None:
+        _post: CmpPrePostDevType = NegativeInfinity
+
+    else:
+        _post = post
+
+    # Versions without a development segment should sort after those with one.
+    if dev is None:
+        _dev: CmpPrePostDevType = Infinity
+
+    else:
+        _dev = dev
+
+    if local is None:
+        # Versions without a local segment should sort before those with one.
+        _local: CmpLocalType = NegativeInfinity
+    else:
+        # Versions with a local segment need that segment parsed to implement
+        # the sorting rules in PEP440.
+        # - Alpha numeric segments sort before numeric segments
+        # - Alpha numeric segments sort lexicographically
+        # - Numeric segments sort numerically
+        # - Shorter versions sort before longer versions when the prefixes
+        #   match exactly
+        _local = tuple(
+            (i, "") if isinstance(i, int) else (NegativeInfinity, i) for i in local
+        )
+
+    return epoch, _release, _pre, _post, _dev, _local
diff --git a/distutils/_vendor/ruff.toml b/distutils/_vendor/ruff.toml
new file mode 100644
index 00000000..00fee625
--- /dev/null
+++ b/distutils/_vendor/ruff.toml
@@ -0,0 +1 @@
+exclude = ["*"]
diff --git a/distutils/archive_util.py b/distutils/archive_util.py
index 7f9e1e00..07cd97f4 100644
--- a/distutils/archive_util.py
+++ b/distutils/archive_util.py
@@ -4,8 +4,8 @@
 that sort of thing)."""
 
 import os
-from warnings import warn
 import sys
+from warnings import warn
 
 try:
     import zipfile
@@ -13,10 +13,10 @@
     zipfile = None
 
 
+from ._log import log
+from .dir_util import mkpath
 from .errors import DistutilsExecError
 from .spawn import spawn
-from .dir_util import mkpath
-from ._log import log
 
 try:
     from pwd import getpwnam
@@ -56,7 +56,13 @@ def _get_uid(name):
 
 
 def make_tarball(
-    base_name, base_dir, compress="gzip", verbose=0, dry_run=0, owner=None, group=None
+    base_name,
+    base_dir,
+    compress="gzip",
+    verbose=False,
+    dry_run=False,
+    owner=None,
+    group=None,
 ):
     """Create a (possibly compressed) tar file from all the files under
     'base_dir'.
@@ -113,7 +119,7 @@ def _set_uid_gid(tarinfo):
         return tarinfo
 
     if not dry_run:
-        tar = tarfile.open(archive_name, 'w|%s' % tar_compression[compress])
+        tar = tarfile.open(archive_name, f'w|{tar_compression[compress]}')
         try:
             tar.add(base_dir, filter=_set_uid_gid)
         finally:
@@ -134,7 +140,7 @@ def _set_uid_gid(tarinfo):
     return archive_name
 
 
-def make_zipfile(base_name, base_dir, verbose=0, dry_run=0):  # noqa: C901
+def make_zipfile(base_name, base_dir, verbose=False, dry_run=False):  # noqa: C901
     """Create a zip file from all the files under 'base_dir'.
 
     The output zip file will be named 'base_name' + ".zip".  Uses either the
@@ -160,12 +166,9 @@ def make_zipfile(base_name, base_dir, verbose=0, dry_run=0):  # noqa: C901
             # XXX really should distinguish between "couldn't find
             # external 'zip' command" and "zip failed".
             raise DistutilsExecError(
-                (
-                    "unable to create zip file '%s': "
-                    "could neither import the 'zipfile' module nor "
-                    "find a standalone zip utility"
-                )
-                % zip_filename
+                f"unable to create zip file '{zip_filename}': "
+                "could neither import the 'zipfile' module nor "
+                "find a standalone zip utility"
             )
 
     else:
@@ -224,8 +227,8 @@ def make_archive(
     format,
     root_dir=None,
     base_dir=None,
-    verbose=0,
-    dry_run=0,
+    verbose=False,
+    dry_run=False,
     owner=None,
     group=None,
 ):
@@ -260,7 +263,7 @@ def make_archive(
     try:
         format_info = ARCHIVE_FORMATS[format]
     except KeyError:
-        raise ValueError("unknown archive format '%s'" % format)
+        raise ValueError(f"unknown archive format '{format}'")
 
     func = format_info[0]
     for arg, val in format_info[1]:
diff --git a/distutils/bcppcompiler.py b/distutils/bcppcompiler.py
index ba45ea2b..e47dca5d 100644
--- a/distutils/bcppcompiler.py
+++ b/distutils/bcppcompiler.py
@@ -11,22 +11,20 @@
 # someone should sit down and factor out the common code as
 # WindowsCCompiler!  --GPW
 
-
 import os
 import warnings
 
+from ._log import log
+from ._modified import newer
+from .ccompiler import CCompiler, gen_preprocess_options
 from .errors import (
-    DistutilsExecError,
     CompileError,
+    DistutilsExecError,
     LibError,
     LinkError,
     UnknownFileError,
 )
-from .ccompiler import CCompiler, gen_preprocess_options
 from .file_util import write_file
-from .dep_util import newer
-from ._log import log
-
 
 warnings.warn(
     "bcppcompiler is deprecated and slated to be removed "
@@ -63,7 +61,7 @@ class BCPPCompiler(CCompiler):
     static_lib_format = shared_lib_format = '%s%s'
     exe_extension = '.exe'
 
-    def __init__(self, verbose=0, dry_run=0, force=0):
+    def __init__(self, verbose=False, dry_run=False, force=False):
         super().__init__(verbose, dry_run, force)
 
         # These executables are assumed to all be in the path.
@@ -86,13 +84,13 @@ def __init__(self, verbose=0, dry_run=0, force=0):
 
     # -- Worker methods ------------------------------------------------
 
-    def compile(  # noqa: C901
+    def compile(
         self,
         sources,
         output_dir=None,
         macros=None,
         include_dirs=None,
-        debug=0,
+        debug=False,
         extra_preargs=None,
         extra_postargs=None,
         depends=None,
@@ -163,7 +161,7 @@ def compile(  # noqa: C901
     # compile ()
 
     def create_static_lib(
-        self, objects, output_libname, output_dir=None, debug=0, target_lang=None
+        self, objects, output_libname, output_dir=None, debug=False, target_lang=None
     ):
         (objects, output_dir) = self._fix_object_args(objects, output_dir)
         output_filename = self.library_filename(output_libname, output_dir=output_dir)
@@ -191,7 +189,7 @@ def link(  # noqa: C901
         library_dirs=None,
         runtime_library_dirs=None,
         export_symbols=None,
-        debug=0,
+        debug=False,
         extra_preargs=None,
         extra_postargs=None,
         build_temp=None,
@@ -236,11 +234,11 @@ def link(  # noqa: C901
                 head, tail = os.path.split(output_filename)
                 modname, ext = os.path.splitext(tail)
                 temp_dir = os.path.dirname(objects[0])  # preserve tree structure
-                def_file = os.path.join(temp_dir, '%s.def' % modname)
+                def_file = os.path.join(temp_dir, f'{modname}.def')
                 contents = ['EXPORTS']
                 for sym in export_symbols or []:
-                    contents.append('  {}=_{}'.format(sym, sym))
-                self.execute(write_file, (def_file, contents), "writing %s" % def_file)
+                    contents.append(f'  {sym}=_{sym}')
+                self.execute(write_file, (def_file, contents), f"writing {def_file}")
 
             # Borland C++ has problems with '/' in paths
             objects2 = map(os.path.normpath, objects)
@@ -256,7 +254,7 @@ def link(  # noqa: C901
                     objects.append(file)
 
             for ell in library_dirs:
-                ld_args.append("/L%s" % os.path.normpath(ell))
+                ld_args.append(f"/L{os.path.normpath(ell)}")
             ld_args.append("/L.")  # we sometimes use relative paths
 
             # list of object files
@@ -315,7 +313,7 @@ def link(  # noqa: C901
 
     # -- Miscellaneous methods -----------------------------------------
 
-    def find_library_file(self, dirs, lib, debug=0):
+    def find_library_file(self, dirs, lib, debug=False):
         # List of effective library names to try, in order of preference:
         # xxx_bcpp.lib is better than xxx.lib
         # and xxx_d.lib is better than xxx.lib if debug is set
@@ -341,7 +339,7 @@ def find_library_file(self, dirs, lib, debug=0):
             return None
 
     # overwrite the one from CCompiler to support rc and res-files
-    def object_filenames(self, source_filenames, strip_dir=0, output_dir=''):
+    def object_filenames(self, source_filenames, strip_dir=False, output_dir=''):
         if output_dir is None:
             output_dir = ''
         obj_names = []
@@ -349,9 +347,7 @@ def object_filenames(self, source_filenames, strip_dir=0, output_dir=''):
             # use normcase to make sure '.rc' is really '.rc' and not '.RC'
             (base, ext) = os.path.splitext(os.path.normcase(src_name))
             if ext not in (self.src_extensions + ['.rc', '.res']):
-                raise UnknownFileError(
-                    "unknown file type '{}' (from '{}')".format(ext, src_name)
-                )
+                raise UnknownFileError(f"unknown file type '{ext}' (from '{src_name}')")
             if strip_dir:
                 base = os.path.basename(base)
             if ext == '.res':
diff --git a/distutils/ccompiler.py b/distutils/ccompiler.py
index 1818fce9..9d5297b9 100644
--- a/distutils/ccompiler.py
+++ b/distutils/ccompiler.py
@@ -3,24 +3,26 @@
 Contains CCompiler, an abstract base class that defines the interface
 for the Distutils compiler abstraction model."""
 
-import sys
 import os
 import re
+import sys
+import types
 import warnings
 
+from ._itertools import always_iterable
+from ._log import log
+from ._modified import newer_group
+from .dir_util import mkpath
 from .errors import (
     CompileError,
+    DistutilsModuleError,
+    DistutilsPlatformError,
     LinkError,
     UnknownFileError,
-    DistutilsPlatformError,
-    DistutilsModuleError,
 )
-from .spawn import spawn
 from .file_util import move_file
-from .dir_util import mkpath
-from .dep_util import newer_group
-from .util import split_quoted, execute
-from ._log import log
+from .spawn import spawn
+from .util import execute, split_quoted, is_mingw
 
 
 class CCompiler:
@@ -103,7 +105,7 @@ class CCompiler:
     library dirs specific to this compiler class
     """
 
-    def __init__(self, verbose=0, dry_run=0, force=0):
+    def __init__(self, verbose=False, dry_run=False, force=False):
         self.dry_run = dry_run
         self.force = force
         self.verbose = verbose
@@ -168,8 +170,7 @@ class (via the 'executables' class attribute), but most will have:
         for key in kwargs:
             if key not in self.executables:
                 raise ValueError(
-                    "unknown executable '%s' for class %s"
-                    % (key, self.__class__.__name__)
+                    f"unknown executable '{key}' for class {self.__class__.__name__}"
                 )
             self.set_executable(key, kwargs[key])
 
@@ -188,24 +189,28 @@ def _find_macro(self, name):
         return None
 
     def _check_macro_definitions(self, definitions):
-        """Ensures that every element of 'definitions' is a valid macro
-        definition, ie. either (name,value) 2-tuple or a (name,) tuple.  Do
-        nothing if all definitions are OK, raise TypeError otherwise.
-        """
+        """Ensure that every element of 'definitions' is valid."""
         for defn in definitions:
-            if not (
-                isinstance(defn, tuple)
-                and (
-                    len(defn) in (1, 2)
-                    and (isinstance(defn[1], str) or defn[1] is None)
-                )
-                and isinstance(defn[0], str)
-            ):
-                raise TypeError(
-                    ("invalid macro definition '%s': " % defn)
-                    + "must be tuple (string,), (string, string), or "
-                    + "(string, None)"
-                )
+            self._check_macro_definition(*defn)
+
+    def _check_macro_definition(self, defn):
+        """
+        Raise a TypeError if defn is not valid.
+
+        A valid definition is either a (name, value) 2-tuple or a (name,) tuple.
+        """
+        if not isinstance(defn, tuple) or not self._is_valid_macro(*defn):
+            raise TypeError(
+                f"invalid macro definition '{defn}': "
+                "must be tuple (string,), (string, string), or (string, None)"
+            )
+
+    @staticmethod
+    def _is_valid_macro(name, value=None):
+        """
+        A valid macro is a ``name : str`` and a ``value : str | None``.
+        """
+        return isinstance(name, str) and isinstance(value, (str, types.NoneType))
 
     # -- Bookkeeping methods -------------------------------------------
 
@@ -342,7 +347,7 @@ def _setup_compile(self, outdir, macros, incdirs, sources, depends, extra):
             extra = []
 
         # Get the list of expected output (object) files
-        objects = self.object_filenames(sources, strip_dir=0, output_dir=outdir)
+        objects = self.object_filenames(sources, strip_dir=False, output_dir=outdir)
         assert len(objects) == len(sources)
 
         pp_opts = gen_preprocess_options(macros, incdirs)
@@ -382,7 +387,7 @@ def _fix_compile_args(self, output_dir, macros, include_dirs):
             raise TypeError("'output_dir' must be a string or None")
 
         if macros is None:
-            macros = self.macros
+            macros = list(self.macros)
         elif isinstance(macros, list):
             macros = macros + (self.macros or [])
         else:
@@ -441,14 +446,14 @@ def _fix_lib_args(self, libraries, library_dirs, runtime_library_dirs):
         fixed versions of all arguments.
         """
         if libraries is None:
-            libraries = self.libraries
+            libraries = list(self.libraries)
         elif isinstance(libraries, (list, tuple)):
             libraries = list(libraries) + (self.libraries or [])
         else:
             raise TypeError("'libraries' (if supplied) must be a list of strings")
 
         if library_dirs is None:
-            library_dirs = self.library_dirs
+            library_dirs = list(self.library_dirs)
         elif isinstance(library_dirs, (list, tuple)):
             library_dirs = list(library_dirs) + (self.library_dirs or [])
         else:
@@ -458,14 +463,14 @@ def _fix_lib_args(self, libraries, library_dirs, runtime_library_dirs):
         library_dirs += self.__class__.library_dirs
 
         if runtime_library_dirs is None:
-            runtime_library_dirs = self.runtime_library_dirs
+            runtime_library_dirs = list(self.runtime_library_dirs)
         elif isinstance(runtime_library_dirs, (list, tuple)):
             runtime_library_dirs = list(runtime_library_dirs) + (
                 self.runtime_library_dirs or []
             )
         else:
             raise TypeError(
-                "'runtime_library_dirs' (if supplied) " "must be a list of strings"
+                "'runtime_library_dirs' (if supplied) must be a list of strings"
             )
 
         return (libraries, library_dirs, runtime_library_dirs)
@@ -532,7 +537,7 @@ def compile(
         output_dir=None,
         macros=None,
         include_dirs=None,
-        debug=0,
+        debug=False,
         extra_preargs=None,
         extra_postargs=None,
         depends=None,
@@ -609,7 +614,7 @@ def _compile(self, obj, src, ext, cc_args, extra_postargs, pp_opts):
         pass
 
     def create_static_lib(
-        self, objects, output_libname, output_dir=None, debug=0, target_lang=None
+        self, objects, output_libname, output_dir=None, debug=False, target_lang=None
     ):
         """Link a bunch of stuff together to create a static library file.
         The "bunch of stuff" consists of the list of object files supplied
@@ -650,7 +655,7 @@ def link(
         library_dirs=None,
         runtime_library_dirs=None,
         export_symbols=None,
-        debug=0,
+        debug=False,
         extra_preargs=None,
         extra_postargs=None,
         build_temp=None,
@@ -712,7 +717,7 @@ def link_shared_lib(
         library_dirs=None,
         runtime_library_dirs=None,
         export_symbols=None,
-        debug=0,
+        debug=False,
         extra_preargs=None,
         extra_postargs=None,
         build_temp=None,
@@ -743,7 +748,7 @@ def link_shared_object(
         library_dirs=None,
         runtime_library_dirs=None,
         export_symbols=None,
-        debug=0,
+        debug=False,
         extra_preargs=None,
         extra_postargs=None,
         build_temp=None,
@@ -773,7 +778,7 @@ def link_executable(
         libraries=None,
         library_dirs=None,
         runtime_library_dirs=None,
-        debug=0,
+        debug=False,
         extra_preargs=None,
         extra_postargs=None,
         target_lang=None,
@@ -857,10 +862,9 @@ def has_function(  # noqa: C901
         if library_dirs is None:
             library_dirs = []
         fd, fname = tempfile.mkstemp(".c", funcname, text=True)
-        f = os.fdopen(fd, "w")
-        try:
+        with os.fdopen(fd, "w", encoding='utf-8') as f:
             for incl in includes:
-                f.write("""#include "%s"\n""" % incl)
+                f.write(f"""#include "{incl}"\n""")
             if not includes:
                 # Use "char func(void);" as the prototype to follow
                 # what autoconf does.  This prototype does not match
@@ -870,25 +874,22 @@ def has_function(  # noqa: C901
                 # know the exact argument types, and the has_function
                 # interface does not provide that level of information.
                 f.write(
-                    """\
+                    f"""\
 #ifdef __cplusplus
 extern "C"
 #endif
-char %s(void);
+char {funcname}(void);
 """
-                    % funcname
                 )
             f.write(
-                """\
-int main (int argc, char **argv) {
-    %s();
+                f"""\
+int main (int argc, char **argv) {{
+    {funcname}();
     return 0;
-}
+}}
 """
-                % funcname
             )
-        finally:
-            f.close()
+
         try:
             objects = self.compile([fname], include_dirs=include_dirs)
         except CompileError:
@@ -911,7 +912,7 @@ def has_function(  # noqa: C901
                 os.remove(fn)
         return True
 
-    def find_library_file(self, dirs, lib, debug=0):
+    def find_library_file(self, dirs, lib, debug=False):
         """Search the specified list of directories for a static or shared
         library file 'lib' and return the full path to that file.  If
         'debug' true, look for a debugging version (if that makes sense on
@@ -954,7 +955,7 @@ def find_library_file(self, dirs, lib, debug=0):
     #   * exe_extension -
     #     extension for executable files, eg. '' or '.exe'
 
-    def object_filenames(self, source_filenames, strip_dir=0, output_dir=''):
+    def object_filenames(self, source_filenames, strip_dir=False, output_dir=''):
         if output_dir is None:
             output_dir = ''
         return list(
@@ -972,9 +973,7 @@ def _make_out_path(self, output_dir, strip_dir, src_name):
         try:
             new_ext = self.out_extensions[ext]
         except LookupError:
-            raise UnknownFileError(
-                "unknown file type '{}' (from '{}')".format(ext, src_name)
-            )
+            raise UnknownFileError(f"unknown file type '{ext}' (from '{src_name}')")
         if strip_dir:
             base = os.path.basename(base)
         return os.path.join(output_dir, base + new_ext)
@@ -991,20 +990,24 @@ def _make_relative(base):
         # If abs, chop off leading /
         return no_drive[os.path.isabs(no_drive) :]
 
-    def shared_object_filename(self, basename, strip_dir=0, output_dir=''):
+    def shared_object_filename(self, basename, strip_dir=False, output_dir=''):
         assert output_dir is not None
         if strip_dir:
             basename = os.path.basename(basename)
         return os.path.join(output_dir, basename + self.shared_lib_extension)
 
-    def executable_filename(self, basename, strip_dir=0, output_dir=''):
+    def executable_filename(self, basename, strip_dir=False, output_dir=''):
         assert output_dir is not None
         if strip_dir:
             basename = os.path.basename(basename)
         return os.path.join(output_dir, basename + (self.exe_extension or ''))
 
     def library_filename(
-        self, libname, lib_type='static', strip_dir=0, output_dir=''  # or 'shared'
+        self,
+        libname,
+        lib_type='static',
+        strip_dir=False,
+        output_dir='',  # or 'shared'
     ):
         assert output_dir is not None
         expected = '"static", "shared", "dylib", "xcode_stub"'
@@ -1032,7 +1035,7 @@ def debug_print(self, msg):
             print(msg)
 
     def warn(self, msg):
-        sys.stderr.write("warning: %s\n" % msg)
+        sys.stderr.write(f"warning: {msg}\n")
 
     def execute(self, func, args, msg=None, level=1):
         execute(func, args, msg, self.dry_run)
@@ -1056,6 +1059,7 @@ def mkpath(self, name, mode=0o777):
     # on a cygwin built python we can use gcc like an ordinary UNIXish
     # compiler
     ('cygwin.*', 'unix'),
+    ('zos', 'zos'),
     # OS name mappings
     ('posix', 'unix'),
     ('nt', 'msvc'),
@@ -1076,6 +1080,10 @@ def get_default_compiler(osname=None, platform=None):
         osname = os.name
     if platform is None:
         platform = sys.platform
+    # Mingw is a special case where sys.platform is 'win32' but we
+    # want to use the 'mingw32' compiler, so check it first
+    if is_mingw():
+        return 'mingw32'
     for pattern, compiler in _default_compilers:
         if (
             re.match(pattern, platform) is not None
@@ -1103,6 +1111,7 @@ def get_default_compiler(osname=None, platform=None):
         "Mingw32 port of GNU C Compiler for Win32",
     ),
     'bcpp': ('bcppcompiler', 'BCPPCompiler', "Borland C++ Compiler"),
+    'zos': ('zosccompiler', 'zOSCCompiler', 'IBM XL C/C++ Compilers'),
 }
 
 
@@ -1123,7 +1132,7 @@ def show_compilers():
     pretty_printer.print_help("List of available compilers:")
 
 
-def new_compiler(plat=None, compiler=None, verbose=0, dry_run=0, force=0):
+def new_compiler(plat=None, compiler=None, verbose=False, dry_run=False, force=False):
     """Generate an instance of some CCompiler subclass for the supplied
     platform/compiler combination.  'plat' defaults to 'os.name'
     (eg. 'posix', 'nt'), and 'compiler' defaults to the default compiler
@@ -1143,9 +1152,9 @@ def new_compiler(plat=None, compiler=None, verbose=0, dry_run=0, force=0):
 
         (module_name, class_name, long_description) = compiler_class[compiler]
     except KeyError:
-        msg = "don't know how to compile C/C++ code on platform '%s'" % plat
+        msg = f"don't know how to compile C/C++ code on platform '{plat}'"
         if compiler is not None:
-            msg = msg + " with '%s' compiler" % compiler
+            msg = msg + f" with '{compiler}' compiler"
         raise DistutilsPlatformError(msg)
 
     try:
@@ -1155,12 +1164,12 @@ def new_compiler(plat=None, compiler=None, verbose=0, dry_run=0, force=0):
         klass = vars(module)[class_name]
     except ImportError:
         raise DistutilsModuleError(
-            "can't compile C/C++ code: unable to load module '%s'" % module_name
+            f"can't compile C/C++ code: unable to load module '{module_name}'"
         )
     except KeyError:
         raise DistutilsModuleError(
-            "can't compile C/C++ code: unable to find class '%s' "
-            "in module '%s'" % (class_name, module_name)
+            f"can't compile C/C++ code: unable to find class '{class_name}' "
+            f"in module '{module_name}'"
         )
 
     # XXX The None is necessary to preserve backwards compatibility
@@ -1194,23 +1203,23 @@ def gen_preprocess_options(macros, include_dirs):
     for macro in macros:
         if not (isinstance(macro, tuple) and 1 <= len(macro) <= 2):
             raise TypeError(
-                "bad macro definition '%s': "
-                "each element of 'macros' list must be a 1- or 2-tuple" % macro
+                f"bad macro definition '{macro}': "
+                "each element of 'macros' list must be a 1- or 2-tuple"
             )
 
         if len(macro) == 1:  # undefine this macro
-            pp_opts.append("-U%s" % macro[0])
+            pp_opts.append(f"-U{macro[0]}")
         elif len(macro) == 2:
             if macro[1] is None:  # define with no explicit value
-                pp_opts.append("-D%s" % macro[0])
+                pp_opts.append(f"-D{macro[0]}")
             else:
                 # XXX *don't* need to be clever about quoting the
                 # macro value here, because we're going to avoid the
                 # shell at all costs when we spawn the command!
-                pp_opts.append("-D%s=%s" % macro)
+                pp_opts.append("-D{}={}".format(*macro))
 
     for dir in include_dirs:
-        pp_opts.append("-I%s" % dir)
+        pp_opts.append(f"-I{dir}")
     return pp_opts
 
 
@@ -1227,11 +1236,7 @@ def gen_lib_options(compiler, library_dirs, runtime_library_dirs, libraries):
         lib_opts.append(compiler.library_dir_option(dir))
 
     for dir in runtime_library_dirs:
-        opt = compiler.runtime_library_dir_option(dir)
-        if isinstance(opt, list):
-            lib_opts = lib_opts + opt
-        else:
-            lib_opts.append(opt)
+        lib_opts.extend(always_iterable(compiler.runtime_library_dir_option(dir)))
 
     # XXX it's important that we *not* remove redundant library mentions!
     # sometimes you really do have to say "-lfoo -lbar -lfoo" in order to
@@ -1247,7 +1252,7 @@ def gen_lib_options(compiler, library_dirs, runtime_library_dirs, libraries):
                 lib_opts.append(lib_file)
             else:
                 compiler.warn(
-                    "no library file corresponding to " "'%s' found (skipping)" % lib
+                    f"no library file corresponding to '{lib}' found (skipping)"
                 )
         else:
             lib_opts.append(compiler.library_option(lib))
diff --git a/distutils/cmd.py b/distutils/cmd.py
index 3860c3ff..2bb97956 100644
--- a/distutils/cmd.py
+++ b/distutils/cmd.py
@@ -4,14 +4,14 @@
 in the distutils.command package.
 """
 
-import sys
+import logging
 import os
 import re
-import logging
+import sys
 
-from .errors import DistutilsOptionError
-from . import util, dir_util, file_util, archive_util, dep_util
+from . import _modified, archive_util, dir_util, file_util, util
 from ._log import log
+from .errors import DistutilsOptionError
 
 
 class Command:
@@ -87,13 +87,13 @@ def __init__(self, dist):
 
         # The 'help' flag is just used for command-line parsing, so
         # none of that complicated bureaucracy is needed.
-        self.help = 0
+        self.help = False
 
         # 'finalized' records whether or not 'finalize_options()' has been
         # called.  'finalize_options()' itself should not pay attention to
         # this flag: it is the business of 'ensure_finalized()', which
         # always calls 'finalize_options()', to respect/update it.
-        self.finalized = 0
+        self.finalized = False
 
     # XXX A more explicit way to customize dry_run would be better.
     def __getattr__(self, attr):
@@ -109,7 +109,7 @@ def __getattr__(self, attr):
     def ensure_finalized(self):
         if not self.finalized:
             self.finalize_options()
-        self.finalized = 1
+        self.finalized = True
 
     # Subclasses must define:
     #   initialize_options()
@@ -135,7 +135,7 @@ def initialize_options(self):
         This method must be implemented by all command classes.
         """
         raise RuntimeError(
-            "abstract method -- subclass %s must override" % self.__class__
+            f"abstract method -- subclass {self.__class__} must override"
         )
 
     def finalize_options(self):
@@ -150,14 +150,14 @@ def finalize_options(self):
         This method must be implemented by all command classes.
         """
         raise RuntimeError(
-            "abstract method -- subclass %s must override" % self.__class__
+            f"abstract method -- subclass {self.__class__} must override"
         )
 
     def dump_options(self, header=None, indent=""):
         from distutils.fancy_getopt import longopt_xlate
 
         if header is None:
-            header = "command options for '%s':" % self.get_command_name()
+            header = f"command options for '{self.get_command_name()}':"
         self.announce(indent + header, level=logging.INFO)
         indent = indent + "  "
         for option, _, _ in self.user_options:
@@ -165,7 +165,7 @@ def dump_options(self, header=None, indent=""):
             if option[-1] == "=":
                 option = option[:-1]
             value = getattr(self, option)
-            self.announce(indent + "{} = {}".format(option, value), level=logging.INFO)
+            self.announce(indent + f"{option} = {value}", level=logging.INFO)
 
     def run(self):
         """A command's raison d'etre: carry out the action it exists to
@@ -178,7 +178,7 @@ def run(self):
         This method must be implemented by all command classes.
         """
         raise RuntimeError(
-            "abstract method -- subclass %s must override" % self.__class__
+            f"abstract method -- subclass {self.__class__} must override"
         )
 
     def announce(self, msg, level=logging.DEBUG):
@@ -213,9 +213,7 @@ def _ensure_stringlike(self, option, what, default=None):
             setattr(self, option, default)
             return default
         elif not isinstance(val, str):
-            raise DistutilsOptionError(
-                "'{}' must be a {} (got `{}`)".format(option, what, val)
-            )
+            raise DistutilsOptionError(f"'{option}' must be a {what} (got `{val}`)")
         return val
 
     def ensure_string(self, option, default=None):
@@ -242,7 +240,7 @@ def ensure_string_list(self, option):
                 ok = False
             if not ok:
                 raise DistutilsOptionError(
-                    "'{}' must be a list of strings (got {!r})".format(option, val)
+                    f"'{option}' must be a list of strings (got {val!r})"
                 )
 
     def _ensure_tested_string(self, option, tester, what, error_fmt, default=None):
@@ -295,7 +293,7 @@ def set_undefined_options(self, src_cmd, *option_pairs):
             if getattr(self, dst_option) is None:
                 setattr(self, dst_option, getattr(src_cmd_obj, src_option))
 
-    def get_finalized_command(self, command, create=1):
+    def get_finalized_command(self, command, create=True):
         """Wrapper around Distribution's 'get_command_obj()' method: find
         (create if necessary and 'create' is true) the command object for
         'command', call its 'ensure_finalized()' method, and return the
@@ -307,7 +305,7 @@ def get_finalized_command(self, command, create=1):
 
     # XXX rename to 'get_reinitialized_command()'? (should do the
     # same in dist.py, if so)
-    def reinitialize_command(self, command, reinit_subcommands=0):
+    def reinitialize_command(self, command, reinit_subcommands=False):
         return self.distribution.reinitialize_command(command, reinit_subcommands)
 
     def run_command(self, command):
@@ -342,7 +340,13 @@ def mkpath(self, name, mode=0o777):
         dir_util.mkpath(name, mode, dry_run=self.dry_run)
 
     def copy_file(
-        self, infile, outfile, preserve_mode=1, preserve_times=1, link=None, level=1
+        self,
+        infile,
+        outfile,
+        preserve_mode=True,
+        preserve_times=True,
+        link=None,
+        level=1,
     ):
         """Copy a file respecting verbose, dry-run and force flags.  (The
         former two default to whatever is in the Distribution object, and
@@ -361,9 +365,9 @@ def copy_tree(
         self,
         infile,
         outfile,
-        preserve_mode=1,
-        preserve_times=1,
-        preserve_symlinks=0,
+        preserve_mode=True,
+        preserve_times=True,
+        preserve_symlinks=False,
         level=1,
     ):
         """Copy an entire directory tree respecting verbose, dry-run,
@@ -383,7 +387,7 @@ def move_file(self, src, dst, level=1):
         """Move a file respecting dry-run flag."""
         return file_util.move_file(src, dst, dry_run=self.dry_run)
 
-    def spawn(self, cmd, search_path=1, level=1):
+    def spawn(self, cmd, search_path=True, level=1):
         """Spawn an external command respecting dry-run flag."""
         from distutils.spawn import spawn
 
@@ -414,7 +418,7 @@ def make_file(
         timestamp checks.
         """
         if skip_msg is None:
-            skip_msg = "skipping %s (inputs unchanged)" % outfile
+            skip_msg = f"skipping {outfile} (inputs unchanged)"
 
         # Allow 'infiles' to be a single string
         if isinstance(infiles, str):
@@ -428,7 +432,7 @@ def make_file(
         # If 'outfile' must be regenerated (either because it doesn't
         # exist, is out-of-date, or the 'force' flag is true) then
         # perform the action that presumably regenerates it
-        if self.force or dep_util.newer_group(infiles, outfile):
+        if self.force or _modified.newer_group(infiles, outfile):
             self.execute(func, args, exec_msg, level)
         # Otherwise, print the "skip" message
         else:
diff --git a/distutils/command/__init__.py b/distutils/command/__init__.py
index 028dcfa0..1e8fbe60 100644
--- a/distutils/command/__init__.py
+++ b/distutils/command/__init__.py
@@ -3,7 +3,7 @@
 Package containing implementation of all the standard Distutils
 commands."""
 
-__all__ = [  # noqa: F822
+__all__ = [
     'build',
     'build_py',
     'build_ext',
diff --git a/distutils/command/_framework_compat.py b/distutils/command/_framework_compat.py
index cffa27cb..00d34bc7 100644
--- a/distutils/command/_framework_compat.py
+++ b/distutils/command/_framework_compat.py
@@ -2,15 +2,14 @@
 Backward compatibility for homebrew builds on macOS.
 """
 
-
-import sys
-import os
 import functools
+import os
 import subprocess
+import sys
 import sysconfig
 
 
-@functools.lru_cache()
+@functools.lru_cache
 def enabled():
     """
     Only enabled for Python 3.9 framework homebrew builds
@@ -38,7 +37,7 @@ def enabled():
 )
 
 
-@functools.lru_cache()
+@functools.lru_cache
 def vars():
     if not enabled():
         return {}
diff --git a/distutils/command/bdist.py b/distutils/command/bdist.py
index 6329039c..1738f4e5 100644
--- a/distutils/command/bdist.py
+++ b/distutils/command/bdist.py
@@ -7,7 +7,7 @@
 import warnings
 
 from ..core import Command
-from ..errors import DistutilsPlatformError, DistutilsOptionError
+from ..errors import DistutilsOptionError, DistutilsPlatformError
 from ..util import get_platform
 
 
@@ -41,24 +41,24 @@ class bdist(Command):
             'plat-name=',
             'p',
             "platform name to embed in generated filenames "
-            "(default: %s)" % get_platform(),
+            f"[default: {get_platform()}]",
         ),
         ('formats=', None, "formats for distribution (comma-separated list)"),
         (
             'dist-dir=',
             'd',
-            "directory to put final built distributions in " "[default: dist]",
+            "directory to put final built distributions in [default: dist]",
         ),
         ('skip-build', None, "skip rebuilding everything (for testing/debugging)"),
         (
             'owner=',
             'u',
-            "Owner name used when creating a tar file" " [default: current user]",
+            "Owner name used when creating a tar file [default: current user]",
         ),
         (
             'group=',
             'g',
-            "Group name used when creating a tar file" " [default: current group]",
+            "Group name used when creating a tar file [default: current group]",
         ),
     ]
 
@@ -76,17 +76,15 @@ class bdist(Command):
     default_format = {'posix': 'gztar', 'nt': 'zip'}
 
     # Define commands in preferred order for the --help-formats option
-    format_commands = ListCompat(
-        {
-            'rpm': ('bdist_rpm', "RPM distribution"),
-            'gztar': ('bdist_dumb', "gzip'ed tar file"),
-            'bztar': ('bdist_dumb', "bzip2'ed tar file"),
-            'xztar': ('bdist_dumb', "xz'ed tar file"),
-            'ztar': ('bdist_dumb', "compressed tar file"),
-            'tar': ('bdist_dumb', "tar file"),
-            'zip': ('bdist_dumb', "ZIP file"),
-        }
-    )
+    format_commands = ListCompat({
+        'rpm': ('bdist_rpm', "RPM distribution"),
+        'gztar': ('bdist_dumb', "gzip'ed tar file"),
+        'bztar': ('bdist_dumb', "bzip2'ed tar file"),
+        'xztar': ('bdist_dumb', "xz'ed tar file"),
+        'ztar': ('bdist_dumb', "compressed tar file"),
+        'tar': ('bdist_dumb', "tar file"),
+        'zip': ('bdist_dumb', "ZIP file"),
+    })
 
     # for compatibility until consumers only reference format_commands
     format_command = format_commands
@@ -96,7 +94,7 @@ def initialize_options(self):
         self.plat_name = None
         self.formats = None
         self.dist_dir = None
-        self.skip_build = 0
+        self.skip_build = False
         self.group = None
         self.owner = None
 
@@ -122,7 +120,7 @@ def finalize_options(self):
             except KeyError:
                 raise DistutilsPlatformError(
                     "don't know how to create built distributions "
-                    "on platform %s" % os.name
+                    f"on platform {os.name}"
                 )
 
         if self.dist_dir is None:
@@ -135,7 +133,7 @@ def run(self):
             try:
                 commands.append(self.format_commands[format][0])
             except KeyError:
-                raise DistutilsOptionError("invalid format '%s'" % format)
+                raise DistutilsOptionError(f"invalid format '{format}'")
 
         # Reinitialize and run each command.
         for i in range(len(self.formats)):
@@ -152,5 +150,5 @@ def run(self):
             # If we're going to need to run this command again, tell it to
             # keep its temporary files around so subsequent runs go faster.
             if cmd_name in commands[i + 1 :]:
-                sub_cmd.keep_temp = 1
+                sub_cmd.keep_temp = True
             self.run_command(cmd_name)
diff --git a/distutils/command/bdist_dumb.py b/distutils/command/bdist_dumb.py
index 01dd7907..67b0c8cc 100644
--- a/distutils/command/bdist_dumb.py
+++ b/distutils/command/bdist_dumb.py
@@ -5,12 +5,13 @@
 $exec_prefix)."""
 
 import os
+from distutils._log import log
+
 from ..core import Command
-from ..util import get_platform
-from ..dir_util import remove_tree, ensure_relative
+from ..dir_util import ensure_relative, remove_tree
 from ..errors import DistutilsPlatformError
 from ..sysconfig import get_python_version
-from distutils._log import log
+from ..util import get_platform
 
 
 class bdist_dumb(Command):
@@ -22,35 +23,34 @@ class bdist_dumb(Command):
             'plat-name=',
             'p',
             "platform name to embed in generated filenames "
-            "(default: %s)" % get_platform(),
+            f"[default: {get_platform()}]",
         ),
         (
             'format=',
             'f',
-            "archive format to create (tar, gztar, bztar, xztar, " "ztar, zip)",
+            "archive format to create (tar, gztar, bztar, xztar, ztar, zip)",
         ),
         (
             'keep-temp',
             'k',
-            "keep the pseudo-installation tree around after "
-            + "creating the distribution archive",
+            "keep the pseudo-installation tree around after creating the distribution archive",
         ),
         ('dist-dir=', 'd', "directory to put final built distributions in"),
         ('skip-build', None, "skip rebuilding everything (for testing/debugging)"),
         (
             'relative',
             None,
-            "build the archive using relative paths " "(default: false)",
+            "build the archive using relative paths [default: false]",
         ),
         (
             'owner=',
             'u',
-            "Owner name used when creating a tar file" " [default: current user]",
+            "Owner name used when creating a tar file [default: current user]",
         ),
         (
             'group=',
             'g',
-            "Group name used when creating a tar file" " [default: current group]",
+            "Group name used when creating a tar file [default: current group]",
         ),
     ]
 
@@ -62,10 +62,10 @@ def initialize_options(self):
         self.bdist_dir = None
         self.plat_name = None
         self.format = None
-        self.keep_temp = 0
+        self.keep_temp = False
         self.dist_dir = None
         self.skip_build = None
-        self.relative = 0
+        self.relative = False
         self.owner = None
         self.group = None
 
@@ -80,7 +80,7 @@ def finalize_options(self):
             except KeyError:
                 raise DistutilsPlatformError(
                     "don't know how to create dumb built distributions "
-                    "on platform %s" % os.name
+                    f"on platform {os.name}"
                 )
 
         self.set_undefined_options(
@@ -94,19 +94,17 @@ def run(self):
         if not self.skip_build:
             self.run_command('build')
 
-        install = self.reinitialize_command('install', reinit_subcommands=1)
+        install = self.reinitialize_command('install', reinit_subcommands=True)
         install.root = self.bdist_dir
         install.skip_build = self.skip_build
-        install.warn_dir = 0
+        install.warn_dir = False
 
         log.info("installing to %s", self.bdist_dir)
         self.run_command('install')
 
         # And make an archive relative to the root of the
         # pseudo-installation tree.
-        archive_basename = "{}.{}".format(
-            self.distribution.get_fullname(), self.plat_name
-        )
+        archive_basename = f"{self.distribution.get_fullname()}.{self.plat_name}"
 
         pseudoinstall_root = os.path.join(self.dist_dir, archive_basename)
         if not self.relative:
@@ -117,8 +115,7 @@ def run(self):
             ):
                 raise DistutilsPlatformError(
                     "can't make a dumb built distribution where "
-                    "base and platbase are different (%s, %s)"
-                    % (repr(install.install_base), repr(install.install_platbase))
+                    f"base and platbase are different ({install.install_base!r}, {install.install_platbase!r})"
                 )
             else:
                 archive_root = os.path.join(
diff --git a/distutils/command/bdist_rpm.py b/distutils/command/bdist_rpm.py
index 3ed608b4..d443eb09 100644
--- a/distutils/command/bdist_rpm.py
+++ b/distutils/command/bdist_rpm.py
@@ -3,21 +3,21 @@
 Implements the Distutils 'bdist_rpm' command (create RPM source and binary
 distributions)."""
 
+import os
 import subprocess
 import sys
-import os
+from distutils._log import log
 
 from ..core import Command
 from ..debug import DEBUG
-from ..file_util import write_file
 from ..errors import (
+    DistutilsExecError,
+    DistutilsFileError,
     DistutilsOptionError,
     DistutilsPlatformError,
-    DistutilsFileError,
-    DistutilsExecError,
 )
+from ..file_util import write_file
 from ..sysconfig import get_python_version
-from distutils._log import log
 
 
 class bdist_rpm(Command):
@@ -34,13 +34,13 @@ class bdist_rpm(Command):
         (
             'dist-dir=',
             'd',
-            "directory to put final RPM files in " "(and .spec files if --spec-only)",
+            "directory to put final RPM files in (and .spec files if --spec-only)",
         ),
         (
             'python=',
             None,
             "path to Python interpreter to hard-code in the .spec file "
-            "(default: \"python\")",
+            "[default: \"python\"]",
         ),
         (
             'fix-python',
@@ -75,7 +75,7 @@ class bdist_rpm(Command):
         (
             'packager=',
             None,
-            "RPM packager (eg. \"Jane Doe \") " "[default: vendor]",
+            "RPM packager (eg. \"Jane Doe \") [default: vendor]",
         ),
         ('doc-files=', None, "list of documentation files (space or comma-separated)"),
         ('changelog=', None, "RPM changelog"),
@@ -187,13 +187,13 @@ def initialize_options(self):
         self.build_requires = None
         self.obsoletes = None
 
-        self.keep_temp = 0
-        self.use_rpm_opt_flags = 1
-        self.rpm3_mode = 1
-        self.no_autoreq = 0
+        self.keep_temp = False
+        self.use_rpm_opt_flags = True
+        self.rpm3_mode = True
+        self.no_autoreq = False
 
         self.force_arch = None
-        self.quiet = 0
+        self.quiet = False
 
     def finalize_options(self):
         self.set_undefined_options('bdist', ('bdist_base', 'bdist_base'))
@@ -214,7 +214,7 @@ def finalize_options(self):
 
         if os.name != 'posix':
             raise DistutilsPlatformError(
-                "don't know how to create RPM " "distributions on platform %s" % os.name
+                f"don't know how to create RPM distributions on platform {os.name}"
             )
         if self.binary_only and self.source_only:
             raise DistutilsOptionError(
@@ -223,7 +223,7 @@ def finalize_options(self):
 
         # don't pass CFLAGS to pure python distributions
         if not self.distribution.has_ext_modules():
-            self.use_rpm_opt_flags = 0
+            self.use_rpm_opt_flags = False
 
         self.set_undefined_options('bdist', ('dist_dir', 'dist_dir'))
         self.finalize_package_data()
@@ -232,8 +232,7 @@ def finalize_package_data(self):
         self.ensure_string('group', "Development/Libraries")
         self.ensure_string(
             'vendor',
-            "%s <%s>"
-            % (self.distribution.get_contact(), self.distribution.get_contact_email()),
+            f"{self.distribution.get_contact()} <{self.distribution.get_contact_email()}>",
         )
         self.ensure_string('packager')
         self.ensure_string_list('doc_files')
@@ -296,9 +295,9 @@ def run(self):  # noqa: C901
 
         # Spec file goes into 'dist_dir' if '--spec-only specified',
         # build/rpm. otherwise.
-        spec_path = os.path.join(spec_dir, "%s.spec" % self.distribution.get_name())
+        spec_path = os.path.join(spec_dir, f"{self.distribution.get_name()}.spec")
         self.execute(
-            write_file, (spec_path, self._make_spec_file()), "writing '%s'" % spec_path
+            write_file, (spec_path, self._make_spec_file()), f"writing '{spec_path}'"
         )
 
         if self.spec_only:  # stop if requested
@@ -323,7 +322,7 @@ def run(self):  # noqa: C901
             if os.path.exists(self.icon):
                 self.copy_file(self.icon, source_dir)
             else:
-                raise DistutilsFileError("icon file '%s' does not exist" % self.icon)
+                raise DistutilsFileError(f"icon file '{self.icon}' does not exist")
 
         # build package
         log.info("building RPMs")
@@ -335,9 +334,9 @@ def run(self):  # noqa: C901
             rpm_cmd.append('-bb')
         else:
             rpm_cmd.append('-ba')
-        rpm_cmd.extend(['--define', '__python %s' % self.python])
+        rpm_cmd.extend(['--define', f'__python {self.python}'])
         if self.rpm3_mode:
-            rpm_cmd.extend(['--define', '_topdir %s' % os.path.abspath(self.rpm_base)])
+            rpm_cmd.extend(['--define', f'_topdir {os.path.abspath(self.rpm_base)}'])
         if not self.keep_temp:
             rpm_cmd.append('--clean')
 
@@ -352,11 +351,7 @@ def run(self):  # noqa: C901
         nvr_string = "%{name}-%{version}-%{release}"
         src_rpm = nvr_string + ".src.rpm"
         non_src_rpm = "%{arch}/" + nvr_string + ".%{arch}.rpm"
-        q_cmd = r"rpm -q --qf '{} {}\n' --specfile '{}'".format(
-            src_rpm,
-            non_src_rpm,
-            spec_path,
-        )
+        q_cmd = rf"rpm -q --qf '{src_rpm} {non_src_rpm}\n' --specfile '{spec_path}'"
 
         out = os.popen(q_cmd)
         try:
@@ -375,7 +370,7 @@ def run(self):  # noqa: C901
 
             status = out.close()
             if status:
-                raise DistutilsExecError("Failed to execute: %s" % repr(q_cmd))
+                raise DistutilsExecError(f"Failed to execute: {q_cmd!r}")
 
         finally:
             out.close()
@@ -401,9 +396,11 @@ def run(self):  # noqa: C901
                     if os.path.exists(rpm):
                         self.move_file(rpm, self.dist_dir)
                         filename = os.path.join(self.dist_dir, os.path.basename(rpm))
-                        self.distribution.dist_files.append(
-                            ('bdist_rpm', pyversion, filename)
-                        )
+                        self.distribution.dist_files.append((
+                            'bdist_rpm',
+                            pyversion,
+                            filename,
+                        ))
 
     def _dist_path(self, path):
         return os.path.join(self.dist_dir, os.path.basename(path))
@@ -428,14 +425,14 @@ def _make_spec_file(self):  # noqa: C901
         # Generate a potential replacement value for __os_install_post (whilst
         # normalizing the whitespace to simplify the test for whether the
         # invocation of brp-python-bytecompile passes in __python):
-        vendor_hook = '\n'.join(
-            ['  %s \\' % line.strip() for line in vendor_hook.splitlines()]
-        )
+        vendor_hook = '\n'.join([
+            f'  {line.strip()} \\' for line in vendor_hook.splitlines()
+        ])
         problem = "brp-python-bytecompile \\\n"
         fixed = "brp-python-bytecompile %{__python} \\\n"
         fixed_hook = vendor_hook.replace(problem, fixed)
         if fixed_hook != vendor_hook:
-            spec_file.append('# Workaround for http://bugs.python.org/issue14443')
+            spec_file.append('# Workaround for https://bugs.python.org/issue14443')
             spec_file.append('%define __os_install_post ' + fixed_hook + '\n')
 
         # put locale summaries into spec file
@@ -445,13 +442,11 @@ def _make_spec_file(self):  # noqa: C901
         #    spec_file.append('Summary(%s): %s' % (locale,
         #                                          self.summaries[locale]))
 
-        spec_file.extend(
-            [
-                'Name: %{name}',
-                'Version: %{version}',
-                'Release: %{release}',
-            ]
-        )
+        spec_file.extend([
+            'Name: %{name}',
+            'Version: %{version}',
+            'Release: %{release}',
+        ])
 
         # XXX yuck! this filename is available from the "sdist" command,
         # but only after it has run: and we create the spec file before
@@ -461,21 +456,19 @@ def _make_spec_file(self):  # noqa: C901
         else:
             spec_file.append('Source0: %{name}-%{unmangled_version}.tar.gz')
 
-        spec_file.extend(
-            [
-                'License: ' + (self.distribution.get_license() or "UNKNOWN"),
-                'Group: ' + self.group,
-                'BuildRoot: %{_tmppath}/%{name}-%{version}-%{release}-buildroot',
-                'Prefix: %{_prefix}',
-            ]
-        )
+        spec_file.extend([
+            'License: ' + (self.distribution.get_license() or "UNKNOWN"),
+            'Group: ' + self.group,
+            'BuildRoot: %{_tmppath}/%{name}-%{version}-%{release}-buildroot',
+            'Prefix: %{_prefix}',
+        ])
 
         if not self.force_arch:
             # noarch if no extension modules
             if not self.distribution.has_ext_modules():
                 spec_file.append('BuildArch: noarch')
         else:
-            spec_file.append('BuildArch: %s' % self.force_arch)
+            spec_file.append(f'BuildArch: {self.force_arch}')
 
         for field in (
             'Vendor',
@@ -489,7 +482,7 @@ def _make_spec_file(self):  # noqa: C901
             if isinstance(val, list):
                 spec_file.append('{}: {}'.format(field, ' '.join(val)))
             elif val is not None:
-                spec_file.append('{}: {}'.format(field, val))
+                spec_file.append(f'{field}: {val}')
 
         if self.distribution.get_url():
             spec_file.append('Url: ' + self.distribution.get_url())
@@ -506,13 +499,11 @@ def _make_spec_file(self):  # noqa: C901
         if self.no_autoreq:
             spec_file.append('AutoReq: 0')
 
-        spec_file.extend(
-            [
-                '',
-                '%description',
-                self.distribution.get_long_description() or "",
-            ]
-        )
+        spec_file.extend([
+            '',
+            '%description',
+            self.distribution.get_long_description() or "",
+        ])
 
         # put locale descriptions into spec file
         # XXX again, suppressed because config file syntax doesn't
@@ -526,8 +517,8 @@ def _make_spec_file(self):  # noqa: C901
 
         # rpm scripts
         # figure out default build script
-        def_setup_call = "{} {}".format(self.python, os.path.basename(sys.argv[0]))
-        def_build = "%s build" % def_setup_call
+        def_setup_call = f"{self.python} {os.path.basename(sys.argv[0])}"
+        def_build = f"{def_setup_call} build"
         if self.use_rpm_opt_flags:
             def_build = 'env CFLAGS="$RPM_OPT_FLAGS" ' + def_build
 
@@ -537,9 +528,7 @@ def _make_spec_file(self):  # noqa: C901
         # that we open and interpolate into the spec file, but the defaults
         # are just text that we drop in as-is.  Hmmm.
 
-        install_cmd = (
-            '%s install -O1 --root=$RPM_BUILD_ROOT ' '--record=INSTALLED_FILES'
-        ) % def_setup_call
+        install_cmd = f'{def_setup_call} install -O1 --root=$RPM_BUILD_ROOT --record=INSTALLED_FILES'
 
         script_options = [
             ('prep', 'prep_script', "%setup -n %{name}-%{unmangled_version}"),
@@ -558,12 +547,10 @@ def _make_spec_file(self):  # noqa: C901
             # use 'default' as contents of script
             val = getattr(self, attr)
             if val or default:
-                spec_file.extend(
-                    [
-                        '',
-                        '%' + rpm_opt,
-                    ]
-                )
+                spec_file.extend([
+                    '',
+                    '%' + rpm_opt,
+                ])
                 if val:
                     with open(val) as f:
                         spec_file.extend(f.read().split('\n'))
@@ -571,24 +558,20 @@ def _make_spec_file(self):  # noqa: C901
                     spec_file.append(default)
 
         # files section
-        spec_file.extend(
-            [
-                '',
-                '%files -f INSTALLED_FILES',
-                '%defattr(-,root,root)',
-            ]
-        )
+        spec_file.extend([
+            '',
+            '%files -f INSTALLED_FILES',
+            '%defattr(-,root,root)',
+        ])
 
         if self.doc_files:
             spec_file.append('%doc ' + ' '.join(self.doc_files))
 
         if self.changelog:
-            spec_file.extend(
-                [
-                    '',
-                    '%changelog',
-                ]
-            )
+            spec_file.extend([
+                '',
+                '%changelog',
+            ])
             spec_file.extend(self.changelog)
 
         return spec_file
diff --git a/distutils/command/build.py b/distutils/command/build.py
index cc9b367e..caf55073 100644
--- a/distutils/command/build.py
+++ b/distutils/command/build.py
@@ -2,8 +2,10 @@
 
 Implements the Distutils 'build' command."""
 
-import sys
 import os
+import sys
+import sysconfig
+
 from ..core import Command
 from ..errors import DistutilsOptionError
 from ..util import get_platform
@@ -25,16 +27,14 @@ class build(Command):
         (
             'build-lib=',
             None,
-            "build directory for all distribution (defaults to either "
-            + "build-purelib or build-platlib",
+            "build directory for all distribution (defaults to either build-purelib or build-platlib",
         ),
         ('build-scripts=', None, "build directory for scripts"),
         ('build-temp=', 't', "temporary build directory"),
         (
             'plat-name=',
             'p',
-            "platform name to build for, if supported "
-            "(default: %s)" % get_platform(),
+            f"platform name to build for, if supported [default: {get_platform()}]",
         ),
         ('compiler=', 'c', "specify the compiler type"),
         ('parallel=', 'j', "number of parallel build jobs"),
@@ -61,7 +61,7 @@ def initialize_options(self):
         self.compiler = None
         self.plat_name = None
         self.debug = None
-        self.force = 0
+        self.force = False
         self.executable = None
         self.parallel = None
 
@@ -78,7 +78,11 @@ def finalize_options(self):  # noqa: C901
                     "using './configure --help' on your platform)"
                 )
 
-        plat_specifier = ".{}-{}".format(self.plat_name, sys.implementation.cache_tag)
+        plat_specifier = f".{self.plat_name}-{sys.implementation.cache_tag}"
+
+        # Python 3.13+ with --disable-gil shouldn't share build directories
+        if sysconfig.get_config_var('Py_GIL_DISABLED'):
+            plat_specifier += 't'
 
         # Make it so Python 2.x and Python 2.x with --with-pydebug don't
         # share the same build directories. Doing so confuses the build
diff --git a/distutils/command/build_clib.py b/distutils/command/build_clib.py
index b3f679b6..a600d093 100644
--- a/distutils/command/build_clib.py
+++ b/distutils/command/build_clib.py
@@ -15,10 +15,11 @@
 # cut 'n paste.  Sigh.
 
 import os
+from distutils._log import log
+
 from ..core import Command
 from ..errors import DistutilsSetupError
 from ..sysconfig import customize_compiler
-from distutils._log import log
 
 
 def show_compilers():
@@ -56,7 +57,7 @@ def initialize_options(self):
         self.define = None
         self.undef = None
         self.debug = None
-        self.force = 0
+        self.force = False
         self.compiler = None
 
     def finalize_options(self):
@@ -137,8 +138,8 @@ def check_library_list(self, libraries):
 
             if '/' in name or (os.sep != '/' and os.sep in name):
                 raise DistutilsSetupError(
-                    "bad library name '%s': "
-                    "may not contain directory separators" % lib[0]
+                    f"bad library name '{lib[0]}': "
+                    "may not contain directory separators"
                 )
 
             if not isinstance(build_info, dict):
@@ -154,7 +155,7 @@ def get_library_names(self):
             return None
 
         lib_names = []
-        for lib_name, build_info in self.libraries:
+        for lib_name, _build_info in self.libraries:
             lib_names.append(lib_name)
         return lib_names
 
@@ -165,9 +166,9 @@ def get_source_files(self):
             sources = build_info.get('sources')
             if sources is None or not isinstance(sources, (list, tuple)):
                 raise DistutilsSetupError(
-                    "in 'libraries' option (library '%s'), "
+                    f"in 'libraries' option (library '{lib_name}'), "
                     "'sources' must be present and must be "
-                    "a list of source filenames" % lib_name
+                    "a list of source filenames"
                 )
 
             filenames.extend(sources)
@@ -178,9 +179,9 @@ def build_libraries(self, libraries):
             sources = build_info.get('sources')
             if sources is None or not isinstance(sources, (list, tuple)):
                 raise DistutilsSetupError(
-                    "in 'libraries' option (library '%s'), "
+                    f"in 'libraries' option (library '{lib_name}'), "
                     "'sources' must be present and must be "
-                    "a list of source filenames" % lib_name
+                    "a list of source filenames"
                 )
             sources = list(sources)
 
diff --git a/distutils/command/build_ext.py b/distutils/command/build_ext.py
index fbeec342..18e1601a 100644
--- a/distutils/command/build_ext.py
+++ b/distutils/command/build_ext.py
@@ -8,24 +8,22 @@
 import os
 import re
 import sys
+from distutils._log import log
+from site import USER_BASE
+
+from .._modified import newer_group
 from ..core import Command
 from ..errors import (
-    DistutilsOptionError,
-    DistutilsSetupError,
     CCompilerError,
-    DistutilsError,
     CompileError,
+    DistutilsError,
+    DistutilsOptionError,
     DistutilsPlatformError,
+    DistutilsSetupError,
 )
-from ..sysconfig import customize_compiler, get_python_version
-from ..sysconfig import get_config_h_filename
-from ..dep_util import newer_group
 from ..extension import Extension
-from ..util import get_platform
-from distutils._log import log
-from . import py37compat
-
-from site import USER_BASE
+from ..sysconfig import customize_compiler, get_config_h_filename, get_python_version
+from ..util import get_platform, is_mingw
 
 # An extension name is just a dot-separated list of Python NAMEs (ie.
 # the same as a fully-qualified module name).
@@ -59,7 +57,7 @@ class build_ext(Command):
     #     takes care of both command-line and client options
     #     in between initialize_options() and finalize_options())
 
-    sep_by = " (separated by '%s')" % os.pathsep
+    sep_by = f" (separated by '{os.pathsep}')"
     user_options = [
         ('build-lib=', 'b', "directory for compiled extension modules"),
         ('build-temp=', 't', "directory for temporary files (build by-products)"),
@@ -67,13 +65,13 @@ class build_ext(Command):
             'plat-name=',
             'p',
             "platform name to cross-compile for, if supported "
-            "(default: %s)" % get_platform(),
+            f"[default: {get_platform()}]",
         ),
         (
             'inplace',
             'i',
             "ignore build-lib and put compiled extensions into the source "
-            + "directory alongside your pure Python modules",
+            "directory alongside your pure Python modules",
         ),
         (
             'include-dirs=',
@@ -111,7 +109,7 @@ def initialize_options(self):
         self.build_lib = None
         self.plat_name = None
         self.build_temp = None
-        self.inplace = 0
+        self.inplace = False
         self.package = None
 
         self.include_dirs = None
@@ -130,6 +128,31 @@ def initialize_options(self):
         self.user = None
         self.parallel = None
 
+    @staticmethod
+    def _python_lib_dir(sysconfig):
+        """
+        Resolve Python's library directory for building extensions
+        that rely on a shared Python library.
+
+        See python/cpython#44264 and python/cpython#48686
+        """
+        if not sysconfig.get_config_var('Py_ENABLE_SHARED'):
+            return
+
+        if sysconfig.python_build:
+            yield '.'
+            return
+
+        if sys.platform == 'zos':
+            # On z/OS, a user is not required to install Python to
+            # a predetermined path, but can use Python portably
+            installed_dir = sysconfig.get_config_var('base')
+            lib_dir = sysconfig.get_config_var('platlibdir')
+            yield os.path.join(installed_dir, lib_dir)
+        else:
+            # building third party extensions
+            yield sysconfig.get_config_var('LIBDIR')
+
     def finalize_options(self):  # noqa: C901
         from distutils import sysconfig
 
@@ -152,7 +175,7 @@ def finalize_options(self):  # noqa: C901
         # Make sure Python's include directories (for Python.h, pyconfig.h,
         # etc.) are in the include search path.
         py_include = sysconfig.get_python_inc()
-        plat_py_include = sysconfig.get_python_inc(plat_specific=1)
+        plat_py_include = sysconfig.get_python_inc(plat_specific=True)
         if self.include_dirs is None:
             self.include_dirs = self.distribution.include_dirs or []
         if isinstance(self.include_dirs, str):
@@ -189,7 +212,7 @@ def finalize_options(self):  # noqa: C901
         # for extensions under windows use different directories
         # for Release and Debug builds.
         # also Python's library directory must be appended to library_dirs
-        if os.name == 'nt':
+        if os.name == 'nt' and not is_mingw():
             # the 'libs' directory is for binary installs - we assume that
             # must be the *native* platform.  But we don't really support
             # cross-compiling via a binary install anyway, so we let it go.
@@ -231,16 +254,7 @@ def finalize_options(self):  # noqa: C901
                 # building python standard extensions
                 self.library_dirs.append('.')
 
-        # For building extensions with a shared Python library,
-        # Python's library directory must be appended to library_dirs
-        # See Issues: #1600860, #4366
-        if sysconfig.get_config_var('Py_ENABLE_SHARED'):
-            if not sysconfig.python_build:
-                # building third party extensions
-                self.library_dirs.append(sysconfig.get_config_var('LIBDIR'))
-            else:
-                # building python standard extensions
-                self.library_dirs.append('.')
+        self.library_dirs.extend(self._python_lib_dir(sysconfig))
 
         # The argument parsing will result in self.define being a string, but
         # it has to be a list of 2-tuples.  All the preprocessor symbols
@@ -412,9 +426,7 @@ def check_extensions_list(self, extensions):  # noqa: C901
             # Medium-easy stuff: same syntax/semantics, different names.
             ext.runtime_library_dirs = build_info.get('rpath')
             if 'def_file' in build_info:
-                log.warning(
-                    "'def_file' element of build info dict " "no longer supported"
-                )
+                log.warning("'def_file' element of build info dict no longer supported")
 
             # Non-trivial stuff: 'macros' split into 'define_macros'
             # and 'undef_macros'.
@@ -499,15 +511,15 @@ def _filter_build_errors(self, ext):
         except (CCompilerError, DistutilsError, CompileError) as e:
             if not ext.optional:
                 raise
-            self.warn('building extension "{}" failed: {}'.format(ext.name, e))
+            self.warn(f'building extension "{ext.name}" failed: {e}')
 
     def build_extension(self, ext):
         sources = ext.sources
         if sources is None or not isinstance(sources, (list, tuple)):
             raise DistutilsSetupError(
-                "in 'ext_modules' option (extension '%s'), "
+                f"in 'ext_modules' option (extension '{ext.name}'), "
                 "'sources' must be present and must be "
-                "a list of source filenames" % ext.name
+                "a list of source filenames"
             )
         # sort to make the resulting .so file build reproducible
         sources = sorted(sources)
@@ -651,7 +663,7 @@ def find_swig(self):
             # Windows (or so I presume!).  If we find it there, great;
             # if not, act like Unix and assume it's in the PATH.
             for vers in ("1.3", "1.2", "1.1"):
-                fn = os.path.join("c:\\swig%s" % vers, "swig.exe")
+                fn = os.path.join(f"c:\\swig{vers}", "swig.exe")
                 if os.path.isfile(fn):
                     return fn
             else:
@@ -659,7 +671,7 @@ def find_swig(self):
         else:
             raise DistutilsPlatformError(
                 "I don't know how to find (much less run) SWIG "
-                "on platform '%s'" % os.name
+                f"on platform '{os.name}'"
             )
 
     # -- Name generators -----------------------------------------------
@@ -742,7 +754,7 @@ def get_libraries(self, ext):  # noqa: C901
         # pyconfig.h that MSVC groks.  The other Windows compilers all seem
         # to need it mentioned explicitly, though, so that's what we do.
         # Append '_d' to the python import library on debug builds.
-        if sys.platform == "win32":
+        if sys.platform == "win32" and not is_mingw():
             from .._msvccompiler import MSVCCompiler
 
             if not isinstance(self.compiler, MSVCCompiler):
@@ -772,7 +784,7 @@ def get_libraries(self, ext):  # noqa: C901
                 # A native build on an Android device or on Cygwin
                 if hasattr(sys, 'getandroidapilevel'):
                     link_libpython = True
-                elif sys.platform == 'cygwin':
+                elif sys.platform == 'cygwin' or is_mingw():
                     link_libpython = True
                 elif '_PYTHON_HOST_PLATFORM' in os.environ:
                     # We are cross-compiling for one of the relevant platforms
@@ -785,4 +797,4 @@ def get_libraries(self, ext):  # noqa: C901
                 ldversion = get_config_var('LDVERSION')
                 return ext.libraries + ['python' + ldversion]
 
-        return ext.libraries + py37compat.pythonlib()
+        return ext.libraries
diff --git a/distutils/command/build_py.py b/distutils/command/build_py.py
index d9df9592..49d71034 100644
--- a/distutils/command/build_py.py
+++ b/distutils/command/build_py.py
@@ -2,15 +2,15 @@
 
 Implements the Distutils 'build_py' command."""
 
-import os
+import glob
 import importlib.util
+import os
 import sys
-import glob
+from distutils._log import log
 
 from ..core import Command
-from ..errors import DistutilsOptionError, DistutilsFileError
+from ..errors import DistutilsFileError, DistutilsOptionError
 from ..util import convert_path
-from distutils._log import log
 
 
 class build_py(Command):
@@ -38,7 +38,7 @@ def initialize_options(self):
         self.package = None
         self.package_data = None
         self.package_dir = None
-        self.compile = 0
+        self.compile = False
         self.optimize = 0
         self.force = None
 
@@ -95,7 +95,7 @@ def run(self):
             self.build_packages()
             self.build_package_data()
 
-        self.byte_compile(self.get_outputs(include_bytecode=0))
+        self.byte_compile(self.get_outputs(include_bytecode=False))
 
     def get_data_files(self):
         """Generate list of '(package,src_dir,build_dir,filenames)' tuples"""
@@ -129,14 +129,14 @@ def find_data_files(self, package, src_dir):
                 os.path.join(glob.escape(src_dir), convert_path(pattern))
             )
             # Files that match more than one pattern are only added once
-            files.extend(
-                [fn for fn in filelist if fn not in files and os.path.isfile(fn)]
-            )
+            files.extend([
+                fn for fn in filelist if fn not in files and os.path.isfile(fn)
+            ])
         return files
 
     def build_package_data(self):
         """Copy data files into build directory"""
-        for package, src_dir, build_dir, filenames in self.data_files:
+        for _package, src_dir, build_dir, filenames in self.data_files:
             for filename in filenames:
                 target = os.path.join(build_dir, filename)
                 self.mkpath(os.path.dirname(target))
@@ -191,12 +191,12 @@ def check_package(self, package, package_dir):
         if package_dir != "":
             if not os.path.exists(package_dir):
                 raise DistutilsFileError(
-                    "package directory '%s' does not exist" % package_dir
+                    f"package directory '{package_dir}' does not exist"
                 )
             if not os.path.isdir(package_dir):
                 raise DistutilsFileError(
-                    "supposed package directory '%s' exists, "
-                    "but is not a directory" % package_dir
+                    f"supposed package directory '{package_dir}' exists, "
+                    "but is not a directory"
                 )
 
         # Directories without __init__.py are namespace packages (PEP 420).
@@ -228,7 +228,7 @@ def find_package_modules(self, package, package_dir):
                 module = os.path.splitext(os.path.basename(f))[0]
                 modules.append((package, module, f))
             else:
-                self.debug_print("excluding %s" % setup_script)
+                self.debug_print(f"excluding {setup_script}")
         return modules
 
     def find_modules(self):
@@ -264,7 +264,7 @@ def find_modules(self):
                 (package_dir, checked) = packages[package]
             except KeyError:
                 package_dir = self.get_package_dir(package)
-                checked = 0
+                checked = False
 
             if not checked:
                 init_py = self.check_package(package, package_dir)
@@ -306,10 +306,10 @@ def get_module_outfile(self, build_dir, package, module):
         outfile_path = [build_dir] + list(package) + [module + ".py"]
         return os.path.join(*outfile_path)
 
-    def get_outputs(self, include_bytecode=1):
+    def get_outputs(self, include_bytecode=True):
         modules = self.find_all_modules()
         outputs = []
-        for package, module, module_file in modules:
+        for package, module, _module_file in modules:
             package = package.split('.')
             filename = self.get_module_outfile(self.build_lib, package, module)
             outputs.append(filename)
@@ -347,7 +347,7 @@ def build_module(self, module, module_file, package):
         outfile = self.get_module_outfile(self.build_lib, package, module)
         dir = os.path.dirname(outfile)
         self.mkpath(dir)
-        return self.copy_file(module_file, outfile, preserve_mode=0)
+        return self.copy_file(module_file, outfile, preserve_mode=False)
 
     def build_modules(self):
         modules = self.find_modules()
diff --git a/distutils/command/build_scripts.py b/distutils/command/build_scripts.py
index ce222f1e..9e5963c2 100644
--- a/distutils/command/build_scripts.py
+++ b/distutils/command/build_scripts.py
@@ -4,13 +4,14 @@
 
 import os
 import re
-from stat import ST_MODE
+import tokenize
 from distutils import sysconfig
+from distutils._log import log
+from stat import ST_MODE
+
+from .._modified import newer
 from ..core import Command
-from ..dep_util import newer
 from ..util import convert_path
-from distutils._log import log
-import tokenize
 
 shebang_pattern = re.compile('^#!.*python[0-9.]*([ \t].*)?$')
 """
@@ -95,7 +96,7 @@ def _copy_script(self, script, outfiles, updated_files):  # noqa: C901
         else:
             first_line = f.readline()
             if not first_line:
-                self.warn("%s is an empty file (skipping)" % script)
+                self.warn(f"{script} is an empty file (skipping)")
                 return
 
             shebang_match = shebang_pattern.match(first_line)
@@ -109,8 +110,7 @@ def _copy_script(self, script, outfiles, updated_files):  # noqa: C901
                 else:
                     executable = os.path.join(
                         sysconfig.get_config_var("BINDIR"),
-                        "python%s%s"
-                        % (
+                        "python{}{}".format(
                             sysconfig.get_config_var("VERSION"),
                             sysconfig.get_config_var("EXE"),
                         ),
@@ -156,9 +156,7 @@ def _validate_shebang(shebang, encoding):
         try:
             shebang.encode('utf-8')
         except UnicodeEncodeError:
-            raise ValueError(
-                "The shebang ({!r}) is not encodable " "to utf-8".format(shebang)
-            )
+            raise ValueError(f"The shebang ({shebang!r}) is not encodable to utf-8")
 
         # If the script is encoded to a custom encoding (use a
         # #coding:xxx cookie), the shebang has to be encodable to
@@ -167,6 +165,6 @@ def _validate_shebang(shebang, encoding):
             shebang.encode(encoding)
         except UnicodeEncodeError:
             raise ValueError(
-                "The shebang ({!r}) is not encodable "
-                "to the script encoding ({})".format(shebang, encoding)
+                f"The shebang ({shebang!r}) is not encodable "
+                f"to the script encoding ({encoding})"
             )
diff --git a/distutils/command/check.py b/distutils/command/check.py
index 575e49fb..58b3f949 100644
--- a/distutils/command/check.py
+++ b/distutils/command/check.py
@@ -2,16 +2,17 @@
 
 Implements the Distutils 'check' command.
 """
+
 import contextlib
 
 from ..core import Command
 from ..errors import DistutilsSetupError
 
 with contextlib.suppress(ImportError):
-    import docutils.utils
-    import docutils.parsers.rst
     import docutils.frontend
     import docutils.nodes
+    import docutils.parsers.rst
+    import docutils.utils
 
     class SilentReporter(docutils.utils.Reporter):
         def __init__(
@@ -20,7 +21,7 @@ def __init__(
             report_level,
             halt_level,
             stream=None,
-            debug=0,
+            debug=False,
             encoding='ascii',
             error_handler='replace',
         ):
@@ -32,7 +33,7 @@ def __init__(
         def system_message(self, level, message, *children, **kwargs):
             self.messages.append((level, message, children, kwargs))
             return docutils.nodes.system_message(
-                message, level=level, type=self.levels[level], *children, **kwargs
+                message, *children, level=level, type=self.levels[level], **kwargs
             )
 
 
@@ -57,9 +58,9 @@ class check(Command):
 
     def initialize_options(self):
         """Sets default values for options."""
-        self.restructuredtext = 0
+        self.restructuredtext = False
         self.metadata = 1
-        self.strict = 0
+        self.strict = False
         self._warnings = 0
 
     def finalize_options(self):
@@ -105,7 +106,7 @@ def check_metadata(self):
                 missing.append(attr)
 
         if missing:
-            self.warn("missing required meta-data: %s" % ', '.join(missing))
+            self.warn("missing required meta-data: {}".format(', '.join(missing)))
 
     def check_restructuredtext(self):
         """Checks if the long string fields are reST-compliant."""
@@ -115,7 +116,7 @@ def check_restructuredtext(self):
             if line is None:
                 warning = warning[1]
             else:
-                warning = '{} (line {})'.format(warning[1], line)
+                warning = f'{warning[1]} (line {line})'
             self.warn(warning)
 
     def _check_rst_data(self, data):
@@ -144,8 +145,11 @@ def _check_rst_data(self, data):
         try:
             parser.parse(data, document)
         except AttributeError as e:
-            reporter.messages.append(
-                (-1, 'Could not finish the parsing: %s.' % e, '', {})
-            )
+            reporter.messages.append((
+                -1,
+                f'Could not finish the parsing: {e}.',
+                '',
+                {},
+            ))
 
         return reporter.messages
diff --git a/distutils/command/clean.py b/distutils/command/clean.py
index 9413f7cf..fb54a60e 100644
--- a/distutils/command/clean.py
+++ b/distutils/command/clean.py
@@ -5,25 +5,26 @@
 # contributed by Bastian Kleineidam , added 2000-03-18
 
 import os
+from distutils._log import log
+
 from ..core import Command
 from ..dir_util import remove_tree
-from distutils._log import log
 
 
 class clean(Command):
     description = "clean up temporary files from 'build' command"
     user_options = [
-        ('build-base=', 'b', "base build directory (default: 'build.build-base')"),
+        ('build-base=', 'b', "base build directory [default: 'build.build-base']"),
         (
             'build-lib=',
             None,
-            "build directory for all modules (default: 'build.build-lib')",
+            "build directory for all modules [default: 'build.build-lib']",
         ),
-        ('build-temp=', 't', "temporary build directory (default: 'build.build-temp')"),
+        ('build-temp=', 't', "temporary build directory [default: 'build.build-temp']"),
         (
             'build-scripts=',
             None,
-            "build directory for scripts (default: 'build.build-scripts')",
+            "build directory for scripts [default: 'build.build-scripts']",
         ),
         ('bdist-base=', None, "temporary directory for built distributions"),
         ('all', 'a', "remove all build output, not just temporary by-products"),
diff --git a/distutils/command/config.py b/distutils/command/config.py
index 494d97d1..fe83c292 100644
--- a/distutils/command/config.py
+++ b/distutils/command/config.py
@@ -9,13 +9,17 @@
 this header file lives".
 """
 
+from __future__ import annotations
+
 import os
+import pathlib
 import re
+from collections.abc import Sequence
+from distutils._log import log
 
 from ..core import Command
 from ..errors import DistutilsExecError
 from ..sysconfig import customize_compiler
-from distutils._log import log
 
 LANG_EXT = {"c": ".c", "c++": ".cxx"}
 
@@ -90,7 +94,7 @@ def _check_compiler(self):
 
         if not isinstance(self.compiler, CCompiler):
             self.compiler = new_compiler(
-                compiler=self.compiler, dry_run=self.dry_run, force=1
+                compiler=self.compiler, dry_run=self.dry_run, force=True
             )
             customize_compiler(self.compiler)
             if self.include_dirs:
@@ -102,10 +106,10 @@ def _check_compiler(self):
 
     def _gen_temp_sourcefile(self, body, headers, lang):
         filename = "_configtest" + LANG_EXT[lang]
-        with open(filename, "w") as file:
+        with open(filename, "w", encoding='utf-8') as file:
             if headers:
                 for header in headers:
-                    file.write("#include <%s>\n" % header)
+                    file.write(f"#include <{header}>\n")
                 file.write("\n")
             file.write(body)
             if body[-1] != "\n":
@@ -122,7 +126,7 @@ def _preprocess(self, body, headers, include_dirs, lang):
     def _compile(self, body, headers, include_dirs, lang):
         src = self._gen_temp_sourcefile(body, headers, lang)
         if self.dump_source:
-            dump_file(src, "compiling '%s':" % src)
+            dump_file(src, f"compiling '{src}':")
         (obj,) = self.compiler.object_filenames([src])
         self.temp_files.extend([src, obj])
         self.compiler.compile([src], include_dirs=include_dirs)
@@ -199,15 +203,8 @@ def search_cpp(self, pattern, body=None, headers=None, include_dirs=None, lang="
         if isinstance(pattern, str):
             pattern = re.compile(pattern)
 
-        with open(out) as file:
-            match = False
-            while True:
-                line = file.readline()
-                if line == '':
-                    break
-                if pattern.search(line):
-                    match = True
-                    break
+        with open(out, encoding='utf-8') as file:
+            match = any(pattern.search(line) for line in file)
 
         self._clean()
         return match
@@ -295,8 +292,8 @@ def check_func(
         include_dirs=None,
         libraries=None,
         library_dirs=None,
-        decl=0,
-        call=0,
+        decl=False,
+        call=False,
     ):
         """Determine if function 'func' is available by constructing a
         source file that refers to 'func', and compiles and links it.
@@ -314,12 +311,12 @@ def check_func(
         self._check_compiler()
         body = []
         if decl:
-            body.append("int %s ();" % func)
+            body.append(f"int {func} ();")
         body.append("int main () {")
         if call:
-            body.append("  %s();" % func)
+            body.append(f"  {func}();")
         else:
-            body.append("  %s;" % func)
+            body.append(f"  {func};")
         body.append("}")
         body = "\n".join(body) + "\n"
 
@@ -331,7 +328,7 @@ def check_lib(
         library_dirs=None,
         headers=None,
         include_dirs=None,
-        other_libraries=[],
+        other_libraries: Sequence[str] = [],
     ):
         """Determine if 'library' is available to be linked against,
         without actually checking that any particular symbols are provided
@@ -346,7 +343,7 @@ def check_lib(
             "int main (void) { }",
             headers,
             include_dirs,
-            [library] + other_libraries,
+            [library] + list(other_libraries),
             library_dirs,
         )
 
@@ -369,8 +366,4 @@ def dump_file(filename, head=None):
         log.info('%s', filename)
     else:
         log.info(head)
-    file = open(filename)
-    try:
-        log.info(file.read())
-    finally:
-        file.close()
+    log.info(pathlib.Path(filename).read_text(encoding='utf-8'))
diff --git a/distutils/command/install.py b/distutils/command/install.py
index a7ac4e60..1fc09eef 100644
--- a/distutils/command/install.py
+++ b/distutils/command/install.py
@@ -2,25 +2,22 @@
 
 Implements the Distutils 'install' command."""
 
-import sys
-import os
 import contextlib
-import sysconfig
 import itertools
-
+import os
+import sys
+import sysconfig
 from distutils._log import log
+from site import USER_BASE, USER_SITE
+
+from .. import _collections
 from ..core import Command
 from ..debug import DEBUG
-from ..sysconfig import get_config_vars
-from ..file_util import write_file
-from ..util import convert_path, subst_vars, change_root
-from ..util import get_platform
 from ..errors import DistutilsOptionError, DistutilsPlatformError
+from ..file_util import write_file
+from ..sysconfig import get_config_vars
+from ..util import change_root, convert_path, get_platform, subst_vars
 from . import _framework_compat as fw
-from .. import _collections
-
-from site import USER_BASE
-from site import USER_SITE
 
 HAS_USER_SITE = True
 
@@ -196,8 +193,7 @@ class install(Command):
         (
             'install-platbase=',
             None,
-            "base installation directory for platform-specific files "
-            + "(instead of --exec-prefix or --home)",
+            "base installation directory for platform-specific files (instead of --exec-prefix or --home)",
         ),
         ('root=', None, "install everything relative to this alternate root directory"),
         # Or, explicitly set the installation scheme
@@ -214,8 +210,7 @@ class install(Command):
         (
             'install-lib=',
             None,
-            "installation directory for all module distributions "
-            + "(overrides --install-purelib and --install-platlib)",
+            "installation directory for all module distributions (overrides --install-purelib and --install-platlib)",
         ),
         ('install-headers=', None, "installation directory for C/C++ headers"),
         ('install-scripts=', None, "installation directory for Python scripts"),
@@ -245,9 +240,11 @@ class install(Command):
     boolean_options = ['compile', 'force', 'skip-build']
 
     if HAS_USER_SITE:
-        user_options.append(
-            ('user', None, "install in user site-package '%s'" % USER_SITE)
-        )
+        user_options.append((
+            'user',
+            None,
+            f"install in user site-package '{USER_SITE}'",
+        ))
         boolean_options.append('user')
 
     negative_opt = {'no-compile': 'compile'}
@@ -259,7 +256,7 @@ def initialize_options(self):
         self.prefix = None
         self.exec_prefix = None
         self.home = None
-        self.user = 0
+        self.user = False
 
         # These select only the installation base; it's up to the user to
         # specify the installation scheme (currently, that means supplying
@@ -294,7 +291,7 @@ def initialize_options(self):
         # 'install_path_file' is always true unless some outsider meddles
         # with it.
         self.extra_path = None
-        self.install_path_file = 1
+        self.install_path_file = True
 
         # 'force' forces installation, even if target files are not
         # out-of-date.  'skip_build' skips running the "build" command,
@@ -302,9 +299,9 @@ def initialize_options(self):
         # a user option, it's just there so the bdist_* commands can turn
         # it off) determines whether we warn about installing to a
         # directory not in sys.path.
-        self.force = 0
-        self.skip_build = 0
-        self.warn_dir = 1
+        self.force = False
+        self.skip_build = False
+        self.warn_dir = True
 
         # These are only here as a conduit from the 'build' command to the
         # 'install_*' commands that do the real work.  ('build_base' isn't
@@ -349,8 +346,7 @@ def finalize_options(self):  # noqa: C901
             self.install_base or self.install_platbase
         ):
             raise DistutilsOptionError(
-                "must supply either prefix/exec-prefix/home or "
-                + "install-base/install-platbase -- not both"
+                "must supply either prefix/exec-prefix/home or install-base/install-platbase -- not both"
             )
 
         if self.home and (self.prefix or self.exec_prefix):
@@ -432,9 +428,12 @@ def finalize_options(self):  # noqa: C901
             local_vars['userbase'] = self.install_userbase
             local_vars['usersite'] = self.install_usersite
 
-        self.config_vars = _collections.DictStack(
-            [fw.vars(), compat_vars, sysconfig.get_config_vars(), local_vars]
-        )
+        self.config_vars = _collections.DictStack([
+            fw.vars(),
+            compat_vars,
+            sysconfig.get_config_vars(),
+            local_vars,
+        ])
 
         self.expand_basedirs()
 
@@ -598,7 +597,7 @@ def finalize_other(self):
                 self.select_scheme(os.name)
             except KeyError:
                 raise DistutilsPlatformError(
-                    "I don't know how to install stuff on '%s'" % os.name
+                    f"I don't know how to install stuff on '{os.name}'"
                 )
 
     def select_scheme(self, name):
@@ -620,16 +619,14 @@ def expand_basedirs(self):
 
     def expand_dirs(self):
         """Calls `os.path.expanduser` on install dirs."""
-        self._expand_attrs(
-            [
-                'install_purelib',
-                'install_platlib',
-                'install_lib',
-                'install_headers',
-                'install_scripts',
-                'install_data',
-            ]
-        )
+        self._expand_attrs([
+            'install_purelib',
+            'install_platlib',
+            'install_lib',
+            'install_headers',
+            'install_scripts',
+            'install_data',
+        ])
 
     def convert_paths(self, *names):
         """Call `convert_path` over `names`."""
@@ -683,9 +680,9 @@ def create_home_path(self):
         if not self.user:
             return
         home = convert_path(os.path.expanduser("~"))
-        for name, path in self.config_vars.items():
+        for _name, path in self.config_vars.items():
             if str(path).startswith(home) and not os.path.isdir(path):
-                self.debug_print("os.makedirs('%s', 0o700)" % path)
+                self.debug_print(f"os.makedirs('{path}', 0o700)")
                 os.makedirs(path, 0o700)
 
     # -- Command execution methods -------------------------------------
@@ -701,7 +698,7 @@ def run(self):
             # internally, and not to sys.path, so we don't check the platform
             # matches what we are running.
             if self.warn_dir and build_plat != get_platform():
-                raise DistutilsPlatformError("Can't install when " "cross-compiling")
+                raise DistutilsPlatformError("Can't install when cross-compiling")
 
         # Run all sub-commands (at least those that need to be run)
         for cmd_name in self.get_sub_commands():
@@ -720,7 +717,7 @@ def run(self):
             self.execute(
                 write_file,
                 (self.record, outputs),
-                "writing list of installed files to '%s'" % self.record,
+                f"writing list of installed files to '{self.record}'",
             )
 
         sys_path = map(os.path.normpath, sys.path)
@@ -745,10 +742,10 @@ def create_path_file(self):
         filename = os.path.join(self.install_libbase, self.path_file + ".pth")
         if self.install_path_file:
             self.execute(
-                write_file, (filename, [self.extra_dirs]), "creating %s" % filename
+                write_file, (filename, [self.extra_dirs]), f"creating {filename}"
             )
         else:
-            self.warn("path file '%s' not created" % filename)
+            self.warn(f"path file '{filename}' not created")
 
     # -- Reporting methods ---------------------------------------------
 
diff --git a/distutils/command/install_data.py b/distutils/command/install_data.py
index 7ba35eef..624c0b90 100644
--- a/distutils/command/install_data.py
+++ b/distutils/command/install_data.py
@@ -6,6 +6,7 @@
 # contributed by Bastian Kleineidam
 
 import os
+
 from ..core import Command
 from ..util import change_root, convert_path
 
@@ -18,7 +19,7 @@ class install_data(Command):
             'install-dir=',
             'd',
             "base directory for installing data files "
-            "(default: installation base dir)",
+            "[default: installation base dir]",
         ),
         ('root=', None, "install everything relative to this alternate root directory"),
         ('force', 'f', "force installation (overwrite existing files)"),
@@ -30,9 +31,9 @@ def initialize_options(self):
         self.install_dir = None
         self.outfiles = []
         self.root = None
-        self.force = 0
+        self.force = False
         self.data_files = self.distribution.data_files
-        self.warn_dir = 1
+        self.warn_dir = True
 
     def finalize_options(self):
         self.set_undefined_options(
@@ -51,7 +52,7 @@ def run(self):
                 if self.warn_dir:
                     self.warn(
                         "setup script did not provide a directory for "
-                        "'%s' -- installing right in '%s'" % (f, self.install_dir)
+                        f"'{f}' -- installing right in '{self.install_dir}'"
                     )
                 (out, _) = self.copy_file(f, self.install_dir)
                 self.outfiles.append(out)
diff --git a/distutils/command/install_egg_info.py b/distutils/command/install_egg_info.py
index f3e8f344..4fbb3440 100644
--- a/distutils/command/install_egg_info.py
+++ b/distutils/command/install_egg_info.py
@@ -6,12 +6,12 @@
 """
 
 import os
-import sys
 import re
+import sys
 
-from ..cmd import Command
 from .. import dir_util
 from .._log import log
+from ..cmd import Command
 
 
 class install_egg_info(Command):
diff --git a/distutils/command/install_headers.py b/distutils/command/install_headers.py
index 085272c1..fbb3b242 100644
--- a/distutils/command/install_headers.py
+++ b/distutils/command/install_headers.py
@@ -19,7 +19,7 @@ class install_headers(Command):
 
     def initialize_options(self):
         self.install_dir = None
-        self.force = 0
+        self.force = False
         self.outfiles = []
 
     def finalize_options(self):
diff --git a/distutils/command/install_lib.py b/distutils/command/install_lib.py
index be4c2433..54a12d38 100644
--- a/distutils/command/install_lib.py
+++ b/distutils/command/install_lib.py
@@ -3,14 +3,13 @@
 Implements the Distutils 'install_lib' command
 (install all Python modules)."""
 
-import os
 import importlib.util
+import os
 import sys
 
 from ..core import Command
 from ..errors import DistutilsOptionError
 
-
 # Extension for Python source files.
 PYTHON_SOURCE_EXTENSION = ".py"
 
@@ -55,7 +54,7 @@ def initialize_options(self):
         # let the 'install' command dictate our installation directory
         self.install_dir = None
         self.build_dir = None
-        self.force = 0
+        self.force = False
         self.compile = None
         self.optimize = None
         self.skip_build = None
@@ -115,7 +114,7 @@ def install(self):
             outfiles = self.copy_tree(self.build_dir, self.install_dir)
         else:
             self.warn(
-                "'%s' does not exist -- no Python modules to install" % self.build_dir
+                f"'{self.build_dir}' does not exist -- no Python modules to install"
             )
             return
         return outfiles
diff --git a/distutils/command/install_scripts.py b/distutils/command/install_scripts.py
index 20f07aaa..bb43387f 100644
--- a/distutils/command/install_scripts.py
+++ b/distutils/command/install_scripts.py
@@ -6,10 +6,11 @@
 # contributed by Bastian Kleineidam
 
 import os
-from ..core import Command
 from distutils._log import log
 from stat import ST_MODE
 
+from ..core import Command
+
 
 class install_scripts(Command):
     description = "install scripts (Python or otherwise)"
@@ -25,7 +26,7 @@ class install_scripts(Command):
 
     def initialize_options(self):
         self.install_dir = None
-        self.force = 0
+        self.force = False
         self.build_dir = None
         self.skip_build = None
 
diff --git a/distutils/command/py37compat.py b/distutils/command/py37compat.py
deleted file mode 100644
index aa0c0a7f..00000000
--- a/distutils/command/py37compat.py
+++ /dev/null
@@ -1,31 +0,0 @@
-import sys
-
-
-def _pythonlib_compat():
-    """
-    On Python 3.7 and earlier, distutils would include the Python
-    library. See pypa/distutils#9.
-    """
-    from distutils import sysconfig
-
-    if not sysconfig.get_config_var('Py_ENABLED_SHARED'):
-        return
-
-    yield 'python{}.{}{}'.format(
-        sys.hexversion >> 24,
-        (sys.hexversion >> 16) & 0xFF,
-        sysconfig.get_config_var('ABIFLAGS'),
-    )
-
-
-def compose(f1, f2):
-    return lambda *args, **kwargs: f1(f2(*args, **kwargs))
-
-
-pythonlib = (
-    compose(list, _pythonlib_compat)
-    if sys.version_info < (3, 8)
-    and sys.platform != 'darwin'
-    and sys.platform[:3] != 'aix'
-    else list
-)
diff --git a/distutils/command/register.py b/distutils/command/register.py
index c19aabb9..c1acd27b 100644
--- a/distutils/command/register.py
+++ b/distutils/command/register.py
@@ -10,10 +10,11 @@
 import logging
 import urllib.parse
 import urllib.request
+from distutils._log import log
 from warnings import warn
 
+from .._itertools import always_iterable
 from ..core import PyPIRCCommand
-from distutils._log import log
 
 
 class register(PyPIRCCommand):
@@ -36,8 +37,8 @@ class register(PyPIRCCommand):
 
     def initialize_options(self):
         PyPIRCCommand.initialize_options(self)
-        self.list_classifiers = 0
-        self.strict = 0
+        self.list_classifiers = False
+        self.strict = False
 
     def finalize_options(self):
         PyPIRCCommand.finalize_options(self)
@@ -73,11 +74,11 @@ def check_metadata(self):
         check = self.distribution.get_command_obj('check')
         check.ensure_finalized()
         check.strict = self.strict
-        check.restructuredtext = 1
+        check.restructuredtext = True
         check.run()
 
     def _set_config(self):
-        '''Reads the configuration file and set attributes.'''
+        """Reads the configuration file and set attributes."""
         config = self._read_pypirc()
         if config != {}:
             self.username = config['username']
@@ -87,25 +88,25 @@ def _set_config(self):
             self.has_config = True
         else:
             if self.repository not in ('pypi', self.DEFAULT_REPOSITORY):
-                raise ValueError('%s not found in .pypirc' % self.repository)
+                raise ValueError(f'{self.repository} not found in .pypirc')
             if self.repository == 'pypi':
                 self.repository = self.DEFAULT_REPOSITORY
             self.has_config = False
 
     def classifiers(self):
-        '''Fetch the list of classifiers from the server.'''
+        """Fetch the list of classifiers from the server."""
         url = self.repository + '?:action=list_classifiers'
         response = urllib.request.urlopen(url)
         log.info(self._read_pypi_response(response))
 
     def verify_metadata(self):
-        '''Send the metadata to the package index server to be checked.'''
+        """Send the metadata to the package index server to be checked."""
         # send the info to the server and report the result
         (code, result) = self.post_to_server(self.build_post_data('verify'))
         log.info('Server response (%s): %s', code, result)
 
     def send_metadata(self):  # noqa: C901
-        '''Send the metadata to the package index server.
+        """Send the metadata to the package index server.
 
         Well, do the following:
         1. figure who the user is, and then
@@ -131,7 +132,7 @@ def send_metadata(self):  # noqa: C901
          2. register as a new user, or
          3. set the password to a random string and email the user.
 
-        '''
+        """
         # see if we can short-cut and get the username/password from the
         # config
         if self.has_config:
@@ -146,13 +147,13 @@ def send_metadata(self):  # noqa: C901
         choices = '1 2 3 4'.split()
         while choice not in choices:
             self.announce(
-                '''\
+                """\
 We need to know who you are, so please choose either:
  1. use your existing login,
  2. register as a new user,
  3. have the server generate a new password for you (and email it to you), or
  4. quit
-Your selection [default 1]: ''',
+Your selection [default 1]: """,
                 logging.INFO,
             )
             choice = input()
@@ -174,7 +175,7 @@ def send_metadata(self):  # noqa: C901
             auth.add_password(self.realm, host, username, password)
             # send the info to the server and report the result
             code, result = self.post_to_server(self.build_post_data('submit'), auth)
-            self.announce('Server response ({}): {}'.format(code, result), logging.INFO)
+            self.announce(f'Server response ({code}): {result}', logging.INFO)
 
             # possibly save the login
             if code == 200:
@@ -191,7 +192,7 @@ def send_metadata(self):  # noqa: C901
                         logging.INFO,
                     )
                     self.announce(
-                        '(the login will be stored in %s)' % self._get_rc_file(),
+                        f'(the login will be stored in {self._get_rc_file()})',
                         logging.INFO,
                     )
                     choice = 'X'
@@ -224,7 +225,7 @@ def send_metadata(self):  # noqa: C901
                 log.info('Server response (%s): %s', code, result)
             else:
                 log.info('You will receive an email shortly.')
-                log.info('Follow the instructions in it to ' 'complete registration.')
+                log.info('Follow the instructions in it to complete registration.')
         elif choice == '3':
             data = {':action': 'password_reset'}
             data['email'] = ''
@@ -262,7 +263,7 @@ def build_post_data(self, action):
         return data
 
     def post_to_server(self, data, auth=None):  # noqa: C901
-        '''Post a query to the server, and return a string response.'''
+        """Post a query to the server, and return a string response."""
         if 'name' in data:
             self.announce(
                 'Registering {} to {}'.format(data['name'], self.repository),
@@ -273,14 +274,10 @@ def post_to_server(self, data, auth=None):  # noqa: C901
         sep_boundary = '\n--' + boundary
         end_boundary = sep_boundary + '--'
         body = io.StringIO()
-        for key, value in data.items():
-            # handle multiple entries for the same name
-            if type(value) not in (type([]), type(())):
-                value = [value]
-            for value in value:
-                value = str(value)
+        for key, values in data.items():
+            for value in map(str, make_iterable(values)):
                 body.write(sep_boundary)
-                body.write('\nContent-Disposition: form-data; name="%s"' % key)
+                body.write(f'\nContent-Disposition: form-data; name="{key}"')
                 body.write("\n\n")
                 body.write(value)
                 if value and value[-1] == '\r':
@@ -291,8 +288,7 @@ def post_to_server(self, data, auth=None):  # noqa: C901
 
         # build the Request
         headers = {
-            'Content-type': 'multipart/form-data; boundary=%s; charset=utf-8'
-            % boundary,
+            'Content-type': f'multipart/form-data; boundary={boundary}; charset=utf-8',
             'Content-length': str(len(body)),
         }
         req = urllib.request.Request(self.repository, body, headers)
@@ -318,3 +314,9 @@ def post_to_server(self, data, auth=None):  # noqa: C901
             msg = '\n'.join(('-' * 75, data, '-' * 75))
             self.announce(msg, logging.INFO)
         return result
+
+
+def make_iterable(values):
+    if values is None:
+        return [None]
+    return always_iterable(values)
diff --git a/distutils/command/sdist.py b/distutils/command/sdist.py
index ac489726..04333dd2 100644
--- a/distutils/command/sdist.py
+++ b/distutils/command/sdist.py
@@ -4,26 +4,25 @@
 
 import os
 import sys
+from distutils import archive_util, dir_util, file_util
+from distutils._log import log
 from glob import glob
+from itertools import filterfalse
 from warnings import warn
 
 from ..core import Command
-from distutils import dir_util
-from distutils import file_util
-from distutils import archive_util
-from ..text_file import TextFile
+from ..errors import DistutilsOptionError, DistutilsTemplateError
 from ..filelist import FileList
-from distutils._log import log
+from ..text_file import TextFile
 from ..util import convert_path
-from ..errors import DistutilsOptionError, DistutilsTemplateError
 
 
 def show_formats():
     """Print all possible values for the 'formats' option (used by
     the "--help-formats" command-line option).
     """
-    from ..fancy_getopt import FancyGetopt
     from ..archive_util import ARCHIVE_FORMATS
+    from ..fancy_getopt import FancyGetopt
 
     formats = []
     for format in ARCHIVE_FORMATS.keys():
@@ -62,7 +61,7 @@ def checking_metadata(self):
         (
             'manifest-only',
             'o',
-            "just regenerate the manifest and then stop " "(implies --force-manifest)",
+            "just regenerate the manifest and then stop (implies --force-manifest)",
         ),
         (
             'force-manifest',
@@ -79,7 +78,7 @@ def checking_metadata(self):
         (
             'dist-dir=',
             'd',
-            "directory to put the source distribution archive(s) in " "[default: dist]",
+            "directory to put the source distribution archive(s) in [default: dist]",
         ),
         (
             'metadata-check',
@@ -126,14 +125,14 @@ def initialize_options(self):
 
         # 'use_defaults': if true, we will include the default file set
         # in the manifest
-        self.use_defaults = 1
-        self.prune = 1
+        self.use_defaults = True
+        self.prune = True
 
-        self.manifest_only = 0
-        self.force_manifest = 0
+        self.manifest_only = False
+        self.force_manifest = False
 
         self.formats = ['gztar']
-        self.keep_temp = 0
+        self.keep_temp = False
         self.dist_dir = None
 
         self.archive_files = None
@@ -151,7 +150,7 @@ def finalize_options(self):
 
         bad_format = archive_util.check_archive_formats(self.formats)
         if bad_format:
-            raise DistutilsOptionError("unknown archive format '%s'" % bad_format)
+            raise DistutilsOptionError(f"unknown archive format '{bad_format}'")
 
         if self.dist_dir is None:
             self.dist_dir = "dist"
@@ -289,7 +288,7 @@ def _add_defaults_standards(self):
                 if self._cs_path_exists(fn):
                     self.filelist.append(fn)
                 else:
-                    self.warn("standard file '%s' not found" % fn)
+                    self.warn(f"standard file '{fn}' not found")
 
     def _add_defaults_optional(self):
         optional = ['tests/test*.py', 'test/test*.py', 'setup.cfg']
@@ -309,7 +308,7 @@ def _add_defaults_python(self):
 
         # getting package_data files
         # (computed in build_py.data_files by build_py.finalize_options)
-        for pkg, src_dir, build_dir, filenames in build_py.data_files:
+        for _pkg, src_dir, _build_dir, filenames in build_py.data_files:
             for filename in filenames:
                 self.filelist.append(os.path.join(src_dir, filename))
 
@@ -354,12 +353,12 @@ def read_template(self):
         log.info("reading manifest template '%s'", self.template)
         template = TextFile(
             self.template,
-            strip_comments=1,
-            skip_blanks=1,
-            join_lines=1,
-            lstrip_ws=1,
-            rstrip_ws=1,
-            collapse_join=1,
+            strip_comments=True,
+            skip_blanks=True,
+            join_lines=True,
+            lstrip_ws=True,
+            rstrip_ws=True,
+            collapse_join=True,
         )
 
         try:
@@ -402,7 +401,7 @@ def prune_file_list(self):
 
         vcs_dirs = ['RCS', 'CVS', r'\.svn', r'\.hg', r'\.git', r'\.bzr', '_darcs']
         vcs_ptrn = r'(^|{})({})({}).*'.format(seps, '|'.join(vcs_dirs), seps)
-        self.filelist.exclude_pattern(vcs_ptrn, is_regex=1)
+        self.filelist.exclude_pattern(vcs_ptrn, is_regex=True)
 
     def write_manifest(self):
         """Write the file list in 'self.filelist' (presumably as filled in
@@ -411,8 +410,7 @@ def write_manifest(self):
         """
         if self._manifest_is_not_generated():
             log.info(
-                "not writing to manually maintained "
-                "manifest file '%s'" % self.manifest
+                f"not writing to manually maintained manifest file '{self.manifest}'"
             )
             return
 
@@ -421,7 +419,7 @@ def write_manifest(self):
         self.execute(
             file_util.write_file,
             (self.manifest, content),
-            "writing manifest file '%s'" % self.manifest,
+            f"writing manifest file '{self.manifest}'",
         )
 
     def _manifest_is_not_generated(self):
@@ -429,11 +427,8 @@ def _manifest_is_not_generated(self):
         if not os.path.isfile(self.manifest):
             return False
 
-        fp = open(self.manifest)
-        try:
-            first_line = fp.readline()
-        finally:
-            fp.close()
+        with open(self.manifest, encoding='utf-8') as fp:
+            first_line = next(fp)
         return first_line != '# file GENERATED by distutils, do NOT edit\n'
 
     def read_manifest(self):
@@ -442,13 +437,11 @@ def read_manifest(self):
         distribution.
         """
         log.info("reading manifest file '%s'", self.manifest)
-        with open(self.manifest) as manifest:
-            for line in manifest:
+        with open(self.manifest, encoding='utf-8') as lines:
+            self.filelist.extend(
                 # ignore comments and blank lines
-                line = line.strip()
-                if line.startswith('#') or not line:
-                    continue
-                self.filelist.append(line)
+                filter(None, filterfalse(is_comment, map(str.strip, lines)))
+            )
 
     def make_release_tree(self, base_dir, files):
         """Create the directory tree that will become the source
@@ -474,10 +467,10 @@ def make_release_tree(self, base_dir, files):
 
         if hasattr(os, 'link'):  # can make hard links on this system
             link = 'hard'
-            msg = "making hard links in %s..." % base_dir
+            msg = f"making hard links in {base_dir}..."
         else:  # nope, have to copy
             link = None
-            msg = "copying files to %s..." % base_dir
+            msg = f"copying files to {base_dir}..."
 
         if not files:
             log.warning("no files to distribute -- empty manifest?")
@@ -528,3 +521,7 @@ def get_archive_files(self):
         was run, or None if the command hasn't run yet.
         """
         return self.archive_files
+
+
+def is_comment(line):
+    return line.startswith('#')
diff --git a/distutils/command/upload.py b/distutils/command/upload.py
index caf15f04..a2461e08 100644
--- a/distutils/command/upload.py
+++ b/distutils/command/upload.py
@@ -5,18 +5,19 @@
 index).
 """
 
-import os
-import io
 import hashlib
+import io
 import logging
+import os
 from base64 import standard_b64encode
-from urllib.request import urlopen, Request, HTTPError
 from urllib.parse import urlparse
-from ..errors import DistutilsError, DistutilsOptionError
+from urllib.request import HTTPError, Request, urlopen
+
+from .._itertools import always_iterable
 from ..core import PyPIRCCommand
+from ..errors import DistutilsError, DistutilsOptionError
 from ..spawn import spawn
 
-
 # PyPI Warehouse supports MD5, SHA256, and Blake2 (blake2-256)
 # https://bugs.python.org/issue40698
 _FILE_CONTENT_DIGESTS = {
@@ -40,7 +41,7 @@ def initialize_options(self):
         PyPIRCCommand.initialize_options(self)
         self.username = ''
         self.password = ''
-        self.show_response = 0
+        self.show_response = False
         self.sign = False
         self.identity = None
 
@@ -74,7 +75,7 @@ def upload_file(self, command, pyversion, filename):  # noqa: C901
         # Makes sure the repository URL is compliant
         schema, netloc, url, params, query, fragments = urlparse(self.repository)
         if params or query or fragments:
-            raise AssertionError("Incompatible url %s" % self.repository)
+            raise AssertionError(f"Incompatible url {self.repository}")
 
         if schema not in ('http', 'https'):
             raise AssertionError("unsupported schema " + schema)
@@ -151,14 +152,11 @@ def upload_file(self, command, pyversion, filename):  # noqa: C901
         sep_boundary = b'\r\n--' + boundary.encode('ascii')
         end_boundary = sep_boundary + b'--\r\n'
         body = io.BytesIO()
-        for key, value in data.items():
-            title = '\r\nContent-Disposition: form-data; name="%s"' % key
-            # handle multiple entries for the same name
-            if not isinstance(value, list):
-                value = [value]
-            for value in value:
+        for key, values in data.items():
+            title = f'\r\nContent-Disposition: form-data; name="{key}"'
+            for value in make_iterable(values):
                 if type(value) is tuple:
-                    title += '; filename="%s"' % value[0]
+                    title += f'; filename="{value[0]}"'
                     value = value[1]
                 else:
                     value = str(value).encode('utf-8')
@@ -169,12 +167,12 @@ def upload_file(self, command, pyversion, filename):  # noqa: C901
         body.write(end_boundary)
         body = body.getvalue()
 
-        msg = "Submitting {} to {}".format(filename, self.repository)
+        msg = f"Submitting {filename} to {self.repository}"
         self.announce(msg, logging.INFO)
 
         # build the Request
         headers = {
-            'Content-type': 'multipart/form-data; boundary=%s' % boundary,
+            'Content-type': f'multipart/form-data; boundary={boundary}',
             'Content-length': str(len(body)),
             'Authorization': auth,
         }
@@ -193,14 +191,18 @@ def upload_file(self, command, pyversion, filename):  # noqa: C901
             raise
 
         if status == 200:
-            self.announce(
-                'Server response ({}): {}'.format(status, reason), logging.INFO
-            )
+            self.announce(f'Server response ({status}): {reason}', logging.INFO)
             if self.show_response:
                 text = self._read_pypi_response(result)
                 msg = '\n'.join(('-' * 75, text, '-' * 75))
                 self.announce(msg, logging.INFO)
         else:
-            msg = 'Upload failed ({}): {}'.format(status, reason)
+            msg = f'Upload failed ({status}): {reason}'
             self.announce(msg, logging.ERROR)
             raise DistutilsError(msg)
+
+
+def make_iterable(values):
+    if values is None:
+        return [None]
+    return always_iterable(values, base_type=(bytes, str, tuple))
diff --git a/distutils/compat/__init__.py b/distutils/compat/__init__.py
new file mode 100644
index 00000000..e12534a3
--- /dev/null
+++ b/distutils/compat/__init__.py
@@ -0,0 +1,15 @@
+from __future__ import annotations
+
+from .py38 import removeprefix
+
+
+def consolidate_linker_args(args: list[str]) -> list[str] | str:
+    """
+    Ensure the return value is a string for backward compatibility.
+
+    Retain until at least 2025-04-31. See pypa/distutils#246
+    """
+
+    if not all(arg.startswith('-Wl,') for arg in args):
+        return args
+    return '-Wl,' + ','.join(removeprefix(arg, '-Wl,') for arg in args)
diff --git a/distutils/compat/py38.py b/distutils/compat/py38.py
new file mode 100644
index 00000000..2d442111
--- /dev/null
+++ b/distutils/compat/py38.py
@@ -0,0 +1,33 @@
+import sys
+
+if sys.version_info < (3, 9):
+
+    def removesuffix(self, suffix):
+        # suffix='' should not call self[:-0].
+        if suffix and self.endswith(suffix):
+            return self[: -len(suffix)]
+        else:
+            return self[:]
+
+    def removeprefix(self, prefix):
+        if self.startswith(prefix):
+            return self[len(prefix) :]
+        else:
+            return self[:]
+else:
+
+    def removesuffix(self, suffix):
+        return self.removesuffix(suffix)
+
+    def removeprefix(self, prefix):
+        return self.removeprefix(prefix)
+
+
+def aix_platform(osname, version, release):
+    try:
+        import _aix_support  # type: ignore
+
+        return _aix_support.aix_platform()
+    except ImportError:
+        pass
+    return f"{osname}-{version}.{release}"
diff --git a/distutils/compat/py39.py b/distutils/compat/py39.py
new file mode 100644
index 00000000..1b436d76
--- /dev/null
+++ b/distutils/compat/py39.py
@@ -0,0 +1,66 @@
+import functools
+import itertools
+import platform
+import sys
+
+
+def add_ext_suffix_39(vars):
+    """
+    Ensure vars contains 'EXT_SUFFIX'. pypa/distutils#130
+    """
+    import _imp
+
+    ext_suffix = _imp.extension_suffixes()[0]
+    vars.update(
+        EXT_SUFFIX=ext_suffix,
+        # sysconfig sets SO to match EXT_SUFFIX, so maintain
+        # that expectation.
+        # https://github.com/python/cpython/blob/785cc6770588de087d09e89a69110af2542be208/Lib/sysconfig.py#L671-L673
+        SO=ext_suffix,
+    )
+
+
+needs_ext_suffix = sys.version_info < (3, 10) and platform.system() == 'Windows'
+add_ext_suffix = add_ext_suffix_39 if needs_ext_suffix else lambda vars: None
+
+
+# from more_itertools
+class UnequalIterablesError(ValueError):
+    def __init__(self, details=None):
+        msg = 'Iterables have different lengths'
+        if details is not None:
+            msg += (': index 0 has length {}; index {} has length {}').format(*details)
+
+        super().__init__(msg)
+
+
+# from more_itertools
+def _zip_equal_generator(iterables):
+    _marker = object()
+    for combo in itertools.zip_longest(*iterables, fillvalue=_marker):
+        for val in combo:
+            if val is _marker:
+                raise UnequalIterablesError()
+        yield combo
+
+
+# from more_itertools
+def _zip_equal(*iterables):
+    # Check whether the iterables are all the same size.
+    try:
+        first_size = len(iterables[0])
+        for i, it in enumerate(iterables[1:], 1):
+            size = len(it)
+            if size != first_size:
+                raise UnequalIterablesError(details=(first_size, i, size))
+        # All sizes are equal, we can use the built-in zip.
+        return zip(*iterables)
+    # If any one of the iterables didn't have a length, start reading
+    # them until one runs out.
+    except TypeError:
+        return _zip_equal_generator(iterables)
+
+
+zip_strict = (
+    _zip_equal if sys.version_info < (3, 10) else functools.partial(zip, strict=True)
+)
diff --git a/distutils/config.py b/distutils/config.py
index 9a4044ad..ebd2e11d 100644
--- a/distutils/config.py
+++ b/distutils/config.py
@@ -3,6 +3,8 @@
 Provides the PyPIRCCommand class, the base class for the command classes
 that uses .pypirc in the distutils.command package.
 """
+
+import email.message
 import os
 from configparser import RawConfigParser
 
@@ -28,7 +30,7 @@ class PyPIRCCommand(Command):
     realm = None
 
     user_options = [
-        ('repository=', 'r', "url of repository [default: %s]" % DEFAULT_REPOSITORY),
+        ('repository=', 'r', f"url of repository [default: {DEFAULT_REPOSITORY}]"),
         ('show-response', None, 'display full response text from server'),
     ]
 
@@ -41,18 +43,19 @@ def _get_rc_file(self):
     def _store_pypirc(self, username, password):
         """Creates a default .pypirc file."""
         rc = self._get_rc_file()
-        with os.fdopen(os.open(rc, os.O_CREAT | os.O_WRONLY, 0o600), 'w') as f:
+        raw = os.open(rc, os.O_CREAT | os.O_WRONLY, 0o600)
+        with os.fdopen(raw, 'w', encoding='utf-8') as f:
             f.write(DEFAULT_PYPIRC % (username, password))
 
     def _read_pypirc(self):  # noqa: C901
         """Reads the .pypirc file."""
         rc = self._get_rc_file()
         if os.path.exists(rc):
-            self.announce('Using PyPI login from %s' % rc)
+            self.announce(f'Using PyPI login from {rc}')
             repository = self.repository or self.DEFAULT_REPOSITORY
 
             config = RawConfigParser()
-            config.read(rc)
+            config.read(rc, encoding='utf-8')
             sections = config.sections()
             if 'distutils' in sections:
                 # let's get the list of servers
@@ -119,17 +122,14 @@ def _read_pypirc(self):  # noqa: C901
 
     def _read_pypi_response(self, response):
         """Read and decode a PyPI HTTP response."""
-        import cgi
-
         content_type = response.getheader('content-type', 'text/plain')
-        encoding = cgi.parse_header(content_type)[1].get('charset', 'ascii')
-        return response.read().decode(encoding)
+        return response.read().decode(_extract_encoding(content_type))
 
     def initialize_options(self):
         """Initialize options."""
         self.repository = None
         self.realm = None
-        self.show_response = 0
+        self.show_response = False
 
     def finalize_options(self):
         """Finalizes options."""
@@ -137,3 +137,15 @@ def finalize_options(self):
             self.repository = self.DEFAULT_REPOSITORY
         if self.realm is None:
             self.realm = self.DEFAULT_REALM
+
+
+def _extract_encoding(content_type):
+    """
+    >>> _extract_encoding('text/plain')
+    'ascii'
+    >>> _extract_encoding('text/html; charset="utf8"')
+    'utf8'
+    """
+    msg = email.message.EmailMessage()
+    msg['content-type'] = content_type
+    return msg['content-type'].params.get('charset', 'ascii')
diff --git a/distutils/core.py b/distutils/core.py
index 05d29719..82113c47 100644
--- a/distutils/core.py
+++ b/distutils/core.py
@@ -10,21 +10,20 @@
 import sys
 import tokenize
 
+from .cmd import Command
+from .config import PyPIRCCommand
 from .debug import DEBUG
+
+# Mainly import these so setup scripts can "from distutils.core import" them.
+from .dist import Distribution
 from .errors import (
-    DistutilsSetupError,
-    DistutilsError,
     CCompilerError,
     DistutilsArgError,
+    DistutilsError,
+    DistutilsSetupError,
 )
-
-# Mainly import these so setup scripts can "from distutils.core import" them.
-from .dist import Distribution
-from .cmd import Command
-from .config import PyPIRCCommand
 from .extension import Extension
 
-
 __all__ = ['Distribution', 'Command', 'PyPIRCCommand', 'Extension', 'setup']
 
 # This is a barebones help message generated displayed when the user
@@ -147,7 +146,7 @@ class found in 'cmdclass' is used in place of the default, which is
         _setup_distribution = dist = klass(attrs)
     except DistutilsSetupError as msg:
         if 'name' not in attrs:
-            raise SystemExit("error in setup command: %s" % msg)
+            raise SystemExit(f"error in setup command: {msg}")
         else:
             raise SystemExit("error in {} setup command: {}".format(attrs['name'], msg))
 
@@ -171,7 +170,7 @@ class found in 'cmdclass' is used in place of the default, which is
     try:
         ok = dist.parse_command_line()
     except DistutilsArgError as msg:
-        raise SystemExit(gen_usage(dist.script_name) + "\nerror: %s" % msg)
+        raise SystemExit(gen_usage(dist.script_name) + f"\nerror: {msg}")
 
     if DEBUG:
         print("options (after parsing command line):")
@@ -203,10 +202,10 @@ def run_commands(dist):
         raise SystemExit("interrupted")
     except OSError as exc:
         if DEBUG:
-            sys.stderr.write("error: {}\n".format(exc))
+            sys.stderr.write(f"error: {exc}\n")
             raise
         else:
-            raise SystemExit("error: {}".format(exc))
+            raise SystemExit(f"error: {exc}")
 
     except (DistutilsError, CCompilerError) as msg:
         if DEBUG:
@@ -249,7 +248,7 @@ def run_setup(script_name, script_args=None, stop_after="run"):
     used to drive the Distutils.
     """
     if stop_after not in ('init', 'config', 'commandline', 'run'):
-        raise ValueError("invalid value for 'stop_after': {!r}".format(stop_after))
+        raise ValueError(f"invalid value for 'stop_after': {stop_after!r}")
 
     global _setup_stop_after, _setup_distribution
     _setup_stop_after = stop_after
@@ -275,11 +274,8 @@ def run_setup(script_name, script_args=None, stop_after="run"):
 
     if _setup_distribution is None:
         raise RuntimeError(
-            (
-                "'distutils.core.setup()' was never called -- "
-                "perhaps '%s' is not a Distutils setup script?"
-            )
-            % script_name
+            "'distutils.core.setup()' was never called -- "
+            f"perhaps '{script_name}' is not a Distutils setup script?"
         )
 
     # I wonder if the setup script's namespace -- g and l -- would be of
diff --git a/distutils/cygwinccompiler.py b/distutils/cygwinccompiler.py
index 47efa377..7b812fd0 100644
--- a/distutils/cygwinccompiler.py
+++ b/distutils/cygwinccompiler.py
@@ -6,25 +6,25 @@
 cygwin in no-cygwin mode).
 """
 
+import copy
 import os
+import pathlib
 import re
-import sys
-import copy
 import shlex
+import sys
 import warnings
 from subprocess import check_output
 
-from .unixccompiler import UnixCCompiler
-from .file_util import write_file
+from ._collections import RangeMap
 from .errors import (
-    DistutilsExecError,
-    DistutilsPlatformError,
     CCompilerError,
     CompileError,
+    DistutilsExecError,
+    DistutilsPlatformError,
 )
+from .file_util import write_file
+from .unixccompiler import UnixCCompiler
 from .version import LooseVersion, suppress_known_deprecation
-from ._collections import RangeMap
-
 
 _msvcr_lookup = RangeMap.left(
     {
@@ -57,11 +57,11 @@ def get_msvcr():
     try:
         msc_ver = int(match.group(1))
     except AttributeError:
-        return
+        return []
     try:
         return _msvcr_lookup[msc_ver]
     except KeyError:
-        raise ValueError("Unknown MS Compiler version %s " % msc_ver)
+        raise ValueError(f"Unknown MS Compiler version {msc_ver} ")
 
 
 _runtime_library_dirs_msg = (
@@ -83,18 +83,16 @@ class CygwinCCompiler(UnixCCompiler):
     dylib_lib_format = "cyg%s%s"
     exe_extension = ".exe"
 
-    def __init__(self, verbose=0, dry_run=0, force=0):
+    def __init__(self, verbose=False, dry_run=False, force=False):
         super().__init__(verbose, dry_run, force)
 
         status, details = check_config_h()
-        self.debug_print(
-            "Python's GCC status: {} (details: {})".format(status, details)
-        )
+        self.debug_print(f"Python's GCC status: {status} (details: {details})")
         if status is not CONFIG_H_OK:
             self.warn(
                 "Python's pyconfig.h doesn't seem to support your compiler. "
-                "Reason: %s. "
-                "Compiling may fail because of undefined preprocessor macros." % details
+                f"Reason: {details}. "
+                "Compiling may fail because of undefined preprocessor macros."
             )
 
         self.cc = os.environ.get('CC', 'gcc')
@@ -104,11 +102,11 @@ def __init__(self, verbose=0, dry_run=0, force=0):
         shared_option = "-shared"
 
         self.set_executables(
-            compiler='%s -mcygwin -O -Wall' % self.cc,
-            compiler_so='%s -mcygwin -mdll -O -Wall' % self.cc,
-            compiler_cxx='%s -mcygwin -O -Wall' % self.cxx,
-            linker_exe='%s -mcygwin' % self.cc,
-            linker_so=('{} -mcygwin {}'.format(self.linker_dll, shared_option)),
+            compiler=f'{self.cc} -mcygwin -O -Wall',
+            compiler_so=f'{self.cc} -mcygwin -mdll -O -Wall',
+            compiler_cxx=f'{self.cxx} -mcygwin -O -Wall',
+            linker_exe=f'{self.cc} -mcygwin',
+            linker_so=(f'{self.linker_dll} -mcygwin {shared_option}'),
         )
 
         # Include the appropriate MSVC runtime library if Python was built
@@ -156,7 +154,7 @@ def link(
         library_dirs=None,
         runtime_library_dirs=None,
         export_symbols=None,
-        debug=0,
+        debug=False,
         extra_preargs=None,
         extra_postargs=None,
         build_temp=None,
@@ -197,10 +195,10 @@ def link(
             def_file = os.path.join(temp_dir, dll_name + ".def")
 
             # Generate .def file
-            contents = ["LIBRARY %s" % os.path.basename(output_filename), "EXPORTS"]
+            contents = [f"LIBRARY {os.path.basename(output_filename)}", "EXPORTS"]
             for sym in export_symbols:
                 contents.append(sym)
-            self.execute(write_file, (def_file, contents), "writing %s" % def_file)
+            self.execute(write_file, (def_file, contents), f"writing {def_file}")
 
             # next add options for def-file
 
@@ -267,7 +265,7 @@ class Mingw32CCompiler(CygwinCCompiler):
 
     compiler_type = 'mingw32'
 
-    def __init__(self, verbose=0, dry_run=0, force=0):
+    def __init__(self, verbose=False, dry_run=False, force=False):
         super().__init__(verbose, dry_run, force)
 
         shared_option = "-shared"
@@ -276,11 +274,11 @@ def __init__(self, verbose=0, dry_run=0, force=0):
             raise CCompilerError('Cygwin gcc cannot be used with --compiler=mingw32')
 
         self.set_executables(
-            compiler='%s -O -Wall' % self.cc,
-            compiler_so='%s -mdll -O -Wall' % self.cc,
-            compiler_cxx='%s -O -Wall' % self.cxx,
-            linker_exe='%s' % self.cc,
-            linker_so='{} {}'.format(self.linker_dll, shared_option),
+            compiler=f'{self.cc} -O -Wall',
+            compiler_so=f'{self.cc} -shared -O -Wall',
+            compiler_cxx=f'{self.cxx} -O -Wall',
+            linker_exe=f'{self.cc}',
+            linker_so=f'{self.linker_dll} {shared_option}',
         )
 
     def runtime_library_dir_option(self, dir):
@@ -331,20 +329,21 @@ def check_config_h():
     # let's see if __GNUC__ is mentioned in python.h
     fn = sysconfig.get_config_h_filename()
     try:
-        config_h = open(fn)
-        try:
-            if "__GNUC__" in config_h.read():
-                return CONFIG_H_OK, "'%s' mentions '__GNUC__'" % fn
-            else:
-                return CONFIG_H_NOTOK, "'%s' does not mention '__GNUC__'" % fn
-        finally:
-            config_h.close()
+        config_h = pathlib.Path(fn).read_text(encoding='utf-8')
+        substring = '__GNUC__'
+        if substring in config_h:
+            code = CONFIG_H_OK
+            mention_inflected = 'mentions'
+        else:
+            code = CONFIG_H_NOTOK
+            mention_inflected = 'does not mention'
+        return code, f"{fn!r} {mention_inflected} {substring!r}"
     except OSError as exc:
-        return (CONFIG_H_UNCERTAIN, "couldn't read '{}': {}".format(fn, exc.strerror))
+        return (CONFIG_H_UNCERTAIN, f"couldn't read '{fn}': {exc.strerror}")
 
 
 def is_cygwincc(cc):
-    '''Try to determine if the compiler that would be used is from cygwin.'''
+    """Try to determine if the compiler that would be used is from cygwin."""
     out_string = check_output(shlex.split(cc) + ['-dumpmachine'])
     return out_string.strip().endswith(b'cygwin')
 
diff --git a/distutils/dep_util.py b/distutils/dep_util.py
index 48da8641..09a8a2e1 100644
--- a/distutils/dep_util.py
+++ b/distutils/dep_util.py
@@ -1,96 +1,14 @@
-"""distutils.dep_util
+import warnings
 
-Utility functions for simple, timestamp-based dependency of files
-and groups of files; also, function based entirely on such
-timestamp dependency analysis."""
+from . import _modified
 
-import os
-from .errors import DistutilsFileError
 
-
-def newer(source, target):
-    """Return true if 'source' exists and is more recently modified than
-    'target', or if 'source' exists and 'target' doesn't.  Return false if
-    both exist and 'target' is the same age or younger than 'source'.
-    Raise DistutilsFileError if 'source' does not exist.
-    """
-    if not os.path.exists(source):
-        raise DistutilsFileError("file '%s' does not exist" % os.path.abspath(source))
-    if not os.path.exists(target):
-        return 1
-
-    from stat import ST_MTIME
-
-    mtime1 = os.stat(source)[ST_MTIME]
-    mtime2 = os.stat(target)[ST_MTIME]
-
-    return mtime1 > mtime2
-
-
-# newer ()
-
-
-def newer_pairwise(sources, targets):
-    """Walk two filename lists in parallel, testing if each source is newer
-    than its corresponding target.  Return a pair of lists (sources,
-    targets) where source is newer than target, according to the semantics
-    of 'newer()'.
-    """
-    if len(sources) != len(targets):
-        raise ValueError("'sources' and 'targets' must be same length")
-
-    # build a pair of lists (sources, targets) where  source is newer
-    n_sources = []
-    n_targets = []
-    for i in range(len(sources)):
-        if newer(sources[i], targets[i]):
-            n_sources.append(sources[i])
-            n_targets.append(targets[i])
-
-    return (n_sources, n_targets)
-
-
-# newer_pairwise ()
-
-
-def newer_group(sources, target, missing='error'):
-    """Return true if 'target' is out-of-date with respect to any file
-    listed in 'sources'.  In other words, if 'target' exists and is newer
-    than every file in 'sources', return false; otherwise return true.
-    'missing' controls what we do when a source file is missing; the
-    default ("error") is to blow up with an OSError from inside 'stat()';
-    if it is "ignore", we silently drop any missing source files; if it is
-    "newer", any missing source files make us assume that 'target' is
-    out-of-date (this is handy in "dry-run" mode: it'll make you pretend to
-    carry out commands that wouldn't work because inputs are missing, but
-    that doesn't matter because you're not actually going to run the
-    commands).
-    """
-    # If the target doesn't even exist, then it's definitely out-of-date.
-    if not os.path.exists(target):
-        return 1
-
-    # Otherwise we have to find out the hard way: if *any* source file
-    # is more recent than 'target', then 'target' is out-of-date and
-    # we can immediately return true.  If we fall through to the end
-    # of the loop, then 'target' is up-to-date and we return false.
-    from stat import ST_MTIME
-
-    target_mtime = os.stat(target)[ST_MTIME]
-    for source in sources:
-        if not os.path.exists(source):
-            if missing == 'error':  # blow up when we stat() the file
-                pass
-            elif missing == 'ignore':  # missing source dropped from
-                continue  # target's dependency list
-            elif missing == 'newer':  # missing source means target is
-                return 1  # out-of-date
-
-        source_mtime = os.stat(source)[ST_MTIME]
-        if source_mtime > target_mtime:
-            return 1
-    else:
-        return 0
-
-
-# newer_group ()
+def __getattr__(name):
+    if name not in ['newer', 'newer_group', 'newer_pairwise']:
+        raise AttributeError(name)
+    warnings.warn(
+        "dep_util is Deprecated. Use functions from setuptools instead.",
+        DeprecationWarning,
+        stacklevel=2,
+    )
+    return getattr(_modified, name)
diff --git a/distutils/dir_util.py b/distutils/dir_util.py
index 23dc3392..724afeff 100644
--- a/distutils/dir_util.py
+++ b/distutils/dir_util.py
@@ -2,17 +2,18 @@
 
 Utility functions for manipulating directories and directory trees."""
 
-import os
 import errno
-from .errors import DistutilsInternalError, DistutilsFileError
+import os
+
 from ._log import log
+from .errors import DistutilsFileError, DistutilsInternalError
 
 # cache for by mkpath() -- in addition to cheapening redundant calls,
 # eliminates redundant "creating /foo/bar/baz" messages in dry-run mode
-_path_created = {}
+_path_created = set()
 
 
-def mkpath(name, mode=0o777, verbose=1, dry_run=0):  # noqa: C901
+def mkpath(name, mode=0o777, verbose=True, dry_run=False):  # noqa: C901
     """Create a directory and any missing ancestor directories.
 
     If the directory already exists (or if 'name' is the empty string, which
@@ -33,9 +34,7 @@ def mkpath(name, mode=0o777, verbose=1, dry_run=0):  # noqa: C901
 
     # Detect a common bug -- name is None
     if not isinstance(name, str):
-        raise DistutilsInternalError(
-            "mkpath: 'name' must be a string (got {!r})".format(name)
-        )
+        raise DistutilsInternalError(f"mkpath: 'name' must be a string (got {name!r})")
 
     # XXX what's the better way to handle verbosity? print as we create
     # each directory in the path (the current behaviour), or only announce
@@ -46,7 +45,7 @@ def mkpath(name, mode=0o777, verbose=1, dry_run=0):  # noqa: C901
     created_dirs = []
     if os.path.isdir(name) or name == '':
         return created_dirs
-    if _path_created.get(os.path.abspath(name)):
+    if os.path.abspath(name) in _path_created:
         return created_dirs
 
     (head, tail) = os.path.split(name)
@@ -64,7 +63,7 @@ def mkpath(name, mode=0o777, verbose=1, dry_run=0):  # noqa: C901
         head = os.path.join(head, d)
         abs_head = os.path.abspath(head)
 
-        if _path_created.get(abs_head):
+        if abs_head in _path_created:
             continue
 
         if verbose >= 1:
@@ -76,15 +75,15 @@ def mkpath(name, mode=0o777, verbose=1, dry_run=0):  # noqa: C901
             except OSError as exc:
                 if not (exc.errno == errno.EEXIST and os.path.isdir(head)):
                     raise DistutilsFileError(
-                        "could not create '{}': {}".format(head, exc.args[-1])
+                        f"could not create '{head}': {exc.args[-1]}"
                     )
             created_dirs.append(head)
 
-        _path_created[abs_head] = 1
+        _path_created.add(abs_head)
     return created_dirs
 
 
-def create_tree(base_dir, files, mode=0o777, verbose=1, dry_run=0):
+def create_tree(base_dir, files, mode=0o777, verbose=True, dry_run=False):
     """Create all the empty directories under 'base_dir' needed to put 'files'
     there.
 
@@ -95,9 +94,7 @@ def create_tree(base_dir, files, mode=0o777, verbose=1, dry_run=0):
     'dry_run' flags are as for 'mkpath()'.
     """
     # First get the list of directories to create
-    need_dir = set()
-    for file in files:
-        need_dir.add(os.path.join(base_dir, os.path.dirname(file)))
+    need_dir = set(os.path.join(base_dir, os.path.dirname(file)) for file in files)
 
     # Now create them
     for dir in sorted(need_dir):
@@ -107,12 +104,12 @@ def create_tree(base_dir, files, mode=0o777, verbose=1, dry_run=0):
 def copy_tree(  # noqa: C901
     src,
     dst,
-    preserve_mode=1,
-    preserve_times=1,
-    preserve_symlinks=0,
-    update=0,
-    verbose=1,
-    dry_run=0,
+    preserve_mode=True,
+    preserve_times=True,
+    preserve_symlinks=False,
+    update=False,
+    verbose=True,
+    dry_run=False,
 ):
     """Copy an entire directory tree 'src' to a new location 'dst'.
 
@@ -136,16 +133,14 @@ def copy_tree(  # noqa: C901
     from distutils.file_util import copy_file
 
     if not dry_run and not os.path.isdir(src):
-        raise DistutilsFileError("cannot copy tree '%s': not a directory" % src)
+        raise DistutilsFileError(f"cannot copy tree '{src}': not a directory")
     try:
         names = os.listdir(src)
     except OSError as e:
         if dry_run:
             names = []
         else:
-            raise DistutilsFileError(
-                "error listing files in '{}': {}".format(src, e.strerror)
-            )
+            raise DistutilsFileError(f"error listing files in '{src}': {e.strerror}")
 
     if not dry_run:
         mkpath(dst, verbose=verbose)
@@ -207,7 +202,7 @@ def _build_cmdtuple(path, cmdtuples):
     cmdtuples.append((os.rmdir, path))
 
 
-def remove_tree(directory, verbose=1, dry_run=0):
+def remove_tree(directory, verbose=True, dry_run=False):
     """Recursively remove an entire directory tree.
 
     Any errors are ignored (apart from being reported to stdout if 'verbose'
@@ -227,7 +222,7 @@ def remove_tree(directory, verbose=1, dry_run=0):
             # remove dir from cache if it's already there
             abspath = os.path.abspath(cmd[1])
             if abspath in _path_created:
-                _path_created.pop(abspath)
+                _path_created.remove(abspath)
         except OSError as exc:
             log.warning("error removing %s: %s", directory, exc)
 
diff --git a/distutils/dist.py b/distutils/dist.py
index 7c0f0e5b..d7d4ca8f 100644
--- a/distutils/dist.py
+++ b/distutils/dist.py
@@ -4,29 +4,32 @@
 being built/installed/distributed.
 """
 
-import sys
-import os
-import re
-import pathlib
 import contextlib
 import logging
+import os
+import pathlib
+import re
+import sys
+from collections.abc import Iterable
 from email import message_from_file
 
+from ._vendor.packaging.utils import canonicalize_name, canonicalize_version
+
 try:
     import warnings
 except ImportError:
     warnings = None
 
+from ._log import log
+from .debug import DEBUG
 from .errors import (
-    DistutilsOptionError,
-    DistutilsModuleError,
     DistutilsArgError,
     DistutilsClassError,
+    DistutilsModuleError,
+    DistutilsOptionError,
 )
 from .fancy_getopt import FancyGetopt, translate_longopt
-from .util import check_environ, strtobool, rfc822_escape
-from ._log import log
-from .debug import DEBUG
+from .util import check_environ, rfc822_escape, strtobool
 
 # Regex to define acceptable Distutils command names.  This is not *quite*
 # the same as a Python NAME -- I don't allow leading underscores.  The fact
@@ -136,9 +139,9 @@ def __init__(self, attrs=None):  # noqa: C901
         """
 
         # Default values for our command-line options
-        self.verbose = 1
-        self.dry_run = 0
-        self.help = 0
+        self.verbose = True
+        self.dry_run = False
+        self.help = False
         for attr in self.display_option_names:
             setattr(self, attr, 0)
 
@@ -261,7 +264,7 @@ def __init__(self, attrs=None):  # noqa: C901
                 elif hasattr(self, key):
                     setattr(self, key, val)
                 else:
-                    msg = "Unknown distribution option: %s" % repr(key)
+                    msg = f"Unknown distribution option: {key!r}"
                     warnings.warn(msg)
 
         # no-user-cfg is handled before other command line args
@@ -310,9 +313,9 @@ def dump_option_dicts(self, header=None, commands=None, indent=""):
         for cmd_name in commands:
             opt_dict = self.command_options.get(cmd_name)
             if opt_dict is None:
-                self.announce(indent + "no option dict for '%s' command" % cmd_name)
+                self.announce(indent + f"no option dict for '{cmd_name}' command")
             else:
-                self.announce(indent + "option dict for '%s' command:" % cmd_name)
+                self.announce(indent + f"option dict for '{cmd_name}' command:")
                 out = pformat(opt_dict)
                 for line in out.split('\n'):
                     self.announce(indent + "  " + line)
@@ -338,7 +341,7 @@ def find_config_files(self):
         files = [str(path) for path in self._gen_paths() if os.path.isfile(path)]
 
         if DEBUG:
-            self.announce("using config files: %s" % ', '.join(files))
+            self.announce("using config files: {}".format(', '.join(files)))
 
         return files
 
@@ -394,8 +397,8 @@ def parse_config_files(self, filenames=None):  # noqa: C901
         parser = ConfigParser()
         for filename in filenames:
             if DEBUG:
-                self.announce("  reading %s" % filename)
-            parser.read(filename)
+                self.announce(f"  reading {filename}")
+            parser.read(filename, encoding='utf-8')
             for section in parser.sections():
                 options = parser.options(section)
                 opt_dict = self.get_option_dict(section)
@@ -414,7 +417,7 @@ def parse_config_files(self, filenames=None):  # noqa: C901
         # to set Distribution options.
 
         if 'global' in self.command_options:
-            for opt, (src, val) in self.command_options['global'].items():
+            for opt, (_src, val) in self.command_options['global'].items():
                 alias = self.negative_opt.get(opt)
                 try:
                     if alias:
@@ -524,7 +527,7 @@ def _parse_command_opts(self, parser, args):  # noqa: C901
         # Pull the current command from the head of the command line
         command = args[0]
         if not command_re.match(command):
-            raise SystemExit("invalid command name '%s'" % command)
+            raise SystemExit(f"invalid command name '{command}'")
         self.commands.append(command)
 
         # Dig up the command class that implements this command, so we
@@ -539,7 +542,7 @@ def _parse_command_opts(self, parser, args):  # noqa: C901
         # to be sure that the basic "command" interface is implemented.
         if not issubclass(cmd_class, Command):
             raise DistutilsClassError(
-                "command class %s must subclass Command" % cmd_class
+                f"command class {cmd_class} must subclass Command"
             )
 
         # Also make sure that the command object provides a list of its
@@ -578,23 +581,22 @@ def _parse_command_opts(self, parser, args):  # noqa: C901
         parser.set_negative_aliases(negative_opt)
         (args, opts) = parser.getopt(args[1:])
         if hasattr(opts, 'help') and opts.help:
-            self._show_help(parser, display_options=0, commands=[cmd_class])
+            self._show_help(parser, display_options=False, commands=[cmd_class])
             return
 
         if hasattr(cmd_class, 'help_options') and isinstance(
             cmd_class.help_options, list
         ):
             help_option_found = 0
-            for help_option, short, desc, func in cmd_class.help_options:
+            for help_option, _short, _desc, func in cmd_class.help_options:
                 if hasattr(opts, parser.get_attr_name(help_option)):
                     help_option_found = 1
                     if callable(func):
                         func()
                     else:
                         raise DistutilsClassError(
-                            "invalid help function %r for help option '%s': "
+                            f"invalid help function {func!r} for help option '{help_option}': "
                             "must be a callable object (function, etc.)"
-                            % (func, help_option)
                         )
 
             if help_option_found:
@@ -621,7 +623,9 @@ def finalize_options(self):
                 value = [elm.strip() for elm in value.split(',')]
                 setattr(self.metadata, attr, value)
 
-    def _show_help(self, parser, global_options=1, display_options=1, commands=[]):
+    def _show_help(
+        self, parser, global_options=True, display_options=True, commands: Iterable = ()
+    ):
         """Show help for the setup script command-line in the form of
         several lists of command-line options.  'parser' should be a
         FancyGetopt instance; do not expect it to be returned in the
@@ -635,8 +639,8 @@ def _show_help(self, parser, global_options=1, display_options=1, commands=[]):
         in 'commands'.
         """
         # late import because of mutual dependence between these modules
-        from distutils.core import gen_usage
         from distutils.cmd import Command
+        from distutils.core import gen_usage
 
         if global_options:
             if display_options:
@@ -645,15 +649,14 @@ def _show_help(self, parser, global_options=1, display_options=1, commands=[]):
                 options = self.global_options
             parser.set_option_table(options)
             parser.print_help(self.common_usage + "\nGlobal options:")
-            print('')
+            print()
 
         if display_options:
             parser.set_option_table(self.display_options)
             parser.print_help(
-                "Information display options (just display "
-                + "information, ignore any commands)"
+                "Information display options (just display information, ignore any commands)"
             )
-            print('')
+            print()
 
         for command in self.commands:
             if isinstance(command, type) and issubclass(command, Command):
@@ -666,8 +669,8 @@ def _show_help(self, parser, global_options=1, display_options=1, commands=[]):
                 )
             else:
                 parser.set_option_table(klass.user_options)
-            parser.print_help("Options for '%s' command:" % klass.__name__)
-            print('')
+            parser.print_help(f"Options for '{klass.__name__}' command:")
+            print()
 
         print(gen_usage(self.script_name))
 
@@ -684,7 +687,7 @@ def handle_display_options(self, option_order):
         # we ignore "foo bar").
         if self.help_commands:
             self.print_commands()
-            print('')
+            print()
             print(gen_usage(self.script_name))
             return 1
 
@@ -692,12 +695,12 @@ def handle_display_options(self, option_order):
         # display that metadata in the order in which the user supplied the
         # metadata options.
         any_display_options = 0
-        is_display_option = {}
+        is_display_option = set()
         for option in self.display_options:
-            is_display_option[option[0]] = 1
+            is_display_option.add(option[0])
 
         for opt, val in option_order:
-            if val and is_display_option.get(opt):
+            if val and opt in is_display_option:
                 opt = translate_longopt(opt)
                 value = getattr(self.metadata, "get_" + opt)()
                 if opt in ('keywords', 'platforms'):
@@ -738,13 +741,13 @@ def print_commands(self):
         import distutils.command
 
         std_commands = distutils.command.__all__
-        is_std = {}
+        is_std = set()
         for cmd in std_commands:
-            is_std[cmd] = 1
+            is_std.add(cmd)
 
         extra_commands = []
         for cmd in self.cmdclass.keys():
-            if not is_std.get(cmd):
+            if cmd not in is_std:
                 extra_commands.append(cmd)
 
         max_length = 0
@@ -769,13 +772,13 @@ def get_command_list(self):
         import distutils.command
 
         std_commands = distutils.command.__all__
-        is_std = {}
+        is_std = set()
         for cmd in std_commands:
-            is_std[cmd] = 1
+            is_std.add(cmd)
 
         extra_commands = []
         for cmd in self.cmdclass.keys():
-            if not is_std.get(cmd):
+            if cmd not in is_std:
                 extra_commands.append(cmd)
 
         rv = []
@@ -821,7 +824,7 @@ def get_command_class(self, command):
             return klass
 
         for pkgname in self.get_command_packages():
-            module_name = "{}.{}".format(pkgname, command)
+            module_name = f"{pkgname}.{command}"
             klass_name = command
 
             try:
@@ -834,16 +837,15 @@ def get_command_class(self, command):
                 klass = getattr(module, klass_name)
             except AttributeError:
                 raise DistutilsModuleError(
-                    "invalid command '%s' (no class '%s' in module '%s')"
-                    % (command, klass_name, module_name)
+                    f"invalid command '{command}' (no class '{klass_name}' in module '{module_name}')"
                 )
 
             self.cmdclass[command] = klass
             return klass
 
-        raise DistutilsModuleError("invalid command '%s'" % command)
+        raise DistutilsModuleError(f"invalid command '{command}'")
 
-    def get_command_obj(self, command, create=1):
+    def get_command_obj(self, command, create=True):
         """Return the command object for 'command'.  Normally this object
         is cached on a previous call to 'get_command_obj()'; if no command
         object for 'command' is in the cache, then we either create and
@@ -854,12 +856,12 @@ def get_command_obj(self, command, create=1):
             if DEBUG:
                 self.announce(
                     "Distribution.get_command_obj(): "
-                    "creating '%s' command object" % command
+                    f"creating '{command}' command object"
                 )
 
             klass = self.get_command_class(command)
             cmd_obj = self.command_obj[command] = klass(self)
-            self.have_run[command] = 0
+            self.have_run[command] = False
 
             # Set any options that were supplied in config files
             # or on the command line.  (NB. support for error
@@ -886,10 +888,10 @@ def _set_command_options(self, command_obj, option_dict=None):  # noqa: C901
             option_dict = self.get_option_dict(command_name)
 
         if DEBUG:
-            self.announce("  setting options for '%s' command:" % command_name)
+            self.announce(f"  setting options for '{command_name}' command:")
         for option, (source, value) in option_dict.items():
             if DEBUG:
-                self.announce("    {} = {} (from {})".format(option, value, source))
+                self.announce(f"    {option} = {value} (from {source})")
             try:
                 bool_opts = [translate_longopt(o) for o in command_obj.boolean_options]
             except AttributeError:
@@ -909,13 +911,12 @@ def _set_command_options(self, command_obj, option_dict=None):  # noqa: C901
                     setattr(command_obj, option, value)
                 else:
                     raise DistutilsOptionError(
-                        "error in %s: command '%s' has no such option '%s'"
-                        % (source, command_name, option)
+                        f"error in {source}: command '{command_name}' has no such option '{option}'"
                     )
             except ValueError as msg:
                 raise DistutilsOptionError(msg)
 
-    def reinitialize_command(self, command, reinit_subcommands=0):
+    def reinitialize_command(self, command, reinit_subcommands=False):
         """Reinitializes a command to the state it was in when first
         returned by 'get_command_obj()': ie., initialized but not yet
         finalized.  This provides the opportunity to sneak option
@@ -945,8 +946,8 @@ def reinitialize_command(self, command, reinit_subcommands=0):
         if not command.finalized:
             return command
         command.initialize_options()
-        command.finalized = 0
-        self.have_run[command_name] = 0
+        command.finalized = False
+        self.have_run[command_name] = False
         self._set_command_options(command)
 
         if reinit_subcommands:
@@ -986,7 +987,7 @@ def run_command(self, command):
         cmd_obj = self.get_command_obj(command)
         cmd_obj.ensure_finalized()
         cmd_obj.run()
-        self.have_run[command] = 1
+        self.have_run[command] = True
 
     # -- Distribution query methods ------------------------------------
 
@@ -1149,9 +1150,9 @@ def write_pkg_file(self, file):
             version = '1.1'
 
         # required fields
-        file.write('Metadata-Version: %s\n' % version)
-        file.write('Name: %s\n' % self.get_name())
-        file.write('Version: %s\n' % self.get_version())
+        file.write(f'Metadata-Version: {version}\n')
+        file.write(f'Name: {self.get_name()}\n')
+        file.write(f'Version: {self.get_version()}\n')
 
         def maybe_write(header, val):
             if val:
@@ -1178,7 +1179,7 @@ def maybe_write(header, val):
     def _write_list(self, file, name, values):
         values = values or []
         for value in values:
-            file.write('{}: {}\n'.format(name, value))
+            file.write(f'{name}: {value}\n')
 
     # -- Metadata query methods ----------------------------------------
 
@@ -1189,7 +1190,26 @@ def get_version(self):
         return self.version or "0.0.0"
 
     def get_fullname(self):
-        return "{}-{}".format(self.get_name(), self.get_version())
+        return self._fullname(self.get_name(), self.get_version())
+
+    @staticmethod
+    def _fullname(name: str, version: str) -> str:
+        """
+        >>> DistributionMetadata._fullname('setup.tools', '1.0-2')
+        'setup_tools-1.0.post2'
+        >>> DistributionMetadata._fullname('setup-tools', '1.2post2')
+        'setup_tools-1.2.post2'
+        >>> DistributionMetadata._fullname('setup-tools', '1.0-r2')
+        'setup_tools-1.0.post2'
+        >>> DistributionMetadata._fullname('setup.tools', '1.0.post')
+        'setup_tools-1.0.post0'
+        >>> DistributionMetadata._fullname('setup.tools', '1.0+ubuntu-1')
+        'setup_tools-1.0+ubuntu.1'
+        """
+        return "{}-{}".format(
+            canonicalize_name(name).replace('-', '_'),
+            canonicalize_version(version, strip_trailing_zero=False),
+        )
 
     def get_author(self):
         return self.author
diff --git a/distutils/extension.py b/distutils/extension.py
index 6b8575de..04e871bc 100644
--- a/distutils/extension.py
+++ b/distutils/extension.py
@@ -102,7 +102,7 @@ def __init__(
         depends=None,
         language=None,
         optional=None,
-        **kw  # To catch unknown keywords
+        **kw,  # To catch unknown keywords
     ):
         if not isinstance(name, str):
             raise AssertionError("'name' must be a string")
@@ -130,22 +130,16 @@ def __init__(
         if len(kw) > 0:
             options = [repr(option) for option in kw]
             options = ', '.join(sorted(options))
-            msg = "Unknown Extension options: %s" % options
+            msg = f"Unknown Extension options: {options}"
             warnings.warn(msg)
 
     def __repr__(self):
-        return '<{}.{}({!r}) at {:#x}>'.format(
-            self.__class__.__module__,
-            self.__class__.__qualname__,
-            self.name,
-            id(self),
-        )
+        return f'<{self.__class__.__module__}.{self.__class__.__qualname__}({self.name!r}) at {id(self):#x}>'
 
 
 def read_setup_file(filename):  # noqa: C901
     """Reads a Setup file and returns Extension instances."""
-    from distutils.sysconfig import parse_makefile, expand_makefile_vars, _variable_rx
-
+    from distutils.sysconfig import _variable_rx, expand_makefile_vars, parse_makefile
     from distutils.text_file import TextFile
     from distutils.util import split_quoted
 
@@ -156,11 +150,11 @@ def read_setup_file(filename):  # noqa: C901
     #    ... [ ...] [ ...] [ ...]
     file = TextFile(
         filename,
-        strip_comments=1,
-        skip_blanks=1,
-        join_lines=1,
-        lstrip_ws=1,
-        rstrip_ws=1,
+        strip_comments=True,
+        skip_blanks=True,
+        join_lines=True,
+        lstrip_ws=True,
+        rstrip_ws=True,
     )
     try:
         extensions = []
@@ -173,7 +167,7 @@ def read_setup_file(filename):  # noqa: C901
                 continue
 
             if line[0] == line[-1] == "*":
-                file.warn("'%s' lines not handled yet" % line)
+                file.warn(f"'{line}' lines not handled yet")
                 continue
 
             line = expand_makefile_vars(line, vars)
@@ -239,7 +233,7 @@ def read_setup_file(filename):  # noqa: C901
                     # and append it to sources.  Hmmmm.
                     ext.extra_objects.append(word)
                 else:
-                    file.warn("unrecognized argument '%s'" % word)
+                    file.warn(f"unrecognized argument '{word}'")
 
             extensions.append(ext)
     finally:
diff --git a/distutils/fancy_getopt.py b/distutils/fancy_getopt.py
index 3b887dc5..907cc2b7 100644
--- a/distutils/fancy_getopt.py
+++ b/distutils/fancy_getopt.py
@@ -8,21 +8,23 @@
   * options set attributes of a passed-in object
 """
 
-import sys
-import string
-import re
 import getopt
-from .errors import DistutilsGetoptError, DistutilsArgError
+import re
+import string
+import sys
+from typing import Any, Sequence
+
+from .errors import DistutilsArgError, DistutilsGetoptError
 
 # Much like command_re in distutils.core, this is close to but not quite
 # the same as a Python NAME -- except, in the spirit of most GNU
 # utilities, we use '-' in place of '_'.  (The spirit of LISP lives on!)
 # The similarities to NAME are again not a coincidence...
 longopt_pat = r'[a-zA-Z](?:[a-zA-Z0-9-]*)'
-longopt_re = re.compile(r'^%s$' % longopt_pat)
+longopt_re = re.compile(rf'^{longopt_pat}$')
 
 # For recognizing "negative alias" options, eg. "quiet=!verbose"
-neg_alias_re = re.compile("^({})=!({})$".format(longopt_pat, longopt_pat))
+neg_alias_re = re.compile(f"^({longopt_pat})=!({longopt_pat})$")
 
 # This is used to translate long options to legitimate Python identifiers
 # (for use as attributes of some object).
@@ -93,7 +95,7 @@ def set_option_table(self, option_table):
     def add_option(self, long_option, short_option=None, help_string=None):
         if long_option in self.option_index:
             raise DistutilsGetoptError(
-                "option conflict: already an option '%s'" % long_option
+                f"option conflict: already an option '{long_option}'"
             )
         else:
             option = (long_option, short_option, help_string)
@@ -116,13 +118,11 @@ def _check_alias_dict(self, aliases, what):
         for alias, opt in aliases.items():
             if alias not in self.option_index:
                 raise DistutilsGetoptError(
-                    ("invalid %s '%s': " "option '%s' not defined")
-                    % (what, alias, alias)
+                    f"invalid {what} '{alias}': option '{alias}' not defined"
                 )
             if opt not in self.option_index:
                 raise DistutilsGetoptError(
-                    ("invalid %s '%s': " "aliased option '%s' not defined")
-                    % (what, alias, opt)
+                    f"invalid {what} '{alias}': aliased option '{opt}' not defined"
                 )
 
     def set_aliases(self, alias):
@@ -157,19 +157,18 @@ def _grok_option_table(self):  # noqa: C901
             else:
                 # the option table is part of the code, so simply
                 # assert that it is correct
-                raise ValueError("invalid option tuple: {!r}".format(option))
+                raise ValueError(f"invalid option tuple: {option!r}")
 
             # Type- and value-check the option names
             if not isinstance(long, str) or len(long) < 2:
                 raise DistutilsGetoptError(
-                    ("invalid long option '%s': " "must be a string of length >= 2")
-                    % long
+                    f"invalid long option '{long}': must be a string of length >= 2"
                 )
 
             if not ((short is None) or (isinstance(short, str) and len(short) == 1)):
                 raise DistutilsGetoptError(
-                    "invalid short option '%s': "
-                    "must a single character or None" % short
+                    f"invalid short option '{short}': "
+                    "must a single character or None"
                 )
 
             self.repeat[long] = repeat
@@ -179,7 +178,7 @@ def _grok_option_table(self):  # noqa: C901
                 if short:
                     short = short + ':'
                 long = long[0:-1]
-                self.takes_arg[long] = 1
+                self.takes_arg[long] = True
             else:
                 # Is option is a "negative alias" for some other option (eg.
                 # "quiet" == "!verbose")?
@@ -187,12 +186,12 @@ def _grok_option_table(self):  # noqa: C901
                 if alias_to is not None:
                     if self.takes_arg[alias_to]:
                         raise DistutilsGetoptError(
-                            "invalid negative alias '%s': "
-                            "aliased option '%s' takes a value" % (long, alias_to)
+                            f"invalid negative alias '{long}': "
+                            f"aliased option '{alias_to}' takes a value"
                         )
 
                     self.long_opts[-1] = long  # XXX redundant?!
-                self.takes_arg[long] = 0
+                self.takes_arg[long] = False
 
             # If this is an alias option, make sure its "takes arg" flag is
             # the same as the option it's aliased to.
@@ -200,9 +199,9 @@ def _grok_option_table(self):  # noqa: C901
             if alias_to is not None:
                 if self.takes_arg[long] != self.takes_arg[alias_to]:
                     raise DistutilsGetoptError(
-                        "invalid alias '%s': inconsistent with "
-                        "aliased option '%s' (one of them takes a value, "
-                        "the other doesn't" % (long, alias_to)
+                        f"invalid alias '{long}': inconsistent with "
+                        f"aliased option '{alias_to}' (one of them takes a value, "
+                        "the other doesn't"
                     )
 
             # Now enforce some bondage on the long option name, so we can
@@ -211,8 +210,8 @@ def _grok_option_table(self):  # noqa: C901
             # '='.
             if not longopt_re.match(long):
                 raise DistutilsGetoptError(
-                    "invalid long option name '%s' "
-                    "(must be letters, numbers, hyphens only" % long
+                    f"invalid long option name '{long}' "
+                    "(must be letters, numbers, hyphens only"
                 )
 
             self.attr_name[long] = self.get_attr_name(long)
@@ -269,7 +268,7 @@ def getopt(self, args=None, object=None):  # noqa: C901
 
             attr = self.attr_name[opt]
             # The only repeating option at the moment is 'verbose'.
-            # It has a negative option -q quiet, which should set verbose = 0.
+            # It has a negative option -q quiet, which should set verbose = False.
             if val and self.repeat.get(attr) is not None:
                 val = getattr(object, attr, 0) + 1
             setattr(object, attr, val)
@@ -359,7 +358,7 @@ def generate_help(self, header=None):  # noqa: C901
             # Case 2: we have a short option, so we have to include it
             # just after the long option
             else:
-                opt_names = "{} (-{})".format(long, short)
+                opt_names = f"{long} (-{short})"
                 if text:
                     lines.append("  --%-*s  %s" % (max_opt, opt_names, text[0]))
                 else:
@@ -450,7 +449,7 @@ class OptionDummy:
     """Dummy class just used as a place to hold command-line option
     values as instance attributes."""
 
-    def __init__(self, options=[]):
+    def __init__(self, options: Sequence[Any] = []):
         """Create a new OptionDummy instance.  The attributes listed in
         'options' will be initialized to None."""
         for opt in options:
diff --git a/distutils/file_util.py b/distutils/file_util.py
index 7c699066..b19a5dcf 100644
--- a/distutils/file_util.py
+++ b/distutils/file_util.py
@@ -4,8 +4,9 @@
 """
 
 import os
-from .errors import DistutilsFileError
+
 from ._log import log
+from .errors import DistutilsFileError
 
 # for generating verbose output in 'copy_file()'
 _copy_action = {None: 'copying', 'hard': 'hard linking', 'sym': 'symbolically linking'}
@@ -26,30 +27,24 @@ def _copy_file_contents(src, dst, buffer_size=16 * 1024):  # noqa: C901
         try:
             fsrc = open(src, 'rb')
         except OSError as e:
-            raise DistutilsFileError("could not open '{}': {}".format(src, e.strerror))
+            raise DistutilsFileError(f"could not open '{src}': {e.strerror}")
 
         if os.path.exists(dst):
             try:
                 os.unlink(dst)
             except OSError as e:
-                raise DistutilsFileError(
-                    "could not delete '{}': {}".format(dst, e.strerror)
-                )
+                raise DistutilsFileError(f"could not delete '{dst}': {e.strerror}")
 
         try:
             fdst = open(dst, 'wb')
         except OSError as e:
-            raise DistutilsFileError(
-                "could not create '{}': {}".format(dst, e.strerror)
-            )
+            raise DistutilsFileError(f"could not create '{dst}': {e.strerror}")
 
         while True:
             try:
                 buf = fsrc.read(buffer_size)
             except OSError as e:
-                raise DistutilsFileError(
-                    "could not read from '{}': {}".format(src, e.strerror)
-                )
+                raise DistutilsFileError(f"could not read from '{src}': {e.strerror}")
 
             if not buf:
                 break
@@ -57,9 +52,7 @@ def _copy_file_contents(src, dst, buffer_size=16 * 1024):  # noqa: C901
             try:
                 fdst.write(buf)
             except OSError as e:
-                raise DistutilsFileError(
-                    "could not write to '{}': {}".format(dst, e.strerror)
-                )
+                raise DistutilsFileError(f"could not write to '{dst}': {e.strerror}")
     finally:
         if fdst:
             fdst.close()
@@ -70,12 +63,12 @@ def _copy_file_contents(src, dst, buffer_size=16 * 1024):  # noqa: C901
 def copy_file(  # noqa: C901
     src,
     dst,
-    preserve_mode=1,
-    preserve_times=1,
-    update=0,
+    preserve_mode=True,
+    preserve_times=True,
+    update=False,
     link=None,
-    verbose=1,
-    dry_run=0,
+    verbose=True,
+    dry_run=False,
 ):
     """Copy a file 'src' to 'dst'.  If 'dst' is a directory, then 'src' is
     copied there with the same name; otherwise, it must be a filename.  (If
@@ -108,12 +101,12 @@ def copy_file(  # noqa: C901
     # changing it (ie. it's not already a hard/soft link to src OR
     # (not update) and (src newer than dst).
 
-    from distutils.dep_util import newer
-    from stat import ST_ATIME, ST_MTIME, ST_MODE, S_IMODE
+    from distutils._modified import newer
+    from stat import S_IMODE, ST_ATIME, ST_MODE, ST_MTIME
 
     if not os.path.isfile(src):
         raise DistutilsFileError(
-            "can't copy '%s': doesn't exist or not a regular file" % src
+            f"can't copy '{src}': doesn't exist or not a regular file"
         )
 
     if os.path.isdir(dst):
@@ -130,7 +123,7 @@ def copy_file(  # noqa: C901
     try:
         action = _copy_action[link]
     except KeyError:
-        raise ValueError("invalid value '%s' for 'link' argument" % link)
+        raise ValueError(f"invalid value '{link}' for 'link' argument")
 
     if verbose >= 1:
         if os.path.basename(dst) == os.path.basename(src):
@@ -175,7 +168,7 @@ def copy_file(  # noqa: C901
 
 
 # XXX I suspect this is Unix-specific -- need porting help!
-def move_file(src, dst, verbose=1, dry_run=0):  # noqa: C901
+def move_file(src, dst, verbose=True, dry_run=False):  # noqa: C901
     """Move a file 'src' to 'dst'.  If 'dst' is a directory, the file will
     be moved into it with the same name; otherwise, 'src' is just renamed
     to 'dst'.  Return the new full name of the file.
@@ -183,8 +176,8 @@ def move_file(src, dst, verbose=1, dry_run=0):  # noqa: C901
     Handles cross-device moves on Unix using 'copy_file()'.  What about
     other systems???
     """
-    from os.path import exists, isfile, isdir, basename, dirname
     import errno
+    from os.path import basename, dirname, exists, isdir, isfile
 
     if verbose >= 1:
         log.info("moving %s -> %s", src, dst)
@@ -193,18 +186,18 @@ def move_file(src, dst, verbose=1, dry_run=0):  # noqa: C901
         return dst
 
     if not isfile(src):
-        raise DistutilsFileError("can't move '%s': not a regular file" % src)
+        raise DistutilsFileError(f"can't move '{src}': not a regular file")
 
     if isdir(dst):
         dst = os.path.join(dst, basename(src))
     elif exists(dst):
         raise DistutilsFileError(
-            "can't move '{}': destination '{}' already exists".format(src, dst)
+            f"can't move '{src}': destination '{dst}' already exists"
         )
 
     if not isdir(dirname(dst)):
         raise DistutilsFileError(
-            "can't move '{}': destination '{}' not a valid path".format(src, dst)
+            f"can't move '{src}': destination '{dst}' not a valid path"
         )
 
     copy_it = False
@@ -215,9 +208,7 @@ def move_file(src, dst, verbose=1, dry_run=0):  # noqa: C901
         if num == errno.EXDEV:
             copy_it = True
         else:
-            raise DistutilsFileError(
-                "couldn't move '{}' to '{}': {}".format(src, dst, msg)
-            )
+            raise DistutilsFileError(f"couldn't move '{src}' to '{dst}': {msg}")
 
     if copy_it:
         copy_file(src, dst, verbose=verbose)
@@ -230,8 +221,8 @@ def move_file(src, dst, verbose=1, dry_run=0):  # noqa: C901
             except OSError:
                 pass
             raise DistutilsFileError(
-                "couldn't move '%s' to '%s' by copy/delete: "
-                "delete '%s' failed: %s" % (src, dst, src, msg)
+                f"couldn't move '{src}' to '{dst}' by copy/delete: "
+                f"delete '{src}' failed: {msg}"
             )
     return dst
 
@@ -240,9 +231,5 @@ def write_file(filename, contents):
     """Create a file with the specified name and write 'contents' (a
     sequence of strings without line terminators) to it.
     """
-    f = open(filename, "w")
-    try:
-        for line in contents:
-            f.write(line + "\n")
-    finally:
-        f.close()
+    with open(filename, 'w', encoding='utf-8') as f:
+        f.writelines(line + '\n' for line in contents)
diff --git a/distutils/filelist.py b/distutils/filelist.py
index 6dadf923..44ae9e67 100644
--- a/distutils/filelist.py
+++ b/distutils/filelist.py
@@ -4,14 +4,14 @@
 and building lists of files.
 """
 
-import os
-import re
 import fnmatch
 import functools
+import os
+import re
 
-from .util import convert_path
-from .errors import DistutilsTemplateError, DistutilsInternalError
 from ._log import log
+from .errors import DistutilsInternalError, DistutilsTemplateError
+from .util import convert_path
 
 
 class FileList:
@@ -84,24 +84,24 @@ def _parse_template_line(self, line):
         if action in ('include', 'exclude', 'global-include', 'global-exclude'):
             if len(words) < 2:
                 raise DistutilsTemplateError(
-                    "'%s' expects   ..." % action
+                    f"'{action}' expects   ..."
                 )
             patterns = [convert_path(w) for w in words[1:]]
         elif action in ('recursive-include', 'recursive-exclude'):
             if len(words) < 3:
                 raise DistutilsTemplateError(
-                    "'%s' expects    ..." % action
+                    f"'{action}' expects    ..."
                 )
             dir = convert_path(words[1])
             patterns = [convert_path(w) for w in words[2:]]
         elif action in ('graft', 'prune'):
             if len(words) != 2:
                 raise DistutilsTemplateError(
-                    "'%s' expects a single " % action
+                    f"'{action}' expects a single "
                 )
             dir_pattern = convert_path(words[1])
         else:
-            raise DistutilsTemplateError("unknown action '%s'" % action)
+            raise DistutilsTemplateError(f"unknown action '{action}'")
 
         return (action, patterns, dir, dir_pattern)
 
@@ -119,13 +119,13 @@ def process_template_line(self, line):  # noqa: C901
         if action == 'include':
             self.debug_print("include " + ' '.join(patterns))
             for pattern in patterns:
-                if not self.include_pattern(pattern, anchor=1):
+                if not self.include_pattern(pattern, anchor=True):
                     log.warning("warning: no files found matching '%s'", pattern)
 
         elif action == 'exclude':
             self.debug_print("exclude " + ' '.join(patterns))
             for pattern in patterns:
-                if not self.exclude_pattern(pattern, anchor=1):
+                if not self.exclude_pattern(pattern, anchor=True):
                     log.warning(
                         (
                             "warning: no previously-included files "
@@ -137,7 +137,7 @@ def process_template_line(self, line):  # noqa: C901
         elif action == 'global-include':
             self.debug_print("global-include " + ' '.join(patterns))
             for pattern in patterns:
-                if not self.include_pattern(pattern, anchor=0):
+                if not self.include_pattern(pattern, anchor=False):
                     log.warning(
                         (
                             "warning: no files found matching '%s' "
@@ -149,7 +149,7 @@ def process_template_line(self, line):  # noqa: C901
         elif action == 'global-exclude':
             self.debug_print("global-exclude " + ' '.join(patterns))
             for pattern in patterns:
-                if not self.exclude_pattern(pattern, anchor=0):
+                if not self.exclude_pattern(pattern, anchor=False):
                     log.warning(
                         (
                             "warning: no previously-included files matching "
@@ -162,9 +162,7 @@ def process_template_line(self, line):  # noqa: C901
             self.debug_print("recursive-include {} {}".format(dir, ' '.join(patterns)))
             for pattern in patterns:
                 if not self.include_pattern(pattern, prefix=dir):
-                    msg = (
-                        "warning: no files found matching '%s' " "under directory '%s'"
-                    )
+                    msg = "warning: no files found matching '%s' under directory '%s'"
                     log.warning(msg, pattern, dir)
 
         elif action == 'recursive-exclude':
@@ -189,17 +187,17 @@ def process_template_line(self, line):  # noqa: C901
             self.debug_print("prune " + dir_pattern)
             if not self.exclude_pattern(None, prefix=dir_pattern):
                 log.warning(
-                    ("no previously-included directories found " "matching '%s'"),
+                    ("no previously-included directories found matching '%s'"),
                     dir_pattern,
                 )
         else:
             raise DistutilsInternalError(
-                "this cannot happen: invalid action '%s'" % action
+                f"this cannot happen: invalid action '{action}'"
             )
 
     # Filtering/selection methods
 
-    def include_pattern(self, pattern, anchor=1, prefix=None, is_regex=0):
+    def include_pattern(self, pattern, anchor=True, prefix=None, is_regex=False):
         """Select strings (presumably filenames) from 'self.files' that
         match 'pattern', a Unix-style wildcard (glob) pattern.  Patterns
         are not quite the same as implemented by the 'fnmatch' module: '*'
@@ -227,7 +225,7 @@ def include_pattern(self, pattern, anchor=1, prefix=None, is_regex=0):
         # XXX docstring lying about what the special chars are?
         files_found = False
         pattern_re = translate_pattern(pattern, anchor, prefix, is_regex)
-        self.debug_print("include_pattern: applying regex r'%s'" % pattern_re.pattern)
+        self.debug_print(f"include_pattern: applying regex r'{pattern_re.pattern}'")
 
         # delayed loading of allfiles list
         if self.allfiles is None:
@@ -240,7 +238,7 @@ def include_pattern(self, pattern, anchor=1, prefix=None, is_regex=0):
                 files_found = True
         return files_found
 
-    def exclude_pattern(self, pattern, anchor=1, prefix=None, is_regex=0):
+    def exclude_pattern(self, pattern, anchor=True, prefix=None, is_regex=False):
         """Remove strings (presumably filenames) from 'files' that match
         'pattern'.  Other parameters are the same as for
         'include_pattern()', above.
@@ -249,7 +247,7 @@ def exclude_pattern(self, pattern, anchor=1, prefix=None, is_regex=0):
         """
         files_found = False
         pattern_re = translate_pattern(pattern, anchor, prefix, is_regex)
-        self.debug_print("exclude_pattern: applying regex r'%s'" % pattern_re.pattern)
+        self.debug_print(f"exclude_pattern: applying regex r'{pattern_re.pattern}'")
         for i in range(len(self.files) - 1, -1, -1):
             if pattern_re.search(self.files[i]):
                 self.debug_print(" removing " + self.files[i])
@@ -329,12 +327,12 @@ def glob_to_re(pattern):
         # we're using a regex to manipulate a regex, so we need
         # to escape the backslash twice
         sep = r'\\\\'
-    escaped = r'\1[^%s]' % sep
+    escaped = rf'\1[^{sep}]'
     pattern_re = re.sub(r'((?= 7:
-            key = r"{}\{:0.1f}\VC\VC_OBJECTS_PLATFORM_INFO\Win32\Directories".format(
-                self.__root,
-                self.__version,
-            )
+            key = rf"{self.__root}\{self.__version:0.1f}\VC\VC_OBJECTS_PLATFORM_INFO\Win32\Directories"
         else:
             key = (
-                r"%s\6.0\Build System\Components\Platforms"
-                r"\Win32 (%s)\Directories" % (self.__root, platform)
+                rf"{self.__root}\6.0\Build System\Components\Platforms"
+                rf"\Win32 ({platform})\Directories"
             )
 
         for base in HKEYS:
@@ -658,7 +654,7 @@ def get_msvc_paths(self, path, platform='x86'):
         # the GUI is run.
         if self.__version == 6:
             for base in HKEYS:
-                if read_values(base, r"%s\6.0" % self.__root) is not None:
+                if read_values(base, rf"{self.__root}\6.0") is not None:
                     self.warn(
                         "It seems you have Visual Studio 6 installed, "
                         "but the expected registry settings are not present.\n"
@@ -686,7 +682,8 @@ def set_path_env_var(self, name):
 if get_build_version() >= 8.0:
     log.debug("Importing new compiler from distutils.msvc9compiler")
     OldMSVCCompiler = MSVCCompiler
-    from distutils.msvc9compiler import MSVCCompiler
-
     # get_build_architecture not really relevant now we support cross-compile
-    from distutils.msvc9compiler import MacroExpander  # noqa: F811
+    from distutils.msvc9compiler import (
+        MacroExpander,
+        MSVCCompiler,
+    )
diff --git a/distutils/py38compat.py b/distutils/py38compat.py
deleted file mode 100644
index 59224e71..00000000
--- a/distutils/py38compat.py
+++ /dev/null
@@ -1,8 +0,0 @@
-def aix_platform(osname, version, release):
-    try:
-        import _aix_support
-
-        return _aix_support.aix_platform()
-    except ImportError:
-        pass
-    return "{}-{}.{}".format(osname, version, release)
diff --git a/distutils/py39compat.py b/distutils/py39compat.py
deleted file mode 100644
index c43e5f10..00000000
--- a/distutils/py39compat.py
+++ /dev/null
@@ -1,22 +0,0 @@
-import sys
-import platform
-
-
-def add_ext_suffix_39(vars):
-    """
-    Ensure vars contains 'EXT_SUFFIX'. pypa/distutils#130
-    """
-    import _imp
-
-    ext_suffix = _imp.extension_suffixes()[0]
-    vars.update(
-        EXT_SUFFIX=ext_suffix,
-        # sysconfig sets SO to match EXT_SUFFIX, so maintain
-        # that expectation.
-        # https://github.com/python/cpython/blob/785cc6770588de087d09e89a69110af2542be208/Lib/sysconfig.py#L671-L673
-        SO=ext_suffix,
-    )
-
-
-needs_ext_suffix = sys.version_info < (3, 10) and platform.system() == 'Windows'
-add_ext_suffix = add_ext_suffix_39 if needs_ext_suffix else lambda vars: None
diff --git a/distutils/spawn.py b/distutils/spawn.py
index afefe525..429d1ccb 100644
--- a/distutils/spawn.py
+++ b/distutils/spawn.py
@@ -2,20 +2,47 @@
 
 Provides the 'spawn()' function, a front-end to various platform-
 specific functions for launching another program in a sub-process.
-Also provides the 'find_executable()' to search the path for a given
-executable name.
 """
 
-import sys
+from __future__ import annotations
+
 import os
+import platform
+import shutil
 import subprocess
+import sys
+import warnings
+
+from typing import Mapping
 
-from .errors import DistutilsExecError
-from .debug import DEBUG
 from ._log import log
+from .debug import DEBUG
+from .errors import DistutilsExecError
+
+
+def _debug(cmd):
+    """
+    Render a subprocess command differently depending on DEBUG.
+    """
+    return cmd if DEBUG else cmd[0]
 
 
-def spawn(cmd, search_path=1, verbose=0, dry_run=0, env=None):  # noqa: C901
+def _inject_macos_ver(env: Mapping[str:str] | None) -> Mapping[str:str] | None:
+    if platform.system() != 'Darwin':
+        return env
+
+    from distutils.util import MACOSX_VERSION_VAR, get_macosx_target_ver
+
+    target_ver = get_macosx_target_ver()
+    update = {MACOSX_VERSION_VAR: target_ver} if target_ver else {}
+    return {**_resolve(env), **update}
+
+
+def _resolve(env: Mapping[str:str] | None) -> Mapping[str:str]:
+    return os.environ if env is None else env
+
+
+def spawn(cmd, search_path=True, verbose=False, dry_run=False, env=None):
     """Run another program, specified as a command list 'cmd', in a new process.
 
     'cmd' is just the argument list for the new process, ie.
@@ -31,45 +58,25 @@ def spawn(cmd, search_path=1, verbose=0, dry_run=0, env=None):  # noqa: C901
     Raise DistutilsExecError if running the program fails in any way; just
     return on success.
     """
-    # cmd is documented as a list, but just in case some code passes a tuple
-    # in, protect our %-formatting code against horrible death
-    cmd = list(cmd)
-
     log.info(subprocess.list2cmdline(cmd))
     if dry_run:
         return
 
     if search_path:
-        executable = find_executable(cmd[0])
+        executable = shutil.which(cmd[0])
         if executable is not None:
             cmd[0] = executable
 
-    env = env if env is not None else dict(os.environ)
-
-    if sys.platform == 'darwin':
-        from distutils.util import MACOSX_VERSION_VAR, get_macosx_target_ver
-
-        macosx_target_ver = get_macosx_target_ver()
-        if macosx_target_ver:
-            env[MACOSX_VERSION_VAR] = macosx_target_ver
-
     try:
-        proc = subprocess.Popen(cmd, env=env)
-        proc.wait()
-        exitcode = proc.returncode
+        subprocess.check_call(cmd, env=_inject_macos_ver(env))
     except OSError as exc:
-        if not DEBUG:
-            cmd = cmd[0]
         raise DistutilsExecError(
-            "command {!r} failed: {}".format(cmd, exc.args[-1])
+            f"command {_debug(cmd)!r} failed: {exc.args[-1]}"
         ) from exc
-
-    if exitcode:
-        if not DEBUG:
-            cmd = cmd[0]
+    except subprocess.CalledProcessError as err:
         raise DistutilsExecError(
-            "command {!r} failed with exit code {}".format(cmd, exitcode)
-        )
+            f"command {_debug(cmd)!r} failed with exit code {err.returncode}"
+        ) from err
 
 
 def find_executable(executable, path=None):
@@ -78,6 +85,9 @@ def find_executable(executable, path=None):
     A string listing directories separated by 'os.pathsep'; defaults to
     os.environ['PATH'].  Returns the complete filename or None if not found.
     """
+    warnings.warn(
+        'Use shutil.which instead of find_executable', DeprecationWarning, stacklevel=2
+    )
     _, ext = os.path.splitext(executable)
     if (sys.platform == 'win32') and (ext != '.exe'):
         executable = executable + '.exe'
@@ -87,14 +97,13 @@ def find_executable(executable, path=None):
 
     if path is None:
         path = os.environ.get('PATH', None)
+        # bpo-35755: Don't fall through if PATH is the empty string
         if path is None:
             try:
                 path = os.confstr("CS_PATH")
             except (AttributeError, ValueError):
                 # os.confstr() or CS_PATH is not available
                 path = os.defpath
-        # bpo-35755: Don't use os.defpath if the PATH environment variable is
-        # set to an empty string
 
     # PATH='' doesn't match, whereas PATH=':' looks in the current directory
     if not path:
diff --git a/distutils/sysconfig.py b/distutils/sysconfig.py
index a40a7231..4ba0be56 100644
--- a/distutils/sysconfig.py
+++ b/distutils/sysconfig.py
@@ -9,15 +9,17 @@
 Email:        
 """
 
+import functools
 import os
+import pathlib
 import re
 import sys
 import sysconfig
-import pathlib
 
-from .errors import DistutilsPlatformError
-from . import py39compat
 from ._functools import pass_none
+from .compat import py39
+from .errors import DistutilsPlatformError
+from .util import is_mingw
 
 IS_PYPY = '__pypy__' in sys.builtin_module_names
 
@@ -107,7 +109,7 @@ def get_python_version():
     return '%d.%d' % sys.version_info[:2]
 
 
-def get_python_inc(plat_specific=0, prefix=None):
+def get_python_inc(plat_specific=False, prefix=None):
     """Return the directory containing installed Python header files.
 
     If 'plat_specific' is false (the default), this is the path to the
@@ -120,12 +122,14 @@ def get_python_inc(plat_specific=0, prefix=None):
     """
     default_prefix = BASE_EXEC_PREFIX if plat_specific else BASE_PREFIX
     resolved_prefix = prefix if prefix is not None else default_prefix
+    # MinGW imitates posix like layout, but os.name != posix
+    os_name = "posix" if is_mingw() else os.name
     try:
-        getter = globals()[f'_get_python_inc_{os.name}']
+        getter = globals()[f'_get_python_inc_{os_name}']
     except KeyError:
         raise DistutilsPlatformError(
             "I don't know where Python installs its C header files "
-            "on platform '%s'" % os.name
+            f"on platform '{os.name}'"
         )
     return getter(resolved_prefix, prefix, plat_specific)
 
@@ -195,12 +199,11 @@ def _get_python_inc_posix_prefix(prefix):
 
 def _get_python_inc_nt(prefix, spec_prefix, plat_specific):
     if python_build:
-        # Include both the include and PC dir to ensure we can find
-        # pyconfig.h
+        # Include both include dirs to ensure we can find pyconfig.h
         return (
             os.path.join(prefix, "include")
             + os.path.pathsep
-            + os.path.join(prefix, "PC")
+            + os.path.dirname(sysconfig.get_config_h_filename())
         )
     return os.path.join(prefix, "include")
 
@@ -213,7 +216,7 @@ def _posix_lib(standard_lib, libpython, early_prefix, prefix):
         return os.path.join(libpython, "site-packages")
 
 
-def get_python_lib(plat_specific=0, standard_lib=0, prefix=None):
+def get_python_lib(plat_specific=False, standard_lib=False, prefix=None):
     """Return the directory containing the Python library (standard or
     site additions).
 
@@ -244,7 +247,7 @@ def get_python_lib(plat_specific=0, standard_lib=0, prefix=None):
         else:
             prefix = plat_specific and EXEC_PREFIX or PREFIX
 
-    if os.name == "posix":
+    if os.name == "posix" or is_mingw():
         if plat_specific or standard_lib:
             # Platform-specific modules (any module from a non-pure-Python
             # module distribution) or standard Python library modules.
@@ -262,34 +265,36 @@ def get_python_lib(plat_specific=0, standard_lib=0, prefix=None):
             return os.path.join(prefix, "Lib", "site-packages")
     else:
         raise DistutilsPlatformError(
-            "I don't know where Python installs its library "
-            "on platform '%s'" % os.name
+            f"I don't know where Python installs its library on platform '{os.name}'"
         )
 
 
+@functools.lru_cache
+def _customize_macos():
+    """
+    Perform first-time customization of compiler-related
+    config vars on macOS. Use after a compiler is known
+    to be needed. This customization exists primarily to support Pythons
+    from binary installers. The kind and paths to build tools on
+    the user system may vary significantly from the system
+    that Python itself was built on.  Also the user OS
+    version and build tools may not support the same set
+    of CPU architectures for universal builds.
+    """
+
+    sys.platform == "darwin" and __import__('_osx_support').customize_compiler(
+        get_config_vars()
+    )
+
+
 def customize_compiler(compiler):  # noqa: C901
     """Do any platform-specific customization of a CCompiler instance.
 
     Mainly needed on Unix, so we can plug in the information that
     varies across Unices and is stored in Python's Makefile.
     """
-    if compiler.compiler_type == "unix":
-        if sys.platform == "darwin":
-            # Perform first-time customization of compiler-related
-            # config vars on OS X now that we know we need a compiler.
-            # This is primarily to support Pythons from binary
-            # installers.  The kind and paths to build tools on
-            # the user system may vary significantly from the system
-            # that Python itself was built on.  Also the user OS
-            # version and build tools may not support the same set
-            # of CPU architectures for universal builds.
-            global _config_vars
-            # Use get_config_var() to ensure _config_vars is initialized.
-            if not get_config_var('CUSTOMIZED_OSX_COMPILER'):
-                import _osx_support
-
-                _osx_support.customize_compiler(_config_vars)
-                _config_vars['CUSTOMIZED_OSX_COMPILER'] = 'True'
+    if compiler.compiler_type in ["unix", "cygwin", "mingw32"]:
+        _customize_macos()
 
         (
             cc,
@@ -361,14 +366,7 @@ def customize_compiler(compiler):  # noqa: C901
 
 def get_config_h_filename():
     """Return full pathname of installed pyconfig.h file."""
-    if python_build:
-        if os.name == "nt":
-            inc_dir = os.path.join(_sys_home or project_base, "PC")
-        else:
-            inc_dir = _sys_home or project_base
-        return os.path.join(inc_dir, 'pyconfig.h')
-    else:
-        return sysconfig.get_config_h_filename()
+    return sysconfig.get_config_h_filename()
 
 
 def get_makefile_filename():
@@ -403,7 +401,11 @@ def parse_makefile(fn, g=None):  # noqa: C901
     from distutils.text_file import TextFile
 
     fp = TextFile(
-        fn, strip_comments=1, skip_blanks=1, join_lines=1, errors="surrogateescape"
+        fn,
+        strip_comments=True,
+        skip_blanks=True,
+        join_lines=True,
+        errors="surrogateescape",
     )
 
     if g is None:
@@ -542,7 +544,7 @@ def get_config_vars(*args):
     global _config_vars
     if _config_vars is None:
         _config_vars = sysconfig.get_config_vars().copy()
-        py39compat.add_ext_suffix(_config_vars)
+        py39.add_ext_suffix(_config_vars)
 
     return [_config_vars.get(name) for name in args] if args else _config_vars
 
diff --git a/distutils/tests/__init__.py b/distutils/tests/__init__.py
index 27e73393..93fbf490 100644
--- a/distutils/tests/__init__.py
+++ b/distutils/tests/__init__.py
@@ -6,3 +6,37 @@
 distutils.command.tests package, since command identification is done
 by import rather than matching pre-defined names.
 """
+
+import shutil
+from typing import Sequence
+
+
+def missing_compiler_executable(cmd_names: Sequence[str] = []):  # pragma: no cover
+    """Check if the compiler components used to build the interpreter exist.
+
+    Check for the existence of the compiler executables whose names are listed
+    in 'cmd_names' or all the compiler executables when 'cmd_names' is empty
+    and return the first missing executable or None when none is found
+    missing.
+
+    """
+    from distutils import ccompiler, errors, sysconfig
+
+    compiler = ccompiler.new_compiler()
+    sysconfig.customize_compiler(compiler)
+    if compiler.compiler_type == "msvc":
+        # MSVC has no executables, so check whether initialization succeeds
+        try:
+            compiler.initialize()
+        except errors.DistutilsPlatformError:
+            return "msvc"
+    for name in compiler.executables:
+        if cmd_names and name not in cmd_names:
+            continue
+        cmd = getattr(compiler, name)
+        if cmd_names:
+            assert cmd is not None, f"the '{name}' executable is not configured"
+        elif not cmd:
+            continue
+        if shutil.which(cmd[0]) is None:
+            return cmd[0]
diff --git a/distutils/tests/compat/__init__.py b/distutils/tests/compat/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/distutils/tests/py38compat.py b/distutils/tests/compat/py38.py
similarity index 100%
rename from distutils/tests/py38compat.py
rename to distutils/tests/compat/py38.py
diff --git a/distutils/tests/py37compat.py b/distutils/tests/py37compat.py
deleted file mode 100644
index e5d406a3..00000000
--- a/distutils/tests/py37compat.py
+++ /dev/null
@@ -1,18 +0,0 @@
-import os
-import sys
-import platform
-
-
-def subprocess_args_compat(*args):
-    return list(map(os.fspath, args))
-
-
-def subprocess_args_passthrough(*args):
-    return list(args)
-
-
-subprocess_args = (
-    subprocess_args_compat
-    if platform.system() == "Windows" and sys.version_info < (3, 8)
-    else subprocess_args_passthrough
-)
diff --git a/distutils/tests/support.py b/distutils/tests/support.py
index fd4b11bf..9cd2b8a9 100644
--- a/distutils/tests/support.py
+++ b/distutils/tests/support.py
@@ -1,17 +1,17 @@
 """Support code for distutils test cases."""
+
+import itertools
 import os
-import sys
+import pathlib
 import shutil
-import tempfile
+import sys
 import sysconfig
-import itertools
-import pathlib
+import tempfile
+from distutils.core import Distribution
 
 import pytest
 from more_itertools import always_iterable
 
-from distutils.core import Distribution
-
 
 @pytest.mark.usefixtures('distutils_managed_tempdir')
 class TempdirManager:
@@ -33,7 +33,7 @@ def write_file(self, path, content='xxx'):
 
         path can be a string or a sequence.
         """
-        pathlib.Path(*always_iterable(path)).write_text(content)
+        pathlib.Path(*always_iterable(path)).write_text(content, encoding='utf-8')
 
     def create_dist(self, pkg_name='foo', **kw):
         """Will generate a test environment.
diff --git a/distutils/tests/test_archive_util.py b/distutils/tests/test_archive_util.py
index 89c415d7..abbcd36c 100644
--- a/distutils/tests/test_archive_util.py
+++ b/distutils/tests/test_archive_util.py
@@ -1,30 +1,30 @@
 """Tests for distutils.archive_util."""
+
+import functools
+import operator
 import os
+import pathlib
 import sys
 import tarfile
-from os.path import splitdrive
 import warnings
-import functools
-import operator
-import pathlib
-
-import pytest
-import path
-
 from distutils import archive_util
 from distutils.archive_util import (
+    ARCHIVE_FORMATS,
     check_archive_formats,
+    make_archive,
     make_tarball,
     make_zipfile,
-    make_archive,
-    ARCHIVE_FORMATS,
 )
 from distutils.spawn import spawn
 from distutils.tests import support
+from os.path import splitdrive
 from test.support import patch
-from .unix_compat import require_unix_id, require_uid_0, grp, pwd, UID_0_SUPPORT
 
-from .py38compat import check_warnings
+import path
+import pytest
+
+from .compat.py38 import check_warnings
+from .unix_compat import UID_0_SUPPORT, grp, pwd, require_uid_0, require_unix_id
 
 
 def can_fs_encode(filename):
@@ -135,7 +135,7 @@ def _create_files(self):
         return tmpdir
 
     @pytest.mark.usefixtures('needs_zlib')
-    @pytest.mark.skipif("not (find_executable('tar') and find_executable('gzip'))")
+    @pytest.mark.skipif("not (shutil.which('tar') and shutil.which('gzip'))")
     def test_tarfile_vs_tar(self):
         tmpdir = self._create_files()
         tmpdir2 = self.mkdtemp()
@@ -190,7 +190,7 @@ def test_tarfile_vs_tar(self):
         tarball = base_name + '.tar'
         assert os.path.exists(tarball)
 
-    @pytest.mark.skipif("not find_executable('compress')")
+    @pytest.mark.skipif("not shutil.which('compress')")
     def test_compress_deprecated(self):
         tmpdir = self._create_files()
         base_name = os.path.join(self.mkdtemp(), 'archive')
diff --git a/distutils/tests/test_bdist.py b/distutils/tests/test_bdist.py
index af330a06..d5696fc3 100644
--- a/distutils/tests/test_bdist.py
+++ b/distutils/tests/test_bdist.py
@@ -1,4 +1,5 @@
 """Tests for distutils.command.bdist."""
+
 from distutils.command.bdist import bdist
 from distutils.tests import support
 
@@ -30,7 +31,7 @@ def test_skip_build(self):
         # bug #10946: bdist --skip-build should trickle down to subcommands
         dist = self.create_dist()[1]
         cmd = bdist(dist)
-        cmd.skip_build = 1
+        cmd.skip_build = True
         cmd.ensure_finalized()
         dist.command_obj['bdist'] = cmd
 
@@ -43,4 +44,4 @@ def test_skip_build(self):
             if getattr(subcmd, '_unsupported', False):
                 # command is not supported on this build
                 continue
-            assert subcmd.skip_build, '%s should take --skip-build from bdist' % name
+            assert subcmd.skip_build, f'{name} should take --skip-build from bdist'
diff --git a/distutils/tests/test_bdist_dumb.py b/distutils/tests/test_bdist_dumb.py
index 6fb50c4b..1fc51d24 100644
--- a/distutils/tests/test_bdist_dumb.py
+++ b/distutils/tests/test_bdist_dumb.py
@@ -3,13 +3,12 @@
 import os
 import sys
 import zipfile
-
-import pytest
-
-from distutils.core import Distribution
 from distutils.command.bdist_dumb import bdist_dumb
+from distutils.core import Distribution
 from distutils.tests import support
 
+import pytest
+
 SETUP_PY = """\
 from distutils.core import setup
 import foo
@@ -38,16 +37,14 @@ def test_simple_built(self):
         self.write_file((pkg_dir, 'MANIFEST.in'), 'include foo.py')
         self.write_file((pkg_dir, 'README'), '')
 
-        dist = Distribution(
-            {
-                'name': 'foo',
-                'version': '0.1',
-                'py_modules': ['foo'],
-                'url': 'xxx',
-                'author': 'xxx',
-                'author_email': 'xxx',
-            }
-        )
+        dist = Distribution({
+            'name': 'foo',
+            'version': '0.1',
+            'py_modules': ['foo'],
+            'url': 'xxx',
+            'author': 'xxx',
+            'author_email': 'xxx',
+        })
         dist.script_name = 'setup.py'
         os.chdir(pkg_dir)
 
@@ -63,7 +60,7 @@ def test_simple_built(self):
 
         # see what we have
         dist_created = os.listdir(os.path.join(pkg_dir, 'dist'))
-        base = "{}.{}.zip".format(dist.get_fullname(), cmd.plat_name)
+        base = f"{dist.get_fullname()}.{cmd.plat_name}.zip"
 
         assert dist_created == [base]
 
@@ -75,7 +72,7 @@ def test_simple_built(self):
             fp.close()
 
         contents = sorted(filter(None, map(os.path.basename, contents)))
-        wanted = ['foo-0.1-py%s.%s.egg-info' % sys.version_info[:2], 'foo.py']
+        wanted = ['foo-0.1-py{}.{}.egg-info'.format(*sys.version_info[:2]), 'foo.py']
         if not sys.dont_write_bytecode:
-            wanted.append('foo.%s.pyc' % sys.implementation.cache_tag)
+            wanted.append(f'foo.{sys.implementation.cache_tag}.pyc')
         assert contents == sorted(wanted)
diff --git a/distutils/tests/test_bdist_rpm.py b/distutils/tests/test_bdist_rpm.py
index 4a702fb9..1109fdf1 100644
--- a/distutils/tests/test_bdist_rpm.py
+++ b/distutils/tests/test_bdist_rpm.py
@@ -1,17 +1,15 @@
 """Tests for distutils.command.bdist_rpm."""
 
-import sys
 import os
-
-import pytest
-
-from distutils.core import Distribution
+import shutil  # noqa: F401
+import sys
 from distutils.command.bdist_rpm import bdist_rpm
+from distutils.core import Distribution
 from distutils.tests import support
-from distutils.spawn import find_executable  # noqa: F401
 
-from .py38compat import requires_zlib
+import pytest
 
+from .compat.py38 import requires_zlib
 
 SETUP_PY = """\
 from distutils.core import setup
@@ -45,8 +43,8 @@ class TestBuildRpm(
 ):
     @mac_woes
     @requires_zlib()
-    @pytest.mark.skipif("not find_executable('rpm')")
-    @pytest.mark.skipif("not find_executable('rpmbuild')")
+    @pytest.mark.skipif("not shutil.which('rpm')")
+    @pytest.mark.skipif("not shutil.which('rpmbuild')")
     def test_quiet(self):
         # let's create a package
         tmp_dir = self.mkdtemp()
@@ -58,16 +56,14 @@ def test_quiet(self):
         self.write_file((pkg_dir, 'MANIFEST.in'), 'include foo.py')
         self.write_file((pkg_dir, 'README'), '')
 
-        dist = Distribution(
-            {
-                'name': 'foo',
-                'version': '0.1',
-                'py_modules': ['foo'],
-                'url': 'xxx',
-                'author': 'xxx',
-                'author_email': 'xxx',
-            }
-        )
+        dist = Distribution({
+            'name': 'foo',
+            'version': '0.1',
+            'py_modules': ['foo'],
+            'url': 'xxx',
+            'author': 'xxx',
+            'author_email': 'xxx',
+        })
         dist.script_name = 'setup.py'
         os.chdir(pkg_dir)
 
@@ -76,7 +72,7 @@ def test_quiet(self):
         cmd.fix_python = True
 
         # running in quiet mode
-        cmd.quiet = 1
+        cmd.quiet = True
         cmd.ensure_finalized()
         cmd.run()
 
@@ -89,9 +85,9 @@ def test_quiet(self):
 
     @mac_woes
     @requires_zlib()
-    # http://bugs.python.org/issue1533164
-    @pytest.mark.skipif("not find_executable('rpm')")
-    @pytest.mark.skipif("not find_executable('rpmbuild')")
+    # https://bugs.python.org/issue1533164
+    @pytest.mark.skipif("not shutil.which('rpm')")
+    @pytest.mark.skipif("not shutil.which('rpmbuild')")
     def test_no_optimize_flag(self):
         # let's create a package that breaks bdist_rpm
         tmp_dir = self.mkdtemp()
@@ -103,16 +99,14 @@ def test_no_optimize_flag(self):
         self.write_file((pkg_dir, 'MANIFEST.in'), 'include foo.py')
         self.write_file((pkg_dir, 'README'), '')
 
-        dist = Distribution(
-            {
-                'name': 'foo',
-                'version': '0.1',
-                'py_modules': ['foo'],
-                'url': 'xxx',
-                'author': 'xxx',
-                'author_email': 'xxx',
-            }
-        )
+        dist = Distribution({
+            'name': 'foo',
+            'version': '0.1',
+            'py_modules': ['foo'],
+            'url': 'xxx',
+            'author': 'xxx',
+            'author_email': 'xxx',
+        })
         dist.script_name = 'setup.py'
         os.chdir(pkg_dir)
 
@@ -120,7 +114,7 @@ def test_no_optimize_flag(self):
         cmd = bdist_rpm(dist)
         cmd.fix_python = True
 
-        cmd.quiet = 1
+        cmd.quiet = True
         cmd.ensure_finalized()
         cmd.run()
 
diff --git a/distutils/tests/test_build.py b/distutils/tests/test_build.py
index 66d8af50..8fb1bc1b 100644
--- a/distutils/tests/test_build.py
+++ b/distutils/tests/test_build.py
@@ -1,9 +1,10 @@
 """Tests for distutils.command.build."""
+
 import os
 import sys
-
 from distutils.command.build import build
 from distutils.tests import support
+from sysconfig import get_config_var
 from sysconfig import get_platform
 
 
@@ -23,7 +24,9 @@ def test_finalize_options(self):
         # build_platlib is 'build/lib.platform-cache_tag[-pydebug]'
         # examples:
         #   build/lib.macosx-10.3-i386-cpython39
-        plat_spec = '.{}-{}'.format(cmd.plat_name, sys.implementation.cache_tag)
+        plat_spec = f'.{cmd.plat_name}-{sys.implementation.cache_tag}'
+        if get_config_var('Py_GIL_DISABLED'):
+            plat_spec += 't'
         if hasattr(sys, 'gettotalrefcount'):
             assert cmd.build_platlib.endswith('-pydebug')
             plat_spec += '-pydebug'
diff --git a/distutils/tests/test_build_clib.py b/distutils/tests/test_build_clib.py
index b5a392a8..f76f26bc 100644
--- a/distutils/tests/test_build_clib.py
+++ b/distutils/tests/test_build_clib.py
@@ -1,13 +1,11 @@
 """Tests for distutils.command.build_clib."""
-import os
-
-from test.support import missing_compiler_executable
-
-import pytest
 
+import os
 from distutils.command.build_clib import build_clib
 from distutils.errors import DistutilsSetupError
-from distutils.tests import support
+from distutils.tests import missing_compiler_executable, support
+
+import pytest
 
 
 class TestBuildCLib(support.TempdirManager):
@@ -127,7 +125,7 @@ def test_run(self):
         # all commands are present on the system.
         ccmd = missing_compiler_executable()
         if ccmd is not None:
-            self.skipTest('The %r command is not found' % ccmd)
+            self.skipTest(f'The {ccmd!r} command is not found')
 
         # this should work
         cmd.run()
diff --git a/distutils/tests/test_build_ext.py b/distutils/tests/test_build_ext.py
index cb61ad74..6c4c4ba8 100644
--- a/distutils/tests/test_build_ext.py
+++ b/distutils/tests/test_build_ext.py
@@ -1,42 +1,44 @@
-import sys
-import os
-from io import StringIO
-import textwrap
-import site
 import contextlib
-import platform
-import tempfile
 import importlib
-import shutil
+import os
+import platform
 import re
-
-import path
-import pytest
-
-from distutils.core import Distribution
-from distutils.command.build_ext import build_ext
+import shutil
+import site
+import sys
+import tempfile
+import textwrap
 from distutils import sysconfig
-from distutils.tests.support import (
-    TempdirManager,
-    copy_xxmodule_c,
-    fixup_build_ext,
-)
-from distutils.extension import Extension
+from distutils.command.build_ext import build_ext
+from distutils.core import Distribution
 from distutils.errors import (
     CompileError,
     DistutilsPlatformError,
     DistutilsSetupError,
     UnknownFileError,
 )
-
+from distutils.extension import Extension
+from distutils.tests import missing_compiler_executable
+from distutils.tests.support import (
+    TempdirManager,
+    copy_xxmodule_c,
+    fixup_build_ext,
+)
+from io import StringIO
 from test import support
-from . import py38compat as import_helper
+
+import jaraco.path
+import path
+import pytest
+
+from .compat import py38 as import_helper
 
 
 @pytest.fixture()
 def user_site_dir(request):
     self = request.instance
     self.tmp_dir = self.mkdtemp()
+    self.tmp_path = path.Path(self.tmp_dir)
     from distutils.command import build_ext
 
     orig_user_base = site.USER_BASE
@@ -47,7 +49,7 @@ def user_site_dir(request):
     # bpo-30132: On Windows, a .pdb file may be created in the current
     # working directory. Create a temporary working directory to cleanup
     # everything at the end of the test.
-    with path.Path(self.tmp_dir):
+    with self.tmp_path:
         yield
 
     site.USER_BASE = orig_user_base
@@ -89,7 +91,7 @@ def build_ext(self, *args, **kwargs):
         return build_ext(*args, **kwargs)
 
     def test_build_ext(self):
-        cmd = support.missing_compiler_executable()
+        missing_compiler_executable()
         copy_xxmodule_c(self.tmp_dir)
         xx_c = os.path.join(self.tmp_dir, 'xxmodule.c')
         xx_ext = Extension('xx', [xx_c])
@@ -138,7 +140,7 @@ def test_solaris_enable_shared(self):
         from distutils.sysconfig import _config_vars
 
         old_var = _config_vars.get('Py_ENABLE_SHARED')
-        _config_vars['Py_ENABLE_SHARED'] = 1
+        _config_vars['Py_ENABLE_SHARED'] = True
         try:
             cmd.ensure_finalized()
         finally:
@@ -162,7 +164,7 @@ def test_user_site(self):
         assert 'user' in options
 
         # setting a value
-        cmd.user = 1
+        cmd.user = True
 
         # setting user based lib and include
         lib = os.path.join(site.USER_BASE, 'lib')
@@ -207,7 +209,7 @@ def test_finalize_options(self):
         for p in py_include.split(os.path.pathsep):
             assert p in cmd.include_dirs
 
-        plat_py_include = sysconfig.get_python_inc(plat_specific=1)
+        plat_py_include = sysconfig.get_python_inc(plat_specific=True)
         for p in plat_py_include.split(os.path.pathsep):
             assert p in cmd.include_dirs
 
@@ -221,7 +223,7 @@ def test_finalize_options(self):
         # make sure cmd.library_dirs is turned into a list
         # if it's a string
         cmd = self.build_ext(dist)
-        cmd.library_dirs = 'my_lib_dir%sother_lib_dir' % os.pathsep
+        cmd.library_dirs = f'my_lib_dir{os.pathsep}other_lib_dir'
         cmd.finalize_options()
         assert 'my_lib_dir' in cmd.library_dirs
         assert 'other_lib_dir' in cmd.library_dirs
@@ -229,7 +231,7 @@ def test_finalize_options(self):
         # make sure rpath is turned into a list
         # if it's a string
         cmd = self.build_ext(dist)
-        cmd.rpath = 'one%stwo' % os.pathsep
+        cmd.rpath = f'one{os.pathsep}two'
         cmd.finalize_options()
         assert cmd.rpath == ['one', 'two']
 
@@ -359,7 +361,7 @@ def test_compiler_option(self):
         assert cmd.compiler == 'unix'
 
     def test_get_outputs(self):
-        cmd = support.missing_compiler_executable()
+        missing_compiler_executable()
         tmp_dir = self.mkdtemp()
         c_file = os.path.join(tmp_dir, 'foo.c')
         self.write_file(c_file, 'void PyInit_foo(void) {}\n')
@@ -379,7 +381,7 @@ def test_get_outputs(self):
         old_wd = os.getcwd()
         os.chdir(other_tmp_dir)
         try:
-            cmd.inplace = 1
+            cmd.inplace = True
             cmd.run()
             so_file = cmd.get_outputs()[0]
         finally:
@@ -390,7 +392,7 @@ def test_get_outputs(self):
         so_dir = os.path.dirname(so_file)
         assert so_dir == other_tmp_dir
 
-        cmd.inplace = 0
+        cmd.inplace = False
         cmd.compiler = None
         cmd.run()
         so_file = cmd.get_outputs()[0]
@@ -399,7 +401,7 @@ def test_get_outputs(self):
         so_dir = os.path.dirname(so_file)
         assert so_dir == cmd.build_lib
 
-        # inplace = 0, cmd.package = 'bar'
+        # inplace = False, cmd.package = 'bar'
         build_py = cmd.get_finalized_command('build_py')
         build_py.package_dir = {'': 'bar'}
         path = cmd.get_ext_fullpath('foo')
@@ -407,8 +409,8 @@ def test_get_outputs(self):
         path = os.path.split(path)[0]
         assert path == cmd.build_lib
 
-        # inplace = 1, cmd.package = 'bar'
-        cmd.inplace = 1
+        # inplace = True, cmd.package = 'bar'
+        cmd.inplace = True
         other_tmp_dir = os.path.realpath(self.mkdtemp())
         old_wd = os.getcwd()
         os.chdir(other_tmp_dir)
@@ -429,7 +431,7 @@ def test_ext_fullpath(self):
         # dist = Distribution({'name': 'lxml', 'ext_modules': [etree_ext]})
         dist = Distribution()
         cmd = self.build_ext(dist)
-        cmd.inplace = 1
+        cmd.inplace = True
         cmd.distribution.package_dir = {'': 'src'}
         cmd.distribution.packages = ['lxml', 'lxml.html']
         curdir = os.getcwd()
@@ -438,7 +440,7 @@ def test_ext_fullpath(self):
         assert wanted == path
 
         # building lxml.etree not inplace
-        cmd.inplace = 0
+        cmd.inplace = False
         cmd.build_lib = os.path.join(curdir, 'tmpdir')
         wanted = os.path.join(curdir, 'tmpdir', 'lxml', 'etree' + ext)
         path = cmd.get_ext_fullpath('lxml.etree')
@@ -453,7 +455,7 @@ def test_ext_fullpath(self):
         assert wanted == path
 
         # building twisted.runner.portmap inplace
-        cmd.inplace = 1
+        cmd.inplace = True
         path = cmd.get_ext_fullpath('twisted.runner.portmap')
         wanted = os.path.join(curdir, 'twisted', 'runner', 'portmap' + ext)
         assert wanted == path
@@ -476,7 +478,7 @@ def test_deployment_target_too_low(self):
 
     @pytest.mark.skipif('platform.system() != "Darwin"')
     @pytest.mark.usefixtures('save_env')
-    def test_deployment_target_higher_ok(self):
+    def test_deployment_target_higher_ok(self):  # pragma: no cover
         # Issue 9516: Test that an extension module can be compiled with a
         # deployment target higher than that of the interpreter: the ext
         # module may depend on some newer OS feature.
@@ -488,32 +490,29 @@ def test_deployment_target_higher_ok(self):
             deptarget = '.'.join(str(i) for i in deptarget)
             self._try_compile_deployment_target('<', deptarget)
 
-    def _try_compile_deployment_target(self, operator, target):
+    def _try_compile_deployment_target(self, operator, target):  # pragma: no cover
         if target is None:
             if os.environ.get('MACOSX_DEPLOYMENT_TARGET'):
                 del os.environ['MACOSX_DEPLOYMENT_TARGET']
         else:
             os.environ['MACOSX_DEPLOYMENT_TARGET'] = target
 
-        deptarget_c = os.path.join(self.tmp_dir, 'deptargetmodule.c')
-
-        with open(deptarget_c, 'w') as fp:
-            fp.write(
-                textwrap.dedent(
-                    '''\
-                #include 
+        jaraco.path.build(
+            {
+                'deptargetmodule.c': textwrap.dedent(f"""\
+                    #include 
 
-                int dummy;
+                    int dummy;
 
-                #if TARGET %s MAC_OS_X_VERSION_MIN_REQUIRED
-                #else
-                #error "Unexpected target"
-                #endif
+                    #if TARGET {operator} MAC_OS_X_VERSION_MIN_REQUIRED
+                    #else
+                    #error "Unexpected target"
+                    #endif
 
-            '''
-                    % operator
-                )
-            )
+                    """),
+            },
+            self.tmp_path,
+        )
 
         # get the deployment target that the interpreter was built with
         target = sysconfig.get_config_var('MACOSX_DEPLOYMENT_TARGET')
@@ -533,8 +532,8 @@ def _try_compile_deployment_target(self, operator, target):
                 target = '%02d0000' % target
         deptarget_ext = Extension(
             'deptarget',
-            [deptarget_c],
-            extra_compile_args=['-DTARGET={}'.format(target)],
+            [self.tmp_path / 'deptargetmodule.c'],
+            extra_compile_args=[f'-DTARGET={target}'],
         )
         dist = Distribution({'name': 'deptarget', 'ext_modules': [deptarget_ext]})
         dist.package_dir = self.tmp_dir
diff --git a/distutils/tests/test_build_py.py b/distutils/tests/test_build_py.py
index 3bef9d79..b316ed43 100644
--- a/distutils/tests/test_build_py.py
+++ b/distutils/tests/test_build_py.py
@@ -2,43 +2,41 @@
 
 import os
 import sys
-
-import pytest
-
 from distutils.command.build_py import build_py
 from distutils.core import Distribution
 from distutils.errors import DistutilsFileError
-
 from distutils.tests import support
 
+import jaraco.path
+import pytest
+
 
 @support.combine_markers
 class TestBuildPy(support.TempdirManager):
     def test_package_data(self):
         sources = self.mkdtemp()
-        f = open(os.path.join(sources, "__init__.py"), "w")
-        try:
-            f.write("# Pretend this is a package.")
-        finally:
-            f.close()
-        f = open(os.path.join(sources, "README.txt"), "w")
-        try:
-            f.write("Info about this package")
-        finally:
-            f.close()
+        jaraco.path.build(
+            {
+                '__init__.py': "# Pretend this is a package.",
+                'README.txt': 'Info about this package',
+            },
+            sources,
+        )
 
         destination = self.mkdtemp()
 
         dist = Distribution({"packages": ["pkg"], "package_dir": {"pkg": sources}})
         # script_name need not exist, it just need to be initialized
         dist.script_name = os.path.join(sources, "setup.py")
-        dist.command_obj["build"] = support.DummyCommand(force=0, build_lib=destination)
+        dist.command_obj["build"] = support.DummyCommand(
+            force=False, build_lib=destination
+        )
         dist.packages = ["pkg"]
         dist.package_data = {"pkg": ["README.txt"]}
         dist.package_dir = {"pkg": sources}
 
         cmd = build_py(dist)
-        cmd.compile = 1
+        cmd.compile = True
         cmd.ensure_finalized()
         assert cmd.package_data == dist.package_data
 
@@ -57,25 +55,19 @@ def test_package_data(self):
             assert not os.path.exists(pycache_dir)
         else:
             pyc_files = os.listdir(pycache_dir)
-            assert "__init__.%s.pyc" % sys.implementation.cache_tag in pyc_files
+            assert f"__init__.{sys.implementation.cache_tag}.pyc" in pyc_files
 
     def test_empty_package_dir(self):
         # See bugs #1668596/#1720897
         sources = self.mkdtemp()
-        open(os.path.join(sources, "__init__.py"), "w").close()
-
-        testdir = os.path.join(sources, "doc")
-        os.mkdir(testdir)
-        open(os.path.join(testdir, "testfile"), "w").close()
+        jaraco.path.build({'__init__.py': '', 'doc': {'testfile': ''}}, sources)
 
         os.chdir(sources)
-        dist = Distribution(
-            {
-                "packages": ["pkg"],
-                "package_dir": {"pkg": ""},
-                "package_data": {"pkg": ["doc/*"]},
-            }
-        )
+        dist = Distribution({
+            "packages": ["pkg"],
+            "package_dir": {"pkg": ""},
+            "package_data": {"pkg": ["doc/*"]},
+        })
         # script_name need not exist, it just need to be initialized
         dist.script_name = os.path.join(sources, "setup.py")
         dist.script_args = ["build"]
@@ -92,7 +84,7 @@ def test_byte_compile(self):
         os.chdir(project_dir)
         self.write_file('boiledeggs.py', 'import antigravity')
         cmd = build_py(dist)
-        cmd.compile = 1
+        cmd.compile = True
         cmd.build_lib = 'here'
         cmd.finalize_options()
         cmd.run()
@@ -100,7 +92,7 @@ def test_byte_compile(self):
         found = os.listdir(cmd.build_lib)
         assert sorted(found) == ['__pycache__', 'boiledeggs.py']
         found = os.listdir(os.path.join(cmd.build_lib, '__pycache__'))
-        assert found == ['boiledeggs.%s.pyc' % sys.implementation.cache_tag]
+        assert found == [f'boiledeggs.{sys.implementation.cache_tag}.pyc']
 
     @pytest.mark.skipif('sys.dont_write_bytecode')
     def test_byte_compile_optimized(self):
@@ -108,7 +100,7 @@ def test_byte_compile_optimized(self):
         os.chdir(project_dir)
         self.write_file('boiledeggs.py', 'import antigravity')
         cmd = build_py(dist)
-        cmd.compile = 0
+        cmd.compile = False
         cmd.optimize = 1
         cmd.build_lib = 'here'
         cmd.finalize_options()
@@ -126,17 +118,19 @@ def test_dir_in_package_data(self):
         """
         # See bug 19286
         sources = self.mkdtemp()
-        pkg_dir = os.path.join(sources, "pkg")
-
-        os.mkdir(pkg_dir)
-        open(os.path.join(pkg_dir, "__init__.py"), "w").close()
-
-        docdir = os.path.join(pkg_dir, "doc")
-        os.mkdir(docdir)
-        open(os.path.join(docdir, "testfile"), "w").close()
-
-        # create the directory that could be incorrectly detected as a file
-        os.mkdir(os.path.join(docdir, 'otherdir'))
+        jaraco.path.build(
+            {
+                'pkg': {
+                    '__init__.py': '',
+                    'doc': {
+                        'testfile': '',
+                        # create a directory that could be incorrectly detected as a file
+                        'otherdir': {},
+                    },
+                }
+            },
+            sources,
+        )
 
         os.chdir(sources)
         dist = Distribution({"packages": ["pkg"], "package_data": {"pkg": ["doc/*"]}})
@@ -154,7 +148,7 @@ def test_dont_write_bytecode(self, caplog):
         # makes sure byte_compile is not used
         dist = self.create_dist()[1]
         cmd = build_py(dist)
-        cmd.compile = 1
+        cmd.compile = True
         cmd.optimize = 1
 
         old_dont_write_bytecode = sys.dont_write_bytecode
@@ -176,9 +170,8 @@ def test_namespace_package_does_not_warn(self, caplog):
         """
         # Create a fake project structure with a package namespace:
         tmp = self.mkdtemp()
+        jaraco.path.build({'ns': {'pkg': {'module.py': ''}}}, tmp)
         os.chdir(tmp)
-        os.makedirs("ns/pkg")
-        open("ns/pkg/module.py", "w").close()
 
         # Configure the package:
         attrs = {
diff --git a/distutils/tests/test_build_scripts.py b/distutils/tests/test_build_scripts.py
index 1a5753c7..3582f691 100644
--- a/distutils/tests/test_build_scripts.py
+++ b/distutils/tests/test_build_scripts.py
@@ -1,13 +1,14 @@
 """Tests for distutils.command.build_scripts."""
 
 import os
-
+import textwrap
+from distutils import sysconfig
 from distutils.command.build_scripts import build_scripts
 from distutils.core import Distribution
-from distutils import sysconfig
-
 from distutils.tests import support
 
+import jaraco.path
+
 
 class TestBuildScripts(support.TempdirManager):
     def test_default_settings(self):
@@ -41,42 +42,31 @@ def get_build_scripts_cmd(self, target, scripts):
         dist = Distribution()
         dist.scripts = scripts
         dist.command_obj["build"] = support.DummyCommand(
-            build_scripts=target, force=1, executable=sys.executable
+            build_scripts=target, force=True, executable=sys.executable
         )
         return build_scripts(dist)
 
-    def write_sample_scripts(self, dir):
-        expected = []
-        expected.append("script1.py")
-        self.write_script(
-            dir,
-            "script1.py",
-            (
-                "#! /usr/bin/env python2.3\n"
-                "# bogus script w/ Python sh-bang\n"
-                "pass\n"
-            ),
-        )
-        expected.append("script2.py")
-        self.write_script(
-            dir,
-            "script2.py",
-            ("#!/usr/bin/python\n" "# bogus script w/ Python sh-bang\n" "pass\n"),
-        )
-        expected.append("shell.sh")
-        self.write_script(
-            dir,
-            "shell.sh",
-            ("#!/bin/sh\n" "# bogus shell script w/ sh-bang\n" "exit 0\n"),
-        )
-        return expected
-
-    def write_script(self, dir, name, text):
-        f = open(os.path.join(dir, name), "w")
-        try:
-            f.write(text)
-        finally:
-            f.close()
+    @staticmethod
+    def write_sample_scripts(dir):
+        spec = {
+            'script1.py': textwrap.dedent("""
+                #! /usr/bin/env python2.3
+                # bogus script w/ Python sh-bang
+                pass
+                """).lstrip(),
+            'script2.py': textwrap.dedent("""
+                #!/usr/bin/python
+                # bogus script w/ Python sh-bang
+                pass
+                """).lstrip(),
+            'shell.sh': textwrap.dedent("""
+                #!/bin/sh
+                # bogus shell script w/ sh-bang
+                exit 0
+                """).lstrip(),
+        }
+        jaraco.path.build(spec, dir)
+        return list(spec)
 
     def test_version_int(self):
         source = self.mkdtemp()
@@ -88,7 +78,7 @@ def test_version_int(self):
         )
         cmd.finalize_options()
 
-        # http://bugs.python.org/issue4524
+        # https://bugs.python.org/issue4524
         #
         # On linux-g++-32 with command line `./configure --enable-ipv6
         # --with-suffix=3`, python is compiled okay but the build scripts
diff --git a/distutils/tests/test_ccompiler.py b/distutils/tests/test_ccompiler.py
index 49691d4b..d23b907c 100644
--- a/distutils/tests/test_ccompiler.py
+++ b/distutils/tests/test_ccompiler.py
@@ -1,13 +1,12 @@
 import os
-import sys
 import platform
-import textwrap
+import sys
 import sysconfig
+import textwrap
+from distutils import ccompiler
 
 import pytest
 
-from distutils import ccompiler
-
 
 def _make_strs(paths):
     """
@@ -36,7 +35,7 @@ def c_file(tmp_path):
         .lstrip()
         .replace('#headers', headers)
     )
-    c_file.write_text(payload)
+    c_file.write_text(payload, encoding='utf-8')
     return c_file
 
 
diff --git a/distutils/tests/test_check.py b/distutils/tests/test_check.py
index 6d240b8b..b672b1f9 100644
--- a/distutils/tests/test_check.py
+++ b/distutils/tests/test_check.py
@@ -1,12 +1,12 @@
 """Tests for distutils.command.check."""
+
 import os
 import textwrap
-
-import pytest
-
 from distutils.command.check import check
-from distutils.tests import support
 from distutils.errors import DistutilsSetupError
+from distutils.tests import support
+
+import pytest
 
 try:
     import pygments
@@ -62,7 +62,7 @@ def test_check_metadata(self):
             self._run({}, **{'strict': 1})
 
         # and of course, no error when all metadata are present
-        cmd = self._run(metadata, strict=1)
+        cmd = self._run(metadata, strict=True)
         assert cmd._warnings == 0
 
         # now a test with non-ASCII characters
@@ -126,7 +126,7 @@ def test_check_restructuredtext(self):
         cmd.check_restructuredtext()
         assert cmd._warnings == 1
 
-        # let's see if we have an error with strict=1
+        # let's see if we have an error with strict=True
         metadata = {
             'url': 'xxx',
             'author': 'xxx',
@@ -140,12 +140,12 @@ def test_check_restructuredtext(self):
 
         # and non-broken rest, including a non-ASCII character to test #12114
         metadata['long_description'] = 'title\n=====\n\ntest \u00df'
-        cmd = self._run(metadata, strict=1, restructuredtext=1)
+        cmd = self._run(metadata, strict=True, restructuredtext=True)
         assert cmd._warnings == 0
 
         # check that includes work to test #31292
         metadata['long_description'] = 'title\n=====\n\n.. include:: includetest.rst'
-        cmd = self._run(metadata, cwd=HERE, strict=1, restructuredtext=1)
+        cmd = self._run(metadata, cwd=HERE, strict=True, restructuredtext=True)
         assert cmd._warnings == 0
 
     def test_check_restructuredtext_with_syntax_highlight(self):
diff --git a/distutils/tests/test_clean.py b/distutils/tests/test_clean.py
index 157b60a1..cc78f30f 100644
--- a/distutils/tests/test_clean.py
+++ b/distutils/tests/test_clean.py
@@ -1,6 +1,6 @@
 """Tests for distutils.command.clean."""
-import os
 
+import os
 from distutils.command.clean import clean
 from distutils.tests import support
 
@@ -36,8 +36,8 @@ def test_simple_run(self):
         cmd.run()
 
         # make sure the files where removed
-        for name, path in dirs:
-            assert not os.path.exists(path), '%s was not removed' % path
+        for _name, path in dirs:
+            assert not os.path.exists(path), f'{path} was not removed'
 
         # let's run the command again (should spit warnings but succeed)
         cmd.all = 1
diff --git a/distutils/tests/test_cmd.py b/distutils/tests/test_cmd.py
index cc740d1a..76e8f598 100644
--- a/distutils/tests/test_cmd.py
+++ b/distutils/tests/test_cmd.py
@@ -1,10 +1,11 @@
 """Tests for distutils.cmd."""
-import os
 
+import os
+from distutils import debug
 from distutils.cmd import Command
 from distutils.dist import Distribution
 from distutils.errors import DistutilsOptionError
-from distutils import debug
+
 import pytest
 
 
@@ -47,7 +48,7 @@ def test_ensure_string_list(self, cmd):
     def test_make_file(self, cmd):
         # making sure it raises when infiles is not a string or a list/tuple
         with pytest.raises(TypeError):
-            cmd.make_file(infiles=1, outfile='', func='func', args=())
+            cmd.make_file(infiles=True, outfile='', func='func', args=())
 
         # making sure execute gets called properly
         def _execute(func, args, exec_msg, level):
diff --git a/distutils/tests/test_config.py b/distutils/tests/test_config.py
index 1ae615db..be5ae0a6 100644
--- a/distutils/tests/test_config.py
+++ b/distutils/tests/test_config.py
@@ -1,10 +1,10 @@
 """Tests for distutils.pypirc.pypirc."""
+
 import os
+from distutils.tests import support
 
 import pytest
 
-from distutils.tests import support
-
 PYPIRC = """\
 [distutils]
 
diff --git a/distutils/tests/test_config_cmd.py b/distutils/tests/test_config_cmd.py
index e72a7c5f..ebee2ef9 100644
--- a/distutils/tests/test_config_cmd.py
+++ b/distutils/tests/test_config_cmd.py
@@ -1,14 +1,15 @@
 """Tests for distutils.command.config."""
+
 import os
 import sys
-from test.support import missing_compiler_executable
+from distutils._log import log
+from distutils.command.config import config, dump_file
+from distutils.tests import missing_compiler_executable, support
 
+import more_itertools
+import path
 import pytest
 
-from distutils.command.config import dump_file, config
-from distutils.tests import support
-from distutils._log import log
-
 
 @pytest.fixture(autouse=True)
 def info_log(request, monkeypatch):
@@ -24,12 +25,9 @@ def _info(self, msg, *args):
             self._logs.append(line)
 
     def test_dump_file(self):
-        this_file = os.path.splitext(__file__)[0] + '.py'
-        f = open(this_file)
-        try:
-            numlines = len(f.readlines())
-        finally:
-            f.close()
+        this_file = path.Path(__file__).with_suffix('.py')
+        with this_file.open(encoding='utf-8') as f:
+            numlines = more_itertools.ilen(f)
 
         dump_file(this_file, 'I am the header')
         assert len(self._logs) == numlines + 1
@@ -38,7 +36,7 @@ def test_dump_file(self):
     def test_search_cpp(self):
         cmd = missing_compiler_executable(['preprocessor'])
         if cmd is not None:
-            self.skipTest('The %r command is not found' % cmd)
+            self.skipTest(f'The {cmd!r} command is not found')
         pkg_dir, dist = self.create_dist()
         cmd = config(dist)
         cmd._check_compiler()
@@ -60,9 +58,9 @@ def test_finalize_options(self):
         # on options
         pkg_dir, dist = self.create_dist()
         cmd = config(dist)
-        cmd.include_dirs = 'one%stwo' % os.pathsep
+        cmd.include_dirs = f'one{os.pathsep}two'
         cmd.libraries = 'one'
-        cmd.library_dirs = 'three%sfour' % os.pathsep
+        cmd.library_dirs = f'three{os.pathsep}four'
         cmd.ensure_finalized()
 
         assert cmd.include_dirs == ['one', 'two']
diff --git a/distutils/tests/test_core.py b/distutils/tests/test_core.py
index 2c11ff76..bad3fb7e 100644
--- a/distutils/tests/test_core.py
+++ b/distutils/tests/test_core.py
@@ -1,14 +1,13 @@
 """Tests for distutils.core."""
 
-import io
 import distutils.core
+import io
 import os
 import sys
+from distutils.dist import Distribution
 
 import pytest
 
-from distutils.dist import Distribution
-
 # setup script that uses __file__
 setup_using___file__ = """\
 
@@ -70,20 +69,20 @@ class TestCore:
     def test_run_setup_provides_file(self, temp_file):
         # Make sure the script can use __file__; if that's missing, the test
         # setup.py script will raise NameError.
-        temp_file.write_text(setup_using___file__)
+        temp_file.write_text(setup_using___file__, encoding='utf-8')
         distutils.core.run_setup(temp_file)
 
     def test_run_setup_preserves_sys_argv(self, temp_file):
         # Make sure run_setup does not clobber sys.argv
         argv_copy = sys.argv.copy()
-        temp_file.write_text(setup_does_nothing)
+        temp_file.write_text(setup_does_nothing, encoding='utf-8')
         distutils.core.run_setup(temp_file)
         assert sys.argv == argv_copy
 
     def test_run_setup_defines_subclass(self, temp_file):
         # Make sure the script can use __file__; if that's missing, the test
         # setup.py script will raise NameError.
-        temp_file.write_text(setup_defines_subclass)
+        temp_file.write_text(setup_defines_subclass, encoding='utf-8')
         dist = distutils.core.run_setup(temp_file)
         install = dist.get_command_obj('install')
         assert 'cmd' in install.sub_commands
@@ -98,7 +97,7 @@ def test_run_setup_uses_current_dir(self, tmp_path):
 
         # Create a directory and write the setup.py file there:
         setup_py = tmp_path / 'setup.py'
-        setup_py.write_text(setup_prints_cwd)
+        setup_py.write_text(setup_prints_cwd, encoding='utf-8')
         distutils.core.run_setup(setup_py)
 
         output = sys.stdout.getvalue()
@@ -107,14 +106,14 @@ def test_run_setup_uses_current_dir(self, tmp_path):
         assert cwd == output
 
     def test_run_setup_within_if_main(self, temp_file):
-        temp_file.write_text(setup_within_if_main)
+        temp_file.write_text(setup_within_if_main, encoding='utf-8')
         dist = distutils.core.run_setup(temp_file, stop_after="config")
         assert isinstance(dist, Distribution)
         assert dist.get_name() == "setup_within_if_main"
 
     def test_run_commands(self, temp_file):
         sys.argv = ['setup.py', 'build']
-        temp_file.write_text(setup_within_if_main)
+        temp_file.write_text(setup_within_if_main, encoding='utf-8')
         dist = distutils.core.run_setup(temp_file, stop_after="commandline")
         assert 'build' not in dist.have_run
         distutils.core.run_commands(dist)
@@ -124,7 +123,7 @@ def test_debug_mode(self, capsys, monkeypatch):
         # this covers the code called when DEBUG is set
         sys.argv = ['setup.py', '--name']
         distutils.core.setup(name='bar')
-        capsys.readouterr().out == 'bar\n'
+        assert capsys.readouterr().out == 'bar\n'
         monkeypatch.setattr(distutils.core, 'DEBUG', True)
         distutils.core.setup(name='bar')
         wanted = "options (after parsing config files):\n"
diff --git a/distutils/tests/test_cygwinccompiler.py b/distutils/tests/test_cygwinccompiler.py
index 6fb449a6..2e1640b7 100644
--- a/distutils/tests/test_cygwinccompiler.py
+++ b/distutils/tests/test_cygwinccompiler.py
@@ -1,18 +1,18 @@
 """Tests for distutils.cygwinccompiler."""
-import sys
-import os
-
-import pytest
 
+import os
+import sys
+from distutils import sysconfig
 from distutils.cygwinccompiler import (
-    check_config_h,
-    CONFIG_H_OK,
     CONFIG_H_NOTOK,
+    CONFIG_H_OK,
     CONFIG_H_UNCERTAIN,
+    check_config_h,
     get_msvcr,
 )
 from distutils.tests import support
-from distutils import sysconfig
+
+import pytest
 
 
 @pytest.fixture(autouse=True)
@@ -71,34 +71,34 @@ def test_check_config_h(self):
         assert check_config_h()[0] == CONFIG_H_OK
 
     def test_get_msvcr(self):
-        # none
+        # []
         sys.version = (
             '2.6.1 (r261:67515, Dec  6 2008, 16:42:21) '
             '\n[GCC 4.0.1 (Apple Computer, Inc. build 5370)]'
         )
-        assert get_msvcr() is None
+        assert get_msvcr() == []
 
         # MSVC 7.0
         sys.version = (
-            '2.5.1 (r251:54863, Apr 18 2007, 08:51:08) ' '[MSC v.1300 32 bits (Intel)]'
+            '2.5.1 (r251:54863, Apr 18 2007, 08:51:08) [MSC v.1300 32 bits (Intel)]'
         )
         assert get_msvcr() == ['msvcr70']
 
         # MSVC 7.1
         sys.version = (
-            '2.5.1 (r251:54863, Apr 18 2007, 08:51:08) ' '[MSC v.1310 32 bits (Intel)]'
+            '2.5.1 (r251:54863, Apr 18 2007, 08:51:08) [MSC v.1310 32 bits (Intel)]'
         )
         assert get_msvcr() == ['msvcr71']
 
         # VS2005 / MSVC 8.0
         sys.version = (
-            '2.5.1 (r251:54863, Apr 18 2007, 08:51:08) ' '[MSC v.1400 32 bits (Intel)]'
+            '2.5.1 (r251:54863, Apr 18 2007, 08:51:08) [MSC v.1400 32 bits (Intel)]'
         )
         assert get_msvcr() == ['msvcr80']
 
         # VS2008 / MSVC 9.0
         sys.version = (
-            '2.5.1 (r251:54863, Apr 18 2007, 08:51:08) ' '[MSC v.1500 32 bits (Intel)]'
+            '2.5.1 (r251:54863, Apr 18 2007, 08:51:08) [MSC v.1500 32 bits (Intel)]'
         )
         assert get_msvcr() == ['msvcr90']
 
@@ -110,7 +110,14 @@ def test_get_msvcr(self):
 
         # unknown
         sys.version = (
-            '2.5.1 (r251:54863, Apr 18 2007, 08:51:08) ' '[MSC v.2000 32 bits (Intel)]'
+            '2.5.1 (r251:54863, Apr 18 2007, 08:51:08) [MSC v.2000 32 bits (Intel)]'
         )
         with pytest.raises(ValueError):
             get_msvcr()
+
+    @pytest.mark.skipif('sys.platform != "cygwin"')
+    def test_dll_libraries_not_none(self):
+        from distutils.cygwinccompiler import CygwinCCompiler
+
+        compiler = CygwinCCompiler()
+        assert compiler.dll_libraries is not None
diff --git a/distutils/tests/test_dir_util.py b/distutils/tests/test_dir_util.py
index 72aca4ee..c00ffbbd 100644
--- a/distutils/tests/test_dir_util.py
+++ b/distutils/tests/test_dir_util.py
@@ -1,18 +1,20 @@
 """Tests for distutils.dir_util."""
+
 import os
 import stat
 import unittest.mock as mock
-
 from distutils import dir_util, errors
 from distutils.dir_util import (
-    mkpath,
-    remove_tree,
-    create_tree,
     copy_tree,
+    create_tree,
     ensure_relative,
+    mkpath,
+    remove_tree,
 )
-
 from distutils.tests import support
+
+import jaraco.path
+import path
 import pytest
 
 
@@ -27,17 +29,17 @@ def stuff(request, monkeypatch, distutils_managed_tempdir):
 
 class TestDirUtil(support.TempdirManager):
     def test_mkpath_remove_tree_verbosity(self, caplog):
-        mkpath(self.target, verbose=0)
+        mkpath(self.target, verbose=False)
         assert not caplog.records
-        remove_tree(self.root_target, verbose=0)
+        remove_tree(self.root_target, verbose=False)
 
-        mkpath(self.target, verbose=1)
-        wanted = ['creating %s' % self.root_target, 'creating %s' % self.target]
+        mkpath(self.target, verbose=True)
+        wanted = [f'creating {self.root_target}', f'creating {self.target}']
         assert caplog.messages == wanted
         caplog.clear()
 
-        remove_tree(self.root_target, verbose=1)
-        wanted = ["removing '%s' (and everything under it)" % self.root_target]
+        remove_tree(self.root_target, verbose=True)
+        wanted = [f"removing '{self.root_target}' (and everything under it)"]
         assert caplog.messages == wanted
 
     @pytest.mark.skipif("platform.system() == 'Windows'")
@@ -51,50 +53,45 @@ def test_mkpath_with_custom_mode(self):
         assert stat.S_IMODE(os.stat(self.target2).st_mode) == 0o555 & ~umask
 
     def test_create_tree_verbosity(self, caplog):
-        create_tree(self.root_target, ['one', 'two', 'three'], verbose=0)
+        create_tree(self.root_target, ['one', 'two', 'three'], verbose=False)
         assert caplog.messages == []
-        remove_tree(self.root_target, verbose=0)
+        remove_tree(self.root_target, verbose=False)
 
-        wanted = ['creating %s' % self.root_target]
-        create_tree(self.root_target, ['one', 'two', 'three'], verbose=1)
+        wanted = [f'creating {self.root_target}']
+        create_tree(self.root_target, ['one', 'two', 'three'], verbose=True)
         assert caplog.messages == wanted
 
-        remove_tree(self.root_target, verbose=0)
+        remove_tree(self.root_target, verbose=False)
 
     def test_copy_tree_verbosity(self, caplog):
-        mkpath(self.target, verbose=0)
+        mkpath(self.target, verbose=False)
 
-        copy_tree(self.target, self.target2, verbose=0)
+        copy_tree(self.target, self.target2, verbose=False)
         assert caplog.messages == []
 
-        remove_tree(self.root_target, verbose=0)
+        remove_tree(self.root_target, verbose=False)
 
-        mkpath(self.target, verbose=0)
-        a_file = os.path.join(self.target, 'ok.txt')
-        with open(a_file, 'w') as f:
-            f.write('some content')
+        mkpath(self.target, verbose=False)
+        a_file = path.Path(self.target) / 'ok.txt'
+        jaraco.path.build({'ok.txt': 'some content'}, self.target)
 
-        wanted = ['copying {} -> {}'.format(a_file, self.target2)]
-        copy_tree(self.target, self.target2, verbose=1)
+        wanted = [f'copying {a_file} -> {self.target2}']
+        copy_tree(self.target, self.target2, verbose=True)
         assert caplog.messages == wanted
 
-        remove_tree(self.root_target, verbose=0)
-        remove_tree(self.target2, verbose=0)
+        remove_tree(self.root_target, verbose=False)
+        remove_tree(self.target2, verbose=False)
 
     def test_copy_tree_skips_nfs_temp_files(self):
-        mkpath(self.target, verbose=0)
+        mkpath(self.target, verbose=False)
 
-        a_file = os.path.join(self.target, 'ok.txt')
-        nfs_file = os.path.join(self.target, '.nfs123abc')
-        for f in a_file, nfs_file:
-            with open(f, 'w') as fh:
-                fh.write('some content')
+        jaraco.path.build({'ok.txt': 'some content', '.nfs123abc': ''}, self.target)
 
         copy_tree(self.target, self.target2)
         assert os.listdir(self.target2) == ['ok.txt']
 
-        remove_tree(self.root_target, verbose=0)
-        remove_tree(self.target2, verbose=0)
+        remove_tree(self.root_target, verbose=False)
+        remove_tree(self.target2, verbose=False)
 
     def test_ensure_relative(self):
         if os.sep == '/':
diff --git a/distutils/tests/test_dist.py b/distutils/tests/test_dist.py
index 30a6f9ff..5bd206fe 100644
--- a/distutils/tests/test_dist.py
+++ b/distutils/tests/test_dist.py
@@ -1,20 +1,21 @@
 """Tests for distutils.dist."""
-import os
+
+import email
+import email.generator
+import email.policy
+import functools
 import io
+import os
 import sys
-import warnings
 import textwrap
-import functools
 import unittest.mock as mock
-
-import pytest
-import jaraco.path
-
-from distutils.dist import Distribution, fix_help_options
+import warnings
 from distutils.cmd import Command
-
+from distutils.dist import Distribution, fix_help_options
 from distutils.tests import support
 
+import jaraco.path
+import pytest
 
 pydistutils_cfg = '.' * (os.name == 'posix') + 'pydistutils.cfg'
 
@@ -66,14 +67,12 @@ def test_command_packages_unspecified(self, clear_argv):
     def test_command_packages_cmdline(self, clear_argv):
         from distutils.tests.test_dist import test_dist
 
-        sys.argv.extend(
-            [
-                "--command-packages",
-                "foo.bar,distutils.tests",
-                "test_dist",
-                "-Ssometext",
-            ]
-        )
+        sys.argv.extend([
+            "--command-packages",
+            "foo.bar,distutils.tests",
+            "test_dist",
+            "-Ssometext",
+        ])
         d = self.create_distribution()
         # let's actually try to load our test command:
         assert d.get_command_packages() == [
@@ -95,9 +94,8 @@ def test_venv_install_options(self, tmp_path):
 
         fakepath = '/somedir'
 
-        jaraco.path.build(
-            {
-                file: f"""
+        jaraco.path.build({
+            file: f"""
                     [install]
                     install-base = {fakepath}
                     install-platbase = {fakepath}
@@ -113,8 +111,7 @@ def test_venv_install_options(self, tmp_path):
                     user = {fakepath}
                     root = {fakepath}
                     """,
-            }
-        )
+        })
 
         # Base case: Not in a Virtual Environment
         with mock.patch.multiple(sys, prefix='/a', base_prefix='/a'):
@@ -155,14 +152,12 @@ def test_venv_install_options(self, tmp_path):
     def test_command_packages_configfile(self, tmp_path, clear_argv):
         sys.argv.append("build")
         file = str(tmp_path / "file")
-        jaraco.path.build(
-            {
-                file: """
+        jaraco.path.build({
+            file: """
                     [global]
                     command_packages = foo.bar, splat
                     """,
-            }
-        )
+        })
 
         d = self.create_distribution([file])
         assert d.get_command_packages() == ["distutils.command", "foo.bar", "splat"]
@@ -259,7 +254,7 @@ def test_find_config_files_permission_error(self, fake_home):
         """
         Finding config files should not fail when directory is inaccessible.
         """
-        fake_home.joinpath(pydistutils_cfg).write_text('')
+        fake_home.joinpath(pydistutils_cfg).write_text('', encoding='utf-8')
         fake_home.chmod(0o000)
         Distribution().find_config_files()
 
@@ -473,7 +468,7 @@ def test_show_help(self, request, capsys):
         # smoke test, just makes sure some help is displayed
         dist = Distribution()
         sys.argv = []
-        dist.help = 1
+        dist.help = True
         dist.script_name = 'setup.py'
         dist.parse_command_line()
 
@@ -510,3 +505,41 @@ def test_read_metadata(self):
         assert metadata.platforms is None
         assert metadata.obsoletes is None
         assert metadata.requires == ['foo']
+
+    def test_round_trip_through_email_generator(self):
+        """
+        In pypa/setuptools#4033, it was shown that once PKG-INFO is
+        re-generated using ``email.generator.Generator``, some control
+        characters might cause problems.
+        """
+        # Given a PKG-INFO file ...
+        attrs = {
+            "name": "package",
+            "version": "1.0",
+            "long_description": "hello\x0b\nworld\n",
+        }
+        dist = Distribution(attrs)
+        metadata = dist.metadata
+
+        with io.StringIO() as buffer:
+            metadata.write_pkg_file(buffer)
+            msg = buffer.getvalue()
+
+        # ... when it is read and re-written using stdlib's email library,
+        orig = email.message_from_string(msg)
+        policy = email.policy.EmailPolicy(
+            utf8=True,
+            mangle_from_=False,
+            max_line_length=0,
+        )
+        with io.StringIO() as buffer:
+            email.generator.Generator(buffer, policy=policy).flatten(orig)
+
+            buffer.seek(0)
+            regen = email.message_from_file(buffer)
+
+        # ... then it should be the same as the original
+        # (except for the specific line break characters)
+        orig_desc = set(orig["Description"].splitlines())
+        regen_desc = set(regen["Description"].splitlines())
+        assert regen_desc == orig_desc
diff --git a/distutils/tests/test_extension.py b/distutils/tests/test_extension.py
index f86af073..527a1355 100644
--- a/distutils/tests/test_extension.py
+++ b/distutils/tests/test_extension.py
@@ -1,12 +1,13 @@
 """Tests for distutils.extension."""
+
 import os
 import warnings
+from distutils.extension import Extension, read_setup_file
 
-from distutils.extension import read_setup_file, Extension
-
-from .py38compat import check_warnings
 import pytest
 
+from .compat.py38 import check_warnings
+
 
 class TestExtension:
     def test_read_setup_file(self):
diff --git a/distutils/tests/test_file_util.py b/distutils/tests/test_file_util.py
index 9f44f91d..85ac2136 100644
--- a/distutils/tests/test_file_util.py
+++ b/distutils/tests/test_file_util.py
@@ -1,50 +1,45 @@
 """Tests for distutils.file_util."""
-import os
+
 import errno
+import os
 import unittest.mock as mock
-
-from distutils.file_util import move_file, copy_file
-from distutils.tests import support
 from distutils.errors import DistutilsFileError
-from .py38compat import unlink
+from distutils.file_util import copy_file, move_file
+
+import jaraco.path
 import pytest
 
 
 @pytest.fixture(autouse=True)
-def stuff(request, monkeypatch, distutils_managed_tempdir):
+def stuff(request, tmp_path):
     self = request.instance
-    tmp_dir = self.mkdtemp()
-    self.source = os.path.join(tmp_dir, 'f1')
-    self.target = os.path.join(tmp_dir, 'f2')
-    self.target_dir = os.path.join(tmp_dir, 'd1')
+    self.source = tmp_path / 'f1'
+    self.target = tmp_path / 'f2'
+    self.target_dir = tmp_path / 'd1'
 
 
-class TestFileUtil(support.TempdirManager):
+class TestFileUtil:
     def test_move_file_verbosity(self, caplog):
-        f = open(self.source, 'w')
-        try:
-            f.write('some content')
-        finally:
-            f.close()
+        jaraco.path.build({self.source: 'some content'})
 
-        move_file(self.source, self.target, verbose=0)
+        move_file(self.source, self.target, verbose=False)
         assert not caplog.messages
 
         # back to original state
-        move_file(self.target, self.source, verbose=0)
+        move_file(self.target, self.source, verbose=False)
 
-        move_file(self.source, self.target, verbose=1)
-        wanted = ['moving {} -> {}'.format(self.source, self.target)]
+        move_file(self.source, self.target, verbose=True)
+        wanted = [f'moving {self.source} -> {self.target}']
         assert caplog.messages == wanted
 
         # back to original state
-        move_file(self.target, self.source, verbose=0)
+        move_file(self.target, self.source, verbose=False)
 
         caplog.clear()
         # now the target is a dir
         os.mkdir(self.target_dir)
-        move_file(self.source, self.target_dir, verbose=1)
-        wanted = ['moving {} -> {}'.format(self.source, self.target_dir)]
+        move_file(self.source, self.target_dir, verbose=True)
+        wanted = [f'moving {self.source} -> {self.target_dir}']
         assert caplog.messages == wanted
 
     def test_move_file_exception_unpacking_rename(self):
@@ -52,9 +47,8 @@ def test_move_file_exception_unpacking_rename(self):
         with mock.patch("os.rename", side_effect=OSError("wrong", 1)), pytest.raises(
             DistutilsFileError
         ):
-            with open(self.source, 'w') as fobj:
-                fobj.write('spam eggs')
-            move_file(self.source, self.target, verbose=0)
+            jaraco.path.build({self.source: 'spam eggs'})
+            move_file(self.source, self.target, verbose=False)
 
     def test_move_file_exception_unpacking_unlink(self):
         # see issue 22182
@@ -63,36 +57,32 @@ def test_move_file_exception_unpacking_unlink(self):
         ), mock.patch("os.unlink", side_effect=OSError("wrong", 1)), pytest.raises(
             DistutilsFileError
         ):
-            with open(self.source, 'w') as fobj:
-                fobj.write('spam eggs')
-            move_file(self.source, self.target, verbose=0)
+            jaraco.path.build({self.source: 'spam eggs'})
+            move_file(self.source, self.target, verbose=False)
 
     def test_copy_file_hard_link(self):
-        with open(self.source, 'w') as f:
-            f.write('some content')
+        jaraco.path.build({self.source: 'some content'})
         # Check first that copy_file() will not fall back on copying the file
         # instead of creating the hard link.
         try:
             os.link(self.source, self.target)
         except OSError as e:
-            self.skipTest('os.link: %s' % e)
+            self.skipTest(f'os.link: {e}')
         else:
-            unlink(self.target)
+            self.target.unlink()
         st = os.stat(self.source)
         copy_file(self.source, self.target, link='hard')
         st2 = os.stat(self.source)
         st3 = os.stat(self.target)
         assert os.path.samestat(st, st2), (st, st2)
         assert os.path.samestat(st2, st3), (st2, st3)
-        with open(self.source) as f:
-            assert f.read() == 'some content'
+        assert self.source.read_text(encoding='utf-8') == 'some content'
 
     def test_copy_file_hard_link_failure(self):
         # If hard linking fails, copy_file() falls back on copying file
         # (some special filesystems don't support hard linking even under
         #  Unix, see issue #8876).
-        with open(self.source, 'w') as f:
-            f.write('some content')
+        jaraco.path.build({self.source: 'some content'})
         st = os.stat(self.source)
         with mock.patch("os.link", side_effect=OSError(0, "linking unsupported")):
             copy_file(self.source, self.target, link='hard')
@@ -101,5 +91,4 @@ def test_copy_file_hard_link_failure(self):
         assert os.path.samestat(st, st2), (st, st2)
         assert not os.path.samestat(st2, st3), (st2, st3)
         for fn in (self.source, self.target):
-            with open(fn) as f:
-                assert f.read() == 'some content'
+            assert fn.read_text(encoding='utf-8') == 'some content'
diff --git a/distutils/tests/test_filelist.py b/distutils/tests/test_filelist.py
index 2cee42cd..ec7e5cf3 100644
--- a/distutils/tests/test_filelist.py
+++ b/distutils/tests/test_filelist.py
@@ -1,18 +1,16 @@
 """Tests for distutils.filelist."""
+
+import logging
 import os
 import re
-import logging
-
-from distutils import debug
+from distutils import debug, filelist
 from distutils.errors import DistutilsTemplateError
-from distutils.filelist import glob_to_re, translate_pattern, FileList
-from distutils import filelist
+from distutils.filelist import FileList, glob_to_re, translate_pattern
 
-import pytest
 import jaraco.path
+import pytest
 
-from . import py38compat as os_helper
-
+from .compat import py38 as os_helper
 
 MANIFEST_IN = """\
 include ok
@@ -321,14 +319,18 @@ def test_non_local_discovery(self, tmp_path):
         When findall is called with another path, the full
         path name should be returned.
         """
-        filename = tmp_path / 'file1.txt'
-        filename.write_text('')
-        expected = [str(filename)]
+        jaraco.path.build({'file1.txt': ''}, tmp_path)
+        expected = [str(tmp_path / 'file1.txt')]
         assert filelist.findall(tmp_path) == expected
 
     @os_helper.skip_unless_symlink
     def test_symlink_loop(self, tmp_path):
-        tmp_path.joinpath('link-to-parent').symlink_to('.')
-        tmp_path.joinpath('somefile').write_text('')
+        jaraco.path.build(
+            {
+                'link-to-parent': jaraco.path.Symlink('.'),
+                'somefile': '',
+            },
+            tmp_path,
+        )
         files = filelist.findall(tmp_path)
         assert len(files) == 1
diff --git a/distutils/tests/test_install.py b/distutils/tests/test_install.py
index 3f525db4..b3ffb2e6 100644
--- a/distutils/tests/test_install.py
+++ b/distutils/tests/test_install.py
@@ -1,24 +1,21 @@
 """Tests for distutils.command.install."""
 
+import logging
 import os
-import sys
-import site
 import pathlib
-import logging
-
-import pytest
-
+import site
+import sys
 from distutils import sysconfig
-from distutils.command.install import install
 from distutils.command import install as install_module
 from distutils.command.build_ext import build_ext
-from distutils.command.install import INSTALL_SCHEMES
+from distutils.command.install import INSTALL_SCHEMES, install
 from distutils.core import Distribution
 from distutils.errors import DistutilsOptionError
 from distutils.extension import Extension
+from distutils.tests import missing_compiler_executable, support
+from distutils.util import is_mingw
 
-from distutils.tests import support
-from test import support as test_support
+import pytest
 
 
 def _make_ext_name(modname):
@@ -104,7 +101,7 @@ def _expanduser(path):
         assert 'user' in options
 
         # setting a value
-        cmd.user = 1
+        cmd.user = True
 
         # user base and site shouldn't be created yet
         assert not os.path.exists(site.USER_BASE)
@@ -121,7 +118,7 @@ def _expanduser(path):
         assert 'usersite' in cmd.config_vars
 
         actual_headers = os.path.relpath(cmd.install_headers, site.USER_BASE)
-        if os.name == 'nt':
+        if os.name == 'nt' and not is_mingw():
             site_path = os.path.relpath(os.path.dirname(orig_site), orig_base)
             include = os.path.join(site_path, 'Include')
         else:
@@ -197,25 +194,21 @@ def test_record(self):
         cmd.ensure_finalized()
         cmd.run()
 
-        f = open(cmd.record)
-        try:
-            content = f.read()
-        finally:
-            f.close()
+        content = pathlib.Path(cmd.record).read_text(encoding='utf-8')
 
-        found = [os.path.basename(line) for line in content.splitlines()]
+        found = [pathlib.Path(line).name for line in content.splitlines()]
         expected = [
             'hello.py',
-            'hello.%s.pyc' % sys.implementation.cache_tag,
+            f'hello.{sys.implementation.cache_tag}.pyc',
             'sayhi',
-            'UNKNOWN-0.0.0-py%s.%s.egg-info' % sys.version_info[:2],
+            'UNKNOWN-0.0.0-py{}.{}.egg-info'.format(*sys.version_info[:2]),
         ]
         assert found == expected
 
     def test_record_extensions(self):
-        cmd = test_support.missing_compiler_executable()
+        cmd = missing_compiler_executable()
         if cmd is not None:
-            pytest.skip('The %r command is not found' % cmd)
+            pytest.skip(f'The {cmd!r} command is not found')
         install_dir = self.mkdtemp()
         project_dir, dist = self.create_dist(
             ext_modules=[Extension('xx', ['xxmodule.c'])]
@@ -235,12 +228,12 @@ def test_record_extensions(self):
         cmd.ensure_finalized()
         cmd.run()
 
-        content = pathlib.Path(cmd.record).read_text()
+        content = pathlib.Path(cmd.record).read_text(encoding='utf-8')
 
-        found = [os.path.basename(line) for line in content.splitlines()]
+        found = [pathlib.Path(line).name for line in content.splitlines()]
         expected = [
             _make_ext_name('xx'),
-            'UNKNOWN-0.0.0-py%s.%s.egg-info' % sys.version_info[:2],
+            'UNKNOWN-0.0.0-py{}.{}.egg-info'.format(*sys.version_info[:2]),
         ]
         assert found == expected
 
diff --git a/distutils/tests/test_install_data.py b/distutils/tests/test_install_data.py
index 9badbc26..f34070b1 100644
--- a/distutils/tests/test_install_data.py
+++ b/distutils/tests/test_install_data.py
@@ -1,11 +1,11 @@
 """Tests for distutils.command.install_data."""
-import os
-
-import pytest
 
+import os
 from distutils.command.install_data import install_data
 from distutils.tests import support
 
+import pytest
+
 
 @pytest.mark.usefixtures('save_env')
 class TestInstallData(
@@ -41,7 +41,7 @@ def test_simple_run(self):
         cmd.outfiles = []
 
         # let's try with warn_dir one
-        cmd.warn_dir = 1
+        cmd.warn_dir = True
         cmd.ensure_finalized()
         cmd.run()
 
diff --git a/distutils/tests/test_install_headers.py b/distutils/tests/test_install_headers.py
index 1e8ccf79..2c74f06b 100644
--- a/distutils/tests/test_install_headers.py
+++ b/distutils/tests/test_install_headers.py
@@ -1,11 +1,11 @@
 """Tests for distutils.command.install_headers."""
-import os
-
-import pytest
 
+import os
 from distutils.command.install_headers import install_headers
 from distutils.tests import support
 
+import pytest
+
 
 @pytest.mark.usefixtures('save_env')
 class TestInstallHeaders(
diff --git a/distutils/tests/test_install_lib.py b/distutils/tests/test_install_lib.py
index 0bd67cd0..f685a579 100644
--- a/distutils/tests/test_install_lib.py
+++ b/distutils/tests/test_install_lib.py
@@ -1,14 +1,14 @@
 """Tests for distutils.command.install_data."""
-import sys
-import os
-import importlib.util
-
-import pytest
 
+import importlib.util
+import os
+import sys
 from distutils.command.install_lib import install_lib
+from distutils.errors import DistutilsOptionError
 from distutils.extension import Extension
 from distutils.tests import support
-from distutils.errors import DistutilsOptionError
+
+import pytest
 
 
 @support.combine_markers
@@ -97,7 +97,7 @@ def test_dont_write_bytecode(self, caplog):
         # makes sure byte_compile is not used
         dist = self.create_dist()[1]
         cmd = install_lib(dist)
-        cmd.compile = 1
+        cmd.compile = True
         cmd.optimize = 1
 
         old_dont_write_bytecode = sys.dont_write_bytecode
diff --git a/distutils/tests/test_install_scripts.py b/distutils/tests/test_install_scripts.py
index 58313f28..868b1c22 100644
--- a/distutils/tests/test_install_scripts.py
+++ b/distutils/tests/test_install_scripts.py
@@ -1,12 +1,12 @@
 """Tests for distutils.command.install_scripts."""
 
 import os
-
 from distutils.command.install_scripts import install_scripts
 from distutils.core import Distribution
-
 from distutils.tests import support
 
+from . import test_build_scripts
+
 
 class TestInstallScripts(support.TempdirManager):
     def test_default_settings(self):
@@ -14,8 +14,8 @@ def test_default_settings(self):
         dist.command_obj["build"] = support.DummyCommand(build_scripts="/foo/bar")
         dist.command_obj["install"] = support.DummyCommand(
             install_scripts="/splat/funk",
-            force=1,
-            skip_build=1,
+            force=True,
+            skip_build=True,
         )
         cmd = install_scripts(dist)
         assert not cmd.force
@@ -32,39 +32,16 @@ def test_default_settings(self):
 
     def test_installation(self):
         source = self.mkdtemp()
-        expected = []
-
-        def write_script(name, text):
-            expected.append(name)
-            f = open(os.path.join(source, name), "w")
-            try:
-                f.write(text)
-            finally:
-                f.close()
 
-        write_script(
-            "script1.py",
-            (
-                "#! /usr/bin/env python2.3\n"
-                "# bogus script w/ Python sh-bang\n"
-                "pass\n"
-            ),
-        )
-        write_script(
-            "script2.py",
-            ("#!/usr/bin/python\n" "# bogus script w/ Python sh-bang\n" "pass\n"),
-        )
-        write_script(
-            "shell.sh", ("#!/bin/sh\n" "# bogus shell script w/ sh-bang\n" "exit 0\n")
-        )
+        expected = test_build_scripts.TestBuildScripts.write_sample_scripts(source)
 
         target = self.mkdtemp()
         dist = Distribution()
         dist.command_obj["build"] = support.DummyCommand(build_scripts=source)
         dist.command_obj["install"] = support.DummyCommand(
             install_scripts=target,
-            force=1,
-            skip_build=1,
+            force=True,
+            skip_build=True,
         )
         cmd = install_scripts(dist)
         cmd.finalize_options()
diff --git a/distutils/tests/test_log.py b/distutils/tests/test_log.py
index ec6a0c80..d67779fc 100644
--- a/distutils/tests/test_log.py
+++ b/distutils/tests/test_log.py
@@ -1,7 +1,6 @@
 """Tests for distutils.log"""
 
 import logging
-
 from distutils._log import log
 
 
diff --git a/distutils/tests/test_mingwccompiler.py b/distutils/tests/test_mingwccompiler.py
new file mode 100644
index 00000000..d81360e7
--- /dev/null
+++ b/distutils/tests/test_mingwccompiler.py
@@ -0,0 +1,45 @@
+import pytest
+
+from distutils.util import split_quoted, is_mingw
+from distutils.errors import DistutilsPlatformError, CCompilerError
+
+
+class TestMingw32CCompiler:
+    @pytest.mark.skipif(not is_mingw(), reason='not on mingw')
+    def test_compiler_type(self):
+        from distutils.cygwinccompiler import Mingw32CCompiler
+
+        compiler = Mingw32CCompiler()
+        assert compiler.compiler_type == 'mingw32'
+
+    @pytest.mark.skipif(not is_mingw(), reason='not on mingw')
+    def test_set_executables(self, monkeypatch):
+        from distutils.cygwinccompiler import Mingw32CCompiler
+
+        monkeypatch.setenv('CC', 'cc')
+        monkeypatch.setenv('CXX', 'c++')
+
+        compiler = Mingw32CCompiler()
+
+        assert compiler.compiler == split_quoted('cc -O -Wall')
+        assert compiler.compiler_so == split_quoted('cc -shared -O -Wall')
+        assert compiler.compiler_cxx == split_quoted('c++ -O -Wall')
+        assert compiler.linker_exe == split_quoted('cc')
+        assert compiler.linker_so == split_quoted('cc -shared')
+
+    @pytest.mark.skipif(not is_mingw(), reason='not on mingw')
+    def test_runtime_library_dir_option(self):
+        from distutils.cygwinccompiler import Mingw32CCompiler
+
+        compiler = Mingw32CCompiler()
+        with pytest.raises(DistutilsPlatformError):
+            compiler.runtime_library_dir_option('/usr/lib')
+
+    @pytest.mark.skipif(not is_mingw(), reason='not on mingw')
+    def test_cygwincc_error(self, monkeypatch):
+        import distutils.cygwinccompiler
+
+        monkeypatch.setattr(distutils.cygwinccompiler, 'is_cygwincc', lambda _: True)
+
+        with pytest.raises(CCompilerError):
+            distutils.cygwinccompiler.Mingw32CCompiler()
diff --git a/distutils/tests/test_dep_util.py b/distutils/tests/test_modified.py
similarity index 59%
rename from distutils/tests/test_dep_util.py
rename to distutils/tests/test_modified.py
index e5dcad94..2bd82346 100644
--- a/distutils/tests/test_dep_util.py
+++ b/distutils/tests/test_modified.py
@@ -1,9 +1,11 @@
-"""Tests for distutils.dep_util."""
-import os
+"""Tests for distutils._modified."""
 
-from distutils.dep_util import newer, newer_pairwise, newer_group
+import os
+import types
+from distutils._modified import newer, newer_group, newer_pairwise, newer_pairwise_group
 from distutils.errors import DistutilsFileError
 from distutils.tests import support
+
 import pytest
 
 
@@ -27,7 +29,7 @@ def test_newer(self):
         # than 'new_file'.
         assert not newer(old_file, new_file)
 
-    def test_newer_pairwise(self):
+    def _setup_1234(self):
         tmpdir = self.mkdtemp()
         sources = os.path.join(tmpdir, 'sources')
         targets = os.path.join(tmpdir, 'targets')
@@ -40,9 +42,30 @@ def test_newer_pairwise(self):
         self.write_file(one)
         self.write_file(two)
         self.write_file(four)
+        return one, two, three, four
+
+    def test_newer_pairwise(self):
+        one, two, three, four = self._setup_1234()
 
         assert newer_pairwise([one, two], [three, four]) == ([one], [three])
 
+    def test_newer_pairwise_mismatch(self):
+        one, two, three, four = self._setup_1234()
+
+        with pytest.raises(ValueError):
+            newer_pairwise([one], [three, four])
+
+        with pytest.raises(ValueError):
+            newer_pairwise([one, two], [three])
+
+    def test_newer_pairwise_empty(self):
+        assert newer_pairwise([], []) == ([], [])
+
+    def test_newer_pairwise_fresh(self):
+        one, two, three, four = self._setup_1234()
+
+        assert newer_pairwise([one, three], [two, four]) == ([], [])
+
     def test_newer_group(self):
         tmpdir = self.mkdtemp()
         sources = os.path.join(tmpdir, 'sources')
@@ -68,3 +91,29 @@ def test_newer_group(self):
         assert not newer_group([one, two, old_file], three, missing='ignore')
 
         assert newer_group([one, two, old_file], three, missing='newer')
+
+
+@pytest.fixture
+def groups_target(tmp_path):
+    """
+    Set up some older sources, a target, and newer sources.
+
+    Returns a simple namespace with these values.
+    """
+    filenames = ['older.c', 'older.h', 'target.o', 'newer.c', 'newer.h']
+    paths = [tmp_path / name for name in filenames]
+
+    for mtime, path in enumerate(paths):
+        path.write_text('', encoding='utf-8')
+
+        # make sure modification times are sequential
+        os.utime(path, (mtime, mtime))
+
+    return types.SimpleNamespace(older=paths[:2], target=paths[2], newer=paths[3:])
+
+
+def test_newer_pairwise_group(groups_target):
+    older = newer_pairwise_group([groups_target.older], [groups_target.target])
+    newer = newer_pairwise_group([groups_target.newer], [groups_target.target])
+    assert older == ([], [])
+    assert newer == ([groups_target.newer], [groups_target.target])
diff --git a/distutils/tests/test_msvc9compiler.py b/distutils/tests/test_msvc9compiler.py
index fe5693e1..6f6aabee 100644
--- a/distutils/tests/test_msvc9compiler.py
+++ b/distutils/tests/test_msvc9compiler.py
@@ -1,9 +1,10 @@
 """Tests for distutils.msvc9compiler."""
-import sys
-import os
 
+import os
+import sys
 from distutils.errors import DistutilsPlatformError
 from distutils.tests import support
+
 import pytest
 
 # A manifest with the only assembly reference being the msvcrt assembly, so
@@ -160,7 +161,7 @@ def test_remove_visual_c_ref(self):
         f = open(manifest)
         try:
             # removing trailing spaces
-            content = '\n'.join([line.rstrip() for line in f.readlines()])
+            content = '\n'.join([line.rstrip() for line in f])
         finally:
             f.close()
 
diff --git a/distutils/tests/test_msvccompiler.py b/distutils/tests/test_msvccompiler.py
index f63537b8..23b6c732 100644
--- a/distutils/tests/test_msvccompiler.py
+++ b/distutils/tests/test_msvccompiler.py
@@ -1,15 +1,14 @@
 """Tests for distutils._msvccompiler."""
-import sys
+
 import os
+import sys
 import threading
 import unittest.mock as mock
-
-import pytest
-
+from distutils import _msvccompiler
 from distutils.errors import DistutilsPlatformError
 from distutils.tests import support
-from distutils import _msvccompiler
 
+import pytest
 
 needs_winreg = pytest.mark.skipif('not hasattr(_msvccompiler, "winreg")')
 
diff --git a/distutils/tests/test_register.py b/distutils/tests/test_register.py
index 34e59324..14dfb832 100644
--- a/distutils/tests/test_register.py
+++ b/distutils/tests/test_register.py
@@ -1,13 +1,14 @@
 """Tests for distutils.command.register."""
-import os
+
 import getpass
+import os
+import pathlib
 import urllib
-
 from distutils.command import register as register_module
 from distutils.command.register import register
 from distutils.errors import DistutilsSetupError
-
 from distutils.tests.test_config import BasePyPIRCCommandTestCase
+
 import pytest
 
 try:
@@ -125,16 +126,8 @@ def test_create_pypirc(self):
         finally:
             del register_module.input
 
-        # we should have a brand new .pypirc file
-        assert os.path.exists(self.rc)
-
-        # with the content similar to WANTED_PYPIRC
-        f = open(self.rc)
-        try:
-            content = f.read()
-            assert content == WANTED_PYPIRC
-        finally:
-            f.close()
+        # A new .pypirc file should contain WANTED_PYPIRC
+        assert pathlib.Path(self.rc).read_text(encoding='utf-8') == WANTED_PYPIRC
 
         # now let's make sure the .pypirc file generated
         # really works : we shouldn't be asked anything
@@ -144,7 +137,7 @@ def _no_way(prompt=''):
 
         register_module.input = _no_way
 
-        cmd.show_response = 1
+        cmd.show_response = True
         cmd.run()
 
         # let's see what the server received : we should
@@ -215,7 +208,7 @@ def test_strict(self):
         # empty metadata
         cmd = self._get_cmd({})
         cmd.ensure_finalized()
-        cmd.strict = 1
+        cmd.strict = True
         with pytest.raises(DistutilsSetupError):
             cmd.run()
 
@@ -231,7 +224,7 @@ def test_strict(self):
 
         cmd = self._get_cmd(metadata)
         cmd.ensure_finalized()
-        cmd.strict = 1
+        cmd.strict = True
         with pytest.raises(DistutilsSetupError):
             cmd.run()
 
@@ -239,7 +232,7 @@ def test_strict(self):
         metadata['long_description'] = 'title\n=====\n\ntext'
         cmd = self._get_cmd(metadata)
         cmd.ensure_finalized()
-        cmd.strict = 1
+        cmd.strict = True
         inputs = Inputs('1', 'tarek', 'y')
         register_module.input = inputs.__call__
         # let's run the command
@@ -272,7 +265,7 @@ def test_strict(self):
 
         cmd = self._get_cmd(metadata)
         cmd.ensure_finalized()
-        cmd.strict = 1
+        cmd.strict = True
         inputs = Inputs('1', 'tarek', 'y')
         register_module.input = inputs.__call__
         # let's run the command
@@ -303,7 +296,7 @@ def test_register_invalid_long_description(self, monkeypatch):
 
     def test_list_classifiers(self, caplog):
         cmd = self._get_cmd()
-        cmd.list_classifiers = 1
+        cmd.list_classifiers = True
         cmd.run()
         assert caplog.messages == ['running check', 'xxx']
 
@@ -312,7 +305,7 @@ def test_show_response(self, caplog):
         cmd = self._get_cmd()
         inputs = Inputs('1', 'tarek', 'y')
         register_module.input = inputs.__call__
-        cmd.show_response = 1
+        cmd.show_response = True
         try:
             cmd.run()
         finally:
diff --git a/distutils/tests/test_sdist.py b/distutils/tests/test_sdist.py
index fdb768e7..7daaaa63 100644
--- a/distutils/tests/test_sdist.py
+++ b/distutils/tests/test_sdist.py
@@ -1,25 +1,27 @@
 """Tests for distutils.command.sdist."""
+
 import os
+import pathlib
+import shutil  # noqa: F401
 import tarfile
 import warnings
 import zipfile
+from distutils.archive_util import ARCHIVE_FORMATS
+from distutils.command.sdist import sdist, show_formats
+from distutils.core import Distribution
+from distutils.errors import DistutilsOptionError
+from distutils.filelist import FileList
+from distutils.tests.test_config import BasePyPIRCCommandTestCase
 from os.path import join
 from textwrap import dedent
-from .unix_compat import require_unix_id, require_uid_0, pwd, grp
 
-import pytest
-import path
 import jaraco.path
+import path
+import pytest
+from more_itertools import ilen
 
-from .py38compat import check_warnings
-
-from distutils.command.sdist import sdist, show_formats
-from distutils.core import Distribution
-from distutils.tests.test_config import BasePyPIRCCommandTestCase
-from distutils.errors import DistutilsOptionError
-from distutils.spawn import find_executable  # noqa: F401
-from distutils.filelist import FileList
-from distutils.archive_util import ARCHIVE_FORMATS
+from .compat.py38 import check_warnings
+from .unix_compat import grp, pwd, require_uid_0, require_unix_id
 
 SETUP_PY = """
 from distutils.core import setup
@@ -61,12 +63,17 @@ def project_dir(request, pypirc):
         yield
 
 
+def clean_lines(filepath):
+    with pathlib.Path(filepath).open(encoding='utf-8') as f:
+        yield from filter(None, map(str.strip, f))
+
+
 class TestSDist(BasePyPIRCCommandTestCase):
     def get_cmd(self, metadata=None):
         """Returns a cmd"""
         if metadata is None:
             metadata = {
-                'name': 'fake',
+                'name': 'ns.fake--pkg',
                 'version': '1.0',
                 'url': 'xxx',
                 'author': 'xxx',
@@ -110,9 +117,9 @@ def test_prune_file_list(self):
         # now let's check what we have
         dist_folder = join(self.tmp_dir, 'dist')
         files = os.listdir(dist_folder)
-        assert files == ['fake-1.0.zip']
+        assert files == ['ns_fake_pkg-1.0.zip']
 
-        zip_file = zipfile.ZipFile(join(dist_folder, 'fake-1.0.zip'))
+        zip_file = zipfile.ZipFile(join(dist_folder, 'ns_fake_pkg-1.0.zip'))
         try:
             content = zip_file.namelist()
         finally:
@@ -127,11 +134,11 @@ def test_prune_file_list(self):
             'somecode/',
             'somecode/__init__.py',
         ]
-        assert sorted(content) == ['fake-1.0/' + x for x in expected]
+        assert sorted(content) == ['ns_fake_pkg-1.0/' + x for x in expected]
 
     @pytest.mark.usefixtures('needs_zlib')
-    @pytest.mark.skipif("not find_executable('tar')")
-    @pytest.mark.skipif("not find_executable('gzip')")
+    @pytest.mark.skipif("not shutil.which('tar')")
+    @pytest.mark.skipif("not shutil.which('gzip')")
     def test_make_distribution(self):
         # now building a sdist
         dist, cmd = self.get_cmd()
@@ -145,10 +152,10 @@ def test_make_distribution(self):
         dist_folder = join(self.tmp_dir, 'dist')
         result = os.listdir(dist_folder)
         result.sort()
-        assert result == ['fake-1.0.tar', 'fake-1.0.tar.gz']
+        assert result == ['ns_fake_pkg-1.0.tar', 'ns_fake_pkg-1.0.tar.gz']
 
-        os.remove(join(dist_folder, 'fake-1.0.tar'))
-        os.remove(join(dist_folder, 'fake-1.0.tar.gz'))
+        os.remove(join(dist_folder, 'ns_fake_pkg-1.0.tar'))
+        os.remove(join(dist_folder, 'ns_fake_pkg-1.0.tar.gz'))
 
         # now trying a tar then a gztar
         cmd.formats = ['tar', 'gztar']
@@ -158,11 +165,11 @@ def test_make_distribution(self):
 
         result = os.listdir(dist_folder)
         result.sort()
-        assert result == ['fake-1.0.tar', 'fake-1.0.tar.gz']
+        assert result == ['ns_fake_pkg-1.0.tar', 'ns_fake_pkg-1.0.tar.gz']
 
     @pytest.mark.usefixtures('needs_zlib')
     def test_add_defaults(self):
-        # http://bugs.python.org/issue2279
+        # https://bugs.python.org/issue2279
 
         # add_default should also include
         # data_files and package_data
@@ -211,9 +218,9 @@ def test_add_defaults(self):
         # now let's check what we have
         dist_folder = join(self.tmp_dir, 'dist')
         files = os.listdir(dist_folder)
-        assert files == ['fake-1.0.zip']
+        assert files == ['ns_fake_pkg-1.0.zip']
 
-        zip_file = zipfile.ZipFile(join(dist_folder, 'fake-1.0.zip'))
+        zip_file = zipfile.ZipFile(join(dist_folder, 'ns_fake_pkg-1.0.zip'))
         try:
             content = zip_file.namelist()
         finally:
@@ -239,14 +246,10 @@ def test_add_defaults(self):
             'somecode/doc.dat',
             'somecode/doc.txt',
         ]
-        assert sorted(content) == ['fake-1.0/' + x for x in expected]
+        assert sorted(content) == ['ns_fake_pkg-1.0/' + x for x in expected]
 
         # checking the MANIFEST
-        f = open(join(self.tmp_dir, 'MANIFEST'))
-        try:
-            manifest = f.read()
-        finally:
-            f.close()
+        manifest = pathlib.Path(self.tmp_dir, 'MANIFEST').read_text(encoding='utf-8')
         assert manifest == MANIFEST % {'sep': os.sep}
 
     @staticmethod
@@ -351,15 +354,7 @@ def test_get_file_list(self):
         cmd.ensure_finalized()
         cmd.run()
 
-        f = open(cmd.manifest)
-        try:
-            manifest = [
-                line.strip() for line in f.read().split('\n') if line.strip() != ''
-            ]
-        finally:
-            f.close()
-
-        assert len(manifest) == 5
+        assert ilen(clean_lines(cmd.manifest)) == 5
 
         # adding a file
         self.write_file((self.tmp_dir, 'somecode', 'doc2.txt'), '#')
@@ -371,13 +366,7 @@ def test_get_file_list(self):
 
         cmd.run()
 
-        f = open(cmd.manifest)
-        try:
-            manifest2 = [
-                line.strip() for line in f.read().split('\n') if line.strip() != ''
-            ]
-        finally:
-            f.close()
+        manifest2 = list(clean_lines(cmd.manifest))
 
         # do we have the new file in MANIFEST ?
         assert len(manifest2) == 6
@@ -390,15 +379,10 @@ def test_manifest_marker(self):
         cmd.ensure_finalized()
         cmd.run()
 
-        f = open(cmd.manifest)
-        try:
-            manifest = [
-                line.strip() for line in f.read().split('\n') if line.strip() != ''
-            ]
-        finally:
-            f.close()
-
-        assert manifest[0] == '# file GENERATED by distutils, do NOT edit'
+        assert (
+            next(clean_lines(cmd.manifest))
+            == '# file GENERATED by distutils, do NOT edit'
+        )
 
     @pytest.mark.usefixtures('needs_zlib')
     def test_manifest_comments(self):
@@ -433,33 +417,25 @@ def test_manual_manifest(self):
         cmd.run()
         assert cmd.filelist.files == ['README.manual']
 
-        f = open(cmd.manifest)
-        try:
-            manifest = [
-                line.strip() for line in f.read().split('\n') if line.strip() != ''
-            ]
-        finally:
-            f.close()
-
-        assert manifest == ['README.manual']
+        assert list(clean_lines(cmd.manifest)) == ['README.manual']
 
-        archive_name = join(self.tmp_dir, 'dist', 'fake-1.0.tar.gz')
+        archive_name = join(self.tmp_dir, 'dist', 'ns_fake_pkg-1.0.tar.gz')
         archive = tarfile.open(archive_name)
         try:
             filenames = [tarinfo.name for tarinfo in archive]
         finally:
             archive.close()
         assert sorted(filenames) == [
-            'fake-1.0',
-            'fake-1.0/PKG-INFO',
-            'fake-1.0/README.manual',
+            'ns_fake_pkg-1.0',
+            'ns_fake_pkg-1.0/PKG-INFO',
+            'ns_fake_pkg-1.0/README.manual',
         ]
 
     @pytest.mark.usefixtures('needs_zlib')
     @require_unix_id
     @require_uid_0
-    @pytest.mark.skipif("not find_executable('tar')")
-    @pytest.mark.skipif("not find_executable('gzip')")
+    @pytest.mark.skipif("not shutil.which('tar')")
+    @pytest.mark.skipif("not shutil.which('gzip')")
     def test_make_distribution_owner_group(self):
         # now building a sdist
         dist, cmd = self.get_cmd()
@@ -472,7 +448,7 @@ def test_make_distribution_owner_group(self):
         cmd.run()
 
         # making sure we have the good rights
-        archive_name = join(self.tmp_dir, 'dist', 'fake-1.0.tar.gz')
+        archive_name = join(self.tmp_dir, 'dist', 'ns_fake_pkg-1.0.tar.gz')
         archive = tarfile.open(archive_name)
         try:
             for member in archive.getmembers():
@@ -490,7 +466,7 @@ def test_make_distribution_owner_group(self):
         cmd.run()
 
         # making sure we have the good rights
-        archive_name = join(self.tmp_dir, 'dist', 'fake-1.0.tar.gz')
+        archive_name = join(self.tmp_dir, 'dist', 'ns_fake_pkg-1.0.tar.gz')
         archive = tarfile.open(archive_name)
 
         # note that we are not testing the group ownership here
diff --git a/distutils/tests/test_spawn.py b/distutils/tests/test_spawn.py
index 08a34ee2..2576bdd5 100644
--- a/distutils/tests/test_spawn.py
+++ b/distutils/tests/test_spawn.py
@@ -1,21 +1,19 @@
 """Tests for distutils.spawn."""
+
 import os
 import stat
 import sys
 import unittest.mock as mock
-
+from distutils.errors import DistutilsExecError
+from distutils.spawn import find_executable, spawn
+from distutils.tests import support
 from test.support import unix_shell
 
 import path
-
-from . import py38compat as os_helper
-
-from distutils.spawn import find_executable
-from distutils.spawn import spawn
-from distutils.errors import DistutilsExecError
-from distutils.tests import support
 import pytest
 
+from .compat import py38 as os_helper
+
 
 class TestSpawn(support.TempdirManager):
     @pytest.mark.skipif("os.name not in ('nt', 'posix')")
@@ -26,7 +24,7 @@ def test_spawn(self):
         # through the shell that returns 1
         if sys.platform != 'win32':
             exe = os.path.join(tmpdir, 'foo.sh')
-            self.write_file(exe, '#!%s\nexit 1' % unix_shell)
+            self.write_file(exe, f'#!{unix_shell}\nexit 1')
         else:
             exe = os.path.join(tmpdir, 'foo.bat')
             self.write_file(exe, 'exit 1')
@@ -38,7 +36,7 @@ def test_spawn(self):
         # now something that works
         if sys.platform != 'win32':
             exe = os.path.join(tmpdir, 'foo.sh')
-            self.write_file(exe, '#!%s\nexit 0' % unix_shell)
+            self.write_file(exe, f'#!{unix_shell}\nexit 0')
         else:
             exe = os.path.join(tmpdir, 'foo.bat')
             self.write_file(exe, 'exit 0')
@@ -47,14 +45,9 @@ def test_spawn(self):
         spawn([exe])  # should work without any error
 
     def test_find_executable(self, tmp_path):
-        program_noeext = 'program'
-        # Give the temporary program an ".exe" suffix for all.
-        # It's needed on Windows and not harmful on other platforms.
-        program = program_noeext + ".exe"
-
-        program_path = tmp_path / program
-        program_path.write_text("")
-        program_path.chmod(stat.S_IXUSR)
+        program_path = self._make_executable(tmp_path, '.exe')
+        program = program_path.name
+        program_noeext = program_path.with_suffix('').name
         filename = str(program_path)
         tmp_dir = path.Path(tmp_path)
 
@@ -123,6 +116,15 @@ def test_find_executable(self, tmp_path):
                 rv = find_executable(program)
                 assert rv == filename
 
+    @staticmethod
+    def _make_executable(tmp_path, ext):
+        # Give the temporary program a suffix regardless of platform.
+        # It's needed on Windows and not harmful on others.
+        program = tmp_path.joinpath('program').with_suffix(ext)
+        program.write_text("", encoding='utf-8')
+        program.chmod(stat.S_IXUSR)
+        return program
+
     def test_spawn_missing_exe(self):
         with pytest.raises(DistutilsExecError) as ctx:
             spawn(['does-not-exist'])
diff --git a/distutils/tests/test_sysconfig.py b/distutils/tests/test_sysconfig.py
index bfeaf9a6..889a398c 100644
--- a/distutils/tests/test_sysconfig.py
+++ b/distutils/tests/test_sysconfig.py
@@ -1,22 +1,25 @@
 """Tests for distutils.sysconfig."""
+
 import contextlib
+import distutils
 import os
+import pathlib
 import subprocess
 import sys
-import pathlib
+from distutils import sysconfig
+from distutils.ccompiler import new_compiler  # noqa: F401
+from distutils.unixccompiler import UnixCCompiler
+from test.support import swap_item
 
-import pytest
 import jaraco.envs
 import path
+import pytest
 from jaraco.text import trim
 
-import distutils
-from distutils import sysconfig
-from distutils.ccompiler import get_default_compiler  # noqa: F401
-from distutils.unixccompiler import UnixCCompiler
-from test.support import swap_item
 
-from . import py37compat
+def _gen_makefile(root, contents):
+    jaraco.path.build({'Makefile': trim(contents)}, root)
+    return root / 'Makefile'
 
 
 @pytest.mark.usefixtures('save_env')
@@ -97,8 +100,6 @@ def set_executables(self, **kw):
             'CCSHARED': '--sc-ccshared',
             'LDSHARED': 'sc_ldshared',
             'SHLIB_SUFFIX': 'sc_shutil_suffix',
-            # On macOS, disable _osx_support.customize_compiler()
-            'CUSTOMIZED_OSX_COMPILER': 'True',
         }
 
         comp = compiler()
@@ -109,7 +110,8 @@ def set_executables(self, **kw):
 
         return comp
 
-    @pytest.mark.skipif("get_default_compiler() != 'unix'")
+    @pytest.mark.skipif("not isinstance(new_compiler(), UnixCCompiler)")
+    @pytest.mark.usefixtures('disable_macos_customization')
     def test_customize_compiler(self):
         # Make sure that sysconfig._config_vars is initialized
         sysconfig.get_config_vars()
@@ -130,12 +132,12 @@ def test_customize_compiler(self):
         assert comp.exes['preprocessor'] == 'env_cpp --env-cppflags'
         assert comp.exes['compiler'] == 'env_cc --sc-cflags --env-cflags --env-cppflags'
         assert comp.exes['compiler_so'] == (
-            'env_cc --sc-cflags ' '--env-cflags ' '--env-cppflags --sc-ccshared'
+            'env_cc --sc-cflags --env-cflags --env-cppflags --sc-ccshared'
         )
         assert comp.exes['compiler_cxx'] == 'env_cxx --env-cxx-flags'
         assert comp.exes['linker_exe'] == 'env_cc'
         assert comp.exes['linker_so'] == (
-            'env_ldshared --env-ldflags --env-cflags' ' --env-cppflags'
+            'env_ldshared --env-ldflags --env-cflags --env-cppflags'
         )
         assert comp.shared_lib_extension == 'sc_shutil_suffix'
 
@@ -167,29 +169,25 @@ def test_customize_compiler(self):
         assert 'ranlib' not in comp.exes
 
     def test_parse_makefile_base(self, tmp_path):
-        makefile = tmp_path / 'Makefile'
-        makefile.write_text(
-            trim(
-                """
-                CONFIG_ARGS=  '--arg1=optarg1' 'ENV=LIB'
-                VAR=$OTHER
-                OTHER=foo
-                """
-            )
+        makefile = _gen_makefile(
+            tmp_path,
+            """
+            CONFIG_ARGS=  '--arg1=optarg1' 'ENV=LIB'
+            VAR=$OTHER
+            OTHER=foo
+            """,
         )
         d = sysconfig.parse_makefile(makefile)
         assert d == {'CONFIG_ARGS': "'--arg1=optarg1' 'ENV=LIB'", 'OTHER': 'foo'}
 
     def test_parse_makefile_literal_dollar(self, tmp_path):
-        makefile = tmp_path / 'Makefile'
-        makefile.write_text(
-            trim(
-                """
-                CONFIG_ARGS=  '--arg1=optarg1' 'ENV=\\$$LIB'
-                VAR=$OTHER
-                OTHER=foo
-                """
-            )
+        makefile = _gen_makefile(
+            tmp_path,
+            """
+            CONFIG_ARGS=  '--arg1=optarg1' 'ENV=\\$$LIB'
+            VAR=$OTHER
+            OTHER=foo
+            """,
         )
         d = sysconfig.parse_makefile(makefile)
         assert d == {'CONFIG_ARGS': r"'--arg1=optarg1' 'ENV=\$LIB'", 'OTHER': 'foo'}
@@ -204,22 +202,21 @@ def test_sysconfig_module(self):
             'LDFLAGS'
         )
 
+    # On macOS, binary installers support extension module building on
+    # various levels of the operating system with differing Xcode
+    # configurations, requiring customization of some of the
+    # compiler configuration directives to suit the environment on
+    # the installed machine. Some of these customizations may require
+    # running external programs and are thus deferred until needed by
+    # the first extension module build. Only
+    # the Distutils version of sysconfig is used for extension module
+    # builds, which happens earlier in the Distutils tests. This may
+    # cause the following tests to fail since no tests have caused
+    # the global version of sysconfig to call the customization yet.
+    # The solution for now is to simply skip this test in this case.
+    # The longer-term solution is to only have one version of sysconfig.
     @pytest.mark.skipif("sysconfig.get_config_var('CUSTOMIZED_OSX_COMPILER')")
     def test_sysconfig_compiler_vars(self):
-        # On OS X, binary installers support extension module building on
-        # various levels of the operating system with differing Xcode
-        # configurations.  This requires customization of some of the
-        # compiler configuration directives to suit the environment on
-        # the installed machine.  Some of these customizations may require
-        # running external programs and, so, are deferred until needed by
-        # the first extension module build.  With Python 3.3, only
-        # the Distutils version of sysconfig is used for extension module
-        # builds, which happens earlier in the Distutils tests.  This may
-        # cause the following tests to fail since no tests have caused
-        # the global version of sysconfig to call the customization yet.
-        # The solution for now is to simply skip this test in this case.
-        # The longer-term solution is to only have one version of sysconfig.
-
         import sysconfig as global_sysconfig
 
         if sysconfig.get_config_var('CUSTOMIZED_OSX_COMPILER'):
@@ -238,23 +235,24 @@ def test_customize_compiler_before_get_config_vars(self, tmp_path):
         # Issue #21923: test that a Distribution compiler
         # instance can be called without an explicit call to
         # get_config_vars().
-        file = tmp_path / 'file'
-        file.write_text(
-            trim(
-                """
-                from distutils.core import Distribution
-                config = Distribution().get_command_obj('config')
-                # try_compile may pass or it may fail if no compiler
-                # is found but it should not raise an exception.
-                rc = config.try_compile('int x;')
-                """
-            )
+        jaraco.path.build(
+            {
+                'file': trim("""
+                    from distutils.core import Distribution
+                    config = Distribution().get_command_obj('config')
+                    # try_compile may pass or it may fail if no compiler
+                    # is found but it should not raise an exception.
+                    rc = config.try_compile('int x;')
+                    """)
+            },
+            tmp_path,
         )
         p = subprocess.Popen(
-            py37compat.subprocess_args(sys.executable, file),
+            [sys.executable, tmp_path / 'file'],
             stdout=subprocess.PIPE,
             stderr=subprocess.STDOUT,
             universal_newlines=True,
+            encoding='utf-8',
         )
         outs, errs = p.communicate()
         assert 0 == p.returncode, "Subprocess failed: " + outs
diff --git a/distutils/tests/test_text_file.py b/distutils/tests/test_text_file.py
index 7c8dc5be..f5111565 100644
--- a/distutils/tests/test_text_file.py
+++ b/distutils/tests/test_text_file.py
@@ -1,7 +1,10 @@
 """Tests for distutils.text_file."""
-import os
-from distutils.text_file import TextFile
+
 from distutils.tests import support
+from distutils.text_file import TextFile
+
+import jaraco.path
+import path
 
 TEST_DATA = """# test file
 
@@ -52,16 +55,16 @@ def test_input(count, description, file, expected_result):
             result = file.readlines()
             assert result == expected_result
 
-        tmpdir = self.mkdtemp()
-        filename = os.path.join(tmpdir, "test.txt")
-        out_file = open(filename, "w")
-        try:
-            out_file.write(TEST_DATA)
-        finally:
-            out_file.close()
+        tmp_path = path.Path(self.mkdtemp())
+        filename = tmp_path / 'test.txt'
+        jaraco.path.build({filename.name: TEST_DATA}, tmp_path)
 
         in_file = TextFile(
-            filename, strip_comments=0, skip_blanks=0, lstrip_ws=0, rstrip_ws=0
+            filename,
+            strip_comments=False,
+            skip_blanks=False,
+            lstrip_ws=False,
+            rstrip_ws=False,
         )
         try:
             test_input(1, "no processing", in_file, result1)
@@ -69,7 +72,11 @@ def test_input(count, description, file, expected_result):
             in_file.close()
 
         in_file = TextFile(
-            filename, strip_comments=1, skip_blanks=0, lstrip_ws=0, rstrip_ws=0
+            filename,
+            strip_comments=True,
+            skip_blanks=False,
+            lstrip_ws=False,
+            rstrip_ws=False,
         )
         try:
             test_input(2, "strip comments", in_file, result2)
@@ -77,7 +84,11 @@ def test_input(count, description, file, expected_result):
             in_file.close()
 
         in_file = TextFile(
-            filename, strip_comments=0, skip_blanks=1, lstrip_ws=0, rstrip_ws=0
+            filename,
+            strip_comments=False,
+            skip_blanks=True,
+            lstrip_ws=False,
+            rstrip_ws=False,
         )
         try:
             test_input(3, "strip blanks", in_file, result3)
@@ -91,7 +102,11 @@ def test_input(count, description, file, expected_result):
             in_file.close()
 
         in_file = TextFile(
-            filename, strip_comments=1, skip_blanks=1, join_lines=1, rstrip_ws=1
+            filename,
+            strip_comments=True,
+            skip_blanks=True,
+            join_lines=True,
+            rstrip_ws=True,
         )
         try:
             test_input(5, "join lines without collapsing", in_file, result5)
@@ -100,11 +115,11 @@ def test_input(count, description, file, expected_result):
 
         in_file = TextFile(
             filename,
-            strip_comments=1,
-            skip_blanks=1,
-            join_lines=1,
-            rstrip_ws=1,
-            collapse_join=1,
+            strip_comments=True,
+            skip_blanks=True,
+            join_lines=True,
+            rstrip_ws=True,
+            collapse_join=True,
         )
         try:
             test_input(6, "join lines with collapsing", in_file, result6)
diff --git a/distutils/tests/test_unixccompiler.py b/distutils/tests/test_unixccompiler.py
index a0184424..d2c88e91 100644
--- a/distutils/tests/test_unixccompiler.py
+++ b/distutils/tests/test_unixccompiler.py
@@ -1,18 +1,19 @@
 """Tests for distutils.unixccompiler."""
+
 import os
 import sys
 import unittest.mock as mock
-
-from .py38compat import EnvironmentVarGuard
-
 from distutils import sysconfig
+from distutils.compat import consolidate_linker_args
 from distutils.errors import DistutilsPlatformError
 from distutils.unixccompiler import UnixCCompiler
 from distutils.util import _clear_cached_macosx_ver
 
-from . import support
 import pytest
 
+from . import support
+from .compat.py38 import EnvironmentVarGuard
+
 
 @pytest.fixture(autouse=True)
 def save_values(monkeypatch):
@@ -31,7 +32,7 @@ def rpath_foo(self):
 
 
 class TestUnixCCompiler(support.TempdirManager):
-    @pytest.mark.skipif('platform.system == "Windows"')  # noqa: C901
+    @pytest.mark.skipif('platform.system == "Windows"')
     def test_runtime_libdir_option(self):  # noqa: C901
         # Issue #5900; GitHub Issue #37
         #
@@ -72,10 +73,7 @@ def gcv(var):
 
         def do_darwin_test(syscfg_macosx_ver, env_macosx_ver, expected_flag):
             env = os.environ
-            msg = "macOS version = (sysconfig={!r}, env={!r})".format(
-                syscfg_macosx_ver,
-                env_macosx_ver,
-            )
+            msg = f"macOS version = (sysconfig={syscfg_macosx_ver!r}, env={env_macosx_ver!r})"
 
             # Save
             old_gcv = sysconfig.get_config_var
@@ -152,7 +150,10 @@ def gcv(v):
                 return 'yes'
 
         sysconfig.get_config_var = gcv
-        assert self.cc.rpath_foo() == '-Wl,--enable-new-dtags,-R/foo'
+        assert self.cc.rpath_foo() == consolidate_linker_args([
+            '-Wl,--enable-new-dtags',
+            '-Wl,-rpath,/foo',
+        ])
 
         def gcv(v):
             if v == 'CC':
@@ -161,7 +162,10 @@ def gcv(v):
                 return 'yes'
 
         sysconfig.get_config_var = gcv
-        assert self.cc.rpath_foo() == '-Wl,--enable-new-dtags,-R/foo'
+        assert self.cc.rpath_foo() == consolidate_linker_args([
+            '-Wl,--enable-new-dtags',
+            '-Wl,-rpath,/foo',
+        ])
 
         # GCC non-GNULD
         sys.platform = 'bar'
@@ -186,7 +190,10 @@ def gcv(v):
                 return 'yes'
 
         sysconfig.get_config_var = gcv
-        assert self.cc.rpath_foo() == '-Wl,--enable-new-dtags,-R/foo'
+        assert self.cc.rpath_foo() == consolidate_linker_args([
+            '-Wl,--enable-new-dtags',
+            '-Wl,-rpath,/foo',
+        ])
 
         # non-GCC GNULD
         sys.platform = 'bar'
@@ -198,7 +205,10 @@ def gcv(v):
                 return 'yes'
 
         sysconfig.get_config_var = gcv
-        assert self.cc.rpath_foo() == '-Wl,--enable-new-dtags,-R/foo'
+        assert self.cc.rpath_foo() == consolidate_linker_args([
+            '-Wl,--enable-new-dtags',
+            '-Wl,-rpath,/foo',
+        ])
 
         # non-GCC non-GNULD
         sys.platform = 'bar'
@@ -235,6 +245,7 @@ def gcvs(*args, _orig=sysconfig.get_config_vars):
         assert self.cc.linker_so[0] == 'my_cc'
 
     @pytest.mark.skipif('platform.system == "Windows"')
+    @pytest.mark.usefixtures('disable_macos_customization')
     def test_cc_overrides_ldshared_for_cxx_correctly(self):
         """
         Ensure that setting CC env variable also changes default linker
diff --git a/distutils/tests/test_upload.py b/distutils/tests/test_upload.py
index af113b8b..56df209c 100644
--- a/distutils/tests/test_upload.py
+++ b/distutils/tests/test_upload.py
@@ -1,15 +1,14 @@
 """Tests for distutils.command.upload."""
+
 import os
 import unittest.mock as mock
-from urllib.request import HTTPError
-
-
 from distutils.command import upload as upload_mod
 from distutils.command.upload import upload
 from distutils.core import Distribution
 from distutils.errors import DistutilsError
-
 from distutils.tests.test_config import PYPIRC, BasePyPIRCCommandTestCase
+from urllib.request import HTTPError
+
 import pytest
 
 PYPIRC_LONG_PASSWORD = """\
@@ -118,7 +117,7 @@ def test_upload(self, caplog):
         # lets run it
         pkg_dir, dist = self.create_dist(dist_files=dist_files)
         cmd = upload(dist)
-        cmd.show_response = 1
+        cmd.show_response = True
         cmd.ensure_finalized()
         cmd.run()
 
@@ -168,7 +167,7 @@ def test_upload_correct_cr(self):
             dist_files=dist_files, description='long description\r'
         )
         cmd = upload(dist)
-        cmd.show_response = 1
+        cmd.show_response = True
         cmd.ensure_finalized()
         cmd.run()
 
diff --git a/distutils/tests/test_util.py b/distutils/tests/test_util.py
index 070a2770..0de4e1a5 100644
--- a/distutils/tests/test_util.py
+++ b/distutils/tests/test_util.py
@@ -1,27 +1,30 @@
 """Tests for distutils.util."""
+
+import email
+import email.generator
+import email.policy
+import io
 import os
 import sys
 import sysconfig as stdlib_sysconfig
 import unittest.mock as mock
 from copy import copy
-
-import pytest
-
+from distutils import sysconfig, util
+from distutils.errors import DistutilsByteCompileError, DistutilsPlatformError
 from distutils.util import (
-    get_platform,
-    convert_path,
+    byte_compile,
     change_root,
     check_environ,
+    convert_path,
+    get_host_platform,
+    get_platform,
+    grok_environment_error,
+    rfc822_escape,
     split_quoted,
     strtobool,
-    rfc822_escape,
-    byte_compile,
-    grok_environment_error,
-    get_host_platform,
 )
-from distutils import util
-from distutils import sysconfig
-from distutils.errors import DistutilsPlatformError, DistutilsByteCompileError
+
+import pytest
 
 
 @pytest.fixture(autouse=True)
@@ -105,6 +108,7 @@ def _join(*path):
 
         # windows
         os.name = 'nt'
+        os.sep = '\\'
 
         def _isabs(path):
             return path.startswith('c:\\')
@@ -151,9 +155,15 @@ def test_check_environ_getpwuid(self):
         import pwd
 
         # only set pw_dir field, other fields are not used
-        result = pwd.struct_passwd(
-            (None, None, None, None, None, '/home/distutils', None)
-        )
+        result = pwd.struct_passwd((
+            None,
+            None,
+            None,
+            None,
+            None,
+            '/home/distutils',
+            None,
+        ))
         with mock.patch.object(pwd, 'getpwuid', return_value=result):
             check_environ()
             assert os.environ['HOME'] == '/home/distutils'
@@ -184,12 +194,55 @@ def test_strtobool(self):
         for n in no:
             assert not strtobool(n)
 
-    def test_rfc822_escape(self):
-        header = 'I am a\npoor\nlonesome\nheader\n'
-        res = rfc822_escape(header)
-        wanted = ('I am a%(8s)spoor%(8s)slonesome%(8s)s' 'header%(8s)s') % {
-            '8s': '\n' + 8 * ' '
-        }
+    indent = 8 * ' '
+
+    @pytest.mark.parametrize(
+        "given,wanted",
+        [
+            # 0x0b, 0x0c, ..., etc are also considered a line break by Python
+            ("hello\x0b\nworld\n", f"hello\x0b{indent}\n{indent}world\n{indent}"),
+            ("hello\x1eworld", f"hello\x1e{indent}world"),
+            ("", ""),
+            (
+                "I am a\npoor\nlonesome\nheader\n",
+                f"I am a\n{indent}poor\n{indent}lonesome\n{indent}header\n{indent}",
+            ),
+        ],
+    )
+    def test_rfc822_escape(self, given, wanted):
+        """
+        We want to ensure a multi-line header parses correctly.
+
+        For interoperability, the escaped value should also "round-trip" over
+        `email.generator.Generator.flatten` and `email.message_from_*`
+        (see pypa/setuptools#4033).
+
+        The main issue is that internally `email.policy.EmailPolicy` uses
+        `splitlines` which will split on some control chars. If all the new lines
+        are not prefixed with spaces, the parser will interrupt reading
+        the current header and produce an incomplete value, while
+        incorrectly interpreting the rest of the headers as part of the payload.
+        """
+        res = rfc822_escape(given)
+
+        policy = email.policy.EmailPolicy(
+            utf8=True,
+            mangle_from_=False,
+            max_line_length=0,
+        )
+        with io.StringIO() as buffer:
+            raw = f"header: {res}\nother-header: 42\n\npayload\n"
+            orig = email.message_from_string(raw)
+            email.generator.Generator(buffer, policy=policy).flatten(orig)
+            buffer.seek(0)
+            regen = email.message_from_file(buffer)
+
+        for msg in (orig, regen):
+            assert msg.get_payload() == "payload\n"
+            assert msg["other-header"] == "42"
+            # Generator may replace control chars with `\n`
+            assert set(msg["header"].splitlines()) == set(res.splitlines())
+
         assert res == wanted
 
     def test_dont_write_bytecode(self):
@@ -205,6 +258,6 @@ def test_dont_write_bytecode(self):
 
     def test_grok_environment_error(self):
         # test obsolete function to ensure backward compat (#4931)
-        exc = IOError("Unable to find batch file")
+        exc = OSError("Unable to find batch file")
         msg = grok_environment_error(exc)
         assert msg == "error: Unable to find batch file"
diff --git a/distutils/tests/test_version.py b/distutils/tests/test_version.py
index ff52ea46..1508e1cc 100644
--- a/distutils/tests/test_version.py
+++ b/distutils/tests/test_version.py
@@ -1,9 +1,9 @@
 """Tests for distutils.version."""
-import pytest
 
 import distutils
-from distutils.version import LooseVersion
-from distutils.version import StrictVersion
+from distutils.version import LooseVersion, StrictVersion
+
+import pytest
 
 
 @pytest.fixture(autouse=True)
@@ -48,20 +48,14 @@ def test_cmp_strict(self):
                 if wanted is ValueError:
                     continue
                 else:
-                    raise AssertionError(
-                        ("cmp(%s, %s) " "shouldn't raise ValueError") % (v1, v2)
-                    )
-            assert res == wanted, 'cmp({}, {}) should be {}, got {}'.format(
-                v1, v2, wanted, res
-            )
+                    raise AssertionError(f"cmp({v1}, {v2}) shouldn't raise ValueError")
+            assert res == wanted, f'cmp({v1}, {v2}) should be {wanted}, got {res}'
             res = StrictVersion(v1)._cmp(v2)
-            assert res == wanted, 'cmp({}, {}) should be {}, got {}'.format(
-                v1, v2, wanted, res
-            )
+            assert res == wanted, f'cmp({v1}, {v2}) should be {wanted}, got {res}'
             res = StrictVersion(v1)._cmp(object())
             assert (
                 res is NotImplemented
-            ), 'cmp({}, {}) should be NotImplemented, got {}'.format(v1, v2, res)
+            ), f'cmp({v1}, {v2}) should be NotImplemented, got {res}'
 
     def test_cmp(self):
         versions = (
@@ -77,14 +71,10 @@ def test_cmp(self):
 
         for v1, v2, wanted in versions:
             res = LooseVersion(v1)._cmp(LooseVersion(v2))
-            assert res == wanted, 'cmp({}, {}) should be {}, got {}'.format(
-                v1, v2, wanted, res
-            )
+            assert res == wanted, f'cmp({v1}, {v2}) should be {wanted}, got {res}'
             res = LooseVersion(v1)._cmp(v2)
-            assert res == wanted, 'cmp({}, {}) should be {}, got {}'.format(
-                v1, v2, wanted, res
-            )
+            assert res == wanted, f'cmp({v1}, {v2}) should be {wanted}, got {res}'
             res = LooseVersion(v1)._cmp(object())
             assert (
                 res is NotImplemented
-            ), 'cmp({}, {}) should be NotImplemented, got {}'.format(v1, v2, res)
+            ), f'cmp({v1}, {v2}) should be NotImplemented, got {res}'
diff --git a/distutils/tests/unix_compat.py b/distutils/tests/unix_compat.py
index 95fc8eeb..a5d9ee45 100644
--- a/distutils/tests/unix_compat.py
+++ b/distutils/tests/unix_compat.py
@@ -8,7 +8,6 @@
 
 import pytest
 
-
 UNIX_ID_SUPPORT = grp and pwd
 UID_0_SUPPORT = UNIX_ID_SUPPORT and sys.platform != "cygwin"
 
diff --git a/distutils/text_file.py b/distutils/text_file.py
index 36f947e5..fec29c73 100644
--- a/distutils/text_file.py
+++ b/distutils/text_file.py
@@ -97,7 +97,7 @@ def __init__(self, filename=None, file=None, **options):
         # sanity check client option hash
         for opt in options.keys():
             if opt not in self.default_options:
-                raise KeyError("invalid TextFile option '%s'" % opt)
+                raise KeyError(f"invalid TextFile option '{opt}'")
 
         if file is None:
             self.open(filename)
@@ -115,7 +115,7 @@ def open(self, filename):
         """Open a new file named 'filename'.  This overrides both the
         'filename' and 'file' arguments to the constructor."""
         self.filename = filename
-        self.file = open(self.filename, errors=self.errors)
+        self.file = open(self.filename, errors=self.errors, encoding='utf-8')
         self.current_line = 0
 
     def close(self):
@@ -220,7 +220,7 @@ def readline(self):  # noqa: C901
             if self.join_lines and buildup_line:
                 # oops: end of file
                 if line is None:
-                    self.warn("continuation line immediately precedes " "end-of-file")
+                    self.warn("continuation line immediately precedes end-of-file")
                     return buildup_line
 
                 if self.collapse_join:
diff --git a/distutils/unixccompiler.py b/distutils/unixccompiler.py
index b1139cfb..e547d489 100644
--- a/distutils/unixccompiler.py
+++ b/distutils/unixccompiler.py
@@ -13,18 +13,21 @@
   * link shared library handled by 'cc -shared'
 """
 
+from __future__ import annotations
+
+import itertools
 import os
-import sys
 import re
 import shlex
-import itertools
+import sys
 
 from . import sysconfig
-from .dep_util import newer
-from .ccompiler import CCompiler, gen_preprocess_options, gen_lib_options
-from .errors import DistutilsExecError, CompileError, LibError, LinkError
 from ._log import log
 from ._macos_compat import compiler_fixup
+from ._modified import newer
+from .ccompiler import CCompiler, gen_lib_options, gen_preprocess_options
+from .compat import consolidate_linker_args
+from .errors import CompileError, DistutilsExecError, LibError, LinkError
 
 # XXX Things not currently handled:
 #   * optimization/debug/warning flags; we just use whatever's in Python's
@@ -191,7 +194,7 @@ def _compile(self, obj, src, ext, cc_args, extra_postargs, pp_opts):
             raise CompileError(msg)
 
     def create_static_lib(
-        self, objects, output_libname, output_dir=None, debug=0, target_lang=None
+        self, objects, output_libname, output_dir=None, debug=False, target_lang=None
     ):
         objects, output_dir = self._fix_object_args(objects, output_dir)
 
@@ -224,7 +227,7 @@ def link(
         library_dirs=None,
         runtime_library_dirs=None,
         export_symbols=None,
-        debug=0,
+        debug=False,
         extra_preargs=None,
         extra_postargs=None,
         build_temp=None,
@@ -285,10 +288,9 @@ def _is_gcc(self):
         compiler = os.path.basename(shlex.split(cc_var)[0])
         return "gcc" in compiler or "g++" in compiler
 
-    def runtime_library_dir_option(self, dir):
+    def runtime_library_dir_option(self, dir: str) -> str | list[str]:
         # XXX Hackish, at the very least.  See Python bug #445902:
-        # http://sourceforge.net/tracker/index.php
-        #   ?func=detail&aid=445902&group_id=5470&atid=105470
+        # https://bugs.python.org/issue445902
         # Linkers on different platforms need different options to
         # specify that directories need to be added to the list of
         # directories searched for dependencies when a dynamic library
@@ -315,13 +317,14 @@ def runtime_library_dir_option(self, dir):
                 "-L" + dir,
             ]
 
-        # For all compilers, `-Wl` is the presumed way to
-        # pass a compiler option to the linker and `-R` is
-        # the way to pass an RPATH.
+        # For all compilers, `-Wl` is the presumed way to pass a
+        # compiler option to the linker
         if sysconfig.get_config_var("GNULD") == "yes":
-            # GNU ld needs an extra option to get a RUNPATH
-            # instead of just an RPATH.
-            return "-Wl,--enable-new-dtags,-R" + dir
+            return consolidate_linker_args([
+                # Force RUNPATH instead of RPATH
+                "-Wl,--enable-new-dtags",
+                "-Wl,-rpath," + dir,
+            ])
         else:
             return "-Wl,-R" + dir
 
@@ -363,7 +366,7 @@ def _library_root(dir):
 
         return os.path.join(match.group(1), dir[1:]) if apply_root else dir
 
-    def find_library_file(self, dirs, lib, debug=0):
+    def find_library_file(self, dirs, lib, debug=False):
         r"""
         Second-guess the linker with not much hard
         data to go on: GCC seems to prefer the shared library, so
@@ -393,10 +396,7 @@ def find_library_file(self, dirs, lib, debug=0):
 
         roots = map(self._library_root, dirs)
 
-        searched = (
-            os.path.join(root, lib_name)
-            for root, lib_name in itertools.product(roots, lib_names)
-        )
+        searched = itertools.starmap(os.path.join, itertools.product(roots, lib_names))
 
         found = filter(os.path.exists, searched)
 
diff --git a/distutils/util.py b/distutils/util.py
index 7ef47176..9db89b09 100644
--- a/distutils/util.py
+++ b/distutils/util.py
@@ -4,6 +4,7 @@
 one of the other *util.py modules.
 """
 
+import functools
 import importlib.util
 import os
 import re
@@ -11,12 +12,12 @@
 import subprocess
 import sys
 import sysconfig
-import functools
+import tempfile
 
-from .errors import DistutilsPlatformError, DistutilsByteCompileError
-from .dep_util import newer
-from .spawn import spawn
 from ._log import log
+from ._modified import newer
+from .errors import DistutilsByteCompileError, DistutilsPlatformError
+from .spawn import spawn
 
 
 def get_host_platform():
@@ -30,18 +31,11 @@ def get_host_platform():
     # even with older Python versions when distutils was split out.
     # Now it delegates to stdlib sysconfig, but maintains compatibility.
 
-    if sys.version_info < (3, 8):
-        if os.name == 'nt':
-            if '(arm)' in sys.version.lower():
-                return 'win-arm32'
-            if '(arm64)' in sys.version.lower():
-                return 'win-arm64'
-
     if sys.version_info < (3, 9):
         if os.name == "posix" and hasattr(os, 'uname'):
             osname, host, release, version, machine = os.uname()
             if osname[:3] == "aix":
-                from .py38compat import aix_platform
+                from .compat.py38 import aix_platform
 
                 return aix_platform(osname, version, release)
 
@@ -109,8 +103,8 @@ def get_macosx_target_ver():
         ):
             my_msg = (
                 '$' + MACOSX_VERSION_VAR + ' mismatch: '
-                'now "%s" but "%s" during configure; '
-                'must use 10.3 or later' % (env_ver, syscfg_ver)
+                f'now "{env_ver}" but "{syscfg_ver}" during configure; '
+                'must use 10.3 or later'
             )
             raise DistutilsPlatformError(my_msg)
         return env_ver
@@ -136,9 +130,9 @@ def convert_path(pathname):
     if not pathname:
         return pathname
     if pathname[0] == '/':
-        raise ValueError("path '%s' cannot be absolute" % pathname)
+        raise ValueError(f"path '{pathname}' cannot be absolute")
     if pathname[-1] == '/':
-        raise ValueError("path '%s' cannot end with '/'" % pathname)
+        raise ValueError(f"path '{pathname}' cannot end with '/'")
 
     paths = pathname.split('/')
     while '.' in paths:
@@ -165,14 +159,14 @@ def change_root(new_root, pathname):
 
     elif os.name == 'nt':
         (drive, path) = os.path.splitdrive(pathname)
-        if path[0] == '\\':
+        if path[0] == os.sep:
             path = path[1:]
         return os.path.join(new_root, path)
 
     raise DistutilsPlatformError(f"nothing known about platform '{os.name}'")
 
 
-@functools.lru_cache()
+@functools.lru_cache
 def check_environ():
     """Ensure that 'os.environ' has all the environment variables we
     guarantee that users can use in config files, command-line options,
@@ -247,7 +241,7 @@ def grok_environment_error(exc, prefix="error: "):
 
 def _init_regex():
     global _wordchars_re, _squote_re, _dquote_re
-    _wordchars_re = re.compile(r'[^\\\'\"%s ]*' % string.whitespace)
+    _wordchars_re = re.compile(rf'[^\\\'\"{string.whitespace} ]*')
     _squote_re = re.compile(r"'(?:[^'\\]|\\.)*'")
     _dquote_re = re.compile(r'"(?:[^"\\]|\\.)*"')
 
@@ -302,7 +296,7 @@ def split_quoted(s):
                 raise RuntimeError("this can't happen (bad char '%c')" % s[end])
 
             if m is None:
-                raise ValueError("bad string (mismatched %s quotes?)" % s[end])
+                raise ValueError(f"bad string (mismatched {s[end]} quotes?)")
 
             (beg, end) = m.span()
             s = s[:beg] + s[beg + 1 : end - 1] + s[end:]
@@ -318,7 +312,7 @@ def split_quoted(s):
 # split_quoted ()
 
 
-def execute(func, args, msg=None, verbose=0, dry_run=0):
+def execute(func, args, msg=None, verbose=False, dry_run=False):
     """Perform some action that affects the outside world (eg.  by
     writing to the filesystem).  Such actions are special because they
     are disabled by the 'dry_run' flag.  This method takes care of all
@@ -328,7 +322,7 @@ def execute(func, args, msg=None, verbose=0, dry_run=0):
     print.
     """
     if msg is None:
-        msg = "{}{!r}".format(func.__name__, args)
+        msg = f"{func.__name__}{args!r}"
         if msg[-2:] == ',)':  # correct for singleton tuple
             msg = msg[0:-2] + ')'
 
@@ -350,17 +344,17 @@ def strtobool(val):
     elif val in ('n', 'no', 'f', 'false', 'off', '0'):
         return 0
     else:
-        raise ValueError("invalid truth value {!r}".format(val))
+        raise ValueError(f"invalid truth value {val!r}")
 
 
 def byte_compile(  # noqa: C901
     py_files,
     optimize=0,
-    force=0,
+    force=False,
     prefix=None,
     base_dir=None,
-    verbose=1,
-    dry_run=0,
+    verbose=True,
+    dry_run=False,
     direct=None,
 ):
     """Byte-compile a collection of Python source files to .pyc
@@ -412,20 +406,10 @@ def byte_compile(  # noqa: C901
     # "Indirect" byte-compilation: write a temporary script and then
     # run it with the appropriate flags.
     if not direct:
-        try:
-            from tempfile import mkstemp
-
-            (script_fd, script_name) = mkstemp(".py")
-        except ImportError:
-            from tempfile import mktemp
-
-            (script_fd, script_name) = None, mktemp(".py")
+        (script_fd, script_name) = tempfile.mkstemp(".py")
         log.info("writing byte-compilation script '%s'", script_name)
         if not dry_run:
-            if script_fd is not None:
-                script = os.fdopen(script_fd, "w")
-            else:
-                script = open(script_name, "w")
+            script = os.fdopen(script_fd, "w", encoding='utf-8')
 
             with script:
                 script.write(
@@ -447,20 +431,19 @@ def byte_compile(  # noqa: C901
 
                 script.write(",\n".join(map(repr, py_files)) + "]\n")
                 script.write(
-                    """
-byte_compile(files, optimize=%r, force=%r,
-             prefix=%r, base_dir=%r,
-             verbose=%r, dry_run=0,
-             direct=1)
+                    f"""
+byte_compile(files, optimize={optimize!r}, force={force!r},
+             prefix={prefix!r}, base_dir={base_dir!r},
+             verbose={verbose!r}, dry_run=False,
+             direct=True)
 """
-                    % (optimize, force, prefix, base_dir, verbose)
                 )
 
         cmd = [sys.executable]
         cmd.extend(subprocess._optim_args_from_interpreter_flags())
         cmd.append(script_name)
         spawn(cmd, dry_run=dry_run)
-        execute(os.remove, (script_name,), "removing %s" % script_name, dry_run=dry_run)
+        execute(os.remove, (script_name,), f"removing {script_name}", dry_run=dry_run)
 
     # "Direct" byte-compilation: use the py_compile module to compile
     # right here, right now.  Note that the script generated in indirect
@@ -487,8 +470,7 @@ def byte_compile(  # noqa: C901
             if prefix:
                 if file[: len(prefix)] != prefix:
                     raise ValueError(
-                        "invalid prefix: filename %r doesn't start with %r"
-                        % (file, prefix)
+                        f"invalid prefix: filename {file!r} doesn't start with {prefix!r}"
                     )
                 dfile = dfile[len(prefix) :]
             if base_dir:
@@ -508,6 +490,21 @@ def rfc822_escape(header):
     """Return a version of the string escaped for inclusion in an
     RFC-822 header, by ensuring there are 8 spaces space after each newline.
     """
-    lines = header.split('\n')
-    sep = '\n' + 8 * ' '
-    return sep.join(lines)
+    indent = 8 * " "
+    lines = header.splitlines(keepends=True)
+
+    # Emulate the behaviour of `str.split`
+    # (the terminal line break in `splitlines` does not result in an extra line):
+    ends_in_newline = lines and lines[-1].splitlines()[0] != lines[-1]
+    suffix = indent if ends_in_newline else ""
+
+    return indent.join(lines) + suffix
+
+
+def is_mingw():
+    """Returns True if the current platform is mingw.
+
+    Python compiled with Mingw-w64 has sys.platform == 'win32' and
+    get_platform() starts with 'mingw'.
+    """
+    return sys.platform == 'win32' and get_platform().startswith('mingw')
diff --git a/distutils/version.py b/distutils/version.py
index 74c40d7b..942b56bf 100644
--- a/distutils/version.py
+++ b/distutils/version.py
@@ -26,9 +26,9 @@
     of the same class, thus must follow the same rules)
 """
 
+import contextlib
 import re
 import warnings
-import contextlib
 
 
 @contextlib.contextmanager
@@ -60,7 +60,7 @@ def __init__(self, vstring=None):
         )
 
     def __repr__(self):
-        return "{} ('{}')".format(self.__class__.__name__, str(self))
+        return f"{self.__class__.__name__} ('{self}')"
 
     def __eq__(self, other):
         c = self._cmp(other)
@@ -111,7 +111,6 @@ def __ge__(self, other):
 
 
 class StrictVersion(Version):
-
     """Version numbering for anal retentives and software idealists.
     Implements the standard interface for version number classes as
     described above.  A version number consists of two or three
@@ -154,7 +153,7 @@ class StrictVersion(Version):
     def parse(self, vstring):
         match = self.version_re.match(vstring)
         if not match:
-            raise ValueError("invalid version number '%s'" % vstring)
+            raise ValueError(f"invalid version number '{vstring}'")
 
         (major, minor, patch, prerelease, prerelease_num) = match.group(1, 2, 4, 5, 6)
 
@@ -179,42 +178,36 @@ def __str__(self):
 
         return vstring
 
-    def _cmp(self, other):  # noqa: C901
+    def _cmp(self, other):
         if isinstance(other, str):
             with suppress_known_deprecation():
                 other = StrictVersion(other)
         elif not isinstance(other, StrictVersion):
             return NotImplemented
 
-        if self.version != other.version:
-            # numeric versions don't match
-            # prerelease stuff doesn't matter
-            if self.version < other.version:
-                return -1
-            else:
-                return 1
-
-        # have to compare prerelease
-        # case 1: neither has prerelease; they're equal
-        # case 2: self has prerelease, other doesn't; other is greater
-        # case 3: self doesn't have prerelease, other does: self is greater
-        # case 4: both have prerelease: must compare them!
-
-        if not self.prerelease and not other.prerelease:
-            return 0
-        elif self.prerelease and not other.prerelease:
+        if self.version == other.version:
+            # versions match; pre-release drives the comparison
+            return self._cmp_prerelease(other)
+
+        return -1 if self.version < other.version else 1
+
+    def _cmp_prerelease(self, other):
+        """
+        case 1: self has prerelease, other doesn't; other is greater
+        case 2: self doesn't have prerelease, other does: self is greater
+        case 3: both or neither have prerelease: compare them!
+        """
+        if self.prerelease and not other.prerelease:
             return -1
         elif not self.prerelease and other.prerelease:
             return 1
-        elif self.prerelease and other.prerelease:
-            if self.prerelease == other.prerelease:
-                return 0
-            elif self.prerelease < other.prerelease:
-                return -1
-            else:
-                return 1
+
+        if self.prerelease == other.prerelease:
+            return 0
+        elif self.prerelease < other.prerelease:
+            return -1
         else:
-            assert False, "never get here"
+            return 1
 
 
 # end class StrictVersion
@@ -286,7 +279,6 @@ def _cmp(self, other):  # noqa: C901
 
 
 class LooseVersion(Version):
-
     """Version numbering for anarchists and software realists.
     Implements the standard interface for version number classes as
     described above.  A version number consists of a series of numbers,
@@ -338,7 +330,7 @@ def __str__(self):
         return self.vstring
 
     def __repr__(self):
-        return "LooseVersion ('%s')" % str(self)
+        return f"LooseVersion ('{self}')"
 
     def _cmp(self, other):
         if isinstance(other, str):
diff --git a/distutils/versionpredicate.py b/distutils/versionpredicate.py
index d6c0c007..fe31b0ed 100644
--- a/distutils/versionpredicate.py
+++ b/distutils/versionpredicate.py
@@ -1,9 +1,9 @@
-"""Module for parsing and testing package version predicate strings.
-"""
-import re
-from . import version
+"""Module for parsing and testing package version predicate strings."""
+
 import operator
+import re
 
+from . import version
 
 re_validPackage = re.compile(r"(?i)^\s*([a-z_]\w*(?:\.[a-z_]\w*)*)(.*)", re.ASCII)
 # (package) (rest)
@@ -20,7 +20,7 @@ def splitUp(pred):
     """
     res = re_splitComparison.match(pred)
     if not res:
-        raise ValueError("bad package restriction syntax: %r" % pred)
+        raise ValueError(f"bad package restriction syntax: {pred!r}")
     comp, verStr = res.groups()
     with version.suppress_known_deprecation():
         other = version.StrictVersion(verStr)
@@ -113,17 +113,17 @@ def __init__(self, versionPredicateStr):
             raise ValueError("empty package restriction")
         match = re_validPackage.match(versionPredicateStr)
         if not match:
-            raise ValueError("bad package name in %r" % versionPredicateStr)
+            raise ValueError(f"bad package name in {versionPredicateStr!r}")
         self.name, paren = match.groups()
         paren = paren.strip()
         if paren:
             match = re_paren.match(paren)
             if not match:
-                raise ValueError("expected parenthesized list: %r" % paren)
+                raise ValueError(f"expected parenthesized list: {paren!r}")
             str = match.groups()[0]
             self.pred = [splitUp(aPred) for aPred in str.split(",")]
             if not self.pred:
-                raise ValueError("empty parenthesized list in %r" % versionPredicateStr)
+                raise ValueError(f"empty parenthesized list in {versionPredicateStr!r}")
         else:
             self.pred = []
 
@@ -167,7 +167,7 @@ def split_provision(value):
     value = value.strip()
     m = _provision_rx.match(value)
     if not m:
-        raise ValueError("illegal provides specification: %r" % value)
+        raise ValueError(f"illegal provides specification: {value!r}")
     ver = m.group(2) or None
     if ver:
         with version.suppress_known_deprecation():
diff --git a/distutils/zosccompiler.py b/distutils/zosccompiler.py
new file mode 100644
index 00000000..af1e7fa5
--- /dev/null
+++ b/distutils/zosccompiler.py
@@ -0,0 +1,229 @@
+"""distutils.zosccompiler
+
+Contains the selection of the c & c++ compilers on z/OS. There are several
+different c compilers on z/OS, all of them are optional, so the correct
+one needs to be chosen based on the users input. This is compatible with
+the following compilers:
+
+IBM C/C++ For Open Enterprise Languages on z/OS 2.0
+IBM Open XL C/C++ 1.1 for z/OS
+IBM XL C/C++ V2.4.1 for z/OS 2.4 and 2.5
+IBM z/OS XL C/C++
+"""
+
+import os
+
+from . import sysconfig
+from .errors import CompileError, DistutilsExecError
+from .unixccompiler import UnixCCompiler
+
+_cc_args = {
+    'ibm-openxl': [
+        '-m64',
+        '-fvisibility=default',
+        '-fzos-le-char-mode=ascii',
+        '-fno-short-enums',
+    ],
+    'ibm-xlclang': [
+        '-q64',
+        '-qexportall',
+        '-qascii',
+        '-qstrict',
+        '-qnocsect',
+        '-Wa,asa,goff',
+        '-Wa,xplink',
+        '-qgonumber',
+        '-qenum=int',
+        '-Wc,DLL',
+    ],
+    'ibm-xlc': [
+        '-q64',
+        '-qexportall',
+        '-qascii',
+        '-qstrict',
+        '-qnocsect',
+        '-Wa,asa,goff',
+        '-Wa,xplink',
+        '-qgonumber',
+        '-qenum=int',
+        '-Wc,DLL',
+        '-qlanglvl=extc99',
+    ],
+}
+
+_cxx_args = {
+    'ibm-openxl': [
+        '-m64',
+        '-fvisibility=default',
+        '-fzos-le-char-mode=ascii',
+        '-fno-short-enums',
+    ],
+    'ibm-xlclang': [
+        '-q64',
+        '-qexportall',
+        '-qascii',
+        '-qstrict',
+        '-qnocsect',
+        '-Wa,asa,goff',
+        '-Wa,xplink',
+        '-qgonumber',
+        '-qenum=int',
+        '-Wc,DLL',
+    ],
+    'ibm-xlc': [
+        '-q64',
+        '-qexportall',
+        '-qascii',
+        '-qstrict',
+        '-qnocsect',
+        '-Wa,asa,goff',
+        '-Wa,xplink',
+        '-qgonumber',
+        '-qenum=int',
+        '-Wc,DLL',
+        '-qlanglvl=extended0x',
+    ],
+}
+
+_asm_args = {
+    'ibm-openxl': ['-fasm', '-fno-integrated-as', '-Wa,--ASA', '-Wa,--GOFF'],
+    'ibm-xlclang': [],
+    'ibm-xlc': [],
+}
+
+_ld_args = {
+    'ibm-openxl': [],
+    'ibm-xlclang': ['-Wl,dll', '-q64'],
+    'ibm-xlc': ['-Wl,dll', '-q64'],
+}
+
+
+# Python on z/OS is built with no compiler specific options in it's CFLAGS.
+# But each compiler requires it's own specific options to build successfully,
+# though some of the options are common between them
+class zOSCCompiler(UnixCCompiler):
+    src_extensions = ['.c', '.C', '.cc', '.cxx', '.cpp', '.m', '.s']
+    _cpp_extensions = ['.cc', '.cpp', '.cxx', '.C']
+    _asm_extensions = ['.s']
+
+    def _get_zos_compiler_name(self):
+        zos_compiler_names = [
+            os.path.basename(binary)
+            for envvar in ('CC', 'CXX', 'LDSHARED')
+            if (binary := os.environ.get(envvar, None))
+        ]
+        if len(zos_compiler_names) == 0:
+            return 'ibm-openxl'
+
+        zos_compilers = {}
+        for compiler in (
+            'ibm-clang',
+            'ibm-clang64',
+            'ibm-clang++',
+            'ibm-clang++64',
+            'clang',
+            'clang++',
+            'clang-14',
+        ):
+            zos_compilers[compiler] = 'ibm-openxl'
+
+        for compiler in ('xlclang', 'xlclang++', 'njsc', 'njsc++'):
+            zos_compilers[compiler] = 'ibm-xlclang'
+
+        for compiler in ('xlc', 'xlC', 'xlc++'):
+            zos_compilers[compiler] = 'ibm-xlc'
+
+        return zos_compilers.get(zos_compiler_names[0], 'ibm-openxl')
+
+    def __init__(self, verbose=False, dry_run=False, force=False):
+        super().__init__(verbose, dry_run, force)
+        self.zos_compiler = self._get_zos_compiler_name()
+        sysconfig.customize_compiler(self)
+
+    def _compile(self, obj, src, ext, cc_args, extra_postargs, pp_opts):
+        local_args = []
+        if ext in self._cpp_extensions:
+            compiler = self.compiler_cxx
+            local_args.extend(_cxx_args[self.zos_compiler])
+        elif ext in self._asm_extensions:
+            compiler = self.compiler_so
+            local_args.extend(_cc_args[self.zos_compiler])
+            local_args.extend(_asm_args[self.zos_compiler])
+        else:
+            compiler = self.compiler_so
+            local_args.extend(_cc_args[self.zos_compiler])
+        local_args.extend(cc_args)
+
+        try:
+            self.spawn(compiler + local_args + [src, '-o', obj] + extra_postargs)
+        except DistutilsExecError as msg:
+            raise CompileError(msg)
+
+    def runtime_library_dir_option(self, dir):
+        return '-L' + dir
+
+    def link(
+        self,
+        target_desc,
+        objects,
+        output_filename,
+        output_dir=None,
+        libraries=None,
+        library_dirs=None,
+        runtime_library_dirs=None,
+        export_symbols=None,
+        debug=False,
+        extra_preargs=None,
+        extra_postargs=None,
+        build_temp=None,
+        target_lang=None,
+    ):
+        # For a built module to use functions from cpython, it needs to use Pythons
+        # side deck file. The side deck is located beside the libpython3.xx.so
+        ldversion = sysconfig.get_config_var('LDVERSION')
+        if sysconfig.python_build:
+            side_deck_path = os.path.join(
+                sysconfig.get_config_var('abs_builddir'),
+                f'libpython{ldversion}.x',
+            )
+        else:
+            side_deck_path = os.path.join(
+                sysconfig.get_config_var('installed_base'),
+                sysconfig.get_config_var('platlibdir'),
+                f'libpython{ldversion}.x',
+            )
+
+        if os.path.exists(side_deck_path):
+            if extra_postargs:
+                extra_postargs.append(side_deck_path)
+            else:
+                extra_postargs = [side_deck_path]
+
+        # Check and replace libraries included side deck files
+        if runtime_library_dirs:
+            for dir in runtime_library_dirs:
+                for library in libraries[:]:
+                    library_side_deck = os.path.join(dir, f'{library}.x')
+                    if os.path.exists(library_side_deck):
+                        libraries.remove(library)
+                        extra_postargs.append(library_side_deck)
+                        break
+
+        # Any required ld args for the given compiler
+        extra_postargs.extend(_ld_args[self.zos_compiler])
+
+        super().link(
+            target_desc,
+            objects,
+            output_filename,
+            output_dir,
+            libraries,
+            library_dirs,
+            runtime_library_dirs,
+            export_symbols,
+            debug,
+            extra_preargs,
+            extra_postargs,
+            build_temp,
+            target_lang,
+        )
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100644
index 00000000..d673f413
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,45 @@
+extensions = [
+    'sphinx.ext.autodoc',
+    'jaraco.packaging.sphinx',
+]
+
+master_doc = "index"
+html_theme = "furo"
+
+# Link dates and other references in the changelog
+extensions += ['rst.linker']
+link_files = {
+    '../NEWS.rst': dict(
+        using=dict(GH='https://github.com'),
+        replace=[
+            dict(
+                pattern=r'(Issue #|\B#)(?P\d+)',
+                url='{package_url}/issues/{issue}',
+            ),
+            dict(
+                pattern=r'(?m:^((?Pv?\d+(\.\d+){1,2}))\n[-=]+\n)',
+                with_scm='{text}\n{rev[timestamp]:%d %b %Y}\n',
+            ),
+            dict(
+                pattern=r'PEP[- ](?P\d+)',
+                url='https://peps.python.org/pep-{pep_number:0>4}/',
+            ),
+        ],
+    )
+}
+
+# Be strict about any broken references
+nitpicky = True
+
+# Include Python intersphinx mapping to prevent failures
+# jaraco/skeleton#51
+extensions += ['sphinx.ext.intersphinx']
+intersphinx_mapping = {
+    'python': ('https://docs.python.org/3', None),
+}
+
+# Preserve authored syntax for defaults
+autodoc_preserve_defaults = True
+
+# for distutils, disable nitpicky
+nitpicky = False
diff --git a/docs/distutils/apiref.rst b/docs/distutils/apiref.rst
index 83b8ef5d..709186ad 100644
--- a/docs/distutils/apiref.rst
+++ b/docs/distutils/apiref.rst
@@ -361,7 +361,7 @@ This module provides the following functions.
    are not given.
 
 
-.. function:: new_compiler(plat=None, compiler=None, verbose=0, dry_run=0, force=0)
+.. function:: new_compiler(plat=None, compiler=None, verbose=False, dry_run=False, force=False)
 
    Factory function to generate an instance of some CCompiler subclass for the
    supplied platform/compiler combination. *plat* defaults to ``os.name`` (eg.
@@ -383,7 +383,7 @@ This module provides the following functions.
    to :command:`build`, :command:`build_ext`, :command:`build_clib`).
 
 
-.. class:: CCompiler([verbose=0, dry_run=0, force=0])
+.. class:: CCompiler([verbose=False, dry_run=False, force=False])
 
    The abstract base class :class:`CCompiler` defines the interface that  must be
    implemented by real compiler classes.  The class also has  some utility methods
@@ -517,7 +517,7 @@ This module provides the following functions.
       list) to do the job.
 
 
-   .. method:: CCompiler.find_library_file(dirs, lib[, debug=0])
+   .. method:: CCompiler.find_library_file(dirs, lib[, debug=False])
 
       Search the specified list of directories for a static or shared library file
       *lib* and return the full path to that file.  If *debug* is true, look for a
@@ -580,7 +580,7 @@ This module provides the following functions.
    The following methods invoke stages in the build process.
 
 
-   .. method:: CCompiler.compile(sources[, output_dir=None, macros=None, include_dirs=None, debug=0, extra_preargs=None, extra_postargs=None, depends=None])
+   .. method:: CCompiler.compile(sources[, output_dir=None, macros=None, include_dirs=None, debug=False, extra_preargs=None, extra_postargs=None, depends=None])
 
       Compile one or more source files. Generates object files (e.g.  transforms a
       :file:`.c` file to a :file:`.o` file.)
@@ -624,7 +624,7 @@ This module provides the following functions.
       Raises :exc:`CompileError` on failure.
 
 
-   .. method:: CCompiler.create_static_lib(objects, output_libname[, output_dir=None, debug=0, target_lang=None])
+   .. method:: CCompiler.create_static_lib(objects, output_libname[, output_dir=None, debug=False, target_lang=None])
 
       Link a bunch of stuff together to create a static library file. The "bunch of
       stuff" consists of the list of object files supplied as *objects*, the extra
@@ -648,7 +648,7 @@ This module provides the following functions.
       Raises :exc:`LibError` on failure.
 
 
-   .. method:: CCompiler.link(target_desc, objects, output_filename[, output_dir=None, libraries=None, library_dirs=None, runtime_library_dirs=None, export_symbols=None, debug=0, extra_preargs=None, extra_postargs=None, build_temp=None, target_lang=None])
+   .. method:: CCompiler.link(target_desc, objects, output_filename[, output_dir=None, libraries=None, library_dirs=None, runtime_library_dirs=None, export_symbols=None, debug=False, extra_preargs=None, extra_postargs=None, build_temp=None, target_lang=None])
 
       Link a bunch of stuff together to create an executable or shared library file.
 
@@ -690,21 +690,21 @@ This module provides the following functions.
       Raises :exc:`LinkError` on failure.
 
 
-   .. method:: CCompiler.link_executable(objects, output_progname[, output_dir=None, libraries=None, library_dirs=None, runtime_library_dirs=None, debug=0, extra_preargs=None, extra_postargs=None, target_lang=None])
+   .. method:: CCompiler.link_executable(objects, output_progname[, output_dir=None, libraries=None, library_dirs=None, runtime_library_dirs=None, debug=False, extra_preargs=None, extra_postargs=None, target_lang=None])
 
       Link an executable.  *output_progname* is the name of the file executable, while
       *objects* are a list of object filenames to link in. Other arguments  are as for
       the :meth:`link` method.
 
 
-   .. method:: CCompiler.link_shared_lib(objects, output_libname[, output_dir=None, libraries=None, library_dirs=None, runtime_library_dirs=None, export_symbols=None, debug=0, extra_preargs=None, extra_postargs=None, build_temp=None, target_lang=None])
+   .. method:: CCompiler.link_shared_lib(objects, output_libname[, output_dir=None, libraries=None, library_dirs=None, runtime_library_dirs=None, export_symbols=None, debug=False, extra_preargs=None, extra_postargs=None, build_temp=None, target_lang=None])
 
       Link a shared library. *output_libname* is the name of the output  library,
       while *objects* is a list of object filenames to link in.  Other arguments are
       as for the :meth:`link` method.
 
 
-   .. method:: CCompiler.link_shared_object(objects, output_filename[, output_dir=None, libraries=None, library_dirs=None, runtime_library_dirs=None, export_symbols=None, debug=0, extra_preargs=None, extra_postargs=None, build_temp=None, target_lang=None])
+   .. method:: CCompiler.link_shared_object(objects, output_filename[, output_dir=None, libraries=None, library_dirs=None, runtime_library_dirs=None, export_symbols=None, debug=False, extra_preargs=None, extra_postargs=None, build_temp=None, target_lang=None])
 
       Link a shared object. *output_filename* is the name of the shared object that
       will be created, while *objects* is a list of object filenames  to link in.
@@ -726,14 +726,14 @@ This module provides the following functions.
    use by the various concrete subclasses.
 
 
-   .. method:: CCompiler.executable_filename(basename[, strip_dir=0, output_dir=''])
+   .. method:: CCompiler.executable_filename(basename[, strip_dir=False, output_dir=''])
 
       Returns the filename of the executable for the given *basename*.  Typically for
       non-Windows platforms this is the same as the basename,  while Windows will get
       a :file:`.exe` added.
 
 
-   .. method:: CCompiler.library_filename(libname[, lib_type='static', strip_dir=0, output_dir=''])
+   .. method:: CCompiler.library_filename(libname[, lib_type='static', strip_dir=False, output_dir=''])
 
       Returns the filename for the given library name on the current platform. On Unix
       a library with *lib_type* of ``'static'`` will typically  be of the form
@@ -741,13 +741,13 @@ This module provides the following functions.
       :file:`liblibname.so`.
 
 
-   .. method:: CCompiler.object_filenames(source_filenames[, strip_dir=0, output_dir=''])
+   .. method:: CCompiler.object_filenames(source_filenames[, strip_dir=False, output_dir=''])
 
       Returns the name of the object files for the given source files.
       *source_filenames* should be a list of filenames.
 
 
-   .. method:: CCompiler.shared_object_filename(basename[, strip_dir=0, output_dir=''])
+   .. method:: CCompiler.shared_object_filename(basename[, strip_dir=False, output_dir=''])
 
       Returns the name of a shared object file for the given file name *basename*.
 
@@ -884,7 +884,7 @@ This module provides a few functions for creating archive files, such as
 tarballs or zipfiles.
 
 
-.. function:: make_archive(base_name, format[, root_dir=None, base_dir=None, verbose=0, dry_run=0])
+.. function:: make_archive(base_name, format[, root_dir=None, base_dir=None, verbose=False, dry_run=False])
 
    Create an archive file (eg. ``zip`` or ``tar``).  *base_name*  is the name of
    the file to create, minus any format-specific extension;  *format* is the
@@ -900,7 +900,7 @@ tarballs or zipfiles.
       Added support for the ``xztar`` format.
 
 
-.. function:: make_tarball(base_name, base_dir[, compress='gzip', verbose=0, dry_run=0])
+.. function:: make_tarball(base_name, base_dir[, compress='gzip', verbose=False, dry_run=False])
 
    'Create an (optional compressed) archive as a tar file from all files in and
    under *base_dir*. *compress* must be ``'gzip'`` (the default),
@@ -915,7 +915,7 @@ tarballs or zipfiles.
       Added support for the ``xz`` compression.
 
 
-.. function:: make_zipfile(base_name, base_dir[, verbose=0, dry_run=0])
+.. function:: make_zipfile(base_name, base_dir[, verbose=False, dry_run=False])
 
    Create a zip file from all files in and under *base_dir*.  The output zip file
    will be named *base_name* + :file:`.zip`.  Uses either the  :mod:`zipfile` Python
@@ -978,7 +978,7 @@ This module provides functions for operating on directories and trees of
 directories.
 
 
-.. function:: mkpath(name[, mode=0o777, verbose=0, dry_run=0])
+.. function:: mkpath(name[, mode=0o777, verbose=False, dry_run=False])
 
    Create a directory and any missing ancestor directories.  If the directory
    already exists (or if *name* is the empty string, which means the current
@@ -989,7 +989,7 @@ directories.
    directories actually created.
 
 
-.. function:: create_tree(base_dir, files[, mode=0o777, verbose=0, dry_run=0])
+.. function:: create_tree(base_dir, files[, mode=0o777, verbose=False, dry_run=False])
 
    Create all the empty directories under *base_dir* needed to put *files* there.
    *base_dir* is just the name of a directory which doesn't necessarily exist
@@ -999,7 +999,7 @@ directories.
    :func:`mkpath`.
 
 
-.. function:: copy_tree(src, dst[, preserve_mode=1, preserve_times=1, preserve_symlinks=0, update=0, verbose=0, dry_run=0])
+.. function:: copy_tree(src, dst[, preserve_mode=True, preserve_times=True, preserve_symlinks=False, update=False, verbose=False, dry_run=False])
 
    Copy an entire directory tree *src* to a new location *dst*.  Both *src* and
    *dst* must be directory names.  If *src* is not a directory, raise
@@ -1021,12 +1021,12 @@ directories.
 
    Files in *src* that begin with :file:`.nfs` are skipped (more information on
    these files is available in answer D2 of the `NFS FAQ page
-   `_).
+   `_).
 
    .. versionchanged:: 3.3.1
       NFS files are ignored.
 
-.. function:: remove_tree(directory[, verbose=0, dry_run=0])
+.. function:: remove_tree(directory[, verbose=False, dry_run=False])
 
    Recursively remove *directory* and all files and directories underneath it. Any
    errors are ignored (apart from being reported to ``sys.stdout`` if *verbose* is
@@ -1043,7 +1043,7 @@ directories.
 This module contains some utility functions for operating on individual files.
 
 
-.. function:: copy_file(src, dst[, preserve_mode=1, preserve_times=1, update=0, link=None, verbose=0, dry_run=0])
+.. function:: copy_file(src, dst[, preserve_mode=True, preserve_times=True, update=False, link=None, verbose=False, dry_run=False])
 
    Copy file *src* to *dst*. If *dst* is a directory, then *src* is copied there
    with the same name; otherwise, it must be a filename. (If the file exists, it
@@ -1216,7 +1216,7 @@ other utility module.
    .. % Should probably be moved into the standard library.
 
 
-.. function:: execute(func, args[, msg=None, verbose=0, dry_run=0])
+.. function:: execute(func, args[, msg=None, verbose=False, dry_run=False])
 
    Perform some action that affects the outside world (for instance, writing to the
    filesystem).  Such actions are special because they are disabled by the
@@ -1234,7 +1234,7 @@ other utility module.
    :exc:`ValueError` if *val*  is anything else.
 
 
-.. function:: byte_compile(py_files[, optimize=0, force=0, prefix=None, base_dir=None, verbose=1, dry_run=0, direct=None])
+.. function:: byte_compile(py_files[, optimize=0, force=False, prefix=None, base_dir=None, verbose=True, dry_run=False, direct=None])
 
    Byte-compile a collection of Python source files to :file:`.pyc` files in a
    :file:`__pycache__` subdirectory (see :pep:`3147` and :pep:`488`).
diff --git a/docs/distutils/configfile.rst b/docs/distutils/configfile.rst
index bdd7c455..f6cb1840 100644
--- a/docs/distutils/configfile.rst
+++ b/docs/distutils/configfile.rst
@@ -96,7 +96,7 @@ configuration file for this distribution:
 .. code-block:: ini
 
    [build_ext]
-   inplace=1
+   inplace=true
 
 This will affect all builds of this module distribution, whether or not you
 explicitly specify :command:`build_ext`.  If you include :file:`setup.cfg` in
@@ -131,13 +131,6 @@ Note that the ``doc_files`` option is simply a whitespace-separated string
 split across multiple lines for readability.
 
 
-.. seealso::
-
-   :ref:`inst-config-syntax` in "Installing Python Modules"
-      More information on the configuration files is available in the manual for
-      system administrators.
-
-
 .. rubric:: Footnotes
 
 .. [#] This ideal probably won't be achieved until auto-configuration is fully
diff --git a/docs/distutils/examples.rst b/docs/distutils/examples.rst
index 28582bab..d758a810 100644
--- a/docs/distutils/examples.rst
+++ b/docs/distutils/examples.rst
@@ -335,4 +335,4 @@ loads its values::
 .. % \section{Putting it all together}
 
 
-.. _docutils: http://docutils.sourceforge.net
+.. _docutils: https://docutils.sourceforge.io
diff --git a/docs/distutils/packageindex.rst b/docs/distutils/packageindex.rst
index ccb9a598..27ea717a 100644
--- a/docs/distutils/packageindex.rst
+++ b/docs/distutils/packageindex.rst
@@ -6,11 +6,10 @@
 The Python Package Index (PyPI)
 *******************************
 
-The `Python Package Index (PyPI)`_ stores metadata describing distributions
-packaged with distutils and other publishing tools, as well the distribution
-archives themselves.
+The `Python Package Index (PyPI) `_ stores
+metadata describing distributions packaged with distutils and
+other publishing tools, as well the distribution archives
+themselves.
 
-References to up to date PyPI documentation can be found at
-:ref:`publishing-python-packages`.
-
-.. _Python Package Index (PyPI): https://pypi.org
+The best resource for working with PyPI is the
+`Python Packaging User Guide `_.
diff --git a/docs/distutils/setupscript.rst b/docs/distutils/setupscript.rst
index 3c8e1ab1..825a6aa9 100644
--- a/docs/distutils/setupscript.rst
+++ b/docs/distutils/setupscript.rst
@@ -273,7 +273,7 @@ search path, though, you can find that directory using the Distutils
 :mod:`distutils.sysconfig` module::
 
     from distutils.sysconfig import get_python_inc
-    incdir = os.path.join(get_python_inc(plat_specific=1), 'Numerical')
+    incdir = os.path.join(get_python_inc(plat_specific=True), 'Numerical')
     setup(...,
           Extension(..., include_dirs=[incdir]),
           )
@@ -642,7 +642,7 @@ Notes:
 
 'long string'
     Multiple lines of plain text in reStructuredText format (see
-    http://docutils.sourceforge.net/).
+    https://docutils.sourceforge.io/).
 
 'list of strings'
     See below.
diff --git a/docs/distutils/uploading.rst b/docs/distutils/uploading.rst
index 4c391cab..f5c4c619 100644
--- a/docs/distutils/uploading.rst
+++ b/docs/distutils/uploading.rst
@@ -4,5 +4,6 @@
 Uploading Packages to the Package Index
 ***************************************
 
-References to up to date PyPI documentation can be found at
-:ref:`publishing-python-packages`.
+See the
+`Python Packaging User Guide `_
+for the best guidance on uploading packages.
diff --git a/docs/history.rst b/docs/history.rst
new file mode 100644
index 00000000..5bdc2320
--- /dev/null
+++ b/docs/history.rst
@@ -0,0 +1,8 @@
+:tocdepth: 2
+
+.. _changes:
+
+History
+*******
+
+.. include:: ../NEWS (links).rst
diff --git a/docs/index.rst b/docs/index.rst
new file mode 100644
index 00000000..6b70ccf9
--- /dev/null
+++ b/docs/index.rst
@@ -0,0 +1,21 @@
+Welcome to |project| documentation!
+===================================
+
+.. sidebar-links::
+   :home:
+   :pypi:
+
+.. toctree::
+   :maxdepth: 1
+
+   history
+   distutils/index
+
+
+Indices and tables
+==================
+
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`
+
diff --git a/mypy.ini b/mypy.ini
new file mode 100644
index 00000000..b6f97276
--- /dev/null
+++ b/mypy.ini
@@ -0,0 +1,5 @@
+[mypy]
+ignore_missing_imports = True
+# required to support namespace packages
+# https://github.com/python/mypy/issues/14057
+explicit_package_bases = True
diff --git a/pyproject.toml b/pyproject.toml
index e6863cff..d3321b5d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,11 +1,63 @@
-[tool.black]
-skip-string-normalization = true
+[build-system]
+requires = ["setuptools>=61.2", "setuptools_scm[toml]>=3.4.1"]
+build-backend = "setuptools.build_meta"
 
-[tool.pytest-enabler.black]
-addopts = "--black"
+[project]
+name = "distutils"
+authors = [
+	{ name = "Jason R. Coombs", email = "jaraco@jaraco.com" },
+]
+description = "Distribution utilities formerly from standard library"
+readme = "README.rst"
+classifiers = [
+	"Development Status :: 5 - Production/Stable",
+	"Intended Audience :: Developers",
+	"License :: OSI Approved :: MIT License",
+	"Programming Language :: Python :: 3",
+	"Programming Language :: Python :: 3 :: Only",
+]
+requires-python = ">=3.8"
+dependencies = []
+dynamic = ["version"]
 
-[tool.pytest-enabler.flake8]
-addopts = "--flake8"
+[project.urls]
+Source = "https://github.com/pypa/distutils"
 
-[tool.pytest-enabler.cov]
-addopts = "--cov"
+[project.optional-dependencies]
+test = [
+	# upstream
+	"pytest >= 6, != 8.1.*",
+	"pytest-checkdocs >= 2.4",
+	"pytest-cov",
+	"pytest-mypy",
+	"pytest-enabler >= 2.2",
+	"pytest-ruff >= 0.2.1",
+
+	# local
+	"pytest >= 7.4.3", # 186
+	"jaraco.envs>=2.4",
+	"jaraco.path",
+	"jaraco.text",
+	"path >= 10.6",
+	"docutils",
+	"pyfakefs",
+	"more_itertools",
+
+	# workaround for pytest-dev/pytest#12490
+	"pytest < 8.1; python_version < '3.12'",
+]
+doc = [
+	# upstream
+	"sphinx >= 3.5",
+	"jaraco.packaging >= 9.3",
+	"rst.linker >= 1.9",
+	"furo",
+	"sphinx-lint",
+
+	# local
+]
+
+[tool.setuptools_scm]
+
+[tool.pytest-enabler.mypy]
+# disabled
diff --git a/pytest.ini b/pytest.ini
index 3f8cc25f..dd57c6ef 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -1,18 +1,28 @@
 [pytest]
-addopts=--doctest-modules
+norecursedirs=dist build .tox .eggs
+addopts=
+	--doctest-modules
+	--import-mode importlib
+consider_namespace_packages=true
 filterwarnings=
-	# Suppress deprecation warning in flake8
-	ignore:SelectableGroups dict interface is deprecated::flake8
+	## upstream
 
-	# shopkeep/pytest-black#55
-	ignore: is not using a cooperative constructor:pytest.PytestDeprecationWarning
-	ignore:The \(fspath. py.path.local\) argument to BlackItem is deprecated.:pytest.PytestDeprecationWarning
-	ignore:BlackItem is an Item subclass and should not be a collector:pytest.PytestWarning
+	# Ensure ResourceWarnings are emitted
+	default::ResourceWarning
 
-	# tholo/pytest-flake8#83
-	ignore: is not using a cooperative constructor:pytest.PytestDeprecationWarning
-	ignore:The \(fspath. py.path.local\) argument to Flake8Item is deprecated.:pytest.PytestDeprecationWarning
-	ignore:Flake8Item is an Item subclass and should not be a collector:pytest.PytestWarning
+	# realpython/pytest-mypy#152
+	ignore:'encoding' argument not specified::pytest_mypy
+
+	# python/cpython#100750
+	ignore:'encoding' argument not specified::platform
+
+	# pypa/build#615
+	ignore:'encoding' argument not specified::build.env
+
+	# dateutil/dateutil#1284
+	ignore:datetime.datetime.utcfromtimestamp:DeprecationWarning:dateutil.tz.tz
+
+	## end upstream
 
 	# acknowledge that TestDistribution isn't a test
 	ignore:cannot collect test class 'TestDistribution'
@@ -25,5 +35,12 @@ filterwarnings=
 	# suppress warnings in deprecated compilers
 	ignore:(bcpp|msvc9?)compiler is deprecated
 
-	# suppress well know deprecation warning
+	# suppress well known deprecation warning
 	ignore:distutils.log.Log is deprecated
+
+	# suppress known deprecation
+	ignore:Use shutil.which instead of find_executable:DeprecationWarning
+
+	# https://sourceforge.net/p/docutils/bugs/490/
+	ignore:'encoding' argument not specified::docutils.io
+	ignore:UTF-8 Mode affects locale.getpreferredencoding()::docutils.io
diff --git a/ruff.toml b/ruff.toml
new file mode 100644
index 00000000..3f71515b
--- /dev/null
+++ b/ruff.toml
@@ -0,0 +1,34 @@
+[lint]
+extend-select = [
+	"C901",
+	"W",
+
+	# local
+	"ISC",
+	"RUF010",
+	"RUF100",
+	"UP",
+]
+ignore = [
+	# https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules
+	"W191",
+	"E111",
+	"E114",
+	"E117",
+	"D206",
+	"D300",
+	"Q000",
+	"Q001",
+	"Q002",
+	"Q003",
+	"COM812",
+	"COM819",
+	"ISC001",
+	"ISC002",
+]
+
+[format]
+# Enable preview, required for quote-style = "preserve"
+preview = true
+# https://docs.astral.sh/ruff/settings/#format-quote-style
+quote-style = "preserve"
diff --git a/towncrier.toml b/towncrier.toml
new file mode 100644
index 00000000..6fa480e4
--- /dev/null
+++ b/towncrier.toml
@@ -0,0 +1,2 @@
+[tool.towncrier]
+title_format = "{version}"
diff --git a/tox.ini b/tox.ini
index c4200483..d4bcc416 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,32 +1,63 @@
-[tox]
-minversion = 3.25
-toxworkdir={env:TOX_WORK_DIR:.tox}
-
-
 [testenv]
+description = perform primary checks (tests, style, types, coverage)
 deps =
-	# < 7.2 due to pypa/distutils#186
-	pytest < 7.2
+setenv =
+	PYTHONWARNDEFAULTENCODING = 1
+	# pypa/distutils#99
+	VIRTUALENV_NO_SETUPTOOLS = 1
+commands =
+	pytest {posargs}
+usedevelop = True
+extras =
+	test
 
-	pytest-flake8
-	# workaround for tholo/pytest-flake8#87
-    flake8 < 5
+[testenv:diffcov]
+description = run tests and check that diff from main is covered
+deps =
+	{[testenv]deps}
+	diff-cover
+commands =
+	pytest {posargs} --cov-report xml
+	diff-cover coverage.xml --compare-branch=origin/main --html-report diffcov.html
+	diff-cover coverage.xml --compare-branch=origin/main --fail-under=100
 
-	pytest-black
-	pytest-cov
-	pytest-enabler >= 1.3
+[testenv:docs]
+description = build the documentation
+extras =
+	doc
+	test
+changedir = docs
+commands =
+	python -m sphinx -W --keep-going . {toxinidir}/build/html
+	python -m sphinxlint \
+		# workaround for sphinx-contrib/sphinx-lint#83
+		--jobs 1
 
-	jaraco.envs>=2.4
-	jaraco.path
-	jaraco.text
-	path
-	docutils
-	pyfakefs
-	more_itertools
+[testenv:finalize]
+description = assemble changelog and tag a release
+skip_install = True
+deps =
+	towncrier
+	jaraco.develop >= 7.23
+pass_env = *
 commands =
-	pytest {posargs}
-setenv =
-	PYTHONPATH = {toxinidir}
-	# pypa/distutils#99
-	VIRTUALENV_NO_SETUPTOOLS = 1
+	python -m jaraco.develop.finalize
+
+
+[testenv:release]
+description = publish the package to PyPI and GitHub
 skip_install = True
+deps =
+	build
+	twine>=3
+	jaraco.develop>=7.1
+pass_env =
+	TWINE_PASSWORD
+	GITHUB_TOKEN
+setenv =
+	TWINE_USERNAME = {env:TWINE_USERNAME:__token__}
+commands =
+	python -c "import shutil; shutil.rmtree('dist', ignore_errors=True)"
+	python -m build
+	python -m twine upload dist/*
+	python -m jaraco.develop.create-github-release