"""
Code for working with pyproject backends
"""
from __future__ import annotations
import dataclasses
import os
import subprocess
import sys
from collections.abc import Callable, Mapping, Sequence
from pathlib import Path
from typing import TYPE_CHECKING, Any, Optional, Union
from pyproject_hooks import BuildBackendHookCaller as _BuildBackendHookCaller
if TYPE_CHECKING:
StrPath = Union[str, os.PathLike[str]]
# This should match the interface of pyproject_hooks.default_subprocess_runner
RunnerFuncT = Callable[
[Sequence[StrPath], StrPath, Optional[Mapping[str, str]]], None
]
@dataclasses.dataclass
class BackendRequirement:
"""
Represents a backend requirement and the reason it was added
"""
name: str
reason: str | None = None
class BackendHookError(Exception):
"""
Unhandled backend hook failure
"""
def __init__(self, hook_name: str, returncode: int) -> None:
self.hook_name = hook_name
self.returncode = returncode
super().__init__(f"Failed to call {self.hook_name} hook")
def __repr__(self) -> str:
hook_name, returncode = self.hook_name, self.returncode
return f"BuildBackendHookCaller({hook_name=}, {returncode=})"
class BuildBackendHookCaller(_BuildBackendHookCaller):
"""
pyproject_hooks.BuildBackendHookCaller subclass that wraps backend errors
"""
def _call_hook(self, hook_name, kwargs):
try:
return super()._call_hook(hook_name, kwargs)
except subprocess.CalledProcessError as exc:
raise BackendHookError(hook_name, exc.returncode) from None
DEFAULT_BUILD_BACKEND = "setuptools.build_meta:__legacy__"
DEFAULT_BUILD_REQUIRES = (
BackendRequirement("setuptools >= 40.8", reason="default build backend"),
BackendRequirement("wheel", reason="default build backend"),
)
def stderr_subprocess_runner(
cmd: Sequence[StrPath], cwd: StrPath, extra_environ: Mapping[str, str] | None = None
) -> None:
"""
Alternative to pyproject_hooks.default_subprocess_runner that redirects all
stdout to stderr.
"""
extra_environ = extra_environ or {}
subprocess.run(
cmd,
stdout=sys.stderr,
cwd=cwd,
env={**os.environ, **extra_environ},
check=True,
)
class _Sentinel:
pass
@dataclasses.dataclass
class BuildBackend:
"""
Represents a build backend
"""
name: str
requires: list[BackendRequirement]
path: list[str] | None = None
directory: Path = Path()
default_runner: RunnerFuncT | None = None
@classmethod
def from_pyproject_data(
cls,
data: dict[str, Any],
directory: Path = Path(),
default_runner: RunnerFuncT | None = None,
) -> BuildBackend:
"""
Create a BuildBackend object based on a parsed pyproject.toml dictionary
"""
buildsystem_data: dict[str, Any] = data.get("build-system", {})
requires: list[BackendRequirement] = [
BackendRequirement(i, reason="build-system.requires")
for i in buildsystem_data.get("requires", [])
]
backend_name: str | None = buildsystem_data.get("build-backend")
if not backend_name:
# https://www.python.org/dev/peps/pep-0517/:
# If the pyproject.toml file is absent, or the build-backend key is
# missing, the source tree is not using this specification, and tools
# should revert to the legacy behaviour of running setup.py
# (either directly, or by implicitly invoking the [following] backend).
# If setup.py is also not present program will mimick pip's behavior
# and end with an error.
if not (directory / "setup.py").exists():
raise FileNotFoundError('"setup.py" not found for legacy project.')
backend_name = DEFAULT_BUILD_BACKEND
# Note: For projects without pyproject.toml, this was already echoed
# by the %pyproject_buildrequires macro, but this also handles cases
# with pyproject.toml without a specified build backend.
# If the default requirements change, also change them in the macro!
requires.extend(DEFAULT_BUILD_REQUIRES)
backend_path: list[str] = buildsystem_data.get("backend-path", [])
if isinstance(backend_path, str):
backend_path = [backend_path]
return cls(
name=backend_name,
requires=requires,
path=backend_path,
directory=directory,
default_runner=default_runner,
)
@classmethod
def from_directory(
cls, directory: Path = Path(), default_runner: RunnerFuncT | None = None
) -> BuildBackend:
"""
Create a BuildBackend object based on the pyproject.toml in `directory`.
"""
if sys.version_info[:2] >= (3, 11):
import tomllib
else:
import tomli as tomllib
try:
f = (directory / "pyproject.toml").open("rb")
except FileNotFoundError:
pyproject_data = {}
else:
with f:
pyproject_data = tomllib.load(f)
return cls.from_pyproject_data(
pyproject_data, directory, default_runner=default_runner
)
_DEFAULT_RUNNER = _Sentinel()
def get_caller(
self,
runner: RunnerFuncT | _Sentinel | None = _DEFAULT_RUNNER,
) -> BuildBackendHookCaller:
"""
Create a BuildBackendHookCaller object based on this BuildBackend
"""
if runner is self._DEFAULT_RUNNER:
runner = self.default_runner
return BuildBackendHookCaller(self.directory, self.name, self.path, runner)
def parse_config_settings_args(config_settings):
"""
Given a list of config `KEY=VALUE` formatted config settings,
return a dictionary that can be passed to PEP 517 hook functions.
"""
if not config_settings:
return config_settings
new_config_settings = {}
for arg in config_settings:
key, _, value = arg.partition('=')
if key in new_config_settings:
if not isinstance(new_config_settings[key], list):
# convert the existing value to a list
new_config_settings[key] = [new_config_settings[key]]
new_config_settings[key].append(value)
else:
new_config_settings[key] = value
return new_config_settings