Skip to content

Generate distutils-stubs on install #4861

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
74 changes: 74 additions & 0 deletions build_with_distutils_stubs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Generate distutils stub files inside the source directory before packaging.
We have to do this as a custom build backend for PEP 660 editable installs.
Doing it this way also allows us to point local type-checkers to types/distutils,
overriding the stdlib types even on Python < 3.12."""
Comment on lines +3 to +4
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like I got this working without leaving an unchecked types/ folder in the source.

Suggested change
Doing it this way also allows us to point local type-checkers to types/distutils,
overriding the stdlib types even on Python < 3.12."""
"""


from __future__ import annotations

import os
import shutil
from pathlib import Path

from setuptools._path import StrPath
from setuptools.build_meta import * # noqa: F403 # expose everything
from setuptools.build_meta import (
_ConfigSettings,
build_editable as _build_editable,
build_sdist as _build_sdist,
build_wheel as _build_wheel,
)

_vendored_distutils_path = Path(__file__).parent / "setuptools" / "_distutils"
_distutils_stubs_path = Path(__file__).parent / "distutils-stubs"


def _regenerate_distutils_stubs() -> None:
shutil.rmtree(_distutils_stubs_path, ignore_errors=True)
_distutils_stubs_path.mkdir(parents=True)
(_distutils_stubs_path / ".gitignore").write_text("*")
(_distutils_stubs_path / "ruff.toml").write_text('[lint]\nignore = ["F403"]')
(_distutils_stubs_path / "py.typed").write_text("\n")
for path in _vendored_distutils_path.rglob("*.py"):
relative_path = path.relative_to(_vendored_distutils_path)
if "tests" in relative_path.parts:
continue
stub_path = _distutils_stubs_path / relative_path.with_suffix(".pyi")
stub_path.parent.mkdir(parents=True, exist_ok=True)
module = "setuptools._distutils." + str(relative_path.with_suffix("")).replace(
os.sep, "."
).removesuffix(".__init__")
if str(relative_path) == "__init__.py":
# Work around python/mypy#18775
stub_path.write_text("""\
from typing import Final

__version__: Final[str]
""")
Comment on lines +41 to +46
Copy link
Contributor Author

@Avasam Avasam Mar 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

else:
stub_path.write_text(f"from {module} import *\n")


def build_wheel( # type: ignore[no-redef]
wheel_directory: StrPath,
config_settings: _ConfigSettings = None,
metadata_directory: StrPath | None = None,
) -> str:
_regenerate_distutils_stubs()
return _build_wheel(wheel_directory, config_settings, metadata_directory)


def build_sdist( # type: ignore[no-redef]
sdist_directory: StrPath,
config_settings: _ConfigSettings = None,
) -> str:
_regenerate_distutils_stubs()
return _build_sdist(sdist_directory, config_settings)


def build_editable( # type: ignore[no-redef]
wheel_directory: StrPath,
config_settings: _ConfigSettings = None,
metadata_directory: StrPath | None = None,
) -> str:
_regenerate_distutils_stubs()
return _build_editable(wheel_directory, config_settings, metadata_directory)
10 changes: 1 addition & 9 deletions mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
strict = False

# Early opt-in even when strict = False
# warn_unused_ignores = True # Disabled until we have distutils stubs for Python 3.12+
warn_unused_ignores = True
warn_redundant_casts = True
enable_error_code = ignore-without-code

Expand Down Expand Up @@ -48,14 +48,6 @@ disable_error_code =
[mypy-pkg_resources.tests.*]
disable_error_code = 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
[mypy-distutils.*]
ignore_missing_imports = True

# - wheel: does not intend on exposing a programmatic API https://github.com/pypa/wheel/pull/610#issuecomment-2081687671
[mypy-wheel.*]
follow_untyped_imports = True
Expand Down
1 change: 1 addition & 0 deletions newsfragments/4861.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
``setuptools`` now provide its own ``distutils-stubs`` instead of relying on typeshed -- by :user:`Avasam`
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[build-system]
requires = []
build-backend = "setuptools.build_meta"
build-backend = "build_with_distutils_stubs"
backend-path = ["."]

[project]
Expand Down Expand Up @@ -202,6 +202,7 @@ include-package-data = true
include = [
"setuptools*",
"pkg_resources*",
"distutils-stubs*",
"_distutils_hack*",
]
exclude = [
Expand Down
2 changes: 2 additions & 0 deletions pyrightconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
],
// Our testing setup doesn't allow passing CLI arguments, so local devs have to set this manually.
// "pythonVersion": "3.9",
// Allow using distutils-stubs on Python 3.12+
"reportMissingModuleSource": false,
// For now we don't mind if mypy's `type: ignore` comments accidentally suppresses pyright issues
"enableTypeIgnoreComments": true,
"typeCheckingMode": "basic",
Expand Down
7 changes: 1 addition & 6 deletions setuptools/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
"""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

