blob: d3043291c2d9324773d81173c336471a07f5c838 [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.
Examples:
.. code-block: sh
# 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'
"""
from __future__ import annotations
import argparse
import concurrent.futures
import os
import logging
from pathlib import Path
import re
import shlex
import sys
import subprocess
import time
from typing import (
Callable,
Generator,
NoReturn,
Sequence,
NamedTuple,
)
from prompt_toolkit.patch_stdout import StdoutProxy
import pw_cli.env
import pw_cli.log
from pw_build.build_recipe import BuildRecipe, create_build_recipes
from pw_build.project_builder_argparse import add_project_builder_arguments
from pw_build.project_builder_context import get_project_builder_context
from pw_build.project_builder_prefs import ProjectBuilderPrefs
_COLOR = pw_cli.color.colors()
_LOG = logging.getLogger('pw_build')
BUILDER_CONTEXT = get_project_builder_context()
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() -> None:
"""Abort function called when not using progress bars."""
# 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...')
BUILDER_CONTEXT.ctrl_c_interrupt()
_NINJA_BUILD_STEP = re.compile(
# Start of line
r'^'
# Step count: [1234/5678]
r'\[(?P<step>[0-9]+)/(?P<total_steps>[0-9]+)\]'
# whitespace
r' *'
# Build step text
r'(?P<action>.*)$'
)
_BAZEL_BUILD_STEP = re.compile(
# Start of line
r'^'
# Optional starting green color
r'(?:\x1b\[32m)?'
# Step count: [1,234 / 5,678]
r'\[(?P<step>[0-9,]+) */ *(?P<total_steps>[0-9,]+)\]'
# Optional ending clear color and space
r'(?:\x1b\[0m)? *'
# Build step text
r'(?P<action>.*)$'
)
_NINJA_FAILURE = re.compile(
# Start of line
r'^'
# Optional red color
r'(?:\x1b\[31m)?'
r'FAILED:'
r' '
# Optional color reset
r'(?:\x1b\[0m)?'
)
_BAZEL_FAILURE = re.compile(
# Start of line
r'^'
# Optional red color
r'(?:\x1b\[31m)?'
# Optional bold color
r'(?:\x1b\[1m)?'
r'FAIL:'
# Optional color reset
r'(?:\x1b\[0m)?'
# whitespace
r' *'
r'.*bazel-out.*'
)
_BAZEL_ELAPSED_TIME = re.compile(
# Start of line
r'^'
# Optional green color
r'(?:\x1b\[32m)?'
r'INFO:'
# single space
r' '
# Optional color reset
r'(?:\x1b\[0m)?'
r'Elapsed time:'
)
def execute_command_no_logging(
command: list,
env: dict,
recipe: BuildRecipe,
# pylint: disable=unused-argument
logger: logging.Logger = _LOG,
line_processed_callback: Callable[[BuildRecipe], None] | None = None,
# pylint: enable=unused-argument
) -> bool:
print()
proc = subprocess.Popen(command, env=env, errors='replace')
BUILDER_CONTEXT.register_process(recipe, proc)
returncode = None
while returncode is None:
if BUILDER_CONTEXT.build_stopping():
try:
proc.terminate()
except ProcessLookupError:
# Process already stopped.
pass
returncode = proc.poll()
time.sleep(0.05)
print()
recipe.status.return_code = returncode
return proc.returncode == 0
def execute_command_with_logging(
command: list,
env: dict,
recipe: BuildRecipe,
logger: logging.Logger = _LOG,
line_processed_callback: Callable[[BuildRecipe], None] | None = None,
) -> bool:
"""Run a command in a subprocess and log all output."""
current_stdout = ''
returncode = None
starting_failure_regex = _NINJA_FAILURE
ending_failure_regex = _NINJA_BUILD_STEP
build_step_regex = _NINJA_BUILD_STEP
if command[0].endswith('ninja'):
build_step_regex = _NINJA_BUILD_STEP
starting_failure_regex = _NINJA_FAILURE
ending_failure_regex = _NINJA_BUILD_STEP
elif command[0].endswith('bazel'):
build_step_regex = _BAZEL_BUILD_STEP
starting_failure_regex = _BAZEL_FAILURE
ending_failure_regex = _BAZEL_ELAPSED_TIME
with subprocess.Popen(
command,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
errors='replace',
) as proc:
BUILDER_CONTEXT.register_process(recipe, proc)
should_log_build_steps = BUILDER_CONTEXT.log_build_steps
# Empty line at the start.
logger.info('')
failure_line = False
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 = build_step_regex.match(output)
if line_match_result:
if failure_line and not BUILDER_CONTEXT.build_stopping():
recipe.status.log_last_failure()
failure_line = False
matches = line_match_result.groupdict()
recipe.status.current_step = line_match_result.group(0)
recipe.status.percent = 0.0
# Remove commas from step and total_steps strings
step = int(matches.get('step', '0').replace(',', ''))
total_steps = int(
matches.get('total_steps', '1').replace(',', '')
)
if total_steps > 0:
recipe.status.percent = float(step / total_steps)
logger_method = logger.info
if starting_failure_regex.match(output):
logger_method = logger.error
if failure_line and not BUILDER_CONTEXT.build_stopping():
recipe.status.log_last_failure()
recipe.status.increment_error_count()
failure_line = True
elif ending_failure_regex.match(output):
if failure_line and not BUILDER_CONTEXT.build_stopping():
recipe.status.log_last_failure()
failure_line = False
# Mypy output mixes character encoding in color coded output
# and uses 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
# sequences.
stripped_output = output.replace('\x1b(B', '').strip()
# If this isn't a build step.
if not line_match_result or (
# Or if this is a build step and logging build steps is
# requested:
line_match_result
and should_log_build_steps
):
# Log this line.
logger_method(stripped_output)
recipe.status.current_step = stripped_output
if failure_line:
recipe.status.append_failure_line(stripped_output)
BUILDER_CONTEXT.redraw_progress()
if line_processed_callback:
line_processed_callback(recipe)
if BUILDER_CONTEXT.build_stopping():
try:
proc.terminate()
except ProcessLookupError:
# Process already stopped.
pass
recipe.status.return_code = returncode
# Log the last failure if not done already
if failure_line and not BUILDER_CONTEXT.build_stopping():
recipe.status.log_last_failure()
# 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:
"""Log recipe start and truncate the build logfile."""
if project_builder.separate_build_file_logging and cfg.logfile:
# Truncate the file
with open(cfg.logfile, 'w'):
pass
BUILDER_CONTEXT.mark_progress_started(cfg)
build_start_msg = [
index_message,
project_builder.color.cyan('Starting ==>'),
project_builder.color.blue('Recipe:'),
str(cfg.display_name),
project_builder.color.blue('Targets:'),
str(' '.join(cfg.targets())),
]
if cfg.logfile:
build_start_msg.extend(
[
project_builder.color.blue('Logfile:'),
str(cfg.logfile.resolve()),
]
)
build_start_str = ' '.join(build_start_msg)
# Log start to the root log if recipe logs are not sent.
if not project_builder.send_recipe_logs_to_root:
logger.info(build_start_str)
if cfg.logfile:
cfg.log.info(build_start_str)
def log_build_recipe_finish(
index_message: str,
project_builder: ProjectBuilder,
cfg: BuildRecipe,
logger: logging.Logger = _LOG,
) -> None:
"""Log recipe finish and any build errors."""
BUILDER_CONTEXT.mark_progress_done(cfg)
if BUILDER_CONTEXT.interrupted():
level = logging.WARNING
tag = project_builder.color.yellow('(ABORT)')
elif cfg.status.failed():
level = logging.ERROR
tag = project_builder.color.red('(FAIL)')
else:
level = logging.INFO
tag = project_builder.color.green('(OK)')
build_finish_msg = [
level,
'%s %s %s %s %s',
index_message,
project_builder.color.cyan('Finished ==>'),
project_builder.color.blue('Recipe:'),
cfg.display_name,
tag,
]
# Log finish to the root log if recipe logs are not sent.
if not project_builder.send_recipe_logs_to_root:
logger.log(*build_finish_msg)
if cfg.logfile:
cfg.log.log(*build_finish_msg)
if (
not BUILDER_CONTEXT.build_stopping()
and cfg.status.failed()
and (cfg.status.error_count == 0 or cfg.status.has_empty_ninja_errors())
):
cfg.status.log_entire_recipe_logfile()
class MissingGlobalLogfile(Exception):
"""Exception raised if a global logfile is not specifed."""
class DispatchingFormatter(logging.Formatter):
"""Dispatch log formatting based on the logger name."""
def __init__(self, formatters, default_formatter):
self._formatters = formatters
self._default_formatter = default_formatter
super().__init__()
def format(self, record):
logger = logging.getLogger(record.name)
formatter = self._formatters.get(logger.name, self._default_formatter)
return formatter.format(record)
class ProjectBuilder: # pylint: disable=too-many-instance-attributes
"""Pigweed Project Builder
Controls how build recipes are executed and logged.
Example usage:
.. code-block:: python
import logging
from pathlib import Path
from pw_build.build_recipe import BuildCommand, BuildRecipe
from pw_build.project_builder import ProjectBuilder
def should_gen_gn(out: Path) -> bool:
return not (out / 'build.ninja').is_file()
recipe = BuildRecipe(
build_dir='out',
title='Vanilla Ninja Build',
steps=[
BuildCommand(command=['gn', 'gen', '{build_dir}'],
run_if=should_gen_gn),
BuildCommand(build_system_command='ninja',
build_system_extra_args=['-k', '0'],
targets=['default']),
],
)
project_builder = ProjectBuilder(
build_recipes=[recipe1, ...]
banners=True,
log_level=logging.INFO
separate_build_file_logging=True,
root_logger=logging.getLogger(),
root_logfile=Path('build_log.txt'),
)
Args:
build_recipes: List of build recipes.
jobs: The number of jobs bazel, make, and ninja should use by passing
``-j`` to each.
keep_going: If True keep going flags are passed to bazel and ninja with
the ``-k`` option.
banners: Print the project banner at the start of each build.
allow_progress_bars: If False progress bar output will be disabled.
log_build_steps: If True all build step lines will be logged to the
screen and logfiles. Default: False.
colors: Print ANSI colors to stdout and logfiles
log_level: Optional log_level, defaults to logging.INFO.
root_logfile: Optional root logfile.
separate_build_file_logging: If True separate logfiles will be created
per build recipe. The location of each file depends on if a
``root_logfile`` is provided. If a root logfile is used each build
recipe logfile will be created in the same location. If no
root_logfile is specified the per build log files are placed in each
build dir as ``log.txt``
send_recipe_logs_to_root: If True will send all build recipie output to
the root logger. This only makes sense to use if the builds are run
in serial.
use_verbatim_error_log_formatting: Use a blank log format when printing
errors from sub builds to the root logger.
"""
def __init__(
# pylint: disable=too-many-arguments,too-many-locals
self,
build_recipes: Sequence[BuildRecipe],
jobs: int | None = None,
banners: bool = True,
keep_going: bool = False,
abort_callback: Callable = _exit,
execute_command: Callable[
[list, dict, BuildRecipe, logging.Logger, Callable | None], bool
] = execute_command_no_logging,
charset: ProjectBuilderCharset = ASCII_CHARSET,
colors: bool = True,
separate_build_file_logging: bool = False,
send_recipe_logs_to_root: bool = False,
root_logger: logging.Logger = _LOG,
root_logfile: Path | None = None,
log_level: int = logging.INFO,
allow_progress_bars: bool = True,
use_verbatim_error_log_formatting: bool = False,
log_build_steps: bool = False,
):
self.charset: ProjectBuilderCharset = charset
self.abort_callback = abort_callback
# Function used to run subprocesses
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]
)
# Set project_builder reference in each recipe.
for recipe in self.build_recipes:
recipe.set_project_builder(self)
# Save build system args
self.extra_ninja_args: list[str] = []
self.extra_bazel_args: list[str] = []
self.extra_bazel_build_args: list[str] = []
# Handle jobs and keep going flags.
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
# Reference to pw_cli.color, will return colored text if colors are
# enabled.
self.color = pw_cli.color.colors(colors)
# Pass color setting to bazel
if colors:
self.extra_bazel_args.append('--color=yes')
else:
self.extra_bazel_args.append('--color=no')
# Progress bar enable/disable flag
self.allow_progress_bars = allow_progress_bars
self.log_build_steps = log_build_steps
self.stdout_proxy: StdoutProxy | None = None
# Logger configuration
self.root_logger = root_logger
self.default_logfile = root_logfile
self.default_log_level = log_level
# Create separate logs per build
self.separate_build_file_logging = separate_build_file_logging
# Propagate logs to the root looger
self.send_recipe_logs_to_root = send_recipe_logs_to_root
# Setup the error logger
self.use_verbatim_error_log_formatting = (
use_verbatim_error_log_formatting
)
self.error_logger = logging.getLogger(f'{root_logger.name}.errors')
self.error_logger.setLevel(log_level)
self.error_logger.propagate = True
for recipe in self.build_recipes:
recipe.set_error_logger(self.error_logger)
# Copy of the standard Pigweed style log formatter, used by default if
# no formatter exists on the root logger.
timestamp_fmt = self.color.black_on_white('%(asctime)s') + ' '
self.default_log_formatter = logging.Formatter(
timestamp_fmt + '%(levelname)s %(message)s', '%Y%m%d %H:%M:%S'
)
# Empty log formatter (optionally used for error reporting)
self.blank_log_formatter = logging.Formatter('%(message)s')
# Setup the default log handler and inherit user defined formatting on
# the root_logger.
self.apply_root_log_formatting()
# Create a root logfile to save what is normally logged to stdout.
if root_logfile:
# Execute subprocesses and capture logs
self.execute_command = execute_command_with_logging
root_logfile.parent.mkdir(parents=True, exist_ok=True)
build_log_filehandler = logging.FileHandler(
root_logfile,
encoding='utf-8',
# Truncate the file
mode='w',
)
build_log_filehandler.setLevel(log_level)
build_log_filehandler.setFormatter(self.dispatching_log_formatter)
root_logger.addHandler(build_log_filehandler)
# Set each recipe to use the root logger by default.
for recipe in self.build_recipes:
recipe.set_logger(root_logger)
# Create separate logfiles per build
if separate_build_file_logging:
self._create_per_build_logfiles()
def _create_per_build_logfiles(self) -> None:
"""Create separate log files per build.
If a root logfile is used, create per build log files in the same
location. If no root logfile is specified create the per build log files
in the build dir as ``log.txt``
"""
self.execute_command = execute_command_with_logging
for recipe in self.build_recipes:
sub_logger_name = recipe.display_name.replace('.', '_')
new_logger = logging.getLogger(
f'{self.root_logger.name}.{sub_logger_name}'
)
new_logger.setLevel(self.default_log_level)
new_logger.propagate = self.send_recipe_logs_to_root
new_logfile_dir = recipe.build_dir
new_logfile_name = Path('log.txt')
new_logfile_postfix = ''
if self.default_logfile:
new_logfile_dir = self.default_logfile.parent
new_logfile_name = self.default_logfile
new_logfile_postfix = '_' + recipe.display_name.replace(
' ', '_'
)
new_logfile = new_logfile_dir / (
new_logfile_name.stem
+ new_logfile_postfix
+ new_logfile_name.suffix
)
new_logfile_dir.mkdir(parents=True, exist_ok=True)
new_log_filehandler = logging.FileHandler(
new_logfile,
encoding='utf-8',
# Truncate the file
mode='w',
)
new_log_filehandler.setLevel(self.default_log_level)
new_log_filehandler.setFormatter(self.dispatching_log_formatter)
new_logger.addHandler(new_log_filehandler)
recipe.set_logger(new_logger)
recipe.set_logfile(new_logfile)
def apply_root_log_formatting(self) -> None:
"""Inherit user defined formatting from the root_logger."""
# Use the the existing root logger formatter if one exists.
for handler in logging.getLogger().handlers:
if handler.formatter:
self.default_log_formatter = handler.formatter
break
formatter_mapping = {
self.root_logger.name: self.default_log_formatter,
}
if self.use_verbatim_error_log_formatting:
formatter_mapping[self.error_logger.name] = self.blank_log_formatter
self.dispatching_log_formatter = DispatchingFormatter(
formatter_mapping,
self.default_log_formatter,
)
def should_use_progress_bars(self) -> bool:
if not self.allow_progress_bars:
return False
if self.separate_build_file_logging or self.default_logfile:
return True
return False
def use_stdout_proxy(self) -> None:
"""Setup StdoutProxy for progress bars."""
self.stdout_proxy = StdoutProxy(raw=True)
root_logger = logging.getLogger()
handlers = root_logger.handlers + self.error_logger.handlers
for handler in handlers:
# Must use type() check here since this returns True:
# isinstance(logging.FileHandler, logging.StreamHandler)
# pylint: disable=unidiomatic-typecheck
if type(handler) == logging.StreamHandler:
handler.setStream(self.stdout_proxy) # type: ignore
handler.setFormatter(self.dispatching_log_formatter)
# pylint: enable=unidiomatic-typecheck
def flush_log_handlers(self) -> None:
root_logger = logging.getLogger()
handlers = root_logger.handlers + self.error_logger.handlers
for cfg in self:
handlers.extend(cfg.log.handlers)
for handler in handlers:
handler.flush()
if self.stdout_proxy:
self.stdout_proxy.flush()
self.stdout_proxy.close()
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: str | None = '',
) -> bool:
"""Run a single build config."""
if BUILDER_CONTEXT.build_stopping():
return False
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_succeeded = False
cfg.reset_status()
cfg.status.mark_started()
if cfg.auto_create_build_dir:
cfg.build_dir.mkdir(parents=True, exist_ok=True)
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,
)
quoted_command_args = ' '.join(
shlex.quote(arg) for arg in command_args
)
build_succeeded = True
if command_step.should_run():
cfg.log.info(
'%s %s %s',
index_message,
self.color.blue('Run ==>'),
quoted_command_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
)
build_succeeded = self.execute_command(
command_args, env, cfg, cfg.log, None
)
else:
cfg.log.info(
'%s %s %s',
index_message,
self.color.yellow('Skipped ==>'),
quoted_command_args,
)
BUILDER_CONTEXT.mark_progress_step_complete(cfg)
# Don't run further steps if a command fails.
if not build_succeeded:
break
# If all steps were skipped the return code will not be set. Force
# status to passed in this case.
if build_succeeded and not cfg.status.passed():
cfg.status.set_passed()
cfg.status.mark_done()
return build_succeeded
def print_pass_fail_banner(
self,
cancelled: bool = False,
logger: logging.Logger = _LOG,
) -> None:
# Check conditions where banners should not be shown:
# Banner flag disabled.
if not self.banners:
return
# If restarting or interrupted.
if BUILDER_CONTEXT.interrupted():
if BUILDER_CONTEXT.ctrl_c_pressed:
_LOG.info(
self.color.yellow('Exited due to keyboard interrupt.')
)
return
# If any build is still pending.
if any(recipe.status.pending() for recipe in self):
return
# Show a large color banner for the overall result.
if all(recipe.status.passed() for recipe in self) and not cancelled:
for line in PASS_MESSAGE.splitlines():
logger.info(self.color.green(line))
else:
for line in FAIL_MESSAGE.splitlines():
logger.info(self.color.red(line))
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(" ╚════════════════════════════════════")
def run_recipe(
index: int, project_builder: ProjectBuilder, cfg: BuildRecipe, env
) -> bool:
if BUILDER_CONTEXT.interrupted():
return False
if not cfg.enabled:
return False
num_builds = len(project_builder)
index_message = f'[{index}/{num_builds}]'
result = False
log_build_recipe_start(index_message, project_builder, cfg)
result = project_builder.run_build(cfg, env, index_message=index_message)
log_build_recipe_finish(index_message, project_builder, cfg)
return result
def run_builds(project_builder: ProjectBuilder, workers: int = 1) -> int:
"""Execute all build recipe steps.
Args:
project_builder: A ProjectBuilder instance
workers: The number of build recipes that should be run in
parallel. Defaults to 1 or no parallel execution.
Returns:
1 for a failed build, 0 for success.
"""
num_builds = len(project_builder)
_LOG.info('Starting build with %d directories', num_builds)
if project_builder.default_logfile:
_LOG.info(
'%s %s',
project_builder.color.blue('Root logfile:'),
project_builder.default_logfile.resolve(),
)
env = os.environ.copy()
# Print status before starting
if not project_builder.should_use_progress_bars():
project_builder.print_build_summary()
project_builder.print_pass_fail_banner()
if workers > 1 and not project_builder.separate_build_file_logging:
_LOG.warning(
project_builder.color.yellow(
'Running in parallel without --separate-logfiles; All build '
'output will be interleaved.'
)
)
BUILDER_CONTEXT.set_project_builder(project_builder)
BUILDER_CONTEXT.set_building()
def _cleanup() -> None:
if not project_builder.should_use_progress_bars():
project_builder.print_build_summary()
project_builder.print_pass_fail_banner()
project_builder.flush_log_handlers()
BUILDER_CONTEXT.set_idle()
BUILDER_CONTEXT.exit_progress()
if workers == 1:
# TODO(tonymd): Try to remove this special case. Using
# ThreadPoolExecutor when running in serial (workers==1) currently
# breaks Ctrl-C handling. Build processes keep running.
try:
if project_builder.should_use_progress_bars():
BUILDER_CONTEXT.add_progress_bars()
for i, cfg in enumerate(project_builder, start=1):
run_recipe(i, project_builder, cfg, env)
# Ctrl-C on Unix generates KeyboardInterrupt
# Ctrl-Z on Windows generates EOFError
except (KeyboardInterrupt, EOFError):
_exit_due_to_interrupt()
finally:
_cleanup()
else:
with concurrent.futures.ThreadPoolExecutor(
max_workers=workers
) as executor:
futures = []
for i, cfg in enumerate(project_builder, start=1):
futures.append(
executor.submit(run_recipe, i, project_builder, cfg, env)
)
try:
if project_builder.should_use_progress_bars():
BUILDER_CONTEXT.add_progress_bars()
for future in concurrent.futures.as_completed(futures):
future.result()
# Ctrl-C on Unix generates KeyboardInterrupt
# Ctrl-Z on Windows generates EOFError
except (KeyboardInterrupt, EOFError):
_exit_due_to_interrupt()
finally:
_cleanup()
project_builder.flush_log_handlers()
return BUILDER_CONTEXT.exit_code()
def main() -> int:
"""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,
)
if project_builder.should_use_progress_bars():
project_builder.use_stdout_proxy()
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(project_builder)
else:
workers = args.parallel_workers
return run_builds(project_builder, workers)
if __name__ == '__main__':
sys.exit(main())