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 frontend would6would 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 29import io30import os31import shlex32import sys33import tokenize34import shutil35import contextlib36import tempfile37import warnings38from pathlib import Path39from typing import Dict, Iterator, List, Optional, Union40 41import setuptools42import distutils43from . import errors44from ._path import same_path45from ._reqs import parse_strings46from .warnings import SetuptoolsDeprecationWarning47from distutils.util import strtobool48 49 50__all__ = [51 'get_requires_for_build_sdist',52 'get_requires_for_build_wheel',53 'prepare_metadata_for_build_wheel',54 'build_wheel',55 'build_sdist',56 'get_requires_for_build_editable',57 'prepare_metadata_for_build_editable',58 'build_editable',59 '__legacy__',60 'SetupRequirementsError',61]62 63SETUPTOOLS_ENABLE_FEATURES = os.getenv("SETUPTOOLS_ENABLE_FEATURES", "").lower()64LEGACY_EDITABLE = "legacy-editable" in SETUPTOOLS_ENABLE_FEATURES.replace("_", "-")65 66 67class SetupRequirementsError(BaseException):68 def __init__(self, specifiers):69 self.specifiers = specifiers70 71 72class Distribution(setuptools.dist.Distribution):73 def fetch_build_eggs(self, specifiers):74 specifier_list = list(parse_strings(specifiers))75 76 raise SetupRequirementsError(specifier_list)77 78 @classmethod79 @contextlib.contextmanager80 def patch(cls):81 """82 Replace83 distutils.dist.Distribution with this class84 for the duration of this context.85 """86 orig = distutils.core.Distribution87 distutils.core.Distribution = cls88 try:89 yield90 finally:91 distutils.core.Distribution = orig92 93 94@contextlib.contextmanager95def no_install_setup_requires():96 """Temporarily disable installing setup_requires97 98 Under PEP 517, the backend reports build dependencies to the frontend,99 and the frontend is responsible for ensuring they're installed.100 So setuptools (acting as a backend) should not try to install them.101 """102 orig = setuptools._install_setup_requires103 setuptools._install_setup_requires = lambda attrs: None104 try:105 yield106 finally:107 setuptools._install_setup_requires = orig108 109 110def _get_immediate_subdirectories(a_dir):111 return [112 name for name in os.listdir(a_dir) if os.path.isdir(os.path.join(a_dir, name))113 ]114 115 116def _file_with_extension(directory, extension):117 matching = (f for f in os.listdir(directory) if f.endswith(extension))118 try:119 (file,) = matching120 except ValueError:121 raise ValueError(122 'No distribution was found. Ensure that `setup.py` '123 'is not empty and that it calls `setup()`.'124 ) from None125 return file126 127 128def _open_setup_script(setup_script):129 if not os.path.exists(setup_script):130 # Supply a default setup.py131 return io.StringIO("from setuptools import setup; setup()")132 133 return tokenize.open(setup_script)134 135 136@contextlib.contextmanager137def suppress_known_deprecation():138 with warnings.catch_warnings():139 warnings.filterwarnings('ignore', 'setup.py install is deprecated')140 yield141 142 143_ConfigSettings = Optional[Dict[str, Union[str, List[str], None]]]144"""145Currently the user can run::146 147 pip install -e . --config-settings key=value148 python -m build -C--key=value -C key=value149 150- pip will pass both key and value as strings and overwriting repeated keys151 (pypa/pip#11059).152- build will accumulate values associated with repeated keys in a list.153 It will also accept keys with no associated value.154 This means that an option passed by build can be ``str | list[str] | None``.155- PEP 517 specifies that ``config_settings`` is an optional dict.156"""157 158 159class _ConfigSettingsTranslator:160 """Translate ``config_settings`` into distutils-style command arguments.161 Only a limited number of options is currently supported.162 """163 164 # See pypa/setuptools#1928 pypa/setuptools#2491165 166 def _get_config(self, key: str, config_settings: _ConfigSettings) -> List[str]:167 """168 Get the value of a specific key in ``config_settings`` as a list of strings.169 170 >>> fn = _ConfigSettingsTranslator()._get_config171 >>> fn("--global-option", None)172 []173 >>> fn("--global-option", {})174 []175 >>> fn("--global-option", {'--global-option': 'foo'})176 ['foo']177 >>> fn("--global-option", {'--global-option': ['foo']})178 ['foo']179 >>> fn("--global-option", {'--global-option': 'foo'})180 ['foo']181 >>> fn("--global-option", {'--global-option': 'foo bar'})182 ['foo', 'bar']183 """184 cfg = config_settings or {}185 opts = cfg.get(key) or []186 return shlex.split(opts) if isinstance(opts, str) else opts187 188 def _global_args(self, config_settings: _ConfigSettings) -> Iterator[str]:189 """190 Let the user specify ``verbose`` or ``quiet`` + escape hatch via191 ``--global-option``.192 Note: ``-v``, ``-vv``, ``-vvv`` have similar effects in setuptools,193 so we just have to cover the basic scenario ``-v``.194 195 >>> fn = _ConfigSettingsTranslator()._global_args196 >>> list(fn(None))197 []198 >>> list(fn({"verbose": "False"}))199 ['-q']200 >>> list(fn({"verbose": "1"}))201 ['-v']202 >>> list(fn({"--verbose": None}))203 ['-v']204 >>> list(fn({"verbose": "true", "--global-option": "-q --no-user-cfg"}))205 ['-v', '-q', '--no-user-cfg']206 >>> list(fn({"--quiet": None}))207 ['-q']208 """209 cfg = config_settings or {}210 falsey = {"false", "no", "0", "off"}211 if "verbose" in cfg or "--verbose" in cfg:212 level = str(cfg.get("verbose") or cfg.get("--verbose") or "1")213 yield ("-q" if level.lower() in falsey else "-v")214 if "quiet" in cfg or "--quiet" in cfg:215 level = str(cfg.get("quiet") or cfg.get("--quiet") or "1")216 yield ("-v" if level.lower() in falsey else "-q")217 218 yield from self._get_config("--global-option", config_settings)219 220 def __dist_info_args(self, config_settings: _ConfigSettings) -> Iterator[str]:221 """222 The ``dist_info`` command accepts ``tag-date`` and ``tag-build``.223 224 .. warning::225 We cannot use this yet as it requires the ``sdist`` and ``bdist_wheel``226 commands run in ``build_sdist`` and ``build_wheel`` to reuse the egg-info227 directory created in ``prepare_metadata_for_build_wheel``.228 229 >>> fn = _ConfigSettingsTranslator()._ConfigSettingsTranslator__dist_info_args230 >>> list(fn(None))231 []232 >>> list(fn({"tag-date": "False"}))233 ['--no-date']234 >>> list(fn({"tag-date": None}))235 ['--no-date']236 >>> list(fn({"tag-date": "true", "tag-build": ".a"}))237 ['--tag-date', '--tag-build', '.a']238 """239 cfg = config_settings or {}240 if "tag-date" in cfg:241 val = strtobool(str(cfg["tag-date"] or "false"))242 yield ("--tag-date" if val else "--no-date")243 if "tag-build" in cfg:244 yield from ["--tag-build", str(cfg["tag-build"])]245 246 def _editable_args(self, config_settings: _ConfigSettings) -> Iterator[str]:247 """248 The ``editable_wheel`` command accepts ``editable-mode=strict``.249 250 >>> fn = _ConfigSettingsTranslator()._editable_args251 >>> list(fn(None))252 []253 >>> list(fn({"editable-mode": "strict"}))254 ['--mode', 'strict']255 """256 cfg = config_settings or {}257 mode = cfg.get("editable-mode") or cfg.get("editable_mode")258 if not mode:259 return260 yield from ["--mode", str(mode)]261 262 def _arbitrary_args(self, config_settings: _ConfigSettings) -> Iterator[str]:263 """264 Users may expect to pass arbitrary lists of arguments to a command265 via "--global-option" (example provided in PEP 517 of a "escape hatch").266 267 >>> fn = _ConfigSettingsTranslator()._arbitrary_args268 >>> list(fn(None))269 []270 >>> list(fn({}))271 []272 >>> list(fn({'--build-option': 'foo'}))273 ['foo']274 >>> list(fn({'--build-option': ['foo']}))275 ['foo']276 >>> list(fn({'--build-option': 'foo'}))277 ['foo']278 >>> list(fn({'--build-option': 'foo bar'}))279 ['foo', 'bar']280 >>> list(fn({'--global-option': 'foo'}))281 []282 """283 yield from self._get_config("--build-option", config_settings)284 285 286class _BuildMetaBackend(_ConfigSettingsTranslator):287 def _get_build_requires(self, config_settings, requirements):288 sys.argv = [289 *sys.argv[:1],290 *self._global_args(config_settings),291 "egg_info",292 ]293 try:294 with Distribution.patch():295 self.run_setup()296 except SetupRequirementsError as e:297 requirements += e.specifiers298 299 return requirements300 301 def run_setup(self, setup_script='setup.py'):302 # Note that we can reuse our build directory between calls303 # Correctness comes first, then optimization later304 __file__ = os.path.abspath(setup_script)305 __name__ = '__main__'306 307 with _open_setup_script(__file__) as f:308 code = f.read().replace(r'\r\n', r'\n')309 310 try:311 exec(code, locals())312 except SystemExit as e:313 if e.code:314 raise315 # We ignore exit code indicating success316 SetuptoolsDeprecationWarning.emit(317 "Running `setup.py` directly as CLI tool is deprecated.",318 "Please avoid using `sys.exit(0)` or similar statements "319 "that don't fit in the paradigm of a configuration file.",320 see_url="https://blog.ganssle.io/articles/2021/10/"321 "setup-py-deprecated.html",322 )323 324 def get_requires_for_build_wheel(self, config_settings=None):325 return self._get_build_requires(config_settings, requirements=['wheel'])326 327 def get_requires_for_build_sdist(self, config_settings=None):328 return self._get_build_requires(config_settings, requirements=[])329 330 def _bubble_up_info_directory(self, metadata_directory: str, suffix: str) -> str:331 """332 PEP 517 requires that the .dist-info directory be placed in the333 metadata_directory. To comply, we MUST copy the directory to the root.334 335 Returns the basename of the info directory, e.g. `proj-0.0.0.dist-info`.336 """337 info_dir = self._find_info_directory(metadata_directory, suffix)338 if not same_path(info_dir.parent, metadata_directory):339 shutil.move(str(info_dir), metadata_directory)340 # PEP 517 allow other files and dirs to exist in metadata_directory341 return info_dir.name342 343 def _find_info_directory(self, metadata_directory: str, suffix: str) -> Path:344 for parent, dirs, _ in os.walk(metadata_directory):345 candidates = [f for f in dirs if f.endswith(suffix)]346 347 if len(candidates) != 0 or len(dirs) != 1:348 assert len(candidates) == 1, f"Multiple {suffix} directories found"349 return Path(parent, candidates[0])350 351 msg = f"No {suffix} directory found in {metadata_directory}"352 raise errors.InternalError(msg)353 354 def prepare_metadata_for_build_wheel(355 self, metadata_directory, config_settings=None356 ):357 sys.argv = [358 *sys.argv[:1],359 *self._global_args(config_settings),360 "dist_info",361 "--output-dir",362 metadata_directory,363 "--keep-egg-info",364 ]365 with no_install_setup_requires():366 self.run_setup()367 368 self._bubble_up_info_directory(metadata_directory, ".egg-info")369 return self._bubble_up_info_directory(metadata_directory, ".dist-info")370 371 def _build_with_temp_dir(372 self,373 setup_command,374 result_extension,375 result_directory,376 config_settings,377 arbitrary_args=(),378 ):379 result_directory = os.path.abspath(result_directory)380 381 # Build in a temporary directory, then copy to the target.382 os.makedirs(result_directory, exist_ok=True)383 temp_opts = {"prefix": ".tmp-", "dir": result_directory}384 385 with tempfile.TemporaryDirectory(**temp_opts) as tmp_dist_dir:386 sys.argv = [387 *sys.argv[:1],388 *self._global_args(config_settings),389 *setup_command,390 "--dist-dir",391 tmp_dist_dir,392 *arbitrary_args,393 ]394 with no_install_setup_requires():395 self.run_setup()396 397 result_basename = _file_with_extension(tmp_dist_dir, result_extension)398 result_path = os.path.join(result_directory, result_basename)399 if os.path.exists(result_path):400 # os.rename will fail overwriting on non-Unix.401 os.remove(result_path)402 os.rename(os.path.join(tmp_dist_dir, result_basename), result_path)403 404 return result_basename405 406 def build_wheel(407 self, wheel_directory, config_settings=None, metadata_directory=None408 ):409 with suppress_known_deprecation():410 return self._build_with_temp_dir(411 ['bdist_wheel'],412 '.whl',413 wheel_directory,414 config_settings,415 self._arbitrary_args(config_settings),416 )417 418 def build_sdist(self, sdist_directory, config_settings=None):419 return self._build_with_temp_dir(420 ['sdist', '--formats', 'gztar'], '.tar.gz', sdist_directory, config_settings421 )422 423 def _get_dist_info_dir(self, metadata_directory: Optional[str]) -> Optional[str]:424 if not metadata_directory:425 return None426 dist_info_candidates = list(Path(metadata_directory).glob("*.dist-info"))427 assert len(dist_info_candidates) <= 1428 return str(dist_info_candidates[0]) if dist_info_candidates else None429 430 if not LEGACY_EDITABLE:431 # PEP660 hooks:432 # build_editable433 # get_requires_for_build_editable434 # prepare_metadata_for_build_editable435 def build_editable(436 self, wheel_directory, config_settings=None, metadata_directory=None437 ):438 # XXX can or should we hide our editable_wheel command normally?439 info_dir = self._get_dist_info_dir(metadata_directory)440 opts = ["--dist-info-dir", info_dir] if info_dir else []441 cmd = ["editable_wheel", *opts, *self._editable_args(config_settings)]442 with suppress_known_deprecation():443 return self._build_with_temp_dir(444 cmd, ".whl", wheel_directory, config_settings445 )446 447 def get_requires_for_build_editable(self, config_settings=None):448 return self.get_requires_for_build_wheel(config_settings)449 450 def prepare_metadata_for_build_editable(451 self, metadata_directory, config_settings=None452 ):453 return self.prepare_metadata_for_build_wheel(454 metadata_directory, config_settings455 )456 457 458class _BuildMetaLegacyBackend(_BuildMetaBackend):459 """Compatibility backend for setuptools460 461 This is a version of setuptools.build_meta that endeavors462 to maintain backwards463 compatibility with pre-PEP 517 modes of invocation. It464 exists as a temporary465 bridge between the old packaging mechanism and the new466 packaging mechanism,467 and will eventually be removed.468 """469 470 def run_setup(self, setup_script='setup.py'):471 # In order to maintain compatibility with scripts assuming that472 # the setup.py script is in a directory on the PYTHONPATH, inject473 # '' into sys.path. (pypa/setuptools#1642)474 sys_path = list(sys.path) # Save the original path475 476 script_dir = os.path.dirname(os.path.abspath(setup_script))477 if script_dir not in sys.path:478 sys.path.insert(0, script_dir)479 480 # Some setup.py scripts (e.g. in pygame and numpy) use sys.argv[0] to481 # get the directory of the source code. They expect it to refer to the482 # setup.py script.483 sys_argv_0 = sys.argv[0]484 sys.argv[0] = setup_script485 486 try:487 super().run_setup(setup_script=setup_script)488 finally:489 # While PEP 517 frontends should be calling each hook in a fresh490 # subprocess according to the standard (and thus it should not be491 # strictly necessary to restore the old sys.path), we'll restore492 # the original path so that the path manipulation does not persist493 # within the hook after run_setup is called.494 sys.path[:] = sys_path495 sys.argv[0] = sys_argv_0496 497 498# The primary backend499_BACKEND = _BuildMetaBackend()500 501get_requires_for_build_wheel = _BACKEND.get_requires_for_build_wheel502get_requires_for_build_sdist = _BACKEND.get_requires_for_build_sdist503prepare_metadata_for_build_wheel = _BACKEND.prepare_metadata_for_build_wheel504build_wheel = _BACKEND.build_wheel505build_sdist = _BACKEND.build_sdist506 507if not LEGACY_EDITABLE:508 get_requires_for_build_editable = _BACKEND.get_requires_for_build_editable509 prepare_metadata_for_build_editable = _BACKEND.prepare_metadata_for_build_editable510 build_editable = _BACKEND.build_editable511 512 513# The legacy backend514__legacy__ = _BuildMetaLegacyBackend()515 