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