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