diff options
Diffstat (limited to 'pw_build/py/pw_build/project_builder_presubmit_runner.py')
-rw-r--r-- | pw_build/py/pw_build/project_builder_presubmit_runner.py | 860 |
1 files changed, 860 insertions, 0 deletions
diff --git a/pw_build/py/pw_build/project_builder_presubmit_runner.py b/pw_build/py/pw_build/project_builder_presubmit_runner.py new file mode 100644 index 000000000..7abb6b717 --- /dev/null +++ b/pw_build/py/pw_build/project_builder_presubmit_runner.py @@ -0,0 +1,860 @@ +# Copyright 2023 The Pigweed Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. +"""pw_build.project_builder_presubmit_runner""" + +import argparse +import fnmatch +import logging +from pathlib import Path +from typing import Callable, Dict, List, Optional, Union + + +import pw_cli.log +from pw_cli.arguments import ( + print_completions_for_option, + add_tab_complete_arguments, +) +from pw_presubmit.presubmit import ( + Program, + Programs, + Presubmit, + PresubmitContext, + Check, + fetch_file_lists, +) +import pw_presubmit.pigweed_presubmit +from pw_presubmit.build import GnGenNinja, gn_args, write_gn_args_file +from pw_presubmit.presubmit_context import get_check_traces, PresubmitCheckTrace +from pw_presubmit.tools import file_summary + +# pw_watch is not required by pw_build, this is an optional feature. +try: + from pw_watch.argparser import ( # type: ignore + add_parser_arguments as add_watch_arguments, + ) + from pw_watch.watch import run_watch, watch_setup # type: ignore + from pw_watch.watch_app import WatchAppPrefs # type: ignore + + PW_WATCH_AVAILABLE = True +except ImportError: + PW_WATCH_AVAILABLE = False + +from pw_build.project_builder import ( + ProjectBuilder, + run_builds, + ASCII_CHARSET, + EMOJI_CHARSET, +) +from pw_build.build_recipe import ( + BuildCommand, + BuildRecipe, + create_build_recipes, + UnknownBuildSystem, +) +from pw_build.project_builder_argparse import add_project_builder_arguments +from pw_build.project_builder_prefs import ProjectBuilderPrefs + + +_COLOR = pw_cli.color.colors() +_LOG = logging.getLogger('pw_build') + + +class PresubmitTraceAnnotationError(Exception): + """Exception for malformed PresubmitCheckTrace annotations.""" + + +def should_gn_gen(out: Path) -> bool: + """Returns True if the gn gen command should be run.""" + # gn gen only needs to run if build.ninja or args.gn files are missing. + expected_files = [ + out / 'build.ninja', + out / 'args.gn', + ] + return any(not gen_file.is_file() for gen_file in expected_files) + + +def should_gn_gen_with_args(gn_arg_dict: Dict[str, str]) -> Callable: + """Returns a callable which writes an args.gn file prior to checks. + + Returns: + Callable which takes a single Path argument and returns a bool + for True if the gn gen command should be run. + """ + + def _write_args_and_check(out: Path) -> bool: + # Always re-write the args.gn file. + write_gn_args_file(out / 'args.gn', **gn_arg_dict) + + return should_gn_gen(out) + + return _write_args_and_check + + +def _pw_package_install_command(package_name: str) -> BuildCommand: + return BuildCommand( + command=[ + 'pw', + '--no-banner', + 'package', + 'install', + package_name, + ], + ) + + +def _pw_package_install_to_build_command( + trace: PresubmitCheckTrace, +) -> BuildCommand: + """Returns a BuildCommand from a PresubmitCheckTrace.""" + package_name = trace.call_annotation.get('pw_package_install', None) + if package_name is None: + raise PresubmitTraceAnnotationError( + 'Missing "pw_package_install" value.' + ) + + return _pw_package_install_command(package_name) + + +def _bazel_command_args_to_build_commands( + trace: PresubmitCheckTrace, +) -> List[BuildCommand]: + """Returns a list of BuildCommands based on a bazel PresubmitCheckTrace.""" + build_steps: List[BuildCommand] = [] + + if not 'bazel' in trace.args: + return build_steps + + bazel_command = list(arg for arg in trace.args if not arg.startswith('--')) + bazel_options = list( + arg for arg in trace.args if arg.startswith('--') and arg != '--' + ) + # Check for `bazel build` or `bazel test` + if not ( + bazel_command[0].endswith('bazel') + and bazel_command[1] in ['build', 'test'] + ): + raise UnknownBuildSystem( + f'Unable to parse bazel command:\n {trace.args}' + ) + + bazel_subcommand = bazel_command[1] + bazel_targets = bazel_command[2:] + if bazel_subcommand == 'build': + build_steps.append( + BuildCommand( + build_system_command='bazel', + build_system_extra_args=['build'] + bazel_options, + targets=bazel_targets, + ) + ) + if bazel_subcommand == 'test': + build_steps.append( + BuildCommand( + build_system_command='bazel', + build_system_extra_args=['test'] + bazel_options, + targets=bazel_targets, + ) + ) + return build_steps + + +def _presubmit_trace_to_build_commands( + ctx: PresubmitContext, + presubmit_step: Check, +) -> List[BuildCommand]: + """Convert a presubmit step to a list of BuildCommands. + + Specifically, this handles the following types of PresubmitCheckTraces: + + - pw package installs + - gn gen followed by ninja + - bazel commands + + If none of the specific scenarios listed above are found the command args + are passed along to BuildCommand as is. + + Returns: + List of BuildCommands representing each command found in the + presubmit_step traces. + """ + build_steps: List[BuildCommand] = [] + + presubmit_step(ctx) + + step_traces = get_check_traces(ctx) + + for trace in step_traces: + trace_args = list(trace.args) + # Check for ninja -t graph command and skip it + if trace_args[0].endswith('ninja'): + try: + dash_t_index = trace_args.index('-t') + graph_index = trace_args.index('graph') + if graph_index == dash_t_index + 1: + # This trace has -t graph, skip it. + continue + except ValueError: + # '-t graph' was not found + pass + + if 'pw_package_install' in trace.call_annotation: + build_steps.append(_pw_package_install_to_build_command(trace)) + continue + + if 'bazel' in trace.args: + build_steps.extend(_bazel_command_args_to_build_commands(trace)) + continue + + # Check for gn gen or pw-wrap-ninja + transformed_args = [] + pw_wrap_ninja_found = False + gn_found = False + gn_gen_found = False + + for arg in trace.args: + # Check for a 'gn gen' command + if arg == 'gn': + gn_found = True + if arg == 'gen' and gn_found: + gn_gen_found = True + + # Check for pw-wrap-ninja, pw build doesn't use this. + if arg == 'pw-wrap-ninja': + # Use ninja instead + transformed_args.append('ninja') + pw_wrap_ninja_found = True + continue + # Remove --log-actions if pw-wrap-ninja was found. This is a + # non-standard ninja arg. + if pw_wrap_ninja_found and arg == '--log-actions': + continue + transformed_args.append(str(arg)) + + if gn_gen_found: + # Run the command with run_if=should_gn_gen + build_steps.append( + BuildCommand(run_if=should_gn_gen, command=transformed_args) + ) + else: + # Run the command as is. + build_steps.append(BuildCommand(command=transformed_args)) + + return build_steps + + +def presubmit_build_recipe( # pylint: disable=too-many-locals + repo_root: Path, + presubmit_out_dir: Path, + package_root: Path, + presubmit_step: Check, + all_files: List[Path], + modified_files: List[Path], +) -> Optional['BuildRecipe']: + """Construct a BuildRecipe from a pw_presubmit step.""" + out_dir = presubmit_out_dir / presubmit_step.name + + ctx = PresubmitContext( + root=repo_root, + repos=(repo_root,), + output_dir=out_dir, + failure_summary_log=out_dir / 'failure-summary.log', + paths=tuple(modified_files), + all_paths=tuple(all_files), + package_root=package_root, + luci=None, + override_gn_args={}, + num_jobs=None, + continue_after_build_error=True, + _failed=False, + format_options=pw_presubmit.presubmit.FormatOptions.load(), + dry_run=True, + ) + + presubmit_instance = Presubmit( + root=repo_root, + repos=(repo_root,), + output_directory=out_dir, + paths=modified_files, + all_paths=all_files, + package_root=package_root, + override_gn_args={}, + continue_after_build_error=True, + rng_seed=1, + full=False, + ) + + program = Program('', [presubmit_step]) + checks = list(presubmit_instance.apply_filters(program)) + if not checks: + _LOG.warning('') + _LOG.warning( + 'Step "%s" is not required for the current set of modified files.', + presubmit_step.name, + ) + _LOG.warning('') + return None + + try: + ctx.paths = tuple(checks[0].paths) + except IndexError: + raise PresubmitTraceAnnotationError( + 'Missing pw_presubmit.presubmit.Check for presubmit step:\n' + + repr(presubmit_step) + ) + + if isinstance(presubmit_step, GnGenNinja): + # GnGenNinja is directly translatable to a BuildRecipe. + selected_gn_args = { + name: value(ctx) if callable(value) else value + for name, value in presubmit_step.gn_args.items() + } + + return BuildRecipe( + build_dir=out_dir, + title=presubmit_step.name, + steps=[ + _pw_package_install_command(name) + for name in presubmit_step._packages # pylint: disable=protected-access + ] + + [ + BuildCommand( + run_if=should_gn_gen, + command=[ + 'gn', + 'gen', + str(out_dir), + gn_args(**selected_gn_args), + ], + ), + BuildCommand( + build_system_command='ninja', + targets=presubmit_step.ninja_targets, + ), + ], + ) + + # Unknown type of presubmit, use dry-run to capture subprocess traces. + build_steps = _presubmit_trace_to_build_commands(ctx, presubmit_step) + + out_dir.mkdir(parents=True, exist_ok=True) + + return BuildRecipe( + build_dir=out_dir, + title=presubmit_step.name, + steps=build_steps, + ) + + +def _get_parser( + presubmit_programs: Optional[Programs] = None, + build_recipes: Optional[List[BuildRecipe]] = None, +) -> argparse.ArgumentParser: + """Setup argparse for pw_build.project_builder and optionally pw_watch.""" + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + if PW_WATCH_AVAILABLE: + parser = add_watch_arguments(parser) + else: + parser = add_project_builder_arguments(parser) + + if build_recipes is not None: + + def build_recipe_argparse_type(arg: str) -> List[BuildRecipe]: + """Return a list of matching presubmit steps.""" + assert build_recipes + all_recipe_names = list( + recipe.display_name for recipe in build_recipes + ) + filtered_names = fnmatch.filter(all_recipe_names, arg) + + if not filtered_names: + recipe_name_str = '\n'.join(sorted(all_recipe_names)) + raise argparse.ArgumentTypeError( + f'"{arg}" does not match the name of a recipe.\n\n' + f'Valid Recipes:\n{recipe_name_str}' + ) + + return list( + recipe + for recipe in build_recipes + if recipe.display_name in filtered_names + ) + + parser.add_argument( + '-r', + '--recipe', + action='extend', + default=[], + help=( + 'Run a build recipe. Include an asterix to match more than one ' + "name. For example: --recipe 'gn_*'" + ), + type=build_recipe_argparse_type, + ) + + if presubmit_programs is not None: + # Add presubmit step arguments. + all_steps = presubmit_programs.all_steps() + + def presubmit_step_argparse_type(arg: str) -> List[Check]: + """Return a list of matching presubmit steps.""" + filtered_step_names = fnmatch.filter(all_steps.keys(), arg) + + if not filtered_step_names: + all_step_names = '\n'.join(sorted(all_steps.keys())) + raise argparse.ArgumentTypeError( + f'"{arg}" does not match the name of a presubmit step.\n\n' + f'Valid Steps:\n{all_step_names}' + ) + + return list(all_steps[name] for name in filtered_step_names) + + parser.add_argument( + '-s', + '--step', + action='extend', + default=[], + help=( + 'Run presubmit step. Include an asterix to match more than one ' + "step name. For example: --step '*_format'" + ), + type=presubmit_step_argparse_type, + ) + + if build_recipes or presubmit_programs: + parser.add_argument( + '-l', + '--list', + action='store_true', + default=False, + help=('List all known build recipes and presubmit steps.'), + ) + + if build_recipes: + parser.add_argument( + '--all', + action='store_true', + default=False, + help=('Run all known build recipes.'), + ) + + parser.add_argument( + '--progress-bars', + action='store_true', + default=True, + help='Show progress bars in the terminal.', + ) + + parser.add_argument( + '--no-progress-bars', + action='store_false', + dest='progress_bars', + help='Hide progress bars in terminal output.', + ) + + parser.add_argument( + '--log-build-steps', + action='store_true', + help='Show ninja build step log lines in output.', + ) + + parser.add_argument( + '--no-log-build-steps', + action='store_false', + dest='log_build_steps', + help='Hide ninja build steps log lines from log output.', + ) + + if PW_WATCH_AVAILABLE: + parser.add_argument( + '-w', + '--watch', + action='store_true', + help='Use pw_watch to monitor changes.', + default=False, + ) + + parser.add_argument( + '-b', + '--base', + help=( + 'Git revision to diff for changed files. This is used for ' + 'presubmit steps.' + ), + ) + + parser = add_tab_complete_arguments(parser) + + parser.add_argument( + '--tab-complete-recipe', + nargs='?', + help='Print tab completions for the supplied recipe name.', + ) + + parser.add_argument( + '--tab-complete-presubmit-step', + nargs='?', + help='Print tab completions for the supplied presubmit name.', + ) + + return parser + + +def _get_prefs( + args: argparse.Namespace, +) -> Union[ProjectBuilderPrefs, WatchAppPrefs]: + """Load either WatchAppPrefs or ProjectBuilderPrefs. + + Applies the command line args to the correct prefs class. + + Returns: + A WatchAppPrefs instance if pw_watch is importable, ProjectBuilderPrefs + otherwise. + """ + prefs: Union[ProjectBuilderPrefs, WatchAppPrefs] + if PW_WATCH_AVAILABLE: + prefs = WatchAppPrefs(load_argparse_arguments=add_watch_arguments) + prefs.apply_command_line_args(args) + else: + prefs = ProjectBuilderPrefs( + load_argparse_arguments=add_project_builder_arguments + ) + prefs.apply_command_line_args(args) + return prefs + + +def load_presubmit_build_recipes( + presubmit_programs: Programs, + presubmit_steps: List[Check], + repo_root: Path, + presubmit_out_dir: Path, + package_root: Path, + all_files: List[Path], + modified_files: List[Path], + default_presubmit_step_names: Optional[List[str]] = None, +) -> List[BuildRecipe]: + """Convert selected presubmit steps into a list of BuildRecipes.""" + # Use the default presubmit if no other steps or command line out + # directories are provided. + if len(presubmit_steps) == 0 and default_presubmit_step_names: + default_steps = list( + check + for name, check in presubmit_programs.all_steps().items() + if name in default_presubmit_step_names + ) + presubmit_steps = default_steps + + presubmit_recipes: List[BuildRecipe] = [] + + for step in presubmit_steps: + build_recipe = presubmit_build_recipe( + repo_root, + presubmit_out_dir, + package_root, + step, + all_files, + modified_files, + ) + if build_recipe: + presubmit_recipes.append(build_recipe) + + return presubmit_recipes + + +def _tab_complete_recipe( + build_recipes: List[BuildRecipe], + text: str = '', +) -> None: + for name in sorted(recipe.display_name for recipe in build_recipes): + if name.startswith(text): + print(name) + + +def _tab_complete_presubmit_step( + presubmit_programs: Programs, + text: str = '', +) -> None: + for name in sorted(presubmit_programs.all_steps().keys()): + if name.startswith(text): + print(name) + + +def _list_steps_and_recipes( + presubmit_programs: Optional[Programs] = None, + build_recipes: Optional[List[BuildRecipe]] = None, +) -> None: + if presubmit_programs: + _LOG.info('Presubmit steps:') + print() + for name in sorted(presubmit_programs.all_steps().keys()): + print(name) + print() + if build_recipes: + _LOG.info('Build recipes:') + print() + for name in sorted(recipe.display_name for recipe in build_recipes): + print(name) + print() + + +def _print_usage_help( + presubmit_programs: Optional[Programs] = None, + build_recipes: Optional[List[BuildRecipe]] = None, +) -> None: + """Print usage examples with known presubmits and build recipes.""" + + def print_pw_build( + option: str, arg: Optional[str] = None, end: str = '\n' + ) -> None: + print( + ' '.join( + [ + 'pw build', + _COLOR.cyan(option), + _COLOR.yellow(arg) if arg else '', + ] + ), + end=end, + ) + + if presubmit_programs: + print(_COLOR.green('All presubmit steps:')) + for name in sorted(presubmit_programs.all_steps().keys()): + print_pw_build('--step', name) + if build_recipes: + if presubmit_programs: + # Add a blank line separator + print() + print(_COLOR.green('All build recipes:')) + for name in sorted(recipe.display_name for recipe in build_recipes): + print_pw_build('--recipe', name) + + print() + print( + _COLOR.green( + 'Recipe and step names may use wildcards and be repeated:' + ) + ) + print_pw_build('--recipe', '"default_*"', end=' ') + print( + _COLOR.cyan('--step'), + _COLOR.yellow('step1'), + _COLOR.cyan('--step'), + _COLOR.yellow('step2'), + ) + print() + print(_COLOR.green('Run all build recipes:')) + print_pw_build('--all') + print() + print(_COLOR.green('For more help please run:')) + print_pw_build('--help') + + +def main( + presubmit_programs: Optional[Programs] = None, + default_presubmit_step_names: Optional[List[str]] = None, + build_recipes: Optional[List[BuildRecipe]] = None, + default_build_recipe_names: Optional[List[str]] = None, + repo_root: Optional[Path] = None, + presubmit_out_dir: Optional[Path] = None, + package_root: Optional[Path] = None, + default_root_logfile: Path = Path('out/build.txt'), + force_pw_watch: bool = False, +) -> int: + """Build upstream Pigweed presubmit steps.""" + # pylint: disable=too-many-locals + parser = _get_parser(presubmit_programs, build_recipes) + args = parser.parse_args() + + if args.tab_complete_option is not None: + print_completions_for_option( + parser, + text=args.tab_complete_option, + tab_completion_format=args.tab_complete_format, + ) + return 0 + + log_level = logging.DEBUG if args.debug_logging else logging.INFO + + pw_cli.log.install( + level=log_level, + use_color=args.colors, + # Hide the date from the timestamp + time_format='%H:%M:%S', + ) + + pw_env = pw_cli.env.pigweed_environment() + if pw_env.PW_EMOJI: + charset = EMOJI_CHARSET + else: + charset = ASCII_CHARSET + + if build_recipes and args.tab_complete_recipe is not None: + _tab_complete_recipe(build_recipes, text=args.tab_complete_recipe) + return 0 + + if presubmit_programs and args.tab_complete_presubmit_step is not None: + _tab_complete_presubmit_step( + presubmit_programs, text=args.tab_complete_presubmit_step + ) + return 0 + + # List valid steps + recipes. + if hasattr(args, 'list') and args.list: + _list_steps_and_recipes(presubmit_programs, build_recipes) + return 0 + + command_line_dash_c_recipes: List[BuildRecipe] = [] + # If -C out directories are provided add them to the recipes list. + if args.build_directories: + prefs = _get_prefs(args) + command_line_dash_c_recipes = create_build_recipes(prefs) + + if repo_root is None: + repo_root = pw_env.PW_PROJECT_ROOT + if presubmit_out_dir is None: + presubmit_out_dir = repo_root / 'out/presubmit' + if package_root is None: + package_root = pw_env.PW_PACKAGE_ROOT + + all_files: List[Path] + modified_files: List[Path] + + all_files, modified_files = fetch_file_lists( + root=repo_root, + repo=repo_root, + pathspecs=[], + base=args.base, + ) + + # Log modified file summary just like pw_presubmit if using --base. + if args.base: + _LOG.info( + 'Running steps that apply to modified files since "%s":', args.base + ) + _LOG.info('') + for line in file_summary( + mf.relative_to(repo_root) for mf in modified_files + ): + _LOG.info(line) + _LOG.info('') + + selected_build_recipes: List[BuildRecipe] = [] + if build_recipes: + if hasattr(args, 'recipe'): + selected_build_recipes = args.recipe + if not selected_build_recipes and default_build_recipe_names: + selected_build_recipes = [ + recipe + for recipe in build_recipes + if recipe.display_name in default_build_recipe_names + ] + + selected_presubmit_recipes: List[BuildRecipe] = [] + if presubmit_programs and hasattr(args, 'step'): + selected_presubmit_recipes = load_presubmit_build_recipes( + presubmit_programs, + args.step, + repo_root, + presubmit_out_dir, + package_root, + all_files, + modified_files, + default_presubmit_step_names=default_presubmit_step_names, + ) + + # If no builds specifed on the command line print a useful help message: + if ( + not selected_build_recipes + and not command_line_dash_c_recipes + and not selected_presubmit_recipes + and not args.all + ): + _print_usage_help(presubmit_programs, build_recipes) + return 1 + + if build_recipes and args.all: + selected_build_recipes = build_recipes + + # Run these builds in order: + recipes_to_build = ( + # -C dirs + command_line_dash_c_recipes + # --step 'name' + + selected_presubmit_recipes + # --recipe 'name' + + selected_build_recipes + ) + + # Always set separate build file logging. + if not args.logfile: + args.logfile = default_root_logfile + if not args.separate_logfiles: + args.separate_logfiles = True + + workers = 1 + if args.parallel: + # If parallel is requested and parallel_workers is set to 0 run all + # recipes in parallel. That is, use the number of recipes as the worker + # count. + if args.parallel_workers == 0: + workers = len(recipes_to_build) + else: + workers = args.parallel_workers + + project_builder = ProjectBuilder( + build_recipes=recipes_to_build, + jobs=args.jobs, + banners=args.banners, + keep_going=args.keep_going, + colors=args.colors, + charset=charset, + separate_build_file_logging=args.separate_logfiles, + # If running builds in serial, send all sub build logs to the root log + # window (or terminal). + send_recipe_logs_to_root=(workers == 1), + root_logfile=args.logfile, + root_logger=_LOG, + log_level=log_level, + allow_progress_bars=args.progress_bars, + log_build_steps=args.log_build_steps, + ) + + if project_builder.should_use_progress_bars(): + project_builder.use_stdout_proxy() + + if PW_WATCH_AVAILABLE and ( + force_pw_watch or (args.watch or args.fullscreen) + ): + event_handler, exclude_list = watch_setup( + project_builder, + parallel=args.parallel, + parallel_workers=workers, + fullscreen=args.fullscreen, + logfile=args.logfile, + separate_logfiles=args.separate_logfiles, + ) + + run_watch( + event_handler, + exclude_list, + fullscreen=args.fullscreen, + ) + return 0 + + # One off build + return run_builds(project_builder, workers) |