/
niceSOFT
/
python3-build
Обзор
Документация
Войти
/
niceSOFT
/
python3-build
Код
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
src/build/_builder.py
433 строки
16 KB
Henry Schreiner
refactor: simplify code paths found in review (#1124)
03 июл 2026, 02:36
Не верифицирован
03 июл 2026, 02:36
43c337a
Код
Авторство
О чём код?
# SPDX-License-Identifier: MIT from __future__ import annotations __lazy_modules__ = [ 'contextlib', 'difflib', f'{__spec__.parent}._compat', f'{__spec__.parent}._exceptions', f'{__spec__.parent}._util', 'os', 'pyproject_hooks', 'subprocess', 'sys', 'warnings', 'zipfile', ] import contextlib import difflib import os import subprocess import sys import warnings import zipfile import pyproject_hooks from . import _ctx, env from ._compat import tomllib from ._exceptions import ( BuildBackendException, BuildException, BuildSystemTableValidationError, TypoWarning, ) from ._util import check_dependency TYPE_CHECKING = False if TYPE_CHECKING: import datetime from collections.abc import Iterable, Iterator, Mapping, Sequence from typing import TypedDict if sys.version_info < (3, 11): from typing_extensions import NotRequired, Self else: from typing import NotRequired, Self from ._types import ConfigSettings, Distribution, StrPath, SubprocessRunner # A value as produced by ``tomllib`` when parsing ``pyproject.toml``. Uses the covariant # ``Sequence``/``Mapping`` so concrete literals (e.g. ``dict[str, list[str]]``) are assignable. TOMLValue = ( str | int | float | bool | datetime.datetime | datetime.date | datetime.time | Sequence['TOMLValue'] | Mapping[str, 'TOMLValue'] ) # The validated ``[build-system]`` table. BuildSystemTable = TypedDict( 'BuildSystemTable', {'requires': list[str], 'build-backend': str, 'backend-path': NotRequired[list[str]]}, ) _DEFAULT_BACKEND: BuildSystemTable = { 'build-backend': 'setuptools.build_meta:__legacy__', 'requires': ['setuptools >= 40.8.0'], } def _find_typo(dictionary: Iterable[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, TOMLValue]: try: with open(path, 'rb') as f: return tomllib.load(f) except FileNotFoundError: return {} except PermissionError as e: # pragma: win32 no cover 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, TOMLValue]) -> BuildSystemTable: # 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 a fresh copy so callers mutating the result (e.g. the ``requires`` # list) cannot corrupt the shared default for later builders. return {**_DEFAULT_BACKEND, 'requires': list(_DEFAULT_BACKEND['requires'])} build_system = pyproject_toml['build-system'] if not isinstance(build_system, dict): msg = '`build-system` must be a table' raise BuildSystemTableValidationError(msg) # If [build-system] is present, it must have a ``requires`` field (per PEP 518) if 'requires' not in build_system: _find_typo(build_system, 'requires') msg = '`requires` is a required property' raise BuildSystemTableValidationError(msg) requires = build_system['requires'] if not isinstance(requires, list) or not all(isinstance(i, str) for i in requires): msg = '`requires` must be an array of strings' raise BuildSystemTableValidationError(msg) table: BuildSystemTable = { 'requires': requires, 'build-backend': _DEFAULT_BACKEND['build-backend'], } match build_system: case {'build-backend': str() as backend}: table['build-backend'] = backend case {'build-backend': _}: msg = '`build-backend` must be a string' raise BuildSystemTableValidationError(msg) case _: # If ``build-backend`` is missing, inject the legacy setuptools backend # but leave ``requires`` intact to emulate pip _find_typo(build_system, 'build-backend') match build_system: case {'backend-path': list() as backend_path} if all(isinstance(i, str) for i in backend_path): table['backend-path'] = backend_path case {'backend-path': _}: msg = '`backend-path` must be an array of strings' raise BuildSystemTableValidationError(msg) unknown_props = build_system.keys() - {'requires', 'build-backend', 'backend-path'} if unknown_props: msg = f'Unknown properties: {", ".join(unknown_props)}' raise BuildSystemTableValidationError(msg) return table def _validate_backend_path(source_dir: str, backend_paths: Sequence[str]) -> None: for path in backend_paths: resolved = os.path.join(source_dir, path) if not os.path.isdir(resolved): msg = f'`backend-path` entry {path!r} does not exist or is not a directory' raise BuildSystemTableValidationError(msg) def _wrap_subprocess_runner(runner: SubprocessRunner, env: env.IsolatedEnv) -> SubprocessRunner: def _invoke_wrapped_runner( cmd: Sequence[str], cwd: str | None = None, extra_environ: Mapping[str, str] | None = 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'] if backend_paths := self._build_system.get('backend-path'): _validate_backend_path(self._source_dir, backend_paths) 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, ) @classmethod def from_isolated_env( cls, env: env.IsolatedEnv, source_dir: StrPath, runner: SubprocessRunner = pyproject_hooks.default_subprocess_runner, ) -> Self: return cls( source_dir=source_dir, python_executable=env.python_executable, runner=_wrap_subprocess_runner(runner, env), ) @property def source_dir(self) -> str: """Project source directory.""" return self._source_dir @property def python_executable(self) -> str: """ The Python executable used to invoke the backend. """ return self._python_executable @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: 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}...', kind=('step',)) 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}...', kind=('step',)) 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}...', kind=('step',)) 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) try: whl = zipfile.ZipFile(wheel) except (OSError, zipfile.BadZipFile) as exception: msg = 'Invalid wheel' raise BuildException(msg) from exception with whl: names = whl.namelist() # The dist-info directory name cannot be reliably derived from the wheel filename (e.g. a build # tag makes the filename ambiguous), so find it by inspecting the wheel's contents instead. metadata_names = [name for name in names if name.count('/') == 1 and name.endswith('.dist-info/METADATA')] if len(metadata_names) != 1: msg = 'Invalid wheel' raise BuildException(msg) distinfo = metadata_names[0].rsplit('/', 1)[0] member_prefix = f'{distinfo}/' whl.extractall( output_directory, (member for member in names 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: str | bool ) -> str: outdir = os.path.abspath(outdir) callback = getattr(self._hook, hook_name) try: os.makedirs(outdir, exist_ok=True) except FileExistsError: msg = f"Build path '{outdir}' exists and is not a directory" raise BuildException(msg) from None except (NotADirectoryError, FileNotFoundError): msg = f"Build path '{outdir}' does not exist and cannot be a directory" raise BuildException(msg) from None 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]: 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