fix(cli): fix format_output() to use rich and color renderers instead of JSON fallback #3227
@@ -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}"
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
@@ -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__":
|
||||
|
||||
@@ -14,6 +14,7 @@ Based on v3_spec.md implementation plan Stage A4b / G5b.render.
|
||||
from __future__ import annotations
|
||||
|
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
freemo
commented
[PATTERN] Redundant lazy import: **[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
|
||||
@@ -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(
|
||||
|
HAL9000
commented
[CONTRIBUTING] Redundant lazy import: **[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:
|
||||
|
HAL9000
commented
[CONTRIBUTING] Redundant lazy import: Same issue as **[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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user
[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 informat_output_session()which already uses a strategy map. Non-blocking but recommended.