first commit

This commit is contained in:
Yura 2024-09-15 15:12:16 +03:00
commit 417e54da96
5696 changed files with 900003 additions and 0 deletions

View file

@ -0,0 +1,31 @@
"""Wrappers to call pyproject.toml-based build backend hooks.
"""
from typing import TYPE_CHECKING
from ._impl import (
BackendUnavailable,
BuildBackendHookCaller,
HookMissing,
UnsupportedOperation,
default_subprocess_runner,
quiet_subprocess_runner,
)
__version__ = "1.1.0"
__all__ = [
"BackendUnavailable",
"BackendInvalid",
"HookMissing",
"UnsupportedOperation",
"default_subprocess_runner",
"quiet_subprocess_runner",
"BuildBackendHookCaller",
]
BackendInvalid = BackendUnavailable # Deprecated alias, previously a separate exception
if TYPE_CHECKING:
from ._impl import SubprocessRunner
__all__ += ["SubprocessRunner"]

View file

@ -0,0 +1,410 @@
import json
import os
import sys
import tempfile
from contextlib import contextmanager
from os.path import abspath
from os.path import join as pjoin
from subprocess import STDOUT, check_call, check_output
from typing import TYPE_CHECKING, Any, Iterator, Mapping, Optional, Sequence
from ._in_process import _in_proc_script_path
if TYPE_CHECKING:
from typing import Protocol
class SubprocessRunner(Protocol):
"""A protocol for the subprocess runner."""
def __call__(
self,
cmd: Sequence[str],
cwd: Optional[str] = None,
extra_environ: Optional[Mapping[str, str]] = None,
) -> None:
...
def write_json(obj: Mapping[str, Any], path: str, **kwargs) -> None:
with open(path, "w", encoding="utf-8") as f:
json.dump(obj, f, **kwargs)
def read_json(path: str) -> Mapping[str, Any]:
with open(path, encoding="utf-8") as f:
return json.load(f)
class BackendUnavailable(Exception):
"""Will be raised if the backend cannot be imported in the hook process."""
def __init__(
self,
traceback: str,
message: Optional[str] = None,
backend_name: Optional[str] = None,
backend_path: Optional[Sequence[str]] = None,
) -> None:
# Preserving arg order for the sake of API backward compatibility.
self.backend_name = backend_name
self.backend_path = backend_path
self.traceback = traceback
super().__init__(message or "Error while importing backend")
class HookMissing(Exception):
"""Will be raised on missing hooks (if a fallback can't be used)."""
def __init__(self, hook_name: str) -> None:
super().__init__(hook_name)
self.hook_name = hook_name
class UnsupportedOperation(Exception):
"""May be raised by build_sdist if the backend indicates that it can't."""
def __init__(self, traceback: str) -> None:
self.traceback = traceback
def default_subprocess_runner(
cmd: Sequence[str],
cwd: Optional[str] = None,
extra_environ: Optional[Mapping[str, str]] = None,
) -> None:
"""The default method of calling the wrapper subprocess.
This uses :func:`subprocess.check_call` under the hood.
"""
env = os.environ.copy()
if extra_environ:
env.update(extra_environ)
check_call(cmd, cwd=cwd, env=env)
def quiet_subprocess_runner(
cmd: Sequence[str],
cwd: Optional[str] = None,
extra_environ: Optional[Mapping[str, str]] = None,
) -> None:
"""Call the subprocess while suppressing output.
This uses :func:`subprocess.check_output` under the hood.
"""
env = os.environ.copy()
if extra_environ:
env.update(extra_environ)
check_output(cmd, cwd=cwd, env=env, stderr=STDOUT)
def norm_and_check(source_tree: str, requested: str) -> str:
"""Normalise and check a backend path.
Ensure that the requested backend path is specified as a relative path,
and resolves to a location under the given source tree.
Return an absolute version of the requested path.
"""
if os.path.isabs(requested):
raise ValueError("paths must be relative")
abs_source = os.path.abspath(source_tree)
abs_requested = os.path.normpath(os.path.join(abs_source, requested))
# We have to use commonprefix for Python 2.7 compatibility. So we
# normalise case to avoid problems because commonprefix is a character
# based comparison :-(
norm_source = os.path.normcase(abs_source)
norm_requested = os.path.normcase(abs_requested)
if os.path.commonprefix([norm_source, norm_requested]) != norm_source:
raise ValueError("paths must be inside source tree")
return abs_requested
class BuildBackendHookCaller:
"""A wrapper to call the build backend hooks for a source directory."""
def __init__(
self,
source_dir: str,
build_backend: str,
backend_path: Optional[Sequence[str]] = None,
runner: Optional["SubprocessRunner"] = None,
python_executable: Optional[str] = None,
) -> None:
"""
:param source_dir: The source directory to invoke the build backend for
:param build_backend: The build backend spec
:param backend_path: Additional path entries for the build backend spec
:param runner: The :ref:`subprocess runner <Subprocess Runners>` to use
:param python_executable:
The Python executable used to invoke the build backend
"""
if runner is None:
runner = default_subprocess_runner
self.source_dir = abspath(source_dir)
self.build_backend = build_backend
if backend_path:
backend_path = [norm_and_check(self.source_dir, p) for p in backend_path]
self.backend_path = backend_path
self._subprocess_runner = runner
if not python_executable:
python_executable = sys.executable
self.python_executable = python_executable
@contextmanager
def subprocess_runner(self, runner: "SubprocessRunner") -> Iterator[None]:
"""A context manager for temporarily overriding the default
:ref:`subprocess runner <Subprocess Runners>`.
:param runner: The new subprocess runner to use within the context.
.. code-block:: python
hook_caller = BuildBackendHookCaller(...)
with hook_caller.subprocess_runner(quiet_subprocess_runner):
...
"""
prev = self._subprocess_runner
self._subprocess_runner = runner
try:
yield
finally:
self._subprocess_runner = prev
def _supported_features(self) -> Sequence[str]:
"""Return the list of optional features supported by the backend."""
return self._call_hook("_supported_features", {})
def get_requires_for_build_wheel(
self,
config_settings: Optional[Mapping[str, Any]] = None,
) -> Sequence[str]:
"""Get additional dependencies required for building a wheel.
:param config_settings: The configuration settings for the build backend
:returns: A list of :pep:`dependency specifiers <508>`.
.. admonition:: Fallback
If the build backend does not defined a hook with this name, an
empty list will be returned.
"""
return self._call_hook(
"get_requires_for_build_wheel", {"config_settings": config_settings}
)
def prepare_metadata_for_build_wheel(
self,
metadata_directory: str,
config_settings: Optional[Mapping[str, Any]] = None,
_allow_fallback: bool = True,
) -> str:
"""Prepare a ``*.dist-info`` folder with metadata for this project.
:param metadata_directory: The directory to write the metadata to
:param config_settings: The configuration settings for the build backend
:param _allow_fallback:
Whether to allow the fallback to building a wheel and extracting
the metadata from it. Should be passed as a keyword argument only.
:returns: Name of the newly created subfolder within
``metadata_directory``, containing the metadata.
.. admonition:: Fallback
If the build backend does not define a hook with this name and
``_allow_fallback`` is truthy, the backend will be asked to build a
wheel via the ``build_wheel`` hook and the dist-info extracted from
that will be returned.
"""
return self._call_hook(
"prepare_metadata_for_build_wheel",
{
"metadata_directory": abspath(metadata_directory),
"config_settings": config_settings,
"_allow_fallback": _allow_fallback,
},
)
def build_wheel(
self,
wheel_directory: str,
config_settings: Optional[Mapping[str, Any]] = None,
metadata_directory: Optional[str] = None,
) -> str:
"""Build a wheel from this project.
:param wheel_directory: The directory to write the wheel to
:param config_settings: The configuration settings for the build backend
:param metadata_directory: The directory to reuse existing metadata from
:returns:
The name of the newly created wheel within ``wheel_directory``.
.. admonition:: Interaction with fallback
If the ``build_wheel`` hook was called in the fallback for
:meth:`prepare_metadata_for_build_wheel`, the build backend would
not be invoked. Instead, the previously built wheel will be copied
to ``wheel_directory`` and the name of that file will be returned.
"""
if metadata_directory is not None:
metadata_directory = abspath(metadata_directory)
return self._call_hook(
"build_wheel",
{
"wheel_directory": abspath(wheel_directory),
"config_settings": config_settings,
"metadata_directory": metadata_directory,
},
)
def get_requires_for_build_editable(
self,
config_settings: Optional[Mapping[str, Any]] = None,
) -> Sequence[str]:
"""Get additional dependencies required for building an editable wheel.
:param config_settings: The configuration settings for the build backend
:returns: A list of :pep:`dependency specifiers <508>`.
.. admonition:: Fallback
If the build backend does not defined a hook with this name, an
empty list will be returned.
"""
return self._call_hook(
"get_requires_for_build_editable", {"config_settings": config_settings}
)
def prepare_metadata_for_build_editable(
self,
metadata_directory: str,
config_settings: Optional[Mapping[str, Any]] = None,
_allow_fallback: bool = True,
) -> Optional[str]:
"""Prepare a ``*.dist-info`` folder with metadata for this project.
:param metadata_directory: The directory to write the metadata to
:param config_settings: The configuration settings for the build backend
:param _allow_fallback:
Whether to allow the fallback to building a wheel and extracting
the metadata from it. Should be passed as a keyword argument only.
:returns: Name of the newly created subfolder within
``metadata_directory``, containing the metadata.
.. admonition:: Fallback
If the build backend does not define a hook with this name and
``_allow_fallback`` is truthy, the backend will be asked to build a
wheel via the ``build_editable`` hook and the dist-info
extracted from that will be returned.
"""
return self._call_hook(
"prepare_metadata_for_build_editable",
{
"metadata_directory": abspath(metadata_directory),
"config_settings": config_settings,
"_allow_fallback": _allow_fallback,
},
)
def build_editable(
self,
wheel_directory: str,
config_settings: Optional[Mapping[str, Any]] = None,
metadata_directory: Optional[str] = None,
) -> str:
"""Build an editable wheel from this project.
:param wheel_directory: The directory to write the wheel to
:param config_settings: The configuration settings for the build backend
:param metadata_directory: The directory to reuse existing metadata from
:returns:
The name of the newly created wheel within ``wheel_directory``.
.. admonition:: Interaction with fallback
If the ``build_editable`` hook was called in the fallback for
:meth:`prepare_metadata_for_build_editable`, the build backend
would not be invoked. Instead, the previously built wheel will be
copied to ``wheel_directory`` and the name of that file will be
returned.
"""
if metadata_directory is not None:
metadata_directory = abspath(metadata_directory)
return self._call_hook(
"build_editable",
{
"wheel_directory": abspath(wheel_directory),
"config_settings": config_settings,
"metadata_directory": metadata_directory,
},
)
def get_requires_for_build_sdist(
self,
config_settings: Optional[Mapping[str, Any]] = None,
) -> Sequence[str]:
"""Get additional dependencies required for building an sdist.
:returns: A list of :pep:`dependency specifiers <508>`.
"""
return self._call_hook(
"get_requires_for_build_sdist", {"config_settings": config_settings}
)
def build_sdist(
self,
sdist_directory: str,
config_settings: Optional[Mapping[str, Any]] = None,
) -> str:
"""Build an sdist from this project.
:returns:
The name of the newly created sdist within ``wheel_directory``.
"""
return self._call_hook(
"build_sdist",
{
"sdist_directory": abspath(sdist_directory),
"config_settings": config_settings,
},
)
def _call_hook(self, hook_name: str, kwargs: Mapping[str, Any]) -> Any:
extra_environ = {"_PYPROJECT_HOOKS_BUILD_BACKEND": self.build_backend}
if self.backend_path:
backend_path = os.pathsep.join(self.backend_path)
extra_environ["_PYPROJECT_HOOKS_BACKEND_PATH"] = backend_path
with tempfile.TemporaryDirectory() as td:
hook_input = {"kwargs": kwargs}
write_json(hook_input, pjoin(td, "input.json"), indent=2)
# Run the hook in a subprocess
with _in_proc_script_path() as script:
python = self.python_executable
self._subprocess_runner(
[python, abspath(str(script)), hook_name, td],
cwd=self.source_dir,
extra_environ=extra_environ,
)
data = read_json(pjoin(td, "output.json"))
if data.get("unsupported"):
raise UnsupportedOperation(data.get("traceback", ""))
if data.get("no_backend"):
raise BackendUnavailable(
data.get("traceback", ""),
message=data.get("backend_error", ""),
backend_name=self.build_backend,
backend_path=self.backend_path,
)
if data.get("hook_missing"):
raise HookMissing(data.get("missing_hook_name") or hook_name)
return data["return_val"]

