blob: 6b91756995b10d41bbe6b8093c7a25d4ace28c89 [file] [log] [blame]
# Copyright 2022 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.
"""Build a Pigweed Project.
Run arbitrary commands or invoke build systems (Ninja, Bazel and make) on one or
more build directories.
Usage examples:
# Build the default target in out/ using ninja.
python -m pw_build.project_builder -C out
# Build pw_run_tests.modules in the out/cmake directory
python -m pw_build.project_builder -C out/cmake pw_run_tests.modules
# Build the default target in out/ and pw_apps in out/cmake
python -m pw_build.project_builder -C out -C out/cmake pw_apps
# Build python.tests in out/ and pw_apps in out/cmake/
python -m pw_build.project_builder python.tests -C out/cmake pw_apps
# Run 'bazel build' and 'bazel test' on the target '//...' in outbazel/
python -m pw_build.project_builder --run-command 'mkdir -p outbazel'
-C outbazel '//...'
--build-system-command outbazel 'bazel build'
--build-system-command outbazel 'bazel test'
"""
import argparse
import concurrent.futures
import os
import logging
from pathlib import Path
import re
import shlex
import sys
import subprocess
from typing import (
Callable,
Dict,
Generator,
List,
NoReturn,
Optional,
Sequence,
NamedTuple,
)
import pw_cli.log
import pw_cli.env
from pw_build.build_recipe import BuildRecipe, create_build_recipes
from pw_build.project_builder_prefs import ProjectBuilderPrefs
from pw_build.project_builder_argparse import add_project_builder_arguments
_COLOR = pw_cli.color.colors()
_LOG = logging.getLogger('pw_build')
PASS_MESSAGE = """
██████╗ █████╗ ███████╗███████╗██╗
██╔══██╗██╔══██╗██╔════╝██╔════╝██║
██████╔╝███████║███████╗███████╗██║
██╔═══╝ ██╔══██║╚════██║╚════██║╚═╝
██║ ██║ ██║███████║███████║██╗
╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚═╝
"""
# Pick a visually-distinct font from "PASS" to ensure that readers can't
# possibly mistake the difference between the two states.
FAIL_MESSAGE = """
▄██████▒░▄▄▄ ██▓ ░██▓
▓█▓ ░▒████▄ ▓██▒ ░▓██▒
▒████▒ ░▒█▀ ▀█▄ ▒██▒ ▒██░
░▓█▒ ░░██▄▄▄▄██ ░██░ ▒██░
░▒█░ ▓█ ▓██▒░██░░ ████████▒
▒█░ ▒▒ ▓▒█░░▓ ░ ▒░▓ ░
░▒ ▒ ▒▒ ░ ▒ ░░ ░ ▒ ░
░ ░ ░ ▒ ▒ ░ ░ ░
░ ░ ░ ░ ░
"""
class ProjectBuilderCharset(NamedTuple):
slug_ok: str
slug_fail: str
slug_building: str
ASCII_CHARSET = ProjectBuilderCharset(
_COLOR.green('OK '),
_COLOR.red('FAIL'),
_COLOR.yellow('... '),
)
EMOJI_CHARSET = ProjectBuilderCharset('✔️ ', '❌', '⏱️ ')
def _exit(*args) -> NoReturn:
_LOG.critical(*args)
sys.exit(1)
def _exit_due_to_interrupt() -> NoReturn:
# To keep the log lines aligned with each other in the presence of
# a '^C' from the keyboard interrupt, add a newline before the log.
print()
_LOG.info('Got Ctrl-C; exiting...')
sys.exit(1)
_NINJA_BUILD_STEP = re.compile(
r'^\[(?P<step>[0-9]+)/(?P<total_steps>[0-9]+)\] (?P<action>.*)$'
)
_NINJA_FAILURE_TEXT = '\033[31mFAILED: '
def execute_command_no_logging(
command: List,
env: Dict,
recipe: BuildRecipe,
# pylint: disable=unused-argument
logger: logging.Logger = _LOG,
line_processed_callback: Optional[Callable[[BuildRecipe], None]] = None,
# pylint: enable=unused-argument
) -> bool:
print()
current_build = subprocess.run(command, env=env, errors='replace')
print()
recipe.status.return_code = current_build.returncode
return current_build.returncode == 0
def execute_command_with_logging(
command: List,
env: Dict,
recipe: BuildRecipe,
logger: logging.Logger = _LOG,
line_processed_callback: Optional[Callable[[BuildRecipe], None]] = None,
) -> bool:
"""Run a command in a subprocess and log all output."""
current_stdout = ''
returncode = None
with subprocess.Popen(
command,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
errors='replace',
) as proc:
# Empty line at the start.
logger.info('')
while returncode is None:
output = ''
error_output = ''
if proc.stdout:
output += proc.stdout.readline()
current_stdout += output
if proc.stderr:
error_output += proc.stderr.readline()
current_stdout += error_output
if not output and not error_output:
returncode = proc.poll()
continue
line_match_result = _NINJA_BUILD_STEP.match(output)
if line_match_result:
matches = line_match_result.groupdict()
recipe.status.current_step = line_match_result.group(0)
recipe.status.percent = float(
int(matches.get('step', 0))
/ int(matches.get('total_steps', 1))
)
if output.startswith(_NINJA_FAILURE_TEXT):
logger.error(output.strip())
recipe.status.error_count += 1
else:
# Mypy output mixes character encoding in its colored output
# due to it's use of the curses module retrieving the 'sgr0'
# (or exit_attribute_mode) capability from the host
# machine's terminfo database.
#
# This can result in this sequence ending up in STDOUT as
# b'\x1b(B\x1b[m'. (B tells terminals to interpret text as
# USASCII encoding but will appear in prompt_toolkit as a B
# character.
#
# The following replace calls will strip out those
# instances.
logger.info(
output.replace('\x1b(B\x1b[m', '')
.replace('\x1b[1m', '')
.strip()
)
if line_processed_callback:
line_processed_callback(recipe)
recipe.status.return_code = returncode
# Empty line at the end.
logger.info('')
return returncode == 0
def log_build_recipe_start(
index_message: str,
project_builder: 'ProjectBuilder',
cfg: BuildRecipe,
logger: logging.Logger = _LOG,
) -> None:
if project_builder.separate_build_file_logging:
cfg.log.propagate = False
build_start_msg = ' '.join(
[index_message, _COLOR.cyan('Starting ==>'), str(cfg)]
)
logger.info(build_start_msg)
if cfg.logfile:
logger.info(
'%s %s: %s',
index_message,
_COLOR.yellow('Logging to'),
cfg.logfile.resolve(),
)
cfg.log.info(build_start_msg)
def log_build_recipe_finish(
index_message: str,
project_builder: 'ProjectBuilder', # pylint: disable=unused-argument
cfg: BuildRecipe,
logger: logging.Logger = _LOG,
) -> None:
if cfg.status.failed():
level = logging.ERROR
tag = _COLOR.red('(FAIL)')
else:
level = logging.INFO
tag = _COLOR.green('(OK)')
build_finish_msg = [
level,
'%s %s %s %s',
index_message,
_COLOR.cyan('Finished ==>'),
cfg,
tag,
]
logger.log(*build_finish_msg)
if cfg.logfile:
cfg.log.log(*build_finish_msg)
class MissingGlobalLogfile(Exception):
"""Exception raised if a global logfile is not specifed."""
class ProjectBuilder: # pylint: disable=too-many-instance-attributes
"""Pigweed Project Builder"""
def __init__(
# pylint: disable=too-many-arguments
self,
build_recipes: Sequence[BuildRecipe],
jobs: Optional[int] = None,
banners: bool = True,
keep_going: bool = False,
abort_callback: Callable = _exit,
execute_command: Callable[
[List, Dict, BuildRecipe, logging.Logger, Optional[Callable]], bool
] = execute_command_no_logging,
charset: ProjectBuilderCharset = ASCII_CHARSET,
colors: bool = True,
separate_build_file_logging: bool = False,
root_logger: logging.Logger = _LOG,
root_logfile: Optional[Path] = None,
log_level: int = logging.INFO,
):
self.charset: ProjectBuilderCharset = charset
self.abort_callback = abort_callback
self.execute_command = execute_command
self.banners = banners
self.build_recipes = build_recipes
self.max_name_width = max(
[len(str(step.display_name)) for step in self.build_recipes]
)
self.extra_ninja_args: List[str] = []
self.extra_bazel_args: List[str] = []
self.extra_bazel_build_args: List[str] = []
if jobs:
job_args = ['-j', f'{jobs}']
self.extra_ninja_args.extend(job_args)
self.extra_bazel_build_args.extend(job_args)
if keep_going:
self.extra_ninja_args.extend(['-k', '0'])
self.extra_bazel_build_args.extend(['-k'])
self.colors = colors
if colors:
self.extra_bazel_args.append('--color=yes')
else:
self.extra_bazel_args.append('--color=no')
if separate_build_file_logging and not root_logfile:
raise MissingGlobalLogfile(
'\n\nA logfile must be specified if using separate logs per '
'build directory.'
)
self.separate_build_file_logging = separate_build_file_logging
self.default_log_level = log_level
self.default_logfile = root_logfile
if root_logfile:
timestamp_fmt = _COLOR.black_on_white('%(asctime)s') + ' '
formatter = logging.Formatter(
timestamp_fmt + '%(levelname)s %(message)s', '%Y%m%d %H:%M:%S'
)
self.execute_command = execute_command_with_logging
build_log_filehandler = logging.FileHandler(
root_logfile, encoding='utf-8'
)
build_log_filehandler.setLevel(log_level)
build_log_filehandler.setFormatter(formatter)
root_logger.addHandler(build_log_filehandler)
if not separate_build_file_logging:
for recipe in self.build_recipes:
recipe.set_logger(root_logger)
else:
for recipe in self.build_recipes:
new_logger = logging.getLogger(
f'{root_logger.name}.{recipe.display_name}'
)
new_logger.setLevel(log_level)
new_logger.propagate = False
new_logfile = root_logfile.parent / (
root_logfile.stem
+ '_'
+ recipe.display_name.replace(' ', '_')
+ root_logfile.suffix
)
new_log_filehandler = logging.FileHandler(
new_logfile, encoding='utf-8'
)
new_log_filehandler.setLevel(log_level)
new_log_filehandler.setFormatter(formatter)
new_logger.addHandler(new_log_filehandler)
recipe.set_logger(new_logger)
recipe.set_logfile(new_logfile)
def __len__(self) -> int:
return len(self.build_recipes)
def __getitem__(self, index: int) -> BuildRecipe:
return self.build_recipes[index]
def __iter__(self) -> Generator[BuildRecipe, None, None]:
return (build_recipe for build_recipe in self.build_recipes)
def run_build(
self,
cfg: BuildRecipe,
env: Dict,
index_message: Optional[str] = '',
) -> bool:
"""Run a single build config."""
if self.colors:
# Force colors in Pigweed subcommands run through the watcher.
env['PW_USE_COLOR'] = '1'
# Force Ninja to output ANSI colors
env['CLICOLOR_FORCE'] = '1'
build_succeded = False
cfg.reset_status()
for command_step in cfg.steps:
command_args = command_step.get_args(
additional_ninja_args=self.extra_ninja_args,
additional_bazel_args=self.extra_bazel_args,
additional_bazel_build_args=self.extra_bazel_build_args,
)
# Verify that the build output directories exist.
if (
command_step.build_system_command is not None
and cfg.build_dir
and (not cfg.build_dir.is_dir())
):
self.abort_callback(
'Build directory does not exist: %s', cfg.build_dir
)
quoted_command_args = ' '.join(
shlex.quote(arg) for arg in command_args
)
build_succeded = True
if command_step.should_run():
cfg.log.info(
'%s %s %s',
index_message,
_COLOR.blue('Run ==>'),
quoted_command_args,
)
build_succeded = self.execute_command(
command_args, env, cfg, cfg.log, None
)
else:
cfg.log.info(
'%s %s %s',
index_message,
_COLOR.yellow('Skipped ==>'),
quoted_command_args,
)
# Don't run further steps if a command fails.
if not build_succeded:
break
return build_succeded
def print_build_summary(
self,
cancelled: bool = False,
logger: logging.Logger = _LOG,
) -> None:
"""Print build status summary table."""
build_descriptions = []
build_status = []
for cfg in self:
description = [str(cfg.display_name).ljust(self.max_name_width)]
description.append(' '.join(cfg.targets()))
build_descriptions.append(' '.join(description))
if cfg.status.passed():
build_status.append(self.charset.slug_ok)
elif cfg.status.failed():
build_status.append(self.charset.slug_fail)
else:
build_status.append(self.charset.slug_building)
if not cancelled:
logger.info(' ╔════════════════════════════════════')
logger.info(' ║')
for (slug, cmd) in zip(build_status, build_descriptions):
logger.info(' ║ %s %s', slug, cmd)
logger.info(' ║')
logger.info(" ╚════════════════════════════════════")
else:
# Build was interrupted.
logger.info('')
logger.info(' ╔════════════════════════════════════')
logger.info(' ║')
logger.info(' ║ %s- interrupted', self.charset.slug_fail)
logger.info(' ║')
logger.info(" ╚════════════════════════════════════")
# Show a large color banner for the overall result.
if self.banners and not any(recipe.status.pending() for recipe in self):
if all(recipe.status.passed() for recipe in self) and not cancelled:
for line in PASS_MESSAGE.splitlines():
logger.info(_COLOR.green(line))
else:
for line in FAIL_MESSAGE.splitlines():
logger.info(_COLOR.red(line))
def run_recipe(
index: int, project_builder: ProjectBuilder, cfg: BuildRecipe, env
) -> None:
num_builds = len(project_builder)
index_message = f'[{index}/{num_builds}]'
log_build_recipe_start(index_message, project_builder, cfg)
try:
project_builder.run_build(cfg, env, index_message=index_message)
# Ctrl-C on Unix generates KeyboardInterrupt
# Ctrl-Z on Windows generates EOFError
except (KeyboardInterrupt, EOFError):
_exit_due_to_interrupt()
log_build_recipe_finish(index_message, project_builder, cfg)
def run_builds(project_builder: ProjectBuilder, workers: int = 1) -> None:
"""Execute build steps in the ProjectBuilder and print a summary."""
num_builds = len(project_builder)
_LOG.info('Starting build with %d directories', num_builds)
if project_builder.default_logfile:
_LOG.info(
'%s: %s',
_COLOR.yellow('Logging to'),
project_builder.default_logfile.resolve(),
)
env = os.environ.copy()
# Print status before starting
project_builder.print_build_summary()
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
future_to_index = {}
for i, cfg in enumerate(project_builder, 1):
future_to_index[
executor.submit(run_recipe, i, project_builder, cfg, env)
] = i
try:
for future in concurrent.futures.as_completed(future_to_index):
future.result()
# Ctrl-C on Unix generates KeyboardInterrupt
# Ctrl-Z on Windows generates EOFError
except (KeyboardInterrupt, EOFError):
_exit_due_to_interrupt()
# Print status when finished
project_builder.print_build_summary()
def main() -> None:
"""Build a Pigweed Project."""
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser = add_project_builder_arguments(parser)
args = parser.parse_args()
pw_env = pw_cli.env.pigweed_environment()
if pw_env.PW_EMOJI:
charset = EMOJI_CHARSET
else:
charset = ASCII_CHARSET
prefs = ProjectBuilderPrefs(
load_argparse_arguments=add_project_builder_arguments
)
prefs.apply_command_line_args(args)
build_recipes = create_build_recipes(prefs)
log_level = logging.DEBUG if args.debug_logging else logging.INFO
pw_cli.log.install(
level=log_level,
use_color=args.colors,
hide_timestamp=False,
)
project_builder = ProjectBuilder(
build_recipes=build_recipes,
jobs=args.jobs,
banners=args.banners,
keep_going=args.keep_going,
colors=args.colors,
charset=charset,
separate_build_file_logging=args.separate_logfiles,
root_logfile=args.logfile,
root_logger=_LOG,
log_level=log_level,
)
run_builds(project_builder)
if __name__ == '__main__':
main()