123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375 |
- import threading
- from contextlib import contextmanager
- import os
- from os.path import abspath, join as pjoin
- import shutil
- from subprocess import check_call, check_output, STDOUT
- import sys
- from tempfile import mkdtemp
- from . import compat
- from .in_process import _in_proc_script_path
- __all__ = [
- 'BackendUnavailable',
- 'BackendInvalid',
- 'HookMissing',
- 'UnsupportedOperation',
- 'default_subprocess_runner',
- 'quiet_subprocess_runner',
- 'Pep517HookCaller',
- ]
- @contextmanager
- def tempdir():
- td = mkdtemp()
- try:
- yield td
- finally:
- shutil.rmtree(td)
- class BackendUnavailable(Exception):
- """Will be raised if the backend cannot be imported in the hook process."""
- def __init__(self, traceback):
- self.traceback = traceback
- class BackendInvalid(Exception):
- """Will be raised if the backend is invalid."""
- def __init__(self, backend_name, backend_path, message):
- self.backend_name = backend_name
- self.backend_path = backend_path
- self.message = message
- class HookMissing(Exception):
- """Will be raised on missing hooks."""
- def __init__(self, hook_name):
- super(HookMissing, self).__init__(hook_name)
- self.hook_name = hook_name
- class UnsupportedOperation(Exception):
- """May be raised by build_sdist if the backend indicates that it can't."""
- def __init__(self, traceback):
- self.traceback = traceback
- def default_subprocess_runner(cmd, cwd=None, extra_environ=None):
- """The default method of calling the wrapper subprocess."""
- env = os.environ.copy()
- if extra_environ:
- env.update(extra_environ)
- check_call(cmd, cwd=cwd, env=env)
- def quiet_subprocess_runner(cmd, cwd=None, extra_environ=None):
- """A method of calling the wrapper subprocess while suppressing output."""
- env = os.environ.copy()
- if extra_environ:
- env.update(extra_environ)
- check_output(cmd, cwd=cwd, env=env, stderr=STDOUT)
- def norm_and_check(source_tree, requested):
- """Normalise and check a backend path.
- Ensure that the requested backend path is specified as a relative path,
- and resolves to a location under the given source tree.
- Return an absolute version of the requested path.
- """
- if os.path.isabs(requested):
- raise ValueError("paths must be relative")
- abs_source = os.path.abspath(source_tree)
- abs_requested = os.path.normpath(os.path.join(abs_source, requested))
- # We have to use commonprefix for Python 2.7 compatibility. So we
- # normalise case to avoid problems because commonprefix is a character
- # based comparison :-(
- norm_source = os.path.normcase(abs_source)
- norm_requested = os.path.normcase(abs_requested)
- if os.path.commonprefix([norm_source, norm_requested]) != norm_source:
- raise ValueError("paths must be inside source tree")
- return abs_requested
- class Pep517HookCaller(object):
- """A wrapper around a source directory to be built with a PEP 517 backend.
- :param source_dir: The path to the source directory, containing
- pyproject.toml.
- :param build_backend: The build backend spec, as per PEP 517, from
- pyproject.toml.
- :param backend_path: The backend path, as per PEP 517, from pyproject.toml.
- :param runner: A callable that invokes the wrapper subprocess.
- :param python_executable: The Python executable used to invoke the backend
- The 'runner', if provided, must expect the following:
- - cmd: a list of strings representing the command and arguments to
- execute, as would be passed to e.g. 'subprocess.check_call'.
- - cwd: a string representing the working directory that must be
- used for the subprocess. Corresponds to the provided source_dir.
- - extra_environ: a dict mapping environment variable names to values
- which must be set for the subprocess execution.
- """
- def __init__(
- self,
- source_dir,
- build_backend,
- backend_path=None,
- runner=None,
- python_executable=None,
- ):
- if runner is None:
- runner = default_subprocess_runner
- self.source_dir = abspath(source_dir)
- self.build_backend = build_backend
- if backend_path:
- backend_path = [
- norm_and_check(self.source_dir, p) for p in backend_path
- ]
- self.backend_path = backend_path
- self._subprocess_runner = runner
- if not python_executable:
- python_executable = sys.executable
- self.python_executable = python_executable
- @contextmanager
- def subprocess_runner(self, runner):
- """A context manager for temporarily overriding the default subprocess
- runner.
- """
- prev = self._subprocess_runner
- self._subprocess_runner = runner
- try:
- yield
- finally:
- self._subprocess_runner = prev
- def _supported_features(self):
- """Return the list of optional features supported by the backend."""
- return self._call_hook('_supported_features', {})
- def get_requires_for_build_wheel(self, config_settings=None):
- """Identify packages required for building a wheel
- Returns a list of dependency specifications, e.g.::
- ["wheel >= 0.25", "setuptools"]
- This does not include requirements specified in pyproject.toml.
- It returns the result of calling the equivalently named hook in a
- subprocess.
- """
- return self._call_hook('get_requires_for_build_wheel', {
- 'config_settings': config_settings
- })
- def prepare_metadata_for_build_wheel(
- self, metadata_directory, config_settings=None,
- _allow_fallback=True):
- """Prepare a ``*.dist-info`` folder with metadata for this project.
- Returns the name of the newly created folder.
- If the build backend defines a hook with this name, it will be called
- in a subprocess. If not, the backend will be asked to build a wheel,
- and the dist-info extracted from that (unless _allow_fallback is
- False).
- """
- return self._call_hook('prepare_metadata_for_build_wheel', {
- 'metadata_directory': abspath(metadata_directory),
- 'config_settings': config_settings,
- '_allow_fallback': _allow_fallback,
- })
- def build_wheel(
- self, wheel_directory, config_settings=None,
- metadata_directory=None):
- """Build a wheel from this project.
- Returns the name of the newly created file.
- In general, this will call the 'build_wheel' hook in the backend.
- However, if that was previously called by
- 'prepare_metadata_for_build_wheel', and the same metadata_directory is
- used, the previously built wheel will be copied to wheel_directory.
- """
- if metadata_directory is not None:
- metadata_directory = abspath(metadata_directory)
- return self._call_hook('build_wheel', {
- 'wheel_directory': abspath(wheel_directory),
- 'config_settings': config_settings,
- 'metadata_directory': metadata_directory,
- })
- def get_requires_for_build_editable(self, config_settings=None):
- """Identify packages required for building an editable wheel
- Returns a list of dependency specifications, e.g.::
- ["wheel >= 0.25", "setuptools"]
- This does not include requirements specified in pyproject.toml.
- It returns the result of calling the equivalently named hook in a
- subprocess.
- """
- return self._call_hook('get_requires_for_build_editable', {
- 'config_settings': config_settings
- })
- def prepare_metadata_for_build_editable(
- self, metadata_directory, config_settings=None,
- _allow_fallback=True):
- """Prepare a ``*.dist-info`` folder with metadata for this project.
- Returns the name of the newly created folder.
- If the build backend defines a hook with this name, it will be called
- in a subprocess. If not, the backend will be asked to build an editable
- wheel, and the dist-info extracted from that (unless _allow_fallback is
- False).
- """
- return self._call_hook('prepare_metadata_for_build_editable', {
- 'metadata_directory': abspath(metadata_directory),
- 'config_settings': config_settings,
- '_allow_fallback': _allow_fallback,
- })
- def build_editable(
- self, wheel_directory, config_settings=None,
- metadata_directory=None):
- """Build an editable wheel from this project.
- Returns the name of the newly created file.
- In general, this will call the 'build_editable' hook in the backend.
- However, if that was previously called by
- 'prepare_metadata_for_build_editable', and the same metadata_directory
- is used, the previously built wheel will be copied to wheel_directory.
- """
- if metadata_directory is not None:
- metadata_directory = abspath(metadata_directory)
- return self._call_hook('build_editable', {
- 'wheel_directory': abspath(wheel_directory),
- 'config_settings': config_settings,
- 'metadata_directory': metadata_directory,
- })
- def get_requires_for_build_sdist(self, config_settings=None):
- """Identify packages required for building a wheel
- Returns a list of dependency specifications, e.g.::
- ["setuptools >= 26"]
- This does not include requirements specified in pyproject.toml.
- It returns the result of calling the equivalently named hook in a
- subprocess.
- """
- return self._call_hook('get_requires_for_build_sdist', {
- 'config_settings': config_settings
- })
- def build_sdist(self, sdist_directory, config_settings=None):
- """Build an sdist from this project.
- Returns the name of the newly created file.
- This calls the 'build_sdist' backend hook in a subprocess.
- """
- return self._call_hook('build_sdist', {
- 'sdist_directory': abspath(sdist_directory),
- 'config_settings': config_settings,
- })
- def _call_hook(self, hook_name, kwargs):
- # On Python 2, pytoml returns Unicode values (which is correct) but the
- # environment passed to check_call needs to contain string values. We
- # convert here by encoding using ASCII (the backend can only contain
- # letters, digits and _, . and : characters, and will be used as a
- # Python identifier, so non-ASCII content is wrong on Python 2 in
- # any case).
- # For backend_path, we use sys.getfilesystemencoding.
- if sys.version_info[0] == 2:
- build_backend = self.build_backend.encode('ASCII')
- else:
- build_backend = self.build_backend
- extra_environ = {'PEP517_BUILD_BACKEND': build_backend}
- if self.backend_path:
- backend_path = os.pathsep.join(self.backend_path)
- if sys.version_info[0] == 2:
- backend_path = backend_path.encode(sys.getfilesystemencoding())
- extra_environ['PEP517_BACKEND_PATH'] = backend_path
- with tempdir() as td:
- hook_input = {'kwargs': kwargs}
- compat.write_json(hook_input, pjoin(td, 'input.json'),
- indent=2)
- # Run the hook in a subprocess
- with _in_proc_script_path() as script:
- python = self.python_executable
- self._subprocess_runner(
- [python, abspath(str(script)), hook_name, td],
- cwd=self.source_dir,
- extra_environ=extra_environ
- )
- data = compat.read_json(pjoin(td, 'output.json'))
- if data.get('unsupported'):
- raise UnsupportedOperation(data.get('traceback', ''))
- if data.get('no_backend'):
- raise BackendUnavailable(data.get('traceback', ''))
- if data.get('backend_invalid'):
- raise BackendInvalid(
- backend_name=self.build_backend,
- backend_path=self.backend_path,
- message=data.get('backend_error', '')
- )
- if data.get('hook_missing'):
- raise HookMissing(data.get('missing_hook_name') or hook_name)
- return data['return_val']
- class LoggerWrapper(threading.Thread):
- """
- Read messages from a pipe and redirect them
- to a logger (see python's logging module).
- """
- def __init__(self, logger, level):
- threading.Thread.__init__(self)
- self.daemon = True
- self.logger = logger
- self.level = level
- # create the pipe and reader
- self.fd_read, self.fd_write = os.pipe()
- self.reader = os.fdopen(self.fd_read)
- self.start()
- def fileno(self):
- return self.fd_write
- @staticmethod
- def remove_newline(msg):
- return msg[:-1] if msg.endswith(os.linesep) else msg
- def run(self):
- for line in self.reader:
- self._write(self.remove_newline(line))
- def _write(self, message):
- self.logger.log(self.level, message)
|