Spaces:
Running
Running
"""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) | |
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 | |
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) | |
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 re-use 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') | |
exec(code, locals()) | |
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() | |