1# Copyright 2022 The Pigweed Authors 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); you may not 4# use this file except in compliance with the License. You may obtain a copy of 5# the License at 6# 7# https://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12# License for the specific language governing permissions and limitations under 13# the License. 14"""Script that preprocesses a Python command then runs it. 15 16This script evaluates expressions in the Python command's arguments then invokes 17the command. 18""" 19 20import argparse 21import atexit 22import json 23import logging 24import os 25from pathlib import Path 26import platform 27import shlex 28import subprocess 29import sys 30import time 31 32try: 33 from pw_build import gn_resolver 34 from pw_build.python_package import load_packages 35except (ImportError, ModuleNotFoundError): 36 # Load from python_package from this directory if pw_build is not available. 37 from python_package import load_packages # type: ignore 38 import gn_resolver # type: ignore 39 40if sys.platform != 'win32': 41 import fcntl # pylint: disable=import-error 42 43 # TODO: b/227670947 - Support Windows. 44 45_LOG = logging.getLogger(__name__) 46_LOCK_ACQUISITION_TIMEOUT = 30 * 60 # 30 minutes in seconds 47 48# TODO(frolv): Remove these aliases once downstream projects are migrated. 49GnPaths = gn_resolver.GnPaths 50expand_expressions = gn_resolver.expand_expressions 51 52 53def _parse_args() -> argparse.Namespace: 54 """Parses arguments for this script, splitting out the command to run.""" 55 56 parser = argparse.ArgumentParser(description=__doc__) 57 parser.add_argument( 58 '--gn-root', 59 type=Path, 60 required=True, 61 help=( 62 'Path to the root of the GN tree; ' 63 'value of rebase_path("//", root_build_dir)' 64 ), 65 ) 66 parser.add_argument( 67 '--current-path', 68 type=Path, 69 required=True, 70 help='Value of rebase_path(".", root_build_dir)', 71 ) 72 parser.add_argument( 73 '--default-toolchain', required=True, help='Value of default_toolchain' 74 ) 75 parser.add_argument( 76 '--current-toolchain', required=True, help='Value of current_toolchain' 77 ) 78 parser.add_argument('--module', help='Run this module instead of a script') 79 parser.add_argument( 80 '--env', 81 action='append', 82 help='Environment variables to set as NAME=VALUE', 83 ) 84 parser.add_argument( 85 '--touch', 86 type=Path, 87 help='File to touch after the command is run', 88 ) 89 parser.add_argument( 90 '--capture-output', 91 action='store_true', 92 help='Capture subcommand output; display only on error', 93 ) 94 parser.add_argument( 95 '--working-directory', 96 type=Path, 97 help='Change to this working directory before running the subcommand', 98 ) 99 parser.add_argument( 100 '--python-dep-list-files', 101 nargs='+', 102 type=Path, 103 help='Paths to text files containing lists of Python package metadata ' 104 'json files.', 105 ) 106 parser.add_argument( 107 '--python-virtualenv-config', 108 type=Path, 109 help='Path to a virtualenv json config to use for this action.', 110 ) 111 parser.add_argument( 112 '--command-launcher', help='Arguments to prepend to Python command' 113 ) 114 parser.add_argument( 115 'original_cmd', 116 nargs=argparse.REMAINDER, 117 help='Python script with arguments to run', 118 ) 119 parser.add_argument( 120 '--lockfile', 121 type=Path, 122 help=( 123 'Path to a pip lockfile. Any pip execution will acquire an ' 124 'exclusive lock on it, any other module a shared lock.' 125 ), 126 ) 127 return parser.parse_args() 128 129 130class LockAcquisitionTimeoutError(Exception): 131 """Raised on a timeout.""" 132 133 134def acquire_lock(lockfile: Path, exclusive: bool): 135 """Attempts to acquire the lock. 136 137 Args: 138 lockfile: pathlib.Path to the lock. 139 exclusive: whether this needs to be an exclusive lock. 140 141 Raises: 142 LockAcquisitionTimeoutError: If the lock is not acquired after a 143 reasonable time. 144 """ 145 if sys.platform == 'win32': 146 # No-op on Windows, which doesn't have POSIX file locking. 147 # TODO: b/227670947 - Get this working on Windows, too. 148 return 149 150 start_time = time.monotonic() 151 if exclusive: 152 # pylint: disable-next=used-before-assignment 153 lock_type = fcntl.LOCK_EX # type: ignore[name-defined] 154 else: 155 # pylint: disable-next=used-before-assignment 156 lock_type = fcntl.LOCK_SH # type: ignore[name-defined] 157 fd = os.open(lockfile, os.O_RDWR | os.O_CREAT) 158 159 # Make sure we close the file when the process exits. If we manage to 160 # acquire the lock below, closing the file will release it. 161 def cleanup(): 162 os.close(fd) 163 164 atexit.register(cleanup) 165 166 backoff = 1 167 while time.monotonic() - start_time < _LOCK_ACQUISITION_TIMEOUT: 168 try: 169 # pylint: disable=used-before-assignment 170 fcntl.flock( # type: ignore[name-defined] 171 fd, lock_type | fcntl.LOCK_NB # type: ignore[name-defined] 172 ) 173 # pylint: enable=used-before-assignment 174 return # Lock acquired! 175 except BlockingIOError: 176 pass # Keep waiting. 177 178 time.sleep(backoff * 0.05) 179 backoff += 1 180 181 raise LockAcquisitionTimeoutError( 182 f"Failed to acquire lock {lockfile} in {_LOCK_ACQUISITION_TIMEOUT}" 183 ) 184 185 186class MissingPythonDependency(Exception): 187 """An error occurred while processing a Python dependency.""" 188 189 190def _load_virtualenv_config(json_file_path: Path) -> tuple[str, str]: 191 with json_file_path.open() as json_fp: 192 json_dict = json.load(json_fp) 193 return json_dict.get('interpreter'), json_dict.get('path') 194 195 196def main( # pylint: disable=too-many-arguments,too-many-branches,too-many-locals 197 gn_root: Path, 198 current_path: Path, 199 original_cmd: list[str], 200 default_toolchain: str, 201 current_toolchain: str, 202 module: str | None, 203 env: list[str] | None, 204 python_dep_list_files: list[Path], 205 python_virtualenv_config: Path | None, 206 capture_output: bool, 207 touch: Path | None, 208 working_directory: Path | None, 209 command_launcher: str | None, 210 lockfile: Path | None, 211) -> int: 212 """Script entry point.""" 213 214 python_paths_list = [] 215 if python_dep_list_files: 216 py_packages = load_packages( 217 python_dep_list_files, 218 # If this python_action has no gn python_deps this file will be 219 # empty. 220 ignore_missing=True, 221 ) 222 223 for pkg in py_packages: 224 top_level_source_dir = pkg.package_dir 225 if not top_level_source_dir: 226 raise MissingPythonDependency( 227 'Unable to find top level source dir for the Python ' 228 f'package "{pkg}"' 229 ) 230 # Don't add this dir to the PYTHONPATH if no __init__.py exists. 231 init_py_files = top_level_source_dir.parent.glob('*/__init__.py') 232 if not any(init_py_files): 233 continue 234 python_paths_list.append( 235 gn_resolver.abspath(top_level_source_dir.parent) 236 ) 237 238 # Sort the PYTHONPATH list, it will be in a different order each build. 239 python_paths_list = sorted(python_paths_list) 240 241 if not original_cmd or original_cmd[0] != '--': 242 _LOG.error('%s requires a command to run', sys.argv[0]) 243 return 1 244 245 # GN build scripts are executed from the root build directory. 246 root_build_dir = gn_resolver.abspath(Path.cwd()) 247 248 tool = current_toolchain if current_toolchain != default_toolchain else '' 249 paths = gn_resolver.GnPaths( 250 root=gn_resolver.abspath(gn_root), 251 build=root_build_dir, 252 cwd=gn_resolver.abspath(current_path), 253 toolchain=tool, 254 ) 255 256 command = [sys.executable] 257 258 python_interpreter = None 259 python_virtualenv = None 260 if python_virtualenv_config: 261 python_interpreter, python_virtualenv = _load_virtualenv_config( 262 python_virtualenv_config 263 ) 264 265 if python_interpreter is not None: 266 command = [str(root_build_dir / python_interpreter)] 267 268 if command_launcher is not None: 269 command = shlex.split(command_launcher) + command 270 271 if module is not None: 272 command += ['-m', module] 273 274 run_args: dict = dict() 275 # Always inherit the environtment by default. If PYTHONPATH or VIRTUALENV is 276 # set below then the environment vars must be copied in or subprocess.run 277 # will run with only the new updated variables. 278 run_args['env'] = os.environ.copy() 279 280 if env is not None: 281 environment = os.environ.copy() 282 environment.update((k, v) for k, v in (a.split('=', 1) for a in env)) 283 run_args['env'] = environment 284 285 script_command = original_cmd[0] 286 if script_command == '--': 287 script_command = original_cmd[1] 288 289 is_pip_command = ( 290 module == 'pip' or 'pip_install_python_deps.py' in script_command 291 ) 292 293 existing_env = run_args['env'] if 'env' in run_args else os.environ.copy() 294 new_env = {} 295 if python_virtualenv: 296 new_env['VIRTUAL_ENV'] = str(root_build_dir / python_virtualenv) 297 bin_folder = 'Scripts' if platform.system() == 'Windows' else 'bin' 298 new_env['PATH'] = os.pathsep.join( 299 [ 300 str(root_build_dir / python_virtualenv / bin_folder), 301 existing_env.get('PATH', ''), 302 ] 303 ) 304 305 if python_virtualenv and python_paths_list and not is_pip_command: 306 python_path_prepend = os.pathsep.join( 307 str(p) for p in set(python_paths_list) 308 ) 309 310 # Append the existing PYTHONPATH to the new one. 311 new_python_path = os.pathsep.join( 312 path_str 313 for path_str in [ 314 python_path_prepend, 315 existing_env.get('PYTHONPATH', ''), 316 ] 317 if path_str 318 ) 319 320 new_env['PYTHONPATH'] = new_python_path 321 322 if 'env' not in run_args: 323 run_args['env'] = {} 324 run_args['env'].update(new_env) 325 326 if capture_output: 327 # Combine stdout and stderr so that error messages are correctly 328 # interleaved with the rest of the output. 329 run_args['stdout'] = subprocess.PIPE 330 run_args['stderr'] = subprocess.STDOUT 331 332 # Build the command to run. 333 try: 334 for arg in original_cmd[1:]: 335 command += gn_resolver.expand_expressions(paths, arg) 336 except gn_resolver.ExpressionError as err: 337 _LOG.error('%s: %s', sys.argv[0], err) 338 return 1 339 340 if working_directory: 341 run_args['cwd'] = working_directory 342 343 # TODO: b/235239674 - Deprecate the --lockfile option as part of the Python 344 # GN template refactor. 345 if lockfile: 346 try: 347 acquire_lock(lockfile, is_pip_command) 348 except LockAcquisitionTimeoutError as exception: 349 _LOG.error('%s', exception) 350 return 1 351 352 _LOG.debug('RUN %s', ' '.join(shlex.quote(arg) for arg in command)) 353 354 completed_process = subprocess.run(command, **run_args) 355 356 if completed_process.returncode != 0: 357 _LOG.debug( 358 'Command failed; exit code: %d', completed_process.returncode 359 ) 360 if capture_output: 361 sys.stdout.buffer.write(completed_process.stdout) 362 elif touch: 363 # If a stamp file is provided and the command executed successfully, 364 # touch the stamp file to indicate a successful run of the command. 365 touch = touch.resolve() 366 _LOG.debug('TOUCH %s', touch) 367 368 # Create the parent directory in case GN / Ninja hasn't created it. 369 touch.parent.mkdir(parents=True, exist_ok=True) 370 touch.touch() 371 372 return completed_process.returncode 373 374 375if __name__ == '__main__': 376 sys.exit(main(**vars(_parse_args()))) 377