codekingpro/portable-devtools
114k
1# SPDX-License-Identifier: MIT2 3from __future__ import annotations4 5 6__lazy_modules__ = [7 'contextlib',8 'difflib',9 f'{__spec__.parent}._compat',10 f'{__spec__.parent}._exceptions',11 f'{__spec__.parent}._util',12 'os',13 'pyproject_hooks',14 'subprocess',15 'sys',16 'warnings',17 'zipfile',18]19 20import contextlib21import difflib22import os23import subprocess24import sys25import warnings26import zipfile27 28from typing import Any29 30import pyproject_hooks31 32from . import _ctx, env33from ._compat import tomllib34from ._exceptions import (35 BuildBackendException,36 BuildException,37 BuildSystemTableValidationError,38 TypoWarning,39)40from ._util import check_dependency, parse_wheel_filename41 42 43TYPE_CHECKING = False44 45if TYPE_CHECKING:46 from collections.abc import Iterator, Mapping, Sequence47 48 if sys.version_info < (3, 11):49 from typing_extensions import Self50 else:51 from typing import Self52 53 from ._types import ConfigSettings, Distribution, StrPath, SubprocessRunner54 55 56_DEFAULT_BACKEND = {57 'build-backend': 'setuptools.build_meta:__legacy__',58 'requires': ['setuptools >= 40.8.0'],59}60 61 62def _find_typo(dictionary: Mapping[str, str], expected: str) -> None:63 for obj in dictionary:64 if difflib.SequenceMatcher(None, expected, obj).ratio() >= 0.8:65 warnings.warn(66 f"Found '{obj}' in pyproject.toml, did you mean '{expected}'?",67 TypoWarning,68 stacklevel=2,69 )70 71 72def _validate_source_directory(source_dir: StrPath) -> None:73 if not os.path.isdir(source_dir):74 msg = f'Source {source_dir} is not a directory'75 raise BuildException(msg)76 pyproject_toml = os.path.join(source_dir, 'pyproject.toml')77 setup_py = os.path.join(source_dir, 'setup.py')78 if not os.path.exists(pyproject_toml) and not os.path.exists(setup_py):79 msg = f'Source {source_dir} does not appear to be a Python project: no pyproject.toml or setup.py'80 raise BuildException(msg)81 82 83def _read_pyproject_toml(path: StrPath) -> Mapping[str, Any]:84 try:85 with open(path, 'rb') as f:86 return tomllib.loads(f.read().decode())87 except FileNotFoundError:88 return {}89 except PermissionError as e: # pragma: win32 no cover90 msg = f"{e.strerror}: '{e.filename}' "91 raise BuildException(msg) from None92 except tomllib.TOMLDecodeError as e:93 msg = f'Failed to parse {path}: {e} '94 raise BuildException(msg) from None95 96 97def _parse_build_system_table(pyproject_toml: Mapping[str, Any]) -> Mapping[str, Any]:98 # If pyproject.toml is missing (per PEP 517) or [build-system] is missing99 # (per PEP 518), use default values100 if 'build-system' not in pyproject_toml:101 _find_typo(pyproject_toml, 'build-system')102 return _DEFAULT_BACKEND103 104 build_system_table = dict(pyproject_toml['build-system'])105 106 # If [build-system] is present, it must have a ``requires`` field (per PEP 518)107 if 'requires' not in build_system_table:108 _find_typo(build_system_table, 'requires')109 msg = '`requires` is a required property'110 raise BuildSystemTableValidationError(msg)111 if not isinstance(build_system_table['requires'], list) or not all(112 isinstance(i, str) for i in build_system_table['requires']113 ):114 msg = '`requires` must be an array of strings'115 raise BuildSystemTableValidationError(msg)116 117 match build_system_table:118 case {'build-backend': str()}:119 pass120 case {'build-backend': _}:121 msg = '`build-backend` must be a string'122 raise BuildSystemTableValidationError(msg)123 case _:124 _find_typo(build_system_table, 'build-backend')125 # If ``build-backend`` is missing, inject the legacy setuptools backend126 # but leave ``requires`` intact to emulate pip127 build_system_table['build-backend'] = _DEFAULT_BACKEND['build-backend']128 129 match build_system_table:130 case {'backend-path': list() as backend_path} if all(isinstance(i, str) for i in backend_path):131 pass132 case {'backend-path': _}:133 msg = '`backend-path` must be an array of strings'134 raise BuildSystemTableValidationError(msg)135 136 unknown_props = build_system_table.keys() - {'requires', 'build-backend', 'backend-path'}137 if unknown_props:138 msg = f'Unknown properties: {", ".join(unknown_props)}'139 raise BuildSystemTableValidationError(msg)140 141 return build_system_table142 143 144def _validate_backend_path(source_dir: str, backend_paths: Sequence[str]) -> None:145 for path in backend_paths:146 resolved = os.path.join(source_dir, path)147 if not os.path.isdir(resolved):148 msg = f'`backend-path` entry {path!r} does not exist or is not a directory'149 raise BuildSystemTableValidationError(msg)150 151 152def _wrap_subprocess_runner(runner: SubprocessRunner, env: env.IsolatedEnv) -> SubprocessRunner:153 def _invoke_wrapped_runner(154 cmd: Sequence[str], cwd: str | None = None, extra_environ: Mapping[str, str] | None = None155 ) -> None:156 runner(cmd, cwd, {**(env.make_extra_environ() or {}), **(extra_environ or {})})157 158 return _invoke_wrapped_runner159 160 161class ProjectBuilder:162 """163 The PEP 517 consumer API.164 """165 166 def __init__(167 self,168 source_dir: StrPath,169 python_executable: str = sys.executable,170 runner: SubprocessRunner = pyproject_hooks.default_subprocess_runner,171 ) -> None:172 """173 :param source_dir: The source directory174 :param python_executable: The python executable where the backend lives175 :param runner: Runner for backend subprocesses176 177 The ``runner``, if provided, must accept the following arguments:178 179 - ``cmd``: a list of strings representing the command and arguments to180 execute, as would be passed to e.g. 'subprocess.check_call'.181 - ``cwd``: a string representing the working directory that must be182 used for the subprocess. Corresponds to the provided source_dir.183 - ``extra_environ``: a dict mapping environment variable names to values184 which must be set for the subprocess execution.185 186 The default runner simply calls the backend hooks in a subprocess, writing backend output187 to stdout/stderr.188 """189 self._source_dir: str = os.path.abspath(source_dir)190 _validate_source_directory(source_dir)191 192 self._python_executable = python_executable193 self._runner = runner194 195 pyproject_toml_path = os.path.join(source_dir, 'pyproject.toml')196 self._build_system = _parse_build_system_table(_read_pyproject_toml(pyproject_toml_path))197 198 self._backend = self._build_system['build-backend']199 200 if backend_paths := self._build_system.get('backend-path'):201 _validate_backend_path(self._source_dir, backend_paths)202 203 self._hook = pyproject_hooks.BuildBackendHookCaller(204 self._source_dir,205 self._backend,206 backend_path=self._build_system.get('backend-path'),207 python_executable=self._python_executable,208 runner=self._runner,209 )210 211 @classmethod212 def from_isolated_env(213 cls,214 env: env.IsolatedEnv,215 source_dir: StrPath,216 runner: SubprocessRunner = pyproject_hooks.default_subprocess_runner,217 ) -> Self:218 return cls(219 source_dir=source_dir,220 python_executable=env.python_executable,221 runner=_wrap_subprocess_runner(runner, env),222 )223 224 @property225 def source_dir(self) -> str:226 """Project source directory."""227 return self._source_dir228 229 @property230 def python_executable(self) -> str:231 """232 The Python executable used to invoke the backend.233 """234 return self._python_executable235 236 @property237 def build_system_requires(self) -> set[str]:238 """239 The dependencies defined in the ``pyproject.toml``'s240 ``build-system.requires`` field or the default build dependencies241 if ``pyproject.toml`` is missing or ``build-system`` is undefined.242 """243 return set(self._build_system['requires'])244 245 def get_requires_for_build(246 self,247 distribution: Distribution,248 config_settings: ConfigSettings | None = None,249 ) -> set[str]:250 """251 Return the dependencies defined by the backend in addition to252 :attr:`build_system_requires` for a given distribution.253 254 :param distribution: Distribution to get the dependencies of255 (``sdist`` or ``wheel``)256 :param config_settings: Config settings for the build backend257 """258 _ctx.log(f'Getting build dependencies for {distribution}...', kind=('step',))259 hook_name = f'get_requires_for_build_{distribution}'260 get_requires = getattr(self._hook, hook_name)261 262 with self._handle_backend(hook_name):263 return set(get_requires(config_settings))264 265 def check_dependencies(266 self,267 distribution: Distribution,268 config_settings: ConfigSettings | None = None,269 ) -> set[tuple[str, ...]]:270 """271 Return the dependencies which are not satisfied from the combined set of272 :attr:`build_system_requires` and :meth:`get_requires_for_build` for a given273 distribution.274 275 :param distribution: Distribution to check (``sdist`` or ``wheel``)276 :param config_settings: Config settings for the build backend277 :returns: Set of variable-length unmet dependency tuples278 """279 dependencies = self.get_requires_for_build(distribution, config_settings).union(self.build_system_requires)280 return {u for d in dependencies for u in check_dependency(d)}281 282 def prepare(283 self,284 distribution: Distribution,285 output_directory: StrPath,286 config_settings: ConfigSettings | None = None,287 ) -> str | None:288 """289 Prepare metadata for a distribution.290 291 :param distribution: Distribution to build (must be ``wheel``)292 :param output_directory: Directory to put the prepared metadata in293 :param config_settings: Config settings for the build backend294 :returns: The full path to the prepared metadata directory295 """296 _ctx.log(f'Getting metadata for {distribution}...', kind=('step',))297 try:298 return self._call_backend(299 f'prepare_metadata_for_build_{distribution}',300 output_directory,301 config_settings,302 _allow_fallback=False,303 )304 except BuildBackendException as exception:305 if isinstance(exception.exception, pyproject_hooks.HookMissing):306 return None307 raise308 309 def build(310 self,311 distribution: Distribution,312 output_directory: StrPath,313 config_settings: ConfigSettings | None = None,314 metadata_directory: str | None = None,315 ) -> str:316 """317 Build a distribution.318 319 :param distribution: Distribution to build (``sdist`` or ``wheel``)320 :param output_directory: Directory to put the built distribution in321 :param config_settings: Config settings for the build backend322 :param metadata_directory: If provided, should be the return value of a323 previous ``prepare`` call on the same ``distribution`` kind324 :returns: The full path to the built distribution325 """326 _ctx.log(f'Building {distribution}...', kind=('step',))327 328 kwargs = {} if metadata_directory is None else {'metadata_directory': metadata_directory}329 return self._call_backend(f'build_{distribution}', output_directory, config_settings, **kwargs)330 331 def metadata_path(self, output_directory: StrPath) -> str:332 """333 Generate the metadata directory of a distribution and return its path.334 335 If the backend does not support the ``prepare_metadata_for_build_wheel``336 hook, a wheel will be built and the metadata will be extracted from it.337 338 :param output_directory: Directory to put the metadata distribution in339 :returns: The path of the metadata directory340 """341 # prepare_metadata hook342 metadata = self.prepare('wheel', output_directory)343 if metadata is not None:344 return metadata345 346 # fallback to build_wheel hook347 wheel = self.build('wheel', output_directory)348 match = parse_wheel_filename(os.path.basename(wheel))349 if not match:350 msg = 'Invalid wheel'351 raise ValueError(msg)352 distinfo = f'{match["distribution"]}-{match["version"]}.dist-info'353 member_prefix = f'{distinfo}/'354 with zipfile.ZipFile(wheel) as w:355 w.extractall(356 output_directory,357 (member for member in w.namelist() if member.startswith(member_prefix)),358 )359 return os.path.join(output_directory, distinfo)360 361 def _call_backend(362 self, hook_name: str, outdir: StrPath, config_settings: ConfigSettings | None = None, **kwargs: Any363 ) -> str:364 outdir = os.path.abspath(outdir)365 366 callback = getattr(self._hook, hook_name)367 368 if os.path.exists(outdir):369 if not os.path.isdir(outdir):370 msg = f"Build path '{outdir}' exists and is not a directory"371 raise BuildException(msg)372 else:373 os.makedirs(outdir, exist_ok=True)374 375 with self._handle_backend(hook_name):376 basename: str = callback(outdir, config_settings, **kwargs)377 378 return os.path.join(outdir, basename)379 380 @contextlib.contextmanager381 def _handle_backend(self, hook: str) -> Iterator[None]:382 try:383 yield384 except pyproject_hooks.BackendUnavailable as exception:385 raise BuildBackendException(386 exception,387 f"Backend '{self._backend}' is not available.",388 sys.exc_info(),389 ) from None390 except subprocess.CalledProcessError as exception:391 raise BuildBackendException(exception, f'Backend subprocess exited when trying to invoke {hook}') from None392 except Exception as exception:393 raise BuildBackendException(exception, exc_info=sys.exc_info()) from None394 