From mboxrd@z Thu Jan 1 00:00:00 1970 Received: from smtp.kernel.org (aws-us-west-2-korg-mail-1.web.codeaurora.org [10.30.226.201]) (using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits)) (No client certificate requested) by smtp.subspace.kernel.org (Postfix) with ESMTPS id 608BD2D7DC4 for ; Wed, 3 Dec 2025 21:11:03 +0000 (UTC) Authentication-Results: smtp.subspace.kernel.org; arc=none smtp.client-ip=10.30.226.201 ARC-Seal:i=1; a=rsa-sha256; d=subspace.kernel.org; s=arc-20240116; t=1764796269; cv=none; b=JCmbFY7RsIQRQy8Bi0/lemeLL2KxOWmUjKOok6m9POnbK+OqcBfUchKLIPJAdscc/p3L6zgUghEvfNKXP6HzU4e3jsHHrRcZizFXAzVrlx8matLNznPoKcZwdmdgxRHe2Z9SE75Gnn2FNpF1rRlRAvuBojStvFizVdQcmsvwhVE= ARC-Message-Signature:i=1; a=rsa-sha256; d=subspace.kernel.org; s=arc-20240116; t=1764796269; c=relaxed/simple; bh=/wUDuROCEpHCOMBGCzQC2YkjzpWmkYpfk59f//cqblc=; h=Date:From:To:Cc:Subject:Message-ID:References:MIME-Version: Content-Type:Content-Disposition:In-Reply-To; b=l4v05VpG66F9Gjpmhdw0Io0sPFqU8LYmkEH1/XkBaLIR1fZrOfYm6EnZYNKhFUtMUkPBnrreVVg0hh7cWB/z3hz5e5YzZG1aBIwd6IDFBMRa7U+wwOtbDWfDety6TduvvdvcMXjIQ+7oiRiVPEteZ/2h1zZYz5IcFkBYosoeAMY= ARC-Authentication-Results:i=1; smtp.subspace.kernel.org; dkim=pass (2048-bit key) header.d=kernel.org header.i=@kernel.org header.b=KGIZVHXv; arc=none smtp.client-ip=10.30.226.201 Authentication-Results: smtp.subspace.kernel.org; dkim=pass (2048-bit key) header.d=kernel.org header.i=@kernel.org header.b="KGIZVHXv" Received: by smtp.kernel.org (Postfix) with ESMTPSA id 5614DC116B1; Wed, 3 Dec 2025 21:11:03 +0000 (UTC) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=kernel.org; s=k20201202; t=1764796263; bh=/wUDuROCEpHCOMBGCzQC2YkjzpWmkYpfk59f//cqblc=; h=Date:From:To:Cc:Subject:References:In-Reply-To:From; b=KGIZVHXvau7ssJ6pVx8+A/n5BnBJU6fFMaw4+VDDARN2BGDl4leRMFdFPBIvVxzMi RzIBXsKkTmF0IbvC2+pmmQF+IWU9Y+UVTM4EKUrbgVB6Xiyauf/3bj2Ef5RbQ/HtvM atx4Z+Q5AAAAkTP7wpFfaia92GG556Y4pUZH2bWw2ZNokzlCPAn2ztubRwwLoEdQ9R An0ABpN6iOAHeWb++yJ6lOM7rjZZpyApvlL8sCKad+G6ZmQVnFYrbYir5bGVqqmVGl pszkxlbKOwgpViJNavBPKSUds/7L366rTWAnpKvoTqkSyPYzBRnZUupS+XB+dOxA0V M3lO5ZfycMemw== Received: from phl-compute-05.internal (phl-compute-05.internal [10.202.2.45]) by mailfauth.phl.internal (Postfix) with ESMTP id 6B9A9F40070; Wed, 3 Dec 2025 16:11:02 -0500 (EST) Received: from phl-mailfrontend-02 ([10.202.2.163]) by phl-compute-05.internal (MEProxy); Wed, 03 Dec 2025 16:11:02 -0500 X-ME-Sender: X-ME-Received: X-ME-Proxy-Cause: gggruggvucftvghtrhhoucdtuddrgeefgedrtddtgdefkeehucetufdoteggodetrfdotf fvucfrrhhofhhilhgvmecuhfgrshhtofgrihhlpdfurfetoffkrfgpnffqhgenuceurghi lhhouhhtmecufedttdenucesvcftvggtihhpihgvnhhtshculddquddttddmnecujfgurh epfffhvfevuffkfhggtggugfgjsehtkeertddttdejnecuhfhrohhmpeevhhhutghkucfn vghvvghruceotggvlheskhgvrhhnvghlrdhorhhgqeenucggtffrrghtthgvrhhnpedtue dvjeejueelveejvdeltdefueevheeuueffheeikeeggfdvhffhieektefggfenucffohhm rghinhepuhhpuggrthgvpghthhhrvggruggpshhtohhprdhishdpsghuihhlthhinhdrsh hhvghllhdppghhohhsthdrnhgrmhgvpdgrnhhsihgslhgvrdgtohhmpdhgvghnvghrrghl rdguihihnecuvehluhhsthgvrhfuihiivgeptdenucfrrghrrghmpehmrghilhhfrhhomh eptghhuhgtkhhlvghvvghrodhmvghsmhhtphgruhhthhhpvghrshhonhgrlhhithihqddu ieefgeelleelheelqdefvdelkeeggedvfedqtggvlheppehkvghrnhgvlhdrohhrghesfh grshhtmhgrihhlrdgtohhmpdhnsggprhgtphhtthhopeehpdhmohguvgepshhmthhpohhu thdprhgtphhtthhopegurgdrghhomhgviieskhgvrhhnvghlrdhorhhgpdhrtghpthhtoh epmhgtghhrohhfsehkvghrnhgvlhdrohhrghdprhgtphhtthhopegthhhutghkrdhlvghv vghrsehorhgrtghlvgdrtghomhdprhgtphhtthhopehkuggvvhhophhssehlihhsthhsrd hlihhnuhigrdguvghvpdhrtghpthhtohepuggrrdhgohhmvgiisehsrghmshhunhhgrdgt ohhm X-ME-Proxy: Feedback-ID: ifa6e4810:Fastmail Received: by mail.messagingengine.com (Postfix) with ESMTPA; Wed, 3 Dec 2025 16:11:01 -0500 (EST) Date: Wed, 3 Dec 2025 16:11:00 -0500 From: Chuck Lever To: Daniel Gomez Cc: Luis Chamberlain , Chuck Lever , kdevops@lists.linux.dev, Daniel Gomez Subject: Re: [PATCH v4 2/2] ansible: add lucid callback plugin for clean output Message-ID: References: <20251202-lucid-v4-0-a077cca27736@samsung.com> <20251202-lucid-v4-2-a077cca27736@samsung.com> Precedence: bulk X-Mailing-List: kdevops@lists.linux.dev List-Id: List-Subscribe: List-Unsubscribe: MIME-Version: 1.0 Content-Type: text/plain; charset=utf-8 Content-Disposition: inline Content-Transfer-Encoding: 8bit In-Reply-To: <20251202-lucid-v4-2-a077cca27736@samsung.com> On Tue, Dec 02, 2025 at 12:11:17AM +0100, Daniel Gomez wrote: > From: Daniel Gomez > > Add the lucid callback plugin to provide clean, minimal Ansible output > for kdevops workflows. Current Ansible output is verbose by default > making it difficult to see what matters during long-running test > operations. Lucid solves this by showing only changes and errors at > default verbosity while providing progressive detail at higher levels. > > The plugin includes dynamic mode with live updates for interactive > terminals showing running tasks with spinners and elapsed time similar > to BitBake/Yocto build output. For CI/CD and piped contexts it > automatically falls back to static output. All runs generate > comprehensive logs in .ansible/logs/ with playbook names and timestamps > allowing multiple executions to coexist without conflicts. > > Task-level output control is available via the output_verbosity variable > allowing playbooks to specify per-task visibility. Execution time is > shown for all tasks providing consistent performance visibility. Logs > always capture full verbosity independent of display settings ensuring > complete audit trails. > > Verbosity levels control output progressively: no flags show only > changed/failed tasks with stdout/stderr, -v adds ok/skipped tasks plus > executed commands, -vv and -vvv enable tasks marked with higher > output_verbosity values. > > Integrate lucid with kdevops Kconfig system providing output mode > selection (auto/static/dynamic). Add defconfig fragments for standard > use (lucid.config) and CI environments (lucid-ci.config). The CI > fragment enables static mode with verbosity level 1 for comprehensive > output suitable for build logs. > > Add documentation covering requirements, parameters, verbosity levels, > status symbols, command display behavior, and CI/CD configuration. > > Generated-by: Claude AI > Signed-off-by: Daniel Gomez Summary: Aside from one or two details, the bulk of my comments are about keeping display output from becoming garbled. > --- > .gitignore | 4 + > CLAUDE.md | 10 + > callback_plugins/__init__.py | 0 > callback_plugins/lucid.py | 879 +++++++++++++++++++++ > defconfigs/configs/lucid-ci.config | 3 + > defconfigs/configs/lucid.config | 2 + > docs/ansible-callbacks.md | 294 +++++++ > kconfigs/Kconfig.ansible_cfg | 74 ++ > .../roles/ansible_cfg/templates/ansible.cfg.j2 | 12 +- > 9 files changed, 1277 insertions(+), 1 deletion(-) > > diff --git a/.gitignore b/.gitignore > index 7472860e..ddc19b17 100644 > --- a/.gitignore > +++ b/.gitignore > @@ -26,6 +26,7 @@ extra_vars.json > .config.old > > ansible.cfg > +callback_plugins/__pycache__/ > > scripts/kconfig/.mconf-cfg > scripts/workflows/fstests/lib/__pycache__/ > @@ -131,3 +132,6 @@ terraform/oci/scripts/__pycache__/ > > scripts/__pycache__/ > docs/contrib/kdevops_contributions* > +__pycache__/ > + > +.ansible > diff --git a/CLAUDE.md b/CLAUDE.md > index 5c9091d9..83376619 100644 > --- a/CLAUDE.md > +++ b/CLAUDE.md > @@ -128,6 +128,16 @@ make fix-whitespace-last-commit # Fixes commit white space damage > make mrproper # Clean everything and restart from scratch > ``` > > +### Ansible Callbacks > + > +kdevops supports multiple Ansible stdout callback plugins (dense, debug, > +diy, lucid, or custom). The default is dense. > + > +See [docs/ansible-callbacks.md](docs/ansible-callbacks.md) for: > +- Supported plugins and configuration > +- Command line override via `ANSIBLE_CFG_CALLBACK_PLUGIN` > +- Lucid plugin features and parameters > + > ## Key Workflows > > ### fstests (Filesystem Testing) > diff --git a/callback_plugins/__init__.py b/callback_plugins/__init__.py > new file mode 100644 > index 00000000..e69de29b > diff --git a/callback_plugins/lucid.py b/callback_plugins/lucid.py > new file mode 100644 > index 00000000..e45f46b1 > --- /dev/null > +++ b/callback_plugins/lucid.py > @@ -0,0 +1,879 @@ > +#!/usr/bin/env python3 > +""" > +Lucid Ansible Callback Plugin > + > +A modern stdout callback plugin providing: > +- Clean, minimal output with progressive verbosity levels > +- Task-level output control via output_verbosity variable > +- Comprehensive logging (always max verbosity) > +- Dynamic terminal display (Yocto/BitBake style) for interactive use > +- Static output for CI/CD and non-interactive terminals > +""" > + > +from __future__ import annotations > + > +import json > +import os > +import shutil > +import sys > +import time > +import threading > +from datetime import datetime > +from typing import Dict, Tuple, Optional, Any > +from collections import deque > + > +from ansible.plugins.callback import CallbackBase > +from ansible import constants as C > + > +DOCUMENTATION = """ > + name: lucid > + type: stdout > + short_description: Clean, minimal Ansible output with dynamic display > + version_added: "2.10" > + description: > + - Provides clean, minimal output by default > + - Progressive verbosity levels (-v, -vv, -vvv) > + - Task-level output control via output_verbosity variable > + - Comprehensive logging independent of display verbosity > + - Dynamic live display for interactive terminals > + - Static output for CI/CD environments > + requirements: > + - Ansible 2.10+ > + - Python 3.8+ > + options: > + output_mode: > + description: Output display mode (auto, static, dynamic) > + default: auto > + type: str > + ini: > + - section: callback_lucid > + key: output_mode > + env: > + - name: ANSIBLE_LUCID_OUTPUT_MODE > + choices: ['auto', 'static', 'dynamic'] > +""" > + > + > +class CallbackModule(CallbackBase): > + """ > + Lucid callback plugin for clean, minimal Ansible output > + with dynamic terminal display and comprehensive logging. > + """ > + > + CALLBACK_VERSION = 2.0 > + CALLBACK_TYPE = "stdout" > + CALLBACK_NAME = "lucid" > + > + # Spinner animation frames > + SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] > + > + # Status symbols and colors (used consistently across static, dynamic, and logging) > + STATUS_SYMBOLS = { > + "ok": "✓", > + "changed": "*", > + "failed": "✗", > + "skipped": "⊘", > + "unreachable": "!", > + } > + > + STATUS_COLORS = { > + "ok": C.COLOR_OK, > + "changed": C.COLOR_CHANGED, > + "failed": C.COLOR_ERROR, > + "skipped": C.COLOR_SKIP, > + "unreachable": C.COLOR_UNREACHABLE, > + } > + > + # Default terminal width when detection fails > + DEFAULT_TERMINAL_WIDTH = 80 > + > + def __init__(self): > + super(CallbackModule, self).__init__() > + > + # State tracking > + self.running_tasks: Dict[Tuple[str, str], Dict[str, Any]] = ( > + {} > + ) # (host, task_uuid) -> task_info > + self.completed_tasks = deque(maxlen=3) # Keep last 3 completed > + self.current_task_name: str = "" > + self.current_task_hosts: list[str] = [] > + self.play_hosts: list[str] = [] # All hosts in current play The requirements above state: > + - Python 3.8+ This syntax (PEP 585 - using built-in generics like list[str], dict[str, int]) was introduced in Python 3.9. In Python 3.8, this will raise a TypeError at runtime. RHEL 8 and Debian (before 11?) still use older versions of Python, and kdevops is supposed to support both of those. Using "List[str] = []" might be a good solution. > + > + # Dynamic display state > + self.display_lines = 0 > + self.last_update = 0.0 > + self.spinner_index = 0 > + self.dynamic_mode = False > + self.update_thread: Optional[threading.Thread] = None > + self.update_thread_stop: Optional[threading.Event] = None > + self.task_lock = threading.Lock() I guess there is more than one thread executing (a display thread and a background thread), so shared state variables need some protection. Consider adding serialization around current_task_name, current_task_hosts, and maybe also completed_tasks, spinner_index, and last_update. > + > + # Will be set in set_options() > + self.output_mode = "auto" > + self.log_file_path: Optional[str] = None > + self.log_timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") > + self.log_write_failed = False > + > + def set_options(self, task_keys=None, var_options=None, direct=None): > + """Set plugin options from ansible.cfg""" > + super(CallbackModule, self).set_options( > + task_keys=task_keys, var_options=var_options, direct=direct > + ) > + > + # Load configuration > + self.output_mode = self.get_option("output_mode") > + > + # Determine display mode based on configuration > + is_interactive = self._detect_interactive() > + if self.output_mode == "static": > + self.dynamic_mode = False > + elif self.output_mode == "dynamic": > + self.dynamic_mode = True > + else: # 'auto' or any other value > + self.dynamic_mode = is_interactive > + > + # Initialize logging > + self._init_log_file() > + > + # Start update thread for dynamic mode > + if self.dynamic_mode: > + self._start_update_thread() > + > + def _detect_interactive(self) -> bool: > + """Detect if running in interactive terminal""" > + return ( > + sys.stdout.isatty() > + and sys.stderr.isatty() > + and os.getenv("TERM") != "dumb" > + and os.getenv("CI") is None > + and os.getenv("JENKINS_HOME") is None > + and os.getenv("GITHUB_ACTIONS") is None > + and os.getenv("GITLAB_CI") is None > + ) > + > + def _init_log_file(self): > + """Defer log file creation until playbook name is known""" > + # Log file path will be set in _create_log_file when playbook starts > + pass > + > + def _create_log_file(self, playbook_name: str): > + """Create log file with playbook name and timestamp using auto-detection""" > + if self.log_file_path: > + # Already initialized > + return > + > + # Remove extension from playbook name > + playbook_base = os.path.splitext(playbook_name)[0] > + > + # Try default locations in order (project dir first, then user home, then system) > + attempts = [ > + f".ansible/logs/{playbook_base}-{self.log_timestamp}.log", > + os.path.expanduser( > + f"~/.ansible/logs/{playbook_base}-{self.log_timestamp}.log" > + ), > + f"/var/log/ansible/{playbook_base}-{self.log_timestamp}.log", > + ] > + > + for path in attempts: > + try: > + os.makedirs(os.path.dirname(path), exist_ok=True) > + # Test write access > + with open(path, "a"): > + pass > + self.log_file_path = path > + break > + except (PermissionError, OSError): > + continue > + > + if self.log_file_path: > + try: > + with open(self.log_file_path, "w") as f: > + f.write( > + f"=== Ansible Playbook Log Started: {datetime.now().isoformat()} ===\n" > + ) > + f.write(f"=== Playbook: {playbook_name} ===\n\n") > + except (PermissionError, OSError) as e: > + self._display.warning( > + f"Could not initialize log file {self.log_file_path}: {e}" > + ) > + self.log_file_path = None > + > + def _write_to_log(self, message: str): > + """Write to log file with timestamp (always max verbosity)""" > + if self.log_file_path: > + timestamp = datetime.now().isoformat() > + try: > + with open(self.log_file_path, "a") as f: > + f.write(f"[{timestamp}] {message}\n") > + except (PermissionError, OSError) as e: > + # Warn once on first failure, then disable logging > + if not self.log_write_failed: > + self._display.warning(f"Log write failed, disabling logging: {e}") > + self.log_write_failed = True > + self.log_file_path = None > + > + def _start_update_thread(self): > + """Start background thread for live display updates""" > + self.update_thread_stop = threading.Event() > + self.update_thread = threading.Thread(target=self._update_loop, daemon=True) If thread termination occurs while _redraw_display() is in the middle of writing ANSI escape sequences, the terminal could be left in a corrupted state with partial escape sequences written, cursor in the wrong position, and/or lines not properly cleared. You could do any one of: 1. Don't use daemon=True - Use a non-daemon thread and ensure proper cleanup via signal handlers 2. Register signal handlers - Catch SIGINT/SIGTERM to perform graceful shutdown with terminal reset 3. Use atexit module - Register a cleanup function to reset terminal state 4. Write ANSI sequences atomically - Build complete escape sequence strings and write in single write() call > + self.update_thread.start() > + > + def _update_loop(self): > + """Update display every 0.5 seconds in dynamic mode""" > + while not self.update_thread_stop.is_set(): > + if self.running_tasks: > + self._redraw_display() > + time.sleep(0.5) > + > + def _should_display_output(self, result, status: str) -> bool: > + """ > + Determine if task output should be shown based on verbosity. > + > + Rules: > + - Changed tasks: ALWAYS show > + - Failed tasks: ALWAYS show > + - OK tasks: Show if current_verbosity >= task's output_verbosity > + - Skipped tasks: Only at -v or higher > + """ > + # Changed and failed always show > + if status == "changed" or status == "failed" or status == "unreachable": > + return True > + > + # Skipped only at -v > + if status == "skipped": > + return self._display.verbosity >= 1 > + > + # Get task verbosity setting (default is 1) > + task_verbosity = self._get_task_verbosity(result) > + > + # Compare with current verbosity > + current_verbosity = self._display.verbosity > + > + return current_verbosity >= task_verbosity > + > + def _get_task_verbosity(self, result) -> int: > + """Get the output_verbosity setting for a task (default is 1)""" > + task_verbosity = 1 > + if hasattr(result, "_task_fields"): > + task_vars = result._task_fields.get("vars", {}) > + task_verbosity = task_vars.get("output_verbosity", 1) > + elif hasattr(result, "_task"): > + task_vars = getattr(result._task, "vars", {}) > + task_verbosity = task_vars.get("output_verbosity", 1) > + return task_verbosity > + > + def _get_task_command(self, result) -> Optional[str]: > + """ > + Extract the command from modules that execute shell commands. > + Returns None for modules that don't expose their commands. > + > + Supported modules: > + - ansible.builtin.shell/command: returns cmd (string or list) > + - ansible.builtin.pip: returns cmd (string) > + - community.general.make: returns command (string) > + - community.general.flatpak: returns command (string) > + - community.general.flatpak_remote: returns command (string) > + - community.general.terraform: returns command (string) > + """ > + task = result._task > + action = task.action > + res = result._result > + > + # Modules that return 'cmd' key > + if action in ( > + "ansible.builtin.shell", > + "ansible.builtin.command", > + "ansible.builtin.pip", > + "shell", > + "command", > + "pip", > + ): > + if "cmd" in res: > + cmd = res["cmd"] > + if isinstance(cmd, list): > + return " ".join(cmd) > + return cmd > + > + # Fall back to task args for shell/command > + if action in ( > + "ansible.builtin.shell", > + "ansible.builtin.command", > + "shell", > + "command", > + ): > + args = task.args > + if "_raw_params" in args: > + return args["_raw_params"] > + if "cmd" in args: > + return args["cmd"] > + > + return None > + > + # Modules that return 'command' key > + if action in ( > + "community.general.make", > + "community.general.flatpak", > + "community.general.flatpak_remote", > + "community.general.terraform", > + "make", > + "flatpak", > + "flatpak_remote", > + "terraform", > + ): > + if "command" in res: > + return res["command"] > + return None > + > + return None > + > + def _has_significant_output(self, result) -> bool: > + """Check if result has stdout, stderr, or msg content worth showing""" > + res = result._result > + return bool( > + (res.get("stdout") and res["stdout"].strip()) > + or (res.get("stderr") and res["stderr"].strip()) > + or (res.get("msg") and not res.get("stdout")) > + ) > + > + def _get_terminal_width(self) -> int: > + """Get current terminal width, with fallback to default""" > + try: > + size = shutil.get_terminal_size(fallback=(self.DEFAULT_TERMINAL_WIDTH, 24)) > + return size.columns > + except Exception: > + return self.DEFAULT_TERMINAL_WIDTH > + > + def _truncate_line(self, line: str, max_width: Optional[int] = None) -> str: > + """Truncate line to fit terminal width, adding ellipsis if needed""" > + if max_width is None: > + max_width = self._get_terminal_width() > + > + if len(line) <= max_width: > + return line > + > + # Reserve 3 characters for ellipsis > + if max_width <= 3: > + return "..." if max_width >= 3 else line[:max_width] > + > + return line[: max_width - 3] + "..." > + > + def _format_duration(self, seconds: float, width: int = 0) -> str: > + """Format duration as human readable, optionally right-aligned to width""" > + if seconds < 60: > + duration = f"{seconds:.1f}s" > + elif seconds < 3600: > + mins = int(seconds // 60) > + secs = int(seconds % 60) > + duration = f"{mins}m {secs}s" > + elif seconds < 86400: > + hours = int(seconds // 3600) > + mins = int((seconds % 3600) // 60) > + duration = f"{hours}h {mins}m" > + else: > + days = int(seconds // 86400) > + hours = int((seconds % 86400) // 3600) > + duration = f"{days}d {hours}h" > + > + if width > 0: > + return duration.rjust(width) > + return duration > + > + def _display_message(self, message: str, color=None): > + """Display message with optional color""" > + if color: > + self._display.display(message, color=color) > + else: > + self._display.display(message) > + > + # ======================================================================== > + # Ansible v2 Callback Methods > + # ======================================================================== > + > + def v2_playbook_on_start(self, playbook): > + """Playbook started""" > + playbook_name = os.path.basename(playbook._file_name) > + > + # Create log file now that we have playbook name > + self._create_log_file(playbook_name) > + > + msg = f"PLAYBOOK: {playbook_name}" > + self._display_message(msg, C.COLOR_HIGHLIGHT) > + self._write_to_log(msg) > + > + # Show log file path early so user can tail -f > + if self.log_file_path: > + self._display.display(f"Log: {self.log_file_path}") > + > + def v2_playbook_on_play_start(self, play): > + """Play started""" > + name = play.get_name().strip() > + > + # Get actual host list from play (resolved from inventory) > + # play.hosts is just the pattern, we need to get actual hosts > + self.play_hosts = [] > + try: > + # Get hosts from the play's host list (resolved by PlayIterator) > + host_list = ( > + play.get_variable_manager() > + .get_vars(play=play) > + .get("ansible_play_hosts_all", []) > + ) > + if host_list: > + self.play_hosts = list(host_list) > + except (AttributeError, TypeError): > + # Fallback: hosts will be populated as tasks start > + pass > + > + hosts = play.hosts > + if isinstance(hosts, list): > + hosts_str = ", ".join(hosts[:3]) > + if len(hosts) > 3: > + hosts_str += f" (+{len(hosts) - 3} more)" > + else: > + hosts_str = str(hosts) > + > + msg = f"\nPLAY: {name} [{hosts_str}]" > + self._display_message(msg, C.COLOR_HIGHLIGHT) > + self._write_to_log(msg) > + > + def v2_playbook_on_task_start(self, task, is_conditional): > + """Task started""" > + self.current_task_name = task.get_name().strip() > + # Initialize with play hosts so display is stable from the start > + self.current_task_hosts = list(self.play_hosts) if self.play_hosts else [] > + > + # In static mode, print immediately (compact - no leading newline) > + if not self.dynamic_mode: > + msg = f"TASK: {self.current_task_name}" > + self._display_message(msg, C.COLOR_HIGHLIGHT) > + > + self._write_to_log(f"TASK: {self.current_task_name}") > + > + def v2_runner_on_start(self, host, task): > + """Task started on a host (for dynamic tracking)""" > + key = (host.name, task._uuid) > + > + # Check for task delegation > + delegate_to = getattr(task, "delegate_to", None) > + > + with self.task_lock: > + self.running_tasks[key] = { > + "start_time": time.time(), > + "host": host.name, > + "delegate_to": delegate_to, > + "task_name": task.get_name().strip(), > + } > + > + if host.name not in self.current_task_hosts: > + self.current_task_hosts.append(host.name) > + > + if self.dynamic_mode: > + self._redraw_display() > + > + def v2_runner_on_ok(self, result): > + """Task succeeded""" > + changed = result._result.get("changed", False) > + status = "changed" if changed else "ok" > + self._handle_result(result, status) > + > + def v2_runner_on_failed(self, result, ignore_errors=False): > + """Task failed""" > + self._handle_result(result, "failed", ignore_errors=ignore_errors) > + > + def v2_runner_on_skipped(self, result): > + """Task skipped""" > + self._handle_result(result, "skipped") > + > + def v2_runner_on_unreachable(self, result): > + """Host unreachable""" > + self._handle_result(result, "unreachable") > + > + def v2_runner_retry(self, result): > + """Task is being retried after failure""" > + host = result._host.name > + task_uuid = result._task._uuid > + key = (host, task_uuid) > + > + retries = result._result.get("retries", 0) > + attempts = result._result.get("attempts", 0) > + > + # Update retry info in running_tasks > + with self.task_lock: > + if key in self.running_tasks: > + self.running_tasks[key]["retry_attempt"] = attempts > + self.running_tasks[key]["retry_total"] = retries > + > + # Log the retry attempt > + self._write_to_log(f" ✗ [{host}] retry {attempts}/{retries} - retrying...") > + > + # In static mode, show retry immediately > + if not self.dynamic_mode: > + msg = f" ✗ [{host}] retry {attempts}/{retries}" > + self._display_message(msg, C.COLOR_WARN) > + > + def v2_playbook_on_stats(self, stats): > + """Final summary""" > + # Stop update thread > + if self.update_thread_stop: > + self.update_thread_stop.set() > + if self.update_thread: > + self.update_thread.join(timeout=1.0) > + > + # Clear dynamic display if active > + if self.dynamic_mode and self.display_lines > 0: > + self._clear_display() > + > + # Print recap > + self._display_recap(stats) > + > + # Log file footer > + self._write_to_log( > + f"\n=== Playbook Completed: {datetime.now().isoformat()} ===" > + ) > + > + # ======================================================================== > + # Result Handling > + # ======================================================================== > + > + def _handle_result(self, result, status: str, ignore_errors: bool = False): > + """Unified result handler for all task outcomes""" > + host = result._host.name > + task_uuid = result._task._uuid > + key = (host, task_uuid) > + > + # Calculate duration with defensive access > + with self.task_lock: > + task_info = self.running_tasks.pop(key, None) > + start_time = ( > + task_info.get("start_time", time.time()) if task_info else time.time() > + ) > + duration = time.time() - start_time > + > + # Get delegation info from result (more reliable than task attribute) > + delegated_vars = result._result.get("_ansible_delegated_vars", {}) > + delegate_to = delegated_vars.get("ansible_delegated_host") Is the potential inconsistency between the result shown during task execution and the result shown after completion documented somewhere? (Does the user know she should expect weirdness?) > + > + # Store result data for dynamic mode display > + result_data = { > + "result": result, > + "status": status, > + "duration": duration, > + "host": host, > + "delegate_to": delegate_to, > + "task_name": result._task.get_name().strip(), > + } > + > + # Add to completed tasks (last 3 for dynamic mode) > + self.completed_tasks.append(result_data) > + > + # Log everything (max verbosity) > + self._log_result(result, status, duration) > + > + # Display based on mode > + if self.dynamic_mode: > + # Dynamic mode will update on next refresh > + # Freeze display and show output for failed tasks (not ignored) > + if status == "failed" and not ignore_errors: > + self._freeze_and_show_output(result_data) > + else: > + # Static mode - display immediately > + self._display_result_static(result, status, duration) > + > + def _display_result_static(self, result, status: str, duration: float): > + """Display result in static mode""" > + host = result._host.name > + > + # Early exit for tasks that shouldn't be shown at current verbosity > + # At verbosity 0: only show changed, failed, unreachable > + if self._display.verbosity < 1 and status in ("ok", "skipped"): > + return > + > + # Determine if we should show output (for tasks that pass visibility check) > + show_output = self._should_display_output(result, status) > + > + # Format status line using class constants > + symbol = self.STATUS_SYMBOLS.get(status, "?") > + color = self.STATUS_COLORS.get(status, C.COLOR_OK) > + > + # Get delegation info for display (like Ansible default: [host -> delegate]) > + delegated_vars = result._result.get("_ansible_delegated_vars", {}) > + delegate_to = delegated_vars.get("ansible_delegated_host") > + if delegate_to: > + host_display = f"{host} -> {delegate_to}" > + else: > + host_display = host > + > + # Format time (always shown for all statuses) > + time_str = f" ({self._format_duration(duration)})" > + > + # Status line with unicode spacing (using regular spaces, not braille) > + status_line = f" {symbol} [{host_display}]{time_str}" > + self._display_message(status_line, color) > + > + # Show command for supported modules when running with -v or higher > + if self._display.verbosity >= 1: > + command = self._get_task_command(result) > + if command: > + # Truncate very long commands > + if len(command) > 200: > + command = command[:197] + "..." > + self._display.display(f" $ {command}", color=C.COLOR_VERBOSE) > + > + # Show output if conditions met > + if show_output: > + self._display_output(result) > + > + def _display_output(self, result): > + """Display stdout/stderr/msg from task result""" > + output = [] > + res = result._result > + > + # stdout > + if "stdout" in res and res["stdout"]: > + output.append(f"\nSTDOUT:\n{res['stdout']}") > + > + # stderr > + if "stderr" in res and res["stderr"]: > + output.append(f"\nSTDERR:\n{res['stderr']}") > + > + # msg (only if no stdout content) > + if "msg" in res and res["msg"] and not res.get("stdout"): > + msg_text = res["msg"] > + # Handle lists/dicts in msg > + if isinstance(msg_text, (list, dict)): > + msg_text = json.dumps(msg_text, indent=2) > + output.append(f"\nMSG:\n{msg_text}") > + > + if output: > + self._display.display("".join(output)) > + > + def _log_result(self, result, status: str, duration: float): > + """Write result to log file (always max verbosity)""" > + host = result._host.name > + res = result._result > + > + # Get delegation info for logging > + delegated_vars = res.get("_ansible_delegated_vars", {}) > + delegate_to = delegated_vars.get("ansible_delegated_host") > + if delegate_to: > + host_display = f"{host} -> {delegate_to}" > + else: > + host_display = host > + > + # Status line using class constants > + symbol = self.STATUS_SYMBOLS.get(status, "?") > + time_str = self._format_duration(duration) > + log_line = f" {symbol} [{host_display}] ({time_str})" > + self._write_to_log(log_line) > + > + # Log command for supported modules > + command = self._get_task_command(result) > + if command: > + self._write_to_log(f"\n$ {command}") > + > + # Always log full output > + if "stdout" in res and res["stdout"]: > + self._write_to_log(f"\nSTDOUT:\n{res['stdout']}\n") > + > + if "stderr" in res and res["stderr"]: > + self._write_to_log(f"\nSTDERR:\n{res['stderr']}\n") > + > + if "msg" in res and res["msg"]: > + msg_text = res["msg"] > + if isinstance(msg_text, (list, dict)): > + msg_text = json.dumps(msg_text, indent=2) > + self._write_to_log(f"\nMSG:\n{msg_text}\n") > + > + if status == "failed" and "exception" in res: > + self._write_to_log(f"\nEXCEPTION:\n{res['exception']}\n") > + > + def _display_recap(self, stats): > + """Display final statistics""" > + self._display_message("\nPLAY RECAP", C.COLOR_HIGHLIGHT) > + > + hosts = sorted(stats.processed.keys()) > + for host in hosts: > + summary = stats.summarize(host) > + > + # More compact format > + msg = ( > + f"{host:25s} : " > + f"ok={summary['ok']:<3d} " > + f"changed={summary['changed']:<3d} " > + f"unreachable={summary['unreachable']:<3d} " > + f"failed={summary['failures']:<3d} " > + f"skipped={summary['skipped']:<3d}" > + ) > + > + # Color based on status > + if summary["failures"] > 0 or summary["unreachable"] > 0: > + color = C.COLOR_ERROR > + elif summary["changed"] > 0: > + color = C.COLOR_CHANGED > + else: > + color = C.COLOR_OK > + > + self._display_message(msg, color) > + > + # ======================================================================== > + # Dynamic Mode Display > + # ======================================================================== > + > + def _redraw_display(self): > + """Redraw entire display in dynamic mode""" > + # Throttle updates (max once per 0.1s) > + now = time.time() > + if now - self.last_update < 0.1: > + return > + self.last_update = now > + > + # Increment spinner > + self.spinner_index = (self.spinner_index + 1) % len(self.SPINNER_FRAMES) > + > + # Clear previous display > + self._clear_display() > + > + # Get terminal width once for all lines > + term_width = self._get_terminal_width() > + > + # Build new display > + lines = [] > + > + # Task header - truncate to terminal width > + if self.current_task_name: > + task_line = f"TASK: {self.current_task_name}" > + lines.append(self._truncate_line(task_line, term_width)) > + lines.append("") > + > + # Host status - show ALL hosts in task, running ones get spinner > + # Create snapshot of running tasks under lock > + with self.task_lock: > + running_hosts = { > + host: info for (host, _), info in self.running_tasks.items() > + } > + > + if self.current_task_hosts: > + running_count = len(running_hosts) > + total_hosts = len(self.current_task_hosts) > + lines.append(f"Hosts: {running_count}/{total_hosts} running") > + > + spinner = self.SPINNER_FRAMES[self.spinner_index] > + > + # Show all hosts in stable order, running ones with spinner > + # Format: [spinner]