• 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"""Functions for building code during presubmit checks."""
15
16import base64
17import contextlib
18from dataclasses import dataclass
19import io
20import itertools
21import json
22import logging
23import os
24import posixpath
25from pathlib import Path
26import re
27import subprocess
28from shutil import which
29import sys
30import tarfile
31from typing import (
32    Any,
33    Callable,
34    Collection,
35    Container,
36    ContextManager,
37    Iterable,
38    Iterator,
39    Mapping,
40    Sequence,
41    Set,
42)
43
44import pw_cli.color
45from pw_cli.plural import plural
46from pw_cli.file_filter import FileFilter
47from pw_presubmit.presubmit import (
48    call,
49    Check,
50    filter_paths,
51    install_package,
52    PresubmitResult,
53    SubStep,
54)
55from pw_presubmit.presubmit_context import (
56    LuciContext,
57    LuciTrigger,
58    PresubmitContext,
59    PresubmitFailure,
60)
61from pw_presubmit import (
62    bazel_parser,
63    format_code,
64    ninja_parser,
65)
66from pw_presubmit.tools import (
67    log_run,
68    format_command,
69)
70
71_LOG = logging.getLogger(__name__)
72
73
74BAZEL_EXECUTABLE = 'bazel'
75
76
77def _get_remote_instance_name(ctx_luci: LuciContext) -> str:
78    instance_name = ''
79    if ctx_luci.project == 'pigweed':
80        instance_name = 'pigweed-rbe-open'
81    else:
82        instance_name = 'pigweed-rbe-private'
83    if ctx_luci.is_try:
84        instance_name += '-pre'
85
86    # pylint: disable-next=line-too-long
87    return f'--remote_instance_name=projects/{instance_name}/instances/default-instance'
88
89
90def bazel(
91    ctx: PresubmitContext,
92    cmd: str,
93    *args: str,
94    remote_download_outputs: str = 'minimal',
95    stdout: io.TextIOWrapper | None = None,
96    strict_module_lockfile: bool = False,
97    use_remote_cache: bool = False,
98    **kwargs,
99) -> None:
100    """Invokes Bazel with some common flags set.
101
102    Intended for use with bazel build and test. May not work with others.
103    """
104
105    num_jobs: list[str] = []
106    if ctx.num_jobs is not None:
107        num_jobs.extend(('--jobs', str(ctx.num_jobs)))
108
109    keep_going: list[str] = []
110    if ctx.continue_after_build_error:
111        keep_going.append('--keep_going')
112
113    strict_lockfile: list[str] = []
114    if strict_module_lockfile:
115        strict_lockfile.append('--lockfile_mode=error')
116
117    remote_cache: list[str] = []
118    if use_remote_cache and ctx.luci:
119        remote_cache.append('--config=remote_cache')
120        remote_cache.append('--remote_upload_local_results=true')
121        remote_cache.append(_get_remote_instance_name(ctx.luci))
122        remote_cache.append(
123            f'--remote_download_outputs={remote_download_outputs}'
124        )
125
126    symlink_prefix: list[str] = []
127    if cmd not in ('mod', 'query'):
128        # bazel query and bazel mod don't support the --symlink_prefix flag.
129        symlink_prefix.append(f'--symlink_prefix={ctx.output_dir / "bazel-"}')
130
131    ctx.output_dir.mkdir(exist_ok=True, parents=True)
132    try:
133        with contextlib.ExitStack() as stack:
134            if not stdout:
135                stdout = stack.enter_context(
136                    (ctx.output_dir / f'bazel.{cmd}.stdout').open('w')
137                )
138
139            with (ctx.output_dir / 'bazel.output.base').open('w') as outs, (
140                ctx.output_dir / 'bazel.output.base.err'
141            ).open('w') as errs:
142                call(
143                    BAZEL_EXECUTABLE,
144                    'info',
145                    'output_base',
146                    tee=outs,
147                    stderr=errs,
148                )
149
150            call(
151                BAZEL_EXECUTABLE,
152                cmd,
153                *symlink_prefix,
154                *num_jobs,
155                *keep_going,
156                *strict_lockfile,
157                *remote_cache,
158                *args,
159                cwd=ctx.root,
160                tee=stdout,
161                call_annotation={'build_system': 'bazel'},
162                **kwargs,
163            )
164
165    except PresubmitFailure as exc:
166        if stdout:
167            failure = bazel_parser.parse_bazel_stdout(Path(stdout.name))
168            if failure:
169                with ctx.failure_summary_log.open('w') as outs:
170                    outs.write(failure)
171
172        raise exc
173
174
175def _gn_value(value) -> str:
176    if isinstance(value, bool):
177        return str(value).lower()
178
179    if (
180        isinstance(value, str)
181        and '"' not in value
182        and not value.startswith("{")
183        and not value.startswith("[")
184    ):
185        return f'"{value}"'
186
187    if isinstance(value, (list, tuple)):
188        return f'[{", ".join(_gn_value(a) for a in value)}]'
189
190    # Fall-back case handles integers as well as strings that already
191    # contain double quotation marks, or look like scopes or lists.
192    return str(value)
193
194
195def gn_args_list(**kwargs) -> list[str]:
196    """Return a list of formatted strings to use as gn args.
197
198    Currently supports bool, int, and str values. In the case of str values,
199    quotation marks will be added automatically, unless the string already
200    contains one or more double quotation marks, or starts with a { or [
201    character, in which case it will be passed through as-is.
202    """
203    transformed_args = []
204    for arg, val in kwargs.items():
205        transformed_args.append(f'{arg}={_gn_value(val)}')
206
207    # Use ccache if available for faster repeat presubmit runs.
208    if which('ccache') and 'pw_command_launcher' not in kwargs:
209        transformed_args.append('pw_command_launcher="ccache"')
210
211    return transformed_args
212
213
214def gn_args(**kwargs) -> str:
215    """Builds a string to use for the --args argument to gn gen.
216
217    Currently supports bool, int, and str values. In the case of str values,
218    quotation marks will be added automatically, unless the string already
219    contains one or more double quotation marks, or starts with a { or [
220    character, in which case it will be passed through as-is.
221    """
222    return '--args=' + ' '.join(gn_args_list(**kwargs))
223
224
225def write_gn_args_file(destination_file: Path, **kwargs) -> str:
226    """Write gn args to a file.
227
228    Currently supports bool, int, and str values. In the case of str values,
229    quotation marks will be added automatically, unless the string already
230    contains one or more double quotation marks, or starts with a { or [
231    character, in which case it will be passed through as-is.
232
233    Returns:
234      The contents of the written file.
235    """
236    contents = '\n'.join(gn_args_list(**kwargs))
237    # Add a trailing linebreak
238    contents += '\n'
239    destination_file.parent.mkdir(exist_ok=True, parents=True)
240
241    if (
242        destination_file.is_file()
243        and destination_file.read_text(encoding='utf-8') == contents
244    ):
245        # File is identical, don't re-write.
246        return contents
247
248    destination_file.write_text(contents, encoding='utf-8')
249    return contents
250
251
252def gn_gen(
253    ctx: PresubmitContext,
254    *args: str,
255    gn_check: bool = True,  # pylint: disable=redefined-outer-name
256    gn_fail_on_unused: bool = True,
257    export_compile_commands: bool | str = True,
258    preserve_args_gn: bool = False,
259    **gn_arguments,
260) -> None:
261    """Runs gn gen in the specified directory with optional GN args.
262
263    Runs with --check=system if gn_check=True. Note that this does not cover
264    generated files. Run gn_check() after building to check generated files.
265    """
266    all_gn_args = {'pw_build_COLORIZE_OUTPUT': pw_cli.color.is_enabled()}
267    all_gn_args.update(gn_arguments)
268    all_gn_args.update(ctx.override_gn_args)
269    _LOG.debug('%r', all_gn_args)
270    args_option = gn_args(**all_gn_args)
271
272    if not ctx.dry_run and not preserve_args_gn:
273        # Delete args.gn to ensure this is a clean build.
274        args_gn = ctx.output_dir / 'args.gn'
275        if args_gn.is_file():
276            args_gn.unlink()
277
278    export_commands_arg = ''
279    if export_compile_commands:
280        export_commands_arg = '--export-compile-commands'
281        if isinstance(export_compile_commands, str):
282            export_commands_arg += f'={export_compile_commands}'
283
284    call(
285        'gn',
286        '--color' if pw_cli.color.is_enabled() else '--nocolor',
287        'gen',
288        ctx.output_dir,
289        *(['--check=system'] if gn_check else []),
290        *(['--fail-on-unused-args'] if gn_fail_on_unused else []),
291        *([export_commands_arg] if export_commands_arg else []),
292        *args,
293        *([args_option] if all_gn_args else []),
294        cwd=ctx.root,
295        call_annotation={
296            'gn_gen_args': all_gn_args,
297            'gn_gen_args_option': args_option,
298        },
299    )
300
301
302def gn_check(ctx: PresubmitContext) -> PresubmitResult:
303    """Runs gn check, including on generated and system files."""
304    call(
305        'gn',
306        'check',
307        ctx.output_dir,
308        '--check-generated',
309        '--check-system',
310        cwd=ctx.root,
311    )
312    return PresubmitResult.PASS
313
314
315def ninja(
316    ctx: PresubmitContext,
317    *args,
318    save_compdb: bool = True,
319    save_graph: bool = True,
320    **kwargs,
321) -> None:
322    """Runs ninja in the specified directory."""
323
324    num_jobs: list[str] = []
325    if ctx.num_jobs is not None:
326        num_jobs.extend(('-j', str(ctx.num_jobs)))
327
328    keep_going: list[str] = []
329    if ctx.continue_after_build_error:
330        keep_going.extend(('-k', '0'))
331
332    if save_compdb:
333        proc = log_run(
334            ['ninja', '-C', ctx.output_dir, '-t', 'compdb', *args],
335            capture_output=True,
336            **kwargs,
337        )
338        if not ctx.dry_run:
339            (ctx.output_dir / 'ninja.compdb').write_bytes(proc.stdout)
340
341    if save_graph:
342        proc = log_run(
343            ['ninja', '-C', ctx.output_dir, '-t', 'graph', *args],
344            capture_output=True,
345            **kwargs,
346        )
347        if not ctx.dry_run:
348            (ctx.output_dir / 'ninja.graph').write_bytes(proc.stdout)
349
350    ninja_stdout = ctx.output_dir / 'ninja.stdout'
351    ctx.output_dir.mkdir(exist_ok=True, parents=True)
352    try:
353        with ninja_stdout.open('w') as outs:
354            if sys.platform == 'win32':
355                # Windows doesn't support pw-wrap-ninja.
356                ninja_command = ['ninja']
357            else:
358                ninja_command = ['pw-wrap-ninja', '--log-actions']
359
360            call(
361                *ninja_command,
362                '-C',
363                ctx.output_dir,
364                *num_jobs,
365                *keep_going,
366                *args,
367                tee=outs,
368                propagate_sigterm=True,
369                call_annotation={'build_system': 'ninja'},
370                **kwargs,
371            )
372
373    except PresubmitFailure as exc:
374        failure = ninja_parser.parse_ninja_stdout(ninja_stdout)
375        if failure:
376            with ctx.failure_summary_log.open('w') as outs:
377                outs.write(failure)
378
379        raise exc
380
381
382def get_gn_args(directory: Path) -> list[dict[str, dict[str, str]]]:
383    """Dumps GN variables to JSON."""
384    proc = log_run(
385        ['gn', 'args', directory, '--list', '--json'], stdout=subprocess.PIPE
386    )
387    return json.loads(proc.stdout)
388
389
390def cmake(
391    ctx: PresubmitContext,
392    *args: str,
393    env: Mapping['str', 'str'] | None = None,
394) -> None:
395    """Runs CMake for Ninja on the given source and output directories."""
396    call(
397        'cmake',
398        '-B',
399        ctx.output_dir,
400        '-S',
401        ctx.root,
402        '-G',
403        'Ninja',
404        *args,
405        env=env,
406    )
407
408
409def env_with_clang_vars() -> Mapping[str, str]:
410    """Returns the environment variables with CC, CXX, etc. set for clang."""
411    env = os.environ.copy()
412    env['CC'] = env['LD'] = env['AS'] = 'clang'
413    env['CXX'] = 'clang++'
414    return env
415
416
417def _get_paths_from_command(source_dir: Path, *args, **kwargs) -> Set[Path]:
418    """Runs a command and reads Bazel or GN //-style paths from it."""
419    process = log_run(args, capture_output=True, cwd=source_dir, **kwargs)
420
421    if process.returncode:
422        _LOG.error(
423            'Build invocation failed with return code %d!', process.returncode
424        )
425        _LOG.error(
426            '[COMMAND] %s\n%s\n%s',
427            *format_command(args, kwargs),
428            process.stderr.decode(),
429        )
430        raise PresubmitFailure
431
432    files = set()
433
434    for line in process.stdout.splitlines():
435        path = line.strip().lstrip(b'/').replace(b':', b'/').decode()
436        path = source_dir.joinpath(path)
437        if path.is_file():
438            files.add(path)
439
440    return files
441
442
443# Finds string literals with '.' in them.
444_MAYBE_A_PATH = re.compile(
445    r'"'  # Starting double quote.
446    # Start capture group 1 - the whole filename:
447    #   File basename, a single period, file extension.
448    r'([^\n" ]+\.[^\n" ]+)'
449    # Non-capturing group 2 (optional).
450    r'(?: > [^\n"]+)?'  # pw_zip style string "input_file.txt > output_file.txt"
451    r'"'  # Ending double quote.
452)
453
454
455def _search_files_for_paths(build_files: Iterable[Path]) -> Iterable[Path]:
456    for build_file in build_files:
457        directory = build_file.parent
458
459        for string in _MAYBE_A_PATH.finditer(build_file.read_text()):
460            path = directory / string.group(1)
461            if path.is_file():
462                yield path
463
464
465def _read_compile_commands(compile_commands: Path) -> dict:
466    with compile_commands.open('rb') as fd:
467        return json.load(fd)
468
469
470def compiled_files(compile_commands: Path) -> Iterable[Path]:
471    for command in _read_compile_commands(compile_commands):
472        file = Path(command['file'])
473        if file.is_absolute():
474            yield file
475        else:
476            yield file.joinpath(command['directory']).resolve()
477
478
479def check_compile_commands_for_files(
480    compile_commands: Path | Iterable[Path],
481    files: Iterable[Path],
482    extensions: Collection[str] = format_code.CPP_SOURCE_EXTS,
483) -> list[Path]:
484    """Checks for paths in one or more compile_commands.json files.
485
486    Only checks C and C++ source files by default.
487    """
488    if isinstance(compile_commands, Path):
489        compile_commands = [compile_commands]
490
491    compiled = frozenset(
492        itertools.chain.from_iterable(
493            compiled_files(cmds) for cmds in compile_commands
494        )
495    )
496    return [f for f in files if f not in compiled and f.suffix in extensions]
497
498
499def check_bazel_build_for_files(
500    bazel_extensions_to_check: Container[str],
501    files: Iterable[Path],
502    bazel_dirs: Iterable[Path] = (),
503) -> list[Path]:
504    """Checks that source files are in the Bazel builds.
505
506    Args:
507        bazel_extensions_to_check: which file suffixes to look for in Bazel
508        files: the files that should be checked
509        bazel_dirs: directories in which to run bazel query
510
511    Returns:
512        a list of missing files; will be empty if there were no missing files
513    """
514
515    # Collect all paths in the Bazel builds.
516    bazel_builds: Set[Path] = set()
517    for directory in bazel_dirs:
518        bazel_builds.update(
519            _get_paths_from_command(
520                directory,
521                BAZEL_EXECUTABLE,
522                'query',
523                'kind("source file", //...:*)',
524            )
525        )
526
527    missing: list[Path] = []
528
529    if bazel_dirs:
530        for path in (p for p in files if p.suffix in bazel_extensions_to_check):
531            if path not in bazel_builds:
532                # TODO: b/234883555 - Replace this workaround for fuzzers.
533                if 'fuzz' not in str(path):
534                    missing.append(path)
535
536    if missing:
537        _LOG.warning(
538            '%s missing from the Bazel build:\n%s',
539            plural(missing, 'file', are=True),
540            '\n'.join(str(x) for x in missing),
541        )
542
543    return missing
544
545
546def check_gn_build_for_files(
547    gn_extensions_to_check: Container[str],
548    files: Iterable[Path],
549    gn_dirs: Iterable[tuple[Path, Path]] = (),
550    gn_build_files: Iterable[Path] = (),
551) -> list[Path]:
552    """Checks that source files are in the GN build.
553
554    Args:
555        gn_extensions_to_check: which file suffixes to look for in GN
556        files: the files that should be checked
557        gn_dirs: (source_dir, output_dir) tuples with which to run gn desc
558        gn_build_files: paths to BUILD.gn files to directly search for paths
559
560    Returns:
561        a list of missing files; will be empty if there were no missing files
562    """
563
564    # Collect all paths in GN builds.
565    gn_builds: Set[Path] = set()
566
567    for source_dir, output_dir in gn_dirs:
568        gn_builds.update(
569            _get_paths_from_command(source_dir, 'gn', 'desc', output_dir, '*')
570        )
571
572    gn_builds.update(_search_files_for_paths(gn_build_files))
573
574    missing: list[Path] = []
575
576    if gn_dirs or gn_build_files:
577        for path in (p for p in files if p.suffix in gn_extensions_to_check):
578            if path not in gn_builds:
579                missing.append(path)
580
581    if missing:
582        _LOG.warning(
583            '%s missing from the GN build:\n%s',
584            plural(missing, 'file', are=True),
585            '\n'.join(str(x) for x in missing),
586        )
587
588    return missing
589
590
591def check_soong_build_for_files(
592    soong_extensions_to_check: Container[str],
593    files: Iterable[Path],
594    soong_build_files: Iterable[Path] = (),
595) -> list[Path]:
596    """Checks that source files are in the Soong build.
597
598    Args:
599        bp_extensions_to_check: which file suffixes to look for in Soong files
600        files: the files that should be checked
601        bp_build_files: paths to Android.bp files to directly search for paths
602
603    Returns:
604        a list of missing files; will be empty if there were no missing files
605    """
606
607    # Collect all paths in Soong builds.
608    soong_builds = set(_search_files_for_paths(soong_build_files))
609
610    missing: list[Path] = []
611
612    if soong_build_files:
613        for path in (p for p in files if p.suffix in soong_extensions_to_check):
614            if path not in soong_builds:
615                missing.append(path)
616
617    if missing:
618        _LOG.warning(
619            '%s missing from the Soong build:\n%s',
620            plural(missing, 'file', are=True),
621            '\n'.join(str(x) for x in missing),
622        )
623
624    return missing
625
626
627def check_builds_for_files(
628    bazel_extensions_to_check: Container[str],
629    gn_extensions_to_check: Container[str],
630    files: Iterable[Path],
631    bazel_dirs: Iterable[Path] = (),
632    gn_dirs: Iterable[tuple[Path, Path]] = (),
633    gn_build_files: Iterable[Path] = (),
634) -> dict[str, list[Path]]:
635    """Checks that source files are in the GN and Bazel builds.
636
637    Args:
638        bazel_extensions_to_check: which file suffixes to look for in Bazel
639        gn_extensions_to_check: which file suffixes to look for in GN
640        files: the files that should be checked
641        bazel_dirs: directories in which to run bazel query
642        gn_dirs: (source_dir, output_dir) tuples with which to run gn desc
643        gn_build_files: paths to BUILD.gn files to directly search for paths
644
645    Returns:
646        a dictionary mapping build system ('Bazel' or 'GN' to a list of missing
647        files; will be empty if there were no missing files
648    """
649
650    bazel_missing = check_bazel_build_for_files(
651        bazel_extensions_to_check=bazel_extensions_to_check,
652        files=files,
653        bazel_dirs=bazel_dirs,
654    )
655    gn_missing = check_gn_build_for_files(
656        gn_extensions_to_check=gn_extensions_to_check,
657        files=files,
658        gn_dirs=gn_dirs,
659        gn_build_files=gn_build_files,
660    )
661
662    result = {}
663    if bazel_missing:
664        result['Bazel'] = bazel_missing
665    if gn_missing:
666        result['GN'] = gn_missing
667    return result
668
669
670@contextlib.contextmanager
671def test_server(executable: str, output_dir: Path):
672    """Context manager that runs a test server executable.
673
674    Args:
675        executable: name of the test server executable
676        output_dir: path to the output directory (for logs)
677    """
678
679    with open(output_dir / 'test_server.log', 'w') as outs:
680        try:
681            proc = subprocess.Popen(
682                [executable, '--verbose'],
683                stdout=outs,
684                stderr=subprocess.STDOUT,
685            )
686
687            yield
688
689        finally:
690            proc.terminate()  # pylint: disable=used-before-assignment
691
692
693@contextlib.contextmanager
694def modified_env(**envvars):
695    """Context manager that sets environment variables.
696
697    Use by assigning values to variable names in the argument list, e.g.:
698        `modified_env(MY_FLAG="some value")`
699
700    Args:
701        envvars: Keyword arguments
702    """
703    saved_env = os.environ.copy()
704    os.environ.update(envvars)
705    try:
706        yield
707    finally:
708        os.environ = saved_env
709
710
711def fuzztest_prng_seed(ctx: PresubmitContext) -> str:
712    """Convert the RNG seed to the format expected by FuzzTest.
713
714    FuzzTest can be configured to use the seed by setting the
715    `FUZZTEST_PRNG_SEED` environment variable to this value.
716
717    Args:
718        ctx: The context that includes a pseudorandom number generator seed.
719    """
720    rng_bytes = ctx.rng_seed.to_bytes(32, sys.byteorder)
721    return base64.urlsafe_b64encode(rng_bytes).decode('ascii').rstrip('=')
722
723
724@filter_paths(
725    file_filter=FileFilter(
726        endswith=('.bzl', '.bazel'),
727        name=('WORKSPACE',),
728        exclude=(r'pw_presubmit/py/pw_presubmit/format/test_data',),
729    )
730)
731def bazel_lint(ctx: PresubmitContext):
732    """Runs buildifier with lint on Bazel files.
733
734    Should be run after bazel_format since that will give more useful output
735    for formatting-only issues.
736    """
737
738    failure = False
739    for path in ctx.paths:
740        try:
741            call('buildifier', '--lint=warn', '--mode=check', path)
742        except PresubmitFailure:
743            failure = True
744
745    if failure:
746        raise PresubmitFailure
747
748
749@Check
750def gn_gen_check(ctx: PresubmitContext):
751    """Runs gn gen --check to enforce correct header dependencies."""
752    gn_gen(ctx, gn_check=True)
753
754
755Item = int | str
756Value = Item | Sequence[Item]
757ValueCallable = Callable[[PresubmitContext], Value]
758InputItem = Item | ValueCallable
759InputValue = InputItem | Sequence[InputItem]
760
761
762def _value(ctx: PresubmitContext, val: InputValue) -> Value:
763    """Process any lambdas inside val
764
765    val is a single value or a list of values, any of which might be a lambda
766    that needs to be resolved. Call each of these lambdas with ctx and replace
767    the lambda with the result. Return the updated top-level structure.
768    """
769
770    if isinstance(val, (str, int)):
771        return val
772    if callable(val):
773        return val(ctx)
774
775    result: list[Item] = []
776    for item in val:
777        if callable(item):
778            call_result = item(ctx)
779            if isinstance(call_result, (int, str)):
780                result.append(call_result)
781            else:  # Sequence.
782                result.extend(call_result)
783        elif isinstance(item, (int, str)):
784            result.append(item)
785        else:  # Sequence.
786            result.extend(item)
787    return result
788
789
790_CtxMgrLambda = Callable[[PresubmitContext], ContextManager]
791_CtxMgrOrLambda = ContextManager | _CtxMgrLambda
792
793
794@dataclass(frozen=True)
795class CommonCoverageOptions:
796    """Coverage options shared by both CodeSearch and Gerrit.
797
798    For Google use only.
799    """
800
801    # The "root" of the Kalypsi GCS bucket path to which the coverage data
802    # should be uploaded. Typically gs://ng3-metrics/ng3-<teamname>-coverage.
803    target_bucket_root: str
804
805    # The project name in the Kalypsi GCS bucket path.
806    target_bucket_project: str
807
808    # See go/kalypsi-abs#trace-type-required.
809    trace_type: str
810
811    # go/kalypsi-abs#owner-required.
812    owner: str
813
814    # go/kalypsi-abs#bug-component-required.
815    bug_component: str
816
817
818@dataclass(frozen=True)
819class CodeSearchCoverageOptions:
820    """CodeSearch-specific coverage options. For Google use only."""
821
822    # The name of the Gerrit host containing the CodeSearch repo. Just the name
823    # ("pigweed"), not the full URL ("pigweed.googlesource.com"). This may be
824    # different from the host from which the code was originally checked out.
825    host: str
826
827    # The name of the project, as expected by CodeSearch. Typically
828    # 'codesearch'.
829    project: str
830
831    # See go/kalypsi-abs#ref-required.
832    ref: str
833
834    # See go/kalypsi-abs#source-required.
835    source: str
836
837    # See go/kalypsi-abs#add-prefix-optional.
838    add_prefix: str = ''
839
840
841@dataclass(frozen=True)
842class GerritCoverageOptions:
843    """Gerrit-specific coverage options. For Google use only."""
844
845    # The name of the project, as expected by Gerrit. This is typically the
846    # repository name, e.g. 'pigweed/pigweed' for upstream Pigweed.
847    # See go/kalypsi-inc#project-required.
848    project: str
849
850
851@dataclass(frozen=True)
852class CoverageOptions:
853    """Coverage collection configuration. For Google use only."""
854
855    common: CommonCoverageOptions
856    codesearch: tuple[CodeSearchCoverageOptions, ...]
857    gerrit: GerritCoverageOptions
858
859
860class _NinjaBase(Check):
861    """Thin wrapper of Check for steps that call ninja."""
862
863    def __init__(
864        self,
865        *args,
866        packages: Sequence[str] = (),
867        ninja_contexts: Sequence[_CtxMgrOrLambda] = (),
868        ninja_targets: str | Sequence[str] | Sequence[Sequence[str]] = (),
869        coverage_options: CoverageOptions | None = None,
870        **kwargs,
871    ):
872        """Initializes a _NinjaBase object.
873
874        Args:
875            *args: Passed on to superclass.
876            packages: List of 'pw package' packages to install.
877            ninja_contexts: List of context managers to apply around ninja
878                calls.
879            ninja_targets: Single ninja target, list of Ninja targets, or list
880                of list of ninja targets. If a list of a list, ninja will be
881                called multiple times with the same build directory.
882            coverage_options: Coverage collection options (or None, if not
883                collecting coverage data).
884            **kwargs: Passed on to superclass.
885        """
886        super().__init__(*args, **kwargs)
887        self._packages: Sequence[str] = packages
888        self._ninja_contexts: tuple[_CtxMgrOrLambda, ...] = tuple(
889            ninja_contexts
890        )
891        self._coverage_options = coverage_options
892
893        if isinstance(ninja_targets, str):
894            ninja_targets = (ninja_targets,)
895        ninja_targets = list(ninja_targets)
896        all_strings = all(isinstance(x, str) for x in ninja_targets)
897        any_strings = any(isinstance(x, str) for x in ninja_targets)
898        if ninja_targets and all_strings != any_strings:
899            raise ValueError(repr(ninja_targets))
900
901        self._ninja_target_lists: tuple[tuple[str, ...], ...]
902        if all_strings:
903            targets: list[str] = []
904            for target in ninja_targets:
905                targets.append(target)  # type: ignore
906            self._ninja_target_lists = (tuple(targets),)
907        else:
908            self._ninja_target_lists = tuple(tuple(x) for x in ninja_targets)
909
910    @property
911    def ninja_targets(self) -> list[str]:
912        return list(itertools.chain(*self._ninja_target_lists))
913
914    def _install_package(  # pylint: disable=no-self-use
915        self,
916        ctx: PresubmitContext,
917        package: str,
918    ) -> PresubmitResult:
919        install_package(ctx, package)
920        return PresubmitResult.PASS
921
922    @contextlib.contextmanager
923    def _context(self, ctx: PresubmitContext):
924        """Apply any context managers necessary for building."""
925        with contextlib.ExitStack() as stack:
926            for mgr in self._ninja_contexts:
927                if isinstance(mgr, contextlib.AbstractContextManager):
928                    stack.enter_context(mgr)
929                else:
930                    stack.enter_context(mgr(ctx))  # type: ignore
931            yield
932
933    def _ninja(
934        self, ctx: PresubmitContext, targets: Sequence[str]
935    ) -> PresubmitResult:
936        with self._context(ctx):
937            ninja(ctx, *targets)
938        return PresubmitResult.PASS
939
940    def _coverage(
941        self, ctx: PresubmitContext, options: CoverageOptions
942    ) -> PresubmitResult:
943        """Archive and (on LUCI) upload coverage reports."""
944        reports = ctx.output_dir / 'coverage_reports'
945        os.makedirs(reports, exist_ok=True)
946        coverage_jsons: list[Path] = []
947        for path in ctx.output_dir.rglob('coverage_report'):
948            _LOG.debug('exploring %s', path)
949            name = str(path.relative_to(ctx.output_dir))
950            name = name.replace('_', '').replace('/', '_')
951            with tarfile.open(reports / f'{name}.tar.gz', 'w:gz') as tar:
952                tar.add(path, arcname=name, recursive=True)
953            json_path = path / 'json' / 'report.json'
954            if json_path.is_file():
955                _LOG.debug('found json %s', json_path)
956                coverage_jsons.append(json_path)
957
958        if not coverage_jsons:
959            ctx.fail('No coverage json file found')
960            return PresubmitResult.FAIL
961
962        if len(coverage_jsons) > 1:
963            _LOG.warning(
964                'More than one coverage json file, selecting first: %r',
965                coverage_jsons,
966            )
967
968        coverage_json = coverage_jsons[0]
969
970        if ctx.luci:
971            if not ctx.luci.is_prod:
972                _LOG.warning('Not uploading coverage since not running in prod')
973                return PresubmitResult.PASS
974
975            with self._context(ctx):
976                metadata_json_paths = _write_coverage_metadata(ctx, options)
977                for i, metadata_json in enumerate(metadata_json_paths):
978                    # GCS bucket paths are POSIX-like.
979                    coverage_gcs_path = posixpath.join(
980                        options.common.target_bucket_root,
981                        'incremental' if ctx.luci.is_try else 'absolute',
982                        options.common.target_bucket_project,
983                        f'{ctx.luci.buildbucket_id}-{i}',
984                    )
985                    _copy_to_gcs(
986                        ctx,
987                        coverage_json,
988                        posixpath.join(coverage_gcs_path, 'report.json'),
989                    )
990                    _copy_to_gcs(
991                        ctx,
992                        metadata_json,
993                        posixpath.join(coverage_gcs_path, 'metadata.json'),
994                    )
995
996                return PresubmitResult.PASS
997
998        _LOG.warning('Not uploading coverage since running locally')
999        return PresubmitResult.PASS
1000
1001    def _package_substeps(self) -> Iterator[SubStep]:
1002        for package in self._packages:
1003            yield SubStep(
1004                f'install {package} package',
1005                self._install_package,
1006                (package,),
1007            )
1008
1009    def _ninja_substeps(self) -> Iterator[SubStep]:
1010        targets_parts = set()
1011        for targets in self._ninja_target_lists:
1012            targets_part = " ".join(targets)
1013            maxlen = 70
1014            if len(targets_part) > maxlen:
1015                targets_part = f'{targets_part[0:maxlen-3]}...'
1016            assert targets_part not in targets_parts
1017            targets_parts.add(targets_part)
1018            yield SubStep(f'ninja {targets_part}', self._ninja, (targets,))
1019
1020    def _coverage_substeps(self) -> Iterator[SubStep]:
1021        if self._coverage_options is not None:
1022            yield SubStep('coverage', self._coverage, (self._coverage_options,))
1023
1024
1025def _copy_to_gcs(ctx: PresubmitContext, filepath: Path, gcs_dst: str):
1026    luci = Path(pw_cli.env.pigweed_environment().PW_LUCI_CIPD_INSTALL_DIR)
1027    gsutil = luci / 'gsutil' / 'gsutil'
1028
1029    cmd = [gsutil, 'cp', filepath, gcs_dst]
1030
1031    upload_stdout = ctx.output_dir / (filepath.name + '.stdout')
1032    with upload_stdout.open('w') as outs:
1033        call(*cmd, tee=outs)
1034
1035
1036class NoPrimaryTriggerError(Exception):
1037    pass
1038
1039
1040def _get_primary_change(ctx: PresubmitContext) -> LuciTrigger:
1041    assert ctx.luci is not None
1042
1043    if len(ctx.luci.triggers) == 1:
1044        return ctx.luci.triggers[0]
1045
1046    for trigger in ctx.luci.triggers:
1047        if trigger.primary:
1048            return trigger
1049
1050    raise NoPrimaryTriggerError(repr(ctx.luci.triggers))
1051
1052
1053def _write_coverage_metadata(
1054    ctx: PresubmitContext, options: CoverageOptions
1055) -> Sequence[Path]:
1056    """Write out Kalypsi coverage metadata file(s) and return their paths."""
1057    assert ctx.luci is not None
1058    change = _get_primary_change(ctx)
1059
1060    metadata = {
1061        'trace_type': options.common.trace_type,
1062        'trim_prefix': str(ctx.root),
1063        'patchset_num': change.patchset,
1064        'change_id': change.number,
1065        'owner': options.common.owner,
1066        'bug_component': options.common.bug_component,
1067    }
1068
1069    if ctx.luci.is_try:
1070        # Running in CQ: uploading incremental coverage
1071        metadata.update(
1072            {
1073                'change_id': change.number,
1074                'host': change.gerrit_name,
1075                'patchset_num': change.patchset,
1076                'project': options.gerrit.project,
1077            }
1078        )
1079
1080        metadata_json = ctx.output_dir / "metadata.json"
1081        with metadata_json.open('w') as metadata_file:
1082            json.dump(metadata, metadata_file)
1083        return (metadata_json,)
1084
1085    # Running in CI: uploading absolute coverage, possibly to multiple locations
1086    # since a repo could be in codesearch in multiple places.
1087    metadata_jsons = []
1088    for i, cs in enumerate(options.codesearch):
1089        metadata.update(
1090            {
1091                'add_prefix': cs.add_prefix,
1092                'commit_id': change.ref,
1093                'host': cs.host,
1094                'project': cs.project,
1095                'ref': cs.ref,
1096                'source': cs.source,
1097            }
1098        )
1099
1100        metadata_json = ctx.output_dir / f'metadata-{i}.json'
1101        with metadata_json.open('w') as metadata_file:
1102            json.dump(metadata, metadata_file)
1103        metadata_jsons.append(metadata_json)
1104
1105    return tuple(metadata_jsons)
1106
1107
1108class GnGenNinja(_NinjaBase):
1109    """Thin wrapper of Check for steps that just call gn/ninja.
1110
1111    Runs gn gen, ninja, then gn check.
1112    """
1113
1114    def __init__(
1115        self,
1116        *args,
1117        gn_args: (  # pylint: disable=redefined-outer-name
1118            dict[str, Any] | None
1119        ) = None,
1120        **kwargs,
1121    ):
1122        """Initializes a GnGenNinja object.
1123
1124        Args:
1125            *args: Passed on to superclass.
1126            gn_args: dict of GN args.
1127            **kwargs: Passed on to superclass.
1128        """
1129        super().__init__(self._substeps(), *args, **kwargs)
1130        self._gn_args: dict[str, Any] = gn_args or {}
1131
1132    def add_default_gn_args(self, args):
1133        """Add any project-specific default GN args to 'args'."""
1134
1135    @property
1136    def gn_args(self) -> dict[str, Any]:
1137        return self._gn_args
1138
1139    def _gn_gen(self, ctx: PresubmitContext) -> PresubmitResult:
1140        args: dict[str, Any] = {}
1141        if self._coverage_options is not None:
1142            args['pw_toolchain_COVERAGE_ENABLED'] = True
1143            args['pw_build_PYTHON_TEST_COVERAGE'] = True
1144
1145            if ctx.incremental:
1146                args['pw_toolchain_PROFILE_SOURCE_FILES'] = [
1147                    f'//{x.relative_to(ctx.root)}' for x in ctx.paths
1148                ]
1149
1150        self.add_default_gn_args(args)
1151
1152        args.update({k: _value(ctx, v) for k, v in self._gn_args.items()})
1153        gn_gen(ctx, gn_check=False, **args)  # type: ignore
1154        return PresubmitResult.PASS
1155
1156    def _substeps(self) -> Iterator[SubStep]:
1157        yield from self._package_substeps()
1158
1159        yield SubStep('gn gen', self._gn_gen)
1160
1161        yield from self._ninja_substeps()
1162
1163        # Run gn check after building so it can check generated files.
1164        yield SubStep('gn check', gn_check)
1165
1166        yield from self._coverage_substeps()
1167