diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 5247cca130..91921ce92d 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 75.1.1 +current_version = 75.2.0 commit = True tag = True diff --git a/.github/workflows/pyright.yml b/.github/workflows/pyright.yml index 38fb910d85..17a1e2dbbe 100644 --- a/.github/workflows/pyright.yml +++ b/.github/workflows/pyright.yml @@ -26,7 +26,7 @@ env: # For help with static-typing issues, or pyright update, ping @Avasam # # An exact version from https://github.com/microsoft/pyright/releases or "latest" - PYRIGHT_VERSION: "1.1.377" + PYRIGHT_VERSION: "1.1.385" # Environment variable to support color support (jaraco/skeleton#66) FORCE_COLOR: 1 @@ -73,4 +73,5 @@ jobs: uses: jakebailey/pyright-action@v2 with: version: ${{ env.PYRIGHT_VERSION }} + python-version: ${{ matrix.python }} extra-args: --threads diff --git a/NEWS.rst b/NEWS.rst index 75ef319dd5..e79b45a623 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,3 +1,20 @@ +v75.2.0 +======= + +Features +-------- + +- Made errors when parsing ``Distribution`` data more explicit about the expected type (``tuple[str, ...] | list[str]``) -- by :user:`Avasam` (#4578) + + +Bugfixes +-------- + +- Fix a `TypeError` when a ``Distribution``'s old included attribute was a `tuple` -- by :user:`Avasam` (#4578) +- Add workaround for ``bdist_wheel --dist-info-dir`` errors + when customisation does not inherit from setuptools. (#4684) + + v75.1.1 ======= diff --git a/mypy.ini b/mypy.ini index 7de7e5a508..2dc8aab56f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,17 +1,27 @@ [mypy] -# CI should test for all versions, local development gets hints for oldest supported -# But our testing setup doesn't allow passing CLI arguments, so local devs have to set this manually. -# python_version = 3.8 +## upstream + +# Is the project well-typed? strict = False + +# Early opt-in even when strict = False warn_unused_ignores = True warn_redundant_casts = True -# required to support namespace packages: https://github.com/python/mypy/issues/14057 +enable_error_code = ignore-without-code + +# Support namespace packages per https://github.com/python/mypy/issues/14057 explicit_package_bases = True disable_error_code = # Disable due to many false positives overload-overlap, +## local + +# CI should test for all versions, local development gets hints for oldest supported +# But our testing setup doesn't allow passing CLI arguments, so local devs have to set this manually. +# python_version = 3.8 + exclude = (?x)( # Avoid scanning Python files in generated folders ^build/ @@ -42,6 +52,7 @@ disable_error_code = import-not-found # for setuptools to import `_distutils` directly # - or non-stdlib distutils typings are exposed # - 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 # - jaraco.envs: https://github.com/jaraco/jaraco.envs/issues/7 # - jaraco.packaging: https://github.com/jaraco/jaraco.packaging/issues/20 @@ -49,11 +60,11 @@ disable_error_code = import-not-found # - jaraco.test: https://github.com/jaraco/jaraco.test/issues/7 # - jaraco.text: https://github.com/jaraco/jaraco.text/issues/17 # - wheel: does not intend on exposing a programmatic API https://github.com/pypa/wheel/pull/610#issuecomment-2081687671 -[mypy-distutils.*,jaraco.develop,jaraco.envs,jaraco.packaging.*,jaraco.path,jaraco.test.*,jaraco.text,wheel.*] +[mypy-distutils.*,jaraco,jaraco.develop,jaraco.envs,jaraco.packaging.*,jaraco.path,jaraco.test.*,jaraco.text,wheel.*] ignore_missing_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 -[mypy-setuptools.config._validate_pyproject.*,setuptools._distutils.*] +[mypy-setuptools.config._validate_pyproject.*,setuptools._vendor.*,setuptools._distutils.*] follow_imports = silent # silent => ignore errors when following imports diff --git a/newsfragments/4560.misc.rst b/newsfragments/4560.misc.rst new file mode 100644 index 0000000000..0878f09abd --- /dev/null +++ b/newsfragments/4560.misc.rst @@ -0,0 +1 @@ +Bumped declared ``platformdirs`` dependency to ``>= 4.2.2`` to help platforms lacking `ctypes` support install setuptools seamlessly -- by :user:`Avasam` diff --git a/newsfragments/4567.bugfix.rst b/newsfragments/4567.bugfix.rst new file mode 100644 index 0000000000..7d7bb282e1 --- /dev/null +++ b/newsfragments/4567.bugfix.rst @@ -0,0 +1,4 @@ +Ensured methods in ``setuptools.modified`` preferably raise a consistent +``distutils.errors.DistutilsError`` type +(except in the deprecated use case of ``SETUPTOOLS_USE_DISTUTILS=stdlib``) +-- by :user:`Avasam` diff --git a/newsfragments/4575.feature.rst b/newsfragments/4575.feature.rst new file mode 100644 index 0000000000..64ab49830f --- /dev/null +++ b/newsfragments/4575.feature.rst @@ -0,0 +1 @@ +Allowed using `dict` as an ordered type in ``setuptools.dist.check_requirements`` -- by :user:`Avasam` diff --git a/newsfragments/4696.bugfix.rst b/newsfragments/4696.bugfix.rst new file mode 100644 index 0000000000..77ebf87c48 --- /dev/null +++ b/newsfragments/4696.bugfix.rst @@ -0,0 +1,4 @@ +Fix clashes for ``optional-dependencies`` in ``pyproject.toml`` and +``extra_requires`` in ``setup.cfg/setup.py``. +As per PEP 621, ``optional-dependencies`` has to be honoured and dynamic +behaviour is not allowed. diff --git a/pkg_resources/__init__.py b/pkg_resources/__init__.py index 4e9b83d83d..f1f0ef2535 100644 --- a/pkg_resources/__init__.py +++ b/pkg_resources/__init__.py @@ -2777,7 +2777,7 @@ def load( if require: # We could pass `env` and `installer` directly, # but keeping `*args` and `**kwargs` for backwards compatibility - self.require(*args, **kwargs) # type: ignore + self.require(*args, **kwargs) # type: ignore[arg-type] return self.resolve() def resolve(self) -> _ResolvedEntryPoint: diff --git a/pyproject.toml b/pyproject.toml index c27a988afd..9dca4c67f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ backend-path = ["."] [project] name = "setuptools" -version = "75.1.1" +version = "75.2.0" authors = [ { name = "Python Packaging Authority", email = "distutils-sig@python.org" }, ] @@ -99,7 +99,7 @@ core = [ "wheel>=0.43.0", # pkg_resources - "platformdirs >= 2.6.2", + "platformdirs >= 4.2.2", # Made ctypes optional (see #4461) # for distutils "jaraco.collections", @@ -136,7 +136,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.11.*", + "mypy==1.12.*", # 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 @@ -216,7 +216,3 @@ formats = "zip" [tool.setuptools_scm] - - -[tool.pytest-enabler.mypy] -# Disabled due to jaraco/skeleton#143 diff --git a/ruff.toml b/ruff.toml index ddae5009ed..484246aa1e 100644 --- a/ruff.toml +++ b/ruff.toml @@ -26,6 +26,7 @@ ignore = [ "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 diff --git a/setuptools/_path.py b/setuptools/_path.py index dd4a9db8cb..c7bef83365 100644 --- a/setuptools/_path.py +++ b/setuptools/_path.py @@ -11,9 +11,10 @@ from more_itertools import unique_everseen -if sys.version_info >= (3, 9): +if TYPE_CHECKING: StrPath: TypeAlias = Union[str, os.PathLike[str]] # Same as _typeshed.StrPath else: + # Python 3.8 support StrPath: TypeAlias = Union[str, os.PathLike] diff --git a/setuptools/build_meta.py b/setuptools/build_meta.py index ecf434bbf3..a3c83c7002 100644 --- a/setuptools/build_meta.py +++ b/setuptools/build_meta.py @@ -417,17 +417,27 @@ def build_wheel( config_settings: _ConfigSettings = None, metadata_directory: StrPath | None = None, ): - cmd = ['bdist_wheel'] - if metadata_directory: - cmd.extend(['--dist-info-dir', metadata_directory]) - with suppress_known_deprecation(): - return self._build_with_temp_dir( - cmd, - '.whl', - wheel_directory, - config_settings, - self._arbitrary_args(config_settings), - ) + def _build(cmd: list[str]): + with suppress_known_deprecation(): + return self._build_with_temp_dir( + cmd, + '.whl', + wheel_directory, + config_settings, + self._arbitrary_args(config_settings), + ) + + if metadata_directory is None: + return _build(['bdist_wheel']) + + try: + return _build(['bdist_wheel', '--dist-info-dir', str(metadata_directory)]) + except SystemExit as ex: # pragma: nocover + # pypa/setuptools#4683 + if "--dist-info-dir not recognized" not in str(ex): + raise + _IncompatibleBdistWheel.emit() + return _build(['bdist_wheel']) def build_sdist( self, sdist_directory: StrPath, config_settings: _ConfigSettings = None @@ -514,6 +524,17 @@ def run_setup(self, setup_script='setup.py'): sys.argv[0] = sys_argv_0 +class _IncompatibleBdistWheel(SetuptoolsDeprecationWarning): + _SUMMARY = "wheel.bdist_wheel is deprecated, please import it from setuptools" + _DETAILS = """ + Ensure that any custom bdist_wheel implementation is a subclass of + setuptools.command.bdist_wheel.bdist_wheel. + """ + _DUE_DATE = (2025, 10, 15) + # Initially introduced in 2024/10/15, but maybe too disruptive to be enforced? + _SEE_URL = "https://github.com/pypa/wheel/pull/631" + + # The primary backend _BACKEND = _BuildMetaBackend() diff --git a/setuptools/command/__init__.py b/setuptools/command/__init__.py index bf011e896d..50e6c2f54f 100644 --- a/setuptools/command/__init__.py +++ b/setuptools/command/__init__.py @@ -1,12 +1,20 @@ +# mypy: disable_error_code=call-overload +# pyright: reportCallIssue=false, reportArgumentType=false +# Can't disable on the exact line because distutils doesn't exists on Python 3.12 +# and type-checkers aren't aware of distutils_hack, +# causing distutils.command.bdist.bdist.format_commands to be Any. + import sys from distutils.command.bdist import bdist if 'egg' not in bdist.format_commands: try: + # format_commands is a dict in vendored distutils + # It used to be a list in older (stdlib) distutils + # We support both for backwards compatibility bdist.format_commands['egg'] = ('bdist_egg', "Python .egg file") except TypeError: - # For backward compatibility with older distutils (stdlib) bdist.format_command['egg'] = ('bdist_egg', "Python .egg file") bdist.format_commands.append('egg') diff --git a/setuptools/command/_requirestxt.py b/setuptools/command/_requirestxt.py index b87476d6f4..d426f5dffb 100644 --- a/setuptools/command/_requirestxt.py +++ b/setuptools/command/_requirestxt.py @@ -18,12 +18,11 @@ from packaging.requirements import Requirement from .. import _reqs +from .._reqs import _StrOrIter # dict can work as an ordered set _T = TypeVar("_T") _Ordered = Dict[_T, None] -_ordered = dict -_StrOrIter = _reqs._StrOrIter def _prepare( diff --git a/setuptools/command/build_clib.py b/setuptools/command/build_clib.py index d532762ebe..eab08e70f2 100644 --- a/setuptools/command/build_clib.py +++ b/setuptools/command/build_clib.py @@ -1,17 +1,10 @@ from ..dist import Distribution +from ..modified import newer_pairwise_group import distutils.command.build_clib as orig from distutils import log from distutils.errors import DistutilsSetupError -try: - from distutils._modified import ( # pyright: ignore[reportMissingImports] - newer_pairwise_group, - ) -except ImportError: - # fallback for SETUPTOOLS_USE_DISTUTILS=stdlib - from .._distutils._modified import newer_pairwise_group - class build_clib(orig.build_clib): """ diff --git a/setuptools/command/egg_info.py b/setuptools/command/egg_info.py index f4d3a2a57e..bc6c677878 100644 --- a/setuptools/command/egg_info.py +++ b/setuptools/command/egg_info.py @@ -2,7 +2,6 @@ Create a distribution's .egg-info directory and contents""" -import collections import functools import os import re @@ -211,11 +210,9 @@ def save_version_info(self, filename): build tag. Install build keys in a deterministic order to avoid arbitrary reordering on subsequent builds. """ - egg_info = collections.OrderedDict() # follow the order these keys would have been added # when PYTHONHASHSEED=0 - egg_info['tag_build'] = self.tags() - egg_info['tag_date'] = 0 + egg_info = dict(tag_build=self.tags(), tag_date=0) edit_config(filename, dict(egg_info=egg_info)) def finalize_options(self): diff --git a/setuptools/command/sdist.py b/setuptools/command/sdist.py index fa9a2c4d81..65ce735dde 100644 --- a/setuptools/command/sdist.py +++ b/setuptools/command/sdist.py @@ -4,6 +4,7 @@ import os import re from itertools import chain +from typing import ClassVar from .._importlib import metadata from ..dist import Distribution @@ -49,7 +50,7 @@ class sdist(orig.sdist): ] distribution: Distribution # override distutils.dist.Distribution with setuptools.dist.Distribution - negative_opt: dict[str, str] = {} + negative_opt: ClassVar[dict[str, str]] = {} README_EXTENSIONS = ['', '.rst', '.txt', '.md'] READMES = tuple('README{0}'.format(ext) for ext in README_EXTENSIONS) diff --git a/setuptools/config/_apply_pyprojecttoml.py b/setuptools/config/_apply_pyprojecttoml.py index 23179f3548..16fe753b58 100644 --- a/setuptools/config/_apply_pyprojecttoml.py +++ b/setuptools/config/_apply_pyprojecttoml.py @@ -217,8 +217,10 @@ def _dependencies(dist: Distribution, val: list, _root_dir): def _optional_dependencies(dist: Distribution, val: dict, _root_dir): - existing = getattr(dist, "extras_require", None) or {} - dist.extras_require = {**existing, **val} + if getattr(dist, "extras_require", None): + msg = "`extras_require` overwritten in `pyproject.toml` (optional-dependencies)" + SetuptoolsWarning.emit(msg) + dist.extras_require = val def _ext_modules(dist: Distribution, val: list[dict]) -> list[Extension]: diff --git a/setuptools/config/expand.py b/setuptools/config/expand.py index e11bcf9b42..8f2040fefa 100644 --- a/setuptools/config/expand.py +++ b/setuptools/config/expand.py @@ -203,7 +203,8 @@ def _load_spec(spec: ModuleSpec, module_name: str) -> ModuleType: return sys.modules[name] module = importlib.util.module_from_spec(spec) sys.modules[name] = module # cache (it also ensures `==` works on loaded items) - spec.loader.exec_module(module) # type: ignore + assert spec.loader is not None + spec.loader.exec_module(module) return module @@ -285,10 +286,11 @@ def find_packages( from setuptools.discovery import construct_package_dir - if namespaces: - from setuptools.discovery import PEP420PackageFinder as PackageFinder + # check "not namespaces" first due to python/mypy#6232 + if not namespaces: + from setuptools.discovery import PackageFinder else: - from setuptools.discovery import PackageFinder # type: ignore + from setuptools.discovery import PEP420PackageFinder as PackageFinder root_dir = root_dir or os.curdir where = kwargs.pop('where', ['.']) @@ -359,7 +361,8 @@ def entry_points(text: str, text_source="entry-points") -> dict[str, dict]: entry-point names, and the second level values are references to objects (that correspond to the entry-point value). """ - parser = ConfigParser(default_section=None, delimiters=("=",)) # type: ignore + # Using undocumented behaviour, see python/typeshed#12700 + parser = ConfigParser(default_section=None, delimiters=("=",)) # type: ignore[call-overload] parser.optionxform = str # case sensitive parser.read_string(text, text_source) groups = {k: dict(v.items()) for k, v in parser.items()} diff --git a/setuptools/config/pyprojecttoml.py b/setuptools/config/pyprojecttoml.py index 5d95e18b83..e0040cefbd 100644 --- a/setuptools/config/pyprojecttoml.py +++ b/setuptools/config/pyprojecttoml.py @@ -44,8 +44,8 @@ def validate(config: dict, filepath: StrPath) -> bool: trove_classifier = validator.FORMAT_FUNCTIONS.get("trove-classifier") if hasattr(trove_classifier, "_disable_download"): - # Improve reproducibility by default. See issue 31 for validate-pyproject. - trove_classifier._disable_download() # type: ignore + # Improve reproducibility by default. See abravalheri/validate-pyproject#31 + trove_classifier._disable_download() # type: ignore[union-attr] try: return validator.validate(config) diff --git a/setuptools/config/setupcfg.py b/setuptools/config/setupcfg.py index 54469f74a3..5b4e1e8d95 100644 --- a/setuptools/config/setupcfg.py +++ b/setuptools/config/setupcfg.py @@ -27,7 +27,6 @@ List, Tuple, TypeVar, - Union, cast, ) @@ -53,7 +52,7 @@ while the second element of the tuple is the option value itself """ AllCommandOptions = Dict["str", SingleCommandOptions] # cmd name => its options -Target = TypeVar("Target", bound=Union["Distribution", "DistributionMetadata"]) +Target = TypeVar("Target", "Distribution", "DistributionMetadata") def read_configuration( @@ -96,7 +95,7 @@ def _apply( filepath: StrPath, other_files: Iterable[StrPath] = (), ignore_option_errors: bool = False, -) -> tuple[ConfigHandler, ...]: +) -> tuple[ConfigMetadataHandler, ConfigOptionsHandler]: """Read configuration from ``filepath`` and applies to the ``dist`` object.""" from setuptools.dist import _Distribution @@ -122,7 +121,7 @@ def _apply( return handlers -def _get_option(target_obj: Target, key: str): +def _get_option(target_obj: Distribution | DistributionMetadata, key: str): """ Given a target object and option key, get that option from the target object, either through a get_{key} method or @@ -134,10 +133,14 @@ def _get_option(target_obj: Target, key: str): return getter() -def configuration_to_dict(handlers: tuple[ConfigHandler, ...]) -> dict: +def configuration_to_dict( + handlers: Iterable[ + ConfigHandler[Distribution] | ConfigHandler[DistributionMetadata] + ], +) -> dict: """Returns configuration data gathered by given handlers as a dict. - :param list[ConfigHandler] handlers: Handlers list, + :param Iterable[ConfigHandler] handlers: Handlers list, usually from parse_configuration() :rtype: dict @@ -254,7 +257,7 @@ def __init__( ensure_discovered: expand.EnsurePackagesDiscovered, ): self.ignore_option_errors = ignore_option_errors - self.target_obj = target_obj + self.target_obj: Target = target_obj self.sections = dict(self._section_options(options)) self.set_options: list[str] = [] self.ensure_discovered = ensure_discovered @@ -301,7 +304,7 @@ def __setitem__(self, option_name, value) -> None: return simple_setter = functools.partial(target_obj.__setattr__, option_name) - setter = getattr(target_obj, 'set_%s' % option_name, simple_setter) + setter = getattr(target_obj, f"set_{option_name}", simple_setter) setter(parsed) self.set_options.append(option_name) @@ -369,8 +372,8 @@ def parser(value): exclude_directive = 'file:' if value.startswith(exclude_directive): raise ValueError( - 'Only strings are accepted for the {0} field, ' - 'files are not accepted'.format(key) + f'Only strings are accepted for the {key} field, ' + 'files are not accepted' ) return value @@ -488,12 +491,12 @@ def parse(self) -> None: for section_name, section_options in self.sections.items(): method_postfix = '' if section_name: # [section.option] variant - method_postfix = '_%s' % section_name + method_postfix = f"_{section_name}" section_parser_method: Callable | None = getattr( self, # Dots in section names are translated into dunderscores. - ('parse_section%s' % method_postfix).replace('.', '__'), + f'parse_section{method_postfix}'.replace('.', '__'), None, ) @@ -698,10 +701,7 @@ def parse_section_packages__find(self, section_options): section_data = self._parse_section_to_dict(section_options, self._parse_list) valid_keys = ['where', 'include', 'exclude'] - - find_kwargs = dict([ - (k, v) for k, v in section_data.items() if k in valid_keys and v - ]) + find_kwargs = {k: v for k, v in section_data.items() if k in valid_keys and v} where = find_kwargs.get('where') if where is not None: diff --git a/setuptools/dist.py b/setuptools/dist.py index 7c516fefb8..d6b8e08214 100644 --- a/setuptools/dist.py +++ b/setuptools/dist.py @@ -8,7 +8,16 @@ import sys from glob import iglob from pathlib import Path -from typing import TYPE_CHECKING, MutableMapping +from typing import ( + TYPE_CHECKING, + Any, + Dict, + List, + MutableMapping, + Sequence, + Tuple, + Union, +) from more_itertools import partition, unique_everseen from packaging.markers import InvalidMarker, Marker @@ -36,9 +45,41 @@ from distutils.fancy_getopt import translate_longopt from distutils.util import strtobool +if TYPE_CHECKING: + from typing_extensions import TypeAlias + __all__ = ['Distribution'] -sequence = tuple, list +_sequence = tuple, list +""" +:meta private: + +Supported iterable types that are known to be: +- ordered (which `set` isn't) +- not match a str (which `Sequence[str]` does) +- not imply a nested type (like `dict`) +for use with `isinstance`. +""" +_Sequence: TypeAlias = Union[Tuple[str, ...], List[str]] +# This is how stringifying _Sequence would look in Python 3.10 +_sequence_type_repr = "tuple[str, ...] | list[str]" +_OrderedStrSequence: TypeAlias = Union[str, Dict[str, Any], Sequence[str]] +""" +:meta private: +Avoid single-use iterable. Disallow sets. +A poor approximation of an OrderedSequence (dict doesn't match a Sequence). +""" + + +def __getattr__(name: str) -> Any: # pragma: no cover + if name == "sequence": + SetuptoolsDeprecationWarning.emit( + "`setuptools.dist.sequence` is an internal implementation detail.", + "Please define your own `sequence = tuple, list` instead.", + due_date=(2025, 8, 28), # Originally added on 2024-08-27 + ) + return _sequence + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") def check_importable(dist, attr, value): @@ -51,17 +92,17 @@ def check_importable(dist, attr, value): ) from e -def assert_string_list(dist, attr, value): +def assert_string_list(dist, attr: str, value: _Sequence) -> None: """Verify that value is a string list""" try: # verify that value is a list or tuple to exclude unordered # or single-use iterables - assert isinstance(value, sequence) + assert isinstance(value, _sequence) # verify that elements of value are strings assert ''.join(value) != value except (TypeError, ValueError, AttributeError, AssertionError) as e: raise DistutilsSetupError( - "%r must be a list of strings (got %r)" % (attr, value) + f"{attr!r} must be of type <{_sequence_type_repr}> (got {value!r})" ) from e @@ -126,8 +167,7 @@ def _check_marker(marker): def assert_bool(dist, attr, value): """Verify that value is True, False, 0, or 1""" if bool(value) != value: - tmpl = "{attr!r} must be a boolean value (got {value!r})" - raise DistutilsSetupError(tmpl.format(attr=attr, value=value)) + raise DistutilsSetupError(f"{attr!r} must be a boolean value (got {value!r})") def invalid_unless_false(dist, attr, value): @@ -138,18 +178,18 @@ def invalid_unless_false(dist, attr, value): raise DistutilsSetupError(f"{attr} is invalid.") -def check_requirements(dist, attr, value): +def check_requirements(dist, attr: str, value: _OrderedStrSequence) -> None: """Verify that install_requires is a valid requirements list""" try: list(_reqs.parse(value)) - if isinstance(value, (dict, set)): + if isinstance(value, set): raise TypeError("Unordered types are not allowed") except (TypeError, ValueError) as error: - tmpl = ( - "{attr!r} must be a string or list of strings " - "containing valid project/version requirement specifiers; {error}" + msg = ( + f"{attr!r} must be a string or iterable of strings " + f"containing valid project/version requirement specifiers; {error}" ) - raise DistutilsSetupError(tmpl.format(attr=attr, error=error)) from error + raise DistutilsSetupError(msg) from error def check_specifier(dist, attr, value): @@ -157,8 +197,8 @@ def check_specifier(dist, attr, value): try: SpecifierSet(value) except (InvalidSpecifier, AttributeError) as error: - tmpl = "{attr!r} must be a string containing valid version specifiers; {error}" - raise DistutilsSetupError(tmpl.format(attr=attr, error=error)) from error + msg = f"{attr!r} must be a string containing valid version specifiers; {error}" + raise DistutilsSetupError(msg) from error def check_entry_points(dist, attr, value): @@ -767,41 +807,43 @@ def has_contents_for(self, package): return False - def _exclude_misc(self, name, value): + def _exclude_misc(self, name: str, value: _Sequence) -> None: """Handle 'exclude()' for list/tuple attrs without a special handler""" - if not isinstance(value, sequence): + if not isinstance(value, _sequence): raise DistutilsSetupError( - "%s: setting must be a list or tuple (%r)" % (name, value) + f"{name}: setting must be of type <{_sequence_type_repr}> (got {value!r})" ) try: old = getattr(self, name) except AttributeError as e: raise DistutilsSetupError("%s: No such distribution setting" % name) from e - if old is not None and not isinstance(old, sequence): + if old is not None and not isinstance(old, _sequence): raise DistutilsSetupError( name + ": this setting cannot be changed via include/exclude" ) elif old: setattr(self, name, [item for item in old if item not in value]) - def _include_misc(self, name, value): + def _include_misc(self, name: str, value: _Sequence) -> None: """Handle 'include()' for list/tuple attrs without a special handler""" - if not isinstance(value, sequence): - raise DistutilsSetupError("%s: setting must be a list (%r)" % (name, value)) + if not isinstance(value, _sequence): + raise DistutilsSetupError( + f"{name}: setting must be of type <{_sequence_type_repr}> (got {value!r})" + ) try: old = getattr(self, name) except AttributeError as e: raise DistutilsSetupError("%s: No such distribution setting" % name) from e if old is None: setattr(self, name, value) - elif not isinstance(old, sequence): + elif not isinstance(old, _sequence): raise DistutilsSetupError( name + ": this setting cannot be changed via include/exclude" ) else: new = [item for item in value if item not in old] - setattr(self, name, old + new) + setattr(self, name, list(old) + new) def exclude(self, **attrs): """Remove items from distribution that are named in keyword arguments @@ -826,10 +868,10 @@ def exclude(self, **attrs): else: self._exclude_misc(k, v) - def _exclude_packages(self, packages): - if not isinstance(packages, sequence): + def _exclude_packages(self, packages: _Sequence) -> None: + if not isinstance(packages, _sequence): raise DistutilsSetupError( - "packages: setting must be a list or tuple (%r)" % (packages,) + f"packages: setting must be of type <{_sequence_type_repr}> (got {packages!r})" ) list(map(self.exclude_package, packages)) diff --git a/setuptools/modified.py b/setuptools/modified.py index 245a61580b..6ba02fab68 100644 --- a/setuptools/modified.py +++ b/setuptools/modified.py @@ -1,8 +1,18 @@ -from ._distutils._modified import ( - newer, - newer_group, - newer_pairwise, - newer_pairwise_group, -) +try: + # Ensure a DistutilsError raised by these methods is the same as distutils.errors.DistutilsError + from distutils._modified import ( + newer, + newer_group, + newer_pairwise, + newer_pairwise_group, + ) +except ImportError: + # fallback for SETUPTOOLS_USE_DISTUTILS=stdlib, because _modified never existed in stdlib + from ._distutils._modified import ( + newer, + newer_group, + newer_pairwise, + newer_pairwise_group, + ) __all__ = ['newer', 'newer_pairwise', 'newer_group', 'newer_pairwise_group'] diff --git a/setuptools/msvc.py b/setuptools/msvc.py index de4b05f928..7ee685e023 100644 --- a/setuptools/msvc.py +++ b/setuptools/msvc.py @@ -1418,7 +1418,7 @@ def VCRuntimeRedist(self) -> str | None: os.path.join(prefix, arch_subdir, crt_dir, vcruntime) for (prefix, crt_dir) in itertools.product(prefixes, crt_dirs) ) - return next(filter(os.path.isfile, candidate_paths), None) + return next(filter(os.path.isfile, candidate_paths), None) # type: ignore[arg-type] #python/mypy#12682 def return_env(self, exists=True): """ diff --git a/setuptools/package_index.py b/setuptools/package_index.py index 9e01d5e082..9b3769fac9 100644 --- a/setuptools/package_index.py +++ b/setuptools/package_index.py @@ -561,10 +561,7 @@ def not_found_in_index(self, requirement): if self[requirement.key]: # we've seen at least one distro meth, msg = self.info, "Couldn't retrieve index page for %r" else: # no distros seen for this name, might be misspelled - meth, msg = ( - self.warn, - "Couldn't find index page for %r (maybe misspelled?)", - ) + meth, msg = self.warn, "Couldn't find index page for %r (maybe misspelled?)" meth(msg, requirement.unsafe_name) self.scan_all() diff --git a/setuptools/tests/config/test_pyprojecttoml_dynamic_deps.py b/setuptools/tests/config/test_pyprojecttoml_dynamic_deps.py index 37e5234a45..e42f28ffaa 100644 --- a/setuptools/tests/config/test_pyprojecttoml_dynamic_deps.py +++ b/setuptools/tests/config/test_pyprojecttoml_dynamic_deps.py @@ -5,6 +5,7 @@ from setuptools.config.pyprojecttoml import apply_configuration from setuptools.dist import Distribution +from setuptools.warnings import SetuptoolsWarning def test_dynamic_dependencies(tmp_path): @@ -77,23 +78,32 @@ def test_mixed_dynamic_optional_dependencies(tmp_path): [tool.setuptools.dynamic.optional-dependencies.images] file = ["requirements-images.txt"] - - [build-system] - requires = ["setuptools", "wheel"] - build-backend = "setuptools.build_meta" """ ), } path.build(files, prefix=tmp_path) - - # Test that the mix-and-match doesn't currently validate. pyproject = tmp_path / "pyproject.toml" with pytest.raises(ValueError, match="project.optional-dependencies"): apply_configuration(Distribution(), pyproject) - # Explicitly disable the validation and try again, to see that the mix-and-match - # result would be correct. - dist = Distribution() - dist = apply_configuration(dist, pyproject, ignore_option_errors=True) - assert dist.extras_require == {"docs": ["sphinx"], "images": ["pillow~=42.0"]} + +def test_mixed_extras_require_optional_dependencies(tmp_path): + files = { + "pyproject.toml": cleandoc( + """ + [project] + name = "myproj" + version = "1.0" + optional-dependencies.docs = ["sphinx"] + """ + ), + } + + path.build(files, prefix=tmp_path) + pyproject = tmp_path / "pyproject.toml" + + with pytest.warns(SetuptoolsWarning, match=".extras_require. overwritten"): + dist = Distribution({"extras_require": {"hello": ["world"]}}) + dist = apply_configuration(dist, pyproject) + assert dist.extras_require == {"docs": ["sphinx"]} diff --git a/setuptools/tests/config/test_setupcfg.py b/setuptools/tests/config/test_setupcfg.py index 4f0a7349f5..8d95798123 100644 --- a/setuptools/tests/config/test_setupcfg.py +++ b/setuptools/tests/config/test_setupcfg.py @@ -7,7 +7,7 @@ import pytest from packaging.requirements import InvalidRequirement -from setuptools.config.setupcfg import ConfigHandler, read_configuration +from setuptools.config.setupcfg import ConfigHandler, Target, read_configuration from setuptools.dist import Distribution, _Distribution from setuptools.warnings import SetuptoolsDeprecationWarning @@ -16,7 +16,7 @@ from distutils.errors import DistutilsFileError, DistutilsOptionError -class ErrConfigHandler(ConfigHandler): +class ErrConfigHandler(ConfigHandler[Target]): """Erroneous handler. Fails to implement required methods.""" section_prefix = "**err**" diff --git a/setuptools/tests/test_build_ext.py b/setuptools/tests/test_build_ext.py index dab8b41cc9..f3e4ccd364 100644 --- a/setuptools/tests/test_build_ext.py +++ b/setuptools/tests/test_build_ext.py @@ -183,7 +183,7 @@ def get_build_ext_cmd(self, optional: bool, **opts): "eggs.c": "#include missingheader.h\n", ".build": {"lib": {}, "tmp": {}}, } - path.build(files) # type: ignore[arg-type] # jaraco/path#232 + path.build(files) # jaraco/path#232 extension = Extension('spam.eggs', ['eggs.c'], optional=optional) dist = Distribution(dict(ext_modules=[extension])) dist.script_name = 'setup.py' diff --git a/setuptools/tests/test_core_metadata.py b/setuptools/tests/test_core_metadata.py index 34828ac750..51d4a10810 100644 --- a/setuptools/tests/test_core_metadata.py +++ b/setuptools/tests/test_core_metadata.py @@ -310,7 +310,6 @@ def test_parity_with_metadata_from_pypa_wheel(tmp_path): python_requires=">=3.8", install_requires=""" packaging==23.2 - ordered-set==3.1.1 more-itertools==8.8.0; extra == "other" jaraco.text==3.7.0 importlib-resources==5.10.2; python_version<"3.8" diff --git a/setuptools/tests/test_dist.py b/setuptools/tests/test_dist.py index 597785b519..1bc4923032 100644 --- a/setuptools/tests/test_dist.py +++ b/setuptools/tests/test_dist.py @@ -1,4 +1,3 @@ -import collections import os import re import urllib.parse @@ -72,15 +71,10 @@ def sdist_with_index(distname, version): def test_provides_extras_deterministic_order(): - extras = collections.OrderedDict() - extras['a'] = ['foo'] - extras['b'] = ['bar'] - attrs = dict(extras_require=extras) + attrs = dict(extras_require=dict(a=['foo'], b=['bar'])) dist = Distribution(attrs) assert list(dist.metadata.provides_extras) == ['a', 'b'] - attrs['extras_require'] = collections.OrderedDict( - reversed(list(attrs['extras_require'].items())) - ) + attrs['extras_require'] = dict(reversed(attrs['extras_require'].items())) dist = Distribution(attrs) assert list(dist.metadata.provides_extras) == ['b', 'a'] @@ -118,8 +112,8 @@ def test_provides_extras_deterministic_order(): 'hello': '*.msg', }, ( - "\"values of 'package_data' dict\" " - "must be a list of strings (got '*.msg')" + "\"values of 'package_data' dict\" must be of type " + " (got '*.msg')" ), ), # Invalid value type (generators are single use) @@ -128,8 +122,8 @@ def test_provides_extras_deterministic_order(): 'hello': (x for x in "generator"), }, ( - "\"values of 'package_data' dict\" must be a list of strings " - "(got " + " (got