Files
cleveragents-core/src/cleveragents/cli/formatting.py
T
freemo cf82bc0db2 fix(cli): fix format_output() to use rich and color renderers instead of JSON fallback
The format_output() function in src/cleveragents/cli/formatting.py had two
routing bugs that caused incorrect output for the 'rich' and 'color' formats:

1. The 'rich' format had no explicit dispatch branch and silently fell through
   to the final JSON fallback, returning raw JSON instead of styled terminal
   output. Since 'rich' is the default CLI format (per ADR-021), this meant
   all commands using format_output() (version, info, diagnostics) produced
   JSON by default.

2. The 'color' format was incorrectly routed to _format_plain() instead of a
   color-aware renderer, producing plain text with no ANSI color codes.

Fix:
- Added _format_rich() helper that delegates to RichMaterializer via
  OutputSession, producing ANSI-styled terminal output consistent with
  format_output_session().
- Added _format_color() helper that delegates to ColorMaterializer via
  OutputSession, producing ANSI-colored terminal output.
- Added explicit OutputFormat.RICH dispatch in format_output() routing.
- Fixed OutputFormat.COLOR dispatch to use _format_color() instead of
  _format_plain().

Tests:
- Updated existing BDD scenario that was validating the buggy behavior
  (expected JSON for rich format) to now assert correct styled output.
- Added new BDD scenarios: 'rich format produces styled terminal output not
  JSON' and 'color format produces ANSI-colored output not plain text'.
- Added Robot Framework integration tests in cli_formats.robot and
  helper_cli_formats.py verifying end-to-end styled output for both formats.

All nox sessions pass: lint, typecheck, unit_tests, security_scan.

ISSUES CLOSED: #2921
2026-04-05 07:58:21 +00:00

321 lines
11 KiB
Python

