Skip to content

[Investigation] Reduce the amount of patching in setuptools.build_meta #5008

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 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bootstrap.egg-info/entry_points.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
[distutils.commands]
egg_info = setuptools.command.egg_info:egg_info
dist_info = setuptools.command.dist_info:dist_info
build_py = setuptools.command.build_py:build_py
sdist = setuptools.command.sdist:sdist
editable_wheel = setuptools.command.editable_wheel:editable_wheel
Expand Down
70 changes: 29 additions & 41 deletions setuptools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@
import os
import sys
from abc import abstractmethod
from collections.abc import Mapping
from typing import TYPE_CHECKING, TypeVar, overload
from typing import TYPE_CHECKING, Any, 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
# workaround for #4476
Expand All @@ -21,6 +20,7 @@
import _distutils_hack.override # noqa: F401

from . import logging, monkey
from .compat import py310
from .depends import Require
from .discovery import PackageFinder, PEP420PackageFinder
from .dist import Distribution
Expand Down Expand Up @@ -49,40 +49,19 @@
find_namespace_packages = PEP420PackageFinder.find


def _install_setup_requires(attrs):
# Note: do not use `setuptools.Distribution` directly, as
# our PEP 517 backend patch `distutils.core.Distribution`.
class MinimalDistribution(distutils.core.Distribution):
"""
A minimal version of a distribution for supporting the
fetch_build_eggs interface.
"""
def _expand_setupcfg(attrs: dict[str, Any]) -> Distribution:
"""Bare minimum setup.cfg parsing so that we can extract setup_requires"""
from setuptools.config.setupcfg import _apply

dist = Distribution(attrs)
dist.set_defaults._disable()
if os.path.exists("setup.cfg"): # Assumes no other config contains setup_requires
_apply(dist, "setup.cfg", ignore_option_errors=True)
return dist


def __init__(self, attrs: Mapping[str, object]) -> None:
_incl = 'dependency_links', 'setup_requires'
filtered = {k: attrs[k] for k in set(_incl) & set(attrs)}
super().__init__(filtered)
# Prevent accidentally triggering discovery with incomplete set of attrs
self.set_defaults._disable()

def _get_project_config_files(self, filenames=None):
"""Ignore ``pyproject.toml``, they are not related to setup_requires"""
try:
cfg, _toml = super()._split_standard_project_metadata(filenames)
except Exception:
return filenames, ()
return cfg, ()

def finalize_options(self):
"""
Disable finalize_options to avoid building the working set.
Ref #2158.
"""

dist = MinimalDistribution(attrs)

# Honor setup.cfg's options.
dist.parse_config_files(ignore_option_errors=True)
def _install_setup_requires(attrs: dict[str, Any]) -> None:
dist = _expand_setupcfg(attrs)
if dist.setup_requires:
_fetch_build_eggs(dist)

Expand All @@ -101,17 +80,21 @@ def _fetch_build_eggs(dist: Distribution):
please contact that package's maintainers or distributors.
"""
if "InvalidVersion" in ex.__class__.__name__:
if hasattr(ex, "add_note"):
ex.add_note(msg) # PEP 678
else:
dist.announce(f"\n{msg}\n")
py310.add_note(ex, msg)
raise


def setup(**attrs):
if "--private-interrupt-setuppy" in sys.argv:
raise _SetupPyInterruption(_expand_setupcfg(attrs))

logging.configure()
# Make sure we have any requirements needed to interpret 'attrs'.
_install_setup_requires(attrs)

if "--private-skip-setup-requires" in sys.argv:
sys.argv.remove("--private-skip-setup-requires")
else:
# Make sure we have any requirements needed to interpret 'attrs'.
_install_setup_requires(attrs)
return distutils.core.setup(**attrs)


Expand Down Expand Up @@ -244,5 +227,10 @@ class sic(str):
"""Treat this string as-is (https://en.wikipedia.org/wiki/Sic)"""


class _SetupPyInterruption(Exception):
def __init__(self, dist: Distribution):
self.dist = dist


# Apply monkey patches
monkey.patch_all()
86 changes: 28 additions & 58 deletions setuptools/build_meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@
from ._reqs import parse_strings
from .warnings import SetuptoolsDeprecationWarning

import distutils
from distutils.util import strtobool

if TYPE_CHECKING:
Expand All @@ -64,53 +63,9 @@
'prepare_metadata_for_build_editable',
'build_editable',
'__legacy__',
'SetupRequirementsError',
]


