Source code for thesis.core.output.renderer

"""
Output rendering for the thesis framework.

Renders events, summaries, and progress to the terminal or JSON.
Handles color, alignment, and non-TTY fallback behavior.

The renderer subscribes to the :class:`EventBus` and filters events
by the configured verbosity level before printing them.

Example:
    >>> from thesis.core.output.renderer import OutputRenderer
    >>> from thesis.core.output.modes import OutputConfig
    >>> renderer = OutputRenderer(OutputConfig())
    >>> renderer.render_run_summary(summary)
"""

import json
import os
import sys
from typing import IO, Optional

from .events import Event, EventBus, EventLevel
from .modes import OutputConfig, SummaryDetail
from .summary import (
    BatchSummary,
    RunResult,
    RunStatus,
    RunSummary,
    build_bullets,
    format_duration,
)

__all__ = [
    "OutputRenderer",
]

# ---------------------------------------------------------------------------
# Status icons -- readable with and without color
# ---------------------------------------------------------------------------

_STATUS_ICONS = {
    RunStatus.SUCCESS: "[OK]",
    RunStatus.PARTIAL: "[PARTIAL]",
    RunStatus.FAILED: "[FAIL]",
    RunStatus.BLOCKED: "[BLOCKED]",
    RunStatus.SKIPPED: "[SKIP]",
}

_EVENT_PREFIXES = {
    EventLevel.ERROR: "[ERROR]",
    EventLevel.WARNING: "[WARN]",
    EventLevel.IMPORTANT: "[*]",
    EventLevel.INFO: "[.]",
    EventLevel.DEBUG: "[D]",
}

# ANSI color codes (applied only when color is enabled)
_COLORS = {
    "reset": "\033[0m",
    "bold": "\033[1m",
    "dim": "\033[2m",
    "red": "\033[31m",
    "green": "\033[32m",
    "yellow": "\033[33m",
    "cyan": "\033[36m",
    "white": "\033[37m",
}

_STATUS_COLORS = {
    RunStatus.SUCCESS: "green",
    RunStatus.PARTIAL: "yellow",
    RunStatus.FAILED: "red",
    RunStatus.BLOCKED: "red",
    RunStatus.SKIPPED: "dim",
}

_LEVEL_COLORS = {
    EventLevel.ERROR: "red",
    EventLevel.WARNING: "yellow",
    EventLevel.IMPORTANT: "cyan",
    EventLevel.INFO: "dim",
    EventLevel.DEBUG: "dim",
}


