codekingpro/portable-devtools
115k
1import json2import os3import sys4import tempfile5from contextlib import contextmanager6from os.path import abspath7from os.path import join as pjoin8from subprocess import STDOUT, check_call, check_output9from typing import TYPE_CHECKING, Any, Iterator, Mapping, Optional, Sequence10 11from ._in_process import _in_proc_script_path12 13if TYPE_CHECKING:14 from typing import Protocol15 16 class SubprocessRunner(Protocol):17 """A protocol for the subprocess runner."""18 19 def __call__(20 self,21 cmd: Sequence[str],22 cwd: Optional[str] = None,23 extra_environ: Optional[Mapping[str, str]] = None,24 ) -> None:25 ...26 27 28def write_json(obj: Mapping[str, Any], path: str, **kwargs) -> None:29 with open(path, "w", encoding="utf-8") as f:30 json.dump(obj, f, **kwargs)31 32 33def read_json(path: str) -> Mapping[str, Any]:34 with open(path, encoding="utf-8") as f:35 return json.load(f)36 37 38class BackendUnavailable(Exception):39 """Will be raised if the backend cannot be imported in the hook process."""40 41 def __init__(42 self,43 traceback: str,44 message: Optional[str] = None,45 backend_name: Optional[str] = None,46 backend_path: Optional[Sequence[str]] = None,47 ) -> None:48 # Preserving arg order for the sake of API backward compatibility.49 self.backend_name = backend_name50 self.backend_path = backend_path51 self.traceback = traceback52 super().__init__(message or "Error while importing backend")53 54 55class HookMissing(Exception):56 """Will be raised on missing hooks (if a fallback can't be used)."""57 58 def __init__(self, hook_name: str) -> None:59 super().__init__(hook_name)60 self.hook_name = hook_name61 62 63class UnsupportedOperation(Exception):64 """May be raised by build_sdist if the backend indicates that it can't."""65 66 def __init__(self, traceback: str) -> None:67 self.traceback = traceback68 69 70def default_subprocess_runner(71 cmd: Sequence[str],72 cwd: Optional[str] = None,73 extra_environ: Optional[Mapping[str, str]] = None,74) -> None:75 """The default method of calling the wrapper subprocess.76 77 This uses :func:`subprocess.check_call` under the hood.78 """79 env = os.environ.copy()80 if extra_environ:81 env.update(extra_environ)82 83 check_call(cmd, cwd=cwd, env=env)84 85 86def quiet_subprocess_runner(87 cmd: Sequence[str],88 cwd: Optional[str] = None,89 extra_environ: Optional[Mapping[str, str]] = None,90) -> None:91 """Call the subprocess while suppressing output.92 93 This uses :func:`subprocess.check_output` under the hood.94 """95 env = os.environ.copy()96 if extra_environ:97 env.update(extra_environ)98 99 check_output(cmd, cwd=cwd, env=env, stderr=STDOUT)100 101 102def norm_and_check(source_tree: str, requested: str) -> str:103 """Normalise and check a backend path.104 105 Ensure that the requested backend path is specified as a relative path,106 and resolves to a location under the given source tree.107 108 Return an absolute version of the requested path.109 """110 if os.path.isabs(requested):111 raise ValueError("paths must be relative")112 113 abs_source = os.path.abspath(source_tree)114 abs_requested = os.path.normpath(os.path.join(abs_source, requested))115 # We have to use commonprefix for Python 2.7 compatibility. So we116 # normalise case to avoid problems because commonprefix is a character117 # based comparison :-(118 norm_source = os.path.normcase(abs_source)119 norm_requested = os.path.normcase(abs_requested)120 if os.path.commonprefix([norm_source, norm_requested]) != norm_source:121 raise ValueError("paths must be inside source tree")122 123 return abs_requested124 125 126class BuildBackendHookCaller:127 """A wrapper to call the build backend hooks for a source directory."""128 129 def __init__(130 self,131 source_dir: str,132 build_backend: str,133 backend_path: Optional[Sequence[str]] = None,134 runner: Optional["SubprocessRunner"] = None,135 python_executable: Optional[str] = None,136 ) -> None:137 """138 :param source_dir: The source directory to invoke the build backend for139 :param build_backend: The build backend spec140 :param backend_path: Additional path entries for the build backend spec141 :param runner: The :ref:`subprocess runner <Subprocess Runners>` to use142 :param python_executable:143 The Python executable used to invoke the build backend144 """145 if runner is None:146 runner = default_subprocess_runner147 148 self.source_dir = abspath(source_dir)149 self.build_backend = build_backend150 if backend_path:151 backend_path = [norm_and_check(self.source_dir, p) for p in backend_path]152 self.backend_path = backend_path153 self._subprocess_runner = runner154 if not python_executable:155 python_executable = sys.executable156 self.python_executable = python_executable157 158 @contextmanager159 def subprocess_runner(self, runner: "SubprocessRunner") -> Iterator[None]:160 """A context manager for temporarily overriding the default161 :ref:`subprocess runner <Subprocess Runners>`.162 163 :param runner: The new subprocess runner to use within the context.164 165 .. code-block:: python166 167 hook_caller = BuildBackendHookCaller(...)168 with hook_caller.subprocess_runner(quiet_subprocess_runner):169 ...170 """171 prev = self._subprocess_runner172 self._subprocess_runner = runner173 try:174 yield175 finally:176 self._subprocess_runner = prev177 178 def _supported_features(self) -> Sequence[str]:179 """Return the list of optional features supported by the backend."""180 return self._call_hook("_supported_features", {})181 182 def get_requires_for_build_wheel(183 self,184 config_settings: Optional[Mapping[str, Any]] = None,185 ) -> Sequence[str]:186 """Get additional dependencies required for building a wheel.187 188 :param config_settings: The configuration settings for the build backend189 :returns: A list of :pep:`dependency specifiers <508>`.190 191 .. admonition:: Fallback192 193 If the build backend does not defined a hook with this name, an194 empty list will be returned.195 """196 return self._call_hook(197 "get_requires_for_build_wheel", {"config_settings": config_settings}198 )199 200 def prepare_metadata_for_build_wheel(201 self,202 metadata_directory: str,203 config_settings: Optional[Mapping[str, Any]] = None,204 _allow_fallback: bool = True,205 ) -> str:206 """Prepare a ``*.dist-info`` folder with metadata for this project.207 208 :param metadata_directory: The directory to write the metadata to209 :param config_settings: The configuration settings for the build backend210 :param _allow_fallback:211 Whether to allow the fallback to building a wheel and extracting212 the metadata from it. Should be passed as a keyword argument only.213 214 :returns: Name of the newly created subfolder within215 ``metadata_directory``, containing the metadata.216 217 .. admonition:: Fallback218 219 If the build backend does not define a hook with this name and220 ``_allow_fallback`` is truthy, the backend will be asked to build a221 wheel via the ``build_wheel`` hook and the dist-info extracted from222 that will be returned.223 """224 return self._call_hook(225 "prepare_metadata_for_build_wheel",226 {227 "metadata_directory": abspath(metadata_directory),228 "config_settings": config_settings,229 "_allow_fallback": _allow_fallback,230 },231 )232 233 def build_wheel(234 self,235 wheel_directory: str,236 config_settings: Optional[Mapping[str, Any]] = None,237 metadata_directory: Optional[str] = None,238 ) -> str:239 """Build a wheel from this project.240 241 :param wheel_directory: The directory to write the wheel to242 :param config_settings: The configuration settings for the build backend243 :param metadata_directory: The directory to reuse existing metadata from244 :returns:245 The name of the newly created wheel within ``wheel_directory``.246 247 .. admonition:: Interaction with fallback248 249 If the ``build_wheel`` hook was called in the fallback for250 :meth:`prepare_metadata_for_build_wheel`, the build backend would251 not be invoked. Instead, the previously built wheel will be copied252 to ``wheel_directory`` and the name of that file will be returned.253 """254 if metadata_directory is not None:255 metadata_directory = abspath(metadata_directory)256 return self._call_hook(257 "build_wheel",258 {259 "wheel_directory": abspath(wheel_directory),260 "config_settings": config_settings,261 "metadata_directory": metadata_directory,262 },263 )264 265 def get_requires_for_build_editable(266 self,267 config_settings: Optional[Mapping[str, Any]] = None,268 ) -> Sequence[str]:269 """Get additional dependencies required for building an editable wheel.270 271 :param config_settings: The configuration settings for the build backend272 :returns: A list of :pep:`dependency specifiers <508>`.273 274 .. admonition:: Fallback275 276 If the build backend does not defined a hook with this name, an277 empty list will be returned.278 """279 return self._call_hook(280 "get_requires_for_build_editable", {"config_settings": config_settings}281 )282 283 def prepare_metadata_for_build_editable(284 self,285 metadata_directory: str,286 config_settings: Optional[Mapping[str, Any]] = None,287 _allow_fallback: bool = True,288 ) -> Optional[str]:289 """Prepare a ``*.dist-info`` folder with metadata for this project.290 291 :param metadata_directory: The directory to write the metadata to292 :param config_settings: The configuration settings for the build backend293 :param _allow_fallback:294 Whether to allow the fallback to building a wheel and extracting295 the metadata from it. Should be passed as a keyword argument only.296 :returns: Name of the newly created subfolder within297 ``metadata_directory``, containing the metadata.298 299 .. admonition:: Fallback300 301 If the build backend does not define a hook with this name and302 ``_allow_fallback`` is truthy, the backend will be asked to build a303 wheel via the ``build_editable`` hook and the dist-info304 extracted from that will be returned.305 """306 return self._call_hook(307 "prepare_metadata_for_build_editable",308 {309 "metadata_directory": abspath(metadata_directory),310 "config_settings": config_settings,311 "_allow_fallback": _allow_fallback,312 },313 )314 315 def build_editable(316 self,317 wheel_directory: str,318 config_settings: Optional[Mapping[str, Any]] = None,319 metadata_directory: Optional[str] = None,320 ) -> str:321 """Build an editable wheel from this project.322 323 :param wheel_directory: The directory to write the wheel to324 :param config_settings: The configuration settings for the build backend325 :param metadata_directory: The directory to reuse existing metadata from326 :returns:327 The name of the newly created wheel within ``wheel_directory``.328 329 .. admonition:: Interaction with fallback330 331 If the ``build_editable`` hook was called in the fallback for332 :meth:`prepare_metadata_for_build_editable`, the build backend333 would not be invoked. Instead, the previously built wheel will be334 copied to ``wheel_directory`` and the name of that file will be335 returned.336 """337 if metadata_directory is not None:338 metadata_directory = abspath(metadata_directory)339 return self._call_hook(340 "build_editable",341 {342 "wheel_directory": abspath(wheel_directory),343 "config_settings": config_settings,344 "metadata_directory": metadata_directory,345 },346 )347 348 def get_requires_for_build_sdist(349 self,350 config_settings: Optional[Mapping[str, Any]] = None,351 ) -> Sequence[str]:352 """Get additional dependencies required for building an sdist.353 354 :returns: A list of :pep:`dependency specifiers <508>`.355 """356 return self._call_hook(357 "get_requires_for_build_sdist", {"config_settings": config_settings}358 )359 360 def build_sdist(361 self,362 sdist_directory: str,363 config_settings: Optional[Mapping[str, Any]] = None,364 ) -> str:365 """Build an sdist from this project.366 367 :returns:368 The name of the newly created sdist within ``wheel_directory``.369 """370 return self._call_hook(371 "build_sdist",372 {373 "sdist_directory": abspath(sdist_directory),374 "config_settings": config_settings,375 },376 )377 378 def _call_hook(self, hook_name: str, kwargs: Mapping[str, Any]) -> Any:379 extra_environ = {"_PYPROJECT_HOOKS_BUILD_BACKEND": self.build_backend}380 381 if self.backend_path:382 backend_path = os.pathsep.join(self.backend_path)383 extra_environ["_PYPROJECT_HOOKS_BACKEND_PATH"] = backend_path384 385 with tempfile.TemporaryDirectory() as td:386 hook_input = {"kwargs": kwargs}387 write_json(hook_input, pjoin(td, "input.json"), indent=2)388 389 # Run the hook in a subprocess390 with _in_proc_script_path() as script:391 python = self.python_executable392 self._subprocess_runner(393 [python, abspath(str(script)), hook_name, td],394 cwd=self.source_dir,395 extra_environ=extra_environ,396 )397 398 data = read_json(pjoin(td, "output.json"))399 if data.get("unsupported"):400 raise UnsupportedOperation(data.get("traceback", ""))401 if data.get("no_backend"):402 raise BackendUnavailable(403 data.get("traceback", ""),404 message=data.get("backend_error", ""),405 backend_name=self.build_backend,406 backend_path=self.backend_path,407 )408 if data.get("hook_missing"):409 raise HookMissing(data.get("missing_hook_name") or hook_name)410 return data["return_val"]411 