Skip to content

Commit

Permalink
Use custom build backend for PEP 660 editable installs
Browse files Browse the repository at this point in the history
  • Loading branch information
Avasam committed Mar 9, 2025
1 parent d9e413e commit 1ac7ac8
Show file tree
Hide file tree
Showing 11 changed files with 83 additions and 39 deletions.
70 changes: 70 additions & 0 deletions build_with_distutils_stubs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""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."""

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"]')
for path in _vendored_distutils_path.rglob("*.py"):
relative_path = path.relative_to(_vendored_distutils_path)
if relative_path.parts[0] == "tests":
continue
# if str(relative_path) == "__init__.py":
# # Work around a mypy issue with types/distutils/__init__.pyi:
# # error: Source file found twice under different module names: "setuptools._distutils.__init__" and "setuptools._distutils"
# (_distutils_stubs_path / "py.typed").write_text('partial')
# 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__")
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)
2 changes: 1 addition & 1 deletion 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 as long as there's inconsistent typing issues between pypa and stdlib's distutils
warn_unused_ignores = True
warn_redundant_casts = True
enable_error_code = ignore-without-code

Expand Down
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
21 changes: 0 additions & 21 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import os
import sys
import textwrap
from pathlib import Path

import setuptools
from setuptools.command.install import install
Expand Down Expand Up @@ -38,22 +37,6 @@ def pypi_link(pkg_filename):
return '/'.join(parts)


vendored_distutils_path = Path(here) / "setuptools" / "_distutils"


def generate_distutils_stubs(destination: Path) -> None:
for path in vendored_distutils_path.rglob("*.py"):
relative_path = path.relative_to(vendored_distutils_path)
if relative_path.parts[0] == "tests":
continue
stub_path = (destination / 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__")
stub_path.write_text(f"from {module} import *\n")


class install_with_pth(install):
"""
Custom install command to install a .pth file for distutils patching.
Expand Down Expand Up @@ -85,10 +68,6 @@ def initialize_options(self):
install.initialize_options(self)
self.extra_path = self._pth_name, self._pth_contents

def run(self):
install.run(self)
generate_distutils_stubs(Path(self.install_lib) / 'distutils-stubs')

def finalize_options(self):
install.finalize_options(self)
self._restore_install_lib()
Expand Down
2 changes: 1 addition & 1 deletion setuptools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ def ensure_string_list(self, option: str) -> None:
f"'{option}' must be a list of strings (got {val!r})"
)

@overload # type: ignore[override] # extra **kw param that distutils doesn't have
@overload
def reinitialize_command(
self, command: str, reinit_subcommands: bool = False, **kw
) -> _Command: ...
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/config/setupcfg.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ def _apply(
filenames = [*other_files, filepath]

try:
_Distribution.parse_config_files(dist, filenames=filenames) # type: ignore[arg-type] # Vendored version of distutils supports PathLike
_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
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 distutils 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/modified.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
try:
# Ensure a DistutilsError raised by these methods is the same as distutils.errors.DistutilsError
from distutils._modified import ( # type: ignore[import-not-found]
from distutils._modified import (
newer,
newer_group,
newer_pairwise,
Expand Down

0 comments on commit 1ac7ac8

Please sign in to comment.