1import json
2import os
3import sys
4import tempfile
5from contextlib import contextmanager
6from os.path import abspath
7from os.path import join as pjoin
8from subprocess import STDOUT, check_call, check_output
9from typing import TYPE_CHECKING, Any, Iterator, Mapping, Optional, Sequence
10
11from ._in_process import _in_proc_script_path
12
13if TYPE_CHECKING:
14 from typing import Protocol
15
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_name
50 self.backend_path = backend_path
51 self.traceback = traceback
52 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_name
61
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 = traceback
68
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 we
116 # normalise case to avoid problems because commonprefix is a character
117 # 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_requested
124
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 for
139 :param build_backend: The build backend spec
140 :param backend_path: Additional path entries for the build backend spec
141 :param runner: The :ref:`subprocess runner <Subprocess Runners>` to use
142 :param python_executable:
143 The Python executable used to invoke the build backend
144 """
145 if runner is None:
146 runner = default_subprocess_runner
147
148 self.source_dir = abspath(source_dir)
149 self.build_backend = build_backend
150 if backend_path:
151 backend_path = [norm_and_check(self.source_dir, p) for p in backend_path]
152 self.backend_path = backend_path
153 self._subprocess_runner = runner
154 if not python_executable:
155 python_executable = sys.executable
156 self.python_executable = python_executable
157
158 @contextmanager
159 def subprocess_runner(self, runner: "SubprocessRunner") -> Iterator[None]:
160 """A context manager for temporarily overriding the default
161 :ref:`subprocess runner <Subprocess Runners>`.
162
163 :param runner: The new subprocess runner to use within the context.
164
165 .. code-block:: python
166
167 hook_caller = BuildBackendHookCaller(...)
168 with hook_caller.subprocess_runner(quiet_subprocess_runner):
169 ...
170 """
171 prev = self._subprocess_runner
172 self._subprocess_runner = runner
173 try:
174 yield
175 finally:
176 self._subprocess_runner = prev
177
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 backend
189 :returns: A list of :pep:`dependency specifiers <508>`.
190
191 .. admonition:: Fallback
192
193 If the build backend does not defined a hook with this name, an
194 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 to
209 :param config_settings: The configuration settings for the build backend
210 :param _allow_fallback:
211 Whether to allow the fallback to building a wheel and extracting
212 the metadata from it. Should be passed as a keyword argument only.
213
214 :returns: Name of the newly created subfolder within
215 ``metadata_directory``, containing the metadata.
216
217 .. admonition:: Fallback
218
219 If the build backend does not define a hook with this name and
220 ``_allow_fallback`` is truthy, the backend will be asked to build a
221 wheel via the ``build_wheel`` hook and the dist-info extracted from
222 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 to
242 :param config_settings: The configuration settings for the build backend
243 :param metadata_directory: The directory to reuse existing metadata from
244 :returns:
245 The name of the newly created wheel within ``wheel_directory``.
246
247 .. admonition:: Interaction with fallback
248
249 If the ``build_wheel`` hook was called in the fallback for
250 :meth:`prepare_metadata_for_build_wheel`, the build backend would
251 not be invoked. Instead, the previously built wheel will be copied
252 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 backend
272 :returns: A list of :pep:`dependency specifiers <508>`.
273
274 .. admonition:: Fallback
275
276 If the build backend does not defined a hook with this name, an
277 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 to
292 :param config_settings: The configuration settings for the build backend
293 :param _allow_fallback:
294 Whether to allow the fallback to building a wheel and extracting
295 the metadata from it. Should be passed as a keyword argument only.
296 :returns: Name of the newly created subfolder within
297 ``metadata_directory``, containing the metadata.
298
299 .. admonition:: Fallback
300
301 If the build backend does not define a hook with this name and
302 ``_allow_fallback`` is truthy, the backend will be asked to build a
303 wheel via the ``build_editable`` hook and the dist-info
304 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 to
324 :param config_settings: The configuration settings for the build backend
325 :param metadata_directory: The directory to reuse existing metadata from
326 :returns:
327 The name of the newly created wheel within ``wheel_directory``.
328
329 .. admonition:: Interaction with fallback
330
331 If the ``build_editable`` hook was called in the fallback for
332 :meth:`prepare_metadata_for_build_editable`, the build backend
333 would not be invoked. Instead, the previously built wheel will be
334 copied to ``wheel_directory`` and the name of that file will be
335 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_path
384
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 subprocess
390 with _in_proc_script_path() as script:
391 python = self.python_executable
392 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"]