pw_build: Python API
- Improvements to the BuildCommand and BuildRecipe APIs
- Allow --build-system-commands for pw watch and pw build commands
to be chained.
- Added logfile related options:
--logfile FILE to log build output
--separate-logfiles option to break out build output to a separate
log file per out directory.
- run_builds() function to execute a collection of BuildRecipes
- run_watch() function to pw-watch a collection of BuildRecipes
- Improvements to the print_build_summary output. Pending builds are
now shown with slug '...'.
- Hookup -j, --keep-going, and --colors options for bazel
Change-Id: I9b856d4324b3851b8e0071626992de8c2f71e2bd
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/124990
Pigweed-Auto-Submit: Anthony DiGirolamo <tonymd@google.com>
Commit-Queue: Auto-Submit <auto-submit@pigweed.google.com.iam.gserviceaccount.com>
Reviewed-by: Chad Norvell <chadnorvell@google.com>
diff --git a/pw_build/py/build_recipe_test.py b/pw_build/py/build_recipe_test.py
index 4b5e7a5..6361da5 100644
--- a/pw_build/py/build_recipe_test.py
+++ b/pw_build/py/build_recipe_test.py
@@ -14,6 +14,7 @@
"""Tests for pw_watch.build_recipe"""
from pathlib import Path
+import shlex
import unittest
from parameterized import parameterized # type: ignore
@@ -63,7 +64,7 @@
'cmake shell command',
BuildCommand(
build_dir=Path('outcmake'),
- command_string='cmake -G Ninja -S ./ -B outcmake',
+ command=shlex.split('cmake -G Ninja -S ./ -B outcmake'),
),
# result
['cmake', '-G', 'Ninja', '-S', './', '-B', 'outcmake'],
@@ -72,7 +73,7 @@
'gn shell command',
BuildCommand(
build_dir=Path('out'),
- command_string='gn gen out --export-compile-commands',
+ command=shlex.split('gn gen out --export-compile-commands'),
),
# result
['gn', 'gen', 'out', '--export-compile-commands'],
@@ -81,11 +82,22 @@
'python shell command',
BuildCommand(
build_dir=Path('outpytest'),
- command_string='python pw_build/py/build_recipe_test.py',
+ command=shlex.split(
+ 'python pw_build/py/build_recipe_test.py'
+ ),
),
# result
['python', 'pw_build/py/build_recipe_test.py'],
),
+ (
+ 'gn shell command with a list',
+ BuildCommand(
+ build_dir=Path('out'),
+ command=['gn', 'gen', 'out', '--export-compile-commands'],
+ ),
+ # result
+ ['gn', 'gen', 'out', '--export-compile-commands'],
+ ),
]
)
def test_build_command_get_args(
diff --git a/pw_build/py/project_builder_prefs_test.py b/pw_build/py/project_builder_prefs_test.py
index 1944ad4..9ede88f 100644
--- a/pw_build/py/project_builder_prefs_test.py
+++ b/pw_build/py/project_builder_prefs_test.py
@@ -85,7 +85,10 @@
changed_args = {
'jobs': 8,
'colors': False,
- 'build_system_commands': [['default', 'bazel']],
+ 'build_system_commands': [
+ ['out', 'bazel build'],
+ ['out', 'bazel test'],
+ ],
}
args_dict.update(changed_args)
@@ -94,7 +97,13 @@
# apply_command_line_args modifies build_system_commands to match the
# prefs dict format.
changed_args['build_system_commands'] = {
- 'default': {'command': 'bazel'}
+ 'default': {'commands': [{'command': 'ninja', 'extra_args': []}]},
+ 'out': {
+ 'commands': [
+ {'command': 'bazel', 'extra_args': ['build']},
+ {'command': 'bazel', 'extra_args': ['test']},
+ ],
+ },
}
# Check that only args changed from their defaults are applied.
diff --git a/pw_build/py/pw_build/build_recipe.py b/pw_build/py/pw_build/build_recipe.py
index 6ed1af7..3387afb 100644
--- a/pw_build/py/pw_build/build_recipe.py
+++ b/pw_build/py/pw_build/build_recipe.py
@@ -15,9 +15,10 @@
"""Watch build config dataclasses."""
from dataclasses import dataclass, field
+import logging
from pathlib import Path
import shlex
-from typing import List, Optional, TYPE_CHECKING
+from typing import Callable, List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from pw_build.project_builder_prefs import ProjectBuilderPrefs
@@ -27,28 +28,91 @@
"""Exception for requesting unsupported build systems."""
+class UnknownBuildDir(Exception):
+ """Exception for an unknown build dir before command running."""
+
+
@dataclass
class BuildCommand:
- """Store details of a single build step."""
+ """Store details of a single build step.
- build_dir: Path
+ Example usage:
+
+ .. code-block:: python
+
+ from pw_build.build_recipe import BuildCommand, BuildRecipe
+
+ def should_gen_gn(out: Path):
+ return not (out / 'build.ninja').is_file()
+
+ cmd1 = BuildCommand(build_dir='out',
+ command=['gn', 'gen', '{build_dir}'],
+ run_if=should_gen_gn)
+
+ cmd2 = BuildCommand(build_dir='out',
+ build_system_command='ninja',
+ build_system_extra_args=['-k', '0'],
+ targets=['default']),
+
+ Args:
+ build_dir: Output directory for this build command. This can be omitted
+ if the BuildCommand is included in the steps of a BuildRecipe.
+ build_system_command: This command should end with ``ninja``, ``make``,
+ or ``bazel``.
+ build_system_extra_args: A list of extra arguments passed to the
+ build_system_command. If running ``bazel test`` include ``test`` as
+ an extra arg here.
+ targets: Optional list of targets to build in the build_dir.
+ command: List of strings to run as a command. These are passed to
+ subprocess.run(). Any instances of the ``'{build_dir}'`` string
+ literal will be replaced at run time with the out directory.
+ run_if: A callable function to run before executing this
+ BuildCommand. The callable takes one Path arg for the build_dir. If
+ the callable returns true this command is executed. All
+ BuildCommands are run by default.
+ """
+
+ build_dir: Optional[Path] = None
build_system_command: Optional[str] = None
build_system_extra_args: List[str] = field(default_factory=list)
targets: List[str] = field(default_factory=list)
- command_string: str = ''
+ command: List[str] = field(default_factory=list)
+ run_if: Callable[[Path], bool] = lambda _build_dir: True
def __post_init__(self) -> None:
- self._expanded_args: List[str] = shlex.split(self.command_string)
+ # Copy self._expanded_args from the command list.
+ self._expanded_args: List[str] = []
+ if self.command:
+ self._expanded_args = self.command
+
+ def should_run(self) -> bool:
+ """Return True if this build command should be run."""
+ if self.build_dir:
+ return self.run_if(self.build_dir)
+ return True
+
+ def _get_starting_build_system_args(self) -> List[str]:
+ """Return flags that appear immediately after the build command."""
+ assert self.build_system_command
+ assert self.build_dir
+ if self.build_system_command.endswith('bazel'):
+ return ['--output_base', str(self.build_dir)]
+ return []
def _get_build_system_args(self) -> List[str]:
assert self.build_system_command
+ assert self.build_dir
+
+ # Both make and ninja use -C for a build directory.
if self.build_system_command.endswith(
'make'
) or self.build_system_command.endswith('ninja'):
return ['-C', str(self.build_dir), *self.targets]
+ # Bazel relies on --output_base which is handled by the
+ # _get_starting_build_system_args() function.
if self.build_system_command.endswith('bazel'):
- return ['--output_base', str(self.build_dir), *self.targets]
+ return [*self.targets]
raise UnknownBuildSystem(
f'\n\nUnknown build system command "{self.build_system_command}" '
@@ -56,34 +120,174 @@
'Supported commands: ninja, bazel, make'
)
+ def _resolve_expanded_args(self) -> List[str]:
+ """Replace instances of '{build_dir}' with the self.build_dir."""
+ resolved_args = []
+ for arg in self._expanded_args:
+ if arg == "{build_dir}":
+ if not self.build_dir:
+ raise UnknownBuildDir(
+ '\n\nUnknown "{build_dir}" value for command:\n'
+ f' {self._expanded_args}\n'
+ f'In BuildCommand: {repr(self)}\n\n'
+ 'Check build_dir is set for the above BuildCommand'
+ 'or included as a step to a BuildRecipe.'
+ )
+ resolved_args.append(str(self.build_dir.resolve()))
+ else:
+ resolved_args.append(arg)
+ return resolved_args
+
+ def ninja_command(self) -> bool:
+ if self.build_system_command and self.build_system_command.endswith(
+ 'ninja'
+ ):
+ return True
+ return False
+
+ def bazel_command(self) -> bool:
+ if self.build_system_command and self.build_system_command.endswith(
+ 'bazel'
+ ):
+ return True
+ return False
+
+ def bazel_build_command(self) -> bool:
+ if self.bazel_command() and 'build' in self.build_system_extra_args:
+ return True
+ return False
+
def get_args(
self,
- additional_build_args: Optional[List[str]] = None,
+ additional_ninja_args: Optional[List[str]] = None,
+ additional_bazel_args: Optional[List[str]] = None,
+ additional_bazel_build_args: Optional[List[str]] = None,
) -> List[str]:
- if self.build_system_command:
- extra_args = []
- extra_args.extend(self.build_system_extra_args)
- if additional_build_args:
- extra_args.extend(additional_build_args)
- command = [
- self.build_system_command,
- *extra_args,
- *self._get_build_system_args(),
- ]
- return command
- return self._expanded_args
+ # If this is a plain command step, return self._expanded_args as-is.
+ if not self.build_system_command:
+ return self._resolve_expanded_args()
+
+ # Assmemble user-defined extra args.
+ extra_args = []
+ extra_args.extend(self.build_system_extra_args)
+ if additional_ninja_args and self.ninja_command():
+ extra_args.extend(additional_ninja_args)
+
+ if additional_bazel_build_args and self.bazel_build_command():
+ extra_args.extend(additional_bazel_build_args)
+
+ if additional_bazel_args and self.bazel_command():
+ extra_args.extend(additional_bazel_args)
+
+ # Construct the build system command args.
+ command = [
+ self.build_system_command,
+ *self._get_starting_build_system_args(),
+ *extra_args,
+ *self._get_build_system_args(),
+ ]
+ return command
def __str__(self) -> str:
return ' '.join(shlex.quote(arg) for arg in self.get_args())
@dataclass
+class BuildRecipeStatus:
+ current_step: str = ''
+ percent: float = 0.0
+ error_count: int = 0
+ return_code: Optional[int] = None
+
+ def pending(self) -> bool:
+ return self.return_code is None
+
+ def failed(self) -> bool:
+ if self.return_code is not None:
+ return self.return_code != 0
+ return False
+
+ def passed(self) -> bool:
+ if self.return_code is not None:
+ return self.return_code == 0
+ return False
+
+
+@dataclass
class BuildRecipe:
+ """Dataclass to store a list of BuildCommands.
+
+ Example usage:
+
+ .. code-block:: python
+
+ from pw_build.build_recipe import BuildCommand, BuildRecipe
+
+ 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']),
+ ],
+ )
+
+ Args:
+ build_dir: Output directory for this BuildRecipe. On init this out dir
+ is set for all included steps.
+ steps: List of BuildCommands to run.
+ title: Custom title. The build_dir is used if this is ommited.
+ """
+
build_dir: Path
- steps: List[BuildCommand]
- build_system_command: Optional[str] = None
+ steps: List[BuildCommand] = field(default_factory=list)
title: Optional[str] = None
+ def __post_init__(self) -> None:
+ # Update all included steps to use this recipe's build_dir.
+ for step in self.steps:
+ if self.build_dir:
+ step.build_dir = self.build_dir
+
+ # Set logging variables
+ self._logger: Optional[logging.Logger] = None
+ self._logfile: Optional[Path] = None
+ self._status: BuildRecipeStatus = BuildRecipeStatus()
+
+ def set_targets(self, new_targets: List[str]) -> None:
+ """Reset all build step targets."""
+ for step in self.steps:
+ step.targets = new_targets
+
+ def set_logger(self, logger: logging.Logger) -> None:
+ self._logger = logger
+
+ def set_logfile(self, log_file: Path) -> None:
+ self._logfile = log_file
+
+ def reset_status(self) -> None:
+ self._status = BuildRecipeStatus()
+
+ @property
+ def status(self) -> BuildRecipeStatus:
+ return self._status
+
+ @property
+ def log(self) -> logging.Logger:
+ if self._logger:
+ return self._logger
+ return logging.getLogger()
+
+ @property
+ def logfile(self) -> Optional[Path]:
+ return self._logfile
+
@property
def display_name(self) -> str:
if self.title:
@@ -96,10 +300,11 @@
)
def __str__(self) -> str:
- message = f"{self.display_name}"
+ message = self.display_name
targets = self.targets()
if targets:
- message = '{} ({})'.format(message, ' '.join(self.targets()))
+ target_list = ' '.join(self.targets())
+ message = f'{message} -- {target_list}'
return message
@@ -112,9 +317,7 @@
build_recipes.append(
BuildRecipe(
build_dir=Path.cwd(),
- steps=[
- BuildCommand(Path.cwd(), command_string=command_str)
- ],
+ steps=[BuildCommand(command=shlex.split(command_str))],
title=command_str,
)
)
@@ -122,27 +325,25 @@
for build_dir, targets in prefs.build_directories.items():
steps: List[BuildCommand] = []
build_path = Path(build_dir)
- (
- build_system_command,
- build_system_extra_args,
- ) = prefs.build_system_commands(build_dir)
-
if not targets:
targets = []
- steps.append(
- BuildCommand(
- build_dir=build_path,
- build_system_command=build_system_command,
- build_system_extra_args=build_system_extra_args,
- targets=targets,
+
+ for (
+ build_system_command,
+ build_system_extra_args,
+ ) in prefs.build_system_commands(build_dir):
+ steps.append(
+ BuildCommand(
+ build_system_command=build_system_command,
+ build_system_extra_args=build_system_extra_args,
+ targets=targets,
+ )
)
- )
build_recipes.append(
BuildRecipe(
build_dir=build_path,
steps=steps,
- build_system_command=build_system_command,
)
)
diff --git a/pw_build/py/pw_build/project_builder.py b/pw_build/py/pw_build/project_builder.py
index 0068321..6b91756 100644
--- a/pw_build/py/pw_build/project_builder.py
+++ b/pw_build/py/pw_build/project_builder.py
@@ -13,21 +13,36 @@
# 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
- pw build -C out/cmake pw_run_tests.modules
+ 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
- pw build -C out -C out/cmake pw_apps
+ python -m pw_build.project_builder -C out -C out/cmake pw_apps
- # Find a directory and build python.tests, and build pw_apps in out/cmake
- pw build python.tests -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
@@ -42,11 +57,11 @@
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()
@@ -79,10 +94,15 @@
class ProjectBuilderCharset(NamedTuple):
slug_ok: str
slug_fail: str
+ slug_building: str
-ASCII_CHARSET = ProjectBuilderCharset(_COLOR.green('OK '), _COLOR.red('FAIL'))
-EMOJI_CHARSET = ProjectBuilderCharset('✔️ ', '💥')
+ASCII_CHARSET = ProjectBuilderCharset(
+ _COLOR.green('OK '),
+ _COLOR.red('FAIL'),
+ _COLOR.yellow('... '),
+)
+EMOJI_CHARSET = ProjectBuilderCharset('✔️ ', '❌', '⏱️ ')
def _exit(*args) -> NoReturn:
@@ -98,37 +118,262 @@
sys.exit(1)
-def _execute_command(command: list, env: dict) -> bool:
+_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
-class ProjectBuilder:
+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], bool] = _execute_command,
+ 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.colors = colors
self.charset: ProjectBuilderCharset = charset
self.abort_callback = abort_callback
self.execute_command = execute_command
self.banners = banners
self.build_recipes = build_recipes
- self.extra_ninja_args = [] if jobs is None else ['-j', f'{jobs}']
+ 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)
@@ -140,7 +385,10 @@
return (build_recipe for build_recipe in self.build_recipes)
def run_build(
- self, cfg: BuildRecipe, env: Dict, index_message: Optional[str] = ''
+ self,
+ cfg: BuildRecipe,
+ env: Dict,
+ index_message: Optional[str] = '',
) -> bool:
"""Run a single build config."""
if self.colors:
@@ -150,26 +398,45 @@
env['CLICOLOR_FORCE'] = '1'
build_succeded = False
+ cfg.reset_status()
for command_step in cfg.steps:
command_args = command_step.get_args(
- additional_build_args=self.extra_ninja_args
- )
-
- _LOG.info(
- '%s Running ==> %s',
- index_message,
- ' '.join(shlex.quote(arg) for arg in command_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 (
- not cfg.build_dir.is_dir()
+ 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_succeded = self.execute_command(command_args, env)
+ 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
@@ -177,41 +444,106 @@
return build_succeded
def print_build_summary(
- self, builds_succeeded: List[bool], cancelled: bool = False
+ self,
+ cancelled: bool = False,
+ logger: logging.Logger = _LOG,
) -> None:
- """Print build results summary table."""
+ """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:
- _LOG.info('')
- _LOG.info(' .------------------------------------')
- _LOG.info(' |')
- for (succeeded, cmd) in zip(
- builds_succeeded, [str(cfg) for cfg in self]
- ):
- slug = (
- self.charset.slug_ok
- if succeeded
- else self.charset.slug_fail
- )
- _LOG.info(' | %s %s', slug, cmd)
- _LOG.info(' |')
- _LOG.info(" '------------------------------------")
+ 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.
- _LOG.info('')
- _LOG.info(' .------------------------------------')
- _LOG.info(' |')
- _LOG.info(' | %s- interrupted', self.charset.slug_fail)
- _LOG.info(' |')
- _LOG.info(" '------------------------------------")
+ 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:
- if all(builds_succeeded) and not cancelled:
+ 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():
- _LOG.info(_COLOR.green(line))
+ logger.info(_COLOR.green(line))
else:
for line in FAIL_MESSAGE.splitlines():
- _LOG.info(_COLOR.red(line))
+ 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:
@@ -235,50 +567,28 @@
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,
)
- pw_cli.log.install(
- level=logging.DEBUG if args.debug_logging else logging.INFO,
- use_color=args.colors,
- hide_timestamp=False,
- )
-
- builds_succeeded = []
- num_builds = len(project_builder)
- _LOG.info('Starting build with %d directories', num_builds)
-
- env = os.environ.copy()
-
- for i, cfg in enumerate(project_builder, 1):
- index = f'[{i}/{num_builds}]'
- build_start_msg = '{} Starting {}'.format(index, cfg)
- _LOG.info(build_start_msg)
-
- try:
- builds_succeeded.append(
- project_builder.run_build(cfg, env, index_message=index)
- )
- # Ctrl-C on Unix generates KeyboardInterrupt
- # Ctrl-Z on Windows generates EOFError
- except (KeyboardInterrupt, EOFError):
- _exit_due_to_interrupt()
-
- if builds_succeeded[-1]:
- level = logging.INFO
- tag = '(OK)'
- else:
- level = logging.ERROR
- tag = '(FAIL)'
-
- _LOG.log(level, '%s Finished %s %s', index, cfg, tag)
-
- project_builder.print_build_summary(builds_succeeded)
+ run_builds(project_builder)
if __name__ == '__main__':
diff --git a/pw_build/py/pw_build/project_builder_argparse.py b/pw_build/py/pw_build/project_builder_argparse.py
index 3c00dc9..603d114 100644
--- a/pw_build/py/pw_build/project_builder_argparse.py
+++ b/pw_build/py/pw_build/project_builder_argparse.py
@@ -14,6 +14,7 @@
"""Pigweed Project Builder Common argparse."""
import argparse
+from pathlib import Path
def add_project_builder_arguments(
@@ -21,16 +22,6 @@
) -> argparse.ArgumentParser:
"""Add ProjectBuilder.main specific arguments."""
parser.add_argument(
- '-k',
- '--keep-going',
- action='store_true',
- help=(
- 'Keep building past the first failure. This is '
- 'equivalent to passing "-k 0" to ninja.'
- ),
- )
-
- parser.add_argument(
'default_build_targets',
nargs='*',
metavar='target',
@@ -59,26 +50,58 @@
),
)
- parser.add_argument(
+ build_options_group = parser.add_argument_group(
+ title='Build system control options'
+ )
+ build_options_group.add_argument(
'-j',
'--jobs',
type=int,
- help="Number of cores to use; defaults to Ninja's default",
+ help="Specify the number of cores to use for each build system.",
+ )
+ build_options_group.add_argument(
+ '-k',
+ '--keep-going',
+ action='store_true',
+ help=(
+ 'Keep building past the first failure. This is equivalent to '
+ 'running "ninja -k 0" or "bazel build -k".'
+ ),
)
- parser.add_argument(
- '--debug-logging', action='store_true', help='Enable debug logging.'
+ logfile_group = parser.add_argument_group(title='Log file options')
+ logfile_group.add_argument(
+ '--logfile',
+ type=Path,
+ help='Global build output log file.',
)
+ logfile_group.add_argument(
+ '--separate-logfiles',
+ action='store_true',
+ help=(
+ 'Create separate log files per build directory. Requires setting '
+ 'the --logfile option.'
+ ),
+ )
+
+ logfile_group.add_argument(
+ '--debug-logging',
+ action='store_true',
+ help='Enable Python build execution tool debug logging.',
+ )
+
+ output_group = parser.add_argument_group(title='Display output options')
+
# TODO(b/248257406) Use argparse.BooleanOptionalAction when Python 3.8 is
# no longer supported.
- parser.add_argument(
+ output_group.add_argument(
'--banners',
action='store_true',
default=True,
help='Show pass/fail banners.',
)
- parser.add_argument(
+ output_group.add_argument(
'--no-banners',
action='store_false',
dest='banners',
@@ -87,13 +110,13 @@
# TODO(b/248257406) Use argparse.BooleanOptionalAction when Python 3.8 is
# no longer supported.
- parser.add_argument(
+ output_group.add_argument(
'--colors',
action='store_true',
default=True,
help='Force color output from ninja.',
)
- parser.add_argument(
+ output_group.add_argument(
'--no-colors',
action='store_false',
dest='colors',
@@ -107,7 +130,7 @@
default=[],
dest='build_system_commands',
metavar=('directory', 'command'),
- help='Build system command.',
+ help='Build system command for . Default: ninja',
)
parser.add_argument(
@@ -115,7 +138,7 @@
action='append',
default=[],
help=(
- 'Additional build commands to run. These are run before any -C '
+ 'Additional commands to run. These are run before any -C '
'arguments and may be repeated. For example: '
"--run-command 'bazel build //pw_cli/...'"
"--run-command 'bazel test //pw_cli/...'"
diff --git a/pw_build/py/pw_build/project_builder_prefs.py b/pw_build/py/pw_build/project_builder_prefs.py
index 53db42a..a6c3515 100644
--- a/pw_build/py/pw_build/project_builder_prefs.py
+++ b/pw_build/py/pw_build/project_builder_prefs.py
@@ -15,6 +15,7 @@
import argparse
import copy
+import shlex
from typing import Any, Callable, Dict, List, Tuple, Union
from pw_cli.toml_config_loader_mixin import TomlConfigLoaderMixin
@@ -23,8 +24,12 @@
# Config settings not available as a command line options go here.
'build_system_commands': {
'default': {
- 'command': 'ninja',
- 'extra_args': [],
+ 'commands': [
+ {
+ 'command': 'ninja',
+ 'extra_args': [],
+ },
+ ],
},
},
}
@@ -64,7 +69,7 @@
project_user_file=False,
user_file=False,
default_config=_DEFAULT_CONFIG,
- environment_var='PW_BUILD_RECIPE_FILE',
+ environment_var='PW_BUILD_CONFIG_FILE',
)
def reset_config(self) -> None:
@@ -78,9 +83,21 @@
) -> Dict[str, Any]:
result = copy.copy(_DEFAULT_CONFIG['build_system_commands'])
for out_dir, command in argparse_input:
- new_dir = result.get('out_dir', {})
- new_dir['command'] = command
- result[out_dir] = new_dir
+ new_dir_spec = result.get(out_dir, {})
+ # Get existing commands list
+ new_commands = new_dir_spec.get('commands', [])
+
+ # Convert 'ninja -k 1' to 'ninja' and ['-k', '1']
+ extra_args = []
+ command_tokens = shlex.split(command)
+ if len(command_tokens) > 1:
+ extra_args = command_tokens[1:]
+ command = command_tokens[0]
+
+ # Append the command step
+ new_commands.append({'command': command, 'extra_args': extra_args})
+ new_dir_spec['commands'] = new_commands
+ result[out_dir] = new_dir_spec
return result
def apply_command_line_args(self, new_args: argparse.Namespace) -> None:
@@ -150,12 +167,17 @@
return build_system_commands
- def build_system_commands(self, build_dir: str) -> Tuple[str, List[str]]:
+ def build_system_commands(
+ self, build_dir: str
+ ) -> List[Tuple[str, List[str]]]:
build_system_commands = self._get_build_system_commands_for(build_dir)
- command: str = build_system_commands.get('command', 'ninja')
- extra_args: List[str] = build_system_commands.get('extra_args', [])
- if not extra_args:
- extra_args = []
- assert isinstance(command, str)
- assert isinstance(extra_args, list)
- return command, extra_args
+
+ command_steps: List[Tuple[str, List[str]]] = []
+ commands: List[Dict[str, Any]] = build_system_commands.get(
+ 'commands', []
+ )
+ for command_step in commands:
+ command_steps.append(
+ (command_step['command'], command_step['extra_args'])
+ )
+ return command_steps
diff --git a/pw_watch/py/pw_watch/argparser.py b/pw_watch/py/pw_watch/argparser.py
index 4a2671f..30ec790 100644
--- a/pw_watch/py/pw_watch/argparser.py
+++ b/pw_watch/py/pw_watch/argparser.py
@@ -53,7 +53,11 @@
parser: argparse.ArgumentParser,
) -> argparse.ArgumentParser:
"""Sets up an argument parser for pw watch."""
- parser.add_argument(
+ parser = add_project_builder_arguments(parser)
+
+ watch_group = parser.add_argument_group(title='Watch options')
+
+ watch_group.add_argument(
'--patterns',
help=(
WATCH_PATTERN_DELIMITER + '-delimited list of globs to '
@@ -62,7 +66,7 @@
default=WATCH_PATTERN_DELIMITER.join(WATCH_PATTERNS),
)
- parser.add_argument(
+ watch_group.add_argument(
'--ignore_patterns',
dest='ignore_patterns_string',
help=(
@@ -71,7 +75,7 @@
),
)
- parser.add_argument(
+ watch_group.add_argument(
'--exclude_list',
nargs='+',
type=Path,
@@ -79,15 +83,13 @@
default=[],
)
- parser.add_argument(
+ watch_group.add_argument(
'--no-restart',
dest='restart',
action='store_false',
help='do not restart ongoing builds if files change',
)
- parser = add_project_builder_arguments(parser)
-
parser.add_argument(
'--serve-docs',
dest='serve_docs',
@@ -110,7 +112,7 @@
'--serve-docs-path',
dest='serve_docs_path',
type=Path,
- default="docs/gen/docs",
+ default='docs/gen/docs',
help='Set the path for the docs to serve. Default to docs/gen/docs'
' in the build directory.',
)
diff --git a/pw_watch/py/pw_watch/debounce.py b/pw_watch/py/pw_watch/debounce.py
index 644be7e..dca6758 100644
--- a/pw_watch/py/pw_watch/debounce.py
+++ b/pw_watch/py/pw_watch/debounce.py
@@ -18,7 +18,7 @@
import threading
from abc import ABC, abstractmethod
-_LOG = logging.getLogger('pw_watch')
+_LOG = logging.getLogger('pw_build.watch')
class DebouncedFunction(ABC):
diff --git a/pw_watch/py/pw_watch/watch.py b/pw_watch/py/pw_watch/watch.py
index 2a1e506..610bb5f 100755
--- a/pw_watch/py/pw_watch/watch.py
+++ b/pw_watch/py/pw_watch/watch.py
@@ -14,15 +14,15 @@
# the License.
"""Watch files for changes and rebuild.
-pw watch runs Ninja in a build directory when source files change. It works with
-any Ninja project (GN or CMake).
+Run arbitrary commands or invoke build systems (Ninja, Bazel and make) on one or
+more build directories whenever source files change.
Usage examples:
- # Find a build directory and build the default target
- pw watch
+ # Build the default target in out/ using ninja.
+ pw watch -C out
- # Find a build directory and build the stm32f429i target
+ # Build python.lint and stm32f429i targets in out/ using ninja.
pw watch python.lint stm32f429i
# Build pw_run_tests.modules in the out/cmake directory
@@ -31,14 +31,19 @@
# Build the default target in out/ and pw_apps in out/cmake
pw watch -C out -C out/cmake pw_apps
- # Find a directory and build python.tests, and build pw_apps in out/cmake
+ # Build python.tests in out/ and pw_apps in out/cmake/
pw watch python.tests -C out/cmake pw_apps
+
+ # Run 'bazel build' and 'bazel test' on the target '//...' in outbazel/
+ pw watch --run-command 'mkdir -p outbazel'
+ -C outbazel '//...'
+ --build-system-command outbazel 'bazel build'
+ --build-system-command outbazel 'bazel test'
"""
import argparse
import errno
import http.server
-from itertools import zip_longest
import logging
import os
from pathlib import Path
@@ -73,6 +78,9 @@
from pw_build.build_recipe import BuildRecipe, create_build_recipes
from pw_build.project_builder import (
ProjectBuilder,
+ execute_command_with_logging,
+ log_build_recipe_start,
+ log_build_recipe_finish,
ASCII_CHARSET,
EMOJI_CHARSET,
)
@@ -83,13 +91,16 @@
import pw_cli.plugins
import pw_console.python_logging
-from pw_watch.argparser import WATCH_PATTERN_DELIMITER, add_parser_arguments
+from pw_watch.argparser import (
+ WATCH_PATTERN_DELIMITER,
+ WATCH_PATTERNS,
+ add_parser_arguments,
+)
from pw_watch.debounce import DebouncedFunction, Debouncer
from pw_watch.watch_app import WatchAppPrefs, WatchApp
_COLOR = pw_cli.color.colors()
-_LOG = logging.getLogger('pw_watch')
-_NINJA_LOG = logging.getLogger('pw_watch_ninja_output')
+_LOG = logging.getLogger('pw_build.watch')
_ERRNO_INOTIFY_LIMIT_REACHED = 28
# Suppress events under 'fsevents', generated by watchdog on every file
@@ -146,13 +157,14 @@
restart: bool = True,
fullscreen: bool = False,
banners: bool = True,
+ use_logfile: bool = False,
+ separate_logfiles: bool = False,
):
super().__init__()
self.banners = banners
self.status_message: Optional[OneStyleAndTextTuple] = None
self.result_message: Optional[StyleAndTextTuples] = None
- self.current_stdout = ''
self.current_build_step = ''
self.current_build_percent = 0.0
self.current_build_errors = 0
@@ -164,6 +176,9 @@
self.fullscreen_enabled = fullscreen
self.watch_app: Optional[WatchApp] = None
+ self.use_logfile = use_logfile
+ self.separate_logfiles = separate_logfiles
+
# Initialize self._current_build to an empty subprocess.
self._current_build = subprocess.Popen('', shell=True, errors='replace')
@@ -172,7 +187,6 @@
# Track state of a build. These need to be members instead of locals
# due to the split between dispatch(), run(), and on_complete().
self.matching_path: Optional[Path] = None
- self.builds_succeeded: List[bool] = []
if not self.fullscreen_enabled:
self.wait_for_keypress_thread = threading.Thread(
@@ -271,58 +285,88 @@
' Watching for changes. Ctrl-C to exit; enter to rebuild'
)
)
- _LOG.info('')
- _LOG.info('Change detected: %s', self.matching_path)
+ if self.matching_path:
+ _LOG.info('')
+ _LOG.info('Change detected: %s', self.matching_path)
self._clear_screen()
- self.builds_succeeded = []
num_builds = len(self.project_builder)
_LOG.info('Starting build with %d directories', num_builds)
+ if self.project_builder.default_logfile:
+ _LOG.info(
+ '%s: %s',
+ _COLOR.yellow('Logging to'),
+ self.project_builder.default_logfile.resolve(),
+ )
+
env = os.environ.copy()
# Force colors in Pigweed subcommands run through the watcher.
env['PW_USE_COLOR'] = '1'
# Force Ninja to output ANSI colors
env['CLICOLOR_FORCE'] = '1'
+ # Reset status messages
+ for cfg in self.project_builder:
+ cfg.reset_status()
+ self.create_result_message()
+
for i, cfg in enumerate(self.project_builder, 1):
- index = f'[{i}/{num_builds}]'
- build_start_msg = '{} Starting {}'.format(index, cfg)
- _LOG.info(build_start_msg)
+ self.run_recipe(i, cfg, env)
- self.builds_succeeded.append(
- self.project_builder.run_build(cfg, env, index_message=index)
- )
- if self.builds_succeeded[-1]:
- level = logging.INFO
- tag = '(OK)'
- else:
- level = logging.ERROR
- tag = '(FAIL)'
+ def run_recipe(self, index: int, cfg: BuildRecipe, env) -> None:
+ num_builds = len(self.project_builder)
+ index_message = f'[{index}/{num_builds}]'
- _LOG.log(level, '%s Finished %s %s', index, cfg, tag)
- self.create_result_message()
+ log_build_recipe_start(
+ index_message, self.project_builder, cfg, logger=_LOG
+ )
+
+ self.project_builder.run_build(
+ cfg,
+ env,
+ index_message=index_message,
+ )
+
+ log_build_recipe_finish(
+ index_message,
+ self.project_builder,
+ cfg,
+ logger=_LOG,
+ )
+
+ self.create_result_message()
def create_result_message(self):
- """TODO(tonymd) Add docstring."""
+ """Update the prompt_toolkit formatted build result message."""
if not self.fullscreen_enabled:
return
self.result_message = []
first_building_target_found = False
- for (succeeded, command) in zip_longest(
- self.builds_succeeded,
- [cfg.build_dir for cfg in self.project_builder],
- ):
- if succeeded:
+ for cfg in self.project_builder:
+ # Add the status
+ if cfg.status.passed():
self.result_message.append(
(
'class:theme-fg-green',
'OK'.rjust(_FULLSCREEN_STATUS_COLUMN_WIDTH),
)
)
- elif succeeded is None and not first_building_target_found:
+ elif cfg.status.failed():
+ self.result_message.append(
+ (
+ 'class:theme-fg-red',
+ 'Failed'.rjust(_FULLSCREEN_STATUS_COLUMN_WIDTH),
+ )
+ )
+ # NOTE: This condition assumes each build dir is run in serial
+ elif first_building_target_found:
+ self.result_message.append(
+ ('', ''.rjust(_FULLSCREEN_STATUS_COLUMN_WIDTH))
+ )
+ else:
first_building_target_found = True
self.result_message.append(
(
@@ -330,98 +374,78 @@
'Building'.rjust(_FULLSCREEN_STATUS_COLUMN_WIDTH),
)
)
- elif first_building_target_found:
- self.result_message.append(
- ('', ''.rjust(_FULLSCREEN_STATUS_COLUMN_WIDTH))
- )
- else:
- self.result_message.append(
- (
- 'class:theme-fg-red',
- 'Failed'.rjust(_FULLSCREEN_STATUS_COLUMN_WIDTH),
- )
- )
- self.result_message.append(('', f' {command}\n'))
- def execute_command(self, command: list, env: dict) -> bool:
+ # Add the build directory
+ self.result_message.append(('', f' {cfg.display_name}\n'))
+
+ def execute_command(
+ self,
+ command: list,
+ env: dict,
+ recipe: BuildRecipe,
+ # pylint: disable=unused-argument
+ *args,
+ **kwargs,
+ # pylint: enable=unused-argument
+ ) -> bool:
"""Runs a command with a blank before/after for visual separation."""
- self.current_build_errors = 0
self.status_message = (
'class:theme-fg-yellow',
'Building'.rjust(_FULLSCREEN_STATUS_COLUMN_WIDTH),
)
+
if self.fullscreen_enabled:
- return self._execute_command_watch_app(command, env)
+ return self._execute_command_watch_app(command, env, recipe)
+
+ if self.separate_logfiles:
+ return execute_command_with_logging(
+ command, env, recipe, logger=recipe.log
+ )
+ if self.use_logfile:
+ return execute_command_with_logging(
+ command, env, recipe, logger=_LOG
+ )
+
print()
self._current_build = subprocess.Popen(
command, env=env, errors='replace'
)
returncode = self._current_build.wait()
print()
+ recipe.status.return_code = returncode
return returncode == 0
- def _execute_command_watch_app(self, command: list, env: dict) -> bool:
+ def _execute_command_watch_app(
+ self,
+ command: list,
+ env: dict,
+ recipe: BuildRecipe,
+ ) -> bool:
"""Runs a command with and outputs the logs."""
if not self.watch_app:
return False
- self.current_stdout = ''
- returncode = None
- with subprocess.Popen(
- command,
- env=env,
- stdout=subprocess.PIPE,
- stderr=subprocess.STDOUT,
- errors='replace',
- ) as proc:
- self._current_build = proc
- # Empty line at the start.
- _NINJA_LOG.info('')
- while returncode is None:
- if not proc.stdout:
- continue
+ def new_line_callback(recipe: BuildRecipe) -> None:
+ self.current_build_step = recipe.status.current_step
+ self.current_build_percent = recipe.status.percent
+ self.current_build_errors = recipe.status.error_count
- output = proc.stdout.readline()
- self.current_stdout += output
-
- line_match_result = self.NINJA_BUILD_STEP.match(output)
- if line_match_result:
- matches = line_match_result.groupdict()
- self.current_build_step = line_match_result.group(0)
- self.current_build_percent = float(
- int(matches.get('step', 0))
- / int(matches.get('total_steps', 1))
- )
-
- elif output.startswith(WatchApp.NINJA_FAILURE_TEXT):
- _NINJA_LOG.critical(output.strip())
- self.current_build_errors += 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.
- _NINJA_LOG.info(
- output.replace('\x1b(B\x1b[m', '')
- .replace('\x1b[1m', '')
- .strip()
- )
+ if self.watch_app:
self.watch_app.redraw_ui()
- returncode = proc.poll()
- # Empty line at the end.
- _NINJA_LOG.info('')
+ desired_logger = _LOG
+ if self.separate_logfiles:
+ desired_logger = recipe.log
- return returncode == 0
+ result = execute_command_with_logging(
+ command,
+ env,
+ recipe,
+ logger=desired_logger,
+ line_processed_callback=new_line_callback,
+ )
+
+ return result
# Implementation of DebouncedFunction.cancel()
def cancel(self) -> bool:
@@ -442,7 +466,7 @@
)
_LOG.error('Finished; build was interrupted')
- elif all(self.builds_succeeded):
+ elif all(recipe.status.passed() for recipe in self.project_builder):
self.status_message = (
'class:theme-fg-green',
'Succeeded'.rjust(_FULLSCREEN_STATUS_COLUMN_WIDTH),
@@ -462,7 +486,7 @@
else:
# Show a more distinct colored banner.
self.project_builder.print_build_summary(
- self.builds_succeeded, cancelled=cancelled
+ cancelled=cancelled, logger=_LOG
)
if self.watch_app:
@@ -704,28 +728,62 @@
threading.Thread(None, server_thread, 'pw_docs_server').start()
+def watch_logging_init(log_level: int, fullscreen: bool) -> None:
+ # Logging setup
+ if not fullscreen:
+ pw_cli.log.install(
+ level=log_level,
+ use_color=True,
+ hide_timestamp=False,
+ )
+ return
+
+ watch_logfile = pw_console.python_logging.create_temp_log_file(
+ prefix=__package__
+ )
+ pw_cli.log.install(
+ level=logging.DEBUG,
+ use_color=True,
+ hide_timestamp=False,
+ log_file=watch_logfile,
+ )
+ pw_console.python_logging.setup_python_logging(
+ last_resort_filename=watch_logfile
+ )
+
+
def watch_setup( # pylint: disable=too-many-locals
- build_recipes: List[BuildRecipe],
- default_build_targets: List[str], # pylint: disable=unused-argument
- build_directories: List[str], # pylint: disable=unused-argument
- build_system_commands: List[str], # pylint: disable=unused-argument
- run_command: List[str], # pylint: disable=unused-argument
- patterns: str,
- ignore_patterns_string: str,
- exclude_list: List[Path],
- restart: bool,
- jobs: Optional[int],
- serve_docs: bool,
- serve_docs_port: int,
- serve_docs_path: Path,
- fullscreen: bool,
- banners: bool,
- keep_going: bool,
- debug_logging: bool, # pylint: disable=unused-argument
- colors: bool,
+ project_builder: ProjectBuilder,
+ # NOTE: The following args should have defaults matching argparse. This
+ # allows use of watch_setup by other project build scripts.
+ patterns: str = WATCH_PATTERN_DELIMITER.join(WATCH_PATTERNS),
+ ignore_patterns_string: str = '',
+ exclude_list: Optional[List[Path]] = None,
+ restart: bool = True,
+ serve_docs: bool = False,
+ serve_docs_port: int = 8000,
+ serve_docs_path: Path = Path('docs/gen/docs'),
+ fullscreen: bool = False,
+ banners: bool = True,
+ logfile: Optional[Path] = None,
+ separate_logfiles: bool = False,
+ # pylint: disable=unused-argument
+ default_build_targets: Optional[List[str]] = None,
+ build_directories: Optional[List[str]] = None,
+ build_system_commands: Optional[List[str]] = None,
+ run_command: Optional[List[str]] = None,
+ jobs: Optional[int] = None,
+ keep_going: bool = False,
+ colors: bool = True,
+ debug_logging: bool = False,
+ # pylint: enable=unused-argument
# pylint: disable=too-many-arguments
-) -> Tuple[str, PigweedBuildWatcher, List[Path]]:
+) -> Tuple[PigweedBuildWatcher, List[Path]]:
"""Watches files and runs Ninja commands when they change."""
+ watch_logging_init(
+ log_level=project_builder.default_log_level, fullscreen=fullscreen
+ )
+
_LOG.info('Starting Pigweed build watcher')
# Get pigweed directory information from environment variable PW_ROOT.
@@ -735,7 +793,11 @@
if Path.cwd().resolve() not in [pw_root, *pw_root.parents]:
_exit_due_to_pigweed_not_installed()
+ build_recipes = project_builder.build_recipes
+
# Preset exclude list for pigweed directory.
+ if not exclude_list:
+ exclude_list = []
exclude_list += get_common_excludes()
# Add build directories to the exclude list.
exclude_list.extend(
@@ -754,11 +816,6 @@
build_recipes[0].build_dir, serve_docs_path, port=serve_docs_port
)
- # Try to make a short display path for the watched directory that has
- # "$HOME" instead of the full home directory. This is nice for users
- # who have deeply nested home directories.
- path_to_log = str(Path().resolve()).replace(str(Path.home()), '$HOME')
-
# Ignore the user-specified patterns.
ignore_patterns = (
ignore_patterns_string.split(WATCH_PATTERN_DELIMITER)
@@ -766,21 +823,6 @@
else []
)
- env = pw_cli.env.pigweed_environment()
- if env.PW_EMOJI:
- charset = EMOJI_CHARSET
- else:
- charset = ASCII_CHARSET
-
- project_builder = ProjectBuilder(
- build_recipes=build_recipes,
- jobs=jobs,
- banners=banners,
- keep_going=keep_going,
- charset=charset,
- colors=colors,
- )
-
event_handler = PigweedBuildWatcher(
project_builder=project_builder,
patterns=patterns.split(WATCH_PATTERN_DELIMITER),
@@ -788,19 +830,25 @@
restart=restart,
fullscreen=fullscreen,
banners=banners,
+ use_logfile=bool(logfile),
+ separate_logfiles=bool(separate_logfiles),
)
project_builder.execute_command = event_handler.execute_command
- return path_to_log, event_handler, exclude_list
+ return event_handler, exclude_list
def watch(
- path_to_log: Path,
event_handler: PigweedBuildWatcher,
exclude_list: List[Path],
):
"""Watches files and runs Ninja commands when they change."""
+ # Try to make a short display path for the watched directory that has
+ # "$HOME" instead of the full home directory. This is nice for users
+ # who have deeply nested home directories.
+ path_to_log = str(Path().resolve()).replace(str(Path.home()), '$HOME')
+
try:
# It can take awhile to configure the filesystem watcher, so have the
# message reflect that with the "...". Run inside the try: to
@@ -839,6 +887,35 @@
raise err
+def run_watch(
+ event_handler: PigweedBuildWatcher,
+ exclude_list: List[Path],
+ prefs: Optional[WatchAppPrefs] = None,
+ fullscreen: bool = False,
+) -> None:
+ """Start pw_watch."""
+ if not prefs:
+ prefs = WatchAppPrefs(load_argparse_arguments=add_parser_arguments)
+
+ if fullscreen:
+ watch_thread = Thread(
+ target=watch,
+ args=(event_handler, exclude_list),
+ daemon=True,
+ )
+ watch_thread.start()
+ watch_app = WatchApp(
+ event_handler=event_handler,
+ prefs=prefs,
+ )
+
+ event_handler.watch_app = watch_app
+ watch_app.run()
+
+ else:
+ watch(event_handler, exclude_list)
+
+
def main() -> None:
"""Watch files for changes and rebuild."""
parser = argparse.ArgumentParser(
@@ -852,46 +929,33 @@
prefs.apply_command_line_args(args)
build_recipes = create_build_recipes(prefs)
- path_to_log, event_handler, exclude_list = watch_setup(
- build_recipes=build_recipes, **vars(args)
+ env = pw_cli.env.pigweed_environment()
+ if env.PW_EMOJI:
+ charset = EMOJI_CHARSET
+ else:
+ charset = ASCII_CHARSET
+
+ 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=logging.DEBUG if args.debug_logging else logging.INFO,
)
- if args.fullscreen:
- watch_logfile = pw_console.python_logging.create_temp_log_file(
- prefix=__package__
- )
- pw_cli.log.install(
- level=logging.DEBUG,
- use_color=True,
- hide_timestamp=False,
- log_file=watch_logfile,
- )
- pw_console.python_logging.setup_python_logging(
- last_resort_filename=watch_logfile
- )
+ event_handler, exclude_list = watch_setup(project_builder, **vars(args))
- watch_thread = Thread(
- target=watch,
- args=(path_to_log, event_handler, exclude_list),
- daemon=True,
- )
- watch_thread.start()
- watch_app = WatchApp(
- event_handler=event_handler,
- prefs=prefs,
- debug_logging=args.debug_logging,
- log_file_name=watch_logfile,
- )
-
- event_handler.watch_app = watch_app
- watch_app.run()
- else:
- pw_cli.log.install(
- level=logging.DEBUG if args.debug_logging else logging.INFO,
- use_color=True,
- hide_timestamp=False,
- )
- watch(Path(path_to_log), event_handler, exclude_list)
+ run_watch(
+ event_handler,
+ exclude_list,
+ prefs=prefs,
+ fullscreen=args.fullscreen,
+ )
if __name__ == '__main__':
diff --git a/pw_watch/py/pw_watch/watch_app.py b/pw_watch/py/pw_watch/watch_app.py
index 58669ed..3886f80 100644
--- a/pw_watch/py/pw_watch/watch_app.py
+++ b/pw_watch/py/pw_watch/watch_app.py
@@ -20,7 +20,7 @@
import re
import sys
import time
-from typing import Callable, Dict, List, NoReturn, Optional
+from typing import Callable, Dict, Iterable, List, NoReturn, Optional
from prompt_toolkit.application import Application
from prompt_toolkit.clipboard.pyperclip import PyperclipClipboard
@@ -160,8 +160,7 @@
return self._config.get('show_python_logger', False)
-_NINJA_LOG = logging.getLogger('pw_watch_ninja_output')
-_LOG = logging.getLogger('pw_watch')
+_LOG = logging.getLogger('pw_build.watch')
class WatchWindowManager(WindowManager):
@@ -184,7 +183,6 @@
self,
event_handler,
prefs: WatchAppPrefs,
- debug_logging: bool = False,
log_file_name: Optional[str] = None,
):
@@ -215,49 +213,25 @@
self.log_ui_update_frequency = 0.1 # 10 FPS
self._last_ui_update_time = time.time()
- self.ninja_log_pane = LogPane(
- application=self, pane_title='Pigweed Watch'
+ debug_logging = (
+ event_handler.project_builder.default_log_level == logging.DEBUG
)
- self.ninja_log_pane.add_log_handler(_NINJA_LOG, level_name='INFO')
- self.ninja_log_pane.add_log_handler(
- _LOG, level_name=('DEBUG' if debug_logging else 'INFO')
- )
- # Set python log format to just the message itself.
- self.ninja_log_pane.log_view.log_store.formatter = logging.Formatter(
- '%(message)s'
- )
- self.ninja_log_pane.table_view = False
- # Disable line wrapping for improved error visibility.
- if self.ninja_log_pane.wrap_lines:
- self.ninja_log_pane.toggle_wrap_lines()
- # Blank right side toolbar text
- self.ninja_log_pane._pane_subtitle = ' '
- self.ninja_log_view = self.ninja_log_pane.log_view
+ level_name = 'DEBUG' if debug_logging else 'INFO'
+ if event_handler.separate_logfiles:
+ for recipe in reversed(event_handler.project_builder.build_recipes):
+ self.add_build_log_pane(
+ recipe.display_name,
+ recipe.log,
+ level_name=level_name,
+ )
- # Make tab and shift-tab search for next and previous error
- next_error_bindings = KeyBindings()
-
- @next_error_bindings.add('s-tab')
- def _previous_error(_event):
- self.jump_to_error(backwards=True)
-
- @next_error_bindings.add('tab')
- def _next_error(_event):
- self.jump_to_error()
-
- existing_log_bindings: Optional[
- KeyBindingsBase
- ] = self.ninja_log_pane.log_content_control.key_bindings
-
- key_binding_list: List[KeyBindingsBase] = []
- if existing_log_bindings:
- key_binding_list.append(existing_log_bindings)
- key_binding_list.append(next_error_bindings)
- self.ninja_log_pane.log_content_control.key_bindings = (
- merge_key_bindings(key_binding_list)
+ self.add_build_log_pane(
+ 'Pigweed Watch',
+ _LOG,
+ level_name=level_name,
)
- self.window_manager.add_pane(self.ninja_log_pane)
+ self.ninja_log_pane = list(self.all_log_panes())[0]
self.time_waster = Twenty48Pane(include_resize_handle=True)
self.time_waster.application = self
@@ -358,7 +332,8 @@
)
self.layout = Layout(
- self.root_container, focused_element=self.ninja_log_pane
+ self.root_container,
+ focused_element=self.ninja_log_pane,
)
self.application: Application = Application(
@@ -384,6 +359,52 @@
plugin_logger_name='pw_watch_stdout_checker',
)
+ def add_build_log_pane(
+ self, title: str, logger: logging.Logger, level_name: str
+ ) -> None:
+ """Setup a new build log pane."""
+ new_log_pane = LogPane(application=self, pane_title=title)
+ new_log_pane.add_log_handler(logger, level_name=level_name)
+
+ # Set python log format to just the message itself.
+ new_log_pane.log_view.log_store.formatter = logging.Formatter(
+ '%(message)s'
+ )
+
+ new_log_pane.table_view = False
+
+ # Disable line wrapping for improved error visibility.
+ if new_log_pane.wrap_lines:
+ new_log_pane.toggle_wrap_lines()
+
+ # Blank right side toolbar text
+ new_log_pane._pane_subtitle = ' ' # pylint: disable=protected-access
+
+ # Make tab and shift-tab search for next and previous error
+ next_error_bindings = KeyBindings()
+
+ @next_error_bindings.add('s-tab')
+ def _previous_error(_event):
+ self.jump_to_error(backwards=True)
+
+ @next_error_bindings.add('tab')
+ def _next_error(_event):
+ self.jump_to_error()
+
+ existing_log_bindings: Optional[
+ KeyBindingsBase
+ ] = new_log_pane.log_content_control.key_bindings
+
+ key_binding_list: List[KeyBindingsBase] = []
+ if existing_log_bindings:
+ key_binding_list.append(existing_log_bindings)
+ key_binding_list.append(next_error_bindings)
+ new_log_pane.log_content_control.key_bindings = merge_key_bindings(
+ key_binding_list
+ )
+
+ self.window_manager.add_pane(new_log_pane)
+
def logs_redraw(self):
emit_time = time.time()
# Has enough time passed since last UI redraw due to new logs?
@@ -445,13 +466,19 @@
# pylint: disable=no-self-use
return False
+ def all_log_panes(self) -> Iterable[LogPane]:
+ for pane in self.window_manager.active_panes():
+ if isinstance(pane, LogPane):
+ yield pane
+
def clear_ninja_log(self) -> None:
- self.ninja_log_view.log_store.clear_logs()
- self.ninja_log_view._restart_filtering() # pylint: disable=protected-access
- self.ninja_log_view.view_mode_changed()
- # Re-enable follow if needed
- if not self.ninja_log_view.follow:
- self.ninja_log_view.toggle_follow()
+ for pane in self.all_log_panes():
+ pane.log_view.log_store.clear_logs()
+ pane.log_view._restart_filtering() # pylint: disable=protected-access
+ pane.log_view.view_mode_changed()
+ # Re-enable follow if needed
+ if not pane.log_view.follow:
+ pane.log_view.toggle_follow()
def run_build(self):
"""Manually trigger a rebuild."""
@@ -459,8 +486,9 @@
self.event_handler.rebuild()
def rebuild_on_filechange(self):
- self.ninja_log_view.log_store.clear_logs()
- self.ninja_log_view.view_mode_changed()
+ for pane in self.all_log_panes():
+ pane.log_view.log_store.clear_logs()
+ pane.log_view.view_mode_changed()
def get_statusbar_text(self):
status = self.event_handler.status_message