forked from cleveragents/cleveragents-core
cf82bc0db2
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
321 lines
11 KiB
Python
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()
|