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