wrappers.py 13 KB

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