]>
Commit | Line | Data |
---|---|---|
53e6db90 DC |
1 | """A PEP 517 interface to setuptools |
2 | ||
3 | Previously, when a user or a command line tool (let's call it a "frontend") | |
4 | needed to make a request of setuptools to take a certain action, for | |
5 | example, generating a list of installation requirements, the frontend would | |
6 | would call "setup.py egg_info" or "setup.py bdist_wheel" on the command line. | |
7 | ||
8 | PEP 517 defines a different method of interfacing with setuptools. Rather | |
9 | than calling "setup.py" directly, the frontend should: | |
10 | ||
11 | 1. Set the current directory to the directory with a setup.py file | |
12 | 2. Import this module into a safe python interpreter (one in which | |
13 | setuptools can potentially set global variables or crash hard). | |
14 | 3. Call one of the functions defined in PEP 517. | |
15 | ||
16 | What each function does is defined in PEP 517. However, here is a "casual" | |
17 | definition of the functions (this definition should not be relied on for | |
18 | bug reports or API stability): | |
19 | ||
20 | - `build_wheel`: build a wheel in the folder and return the basename | |
21 | - `get_requires_for_build_wheel`: get the `setup_requires` to build | |
22 | - `prepare_metadata_for_build_wheel`: get the `install_requires` | |
23 | - `build_sdist`: build an sdist in the folder and return the basename | |
24 | - `get_requires_for_build_sdist`: get the `setup_requires` to build | |
25 | ||
26 | Again, this is not a formal definition! Just a "taste" of the module. | |
27 | """ | |
28 | ||
29 | import io | |
30 | import os | |
31 | import shlex | |
32 | import sys | |
33 | import tokenize | |
34 | import shutil | |
35 | import contextlib | |
36 | import tempfile | |
37 | import warnings | |
38 | from pathlib import Path | |
39 | from typing import Dict, Iterator, List, Optional, Union | |
40 | ||
41 | import setuptools | |
42 | import distutils | |
43 | from . import errors | |
44 | from ._path import same_path | |
45 | from ._reqs import parse_strings | |
46 | from ._deprecation_warning import SetuptoolsDeprecationWarning | |
47 | from distutils.util import strtobool | |
48 | ||
49 | ||
50 | __all__ = ['get_requires_for_build_sdist', | |
51 | 'get_requires_for_build_wheel', | |
52 | 'prepare_metadata_for_build_wheel', | |
53 | 'build_wheel', | |
54 | 'build_sdist', | |
55 | 'get_requires_for_build_editable', | |
56 | 'prepare_metadata_for_build_editable', | |
57 | 'build_editable', | |
58 | '__legacy__', | |
59 | 'SetupRequirementsError'] | |
60 | ||
61 | SETUPTOOLS_ENABLE_FEATURES = os.getenv("SETUPTOOLS_ENABLE_FEATURES", "").lower() | |
62 | LEGACY_EDITABLE = "legacy-editable" in SETUPTOOLS_ENABLE_FEATURES.replace("_", "-") | |
63 | ||
64 | ||
65 | class SetupRequirementsError(BaseException): | |
66 | def __init__(self, specifiers): | |
67 | self.specifiers = specifiers | |
68 | ||
69 | ||
70 | class Distribution(setuptools.dist.Distribution): | |
71 | def fetch_build_eggs(self, specifiers): | |
72 | specifier_list = list(parse_strings(specifiers)) | |
73 | ||
74 | raise SetupRequirementsError(specifier_list) | |
75 | ||
76 | @classmethod | |
77 | @contextlib.contextmanager | |
78 | def patch(cls): | |
79 | """ | |
80 | Replace | |
81 | distutils.dist.Distribution with this class | |
82 | for the duration of this context. | |
83 | """ | |
84 | orig = distutils.core.Distribution | |
85 | distutils.core.Distribution = cls | |
86 | try: | |
87 | yield | |
88 | finally: | |
89 | distutils.core.Distribution = orig | |
90 | ||
91 | ||
92 | @contextlib.contextmanager | |
93 | def no_install_setup_requires(): | |
94 | """Temporarily disable installing setup_requires | |
95 | ||
96 | Under PEP 517, the backend reports build dependencies to the frontend, | |
97 | and the frontend is responsible for ensuring they're installed. | |
98 | So setuptools (acting as a backend) should not try to install them. | |
99 | """ | |
100 | orig = setuptools._install_setup_requires | |
101 | setuptools._install_setup_requires = lambda attrs: None | |
102 | try: | |
103 | yield | |
104 | finally: | |
105 | setuptools._install_setup_requires = orig | |
106 | ||
107 | ||
108 | def _get_immediate_subdirectories(a_dir): | |
109 | return [name for name in os.listdir(a_dir) | |
110 | if os.path.isdir(os.path.join(a_dir, name))] | |
111 | ||
112 | ||
113 | def _file_with_extension(directory, extension): | |
114 | matching = ( | |
115 | f for f in os.listdir(directory) | |
116 | if f.endswith(extension) | |
117 | ) | |
118 | try: | |
119 | file, = matching | |
120 | except ValueError: | |
121 | raise ValueError( | |
122 | 'No distribution was found. Ensure that `setup.py` ' | |
123 | 'is not empty and that it calls `setup()`.') | |
124 | return file | |
125 | ||
126 | ||
127 | def _open_setup_script(setup_script): | |
128 | if not os.path.exists(setup_script): | |
129 | # Supply a default setup.py | |
130 | return io.StringIO(u"from setuptools import setup; setup()") | |
131 | ||
132 | return getattr(tokenize, 'open', open)(setup_script) | |
133 | ||
134 | ||
135 | @contextlib.contextmanager | |
136 | def suppress_known_deprecation(): | |
137 | with warnings.catch_warnings(): | |
138 | warnings.filterwarnings('ignore', 'setup.py install is deprecated') | |
139 | yield | |
140 | ||
141 | ||
142 | _ConfigSettings = Optional[Dict[str, Union[str, List[str], None]]] | |
143 | """ | |
144 | Currently the user can run:: | |
145 | ||
146 | pip install -e . --config-settings key=value | |
147 | python -m build -C--key=value -C key=value | |
148 | ||
149 | - pip will pass both key and value as strings and overwriting repeated keys | |
150 | (pypa/pip#11059). | |
151 | - build will accumulate values associated with repeated keys in a list. | |
152 | It will also accept keys with no associated value. | |
153 | This means that an option passed by build can be ``str | list[str] | None``. | |
154 | - PEP 517 specifies that ``config_settings`` is an optional dict. | |
155 | """ | |
156 | ||
157 | ||
158 | class _ConfigSettingsTranslator: | |
159 | """Translate ``config_settings`` into distutils-style command arguments. | |
160 | Only a limited number of options is currently supported. | |
161 | """ | |
162 | # See pypa/setuptools#1928 pypa/setuptools#2491 | |
163 | ||
164 | def _get_config(self, key: str, config_settings: _ConfigSettings) -> List[str]: | |
165 | """ | |
166 | Get the value of a specific key in ``config_settings`` as a list of strings. | |
167 | ||
168 | >>> fn = _ConfigSettingsTranslator()._get_config | |
169 | >>> fn("--global-option", None) | |
170 | [] | |
171 | >>> fn("--global-option", {}) | |
172 | [] | |
173 | >>> fn("--global-option", {'--global-option': 'foo'}) | |
174 | ['foo'] | |
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 bar'}) | |
180 | ['foo', 'bar'] | |
181 | """ | |
182 | cfg = config_settings or {} | |
183 | opts = cfg.get(key) or [] | |
184 | return shlex.split(opts) if isinstance(opts, str) else opts | |
185 | ||
186 | def _valid_global_options(self): | |
187 | """Global options accepted by setuptools (e.g. quiet or verbose).""" | |
188 | options = (opt[:2] for opt in setuptools.dist.Distribution.global_options) | |
189 | return {flag for long_and_short in options for flag in long_and_short if flag} | |
190 | ||
191 | def _global_args(self, config_settings: _ConfigSettings) -> Iterator[str]: | |
192 | """ | |
193 | Let the user specify ``verbose`` or ``quiet`` + escape hatch via | |
194 | ``--global-option``. | |
195 | Note: ``-v``, ``-vv``, ``-vvv`` have similar effects in setuptools, | |
196 | so we just have to cover the basic scenario ``-v``. | |
197 | ||
198 | >>> fn = _ConfigSettingsTranslator()._global_args | |
199 | >>> list(fn(None)) | |
200 | [] | |
201 | >>> list(fn({"verbose": "False"})) | |
202 | ['-q'] | |
203 | >>> list(fn({"verbose": "1"})) | |
204 | ['-v'] | |
205 | >>> list(fn({"--verbose": None})) | |
206 | ['-v'] | |
207 | >>> list(fn({"verbose": "true", "--global-option": "-q --no-user-cfg"})) | |
208 | ['-v', '-q', '--no-user-cfg'] | |
209 | >>> list(fn({"--quiet": None})) | |
210 | ['-q'] | |
211 | """ | |
212 | cfg = config_settings or {} | |
213 | falsey = {"false", "no", "0", "off"} | |
214 | if "verbose" in cfg or "--verbose" in cfg: | |
215 | level = str(cfg.get("verbose") or cfg.get("--verbose") or "1") | |
216 | yield ("-q" if level.lower() in falsey else "-v") | |
217 | if "quiet" in cfg or "--quiet" in cfg: | |
218 | level = str(cfg.get("quiet") or cfg.get("--quiet") or "1") | |
219 | yield ("-v" if level.lower() in falsey else "-q") | |
220 | ||
221 | valid = self._valid_global_options() | |
222 | args = self._get_config("--global-option", config_settings) | |
223 | yield from (arg for arg in args if arg.strip("-") in valid) | |
224 | ||
225 | def __dist_info_args(self, config_settings: _ConfigSettings) -> Iterator[str]: | |
226 | """ | |
227 | The ``dist_info`` command accepts ``tag-date`` and ``tag-build``. | |
228 | ||
229 | .. warning:: | |
230 | We cannot use this yet as it requires the ``sdist`` and ``bdist_wheel`` | |
231 | commands run in ``build_sdist`` and ``build_wheel`` to re-use the egg-info | |
232 | directory created in ``prepare_metadata_for_build_wheel``. | |
233 | ||
234 | >>> fn = _ConfigSettingsTranslator()._ConfigSettingsTranslator__dist_info_args | |
235 | >>> list(fn(None)) | |
236 | [] | |
237 | >>> list(fn({"tag-date": "False"})) | |
238 | ['--no-date'] | |
239 | >>> list(fn({"tag-date": None})) | |
240 | ['--no-date'] | |
241 | >>> list(fn({"tag-date": "true", "tag-build": ".a"})) | |
242 | ['--tag-date', '--tag-build', '.a'] | |
243 | """ | |
244 | cfg = config_settings or {} | |
245 | if "tag-date" in cfg: | |
246 | val = strtobool(str(cfg["tag-date"] or "false")) | |
247 | yield ("--tag-date" if val else "--no-date") | |
248 | if "tag-build" in cfg: | |
249 | yield from ["--tag-build", str(cfg["tag-build"])] | |
250 | ||
251 | def _editable_args(self, config_settings: _ConfigSettings) -> Iterator[str]: | |
252 | """ | |
253 | The ``editable_wheel`` command accepts ``editable-mode=strict``. | |
254 | ||
255 | >>> fn = _ConfigSettingsTranslator()._editable_args | |
256 | >>> list(fn(None)) | |
257 | [] | |
258 | >>> list(fn({"editable-mode": "strict"})) | |
259 | ['--mode', 'strict'] | |
260 | """ | |
261 | cfg = config_settings or {} | |
262 | mode = cfg.get("editable-mode") or cfg.get("editable_mode") | |
263 | if not mode: | |
264 | return | |
265 | yield from ["--mode", str(mode)] | |
266 | ||
267 | def _arbitrary_args(self, config_settings: _ConfigSettings) -> Iterator[str]: | |
268 | """ | |
269 | Users may expect to pass arbitrary lists of arguments to a command | |
270 | via "--global-option" (example provided in PEP 517 of a "escape hatch"). | |
271 | ||
272 | >>> fn = _ConfigSettingsTranslator()._arbitrary_args | |
273 | >>> list(fn(None)) | |
274 | [] | |
275 | >>> list(fn({})) | |
276 | [] | |
277 | >>> list(fn({'--build-option': 'foo'})) | |
278 | ['foo'] | |
279 | >>> list(fn({'--build-option': ['foo']})) | |
280 | ['foo'] | |
281 | >>> list(fn({'--build-option': 'foo'})) | |
282 | ['foo'] | |
283 | >>> list(fn({'--build-option': 'foo bar'})) | |
284 | ['foo', 'bar'] | |
285 | >>> warnings.simplefilter('error', SetuptoolsDeprecationWarning) | |
286 | >>> list(fn({'--global-option': 'foo'})) # doctest: +IGNORE_EXCEPTION_DETAIL | |
287 | Traceback (most recent call last): | |
288 | SetuptoolsDeprecationWarning: ...arguments given via `--global-option`... | |
289 | """ | |
290 | args = self._get_config("--global-option", config_settings) | |
291 | global_opts = self._valid_global_options() | |
292 | bad_args = [] | |
293 | ||
294 | for arg in args: | |
295 | if arg.strip("-") not in global_opts: | |
296 | bad_args.append(arg) | |
297 | yield arg | |
298 | ||
299 | yield from self._get_config("--build-option", config_settings) | |
300 | ||
301 | if bad_args: | |
302 | msg = f""" | |
303 | The arguments {bad_args!r} were given via `--global-option`. | |
304 | Please use `--build-option` instead, | |
305 | `--global-option` is reserved to flags like `--verbose` or `--quiet`. | |
306 | """ | |
307 | warnings.warn(msg, SetuptoolsDeprecationWarning) | |
308 | ||
309 | ||
310 | class _BuildMetaBackend(_ConfigSettingsTranslator): | |
311 | def _get_build_requires(self, config_settings, requirements): | |
312 | sys.argv = [ | |
313 | *sys.argv[:1], | |
314 | *self._global_args(config_settings), | |
315 | "egg_info", | |
316 | *self._arbitrary_args(config_settings), | |
317 | ] | |
318 | try: | |
319 | with Distribution.patch(): | |
320 | self.run_setup() | |
321 | except SetupRequirementsError as e: | |
322 | requirements += e.specifiers | |
323 | ||
324 | return requirements | |
325 | ||
326 | def run_setup(self, setup_script='setup.py'): | |
327 | # Note that we can reuse our build directory between calls | |
328 | # Correctness comes first, then optimization later | |
329 | __file__ = setup_script | |
330 | __name__ = '__main__' | |
331 | ||
332 | with _open_setup_script(__file__) as f: | |
333 | code = f.read().replace(r'\r\n', r'\n') | |
334 | ||
335 | exec(code, locals()) | |
336 | ||
337 | def get_requires_for_build_wheel(self, config_settings=None): | |
338 | return self._get_build_requires(config_settings, requirements=['wheel']) | |
339 | ||
340 | def get_requires_for_build_sdist(self, config_settings=None): | |
341 | return self._get_build_requires(config_settings, requirements=[]) | |
342 | ||
343 | def _bubble_up_info_directory(self, metadata_directory: str, suffix: str) -> str: | |
344 | """ | |
345 | PEP 517 requires that the .dist-info directory be placed in the | |
346 | metadata_directory. To comply, we MUST copy the directory to the root. | |
347 | ||
348 | Returns the basename of the info directory, e.g. `proj-0.0.0.dist-info`. | |
349 | """ | |
350 | info_dir = self._find_info_directory(metadata_directory, suffix) | |
351 | if not same_path(info_dir.parent, metadata_directory): | |
352 | shutil.move(str(info_dir), metadata_directory) | |
353 | # PEP 517 allow other files and dirs to exist in metadata_directory | |
354 | return info_dir.name | |
355 | ||
356 | def _find_info_directory(self, metadata_directory: str, suffix: str) -> Path: | |
357 | for parent, dirs, _ in os.walk(metadata_directory): | |
358 | candidates = [f for f in dirs if f.endswith(suffix)] | |
359 | ||
360 | if len(candidates) != 0 or len(dirs) != 1: | |
361 | assert len(candidates) == 1, f"Multiple {suffix} directories found" | |
362 | return Path(parent, candidates[0]) | |
363 | ||
364 | msg = f"No {suffix} directory found in {metadata_directory}" | |
365 | raise errors.InternalError(msg) | |
366 | ||
367 | def prepare_metadata_for_build_wheel(self, metadata_directory, | |
368 | config_settings=None): | |
369 | sys.argv = [ | |
370 | *sys.argv[:1], | |
371 | *self._global_args(config_settings), | |
372 | "dist_info", | |
373 | "--output-dir", metadata_directory, | |
374 | "--keep-egg-info", | |
375 | ] | |
376 | with no_install_setup_requires(): | |
377 | self.run_setup() | |
378 | ||
379 | self._bubble_up_info_directory(metadata_directory, ".egg-info") | |
380 | return self._bubble_up_info_directory(metadata_directory, ".dist-info") | |
381 | ||
382 | def _build_with_temp_dir(self, setup_command, result_extension, | |
383 | result_directory, config_settings): | |
384 | result_directory = os.path.abspath(result_directory) | |
385 | ||
386 | # Build in a temporary directory, then copy to the target. | |
387 | os.makedirs(result_directory, exist_ok=True) | |
388 | temp_opts = {"prefix": ".tmp-", "dir": result_directory} | |
389 | with tempfile.TemporaryDirectory(**temp_opts) as tmp_dist_dir: | |
390 | sys.argv = [ | |
391 | *sys.argv[:1], | |
392 | *self._global_args(config_settings), | |
393 | *setup_command, | |
394 | "--dist-dir", tmp_dist_dir, | |
395 | *self._arbitrary_args(config_settings), | |
396 | ] | |
397 | with no_install_setup_requires(): | |
398 | self.run_setup() | |
399 | ||
400 | result_basename = _file_with_extension( | |
401 | tmp_dist_dir, result_extension) | |
402 | result_path = os.path.join(result_directory, result_basename) | |
403 | if os.path.exists(result_path): | |
404 | # os.rename will fail overwriting on non-Unix. | |
405 | os.remove(result_path) | |
406 | os.rename(os.path.join(tmp_dist_dir, result_basename), result_path) | |
407 | ||
408 | return result_basename | |
409 | ||
410 | def build_wheel(self, wheel_directory, config_settings=None, | |
411 | metadata_directory=None): | |
412 | with suppress_known_deprecation(): | |
413 | return self._build_with_temp_dir(['bdist_wheel'], '.whl', | |
414 | wheel_directory, config_settings) | |
415 | ||
416 | def build_sdist(self, sdist_directory, config_settings=None): | |
417 | return self._build_with_temp_dir(['sdist', '--formats', 'gztar'], | |
418 | '.tar.gz', sdist_directory, | |
419 | config_settings) | |
420 | ||
421 | def _get_dist_info_dir(self, metadata_directory: Optional[str]) -> Optional[str]: | |
422 | if not metadata_directory: | |
423 | return None | |
424 | dist_info_candidates = list(Path(metadata_directory).glob("*.dist-info")) | |
425 | assert len(dist_info_candidates) <= 1 | |
426 | return str(dist_info_candidates[0]) if dist_info_candidates else None | |
427 | ||
428 | if not LEGACY_EDITABLE: | |
429 | ||
430 | # PEP660 hooks: | |
431 | # build_editable | |
432 | # get_requires_for_build_editable | |
433 | # prepare_metadata_for_build_editable | |
434 | def build_editable( | |
435 | self, wheel_directory, config_settings=None, metadata_directory=None | |
436 | ): | |
437 | # XXX can or should we hide our editable_wheel command normally? | |
438 | info_dir = self._get_dist_info_dir(metadata_directory) | |
439 | opts = ["--dist-info-dir", info_dir] if info_dir else [] | |
440 | cmd = ["editable_wheel", *opts, *self._editable_args(config_settings)] | |
441 | with suppress_known_deprecation(): | |
442 | return self._build_with_temp_dir( | |
443 | cmd, ".whl", wheel_directory, config_settings | |
444 | ) | |
445 | ||
446 | def get_requires_for_build_editable(self, config_settings=None): | |
447 | return self.get_requires_for_build_wheel(config_settings) | |
448 | ||
449 | def prepare_metadata_for_build_editable(self, metadata_directory, | |
450 | config_settings=None): | |
451 | return self.prepare_metadata_for_build_wheel( | |
452 | metadata_directory, config_settings | |
453 | ) | |
454 | ||
455 | ||
456 | class _BuildMetaLegacyBackend(_BuildMetaBackend): | |
457 | """Compatibility backend for setuptools | |
458 | ||
459 | This is a version of setuptools.build_meta that endeavors | |
460 | to maintain backwards | |
461 | compatibility with pre-PEP 517 modes of invocation. It | |
462 | exists as a temporary | |
463 | bridge between the old packaging mechanism and the new | |
464 | packaging mechanism, | |
465 | and will eventually be removed. | |
466 | """ | |
467 | def run_setup(self, setup_script='setup.py'): | |
468 | # In order to maintain compatibility with scripts assuming that | |
469 | # the setup.py script is in a directory on the PYTHONPATH, inject | |
470 | # '' into sys.path. (pypa/setuptools#1642) | |
471 | sys_path = list(sys.path) # Save the original path | |
472 | ||
473 | script_dir = os.path.dirname(os.path.abspath(setup_script)) | |
474 | if script_dir not in sys.path: | |
475 | sys.path.insert(0, script_dir) | |
476 | ||
477 | # Some setup.py scripts (e.g. in pygame and numpy) use sys.argv[0] to | |
478 | # get the directory of the source code. They expect it to refer to the | |
479 | # setup.py script. | |
480 | sys_argv_0 = sys.argv[0] | |
481 | sys.argv[0] = setup_script | |
482 | ||
483 | try: | |
484 | super(_BuildMetaLegacyBackend, | |
485 | self).run_setup(setup_script=setup_script) | |
486 | finally: | |
487 | # While PEP 517 frontends should be calling each hook in a fresh | |
488 | # subprocess according to the standard (and thus it should not be | |
489 | # strictly necessary to restore the old sys.path), we'll restore | |
490 | # the original path so that the path manipulation does not persist | |
491 | # within the hook after run_setup is called. | |
492 | sys.path[:] = sys_path | |
493 | sys.argv[0] = sys_argv_0 | |
494 | ||
495 | ||
496 | # The primary backend | |
497 | _BACKEND = _BuildMetaBackend() | |
498 | ||
499 | get_requires_for_build_wheel = _BACKEND.get_requires_for_build_wheel | |
500 | get_requires_for_build_sdist = _BACKEND.get_requires_for_build_sdist | |
501 | prepare_metadata_for_build_wheel = _BACKEND.prepare_metadata_for_build_wheel | |
502 | build_wheel = _BACKEND.build_wheel | |
503 | build_sdist = _BACKEND.build_sdist | |
504 | ||
505 | if not LEGACY_EDITABLE: | |
506 | get_requires_for_build_editable = _BACKEND.get_requires_for_build_editable | |
507 | prepare_metadata_for_build_editable = _BACKEND.prepare_metadata_for_build_editable | |
508 | build_editable = _BACKEND.build_editable | |
509 | ||
510 | ||
511 | # The legacy backend | |
512 | __legacy__ = _BuildMetaLegacyBackend() |