1# Copyright 2020 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"""Argument parsing code for presubmit checks.""" 15 16import argparse 17import logging 18import os 19from pathlib import Path 20import re 21import shutil 22import textwrap 23from typing import Callable, Collection, List, Optional, Sequence 24 25from pw_presubmit import git_repo, presubmit 26 27_LOG = logging.getLogger(__name__) 28DEFAULT_PATH = Path('out', 'presubmit') 29 30_OUTPUT_PATH_README = '''\ 31This directory was created by pw_presubmit to run presubmit checks for the 32{repo} repository. This directory is not used by the regular GN or CMake Ninja 33builds. It may be deleted safely. 34''' 35 36 37def add_path_arguments(parser) -> None: 38 """Adds common presubmit check options to an argument parser.""" 39 40 parser.add_argument( 41 'paths', 42 metavar='pathspec', 43 nargs='*', 44 help=( 45 'Paths or patterns to which to restrict the checks. These are ' 46 'interpreted as Git pathspecs. If --base is provided, only ' 47 'paths changed since that commit are checked.' 48 ), 49 ) 50 51 base = parser.add_mutually_exclusive_group() 52 base.add_argument( 53 '-b', 54 '--base', 55 metavar='commit', 56 default=git_repo.TRACKING_BRANCH_ALIAS, 57 help=( 58 'Git revision against which to diff for changed files. ' 59 'Default is the tracking branch of the current branch.' 60 ), 61 ) 62 63 base.add_argument( 64 '--all', 65 '--full', 66 dest='base', 67 action='store_const', 68 const=None, 69 help='Run actions for all files, not just changed files.', 70 ) 71 72 parser.add_argument( 73 '-e', 74 '--exclude', 75 metavar='regular_expression', 76 default=[], 77 action='append', 78 type=re.compile, 79 help=( 80 'Exclude paths matching any of these regular expressions, ' 81 "which are interpreted relative to each Git repository's root." 82 ), 83 ) 84 85 86def _add_programs_arguments( 87 parser: argparse.ArgumentParser, programs: presubmit.Programs, default: str 88): 89 def presubmit_program(arg: str) -> presubmit.Program: 90 if arg not in programs: 91 all_program_names = ', '.join(sorted(programs.keys())) 92 raise argparse.ArgumentTypeError( 93 f'{arg} is not the name of a presubmit program\n\n' 94 f'Valid Programs:\n{all_program_names}' 95 ) 96 97 return programs[arg] 98 99 # This argument is used to copy the default program into the argparse 100 # namespace argument. It's not intended to be set by users. 101 parser.add_argument( 102 '--default-program', 103 default=[presubmit_program(default)], 104 help=argparse.SUPPRESS, 105 ) 106 107 parser.add_argument( 108 '-p', 109 '--program', 110 choices=programs.values(), 111 type=presubmit_program, 112 action='append', 113 default=[], 114 help='Which presubmit program to run', 115 ) 116 117 parser.add_argument( 118 '--list-steps-file', 119 dest='list_steps_file', 120 type=Path, 121 help=argparse.SUPPRESS, 122 ) 123 124 all_steps = programs.all_steps() 125 126 def list_steps() -> None: 127 """List all available presubmit steps and their docstrings.""" 128 for step in sorted(all_steps.values(), key=str): 129 _LOG.info('%s', step) 130 if step.doc: 131 first, *rest = step.doc.split('\n', 1) 132 _LOG.info(' %s', first) 133 if rest and _LOG.isEnabledFor(logging.DEBUG): 134 for line in textwrap.dedent(*rest).splitlines(): 135 _LOG.debug(' %s', line) 136 137 parser.add_argument( 138 '--list-steps', 139 action='store_const', 140 const=list_steps, 141 default=None, 142 help='List all the available steps.', 143 ) 144 145 def presubmit_step(arg: str) -> presubmit.Check: 146 if arg not in all_steps: 147 all_step_names = ', '.join(sorted(all_steps.keys())) 148 raise argparse.ArgumentTypeError( 149 f'{arg} is not the name of a presubmit step\n\n' 150 f'Valid Steps:\n{all_step_names}' 151 ) 152 return all_steps[arg] 153 154 parser.add_argument( 155 '--step', 156 action='append', 157 choices=all_steps.values(), 158 default=[], 159 help='Run specific steps instead of running a full program.', 160 type=presubmit_step, 161 ) 162 163 parser.add_argument( 164 '--substep', 165 action='store', 166 help=( 167 "Run a specific substep of a step. Only supported if there's only " 168 'one --step argument and no --program arguments.' 169 ), 170 ) 171 172 def gn_arg(argument): 173 key, value = argument.split('=', 1) 174 return (key, value) 175 176 # Recipe code for handling builds with pre-release toolchains requires the 177 # ability to pass through GN args. This ability is not expected to be used 178 # directly outside of this case, so the option is hidden. Values passed in 179 # to this argument should be of the form 'key=value'. 180 parser.add_argument( 181 '--override-gn-arg', 182 dest='override_gn_args', 183 action='append', 184 type=gn_arg, 185 help=argparse.SUPPRESS, 186 ) 187 188 189def add_arguments( 190 parser: argparse.ArgumentParser, 191 programs: Optional[presubmit.Programs] = None, 192 default: str = '', 193) -> None: 194 """Adds common presubmit check options to an argument parser.""" 195 196 add_path_arguments(parser) 197 parser.add_argument( 198 '-k', 199 '--keep-going', 200 action='store_true', 201 help='Continue running presubmit steps after a failure.', 202 ) 203 parser.add_argument( 204 '--continue-after-build-error', 205 action='store_true', 206 help=( 207 'Within presubmit steps, continue running build steps after a ' 208 'failure.' 209 ), 210 ) 211 parser.add_argument( 212 '--output-directory', 213 type=Path, 214 help=f'Output directory (default: {"<repo root>" / DEFAULT_PATH})', 215 ) 216 parser.add_argument( 217 '--package-root', 218 type=Path, 219 help='Package root directory (default: <env directory>/packages)', 220 ) 221 222 exclusive = parser.add_mutually_exclusive_group() 223 exclusive.add_argument( 224 '--clear', 225 '--clean', 226 action='store_true', 227 help='Delete the presubmit output directory and exit.', 228 ) 229 230 if programs: 231 if not default: 232 raise ValueError('A default must be provided with programs') 233 234 _add_programs_arguments(parser, programs, default) 235 236 # LUCI builders extract the list of steps from the program and run them 237 # individually for a better UX in MILO. 238 parser.add_argument( 239 '--only-list-steps', 240 action='store_true', 241 help=argparse.SUPPRESS, 242 ) 243 244 245def run( # pylint: disable=too-many-arguments 246 default_program: Optional[presubmit.Program], 247 program: Sequence[presubmit.Program], 248 step: Sequence[presubmit.Check], 249 substep: str, 250 output_directory: Optional[Path], 251 package_root: Path, 252 clear: bool, 253 root: Optional[Path] = None, 254 repositories: Collection[Path] = (), 255 only_list_steps=False, 256 list_steps: Optional[Callable[[], None]] = None, 257 **other_args, 258) -> int: 259 """Processes arguments from add_arguments and runs the presubmit. 260 261 Args: 262 default_program: program to use if neither --program nor --step is used 263 program: from the --program option 264 step: from the --step option 265 substep: from the --substep option 266 output_directory: from --output-directory option 267 package_root: from --package-root option 268 clear: from the --clear option 269 root: base path from which to run presubmit checks; defaults to the root 270 of the current directory's repository 271 repositories: roots of Git repositories on which to run presubmit checks; 272 defaults to the root of the current directory's repository 273 only_list_steps: list the steps that would be executed, one per line, 274 instead of executing them 275 list_steps: list the steps that would be executed with their docstrings 276 **other_args: remaining arguments defined by by add_arguments 277 278 Returns: 279 exit code for sys.exit; 0 if successful, 1 if an error occurred 280 """ 281 if root is None: 282 root = git_repo.root() 283 284 if not repositories: 285 repositories = [root] 286 287 if output_directory is None: 288 output_directory = root / DEFAULT_PATH 289 290 output_directory.mkdir(parents=True, exist_ok=True) 291 output_directory.joinpath('README.txt').write_text( 292 _OUTPUT_PATH_README.format(repo=root) 293 ) 294 295 if not package_root: 296 package_root = Path(os.environ['PW_PACKAGE_ROOT']) 297 298 _LOG.debug('Using environment at %s', output_directory) 299 300 if clear: 301 _LOG.info('Clearing presubmit output directory') 302 303 if output_directory.exists(): 304 shutil.rmtree(output_directory) 305 _LOG.info('Deleted %s', output_directory) 306 307 return 0 308 309 if list_steps: 310 list_steps() 311 return 0 312 313 final_program: Optional[presubmit.Program] = None 314 if not program and not step: 315 assert default_program # Cast away Optional[]. 316 final_program = default_program 317 elif len(program) == 1 and not step: 318 final_program = program[0] 319 else: 320 steps: List[presubmit.Check] = [] 321 steps.extend(step) 322 for prog in program: 323 steps.extend(prog) 324 final_program = presubmit.Program('', steps) 325 326 if substep and len(final_program) > 1: 327 _LOG.error('--substep not supported if there are multiple steps') 328 return 1 329 330 if presubmit.run( 331 final_program, 332 root, 333 repositories, 334 only_list_steps=only_list_steps, 335 output_directory=output_directory, 336 package_root=package_root, 337 substep=substep, 338 **other_args, 339 ): 340 return 0 341 342 return 1 343