1# Copyright 2019 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"""Python wrapper that runs a program. For use in GN.""" 15 16import argparse 17import logging 18import os 19import re 20import shlex 21import subprocess 22import sys 23import pathlib 24 25# Need to be able to run without pw_cli installed in the virtualenv. 26try: 27 import pw_cli.log 28except ImportError: 29 pass 30 31_LOG = logging.getLogger(__name__) 32 33 34def argument_parser( 35 parser: argparse.ArgumentParser | None = None, 36) -> argparse.ArgumentParser: 37 """Registers the script's arguments on an argument parser.""" 38 39 if parser is None: 40 parser = argparse.ArgumentParser(description=__doc__) 41 42 parser.add_argument( 43 '--args-file', 44 type=argparse.FileType('r'), 45 help='File containing extra positional arguments to the program', 46 ) 47 parser.add_argument( 48 '--capture-output', 49 action='store_true', 50 help='Hide output from the program unless it fails', 51 ) 52 parser.add_argument( 53 '-e', 54 '--env', 55 action='append', 56 default=[], 57 help='key=value environment pair for the process', 58 ) 59 parser.add_argument( 60 '--env-file', 61 type=argparse.FileType('r'), 62 help='File defining environment variables for the process', 63 ) 64 parser.add_argument( 65 '--skip-empty-args', 66 action='store_true', 67 help='Don\'t run the program if --args-file is empty', 68 ) 69 parser.add_argument( 70 '--target', 71 help='GN build target that runs the program', 72 ) 73 parser.add_argument( 74 '--working-directory', 75 type=pathlib.Path, 76 help='Directory to execute program in', 77 ) 78 parser.add_argument( 79 'command', 80 nargs=argparse.REMAINDER, 81 help='Program to run with arguments', 82 ) 83 84 return parser 85 86 87_ENV_REGEX = re.compile(r'(\w+)(\+)?=(.+)') 88 89 90def apply_env_var(string: str, env: dict[str, str]) -> None: 91 """Update an environment map with provided a key-value pair. 92 93 Pairs are accepted in two forms: 94 95 KEY=value sets environment variable "KEY" to "value" 96 KEY+=value appends OS-specific PATH separator and "value" to 97 environment variable "KEY" 98 """ 99 result = _ENV_REGEX.search(string.strip()) 100 if not result: 101 return 102 103 key, append, val = result.groups() 104 if append is not None: 105 curr = env.get(key) 106 val = f'{curr}{os.path.pathsep}{val}' if curr else val 107 108 env[key] = val 109 110 111def main() -> int: 112 """Runs a program specified by command-line arguments.""" 113 args = argument_parser().parse_args() 114 if not args.command or args.command[0] != '--': 115 return 1 116 117 env = os.environ.copy() 118 119 # Command starts after the "--". 120 command = args.command[1:] 121 # command[0] is the invoker.prog from gn and gn will escape 122 # the various spaces in the command which means when argparse 123 # gets the string argparse believes this as a single argument 124 # and cannot correctly break the string into a list that 125 # subprocess can handle. By splitting the first element 126 # in the command list, if there is a space, all of the 127 # command[0] elements will be made into a list and if not 128 # then split won't do everything and the old behavior 129 # will continue. 130 front_command = command[0].split(' ') 131 del command[0] 132 command = front_command + command 133 extra_kw_args = {} 134 135 if args.args_file is not None: 136 empty = True 137 for line in args.args_file: 138 empty = False 139 command.append(line.strip()) 140 141 if args.skip_empty_args and empty: 142 return 0 143 144 if args.env_file is not None: 145 for line in args.env_file: 146 apply_env_var(line, env) 147 148 # Apply command-line overrides at a higher priority than the env file. 149 for string in args.env: 150 apply_env_var(string, env) 151 152 if args.capture_output: 153 extra_kw_args['stdout'] = subprocess.PIPE 154 extra_kw_args['stderr'] = subprocess.STDOUT 155 156 if args.working_directory: 157 extra_kw_args['cwd'] = args.working_directory 158 159 process = subprocess.run(command, env=env, **extra_kw_args) # type: ignore 160 161 if process.returncode != 0 and args.capture_output: 162 _LOG.error('') 163 _LOG.error( 164 'Command failed with exit code %d in GN build.', process.returncode 165 ) 166 _LOG.error('') 167 _LOG.error('Build target:') 168 _LOG.error('') 169 _LOG.error(' %s', args.target) 170 _LOG.error('') 171 _LOG.error('Full command:') 172 _LOG.error('') 173 _LOG.error(' %s', ' '.join(shlex.quote(arg) for arg in command)) 174 _LOG.error('') 175 _LOG.error('Process output:') 176 print(flush=True) 177 sys.stdout.buffer.write(process.stdout) 178 print(flush=True) 179 _LOG.error('') 180 181 return process.returncode 182 183 184if __name__ == '__main__': 185 # If pw_cli is not yet installed in the virtualenv just skip it. 186 if 'pw_cli' in globals(): 187 pw_cli.log.install() 188 sys.exit(main()) 189