View file

@ -0,0 +1,21 @@
"""This is a subpackage because the directory is on sys.path for _in_process.py
The subpackage should stay as empty as possible to avoid shadowing modules that
the backend might import.
"""
import importlib.resources as resources
try:
resources.files
except AttributeError:
# Python 3.8 compatibility
def _in_proc_script_path():
return resources.path(__package__, "_in_process.py")
else:
def _in_proc_script_path():
return resources.as_file(
resources.files(__package__).joinpath("_in_process.py")
)

View file

@ -0,0 +1,373 @@
"""This is invoked in a subprocess to call the build backend hooks.
It expects:
- Command line args: hook_name, control_dir
- Environment variables:
_PYPROJECT_HOOKS_BUILD_BACKEND=entry.point:spec
_PYPROJECT_HOOKS_BACKEND_PATH=paths (separated with os.pathsep)
- control_dir/input.json:
- {"kwargs": {...}}
Results:
- control_dir/output.json
- {"return_val": ...}
"""
import json
import os
import os.path
import re
import shutil
import sys
import traceback
from glob import glob
from importlib import import_module
from importlib.machinery import PathFinder
from os.path import join as pjoin
# This file is run as a script, and `import wrappers` is not zip-safe, so we
# include write_json() and read_json() from wrappers.py.
def write_json(obj, path, **kwargs):
with open(path, "w", encoding="utf-8") as f:
json.dump(obj, f, **kwargs)
def read_json(path):
with open(path, encoding="utf-8") as f:
return json.load(f)
class BackendUnavailable(Exception):
"""Raised if we cannot import the backend"""
def __init__(self, message, traceback=None):
super().__init__(message)
self.message = message
self.traceback = traceback
class HookMissing(Exception):
"""Raised if a hook is missing and we are not executing the fallback"""
def __init__(self, hook_name=None):
super().__init__(hook_name)
self.hook_name = hook_name
def _build_backend():
"""Find and load the build backend"""
backend_path = os.environ.get("_PYPROJECT_HOOKS_BACKEND_PATH")
ep = os.environ["_PYPROJECT_HOOKS_BUILD_BACKEND"]
mod_path, _, obj_path = ep.partition(":")
if backend_path:
# Ensure in-tree backend directories have the highest priority when importing.
extra_pathitems = backend_path.split(os.pathsep)
sys.meta_path.insert(0, _BackendPathFinder(extra_pathitems, mod_path))
try:
obj = import_module(mod_path)
except ImportError:
msg = f"Cannot import {mod_path!r}"
raise BackendUnavailable(msg, traceback.format_exc())
if obj_path:
for path_part in obj_path.split("."):
obj = getattr(obj, path_part)
return obj
class _BackendPathFinder:
"""Implements the MetaPathFinder interface to locate modules in ``backend-path``.
Since the environment provided by the frontend can contain all sorts of
MetaPathFinders, the only way to ensure the backend is loaded from the
right place is to prepend our own.
"""
def __init__(self, backend_path, backend_module):
self.backend_path = backend_path
self.backend_module = backend_module
self.backend_parent, _, _ = backend_module.partition(".")
def find_spec(self, fullname, _path, _target=None):
if "." in fullname:
# Rely on importlib to find nested modules based on parent's path
return None
# Ignore other items in _path or sys.path and use backend_path instead:
spec = PathFinder.find_spec(fullname, path=self.backend_path)
if spec is None and fullname == self.backend_parent:
# According to the spec, the backend MUST be loaded from backend-path.
# Therefore, we can halt the import machinery and raise a clean error.
msg = f"Cannot find module {self.backend_module!r} in {self.backend_path!r}"
raise BackendUnavailable(msg)
return spec
def _supported_features():
"""Return the list of options features supported by the backend.
Returns a list of strings.
The only possible value is 'build_editable'.
"""
backend = _build_backend()
features = []
if hasattr(backend, "build_editable"):
features.append("build_editable")
return features
def get_requires_for_build_wheel(config_settings):
"""Invoke the optional get_requires_for_build_wheel hook
Returns [] if the hook is not defined.
"""
backend = _build_backend()
try:
hook = backend.get_requires_for_build_wheel
except AttributeError:
return []
else:
return hook(config_settings)
def get_requires_for_build_editable(config_settings):
"""Invoke the optional get_requires_for_build_editable hook
Returns [] if the hook is not defined.
"""
backend = _build_backend()
try:
hook = backend.get_requires_for_build_editable
except AttributeError:
return []
else:
return hook(config_settings)
def prepare_metadata_for_build_wheel(
metadata_directory, config_settings, _allow_fallback
):
"""Invoke optional prepare_metadata_for_build_wheel
Implements a fallback by building a wheel if the hook isn't defined,
unless _allow_fallback is False in which case HookMissing is raised.
"""
backend = _build_backend()
try:
hook = backend.prepare_metadata_for_build_wheel
except AttributeError:
if not _allow_fallback:
raise HookMissing()
else:
return hook(metadata_directory, config_settings)
# fallback to build_wheel outside the try block to avoid exception chaining
# which can be confusing to users and is not relevant
whl_basename = backend.build_wheel(metadata_directory, config_settings)
return _get_wheel_metadata_from_wheel(
whl_basename, metadata_directory, config_settings
)
def prepare_metadata_for_build_editable(
metadata_directory, config_settings, _allow_fallback
):
"""Invoke optional prepare_metadata_for_build_editable
Implements a fallback by building an editable wheel if the hook isn't
defined, unless _allow_fallback is False in which case HookMissing is
raised.
"""
backend = _build_backend()
try:
hook = backend.prepare_metadata_for_build_editable
except AttributeError:
if not _allow_fallback:
raise HookMissing()
try:
build_hook = backend.build_editable
except AttributeError:
raise HookMissing(hook_name="build_editable")
else:
whl_basename = build_hook(metadata_directory, config_settings)
return _get_wheel_metadata_from_wheel(
whl_basename, metadata_directory, config_settings
)
else:
return hook(metadata_directory, config_settings)
WHEEL_BUILT_MARKER = "PYPROJECT_HOOKS_ALREADY_BUILT_WHEEL"
def _dist_info_files(whl_zip):
"""Identify the .dist-info folder inside a wheel ZipFile."""
res = []
for path in whl_zip.namelist():
m = re.match(r"[^/\\]+-[^/\\]+\.dist-info/", path)
if m:
res.append(path)
if res:
return res
raise Exception("No .dist-info folder found in wheel")
def _get_wheel_metadata_from_wheel(whl_basename, metadata_directory, config_settings):
"""Extract the metadata from a wheel.
Fallback for when the build backend does not
define the 'get_wheel_metadata' hook.
"""
from zipfile import ZipFile
with open(os.path.join(metadata_directory, WHEEL_BUILT_MARKER), "wb"):
pass # Touch marker file
whl_file = os.path.join(metadata_directory, whl_basename)
with ZipFile(whl_file) as zipf:
dist_info = _dist_info_files(zipf)
zipf.extractall(path=metadata_directory, members=dist_info)
return dist_info[0].split("/")[0]
def _find_already_built_wheel(metadata_directory):
"""Check for a wheel already built during the get_wheel_metadata hook."""
if not metadata_directory:
return None
metadata_parent = os.path.dirname(metadata_directory)
if not os.path.isfile(pjoin(metadata_parent, WHEEL_BUILT_MARKER)):
return None
whl_files = glob(os.path.join(metadata_parent, "*.whl"))
if not whl_files:
print("Found wheel built marker, but no .whl files")
return None
if len(whl_files) > 1:
print(
"Found multiple .whl files; unspecified behaviour. "
"Will call build_wheel."
)
return None
# Exactly one .whl file
return whl_files[0]
def build_wheel(wheel_directory, config_settings, metadata_directory=None):
"""Invoke the mandatory build_wheel hook.
If a wheel was already built in the
prepare_metadata_for_build_wheel fallback, this
will copy it rather than rebuilding the wheel.
"""
prebuilt_whl = _find_already_built_wheel(metadata_directory)
if prebuilt_whl:
shutil.copy2(prebuilt_whl, wheel_directory)
return os.path.basename(prebuilt_whl)
return _build_backend().build_wheel(
wheel_directory, config_settings, metadata_directory
)
def build_editable(wheel_directory, config_settings, metadata_directory=None):
"""Invoke the optional build_editable hook.
If a wheel was already built in the
prepare_metadata_for_build_editable fallback, this
will copy it rather than rebuilding the wheel.
"""
backend = _build_backend()
try:
hook = backend.build_editable
except AttributeError:
raise HookMissing()
else:
prebuilt_whl = _find_already_built_wheel(metadata_directory)
if prebuilt_whl:
shutil.copy2(prebuilt_whl, wheel_directory)
return os.path.basename(prebuilt_whl)
return hook(wheel_directory, config_settings, metadata_directory)
def get_requires_for_build_sdist(config_settings):
"""Invoke the optional get_requires_for_build_wheel hook
Returns [] if the hook is not defined.
"""
backend = _build_backend()
try:
hook = backend.get_requires_for_build_sdist
except AttributeError:
return []
else:
return hook(config_settings)
class _DummyException(Exception):
"""Nothing should ever raise this exception"""
class GotUnsupportedOperation(Exception):
"""For internal use when backend raises UnsupportedOperation"""
def __init__(self, traceback):
self.traceback = traceback
def build_sdist(sdist_directory, config_settings):
"""Invoke the mandatory build_sdist hook."""
backend = _build_backend()
try:
return backend.build_sdist(sdist_directory, config_settings)
except getattr(backend, "UnsupportedOperation", _DummyException):
raise GotUnsupportedOperation(traceback.format_exc())
HOOK_NAMES = {
"get_requires_for_build_wheel",
"prepare_metadata_for_build_wheel",
"build_wheel",
"get_requires_for_build_editable",
"prepare_metadata_for_build_editable",
"build_editable",
"get_requires_for_build_sdist",
"build_sdist",
"_supported_features",
}
def main():
if len(sys.argv) < 3:
sys.exit("Needs args: hook_name, control_dir")
hook_name = sys.argv[1]
control_dir = sys.argv[2]
if hook_name not in HOOK_NAMES:
sys.exit("Unknown hook: %s" % hook_name)
hook = globals()[hook_name]
hook_input = read_json(pjoin(control_dir, "input.json"))
json_out = {"unsupported": False, "return_val": None}
try:
json_out["return_val"] = hook(**hook_input["kwargs"])
except BackendUnavailable as e:
json_out["no_backend"] = True
json_out["traceback"] = e.traceback
json_out["backend_error"] = e.message
except GotUnsupportedOperation as e:
json_out["unsupported"] = True
json_out["traceback"] = e.traceback
except HookMissing as e:
json_out["hook_missing"] = True
json_out["missing_hook_name"] = e.hook_name or hook_name
write_json(json_out, pjoin(control_dir, "output.json"), indent=2)
if __name__ == "__main__":
main()