summaryrefslogtreecommitdiffstats
path: root/src/build/__init__.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/build/__init__.py')
-rw-r--r--src/build/__init__.py539
1 files changed, 539 insertions, 0 deletions
diff --git a/src/build/__init__.py b/src/build/__init__.py
new file mode 100644
index 0000000..0425a85
--- /dev/null
+++ b/src/build/__init__.py
@@ -0,0 +1,539 @@
+# SPDX-License-Identifier: MIT
+
+"""
+build - A simple, correct PEP 517 build frontend
+"""
+
+__version__ = '0.9.0'
+
+import contextlib
+import difflib
+import logging
+import os
+import re
+import subprocess
+import sys
+import textwrap
+import types
+import warnings
+import zipfile
+
+from collections import OrderedDict
+from typing import (
+ AbstractSet,
+ Any,
+ Callable,
+ Dict,
+ Iterator,
+ List,
+ Mapping,
+ MutableMapping,
+ Optional,
+ Sequence,
+ Set,
+ Tuple,
+ Type,
+ Union,
+)
+
+import pep517.wrappers
+
+
+TOMLDecodeError: Type[Exception]
+toml_loads: Callable[[str], MutableMapping[str, Any]]
+
+if sys.version_info >= (3, 11):
+ from tomllib import TOMLDecodeError
+ from tomllib import loads as toml_loads
+else:
+ try:
+ from tomli import TOMLDecodeError
+ from tomli import loads as toml_loads
+ except ModuleNotFoundError: # pragma: no cover
+ from toml import TomlDecodeError as TOMLDecodeError # type: ignore[import,no-redef]
+ from toml import loads as toml_loads # type: ignore[no-redef]
+
+
+RunnerType = Callable[[Sequence[str], Optional[str], Optional[Mapping[str, str]]], None]
+ConfigSettingsType = Mapping[str, Union[str, Sequence[str]]]
+PathType = Union[str, 'os.PathLike[str]']
+_ExcInfoType = Union[Tuple[Type[BaseException], BaseException, types.TracebackType], Tuple[None, None, None]]
+
+
+_WHEEL_NAME_REGEX = re.compile(
+ r'(?P<distribution>.+)-(?P<version>.+)'
+ r'(-(?P<build_tag>.+))?-(?P<python_tag>.+)'
+ r'-(?P<abi_tag>.+)-(?P<platform_tag>.+)\.whl'
+)
+
+
+_DEFAULT_BACKEND = {
+ 'build-backend': 'setuptools.build_meta:__legacy__',
+ 'requires': ['setuptools >= 40.8.0', 'wheel'],
+}
+
+
+_logger = logging.getLogger(__name__)
+
+
+class BuildException(Exception):
+ """
+ Exception raised by :class:`ProjectBuilder`
+ """
+
+
+class BuildBackendException(Exception):
+ """
+ Exception raised when a backend operation fails
+ """
+
+ def __init__(
+ self, exception: Exception, description: Optional[str] = None, exc_info: _ExcInfoType = (None, None, None)
+ ) -> None:
+ super().__init__()
+ self.exception = exception
+ self.exc_info = exc_info
+ self._description = description
+
+ def __str__(self) -> str:
+ if self._description:
+ return self._description
+ return f'Backend operation failed: {self.exception!r}'
+
+
+class BuildSystemTableValidationError(BuildException):
+ """
+ Exception raised when the ``[build-system]`` table in pyproject.toml is invalid.
+ """
+
+ def __str__(self) -> str:
+ return f'Failed to validate `build-system` in pyproject.toml: {self.args[0]}'
+
+
+class FailedProcessError(Exception):
+ """
+ Exception raised when an setup or prepration operation fails.
+ """
+
+ def __init__(self, exception: subprocess.CalledProcessError, description: str) -> None:
+ super().__init__()
+ self.exception = exception
+ self._description = description
+
+ def __str__(self) -> str:
+ cmd = ' '.join(self.exception.cmd)
+ description = f"{self._description}\n Command '{cmd}' failed with return code {self.exception.returncode}"
+ for stream_name in ('stdout', 'stderr'):
+ stream = getattr(self.exception, stream_name)
+ if stream:
+ description += f'\n {stream_name}:\n'
+ description += textwrap.indent(stream.decode(), ' ')
+ return description
+
+
+class TypoWarning(Warning):
+ """
+ Warning raised when a possible typo is found
+ """
+
+
+@contextlib.contextmanager
+def _working_directory(path: str) -> Iterator[None]:
+ current = os.getcwd()
+
+ os.chdir(path)
+
+ try:
+ yield
+ finally:
+ os.chdir(current)
+
+
+def _validate_source_directory(srcdir: PathType) -> None:
+ if not os.path.isdir(srcdir):
+ raise BuildException(f'Source {srcdir} is not a directory')
+ pyproject_toml = os.path.join(srcdir, 'pyproject.toml')
+ setup_py = os.path.join(srcdir, 'setup.py')
+ if not os.path.exists(pyproject_toml) and not os.path.exists(setup_py):
+ raise BuildException(f'Source {srcdir} does not appear to be a Python project: no pyproject.toml or setup.py')
+
+
+def check_dependency(
+ req_string: str, ancestral_req_strings: Tuple[str, ...] = (), parent_extras: AbstractSet[str] = frozenset()
+) -> Iterator[Tuple[str, ...]]:
+ """
+ Verify that a dependency and all of its dependencies are met.
+
+ :param req_string: Requirement string
+ :param parent_extras: Extras (eg. "test" in myproject[test])
+ :yields: Unmet dependencies
+ """
+ import packaging.requirements
+
+ if sys.version_info >= (3, 8):
+ import importlib.metadata as importlib_metadata
+ else:
+ import importlib_metadata
+
+ req = packaging.requirements.Requirement(req_string)
+ normalised_req_string = str(req)
+
+ # ``Requirement`` doesn't implement ``__eq__`` so we cannot compare reqs for
+ # equality directly but the string representation is stable.
+ if normalised_req_string in ancestral_req_strings:
+ # cyclical dependency, already checked.
+ return
+
+ if req.marker:
+ extras = frozenset(('',)).union(parent_extras)
+ # a requirement can have multiple extras but ``evaluate`` can
+ # only check one at a time.
+ if all(not req.marker.evaluate(environment={'extra': e}) for e in extras):
+ # if the marker conditions are not met, we pretend that the
+ # dependency is satisfied.
+ return
+
+ try:
+ dist = importlib_metadata.distribution(req.name) # type: ignore[no-untyped-call]
+ except importlib_metadata.PackageNotFoundError:
+ # dependency is not installed in the environment.
+ yield ancestral_req_strings + (normalised_req_string,)
+ else:
+ if req.specifier and not req.specifier.contains(dist.version, prereleases=True):
+ # the installed version is incompatible.
+ yield ancestral_req_strings + (normalised_req_string,)
+ elif dist.requires:
+ for other_req_string in dist.requires:
+ # yields transitive dependencies that are not satisfied.
+ yield from check_dependency(other_req_string, ancestral_req_strings + (normalised_req_string,), req.extras)
+
+
+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,
+ )
+
+
+def _parse_build_system_table(pyproject_toml: Mapping[str, Any]) -> Dict[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')
+ raise BuildSystemTableValidationError('`requires` is a required property')
+ elif not isinstance(build_system_table['requires'], list) or not all(
+ isinstance(i, str) for i in build_system_table['requires']
+ ):
+ raise BuildSystemTableValidationError('`requires` must be an array of strings')
+
+ 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):
+ raise BuildSystemTableValidationError('`build-backend` must be a string')
+
+ 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'])
+ ):
+ raise BuildSystemTableValidationError('`backend-path` must be an array of strings')
+
+ unknown_props = build_system_table.keys() - {'requires', 'build-backend', 'backend-path'}
+ if unknown_props:
+ raise BuildSystemTableValidationError(f'Unknown properties: {", ".join(unknown_props)}')
+
+ return build_system_table
+
+
+class ProjectBuilder:
+ """
+ The PEP 517 consumer API.
+ """
+
+ def __init__(
+ self,
+ srcdir: PathType,
+ python_executable: str = sys.executable,
+ scripts_dir: Optional[str] = None,
+ runner: RunnerType = pep517.wrappers.default_subprocess_runner,
+ ) -> None:
+ """
+ :param srcdir: The source directory
+ :param scripts_dir: The location of the scripts dir (defaults to the folder where the python executable lives)
+ :param python_executable: The python executable where the backend lives
+ :param runner: An alternative 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 srcdir.
+ - 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._srcdir: str = os.path.abspath(srcdir)
+ _validate_source_directory(srcdir)
+
+ spec_file = os.path.join(srcdir, 'pyproject.toml')
+
+ try:
+ with open(spec_file, 'rb') as f:
+ spec = toml_loads(f.read().decode())
+ except FileNotFoundError:
+ spec = {}
+ except PermissionError as e:
+ raise BuildException(f"{e.strerror}: '{e.filename}' ") # noqa: B904 # use raise from
+ except TOMLDecodeError as e:
+ raise BuildException(f'Failed to parse {spec_file}: {e} ') # noqa: B904 # use raise from
+
+ self._build_system = _parse_build_system_table(spec)
+ self._backend = self._build_system['build-backend']
+ self._scripts_dir = scripts_dir
+ self._hook_runner = runner
+ self._hook = pep517.wrappers.Pep517HookCaller(
+ self.srcdir,
+ self._backend,
+ backend_path=self._build_system.get('backend-path'),
+ python_executable=python_executable,
+ runner=self._runner,
+ )
+
+ def _runner(
+ self, cmd: Sequence[str], cwd: Optional[str] = None, extra_environ: Optional[Mapping[str, str]] = None
+ ) -> None:
+ # if script dir is specified must be inserted at the start of PATH (avoid duplicate path while doing so)
+ if self.scripts_dir is not None:
+ paths: Dict[str, None] = OrderedDict()
+ paths[str(self.scripts_dir)] = None
+ if 'PATH' in os.environ:
+ paths.update((i, None) for i in os.environ['PATH'].split(os.pathsep))
+ extra_environ = {} if extra_environ is None else dict(extra_environ)
+ extra_environ['PATH'] = os.pathsep.join(paths)
+ self._hook_runner(cmd, cwd, extra_environ)
+
+ @property
+ def srcdir(self) -> str:
+ """Project source directory."""
+ return self._srcdir
+
+ @property
+ def python_executable(self) -> str:
+ """
+ The Python executable used to invoke the backend.
+ """
+ # make mypy happy
+ exe: str = self._hook.python_executable
+ return exe
+
+ @python_executable.setter
+ def python_executable(self, value: str) -> None:
+ self._hook.python_executable = value
+
+ @property
+ def scripts_dir(self) -> Optional[str]:
+ """
+ The folder where the scripts are stored for the python executable.
+ """
+ return self._scripts_dir
+
+ @scripts_dir.setter
+ def scripts_dir(self, value: Optional[str]) -> None:
+ self._scripts_dir = value
+
+ @property
+ 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: str, config_settings: Optional[ConfigSettingsType] = 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
+ """
+ self.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: str, config_settings: Optional[ConfigSettingsType] = 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: str, output_directory: PathType, config_settings: Optional[ConfigSettingsType] = None
+ ) -> Optional[str]:
+ """
+ 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
+ """
+ self.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, pep517.wrappers.HookMissing):
+ return None
+ raise
+
+ def build(
+ self,
+ distribution: str,
+ output_directory: PathType,
+ config_settings: Optional[ConfigSettingsType] = None,
+ metadata_directory: Optional[str] = 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
+ """
+ self.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: PathType) -> 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 = _WHEEL_NAME_REGEX.match(os.path.basename(wheel))
+ if not match:
+ raise ValueError('Invalid wheel')
+ 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: PathType, config_settings: Optional[ConfigSettingsType] = 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):
+ raise BuildException(f"Build path '{outdir}' exists and is not a directory")
+ else:
+ os.makedirs(outdir)
+
+ with self._handle_backend(hook_name):
+ basename: str = callback(outdir, config_settings, **kwargs)
+
+ return os.path.join(outdir, basename)
+
+ @contextlib.contextmanager
+ def _handle_backend(self, hook: str) -> Iterator[None]:
+ with _working_directory(self.srcdir):
+ try:
+ yield
+ except pep517.wrappers.BackendUnavailable as exception:
+ raise BuildBackendException( # noqa: B904 # use raise from
+ exception,
+ f"Backend '{self._backend}' is not available.",
+ sys.exc_info(),
+ )
+ except subprocess.CalledProcessError as exception:
+ raise BuildBackendException( # noqa: B904 # use raise from
+ exception, f'Backend subprocess exited when trying to invoke {hook}'
+ )
+ except Exception as exception:
+ raise BuildBackendException(exception, exc_info=sys.exc_info()) # noqa: B904 # use raise from
+
+ @staticmethod
+ def log(message: str) -> None:
+ """
+ Log a message.
+
+ The default implementation uses the logging module but this function can be
+ overridden by users to have a different implementation.
+
+ :param message: Message to output
+ """
+ if sys.version_info >= (3, 8):
+ _logger.log(logging.INFO, message, stacklevel=2)
+ else:
+ _logger.log(logging.INFO, message)
+
+
+__all__ = [
+ '__version__',
+ 'BuildSystemTableValidationError',
+ 'BuildBackendException',
+ 'BuildException',
+ 'ConfigSettingsType',
+ 'FailedProcessError',
+ 'ProjectBuilder',
+ 'RunnerType',
+ 'TypoWarning',
+ 'check_dependency',
+]
+
+
+def __dir__() -> List[str]:
+ return __all__