aboutsummaryrefslogtreecommitdiff
path: root/pw_watch/py/pw_watch/watch.py
diff options
context:
space:
mode:
Diffstat (limited to 'pw_watch/py/pw_watch/watch.py')
-rwxr-xr-xpw_watch/py/pw_watch/watch.py1120
1 files changed, 576 insertions, 544 deletions
diff --git a/pw_watch/py/pw_watch/watch.py b/pw_watch/py/pw_watch/watch.py
index fe495efba..d956df3ec 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:
+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,41 +31,60 @@ Usage examples:
# 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
-from dataclasses import dataclass
+import concurrent.futures
import errno
-from itertools import zip_longest
+import http.server
import logging
import os
from pathlib import Path
import re
-import shlex
import subprocess
+import socketserver
import sys
import threading
from threading import Thread
from typing import (
+ Callable,
Iterable,
List,
- NamedTuple,
NoReturn,
Optional,
Sequence,
Tuple,
)
-import httpwatcher # type: ignore
+try:
+ import httpwatcher # type: ignore[import]
+except ImportError:
+ httpwatcher = None
from watchdog.events import FileSystemEventHandler # type: ignore[import]
from watchdog.observers import Observer # type: ignore[import]
-from prompt_toolkit.formatted_text.base import OneStyleAndTextTuple
-from prompt_toolkit.formatted_text import StyleAndTextTuples
-
+from prompt_toolkit import prompt
+
+from pw_build.build_recipe import BuildRecipe, create_build_recipes
+from pw_build.project_builder import (
+ ProjectBuilder,
+ execute_command_no_logging,
+ execute_command_with_logging,
+ log_build_recipe_start,
+ log_build_recipe_finish,
+ ASCII_CHARSET,
+ EMOJI_CHARSET,
+)
+from pw_build.project_builder_context import get_project_builder_context
import pw_cli.branding
import pw_cli.color
import pw_cli.env
@@ -73,12 +92,16 @@ import pw_cli.log
import pw_cli.plugins
import pw_console.python_logging
-from pw_watch.watch_app import WatchApp
+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
@@ -87,59 +110,9 @@ _ERRNO_INOTIFY_LIMIT_REACHED = 28
_FSEVENTS_LOG = logging.getLogger('fsevents')
_FSEVENTS_LOG.setLevel(logging.WARNING)
-_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 = """
- ▄██████▒░▄▄▄ ██▓ ░██▓
- ▓█▓ ░▒████▄ ▓██▒ ░▓██▒
- ▒████▒ ░▒█▀ ▀█▄ ▒██▒ ▒██░
- ░▓█▒ ░░██▄▄▄▄██ ░██░ ▒██░
- ░▒█░ ▓█ ▓██▒░██░░ ████████▒
- ▒█░ ▒▒ ▓▒█░░▓ ░ ▒░▓ ░
- ░▒ ▒ ▒▒ ░ ▒ ░░ ░ ▒ ░
- ░ ░ ░ ▒ ▒ ░ ░ ░
- ░ ░ ░ ░ ░
-"""
-
_FULLSCREEN_STATUS_COLUMN_WIDTH = 10
-
-# TODO(keir): Figure out a better strategy for exiting. The problem with the
-# watcher is that doing a "clean exit" is slow. However, by directly exiting,
-# we remove the possibility of the wrapper script doing anything on exit.
-def _die(*args) -> NoReturn:
- _LOG.critical(*args)
- sys.exit(1)
-
-
-class WatchCharset(NamedTuple):
- slug_ok: str
- slug_fail: str
-
-
-_ASCII_CHARSET = WatchCharset(_COLOR.green('OK '), _COLOR.red('FAIL'))
-_EMOJI_CHARSET = WatchCharset('✔️ ', '💥')
-
-
-@dataclass(frozen=True)
-class BuildCommand:
- build_dir: Path
- targets: Tuple[str, ...] = ()
-
- def args(self) -> Tuple[str, ...]:
- return (str(self.build_dir), *self.targets)
-
- def __str__(self) -> str:
- return ' '.join(shlex.quote(arg) for arg in self.args())
+BUILDER_CONTEXT = get_project_builder_context()
def git_ignored(file: Path) -> bool:
@@ -157,7 +130,8 @@ def git_ignored(file: Path) -> bool:
['git', 'check-ignore', '--quiet', '--no-index', file],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
- cwd=directory).returncode
+ cwd=directory,
+ ).returncode
return returncode in (0, 128)
except FileNotFoundError:
# If the directory no longer exists, try parent directories until
@@ -172,77 +146,82 @@ def git_ignored(file: Path) -> bool:
class PigweedBuildWatcher(FileSystemEventHandler, DebouncedFunction):
"""Process filesystem events and launch builds if necessary."""
+
# pylint: disable=too-many-instance-attributes
NINJA_BUILD_STEP = re.compile(
- r'^\[(?P<step>[0-9]+)/(?P<total_steps>[0-9]+)\] (?P<action>.*)$')
+ r'^\[(?P<step>[0-9]+)/(?P<total_steps>[0-9]+)\] (?P<action>.*)$'
+ )
- def __init__(
+ def __init__( # pylint: disable=too-many-arguments
self,
- build_commands: Sequence[BuildCommand],
+ project_builder: ProjectBuilder,
patterns: Sequence[str] = (),
ignore_patterns: Sequence[str] = (),
- charset: WatchCharset = _ASCII_CHARSET,
restart: bool = True,
- jobs: int = None,
fullscreen: bool = False,
banners: bool = True,
+ use_logfile: bool = False,
+ separate_logfiles: bool = False,
+ parallel_workers: int = 1,
):
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
self.patterns = patterns
self.ignore_patterns = ignore_patterns
- self.build_commands = build_commands
- self.charset: WatchCharset = charset
+ self.project_builder = project_builder
+ self.parallel_workers = parallel_workers
self.restart_on_changes = restart
self.fullscreen_enabled = fullscreen
self.watch_app: Optional[WatchApp] = None
- self._current_build: subprocess.Popen
- self._extra_ninja_args = [] if jobs is None else [f'-j{jobs}']
+ self.use_logfile = use_logfile
+ self.separate_logfiles = separate_logfiles
+ if self.parallel_workers > 1:
+ self.separate_logfiles = True
self.debouncer = Debouncer(self)
# 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:
+ if (
+ not self.fullscreen_enabled
+ and not self.project_builder.should_use_progress_bars()
+ ):
self.wait_for_keypress_thread = threading.Thread(
- None, self._wait_for_enter)
+ None, self._wait_for_enter
+ )
self.wait_for_keypress_thread.start()
+ if self.fullscreen_enabled:
+ BUILDER_CONTEXT.using_fullscreen = True
+
def rebuild(self):
- """ Rebuild command triggered from watch app."""
- self._current_build.terminate()
- self._current_build.wait()
+ """Rebuild command triggered from watch app."""
self.debouncer.press('Manual build requested')
- def _wait_for_enter(self) -> NoReturn:
+ def _wait_for_enter(self) -> None:
try:
while True:
- _ = input()
- self._current_build.terminate()
- self._current_build.wait()
-
- self.debouncer.press('Manual build requested...')
+ _ = prompt('')
+ self.rebuild()
# Ctrl-C on Unix generates KeyboardInterrupt
# Ctrl-Z on Windows generates EOFError
except (KeyboardInterrupt, EOFError):
+ # Force stop any running ninja builds.
_exit_due_to_interrupt()
def _path_matches(self, path: Path) -> bool:
"""Returns true if path matches according to the watcher patterns"""
- return (not any(path.match(x) for x in self.ignore_patterns)
- and any(path.match(x) for x in self.patterns))
+ return not any(path.match(x) for x in self.ignore_patterns) and any(
+ path.match(x) for x in self.patterns
+ )
def dispatch(self, event) -> None:
# There isn't any point in triggering builds on new directory creation.
@@ -273,15 +252,19 @@ class PigweedBuildWatcher(FileSystemEventHandler, DebouncedFunction):
log_message = f'File change detected: {os.path.relpath(matching_path)}'
if self.restart_on_changes:
if self.fullscreen_enabled and self.watch_app:
- self.watch_app.rebuild_on_filechange()
+ self.watch_app.clear_log_panes()
self.debouncer.press(f'{log_message} Triggering build...')
else:
_LOG.info('%s ; not rebuilding', log_message)
def _clear_screen(self) -> None:
- if not self.fullscreen_enabled:
- print('\033c', end='') # TODO(pwbug/38): Not Windows compatible.
- sys.stdout.flush()
+ if self.fullscreen_enabled:
+ return
+ if self.project_builder.should_use_progress_bars():
+ BUILDER_CONTEXT.clear_progress_scrollback()
+ return
+ print('\033c', end='') # TODO(pwbug/38): Not Windows compatible.
+ sys.stdout.flush()
# Implementation of DebouncedFunction.run()
#
@@ -289,353 +272,211 @@ class PigweedBuildWatcher(FileSystemEventHandler, DebouncedFunction):
# than on the main thread that's watching file events. This enables the
# watcher to continue receiving file change events during a build.
def run(self) -> None:
- """Run all the builds in serial and capture pass/fail for each."""
+ """Run all the builds and capture pass/fail for each."""
# Clear the screen and show a banner indicating the build is starting.
self._clear_screen()
+ if self.banners:
+ for line in pw_cli.branding.banner().splitlines():
+ _LOG.info(line)
if self.fullscreen_enabled:
- self.create_result_message()
_LOG.info(
- _COLOR.green(
- 'Watching for changes. Ctrl-d to exit; enter to rebuild'))
+ self.project_builder.color.green(
+ 'Watching for changes. Ctrl-d to exit; enter to rebuild'
+ )
+ )
else:
- for line in pw_cli.branding.banner().splitlines():
- _LOG.info(line)
_LOG.info(
- _COLOR.green(
- ' Watching for changes. Ctrl-C to exit; enter to rebuild')
+ self.project_builder.color.green(
+ '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.build_commands)
+ num_builds = len(self.project_builder)
_LOG.info('Starting build with %d directories', num_builds)
- 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'
-
- for i, cmd in enumerate(self.build_commands, 1):
- index = f'[{i}/{num_builds}]'
- self.builds_succeeded.append(self._run_build(index, cmd, env))
- if self.builds_succeeded[-1]:
- level = logging.INFO
- tag = '(OK)'
- else:
- level = logging.ERROR
- tag = '(FAIL)'
-
- _LOG.log(level, '%s Finished build: %s %s', index, cmd, tag)
- self.create_result_message()
+ if self.project_builder.default_logfile:
+ _LOG.info(
+ '%s %s',
+ self.project_builder.color.blue('Root logfile:'),
+ self.project_builder.default_logfile.resolve(),
+ )
- def create_result_message(self):
- if not self.fullscreen_enabled:
+ env = os.environ.copy()
+ if self.project_builder.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'
+
+ # Reset status
+ BUILDER_CONTEXT.set_project_builder(self.project_builder)
+ BUILDER_CONTEXT.set_enter_callback(self.rebuild)
+ BUILDER_CONTEXT.set_building()
+
+ for cfg in self.project_builder:
+ cfg.reset_status()
+
+ with concurrent.futures.ThreadPoolExecutor(
+ max_workers=self.parallel_workers
+ ) as executor:
+ futures = []
+ if (
+ not self.fullscreen_enabled
+ and self.project_builder.should_use_progress_bars()
+ ):
+ BUILDER_CONTEXT.add_progress_bars()
+
+ for i, cfg in enumerate(self.project_builder, start=1):
+ futures.append(executor.submit(self.run_recipe, i, cfg, env))
+
+ for future in concurrent.futures.as_completed(futures):
+ future.result()
+
+ BUILDER_CONTEXT.set_idle()
+
+ def run_recipe(self, index: int, cfg: BuildRecipe, env) -> None:
+ if BUILDER_CONTEXT.interrupted():
+ return
+ if not cfg.enabled:
return
- self.result_message = []
- first_building_target_found = False
- for (succeeded, command) in zip_longest(self.builds_succeeded,
- self.build_commands):
- if succeeded:
- self.result_message.append(
- ('class:theme-fg-green',
- 'OK'.rjust(_FULLSCREEN_STATUS_COLUMN_WIDTH)))
- elif succeeded is None and not first_building_target_found:
- first_building_target_found = True
- self.result_message.append(
- ('class:theme-fg-yellow',
- '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 _run_build(self, index: str, cmd: BuildCommand, env: dict) -> bool:
- # Make sure there is a build.ninja file for Ninja to use.
- build_ninja = cmd.build_dir / 'build.ninja'
- if not build_ninja.exists():
- # If this is a CMake directory, prompt the user to re-run CMake.
- if cmd.build_dir.joinpath('CMakeCache.txt').exists():
- _LOG.error('%s %s does not exist; re-run CMake to generate it',
- index, build_ninja)
- return False
+ num_builds = len(self.project_builder)
+ index_message = f'[{index}/{num_builds}]'
- _LOG.warning('%s %s does not exist; running gn gen %s', index,
- build_ninja, cmd.build_dir)
- if not self._execute_command(['gn', 'gen', cmd.build_dir], env):
- return False
+ log_build_recipe_start(
+ index_message, self.project_builder, cfg, logger=_LOG
+ )
- command = ['ninja', *self._extra_ninja_args, '-C', *cmd.args()]
- _LOG.info('%s Starting build: %s', index,
- ' '.join(shlex.quote(arg) for arg in command))
+ self.project_builder.run_build(
+ cfg,
+ env,
+ index_message=index_message,
+ )
- return self._execute_command(command, env)
+ log_build_recipe_finish(
+ index_message,
+ self.project_builder,
+ cfg,
+ logger=_LOG,
+ )
- def _execute_command(self, command: list, env: dict) -> bool:
+ 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)
- print()
- self._current_build = subprocess.Popen(command, env=env)
- returncode = self._current_build.wait()
- print()
- return returncode == 0
-
- def _execute_command_watch_app(self, command: list, env: dict) -> bool:
+ 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
+ )
+
+ return execute_command_no_logging(command, env, recipe)
+
+ 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
-
- 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())
+
+ self.watch_app.redraw_ui()
+
+ 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
+
+ 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
+
+ result = execute_command_with_logging(
+ command,
+ env,
+ recipe,
+ logger=desired_logger,
+ line_processed_callback=new_line_callback,
+ )
+
+ self.watch_app.redraw_ui()
- return returncode == 0
+ return result
# Implementation of DebouncedFunction.cancel()
def cancel(self) -> bool:
if self.restart_on_changes:
- self._current_build.terminate()
- self._current_build.wait()
+ BUILDER_CONTEXT.restart_flag = True
+ BUILDER_CONTEXT.terminate_and_wait()
return True
return False
- # Implementation of DebouncedFunction.run()
+ # Implementation of DebouncedFunction.on_complete()
def on_complete(self, cancelled: bool = False) -> None:
# First, use the standard logging facilities to report build status.
if cancelled:
- self.status_message = (
- '', 'Cancelled'.rjust(_FULLSCREEN_STATUS_COLUMN_WIDTH))
- _LOG.error('Finished; build was interrupted')
- elif all(self.builds_succeeded):
- self.status_message = (
- 'class:theme-fg-green',
- 'Succeeded'.rjust(_FULLSCREEN_STATUS_COLUMN_WIDTH))
+ _LOG.info('Build stopped.')
+ elif BUILDER_CONTEXT.interrupted():
+ pass # Don't print anything.
+ elif all(
+ recipe.status.passed()
+ for recipe in self.project_builder
+ if recipe.enabled
+ ):
_LOG.info('Finished; all successful')
else:
- self.status_message = (
- 'class:theme-fg-red',
- 'Failed'.rjust(_FULLSCREEN_STATUS_COLUMN_WIDTH))
_LOG.info('Finished; some builds failed')
- # Show individual build results for fullscreen app
- if self.fullscreen_enabled:
- self.create_result_message()
# For non-fullscreen pw watch
- else:
+ if (
+ not self.fullscreen_enabled
+ and not self.project_builder.should_use_progress_bars()
+ ):
# Show a more distinct colored banner.
- if not cancelled:
- # Write out build summary table so you can tell which builds
- # passed and which builds failed.
- _LOG.info('')
- _LOG.info(' .------------------------------------')
- _LOG.info(' |')
- for (succeeded, cmd) in zip(self.builds_succeeded,
- self.build_commands):
- slug = (self.charset.slug_ok
- if succeeded else self.charset.slug_fail)
- _LOG.info(' | %s %s', slug, cmd)
- _LOG.info(' |')
- _LOG.info(" '------------------------------------")
- else:
- # Build was interrupted.
- _LOG.info('')
- _LOG.info(' .------------------------------------')
- _LOG.info(' |')
- _LOG.info(' | %s- interrupted', self.charset.slug_fail)
- _LOG.info(' |')
- _LOG.info(" '------------------------------------")
-
- # Show a large color banner for the overall result.
- if self.banners:
- if all(self.builds_succeeded) and not cancelled:
- for line in _PASS_MESSAGE.splitlines():
- _LOG.info(_COLOR.green(line))
- else:
- for line in _FAIL_MESSAGE.splitlines():
- _LOG.info(_COLOR.red(line))
+ self.project_builder.print_build_summary(
+ cancelled=cancelled, logger=_LOG
+ )
+ self.project_builder.print_pass_fail_banner(
+ cancelled=cancelled, logger=_LOG
+ )
if self.watch_app:
self.watch_app.redraw_ui()
self.matching_path = None
# Implementation of DebouncedFunction.on_keyboard_interrupt()
- def on_keyboard_interrupt(self) -> NoReturn:
+ def on_keyboard_interrupt(self) -> None:
_exit_due_to_interrupt()
-_WATCH_PATTERN_DELIMITER = ','
-_WATCH_PATTERNS = (
- '*.bloaty',
- '*.c',
- '*.cc',
- '*.css',
- '*.cpp',
- '*.cmake',
- 'CMakeLists.txt',
- '*.gn',
- '*.gni',
- '*.go',
- '*.h',
- '*.hpp',
- '*.ld',
- '*.md',
- '*.options',
- '*.proto',
- '*.py',
- '*.rst',
- '*.s',
- '*.S',
-)
-
-
-def add_parser_arguments(parser: argparse.ArgumentParser) -> None:
- """Sets up an argument parser for pw watch."""
- parser.add_argument('--patterns',
- help=(_WATCH_PATTERN_DELIMITER +
- '-delimited list of globs to '
- 'watch to trigger recompile'),
- default=_WATCH_PATTERN_DELIMITER.join(_WATCH_PATTERNS))
- parser.add_argument('--ignore_patterns',
- dest='ignore_patterns_string',
- help=(_WATCH_PATTERN_DELIMITER +
- '-delimited list of globs to '
- 'ignore events from'))
-
- parser.add_argument('--exclude_list',
- nargs='+',
- type=Path,
- help='directories to ignore during pw watch',
- default=[])
- parser.add_argument('--no-restart',
- dest='restart',
- action='store_false',
- help='do not restart ongoing builds if files change')
- parser.add_argument(
- 'default_build_targets',
- nargs='*',
- metavar='target',
- default=[],
- help=('Automatically locate a build directory and build these '
- 'targets. For example, `host docs` searches for a Ninja '
- 'build directory at out/ and builds the `host` and `docs` '
- 'targets. To specify one or more directories, ust the '
- '-C / --build_directory option.'))
- parser.add_argument(
- '-C',
- '--build_directory',
- dest='build_directories',
- nargs='+',
- action='append',
- default=[],
- metavar=('directory', 'target'),
- help=('Specify a build directory and optionally targets to '
- 'build. `pw watch -C out tgt` is equivalent to `ninja '
- '-C out tgt`'))
- parser.add_argument(
- '--serve-docs',
- dest='serve_docs',
- action='store_true',
- default=False,
- help='Start a webserver for docs on localhost. The port for this '
- ' webserver can be set with the --serve-docs-port option. '
- ' Defaults to http://127.0.0.1:8000')
- parser.add_argument(
- '--serve-docs-port',
- dest='serve_docs_port',
- type=int,
- default=8000,
- help='Set the port for the docs webserver. Default to 8000.')
-
- parser.add_argument(
- '--serve-docs-path',
- dest='serve_docs_path',
- type=Path,
- default="docs/gen/docs",
- help='Set the path for the docs to serve. Default to docs/gen/docs'
- ' in the build directory.')
- parser.add_argument(
- '-j',
- '--jobs',
- type=int,
- help="Number of cores to use; defaults to Ninja's default")
- parser.add_argument('-f',
- '--fullscreen',
- action='store_true',
- default=False,
- help='Use a fullscreen interface.')
- parser.add_argument('--debug-logging',
- action='store_true',
- help='Enable debug logging.')
- parser.add_argument('--no-banners',
- dest='banners',
- action='store_false',
- help='Hide pass/fail banners.')
-
-
def _exit(code: int) -> NoReturn:
+ # Flush all log handlers
+ logging.shutdown()
# Note: The "proper" way to exit is via observer.stop(), then
# running a join. However it's slower, so just exit immediately.
#
@@ -645,46 +486,77 @@ def _exit(code: int) -> NoReturn:
os._exit(code) # pylint: disable=protected-access
-def _exit_due_to_interrupt() -> NoReturn:
+def _exit_due_to_interrupt() -> None:
# 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...')
- _exit(0)
+ BUILDER_CONTEXT.ctrl_c_interrupt()
-def _exit_due_to_inotify_watch_limit():
+def _log_inotify_watch_limit_reached():
# Show information and suggested commands in OSError: inotify limit reached.
- _LOG.error('Inotify watch limit reached: run this in your terminal if '
- 'you are in Linux to temporarily increase inotify limit. \n')
+ _LOG.error(
+ 'Inotify watch limit reached: run this in your terminal if '
+ 'you are in Linux to temporarily increase inotify limit.'
+ )
+ _LOG.info('')
_LOG.info(
- _COLOR.green(' sudo sysctl fs.inotify.max_user_watches='
- '$NEW_LIMIT$\n'))
- _LOG.info(' Change $NEW_LIMIT$ with an integer number, '
- 'e.g., 20000 should be enough.')
- _exit(0)
+ _COLOR.green(
+ ' sudo sysctl fs.inotify.max_user_watches=' '$NEW_LIMIT$'
+ )
+ )
+ _LOG.info('')
+ _LOG.info(
+ ' Change $NEW_LIMIT$ with an integer number, '
+ 'e.g., 20000 should be enough.'
+ )
-def _exit_due_to_inotify_instance_limit():
+def _exit_due_to_inotify_watch_limit():
+ _log_inotify_watch_limit_reached()
+ _exit(1)
+
+
+def _log_inotify_instance_limit_reached():
# Show information and suggested commands in OSError: inotify limit reached.
- _LOG.error('Inotify instance limit reached: run this in your terminal if '
- 'you are in Linux to temporarily increase inotify limit. \n')
+ _LOG.error(
+ 'Inotify instance limit reached: run this in your terminal if '
+ 'you are in Linux to temporarily increase inotify limit.'
+ )
+ _LOG.info('')
+ _LOG.info(
+ _COLOR.green(
+ ' sudo sysctl fs.inotify.max_user_instances=' '$NEW_LIMIT$'
+ )
+ )
+ _LOG.info('')
_LOG.info(
- _COLOR.green(' sudo sysctl fs.inotify.max_user_instances='
- '$NEW_LIMIT$\n'))
- _LOG.info(' Change $NEW_LIMIT$ with an integer number, '
- 'e.g., 20000 should be enough.')
- _exit(0)
+ ' Change $NEW_LIMIT$ with an integer number, '
+ 'e.g., 20000 should be enough.'
+ )
+
+
+def _exit_due_to_inotify_instance_limit():
+ _log_inotify_instance_limit_reached()
+ _exit(1)
def _exit_due_to_pigweed_not_installed():
# Show information and suggested commands when pigweed environment variable
# not found.
- _LOG.error('Environment variable $PW_ROOT not defined or is defined '
- 'outside the current directory.')
- _LOG.error('Did you forget to activate the Pigweed environment? '
- 'Try source ./activate.sh')
- _LOG.error('Did you forget to install the Pigweed environment? '
- 'Try source ./bootstrap.sh')
+ _LOG.error(
+ 'Environment variable $PW_ROOT not defined or is defined '
+ 'outside the current directory.'
+ )
+ _LOG.error(
+ 'Did you forget to activate the Pigweed environment? '
+ 'Try source ./activate.sh'
+ )
+ _LOG.error(
+ 'Did you forget to install the Pigweed environment? '
+ 'Try source ./bootstrap.sh'
+ )
_exit(1)
@@ -712,8 +584,9 @@ def minimal_watch_directories(to_watch: Path, to_exclude: Iterable[Path]):
# and generate all parent paths needed to be watched without recursion.
exclude_dir_parents = {to_watch}
for directory_to_exclude in directories_to_exclude:
- parts = list(
- Path(directory_to_exclude).relative_to(to_watch).parts)[:-1]
+ parts = list(Path(directory_to_exclude).relative_to(to_watch).parts)[
+ :-1
+ ]
dir_tmp = to_watch
for part in parts:
dir_tmp = Path(dir_tmp, part)
@@ -726,8 +599,11 @@ def minimal_watch_directories(to_watch: Path, to_exclude: Iterable[Path]):
dir_path = Path(directory)
yield dir_path, False
for item in Path(directory).iterdir():
- if (item.is_dir() and item not in exclude_dir_parents
- and item not in directories_to_exclude):
+ if (
+ item.is_dir()
+ and item not in exclude_dir_parents
+ and item not in directories_to_exclude
+ ):
yield item, True
@@ -747,8 +623,10 @@ def get_common_excludes() -> List[Path]:
# Preset exclude list for Pigweed's upstream directories.
pw_root_dir = Path(os.environ['PW_ROOT'])
- exclude_list.extend(pw_root_dir / ignored_directory
- for ignored_directory in typical_ignored_directories)
+ exclude_list.extend(
+ pw_root_dir / ignored_directory
+ for ignored_directory in typical_ignored_directories
+ )
# Preset exclude for common downstream project structures.
#
@@ -758,7 +636,8 @@ def get_common_excludes() -> List[Path]:
if pw_project_root_dir != pw_root_dir:
exclude_list.extend(
pw_project_root_dir / ignored_directory
- for ignored_directory in typical_ignored_directories)
+ for ignored_directory in typical_ignored_directories
+ )
# Check for and warn about legacy directories.
legacy_directories = [
@@ -769,33 +648,141 @@ def get_common_excludes() -> List[Path]:
for legacy_directory in legacy_directories:
full_legacy_directory = pw_root_dir / legacy_directory
if full_legacy_directory.is_dir():
- _LOG.warning('Legacy environment directory found: %s',
- str(full_legacy_directory))
+ _LOG.warning(
+ 'Legacy environment directory found: %s',
+ str(full_legacy_directory),
+ )
exclude_list.append(full_legacy_directory)
found_legacy = True
if found_legacy:
- _LOG.warning('Found legacy environment directory(s); these '
- 'should be deleted')
+ _LOG.warning(
+ 'Found legacy environment directory(s); these ' 'should be deleted'
+ )
return exclude_list
-def watch_setup(
- default_build_targets: List[str],
- build_directories: List[str],
- 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,
+def _simple_docs_server(
+ address: str, port: int, path: Path
+) -> Callable[[], None]:
+ class Handler(http.server.SimpleHTTPRequestHandler):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, directory=path, **kwargs)
+
+ # Disable logs to stdout
+ def log_message(
+ self, format: str, *args # pylint: disable=redefined-builtin
+ ) -> None:
+ return
+
+ def simple_http_server_thread():
+ with socketserver.TCPServer((address, port), Handler) as httpd:
+ httpd.serve_forever()
+
+ return simple_http_server_thread
+
+
+def _httpwatcher_docs_server(
+ address: str, port: int, path: Path
+) -> Callable[[], None]:
+ def httpwatcher_thread():
+ # Disable logs from httpwatcher and deps
+ logging.getLogger('httpwatcher').setLevel(logging.CRITICAL)
+ logging.getLogger('tornado').setLevel(logging.CRITICAL)
+
+ httpwatcher.watch(path, host=address, port=port)
+
+ return httpwatcher_thread
+
+
+def _serve_docs(
+ build_dir: Path,
+ docs_path: Path,
+ address: str = '127.0.0.1',
+ port: int = 8000,
+) -> None:
+ address = '127.0.0.1'
+ docs_path = build_dir.joinpath(docs_path.joinpath('html'))
+
+ if httpwatcher is not None:
+ _LOG.info('Using httpwatcher. Docs will reload when changed.')
+ server_thread = _httpwatcher_docs_server(address, port, docs_path)
+ else:
+ _LOG.info(
+ 'Using simple HTTP server. Docs will not reload when changed.'
+ )
+ _LOG.info('Install httpwatcher and restart for automatic docs reload.')
+ server_thread = _simple_docs_server(address, port, docs_path)
+
+ # Spin up server in a new thread since it blocks
+ threading.Thread(None, server_thread, 'pw_docs_server').start()
+
+
+def watch_logging_init(log_level: int, fullscreen: bool, colors: bool) -> None:
+ # Logging setup
+ if not fullscreen:
+ pw_cli.log.install(
+ level=log_level,
+ use_color=colors,
+ 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=colors,
+ hide_timestamp=False,
+ log_file=watch_logfile,
+ )
+
+
+def watch_setup( # pylint: disable=too-many-locals
+ 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,
+ parallel: bool = False,
+ parallel_workers: int = 0,
+ # 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,
+ colors=colors,
+ )
+
+ # Update the project_builder log formatters since pw_cli.log.install may
+ # have changed it.
+ project_builder.apply_root_log_formatting()
+
+ if project_builder.should_use_progress_bars():
+ project_builder.use_stdout_proxy()
+
_LOG.info('Starting Pigweed build watcher')
# Get pigweed directory information from environment variable PW_ROOT.
@@ -805,83 +792,81 @@ def watch_setup(
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(
- Path(build_dir[0]).resolve() for build_dir in build_directories)
-
- build_commands = [
- BuildCommand(Path(build_dir[0]), tuple(build_dir[1:]))
- for build_dir in build_directories
- ]
-
- # If no build directory was specified, check for out/build.ninja.
- if default_build_targets or not build_directories:
- # Make sure we found something; if not, bail.
- if not Path('out').exists():
- _die("No build dirs found. Did you forget to run 'gn gen out'?")
-
- build_commands.append(
- BuildCommand(Path('out'), tuple(default_build_targets)))
+ cfg.build_dir.resolve()
+ for cfg in build_recipes
+ if isinstance(cfg.build_dir, Path)
+ )
- # Verify that the build output directories exist.
- for i, build_target in enumerate(build_commands, 1):
- if not build_target.build_dir.is_dir():
- _die("Build directory doesn't exist: %s", build_target)
- else:
- _LOG.info('Will build [%d/%d]: %s', i, len(build_commands),
- build_target)
+ for i, build_recipe in enumerate(build_recipes, start=1):
+ _LOG.info('Will build [%d/%d]: %s', i, len(build_recipes), build_recipe)
_LOG.debug('Patterns: %s', patterns)
if serve_docs:
-
- def _serve_docs():
- # Disable logs from httpwatcher and deps
- logging.getLogger('httpwatcher').setLevel(logging.CRITICAL)
- logging.getLogger('tornado').setLevel(logging.CRITICAL)
-
- docs_path = build_commands[0].build_dir.joinpath(
- serve_docs_path.joinpath('html'))
- httpwatcher.watch(docs_path,
- host="127.0.0.1",
- port=serve_docs_port)
-
- # Spin up an httpwatcher in a new thread since it blocks
- threading.Thread(None, _serve_docs, "httpwatcher").start()
-
- # 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')
+ _serve_docs(
+ build_recipes[0].build_dir, serve_docs_path, port=serve_docs_port
+ )
# Ignore the user-specified patterns.
- ignore_patterns = (ignore_patterns_string.split(_WATCH_PATTERN_DELIMITER)
- if ignore_patterns_string else [])
+ ignore_patterns = (
+ ignore_patterns_string.split(WATCH_PATTERN_DELIMITER)
+ if ignore_patterns_string
+ else []
+ )
- env = pw_cli.env.pigweed_environment()
- if env.PW_EMOJI:
- charset = _EMOJI_CHARSET
- else:
- charset = _ASCII_CHARSET
+ # Add project_builder logfiles to ignore_patterns
+ if project_builder.default_logfile:
+ ignore_patterns.append(str(project_builder.default_logfile))
+ if project_builder.separate_build_file_logging:
+ for recipe in project_builder:
+ if recipe.logfile:
+ ignore_patterns.append(str(recipe.logfile))
+
+ workers = 1
+ if 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 parallel_workers == 0:
+ workers = len(project_builder)
+ else:
+ workers = parallel_workers
event_handler = PigweedBuildWatcher(
- build_commands=build_commands,
- patterns=patterns.split(_WATCH_PATTERN_DELIMITER),
+ project_builder=project_builder,
+ patterns=patterns.split(WATCH_PATTERN_DELIMITER),
ignore_patterns=ignore_patterns,
- charset=charset,
restart=restart,
- jobs=jobs,
fullscreen=fullscreen,
banners=banners,
+ use_logfile=bool(logfile),
+ separate_logfiles=separate_logfiles,
+ parallel_workers=workers,
)
- return path_to_log, event_handler, exclude_list
+ project_builder.execute_command = event_handler.execute_command
+
+ return event_handler, exclude_list
-def watch(path_to_log: Path, event_handler: PigweedBuildWatcher,
- exclude_list: List[Path]):
+
+def watch(
+ 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
@@ -907,6 +892,7 @@ def watch(path_to_log: Path, event_handler: PigweedBuildWatcher,
for observer in observers:
while observer.is_alive():
observer.join(1)
+ _LOG.error('observers joined')
# Ctrl-C on Unix generates KeyboardInterrupt
# Ctrl-Z on Windows generates EOFError
@@ -914,67 +900,113 @@ def watch(path_to_log: Path, event_handler: PigweedBuildWatcher,
_exit_due_to_interrupt()
except OSError as err:
if err.args[0] == _ERRNO_INOTIFY_LIMIT_REACHED:
- _exit_due_to_inotify_watch_limit()
+ if event_handler.watch_app:
+ event_handler.watch_app.exit(
+ log_after_shutdown=_log_inotify_watch_limit_reached
+ )
+ elif event_handler.project_builder.should_use_progress_bars():
+ BUILDER_CONTEXT.exit(
+ log_after_shutdown=_log_inotify_watch_limit_reached,
+ )
+ else:
+ _exit_due_to_inotify_watch_limit()
if err.errno == errno.EMFILE:
- _exit_due_to_inotify_instance_limit()
+ if event_handler.watch_app:
+ event_handler.watch_app.exit(
+ log_after_shutdown=_log_inotify_instance_limit_reached
+ )
+ elif event_handler.project_builder.should_use_progress_bars():
+ BUILDER_CONTEXT.exit(
+ log_after_shutdown=_log_inotify_instance_limit_reached
+ )
+ else:
+ _exit_due_to_inotify_instance_limit()
raise err
- _LOG.critical('Should never get here')
- observer.join()
+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,
+ )
-def main() -> None:
- """Watch files for changes and rebuild."""
+ event_handler.watch_app = watch_app
+ watch_app.run()
+
+ else:
+ watch(event_handler, exclude_list)
+
+
+def get_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=__doc__,
- formatter_class=argparse.RawDescriptionHelpFormatter)
- add_parser_arguments(parser)
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ parser = add_parser_arguments(parser)
+ return parser
+
+
+def main() -> None:
+ """Watch files for changes and rebuild."""
+ parser = get_parser()
args = parser.parse_args()
- path_to_log, event_handler, exclude_list = watch_setup(
- default_build_targets=args.default_build_targets,
- build_directories=args.build_directories,
- patterns=args.patterns,
- ignore_patterns_string=args.ignore_patterns_string,
- exclude_list=args.exclude_list,
- restart=args.restart,
+ prefs = WatchAppPrefs(load_argparse_arguments=add_parser_arguments)
+ prefs.apply_command_line_args(args)
+ build_recipes = create_build_recipes(prefs)
+
+ env = pw_cli.env.pigweed_environment()
+ if env.PW_EMOJI:
+ charset = EMOJI_CHARSET
+ else:
+ charset = ASCII_CHARSET
+
+ # Force separate-logfiles for split window panes if running in parallel.
+ separate_logfiles = args.separate_logfiles
+ if args.parallel:
+ separate_logfiles = True
+
+ def _recipe_abort(*args) -> None:
+ _LOG.critical(*args)
+
+ project_builder = ProjectBuilder(
+ build_recipes=build_recipes,
jobs=args.jobs,
- serve_docs=args.serve_docs,
- serve_docs_port=args.serve_docs_port,
- serve_docs_path=args.serve_docs_path,
- fullscreen=args.fullscreen,
banners=args.banners,
+ keep_going=args.keep_going,
+ colors=args.colors,
+ charset=charset,
+ separate_build_file_logging=separate_logfiles,
+ root_logfile=args.logfile,
+ root_logger=_LOG,
+ log_level=logging.DEBUG if args.debug_logging else logging.INFO,
+ abort_callback=_recipe_abort,
)
- 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)
-
- 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,
- debug_logging=args.debug_logging,
- log_file_name=watch_logfile)
+ event_handler, exclude_list = watch_setup(project_builder, **vars(args))
- 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__':