diff --git a/.github/workflows/pyright.yml b/.github/workflows/pyright.yml new file mode 100644 index 0000000000..bb25f1ba82 --- /dev/null +++ b/.github/workflows/pyright.yml @@ -0,0 +1,74 @@ +# Split workflow file to not interfere with skeleton +name: pyright + +on: + merge_group: + push: + branches-ignore: + # temporary GH branches relating to merge queues (jaraco/skeleton#93) + - gh-readonly-queue/** + tags: + # required if branches-ignore is supplied (jaraco/skeleton#103) + - '**' + pull_request: + workflow_dispatch: + +concurrency: + group: >- + ${{ github.workflow }}- + ${{ github.ref_type }}- + ${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +env: + # pin pyright 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 pyright update, ping @Avasam + PYRIGHT_VERSION: "1.1.377" + + # Environment variable to support color support (jaraco/skeleton#66) + FORCE_COLOR: 1 + + # Suppress noisy pip warnings + PIP_DISABLE_PIP_VERSION_CHECK: 'true' + PIP_NO_PYTHON_VERSION_WARNING: 'true' + PIP_NO_WARN_SCRIPT_LOCATION: 'true' + +jobs: + pyright: + strategy: + # https://blog.jaraco.com/efficient-use-of-ci-resources/ + matrix: + python: + - "3.8" + - "3.12" + platform: + - ubuntu-latest + runs-on: ${{ matrix.platform }} + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + allow-prereleases: true + - name: Install typed dependencies + run: python -m pip install -e .[core,type] + - name: Inform how to run locally + run: | + echo 'To run this test locally with npm pre-installed, run:' + echo '> npx -y pyright@${{ env.PYRIGHT_VERSION }} --threads' + echo 'You can also instead install "Pyright for Python" which will install npm for you:' + if [ '$PYRIGHT_VERSION' == 'latest' ]; then + echo '> pip install -U' + else + echo '> pip install pyright==${{ env.PYRIGHT_VERSION }}' + fi + echo 'pyright --threads' + shell: bash + - name: Run pyright + uses: jakebailey/pyright-action@v2 + with: + version: ${{ env.PYRIGHT_VERSION }} + extra-args: --threads diff --git a/pkg_resources/__init__.py b/pkg_resources/__init__.py index a8ca8ab818..ab46d1dc42 100644 --- a/pkg_resources/__init__.py +++ b/pkg_resources/__init__.py @@ -561,24 +561,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): @@ -590,6 +596,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 @@ -597,6 +604,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 @@ -604,15 +612,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 023adf60b0..18adb3c9d2 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 3b67296952..f5e793fb90 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 ced13342d2..fbcd4b48ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -131,6 +131,10 @@ type = [ # until types-setuptools is removed from typeshed. # For help with static-typing issues, or mypy update, ping @Avasam "mypy==1.11.*", + # 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 + 'jaraco.develop >= 7.21; sys_platform != "cygwin"', ] diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000000..cd04c33371 --- /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 1c39fd9dab..ab373c51d6 100644 --- a/setuptools/__init__.py +++ b/setuptools/__init__.py @@ -12,6 +12,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 @@ -59,7 +60,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) diff --git a/setuptools/_reqs.py b/setuptools/_reqs.py index 2d09244b43..71ea23dea9 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 0c5e544804..f60fcbda15 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 9db57ac8a2..d532762ebe 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/easy_install.py b/setuptools/command/easy_install.py index 54d1e48449..46c0a231eb 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 46852c1a94..9eaa62aba0 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/pyprojecttoml.py b/setuptools/config/pyprojecttoml.py index 943b9f5a00..a381e38eae 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 abcc2755be..a69ccd3312 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 1510e01934..9e01d5e082 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 7d545f1004..98bd26ab9e 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 af172953e3..6b60c7e7f7 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 6af1d98c6b..72b8ed47f1 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__