[docs] class OutputRenderer: """Renders structured output to the terminal or as JSON. Connects to the :class:`EventBus` and prints events that pass the verbosity filter. Also provides methods for rendering run and batch summaries. Args: config: Output configuration controlling verbosity and format. event_bus: Event bus to subscribe to. Uses the global singleton if not provided. file: Output stream (default: stderr). """
[docs] def __init__( self, config: Optional[OutputConfig] = None, event_bus: Optional[EventBus] = None, file: Optional[IO[str]] = None, ) -> None: self._config = config or OutputConfig() self._file = file or sys.stderr self._color_enabled = self._detect_color() self._bus = event_bus self._subscribed = False
# ------------------------------------------------------------------ # Event bus integration # ------------------------------------------------------------------
[docs] def attach(self, bus: Optional[EventBus] = None) -> None: """Subscribe to an event bus to render events in real time. Args: bus: Event bus to subscribe to. If ``None``, uses the global singleton. """ if bus is None: from .events import get_event_bus bus = get_event_bus() self._bus = bus if self._subscribed: return bus.subscribe(self._on_event) self._subscribed = True
[docs] def detach(self) -> None: """Unsubscribe from the event bus.""" if self._bus is not None and self._subscribed: self._bus.unsubscribe(self._on_event) self._subscribed = False
def _on_event(self, event: Event) -> None: """Event listener callback -- filter and render.""" if event.level < self._config.min_event_level: return self.render_event(event) # ------------------------------------------------------------------ # Event rendering # ------------------------------------------------------------------
[docs] def render_event(self, event: Event) -> None: """Render a single event to the output stream. Args: event: The event to render. """ if self._config.is_json: self._write_json_event(event) return prefix = _EVENT_PREFIXES.get(event.level, "[.]") color_name = _LEVEL_COLORS.get(event.level, "white") # Build the line parts = [] if event.patient_id: parts.append(f"[{event.patient_id}]") parts.append(event.message) body = " ".join(parts) line = f" {self._color(prefix, color_name)} {body}" self._write(line)
# ------------------------------------------------------------------ # Summary rendering # ------------------------------------------------------------------
[docs] def render_run_summary(self, summary: RunSummary) -> None: """Render a single-run summary footer. Args: summary: The run summary to render. """ if self._config.summary == SummaryDetail.OFF: return if self._config.is_json: self._write_json({"type": "run_summary", "data": summary.model_dump(mode="json")}) return self._write("") # blank line separator self._write(self._separator()) # Headline status_color = _STATUS_COLORS.get(summary.status, "white") icon = _STATUS_ICONS.get(summary.status, "[?]") headline = f" {self._color(icon, status_color)} {summary.headline}" self._write(headline) # Bullets (compact: capped at 6; full: all bullets, uncapped) is_full = self._config.summary == SummaryDetail.FULL if self._config.summary in (SummaryDetail.COMPACT, SummaryDetail.FULL): if is_full and summary.result is not None: bullets = build_bullets(summary.result, cap=None) else: bullets = summary.bullets for bullet in bullets: self._write(f" - {bullet}") # Next step if summary.next_step: self._write(f" > {self._color(summary.next_step, 'dim')}") # Full result details -- shown in verbose mode OR full summary detail, # so `--summary full` is meaningfully more detailed than compact even # without `-v`. if (self._config.is_verbose or is_full) and summary.result: self._render_verbose_result(summary.result) self._write(self._separator())
[docs] def render_batch_summary(self, batch: BatchSummary) -> None: """Render a batch summary footer. Args: batch: The batch summary to render. """ if self._config.summary == SummaryDetail.OFF: return if self._config.is_json: self._write_json({"type": "batch_summary", "data": batch.model_dump(mode="json")}) return self._write("") self._write(self._separator("=")) # Top-level outcome all_ok = batch.failed == 0 and batch.blocked == 0 overall_status = "SUCCESS" if all_ok else "FAILED" overall_color = "green" if all_ok else "red" overall_icon = "[OK]" if all_ok else "[FAIL]" duration = format_duration(batch.total_elapsed_seconds) headline = ( f" {self._color(overall_icon, overall_color)} " f"Batch {overall_status}: {batch.succeeded}/{batch.total} succeeded " f"{duration}" ) self._write(headline) # Counts line counts = [] if batch.succeeded: counts.append(self._color(f"{batch.succeeded} ok", "green")) if batch.partial: counts.append(self._color(f"{batch.partial} partial", "yellow")) if batch.failed: counts.append(self._color(f"{batch.failed} failed", "red")) if batch.blocked: counts.append(self._color(f"{batch.blocked} blocked", "red")) if batch.skipped: counts.append(f"{batch.skipped} skipped") if counts: self._write(f" {' | '.join(counts)}") # Timing if batch.avg_elapsed_seconds > 0: avg = format_duration(batch.avg_elapsed_seconds) self._write(f" Avg per patient: {avg}") self._write(f" Retries configured: {batch.retries}") # Per-run status list self._write("") show_limit = 50 if self._config.is_verbose else 20 for i, rs in enumerate(batch.run_summaries[:show_limit]): icon = _STATUS_ICONS.get(rs.status, "[?]") color = _STATUS_COLORS.get(rs.status, "white") # Only show per-patient duration if it was actually measured # (parallel runs don't have per-patient timing). dur = format_duration(rs.elapsed_seconds) if rs.elapsed_seconds > 0 else "" note = "" if rs.result and rs.result.error_message and not self._config.is_quiet: msg = rs.result.error_message if len(msg) > 60: msg = msg[:57] + "..." note = f" {msg}" self._write(f" {self._color(icon, color)} {rs.patient_id:<16} {dur:>10}{note}") remaining = len(batch.run_summaries) - show_limit if remaining > 0: self._write(f" ... and {remaining} more (use -v to see all)") # Failure reasons (grouped) if batch.failure_reasons and not self._config.is_quiet: self._write("") self._write(" Failure reasons:") for reason, pids in batch.failure_reasons.items(): truncated = reason if len(reason) <= 80 else reason[:77] + "..." pid_str = ", ".join(pids[:5]) if len(pids) > 5: pid_str += f" (+{len(pids) - 5})" self._write(f" - {truncated}") self._write(f" Patients: {pid_str}") # Follow-up suggestion if batch.suggested_follow_up: self._write("") self._write(f" > {self._color(batch.suggested_follow_up, 'dim')}") self._write(self._separator("="))
# ------------------------------------------------------------------ # Verbose result details # ------------------------------------------------------------------ def _render_verbose_result(self, result: RunResult) -> None: """Render full result details in verbose mode.""" self._write("") if result.steps_completed: self._write(" Steps completed:") for step in result.steps_completed: self._write(f" + {step}") if result.steps_failed: self._write(" Steps failed:") for step in result.steps_failed: self._write(f" x {step}") if result.files_changed: self._write(" Files changed:") for f in result.files_changed[:20]: self._write(f" {f}") if len(result.files_changed) > 20: self._write(f" ... and {len(result.files_changed) - 20} more") if result.commands_run: self._write(" Commands:") for cmd in result.commands_run[:10]: self._write(f" $ {cmd}") if len(result.commands_run) > 10: self._write(f" ... and {len(result.commands_run) - 10} more") if result.warnings: self._write(" Warnings:") for w in result.warnings: self._write(f" ! {w}") # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ def _write(self, text: str) -> None: """Write a line to the output stream.""" try: self._file.write(text + "\n") self._file.flush() except (OSError, AttributeError): pass def _write_json(self, data: object) -> None: """Write a JSON object as a single line.""" try: self._file.write(json.dumps(data, default=str) + "\n") self._file.flush() except (OSError, AttributeError, TypeError): pass def _write_json_event(self, event: Event) -> None: """Serialize an event as JSON.""" self._write_json( { "type": "event", "level": event.level.name, "message": event.message, "category": event.category, "patient_id": event.patient_id, "timestamp": event.timestamp, "metadata": event.metadata, } ) def _color(self, text: str, color_name: str) -> str: """Apply ANSI color if color is enabled.""" if not self._color_enabled: return text code = _COLORS.get(color_name, "") reset = _COLORS["reset"] return f"{code}{text}{reset}" if code else text def _separator(self, char: str = "-") -> str: """Build a visual separator line.""" width = _terminal_width() return f" {char * min(width - 4, 72)}" def _detect_color(self) -> bool: """Detect whether color output should be enabled.""" if os.environ.get("NO_COLOR"): return False if self._config.is_json: return False try: return self._file.isatty() except AttributeError: return False
def _terminal_width() -> int: """Get terminal width, defaulting to 80.""" try: return os.get_terminal_size().columns except (OSError, ValueError): return 80