diff --git a/pkg_resources/__init__.py b/pkg_resources/__init__.py index 76aa5e77bac..85d0645403a 100644 --- a/pkg_resources/__init__.py +++ b/pkg_resources/__init__.py @@ -568,24 +568,30 @@ def get_entry_info(dist: _EPDistType, group: str, name: str) -> EntryPoint | Non class IMetadataProvider(Protocol): def has_metadata(self, name: str) -> bool: """Does the package's distribution contain the named metadata?""" + ... def get_metadata(self, name: str) -> str: """The named metadata resource as a string""" + ... def get_metadata_lines(self, name: str) -> Iterator[str]: """Yield named metadata resource as list of non-blank non-comment lines Leading and trailing whitespace is stripped from each line, and lines with ``#`` as the first non-blank character are omitted.""" + ... def metadata_isdir(self, name: str) -> bool: """Is the named metadata a directory? (like ``os.path.isdir()``)""" + ... def metadata_listdir(self, name: str) -> list[str]: """List of metadata names in the directory (like ``os.listdir()``)""" + ... def run_script(self, script_name: str, namespace: dict[str, Any]) -> None: """Execute the named script in the supplied namespace dictionary""" + ... class IResourceProvider(IMetadataProvider, Protocol): @@ -597,6 +603,7 @@ def get_resource_filename( """Return a true filesystem path for `resource_name` `manager` must be a ``ResourceManager``""" + ... def get_resource_stream( self, manager: ResourceManager, resource_name: str @@ -604,6 +611,7 @@ def get_resource_stream( """Return a readable file-like object for `resource_name` `manager` must be a ``ResourceManager``""" + ... def get_resource_string( self, manager: ResourceManager, resource_name: str @@ -611,15 +619,19 @@ def get_resource_string( """Return the contents of `resource_name` as :obj:`bytes` `manager` must be a ``ResourceManager``""" + ... def has_resource(self, resource_name: str) -> bool: """Does the package contain the named resource?""" + ... def resource_isdir(self, resource_name: str) -> bool: """Is the named resource a directory? (like ``os.path.isdir()``)""" + ... def resource_listdir(self, resource_name: str) -> list[str]: """List of resource names in the directory (like ``os.listdir()``)""" + ... class WorkingSet: diff --git a/pkg_resources/tests/test_pkg_resources.py b/pkg_resources/tests/test_pkg_resources.py index 023adf60b0c..18adb3c9d22 100644 --- a/pkg_resources/tests/test_pkg_resources.py +++ b/pkg_resources/tests/test_pkg_resources.py @@ -70,7 +70,7 @@ def teardown_class(cls): finalizer() def test_resource_listdir(self): - import mod + import mod # pyright: ignore[reportMissingImports] # Temporary package for test zp = pkg_resources.ZipProvider(mod) @@ -84,7 +84,7 @@ def test_resource_listdir(self): assert zp.resource_listdir('nonexistent') == [] assert zp.resource_listdir('nonexistent/') == [] - import mod2 + import mod2 # pyright: ignore[reportMissingImports] # Temporary package for test zp2 = pkg_resources.ZipProvider(mod2) @@ -100,7 +100,7 @@ def test_resource_filename_rewrites_on_change(self): same size and modification time, it should not be overwritten on a subsequent call to get_resource_filename. """ - import mod + import mod # pyright: ignore[reportMissingImports] # Temporary package for test manager = pkg_resources.ResourceManager() zp = pkg_resources.ZipProvider(mod) diff --git a/pkg_resources/tests/test_resources.py b/pkg_resources/tests/test_resources.py index 3b672969528..f5e793fb90a 100644 --- a/pkg_resources/tests/test_resources.py +++ b/pkg_resources/tests/test_resources.py @@ -817,11 +817,11 @@ def test_two_levels_deep(self, symlinked_tmpdir): (pkg1 / '__init__.py').write_text(self.ns_str, encoding='utf-8') (pkg2 / '__init__.py').write_text(self.ns_str, encoding='utf-8') with pytest.warns(DeprecationWarning, match="pkg_resources.declare_namespace"): - import pkg1 + import pkg1 # pyright: ignore[reportMissingImports] # Temporary package for test assert "pkg1" in pkg_resources._namespace_packages # attempt to import pkg2 from site-pkgs2 with pytest.warns(DeprecationWarning, match="pkg_resources.declare_namespace"): - import pkg1.pkg2 + import pkg1.pkg2 # pyright: ignore[reportMissingImports] # Temporary package for test # check the _namespace_packages dict assert "pkg1.pkg2" in pkg_resources._namespace_packages assert pkg_resources._namespace_packages["pkg1"] == ["pkg1.pkg2"] @@ -862,8 +862,8 @@ def test_path_order(self, symlinked_tmpdir): (subpkg / '__init__.py').write_text(vers_str % number, encoding='utf-8') with pytest.warns(DeprecationWarning, match="pkg_resources.declare_namespace"): - import nspkg - import nspkg.subpkg + import nspkg # pyright: ignore[reportMissingImports] # Temporary package for test + import nspkg.subpkg # pyright: ignore[reportMissingImports] # Temporary package for test expected = [str(site.realpath() / 'nspkg') for site in site_dirs] assert nspkg.__path__ == expected assert nspkg.subpkg.__version__ == 1 diff --git a/pyproject.toml b/pyproject.toml index bae68252e4d..ce78b204e1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ test = [ # local "virtualenv>=13.0.0", - "wheel>=0.44.0", # Consistent requirement normalisation in METADATA (see #4547) + "wheel>=0.44.0", # Consistent requirement normalisation in METADATA (see #4547) "pip>=19.1", # For proper file:// URLs support. "packaging>=23.2", "jaraco.envs>=2.2", @@ -52,11 +52,12 @@ test = [ "pytest-timeout", 'pytest-perf; sys_platform != "cygwin"', # workaround for jaraco/inflect#195, pydantic/pydantic-core#773 (see #3986) # for tools/finalize.py - 'jaraco.develop >= 7.21; python_version >= "3.9" and sys_platform != "cygwin"', + 'jaraco.develop >= 7.21; sys_platform != "cygwin"', "pytest-home >= 0.5", - # pin mypy version so a new version doesn't suddenly cause the CI to fail, + # pin type-checkers 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 + # For help with static-typing issues, or mypy/pyright update, ping @Avasam + "pyright == 1.1.377", "mypy==1.11.*", # No Python 3.11 dependencies require tomli, but needed for type-checking since we import it directly "tomli", diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 00000000000..cd04c33371e --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://raw.githubusercontent.com/microsoft/pyright/main/packages/vscode-pyright/schemas/pyrightconfig.schema.json", + "exclude": [ + "build", + ".tox", + ".eggs", + "**/_vendor", // Vendored + "setuptools/_distutils", // Vendored + "setuptools/config/_validate_pyproject/**", // Auto-generated + ], + // Our testing setup doesn't allow passing CLI arguments, so local devs have to set this manually. + // "pythonVersion": "3.8", + // For now we don't mind if mypy's `type: ignore` comments accidentally suppresses pyright issues + "enableTypeIgnoreComments": true, + "typeCheckingMode": "basic", + // Too many issues caused by dynamic patching, still worth fixing when we can + "reportAttributeAccessIssue": "warning", + // Fails on Python 3.12 due to missing distutils and on cygwin CI tests + "reportAssignmentType": "warning", + "reportMissingImports": "warning", + "reportOptionalCall": "warning", + // FIXME: A handful of reportOperatorIssue spread throughout the codebase + "reportOperatorIssue": "warning", + // Deferred initialization (initialize_options/finalize_options) causes many "potentially None" issues + // TODO: Fix with type-guards or by changing how it's initialized + "reportArgumentType": "warning", // A lot of these are caused by jaraco.path.build's spec argument not being a Mapping https://github.com/jaraco/jaraco.path/pull/3 + "reportCallIssue": "warning", + "reportGeneralTypeIssues": "warning", + "reportOptionalIterable": "warning", + "reportOptionalMemberAccess": "warning", + "reportOptionalOperand": "warning", +} diff --git a/setuptools/__init__.py b/setuptools/__init__.py index 1d3156ff107..29267b6c1fe 100644 --- a/setuptools/__init__.py +++ b/setuptools/__init__.py @@ -7,6 +7,7 @@ import re import sys from abc import abstractmethod +from collections.abc import Mapping from typing import TYPE_CHECKING, TypeVar, overload sys.path.extend(((vendor_path := os.path.join(os.path.dirname(os.path.dirname(__file__)), 'setuptools', '_vendor')) not in sys.path) * [vendor_path]) # fmt: skip @@ -54,7 +55,7 @@ class MinimalDistribution(distutils.core.Distribution): fetch_build_eggs interface. """ - def __init__(self, attrs): + def __init__(self, attrs: Mapping[str, object]): _incl = 'dependency_links', 'setup_requires' filtered = {k: attrs[k] for k in set(_incl) & set(attrs)} super().__init__(filtered) @@ -114,8 +115,10 @@ def setup(**attrs): setup.__doc__ = distutils.core.setup.__doc__ if TYPE_CHECKING: + from typing_extensions import TypeAlias + # Work around a mypy issue where type[T] can't be used as a base: https://github.com/python/mypy/issues/10962 - _Command = distutils.core.Command + _Command: TypeAlias = distutils.core.Command else: _Command = monkey.get_unpatched(distutils.core.Command) diff --git a/setuptools/_reqs.py b/setuptools/_reqs.py index 2d09244b430..71ea23dea9f 100644 --- a/setuptools/_reqs.py +++ b/setuptools/_reqs.py @@ -28,15 +28,13 @@ def parse_strings(strs: _StrOrIter) -> Iterator[str]: return text.join_continuation(map(text.drop_comment, text.yield_lines(strs))) +# These overloads are only needed because of a mypy false-positive, pyright gets it right +# https://github.com/python/mypy/issues/3737 @overload def parse(strs: _StrOrIter) -> Iterator[Requirement]: ... - - @overload def parse(strs: _StrOrIter, parser: Callable[[str], _T]) -> Iterator[_T]: ... - - -def parse(strs, parser=parse_req): +def parse(strs: _StrOrIter, parser: Callable[[str], _T] = parse_req) -> Iterator[_T]: # type: ignore[assignment] """ Replacement for ``pkg_resources.parse_requirements`` that uses ``packaging``. """ diff --git a/setuptools/command/build.py b/setuptools/command/build.py index 0c5e544804f..f60fcbda154 100644 --- a/setuptools/command/build.py +++ b/setuptools/command/build.py @@ -87,12 +87,15 @@ def finalize_options(self): def initialize_options(self): """(Required by the original :class:`setuptools.Command` interface)""" + ... def finalize_options(self): """(Required by the original :class:`setuptools.Command` interface)""" + ... def run(self): """(Required by the original :class:`setuptools.Command` interface)""" + ... def get_source_files(self) -> list[str]: """ @@ -104,6 +107,7 @@ def get_source_files(self) -> list[str]: with all the files necessary to build the distribution. All files should be strings relative to the project root directory. """ + ... def get_outputs(self) -> list[str]: """ @@ -117,6 +121,7 @@ def get_outputs(self) -> list[str]: in ``get_output_mapping()`` plus files that are generated during the build and don't correspond to any source file already present in the project. """ + ... def get_output_mapping(self) -> dict[str, str]: """ @@ -127,3 +132,4 @@ def get_output_mapping(self) -> dict[str, str]: Destination files should be strings in the form of ``"{build_lib}/destination/file/path"``. """ + ... diff --git a/setuptools/command/build_clib.py b/setuptools/command/build_clib.py index 9db57ac8a20..d532762ebe1 100644 --- a/setuptools/command/build_clib.py +++ b/setuptools/command/build_clib.py @@ -5,7 +5,9 @@ from distutils.errors import DistutilsSetupError try: - from distutils._modified import newer_pairwise_group + 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 diff --git a/setuptools/command/build_ext.py b/setuptools/command/build_ext.py index 51c1771a338..1bb48eb9fa7 100644 --- a/setuptools/command/build_ext.py +++ b/setuptools/command/build_ext.py @@ -156,20 +156,18 @@ def _get_output_mapping(self) -> Iterator[tuple[str, str]]: yield (output_cache, inplace_cache) def get_ext_filename(self, fullname): - so_ext = os.getenv('SETUPTOOLS_EXT_SUFFIX') + so_ext = os.getenv('SETUPTOOLS_EXT_SUFFIX', '') if so_ext: filename = os.path.join(*fullname.split('.')) + so_ext else: filename = _build_ext.get_ext_filename(self, fullname) - so_ext = get_config_var('EXT_SUFFIX') + so_ext = str(get_config_var('EXT_SUFFIX')) if fullname in self.ext_map: ext = self.ext_map[fullname] - use_abi3 = ext.py_limited_api and get_abi3_suffix() - if use_abi3: - filename = filename[: -len(so_ext)] - so_ext = get_abi3_suffix() - filename = filename + so_ext + abi3_suffix = get_abi3_suffix() + if ext.py_limited_api and abi3_suffix: # Use abi3 + filename = filename[: -len(so_ext)] + abi3_suffix if isinstance(ext, Library): fn, ext = os.path.splitext(filename) return self.shlib_compiler.library_filename(fn, libtype) diff --git a/setuptools/command/easy_install.py b/setuptools/command/easy_install.py index 54d1e48449e..46c0a231eb1 100644 --- a/setuptools/command/easy_install.py +++ b/setuptools/command/easy_install.py @@ -1792,7 +1792,7 @@ def auto_chmod(func, arg, exc): return func(arg) et, ev, _ = sys.exc_info() # TODO: This code doesn't make sense. What is it trying to do? - raise (ev[0], ev[1] + (" %s %s" % (func, arg))) + raise (ev[0], ev[1] + (" %s %s" % (func, arg))) # pyright: ignore[reportOptionalSubscript, reportIndexIssue] def update_dist_caches(dist_path, fix_zipimporter_caches): @@ -2018,7 +2018,9 @@ def is_python_script(script_text, filename): try: - from os import chmod as _chmod + from os import ( + chmod as _chmod, # pyright: ignore[reportAssignmentType] # Loosing type-safety w/ pyright, but that's ok + ) except ImportError: # Jython compatibility def _chmod(*args: object, **kwargs: object) -> None: # type: ignore[misc] # Mypy re-uses the imported definition anyway diff --git a/setuptools/command/editable_wheel.py b/setuptools/command/editable_wheel.py index 46852c1a948..9eaa62aba0f 100644 --- a/setuptools/command/editable_wheel.py +++ b/setuptools/command/editable_wheel.py @@ -39,6 +39,8 @@ from .install_scripts import install_scripts as install_scripts_cls if TYPE_CHECKING: + from typing_extensions import Self + from .._vendor.wheel.wheelfile import WheelFile _P = TypeVar("_P", bound=StrPath) @@ -379,7 +381,7 @@ def _select_strategy( class EditableStrategy(Protocol): def __call__(self, wheel: WheelFile, files: list[str], mapping: dict[str, str]): ... - def __enter__(self): ... + def __enter__(self) -> Self: ... def __exit__(self, _exc_type, _exc_value, _traceback): ... diff --git a/setuptools/config/_apply_pyprojecttoml.py b/setuptools/config/_apply_pyprojecttoml.py index 7b9c0b1a594..da1a900c7e7 100644 --- a/setuptools/config/_apply_pyprojecttoml.py +++ b/setuptools/config/_apply_pyprojecttoml.py @@ -16,7 +16,7 @@ from functools import partial, reduce from inspect import cleandoc from itertools import chain -from types import MappingProxyType +from types import MappingProxyType, ModuleType from typing import TYPE_CHECKING, Any, Callable, Dict, Mapping, Union from .._path import StrPath @@ -275,7 +275,7 @@ def _valid_command_options(cmdclass: Mapping = EMPTY) -> dict[str, set[str]]: return valid_options -def _load_ep(ep: metadata.EntryPoint) -> tuple[str, type] | None: +def _load_ep(ep: metadata.EntryPoint) -> tuple[str, ModuleType] | None: if ep.value.startswith("wheel.bdist_wheel"): # Ignore deprecated entrypoint from wheel and avoid warning pypa/wheel#631 # TODO: remove check when `bdist_wheel` has been fully removed from pypa/wheel diff --git a/setuptools/config/pyprojecttoml.py b/setuptools/config/pyprojecttoml.py index 943b9f5a00a..a381e38eae7 100644 --- a/setuptools/config/pyprojecttoml.py +++ b/setuptools/config/pyprojecttoml.py @@ -303,7 +303,10 @@ def _obtain(self, dist: Distribution, field: str, package_dir: Mapping[str, str] def _obtain_version(self, dist: Distribution, package_dir: Mapping[str, str]): # Since plugins can set version, let's silently skip if it cannot be obtained if "version" in self.dynamic and "version" in self.dynamic_cfg: - return _expand.version(self._obtain(dist, "version", package_dir)) + return _expand.version( + # We already do an early check for the presence of "version" + self._obtain(dist, "version", package_dir) # pyright: ignore[reportArgumentType] + ) return None def _obtain_readme(self, dist: Distribution) -> dict[str, str] | None: @@ -313,9 +316,10 @@ def _obtain_readme(self, dist: Distribution) -> dict[str, str] | None: dynamic_cfg = self.dynamic_cfg if "readme" in dynamic_cfg: return { + # We already do an early check for the presence of "readme" "text": self._obtain(dist, "readme", {}), "content-type": dynamic_cfg["readme"].get("content-type", "text/x-rst"), - } + } # pyright: ignore[reportReturnType] self._ensure_previously_set(dist, "readme") return None diff --git a/setuptools/monkey.py b/setuptools/monkey.py index abcc2755be0..a69ccd3312c 100644 --- a/setuptools/monkey.py +++ b/setuptools/monkey.py @@ -10,11 +10,13 @@ import sys import types from importlib import import_module -from typing import TypeVar +from typing import Type, TypeVar, cast, overload import distutils.filelist _T = TypeVar("_T") +_UnpatchT = TypeVar("_UnpatchT", type, types.FunctionType) + __all__: list[str] = [] """ @@ -37,25 +39,30 @@ def _get_mro(cls): return inspect.getmro(cls) -def get_unpatched(item: _T) -> _T: - lookup = ( - get_unpatched_class - if isinstance(item, type) - else get_unpatched_function - if isinstance(item, types.FunctionType) - else lambda item: None - ) - return lookup(item) +@overload +def get_unpatched(item: _UnpatchT) -> _UnpatchT: ... +@overload +def get_unpatched(item: object) -> None: ... +def get_unpatched( + item: type | types.FunctionType | object, +) -> type | types.FunctionType | None: + if isinstance(item, type): + return get_unpatched_class(item) + if isinstance(item, types.FunctionType): + return get_unpatched_function(item) + return None -def get_unpatched_class(cls): +def get_unpatched_class(cls: type[_T]) -> type[_T]: """Protect against re-patching the distutils if reloaded Also ensures that no other distutils extension monkeypatched the distutils first. """ external_bases = ( - cls for cls in _get_mro(cls) if not cls.__module__.startswith('setuptools') + cast(Type[_T], cls) + for cls in _get_mro(cls) + if not cls.__module__.startswith('setuptools') ) base = next(external_bases) if not base.__module__.startswith('distutils'): diff --git a/setuptools/package_index.py b/setuptools/package_index.py index a66cbb2e613..71c34704d41 100644 --- a/setuptools/package_index.py +++ b/setuptools/package_index.py @@ -1072,7 +1072,7 @@ def open_with_auth(url, opener=urllib.request.urlopen): if scheme in ('http', 'https'): auth, address = _splituser(netloc) else: - auth = None + auth, address = (None, None) if not auth: cred = PyPIConfig().find_credential(url) diff --git a/setuptools/sandbox.py b/setuptools/sandbox.py index 7d545f1004c..98bd26ab9e5 100644 --- a/setuptools/sandbox.py +++ b/setuptools/sandbox.py @@ -18,7 +18,7 @@ from distutils.errors import DistutilsError if sys.platform.startswith('java'): - import org.python.modules.posix.PosixModule as _os + import org.python.modules.posix.PosixModule as _os # pyright: ignore[reportMissingImports] else: _os = sys.modules[os.name] _open = open diff --git a/setuptools/tests/test_config_discovery.py b/setuptools/tests/test_config_discovery.py index af172953e33..6b60c7e7f7a 100644 --- a/setuptools/tests/test_config_discovery.py +++ b/setuptools/tests/test_config_discovery.py @@ -2,6 +2,7 @@ import sys from configparser import ConfigParser from itertools import product +from typing import cast import jaraco.path import pytest @@ -618,7 +619,10 @@ def _get_dist(dist_path, attrs): script = dist_path / 'setup.py' if script.exists(): with Path(dist_path): - dist = distutils.core.run_setup("setup.py", {}, stop_after="init") + dist = cast( + Distribution, + distutils.core.run_setup("setup.py", {}, stop_after="init"), + ) else: dist = Distribution(attrs) diff --git a/setuptools/tests/test_setuptools.py b/setuptools/tests/test_setuptools.py index 6af1d98c6b6..72b8ed47f11 100644 --- a/setuptools/tests/test_setuptools.py +++ b/setuptools/tests/test_setuptools.py @@ -53,7 +53,7 @@ def testExtractConst(self): def f1(): global x, y, z x = "test" - y = z + y = z # pyright: ignore[reportUnboundVariable] # Explicitly testing for this runtime issue fc = f1.__code__ diff --git a/tox.ini b/tox.ini index cbade4ff462..ef4185e3d79 100644 --- a/tox.ini +++ b/tox.ini @@ -6,6 +6,7 @@ setenv = PYTHONWARNDEFAULTENCODING = 1 SETUPTOOLS_ENFORCE_DEPRECATION = {env:SETUPTOOLS_ENFORCE_DEPRECATION:1} commands = + pyright --threads pytest {posargs} usedevelop = True extras =