codekingpro/portable-devtools
114k
1"""A PEP 517 interface to setuptools2 3Previously, when a user or a command line tool (let's call it a "frontend")4needed to make a request of setuptools to take a certain action, for5example, generating a list of installation requirements, the frontend6would call "setup.py egg_info" or "setup.py bdist_wheel" on the command line.7 8PEP 517 defines a different method of interfacing with setuptools. Rather9than calling "setup.py" directly, the frontend should:10 11 1. Set the current directory to the directory with a setup.py file12 2. Import this module into a safe python interpreter (one in which13 setuptools can potentially set global variables or crash hard).14 3. Call one of the functions defined in PEP 517.15 16What each function does is defined in PEP 517. However, here is a "casual"17definition of the functions (this definition should not be relied on for18bug reports or API stability):19 20 - `build_wheel`: build a wheel in the folder and return the basename21 - `get_requires_for_build_wheel`: get the `setup_requires` to build22 - `prepare_metadata_for_build_wheel`: get the `install_requires`23 - `build_sdist`: build an sdist in the folder and return the basename24 - `get_requires_for_build_sdist`: get the `setup_requires` to build25 26Again, this is not a formal definition! Just a "taste" of the module.27"""28 29from __future__ import annotations30 31import contextlib32import io33import os34import shlex35import shutil36import sys37import tempfile38import tokenize39import warnings40from collections.abc import Iterable, Iterator, Mapping41from pathlib import Path42from typing import TYPE_CHECKING, NoReturn, Union43 44import setuptools45 46from . import errors47from ._path import StrPath, same_path48from ._reqs import parse_strings49from .warnings import SetuptoolsDeprecationWarning50 51import distutils52from distutils.util import strtobool53 54if TYPE_CHECKING:55 from typing_extensions import TypeAlias56 57__all__ = [58 'get_requires_for_build_sdist',59 'get_requires_for_build_wheel',60 'prepare_metadata_for_build_wheel',61 'build_wheel',62 'build_sdist',63 'get_requires_for_build_editable',64 'prepare_metadata_for_build_editable',65 'build_editable',66 '__legacy__',67 'SetupRequirementsError',68]69 70 71class SetupRequirementsError(BaseException):72 def __init__(self, specifiers) -> None:73 self.specifiers = specifiers74 75 76class Distribution(setuptools.dist.Distribution):77 def fetch_build_eggs(self, specifiers) -> NoReturn:78 specifier_list = list(parse_strings(specifiers))79 80 raise SetupRequirementsError(specifier_list)81 82 @classmethod83 @contextlib.contextmanager84 def patch(cls) -> Iterator[None]:85 """86 Replace87 distutils.dist.Distribution with this class88 for the duration of this context.89 """90 orig = distutils.core.Distribution91 distutils.core.Distribution = cls # type: ignore[misc] # monkeypatching92 try:93 yield94 finally:95 distutils.core.Distribution = orig # type: ignore[misc] # monkeypatching96 97 98@contextlib.contextmanager99def no_install_setup_requires():100 """Temporarily disable installing setup_requires101 102 Under PEP 517, the backend reports build dependencies to the frontend,103 and the frontend is responsible for ensuring they're installed.104 So setuptools (acting as a backend) should not try to install them.105 """106 orig = setuptools._install_setup_requires107 setuptools._install_setup_requires = lambda attrs: None108 try:109 yield110 finally:111 setuptools._install_setup_requires = orig112 113 114def _get_immediate_subdirectories(a_dir):115 return [116 name for name in os.listdir(a_dir) if os.path.isdir(os.path.join(a_dir, name))117 ]118 119 120def _file_with_extension(directory: StrPath, extension: str | tuple[str, ...]):121 matching = (f for f in os.listdir(directory) if f.endswith(extension))122 try:123 (file,) = matching124 except ValueError:125 raise ValueError(126 'No distribution was found. Ensure that `setup.py` '127 'is not empty and that it calls `setup()`.'128 ) from None129 return file130 131 132def _open_setup_script(setup_script):133 if not os.path.exists(setup_script):134 # Supply a default setup.py135 return io.StringIO("from setuptools import setup; setup()")136 137 return tokenize.open(setup_script)138 139 140@contextlib.contextmanager141def suppress_known_deprecation():142 with warnings.catch_warnings():143 warnings.filterwarnings('ignore', 'setup.py install is deprecated')144 yield145 146 147_ConfigSettings: TypeAlias = Union[Mapping[str, Union[str, list[str], None]], None]148"""149Currently the user can run::150 151 pip install -e . --config-settings key=value152 python -m build -C--key=value -C key=value153 154- pip will pass both key and value as strings and overwriting repeated keys155 (pypa/pip#11059).156- build will accumulate values associated with repeated keys in a list.157 It will also accept keys with no associated value.158 This means that an option passed by build can be ``str | list[str] | None``.159- PEP 517 specifies that ``config_settings`` is an optional dict.160"""161 162 163class _ConfigSettingsTranslator:164 """Translate ``config_settings`` into distutils-style command arguments.165 Only a limited number of options is currently supported.166 """167 168 # See pypa/setuptools#1928 pypa/setuptools#2491169 170 def _get_config(self, key: str, config_settings: _ConfigSettings) -> list[str]:171 """172 Get the value of a specific key in ``config_settings`` as a list of strings.173 174 >>> fn = _ConfigSettingsTranslator()._get_config175 >>> fn("--global-option", None)176 []177 >>> fn("--global-option", {})178 []179 >>> fn("--global-option", {'--global-option': 'foo'})180 ['foo']181 >>> fn("--global-option", {'--global-option': ['foo']})182 ['foo']183 >>> fn("--global-option", {'--global-option': 'foo'})184 ['foo']185 >>> fn("--global-option", {'--global-option': 'foo bar'})186 ['foo', 'bar']187 """188 cfg = config_settings or {}189 opts = cfg.get(key) or []190 return shlex.split(opts) if isinstance(opts, str) else opts191 192 def _global_args(self, config_settings: _ConfigSettings) -> Iterator[str]:193 """194 Let the user specify ``verbose`` or ``quiet`` + escape hatch via195 ``--global-option``.196 Note: ``-v``, ``-vv``, ``-vvv`` have similar effects in setuptools,197 so we just have to cover the basic scenario ``-v``.198 199 >>> fn = _ConfigSettingsTranslator()._global_args200 >>> list(fn(None))201 []202 >>> list(fn({"verbose": "False"}))203 ['-q']204 >>> list(fn({"verbose": "1"}))205 ['-v']206 >>> list(fn({"--verbose": None}))207 ['-v']208 >>> list(fn({"verbose": "true", "--global-option": "-q --no-user-cfg"}))209 ['-v', '-q', '--no-user-cfg']210 >>> list(fn({"--quiet": None}))211 ['-q']212 """213 cfg = config_settings or {}214 falsey = {"false", "no", "0", "off"}215 if "verbose" in cfg or "--verbose" in cfg:216 level = str(cfg.get("verbose") or cfg.get("--verbose") or "1")217 yield ("-q" if level.lower() in falsey else "-v")218 if "quiet" in cfg or "--quiet" in cfg:219 level = str(cfg.get("quiet") or cfg.get("--quiet") or "1")220 yield ("-v" if level.lower() in falsey else "-q")221 222 yield from self._get_config("--global-option", config_settings)223 224 def __dist_info_args(self, config_settings: _ConfigSettings) -> Iterator[str]:225 """226 The ``dist_info`` command accepts ``tag-date`` and ``tag-build``.227 228 .. warning::229 We cannot use this yet as it requires the ``sdist`` and ``bdist_wheel``230 commands run in ``build_sdist`` and ``build_wheel`` to reuse the egg-info231 directory created in ``prepare_metadata_for_build_wheel``.232 233 >>> fn = _ConfigSettingsTranslator()._ConfigSettingsTranslator__dist_info_args234 >>> list(fn(None))235 []236 >>> list(fn({"tag-date": "False"}))237 ['--no-date']238 >>> list(fn({"tag-date": None}))239 ['--no-date']240 >>> list(fn({"tag-date": "true", "tag-build": ".a"}))241 ['--tag-date', '--tag-build', '.a']242 """243 cfg = config_settings or {}244 if "tag-date" in cfg:245 val = strtobool(str(cfg["tag-date"] or "false"))246 yield ("--tag-date" if val else "--no-date")247 if "tag-build" in cfg:248 yield from ["--tag-build", str(cfg["tag-build"])]249 250 def _editable_args(self, config_settings: _ConfigSettings) -> Iterator[str]:251 """252 The ``editable_wheel`` command accepts ``editable-mode=strict``.253 254 >>> fn = _ConfigSettingsTranslator()._editable_args255 >>> list(fn(None))256 []257 >>> list(fn({"editable-mode": "strict"}))258 ['--mode', 'strict']259 """260 cfg = config_settings or {}261 mode = cfg.get("editable-mode") or cfg.get("editable_mode")262 if not mode:263 return264 yield from ["--mode", str(mode)]265 266 def _arbitrary_args(self, config_settings: _ConfigSettings) -> Iterator[str]:267 """268 Users may expect to pass arbitrary lists of arguments to a command269 via "--global-option" (example provided in PEP 517 of a "escape hatch").270 271 >>> fn = _ConfigSettingsTranslator()._arbitrary_args272 >>> list(fn(None))273 []274 >>> list(fn({}))275 []276 >>> list(fn({'--build-option': 'foo'}))277 ['foo']278 >>> list(fn({'--build-option': ['foo']}))279 ['foo']280 >>> list(fn({'--build-option': 'foo'}))281 ['foo']282 >>> list(fn({'--build-option': 'foo bar'}))283 ['foo', 'bar']284 >>> list(fn({'--global-option': 'foo'}))285 []286 """287 yield from self._get_config("--build-option", config_settings)288 289 290class _BuildMetaBackend(_ConfigSettingsTranslator):291 def _get_build_requires(292 self, config_settings: _ConfigSettings, requirements: list[str]293 ):294 sys.argv = [295 *sys.argv[:1],296 *self._global_args(config_settings),297 "egg_info",298 ]299 try:300 with Distribution.patch():301 self.run_setup()302 except SetupRequirementsError as e:303 requirements += e.specifiers304 305 return requirements306 307 def run_setup(self, setup_script: str = 'setup.py') -> None:308 # Note that we can reuse our build directory between calls309 # Correctness comes first, then optimization later310 __file__ = os.path.abspath(setup_script)311 __name__ = '__main__'312 313 with _open_setup_script(__file__) as f:314 code = f.read().replace(r'\r\n', r'\n')315 316 try:317 exec(code, locals())318 except SystemExit as e:319 if e.code:320 raise321 # We ignore exit code indicating success322 SetuptoolsDeprecationWarning.emit(323 "Running `setup.py` directly as CLI tool is deprecated.",324 "Please avoid using `sys.exit(0)` or similar statements "325 "that don't fit in the paradigm of a configuration file.",326 see_url="https://blog.ganssle.io/articles/2021/10/"327 "setup-py-deprecated.html",328 )329 330 def get_requires_for_build_wheel(331 self, config_settings: _ConfigSettings = None332 ) -> list[str]:333 return self._get_build_requires(config_settings, requirements=[])334 335 def get_requires_for_build_sdist(336 self, config_settings: _ConfigSettings = None337 ) -> list[str]:338 return self._get_build_requires(config_settings, requirements=[])339 340 def _bubble_up_info_directory(341 self, metadata_directory: StrPath, suffix: str342 ) -> str:343 """344 PEP 517 requires that the .dist-info directory be placed in the345 metadata_directory. To comply, we MUST copy the directory to the root.346 347 Returns the basename of the info directory, e.g. `proj-0.0.0.dist-info`.348 """349 info_dir = self._find_info_directory(metadata_directory, suffix)350 if not same_path(info_dir.parent, metadata_directory):351 shutil.move(str(info_dir), metadata_directory)352 # PEP 517 allow other files and dirs to exist in metadata_directory353 return info_dir.name354 355 def _find_info_directory(self, metadata_directory: StrPath, suffix: str) -> Path:356 for parent, dirs, _ in os.walk(metadata_directory):357 candidates = [f for f in dirs if f.endswith(suffix)]358 359 if len(candidates) != 0 or len(dirs) != 1:360 assert len(candidates) == 1, (361 f"Exactly one {suffix} should have been produced, but found {len(candidates)}: {candidates}"362 )363 return Path(parent, candidates[0])364 365 msg = f"No {suffix} directory found in {metadata_directory}"366 raise errors.InternalError(msg)367 368 def prepare_metadata_for_build_wheel(369 self, metadata_directory: StrPath, config_settings: _ConfigSettings = None370 ) -> str:371 sys.argv = [372 *sys.argv[:1],373 *self._global_args(config_settings),374 "dist_info",375 "--output-dir",376 str(metadata_directory),377 "--keep-egg-info",378 ]379 with no_install_setup_requires():380 self.run_setup()381 382 self._bubble_up_info_directory(metadata_directory, ".egg-info")383 return self._bubble_up_info_directory(metadata_directory, ".dist-info")384 385 def _build_with_temp_dir(386 self,387 setup_command: Iterable[str],388 result_extension: str | tuple[str, ...],389 result_directory: StrPath,390 config_settings: _ConfigSettings,391 arbitrary_args: Iterable[str] = (),392 ):393 result_directory = os.path.abspath(result_directory)394 395 # Build in a temporary directory, then copy to the target.396 os.makedirs(result_directory, exist_ok=True)397 398 with tempfile.TemporaryDirectory(399 prefix=".tmp-", dir=result_directory400 ) as tmp_dist_dir:401 sys.argv = [402 *sys.argv[:1],403 *self._global_args(config_settings),404 *setup_command,405 "--dist-dir",406 tmp_dist_dir,407 *arbitrary_args,408 ]409 with no_install_setup_requires():410 self.run_setup()411 412 result_basename = _file_with_extension(tmp_dist_dir, result_extension)413 result_path = os.path.join(result_directory, result_basename)414 if os.path.exists(result_path):415 # os.rename will fail overwriting on non-Unix.416 os.remove(result_path)417 os.rename(os.path.join(tmp_dist_dir, result_basename), result_path)418 419 return result_basename420 421 def build_wheel(422 self,423 wheel_directory: StrPath,424 config_settings: _ConfigSettings = None,425 metadata_directory: StrPath | None = None,426 ) -> str:427 def _build(cmd: list[str]):428 with suppress_known_deprecation():429 return self._build_with_temp_dir(430 cmd,431 '.whl',432 wheel_directory,433 config_settings,434 self._arbitrary_args(config_settings),435 )436 437 if metadata_directory is None:438 return _build(['bdist_wheel'])439 440 try:441 return _build(['bdist_wheel', '--dist-info-dir', str(metadata_directory)])442 except SystemExit as ex: # pragma: nocover443 # pypa/setuptools#4683444 if "--dist-info-dir not recognized" not in str(ex):445 raise446 _IncompatibleBdistWheel.emit()447 return _build(['bdist_wheel'])448 449 def build_sdist(450 self, sdist_directory: StrPath, config_settings: _ConfigSettings = None451 ) -> str:452 return self._build_with_temp_dir(453 ['sdist', '--formats', 'gztar'], '.tar.gz', sdist_directory, config_settings454 )455 456 def _get_dist_info_dir(self, metadata_directory: StrPath | None) -> str | None:457 if not metadata_directory:458 return None459 dist_info_candidates = list(Path(metadata_directory).glob("*.dist-info"))460 assert len(dist_info_candidates) <= 1461 return str(dist_info_candidates[0]) if dist_info_candidates else None462 463 def build_editable(464 self,465 wheel_directory: StrPath,466 config_settings: _ConfigSettings = None,467 metadata_directory: StrPath | None = None,468 ) -> str:469 # XXX can or should we hide our editable_wheel command normally?470 info_dir = self._get_dist_info_dir(metadata_directory)471 opts = ["--dist-info-dir", info_dir] if info_dir else []472 cmd = ["editable_wheel", *opts, *self._editable_args(config_settings)]473 with suppress_known_deprecation():474 return self._build_with_temp_dir(475 cmd, ".whl", wheel_directory, config_settings476 )477 478 def get_requires_for_build_editable(479 self, config_settings: _ConfigSettings = None480 ) -> list[str]:481 return self.get_requires_for_build_wheel(config_settings)482 483 def prepare_metadata_for_build_editable(484 self, metadata_directory: StrPath, config_settings: _ConfigSettings = None485 ) -> str:486 return self.prepare_metadata_for_build_wheel(487 metadata_directory, config_settings488 )489 490 491class _BuildMetaLegacyBackend(_BuildMetaBackend):492 """Compatibility backend for setuptools493 494 This is a version of setuptools.build_meta that endeavors495 to maintain backwards496 compatibility with pre-PEP 517 modes of invocation. It497 exists as a temporary498 bridge between the old packaging mechanism and the new499 packaging mechanism,500 and will eventually be removed.501 """502 503 def run_setup(self, setup_script: str = 'setup.py') -> None:504 # In order to maintain compatibility with scripts assuming that505 # the setup.py script is in a directory on the PYTHONPATH, inject506 # '' into sys.path. (pypa/setuptools#1642)507 sys_path = list(sys.path) # Save the original path508 509 script_dir = os.path.dirname(os.path.abspath(setup_script))510 if script_dir not in sys.path:511 sys.path.insert(0, script_dir)512 513 # Some setup.py scripts (e.g. in pygame and numpy) use sys.argv[0] to514 # get the directory of the source code. They expect it to refer to the515 # setup.py script.516 sys_argv_0 = sys.argv[0]517 sys.argv[0] = setup_script518 519 try:520 super().run_setup(setup_script=setup_script)521 finally:522 # While PEP 517 frontends should be calling each hook in a fresh523 # subprocess according to the standard (and thus it should not be524 # strictly necessary to restore the old sys.path), we'll restore525 # the original path so that the path manipulation does not persist526 # within the hook after run_setup is called.527 sys.path[:] = sys_path528 sys.argv[0] = sys_argv_0529 530 531class _IncompatibleBdistWheel(SetuptoolsDeprecationWarning):532 _SUMMARY = "wheel.bdist_wheel is deprecated, please import it from setuptools"533 _DETAILS = """534 Ensure that any custom bdist_wheel implementation is a subclass of535 setuptools.command.bdist_wheel.bdist_wheel.536 """537 _DUE_DATE = (2025, 10, 15)538 # Initially introduced in 2024/10/15, but maybe too disruptive to be enforced?539 _SEE_URL = "https://github.com/pypa/wheel/pull/631"540 541 542# The primary backend543_BACKEND = _BuildMetaBackend()544 545get_requires_for_build_wheel = _BACKEND.get_requires_for_build_wheel546get_requires_for_build_sdist = _BACKEND.get_requires_for_build_sdist547prepare_metadata_for_build_wheel = _BACKEND.prepare_metadata_for_build_wheel548build_wheel = _BACKEND.build_wheel549build_sdist = _BACKEND.build_sdist550get_requires_for_build_editable = _BACKEND.get_requires_for_build_editable551prepare_metadata_for_build_editable = _BACKEND.prepare_metadata_for_build_editable552build_editable = _BACKEND.build_editable553 554 555# The legacy backend556__legacy__ = _BuildMetaLegacyBackend()557 