• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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