class SetupRequirementsError(BaseException):
def __init__(self, specifiers) -> None:
self.specifiers = specifiers


class Distribution(setuptools.dist.Distribution):
def fetch_build_eggs(self, specifiers):
specifier_list = list(parse_strings(specifiers))

raise SetupRequirementsError(specifier_list)

@classmethod
@contextlib.contextmanager
def patch(cls):
"""
Replace
distutils.dist.Distribution with this class
for the duration of this context.
"""
orig = distutils.core.Distribution
distutils.core.Distribution = cls # type: ignore[misc] # monkeypatching
try:
yield
finally:
distutils.core.Distribution = orig # type: ignore[misc] # monkeypatching


@contextlib.contextmanager
def no_install_setup_requires():
"""Temporarily disable installing setup_requires

Under PEP 517, the backend reports build dependencies to the frontend,
and the frontend is responsible for ensuring they're installed.
So setuptools (acting as a backend) should not try to install them.
"""
orig = setuptools._install_setup_requires
setuptools._install_setup_requires = lambda attrs: None
try:
yield
finally:
setuptools._install_setup_requires = orig


def _get_immediate_subdirectories(a_dir):
return [
name for name in os.listdir(a_dir) if os.path.isdir(os.path.join(a_dir, name))
Expand Down Expand Up @@ -291,16 +246,14 @@ class _BuildMetaBackend(_ConfigSettingsTranslator):
def _get_build_requires(
self, config_settings: _ConfigSettings, requirements: list[str]
):
sys.argv = [
*sys.argv[:1],
*self._global_args(config_settings),
"egg_info",
]
sys.argv = [*sys.argv[:1], "--private-interrupt-setuppy"]
try:
with Distribution.patch():
self.run_setup()
except SetupRequirementsError as e:
requirements += e.specifiers
self.run_setup()
except setuptools._SetupPyInterruption as ex:
setup_requires = parse_strings(ex.dist.setup_requires or "")
return requirements + list(setup_requires)
except Exception:
pass # Ignore other arbitrary exceptions from setup.py, e.g. SystemExit

return requirements

Expand All @@ -313,6 +266,8 @@ def run_setup(self, setup_script: str = 'setup.py'):
with _open_setup_script(__file__) as f:
code = f.read().replace(r'\r\n', r'\n')

sys.argv.append("--private-skip-setup-requires")

try:
exec(code, locals())
except SystemExit as e:
Expand Down Expand Up @@ -370,8 +325,7 @@ def prepare_metadata_for_build_wheel(
str(metadata_directory),
"--keep-egg-info",
]
with no_install_setup_requires():
self.run_setup()
self.run_setup()

self._bubble_up_info_directory(metadata_directory, ".egg-info")
return self._bubble_up_info_directory(metadata_directory, ".dist-info")
Expand Down Expand Up @@ -400,8 +354,7 @@ def _build_with_temp_dir(
tmp_dist_dir,
*arbitrary_args,
]
with no_install_setup_requires():
self.run_setup()
self.run_setup()

result_basename = _file_with_extension(tmp_dist_dir, result_extension)
result_path = os.path.join(result_directory, result_basename)
Expand Down Expand Up @@ -546,3 +499,20 @@ class _IncompatibleBdistWheel(SetuptoolsDeprecationWarning):

# The legacy backend
__legacy__ = _BuildMetaLegacyBackend()


def __getattr__(name):
if name == "SetupRequirementsError":
SetuptoolsDeprecationWarning.emit(
"SetupRequirementsError is no longer part of the public API.",
"Please do not import SetupRequirementsError.",
due_date=(2026, 5, 12),
)

class SetupRequirementsError(BaseException):
def __init__(self, specifiers) -> None:
self.specifiers = specifiers

return SetupRequirementsError

raise AttributeError(name)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

For some reason SetupRequirementsError has been listed in __all__, so this __getattr__ and deprecation warning are here just to be on the safe side (and be very conservative regarding breaking changes).

Copy link
Contributor

Choose a reason for hiding this comment

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

Hi, I'm trying to use SetupRequirementsError to add dynamic build requirements because https://setuptools.pypa.io/en/stable/build_meta.html#dynamic-build-dependencies-and-other-build-meta-tweaks does not work in some cases due to setuptools invoking setup.py during get_requires_for_build_wheel.

If this gets removed, what's the recommended alternative?

Loading