Expand Down Expand Up @@ -224,7 +219,7 @@ def reinitialize_command(
) -> _Command:
cmd = _Command.reinitialize_command(self, command, reinit_subcommands)
vars(cmd).update(kw)
return cmd # pyright: ignore[reportReturnType] # pypa/distutils#307
return cmd

@abstractmethod
def initialize_options(self) -> None:
Expand Down
4 changes: 2 additions & 2 deletions setuptools/build_meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,11 @@ def patch(cls):
for the duration of this context.
"""
orig = distutils.core.Distribution
distutils.core.Distribution = cls # type: ignore[misc] # monkeypatching
distutils.core.Distribution = cls
try:
yield
finally:
distutils.core.Distribution = orig # type: ignore[misc] # monkeypatching
distutils.core.Distribution = orig


@contextlib.contextmanager
Expand Down
2 changes: 1 addition & 1 deletion setuptools/command/build_ext.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ def setup_shlib_compiler(self):
compiler.set_link_objects(self.link_objects)

# hack so distutils' build_extension() builds a library instead
compiler.link_shared_object = link_shared_object.__get__(compiler) # type: ignore[method-assign]
compiler.link_shared_object = link_shared_object.__get__(compiler)

def get_export_symbols(self, ext):
if isinstance(ext, Library):
Expand Down
4 changes: 2 additions & 2 deletions setuptools/command/build_py.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def finalize_options(self):
if 'data_files' in self.__dict__:
del self.__dict__['data_files']

def copy_file( # type: ignore[override] # No overload, no bytes support
def copy_file(
self,
infile: StrPath,
outfile: StrPathT,
Expand Down Expand Up @@ -135,7 +135,7 @@ def find_data_files(self, package, src_dir):
)
return self.exclude_data_files(package, src_dir, files)

def get_outputs(self, include_bytecode: bool = True) -> list[str]: # type: ignore[override] # Using a real boolean instead of 0|1
def get_outputs(self, include_bytecode: bool = True) -> list[str]:
"""See :class:`setuptools.commands.build.SubCommand`"""
if self.editable_mode:
return list(self.get_output_mapping().keys())
Expand Down
7 changes: 3 additions & 4 deletions setuptools/command/install_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,9 @@ def copy_tree(
self,
infile: StrPath,
outfile: str,
# override: Using actual booleans
preserve_mode: bool = True, # type: ignore[override]
preserve_times: bool = True, # type: ignore[override]
preserve_symlinks: bool = False, # type: ignore[override]
preserve_mode: bool = True,
preserve_times: bool = True,
preserve_symlinks: bool = False,
level: object = 1,
) -> list[str]:
assert preserve_mode
Expand Down
2 changes: 1 addition & 1 deletion setuptools/command/sdist.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ class sdist(orig.sdist):
]

distribution: Distribution # override distutils.dist.Distribution with setuptools.dist.Distribution
negative_opt: ClassVar[dict[str, str]] = {}
negative_opt: ClassVar[dict[str, str]] = {} # type: ignore[misc] # TODO: Fix upstream

README_EXTENSIONS = ['', '.rst', '.txt', '.md']
READMES = tuple(f'README{ext}' for ext in README_EXTENSIONS)
Expand Down
2 changes: 1 addition & 1 deletion setuptools/command/setopt.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def edit_config(filename, settings, dry_run=False):
"""
log.debug("Reading configuration from %s", filename)
opts = configparser.RawConfigParser()
opts.optionxform = lambda optionstr: optionstr # type: ignore[method-assign] # overriding method
opts.optionxform = lambda optionstr: optionstr
_cfg_read_utf8_with_fallback(opts, filename)

for section, options in settings.items():
Expand Down
5 changes: 2 additions & 3 deletions setuptools/config/setupcfg.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from collections import defaultdict
from collections.abc import Iterable, Iterator
from functools import partial, wraps
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Generic, TypeVar, cast
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Generic, TypeVar

