diff --git a/.bumpversion.cfg b/.bumpversion.cfg index e21bc31417..384a18455d 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 75.6.0 +current_version = 75.8.0 commit = True tag = True diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index db8c150173..c9de26d330 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -90,6 +90,10 @@ jobs: if: steps.cache.outputs.cache-hit != 'true' working-directory: setuptools/tests/config run: python -m downloads.preload setupcfg_examples.txt + - name: Adjust env vars + shell: bash + run: | + echo 'PIPX_DEFAULT_PYTHON=${{ steps.python-install.outputs.python-path }}' >> $GITHUB_ENV - name: Pre-build distributions for test shell: bash run: | diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 04870d16bf..aecc11eb22 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.1 + rev: v0.8.0 hooks: - id: ruff args: [--fix, --unsafe-fixes] diff --git a/NEWS.rst b/NEWS.rst index 326fdac650..e9e795005a 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,3 +1,24 @@ +v75.8.0 +======= + +Features +-------- + +- Implemented ``Dynamic`` field for core metadata (as introduced in PEP 643). + The existing implementation is currently experimental and the exact approach + may change in future releases. (#4698) + + +v75.7.0 +======= + +Features +-------- + +- Synced with pypa/distutils@c97a3db2f including better support for free threaded Python on Windows (pypa/distutils#310), improved typing support, and linter accommodations. (#4478) +- Synced with pypa/distutils@ff11eed0c including bugfix for duplicate CFLAGS and adaption to support Python 3.13 is_abs in the C compiler (#4669). (#4790) + + v75.6.0 ======= diff --git a/docs/userguide/extension.rst b/docs/userguide/extension.rst index e1e37b5db1..ef5e33f3a8 100644 --- a/docs/userguide/extension.rst +++ b/docs/userguide/extension.rst @@ -122,7 +122,7 @@ a non-``None`` value. Here's an example validation function:: """Verify that value is True, False, 0, or 1""" if bool(value) != value: raise SetupError( - "%r must be a boolean value (got %r)" % (attr,value) + f"{attr!r} must be a boolean value (got {value!r}" ) Your function should accept three arguments: the ``Distribution`` object, diff --git a/docs/userguide/quickstart.rst b/docs/userguide/quickstart.rst index d303ab9355..606654f86c 100644 --- a/docs/userguide/quickstart.rst +++ b/docs/userguide/quickstart.rst @@ -199,7 +199,7 @@ Package discovery ----------------- For projects that follow a simple directory structure, ``setuptools`` should be able to automatically detect all :term:`packages ` and -:term:`namespaces `. However, complex projects might include +:term:`namespaces `. However, complex projects might include additional folders and supporting files that not necessarily should be distributed (or that can confuse ``setuptools`` auto discovery algorithm). diff --git a/mypy.ini b/mypy.ini index 57e19efa9e..c1d01a42c3 100644 --- a/mypy.ini +++ b/mypy.ini @@ -58,7 +58,7 @@ ignore_missing_imports = True # - wheel: does not intend on exposing a programmatic API https://github.com/pypa/wheel/pull/610#issuecomment-2081687671 [mypy-wheel.*] -ignore_missing_imports = True +follow_untyped_imports = True # - The following are not marked as py.typed: # - jaraco: Since mypy 1.12, the root name of the untyped namespace package gets called-out too # - jaraco.develop: https://github.com/jaraco/jaraco.develop/issues/22 @@ -66,8 +66,8 @@ ignore_missing_imports = True # - jaraco.packaging: https://github.com/jaraco/jaraco.packaging/issues/20 # - jaraco.path: https://github.com/jaraco/jaraco.path/issues/2 # - jaraco.text: https://github.com/jaraco/jaraco.text/issues/17 -[mypy-jaraco,jaraco.develop,jaraco.envs,jaraco.packaging.*,jaraco.path,jaraco.text] -ignore_missing_imports = True +[mypy-jaraco,jaraco.develop.*,jaraco.envs,jaraco.packaging.*,jaraco.path,jaraco.text] +follow_untyped_imports = True # Even when excluding a module, import issues can show up due to following import # https://github.com/python/mypy/issues/11936#issuecomment-1466764006 diff --git a/pkg_resources/__init__.py b/pkg_resources/__init__.py index 74b0465bfa..68feeb0593 100644 --- a/pkg_resources/__init__.py +++ b/pkg_resources/__init__.py @@ -200,7 +200,9 @@ def get_supported_platform(): m = macosVersionString.match(plat) if m is not None and sys.platform == "darwin": try: - plat = 'macosx-%s-%s' % ('.'.join(_macos_vers()[:2]), m.group(3)) + major_minor = '.'.join(_macos_vers()[:2]) + build = m.group(3) + plat = f'macosx-{major_minor}-{build}' except ValueError: # not macOS pass @@ -449,12 +451,8 @@ def get_build_platform(): if sys.platform == "darwin" and not plat.startswith('macosx-'): try: version = _macos_vers() - machine = os.uname()[4].replace(" ", "_") - return "macosx-%d.%d-%s" % ( - int(version[0]), - int(version[1]), - _macos_arch(machine), - ) + machine = _macos_arch(os.uname()[4].replace(" ", "_")) + return f"macosx-{version[0]}.{version[1]}-{machine}" except ValueError: # if someone is running a non-Mac darwin system, this will fall # through to the default implementation @@ -492,7 +490,7 @@ def compatible_platforms(provided: str | None, required: str | None) -> bool: provDarwin = darwinVersionString.match(provided) if provDarwin: dversion = int(provDarwin.group(1)) - macosversion = "%s.%s" % (reqMac.group(1), reqMac.group(2)) + macosversion = f"{reqMac.group(1)}.{reqMac.group(2)}" if ( dversion == 7 and macosversion >= "10.3" @@ -875,9 +873,7 @@ def resolve( # Mapping of requirement to set of distributions that required it; # useful for reporting info about conflicts. - required_by: collections.defaultdict[Requirement, set[str]] = ( - collections.defaultdict(set) - ) + required_by = collections.defaultdict[Requirement, set[str]](set) while requirements: # process dependencies breadth-first @@ -1316,7 +1312,7 @@ def __iadd__(self, other: Distribution | Environment) -> Self: for dist in other[project]: self.add(dist) else: - raise TypeError("Can't add %r to environment" % (other,)) + raise TypeError(f"Can't add {other!r} to environment") return self def __add__(self, other: Distribution | Environment) -> Self: @@ -1699,7 +1695,7 @@ def get_metadata(self, name: str) -> str: except UnicodeDecodeError as exc: # Include the path in the error message to simplify # troubleshooting, and without changing the exception type. - exc.reason += ' in {} file at path: {}'.format(name, path) + exc.reason += f' in {name} file at path: {path}' raise def get_metadata_lines(self, name: str) -> Iterator[str]: @@ -2018,7 +2014,7 @@ def _zipinfo_name(self, fspath): return '' if fspath.startswith(self.zip_pre): return fspath[len(self.zip_pre) :] - raise AssertionError("%s is not a subpath of %s" % (fspath, self.zip_pre)) + raise AssertionError(f"{fspath} is not a subpath of {self.zip_pre}") def _parts(self, zip_path): # Convert a zipfile subpath into an egg-relative path part list. @@ -2026,7 +2022,7 @@ def _parts(self, zip_path): fspath = self.zip_pre + zip_path if fspath.startswith(self.egg_root + os.sep): return fspath[len(self.egg_root) + 1 :].split(os.sep) - raise AssertionError("%s is not a subpath of %s" % (fspath, self.egg_root)) + raise AssertionError(f"{fspath} is not a subpath of {self.egg_root}") @property def zipinfo(self): @@ -2729,15 +2725,16 @@ def __init__( self.dist = dist def __str__(self) -> str: - s = "%s = %s" % (self.name, self.module_name) + s = f"{self.name} = {self.module_name}" if self.attrs: s += ':' + '.'.join(self.attrs) if self.extras: - s += ' [%s]' % ','.join(self.extras) + extras = ','.join(self.extras) + s += f' [{extras}]' return s def __repr__(self) -> str: - return "EntryPoint.parse(%r)" % str(self) + return f"EntryPoint.parse({str(self)!r})" @overload def load( @@ -3049,9 +3046,7 @@ def version(self): version = self._get_version() if version is None: path = self._get_metadata_path_for_display(self.PKG_INFO) - msg = ("Missing 'Version:' header and/or {} file at path: {}").format( - self.PKG_INFO, path - ) + msg = f"Missing 'Version:' header and/or {self.PKG_INFO} file at path: {path}" raise ValueError(msg, self) from e return version @@ -3107,9 +3102,7 @@ def requires(self, extras: Iterable[str] = ()) -> list[Requirement]: try: deps.extend(dm[safe_extra(ext)]) except KeyError as e: - raise UnknownExtra( - "%s has no such extra feature %r" % (self, ext) - ) from e + raise UnknownExtra(f"{self} has no such extra feature {ext!r}") from e return deps def _get_metadata_path_for_display(self, name): @@ -3150,11 +3143,7 @@ def activate(self, path: list[str] | None = None, replace: bool = False) -> None def egg_name(self): """Return what this distribution's standard .egg filename should be""" - filename = "%s-%s-py%s" % ( - to_filename(self.project_name), - to_filename(self.version), - self.py_version or PY_MAJOR, - ) + filename = f"{to_filename(self.project_name)}-{to_filename(self.version)}-py{self.py_version or PY_MAJOR}" if self.platform: filename += '-' + self.platform @@ -3162,7 +3151,7 @@ def egg_name(self): def __repr__(self) -> str: if self.location: - return "%s (%s)" % (self, self.location) + return f"{self} ({self.location})" else: return str(self) @@ -3172,7 +3161,7 @@ def __str__(self) -> str: except ValueError: version = None version = version or "[unknown version]" - return "%s %s" % (self.project_name, version) + return f"{self.project_name} {version}" def __getattr__(self, attr: str): """Delegate all unrecognized public attributes to .metadata provider""" @@ -3200,9 +3189,9 @@ def from_filename( def as_requirement(self): """Return a ``Requirement`` that matches this distribution exactly""" if isinstance(self.parsed_version, packaging.version.Version): - spec = "%s==%s" % (self.project_name, self.parsed_version) + spec = f"{self.project_name}=={self.parsed_version}" else: - spec = "%s===%s" % (self.project_name, self.parsed_version) + spec = f"{self.project_name}==={self.parsed_version}" return Requirement.parse(spec) @@ -3210,7 +3199,7 @@ def load_entry_point(self, group: str, name: str) -> _ResolvedEntryPoint: """Return the `name` entry point of `group` or raise ImportError""" ep = self.get_entry_info(group, name) if ep is None: - raise ImportError("Entry point %r not found" % ((group, name),)) + raise ImportError(f"Entry point {(group, name)!r} not found") return ep.load() @overload @@ -3327,8 +3316,8 @@ def check_version_conflict(self): ): continue issue_warning( - "Module %s was already imported from %s, but %s is being added" - " to sys.path" % (modname, fn, self.location), + f"Module {modname} was already imported from {fn}, " + f"but {self.location} is being added to sys.path", ) def has_version(self) -> bool: @@ -3512,7 +3501,7 @@ def __hash__(self) -> int: return self.__hash def __repr__(self) -> str: - return "Requirement.parse(%r)" % str(self) + return f"Requirement.parse({str(self)!r})" @staticmethod def parse(s: str | Iterable[str]) -> Requirement: diff --git a/pkg_resources/tests/test_pkg_resources.py b/pkg_resources/tests/test_pkg_resources.py index 2e5526d1aa..0f696e8502 100644 --- a/pkg_resources/tests/test_pkg_resources.py +++ b/pkg_resources/tests/test_pkg_resources.py @@ -214,8 +214,8 @@ def test_get_metadata__bad_utf8(tmpdir): "codec can't decode byte 0xe9 in position 1: " 'invalid continuation byte in METADATA file at path: ' ) - assert expected in actual, 'actual: {}'.format(actual) - assert actual.endswith(metadata_path), 'actual: {}'.format(actual) + assert expected in actual, f'actual: {actual}' + assert actual.endswith(metadata_path), f'actual: {actual}' def make_distribution_no_version(tmpdir, basename): @@ -252,11 +252,11 @@ def test_distribution_version_missing( """ Test Distribution.version when the "Version" header is missing. """ - basename = 'foo.{}'.format(suffix) + basename = f'foo.{suffix}' dist, dist_dir = make_distribution_no_version(tmpdir, basename) - expected_text = ("Missing 'Version:' header and/or {} file at path: ").format( - expected_filename + expected_text = ( + f"Missing 'Version:' header and/or {expected_filename} file at path: " ) metadata_path = os.path.join(dist_dir, expected_filename) diff --git a/pkg_resources/tests/test_working_set.py b/pkg_resources/tests/test_working_set.py index 7bb84952c1..ed20c59dd3 100644 --- a/pkg_resources/tests/test_working_set.py +++ b/pkg_resources/tests/test_working_set.py @@ -104,9 +104,13 @@ def parametrize_test_working_set_resolve(*test_list): ) ) return pytest.mark.parametrize( - 'installed_dists,installable_dists,' - 'requirements,replace_conflicting,' - 'resolved_dists_or_exception', + ( + "installed_dists", + "installable_dists", + "requirements", + "replace_conflicting", + "resolved_dists_or_exception", + ), argvalues, ids=idlist, ) diff --git a/pyproject.toml b/pyproject.toml index a19d4ac164..a9febdbe8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ backend-path = ["."] [project] name = "setuptools" -version = "75.6.0" +version = "75.8.0" authors = [ { name = "Python Packaging Authority", email = "distutils-sig@python.org" }, ] @@ -44,7 +44,7 @@ test = [ "packaging>=24.2", "jaraco.envs>=2.2", "pytest-xdist>=3", # Dropped dependency on pytest-fork and py - "jaraco.path>=3.2.0", + "jaraco.path>=3.7.2", # Typing fixes "build[virtualenv]>=1.0.3", "filelock>=3.4.0", "ini2toml[lite]>=0.14", @@ -102,7 +102,7 @@ core = [ # for distutils "jaraco.collections", - "jaraco.functools>=4", + "jaraco.functools >= 4", "packaging", "more_itertools", ] @@ -114,8 +114,8 @@ check = [ # local - # changed defaults for PT001 and PT023 astral-sh/ruff#13292 - "ruff >= 0.7.0; sys_platform != 'cygwin'", + # Removal of deprecated UP027, PT004 & PT005 astral-sh/ruff#14383 + "ruff >= 0.8.0; sys_platform != 'cygwin'", ] cover = [ @@ -135,7 +135,7 @@ type = [ # pin mypy version so a new version doesn't suddenly cause the CI to fail, # until types-setuptools is removed from typeshed. # For help with static-typing issues, or mypy update, ping @Avasam - "mypy>=1.12,<1.14", + "mypy==1.14.*", # Typing fixes in version newer than we require at runtime "importlib_metadata>=7.0.2; python_version < '3.10'", # Imported unconditionally in tools/finalize.py diff --git a/ruff.toml b/ruff.toml index 9f20438943..b9c4a8f569 100644 --- a/ruff.toml +++ b/ruff.toml @@ -29,18 +29,12 @@ extend-select = [ ] ignore = [ "PERF203", # try-except-in-loop, micro-optimisation with many false-positive. Worth checking but don't block CI - "PT004", # deprecated https://github.com/astral-sh/ruff/issues/8796#issuecomment-2057143531 - "PT005", # deprecated https://github.com/astral-sh/ruff/issues/8796#issuecomment-2057143531 "PT007", # temporarily disabled, TODO: configure and standardize to preference "PT011", # temporarily disabled, TODO: tighten expected error "PT012", # pytest-raises-with-multiple-statements, avoid extra dummy methods for a few lines, sometimes we explicitly assert in case of no error "TRY003", # raise-vanilla-args, avoid multitude of exception classes "TRY301", # raise-within-try, it's handy "UP015", # redundant-open-modes, explicit is preferred - "UP027", # unpacked-list-comprehension, is actually slower for cases relevant to unpacking, set for deprecation: https://github.com/astral-sh/ruff/issues/12754 - "UP030", # temporarily disabled - "UP031", # temporarily disabled - "UP032", # temporarily disabled "UP038", # Using `X | Y` in `isinstance` call is slower and more verbose https://github.com/astral-sh/ruff/issues/7871 # Only enforcing return type annotations for public functions "ANN202", # missing-return-type-private-function diff --git a/setuptools/__init__.py b/setuptools/__init__.py index 4f5c01708a..64464dfaa3 100644 --- a/setuptools/__init__.py +++ b/setuptools/__init__.py @@ -181,9 +181,7 @@ def _ensure_stringlike(self, option, what, default=None): setattr(self, option, default) return default elif not isinstance(val, str): - raise DistutilsOptionError( - "'%s' must be a %s (got `%s`)" % (option, what, val) - ) + raise DistutilsOptionError(f"'{option}' must be a {what} (got `{val}`)") return val def ensure_string_list(self, option: str) -> None: @@ -210,7 +208,7 @@ def ensure_string_list(self, option: str) -> None: ok = False if not ok: raise DistutilsOptionError( - "'%s' must be a list of strings (got %r)" % (option, val) + f"'{option}' must be a list of strings (got {val!r})" ) @overload diff --git a/setuptools/_core_metadata.py b/setuptools/_core_metadata.py index 2e9c48a77b..850cc409f7 100644 --- a/setuptools/_core_metadata.py +++ b/setuptools/_core_metadata.py @@ -19,6 +19,7 @@ from packaging.version import Version from . import _normalization, _reqs +from ._static import is_static from .warnings import SetuptoolsDeprecationWarning from distutils.util import rfc822_escape @@ -27,7 +28,7 @@ def get_metadata_version(self): mv = getattr(self, 'metadata_version', None) if mv is None: - mv = Version('2.1') + mv = Version('2.2') self.metadata_version = mv return mv @@ -150,7 +151,7 @@ def write_pkg_file(self, file): # noqa: C901 # is too complex (14) # FIXME version = self.get_metadata_version() def write_field(key, value): - file.write("%s: %s\n" % (key, value)) + file.write(f"{key}: {value}\n") write_field('Metadata-Version', str(version)) write_field('Name', self.get_name()) @@ -178,8 +179,8 @@ def write_field(key, value): if license: write_field('License', rfc822_escape(license)) - for project_url in self.project_urls.items(): - write_field('Project-URL', '%s, %s' % project_url) + for label, url in self.project_urls.items(): + write_field('Project-URL', f'{label}, {url}') keywords = ','.join(self.get_keywords()) if keywords: @@ -207,9 +208,13 @@ def write_field(key, value): self._write_list(file, 'License-File', self.license_files or []) _write_requirements(self, file) + for field, attr in _POSSIBLE_DYNAMIC_FIELDS.items(): + if (val := getattr(self, attr, None)) and not is_static(val): + write_field('Dynamic', field) + long_description = self.get_long_description() if long_description: - file.write("\n%s" % long_description) + file.write(f"\n{long_description}") if not long_description.endswith("\n"): file.write("\n") @@ -284,3 +289,33 @@ def _distribution_fullname(name: str, version: str) -> str: canonicalize_name(name).replace('-', '_'), canonicalize_version(version, strip_trailing_zero=False), ) + + +_POSSIBLE_DYNAMIC_FIELDS = { + # Core Metadata Field x related Distribution attribute + "author": "author", + "author-email": "author_email", + "classifier": "classifiers", + "description": "long_description", + "description-content-type": "long_description_content_type", + "download-url": "download_url", + "home-page": "url", + "keywords": "keywords", + "license": "license", + # "license-file": "license_files", # XXX: does PEP 639 exempt Dynamic ?? + "maintainer": "maintainer", + "maintainer-email": "maintainer_email", + "obsoletes": "obsoletes", + # "obsoletes-dist": "obsoletes_dist", # NOT USED + "platform": "platforms", + "project-url": "project_urls", + "provides": "provides", + # "provides-dist": "provides_dist", # NOT USED + "provides-extra": "extras_require", + "requires": "requires", + "requires-dist": "install_requires", + # "requires-external": "requires_external", # NOT USED + "requires-python": "python_requires", + "summary": "description", + # "supported-platform": "supported_platforms", # NOT USED +} diff --git a/setuptools/_distutils/ccompiler.py b/setuptools/_distutils/ccompiler.py index 5e73e56d02..714f13d8d3 100644 --- a/setuptools/_distutils/ccompiler.py +++ b/setuptools/_distutils/ccompiler.py @@ -4,6 +4,7 @@ for the Distutils compiler abstraction model.""" import os +import pathlib import re import sys import types @@ -969,27 +970,33 @@ def out_extensions(self): return dict.fromkeys(self.src_extensions, self.obj_extension) def _make_out_path(self, output_dir, strip_dir, src_name): - base, ext = os.path.splitext(src_name) - base = self._make_relative(base) + return self._make_out_path_exts( + output_dir, strip_dir, src_name, self.out_extensions + ) + + @classmethod + def _make_out_path_exts(cls, output_dir, strip_dir, src_name, extensions): + r""" + >>> exts = {'.c': '.o'} + >>> CCompiler._make_out_path_exts('.', False, '/foo/bar.c', exts).replace('\\', '/') + './foo/bar.o' + >>> CCompiler._make_out_path_exts('.', True, '/foo/bar.c', exts).replace('\\', '/') + './bar.o' + """ + src = pathlib.PurePath(src_name) + # Ensure base is relative to honor output_dir (python/cpython#37775). + base = cls._make_relative(src) try: - new_ext = self.out_extensions[ext] + new_ext = extensions[src.suffix] except LookupError: - raise UnknownFileError(f"unknown file type '{ext}' (from '{src_name}')") + raise UnknownFileError(f"unknown file type '{src.suffix}' (from '{src}')") if strip_dir: - base = os.path.basename(base) - return os.path.join(output_dir, base + new_ext) + base = pathlib.PurePath(base.name) + return os.path.join(output_dir, base.with_suffix(new_ext)) @staticmethod - def _make_relative(base): - """ - In order to ensure that a filename always honors the - indicated output_dir, make sure it's relative. - Ref python/cpython#37775. - """ - # Chop off the drive - no_drive = os.path.splitdrive(base)[1] - # If abs, chop off leading / - return no_drive[os.path.isabs(no_drive) :] + def _make_relative(base: pathlib.Path): + return base.relative_to(base.anchor) def shared_object_filename(self, basename, strip_dir=False, output_dir=''): assert output_dir is not None diff --git a/setuptools/_distutils/cmd.py b/setuptools/_distutils/cmd.py index 2bb97956ab..9c6fa6566c 100644 --- a/setuptools/_distutils/cmd.py +++ b/setuptools/_distutils/cmd.py @@ -4,15 +4,21 @@ in the distutils.command package. """ +from __future__ import annotations + import logging import os import re import sys +from collections.abc import Callable +from typing import Any, ClassVar, TypeVar, overload from . import _modified, archive_util, dir_util, file_util, util from ._log import log from .errors import DistutilsOptionError +_CommandT = TypeVar("_CommandT", bound="Command") + class Command: """Abstract base class for defining command classes, the "worker bees" @@ -44,7 +50,14 @@ class Command: # 'sub_commands' is usually defined at the *end* of a class, because # predicates can be unbound methods, so they must already have been # defined. The canonical example is the "install" command. - sub_commands = [] + sub_commands: ClassVar[ # Any to work around variance issues + list[tuple[str, Callable[[Any], bool] | None]] + ] = [] + + user_options: ClassVar[ + # Specifying both because list is invariant. Avoids mypy override assignment issues + list[tuple[str, str, str]] | list[tuple[str, str | None, str]] + ] = [] # -- Creation/initialization methods ------------------------------- @@ -305,7 +318,17 @@ def get_finalized_command(self, command, create=True): # XXX rename to 'get_reinitialized_command()'? (should do the # same in dist.py, if so) - def reinitialize_command(self, command, reinit_subcommands=False): + @overload + def reinitialize_command( + self, command: str, reinit_subcommands: bool = False + ) -> Command: ... + @overload + def reinitialize_command( + self, command: _CommandT, reinit_subcommands: bool = False + ) -> _CommandT: ... + def reinitialize_command( + self, command: str | Command, reinit_subcommands=False + ) -> Command: return self.distribution.reinitialize_command(command, reinit_subcommands) def run_command(self, command): diff --git a/setuptools/_distutils/command/bdist.py b/setuptools/_distutils/command/bdist.py index f334075159..1ec3c35f40 100644 --- a/setuptools/_distutils/command/bdist.py +++ b/setuptools/_distutils/command/bdist.py @@ -5,6 +5,7 @@ import os import warnings +from typing import ClassVar from ..core import Command from ..errors import DistutilsOptionError, DistutilsPlatformError @@ -23,7 +24,7 @@ def show_formats(): pretty_printer.print_help("List of available distribution formats:") -class ListCompat(dict): +class ListCompat(dict[str, tuple[str, str]]): # adapter to allow for Setuptools compatibility in format_commands def append(self, item): warnings.warn( @@ -70,7 +71,7 @@ class bdist(Command): ] # The following commands do not take a format option from bdist - no_format_option = ('bdist_rpm',) + no_format_option: ClassVar[tuple[str, ...]] = ('bdist_rpm',) # This won't do in reality: will need to distinguish RPM-ish Linux, # Debian-ish Linux, Solaris, FreeBSD, ..., Windows, Mac OS. diff --git a/setuptools/_distutils/command/build.py b/setuptools/_distutils/command/build.py index caf55073af..ccd2c706a3 100644 --- a/setuptools/_distutils/command/build.py +++ b/setuptools/_distutils/command/build.py @@ -113,7 +113,8 @@ def finalize_options(self): # noqa: C901 self.build_temp = os.path.join(self.build_base, 'temp' + plat_specifier) if self.build_scripts is None: self.build_scripts = os.path.join( - self.build_base, 'scripts-%d.%d' % sys.version_info[:2] + self.build_base, + f'scripts-{sys.version_info.major}.{sys.version_info.minor}', ) if self.executable is None and sys.executable: diff --git a/setuptools/_distutils/command/build_clib.py b/setuptools/_distutils/command/build_clib.py index a600d09373..3e1832768b 100644 --- a/setuptools/_distutils/command/build_clib.py +++ b/setuptools/_distutils/command/build_clib.py @@ -16,6 +16,7 @@ import os from distutils._log import log +from typing import ClassVar from ..core import Command from ..errors import DistutilsSetupError @@ -31,7 +32,7 @@ def show_compilers(): class build_clib(Command): description = "build C/C++ libraries used by Python extensions" - user_options = [ + user_options: ClassVar[list[tuple[str, str, str]]] = [ ('build-clib=', 'b', "directory to build C/C++ libraries to"), ('build-temp=', 't', "directory to put temporary build by-products"), ('debug', 'g', "compile with debugging information"), @@ -138,8 +139,7 @@ def check_library_list(self, libraries): if '/' in name or (os.sep != '/' and os.sep in name): raise DistutilsSetupError( - f"bad library name '{lib[0]}': " - "may not contain directory separators" + f"bad library name '{lib[0]}': may not contain directory separators" ) if not isinstance(build_info, dict): diff --git a/setuptools/_distutils/command/build_ext.py b/setuptools/_distutils/command/build_ext.py index a7e3038be6..cf60bd0ad8 100644 --- a/setuptools/_distutils/command/build_ext.py +++ b/setuptools/_distutils/command/build_ext.py @@ -23,7 +23,7 @@ ) from ..extension import Extension from ..sysconfig import customize_compiler, get_config_h_filename, get_python_version -from ..util import get_platform, is_mingw +from ..util import get_platform, is_freethreaded, is_mingw # An extension name is just a dot-separated list of Python NAMEs (ie. # the same as a fully-qualified module name). @@ -333,6 +333,12 @@ def run(self): # noqa: C901 if os.name == 'nt' and self.plat_name != get_platform(): self.compiler.initialize(self.plat_name) + # The official Windows free threaded Python installer doesn't set + # Py_GIL_DISABLED because its pyconfig.h is shared with the + # default build, so define it here (pypa/setuptools#4662). + if os.name == 'nt' and is_freethreaded(): + self.compiler.define_macro('Py_GIL_DISABLED', '1') + # And make sure that any compile/link-related options (which might # come from the command-line or from the setup script) are set in # that CCompiler object -- that way, they automatically apply to @@ -437,8 +443,7 @@ def check_extensions_list(self, extensions): # noqa: C901 for macro in macros: if not (isinstance(macro, tuple) and len(macro) in (1, 2)): raise DistutilsSetupError( - "'macros' element of build info dict " - "must be 1- or 2-tuple" + "'macros' element of build info dict must be 1- or 2-tuple" ) if len(macro) == 1: ext.undef_macros.append(macro[0]) @@ -666,8 +671,7 @@ def find_swig(self): return "swig.exe" else: raise DistutilsPlatformError( - "I don't know how to find (much less run) SWIG " - f"on platform '{os.name}'" + f"I don't know how to find (much less run) SWIG on platform '{os.name}'" ) # -- Name generators ----------------------------------------------- diff --git a/setuptools/_distutils/command/build_scripts.py b/setuptools/_distutils/command/build_scripts.py index 9e5963c243..1c6fd3caff 100644 --- a/setuptools/_distutils/command/build_scripts.py +++ b/setuptools/_distutils/command/build_scripts.py @@ -8,6 +8,7 @@ from distutils import sysconfig from distutils._log import log from stat import ST_MODE +from typing import ClassVar from .._modified import newer from ..core import Command @@ -25,7 +26,7 @@ class build_scripts(Command): description = "\"build\" scripts (copy and fixup #! line)" - user_options = [ + user_options: ClassVar[list[tuple[str, str, str]]] = [ ('build-dir=', 'd', "directory to \"build\" (copy) to"), ('force', 'f', "forcibly build everything (ignore file timestamps"), ('executable=', 'e', "specify final destination interpreter path"), diff --git a/setuptools/_distutils/command/check.py b/setuptools/_distutils/command/check.py index 93d754e73d..078c1ce87e 100644 --- a/setuptools/_distutils/command/check.py +++ b/setuptools/_distutils/command/check.py @@ -4,6 +4,7 @@ """ import contextlib +from typing import ClassVar from ..core import Command from ..errors import DistutilsSetupError @@ -41,15 +42,12 @@ class check(Command): """This command checks the meta-data of the package.""" description = "perform some checks on the package" - user_options = [ + user_options: ClassVar[list[tuple[str, str, str]]] = [ ('metadata', 'm', 'Verify meta-data'), ( 'restructuredtext', 'r', - ( - 'Checks if long string meta-data syntax ' - 'are reStructuredText-compliant' - ), + 'Checks if long string meta-data syntax are reStructuredText-compliant', ), ('strict', 's', 'Will exit with an error if a check fails'), ] diff --git a/setuptools/_distutils/command/command_template b/setuptools/_distutils/command/command_template index 6106819db8..a4a751ad3c 100644 --- a/setuptools/_distutils/command/command_template +++ b/setuptools/_distutils/command/command_template @@ -8,18 +8,18 @@ Implements the Distutils 'x' command. __revision__ = "$Id$" from distutils.core import Command +from typing import ClassVar class x(Command): - # Brief (40-50 characters) description of the command description = "" # List of option tuples: long name, short name (None if no short # name), and help string. - user_options = [('', '', - ""), - ] + user_options: ClassVar[list[tuple[str, str, str]]] = [ + ('', '', ""), + ] def initialize_options(self): self. = None diff --git a/setuptools/_distutils/command/install.py b/setuptools/_distutils/command/install.py index ceb453e041..9400995024 100644 --- a/setuptools/_distutils/command/install.py +++ b/setuptools/_distutils/command/install.py @@ -407,8 +407,8 @@ def finalize_options(self): # noqa: C901 'dist_version': self.distribution.get_version(), 'dist_fullname': self.distribution.get_fullname(), 'py_version': py_version, - 'py_version_short': '%d.%d' % sys.version_info[:2], - 'py_version_nodot': '%d%d' % sys.version_info[:2], + 'py_version_short': f'{sys.version_info.major}.{sys.version_info.minor}', + 'py_version_nodot': f'{sys.version_info.major}{sys.version_info.minor}', 'sys_prefix': prefix, 'prefix': prefix, 'sys_exec_prefix': exec_prefix, diff --git a/setuptools/_distutils/command/install_data.py b/setuptools/_distutils/command/install_data.py index a90ec3b4d0..36f5bcc8bf 100644 --- a/setuptools/_distutils/command/install_data.py +++ b/setuptools/_distutils/command/install_data.py @@ -9,7 +9,7 @@ import functools import os -from typing import Iterable +from collections.abc import Iterable from ..core import Command from ..util import change_root, convert_path @@ -22,8 +22,7 @@ class install_data(Command): ( 'install-dir=', 'd', - "base directory for installing data files " - "[default: installation base dir]", + "base directory for installing data files [default: installation base dir]", ), ('root=', None, "install everything relative to this alternate root directory"), ('force', 'f', "force installation (overwrite existing files)"), diff --git a/setuptools/_distutils/command/install_egg_info.py b/setuptools/_distutils/command/install_egg_info.py index 4fbb3440ab..230e94ab46 100644 --- a/setuptools/_distutils/command/install_egg_info.py +++ b/setuptools/_distutils/command/install_egg_info.py @@ -8,6 +8,7 @@ import os import re import sys +from typing import ClassVar from .. import dir_util from .._log import log @@ -18,7 +19,7 @@ class install_egg_info(Command): """Install an .egg-info file for the package""" description = "Install package's PKG-INFO metadata as an .egg-info file" - user_options = [ + user_options: ClassVar[list[tuple[str, str, str]]] = [ ('install-dir=', 'd', "directory to install to"), ] @@ -31,11 +32,9 @@ def basename(self): Allow basename to be overridden by child class. Ref pypa/distutils#2. """ - return "%s-%s-py%d.%d.egg-info" % ( - to_filename(safe_name(self.distribution.get_name())), - to_filename(safe_version(self.distribution.get_version())), - *sys.version_info[:2], - ) + name = to_filename(safe_name(self.distribution.get_name())) + version = to_filename(safe_version(self.distribution.get_version())) + return f"{name}-{version}-py{sys.version_info.major}.{sys.version_info.minor}.egg-info" def finalize_options(self): self.set_undefined_options('install_lib', ('install_dir', 'install_dir')) diff --git a/setuptools/_distutils/command/install_headers.py b/setuptools/_distutils/command/install_headers.py index fbb3b242ea..586121e089 100644 --- a/setuptools/_distutils/command/install_headers.py +++ b/setuptools/_distutils/command/install_headers.py @@ -3,6 +3,8 @@ Implements the Distutils 'install_headers' command, to install C/C++ header files to the Python include directory.""" +from typing import ClassVar + from ..core import Command @@ -10,7 +12,7 @@ class install_headers(Command): description = "install C/C++ header files" - user_options = [ + user_options: ClassVar[list[tuple[str, str, str]]] = [ ('install-dir=', 'd', "directory to install header files to"), ('force', 'f', "force installation (overwrite existing files)"), ] diff --git a/setuptools/_distutils/command/sdist.py b/setuptools/_distutils/command/sdist.py index d723a1c9fb..acb3a41650 100644 --- a/setuptools/_distutils/command/sdist.py +++ b/setuptools/_distutils/command/sdist.py @@ -8,6 +8,7 @@ from distutils._log import log from glob import glob from itertools import filterfalse +from typing import ClassVar from ..core import Command from ..errors import DistutilsOptionError, DistutilsTemplateError @@ -114,7 +115,7 @@ def checking_metadata(self): sub_commands = [('check', checking_metadata)] - READMES = ('README', 'README.txt', 'README.rst') + READMES: ClassVar[tuple[str, ...]] = ('README', 'README.txt', 'README.rst') def initialize_options(self): # 'template' and 'manifest' are, respectively, the names of @@ -362,8 +363,7 @@ def read_template(self): # convert_path function except (DistutilsTemplateError, ValueError) as msg: self.warn( - "%s, line %d: %s" - % (template.filename, template.current_line, msg) + f"{template.filename}, line {int(template.current_line)}: {msg}" ) finally: template.close() diff --git a/setuptools/_distutils/compat/__init__.py b/setuptools/_distutils/compat/__init__.py index e12534a32c..c715ee9cc5 100644 --- a/setuptools/_distutils/compat/__init__.py +++ b/setuptools/_distutils/compat/__init__.py @@ -1,7 +1,5 @@ from __future__ import annotations -from .py38 import removeprefix - def consolidate_linker_args(args: list[str]) -> list[str] | str: """ @@ -12,4 +10,4 @@ def consolidate_linker_args(args: list[str]) -> list[str] | str: if not all(arg.startswith('-Wl,') for arg in args): return args - return '-Wl,' + ','.join(removeprefix(arg, '-Wl,') for arg in args) + return '-Wl,' + ','.join(arg.removeprefix('-Wl,') for arg in args) diff --git a/setuptools/_distutils/compat/py38.py b/setuptools/_distutils/compat/py38.py deleted file mode 100644 index afe5345553..0000000000 --- a/setuptools/_distutils/compat/py38.py +++ /dev/null @@ -1,34 +0,0 @@ -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 - - return _aix_support.aix_platform() - except ImportError: - pass - return f"{osname}-{version}.{release}" diff --git a/setuptools/_distutils/core.py b/setuptools/_distutils/core.py index bc06091abb..bd62546bdd 100644 --- a/setuptools/_distutils/core.py +++ b/setuptools/_distutils/core.py @@ -6,9 +6,12 @@ really defined in distutils.dist and distutils.cmd. """ +from __future__ import annotations + import os import sys import tokenize +from collections.abc import Iterable from .cmd import Command from .debug import DEBUG @@ -215,7 +218,7 @@ def run_commands(dist): return dist -def run_setup(script_name, script_args=None, stop_after="run"): +def run_setup(script_name, script_args: Iterable[str] | None = None, stop_after="run"): """Run a setup script in a somewhat controlled environment, and return the Distribution instance that drives things. This is useful if you need to find out the distribution meta-data (passed as diff --git a/setuptools/_distutils/dist.py b/setuptools/_distutils/dist.py index 154301baff..33ed8ebd7a 100644 --- a/setuptools/_distutils/dist.py +++ b/setuptools/_distutils/dist.py @@ -4,6 +4,8 @@ being built/installed/distributed. """ +from __future__ import annotations + import contextlib import logging import os @@ -13,6 +15,7 @@ import warnings from collections.abc import Iterable from email import message_from_file +from typing import TYPE_CHECKING, Literal, TypeVar, overload from packaging.utils import canonicalize_name, canonicalize_version @@ -27,6 +30,12 @@ from .fancy_getopt import FancyGetopt, translate_longopt from .util import check_environ, rfc822_escape, strtobool +if TYPE_CHECKING: + # type-only import because of mutual dependence between these modules + from .cmd import Command + +_CommandT = TypeVar("_CommandT", bound="Command") + # 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 # that they're very similar is no coincidence; the default naming scheme is @@ -139,7 +148,7 @@ def __init__(self, attrs=None): # noqa: C901 self.dry_run = False self.help = False for attr in self.display_option_names: - setattr(self, attr, 0) + setattr(self, attr, False) # Store the distribution meta-data (name, version, author, and so # forth) in a separate object -- we're getting to have enough @@ -169,7 +178,7 @@ def __init__(self, attrs=None): # noqa: C901 # and sys.argv[1:], but they can be overridden when the caller is # not necessarily a setup script run from the command-line. self.script_name = None - self.script_args = None + self.script_args: list[str] | None = None # 'command_options' is where we store command options between # parsing them (from config files, the command-line, etc.) and when @@ -269,6 +278,8 @@ def __init__(self, attrs=None): # noqa: C901 self.want_user_cfg = True if self.script_args is not None: + # Coerce any possible iterable from attrs into a list + self.script_args = list(self.script_args) for arg in self.script_args: if not arg.startswith('-'): break @@ -722,7 +733,7 @@ def print_command_list(self, commands, header, max_length): except AttributeError: description = "(no description available)" - print(" %-*s %s" % (max_length, cmd, description)) + print(f" {cmd:<{max_length}} {description}") def print_commands(self): """Print out a help message listing all available commands with a @@ -829,7 +840,15 @@ def get_command_class(self, command): raise DistutilsModuleError(f"invalid command '{command}'") - def get_command_obj(self, command, create=True): + @overload + def get_command_obj( + self, command: str, create: Literal[True] = True + ) -> Command: ... + @overload + def get_command_obj( + self, command: str, create: Literal[False] + ) -> Command | None: ... + def get_command_obj(self, command: str, create: bool = True) -> Command | None: """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 @@ -900,7 +919,17 @@ def _set_command_options(self, command_obj, option_dict=None): # noqa: C901 except ValueError as msg: raise DistutilsOptionError(msg) - def reinitialize_command(self, command, reinit_subcommands=False): + @overload + def reinitialize_command( + self, command: str, reinit_subcommands: bool = False + ) -> Command: ... + @overload + def reinitialize_command( + self, command: _CommandT, reinit_subcommands: bool = False + ) -> _CommandT: ... + def reinitialize_command( + self, command: str | Command, reinit_subcommands=False + ) -> Command: """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 diff --git a/setuptools/_distutils/extension.py b/setuptools/_distutils/extension.py index 33159079c1..e053273436 100644 --- a/setuptools/_distutils/extension.py +++ b/setuptools/_distutils/extension.py @@ -26,12 +26,14 @@ class Extension: name : string the full name of the extension, including any packages -- ie. *not* a filename or pathname, but Python dotted name - sources : [string | os.PathLike] - list of source filenames, relative to the distribution root - (where the setup script lives), in Unix form (slash-separated) - for portability. Source files may be C, C++, SWIG (.i), - platform-specific resource files, or whatever else is recognized - by the "build_ext" command as source for a Python extension. + sources : Iterable[string | os.PathLike] + iterable of source filenames (except strings, which could be misinterpreted + as a single filename), relative to the distribution root (where the setup + script lives), in Unix form (slash-separated) for portability. Can be any + non-string iterable (list, tuple, set, etc.) containing strings or + PathLike objects. Source files may be C, C++, SWIG (.i), platform-specific + resource files, or whatever else is recognized by the "build_ext" command + as source for a Python extension. include_dirs : [string] list of directories to search for C/C++ header files (in Unix form for portability) @@ -105,17 +107,23 @@ def __init__( **kw, # To catch unknown keywords ): if not isinstance(name, str): - raise AssertionError("'name' must be a string") # noqa: TRY004 - if not ( - isinstance(sources, list) - and all(isinstance(v, (str, os.PathLike)) for v in sources) - ): - raise AssertionError( - "'sources' must be a list of strings or PathLike objects." + raise TypeError("'name' must be a string") + + # handle the string case first; since strings are iterable, disallow them + if isinstance(sources, str): + raise TypeError( + "'sources' must be an iterable of strings or PathLike objects, not a string" + ) + + # now we check if it's iterable and contains valid types + try: + self.sources = list(map(os.fspath, sources)) + except TypeError: + raise TypeError( + "'sources' must be an iterable of strings or PathLike objects" ) self.name = name - self.sources = list(map(os.fspath, sources)) self.include_dirs = include_dirs or [] self.define_macros = define_macros or [] self.undef_macros = undef_macros or [] diff --git a/setuptools/_distutils/fancy_getopt.py b/setuptools/_distutils/fancy_getopt.py index 907cc2b73c..1a1d3a05da 100644 --- a/setuptools/_distutils/fancy_getopt.py +++ b/setuptools/_distutils/fancy_getopt.py @@ -8,11 +8,14 @@ * options set attributes of a passed-in object """ +from __future__ import annotations + import getopt import re import string import sys -from typing import Any, Sequence +from collections.abc import Sequence +from typing import Any from .errors import DistutilsArgError, DistutilsGetoptError @@ -167,8 +170,7 @@ def _grok_option_table(self): # noqa: C901 if not ((short is None) or (isinstance(short, str) and len(short) == 1)): raise DistutilsGetoptError( - f"invalid short option '{short}': " - "must a single character or None" + f"invalid short option '{short}': must a single character or None" ) self.repeat[long] = repeat @@ -219,7 +221,7 @@ def _grok_option_table(self): # noqa: C901 self.short_opts.append(short) self.short2long[short[0]] = long - def getopt(self, args=None, object=None): # noqa: C901 + def getopt(self, args: Sequence[str] | None = None, object=None): # noqa: C901 """Parse command-line options in args. Store as attributes on object. If 'args' is None or not supplied, uses 'sys.argv[1:]'. If @@ -351,18 +353,18 @@ def generate_help(self, header=None): # noqa: C901 # Case 1: no short option at all (makes life easy) if short is None: if text: - lines.append(" --%-*s %s" % (max_opt, long, text[0])) + lines.append(f" --{long:<{max_opt}} {text[0]}") else: - lines.append(" --%-*s " % (max_opt, long)) + lines.append(f" --{long:<{max_opt}}") # Case 2: we have a short option, so we have to include it # just after the long option else: opt_names = f"{long} (-{short})" if text: - lines.append(" --%-*s %s" % (max_opt, opt_names, text[0])) + lines.append(f" --{opt_names:<{max_opt}} {text[0]}") else: - lines.append(" --%-*s" % opt_names) + lines.append(f" --{opt_names:<{max_opt}}") for ell in text[1:]: lines.append(big_indent + ell) @@ -375,7 +377,7 @@ def print_help(self, header=None, file=None): file.write(line + "\n") -def fancy_getopt(options, negative_opt, object, args): +def fancy_getopt(options, negative_opt, object, args: Sequence[str] | None): parser = FancyGetopt(options) parser.set_negative_aliases(negative_opt) return parser.getopt(args, object) @@ -464,6 +466,6 @@ def __init__(self, options: Sequence[Any] = []): say, "How should I know?"].)""" for w in (10, 20, 30, 40): - print("width: %d" % w) + print(f"width: {w}") print("\n".join(wrap_text(text, w))) print() diff --git a/setuptools/_distutils/file_util.py b/setuptools/_distutils/file_util.py index 85ee4dafcb..0acc8cb84b 100644 --- a/setuptools/_distutils/file_util.py +++ b/setuptools/_distutils/file_util.py @@ -118,7 +118,7 @@ def copy_file( # noqa: C901 if update and not newer(src, dst): if verbose >= 1: log.debug("not copying %s (output up-to-date)", src) - return (dst, 0) + return (dst, False) try: action = _copy_action[link] @@ -132,7 +132,7 @@ def copy_file( # noqa: C901 log.info("%s %s -> %s", action, src, dst) if dry_run: - return (dst, 1) + return (dst, True) # If linking (hard or symbolic), use the appropriate system call # (Unix only, of course, but that's the caller's responsibility) @@ -146,11 +146,11 @@ def copy_file( # noqa: C901 # even under Unix, see issue #8876). pass else: - return (dst, 1) + return (dst, True) elif link == 'sym': if not (os.path.exists(dst) and os.path.samefile(src, dst)): os.symlink(src, dst) - return (dst, 1) + return (dst, True) # Otherwise (non-Mac, not linking), copy the file contents and # (optionally) copy the times and mode. @@ -165,7 +165,7 @@ def copy_file( # noqa: C901 if preserve_mode: os.chmod(dst, S_IMODE(st[ST_MODE])) - return (dst, 1) + return (dst, True) # XXX I suspect this is Unix-specific -- need porting help! diff --git a/setuptools/_distutils/filelist.py b/setuptools/_distutils/filelist.py index 44ae9e67ef..9857b19549 100644 --- a/setuptools/_distutils/filelist.py +++ b/setuptools/_distutils/filelist.py @@ -127,10 +127,7 @@ def process_template_line(self, line): # noqa: C901 for pattern in patterns: if not self.exclude_pattern(pattern, anchor=True): log.warning( - ( - "warning: no previously-included files " - "found matching '%s'" - ), + "warning: no previously-included files found matching '%s'", pattern, ) diff --git a/setuptools/_distutils/spawn.py b/setuptools/_distutils/spawn.py index 107b011397..ba280334d1 100644 --- a/setuptools/_distutils/spawn.py +++ b/setuptools/_distutils/spawn.py @@ -12,7 +12,7 @@ import subprocess import sys import warnings -from typing import Mapping +from collections.abc import Mapping from ._log import log from .debug import DEBUG diff --git a/setuptools/_distutils/sysconfig.py b/setuptools/_distutils/sysconfig.py index da1eecbe7e..ef3def83eb 100644 --- a/setuptools/_distutils/sysconfig.py +++ b/setuptools/_distutils/sysconfig.py @@ -107,7 +107,7 @@ def get_python_version(): leaving off the patchlevel. Sample return values could be '1.5' or '2.2'. """ - return '%d.%d' % sys.version_info[:2] + return f'{sys.version_info.major}.{sys.version_info.minor}' def get_python_inc(plat_specific=False, prefix=None): @@ -340,7 +340,7 @@ def customize_compiler(compiler): ldshared = _add_flags(ldshared, 'LD') ldcxxshared = _add_flags(ldcxxshared, 'LD') - cflags = _add_flags(cflags, 'C') + cflags = os.environ.get('CFLAGS', cflags) ldshared = _add_flags(ldshared, 'C') cxxflags = os.environ.get('CXXFLAGS', cxxflags) ldcxxshared = _add_flags(ldcxxshared, 'CXX') diff --git a/setuptools/_distutils/tests/__init__.py b/setuptools/_distutils/tests/__init__.py index 93fbf49074..5a8ab06100 100644 --- a/setuptools/_distutils/tests/__init__.py +++ b/setuptools/_distutils/tests/__init__.py @@ -8,7 +8,7 @@ """ import shutil -from typing import Sequence +from collections.abc import Sequence def missing_compiler_executable(cmd_names: Sequence[str] = []): # pragma: no cover diff --git a/setuptools/_distutils/tests/compat/py38.py b/setuptools/_distutils/tests/compat/py38.py deleted file mode 100644 index 211d3a6c50..0000000000 --- a/setuptools/_distutils/tests/compat/py38.py +++ /dev/null @@ -1,50 +0,0 @@ -# flake8: noqa - -import contextlib -import builtins -import sys - -from test.support import requires_zlib -import test.support - - -ModuleNotFoundError = getattr(builtins, 'ModuleNotFoundError', ImportError) - -try: - from test.support.warnings_helper import check_warnings -except (ModuleNotFoundError, ImportError): - from test.support import check_warnings - - -try: - from test.support.os_helper import ( - rmtree, - EnvironmentVarGuard, - unlink, - skip_unless_symlink, - temp_dir, - ) -except (ModuleNotFoundError, ImportError): - from test.support import ( - rmtree, - EnvironmentVarGuard, - unlink, - skip_unless_symlink, - temp_dir, - ) - - -try: - from test.support.import_helper import ( - DirsOnSysPath, - CleanImport, - ) -except (ModuleNotFoundError, ImportError): - from test.support import ( - DirsOnSysPath, - CleanImport, - ) - - -if sys.version_info < (3, 9): - requires_zlib = lambda: test.support.requires_zlib diff --git a/setuptools/_distutils/tests/compat/py39.py b/setuptools/_distutils/tests/compat/py39.py new file mode 100644 index 0000000000..aca3939a0c --- /dev/null +++ b/setuptools/_distutils/tests/compat/py39.py @@ -0,0 +1,40 @@ +import sys + +if sys.version_info >= (3, 10): + from test.support.import_helper import ( + CleanImport as CleanImport, + ) + from test.support.import_helper import ( + DirsOnSysPath as DirsOnSysPath, + ) + from test.support.os_helper import ( + EnvironmentVarGuard as EnvironmentVarGuard, + ) + from test.support.os_helper import ( + rmtree as rmtree, + ) + from test.support.os_helper import ( + skip_unless_symlink as skip_unless_symlink, + ) + from test.support.os_helper import ( + unlink as unlink, + ) +else: + from test.support import ( + CleanImport as CleanImport, + ) + from test.support import ( + DirsOnSysPath as DirsOnSysPath, + ) + from test.support import ( + EnvironmentVarGuard as EnvironmentVarGuard, + ) + from test.support import ( + rmtree as rmtree, + ) + from test.support import ( + skip_unless_symlink as skip_unless_symlink, + ) + from test.support import ( + unlink as unlink, + ) diff --git a/setuptools/_distutils/tests/test_bdist_rpm.py b/setuptools/_distutils/tests/test_bdist_rpm.py index 1109fdf117..75051430e2 100644 --- a/setuptools/_distutils/tests/test_bdist_rpm.py +++ b/setuptools/_distutils/tests/test_bdist_rpm.py @@ -8,8 +8,7 @@ from distutils.tests import support import pytest - -from .compat.py38 import requires_zlib +from test.support import requires_zlib SETUP_PY = """\ from distutils.core import setup diff --git a/setuptools/_distutils/tests/test_build.py b/setuptools/_distutils/tests/test_build.py index d379aca0bb..f7fe69acd1 100644 --- a/setuptools/_distutils/tests/test_build.py +++ b/setuptools/_distutils/tests/test_build.py @@ -40,7 +40,9 @@ def test_finalize_options(self): assert cmd.build_temp == wanted # build_scripts is build/scripts-x.x - wanted = os.path.join(cmd.build_base, 'scripts-%d.%d' % sys.version_info[:2]) + wanted = os.path.join( + cmd.build_base, f'scripts-{sys.version_info.major}.{sys.version_info.minor}' + ) assert cmd.build_scripts == wanted # executable is os.path.normpath(sys.executable) diff --git a/setuptools/_distutils/tests/test_build_ext.py b/setuptools/_distutils/tests/test_build_ext.py index 8bd3cef855..beeba4850c 100644 --- a/setuptools/_distutils/tests/test_build_ext.py +++ b/setuptools/_distutils/tests/test_build_ext.py @@ -19,11 +19,7 @@ ) 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 distutils.tests.support import TempdirManager, copy_xxmodule_c, fixup_build_ext from io import StringIO import jaraco.path @@ -31,7 +27,7 @@ import pytest from test import support -from .compat import py38 as import_helper +from .compat import py39 as import_helper @pytest.fixture() @@ -522,14 +518,15 @@ def _try_compile_deployment_target(self, operator, target): # pragma: no cover # at least one value we test with will not exist yet. if target[:2] < (10, 10): # for 10.1 through 10.9.x -> "10n0" - target = '%02d%01d0' % target + tmpl = '{:02}{:01}0' else: # for 10.10 and beyond -> "10nn00" if len(target) >= 2: - target = '%02d%02d00' % target + tmpl = '{:02}{:02}00' else: # 11 and later can have no minor version (11 instead of 11.0) - target = '%02d0000' % target + tmpl = '{:02}0000' + target = tmpl.format(*target) deptarget_ext = Extension( 'deptarget', [self.tmp_path / 'deptargetmodule.c'], diff --git a/setuptools/_distutils/tests/test_ccompiler.py b/setuptools/_distutils/tests/test_ccompiler.py index d23b907cad..7ebfed56be 100644 --- a/setuptools/_distutils/tests/test_ccompiler.py +++ b/setuptools/_distutils/tests/test_ccompiler.py @@ -7,6 +7,8 @@ import pytest +pytestmark = pytest.mark.usefixtures('suppress_path_mangle') + def _make_strs(paths): """ diff --git a/setuptools/_distutils/tests/test_dir_util.py b/setuptools/_distutils/tests/test_dir_util.py index fcc37ac568..326cb34614 100644 --- a/setuptools/_distutils/tests/test_dir_util.py +++ b/setuptools/_distutils/tests/test_dir_util.py @@ -3,6 +3,7 @@ import os import pathlib import stat +import sys import unittest.mock as mock from distutils import dir_util, errors from distutils.dir_util import ( @@ -106,8 +107,9 @@ def test_copy_tree_exception_in_listdir(self): """ An exception in listdir should raise a DistutilsFileError """ - with mock.patch("os.listdir", side_effect=OSError()), pytest.raises( - errors.DistutilsFileError + with ( + mock.patch("os.listdir", side_effect=OSError()), + pytest.raises(errors.DistutilsFileError), ): src = self.tempdirs[-1] dir_util.copy_tree(src, None) @@ -123,6 +125,9 @@ class FailPath(pathlib.Path): def mkdir(self, *args, **kwargs): raise OSError("Failed to create directory") + if sys.version_info < (3, 12): + _flavour = pathlib.Path()._flavour + target = tmp_path / 'foodir' with pytest.raises(errors.DistutilsFileError): diff --git a/setuptools/_distutils/tests/test_dist.py b/setuptools/_distutils/tests/test_dist.py index 4d78a19803..2c5beebe64 100644 --- a/setuptools/_distutils/tests/test_dist.py +++ b/setuptools/_distutils/tests/test_dist.py @@ -13,6 +13,7 @@ from distutils.cmd import Command from distutils.dist import Distribution, fix_help_options from distutils.tests import support +from typing import ClassVar import jaraco.path import pytest @@ -23,7 +24,7 @@ class test_dist(Command): """Sample distutils extension command.""" - user_options = [ + user_options: ClassVar[list[tuple[str, str, str]]] = [ ("sample-option=", "S", "help text"), ] @@ -246,6 +247,12 @@ def test_find_config_files_disable(self, temp_home): # make sure --no-user-cfg disables the user cfg file assert len(all_files) - 1 == len(files) + def test_script_args_list_coercion(self): + d = Distribution(attrs={'script_args': ('build', '--no-user-cfg')}) + + # make sure script_args is a list even if it started as a different iterable + assert d.script_args == ['build', '--no-user-cfg'] + @pytest.mark.skipif( 'platform.system() == "Windows"', reason='Windows does not honor chmod 000', diff --git a/setuptools/_distutils/tests/test_extension.py b/setuptools/_distutils/tests/test_extension.py index 41872e04e8..5e8e768223 100644 --- a/setuptools/_distutils/tests/test_extension.py +++ b/setuptools/_distutils/tests/test_extension.py @@ -6,8 +6,7 @@ from distutils.extension import Extension, read_setup_file import pytest - -from .compat.py38 import check_warnings +from test.support.warnings_helper import check_warnings class TestExtension: @@ -63,22 +62,32 @@ def test_read_setup_file(self): def test_extension_init(self): # the first argument, which is the name, must be a string - with pytest.raises(AssertionError): + with pytest.raises(TypeError): Extension(1, []) ext = Extension('name', []) assert ext.name == 'name' # the second argument, which is the list of files, must - # be a list of strings or PathLike objects - with pytest.raises(AssertionError): + # be an iterable of strings or PathLike objects, and not a string + with pytest.raises(TypeError): Extension('name', 'file') - with pytest.raises(AssertionError): + with pytest.raises(TypeError): Extension('name', ['file', 1]) ext = Extension('name', ['file1', 'file2']) assert ext.sources == ['file1', 'file2'] ext = Extension('name', [pathlib.Path('file1'), pathlib.Path('file2')]) assert ext.sources == ['file1', 'file2'] + # any non-string iterable of strings or PathLike objects should work + ext = Extension('name', ('file1', 'file2')) # tuple + assert ext.sources == ['file1', 'file2'] + ext = Extension('name', {'file1', 'file2'}) # set + assert sorted(ext.sources) == ['file1', 'file2'] + ext = Extension('name', iter(['file1', 'file2'])) # iterator + assert ext.sources == ['file1', 'file2'] + ext = Extension('name', [pathlib.Path('file1'), 'file2']) # mixed types + assert ext.sources == ['file1', 'file2'] + # others arguments have defaults for attr in ( 'include_dirs', diff --git a/setuptools/_distutils/tests/test_file_util.py b/setuptools/_distutils/tests/test_file_util.py index 85ac2136b3..a75d4a0317 100644 --- a/setuptools/_distutils/tests/test_file_util.py +++ b/setuptools/_distutils/tests/test_file_util.py @@ -44,18 +44,19 @@ def test_move_file_verbosity(self, caplog): def test_move_file_exception_unpacking_rename(self): # see issue 22182 - with mock.patch("os.rename", side_effect=OSError("wrong", 1)), pytest.raises( - DistutilsFileError + with ( + mock.patch("os.rename", side_effect=OSError("wrong", 1)), + pytest.raises(DistutilsFileError), ): 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 - with mock.patch( - "os.rename", side_effect=OSError(errno.EXDEV, "wrong") - ), mock.patch("os.unlink", side_effect=OSError("wrong", 1)), pytest.raises( - DistutilsFileError + with ( + mock.patch("os.rename", side_effect=OSError(errno.EXDEV, "wrong")), + mock.patch("os.unlink", side_effect=OSError("wrong", 1)), + pytest.raises(DistutilsFileError), ): jaraco.path.build({self.source: 'spam eggs'}) move_file(self.source, self.target, verbose=False) diff --git a/setuptools/_distutils/tests/test_filelist.py b/setuptools/_distutils/tests/test_filelist.py index ec7e5cf363..130e6fb53b 100644 --- a/setuptools/_distutils/tests/test_filelist.py +++ b/setuptools/_distutils/tests/test_filelist.py @@ -10,7 +10,7 @@ import jaraco.path import pytest -from .compat import py38 as os_helper +from .compat import py39 as os_helper MANIFEST_IN = """\ include ok diff --git a/setuptools/_distutils/tests/test_spawn.py b/setuptools/_distutils/tests/test_spawn.py index fd7b669cbf..3b9fc926f6 100644 --- a/setuptools/_distutils/tests/test_spawn.py +++ b/setuptools/_distutils/tests/test_spawn.py @@ -12,7 +12,7 @@ import pytest from test.support import unix_shell -from .compat import py38 as os_helper +from .compat import py39 as os_helper class TestSpawn(support.TempdirManager): @@ -73,9 +73,12 @@ def test_find_executable(self, tmp_path): # PATH='': no match, except in the current directory with os_helper.EnvironmentVarGuard() as env: env['PATH'] = '' - with mock.patch( - 'distutils.spawn.os.confstr', return_value=tmp_dir, create=True - ), mock.patch('distutils.spawn.os.defpath', tmp_dir): + with ( + mock.patch( + 'distutils.spawn.os.confstr', return_value=tmp_dir, create=True + ), + mock.patch('distutils.spawn.os.defpath', tmp_dir), + ): rv = find_executable(program) assert rv is None @@ -87,9 +90,10 @@ def test_find_executable(self, tmp_path): # PATH=':': explicitly looks in the current directory with os_helper.EnvironmentVarGuard() as env: env['PATH'] = os.pathsep - with mock.patch( - 'distutils.spawn.os.confstr', return_value='', create=True - ), mock.patch('distutils.spawn.os.defpath', ''): + with ( + mock.patch('distutils.spawn.os.confstr', return_value='', create=True), + mock.patch('distutils.spawn.os.defpath', ''), + ): rv = find_executable(program) assert rv is None @@ -103,16 +107,22 @@ def test_find_executable(self, tmp_path): env.pop('PATH', None) # without confstr - with mock.patch( - 'distutils.spawn.os.confstr', side_effect=ValueError, create=True - ), mock.patch('distutils.spawn.os.defpath', tmp_dir): + with ( + mock.patch( + 'distutils.spawn.os.confstr', side_effect=ValueError, create=True + ), + mock.patch('distutils.spawn.os.defpath', tmp_dir), + ): rv = find_executable(program) assert rv == filename # with confstr - with mock.patch( - 'distutils.spawn.os.confstr', return_value=tmp_dir, create=True - ), mock.patch('distutils.spawn.os.defpath', ''): + with ( + mock.patch( + 'distutils.spawn.os.confstr', return_value=tmp_dir, create=True + ), + mock.patch('distutils.spawn.os.defpath', ''), + ): rv = find_executable(program) assert rv == filename diff --git a/setuptools/_distutils/tests/test_sysconfig.py b/setuptools/_distutils/tests/test_sysconfig.py index 49274a36ae..43d77c23fa 100644 --- a/setuptools/_distutils/tests/test_sysconfig.py +++ b/setuptools/_distutils/tests/test_sysconfig.py @@ -130,9 +130,9 @@ def test_customize_compiler(self): comp = self.customize_compiler() assert comp.exes['archiver'] == 'env_ar --env-arflags' assert comp.exes['preprocessor'] == 'env_cpp --env-cppflags' - assert comp.exes['compiler'] == 'env_cc --sc-cflags --env-cflags --env-cppflags' + assert comp.exes['compiler'] == 'env_cc --env-cflags --env-cppflags' assert comp.exes['compiler_so'] == ( - 'env_cc --sc-cflags --env-cflags --env-cppflags --sc-ccshared' + 'env_cc --env-cflags --env-cppflags --sc-ccshared' ) assert ( comp.exes['compiler_cxx'] diff --git a/setuptools/_distutils/tests/test_unixccompiler.py b/setuptools/_distutils/tests/test_unixccompiler.py index 50b66544a8..2c2f4aaec2 100644 --- a/setuptools/_distutils/tests/test_unixccompiler.py +++ b/setuptools/_distutils/tests/test_unixccompiler.py @@ -12,7 +12,7 @@ import pytest from . import support -from .compat.py38 import EnvironmentVarGuard +from .compat.py39 import EnvironmentVarGuard @pytest.fixture(autouse=True) @@ -272,13 +272,12 @@ def gcvs(*args, _orig=sysconfig.get_config_vars): sysconfig.get_config_var = gcv sysconfig.get_config_vars = gcvs - with mock.patch.object( - self.cc, 'spawn', return_value=None - ) as mock_spawn, mock.patch.object( - self.cc, '_need_link', return_value=True - ), mock.patch.object( - self.cc, 'mkpath', return_value=None - ), EnvironmentVarGuard() as env: + with ( + mock.patch.object(self.cc, 'spawn', return_value=None) as mock_spawn, + mock.patch.object(self.cc, '_need_link', return_value=True), + mock.patch.object(self.cc, 'mkpath', return_value=None), + EnvironmentVarGuard() as env, + ): env['CC'] = 'ccache my_cc' env['CXX'] = 'my_cxx' del env['LDSHARED'] diff --git a/setuptools/_distutils/tests/test_version.py b/setuptools/_distutils/tests/test_version.py index 1508e1cc0a..b68f097724 100644 --- a/setuptools/_distutils/tests/test_version.py +++ b/setuptools/_distutils/tests/test_version.py @@ -53,9 +53,9 @@ def test_cmp_strict(self): res = StrictVersion(v1)._cmp(v2) assert res == wanted, f'cmp({v1}, {v2}) should be {wanted}, got {res}' res = StrictVersion(v1)._cmp(object()) - assert ( - res is NotImplemented - ), f'cmp({v1}, {v2}) should be NotImplemented, got {res}' + assert res is NotImplemented, ( + f'cmp({v1}, {v2}) should be NotImplemented, got {res}' + ) def test_cmp(self): versions = ( @@ -75,6 +75,6 @@ def test_cmp(self): res = LooseVersion(v1)._cmp(v2) assert res == wanted, f'cmp({v1}, {v2}) should be {wanted}, got {res}' res = LooseVersion(v1)._cmp(object()) - assert ( - res is NotImplemented - ), f'cmp({v1}, {v2}) should be NotImplemented, got {res}' + assert res is NotImplemented, ( + f'cmp({v1}, {v2}) should be NotImplemented, got {res}' + ) diff --git a/setuptools/_distutils/text_file.py b/setuptools/_distutils/text_file.py index fec29c73b0..89d9048d59 100644 --- a/setuptools/_distutils/text_file.py +++ b/setuptools/_distutils/text_file.py @@ -133,9 +133,9 @@ def gen_error(self, msg, line=None): line = self.current_line outmsg.append(self.filename + ", ") if isinstance(line, (list, tuple)): - outmsg.append("lines %d-%d: " % tuple(line)) + outmsg.append("lines {}-{}: ".format(*line)) else: - outmsg.append("line %d: " % line) + outmsg.append(f"line {int(line)}: ") outmsg.append(str(msg)) return "".join(outmsg) diff --git a/setuptools/_distutils/util.py b/setuptools/_distutils/util.py index 609c1a50cd..83ad39e958 100644 --- a/setuptools/_distutils/util.py +++ b/setuptools/_distutils/util.py @@ -25,7 +25,7 @@ from .spawn import spawn -def get_host_platform(): +def get_host_platform() -> str: """ Return a string that identifies the current platform. Use this function to distinguish platform-specific build directories and @@ -34,15 +34,7 @@ def get_host_platform(): # This function initially exposed platforms as defined in Python 3.9 # even with older Python versions when distutils was split out. - # Now it delegates to stdlib sysconfig, but maintains compatibility. - - 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 .compat.py38 import aix_platform - - return aix_platform(osname, version, release) + # Now it delegates to stdlib sysconfig. return sysconfig.get_platform() @@ -288,7 +280,7 @@ def split_quoted(s): elif s[end] == '"': # slurp doubly-quoted string m = _dquote_re.match(s, end) else: - raise RuntimeError("this can't happen (bad char '%c')" % s[end]) + raise RuntimeError(f"this can't happen (bad char '{s[end]}')") if m is None: raise ValueError(f"bad string (mismatched {s[end]} quotes?)") @@ -503,3 +495,8 @@ def is_mingw(): get_platform() starts with 'mingw'. """ return sys.platform == 'win32' and get_platform().startswith('mingw') + + +def is_freethreaded(): + """Return True if the Python interpreter is built with free threading support.""" + return bool(sysconfig.get_config_var('Py_GIL_DISABLED')) diff --git a/setuptools/_distutils/version.py b/setuptools/_distutils/version.py index 942b56bf94..2223ee9c8c 100644 --- a/setuptools/_distutils/version.py +++ b/setuptools/_distutils/version.py @@ -53,8 +53,7 @@ def __init__(self, vstring=None): if vstring: self.parse(vstring) warnings.warn( - "distutils Version classes are deprecated. " - "Use packaging.version instead.", + "distutils Version classes are deprecated. Use packaging.version instead.", DeprecationWarning, stacklevel=2, ) diff --git a/setuptools/_imp.py b/setuptools/_imp.py index bddbf6a683..f1d9f29218 100644 --- a/setuptools/_imp.py +++ b/setuptools/_imp.py @@ -29,7 +29,7 @@ def find_module(module, paths=None): """Just like 'imp.find_module()', but with package support""" spec = find_spec(module, paths) if spec is None: - raise ImportError("Can't find %s" % module) + raise ImportError(f"Can't find {module}") if not spec.has_location and hasattr(spec, 'submodule_search_locations'): spec = importlib.util.spec_from_loader('__init__.py', spec.loader) @@ -76,12 +76,12 @@ def find_module(module, paths=None): def get_frozen_object(module, paths=None): spec = find_spec(module, paths) if not spec: - raise ImportError("Can't find %s" % module) + raise ImportError(f"Can't find {module}") return spec.loader.get_code(module) def get_module(module, paths, info): spec = find_spec(module, paths) if not spec: - raise ImportError("Can't find %s" % module) + raise ImportError(f"Can't find {module}") return module_from_spec(spec) diff --git a/setuptools/_static.py b/setuptools/_static.py new file mode 100644 index 0000000000..075a0bcddf --- /dev/null +++ b/setuptools/_static.py @@ -0,0 +1,188 @@ +from functools import wraps +from typing import TypeVar + +import packaging.specifiers + +from .warnings import SetuptoolsDeprecationWarning + + +class Static: + """ + Wrapper for built-in object types that are allow setuptools to identify + static core metadata (in opposition to ``Dynamic``, as defined :pep:`643`). + + The trick is to mark values with :class:`Static` when they come from + ``pyproject.toml`` or ``setup.cfg``, so if any plugin overwrite the value + with a built-in, setuptools will be able to recognise the change. + + We inherit from built-in classes, so that we don't need to change the existing + code base to deal with the new types. + We also should strive for immutability objects to avoid changes after the + initial parsing. + """ + + _mutated_: bool = False # TODO: Remove after deprecation warning is solved + + +def _prevent_modification(target: type, method: str, copying: str) -> None: + """ + Because setuptools is very flexible we cannot fully prevent + plugins and user customisations from modifying static values that were + parsed from config files. + But we can attempt to block "in-place" mutations and identify when they + were done. + """ + fn = getattr(target, method, None) + if fn is None: + return + + @wraps(fn) + def _replacement(self: Static, *args, **kwargs): + # TODO: After deprecation period raise NotImplementedError instead of warning + # which obviated the existence and checks of the `_mutated_` attribute. + self._mutated_ = True + SetuptoolsDeprecationWarning.emit( + "Direct modification of value will be disallowed", + f""" + In an effort to implement PEP 643, direct/in-place changes of static values + that come from configuration files are deprecated. + If you need to modify this value, please first create a copy with {copying} + and make sure conform to all relevant standards when overriding setuptools + functionality (https://packaging.python.org/en/latest/specifications/). + """, + due_date=(2025, 10, 10), # Initially introduced in 2024-09-06 + ) + return fn(self, *args, **kwargs) + + _replacement.__doc__ = "" # otherwise doctest may fail. + setattr(target, method, _replacement) + + +class Str(str, Static): + pass + + +class Tuple(tuple, Static): + pass + + +class List(list, Static): + """ + :meta private: + >>> x = List([1, 2, 3]) + >>> is_static(x) + True + >>> x += [0] # doctest: +IGNORE_EXCEPTION_DETAIL + Traceback (most recent call last): + SetuptoolsDeprecationWarning: Direct modification ... + >>> is_static(x) # no longer static after modification + False + >>> y = list(x) + >>> y.clear() + >>> y + [] + >>> y == x + False + >>> is_static(List(y)) + True + """ + + +# Make `List` immutable-ish +# (certain places of setuptools/distutils issue a warn if we use tuple instead of list) +for _method in ( + '__delitem__', + '__iadd__', + '__setitem__', + 'append', + 'clear', + 'extend', + 'insert', + 'remove', + 'reverse', + 'pop', +): + _prevent_modification(List, _method, "`list(value)`") + + +class Dict(dict, Static): + """ + :meta private: + >>> x = Dict({'a': 1, 'b': 2}) + >>> is_static(x) + True + >>> x['c'] = 0 # doctest: +IGNORE_EXCEPTION_DETAIL + Traceback (most recent call last): + SetuptoolsDeprecationWarning: Direct modification ... + >>> x._mutated_ + True + >>> is_static(x) # no longer static after modification + False + >>> y = dict(x) + >>> y.popitem() + ('b', 2) + >>> y == x + False + >>> is_static(Dict(y)) + True + """ + + +# Make `Dict` immutable-ish (we cannot inherit from types.MappingProxyType): +for _method in ( + '__delitem__', + '__ior__', + '__setitem__', + 'clear', + 'pop', + 'popitem', + 'setdefault', + 'update', +): + _prevent_modification(Dict, _method, "`dict(value)`") + + +class SpecifierSet(packaging.specifiers.SpecifierSet, Static): + """Not exactly a built-in type but useful for ``requires-python``""" + + +T = TypeVar("T") + + +def noop(value: T) -> T: + """ + >>> noop(42) + 42 + """ + return value + + +_CONVERSIONS = {str: Str, tuple: Tuple, list: List, dict: Dict} + + +def attempt_conversion(value: T) -> T: + """ + >>> is_static(attempt_conversion("hello")) + True + >>> is_static(object()) + False + """ + return _CONVERSIONS.get(type(value), noop)(value) # type: ignore[call-overload] + + +def is_static(value: object) -> bool: + """ + >>> is_static(a := Dict({'a': 1})) + True + >>> is_static(dict(a)) + False + >>> is_static(b := List([1, 2, 3])) + True + >>> is_static(list(b)) + False + """ + return isinstance(value, Static) and not value._mutated_ + + +EMPTY_LIST = List() +EMPTY_DICT = Dict() diff --git a/setuptools/archive_util.py b/setuptools/archive_util.py index cd9cf9c08f..1a02010bb2 100644 --- a/setuptools/archive_util.py +++ b/setuptools/archive_util.py @@ -62,7 +62,7 @@ def unpack_archive( else: return else: - raise UnrecognizedFormat("Not a recognized archive type: %s" % filename) + raise UnrecognizedFormat(f"Not a recognized archive type: {filename}") def unpack_directory(filename, extract_dir, progress_filter=default_filter) -> None: @@ -71,7 +71,7 @@ def unpack_directory(filename, extract_dir, progress_filter=default_filter) -> N Raises ``UnrecognizedFormat`` if `filename` is not a directory """ if not os.path.isdir(filename): - raise UnrecognizedFormat("%s is not a directory" % filename) + raise UnrecognizedFormat(f"{filename} is not a directory") paths = { filename: ('', extract_dir), @@ -101,7 +101,7 @@ def unpack_zipfile(filename, extract_dir, progress_filter=default_filter) -> Non """ if not zipfile.is_zipfile(filename): - raise UnrecognizedFormat("%s is not a zip file" % (filename,)) + raise UnrecognizedFormat(f"{filename} is not a zip file") with zipfile.ZipFile(filename) as z: _unpack_zipfile_obj(z, extract_dir, progress_filter) @@ -198,7 +198,7 @@ def unpack_tarfile(filename, extract_dir, progress_filter=default_filter) -> boo tarobj = tarfile.open(filename) except tarfile.TarError as e: raise UnrecognizedFormat( - "%s is not a compressed or uncompressed tar file" % (filename,) + f"{filename} is not a compressed or uncompressed tar file" ) from e for member, final_dst in _iter_open_tar( diff --git a/setuptools/command/_requirestxt.py b/setuptools/command/_requirestxt.py index 171f41b87e..9029b12514 100644 --- a/setuptools/command/_requirestxt.py +++ b/setuptools/command/_requirestxt.py @@ -38,13 +38,13 @@ def _prepare( def _convert_extras_requirements( extras_require: Mapping[str, _StrOrIter], -) -> Mapping[str, _Ordered[Requirement]]: +) -> defaultdict[str, _Ordered[Requirement]]: """ Convert requirements in `extras_require` of the form `"extra": ["barbazquux; {marker}"]` to `"extra:{marker}": ["barbazquux"]`. """ - output: Mapping[str, _Ordered[Requirement]] = defaultdict(dict) + output = defaultdict[str, _Ordered[Requirement]](dict) for section, v in extras_require.items(): # Do not strip empty sections. output[section] diff --git a/setuptools/command/alias.py b/setuptools/command/alias.py index 388830d7a6..b8d74af71d 100644 --- a/setuptools/command/alias.py +++ b/setuptools/command/alias.py @@ -55,7 +55,7 @@ def run(self) -> None: print("setup.py alias", format_alias(alias, aliases)) return else: - print("No alias definition found for %r" % alias) + print(f"No alias definition found for {alias!r}") return else: alias = self.args[0] @@ -73,5 +73,5 @@ def format_alias(name, aliases): elif source == config_file('local'): source = '' else: - source = '--filename=%r' % source + source = f'--filename={source!r}' return source + name + ' ' + command diff --git a/setuptools/command/bdist_egg.py b/setuptools/command/bdist_egg.py index ac3e6ef1f9..7f66c3ba6a 100644 --- a/setuptools/command/bdist_egg.py +++ b/setuptools/command/bdist_egg.py @@ -69,7 +69,7 @@ def __bootstrap__(): class bdist_egg(Command): - description = "create an \"egg\" distribution" + description = 'create an "egg" distribution' user_options = [ ('bdist-dir=', 'b', "temporary directory for creating the distribution"), @@ -263,7 +263,7 @@ def zap_pyfiles(self): pattern = r'(?P.+)\.(?P[^.]+)\.pyc' m = re.match(pattern, name) path_new = os.path.join(base, os.pardir, m.group('name') + '.pyc') - log.info("Renaming file from [%s] to [%s]" % (path_old, path_new)) + log.info(f"Renaming file from [{path_old}] to [{path_new}]") try: os.remove(path_new) except OSError: diff --git a/setuptools/command/bdist_wheel.py b/setuptools/command/bdist_wheel.py index 2f129481fa..d47f63c4d8 100644 --- a/setuptools/command/bdist_wheel.py +++ b/setuptools/command/bdist_wheel.py @@ -190,9 +190,7 @@ class bdist_wheel(Command): ( "compression=", None, - "zipfile compression (one of: {}) [default: 'deflated']".format( - ", ".join(supported_compressions) - ), + f"zipfile compression (one of: {', '.join(supported_compressions)}) [default: 'deflated']", ), ( "python-tag=", @@ -506,7 +504,7 @@ def license_paths(self) -> Iterable[str]: # Setuptools has resolved any patterns to actual file names return self.distribution.metadata.license_files or () - files: set[str] = set() + files = set[str]() metadata = self.distribution.get_option_dict("metadata") if setuptools_major_version >= 42: # Setuptools recognizes the license_files option but does not do globbing diff --git a/setuptools/command/build_clib.py b/setuptools/command/build_clib.py index bee3d58c03..f376f4ce4d 100644 --- a/setuptools/command/build_clib.py +++ b/setuptools/command/build_clib.py @@ -29,9 +29,9 @@ def build_libraries(self, libraries) -> None: 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 = sorted(list(sources)) @@ -43,9 +43,9 @@ def build_libraries(self, libraries) -> None: obj_deps = build_info.get('obj_deps', dict()) if not isinstance(obj_deps, dict): raise DistutilsSetupError( - "in 'libraries' option (library '%s'), " + f"in 'libraries' option (library '{lib_name}'), " "'obj_deps' must be a dictionary of " - "type 'source: list'" % lib_name + "type 'source: list'" ) dependencies = [] @@ -54,9 +54,9 @@ def build_libraries(self, libraries) -> None: global_deps = obj_deps.get('', list()) if not isinstance(global_deps, (list, tuple)): raise DistutilsSetupError( - "in 'libraries' option (library '%s'), " + f"in 'libraries' option (library '{lib_name}'), " "'obj_deps' must be a dictionary of " - "type 'source: list'" % lib_name + "type 'source: list'" ) # Build the list to be used by newer_pairwise_group @@ -67,9 +67,9 @@ def build_libraries(self, libraries) -> None: extra_deps = obj_deps.get(source, list()) if not isinstance(extra_deps, (list, tuple)): raise DistutilsSetupError( - "in 'libraries' option (library '%s'), " + f"in 'libraries' option (library '{lib_name}'), " "'obj_deps' must be a dictionary of " - "type 'source: list'" % lib_name + "type 'source: list'" ) src_deps.extend(extra_deps) dependencies.append(src_deps) diff --git a/setuptools/command/build_ext.py b/setuptools/command/build_ext.py index e5c6b76b38..be833a379c 100644 --- a/setuptools/command/build_ext.py +++ b/setuptools/command/build_ext.py @@ -168,7 +168,7 @@ def get_ext_filename(self, fullname: str) -> str: if not isinstance(ext_suffix, str): raise OSError( "Configuration variable EXT_SUFFIX not found for this platform " - + "and environment variable SETUPTOOLS_EXT_SUFFIX is missing" + "and environment variable SETUPTOOLS_EXT_SUFFIX is missing" ) so_ext = ext_suffix @@ -360,7 +360,7 @@ def _write_stub_file(self, stub_file: str, ext: Extension, compile=False): " global __bootstrap__, __file__, __loader__", " import sys, os, pkg_resources, importlib.util" + if_dl(", dl"), " __file__ = pkg_resources.resource_filename" - "(__name__,%r)" % os.path.basename(ext._file_name), + f"(__name__,{os.path.basename(ext._file_name)!r})", " del __bootstrap__", " if '__loader__' in globals():", " del __loader__", diff --git a/setuptools/command/build_py.py b/setuptools/command/build_py.py index e7d60c6440..2f6fcb7cdc 100644 --- a/setuptools/command/build_py.py +++ b/setuptools/command/build_py.py @@ -259,10 +259,10 @@ def check_package(self, package, package_dir): contents = f.read() if b'declare_namespace' not in contents: raise distutils.errors.DistutilsError( - "Namespace package problem: %s is a namespace package, but " + f"Namespace package problem: {package} is a namespace package, but " "its\n__init__.py does not call declare_namespace()! Please " 'fix it.\n(See the setuptools manual under ' - '"Namespace Packages" for details.)\n"' % (package,) + '"Namespace Packages" for details.)\n"' ) return init_py diff --git a/setuptools/command/dist_info.py b/setuptools/command/dist_info.py index 0192ebb260..dca01ff0ce 100644 --- a/setuptools/command/dist_info.py +++ b/setuptools/command/dist_info.py @@ -95,7 +95,7 @@ def run(self) -> None: egg_info_dir = self.egg_info.egg_info assert os.path.isdir(egg_info_dir), ".egg-info dir should have been created" - log.info("creating '{}'".format(os.path.abspath(self.dist_info_dir))) + log.info(f"creating '{os.path.abspath(self.dist_info_dir)}'") bdist_wheel = self.get_finalized_command('bdist_wheel') # TODO: if bdist_wheel if merged into setuptools, just add "keep_egg_info" there diff --git a/setuptools/command/easy_install.py b/setuptools/command/easy_install.py index 66fe68f7a9..eb1b4c1fcc 100644 --- a/setuptools/command/easy_install.py +++ b/setuptools/command/easy_install.py @@ -132,8 +132,8 @@ class easy_install(Command): ( 'optimize=', 'O', - "also compile with optimization: -O1 for \"python -O\", " - "-O2 for \"python -OO\", and -O0 to disable [default: -O0]", + 'also compile with optimization: -O1 for "python -O", ' + '-O2 for "python -OO", and -O0 to disable [default: -O0]', ), ('record=', None, "filename in which to record list of installed files"), ('always-unzip', 'Z', "don't install as a zipfile, no matter what"), @@ -148,7 +148,7 @@ class easy_install(Command): None, "Don't load find-links defined in packages being installed", ), - ('user', None, "install in user site-package '%s'" % site.USER_SITE), + ('user', None, f"install in user site-package '{site.USER_SITE}'"), ] boolean_options = [ 'zip-ok', @@ -446,7 +446,7 @@ def run(self, show_deprecation: bool = True) -> None: self.execute( file_util.write_file, (self.record, outputs), - "writing list of installed files to '%s'" % self.record, + f"writing list of installed files to '{self.record}'", ) self.warn_deprecated_options() finally: @@ -461,7 +461,7 @@ def pseudo_tempname(self): pid = os.getpid() except Exception: pid = random.randint(0, sys.maxsize) - return os.path.join(self.install_dir, "test-easy-install-%s" % pid) + return os.path.join(self.install_dir, f"test-easy-install-{pid}") def warn_deprecated_options(self) -> None: pass @@ -649,8 +649,8 @@ def add_output(self, path) -> None: def not_editable(self, spec) -> None: if self.editable: raise DistutilsArgError( - "Invalid argument %r: you can't use filenames or URLs " - "with --editable (except via the --find-links option)." % (spec,) + f"Invalid argument {spec!r}: you can't use filenames or URLs " + "with --editable (except via the --find-links option)." ) def check_editable(self, spec) -> None: @@ -659,8 +659,7 @@ def check_editable(self, spec) -> None: if os.path.exists(os.path.join(self.build_directory, spec.key)): raise DistutilsArgError( - "%r already exists in %s; can't do a checkout there" - % (spec.key, self.build_directory) + f"{spec.key!r} already exists in {self.build_directory}; can't do a checkout there" ) @contextlib.contextmanager @@ -698,7 +697,7 @@ def easy_install(self, spec, deps: bool = False) -> Distribution | None: self.local_index, ) if dist is None: - msg = "Could not find suitable distribution for %r" % spec + msg = f"Could not find suitable distribution for {spec!r}" if self.always_copy: msg += " (--always-copy skips system and development eggs)" raise DistutilsError(msg) @@ -917,12 +916,11 @@ def install_eggs(self, spec, dist_filename, tmpdir) -> list[Distribution]: setups = glob(os.path.join(setup_base, '*', 'setup.py')) if not setups: raise DistutilsError( - "Couldn't find a setup script in %s" - % os.path.abspath(dist_filename) + f"Couldn't find a setup script in {os.path.abspath(dist_filename)}" ) if len(setups) > 1: raise DistutilsError( - "Multiple setup scripts in %s" % os.path.abspath(dist_filename) + f"Multiple setup scripts in {os.path.abspath(dist_filename)}" ) setup_script = setups[0] @@ -1000,7 +998,7 @@ def install_exe(self, dist_filename, tmpdir): cfg = extract_wininst_cfg(dist_filename) if cfg is None: raise DistutilsError( - "%s is not a valid distutils Windows .exe" % dist_filename + f"{dist_filename} is not a valid distutils Windows .exe" ) # Create a dummy distribution object until we build the real distro dist = Distribution( @@ -1026,7 +1024,8 @@ def install_exe(self, dist_filename, tmpdir): f.write('Metadata-Version: 1.0\n') for k, v in cfg.items('metadata'): if k != 'target_version': - f.write('%s: %s\n' % (k.replace('_', '-').title(), v)) + k = k.replace('_', '-').title() + f.write(f'{k}: {v}\n') script_dir = os.path.join(_egg_info, 'scripts') # delete entry-point scripts to avoid duping self.delete_blockers([ @@ -1114,8 +1113,9 @@ def install_wheel(self, wheel_path, tmpdir): self.execute( wheel.install_as_egg, (destination,), - ("Installing %s to %s") - % (os.path.basename(wheel_path), os.path.dirname(destination)), + ( + f"Installing {os.path.basename(wheel_path)} to {os.path.dirname(destination)}" + ), ) finally: update_dist_caches(destination, fix_zipimporter_caches=False) @@ -1191,7 +1191,7 @@ def run_setup(self, setup_script, setup_base, args) -> None: try: run_setup(setup_script, args) except SystemExit as v: - raise DistutilsError("Setup script exited with %s" % (v.args[0],)) from v + raise DistutilsError(f"Setup script exited with {v.args[0]}") from v def build_and_install(self, setup_script, setup_base): args = ['bdist_egg', '--dist-dir'] @@ -1374,7 +1374,7 @@ def create_home_path(self) -> None: home = convert_path(os.path.expanduser("~")) for path in only_strs(self.config_vars.values()): if 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) INSTALL_SCHEMES = dict( @@ -1599,7 +1599,7 @@ def get_exe_prefixes(exe_filename): for pth in yield_lines(contents): pth = pth.strip().replace('\\', '/') if not pth.startswith('import'): - prefixes.append((('%s/%s/' % (parts[0], pth)), '')) + prefixes.append(((f'{parts[0]}/{pth}/'), '')) finally: z.close() prefixes = [(x.lower(), y) for x, y in prefixes] @@ -2305,7 +2305,7 @@ def get_win_launcher(type): Returns the executable as a byte string. """ - launcher_fn = '%s.exe' % type + launcher_fn = f'{type}.exe' if is_64bit(): if get_platform() == "win-arm64": launcher_fn = launcher_fn.replace(".", "-arm64.") diff --git a/setuptools/command/editable_wheel.py b/setuptools/command/editable_wheel.py index 6d23d11fad..b03e677757 100644 --- a/setuptools/command/editable_wheel.py +++ b/setuptools/command/editable_wheel.py @@ -506,7 +506,7 @@ def template_vars(self) -> tuple[str, str, dict[str, str], dict[str, list[str]]] package_dir = self.dist.package_dir or {} roots = _find_package_roots(top_level, package_dir, src_root) - namespaces_: dict[str, list[str]] = dict( + namespaces_ = dict( chain( _find_namespaces(self.dist.packages or [], roots), ((ns, []) for ns in _find_virtual_namespaces(roots)), diff --git a/setuptools/command/egg_info.py b/setuptools/command/egg_info.py index a300356d33..f77631168f 100644 --- a/setuptools/command/egg_info.py +++ b/setuptools/command/egg_info.py @@ -48,7 +48,7 @@ def translate_pattern(glob): # noqa: C901 # is too complex (14) # FIXME chunks = glob.split(os.path.sep) sep = re.escape(os.sep) - valid_char = '[^%s]' % (sep,) + valid_char = f'[^{sep}]' for c, chunk in enumerate(chunks): last_chunk = c == len(chunks) - 1 @@ -60,7 +60,7 @@ def translate_pattern(glob): # noqa: C901 # is too complex (14) # FIXME pat += '.*' else: # Match '(name/)*' - pat += '(?:%s+%s)*' % (valid_char, sep) + pat += f'(?:{valid_char}+{sep})*' continue # Break here as the whole path component has been handled # Find any special characters in the remainder @@ -102,7 +102,7 @@ def translate_pattern(glob): # noqa: C901 # is too complex (14) # FIXME inner = inner[1:] char_class += re.escape(inner) - pat += '[%s]' % (char_class,) + pat += f'[{char_class}]' # Skip to the end ] i = inner_i @@ -231,8 +231,7 @@ def finalize_options(self) -> None: packaging.requirements.Requirement(spec % (self.egg_name, self.egg_version)) except ValueError as e: raise distutils.errors.DistutilsOptionError( - "Invalid distribution name or version syntax: %s-%s" - % (self.egg_name, self.egg_version) + f"Invalid distribution name or version syntax: {self.egg_name}-{self.egg_version}" ) from e if self.egg_base is None: @@ -502,7 +501,7 @@ def _safe_path(self, path): # To avoid accidental trans-codings errors, first to unicode u_path = unicode_utils.filesys_decode(path) if u_path is None: - log.warn("'%s' in unexpected encoding -- skipping" % path) + log.warn(f"'{path}' in unexpected encoding -- skipping") return False # Must ensure utf-8 encodability @@ -564,7 +563,7 @@ def write_manifest(self) -> None: # Now _repairs should encodability, but not unicode files = [self._manifest_normalize(f) for f in self.filelist.files] - msg = "writing manifest file '%s'" % self.manifest + msg = f"writing manifest file '{self.manifest}'" self.execute(write_file, (self.manifest, files), msg) def warn(self, msg) -> None: diff --git a/setuptools/command/install_egg_info.py b/setuptools/command/install_egg_info.py index a6e6ec6446..44f22ccf51 100644 --- a/setuptools/command/install_egg_info.py +++ b/setuptools/command/install_egg_info.py @@ -36,7 +36,7 @@ def run(self) -> None: self.execute(os.unlink, (self.target,), "Removing " + self.target) if not self.dry_run: ensure_directory(self.target) - self.execute(self.copytree, (), "Copying %s to %s" % (self.source, self.target)) + self.execute(self.copytree, (), f"Copying {self.source} to {self.target}") self.install_namespaces() def get_outputs(self): diff --git a/setuptools/command/sdist.py b/setuptools/command/sdist.py index 64e866c96b..9631cf3114 100644 --- a/setuptools/command/sdist.py +++ b/setuptools/command/sdist.py @@ -53,7 +53,7 @@ class sdist(orig.sdist): negative_opt: ClassVar[dict[str, str]] = {} README_EXTENSIONS = ['', '.rst', '.txt', '.md'] - READMES = tuple('README{0}'.format(ext) for ext in README_EXTENSIONS) + READMES = tuple(f'README{ext}' for ext in README_EXTENSIONS) def run(self) -> None: self.run_command('egg_info') @@ -207,7 +207,7 @@ def read_manifest(self): try: line = bytes_line.decode('UTF-8') except UnicodeDecodeError: - log.warn("%r not UTF-8 decodable -- skipping" % line) + log.warn(f"{line!r} not UTF-8 decodable -- skipping") continue # ignore comments and blank lines line = line.strip() diff --git a/setuptools/command/setopt.py b/setuptools/command/setopt.py index 200cdff0f7..678a0593d6 100644 --- a/setuptools/command/setopt.py +++ b/setuptools/command/setopt.py @@ -23,7 +23,7 @@ def config_file(kind="local"): return os.path.join(os.path.dirname(distutils.__file__), 'distutils.cfg') if kind == 'user': dot = os.name == 'posix' and '.' or '' - return os.path.expanduser(convert_path("~/%spydistutils.cfg" % dot)) + return os.path.expanduser(convert_path(f"~/{dot}pydistutils.cfg")) raise ValueError("config_file() type must be 'local', 'global', or 'user'", kind) diff --git a/setuptools/config/_apply_pyprojecttoml.py b/setuptools/config/_apply_pyprojecttoml.py index c4bbcff730..331596bdd7 100644 --- a/setuptools/config/_apply_pyprojecttoml.py +++ b/setuptools/config/_apply_pyprojecttoml.py @@ -20,6 +20,7 @@ from types import MappingProxyType from typing import TYPE_CHECKING, Any, Callable, TypeVar, Union +from .. import _static from .._path import StrPath from ..errors import RemovedConfigError from ..extension import Extension @@ -65,10 +66,11 @@ def apply(dist: Distribution, config: dict, filename: StrPath) -> Distribution: def _apply_project_table(dist: Distribution, config: dict, root_dir: StrPath): - project_table = config.get("project", {}).copy() - if not project_table: + orig_config = config.get("project", {}) + if not orig_config: return # short-circuit + project_table = {k: _static.attempt_conversion(v) for k, v in orig_config.items()} _handle_missing_dynamic(dist, project_table) _unify_entry_points(project_table) @@ -98,7 +100,11 @@ def _apply_tool_table(dist: Distribution, config: dict, filename: StrPath): raise RemovedConfigError("\n".join([cleandoc(msg), suggestion])) norm_key = TOOL_TABLE_RENAMES.get(norm_key, norm_key) - _set_config(dist, norm_key, value) + corresp = TOOL_TABLE_CORRESPONDENCE.get(norm_key, norm_key) + if callable(corresp): + corresp(dist, value) + else: + _set_config(dist, corresp, value) _copy_command_options(config, dist, filename) @@ -143,7 +149,7 @@ def _guess_content_type(file: str) -> str | None: return None if ext in _CONTENT_TYPES: - return _CONTENT_TYPES[ext] + return _static.Str(_CONTENT_TYPES[ext]) valid = ", ".join(f"{k} ({v})" for k, v in _CONTENT_TYPES.items()) msg = f"only the following file extensions are recognized: {valid}." @@ -165,10 +171,11 @@ def _long_description( text = val.get("text") or expand.read_files(file, root_dir) ctype = val["content-type"] - _set_config(dist, "long_description", text) + # XXX: Is it completely safe to assume static? + _set_config(dist, "long_description", _static.Str(text)) if ctype: - _set_config(dist, "long_description_content_type", ctype) + _set_config(dist, "long_description_content_type", _static.Str(ctype)) if file: dist._referenced_files.add(file) @@ -178,10 +185,12 @@ def _license(dist: Distribution, val: dict, root_dir: StrPath | None): from setuptools.config import expand if "file" in val: - _set_config(dist, "license", expand.read_files([val["file"]], root_dir)) + # XXX: Is it completely safe to assume static? + value = expand.read_files([val["file"]], root_dir) + _set_config(dist, "license", _static.Str(value)) dist._referenced_files.add(val["file"]) else: - _set_config(dist, "license", val["text"]) + _set_config(dist, "license", _static.Str(val["text"])) def _people(dist: Distribution, val: list[dict], _root_dir: StrPath | None, kind: str): @@ -197,9 +206,9 @@ def _people(dist: Distribution, val: list[dict], _root_dir: StrPath | None, kind email_field.append(str(addr)) if field: - _set_config(dist, kind, ", ".join(field)) + _set_config(dist, kind, _static.Str(", ".join(field))) if email_field: - _set_config(dist, f"{kind}_email", ", ".join(email_field)) + _set_config(dist, f"{kind}_email", _static.Str(", ".join(email_field))) def _project_urls(dist: Distribution, val: dict, _root_dir: StrPath | None): @@ -207,9 +216,7 @@ def _project_urls(dist: Distribution, val: dict, _root_dir: StrPath | None): def _python_requires(dist: Distribution, val: str, _root_dir: StrPath | None): - from packaging.specifiers import SpecifierSet - - _set_config(dist, "python_requires", SpecifierSet(val)) + _set_config(dist, "python_requires", _static.SpecifierSet(val)) def _dependencies(dist: Distribution, val: list, _root_dir: StrPath | None): @@ -237,9 +244,14 @@ def _noop(_dist: Distribution, val: _T) -> _T: return val +def _identity(val: _T) -> _T: + return val + + def _unify_entry_points(project_table: dict): project = project_table - entry_points = project.pop("entry-points", project.pop("entry_points", {})) + given = project.pop("entry-points", project.pop("entry_points", {})) + entry_points = dict(given) # Avoid problems with static renaming = {"scripts": "console_scripts", "gui_scripts": "gui_scripts"} for key, value in list(project.items()): # eager to allow modifications norm_key = json_compatible_key(key) @@ -333,6 +345,14 @@ def _get_previous_gui_scripts(dist: Distribution) -> list | None: return value.get("gui_scripts") +def _set_static_list_metadata(attr: str, dist: Distribution, val: list) -> None: + """Apply distutils metadata validation but preserve "static" behaviour""" + meta = dist.metadata + setter, getter = getattr(meta, f"set_{attr}"), getattr(meta, f"get_{attr}") + setter(val) + setattr(meta, attr, _static.List(getter())) + + def _attrgetter(attr): """ Similar to ``operator.attrgetter`` but returns None if ``attr`` is not found @@ -386,6 +406,12 @@ def _acessor(obj): See https://packaging.python.org/en/latest/guides/packaging-namespace-packages/. """, } +TOOL_TABLE_CORRESPONDENCE = { + # Fields with corresponding core metadata need to be marked as static: + "obsoletes": partial(_set_static_list_metadata, "obsoletes"), + "provides": partial(_set_static_list_metadata, "provides"), + "platforms": partial(_set_static_list_metadata, "platforms"), +} SETUPTOOLS_PATCHES = { "long_description_content_type", @@ -422,17 +448,17 @@ def _acessor(obj): _RESET_PREVIOUSLY_DEFINED: dict = { # Fix improper setting: given in `setup.py`, but not listed in `dynamic` # dict: pyproject name => value to which reset - "license": {}, - "authors": [], - "maintainers": [], - "keywords": [], - "classifiers": [], - "urls": {}, - "entry-points": {}, - "scripts": {}, - "gui-scripts": {}, - "dependencies": [], - "optional-dependencies": {}, + "license": _static.EMPTY_DICT, + "authors": _static.EMPTY_LIST, + "maintainers": _static.EMPTY_LIST, + "keywords": _static.EMPTY_LIST, + "classifiers": _static.EMPTY_LIST, + "urls": _static.EMPTY_DICT, + "entry-points": _static.EMPTY_DICT, + "scripts": _static.EMPTY_DICT, + "gui-scripts": _static.EMPTY_DICT, + "dependencies": _static.EMPTY_LIST, + "optional-dependencies": _static.EMPTY_DICT, } diff --git a/setuptools/config/expand.py b/setuptools/config/expand.py index 54c68bed4f..531f965013 100644 --- a/setuptools/config/expand.py +++ b/setuptools/config/expand.py @@ -34,6 +34,7 @@ from types import ModuleType, TracebackType from typing import TYPE_CHECKING, Any, Callable, TypeVar +from .. import _static from .._path import StrPath, same_path as _same_path from ..discovery import find_package_path from ..warnings import SetuptoolsWarning @@ -181,7 +182,9 @@ def read_attr( spec = _find_spec(module_name, path) try: - return getattr(StaticModule(module_name, spec), attr_name) + value = getattr(StaticModule(module_name, spec), attr_name) + # XXX: Is marking as static contents coming from modules too optimistic? + return _static.attempt_conversion(value) except Exception: # fallback to evaluate module module = _load_spec(spec, module_name) @@ -329,7 +332,7 @@ def version(value: Callable | Iterable[str | int] | str) -> str: return _value if hasattr(_value, '__iter__'): return '.'.join(map(str, _value)) - return '%s' % _value + return f'{_value}' def canonic_package_data(package_data: dict) -> dict: diff --git a/setuptools/config/pyprojecttoml.py b/setuptools/config/pyprojecttoml.py index 15b0baa18e..fd6c5968c8 100644 --- a/setuptools/config/pyprojecttoml.py +++ b/setuptools/config/pyprojecttoml.py @@ -185,7 +185,7 @@ def __init__( self.dynamic_cfg = self.setuptools_cfg.get("dynamic", {}) self.ignore_option_errors = ignore_option_errors self._dist = dist - self._referenced_files: set[str] = set() + self._referenced_files = set[str]() def _ensure_dist(self) -> Distribution: from setuptools.dist import Distribution diff --git a/setuptools/config/setupcfg.py b/setuptools/config/setupcfg.py index b35d0b00cd..633aa9d45d 100644 --- a/setuptools/config/setupcfg.py +++ b/setuptools/config/setupcfg.py @@ -21,9 +21,9 @@ from packaging.markers import default_environment as marker_env from packaging.requirements import InvalidRequirement, Requirement -from packaging.specifiers import SpecifierSet from packaging.version import InvalidVersion, Version +from .. import _static from .._path import StrPath from ..errors import FileError, OptionError from ..warnings import SetuptoolsDeprecationWarning @@ -253,7 +253,7 @@ def __init__( self.sections = dict(self._section_options(options)) self.set_options: list[str] = [] self.ensure_discovered = ensure_discovered - self._referenced_files: set[str] = set() + self._referenced_files = set[str]() """After parsing configurations, this property will enumerate all files referenced by the "file:" directive. Private API for setuptools only. """ @@ -272,7 +272,7 @@ def _section_options( def parsers(self): """Metadata item name to parser function mapping.""" raise NotImplementedError( - '%s must provide .parsers property' % self.__class__.__name__ + f'{self.__class__.__name__} must provide .parsers property' ) def __setitem__(self, option_name, value) -> None: @@ -367,7 +367,7 @@ def parser(value): f'Only strings are accepted for the {key} field, ' 'files are not accepted' ) - return value + return _static.Str(value) return parser @@ -390,12 +390,13 @@ def _parse_file(self, value, root_dir: StrPath | None): return value if not value.startswith(include_directive): - return value + return _static.Str(value) spec = value[len(include_directive) :] filepaths = [path.strip() for path in spec.split(',')] self._referenced_files.update(filepaths) - return expand.read_files(filepaths, root_dir) + # XXX: Is marking as static contents coming from files too optimistic? + return _static.Str(expand.read_files(filepaths, root_dir)) def _parse_attr(self, value, package_dir, root_dir: StrPath): """Represents value as a module attribute. @@ -409,7 +410,7 @@ def _parse_attr(self, value, package_dir, root_dir: StrPath): """ attr_directive = 'attr:' if not value.startswith(attr_directive): - return value + return _static.Str(value) attr_desc = value.replace(attr_directive, '') @@ -548,23 +549,29 @@ def __init__( @property def parsers(self): """Metadata item name to parser function mapping.""" - parse_list = self._parse_list + parse_list_static = self._get_parser_compound(self._parse_list, _static.List) + parse_dict_static = self._get_parser_compound(self._parse_dict, _static.Dict) parse_file = partial(self._parse_file, root_dir=self.root_dir) - parse_dict = self._parse_dict exclude_files_parser = self._exclude_files_parser return { - 'platforms': parse_list, - 'keywords': parse_list, - 'provides': parse_list, - 'obsoletes': parse_list, - 'classifiers': self._get_parser_compound(parse_file, parse_list), + 'author': _static.Str, + 'author_email': _static.Str, + 'maintainer': _static.Str, + 'maintainer_email': _static.Str, + 'platforms': parse_list_static, + 'keywords': parse_list_static, + 'provides': parse_list_static, + 'obsoletes': parse_list_static, + 'classifiers': self._get_parser_compound(parse_file, parse_list_static), 'license': exclude_files_parser('license'), - 'license_files': parse_list, + 'license_files': parse_list_static, 'description': parse_file, 'long_description': parse_file, - 'version': self._parse_version, - 'project_urls': parse_dict, + 'long_description_content_type': _static.Str, + 'version': self._parse_version, # Cannot be marked as dynamic + 'url': _static.Str, + 'project_urls': parse_dict_static, } def _parse_version(self, value): @@ -620,20 +627,20 @@ def _parse_requirements_list(self, label: str, value: str): _warn_accidental_env_marker_misconfig(label, value, parsed) # Filter it to only include lines that are not comments. `parse_list` # will have stripped each line and filtered out empties. - return [line for line in parsed if not line.startswith("#")] + return _static.List(line for line in parsed if not line.startswith("#")) + # ^-- Use `_static.List` to mark a non-`Dynamic` Core Metadata @property def parsers(self): """Metadata item name to parser function mapping.""" parse_list = self._parse_list parse_bool = self._parse_bool - parse_dict = self._parse_dict parse_cmdclass = self._parse_cmdclass return { 'zip_safe': parse_bool, 'include_package_data': parse_bool, - 'package_dir': parse_dict, + 'package_dir': self._parse_dict, 'scripts': parse_list, 'eager_resources': parse_list, 'dependency_links': parse_list, @@ -643,14 +650,14 @@ def parsers(self): "consider using implicit namespaces instead (PEP 420).", # TODO: define due date, see setuptools.dist:check_nsp. ), - 'install_requires': partial( + 'install_requires': partial( # Core Metadata self._parse_requirements_list, "install_requires" ), 'setup_requires': self._parse_list_semicolon, 'packages': self._parse_packages, 'entry_points': self._parse_file_in_root, 'py_modules': parse_list, - 'python_requires': SpecifierSet, + 'python_requires': _static.SpecifierSet, # Core Metadata 'cmdclass': parse_cmdclass, } @@ -727,7 +734,7 @@ def parse_section_exclude_package_data(self, section_options) -> None: """ self['exclude_package_data'] = self._parse_package_data(section_options) - def parse_section_extras_require(self, section_options) -> None: + def parse_section_extras_require(self, section_options) -> None: # Core Metadata """Parses `extras_require` configuration file section. :param dict section_options: @@ -737,7 +744,8 @@ def parse_section_extras_require(self, section_options) -> None: lambda k, v: self._parse_requirements_list(f"extras_require[{k}]", v), ) - self['extras_require'] = parsed + self['extras_require'] = _static.Dict(parsed) + # ^-- Use `_static.Dict` to mark a non-`Dynamic` Core Metadata def parse_section_data_files(self, section_options) -> None: """Parses `data_files` configuration file section. diff --git a/setuptools/depends.py b/setuptools/depends.py index 1be71857a5..e5223b7956 100644 --- a/setuptools/depends.py +++ b/setuptools/depends.py @@ -43,7 +43,7 @@ def __init__( def full_name(self): """Return full package/distribution name, w/version""" if self.requested_version is not None: - return '%s-%s' % (self.name, self.requested_version) + return f'{self.name}-{self.requested_version}' return self.name def version_ok(self, version): diff --git a/setuptools/dist.py b/setuptools/dist.py index 5b3175fb5b..0249651267 100644 --- a/setuptools/dist.py +++ b/setuptools/dist.py @@ -19,6 +19,7 @@ from . import ( _entry_points, _reqs, + _static, command as _, # noqa: F401 # imported for side-effects ) from ._importlib import metadata @@ -85,7 +86,7 @@ def check_importable(dist, attr, value): assert not ep.extras except (TypeError, ValueError, AttributeError, AssertionError) as e: raise DistutilsSetupError( - "%r must be importable 'module:attrs' string (got %r)" % (attr, value) + f"{attr!r} must be importable 'module:attrs' string (got {value!r})" ) from e @@ -110,8 +111,7 @@ def check_nsp(dist, attr, value): for nsp in ns_packages: if not dist.has_contents_for(nsp): raise DistutilsSetupError( - "Distribution contains no modules or packages for " - + "namespace package %r" % nsp + f"Distribution contains no modules or packages for namespace package {nsp!r}" ) parent, _sep, _child = nsp.rpartition('.') if parent and parent not in ns_packages: @@ -210,15 +210,15 @@ def check_package_data(dist, attr, value): """Verify that value is a dictionary of package names to glob lists""" if not isinstance(value, dict): raise DistutilsSetupError( - "{!r} must be a dictionary mapping package names to lists of " - "string wildcard patterns".format(attr) + f"{attr!r} must be a dictionary mapping package names to lists of " + "string wildcard patterns" ) for k, v in value.items(): if not isinstance(k, str): raise DistutilsSetupError( - "keys of {!r} dict must be strings (got {!r})".format(attr, k) + f"keys of {attr!r} dict must be strings (got {k!r})" ) - assert_string_list(dist, 'values of {!r} dict'.format(attr), v) + assert_string_list(dist, f'values of {attr!r} dict', v) def check_packages(dist, attr, value): @@ -321,7 +321,7 @@ def __init__(self, attrs: MutableMapping[str, Any] | None = None) -> None: # Private API (setuptools-use only, not restricted to Distribution) # Stores files that are referenced by the configuration and need to be in the # sdist (e.g. `version = file: VERSION.txt`) - self._referenced_files: set[str] = set() + self._referenced_files = set[str]() self.set_defaults = ConfigDiscovery(self) @@ -392,15 +392,20 @@ def _normalize_requires(self): """Make sure requirement-related attributes exist and are normalized""" install_requires = getattr(self, "install_requires", None) or [] extras_require = getattr(self, "extras_require", None) or {} - self.install_requires = list(map(str, _reqs.parse(install_requires))) - self.extras_require = { - k: list(map(str, _reqs.parse(v or []))) for k, v in extras_require.items() - } + + # Preserve the "static"-ness of values parsed from config files + list_ = _static.List if _static.is_static(install_requires) else list + self.install_requires = list_(map(str, _reqs.parse(install_requires))) + + dict_ = _static.Dict if _static.is_static(extras_require) else dict + self.extras_require = dict_( + (k, list(map(str, _reqs.parse(v or [])))) for k, v in extras_require.items() + ) def _finalize_license_files(self) -> None: """Compute names of all license files which should be included.""" license_files: list[str] | None = self.metadata.license_files - patterns: list[str] = license_files if license_files else [] + patterns = license_files or [] license_file: str | None = self.metadata.license_file if license_file and license_file not in patterns: @@ -588,10 +593,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(" %s = %s (from %s)" % (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: @@ -611,8 +616,7 @@ 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 e: raise DistutilsOptionError(e) from e @@ -818,7 +822,7 @@ def _exclude_misc(self, name: str, value: _Sequence) -> None: try: old = getattr(self, name) except AttributeError as e: - raise DistutilsSetupError("%s: No such distribution setting" % name) from e + raise DistutilsSetupError(f"{name}: No such distribution setting") from e if old is not None and not isinstance(old, _sequence): raise DistutilsSetupError( name + ": this setting cannot be changed via include/exclude" @@ -836,7 +840,7 @@ def _include_misc(self, name: str, value: _Sequence) -> None: try: old = getattr(self, name) except AttributeError as e: - raise DistutilsSetupError("%s: No such distribution setting" % name) from e + raise DistutilsSetupError(f"{name}: No such distribution setting") from e if old is None: setattr(self, name, value) elif not isinstance(old, _sequence): diff --git a/setuptools/monkey.py b/setuptools/monkey.py index d8e30dbb80..6ad1abac29 100644 --- a/setuptools/monkey.py +++ b/setuptools/monkey.py @@ -64,7 +64,7 @@ def get_unpatched_class(cls: type[_T]) -> type[_T]: ) base = next(external_bases) if not base.__module__.startswith('distutils'): - msg = "distutils has already been patched by %r" % cls + msg = f"distutils has already been patched by {cls!r}" raise AssertionError(msg) return base diff --git a/setuptools/msvc.py b/setuptools/msvc.py index 8d6d2cf084..9c9a63568e 100644 --- a/setuptools/msvc.py +++ b/setuptools/msvc.py @@ -108,7 +108,7 @@ def current_dir(self, hidex86=False, x64=False) -> str: if (self.current_cpu == 'x86' and hidex86) else r'\x64' if (self.current_cpu == 'amd64' and x64) - else r'\%s' % self.current_cpu + else rf'\{self.current_cpu}' ) def target_dir(self, hidex86=False, x64=False) -> str: @@ -132,7 +132,7 @@ def target_dir(self, hidex86=False, x64=False) -> str: if (self.target_cpu == 'x86' and hidex86) else r'\x64' if (self.target_cpu == 'amd64' and x64) - else r'\%s' % self.target_cpu + else rf'\{self.target_cpu}' ) def cross_dir(self, forcex86=False): @@ -155,7 +155,7 @@ def cross_dir(self, forcex86=False): return ( '' if self.target_cpu == current - else self.target_dir().replace('\\', '\\%s_' % current) + else self.target_dir().replace('\\', f'\\{current}_') ) @@ -497,11 +497,11 @@ def VSInstallDir(self): """ # Default path default = os.path.join( - self.ProgramFilesx86, 'Microsoft Visual Studio %0.1f' % self.vs_ver + self.ProgramFilesx86, f'Microsoft Visual Studio {self.vs_ver:0.1f}' ) # Try to get path from registry, if fail use default path - return self.ri.lookup(self.ri.vs, '%0.1f' % self.vs_ver) or default + return self.ri.lookup(self.ri.vs, f'{self.vs_ver:0.1f}') or default @property def VCInstallDir(self): @@ -561,16 +561,17 @@ def _guess_vc_legacy(self): path """ default = os.path.join( - self.ProgramFilesx86, r'Microsoft Visual Studio %0.1f\VC' % self.vs_ver + self.ProgramFilesx86, + rf'Microsoft Visual Studio {self.vs_ver:0.1f}\VC', ) # Try to get "VC++ for Python" path from registry as default path - reg_path = os.path.join(self.ri.vc_for_python, '%0.1f' % self.vs_ver) + reg_path = os.path.join(self.ri.vc_for_python, f'{self.vs_ver:0.1f}') python_vc = self.ri.lookup(reg_path, 'installdir') default_vc = os.path.join(python_vc, 'VC') if python_vc else default # Try to get path from registry, if fail use default path - return self.ri.lookup(self.ri.vc, '%0.1f' % self.vs_ver) or default_vc + return self.ri.lookup(self.ri.vc, f'{self.vs_ver:0.1f}') or default_vc @property def WindowsSdkVersion(self) -> tuple[LiteralString, ...]: @@ -619,13 +620,13 @@ def WindowsSdkDir(self) -> str | None: # noqa: C901 # is too complex (12) # F sdkdir: str | None = '' for ver in self.WindowsSdkVersion: # Try to get it from registry - loc = os.path.join(self.ri.windows_sdk, 'v%s' % ver) + loc = os.path.join(self.ri.windows_sdk, f'v{ver}') sdkdir = self.ri.lookup(loc, 'installationfolder') if sdkdir: break if not sdkdir or not os.path.isdir(sdkdir): # Try to get "VC++ for Python" version from registry - path = os.path.join(self.ri.vc_for_python, '%0.1f' % self.vc_ver) + path = os.path.join(self.ri.vc_for_python, f'{self.vc_ver:0.1f}') install_base = self.ri.lookup(path, 'installdir') if install_base: sdkdir = os.path.join(install_base, 'WinSDK') @@ -633,14 +634,14 @@ def WindowsSdkDir(self) -> str | None: # noqa: C901 # is too complex (12) # F # If fail, use default new path for ver in self.WindowsSdkVersion: intver = ver[: ver.rfind('.')] - path = r'Microsoft SDKs\Windows Kits\%s' % intver + path = rf'Microsoft SDKs\Windows Kits\{intver}' d = os.path.join(self.ProgramFiles, path) if os.path.isdir(d): sdkdir = d if not sdkdir or not os.path.isdir(sdkdir): # If fail, use default old path for ver in self.WindowsSdkVersion: - path = r'Microsoft SDKs\Windows\v%s' % ver + path = rf'Microsoft SDKs\Windows\v{ver}' d = os.path.join(self.ProgramFiles, path) if os.path.isdir(d): sdkdir = d @@ -666,8 +667,8 @@ def WindowsSDKExecutablePath(self): else: netfxver = 40 hidex86 = True if self.vs_ver <= 12.0 else False - arch = self.pi.current_dir(x64=True, hidex86=hidex86) - fx = 'WinSDK-NetFx%dTools%s' % (netfxver, arch.replace('\\', '-')) + arch = self.pi.current_dir(x64=True, hidex86=hidex86).replace('\\', '-') + fx = f'WinSDK-NetFx{netfxver}Tools{arch}' # list all possibles registry paths regpaths = [] @@ -676,7 +677,7 @@ def WindowsSDKExecutablePath(self): regpaths += [os.path.join(self.ri.netfx_sdk, ver, fx)] for ver in self.WindowsSdkVersion: - regpaths += [os.path.join(self.ri.windows_sdk, 'v%sA' % ver, fx)] + regpaths += [os.path.join(self.ri.windows_sdk, f'v{ver}A', fx)] # Return installation folder from the more recent path for path in regpaths: @@ -696,7 +697,7 @@ def FSharpInstallDir(self): str path """ - path = os.path.join(self.ri.visualstudio, r'%0.1f\Setup\F#' % self.vs_ver) + path = os.path.join(self.ri.visualstudio, rf'{self.vs_ver:0.1f}\Setup\F#') return self.ri.lookup(path, 'productdir') or '' @property @@ -714,7 +715,7 @@ def UniversalCRTSdkDir(self): # Find path of the more recent Kit for ver in vers: - sdkdir = self.ri.lookup(self.ri.windows_kits_roots, 'kitsroot%s' % ver) + sdkdir = self.ri.lookup(self.ri.windows_kits_roots, f'kitsroot{ver}') if sdkdir: return sdkdir or '' @@ -838,8 +839,8 @@ def _find_dot_net_versions(self, bits) -> tuple[str, ...]: versions """ # Find actual .NET version in registry - reg_ver = self.ri.lookup(self.ri.vc, 'frameworkver%d' % bits) - dot_net_dir = getattr(self, 'FrameworkDir%d' % bits) + reg_ver = self.ri.lookup(self.ri.vc, f'frameworkver{bits}') + dot_net_dir = getattr(self, f'FrameworkDir{bits}') ver = reg_ver or self._use_last_dir_name(dot_net_dir, 'v') or '' # Set .NET versions for specified MSVC++ version @@ -960,7 +961,7 @@ def VSTools(self): arch_subdir = self.pi.current_dir(hidex86=True, x64=True) paths += [r'Common7\IDE\CommonExtensions\Microsoft\TestWindow'] paths += [r'Team Tools\Performance Tools'] - paths += [r'Team Tools\Performance Tools%s' % arch_subdir] + paths += [rf'Team Tools\Performance Tools{arch_subdir}'] return [os.path.join(self.si.VSInstallDir, path) for path in paths] @@ -993,10 +994,10 @@ def VCLibraries(self): arch_subdir = self.pi.target_dir(x64=True) else: arch_subdir = self.pi.target_dir(hidex86=True) - paths = ['Lib%s' % arch_subdir, r'ATLMFC\Lib%s' % arch_subdir] + paths = [f'Lib{arch_subdir}', rf'ATLMFC\Lib{arch_subdir}'] if self.vs_ver >= 14.0: - paths += [r'Lib\store%s' % arch_subdir] + paths += [rf'Lib\store{arch_subdir}'] return [os.path.join(self.si.VCInstallDir, path) for path in paths] @@ -1030,10 +1031,10 @@ def VCTools(self): forcex86 = True if self.vs_ver <= 10.0 else False arch_subdir = self.pi.cross_dir(forcex86) if arch_subdir: - tools += [os.path.join(si.VCInstallDir, 'Bin%s' % arch_subdir)] + tools += [os.path.join(si.VCInstallDir, f'Bin{arch_subdir}')] if self.vs_ver == 14.0: - path = 'Bin%s' % self.pi.current_dir(hidex86=True) + path = f'Bin{self.pi.current_dir(hidex86=True)}' tools += [os.path.join(si.VCInstallDir, path)] elif self.vs_ver >= 15.0: @@ -1068,13 +1069,13 @@ def OSLibraries(self): """ if self.vs_ver <= 10.0: arch_subdir = self.pi.target_dir(hidex86=True, x64=True) - return [os.path.join(self.si.WindowsSdkDir, 'Lib%s' % arch_subdir)] + return [os.path.join(self.si.WindowsSdkDir, f'Lib{arch_subdir}')] else: arch_subdir = self.pi.target_dir(x64=True) lib = os.path.join(self.si.WindowsSdkDir, 'lib') libver = self._sdk_subdir - return [os.path.join(lib, '%sum%s' % (libver, arch_subdir))] + return [os.path.join(lib, f'{libver}um{arch_subdir}')] @property def OSIncludes(self): @@ -1097,9 +1098,9 @@ def OSIncludes(self): else: sdkver = '' return [ - os.path.join(include, '%sshared' % sdkver), - os.path.join(include, '%sum' % sdkver), - os.path.join(include, '%swinrt' % sdkver), + os.path.join(include, f'{sdkver}shared'), + os.path.join(include, f'{sdkver}um'), + os.path.join(include, f'{sdkver}winrt'), ] @property @@ -1134,7 +1135,7 @@ def OSLibpath(self): self.si.WindowsSdkDir, 'ExtensionSDKs', 'Microsoft.VCLibs', - '%0.1f' % self.vs_ver, + f'{self.vs_ver:0.1f}', 'References', 'CommonConfiguration', 'neutral', @@ -1169,7 +1170,7 @@ def _sdk_tools(self): if not self.pi.current_is_x86(): arch_subdir = self.pi.current_dir(x64=True) - path = 'Bin%s' % arch_subdir + path = f'Bin{arch_subdir}' yield os.path.join(self.si.WindowsSdkDir, path) if self.vs_ver in (10.0, 11.0): @@ -1177,14 +1178,14 @@ def _sdk_tools(self): arch_subdir = '' else: arch_subdir = self.pi.current_dir(hidex86=True, x64=True) - path = r'Bin\NETFX 4.0 Tools%s' % arch_subdir + path = rf'Bin\NETFX 4.0 Tools{arch_subdir}' yield os.path.join(self.si.WindowsSdkDir, path) elif self.vs_ver >= 15.0: path = os.path.join(self.si.WindowsSdkDir, 'Bin') arch_subdir = self.pi.current_dir(x64=True) sdkver = self.si.WindowsSdkLastVersion - yield os.path.join(path, '%s%s' % (sdkver, arch_subdir)) + yield os.path.join(path, f'{sdkver}{arch_subdir}') if self.si.WindowsSDKExecutablePath: yield self.si.WindowsSDKExecutablePath @@ -1200,7 +1201,7 @@ def _sdk_subdir(self): subdir """ ucrtver = self.si.WindowsSdkLastVersion - return ('%s\\' % ucrtver) if ucrtver else '' + return (f'{ucrtver}\\') if ucrtver else '' @property def SdkSetup(self): @@ -1262,7 +1263,7 @@ def NetFxSDKLibraries(self): return [] arch_subdir = self.pi.target_dir(x64=True) - return [os.path.join(self.si.NetFxSdkDir, r'lib\um%s' % arch_subdir)] + return [os.path.join(self.si.NetFxSdkDir, rf'lib\um{arch_subdir}')] @property def NetFxSDKIncludes(self): @@ -1310,7 +1311,7 @@ def MSBuild(self): base_path = self.si.VSInstallDir arch_subdir = '' - path = r'MSBuild\%0.1f\bin%s' % (self.vs_ver, arch_subdir) + path = rf'MSBuild\{self.vs_ver:0.1f}\bin{arch_subdir}' build = [os.path.join(base_path, path)] if self.vs_ver >= 15.0: @@ -1350,7 +1351,7 @@ def UCRTLibraries(self): arch_subdir = self.pi.target_dir(x64=True) lib = os.path.join(self.si.UniversalCRTSdkDir, 'lib') ucrtver = self._ucrt_subdir - return [os.path.join(lib, '%sucrt%s' % (ucrtver, arch_subdir))] + return [os.path.join(lib, f'{ucrtver}ucrt{arch_subdir}')] @property def UCRTIncludes(self): @@ -1366,7 +1367,7 @@ def UCRTIncludes(self): return [] include = os.path.join(self.si.UniversalCRTSdkDir, 'include') - return [os.path.join(include, '%sucrt' % self._ucrt_subdir)] + return [os.path.join(include, f'{self._ucrt_subdir}ucrt')] @property def _ucrt_subdir(self): @@ -1379,7 +1380,7 @@ def _ucrt_subdir(self): subdir """ ucrtver = self.si.UniversalCRTSdkLastVersion - return ('%s\\' % ucrtver) if ucrtver else '' + return (f'{ucrtver}\\') if ucrtver else '' @property def FSharp(self): @@ -1403,7 +1404,7 @@ def VCRuntimeRedist(self) -> str | None: Returns the first suitable path found or None. """ - vcruntime = 'vcruntime%d0.dll' % self.vc_ver + vcruntime = f'vcruntime{self.vc_ver}0.dll' arch_subdir = self.pi.target_dir(x64=True).strip('\\') # Installation prefixes candidates @@ -1419,9 +1420,9 @@ def VCRuntimeRedist(self) -> str | None: # CRT directory crt_dirs = ( - 'Microsoft.VC%d.CRT' % (self.vc_ver * 10), + f'Microsoft.VC{self.vc_ver * 10}.CRT', # Sometime store in directory with VS version instead of VC - 'Microsoft.VC%d.CRT' % (int(self.vs_ver) * 10), + f'Microsoft.VC{int(self.vs_ver) * 10}.CRT', ) # vcruntime path @@ -1520,7 +1521,7 @@ def _build_paths(self, name, spec_path_lists, exists): paths = itertools.chain(spec_paths, env_paths) extant_paths = list(filter(os.path.isdir, paths)) if exists else paths if not extant_paths: - msg = "%s environment variable is empty" % name.upper() + msg = f"{name.upper()} environment variable is empty" raise distutils.errors.DistutilsPlatformError(msg) unique_paths = unique_everseen(extant_paths) return os.pathsep.join(unique_paths) diff --git a/setuptools/package_index.py b/setuptools/package_index.py index 97806e8ff8..1a6abebcda 100644 --- a/setuptools/package_index.py +++ b/setuptools/package_index.py @@ -74,7 +74,7 @@ def parse_requirement_arg(spec): return Requirement.parse(spec) except ValueError as e: raise DistutilsError( - "Not a URL, existing file, or requirement spec: %r" % (spec,) + f"Not a URL, existing file, or requirement spec: {spec!r}" ) from e @@ -357,7 +357,7 @@ def process_url(self, url, retrieve: bool = False) -> None: # noqa: C901 if f is None: return if isinstance(f, urllib.error.HTTPError) and f.code == 401: - self.info("Authentication error: %s" % f.msg) + self.info(f"Authentication error: {f.msg}") self.fetched_urls[f.url] = True if 'html' not in f.headers.get('content-type', '').lower(): f.close() # not html, we can't process it @@ -474,13 +474,13 @@ def process_index(self, url, page): base, frag = egg_info_for_url(new_url) if base.endswith('.py') and not frag: if ver: - new_url += '#egg=%s-%s' % (pkg, ver) + new_url += f'#egg={pkg}-{ver}' else: self.need_version_info(url) self.scan_url(new_url) return PYPI_MD5.sub( - lambda m: '%s' % m.group(1, 3, 2), page + lambda m: '{}'.format(*m.group(1, 3, 2)), page ) def need_version_info(self, url) -> None: @@ -525,14 +525,13 @@ def check_hash(self, checker, filename, tfp) -> None: """ checker is a ContentChecker """ - checker.report(self.debug, "Validating %%s checksum for %s" % filename) + checker.report(self.debug, f"Validating %s checksum for {filename}") if not checker.is_valid(): tfp.close() os.unlink(filename) raise DistutilsError( - "%s validation failed for %s; " + f"{checker.hash.name} validation failed for {os.path.basename(filename)}; " "possible download problem?" - % (checker.hash.name, os.path.basename(filename)) ) def add_find_links(self, urls) -> None: @@ -720,20 +719,15 @@ def gen_setup(self, filename, fragment, tmpdir): with open(os.path.join(tmpdir, 'setup.py'), 'w', encoding="utf-8") as file: file.write( "from setuptools import setup\n" - "setup(name=%r, version=%r, py_modules=[%r])\n" - % ( - dists[0].project_name, - dists[0].version, - os.path.splitext(basename)[0], - ) + f"setup(name={dists[0].project_name!r}, version={dists[0].version!r}, py_modules=[{os.path.splitext(basename)[0]!r}])\n" ) return filename elif match: raise DistutilsError( - "Can't unambiguously interpret project/version identifier %r; " + f"Can't unambiguously interpret project/version identifier {fragment!r}; " "any dashes in the name or version should be escaped using " - "underscores. %r" % (fragment, dists) + f"underscores. {dists!r}" ) else: raise DistutilsError( @@ -751,9 +745,7 @@ def _download_to(self, url, filename): checker = HashChecker.from_url(url) fp = self.open_url(url) if isinstance(fp, urllib.error.HTTPError): - raise DistutilsError( - "Can't download %s: %s %s" % (url, fp.code, fp.msg) - ) + raise DistutilsError(f"Can't download {url}: {fp.code} {fp.msg}") headers = fp.info() blocknum = 0 bs = self.dl_blocksize @@ -793,29 +785,27 @@ def open_url(self, url, warning=None): # noqa: C901 # is too complex (12) if warning: self.warn(warning, msg) else: - raise DistutilsError('%s %s' % (url, msg)) from v + raise DistutilsError(f'{url} {msg}') from v except urllib.error.HTTPError as v: return v except urllib.error.URLError as v: if warning: self.warn(warning, v.reason) else: - raise DistutilsError( - "Download error for %s: %s" % (url, v.reason) - ) from v + raise DistutilsError(f"Download error for {url}: {v.reason}") from v except http.client.BadStatusLine as v: if warning: self.warn(warning, v.line) else: raise DistutilsError( - '%s returned a bad status line. The server might be ' - 'down, %s' % (url, v.line) + f'{url} returned a bad status line. The server might be ' + f'down, {v.line}' ) from v except (http.client.HTTPException, OSError) as v: if warning: self.warn(warning, v) else: - raise DistutilsError("Download error for %s: %s" % (url, v)) from v + raise DistutilsError(f"Download error for {url}: {v}") from v def _download_url(self, url, tmpdir): # Determine download filename @@ -1134,7 +1124,7 @@ def local_open(url): break elif os.path.isdir(filepath): f += '/' - files.append('{name}'.format(name=f)) + files.append(f'{f}') else: tmpl = "{url}{files}" body = tmpl.format(url=url, files='\n'.join(files)) diff --git a/setuptools/tests/config/test_apply_pyprojecttoml.py b/setuptools/tests/config/test_apply_pyprojecttoml.py index da43bb6a2b..20146b4a89 100644 --- a/setuptools/tests/config/test_apply_pyprojecttoml.py +++ b/setuptools/tests/config/test_apply_pyprojecttoml.py @@ -18,6 +18,7 @@ from packaging.metadata import Metadata import setuptools # noqa: F401 # ensure monkey patch to metadata +from setuptools._static import is_static from setuptools.command.egg_info import write_requirements from setuptools.config import expand, pyprojecttoml, setupcfg from setuptools.config._apply_pyprojecttoml import _MissingDynamic, _some_attrgetter @@ -480,6 +481,32 @@ def test_version(self, tmp_path, monkeypatch, capsys): assert "42.0" in captured.out +class TestStaticConfig: + def test_mark_static_fields(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + toml_config = """ + [project] + name = "test" + version = "42.0" + dependencies = ["hello"] + keywords = ["world"] + classifiers = ["private :: hello world"] + [tool.setuptools] + obsoletes = ["abcd"] + provides = ["abcd"] + platforms = ["abcd"] + """ + pyproject = Path(tmp_path, "pyproject.toml") + pyproject.write_text(cleandoc(toml_config), encoding="utf-8") + dist = pyprojecttoml.apply_configuration(Distribution({}), pyproject) + assert is_static(dist.install_requires) + assert is_static(dist.metadata.keywords) + assert is_static(dist.metadata.classifiers) + assert is_static(dist.metadata.obsoletes) + assert is_static(dist.metadata.provides) + assert is_static(dist.metadata.platforms) + + # --- Auxiliary Functions --- diff --git a/setuptools/tests/config/test_expand.py b/setuptools/tests/config/test_expand.py index fa9122b32c..c5710ec63d 100644 --- a/setuptools/tests/config/test_expand.py +++ b/setuptools/tests/config/test_expand.py @@ -4,6 +4,7 @@ import pytest +from setuptools._static import is_static from setuptools.config import expand from setuptools.discovery import find_package_path @@ -93,11 +94,15 @@ def test_read_attr(self, tmp_path, monkeypatch): with monkeypatch.context() as m: m.chdir(tmp_path) # Make sure it can read the attr statically without evaluating the module - assert expand.read_attr('pkg.sub.VERSION') == '0.1.1' + version = expand.read_attr('pkg.sub.VERSION') values = expand.read_attr('lib.mod.VALUES', {'lib': 'pkg/sub'}) + assert version == '0.1.1' + assert is_static(values) + assert values['a'] == 0 assert values['b'] == {42} + assert is_static(values) # Make sure the same APIs work outside cwd assert expand.read_attr('pkg.sub.VERSION', root_dir=tmp_path) == '0.1.1' @@ -118,7 +123,28 @@ def test_read_annotated_attr(self, tmp_path, example): } write_files(files, tmp_path) # Make sure this attribute can be read statically - assert expand.read_attr('pkg.sub.VERSION', root_dir=tmp_path) == '0.1.1' + version = expand.read_attr('pkg.sub.VERSION', root_dir=tmp_path) + assert version == '0.1.1' + assert is_static(version) + + @pytest.mark.parametrize( + "example", + [ + "VERSION = (lambda: '0.1.1')()\n", + "def fn(): return '0.1.1'\nVERSION = fn()\n", + "VERSION: str = (lambda: '0.1.1')()\n", + ], + ) + def test_read_dynamic_attr(self, tmp_path, monkeypatch, example): + files = { + "pkg/__init__.py": "", + "pkg/sub/__init__.py": example, + } + write_files(files, tmp_path) + monkeypatch.chdir(tmp_path) + version = expand.read_attr('pkg.sub.VERSION') + assert version == '0.1.1' + assert not is_static(version) def test_import_order(self, tmp_path): """ diff --git a/setuptools/tests/config/test_setupcfg.py b/setuptools/tests/config/test_setupcfg.py index b31118c0fb..adadc02da3 100644 --- a/setuptools/tests/config/test_setupcfg.py +++ b/setuptools/tests/config/test_setupcfg.py @@ -87,14 +87,14 @@ def test_basic(self, tmpdir): '[options]\n' 'scripts = bin/a.py, bin/b.py\n', ) - config_dict = read_configuration('%s' % config) + config_dict = read_configuration(str(config)) assert config_dict['metadata']['version'] == '10.1.1' assert config_dict['metadata']['keywords'] == ['one', 'two'] assert config_dict['options']['scripts'] == ['bin/a.py', 'bin/b.py'] def test_no_config(self, tmpdir): with pytest.raises(DistutilsFileError): - read_configuration('%s' % tmpdir.join('setup.cfg')) + read_configuration(str(tmpdir.join('setup.cfg'))) def test_ignore_errors(self, tmpdir): _, config = fake_env( @@ -102,9 +102,9 @@ def test_ignore_errors(self, tmpdir): '[metadata]\nversion = attr: none.VERSION\nkeywords = one, two\n', ) with pytest.raises(ImportError): - read_configuration('%s' % config) + read_configuration(str(config)) - config_dict = read_configuration('%s' % config, ignore_option_errors=True) + config_dict = read_configuration(str(config), ignore_option_errors=True) assert config_dict['metadata']['keywords'] == ['one', 'two'] assert 'version' not in config_dict['metadata'] diff --git a/setuptools/tests/server.py b/setuptools/tests/server.py index 15bbc3b1f0..623a49a550 100644 --- a/setuptools/tests/server.py +++ b/setuptools/tests/server.py @@ -44,7 +44,7 @@ def stop(self): def base_url(self): port = self.server_port - return 'http://127.0.0.1:%s/setuptools/tests/indexes/' % port + return f'http://127.0.0.1:{port}/setuptools/tests/indexes/' class RequestRecorder(http.server.BaseHTTPRequestHandler): @@ -70,11 +70,11 @@ def run(self): @property def netloc(self): - return 'localhost:%s' % self.server_port + return f'localhost:{self.server_port}' @property def url(self): - return 'http://%s/' % self.netloc + return f'http://{self.netloc}/' def path_to_url(path, authority=None): diff --git a/setuptools/tests/test_build_ext.py b/setuptools/tests/test_build_ext.py index 88318b26c5..c7b60ac32f 100644 --- a/setuptools/tests/test_build_ext.py +++ b/setuptools/tests/test_build_ext.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import os import sys from importlib.util import cache_from_source as _compiled_file_name @@ -179,11 +181,11 @@ def C(file): class TestBuildExtInplace: def get_build_ext_cmd(self, optional: bool, **opts) -> build_ext: - files = { + files: dict[str, str | dict[str, dict[str, str]]] = { "eggs.c": "#include missingheader.h\n", ".build": {"lib": {}, "tmp": {}}, } - path.build(files) # jaraco/path#232 + path.build(files) extension = Extension('spam.eggs', ['eggs.c'], optional=optional) dist = Distribution(dict(ext_modules=[extension])) dist.script_name = 'setup.py' @@ -284,8 +286,8 @@ def test_build_ext_config_handling(tmpdir_cwd): ), } path.build(files) - code, output = environment.run_setup_py( + code, (stdout, stderr) = environment.run_setup_py( cmd=['build'], data_stream=(0, 2), ) - assert code == 0, '\nSTDOUT:\n%s\nSTDERR:\n%s' % output + assert code == 0, f'\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}' diff --git a/setuptools/tests/test_core_metadata.py b/setuptools/tests/test_core_metadata.py index cf0bb32e9f..b1edb79b40 100644 --- a/setuptools/tests/test_core_metadata.py +++ b/setuptools/tests/test_core_metadata.py @@ -5,9 +5,10 @@ import io from email import message_from_string from email.generator import Generator -from email.message import Message +from email.message import EmailMessage, Message from email.parser import Parser from email.policy import EmailPolicy +from inspect import cleandoc from pathlib import Path from unittest.mock import Mock @@ -311,7 +312,7 @@ def test_maintainer_author(name, attrs, tmpdir): for line in pkg_lines: assert not line.startswith(fkey + ':') else: - line = '%s: %s' % (fkey, val) + line = f'{fkey}: {val}' assert line in pkg_lines_set @@ -411,12 +412,97 @@ def test_equivalent_output(self, tmp_path, dist): _assert_roundtrip_message(pkg_info) +class TestPEP643: + STATIC_CONFIG = { + "setup.cfg": cleandoc( + """ + [metadata] + name = package + version = 0.0.1 + author = Foo Bar + author_email = foo@bar.net + long_description = Long + description + description = Short description + keywords = one, two + platforms = abcd + [options] + install_requires = requests + """ + ), + "pyproject.toml": cleandoc( + """ + [project] + name = "package" + version = "0.0.1" + authors = [ + {name = "Foo Bar", email = "foo@bar.net"} + ] + description = "Short description" + readme = {text = "Long\\ndescription", content-type = "text/plain"} + keywords = ["one", "two"] + dependencies = ["requests"] + [tool.setuptools] + provides = ["abcd"] + obsoletes = ["abcd"] + """ + ), + } + + @pytest.mark.parametrize("file", STATIC_CONFIG.keys()) + def test_static_config_has_no_dynamic(self, file, tmpdir_cwd): + Path(file).write_text(self.STATIC_CONFIG[file], encoding="utf-8") + metadata = _get_metadata() + assert metadata.get_all("Dynamic") is None + assert metadata.get_all("dynamic") is None + + @pytest.mark.parametrize("file", STATIC_CONFIG.keys()) + @pytest.mark.parametrize( + "fields", + [ + # Single dynamic field + {"requires-python": ("python_requires", ">=3.12")}, + {"author-email": ("author_email", "snoopy@peanuts.com")}, + {"keywords": ("keywords", ["hello", "world"])}, + {"platform": ("platforms", ["abcd"])}, + # Multiple dynamic fields + { + "summary": ("description", "hello world"), + "description": ("long_description", "bla bla bla bla"), + "requires-dist": ("install_requires", ["hello-world"]), + }, + ], + ) + def test_modified_fields_marked_as_dynamic(self, file, fields, tmpdir_cwd): + # We start with a static config + Path(file).write_text(self.STATIC_CONFIG[file], encoding="utf-8") + dist = _makedist() + + # ... but then we simulate the effects of a plugin modifying the distribution + for attr, value in fields.values(): + # `dist` and `dist.metadata` are complicated... + # Some attributes work when set on `dist`, others on `dist.metadata`... + # Here we set in both just in case (this also avoids calling `_finalize_*`) + setattr(dist, attr, value) + setattr(dist.metadata, attr, value) + + # Then we should be able to list the modified fields as Dynamic + metadata = _get_metadata(dist) + assert set(metadata.get_all("Dynamic")) == set(fields) + + +def _makedist(**attrs): + dist = Distribution(attrs) + dist.parse_config_files() + return dist + + def _assert_roundtrip_message(metadata: str) -> None: """Emulate the way wheel.bdist_wheel parses and regenerates the message, then ensures the metadata generated by setuptools is compatible. """ with io.StringIO(metadata) as buffer: - msg = Parser().parse(buffer) + msg = Parser(EmailMessage).parse(buffer) serialization_policy = EmailPolicy( utf8=True, @@ -462,6 +548,9 @@ def _normalize_metadata(msg: Message) -> str: for extra in sorted(extras): msg["Provides-Extra"] = extra + # TODO: Handle lack of PEP 643 implementation in pypa/wheel? + del msg["Metadata-Version"] + return msg.as_string() @@ -479,6 +568,10 @@ def _get_pkginfo(dist: Distribution): return fp.getvalue() +def _get_metadata(dist: Distribution | None = None): + return message_from_string(_get_pkginfo(dist or _makedist())) + + def _valid_metadata(text: str) -> bool: metadata = Metadata.from_email(text, validate=True) # can raise exceptions return metadata is not None diff --git a/setuptools/tests/test_dist.py b/setuptools/tests/test_dist.py index 7b8cb91469..533eb9f45e 100644 --- a/setuptools/tests/test_dist.py +++ b/setuptools/tests/test_dist.py @@ -24,7 +24,7 @@ def test_dist_fetch_build_egg(tmpdir): def sdist_with_index(distname, version): dist_dir = index.mkdir(distname) - dist_sdist = '%s-%s.tar.gz' % (distname, version) + dist_sdist = f'{distname}-{version}.tar.gz' make_nspkg_sdist(str(dist_dir.join(dist_sdist)), distname, version) with dist_dir.join('index.html').open('w') as fp: fp.write( diff --git a/setuptools/tests/test_easy_install.py b/setuptools/tests/test_easy_install.py index f7a39c8ca6..126efc7060 100644 --- a/setuptools/tests/test_easy_install.py +++ b/setuptools/tests/test_easy_install.py @@ -735,14 +735,14 @@ def make_dependency_sdist(dist_path, distname, version): ( 'setup.py', DALS( - """ + f""" import setuptools setuptools.setup( - name={name!r}, + name={distname!r}, version={version!r}, - py_modules=[{name!r}], + py_modules=[{distname!r}], ) - """.format(name=distname, version=version) + """ ), ), ( @@ -814,7 +814,7 @@ def test_setup_requires_with_pep508_url(self, mock_index, monkeypatch): # Ignored (overridden by setup_attrs) 'python-xlib', '0.19', - setup_attrs=dict(setup_requires='dependency @ %s' % dep_url), + setup_attrs=dict(setup_requires=f'dependency @ {dep_url}'), ) test_setup_py = os.path.join(test_pkg, 'setup.py') run_setup(test_setup_py, ['--version']) @@ -1100,14 +1100,13 @@ def make_trivial_sdist(dist_path, distname, version): ( 'setup.py', DALS( - """\ + f"""\ import setuptools setuptools.setup( - name=%r, - version=%r + name={distname!r}, + version={version!r} ) """ - % (distname, version) ), ), ('setup.cfg', ''), @@ -1128,16 +1127,15 @@ def make_nspkg_sdist(dist_path, distname, version): packages = ['.'.join(parts[:idx]) for idx in range(1, len(parts) + 1)] setup_py = DALS( - """\ + f"""\ import setuptools setuptools.setup( - name=%r, - version=%r, - packages=%r, - namespace_packages=[%r] + name={distname!r}, + version={version!r}, + packages={packages!r}, + namespace_packages=[{nspackage!r}] ) """ - % (distname, version, packages, nspackage) ) init = "__import__('pkg_resources').declare_namespace(__name__)" @@ -1212,7 +1210,7 @@ def create_setup_requires_package( test_setup_attrs = { 'name': 'test_pkg', 'version': '0.0', - 'setup_requires': ['%s==%s' % (distname, version)], + 'setup_requires': [f'{distname}=={version}'], 'dependency_links': [os.path.abspath(path)], } if setup_attrs: @@ -1233,7 +1231,7 @@ def create_setup_requires_package( section = options if isinstance(value, (tuple, list)): value = ';'.join(value) - section.append('%s: %s' % (name, value)) + section.append(f'{name}: {value}') test_setup_cfg_contents = DALS( """ [metadata] @@ -1261,7 +1259,7 @@ def create_setup_requires_package( with open(os.path.join(test_pkg, 'setup.py'), 'w', encoding="utf-8") as f: f.write(setup_py_template % test_setup_attrs) - foobar_path = os.path.join(path, '%s-%s.tar.gz' % (distname, version)) + foobar_path = os.path.join(path, f'{distname}-{version}.tar.gz') make_package(foobar_path, distname, version) return test_pkg @@ -1276,12 +1274,12 @@ class TestScriptHeader: exe_with_spaces = r'C:\Program Files\Python36\python.exe' def test_get_script_header(self): - expected = '#!%s\n' % ei.nt_quote_arg(os.path.normpath(sys.executable)) + expected = f'#!{ei.nt_quote_arg(os.path.normpath(sys.executable))}\n' actual = ei.ScriptWriter.get_header('#!/usr/local/bin/python') assert actual == expected def test_get_script_header_args(self): - expected = '#!%s -x\n' % ei.nt_quote_arg(os.path.normpath(sys.executable)) + expected = f'#!{ei.nt_quote_arg(os.path.normpath(sys.executable))} -x\n' actual = ei.ScriptWriter.get_header('#!/usr/bin/python -x') assert actual == expected @@ -1289,14 +1287,14 @@ def test_get_script_header_non_ascii_exe(self): actual = ei.ScriptWriter.get_header( '#!/usr/bin/python', executable=self.non_ascii_exe ) - expected = '#!%s -x\n' % self.non_ascii_exe + expected = f'#!{self.non_ascii_exe} -x\n' assert actual == expected def test_get_script_header_exe_with_spaces(self): actual = ei.ScriptWriter.get_header( '#!/usr/bin/python', executable='"' + self.exe_with_spaces + '"' ) - expected = '#!"%s"\n' % self.exe_with_spaces + expected = f'#!"{self.exe_with_spaces}"\n' assert actual == expected diff --git a/setuptools/tests/test_egg_info.py b/setuptools/tests/test_egg_info.py index a68ecaba4c..8879ec58ce 100644 --- a/setuptools/tests/test_egg_info.py +++ b/setuptools/tests/test_egg_info.py @@ -38,9 +38,8 @@ def env(): '.pydistutils.cfg': DALS( """ [egg_info] - egg-base = %(egg-base)s - """ - % env.paths + egg-base = {egg-base} + """.format(**env.paths) ) } }) @@ -260,13 +259,9 @@ def _setup_script_with_requires(self, requires, use_setup_cfg=False): 'setup.cfg': setup_config, }) - mismatch_marker = "python_version<'{this_ver}'".format( - this_ver=sys.version_info.major, - ) + mismatch_marker = f"python_version<'{sys.version_info[0]}'" # Alternate equivalent syntax. - mismatch_marker_alternate = 'python_version < "{this_ver}"'.format( - this_ver=sys.version_info.major, - ) + mismatch_marker_alternate = f'python_version < "{sys.version_info[0]}"' invalid_marker = "<=>++" class RequiresTestHelper: @@ -305,7 +300,12 @@ def parametrize(*test_list, **format_dict): ) ) return pytest.mark.parametrize( - 'requires,use_setup_cfg,expected_requires,install_cmd_kwargs', + ( + "requires", + "use_setup_cfg", + "expected_requires", + "install_cmd_kwargs", + ), argvalues, ids=idlist, ) @@ -522,7 +522,7 @@ def test_provides_extra(self, tmpdir_cwd, env): with open(os.path.join(egg_info_dir, 'PKG-INFO'), encoding="utf-8") as fp: pkg_info_lines = fp.read().split('\n') assert 'Provides-Extra: foobar' in pkg_info_lines - assert 'Metadata-Version: 2.1' in pkg_info_lines + assert 'Metadata-Version: 2.2' in pkg_info_lines def test_doesnt_provides_extra(self, tmpdir_cwd, env): self._setup_script_with_requires( @@ -1069,7 +1069,7 @@ def test_metadata_version(self, tmpdir_cwd, env): with open(os.path.join(egg_info_dir, 'PKG-INFO'), encoding="utf-8") as fp: pkg_info_lines = fp.read().split('\n') # Update metadata version if changed - assert self._extract_mv_version(pkg_info_lines) == (2, 1) + assert self._extract_mv_version(pkg_info_lines) == (2, 2) def test_long_description_content_type(self, tmpdir_cwd, env): # Test that specifying a `long_description_content_type` keyword arg to @@ -1096,7 +1096,7 @@ def test_long_description_content_type(self, tmpdir_cwd, env): pkg_info_lines = fp.read().split('\n') expected_line = 'Description-Content-Type: text/markdown' assert expected_line in pkg_info_lines - assert 'Metadata-Version: 2.1' in pkg_info_lines + assert 'Metadata-Version: 2.2' in pkg_info_lines def test_long_description(self, tmpdir_cwd, env): # Test that specifying `long_description` and `long_description_content_type` @@ -1115,7 +1115,7 @@ def test_long_description(self, tmpdir_cwd, env): egg_info_dir = os.path.join('.', 'foo.egg-info') with open(os.path.join(egg_info_dir, 'PKG-INFO'), encoding="utf-8") as fp: pkg_info_lines = fp.read().split('\n') - assert 'Metadata-Version: 2.1' in pkg_info_lines + assert 'Metadata-Version: 2.2' in pkg_info_lines assert '' == pkg_info_lines[-1] # last line should be empty long_desc_lines = pkg_info_lines[pkg_info_lines.index('') :] assert 'This is a long description' in long_desc_lines diff --git a/setuptools/tests/test_install_scripts.py b/setuptools/tests/test_install_scripts.py index 2ae5496525..e62a6b7f31 100644 --- a/setuptools/tests/test_install_scripts.py +++ b/setuptools/tests/test_install_scripts.py @@ -38,7 +38,7 @@ def test_sys_executable_escaping_unix(self, tmpdir, monkeypatch): Ensure that shebang is not quoted on Unix when getting the Python exe from sys.executable. """ - expected = '#!%s\n' % self.unix_exe + expected = f'#!{self.unix_exe}\n' monkeypatch.setattr('sys.executable', self.unix_exe) with tmpdir.as_cwd(): self._run_install_scripts(str(tmpdir)) @@ -52,7 +52,7 @@ def test_sys_executable_escaping_win32(self, tmpdir, monkeypatch): Ensure that shebang is quoted on Windows when getting the Python exe from sys.executable and it contains a space. """ - expected = '#!"%s"\n' % self.win32_exe + expected = f'#!"{self.win32_exe}"\n' monkeypatch.setattr('sys.executable', self.win32_exe) with tmpdir.as_cwd(): self._run_install_scripts(str(tmpdir)) @@ -67,7 +67,7 @@ def test_executable_with_spaces_escaping_unix(self, tmpdir): a value with spaces is specified using --executable. """ - expected = '#!%s\n' % self.unix_spaces_exe + expected = f'#!{self.unix_spaces_exe}\n' with tmpdir.as_cwd(): self._run_install_scripts(str(tmpdir), self.unix_spaces_exe) with open(str(tmpdir.join('foo')), 'r', encoding="utf-8") as f: @@ -81,7 +81,7 @@ def test_executable_arg_escaping_win32(self, tmpdir): getting a path with spaces from --executable, that is itself properly quoted. """ - expected = '#!"%s"\n' % self.win32_exe + expected = f'#!"{self.win32_exe}"\n' with tmpdir.as_cwd(): self._run_install_scripts(str(tmpdir), '"' + self.win32_exe + '"') with open(str(tmpdir.join('foo-script.py')), 'r', encoding="utf-8") as f: diff --git a/setuptools/tests/test_manifest.py b/setuptools/tests/test_manifest.py index ad988d2c5f..903a528db0 100644 --- a/setuptools/tests/test_manifest.py +++ b/setuptools/tests/test_manifest.py @@ -34,14 +34,11 @@ def make_local_path(s): 'packages': ['app'], } -SETUP_PY = ( - """\ +SETUP_PY = f"""\ from setuptools import setup -setup(**%r) +setup(**{SETUP_ATTRS!r}) """ - % SETUP_ATTRS -) @contextlib.contextmanager diff --git a/setuptools/tests/test_sandbox.py b/setuptools/tests/test_sandbox.py index 20db6baaa6..a476b7c93d 100644 --- a/setuptools/tests/test_sandbox.py +++ b/setuptools/tests/test_sandbox.py @@ -76,7 +76,7 @@ class CantPickleThis(Exception): "This Exception is unpickleable because it's not in globals" def __repr__(self) -> str: - return 'CantPickleThis%r' % (self.args,) + return f'CantPickleThis{self.args!r}' with setuptools.sandbox.ExceptionSaver() as saved_exc: raise CantPickleThis('detail') diff --git a/setuptools/tests/test_sdist.py b/setuptools/tests/test_sdist.py index 30347190db..3ee0511b1c 100644 --- a/setuptools/tests/test_sdist.py +++ b/setuptools/tests/test_sdist.py @@ -37,14 +37,11 @@ 'data_files': [("data", [os.path.join("d", "e.dat")])], } -SETUP_PY = ( - """\ +SETUP_PY = f"""\ from setuptools import setup -setup(**%r) +setup(**{SETUP_ATTRS!r}) """ - % SETUP_ATTRS -) EXTENSION = Extension( name="sdist_test.f", diff --git a/setuptools/tests/test_wheel.py b/setuptools/tests/test_wheel.py index 5724c6eabc..70165c608b 100644 --- a/setuptools/tests/test_wheel.py +++ b/setuptools/tests/test_wheel.py @@ -176,7 +176,7 @@ def __init__(self, id, **kwargs): self._fields = kwargs def __repr__(self) -> str: - return '%s(**%r)' % (self._id, self._fields) + return f'{self._id}(**{self._fields!r})' # Using Any to avoid possible type union issues later in test @@ -367,11 +367,10 @@ def __repr__(self) -> str: ), dict( id='requires2', - install_requires=""" + install_requires=f""" bar - foo<=2.0; %r in sys_platform - """ - % sys.platform, + foo<=2.0; {sys.platform!r} in sys_platform + """, requires_txt=DALS( """ bar @@ -381,10 +380,9 @@ def __repr__(self) -> str: ), dict( id='requires3', - install_requires=""" - bar; %r != sys_platform - """ - % sys.platform, + install_requires=f""" + bar; {sys.platform!r} != sys_platform + """, ), dict( id='requires4', @@ -406,7 +404,7 @@ def __repr__(self) -> str: dict( id='requires5', extras_require={ - 'extra': 'foobar; %r != sys_platform' % sys.platform, + 'extra': f'foobar; {sys.platform!r} != sys_platform', }, requires_txt=DALS( """ @@ -605,7 +603,7 @@ def test_wheel_install_pep_503(): def test_wheel_no_dist_dir(): project_name = 'nodistinfo' version = '1.0' - wheel_name = '{0}-{1}-py2.py3-none-any.whl'.format(project_name, version) + wheel_name = f'{project_name}-{version}-py2.py3-none-any.whl' with tempdir() as source_dir: wheel_path = os.path.join(source_dir, wheel_name) # create an empty zip file diff --git a/setuptools/tests/test_windows_wrappers.py b/setuptools/tests/test_windows_wrappers.py index e46bb6abc0..f895485387 100644 --- a/setuptools/tests/test_windows_wrappers.py +++ b/setuptools/tests/test_windows_wrappers.py @@ -57,9 +57,9 @@ def win_launcher_exe(prefix): """A simple routine to select launcher script based on platform.""" assert prefix in ('cli', 'gui') if platform.machine() == "ARM64": - return "{}-arm64.exe".format(prefix) + return f"{prefix}-arm64.exe" else: - return "{}-32.exe".format(prefix) + return f"{prefix}-32.exe" class TestCLI(WrapperTester): diff --git a/setuptools/wheel.py b/setuptools/wheel.py index fb19f1a65a..c7ca43b5cf 100644 --- a/setuptools/wheel.py +++ b/setuptools/wheel.py @@ -79,7 +79,7 @@ class Wheel: def __init__(self, filename) -> None: match = WHEEL_NAME(os.path.basename(filename)) if match is None: - raise ValueError('invalid wheel name: %r' % filename) + raise ValueError(f'invalid wheel name: {filename!r}') self.filename = filename for k, v in match.groupdict().items(): setattr(self, k, v) @@ -122,9 +122,9 @@ def install_as_egg(self, destination_eggdir) -> None: self._install_as_egg(destination_eggdir, zf) def _install_as_egg(self, destination_eggdir, zf): - dist_basename = '%s-%s' % (self.project_name, self.version) + dist_basename = f'{self.project_name}-{self.version}' dist_info = self.get_dist_info(zf) - dist_data = '%s.data' % dist_basename + dist_data = f'{dist_basename}.data' egg_info = os.path.join(destination_eggdir, 'EGG-INFO') self._convert_metadata(zf, destination_eggdir, dist_info, egg_info) @@ -145,7 +145,7 @@ def get_metadata(name): wheel_version = parse_version(wheel_metadata.get('Wheel-Version')) wheel_v1 = parse_version('1.0') <= wheel_version < parse_version('2.0dev0') if not wheel_v1: - raise ValueError('unsupported wheel format version: %s' % wheel_version) + raise ValueError(f'unsupported wheel format version: {wheel_version}') # Extract to target directory. _unpack_zipfile_obj(zf, destination_eggdir) # Convert metadata.