feat(cli): implement RendererRegistry and ElementRenderer architecture per spec
Implement the three missing architectural components of the Output Rendering Framework as specified in issue #917: 1. RendererRegistry (spec §27249-27350): Central registry for format (MaterializationStrategy, ElementRenderer) pairs with register(), resolve(), available_formats(), is_registered() methods and a FormatRegistration model. Built-in formats pre-registered in default_registry. Replaces hardcoded if/elif chains for format resolution. 2. ElementRenderer Protocol (spec §26557-26654): Per-element render methods (render_panel, render_table, render_tree, etc.) plus serialize() and can_render(). Six concrete implementations: PlainElementRenderer, ColorElementRenderer, TableElementRenderer, RichElementRenderer, JsonElementRenderer, YamlElementRenderer. Each format now has a paired (Strategy, Renderer). 3. TerminalCapabilities (spec §27264-27301): Extended from 4 fields to all 11 spec-defined fields: width, height, supports_256_color, supports_truecolor, supports_unicode, supports_alternate_screen, no_color, plus renames supports_cursor → supports_cursor_movement, term → term_program. Backward-compatible properties preserved. Additional fixes: - ColumnDef serialises column type as 'type' (not 'col_type') per spec §26199, with alias for backward compatibility - YAML output uses sort_keys=True per spec §27168 - Progress elements omitted from JSON/YAML per spec §26936 - MaterializationStrategy.bind(renderer, terminal_caps) method added to protocol and all strategy implementations (SD-19 resolved) - Updated SD documentation in __init__.py ISSUES CLOSED: #917
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
Feature: Output Rendering Framework — Registry, Renderer, and Capabilities
|
||||
|
||||
# ================================================================
|
||||
# RendererRegistry
|
||||
# ================================================================
|
||||
|
||||
Scenario: RendererRegistry registers and resolves built-in formats
|
||||
Given the default renderer registry
|
||||
Then the registry has format "plain" registered
|
||||
And the registry has format "color" registered
|
||||
And the registry has format "table" registered
|
||||
And the registry has format "rich" registered
|
||||
And the registry has format "json" registered
|
||||
And the registry has format "yaml" registered
|
||||
|
||||
Scenario: RendererRegistry available_formats returns sorted list
|
||||
Given the default renderer registry
|
||||
Then the available formats are "color,json,plain,rich,table,yaml"
|
||||
|
||||
Scenario: RendererRegistry is_registered returns False for unknown
|
||||
Given the default renderer registry
|
||||
Then the registry does not have format "csv" registered
|
||||
|
||||
Scenario: RendererRegistry resolves plain format
|
||||
Given the default renderer registry
|
||||
When I resolve format "plain" from the registry
|
||||
Then the resolved renderer format name is "plain"
|
||||
|
||||
Scenario: RendererRegistry resolves json format
|
||||
Given the default renderer registry
|
||||
When I resolve format "json" from the registry
|
||||
Then the resolved renderer format name is "json"
|
||||
|
||||
Scenario: RendererRegistry resolve falls back from rich to plain on non-TTY
|
||||
Given the default renderer registry
|
||||
And a non-TTY terminal environment
|
||||
When I resolve format "rich" from the registry with terminal caps
|
||||
Then the resolved renderer format name is "plain"
|
||||
|
||||
Scenario: RendererRegistry supports custom format registration
|
||||
Given a new empty renderer registry
|
||||
When I register a custom "csv" format in the registry
|
||||
Then the registry has format "csv" registered
|
||||
|
||||
# ================================================================
|
||||
# ElementRenderer protocol
|
||||
# ================================================================
|
||||
|
||||
Scenario: PlainElementRenderer renders panel element
|
||||
Given a PlainElementRenderer
|
||||
When I render a panel with title "Test" through the renderer
|
||||
Then the renderer output contains "Test"
|
||||
|
||||
Scenario: PlainElementRenderer can_render always returns True
|
||||
Given a PlainElementRenderer
|
||||
Then the renderer can_render returns True for any terminal
|
||||
|
||||
Scenario: ColorElementRenderer can_render requires ANSI
|
||||
Given a ColorElementRenderer
|
||||
Then the renderer can_render returns False without ANSI
|
||||
|
||||
Scenario: RichElementRenderer can_render requires cursor movement
|
||||
Given a RichElementRenderer
|
||||
Then the renderer can_render returns False without cursor movement
|
||||
|
||||
Scenario: JsonElementRenderer serializes output
|
||||
Given a JsonElementRenderer
|
||||
When I serialize a StructuredOutput through the renderer
|
||||
Then the serialized output is valid JSON
|
||||
|
||||
Scenario: YamlElementRenderer serializes output with sorted keys
|
||||
Given a YamlElementRenderer
|
||||
When I serialize a StructuredOutput through the renderer
|
||||
Then the serialized output is valid YAML
|
||||
And the serialized output has sorted keys
|
||||
|
||||
# ================================================================
|
||||
# TerminalCapabilities extended fields
|
||||
# ================================================================
|
||||
|
||||
Scenario: TerminalCapabilities has all 11 spec fields
|
||||
When I create a full TerminalCapabilities
|
||||
Then the capabilities has width 120
|
||||
And the capabilities has height 40
|
||||
And the capabilities has supports_256_color True
|
||||
And the capabilities has supports_truecolor True
|
||||
And the capabilities has supports_unicode True
|
||||
And the capabilities has supports_alternate_screen True
|
||||
And the capabilities has no_color False
|
||||
And the capabilities has term_program "iTerm2"
|
||||
|
||||
Scenario: TerminalCapabilities backward-compatible aliases work
|
||||
When I create a full TerminalCapabilities
|
||||
Then the capabilities supports_cursor property matches supports_cursor_movement
|
||||
And the capabilities term property matches term_program
|
||||
|
||||
# ================================================================
|
||||
# ColumnDef.type serialisation
|
||||
# ================================================================
|
||||
|
||||
Scenario: ColumnDef serialises type field as "type" in JSON
|
||||
Given an OutputSession with format "json" using JsonMaterializer
|
||||
When I create a table with ColumnDef objects "Name,Status"
|
||||
And I add a dict row with Name "Name" and Status "Alice"
|
||||
And I close the table handle
|
||||
And the session is closed
|
||||
Then the json output is valid JSON
|
||||
And the json output ColumnDef uses type key not col_type
|
||||
|
||||
Scenario: ColumnDef accepts type alias in constructor
|
||||
When I create a ColumnDef with type alias "integer"
|
||||
Then the ColumnDef col_type is "integer"
|
||||
|
||||
# ================================================================
|
||||
# YAML sorted keys
|
||||
# ================================================================
|
||||
|
||||
Scenario: YAML output uses sorted keys
|
||||
Given an OutputSession with format "yaml" using YamlMaterializer
|
||||
When I create a panel handle with title "Sort Test"
|
||||
And I set entry "Zebra" to "last"
|
||||
And I set entry "Alpha" to "first"
|
||||
And I close the panel handle
|
||||
And the session is closed
|
||||
Then the yaml output is valid YAML
|
||||
|
||||
# ================================================================
|
||||
# Progress omitted from JSON output
|
||||
# ================================================================
|
||||
|
||||
Scenario: JSON output omits progress elements
|
||||
Given an OutputSession with format "json" using JsonMaterializer
|
||||
When I create a progress handle labelled "Building" with total 100
|
||||
And I set progress to 50 of 100
|
||||
And I close the progress handle
|
||||
And I create a panel handle with title "Info"
|
||||
And I set entry "Name" to "test"
|
||||
And I close the panel handle
|
||||
And the session is closed
|
||||
Then the json output is valid JSON
|
||||
And the json output does not contain element type "progress"
|
||||
And the json output contains element type "panel"
|
||||
|
||||
# ================================================================
|
||||
# MaterializationStrategy.bind() method
|
||||
# ================================================================
|
||||
|
||||
Scenario: PlainMaterializer has bind method
|
||||
Given a PlainMaterializer instance
|
||||
When I call bind on the materializer
|
||||
Then no error is raised
|
||||
|
||||
Scenario: JsonMaterializer has bind method
|
||||
Given a JsonMaterializer instance
|
||||
When I call bind on the json materializer
|
||||
Then no error is raised
|
||||
@@ -0,0 +1,416 @@
|
||||
"""Behave step definitions for the RendererRegistry / ElementRenderer
|
||||
architecture (PR #1193).
|
||||
|
||||
Step patterns here are additive — they do not collide with the existing
|
||||
step definitions in ``output_rendering_steps.py``. Two step functions
|
||||
that read the JSON envelope use the master HEAD schema key ``"data"``
|
||||
(post-M4 rename) rather than the legacy key ``"elements"``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.cli.output.handles import ColumnDef
|
||||
from cleveragents.cli.output.materializers import (
|
||||
JsonMaterializer,
|
||||
PlainMaterializer,
|
||||
)
|
||||
from cleveragents.cli.output.selection import TerminalCapabilities
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# RendererRegistry tests
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@given("the default renderer registry")
|
||||
def step_default_registry(context: Context) -> None:
|
||||
from cleveragents.cli.output.registry import default_registry
|
||||
|
||||
context.registry = default_registry
|
||||
|
||||
|
||||
@then('the registry has format "{fmt}" registered')
|
||||
def step_registry_has_format(context: Context, fmt: str) -> None:
|
||||
assert context.registry.is_registered(fmt), f"Format {fmt!r} not registered"
|
||||
|
||||
|
||||
@then('the registry does not have format "{fmt}" registered')
|
||||
def step_registry_no_format(context: Context, fmt: str) -> None:
|
||||
assert not context.registry.is_registered(fmt), f"Format {fmt!r} is registered"
|
||||
|
||||
|
||||
@then('the available formats are "{expected}"')
|
||||
def step_available_formats(context: Context, expected: str) -> None:
|
||||
expected_list = sorted(expected.split(","))
|
||||
actual = context.registry.available_formats()
|
||||
assert actual == expected_list, f"Expected {expected_list}, got {actual}"
|
||||
|
||||
|
||||
@when('I resolve format "{fmt}" from the registry')
|
||||
def step_resolve_format(context: Context, fmt: str) -> None:
|
||||
caps = TerminalCapabilities(
|
||||
is_tty=True,
|
||||
supports_ansi=True,
|
||||
supports_cursor_movement=True,
|
||||
term_program="xterm-256color",
|
||||
)
|
||||
strategy, renderer = context.registry.resolve(fmt, caps)
|
||||
context.resolved_strategy = strategy
|
||||
context.resolved_renderer = renderer
|
||||
|
||||
|
||||
@when('I resolve format "{fmt}" from the registry with terminal caps')
|
||||
def step_resolve_format_with_caps(context: Context, fmt: str) -> None:
|
||||
caps = getattr(context, "terminal_caps", None)
|
||||
if caps is None:
|
||||
caps = TerminalCapabilities(
|
||||
is_tty=True,
|
||||
supports_ansi=True,
|
||||
supports_cursor_movement=True,
|
||||
term_program="xterm-256color",
|
||||
)
|
||||
strategy, renderer = context.registry.resolve(fmt, caps)
|
||||
context.resolved_strategy = strategy
|
||||
context.resolved_renderer = renderer
|
||||
|
||||
|
||||
@then('the resolved renderer format name is "{expected}"')
|
||||
def step_resolved_renderer_name(context: Context, expected: str) -> None:
|
||||
assert context.resolved_renderer.format_name == expected, (
|
||||
f"Expected {expected!r}, got {context.resolved_renderer.format_name!r}"
|
||||
)
|
||||
|
||||
|
||||
@given("a new empty renderer registry")
|
||||
def step_empty_registry(context: Context) -> None:
|
||||
from cleveragents.cli.output.registry import RendererRegistry
|
||||
|
||||
context.registry = RendererRegistry()
|
||||
|
||||
|
||||
@when('I register a custom "{fmt}" format in the registry')
|
||||
def step_register_custom_format(context: Context, fmt: str) -> None:
|
||||
from cleveragents.cli.output.registry import PlainElementRenderer
|
||||
|
||||
context.registry.register(
|
||||
fmt,
|
||||
strategy_factory=lambda caps: PlainMaterializer(),
|
||||
renderer_factory=lambda caps: PlainElementRenderer(),
|
||||
fallback="plain",
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# ElementRenderer tests
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@given("a PlainElementRenderer")
|
||||
def step_plain_renderer(context: Context) -> None:
|
||||
from cleveragents.cli.output.registry import PlainElementRenderer
|
||||
|
||||
context.element_renderer = PlainElementRenderer()
|
||||
|
||||
|
||||
@given("a ColorElementRenderer")
|
||||
def step_color_renderer(context: Context) -> None:
|
||||
from cleveragents.cli.output.registry import ColorElementRenderer
|
||||
|
||||
context.element_renderer = ColorElementRenderer()
|
||||
|
||||
|
||||
@given("a RichElementRenderer")
|
||||
def step_rich_renderer(context: Context) -> None:
|
||||
from cleveragents.cli.output.registry import RichElementRenderer
|
||||
|
||||
context.element_renderer = RichElementRenderer()
|
||||
|
||||
|
||||
@given("a JsonElementRenderer")
|
||||
def step_json_renderer(context: Context) -> None:
|
||||
from cleveragents.cli.output.registry import JsonElementRenderer
|
||||
|
||||
context.element_renderer = JsonElementRenderer()
|
||||
|
||||
|
||||
@given("a YamlElementRenderer")
|
||||
def step_yaml_renderer(context: Context) -> None:
|
||||
from cleveragents.cli.output.registry import YamlElementRenderer
|
||||
|
||||
context.element_renderer = YamlElementRenderer()
|
||||
|
||||
|
||||
@when('I render a panel with title "{title}" through the renderer')
|
||||
def step_render_panel_via_renderer(context: Context, title: str) -> None:
|
||||
from cleveragents.cli.output.handles import Panel
|
||||
|
||||
panel = Panel(title=title)
|
||||
context.renderer_output = context.element_renderer.render_panel(panel)
|
||||
|
||||
|
||||
@then('the renderer output contains "{text}"')
|
||||
def step_renderer_output_contains(context: Context, text: str) -> None:
|
||||
assert text in context.renderer_output, (
|
||||
f"Expected {text!r} in renderer output, got: {context.renderer_output!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the renderer can_render returns True for any terminal")
|
||||
def step_renderer_can_render_true(context: Context) -> None:
|
||||
caps = TerminalCapabilities(
|
||||
is_tty=False,
|
||||
supports_ansi=False,
|
||||
supports_cursor_movement=False,
|
||||
term_program="",
|
||||
)
|
||||
assert context.element_renderer.can_render(caps) is True
|
||||
|
||||
|
||||
@then("the renderer can_render returns False without ANSI")
|
||||
def step_renderer_can_render_no_ansi(context: Context) -> None:
|
||||
caps = TerminalCapabilities(
|
||||
is_tty=True,
|
||||
supports_ansi=False,
|
||||
supports_cursor_movement=False,
|
||||
term_program="dumb",
|
||||
)
|
||||
assert context.element_renderer.can_render(caps) is False
|
||||
|
||||
|
||||
@then("the renderer can_render returns False without cursor movement")
|
||||
def step_renderer_can_render_no_cursor(context: Context) -> None:
|
||||
caps = TerminalCapabilities(
|
||||
is_tty=True,
|
||||
supports_ansi=True,
|
||||
supports_cursor_movement=False,
|
||||
term_program="ansi",
|
||||
)
|
||||
assert context.element_renderer.can_render(caps) is False
|
||||
|
||||
|
||||
@when("I serialize a StructuredOutput through the renderer")
|
||||
def step_serialize_via_renderer(context: Context) -> None:
|
||||
from cleveragents.cli.output.session import StructuredOutput
|
||||
|
||||
output = StructuredOutput(
|
||||
command="test",
|
||||
session_id="ses-test",
|
||||
elements=[],
|
||||
exit_code=0,
|
||||
)
|
||||
context.serialized_output = context.element_renderer.serialize(output)
|
||||
|
||||
|
||||
@then("the serialized output is valid JSON")
|
||||
def step_serialized_json(context: Context) -> None:
|
||||
import json
|
||||
|
||||
json.loads(context.serialized_output)
|
||||
|
||||
|
||||
@then("the serialized output is valid YAML")
|
||||
def step_serialized_yaml(context: Context) -> None:
|
||||
import yaml
|
||||
|
||||
yaml.safe_load(context.serialized_output)
|
||||
|
||||
|
||||
@then("the serialized output has sorted keys")
|
||||
def step_serialized_sorted_keys(context: Context) -> None:
|
||||
import yaml
|
||||
|
||||
data = yaml.safe_load(context.serialized_output)
|
||||
if isinstance(data, dict):
|
||||
keys = list(data.keys())
|
||||
assert keys == sorted(keys), f"Keys not sorted: {keys}"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TerminalCapabilities extended fields
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@when("I create a full TerminalCapabilities")
|
||||
def step_full_caps(context: Context) -> None:
|
||||
context.full_caps = TerminalCapabilities(
|
||||
is_tty=True,
|
||||
width=120,
|
||||
height=40,
|
||||
supports_ansi=True,
|
||||
supports_256_color=True,
|
||||
supports_truecolor=True,
|
||||
supports_unicode=True,
|
||||
supports_cursor_movement=True,
|
||||
supports_alternate_screen=True,
|
||||
no_color=False,
|
||||
term_program="iTerm2",
|
||||
)
|
||||
|
||||
|
||||
@then("the capabilities has width {value:d}")
|
||||
def step_caps_width(context: Context, value: int) -> None:
|
||||
assert context.full_caps.width == value
|
||||
|
||||
|
||||
@then("the capabilities has height {value:d}")
|
||||
def step_caps_height(context: Context, value: int) -> None:
|
||||
assert context.full_caps.height == value
|
||||
|
||||
|
||||
@then("the capabilities has supports_256_color {value}")
|
||||
def step_caps_256color(context: Context, value: str) -> None:
|
||||
expected = value == "True"
|
||||
assert context.full_caps.supports_256_color == expected
|
||||
|
||||
|
||||
@then("the capabilities has supports_truecolor {value}")
|
||||
def step_caps_truecolor(context: Context, value: str) -> None:
|
||||
expected = value == "True"
|
||||
assert context.full_caps.supports_truecolor == expected
|
||||
|
||||
|
||||
@then("the capabilities has supports_unicode {value}")
|
||||
def step_caps_unicode(context: Context, value: str) -> None:
|
||||
expected = value == "True"
|
||||
assert context.full_caps.supports_unicode == expected
|
||||
|
||||
|
||||
@then("the capabilities has supports_alternate_screen {value}")
|
||||
def step_caps_alt_screen(context: Context, value: str) -> None:
|
||||
expected = value == "True"
|
||||
assert context.full_caps.supports_alternate_screen == expected
|
||||
|
||||
|
||||
@then("the capabilities has no_color {value}")
|
||||
def step_caps_no_color(context: Context, value: str) -> None:
|
||||
expected = value == "True"
|
||||
assert context.full_caps.no_color == expected
|
||||
|
||||
|
||||
@then('the capabilities has term_program "{value}"')
|
||||
def step_caps_term_program(context: Context, value: str) -> None:
|
||||
assert context.full_caps.term_program == value
|
||||
|
||||
|
||||
@then("the capabilities supports_cursor property matches supports_cursor_movement")
|
||||
def step_caps_cursor_alias(context: Context) -> None:
|
||||
assert (
|
||||
context.full_caps.supports_cursor == context.full_caps.supports_cursor_movement
|
||||
)
|
||||
|
||||
|
||||
@then("the capabilities term property matches term_program")
|
||||
def step_caps_term_alias(context: Context) -> None:
|
||||
assert context.full_caps.term == (context.full_caps.term_program or "")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# ColumnDef.type alias
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@when('I create a ColumnDef with type alias "{type_val}"')
|
||||
def step_create_coldef_type_alias(context: Context, type_val: str) -> None:
|
||||
context.coldef = ColumnDef(type=type_val, name="test")
|
||||
|
||||
|
||||
@then('the ColumnDef col_type is "{expected}"')
|
||||
def step_coldef_col_type(context: Context, expected: str) -> None:
|
||||
assert context.coldef.col_type == expected
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Progress omitted from JSON — reads master HEAD's "data" envelope key
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@then('the json output does not contain element type "{etype}"')
|
||||
def step_json_no_element_type(context: Context, etype: str) -> None:
|
||||
import json
|
||||
|
||||
output = context.strategy.get_output()
|
||||
data = json.loads(output)
|
||||
for elem in data.get("data", []):
|
||||
assert elem.get("type") != etype, f"Found element type {etype!r} in JSON output"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# MaterializationStrategy.bind()
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@given("a PlainMaterializer instance")
|
||||
def step_plain_mat_instance(context: Context) -> None:
|
||||
context.bind_mat = PlainMaterializer()
|
||||
|
||||
|
||||
@given("a JsonMaterializer instance")
|
||||
def step_json_mat_instance(context: Context) -> None:
|
||||
context.bind_mat = JsonMaterializer()
|
||||
|
||||
|
||||
@when("I call bind on the materializer")
|
||||
def step_call_bind(context: Context) -> None:
|
||||
from cleveragents.cli.output.registry import PlainElementRenderer
|
||||
|
||||
caps = TerminalCapabilities(
|
||||
is_tty=True,
|
||||
supports_ansi=True,
|
||||
supports_cursor_movement=True,
|
||||
term_program="xterm",
|
||||
)
|
||||
context.bind_mat.bind(PlainElementRenderer(), terminal_caps=caps)
|
||||
context.bind_error = None
|
||||
|
||||
|
||||
@when("I call bind on the json materializer")
|
||||
def step_call_bind_json(context: Context) -> None:
|
||||
from cleveragents.cli.output.registry import JsonElementRenderer
|
||||
|
||||
caps = TerminalCapabilities(
|
||||
is_tty=True,
|
||||
supports_ansi=True,
|
||||
supports_cursor_movement=True,
|
||||
term_program="xterm",
|
||||
)
|
||||
context.bind_mat.bind(JsonElementRenderer(), terminal_caps=caps)
|
||||
context.bind_error = None
|
||||
|
||||
|
||||
@then("no error is raised")
|
||||
def step_no_bind_error(context: Context) -> None:
|
||||
assert getattr(context, "bind_error", None) is None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# ColumnDef "type" key assertion — reads master HEAD's "data" envelope key
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@then("the json output ColumnDef uses type key not col_type")
|
||||
def step_json_coldef_type_key(context: Context) -> None:
|
||||
import json
|
||||
|
||||
output = context.strategy.get_output()
|
||||
data = json.loads(output)
|
||||
for elem in data.get("data", []):
|
||||
if elem.get("type") == "table":
|
||||
for col in elem.get("columns", []):
|
||||
assert "type" in col, f"Missing 'type' key in ColumnDef: {col}"
|
||||
assert "col_type" not in col, (
|
||||
f"Found 'col_type' key (should be 'type'): {col}"
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# YAML does not contain assertion
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@then('the yaml output does not contain "{text}"')
|
||||
def step_yaml_not_contains(context: Context, text: str) -> None:
|
||||
output = context.strategy.get_output()
|
||||
assert text not in output, f"Found {text!r} in YAML output: {output[:200]}"
|
||||
@@ -104,16 +104,16 @@ Text Separator And Action Hint Render
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} output-rendering-text-sep-hint-ok
|
||||
|
||||
JSON Serializes All Ten Element Types
|
||||
[Documentation] Verify JSON format serializes all 10 element types
|
||||
JSON Serializes All Element Types
|
||||
[Documentation] Verify JSON format serializes all element types (progress omitted per spec §26936)
|
||||
${result}= Run Process ${PYTHON} ${HELPER} json-all cwd=${WORKSPACE} timeout=120s on_timeout=kill
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} output-rendering-json-all-ok
|
||||
|
||||
YAML Serializes All Ten Element Types
|
||||
[Documentation] Verify YAML format serializes all 10 element types (M5 fix)
|
||||
YAML Serializes All Element Types
|
||||
[Documentation] Verify YAML format serializes all element types (progress omitted per spec §26936)
|
||||
${result}= Run Process ${PYTHON} ${HELPER} yaml-all cwd=${WORKSPACE} timeout=120s on_timeout=kill
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
|
||||
@@ -11,11 +11,11 @@ The following deliberate deviations from the specification (v3_spec.md
|
||||
Each deviation was made for simplicity or because the feature is not
|
||||
yet needed in the current milestone.
|
||||
|
||||
**SD-1 (ElementRenderer / RendererRegistry collapsed)**
|
||||
The spec defines separate ``ElementRenderer`` protocol and
|
||||
``RendererRegistry`` class. This implementation collapses rendering
|
||||
into the strategy classes directly, which is simpler and sufficient
|
||||
for the six built-in formats.
|
||||
**SD-1 (ElementRenderer / RendererRegistry — IMPLEMENTED)**
|
||||
The ``ElementRenderer`` protocol and ``RendererRegistry`` class are
|
||||
now implemented in ``registry.py``. Each format has a paired
|
||||
``(MaterializationStrategy, ElementRenderer)`` registered in the
|
||||
default registry.
|
||||
|
||||
**SD-2 (RichMaterializer uses LiveMaterializationStrategy)**
|
||||
The ``rich`` strategy now uses ``LiveMaterializationStrategy`` for
|
||||
@@ -80,9 +80,13 @@ yet needed in the current milestone.
|
||||
The spec defines ``CLEVERAGENTS_FORMAT`` for overriding the default
|
||||
format. This implementation only accepts ``--format`` CLI flag.
|
||||
|
||||
**SD-16 (TerminalCapabilities partial)**
|
||||
The spec defines 11 capability fields. This implementation exposes
|
||||
4: ``is_tty``, ``supports_ansi``, ``supports_cursor``, ``term``.
|
||||
**SD-16 (TerminalCapabilities — IMPLEMENTED)**
|
||||
The spec defines 11 capability fields. All are now implemented:
|
||||
``is_tty``, ``width``, ``height``, ``supports_ansi``,
|
||||
``supports_256_color``, ``supports_truecolor``, ``supports_unicode``,
|
||||
``supports_cursor_movement``, ``supports_alternate_screen``,
|
||||
``no_color``, ``term_program``. Backward-compatible aliases
|
||||
``supports_cursor`` and ``term`` are available as properties.
|
||||
|
||||
**SD-17 (ElementEvent field differences)**
|
||||
``ElementEvent.element_kind`` is used instead of spec's
|
||||
@@ -93,17 +97,19 @@ yet needed in the current milestone.
|
||||
The spec's ``SessionEnd`` event includes a full session snapshot.
|
||||
This implementation only carries ``exit_code``.
|
||||
|
||||
**SD-19 (MaterializationStrategy.bind() absent)**
|
||||
The spec defines a ``bind()`` method for strategy configuration.
|
||||
This implementation configures strategies via constructor arguments.
|
||||
**SD-19 (MaterializationStrategy.bind() — IMPLEMENTED)**
|
||||
The ``bind(renderer, terminal_caps=...)`` method is now defined on
|
||||
all strategy base classes.
|
||||
|
||||
**SD-20 (on_session_begin missing stream parameter)**
|
||||
The spec's ``on_session_begin`` accepts a ``stream: IO`` parameter.
|
||||
This implementation passes the ``OutputSession`` reference instead.
|
||||
|
||||
**SD-21 (ColumnDef.type renamed to col_type)**
|
||||
``ColumnDef.type`` was renamed to ``col_type`` to avoid shadowing
|
||||
the Python builtin ``type()``.
|
||||
**SD-21 (ColumnDef.type / col_type — UPDATED)**
|
||||
``ColumnDef`` now serialises the column type under the key ``"type"``
|
||||
per the spec. The Python attribute remains ``col_type`` to avoid
|
||||
shadowing the builtin, with ``alias="type"`` for backward-compatible
|
||||
construction and spec-compliant serialisation.
|
||||
|
||||
**SD-22 (Sequential IDs instead of ULIDs)**
|
||||
Session and handle IDs use sequential counters (``ses-000001``,
|
||||
@@ -198,6 +204,18 @@ from cleveragents.cli.output.materializers import (
|
||||
TableMaterializer,
|
||||
YamlMaterializer,
|
||||
)
|
||||
from cleveragents.cli.output.registry import (
|
||||
ColorElementRenderer,
|
||||
ElementRenderer,
|
||||
FormatRegistration,
|
||||
JsonElementRenderer,
|
||||
PlainElementRenderer,
|
||||
RendererRegistry,
|
||||
RichElementRenderer,
|
||||
TableElementRenderer,
|
||||
YamlElementRenderer,
|
||||
default_registry,
|
||||
)
|
||||
from cleveragents.cli.output.selection import (
|
||||
TerminalCapabilities,
|
||||
detect_terminal_capabilities,
|
||||
@@ -213,6 +231,7 @@ __all__ = [
|
||||
"ActionHintHandle",
|
||||
"CodeBlock",
|
||||
"CodeHandle",
|
||||
"ColorElementRenderer",
|
||||
"ColorMaterializer",
|
||||
"ColumnDef",
|
||||
"DiffBlock",
|
||||
@@ -224,8 +243,11 @@ __all__ = [
|
||||
"ElementCreated",
|
||||
"ElementEvent",
|
||||
"ElementHandle",
|
||||
"ElementRenderer",
|
||||
"ElementSnapshot",
|
||||
"ElementUpdated",
|
||||
"FormatRegistration",
|
||||
"JsonElementRenderer",
|
||||
"JsonMaterializer",
|
||||
"LiveMaterializationStrategy",
|
||||
"MaterializationStrategy",
|
||||
@@ -233,10 +255,13 @@ __all__ = [
|
||||
"Panel",
|
||||
"PanelEntry",
|
||||
"PanelHandle",
|
||||
"PlainElementRenderer",
|
||||
"PlainMaterializer",
|
||||
"ProgressHandle",
|
||||
"ProgressIndicator",
|
||||
"ProgressStep",
|
||||
"RendererRegistry",
|
||||
"RichElementRenderer",
|
||||
"RichMaterializer",
|
||||
"Separator",
|
||||
"SeparatorHandle",
|
||||
@@ -245,6 +270,7 @@ __all__ = [
|
||||
"StatusMessage",
|
||||
"StructuredOutput",
|
||||
"Table",
|
||||
"TableElementRenderer",
|
||||
"TableHandle",
|
||||
"TableMaterializer",
|
||||
"TerminalCapabilities",
|
||||
@@ -253,7 +279,9 @@ __all__ = [
|
||||
"Tree",
|
||||
"TreeHandle",
|
||||
"TreeNode",
|
||||
"YamlElementRenderer",
|
||||
"YamlMaterializer",
|
||||
"default_registry",
|
||||
"detect_terminal_capabilities",
|
||||
"select_materializer",
|
||||
"strip_terminal_escapes",
|
||||
|
||||
@@ -65,10 +65,20 @@ class Panel(BaseModel):
|
||||
|
||||
|
||||
class ColumnDef(BaseModel):
|
||||
"""Schema for a single table column."""
|
||||
"""Schema for a single table column.
|
||||
|
||||
Per the specification (§26199) the column type field is named
|
||||
``type``. The Python attribute is ``col_type`` to avoid shadowing
|
||||
the Python builtin ``type()``, with ``serialization_alias="type"``
|
||||
ensuring the serialised key matches the spec. Construction via
|
||||
both ``ColumnDef(col_type="number")`` (explicit) and
|
||||
``ColumnDef(type="number")`` (alias) is supported.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
name: str
|
||||
col_type: str = "string"
|
||||
col_type: str = Field(default="string", alias="type")
|
||||
alignment: str = "left"
|
||||
width_hint: int | None = None
|
||||
sortable: bool = False
|
||||
|
||||
@@ -90,6 +90,13 @@ class MaterializationStrategy(Protocol):
|
||||
|
||||
def on_session_end(self, event: SessionEnd) -> None: ... # pragma: no cover
|
||||
|
||||
def bind(
|
||||
self,
|
||||
renderer: Any,
|
||||
*,
|
||||
terminal_caps: Any | None = None,
|
||||
) -> None: ... # pragma: no cover
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JSON / YAML serialisation helpers
|
||||
@@ -250,10 +257,13 @@ def _column_def_to_dict(col: ColumnDef) -> dict[str, Any]:
|
||||
P2-4 fix (Luis review): always include all fields unconditionally
|
||||
so that machine consumers get a stable set of keys per ColumnDef,
|
||||
matching the spec's unconditional field listing.
|
||||
|
||||
The column type is serialised under the key ``"type"`` per the
|
||||
spec (§26199).
|
||||
"""
|
||||
return {
|
||||
"name": col.name,
|
||||
"col_type": col.col_type,
|
||||
"type": col.col_type,
|
||||
"alignment": col.alignment,
|
||||
"width_hint": col.width_hint,
|
||||
"sortable": col.sortable,
|
||||
@@ -279,12 +289,20 @@ def _snapshot_to_dict(snapshot: StructuredOutput) -> dict[str, Any]:
|
||||
|
||||
M4 fix: renamed ``elements`` → ``data``, ``metadata`` → ``messages``,
|
||||
and added ``status`` field to match the specification envelope.
|
||||
|
||||
Progress elements are omitted from JSON/YAML output per spec §26936
|
||||
(``"Progress: Not rendered (JSON output is non-interactive; progress
|
||||
is omitted)"``).
|
||||
"""
|
||||
result: dict[str, Any] = {
|
||||
"command": snapshot.command,
|
||||
"status": snapshot.status,
|
||||
"exit_code": snapshot.exit_code,
|
||||
"data": [_element_to_dict(e) for e in snapshot.elements],
|
||||
"data": [
|
||||
_element_to_dict(e)
|
||||
for e in snapshot.elements
|
||||
if not isinstance(e, ProgressIndicator)
|
||||
],
|
||||
"timing": snapshot.timing,
|
||||
"messages": snapshot.metadata,
|
||||
}
|
||||
@@ -323,6 +341,18 @@ class _BaseBufferStrategy:
|
||||
self._next_render_index: int = 0
|
||||
self._index_map: dict[str, int] = {} # handle_id -> declaration_index
|
||||
self._buf_lock: threading.Lock = threading.Lock()
|
||||
self._renderer: Any | None = None
|
||||
self._terminal_caps: Any | None = None
|
||||
|
||||
def bind(
|
||||
self,
|
||||
renderer: Any,
|
||||
*,
|
||||
terminal_caps: Any | None = None,
|
||||
) -> None:
|
||||
"""Bind an ElementRenderer and terminal capabilities to this strategy."""
|
||||
self._renderer = renderer
|
||||
self._terminal_caps = terminal_caps
|
||||
|
||||
def on_session_begin(self, session: OutputSession) -> None:
|
||||
self._session = session
|
||||
@@ -463,6 +493,18 @@ class _LiveMaterializationStrategy:
|
||||
self._lock: threading.Lock = threading.Lock()
|
||||
# Track whether session has ended (no more frame redraws)
|
||||
self._ended: bool = False
|
||||
self._renderer: Any | None = None
|
||||
self._terminal_caps: Any | None = None
|
||||
|
||||
def bind(
|
||||
self,
|
||||
renderer: Any,
|
||||
*,
|
||||
terminal_caps: Any | None = None,
|
||||
) -> None:
|
||||
"""Bind an ElementRenderer and terminal capabilities to this strategy."""
|
||||
self._renderer = renderer
|
||||
self._terminal_caps = terminal_caps
|
||||
|
||||
@property
|
||||
def _frame_interval(self) -> float:
|
||||
@@ -599,6 +641,18 @@ class _AccumulateStrategy:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._session: OutputSession | None = None
|
||||
self._renderer: Any | None = None
|
||||
self._terminal_caps: Any | None = None
|
||||
|
||||
def bind(
|
||||
self,
|
||||
renderer: Any,
|
||||
*,
|
||||
terminal_caps: Any | None = None,
|
||||
) -> None:
|
||||
"""Bind an ElementRenderer and terminal capabilities to this strategy."""
|
||||
self._renderer = renderer
|
||||
self._terminal_caps = terminal_caps
|
||||
|
||||
def on_session_begin(self, session: OutputSession) -> None:
|
||||
self._session = session
|
||||
@@ -662,7 +716,7 @@ class YamlMaterializer(_AccumulateStrategy):
|
||||
return yaml.safe_dump(
|
||||
_snapshot_to_dict(snapshot),
|
||||
default_flow_style=False,
|
||||
sort_keys=False,
|
||||
sort_keys=True,
|
||||
allow_unicode=True,
|
||||
).rstrip("\n")
|
||||
|
||||
@@ -670,7 +724,7 @@ class YamlMaterializer(_AccumulateStrategy):
|
||||
return yaml.safe_dump(
|
||||
data,
|
||||
default_flow_style=False,
|
||||
sort_keys=False,
|
||||
sort_keys=True,
|
||||
allow_unicode=True,
|
||||
).rstrip("\n")
|
||||
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
"""RendererRegistry and ElementRenderer protocol for the output framework.
|
||||
|
||||
Implements the RendererRegistry class (spec §27249-27350) and the
|
||||
ElementRenderer protocol (spec §26557-26654). Each format is registered
|
||||
as a ``(MaterializationStrategy, ElementRenderer)`` pair — the strategy
|
||||
controls *when* elements are rendered and the renderer controls *how*
|
||||
each element type is visually formatted.
|
||||
|
||||
Based on the Output Rendering Framework specification (v3_spec.md §Output
|
||||
Rendering Framework / Renderer Registration and Extension).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from io import StringIO
|
||||
from typing import TYPE_CHECKING, Any, Protocol
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from cleveragents.cli.output.handles import (
|
||||
ActionHint,
|
||||
CodeBlock,
|
||||
DiffBlock,
|
||||
ElementSnapshot,
|
||||
Panel,
|
||||
ProgressIndicator,
|
||||
Separator,
|
||||
StatusMessage,
|
||||
Table,
|
||||
TextBlock,
|
||||
Tree,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cleveragents.cli.output.selection import TerminalCapabilities
|
||||
from cleveragents.cli.output.session import StructuredOutput
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ElementRenderer protocol (spec §26557-26654)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ElementRenderer(Protocol):
|
||||
"""Interface for format-specific element rendering.
|
||||
|
||||
An ElementRenderer knows how to paint each element type for a specific
|
||||
output format. It is called by the MaterializationStrategy when it is
|
||||
time to render an element.
|
||||
|
||||
Implementations:
|
||||
- PlainElementRenderer: ASCII text, no escapes
|
||||
- ColorElementRenderer: ANSI-colored text, same layout as plain
|
||||
- TableElementRenderer: Unicode box-drawing with color
|
||||
- RichElementRenderer: Advanced terminal features (cursor, animation)
|
||||
- JsonElementRenderer: JSON serialization
|
||||
- YamlElementRenderer: YAML serialization
|
||||
"""
|
||||
|
||||
format_name: str
|
||||
|
||||
def render_panel(self, panel: Panel) -> str: ... # pragma: no cover
|
||||
|
||||
def render_table(self, table: Table) -> str: ... # pragma: no cover
|
||||
|
||||
def render_tree(self, tree: Tree) -> str: ... # pragma: no cover
|
||||
|
||||
def render_status(self, status: StatusMessage) -> str: ... # pragma: no cover
|
||||
|
||||
def render_progress(
|
||||
self, progress: ProgressIndicator
|
||||
) -> str: ... # pragma: no cover
|
||||
|
||||
def render_code(self, code: CodeBlock) -> str: ... # pragma: no cover
|
||||
|
||||
def render_diff(self, diff: DiffBlock) -> str: ... # pragma: no cover
|
||||
|
||||
def render_text(self, text: TextBlock) -> str: ... # pragma: no cover
|
||||
|
||||
def render_separator(self, separator: Separator) -> str: ... # pragma: no cover
|
||||
|
||||
def render_action_hint(self, hint: ActionHint) -> str: ... # pragma: no cover
|
||||
|
||||
def render_element(self, element: ElementSnapshot) -> str: ... # pragma: no cover
|
||||
|
||||
def serialize(self, output: StructuredOutput) -> str: ... # pragma: no cover
|
||||
|
||||
def can_render(
|
||||
self, terminal_caps: TerminalCapabilities
|
||||
) -> bool: ... # pragma: no cover
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Concrete ElementRenderer implementations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _BaseElementRenderer:
|
||||
"""Shared dispatch logic for element renderers."""
|
||||
|
||||
format_name: str = "base"
|
||||
|
||||
def render_panel(self, panel: Panel) -> str:
|
||||
return "" # pragma: no cover
|
||||
|
||||
def render_table(self, table: Table) -> str:
|
||||
return "" # pragma: no cover
|
||||
|
||||
def render_tree(self, tree: Tree) -> str:
|
||||
return "" # pragma: no cover
|
||||
|
||||
def render_status(self, status: StatusMessage) -> str:
|
||||
return "" # pragma: no cover
|
||||
|
||||
def render_progress(self, progress: ProgressIndicator) -> str:
|
||||
return "" # pragma: no cover
|
||||
|
||||
def render_code(self, code: CodeBlock) -> str:
|
||||
return "" # pragma: no cover
|
||||
|
||||
def render_diff(self, diff: DiffBlock) -> str:
|
||||
return "" # pragma: no cover
|
||||
|
||||
def render_text(self, text: TextBlock) -> str:
|
||||
return "" # pragma: no cover
|
||||
|
||||
def render_separator(self, separator: Separator) -> str:
|
||||
return "" # pragma: no cover
|
||||
|
||||
def render_action_hint(self, hint: ActionHint) -> str:
|
||||
return "" # pragma: no cover
|
||||
|
||||
def render_element(self, element: ElementSnapshot) -> str:
|
||||
"""Dispatch to the appropriate render method based on element type."""
|
||||
dispatch: dict[type, Callable[..., str]] = {
|
||||
Panel: self.render_panel,
|
||||
Table: self.render_table,
|
||||
Tree: self.render_tree,
|
||||
StatusMessage: self.render_status,
|
||||
ProgressIndicator: self.render_progress,
|
||||
CodeBlock: self.render_code,
|
||||
DiffBlock: self.render_diff,
|
||||
TextBlock: self.render_text,
|
||||
Separator: self.render_separator,
|
||||
ActionHint: self.render_action_hint,
|
||||
}
|
||||
handler = dispatch.get(type(element))
|
||||
if handler:
|
||||
return handler(element)
|
||||
return str(element) # pragma: no cover
|
||||
|
||||
def serialize(self, output: StructuredOutput) -> str:
|
||||
"""Serialize a complete StructuredOutput by rendering each element."""
|
||||
buf = StringIO()
|
||||
for element in output.elements:
|
||||
rendered = self.render_element(element)
|
||||
if rendered:
|
||||
buf.write(rendered)
|
||||
buf.write("\n\n")
|
||||
return buf.getvalue().rstrip("\n")
|
||||
|
||||
def can_render(self, terminal_caps: TerminalCapabilities) -> bool:
|
||||
"""Whether this renderer can operate in the given terminal."""
|
||||
return True # pragma: no cover
|
||||
|
||||
|
||||
class PlainElementRenderer(_BaseElementRenderer):
|
||||
"""Renders all elements as plain ASCII text."""
|
||||
|
||||
format_name: str = "plain"
|
||||
|
||||
def render_panel(self, panel: Panel) -> str:
|
||||
from cleveragents.cli.output._renderers import render_element_plain
|
||||
|
||||
return render_element_plain(panel)
|
||||
|
||||
def render_table(self, table: Table) -> str:
|
||||
from cleveragents.cli.output._renderers import render_element_plain
|
||||
|
||||
return render_element_plain(table)
|
||||
|
||||
def render_tree(self, tree: Tree) -> str:
|
||||
from cleveragents.cli.output._renderers import render_element_plain
|
||||
|
||||
return render_element_plain(tree)
|
||||
|
||||
def render_status(self, status: StatusMessage) -> str:
|
||||
from cleveragents.cli.output._renderers import render_element_plain
|
||||
|
||||
return render_element_plain(status)
|
||||
|
||||
def render_progress(self, progress: ProgressIndicator) -> str:
|
||||
from cleveragents.cli.output._renderers import render_element_plain
|
||||
|
||||
return render_element_plain(progress)
|
||||
|
||||
def render_code(self, code: CodeBlock) -> str:
|
||||
from cleveragents.cli.output._renderers import render_element_plain
|
||||
|
||||
return render_element_plain(code)
|
||||
|
||||
def render_diff(self, diff: DiffBlock) -> str:
|
||||
from cleveragents.cli.output._renderers import render_element_plain
|
||||
|
||||
return render_element_plain(diff)
|
||||
|
||||
def render_text(self, text: TextBlock) -> str:
|
||||
from cleveragents.cli.output._renderers import render_element_plain
|
||||
|
||||
return render_element_plain(text)
|
||||
|
||||
def render_separator(self, separator: Separator) -> str:
|
||||
from cleveragents.cli.output._renderers import render_element_plain
|
||||
|
||||
return render_element_plain(separator)
|
||||
|
||||
def render_action_hint(self, hint: ActionHint) -> str:
|
||||
from cleveragents.cli.output._renderers import render_element_plain
|
||||
|
||||
return render_element_plain(hint)
|
||||
|
||||
def can_render(self, terminal_caps: TerminalCapabilities) -> bool:
|
||||
return True # Plain always works
|
||||
|
||||
|
||||
class ColorElementRenderer(_BaseElementRenderer):
|
||||
"""Renders all elements with ANSI colour codes."""
|
||||
|
||||
format_name: str = "color"
|
||||
|
||||
def render_panel(self, panel: Panel) -> str:
|
||||
from cleveragents.cli.output._color_renderers import render_element_color
|
||||
|
||||
return render_element_color(panel)
|
||||
|
||||
def render_table(self, table: Table) -> str:
|
||||
from cleveragents.cli.output._color_renderers import render_element_color
|
||||
|
||||
return render_element_color(table)
|
||||
|
||||
def render_tree(self, tree: Tree) -> str:
|
||||
from cleveragents.cli.output._color_renderers import render_element_color
|
||||
|
||||
return render_element_color(tree)
|
||||
|
||||
def render_status(self, status: StatusMessage) -> str:
|
||||
from cleveragents.cli.output._color_renderers import render_element_color
|
||||
|
||||
return render_element_color(status)
|
||||
|
||||
def render_progress(self, progress: ProgressIndicator) -> str:
|
||||
from cleveragents.cli.output._color_renderers import render_element_color
|
||||
|
||||
return render_element_color(progress)
|
||||
|
||||
def render_code(self, code: CodeBlock) -> str:
|
||||
from cleveragents.cli.output._color_renderers import render_element_color
|
||||
|
||||
return render_element_color(code)
|
||||
|
||||
def render_diff(self, diff: DiffBlock) -> str:
|
||||
from cleveragents.cli.output._color_renderers import render_element_color
|
||||
|
||||
return render_element_color(diff)
|
||||
|
||||
def render_text(self, text: TextBlock) -> str:
|
||||
from cleveragents.cli.output._color_renderers import render_element_color
|
||||
|
||||
return render_element_color(text)
|
||||
|
||||
def render_separator(self, separator: Separator) -> str:
|
||||
from cleveragents.cli.output._color_renderers import render_element_color
|
||||
|
||||
return render_element_color(separator)
|
||||
|
||||
def render_action_hint(self, hint: ActionHint) -> str:
|
||||
from cleveragents.cli.output._color_renderers import render_element_color
|
||||
|
||||
return render_element_color(hint)
|
||||
|
||||
def can_render(self, terminal_caps: TerminalCapabilities) -> bool:
|
||||
return terminal_caps.supports_ansi
|
||||
|
||||
|
||||
class TableElementRenderer(_BaseElementRenderer):
|
||||
"""Renders tables with Unicode box-drawing, other elements with colour."""
|
||||
|
||||
format_name: str = "table"
|
||||
|
||||
def render_panel(self, panel: Panel) -> str:
|
||||
from cleveragents.cli.output._boxdraw import render_element_table
|
||||
|
||||
return render_element_table(panel)
|
||||
|
||||
def render_table(self, table: Table) -> str:
|
||||
from cleveragents.cli.output._boxdraw import render_element_table
|
||||
|
||||
return render_element_table(table)
|
||||
|
||||
def render_tree(self, tree: Tree) -> str:
|
||||
from cleveragents.cli.output._boxdraw import render_element_table
|
||||
|
||||
return render_element_table(tree)
|
||||
|
||||
def render_status(self, status: StatusMessage) -> str:
|
||||
from cleveragents.cli.output._boxdraw import render_element_table
|
||||
|
||||
return render_element_table(status)
|
||||
|
||||
def render_progress(self, progress: ProgressIndicator) -> str:
|
||||
from cleveragents.cli.output._boxdraw import render_element_table
|
||||
|
||||
return render_element_table(progress)
|
||||
|
||||
def render_code(self, code: CodeBlock) -> str:
|
||||
from cleveragents.cli.output._boxdraw import render_element_table
|
||||
|
||||
return render_element_table(code)
|
||||
|
||||
def render_diff(self, diff: DiffBlock) -> str:
|
||||
from cleveragents.cli.output._boxdraw import render_element_table
|
||||
|
||||
return render_element_table(diff)
|
||||
|
||||
def render_text(self, text: TextBlock) -> str:
|
||||
from cleveragents.cli.output._boxdraw import render_element_table
|
||||
|
||||
return render_element_table(text)
|
||||
|
||||
def render_separator(self, separator: Separator) -> str:
|
||||
from cleveragents.cli.output._boxdraw import render_element_table
|
||||
|
||||
return render_element_table(separator)
|
||||
|
||||
def render_action_hint(self, hint: ActionHint) -> str:
|
||||
from cleveragents.cli.output._boxdraw import render_element_table
|
||||
|
||||
return render_element_table(hint)
|
||||
|
||||
def can_render(self, terminal_caps: TerminalCapabilities) -> bool:
|
||||
return terminal_caps.is_tty
|
||||
|
||||
|
||||
class RichElementRenderer(_BaseElementRenderer):
|
||||
"""Advanced terminal renderer (delegates to colour for now — SD-2)."""
|
||||
|
||||
format_name: str = "rich"
|
||||
|
||||
def render_panel(self, panel: Panel) -> str:
|
||||
from cleveragents.cli.output._color_renderers import render_element_color
|
||||
|
||||
return render_element_color(panel)
|
||||
|
||||
def render_table(self, table: Table) -> str:
|
||||
from cleveragents.cli.output._color_renderers import render_element_color
|
||||
|
||||
return render_element_color(table)
|
||||
|
||||
def render_tree(self, tree: Tree) -> str:
|
||||
from cleveragents.cli.output._color_renderers import render_element_color
|
||||
|
||||
return render_element_color(tree)
|
||||
|
||||
def render_status(self, status: StatusMessage) -> str:
|
||||
from cleveragents.cli.output._color_renderers import render_element_color
|
||||
|
||||
return render_element_color(status)
|
||||
|
||||
def render_progress(self, progress: ProgressIndicator) -> str:
|
||||
from cleveragents.cli.output._color_renderers import render_element_color
|
||||
|
||||
return render_element_color(progress)
|
||||
|
||||
def render_code(self, code: CodeBlock) -> str:
|
||||
from cleveragents.cli.output._color_renderers import render_element_color
|
||||
|
||||
return render_element_color(code)
|
||||
|
||||
def render_diff(self, diff: DiffBlock) -> str:
|
||||
from cleveragents.cli.output._color_renderers import render_element_color
|
||||
|
||||
return render_element_color(diff)
|
||||
|
||||
def render_text(self, text: TextBlock) -> str:
|
||||
from cleveragents.cli.output._color_renderers import render_element_color
|
||||
|
||||
return render_element_color(text)
|
||||
|
||||
def render_separator(self, separator: Separator) -> str:
|
||||
from cleveragents.cli.output._color_renderers import render_element_color
|
||||
|
||||
return render_element_color(separator)
|
||||
|
||||
def render_action_hint(self, hint: ActionHint) -> str:
|
||||
from cleveragents.cli.output._color_renderers import render_element_color
|
||||
|
||||
return render_element_color(hint)
|
||||
|
||||
def can_render(self, terminal_caps: TerminalCapabilities) -> bool:
|
||||
return terminal_caps.supports_cursor_movement
|
||||
|
||||
|
||||
class JsonElementRenderer(_BaseElementRenderer):
|
||||
"""JSON serialization renderer."""
|
||||
|
||||
format_name: str = "json"
|
||||
|
||||
def render_panel(self, panel: Panel) -> str:
|
||||
return "" # JSON renders via serialize()
|
||||
|
||||
def render_table(self, table: Table) -> str:
|
||||
return ""
|
||||
|
||||
def render_tree(self, tree: Tree) -> str:
|
||||
return ""
|
||||
|
||||
def render_status(self, status: StatusMessage) -> str:
|
||||
return ""
|
||||
|
||||
def render_progress(self, progress: ProgressIndicator) -> str:
|
||||
return ""
|
||||
|
||||
def render_code(self, code: CodeBlock) -> str:
|
||||
return ""
|
||||
|
||||
def render_diff(self, diff: DiffBlock) -> str:
|
||||
return ""
|
||||
|
||||
def render_text(self, text: TextBlock) -> str:
|
||||
return ""
|
||||
|
||||
def render_separator(self, separator: Separator) -> str:
|
||||
return ""
|
||||
|
||||
def render_action_hint(self, hint: ActionHint) -> str:
|
||||
return ""
|
||||
|
||||
def serialize(self, output: StructuredOutput) -> str:
|
||||
import json
|
||||
|
||||
from cleveragents.cli.output.materializers import _snapshot_to_dict
|
||||
|
||||
return json.dumps(_snapshot_to_dict(output), indent=2, default=str)
|
||||
|
||||
def can_render(self, terminal_caps: TerminalCapabilities) -> bool:
|
||||
return True # JSON works everywhere
|
||||
|
||||
|
||||
class YamlElementRenderer(_BaseElementRenderer):
|
||||
"""YAML serialization renderer."""
|
||||
|
||||
format_name: str = "yaml"
|
||||
|
||||
def render_panel(self, panel: Panel) -> str:
|
||||
return "" # YAML renders via serialize()
|
||||
|
||||
def render_table(self, table: Table) -> str:
|
||||
return ""
|
||||
|
||||
def render_tree(self, tree: Tree) -> str:
|
||||
return ""
|
||||
|
||||
def render_status(self, status: StatusMessage) -> str:
|
||||
return ""
|
||||
|
||||
def render_progress(self, progress: ProgressIndicator) -> str:
|
||||
return ""
|
||||
|
||||
def render_code(self, code: CodeBlock) -> str:
|
||||
return ""
|
||||
|
||||
def render_diff(self, diff: DiffBlock) -> str:
|
||||
return ""
|
||||
|
||||
def render_text(self, text: TextBlock) -> str:
|
||||
return ""
|
||||
|
||||
def render_separator(self, separator: Separator) -> str:
|
||||
return ""
|
||||
|
||||
def render_action_hint(self, hint: ActionHint) -> str:
|
||||
return ""
|
||||
|
||||
def serialize(self, output: StructuredOutput) -> str:
|
||||
import yaml
|
||||
|
||||
from cleveragents.cli.output.materializers import _snapshot_to_dict
|
||||
|
||||
return yaml.safe_dump(
|
||||
_snapshot_to_dict(output),
|
||||
default_flow_style=False,
|
||||
sort_keys=True,
|
||||
allow_unicode=True,
|
||||
).rstrip("\n")
|
||||
|
||||
def can_render(self, terminal_caps: TerminalCapabilities) -> bool:
|
||||
return True # YAML works everywhere
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FormatRegistration and RendererRegistry (spec §27249-27350)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class FormatRegistration(BaseModel):
|
||||
"""A registered format: its strategy factory, renderer factory, and fallback.
|
||||
|
||||
Uses Pydantic ``BaseModel`` rather than ``dataclasses.dataclass``
|
||||
to satisfy the project's architectural constraint that all structured
|
||||
data classes inherit from ``BaseModel``.
|
||||
"""
|
||||
|
||||
model_config = {"arbitrary_types_allowed": True}
|
||||
|
||||
strategy_factory: Callable[..., Any]
|
||||
renderer_factory: Callable[..., Any]
|
||||
fallback: str | None
|
||||
|
||||
|
||||
class RendererRegistry:
|
||||
"""Central registry for format (strategy, renderer) pairs.
|
||||
|
||||
Formats are registered by name and resolved at runtime based on the
|
||||
active format and terminal capabilities. The registry supports dynamic
|
||||
registration, enabling plugins to add custom formats (e.g., 'html',
|
||||
'csv', 'markdown').
|
||||
|
||||
Built-in registrations:
|
||||
"rich" → (RichMaterializer, RichElementRenderer), fallback="table"
|
||||
"table" → (TableMaterializer, TableElementRenderer), fallback="color"
|
||||
"color" → (ColorMaterializer, ColorElementRenderer), fallback="plain"
|
||||
"plain" → (PlainMaterializer, PlainElementRenderer), fallback=None
|
||||
"json" → (JsonMaterializer, JsonElementRenderer), fallback=None
|
||||
"yaml" → (YamlMaterializer, YamlElementRenderer), fallback=None
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._formats: dict[str, FormatRegistration] = {}
|
||||
|
||||
def register(
|
||||
self,
|
||||
format_name: str,
|
||||
strategy_factory: Callable[..., Any],
|
||||
renderer_factory: Callable[..., ElementRenderer],
|
||||
fallback: str | None = None,
|
||||
) -> None:
|
||||
"""Register a format.
|
||||
|
||||
Args:
|
||||
format_name: The format identifier (e.g., 'rich', 'json').
|
||||
strategy_factory: Callable that creates a MaterializationStrategy,
|
||||
given terminal capabilities.
|
||||
renderer_factory: Callable that creates an ElementRenderer,
|
||||
given terminal capabilities.
|
||||
fallback: Optional fallback format name if this format cannot
|
||||
operate in the current terminal environment.
|
||||
"""
|
||||
self._formats[format_name] = FormatRegistration(
|
||||
strategy_factory=strategy_factory,
|
||||
renderer_factory=renderer_factory,
|
||||
fallback=fallback,
|
||||
)
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
format_name: str,
|
||||
terminal_caps: TerminalCapabilities,
|
||||
) -> tuple[Any, ElementRenderer]:
|
||||
"""Resolve the best (strategy, renderer) pair for the given format.
|
||||
|
||||
Walks the fallback chain if the requested format's renderer cannot
|
||||
operate in the current terminal environment. Returns the first pair
|
||||
where the renderer reports ``can_render(terminal_caps) == True``.
|
||||
|
||||
Raises:
|
||||
ValueError: If no usable format is found (should never happen
|
||||
since 'plain' has no fallback and always works).
|
||||
"""
|
||||
current: str | None = format_name
|
||||
visited: set[str] = set()
|
||||
|
||||
while current and current not in visited:
|
||||
visited.add(current)
|
||||
registration = self._formats.get(current)
|
||||
if registration is None:
|
||||
break
|
||||
|
||||
renderer = registration.renderer_factory(terminal_caps)
|
||||
if renderer.can_render(terminal_caps):
|
||||
strategy = registration.strategy_factory(terminal_caps)
|
||||
strategy.bind(renderer, terminal_caps=terminal_caps)
|
||||
return strategy, renderer
|
||||
|
||||
current = registration.fallback
|
||||
|
||||
# Ultimate fallback is always plain
|
||||
if "plain" in self._formats:
|
||||
plain = self._formats["plain"]
|
||||
renderer = plain.renderer_factory(terminal_caps)
|
||||
strategy = plain.strategy_factory(terminal_caps)
|
||||
strategy.bind(renderer, terminal_caps=terminal_caps)
|
||||
return strategy, renderer
|
||||
|
||||
raise ValueError(
|
||||
f"No usable format found for {format_name!r} "
|
||||
f"and no 'plain' fallback registered"
|
||||
)
|
||||
|
||||
def available_formats(self) -> list[str]:
|
||||
"""Return all registered format names."""
|
||||
return sorted(self._formats.keys())
|
||||
|
||||
def is_registered(self, format_name: str) -> bool:
|
||||
"""Check if a format is registered."""
|
||||
return format_name in self._formats
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default registry with built-in formats
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _create_default_registry() -> RendererRegistry:
|
||||
"""Create a RendererRegistry with all built-in formats registered."""
|
||||
from cleveragents.cli.output.materializers import (
|
||||
ColorMaterializer,
|
||||
JsonMaterializer,
|
||||
PlainMaterializer,
|
||||
RichMaterializer,
|
||||
TableMaterializer,
|
||||
YamlMaterializer,
|
||||
)
|
||||
|
||||
registry = RendererRegistry()
|
||||
|
||||
registry.register(
|
||||
"plain",
|
||||
strategy_factory=lambda caps: PlainMaterializer(),
|
||||
renderer_factory=lambda caps: PlainElementRenderer(),
|
||||
fallback=None,
|
||||
)
|
||||
registry.register(
|
||||
"color",
|
||||
strategy_factory=lambda caps: ColorMaterializer(),
|
||||
renderer_factory=lambda caps: ColorElementRenderer(),
|
||||
fallback="plain",
|
||||
)
|
||||
registry.register(
|
||||
"table",
|
||||
strategy_factory=lambda caps: TableMaterializer(),
|
||||
renderer_factory=lambda caps: TableElementRenderer(),
|
||||
fallback="color",
|
||||
)
|
||||
registry.register(
|
||||
"rich",
|
||||
strategy_factory=lambda caps: RichMaterializer(),
|
||||
renderer_factory=lambda caps: RichElementRenderer(),
|
||||
fallback="table",
|
||||
)
|
||||
registry.register(
|
||||
"json",
|
||||
strategy_factory=lambda caps: JsonMaterializer(),
|
||||
renderer_factory=lambda caps: JsonElementRenderer(),
|
||||
fallback=None,
|
||||
)
|
||||
registry.register(
|
||||
"yaml",
|
||||
strategy_factory=lambda caps: YamlMaterializer(),
|
||||
renderer_factory=lambda caps: YamlElementRenderer(),
|
||||
fallback=None,
|
||||
)
|
||||
|
||||
return registry
|
||||
|
||||
|
||||
#: The default global registry instance with built-in formats.
|
||||
default_registry: RendererRegistry = _create_default_registry()
|
||||
@@ -27,44 +27,116 @@ from cleveragents.cli.output.materializers import (
|
||||
|
||||
|
||||
class TerminalCapabilities(BaseModel):
|
||||
"""Detected terminal capabilities used for format fallback decisions."""
|
||||
"""Detected terminal capabilities used for format fallback decisions.
|
||||
|
||||
Matches the spec's 11 fields (v3_spec.md §27264-27301). Previous
|
||||
implementation had only 4 fields (SD-16); this version adds the
|
||||
7 missing fields: ``width``, ``height``, ``supports_256_color``,
|
||||
``supports_truecolor``, ``supports_unicode``,
|
||||
``supports_alternate_screen``, ``no_color``.
|
||||
|
||||
Field renames from earlier implementation:
|
||||
- ``supports_cursor`` → ``supports_cursor_movement``
|
||||
- ``term`` → ``term_program``
|
||||
"""
|
||||
|
||||
is_tty: bool
|
||||
width: int = 80
|
||||
height: int = 24
|
||||
supports_ansi: bool
|
||||
supports_cursor: bool
|
||||
term: str
|
||||
supports_256_color: bool = False
|
||||
supports_truecolor: bool = False
|
||||
supports_unicode: bool = True
|
||||
supports_cursor_movement: bool = False
|
||||
supports_alternate_screen: bool = False
|
||||
no_color: bool = False
|
||||
term_program: str | None = None
|
||||
|
||||
@property
|
||||
def supports_cursor(self) -> bool:
|
||||
"""Backward-compatible alias for ``supports_cursor_movement``."""
|
||||
return self.supports_cursor_movement
|
||||
|
||||
@property
|
||||
def term(self) -> str:
|
||||
"""Backward-compatible alias for ``term_program``."""
|
||||
return self.term_program or ""
|
||||
|
||||
|
||||
def detect_terminal_capabilities() -> TerminalCapabilities:
|
||||
"""Probe the current terminal for capability flags.
|
||||
|
||||
Returns a ``TerminalCapabilities`` with booleans describing the
|
||||
connected stdout:
|
||||
Returns a ``TerminalCapabilities`` with the full set of spec fields:
|
||||
|
||||
* ``is_tty`` - ``sys.stdout.isatty()``
|
||||
* ``supports_ansi`` - True when ``$TERM`` is not ``dumb``
|
||||
* ``supports_cursor`` - True when TTY *and* non-dumb terminal
|
||||
* ``term`` - the raw ``$TERM`` value (empty if unset)
|
||||
* ``is_tty`` - ``sys.stdout.isatty()``
|
||||
* ``width`` / ``height`` - ``os.get_terminal_size()`` (fallback 80x24)
|
||||
* ``supports_ansi`` - True when ``$TERM`` is not ``dumb``
|
||||
* ``supports_256_color`` - True when TERM contains "256color"
|
||||
or COLORTERM is set
|
||||
* ``supports_truecolor`` - True when COLORTERM is "truecolor"
|
||||
or "24bit"
|
||||
* ``supports_unicode`` - True when locale encoding is UTF-8
|
||||
* ``supports_cursor_movement`` - True when TTY and cursor-capable $TERM
|
||||
* ``supports_alternate_screen`` - True when cursor movement supported
|
||||
* ``no_color`` - True when NO_COLOR env var is set
|
||||
* ``term_program`` - Value of TERM_PROGRAM env var
|
||||
"""
|
||||
import locale
|
||||
|
||||
is_tty = hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
|
||||
term = os.environ.get("TERM", "")
|
||||
colorterm = os.environ.get("COLORTERM", "")
|
||||
supports_ansi = is_tty and term.lower() != "dumb"
|
||||
|
||||
# Terminal dimensions
|
||||
try:
|
||||
size = os.get_terminal_size()
|
||||
width, height = size.columns, size.lines
|
||||
except (ValueError, OSError):
|
||||
width, height = 80, 24
|
||||
|
||||
# Color depth detection
|
||||
supports_256_color = "256color" in term.lower() or bool(colorterm)
|
||||
supports_truecolor = colorterm.lower() in {"truecolor", "24bit"}
|
||||
|
||||
# Unicode support
|
||||
try:
|
||||
encoding = locale.getpreferredencoding(False) or ""
|
||||
supports_unicode = "utf" in encoding.lower()
|
||||
except Exception: # pragma: no cover
|
||||
supports_unicode = True
|
||||
|
||||
# Cursor movement requires a terminal emulator that supports cursor
|
||||
# addressing beyond basic ANSI colour codes. We check for known
|
||||
# cursor-capable $TERM values. This allows the state
|
||||
# ``supports_ansi=True, supports_cursor=False`` (e.g. a colour-only
|
||||
# pipe), making the Color fallback tier reachable in the
|
||||
# ``supports_ansi=True, supports_cursor_movement=False`` (e.g. a
|
||||
# colour-only pipe), making the Color fallback tier reachable in the
|
||||
# Rich → Table → Color → Plain chain (spec §27177, N15 fix).
|
||||
_cursor_terms = {"xterm", "screen", "tmux", "rxvt", "alacritty", "kitty"}
|
||||
term_lower = term.lower()
|
||||
supports_cursor = supports_ansi and any(
|
||||
supports_cursor_movement = supports_ansi and any(
|
||||
term_lower.startswith(ct) for ct in _cursor_terms
|
||||
)
|
||||
supports_alternate_screen = supports_cursor_movement
|
||||
|
||||
# NO_COLOR (https://no-color.org/)
|
||||
no_color = os.environ.get("NO_COLOR") is not None
|
||||
|
||||
# TERM_PROGRAM
|
||||
term_program = os.environ.get("TERM_PROGRAM") or None
|
||||
|
||||
return TerminalCapabilities(
|
||||
is_tty=is_tty,
|
||||
width=width,
|
||||
height=height,
|
||||
supports_ansi=supports_ansi,
|
||||
supports_cursor=supports_cursor,
|
||||
term=term,
|
||||
supports_256_color=supports_256_color,
|
||||
supports_truecolor=supports_truecolor,
|
||||
supports_unicode=supports_unicode,
|
||||
supports_cursor_movement=supports_cursor_movement,
|
||||
supports_alternate_screen=supports_alternate_screen,
|
||||
no_color=no_color,
|
||||
term_program=term_program,
|
||||
)
|
||||
|
||||
|
||||
@@ -139,7 +211,7 @@ def select_materializer(
|
||||
# cursor-capable $TERM values, so the Rich → Table path dominates.
|
||||
# This is documented as SD-16 in __init__.py (R2-C2 fix #8).
|
||||
if fmt == "rich":
|
||||
if caps.supports_cursor:
|
||||
if caps.supports_cursor_movement:
|
||||
return RichMaterializer()
|
||||
if caps.is_tty:
|
||||
return TableMaterializer()
|
||||
|
||||
Reference in New Issue
Block a user