fix(cli): fix format_output() to use rich and color renderers instead of JSON fallback #3227

Merged
HAL9000 merged 4 commits from fix/format-output-rich-color-renderers into master 2026-05-30 17:06:23 +00:00
6 changed files with 343 additions and 60 deletions
+21 -2
View File
@@ -92,7 +92,6 @@ Feature: CLI output formats parity
Then json_keys and yaml_keys should match
# Direct formatting module tests
@tdd_issue @tdd_issue_4364 @tdd_expected_fail
Scenario: Format output handles all format types for dict
When I call format_output with a dict and format json
Then the format result should be valid JSON dict
@@ -102,13 +101,33 @@ Feature: CLI output formats parity
Then the format result should contain plain key-value pairs
When I call format_output with a dict and format table
Then the format result should contain table output
# fix(cli): format_output() rich format must produce styled output, not JSON
Scenario: Format output rich format produces styled terminal output not JSON
When I call format_output with a dict and format rich
Then the format result should be valid JSON dict
Then the format result should contain rich styled output
And the format result should not be valid JSON
# fix(cli): format_output() color format must produce ANSI-colored output not plain text
Scenario: Format output color format produces ANSI-colored output not plain text
When I call format_output with a dict and format color
Then the format result should contain color styled output
And the format result should not be plain text only
Scenario: Format output handles list data
When I call format_output with a list and format plain
Then the format result should contain separator lines
Scenario: Format output rich format handles list data
When I call format_output with a list and format rich
Then the format result should contain rich styled list output
And the format result should not be valid JSON
Scenario: Format output color format handles list data
When I call format_output with a list and format color
Then the format result should contain color styled list output
And the format result should not be plain text only
Scenario: Serialize value handles enums and nested dicts
When I call serialize_value with enum and nested data
Then the serialized result should have string enum values
@@ -0,0 +1,151 @@
"""Step definitions for CLI output format validation (rich / color formats)."""
from __future__ import annotations
import json
import yaml
from behave import then
from behave.runner import Context
@then("the format result should be valid JSON dict")
def step_format_result_json_dict(context: Context) -> None:
parsed = json.loads(context.format_result)
assert isinstance(parsed, dict)
# Verify spec-required envelope fields are present
for field in ("command", "status", "exit_code", "data", "timing", "messages"):
assert field in parsed, f"Envelope field '{field}' missing from JSON result"
# Verify data field contains the original dict
assert isinstance(parsed["data"], dict)
@then("the format result should be valid YAML dict")
def step_format_result_yaml_dict(context: Context) -> None:
parsed = yaml.safe_load(context.format_result)
assert isinstance(parsed, dict)
# Verify spec-required envelope fields are present
for field in ("command", "status", "exit_code", "data", "timing", "messages"):
assert field in parsed, f"Envelope field '{field}' missing from YAML result"
# Verify data field contains the original dict
assert isinstance(parsed["data"], dict)
@then("the format result should contain plain key-value pairs")
def step_format_result_plain(context: Context) -> None:
assert "name: test" in context.format_result
assert "nested:" in context.format_result
assert "items:" in context.format_result
@then("the format result should contain table output")
def step_format_result_table(context: Context) -> None:
assert "name" in context.format_result
assert "count" in context.format_result
@then("the format result should contain separator lines")
def step_format_result_separator(context: Context) -> None:
assert "---" in context.format_result
@then("the serialized result should have string enum values")
def step_serialized_enum_values(context: Context) -> None:
assert context.serialized["state"] == "available"
assert isinstance(context.serialized["nested"], dict)
assert context.serialized["items"][0] == "archived"
assert isinstance(context.serialized["ts"], str)
@then("the format result should contain rich styled output")
def step_format_result_rich_styled(context: Context) -> None:
"""Assert that rich format produces styled output (ANSI codes or key-value lines).
The rich format must NOT produce raw JSON — it must produce styled terminal
output via the RichMaterializer (ANSI escape codes or structured key-value
lines with panel headers).
"""
result = context.format_result
# Rich output must contain the data keys
assert "name" in result, f"Rich output missing 'name': {result!r}"
assert "count" in result, f"Rich output missing 'count': {result!r}"
# Rich output must contain styled panel header or ANSI codes
has_ansi = "\033[" in result or "\x1b[" in result
has_panel_header = "Output" in result
assert has_ansi or has_panel_header, (
f"Rich output has no ANSI codes or panel header — got: {result!r}"
)
@then("the format result should not be valid JSON")
def step_format_result_not_json(context: Context) -> None:
"""Assert that the format result is NOT valid JSON (i.e. not the JSON fallback)."""
try:
json.loads(context.format_result)
raise AssertionError(
f"format_output() with 'rich' returned valid JSON — "
f"expected styled terminal output, got: {context.format_result!r}"
)
except (json.JSONDecodeError, ValueError):
pass # Expected: rich output is not JSON
@then("the format result should contain color styled output")
def step_format_result_color_styled(context: Context) -> None:
"""Assert that color format produces ANSI-colored output (not plain text).
The color format must NOT produce plain text — it must produce ANSI-colored
terminal output via the ColorMaterializer.
"""
result = context.format_result
# Color output must contain the data keys
assert "name" in result, f"Color output missing 'name': {result!r}"
assert "count" in result, f"Color output missing 'count': {result!r}"
# Color output must contain ANSI escape codes or styled panel header
has_ansi = "\033[" in result or "\x1b[" in result
has_panel_header = "Output" in result
assert has_ansi or has_panel_header, (
f"Color output has no ANSI codes or panel header — got: {result!r}"
)
@then("the format result should not be plain text only")
def step_format_result_not_plain_text(context: Context) -> None:
"""Assert that the color format result is NOT plain text (i.e. not the plain fallback).
Plain text would look like 'name: test\\ncount: 42' with no ANSI codes.
Color output must have ANSI escape codes or styled panel headers.
"""
result = context.format_result
has_ansi = "\033[" in result or "\x1b[" in result
has_panel_header = "Output" in result
# If it's purely plain text (no ANSI, no panel header), that's the bug
is_plain_only = not has_ansi and not has_panel_header
assert not is_plain_only, (
f"format_output() with 'color' returned plain text — "
f"expected ANSI-colored output, got: {result!r}"
)
@then("the format result should contain rich styled list output")
def step_format_result_rich_list(context: Context) -> None:
"""Assert rich format produces styled output for list data (panel per item)."""
result = context.format_result
assert "name" in result, f"Rich list output missing 'name': {result!r}"
has_ansi = "\033[" in result or "\x1b[" in result
has_item_panel = "Item 1" in result or "Item 2" in result
assert has_ansi or has_item_panel, (
f"Rich list output has no ANSI codes or item panels — got: {result!r}"
)
@then("the format result should contain color styled list output")
def step_format_result_color_list(context: Context) -> None:
"""Assert color format produces styled output for list data (panel per item)."""
result = context.format_result
assert "name" in result, f"Color list output missing 'name': {result!r}"
has_ansi = "\033[" in result or "\x1b[" in result
has_item_panel = "Item 1" in result or "Item 2" in result
assert has_ansi or has_item_panel, (
f"Color list output has no ANSI codes or item panels — got: {result!r}"
)
+24 -51
View File
@@ -441,6 +441,12 @@ def step_call_format_rich(context: Context) -> None:
context.format_result = _capture_format_output(data, "rich")
@when("I call format_output with a dict and format color")
def step_call_format_color(context: Context) -> None:
data = {"name": "test", "count": 42}
context.format_result = _capture_format_output(data, "color")
@when("I call format_output with a list and format plain")
def step_call_format_list_plain(context: Context) -> None:
data = [
@@ -450,6 +456,24 @@ def step_call_format_list_plain(context: Context) -> None:
context.format_result = _capture_format_output(data, "plain")
@when("I call format_output with a list and format rich")
def step_call_format_list_rich(context: Context) -> None:
data = [
{"name": "a", "count": 1},
{"name": "b", "count": 2},
]
context.format_result = _capture_format_output(data, "rich")
@when("I call format_output with a list and format color")
def step_call_format_list_color(context: Context) -> None:
data = [
{"name": "a", "count": 1},
{"name": "b", "count": 2},
]
context.format_result = _capture_format_output(data, "color")
@when("I call serialize_value with enum and nested data")
def step_call_serialize_with_enum(context: Context) -> None:
context.serialized = _serialize_value(
@@ -460,54 +484,3 @@ def step_call_serialize_with_enum(context: Context) -> None:
"ts": datetime(2025, 6, 1, 12, 0),
}
)
# ------- Additional Then steps -------
@then("the format result should be valid JSON dict")
def step_format_result_json_dict(context: Context) -> None:
parsed = json.loads(context.format_result)
assert isinstance(parsed, dict)
# Verify spec-required envelope fields are present
for field in ("command", "status", "exit_code", "data", "timing", "messages"):
assert field in parsed, f"Envelope field '{field}' missing from JSON result"
# Verify data field contains the original dict
assert isinstance(parsed["data"], dict)
@then("the format result should be valid YAML dict")
def step_format_result_yaml_dict(context: Context) -> None:
parsed = yaml.safe_load(context.format_result)
assert isinstance(parsed, dict)
# Verify spec-required envelope fields are present
for field in ("command", "status", "exit_code", "data", "timing", "messages"):
assert field in parsed, f"Envelope field '{field}' missing from YAML result"
# Verify data field contains the original dict
assert isinstance(parsed["data"], dict)
@then("the format result should contain plain key-value pairs")
def step_format_result_plain(context: Context) -> None:
assert "name: test" in context.format_result
assert "nested:" in context.format_result
assert "items:" in context.format_result
@then("the format result should contain table output")
def step_format_result_table(context: Context) -> None:
assert "name" in context.format_result
assert "count" in context.format_result
@then("the format result should contain separator lines")
def step_format_result_separator(context: Context) -> None:
assert "---" in context.format_result
@then("the serialized result should have string enum values")
def step_serialized_enum_values(context: Context) -> None:
assert context.serialized["state"] == "available"
assert isinstance(context.serialized["nested"], dict)
assert context.serialized["items"][0] == "archived"
assert isinstance(context.serialized["ts"], str)
+22
View File
@@ -93,3 +93,25 @@ All Six Formats Work With Version Command
${result}= Run Process ${PYTHON} ${HELPER} global-format-all-six cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} cli-global-format-all-six-ok
# ---------------------------------------------------------------------------
# format_output() renderer routing tests (Issue #2921)
# ---------------------------------------------------------------------------
Format Output Rich Produces Styled Output Not JSON
[Documentation] Verify that format_output(data, "rich") produces styled terminal output
... and NOT raw JSON (fix for issue #2921: rich format silently fell back to JSON)
${result}= Run Process ${PYTHON} ${HELPER} format-output-rich cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} cli-formats-format-output-rich-ok
Format Output Color Produces ANSI Colored Output Not Plain Text
[Documentation] Verify that format_output(data, "color") produces ANSI-colored output
... and NOT plain text (fix for issue #2921: color format used plain renderer)
${result}= Run Process ${PYTHON} ${HELPER} format-output-color cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} cli-formats-format-output-color-ok
+68 -3
View File
@@ -49,13 +49,13 @@ def _mock_action(name: str = "local/fmt-smoke") -> Action:
read_only=False,
state=ActionState.AVAILABLE,
created_by=None,
created_at=datetime.now(),
updated_at=datetime.now(),
created_at=datetime(2025, 1, 15, 10, 0, 0),
updated_at=datetime(2025, 1, 15, 10, 0, 0),
)
def _mock_plan(name: str = "local/fmt-smoke-plan") -> Plan:
now = datetime.now()
now = datetime(2025, 1, 15, 10, 0, 0)
return Plan(
identity=PlanIdentity(plan_id=_ULID),
namespaced_name=NamespacedName.parse(name),
1
@@ -214,6 +214,69 @@ def global_format_all_six() -> None:
print("cli-global-format-all-six-ok")
def format_output_rich() -> None:
"""Verify format_output(data, 'rich') produces styled output, not JSON.
Regression test for issue #2921: rich format silently fell back to JSON.
"""
from cleveragents.cli.formatting import format_output
data = {"name": "smoke-test", "status": "active", "count": 42}
result = format_output(data, "rich")
# Must NOT be valid JSON (that was the bug)
try:
json.loads(result)
raise AssertionError(
f"format_output(data, 'rich') returned valid JSON — "
f"expected styled terminal output, got: {result!r}"
)
except (json.JSONDecodeError, ValueError):
pass # Expected: rich output is not JSON
# Must contain the data keys
assert "name" in result, f"Rich output missing 'name': {result!r}"
assert "status" in result, f"Rich output missing 'status': {result!r}"
# Must contain ANSI codes or panel header (styled output)
has_ansi = "\033[" in result or "\x1b[" in result
has_panel = "Output" in result
assert has_ansi or has_panel, (
f"Rich output has no ANSI codes or panel header: {result!r}"
)
print("cli-formats-format-output-rich-ok")
def format_output_color() -> None:
"""Verify format_output(data, 'color') produces ANSI-colored output, not plain text.
Regression test for issue #2921: color format used plain renderer instead of color.
"""
from cleveragents.cli.formatting import format_output
data = {"name": "smoke-test", "status": "active", "count": 42}
result = format_output(data, "color")
# Must contain the data keys
assert "name" in result, f"Color output missing 'name': {result!r}"
assert "status" in result, f"Color output missing 'status': {result!r}"
# Must NOT be plain text only (that was the bug)
plain_text = "name: smoke-test\nstatus: active\ncount: 42"
assert result != plain_text, (
f"format_output(data, 'color') returned plain text — "
f"expected ANSI-colored output, got: {result!r}"
)
# Must contain ANSI codes or panel header (styled output)
has_ansi = "\033[" in result or "\x1b[" in result
has_panel = "Output" in result
assert has_ansi or has_panel, (
f"Color output has no ANSI codes or panel header: {result!r}"
)
print("cli-formats-format-output-color-ok")
_COMMANDS = {
"action-list-json": action_list_json,
"action-show-yaml": action_show_yaml,
@@ -226,6 +289,8 @@ _COMMANDS = {
"global-format-json-diagnostics": global_format_json_diagnostics,
"global-format-shorthand": global_format_shorthand,
"global-format-all-six": global_format_all_six,
"format-output-rich": format_output_rich,
"format-output-color": format_output_color,
}
if __name__ == "__main__":
+57 -4
View File
@@ -14,6 +14,7 @@ Based on v3_spec.md implementation plan Stage A4b / G5b.render.
from __future__ import annotations
Review

[API-CONSISTENCY] DRY opportunity: _format_rich() and _format_color() are nearly identical — they differ only in the materializer class and format string. Consider extracting a shared _format_with_materializer(data, strategy_cls, format_name) helper. This would also align with the pattern in format_output_session() which already uses a strategy map. Non-blocking but recommended.

**[API-CONSISTENCY] DRY opportunity**: `_format_rich()` and `_format_color()` are nearly identical — they differ only in the materializer class and format string. Consider extracting a shared `_format_with_materializer(data, strategy_cls, format_name)` helper. This would also align with the pattern in `format_output_session()` which already uses a strategy map. Non-blocking but recommended.
import json
import sys
import time
from datetime import datetime
Review

[PATTERN] Redundant lazy import: OutputSession is already imported at the top of this file (line 34). This function-level import shadows the top-level import and violates the project convention that all imports belong at the top of the file. Remove this line.

**[PATTERN] Redundant lazy import**: `OutputSession` is already imported at the top of this file (line 34). This function-level import shadows the top-level import and violates the project convention that all imports belong at the top of the file. Remove this line.
from enum import Enum, StrEnum
1
@@ -149,6 +150,58 @@ def _format_table(data: dict[str, Any] | list[dict[str, Any]]) -> str:
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.
"""
strategy = RichMaterializer()
with OutputSession(
Outdated
Review

[CONTRIBUTING] Redundant lazy import: OutputSession is already imported at the top of this file (line 34). This function-level import shadows the top-level import unnecessarily and violates the project convention that all imports belong at the top of the file. Remove this line.

**[CONTRIBUTING] Redundant lazy import**: `OutputSession` is already imported at the top of this file (line 34). This function-level import shadows the top-level import unnecessarily and violates the project convention that all imports belong at the top of the file. Remove this line.
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.
"""
strategy = ColorMaterializer()
with OutputSession(
format="color", command="format_output", strategy=strategy
) as session:
Outdated
Review

[CONTRIBUTING] Redundant lazy import: Same issue as _format_rich()OutputSession is already imported at the top of the file. Remove this function-level import.

**[CONTRIBUTING] Redundant lazy import**: Same issue as `_format_rich()` — `OutputSession` is already imported at the top of the file. Remove this function-level import.
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]]:
@@ -283,8 +336,6 @@ def format_output(
The rendered string. For machine-readable formats the output
is written directly to stdout and an empty string is returned.
"""
import sys
t_start = time.monotonic()
safe_data = _redact_data(data)
fmt = format_type.lower()
@@ -316,9 +367,11 @@ def format_output(
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_output_session(safe_data, fmt)
# ``rich`` and any unknown value fall back to JSON
return _format_color(safe_data)
# Unknown format: fall back to JSON
return _format_json(safe_data)