from packaging.markers import default_environment as marker_env
from packaging.requirements import InvalidRequirement, Requirement
Expand Down Expand Up @@ -101,8 +101,7 @@ def _apply(
filenames = [*other_files, filepath]

try:
# TODO: Temporary cast until mypy 1.12 is released with upstream fixes from typeshed
_Distribution.parse_config_files(dist, filenames=cast(list[str], filenames))
_Distribution.parse_config_files(dist, filenames=filenames)
handlers = parse_configuration(
dist, dist.command_options, ignore_option_errors=ignore_option_errors
)
Expand Down
2 changes: 1 addition & 1 deletion setuptools/dist.py
Original file line number Diff line number Diff line change
Expand Up @@ -727,7 +727,7 @@ def fetch_build_egg(self, req):

return fetch_build_egg(self, req)

def get_command_class(self, command: str) -> type[distutils.cmd.Command]: # type: ignore[override] # Not doing complex overrides yet
def get_command_class(self, command: str) -> type[distutils.cmd.Command]:
"""Pluggable version of get_command_class()"""
if command in self.cmdclass:
return self.cmdclass[command]
Expand Down
8 changes: 4 additions & 4 deletions setuptools/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,15 @@
BaseError = _distutils_errors.DistutilsError


class InvalidConfigError(OptionError): # type: ignore[valid-type, misc] # distutils imports are `Any` on python 3.12+
class InvalidConfigError(OptionError):
"""Error used for invalid configurations."""


class RemovedConfigError(OptionError): # type: ignore[valid-type, misc] # distutils imports are `Any` on python 3.12+
class RemovedConfigError(OptionError):
"""Error used for configurations that were deprecated and removed."""


class RemovedCommandError(BaseError, RuntimeError): # type: ignore[valid-type, misc] # distutils imports are `Any` on python 3.12+
class RemovedCommandError(BaseError, RuntimeError):
"""Error used for commands that have been removed in setuptools.

Since ``setuptools`` is built on ``distutils``, simply removing a command
Expand All @@ -48,7 +48,7 @@ class RemovedCommandError(BaseError, RuntimeError): # type: ignore[valid-type,
"""


class PackageDiscoveryError(BaseError, RuntimeError): # type: ignore[valid-type, misc] # distutils imports are `Any` on python 3.12+
class PackageDiscoveryError(BaseError, RuntimeError):
"""Impossible to perform automatic discovery of packages and/or modules.

The current project layout or given discovery options can lead to problems when
Expand Down
7 changes: 1 addition & 6 deletions setuptools/extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,12 +151,7 @@ def __init__(
# The *args is needed for compatibility as calls may use positional
# arguments. py_limited_api may be set only via keyword.
self.py_limited_api = py_limited_api
super().__init__(
name,
sources, # type: ignore[arg-type] # Vendored version of setuptools supports PathLike
*args,
**kw,
)
super().__init__(name, sources, *args, **kw)

def _convert_pyx_sources_to_lang(self):
"""
Expand Down
2 changes: 1 addition & 1 deletion setuptools/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def configure() -> None:
# and then loaded again when patched,
# implying: id(distutils.log) != id(distutils.dist.log).
# Make sure the same module object is used everywhere:
distutils.dist.log = distutils.log
distutils.dist.log = distutils.log # type: ignore[assignment]


def set_threshold(level: int) -> int:
Expand Down
6 changes: 3 additions & 3 deletions setuptools/monkey.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ def patch_all():
import setuptools

# we can't patch distutils.cmd, alas
distutils.core.Command = setuptools.Command # type: ignore[misc,assignment] # monkeypatching
distutils.core.Command = setuptools.Command

_patch_distribution_metadata()

Expand All @@ -82,8 +82,8 @@ def patch_all():
module.Distribution = setuptools.dist.Distribution

# Install the patched Extension
distutils.core.Extension = setuptools.extension.Extension # type: ignore[misc,assignment] # monkeypatching
distutils.extension.Extension = setuptools.extension.Extension # type: ignore[misc,assignment] # monkeypatching
distutils.core.Extension = setuptools.extension.Extension
distutils.extension.Extension = setuptools.extension.Extension
if 'distutils.command.build_ext' in sys.modules:
sys.modules[
'distutils.command.build_ext'
Expand Down
Loading