Blob Blame History Raw
"""
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