You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
534 lines
20 KiB
534 lines
20 KiB
"""A PEP 517 interface to setuptools |
|
|
|
Previously, when a user or a command line tool (let's call it a "frontend") |
|
needed to make a request of setuptools to take a certain action, for |
|
example, generating a list of installation requirements, the frontend would |
|
would call "setup.py egg_info" or "setup.py bdist_wheel" on the command line. |
|
|
|
PEP 517 defines a different method of interfacing with setuptools. Rather |
|
than calling "setup.py" directly, the frontend should: |
|
|
|
1. Set the current directory to the directory with a setup.py file |
|
2. Import this module into a safe python interpreter (one in which |
|
setuptools can potentially set global variables or crash hard). |
|
3. Call one of the functions defined in PEP 517. |
|
|
|
What each function does is defined in PEP 517. However, here is a "casual" |
|
definition of the functions (this definition should not be relied on for |
|
bug reports or API stability): |
|
|
|
- `build_wheel`: build a wheel in the folder and return the basename |
|
- `get_requires_for_build_wheel`: get the `setup_requires` to build |
|
- `prepare_metadata_for_build_wheel`: get the `install_requires` |
|
- `build_sdist`: build an sdist in the folder and return the basename |
|
- `get_requires_for_build_sdist`: get the `setup_requires` to build |
|
|
|
Again, this is not a formal definition! Just a "taste" of the module. |
|
""" |
|
|
|
import io |
|
import os |
|
import shlex |
|
import sys |
|
import tokenize |
|
import shutil |
|
import contextlib |
|
import tempfile |
|
import warnings |
|
from pathlib import Path |
|
from typing import Dict, Iterator, List, Optional, Union |
|
|
|
import setuptools |
|
import distutils |
|
from . import errors |
|
from ._path import same_path |
|
from ._reqs import parse_strings |
|
from .warnings import SetuptoolsDeprecationWarning |
|
from distutils.util import strtobool |
|
|
|
|
|
__all__ = [ |
|
'get_requires_for_build_sdist', |
|
'get_requires_for_build_wheel', |
|
'prepare_metadata_for_build_wheel', |
|
'build_wheel', |
|
'build_sdist', |
|
'get_requires_for_build_editable', |
|
'prepare_metadata_for_build_editable', |
|
'build_editable', |
|
'__legacy__', |
|
'SetupRequirementsError', |
|
] |
|
|
|
SETUPTOOLS_ENABLE_FEATURES = os.getenv("SETUPTOOLS_ENABLE_FEATURES", "").lower() |
|
LEGACY_EDITABLE = "legacy-editable" in SETUPTOOLS_ENABLE_FEATURES.replace("_", "-") |
|
|
|
|
|
class SetupRequirementsError(BaseException): |
|
def __init__(self, specifiers): |
|
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 |
|
try: |
|
yield |
|
finally: |
|
distutils.core.Distribution = orig |
|
|
|
|
|
@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)) |
|
] |
|
|
|
|
|
def _file_with_extension(directory, extension): |
|
matching = (f for f in os.listdir(directory) if f.endswith(extension)) |
|
try: |
|
(file,) = matching |
|
except ValueError: |
|
raise ValueError( |
|
'No distribution was found. Ensure that `setup.py` ' |
|
'is not empty and that it calls `setup()`.' |
|
) |
|
return file |
|
|
|
|
|
def _open_setup_script(setup_script): |
|
if not os.path.exists(setup_script): |
|
# Supply a default setup.py |
|
return io.StringIO(u"from setuptools import setup; setup()") |
|
|
|
return getattr(tokenize, 'open', open)(setup_script) |
|
|
|
|
|
@contextlib.contextmanager |
|
def suppress_known_deprecation(): |
|
with warnings.catch_warnings(): |
|
warnings.filterwarnings('ignore', 'setup.py install is deprecated') |
|
yield |
|
|
|
|
|
_ConfigSettings = Optional[Dict[str, Union[str, List[str], None]]] |
|
""" |
|
Currently the user can run:: |
|
|
|
pip install -e . --config-settings key=value |
|
python -m build -C--key=value -C key=value |
|
|
|
- pip will pass both key and value as strings and overwriting repeated keys |
|
(pypa/pip#11059). |
|
- build will accumulate values associated with repeated keys in a list. |
|
It will also accept keys with no associated value. |
|
This means that an option passed by build can be ``str | list[str] | None``. |
|
- PEP 517 specifies that ``config_settings`` is an optional dict. |
|
""" |
|
|
|
|
|
class _ConfigSettingsTranslator: |
|
"""Translate ``config_settings`` into distutils-style command arguments. |
|
Only a limited number of options is currently supported. |
|
""" |
|
|
|
# See pypa/setuptools#1928 pypa/setuptools#2491 |
|
|
|
def _get_config(self, key: str, config_settings: _ConfigSettings) -> List[str]: |
|
""" |
|
Get the value of a specific key in ``config_settings`` as a list of strings. |
|
|
|
>>> fn = _ConfigSettingsTranslator()._get_config |
|
>>> fn("--global-option", None) |
|
[] |
|
>>> fn("--global-option", {}) |
|
[] |
|
>>> fn("--global-option", {'--global-option': 'foo'}) |
|
['foo'] |
|
>>> fn("--global-option", {'--global-option': ['foo']}) |
|
['foo'] |
|
>>> fn("--global-option", {'--global-option': 'foo'}) |
|
['foo'] |
|
>>> fn("--global-option", {'--global-option': 'foo bar'}) |
|
['foo', 'bar'] |
|
""" |
|
cfg = config_settings or {} |
|
opts = cfg.get(key) or [] |
|
return shlex.split(opts) if isinstance(opts, str) else opts |
|
|
|
def _valid_global_options(self): |
|
"""Global options accepted by setuptools (e.g. quiet or verbose).""" |
|
options = (opt[:2] for opt in setuptools.dist.Distribution.global_options) |
|
return {flag for long_and_short in options for flag in long_and_short if flag} |
|
|
|
def _global_args(self, config_settings: _ConfigSettings) -> Iterator[str]: |
|
""" |
|
Let the user specify ``verbose`` or ``quiet`` + escape hatch via |
|
``--global-option``. |
|
Note: ``-v``, ``-vv``, ``-vvv`` have similar effects in setuptools, |
|
so we just have to cover the basic scenario ``-v``. |
|
|
|
>>> fn = _ConfigSettingsTranslator()._global_args |
|
>>> list(fn(None)) |
|
[] |
|
>>> list(fn({"verbose": "False"})) |
|
['-q'] |
|
>>> list(fn({"verbose": "1"})) |
|
['-v'] |
|
>>> list(fn({"--verbose": None})) |
|
['-v'] |
|
>>> list(fn({"verbose": "true", "--global-option": "-q --no-user-cfg"})) |
|
['-v', '-q', '--no-user-cfg'] |
|
>>> list(fn({"--quiet": None})) |
|
['-q'] |
|
""" |
|
cfg = config_settings or {} |
|
falsey = {"false", "no", "0", "off"} |
|
if "verbose" in cfg or "--verbose" in cfg: |
|
level = str(cfg.get("verbose") or cfg.get("--verbose") or "1") |
|
yield ("-q" if level.lower() in falsey else "-v") |
|
if "quiet" in cfg or "--quiet" in cfg: |
|
level = str(cfg.get("quiet") or cfg.get("--quiet") or "1") |
|
yield ("-v" if level.lower() in falsey else "-q") |
|
|
|
valid = self._valid_global_options() |
|
args = self._get_config("--global-option", config_settings) |
|
yield from (arg for arg in args if arg.strip("-") in valid) |
|
|
|
def __dist_info_args(self, config_settings: _ConfigSettings) -> Iterator[str]: |
|
""" |
|
The ``dist_info`` command accepts ``tag-date`` and ``tag-build``. |
|
|
|
.. warning:: |
|
We cannot use this yet as it requires the ``sdist`` and ``bdist_wheel`` |
|
commands run in ``build_sdist`` and ``build_wheel`` to reuse the egg-info |
|
directory created in ``prepare_metadata_for_build_wheel``. |
|
|
|
>>> fn = _ConfigSettingsTranslator()._ConfigSettingsTranslator__dist_info_args |
|
>>> list(fn(None)) |
|
[] |
|
>>> list(fn({"tag-date": "False"})) |
|
['--no-date'] |
|
>>> list(fn({"tag-date": None})) |
|
['--no-date'] |
|
>>> list(fn({"tag-date": "true", "tag-build": ".a"})) |
|
['--tag-date', '--tag-build', '.a'] |
|
""" |
|
cfg = config_settings or {} |
|
if "tag-date" in cfg: |
|
val = strtobool(str(cfg["tag-date"] or "false")) |
|
yield ("--tag-date" if val else "--no-date") |
|
if "tag-build" in cfg: |
|
yield from ["--tag-build", str(cfg["tag-build"])] |
|
|
|
def _editable_args(self, config_settings: _ConfigSettings) -> Iterator[str]: |
|
""" |
|
The ``editable_wheel`` command accepts ``editable-mode=strict``. |
|
|
|
>>> fn = _ConfigSettingsTranslator()._editable_args |
|
>>> list(fn(None)) |
|
[] |
|
>>> list(fn({"editable-mode": "strict"})) |
|
['--mode', 'strict'] |
|
""" |
|
cfg = config_settings or {} |
|
mode = cfg.get("editable-mode") or cfg.get("editable_mode") |
|
if not mode: |
|
return |
|
yield from ["--mode", str(mode)] |
|
|
|
def _arbitrary_args(self, config_settings: _ConfigSettings) -> Iterator[str]: |
|
""" |
|
Users may expect to pass arbitrary lists of arguments to a command |
|
via "--global-option" (example provided in PEP 517 of a "escape hatch"). |
|
|
|
>>> fn = _ConfigSettingsTranslator()._arbitrary_args |
|
>>> list(fn(None)) |
|
[] |
|
>>> list(fn({})) |
|
[] |
|
>>> list(fn({'--build-option': 'foo'})) |
|
['foo'] |
|
>>> list(fn({'--build-option': ['foo']})) |
|
['foo'] |
|
>>> list(fn({'--build-option': 'foo'})) |
|
['foo'] |
|
>>> list(fn({'--build-option': 'foo bar'})) |
|
['foo', 'bar'] |
|
>>> warnings.simplefilter('error', SetuptoolsDeprecationWarning) |
|
>>> list(fn({'--global-option': 'foo'})) # doctest: +IGNORE_EXCEPTION_DETAIL |
|
Traceback (most recent call last): |
|
SetuptoolsDeprecationWarning: ...arguments given via `--global-option`... |
|
""" |
|
args = self._get_config("--global-option", config_settings) |
|
global_opts = self._valid_global_options() |
|
bad_args = [] |
|
|
|
for arg in args: |
|
if arg.strip("-") not in global_opts: |
|
bad_args.append(arg) |
|
yield arg |
|
|
|
yield from self._get_config("--build-option", config_settings) |
|
|
|
if bad_args: |
|
SetuptoolsDeprecationWarning.emit( |
|
"Incompatible `config_settings` passed to build backend.", |
|
f""" |
|
The arguments {bad_args!r} were given via `--global-option`. |
|
Please use `--build-option` instead, |
|
`--global-option` is reserved for flags like `--verbose` or `--quiet`. |
|
""", |
|
due_date=(2023, 9, 26), # Warning introduced in v64.0.1, 11/Aug/2022. |
|
) |
|
|
|
|
|
class _BuildMetaBackend(_ConfigSettingsTranslator): |
|
def _get_build_requires(self, config_settings, requirements): |
|
sys.argv = [ |
|
*sys.argv[:1], |
|
*self._global_args(config_settings), |
|
"egg_info", |
|
*self._arbitrary_args(config_settings), |
|
] |
|
try: |
|
with Distribution.patch(): |
|
self.run_setup() |
|
except SetupRequirementsError as e: |
|
requirements += e.specifiers |
|
|
|
return requirements |
|
|
|
def run_setup(self, setup_script='setup.py'): |
|
# Note that we can reuse our build directory between calls |
|
# Correctness comes first, then optimization later |
|
__file__ = os.path.abspath(setup_script) |
|
__name__ = '__main__' |
|
|
|
with _open_setup_script(__file__) as f: |
|
code = f.read().replace(r'\r\n', r'\n') |
|
|
|
try: |
|
exec(code, locals()) |
|
except SystemExit as e: |
|
if e.code: |
|
raise |
|
# We ignore exit code indicating success |
|
SetuptoolsDeprecationWarning.emit( |
|
"Running `setup.py` directly as CLI tool is deprecated.", |
|
"Please avoid using `sys.exit(0)` or similar statements " |
|
"that don't fit in the paradigm of a configuration file.", |
|
see_url="https://blog.ganssle.io/articles/2021/10/" |
|
"setup-py-deprecated.html", |
|
) |
|
|
|
def get_requires_for_build_wheel(self, config_settings=None): |
|
return self._get_build_requires(config_settings, requirements=['wheel']) |
|
|
|
def get_requires_for_build_sdist(self, config_settings=None): |
|
return self._get_build_requires(config_settings, requirements=[]) |
|
|
|
def _bubble_up_info_directory(self, metadata_directory: str, suffix: str) -> str: |
|
""" |
|
PEP 517 requires that the .dist-info directory be placed in the |
|
metadata_directory. To comply, we MUST copy the directory to the root. |
|
|
|
Returns the basename of the info directory, e.g. `proj-0.0.0.dist-info`. |
|
""" |
|
info_dir = self._find_info_directory(metadata_directory, suffix) |
|
if not same_path(info_dir.parent, metadata_directory): |
|
shutil.move(str(info_dir), metadata_directory) |
|
# PEP 517 allow other files and dirs to exist in metadata_directory |
|
return info_dir.name |
|
|
|
def _find_info_directory(self, metadata_directory: str, suffix: str) -> Path: |
|
for parent, dirs, _ in os.walk(metadata_directory): |
|
candidates = [f for f in dirs if f.endswith(suffix)] |
|
|
|
if len(candidates) != 0 or len(dirs) != 1: |
|
assert len(candidates) == 1, f"Multiple {suffix} directories found" |
|
return Path(parent, candidates[0]) |
|
|
|
msg = f"No {suffix} directory found in {metadata_directory}" |
|
raise errors.InternalError(msg) |
|
|
|
def prepare_metadata_for_build_wheel( |
|
self, metadata_directory, config_settings=None |
|
): |
|
sys.argv = [ |
|
*sys.argv[:1], |
|
*self._global_args(config_settings), |
|
"dist_info", |
|
"--output-dir", |
|
metadata_directory, |
|
"--keep-egg-info", |
|
] |
|
with no_install_setup_requires(): |
|
self.run_setup() |
|
|
|
self._bubble_up_info_directory(metadata_directory, ".egg-info") |
|
return self._bubble_up_info_directory(metadata_directory, ".dist-info") |
|
|
|
def _build_with_temp_dir( |
|
self, setup_command, result_extension, result_directory, config_settings |
|
): |
|
result_directory = os.path.abspath(result_directory) |
|
|
|
# Build in a temporary directory, then copy to the target. |
|
os.makedirs(result_directory, exist_ok=True) |
|
temp_opts = {"prefix": ".tmp-", "dir": result_directory} |
|
with tempfile.TemporaryDirectory(**temp_opts) as tmp_dist_dir: |
|
sys.argv = [ |
|
*sys.argv[:1], |
|
*self._global_args(config_settings), |
|
*setup_command, |
|
"--dist-dir", |
|
tmp_dist_dir, |
|
*self._arbitrary_args(config_settings), |
|
] |
|
with no_install_setup_requires(): |
|
self.run_setup() |
|
|
|
result_basename = _file_with_extension(tmp_dist_dir, result_extension) |
|
result_path = os.path.join(result_directory, result_basename) |
|
if os.path.exists(result_path): |
|
# os.rename will fail overwriting on non-Unix. |
|
os.remove(result_path) |
|
os.rename(os.path.join(tmp_dist_dir, result_basename), result_path) |
|
|
|
return result_basename |
|
|
|
def build_wheel( |
|
self, wheel_directory, config_settings=None, metadata_directory=None |
|
): |
|
with suppress_known_deprecation(): |
|
return self._build_with_temp_dir( |
|
['bdist_wheel'], '.whl', wheel_directory, config_settings |
|
) |
|
|
|
def build_sdist(self, sdist_directory, config_settings=None): |
|
return self._build_with_temp_dir( |
|
['sdist', '--formats', 'gztar'], '.tar.gz', sdist_directory, config_settings |
|
) |
|
|
|
def _get_dist_info_dir(self, metadata_directory: Optional[str]) -> Optional[str]: |
|
if not metadata_directory: |
|
return None |
|
dist_info_candidates = list(Path(metadata_directory).glob("*.dist-info")) |
|
assert len(dist_info_candidates) <= 1 |
|
return str(dist_info_candidates[0]) if dist_info_candidates else None |
|
|
|
if not LEGACY_EDITABLE: |
|
# PEP660 hooks: |
|
# build_editable |
|
# get_requires_for_build_editable |
|
# prepare_metadata_for_build_editable |
|
def build_editable( |
|
self, wheel_directory, config_settings=None, metadata_directory=None |
|
): |
|
# XXX can or should we hide our editable_wheel command normally? |
|
info_dir = self._get_dist_info_dir(metadata_directory) |
|
opts = ["--dist-info-dir", info_dir] if info_dir else [] |
|
cmd = ["editable_wheel", *opts, *self._editable_args(config_settings)] |
|
with suppress_known_deprecation(): |
|
return self._build_with_temp_dir( |
|
cmd, ".whl", wheel_directory, config_settings |
|
) |
|
|
|
def get_requires_for_build_editable(self, config_settings=None): |
|
return self.get_requires_for_build_wheel(config_settings) |
|
|
|
def prepare_metadata_for_build_editable( |
|
self, metadata_directory, config_settings=None |
|
): |
|
return self.prepare_metadata_for_build_wheel( |
|
metadata_directory, config_settings |
|
) |
|
|
|
|
|
class _BuildMetaLegacyBackend(_BuildMetaBackend): |
|
"""Compatibility backend for setuptools |
|
|
|
This is a version of setuptools.build_meta that endeavors |
|
to maintain backwards |
|
compatibility with pre-PEP 517 modes of invocation. It |
|
exists as a temporary |
|
bridge between the old packaging mechanism and the new |
|
packaging mechanism, |
|
and will eventually be removed. |
|
""" |
|
|
|
def run_setup(self, setup_script='setup.py'): |
|
# In order to maintain compatibility with scripts assuming that |
|
# the setup.py script is in a directory on the PYTHONPATH, inject |
|
# '' into sys.path. (pypa/setuptools#1642) |
|
sys_path = list(sys.path) # Save the original path |
|
|
|
script_dir = os.path.dirname(os.path.abspath(setup_script)) |
|
if script_dir not in sys.path: |
|
sys.path.insert(0, script_dir) |
|
|
|
# Some setup.py scripts (e.g. in pygame and numpy) use sys.argv[0] to |
|
# get the directory of the source code. They expect it to refer to the |
|
# setup.py script. |
|
sys_argv_0 = sys.argv[0] |
|
sys.argv[0] = setup_script |
|
|
|
try: |
|
super(_BuildMetaLegacyBackend, self).run_setup(setup_script=setup_script) |
|
finally: |
|
# While PEP 517 frontends should be calling each hook in a fresh |
|
# subprocess according to the standard (and thus it should not be |
|
# strictly necessary to restore the old sys.path), we'll restore |
|
# the original path so that the path manipulation does not persist |
|
# within the hook after run_setup is called. |
|
sys.path[:] = sys_path |
|
sys.argv[0] = sys_argv_0 |
|
|
|
|
|
# The primary backend |
|
_BACKEND = _BuildMetaBackend() |
|
|
|
get_requires_for_build_wheel = _BACKEND.get_requires_for_build_wheel |
|
get_requires_for_build_sdist = _BACKEND.get_requires_for_build_sdist |
|
prepare_metadata_for_build_wheel = _BACKEND.prepare_metadata_for_build_wheel |
|
build_wheel = _BACKEND.build_wheel |
|
build_sdist = _BACKEND.build_sdist |
|
|
|
if not LEGACY_EDITABLE: |
|
get_requires_for_build_editable = _BACKEND.get_requires_for_build_editable |
|
prepare_metadata_for_build_editable = _BACKEND.prepare_metadata_for_build_editable |
|
build_editable = _BACKEND.build_editable |
|
|
|
|
|
# The legacy backend |
|
__legacy__ = _BuildMetaLegacyBackend()
|
|
|