ee511ff20d73bb245fe7ae0c1fc31a41c33e7d29
[SubU] /
1 """This is invoked in a subprocess to call the build backend hooks.
2
3 It expects:
4 - Command line args: hook_name, control_dir
5 - Environment variables:
6       PEP517_BUILD_BACKEND=entry.point:spec
7       PEP517_BACKEND_PATH=paths (separated with os.pathsep)
8 - control_dir/input.json:
9   - {"kwargs": {...}}
10
11 Results:
12 - control_dir/output.json
13   - {"return_val": ...}
14 """
15 import json
16 import os
17 import os.path
18 import re
19 import shutil
20 import sys
21 import traceback
22 from glob import glob
23 from importlib import import_module
24 from os.path import join as pjoin
25
26 # This file is run as a script, and `import wrappers` is not zip-safe, so we
27 # include write_json() and read_json() from wrappers.py.
28
29
30 def write_json(obj, path, **kwargs):
31     with open(path, 'w', encoding='utf-8') as f:
32         json.dump(obj, f, **kwargs)
33
34
35 def read_json(path):
36     with open(path, encoding='utf-8') as f:
37         return json.load(f)
38
39
40 class BackendUnavailable(Exception):
41     """Raised if we cannot import the backend"""
42     def __init__(self, traceback):
43         self.traceback = traceback
44
45
46 class BackendInvalid(Exception):
47     """Raised if the backend is invalid"""
48     def __init__(self, message):
49         self.message = message
50
51
52 class HookMissing(Exception):
53     """Raised if a hook is missing and we are not executing the fallback"""
54     def __init__(self, hook_name=None):
55         super().__init__(hook_name)
56         self.hook_name = hook_name
57
58
59 def contained_in(filename, directory):
60     """Test if a file is located within the given directory."""
61     filename = os.path.normcase(os.path.abspath(filename))
62     directory = os.path.normcase(os.path.abspath(directory))
63     return os.path.commonprefix([filename, directory]) == directory
64
65
66 def _build_backend():
67     """Find and load the build backend"""
68     # Add in-tree backend directories to the front of sys.path.
69     backend_path = os.environ.get('PEP517_BACKEND_PATH')
70     if backend_path:
71         extra_pathitems = backend_path.split(os.pathsep)
72         sys.path[:0] = extra_pathitems
73
74     ep = os.environ['PEP517_BUILD_BACKEND']
75     mod_path, _, obj_path = ep.partition(':')
76     try:
77         obj = import_module(mod_path)
78     except ImportError:
79         raise BackendUnavailable(traceback.format_exc())
80
81     if backend_path:
82         if not any(
83             contained_in(obj.__file__, path)
84             for path in extra_pathitems
85         ):
86             raise BackendInvalid("Backend was not loaded from backend-path")
87
88     if obj_path:
89         for path_part in obj_path.split('.'):
90             obj = getattr(obj, path_part)
91     return obj
92
93
94 def _supported_features():
95     """Return the list of options features supported by the backend.
96
97     Returns a list of strings.
98     The only possible value is 'build_editable'.
99     """
100     backend = _build_backend()
101     features = []
102     if hasattr(backend, "build_editable"):
103         features.append("build_editable")
104     return features
105
106
107 def get_requires_for_build_wheel(config_settings):
108     """Invoke the optional get_requires_for_build_wheel hook
109
110     Returns [] if the hook is not defined.
111     """
112     backend = _build_backend()
113     try:
114         hook = backend.get_requires_for_build_wheel
115     except AttributeError:
116         return []
117     else:
118         return hook(config_settings)
119
120
121 def get_requires_for_build_editable(config_settings):
122     """Invoke the optional get_requires_for_build_editable hook
123
124     Returns [] if the hook is not defined.
125     """
126     backend = _build_backend()
127     try:
128         hook = backend.get_requires_for_build_editable
129     except AttributeError:
130         return []
131     else:
132         return hook(config_settings)
133
134
135 def prepare_metadata_for_build_wheel(
136         metadata_directory, config_settings, _allow_fallback):
137     """Invoke optional prepare_metadata_for_build_wheel
138
139     Implements a fallback by building a wheel if the hook isn't defined,
140     unless _allow_fallback is False in which case HookMissing is raised.
141     """
142     backend = _build_backend()
143     try:
144         hook = backend.prepare_metadata_for_build_wheel
145     except AttributeError:
146         if not _allow_fallback:
147             raise HookMissing()
148     else:
149         return hook(metadata_directory, config_settings)
150     # fallback to build_wheel outside the try block to avoid exception chaining
151     # which can be confusing to users and is not relevant
152     whl_basename = backend.build_wheel(metadata_directory, config_settings)
153     return _get_wheel_metadata_from_wheel(whl_basename, metadata_directory,
154                                           config_settings)
155
156
157 def prepare_metadata_for_build_editable(
158         metadata_directory, config_settings, _allow_fallback):
159     """Invoke optional prepare_metadata_for_build_editable
160
161     Implements a fallback by building an editable wheel if the hook isn't
162     defined, unless _allow_fallback is False in which case HookMissing is
163     raised.
164     """
165     backend = _build_backend()
166     try:
167         hook = backend.prepare_metadata_for_build_editable
168     except AttributeError:
169         if not _allow_fallback:
170             raise HookMissing()
171         try:
172             build_hook = backend.build_editable
173         except AttributeError:
174             raise HookMissing(hook_name='build_editable')
175         else:
176             whl_basename = build_hook(metadata_directory, config_settings)
177             return _get_wheel_metadata_from_wheel(whl_basename,
178                                                   metadata_directory,
179                                                   config_settings)
180     else:
181         return hook(metadata_directory, config_settings)
182
183
184 WHEEL_BUILT_MARKER = 'PEP517_ALREADY_BUILT_WHEEL'
185
186
187 def _dist_info_files(whl_zip):
188     """Identify the .dist-info folder inside a wheel ZipFile."""
189     res = []
190     for path in whl_zip.namelist():
191         m = re.match(r'[^/\\]+-[^/\\]+\.dist-info/', path)
192         if m:
193             res.append(path)
194     if res:
195         return res
196     raise Exception("No .dist-info folder found in wheel")
197
198
199 def _get_wheel_metadata_from_wheel(
200         whl_basename, metadata_directory, config_settings):
201     """Extract the metadata from a wheel.
202
203     Fallback for when the build backend does not
204     define the 'get_wheel_metadata' hook.
205     """
206     from zipfile import ZipFile
207     with open(os.path.join(metadata_directory, WHEEL_BUILT_MARKER), 'wb'):
208         pass  # Touch marker file
209
210     whl_file = os.path.join(metadata_directory, whl_basename)
211     with ZipFile(whl_file) as zipf:
212         dist_info = _dist_info_files(zipf)
213         zipf.extractall(path=metadata_directory, members=dist_info)
214     return dist_info[0].split('/')[0]
215
216
217 def _find_already_built_wheel(metadata_directory):
218     """Check for a wheel already built during the get_wheel_metadata hook.
219     """
220     if not metadata_directory:
221         return None
222     metadata_parent = os.path.dirname(metadata_directory)
223     if not os.path.isfile(pjoin(metadata_parent, WHEEL_BUILT_MARKER)):
224         return None
225
226     whl_files = glob(os.path.join(metadata_parent, '*.whl'))
227     if not whl_files:
228         print('Found wheel built marker, but no .whl files')
229         return None
230     if len(whl_files) > 1:
231         print('Found multiple .whl files; unspecified behaviour. '
232               'Will call build_wheel.')
233         return None
234
235     # Exactly one .whl file
236     return whl_files[0]
237
238
239 def build_wheel(wheel_directory, config_settings, metadata_directory=None):
240     """Invoke the mandatory build_wheel hook.
241
242     If a wheel was already built in the
243     prepare_metadata_for_build_wheel fallback, this
244     will copy it rather than rebuilding the wheel.
245     """
246     prebuilt_whl = _find_already_built_wheel(metadata_directory)
247     if prebuilt_whl:
248         shutil.copy2(prebuilt_whl, wheel_directory)
249         return os.path.basename(prebuilt_whl)
250
251     return _build_backend().build_wheel(wheel_directory, config_settings,
252                                         metadata_directory)
253
254
255 def build_editable(wheel_directory, config_settings, metadata_directory=None):
256     """Invoke the optional build_editable hook.
257
258     If a wheel was already built in the
259     prepare_metadata_for_build_editable fallback, this
260     will copy it rather than rebuilding the wheel.
261     """
262     backend = _build_backend()
263     try:
264         hook = backend.build_editable
265     except AttributeError:
266         raise HookMissing()
267     else:
268         prebuilt_whl = _find_already_built_wheel(metadata_directory)
269         if prebuilt_whl:
270             shutil.copy2(prebuilt_whl, wheel_directory)
271             return os.path.basename(prebuilt_whl)
272
273         return hook(wheel_directory, config_settings, metadata_directory)
274
275
276 def get_requires_for_build_sdist(config_settings):
277     """Invoke the optional get_requires_for_build_wheel hook
278
279     Returns [] if the hook is not defined.
280     """
281     backend = _build_backend()
282     try:
283         hook = backend.get_requires_for_build_sdist
284     except AttributeError:
285         return []
286     else:
287         return hook(config_settings)
288
289
290 class _DummyException(Exception):
291     """Nothing should ever raise this exception"""
292
293
294 class GotUnsupportedOperation(Exception):
295     """For internal use when backend raises UnsupportedOperation"""
296     def __init__(self, traceback):
297         self.traceback = traceback
298
299
300 def build_sdist(sdist_directory, config_settings):
301     """Invoke the mandatory build_sdist hook."""
302     backend = _build_backend()
303     try:
304         return backend.build_sdist(sdist_directory, config_settings)
305     except getattr(backend, 'UnsupportedOperation', _DummyException):
306         raise GotUnsupportedOperation(traceback.format_exc())
307
308
309 HOOK_NAMES = {
310     'get_requires_for_build_wheel',
311     'prepare_metadata_for_build_wheel',
312     'build_wheel',
313     'get_requires_for_build_editable',
314     'prepare_metadata_for_build_editable',
315     'build_editable',
316     'get_requires_for_build_sdist',
317     'build_sdist',
318     '_supported_features',
319 }
320
321
322 def main():
323     if len(sys.argv) < 3:
324         sys.exit("Needs args: hook_name, control_dir")
325     hook_name = sys.argv[1]
326     control_dir = sys.argv[2]
327     if hook_name not in HOOK_NAMES:
328         sys.exit("Unknown hook: %s" % hook_name)
329     hook = globals()[hook_name]
330
331     hook_input = read_json(pjoin(control_dir, 'input.json'))
332
333     json_out = {'unsupported': False, 'return_val': None}
334     try:
335         json_out['return_val'] = hook(**hook_input['kwargs'])
336     except BackendUnavailable as e:
337         json_out['no_backend'] = True
338         json_out['traceback'] = e.traceback
339     except BackendInvalid as e:
340         json_out['backend_invalid'] = True
341         json_out['backend_error'] = e.message
342     except GotUnsupportedOperation as e:
343         json_out['unsupported'] = True
344         json_out['traceback'] = e.traceback
345     except HookMissing as e:
346         json_out['hook_missing'] = True
347         json_out['missing_hook_name'] = e.hook_name or hook_name
348
349     write_json(json_out, pjoin(control_dir, 'output.json'), indent=2)
350
351
352 if __name__ == '__main__':
353     main()