"""Shared CLI output formatting helpers.
Provides ``format_output`` to render data as JSON, YAML, plain text,
ASCII table, Rich console markup, or ANSI colour. All serialisation
uses stable field names from the domain models' ``as_cli_dict`` methods.
The implementation now delegates to the Output Rendering Framework
(``cleveragents.cli.output``) while preserving the original public API
for backward compatibility.
Based on v3_spec.md implementation plan Stage A4b / G5b.render.
"""
from __future__ import annotations
import json
from datetime import datetime
from enum import Enum, StrEnum
from io import StringIO
from typing import Any
import yaml
from rich.console import Console
from rich.table import Table
from cleveragents.cli.output.materializers import (
ColorMaterializer,
JsonMaterializer,
PlainMaterializer,
RichMaterializer,
TableMaterializer,
YamlMaterializer,
)
from cleveragents.cli.output.session import OutputSession
class OutputFormat(StrEnum):
"""Supported CLI output formats."""
JSON = "json"
YAML = "yaml"
PLAIN = "plain"
TABLE = "table"
RICH = "rich"
COLOR = "color"
def _serialize_value(value: Any) -> Any:
"""Recursively normalise values for JSON/YAML serialisation.
* ``datetime`` -> ISO-8601 string
* ``Enum`` -> ``.value``
* ``dict`` -> recursed
* ``list`` -> recursed
* everything else -> unchanged
"""
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, Enum):
return value.value
if isinstance(value, dict):
return {k: _serialize_value(v) for k, v in value.items()}
if isinstance(value, list):
return [_serialize_value(item) for item in value]
return value
def _format_json(data: dict[str, Any] | list[dict[str, Any]]) -> str:
"""Render data as indented JSON."""
return json.dumps(_serialize_value(data), indent=2, default=str)
def _format_yaml(data: dict[str, Any] | list[dict[str, Any]]) -> str:
"""Render data as YAML."""
return yaml.dump(
_serialize_value(data),
default_flow_style=False,
sort_keys=False,
allow_unicode=True,
).rstrip("\n")
def _format_plain(data: dict[str, Any] | list[dict[str, Any]]) -> str:
"""Render data as plain key: value lines (no Rich markup)."""
if isinstance(data, list):
parts: list[str] = []
for idx, item in enumerate(data):
if idx > 0:
parts.append("---")
parts.append(_format_plain_dict(item))
return "\n".join(parts)
return _format_plain_dict(data)
def _format_plain_dict(data: dict[str, Any]) -> str:
"""Render a single dict as plain text."""
lines: list[str] = []
for key, value in data.items():
serialised = _serialize_value(value)
if isinstance(serialised, dict):
lines.append(f"{key}:")
for k2, v2 in serialised.items():
lines.append(f" {k2}: {v2}")
elif isinstance(serialised, list):
lines.append(f"{key}:")
for item in serialised:
if isinstance(item, dict):
lines.append(f" - {json.dumps(item, default=str)}")
else:
lines.append(f" - {item}")
else:
lines.append(f"{key}: {serialised}")
return "\n".join(lines)
def _format_table(data: dict[str, Any] | list[dict[str, Any]]) -> str:
"""Render data as an ASCII table (no Rich styling)."""
rows: list[dict[str, Any]] = [data] if isinstance(data, dict) else data
if not rows:
return "(empty)"
# Collect all keys preserving order from first row
columns: list[str] = list(rows[0].keys())
for row in rows[1:]:
for key in row:
if key not in columns:
columns.append(key)
# Build Rich table and capture to string
table = Table(show_header=True, show_edge=True)
for col in columns:
table.add_column(col)
for row in rows:
cells: list[str] = []
for col in columns:
val = row.get(col, "")
serialised = _serialize_value(val)
if isinstance(serialised, (dict, list)):
cells.append(json.dumps(serialised, default=str))
else:
cells.append(str(serialised))
table.add_row(*cells)
buf = StringIO()
console = Console(file=buf, width=200, no_color=True)
console.print(table)
return buf.getvalue().rstrip("\n")
def _format_rich(data: dict[str, Any] | list[dict[str, Any]]) -> str:
"""Render data using the Rich materializer (ANSI-styled terminal output).
Delegates to :func:`format_output_session` with the ``rich`` strategy so
that the output is consistent with the newer session-based rendering path.
The data passed here has already been redacted by the caller.
"""
from cleveragents.cli.output.session import OutputSession
strategy = RichMaterializer()
with OutputSession(
format="rich", command="format_output", strategy=strategy
) as session:
if isinstance(data, list):
for idx, item in enumerate(data):
if isinstance(item, dict):
panel = session.panel(f"Item {idx + 1}")
for key, val in item.items():
panel.set_entry(key, str(_serialize_value(val)))
panel.close()
elif isinstance(data, dict):
panel = session.panel("Output")
for key, val in data.items():
panel.set_entry(key, str(_serialize_value(val)))
panel.close()
return strategy.get_output()
def _format_color(data: dict[str, Any] | list[dict[str, Any]]) -> str:
"""Render data using the Color materializer (ANSI-colored terminal output).
Delegates to :func:`format_output_session` with the ``color`` strategy so
that the output is consistent with the newer session-based rendering path.
The data passed here has already been redacted by the caller.
"""
from cleveragents.cli.output.session import OutputSession
strategy = ColorMaterializer()
with OutputSession(
format="color", command="format_output", strategy=strategy
) as session:
if isinstance(data, list):
for idx, item in enumerate(data):
if isinstance(item, dict):
panel = session.panel(f"Item {idx + 1}")
for key, val in item.items():
panel.set_entry(key, str(_serialize_value(val)))
panel.close()
elif isinstance(data, dict):
panel = session.panel("Output")
for key, val in data.items():
panel.set_entry(key, str(_serialize_value(val)))
panel.close()
return strategy.get_output()
def _redact_data(
data: dict[str, Any] | list[dict[str, Any]],
) -> dict[str, Any] | list[dict[str, Any]]:
"""Apply secrets redaction to output data before rendering."""
from cleveragents.shared.redaction import redact_dict
if isinstance(data, list):
return [redact_dict(item) if isinstance(item, dict) else item for item in data]
return redact_dict(data)
def format_output(
data: dict[str, Any] | list[dict[str, Any]],
format_type: str,
) -> str:
"""Format *data* according to *format_type*.
All output is passed through :func:`redact_dict` before
rendering so that sensitive values are masked unless the
global ``show_secrets`` flag is enabled.
For machine-readable formats (json, yaml, plain) the output is
written directly to ``sys.stdout`` to avoid Rich ``console.print``
line-wrapping which can introduce literal newlines inside JSON
string values. An empty string is returned so the caller's
``console.print(result)`` simply emits a trailing newline.
Parameters
----------
data:
A single dict or a list of dicts (from ``model.as_cli_dict()``).
format_type:
One of ``json``, ``yaml``, ``plain``, ``table``, ``rich``, ``color``.
Returns
-------
str
The rendered string. For machine-readable formats the output
is written directly to stdout and an empty string is returned.
"""
import sys
safe_data = _redact_data(data)
fmt = format_type.lower()
# Machine-readable formats: write directly to stdout to avoid
# Rich console.print() line-wrapping artefacts.
if fmt in (
OutputFormat.JSON.value,
OutputFormat.YAML.value,
OutputFormat.PLAIN.value,
):
if fmt == OutputFormat.JSON.value:
rendered = _format_json(safe_data)
elif fmt == OutputFormat.YAML.value:
rendered = _format_yaml(safe_data)
else:
rendered = _format_plain(safe_data)
sys.stdout.write(rendered + "\n")
sys.stdout.flush()
return ""
if fmt == OutputFormat.TABLE.value:
return _format_table(safe_data)
if fmt == OutputFormat.RICH.value:
return _format_rich(safe_data)
if fmt == OutputFormat.COLOR.value:
return _format_color(safe_data)
# Unknown format: fall back to JSON
return _format_json(safe_data)
def format_output_session(
data: dict[str, Any] | list[dict[str, Any]],
format_type: str,
) -> str:
"""Format *data* via the ``OutputSession`` framework.
This is the new entry-point that fully utilises the output rendering
framework. It converts a dict/list payload into panels in an
``OutputSession``, applies the requested materialiser, and returns
the rendered string.
"""
safe_data = _redact_data(data)
strategy_map: dict[str, type] = {
"rich": RichMaterializer,
"color": ColorMaterializer,
"table": TableMaterializer,
"plain": PlainMaterializer,
"json": JsonMaterializer,
"yaml": YamlMaterializer,
}
fmt = format_type.lower()
strategy_cls = strategy_map.get(fmt, PlainMaterializer)
strategy = strategy_cls()
with OutputSession(
format=fmt, command="format_output", strategy=strategy
) as session:
if isinstance(safe_data, list):
for idx, item in enumerate(safe_data):
if isinstance(item, dict):
panel = session.panel(f"Item {idx + 1}")
for key, val in item.items():
panel.set_entry(key, str(_serialize_value(val)))
panel.close()
elif isinstance(safe_data, dict):
panel = session.panel("Output")
for key, val in safe_data.items():
panel.set_entry(key, str(_serialize_value(val)))
panel.close()
return strategy.get_output()