1import threading
2from contextlib import contextmanager
3import os
4from os.path import abspath, join as pjoin
5import shutil
6from subprocess import check_call, check_output, STDOUT
7import sys
8from tempfile import mkdtemp
9
10from . import compat
11from .in_process import _in_proc_script_path
12
13__all__ = [
14    'BackendUnavailable',
15    'BackendInvalid',
16    'HookMissing',
17    'UnsupportedOperation',
18    'default_subprocess_runner',
19    'quiet_subprocess_runner',
20    'Pep517HookCaller',
21]
22
23
24@contextmanager
25def tempdir():
26    td = mkdtemp()
27    try:
28        yield td
29    finally:
30        shutil.rmtree(td)
31
32
33class BackendUnavailable(Exception):
34    """Will be raised if the backend cannot be imported in the hook process."""
35    def __init__(self, traceback):
36        self.traceback = traceback
37
38
39class BackendInvalid(Exception):
40    """Will be raised if the backend is invalid."""
41    def __init__(self, backend_name, backend_path, message):
42        self.backend_name = backend_name
43        self.backend_path = backend_path
44        self.message = message
45
46
47class HookMissing(Exception):
48    """Will be raised on missing hooks."""
49    def __init__(self, hook_name):
50        super(HookMissing, self).__init__(hook_name)
51        self.hook_name = hook_name
52
53
54class UnsupportedOperation(Exception):
55    """May be raised by build_sdist if the backend indicates that it can't."""
56    def __init__(self, traceback):
57        self.traceback = traceback
58
59
60def default_subprocess_runner(cmd, cwd=None, extra_environ=None):
61    """The default method of calling the wrapper subprocess."""
62    env = os.environ.copy()
63    if extra_environ:
64        env.update(extra_environ)
65
66    check_call(cmd, cwd=cwd, env=env)
67
68
69def quiet_subprocess_runner(cmd, cwd=None, extra_environ=None):
70    """A method of calling the wrapper subprocess while suppressing output."""
71    env = os.environ.copy()
72    if extra_environ:
73        env.update(extra_environ)
74
75    check_output(cmd, cwd=cwd, env=env, stderr=STDOUT)
76
77
78def norm_and_check(source_tree, requested):
79    """Normalise and check a backend path.
80
81    Ensure that the requested backend path is specified as a relative path,
82    and resolves to a location under the given source tree.
83
84    Return an absolute version of the requested path.
85    """
86    if os.path.isabs(requested):
87        raise ValueError("paths must be relative")
88
89    abs_source = os.path.abspath(source_tree)
90    abs_requested = os.path.normpath(os.path.join(abs_source, requested))
91    # We have to use commonprefix for Python 2.7 compatibility. So we
92    # normalise case to avoid problems because commonprefix is a character
93    # based comparison :-(
94    norm_source = os.path.normcase(abs_source)
95    norm_requested = os.path.normcase(abs_requested)
96    if os.path.commonprefix([norm_source, norm_requested]) != norm_source:
97        raise ValueError("paths must be inside source tree")
98
99    return abs_requested
100
101
102class Pep517HookCaller(object):
103    """A wrapper around a source directory to be built with a PEP 517 backend.
104
105    :param source_dir: The path to the source directory, containing
106        pyproject.toml.
107    :param build_backend: The build backend spec, as per PEP 517, from
108        pyproject.toml.
109    :param backend_path: The backend path, as per PEP 517, from pyproject.toml.
110    :param runner: A callable that invokes the wrapper subprocess.
111    :param python_executable: The Python executable used to invoke the backend
112
113    The 'runner', if provided, must expect the following:
114
115    - cmd: a list of strings representing the command and arguments to
116      execute, as would be passed to e.g. 'subprocess.check_call'.
117    - cwd: a string representing the working directory that must be
118      used for the subprocess. Corresponds to the provided source_dir.
119    - extra_environ: a dict mapping environment variable names to values
120      which must be set for the subprocess execution.
121    """
122    def __init__(
123            self,
124            source_dir,
125            build_backend,
126            backend_path=None,
127            runner=None,
128            python_executable=None,
129    ):
130        if runner is None:
131            runner = default_subprocess_runner
132
133        self.source_dir = abspath(source_dir)
134        self.build_backend = build_backend
135        if backend_path:
136            backend_path = [
137                norm_and_check(self.source_dir, p) for p in backend_path
138            ]
139        self.backend_path = backend_path
140        self._subprocess_runner = runner
141        if not python_executable:
142            python_executable = sys.executable
143        self.python_executable = python_executable
144
145    @contextmanager
146    def subprocess_runner(self, runner):
147        """A context manager for temporarily overriding the default subprocess
148        runner.
149        """
150        prev = self._subprocess_runner
151        self._subprocess_runner = runner
152        try:
153            yield
154        finally:
155            self._subprocess_runner = prev
156
157    def get_requires_for_build_wheel(self, config_settings=None):
158        """Identify packages required for building a wheel
159
160        Returns a list of dependency specifications, e.g.::
161
162            ["wheel >= 0.25", "setuptools"]
163
164        This does not include requirements specified in pyproject.toml.
165        It returns the result of calling the equivalently named hook in a
166        subprocess.
167        """
168        return self._call_hook('get_requires_for_build_wheel', {
169            'config_settings': config_settings
170        })
171
172    def prepare_metadata_for_build_wheel(
173            self, metadata_directory, config_settings=None,
174            _allow_fallback=True):
175        """Prepare a ``*.dist-info`` folder with metadata for this project.
176
177        Returns the name of the newly created folder.
178
179        If the build backend defines a hook with this name, it will be called
180        in a subprocess. If not, the backend will be asked to build a wheel,
181        and the dist-info extracted from that (unless _allow_fallback is
182        False).
183        """
184        return self._call_hook('prepare_metadata_for_build_wheel', {
185            'metadata_directory': abspath(metadata_directory),
186            'config_settings': config_settings,
187            '_allow_fallback': _allow_fallback,
188        })
189
190    def build_wheel(
191            self, wheel_directory, config_settings=None,
192            metadata_directory=None):
193        """Build a wheel from this project.
194
195        Returns the name of the newly created file.
196
197        In general, this will call the 'build_wheel' hook in the backend.
198        However, if that was previously called by
199        'prepare_metadata_for_build_wheel', and the same metadata_directory is
200        used, the previously built wheel will be copied to wheel_directory.
201        """
202        if metadata_directory is not None:
203            metadata_directory = abspath(metadata_directory)
204        return self._call_hook('build_wheel', {
205            'wheel_directory': abspath(wheel_directory),
206            'config_settings': config_settings,
207            'metadata_directory': metadata_directory,
208        })
209
210    def get_requires_for_build_editable(self, config_settings=None):
211        """Identify packages required for building an editable wheel
212
213        Returns a list of dependency specifications, e.g.::
214
215            ["wheel >= 0.25", "setuptools"]
216
217        This does not include requirements specified in pyproject.toml.
218        It returns the result of calling the equivalently named hook in a
219        subprocess.
220        """
221        return self._call_hook('get_requires_for_build_editable', {
222            'config_settings': config_settings
223        })
224
225    def prepare_metadata_for_build_editable(
226            self, metadata_directory, config_settings=None,
227            _allow_fallback=True):
228        """Prepare a ``*.dist-info`` folder with metadata for this project.
229
230        Returns the name of the newly created folder.
231
232        If the build backend defines a hook with this name, it will be called
233        in a subprocess. If not, the backend will be asked to build an editable
234        wheel, and the dist-info extracted from that (unless _allow_fallback is
235        False).
236        """
237        return self._call_hook('prepare_metadata_for_build_editable', {
238            'metadata_directory': abspath(metadata_directory),
239            'config_settings': config_settings,
240            '_allow_fallback': _allow_fallback,
241        })
242
243    def build_editable(
244            self, wheel_directory, config_settings=None,
245            metadata_directory=None):
246        """Build an editable wheel from this project.
247
248        Returns the name of the newly created file.
249
250        In general, this will call the 'build_editable' hook in the backend.
251        However, if that was previously called by
252        'prepare_metadata_for_build_editable', and the same metadata_directory
253        is used, the previously built wheel will be copied to wheel_directory.
254        """
255        if metadata_directory is not None:
256            metadata_directory = abspath(metadata_directory)
257        return self._call_hook('build_editable', {
258            'wheel_directory': abspath(wheel_directory),
259            'config_settings': config_settings,
260            'metadata_directory': metadata_directory,
261        })
262
263    def get_requires_for_build_sdist(self, config_settings=None):
264        """Identify packages required for building a wheel
265
266        Returns a list of dependency specifications, e.g.::
267
268            ["setuptools >= 26"]
269
270        This does not include requirements specified in pyproject.toml.
271        It returns the result of calling the equivalently named hook in a
272        subprocess.
273        """
274        return self._call_hook('get_requires_for_build_sdist', {
275            'config_settings': config_settings
276        })
277
278    def build_sdist(self, sdist_directory, config_settings=None):
279        """Build an sdist from this project.
280
281        Returns the name of the newly created file.
282
283        This calls the 'build_sdist' backend hook in a subprocess.
284        """
285        return self._call_hook('build_sdist', {
286            'sdist_directory': abspath(sdist_directory),
287            'config_settings': config_settings,
288        })
289
290    def _call_hook(self, hook_name, kwargs):
291        # On Python 2, pytoml returns Unicode values (which is correct) but the
292        # environment passed to check_call needs to contain string values. We
293        # convert here by encoding using ASCII (the backend can only contain
294        # letters, digits and _, . and : characters, and will be used as a
295        # Python identifier, so non-ASCII content is wrong on Python 2 in
296        # any case).
297        # For backend_path, we use sys.getfilesystemencoding.
298        if sys.version_info[0] == 2:
299            build_backend = self.build_backend.encode('ASCII')
300        else:
301            build_backend = self.build_backend
302        extra_environ = {'PEP517_BUILD_BACKEND': build_backend}
303
304        if self.backend_path:
305            backend_path = os.pathsep.join(self.backend_path)
306            if sys.version_info[0] == 2:
307                backend_path = backend_path.encode(sys.getfilesystemencoding())
308            extra_environ['PEP517_BACKEND_PATH'] = backend_path
309
310        with tempdir() as td:
311            hook_input = {'kwargs': kwargs}
312            compat.write_json(hook_input, pjoin(td, 'input.json'),
313                              indent=2)
314
315            # Run the hook in a subprocess
316            with _in_proc_script_path() as script:
317                python = self.python_executable
318                self._subprocess_runner(
319                    [python, abspath(str(script)), hook_name, td],
320                    cwd=self.source_dir,
321                    extra_environ=extra_environ
322                )
323
324            data = compat.read_json(pjoin(td, 'output.json'))
325            if data.get('unsupported'):
326                raise UnsupportedOperation(data.get('traceback', ''))
327            if data.get('no_backend'):
328                raise BackendUnavailable(data.get('traceback', ''))
329            if data.get('backend_invalid'):
330                raise BackendInvalid(
331                    backend_name=self.build_backend,
332                    backend_path=self.backend_path,
333                    message=data.get('backend_error', '')
334                )
335            if data.get('hook_missing'):
336                raise HookMissing(data.get('missing_hook_name') or hook_name)
337            return data['return_val']
338
339
340class LoggerWrapper(threading.Thread):
341    """
342    Read messages from a pipe and redirect them
343    to a logger (see python's logging module).
344    """
345
346    def __init__(self, logger, level):
347        threading.Thread.__init__(self)
348        self.daemon = True
349
350        self.logger = logger
351        self.level = level
352
353        # create the pipe and reader
354        self.fd_read, self.fd_write = os.pipe()
355        self.reader = os.fdopen(self.fd_read)
356
357        self.start()
358
359    def fileno(self):
360        return self.fd_write
361
362    @staticmethod
363    def remove_newline(msg):
364        return msg[:-1] if msg.endswith(os.linesep) else msg
365
366    def run(self):
367        for line in self.reader:
368            self._write(self.remove_newline(line))
369
370    def _write(self, message):
371        self.logger.log(self.level, message)
372