Spaces:
Paused
Paused
| # SPDX-License-Identifier: MIT | |
| from __future__ import annotations | |
| import contextlib | |
| import difflib | |
| import os | |
| import subprocess | |
| import sys | |
| import warnings | |
| import zipfile | |
| from collections.abc import Iterator | |
| from typing import Any, Mapping, Sequence, TypeVar | |
| import pyproject_hooks | |
| from . import _ctx, env | |
| from ._compat import tomllib | |
| from ._exceptions import ( | |
| BuildBackendException, | |
| BuildException, | |
| BuildSystemTableValidationError, | |
| TypoWarning, | |
| ) | |
| from ._types import ConfigSettings, Distribution, StrPath, SubprocessRunner | |
| from ._util import check_dependency, parse_wheel_filename | |
| _TProjectBuilder = TypeVar('_TProjectBuilder', bound='ProjectBuilder') | |
| _DEFAULT_BACKEND = { | |
| 'build-backend': 'setuptools.build_meta:__legacy__', | |
| 'requires': ['setuptools >= 40.8.0'], | |
| } | |
| def _find_typo(dictionary: Mapping[str, str], expected: str) -> None: | |
| for obj in dictionary: | |
| if difflib.SequenceMatcher(None, expected, obj).ratio() >= 0.8: | |
| warnings.warn( | |
| f"Found '{obj}' in pyproject.toml, did you mean '{expected}'?", | |
| TypoWarning, | |
| stacklevel=2, | |
| ) | |
| def _validate_source_directory(source_dir: StrPath) -> None: | |
| if not os.path.isdir(source_dir): | |
| msg = f'Source {source_dir} is not a directory' | |
| raise BuildException(msg) | |
| pyproject_toml = os.path.join(source_dir, 'pyproject.toml') | |
| setup_py = os.path.join(source_dir, 'setup.py') | |
| if not os.path.exists(pyproject_toml) and not os.path.exists(setup_py): | |
| msg = f'Source {source_dir} does not appear to be a Python project: no pyproject.toml or setup.py' | |
| raise BuildException(msg) | |
| def _read_pyproject_toml(path: StrPath) -> Mapping[str, Any]: | |
| try: | |
| with open(path, 'rb') as f: | |
| return tomllib.loads(f.read().decode()) | |
| except FileNotFoundError: | |
| return {} | |
| except PermissionError as e: | |
| msg = f"{e.strerror}: '{e.filename}' " | |
| raise BuildException(msg) from None | |
| except tomllib.TOMLDecodeError as e: | |
| msg = f'Failed to parse {path}: {e} ' | |
| raise BuildException(msg) from None | |
| def _parse_build_system_table(pyproject_toml: Mapping[str, Any]) -> Mapping[str, Any]: | |
| # If pyproject.toml is missing (per PEP 517) or [build-system] is missing | |
| # (per PEP 518), use default values | |
| if 'build-system' not in pyproject_toml: | |
| _find_typo(pyproject_toml, 'build-system') | |
| return _DEFAULT_BACKEND | |
| build_system_table = dict(pyproject_toml['build-system']) | |
| # If [build-system] is present, it must have a ``requires`` field (per PEP 518) | |
| if 'requires' not in build_system_table: | |
| _find_typo(build_system_table, 'requires') | |
| msg = '`requires` is a required property' | |
| raise BuildSystemTableValidationError(msg) | |
| elif not isinstance(build_system_table['requires'], list) or not all( | |
| isinstance(i, str) for i in build_system_table['requires'] | |
| ): | |
| msg = '`requires` must be an array of strings' | |
| raise BuildSystemTableValidationError(msg) | |
| if 'build-backend' not in build_system_table: | |
| _find_typo(build_system_table, 'build-backend') | |
| # If ``build-backend`` is missing, inject the legacy setuptools backend | |
| # but leave ``requires`` intact to emulate pip | |
| build_system_table['build-backend'] = _DEFAULT_BACKEND['build-backend'] | |
| elif not isinstance(build_system_table['build-backend'], str): | |
| msg = '`build-backend` must be a string' | |
| raise BuildSystemTableValidationError(msg) | |
| if 'backend-path' in build_system_table and ( | |
| not isinstance(build_system_table['backend-path'], list) | |
| or not all(isinstance(i, str) for i in build_system_table['backend-path']) | |
| ): | |
| msg = '`backend-path` must be an array of strings' | |
| raise BuildSystemTableValidationError(msg) | |
| unknown_props = build_system_table.keys() - {'requires', 'build-backend', 'backend-path'} | |
| if unknown_props: | |
| msg = f'Unknown properties: {", ".join(unknown_props)}' | |
| raise BuildSystemTableValidationError(msg) | |
| return build_system_table | |
| def _wrap_subprocess_runner(runner: SubprocessRunner, env: env.IsolatedEnv) -> SubprocessRunner: | |
| def _invoke_wrapped_runner(cmd: Sequence[str], cwd: str | None, extra_environ: Mapping[str, str] | None) -> None: | |
| runner(cmd, cwd, {**(env.make_extra_environ() or {}), **(extra_environ or {})}) | |
| return _invoke_wrapped_runner | |
| class ProjectBuilder: | |
| """ | |
| The PEP 517 consumer API. | |
| """ | |
| def __init__( | |
| self, | |
| source_dir: StrPath, | |
| python_executable: str = sys.executable, | |
| runner: SubprocessRunner = pyproject_hooks.default_subprocess_runner, | |
| ) -> None: | |
| """ | |
| :param source_dir: The source directory | |
| :param python_executable: The python executable where the backend lives | |
| :param runner: Runner for backend subprocesses | |
| The ``runner``, if provided, must accept the following arguments: | |
| - ``cmd``: a list of strings representing the command and arguments to | |
| execute, as would be passed to e.g. 'subprocess.check_call'. | |
| - ``cwd``: a string representing the working directory that must be | |
| used for the subprocess. Corresponds to the provided source_dir. | |
| - ``extra_environ``: a dict mapping environment variable names to values | |
| which must be set for the subprocess execution. | |
| The default runner simply calls the backend hooks in a subprocess, writing backend output | |
| to stdout/stderr. | |
| """ | |
| self._source_dir: str = os.path.abspath(source_dir) | |
| _validate_source_directory(source_dir) | |
| self._python_executable = python_executable | |
| self._runner = runner | |
| pyproject_toml_path = os.path.join(source_dir, 'pyproject.toml') | |
| self._build_system = _parse_build_system_table(_read_pyproject_toml(pyproject_toml_path)) | |
| self._backend = self._build_system['build-backend'] | |
| self._hook = pyproject_hooks.BuildBackendHookCaller( | |
| self._source_dir, | |
| self._backend, | |
| backend_path=self._build_system.get('backend-path'), | |
| python_executable=self._python_executable, | |
| runner=self._runner, | |
| ) | |
| def from_isolated_env( | |
| cls: type[_TProjectBuilder], | |
| env: env.IsolatedEnv, | |
| source_dir: StrPath, | |
| runner: SubprocessRunner = pyproject_hooks.default_subprocess_runner, | |
| ) -> _TProjectBuilder: | |
| return cls( | |
| source_dir=source_dir, | |
| python_executable=env.python_executable, | |
| runner=_wrap_subprocess_runner(runner, env), | |
| ) | |
| def source_dir(self) -> str: | |
| """Project source directory.""" | |
| return self._source_dir | |
| def python_executable(self) -> str: | |
| """ | |
| The Python executable used to invoke the backend. | |
| """ | |
| return self._python_executable | |
| def build_system_requires(self) -> set[str]: | |
| """ | |
| The dependencies defined in the ``pyproject.toml``'s | |
| ``build-system.requires`` field or the default build dependencies | |
| if ``pyproject.toml`` is missing or ``build-system`` is undefined. | |
| """ | |
| return set(self._build_system['requires']) | |
| def get_requires_for_build(self, distribution: Distribution, config_settings: ConfigSettings | None = None) -> set[str]: | |
| """ | |
| Return the dependencies defined by the backend in addition to | |
| :attr:`build_system_requires` for a given distribution. | |
| :param distribution: Distribution to get the dependencies of | |
| (``sdist`` or ``wheel``) | |
| :param config_settings: Config settings for the build backend | |
| """ | |
| _ctx.log(f'Getting build dependencies for {distribution}...') | |
| hook_name = f'get_requires_for_build_{distribution}' | |
| get_requires = getattr(self._hook, hook_name) | |
| with self._handle_backend(hook_name): | |
| return set(get_requires(config_settings)) | |
| def check_dependencies( | |
| self, distribution: Distribution, config_settings: ConfigSettings | None = None | |
| ) -> set[tuple[str, ...]]: | |
| """ | |
| Return the dependencies which are not satisfied from the combined set of | |
| :attr:`build_system_requires` and :meth:`get_requires_for_build` for a given | |
| distribution. | |
| :param distribution: Distribution to check (``sdist`` or ``wheel``) | |
| :param config_settings: Config settings for the build backend | |
| :returns: Set of variable-length unmet dependency tuples | |
| """ | |
| dependencies = self.get_requires_for_build(distribution, config_settings).union(self.build_system_requires) | |
| return {u for d in dependencies for u in check_dependency(d)} | |
| def prepare( | |
| self, | |
| distribution: Distribution, | |
| output_directory: StrPath, | |
| config_settings: ConfigSettings | None = None, | |
| ) -> str | None: | |
| """ | |
| Prepare metadata for a distribution. | |
| :param distribution: Distribution to build (must be ``wheel``) | |
| :param output_directory: Directory to put the prepared metadata in | |
| :param config_settings: Config settings for the build backend | |
| :returns: The full path to the prepared metadata directory | |
| """ | |
| _ctx.log(f'Getting metadata for {distribution}...') | |
| try: | |
| return self._call_backend( | |
| f'prepare_metadata_for_build_{distribution}', | |
| output_directory, | |
| config_settings, | |
| _allow_fallback=False, | |
| ) | |
| except BuildBackendException as exception: | |
| if isinstance(exception.exception, pyproject_hooks.HookMissing): | |
| return None | |
| raise | |
| def build( | |
| self, | |
| distribution: Distribution, | |
| output_directory: StrPath, | |
| config_settings: ConfigSettings | None = None, | |
| metadata_directory: str | None = None, | |
| ) -> str: | |
| """ | |
| Build a distribution. | |
| :param distribution: Distribution to build (``sdist`` or ``wheel``) | |
| :param output_directory: Directory to put the built distribution in | |
| :param config_settings: Config settings for the build backend | |
| :param metadata_directory: If provided, should be the return value of a | |
| previous ``prepare`` call on the same ``distribution`` kind | |
| :returns: The full path to the built distribution | |
| """ | |
| _ctx.log(f'Building {distribution}...') | |
| kwargs = {} if metadata_directory is None else {'metadata_directory': metadata_directory} | |
| return self._call_backend(f'build_{distribution}', output_directory, config_settings, **kwargs) | |
| def metadata_path(self, output_directory: StrPath) -> str: | |
| """ | |
| Generate the metadata directory of a distribution and return its path. | |
| If the backend does not support the ``prepare_metadata_for_build_wheel`` | |
| hook, a wheel will be built and the metadata will be extracted from it. | |
| :param output_directory: Directory to put the metadata distribution in | |
| :returns: The path of the metadata directory | |
| """ | |
| # prepare_metadata hook | |
| metadata = self.prepare('wheel', output_directory) | |
| if metadata is not None: | |
| return metadata | |
| # fallback to build_wheel hook | |
| wheel = self.build('wheel', output_directory) | |
| match = parse_wheel_filename(os.path.basename(wheel)) | |
| if not match: | |
| msg = 'Invalid wheel' | |
| raise ValueError(msg) | |
| distinfo = f"{match['distribution']}-{match['version']}.dist-info" | |
| member_prefix = f'{distinfo}/' | |
| with zipfile.ZipFile(wheel) as w: | |
| w.extractall( | |
| output_directory, | |
| (member for member in w.namelist() if member.startswith(member_prefix)), | |
| ) | |
| return os.path.join(output_directory, distinfo) | |
| def _call_backend( | |
| self, hook_name: str, outdir: StrPath, config_settings: ConfigSettings | None = None, **kwargs: Any | |
| ) -> str: | |
| outdir = os.path.abspath(outdir) | |
| callback = getattr(self._hook, hook_name) | |
| if os.path.exists(outdir): | |
| if not os.path.isdir(outdir): | |
| msg = f"Build path '{outdir}' exists and is not a directory" | |
| raise BuildException(msg) | |
| else: | |
| os.makedirs(outdir) | |
| with self._handle_backend(hook_name): | |
| basename: str = callback(outdir, config_settings, **kwargs) | |
| return os.path.join(outdir, basename) | |
| def _handle_backend(self, hook: str) -> Iterator[None]: | |
| try: | |
| yield | |
| except pyproject_hooks.BackendUnavailable as exception: | |
| raise BuildBackendException( | |
| exception, | |
| f"Backend '{self._backend}' is not available.", | |
| sys.exc_info(), | |
| ) from None | |
| except subprocess.CalledProcessError as exception: | |
| raise BuildBackendException(exception, f'Backend subprocess exited when trying to invoke {hook}') from None | |
| except Exception as exception: | |
| raise BuildBackendException(exception, exc_info=sys.exc_info()) from None | |