diff --git a/.coveragerc b/.coveragerc index 5b7fdefd2a..c8d1cbbd6e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -17,6 +17,8 @@ disable_warnings = [report] show_missing = True exclude_also = - # jaraco/skeleton#97 - @overload + # Exclude common false positives per + # https://coverage.readthedocs.io/en/latest/excluding.html#advanced-exclusion + # Ref jaraco/skeleton#97 and jaraco/skeleton#135 + class .*\bProtocol\): if TYPE_CHECKING: 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/.pre-commit-config.yaml b/.pre-commit-config.yaml index aaefd5fbbb..a88677f50c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ repos: - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.7 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.5.7 + hooks: + - id: ruff + args: [--fix, --unsafe-fixes] + - id: ruff-format diff --git a/docs/userguide/pyproject_config.rst b/docs/userguide/pyproject_config.rst index 749afe2344..4f60ad9324 100644 --- a/docs/userguide/pyproject_config.rst +++ b/docs/userguide/pyproject_config.rst @@ -192,7 +192,7 @@ corresponding entry is required in the ``tool.setuptools.dynamic`` table dynamic = ["version", "readme"] # ... [tool.setuptools.dynamic] - version = {attr = "my_package.VERSION"} + version = {attr = "my_package.__version__"} # any module attribute compatible with ast.literal_eval readme = {file = ["README.rst", "USAGE.rst"]} In the ``dynamic`` table, the ``attr`` directive [#directives]_ will read an @@ -280,7 +280,7 @@ not installed yet. You may also need to manually add the project directory to directive for ``tool.setuptools.dynamic.version``. .. [#attr] ``attr`` is meant to be used when the module attribute is statically - specified (e.g. as a string, list or tuple). As a rule of thumb, the + specified (e.g. as a string). As a rule of thumb, the attribute should be able to be parsed with :func:`ast.literal_eval`, and should not be modified or re-assigned. diff --git a/mypy.ini b/mypy.ini index 569c7f0ace..43bb9d56c9 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,7 +1,7 @@ [mypy] # CI should test for all versions, local development gets hints for oldest supported -# Some upstream typeshed distutils stubs fixes are necessary before we can start testing on Python 3.12 -python_version = 3.8 +# But our testing setup doesn't allow passing CLI arguments, so local devs have to set this manually. +# python_version = 3.8 strict = False warn_unused_ignores = True warn_redundant_casts = True @@ -30,15 +30,19 @@ disable_error_code = attr-defined [mypy-pkg_resources.tests.*] disable_error_code = import-not-found -# - distutils._modified has different errors on Python 3.8 [import-untyped], on Python 3.9+ [import-not-found] +# - distutils doesn't exist on Python 3.12, unfortunately, this means typing +# will be missing for subclasses of distutils on Python 3.12 until either: +# - support for `SETUPTOOLS_USE_DISTUTILS=stdlib` is dropped (#3625) +# for setuptools to import `_distutils` directly +# - or non-stdlib distutils typings are exposed # - All jaraco modules are still untyped # - _validate_project sometimes complains about trove_classifiers (#4296) # - wheel appears to be untyped -[mypy-distutils._modified,jaraco.*,trove_classifiers,wheel.*] +[mypy-distutils.*,jaraco.*,trove_classifiers,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.*] +[mypy-setuptools.config._validate_pyproject.*,setuptools._distutils.*] follow_imports = silent # silent => ignore errors when following imports diff --git a/newsfragments/4553.feature.rst b/newsfragments/4553.feature.rst new file mode 100644 index 0000000000..43ea1eeac9 --- /dev/null +++ b/newsfragments/4553.feature.rst @@ -0,0 +1 @@ +Added detection of ARM64 variant of MSVC -- by :user:`saschanaz` diff --git a/newsfragments/4585.feature.rst b/newsfragments/4585.feature.rst new file mode 100644 index 0000000000..566bca75f8 --- /dev/null +++ b/newsfragments/4585.feature.rst @@ -0,0 +1 @@ +Made ``setuptools.package_index.Credential`` a `typing.NamedTuple` -- by :user:`Avasam` diff --git a/newsfragments/4592.misc.rst b/newsfragments/4592.misc.rst new file mode 100644 index 0000000000..79d36a9d82 --- /dev/null +++ b/newsfragments/4592.misc.rst @@ -0,0 +1 @@ +If somehow the ``EXT_SUFFIX`` configuration variable and ``SETUPTOOLS_EXT_SUFFIX`` environment variables are both missing, ``setuptools.command.build_ext.pyget_ext_filename`` will now raise an `OSError` instead of a `TypeError` -- by :user:`Avasam` diff --git a/newsfragments/4598.feature.rst b/newsfragments/4598.feature.rst new file mode 100644 index 0000000000..ee2ea40dfe --- /dev/null +++ b/newsfragments/4598.feature.rst @@ -0,0 +1 @@ +Fully typed all collection attributes in ``pkg_resources`` -- by :user:`Avasam` diff --git a/pkg_resources/__init__.py b/pkg_resources/__init__.py index 88a3259f26..f64c0b18fe 100644 --- a/pkg_resources/__init__.py +++ b/pkg_resources/__init__.py @@ -1,6 +1,3 @@ -# TODO: Add Generic type annotations to initialized collections. -# For now we'd simply use implicit Any/Unknown which would add redundant annotations -# mypy: disable-error-code="var-annotated" """ Package resource API -------------------- @@ -100,6 +97,7 @@ if TYPE_CHECKING: from _typeshed import BytesPath, StrOrBytesPath, StrPath + from _typeshed.importlib import LoaderProtocol from typing_extensions import Self, TypeAlias warnings.warn( @@ -131,11 +129,6 @@ ) -# Use _typeshed.importlib.LoaderProtocol once available https://github.com/python/typeshed/pull/11890 -class _LoaderProtocol(Protocol): - def load_module(self, fullname: str, /) -> types.ModuleType: ... - - class _ZipLoaderModule(Protocol): __loader__: zipimport.zipimporter @@ -568,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): @@ -597,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 @@ -604,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 @@ -611,27 +612,31 @@ 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: """A collection of active distributions on sys.path (or a similar list)""" - def __init__(self, entries: Iterable[str] | None = None): + def __init__(self, entries: Iterable[str] | None = None) -> None: """Create working set from list of path entries (default=sys.path)""" self.entries: list[str] = [] - self.entry_keys = {} - self.by_key = {} - self.normalized_to_canonical_keys = {} - self.callbacks = [] + self.entry_keys: dict[str | None, list[str]] = {} + self.by_key: dict[str, Distribution] = {} + self.normalized_to_canonical_keys: dict[str, str] = {} + self.callbacks: list[Callable[[Distribution], object]] = [] if entries is None: entries = sys.path @@ -868,14 +873,16 @@ def resolve( # set of processed requirements processed = set() # key -> dist - best = {} - to_activate = [] + best: dict[str, Distribution] = {} + to_activate: list[Distribution] = [] req_extras = _ReqExtras() # Mapping of requirement to set of distributions that required it; # useful for reporting info about conflicts. - required_by = collections.defaultdict(set) + required_by: collections.defaultdict[Requirement, set[str]] = ( + collections.defaultdict(set) + ) while requirements: # process dependencies breadth-first @@ -1132,7 +1139,7 @@ def __init__( search_path: Iterable[str] | None = None, platform: str | None = get_supported_platform(), python: str | None = PY_MAJOR, - ): + ) -> None: """Snapshot distributions available on a search path Any distributions found on `search_path` are added to the environment. @@ -1149,7 +1156,7 @@ def __init__( wish to map *all* distributions, not just those compatible with the running platform or Python version. """ - self._distmap = {} + self._distmap: dict[str, list[Distribution]] = {} self.platform = platform self.python = python self.scan(search_path) @@ -1346,8 +1353,9 @@ class ResourceManager: extraction_path: str | None = None - def __init__(self): - self.cached_files = {} + def __init__(self) -> None: + # acts like a set + self.cached_files: dict[str, Literal[True]] = {} def resource_exists( self, package_or_requirement: _PkgReqType, resource_name: str @@ -1644,9 +1652,9 @@ class NullProvider: egg_name: str | None = None egg_info: str | None = None - loader: _LoaderProtocol | None = None + loader: LoaderProtocol | None = None - def __init__(self, module: _ModuleLike): + def __init__(self, module: _ModuleLike) -> None: self.loader = getattr(module, '__loader__', None) self.module_path = os.path.dirname(getattr(module, '__file__', '')) @@ -1863,7 +1871,7 @@ def _parents(path): class EggProvider(NullProvider): """Provider based on a virtual filesystem""" - def __init__(self, module: _ModuleLike): + def __init__(self, module: _ModuleLike) -> None: super().__init__(module) self._setup_prefix() @@ -1929,7 +1937,7 @@ def _get(self, path) -> bytes: def _listdir(self, path): return [] - def __init__(self): + def __init__(self) -> None: pass @@ -1995,7 +2003,7 @@ class ZipProvider(EggProvider): # ZipProvider's loader should always be a zipimporter or equivalent loader: zipimport.zipimporter - def __init__(self, module: _ZipLoaderModule): + def __init__(self, module: _ZipLoaderModule) -> None: super().__init__(module) self.zip_pre = self.loader.archive + os.sep @@ -2174,7 +2182,7 @@ class FileMetadata(EmptyProvider): the provided location. """ - def __init__(self, path: StrPath): + def __init__(self, path: StrPath) -> None: self.path = path def _get_metadata_path(self, name): @@ -2223,7 +2231,7 @@ class PathMetadata(DefaultProvider): dist = Distribution.from_filename(egg_path, metadata=metadata) """ - def __init__(self, path: str, egg_info: str): + def __init__(self, path: str, egg_info: str) -> None: self.module_path = path self.egg_info = egg_info @@ -2231,7 +2239,7 @@ def __init__(self, path: str, egg_info: str): class EggMetadata(ZipProvider): """Metadata provider for .egg files""" - def __init__(self, importer: zipimport.zipimporter): + def __init__(self, importer: zipimport.zipimporter) -> None: """Create a metadata provider from a zipimporter""" self.zip_pre = importer.archive + os.sep @@ -2358,7 +2366,7 @@ class NoDists: def __bool__(self): return False - def __call__(self, fullpath): + def __call__(self, fullpath: object): return iter(()) @@ -2708,7 +2716,7 @@ def __init__( attrs: Iterable[str] = (), extras: Iterable[str] = (), dist: Distribution | None = None, - ): + ) -> None: if not MODULE(module_name): raise ValueError("Invalid module name", module_name) self.name = name @@ -2902,7 +2910,7 @@ def __init__( py_version: str | None = PY_MAJOR, platform: str | None = None, precedence: int = EGG_DIST, - ): + ) -> None: self.project_name = safe_name(project_name or 'Unknown') if version is not None: self._version = safe_version(version) @@ -3455,7 +3463,7 @@ class Requirement(packaging.requirements.Requirement): # packaging.requirements.Requirement) extras: tuple[str, ...] # type: ignore[assignment] - def __init__(self, requirement_string: str): + def __init__(self, requirement_string: str) -> None: """DO NOT CALL THIS UNDOCUMENTED METHOD; use Requirement.parse()!""" super().__init__(requirement_string) self.unsafe_name = self.name @@ -3558,7 +3566,7 @@ def split_sections(s: _NestedStr) -> Iterator[tuple[str | None, list[str]]]: header, they're returned in a first ``section`` of ``None``. """ section = None - content = [] + content: list[str] = [] for line in yield_lines(s): if line.startswith("["): if line.endswith("]"): 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 390da0c634..fbcd4b48ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,15 +36,10 @@ Changelog = "https://setuptools.pypa.io/en/stable/history.html" test = [ # upstream "pytest >= 6, != 8.1.*", - "pytest-checkdocs >= 2.4", - "pytest-cov", - "pytest-mypy", - "pytest-enabler >= 2.2", - "pytest-ruff >= 0.2.1; sys_platform != 'cygwin'", # 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", @@ -59,28 +54,14 @@ test = [ # for tools/finalize.py 'jaraco.develop >= 7.21; python_version >= "3.9" and sys_platform != "cygwin"', "pytest-home >= 0.5", - # 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.*", - # No Python 3.11 dependencies require tomli, but needed for type-checking since we import it directly - "tomli", - # No Python 3.12 dependencies require importlib_metadata, but needed for type-checking since we import it directly - "importlib_metadata", "pytest-subprocess", - # require newer pytest-ruff than upstream for pypa/setuptools#4368 - # also exclude cygwin for pypa/setuptools#3921 - 'pytest-ruff >= 0.3.2; sys_platform != "cygwin"', - # workaround for pypa/setuptools#4333 "pyproject-hooks!=1.1", "jaraco.test", - - # workaround for businho/pytest-ruff#28 - 'pytest-ruff < 0.4; platform_system == "Windows"', ] + doc = [ # upstream "sphinx >= 3.5", @@ -121,6 +102,42 @@ core = [ "platformdirs >= 2.6.2", ] +check = [ + # upstream + "pytest-checkdocs >= 2.4", + "pytest-ruff >= 0.2.1; sys_platform != 'cygwin'", + + # local + + # workaround for businho/pytest-ruff#28 + "ruff >= 0.5.2; sys_platform != 'cygwin'", +] + +cover = [ + "pytest-cov", +] + +enabler = [ + "pytest-enabler >= 2.2", +] + +type = [ + # upstream + "pytest-mypy", + + # local + + # 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.*", + # 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"', +] + + [project.entry-points."distutils.commands"] alias = "setuptools.command.alias:alias" bdist_egg = "setuptools.command.bdist_egg:bdist_egg" @@ -192,4 +209,9 @@ namespaces = true [tool.distutils.sdist] formats = "zip" + [tool.setuptools_scm] + + +[tool.pytest-enabler.mypy] +# Disabled due to jaraco/skeleton#143 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 1d3156ff10..ab373c51d6 100644 --- a/setuptools/__init__.py +++ b/setuptools/__init__.py @@ -1,4 +1,9 @@ """Extensions to the 'distutils' for large or complex distributions""" +# mypy: disable_error_code=override +# Command.reinitialize_command has an extra **kw param that distutils doesn't have +# Can't disable on the exact line because distutils doesn't exists on Python 3.12 +# and mypy isn't aware of distutils_hack, causing distutils.core.Command to be Any, +# and a [unused-ignore] to be raised on 3.12+ from __future__ import annotations @@ -7,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 @@ -54,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) @@ -114,8 +120,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) @@ -207,7 +215,7 @@ def ensure_string_list(self, option): "'%s' must be a list of strings (got %r)" % (option, val) ) - @overload # type:ignore[override] # Extra **kw param + @overload def reinitialize_command( self, command: str, reinit_subcommands: bool = False, **kw ) -> _Command: ... 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/build_meta.py b/setuptools/build_meta.py index 7663306862..a6b85afc42 100644 --- a/setuptools/build_meta.py +++ b/setuptools/build_meta.py @@ -387,9 +387,10 @@ def _build_with_temp_dir( # Build in a temporary directory, then copy to the target. os.makedirs(result_directory, exist_ok=True) - temp_opts = {"prefix": ".tmp-", "dir": result_directory} - with tempfile.TemporaryDirectory(**temp_opts) as tmp_dist_dir: + with tempfile.TemporaryDirectory( + prefix=".tmp-", dir=result_directory + ) as tmp_dist_dir: sys.argv = [ *sys.argv[:1], *self._global_args(config_settings), 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/build_ext.py b/setuptools/command/build_ext.py index 51c1771a33..e2a88ce218 100644 --- a/setuptools/command/build_ext.py +++ b/setuptools/command/build_ext.py @@ -6,7 +6,7 @@ from importlib.machinery import EXTENSION_SUFFIXES from importlib.util import cache_from_source as _compiled_file_name from pathlib import Path -from typing import Iterator +from typing import TYPE_CHECKING, Iterator from setuptools.dist import Distribution from setuptools.errors import BaseError @@ -16,23 +16,25 @@ from distutils.ccompiler import new_compiler from distutils.sysconfig import customize_compiler, get_config_var -try: - # Attempt to use Cython for building extensions, if available - from Cython.Distutils.build_ext import ( # type: ignore[import-not-found] # Cython not installed on CI tests - build_ext as _build_ext, - ) - - # Additionally, assert that the compiler module will load - # also. Ref #1229. - __import__('Cython.Compiler.Main') -except ImportError: +if TYPE_CHECKING: + # Cython not installed on CI tests, causing _build_ext to be `Any` from distutils.command.build_ext import build_ext as _build_ext +else: + try: + # Attempt to use Cython for building extensions, if available + from Cython.Distutils.build_ext import build_ext as _build_ext + + # Additionally, assert that the compiler module will load + # also. Ref #1229. + __import__('Cython.Compiler.Main') + except ImportError: + from distutils.command.build_ext import build_ext as _build_ext # make sure _config_vars is initialized get_config_var("LDSHARED") # Not publicly exposed in typeshed distutils stubs, but this is done on purpose # See https://github.com/pypa/setuptools/pull/4228#issuecomment-1959856400 -from distutils.sysconfig import _config_vars as _CONFIG_VARS # type: ignore # noqa +from distutils.sysconfig import _config_vars as _CONFIG_VARS # noqa: E402 def _customize_compiler_for_shlib(compiler): @@ -155,21 +157,25 @@ def _get_output_mapping(self) -> Iterator[tuple[str, str]]: output_cache = _compiled_file_name(regular_stub, optimization=opt) yield (output_cache, inplace_cache) - def get_ext_filename(self, fullname): + def get_ext_filename(self, fullname: str) -> str: 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') + ext_suffix = get_config_var('EXT_SUFFIX') + 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" + ) + so_ext = 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 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/command/sdist.py b/setuptools/command/sdist.py index 23393c0797..68afab89b4 100644 --- a/setuptools/command/sdist.py +++ b/setuptools/command/sdist.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import contextlib import os from itertools import chain @@ -46,7 +48,7 @@ class sdist(orig.sdist): ] distribution: Distribution # override distutils.dist.Distribution with setuptools.dist.Distribution - negative_opt = {} + negative_opt: dict[str, str] = {} README_EXTENSIONS = ['', '.rst', '.txt', '.md'] READMES = tuple('README{0}'.format(ext) for ext in README_EXTENSIONS) 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/config/setupcfg.py b/setuptools/config/setupcfg.py index e825477043..072b787062 100644 --- a/setuptools/config/setupcfg.py +++ b/setuptools/config/setupcfg.py @@ -24,9 +24,11 @@ Generic, Iterable, Iterator, + List, Tuple, TypeVar, Union, + cast, ) from packaging.markers import default_environment as marker_env @@ -108,7 +110,8 @@ def _apply( filenames = [*other_files, filepath] try: - _Distribution.parse_config_files(dist, filenames=filenames) # type: ignore[arg-type] # TODO: fix in distutils stubs + # TODO: Temporary cast until mypy 1.12 is released with upstream fixes from typeshed + _Distribution.parse_config_files(dist, filenames=cast(List[str], filenames)) handlers = parse_configuration( dist, dist.command_options, ignore_option_errors=ignore_option_errors ) diff --git a/setuptools/dist.py b/setuptools/dist.py index 715e8fbb73..68f877decd 100644 --- a/setuptools/dist.py +++ b/setuptools/dist.py @@ -195,8 +195,10 @@ def check_packages(dist, attr, value): 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 - _Distribution = distutils.core.Distribution + _Distribution: TypeAlias = distutils.core.Distribution else: _Distribution = get_unpatched(distutils.core.Distribution) diff --git a/setuptools/errors.py b/setuptools/errors.py index dd4e58e9fc..90fcf7170e 100644 --- a/setuptools/errors.py +++ b/setuptools/errors.py @@ -3,29 +3,36 @@ Provides exceptions used by setuptools modules. """ +from __future__ import annotations + +from typing import TYPE_CHECKING + from distutils import errors as _distutils_errors +if TYPE_CHECKING: + from typing_extensions import TypeAlias + # Re-export errors from distutils to facilitate the migration to PEP632 -ByteCompileError = _distutils_errors.DistutilsByteCompileError -CCompilerError = _distutils_errors.CCompilerError -ClassError = _distutils_errors.DistutilsClassError -CompileError = _distutils_errors.CompileError -ExecError = _distutils_errors.DistutilsExecError -FileError = _distutils_errors.DistutilsFileError -InternalError = _distutils_errors.DistutilsInternalError -LibError = _distutils_errors.LibError -LinkError = _distutils_errors.LinkError -ModuleError = _distutils_errors.DistutilsModuleError -OptionError = _distutils_errors.DistutilsOptionError -PlatformError = _distutils_errors.DistutilsPlatformError -PreprocessError = _distutils_errors.PreprocessError -SetupError = _distutils_errors.DistutilsSetupError -TemplateError = _distutils_errors.DistutilsTemplateError -UnknownFileError = _distutils_errors.UnknownFileError +ByteCompileError: TypeAlias = _distutils_errors.DistutilsByteCompileError +CCompilerError: TypeAlias = _distutils_errors.CCompilerError +ClassError: TypeAlias = _distutils_errors.DistutilsClassError +CompileError: TypeAlias = _distutils_errors.CompileError +ExecError: TypeAlias = _distutils_errors.DistutilsExecError +FileError: TypeAlias = _distutils_errors.DistutilsFileError +InternalError: TypeAlias = _distutils_errors.DistutilsInternalError +LibError: TypeAlias = _distutils_errors.LibError +LinkError: TypeAlias = _distutils_errors.LinkError +ModuleError: TypeAlias = _distutils_errors.DistutilsModuleError +OptionError: TypeAlias = _distutils_errors.DistutilsOptionError +PlatformError: TypeAlias = _distutils_errors.DistutilsPlatformError +PreprocessError: TypeAlias = _distutils_errors.PreprocessError +SetupError: TypeAlias = _distutils_errors.DistutilsSetupError +TemplateError: TypeAlias = _distutils_errors.DistutilsTemplateError +UnknownFileError: TypeAlias = _distutils_errors.UnknownFileError # The root error class in the hierarchy -BaseError = _distutils_errors.DistutilsError +BaseError: TypeAlias = _distutils_errors.DistutilsError class InvalidConfigError(OptionError): diff --git a/setuptools/extension.py b/setuptools/extension.py index b9fff2367f..dcc7709982 100644 --- a/setuptools/extension.py +++ b/setuptools/extension.py @@ -27,8 +27,10 @@ def _have_cython(): # for compatibility have_pyrex = _have_cython 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 - _Extension = distutils.core.Extension + _Extension: TypeAlias = distutils.core.Extension else: _Extension = get_unpatched(distutils.core.Extension) 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/msvc.py b/setuptools/msvc.py index ca332d59aa..57f09417ca 100644 --- a/setuptools/msvc.py +++ b/setuptools/msvc.py @@ -26,6 +26,7 @@ from more_itertools import unique_everseen import distutils.errors +from distutils.util import get_platform # https://github.com/python/mypy/issues/8166 if not TYPE_CHECKING and platform.system() == 'Windows': @@ -89,8 +90,9 @@ def _msvc14_find_vc2017(): if not root: return None, None + variant = 'arm64' if get_platform() == 'win-arm64' else 'x86.x64' suitable_components = ( - "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", + f"Microsoft.VisualStudio.Component.VC.Tools.{variant}", "Microsoft.VisualStudio.Workload.WDExpress", ) diff --git a/setuptools/package_index.py b/setuptools/package_index.py index e22b46cd94..9e01d5e082 100644 --- a/setuptools/package_index.py +++ b/setuptools/package_index.py @@ -18,6 +18,7 @@ import urllib.request from fnmatch import translate from functools import wraps +from typing import NamedTuple from more_itertools import unique_everseen @@ -1001,21 +1002,20 @@ def _encode_auth(auth): return encoded.replace('\n', '') -class Credential: - """ - A username/password pair. Use like a namedtuple. +class Credential(NamedTuple): """ + A username/password pair. - def __init__(self, username, password): - self.username = username - self.password = password + Displayed separated by `:`. + >>> str(Credential('username', 'password')) + 'username:password' + """ - def __iter__(self): - yield self.username - yield self.password + username: str + password: str def __str__(self) -> str: - return '%(username)s:%(password)s' % vars(self) + return f'{self.username}:{self.password}' class PyPIConfig(configparser.RawConfigParser): @@ -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 8dc2fe671f..dc24e17d7b 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/integration/test_pip_install_sdist.py b/setuptools/tests/integration/test_pip_install_sdist.py index 2d59337aff..e5203d18f9 100644 --- a/setuptools/tests/integration/test_pip_install_sdist.py +++ b/setuptools/tests/integration/test_pip_install_sdist.py @@ -202,13 +202,12 @@ def build_deps(package, sdist_file): "Manually" install them, since pip will not install build deps with `--no-build-isolation`. """ - import tomli as toml - # delay importing, since pytest discovery phase may hit this file from a # testenv without tomli + from setuptools.compat.py310 import tomllib archive = Archive(sdist_file) - info = toml.loads(_read_pyproject(archive)) + info = tomllib.loads(_read_pyproject(archive)) deps = info.get("build-system", {}).get("requires", []) deps += EXTRA_BUILD_DEPS.get(package, []) # Remove setuptools from requirements (and deduplicate) diff --git a/setuptools/tests/test_build_ext.py b/setuptools/tests/test_build_ext.py index 814fbd86aa..dab8b41cc9 100644 --- a/setuptools/tests/test_build_ext.py +++ b/setuptools/tests/test_build_ext.py @@ -183,12 +183,11 @@ def get_build_ext_cmd(self, optional: bool, **opts): "eggs.c": "#include missingheader.h\n", ".build": {"lib": {}, "tmp": {}}, } - path.build(files) + path.build(files) # type: ignore[arg-type] # jaraco/path#232 extension = Extension('spam.eggs', ['eggs.c'], optional=optional) dist = Distribution(dict(ext_modules=[extension])) dist.script_name = 'setup.py' cmd = build_ext(dist) - # TODO: False-positive [attr-defined], raise upstream vars(cmd).update(build_lib=".build/lib", build_temp=".build/tmp", **opts) cmd.ensure_finalized() return cmd 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__ diff --git a/tox.ini b/tox.ini index 6fc71eb16a..cbade4ff46 100644 --- a/tox.ini +++ b/tox.ini @@ -10,6 +10,10 @@ commands = usedevelop = True extras = test + check + cover + enabler + type core pass_env = SETUPTOOLS_USE_DISTUTILS