2434253c1a
CI / build (push) Successful in 16s
CI / lint (push) Successful in 18s
CI / quality (push) Successful in 28s
CI / typecheck (push) Successful in 1m0s
CI / security (push) Successful in 1m0s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 3m14s
CI / integration_tests (push) Successful in 3m38s
CI / docker (push) Successful in 55s
CI / e2e_tests (push) Successful in 5m15s
CI / coverage (push) Successful in 7m8s
CI / benchmark-publish (push) Successful in 20m34s
## Summary Implement the 6 missing element handle types (Tree, Text, Code, Diff, Separator, ActionHint) and ensure all 6 materialization strategies (rich, color, table, plain, json, yaml) support all 10 element types. This completes the output rendering framework per the specification (§25417-27276). Closes #550 ## Changes ### Source (restructured into smaller modules) #### `handles/` package (was `handles.py` — 1062 lines → 5 files, all ≤500 lines) - **`_models.py`**: All Pydantic data models, constants (MAX_TREE_DEPTH, MAX_ELEMENTS_PER_SESSION, MAX_TABLE_ROWS), exceptions (ElementClosedError), event classes, ElementSnapshot union. P2-1: `DiffLine.type` renamed to `line_type` with backward-compatible alias. - **`_base.py`**: Generic `ElementHandle[E]` base class with thread-safe `element_copy()` (lock-protected). - **`_panel_table.py`**: PanelHandle, TableHandle, StatusHandle. - **`_concrete.py`**: ProgressHandle (P1-1: validates `total` non-negative), TreeHandle, TextHandle (P3-2: close() delegates to super()), CodeHandle, DiffHandle, SeparatorHandle, ActionHintHandle. P3-8: `increment(delta=0)` now rejected. - **`__init__.py`**: Re-exports all public symbols. #### `_renderers.py` — plain renderers, sanitization, and shared helpers - Plain render functions for all 10 element types. - Terminal escape sanitization (`strip_terminal_escapes`). - P2-3: `_sort_table_rows` now consults `ColumnDef.col_type` for numeric sorting. - P2-6: `compute_column_widths()` shared helper extracted (DRY fix). #### `_color_renderers.py` — ANSI colour renderers - Colour-coded render functions for all 10 element types. - P2-2: TextBlock now gets color treatment (`_render_text_color`) instead of falling through to plain. #### `_boxdraw.py` — box-drawing renderers - P2-5: Upgraded from ASCII `+-|` to Unicode box-drawing `╭─╮│╰╯` with rounded corners per spec §26821. #### `_ids.py` — ID generation helpers - P3-1: Session and handle IDs now use separate counters, making IDs monotonic within their namespace. #### `materializers.py` — strategy protocol and 6 concrete strategies - P1-2: `_snapshot_to_dict` now includes `timing` field in JSON/YAML output per spec §27022. - P2-4: `_column_def_to_dict` always includes all fields unconditionally for stable JSON schemas. #### `selection.py` — materializer selection with fallback - P1-4: `NO_COLOR` environment variable now respected (https://no-color.org/). When set, all visual formats fall back to plain. Precedence: explicit flag > NO_COLOR > terminal capability fallback. #### `session.py` - P1-1: `session.progress()` factory validates `total >= 0`. - P1-2: `snapshot()` includes `timing` when available. #### `__init__.py` — package docstring - P1-3: SD-29 corrected to reflect actual Table → Color → Plain fallback chain. - SD-14 marked as implemented (NO_COLOR support added). ### Spec Deviations (Documented) 28 deliberate deviations documented in `__init__.py` module docstring (SD-1 through SD-29, with SD-14 now implemented). SD-29 corrected. ### Tests (updated) - **+23 new BDD scenarios** covering: P1-1 total validation, P1-4 NO_COLOR, P2-1 DiffLine.line_type alias, P2-2 text color, P2-3 numeric sorting, P2-4 ColumnDef serialization, P2-5 Unicode box-drawing, P3-3 add_rows limit, P3-5 10-thread stress test, P3-6 summary truncation, P3-7 explicit format for color/table, P3-8 zero delta. - **Robot tests**: Updated box-drawing assertion for Unicode chars. ## Verification | Check | Result | |-------|--------| | Pyright | 0 errors, 1 pre-existing warning | | Ruff lint | All passed | | Unit tests | 393 features, 11,344 scenarios, 0 failures | | Integration tests | All passed | | E2E tests | All passed | | Coverage | 97% overall (threshold: 97%) | ## Review Fixes Applied (Luis Review #2412) | ID | Severity | Fix | |----|----------|-----| | P1-1 | High | `set_progress()` and `session.progress()` validate `total >= 0` | | P1-2 | High | `_snapshot_to_dict` includes `timing` field | | P1-3 | High | SD-29 documentation corrected | | P1-4 | High | `NO_COLOR` env var respected | | P2-1 | Medium | `DiffLine.type` → `line_type` with alias | | P2-2 | Medium | TextBlock gets color treatment | | P2-3 | Medium | Numeric column sorting | | P2-4 | Medium | ColumnDef always serializes all fields | | P2-5 | Medium | Unicode box-drawing characters | | P2-6 | Medium | Shared `compute_column_widths()` helper | | P3-1 | Low | Separate ID counters | | P3-2 | Low | TextHandle.close() delegates to super() | | P3-3 | Low | add_rows batch limit test | | P3-5 | Low | 10-thread stress test | | P3-6 | Low | Summary truncation test | | P3-7 | Low | Explicit format tests for color/table | | P3-8 | Low | Zero delta rejected | ### Deferred Items | ID | Reason | |----|--------| | P3-9 | snapshot() lock scope — acceptable correctness trade-off | | P3-10 | CLEVERAGENTS_FORMAT env var — documented as SD-15, requires CLI framework changes | Reviewed-on: #812 Reviewed-by: Jeffrey Phillips Freeman <jeffrey.freeman@cleverthis.com> Co-authored-by: Rui Hu <rui.hu@cleverthis.com> Co-committed-by: Rui Hu <rui.hu@cleverthis.com>
312 lines
11 KiB
Python
312 lines
11 KiB
Python
"""ASV benchmarks for the output rendering framework.
|
|
|
|
Measures materialiser throughput, progress handle overhead, and session
|
|
lifecycle costs across all six output formats.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# Ensure the local *source* tree is importable even when ASV has an
|
|
# older build of the package installed.
|
|
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
|
if _SRC not in sys.path:
|
|
sys.path.insert(0, _SRC)
|
|
|
|
import cleveragents # noqa: E402
|
|
|
|
importlib.reload(cleveragents)
|
|
|
|
from cleveragents.cli.output.handles import DiffLine # noqa: E402
|
|
from cleveragents.cli.output.materializers import ( # noqa: E402
|
|
ColorMaterializer,
|
|
JsonMaterializer,
|
|
PlainMaterializer,
|
|
RichMaterializer,
|
|
TableMaterializer,
|
|
YamlMaterializer,
|
|
)
|
|
from cleveragents.cli.output.session import OutputSession # noqa: E402
|
|
|
|
_STRATEGY_MAP: dict[str, type] = {
|
|
"rich": RichMaterializer,
|
|
"color": ColorMaterializer,
|
|
"table": TableMaterializer,
|
|
"plain": PlainMaterializer,
|
|
"json": JsonMaterializer,
|
|
"yaml": YamlMaterializer,
|
|
}
|
|
|
|
|
|
class MaterializerThroughputSuite:
|
|
"""Benchmark materialiser throughput for panel + table rendering."""
|
|
|
|
params: list[str] = ["rich", "color", "table", "plain", "json", "yaml"] # noqa: RUF012
|
|
param_names: list[str] = ["format"] # noqa: RUF012
|
|
|
|
def time_panel_render(self, fmt: str) -> None:
|
|
strategy = _STRATEGY_MAP[fmt]()
|
|
with OutputSession(format=fmt, command="bench", strategy=strategy) as session:
|
|
panel = session.panel("Benchmark Panel")
|
|
for i in range(50):
|
|
panel.set_entry(f"key-{i}", f"value-{i}")
|
|
panel.close()
|
|
strategy.get_output()
|
|
|
|
def time_table_render(self, fmt: str) -> None:
|
|
strategy = _STRATEGY_MAP[fmt]()
|
|
with OutputSession(format=fmt, command="bench", strategy=strategy) as session:
|
|
table = session.table(columns=["Name", "Status", "Count"])
|
|
for i in range(100):
|
|
table.add_row(
|
|
{"Name": f"item-{i}", "Status": "active", "Count": str(i)}
|
|
)
|
|
table.close()
|
|
strategy.get_output()
|
|
|
|
|
|
class ProgressHandleSuite:
|
|
"""Benchmark progress handle update overhead."""
|
|
|
|
params: list[str] = ["plain", "json"] # noqa: RUF012
|
|
param_names: list[str] = ["format"] # noqa: RUF012
|
|
|
|
def time_progress_ticks(self, fmt: str) -> None:
|
|
strategy = _STRATEGY_MAP[fmt]()
|
|
with OutputSession(format=fmt, command="bench", strategy=strategy) as session:
|
|
prog = session.progress("Ticking", total=1000)
|
|
for _ in range(1000):
|
|
prog.tick()
|
|
prog.close()
|
|
|
|
def time_progress_set(self, fmt: str) -> None:
|
|
strategy = _STRATEGY_MAP[fmt]()
|
|
with OutputSession(format=fmt, command="bench", strategy=strategy) as session:
|
|
prog = session.progress("Stepping", total=500)
|
|
for i in range(500):
|
|
prog.set_progress(i, 500)
|
|
prog.close()
|
|
|
|
|
|
class SessionLifecycleSuite:
|
|
"""Benchmark session open/close overhead."""
|
|
|
|
params: list[str] = ["plain", "json"] # noqa: RUF012
|
|
param_names: list[str] = ["format"] # noqa: RUF012
|
|
|
|
def time_empty_session(self, fmt: str) -> None:
|
|
strategy = _STRATEGY_MAP[fmt]()
|
|
with OutputSession(format=fmt, command="bench", strategy=strategy):
|
|
pass
|
|
|
|
def time_session_with_handles(self, fmt: str) -> None:
|
|
strategy = _STRATEGY_MAP[fmt]()
|
|
with OutputSession(format=fmt, command="bench", strategy=strategy) as session:
|
|
p = session.panel("P")
|
|
p.set_entry("k", "v")
|
|
p.close()
|
|
t = session.table(columns=["A"])
|
|
t.add_row({"A": "1"})
|
|
t.close()
|
|
s = session.status("ok")
|
|
s.close()
|
|
|
|
|
|
class TreeThroughputSuite:
|
|
"""Benchmark tree-building throughput with deep/wide trees."""
|
|
|
|
params: list[str] = ["plain", "json"] # noqa: RUF012
|
|
param_names: list[str] = ["format"] # noqa: RUF012
|
|
|
|
def time_wide_tree(self, fmt: str) -> None:
|
|
strategy = _STRATEGY_MAP[fmt]()
|
|
with OutputSession(format=fmt, command="bench", strategy=strategy) as session:
|
|
tree = session.tree("Root")
|
|
for i in range(100):
|
|
tree.add_child("Root", f"child-{i}")
|
|
tree.close()
|
|
strategy.get_output()
|
|
|
|
def time_deep_tree(self, fmt: str) -> None:
|
|
strategy = _STRATEGY_MAP[fmt]()
|
|
with OutputSession(format=fmt, command="bench", strategy=strategy) as session:
|
|
tree = session.tree("Root")
|
|
path = "Root"
|
|
for i in range(50):
|
|
label = f"level-{i}"
|
|
path = tree.add_child(path, label)
|
|
tree.close()
|
|
strategy.get_output()
|
|
|
|
|
|
class DiffThroughputSuite:
|
|
"""Benchmark diff rendering with multi-hunk diffs."""
|
|
|
|
params: list[str] = ["plain", "color", "json"] # noqa: RUF012
|
|
param_names: list[str] = ["format"] # noqa: RUF012
|
|
|
|
def time_multi_hunk_diff(self, fmt: str) -> None:
|
|
strategy = _STRATEGY_MAP[fmt]()
|
|
with OutputSession(format=fmt, command="bench", strategy=strategy) as session:
|
|
diff = session.diff(file_a="old.py", file_b="new.py")
|
|
for h in range(20):
|
|
lines = []
|
|
for j in range(10):
|
|
lines.append(DiffLine(type="context", content=f"ctx-{j}"))
|
|
lines.append(DiffLine(type="add", content=f"add-{j}"))
|
|
lines.append(DiffLine(type="remove", content=f"rem-{j}"))
|
|
diff.add_hunk(f"@@ -{h * 30},{30} +{h * 30},{30} @@", lines)
|
|
diff.set_stats(200, 200)
|
|
diff.close()
|
|
strategy.get_output()
|
|
|
|
|
|
class MixedElementSuite:
|
|
"""Benchmark session throughput with all 10 element types."""
|
|
|
|
params: list[str] = ["plain", "json"] # noqa: RUF012
|
|
param_names: list[str] = ["format"] # noqa: RUF012
|
|
|
|
def time_all_element_types(self, fmt: str) -> None:
|
|
strategy = _STRATEGY_MAP[fmt]()
|
|
with OutputSession(format=fmt, command="bench", strategy=strategy) as session:
|
|
p = session.panel("P")
|
|
p.set_entry("k", "v")
|
|
p.close()
|
|
t = session.table(columns=["A"])
|
|
t.add_row({"A": "1"})
|
|
t.close()
|
|
s = session.status("ok")
|
|
s.close()
|
|
pr = session.progress("go", total=10)
|
|
pr.set_progress(5)
|
|
pr.close()
|
|
tr = session.tree("Root")
|
|
tr.add_child("Root", "leaf")
|
|
tr.close()
|
|
tx = session.text("hello world")
|
|
tx.close()
|
|
co = session.code("x = 1", language="python")
|
|
co.close()
|
|
d = session.diff(file_a="a", file_b="b")
|
|
d.add_hunk("@@", [DiffLine(type="add", content="new")])
|
|
d.close()
|
|
session.separator("line")
|
|
session.action_hint(["cmd"])
|
|
strategy.get_output()
|
|
|
|
|
|
class TextAppendSuite:
|
|
"""Benchmark TextHandle.append() throughput (N10: detect O(N²) regressions)."""
|
|
|
|
params: list[str] = ["plain", "json"] # noqa: RUF012
|
|
param_names: list[str] = ["format"] # noqa: RUF012
|
|
|
|
def time_text_append_1000(self, fmt: str) -> None:
|
|
strategy = _STRATEGY_MAP[fmt]()
|
|
with OutputSession(format=fmt, command="bench", strategy=strategy) as session:
|
|
tx = session.text("")
|
|
for i in range(1000):
|
|
tx.append(f"Line {i}\n")
|
|
tx.close()
|
|
strategy.get_output()
|
|
|
|
def time_text_append_5000(self, fmt: str) -> None:
|
|
strategy = _STRATEGY_MAP[fmt]()
|
|
with OutputSession(format=fmt, command="bench", strategy=strategy) as session:
|
|
tx = session.text("")
|
|
for i in range(5000):
|
|
tx.append(f"Line {i}\n")
|
|
tx.close()
|
|
strategy.get_output()
|
|
|
|
|
|
class LargeTableSuite:
|
|
"""Benchmark table rendering at 10K+ rows (N10)."""
|
|
|
|
params: list[str] = ["plain", "json"] # noqa: RUF012
|
|
param_names: list[str] = ["format"] # noqa: RUF012
|
|
|
|
def time_table_10k_rows(self, fmt: str) -> None:
|
|
strategy = _STRATEGY_MAP[fmt]()
|
|
with OutputSession(format=fmt, command="bench", strategy=strategy) as session:
|
|
table = session.table(columns=["ID", "Name", "Value"])
|
|
for i in range(10_000):
|
|
table.add_row({"ID": str(i), "Name": f"item-{i}", "Value": str(i * 10)})
|
|
table.close()
|
|
strategy.get_output()
|
|
|
|
|
|
class SnapshotCostSuite:
|
|
"""Benchmark snapshot() cost (N10)."""
|
|
|
|
params: list[str] = ["plain", "json"] # noqa: RUF012
|
|
param_names: list[str] = ["format"] # noqa: RUF012
|
|
|
|
def time_snapshot_with_many_elements(self, fmt: str) -> None:
|
|
strategy = _STRATEGY_MAP[fmt]()
|
|
with OutputSession(format=fmt, command="bench", strategy=strategy) as session:
|
|
for i in range(50):
|
|
p = session.panel(f"Panel-{i}")
|
|
p.set_entry("k", "v")
|
|
p.close()
|
|
for _ in range(20):
|
|
session.snapshot()
|
|
|
|
def time_snapshot_with_large_table(self, fmt: str) -> None:
|
|
strategy = _STRATEGY_MAP[fmt]()
|
|
with OutputSession(format=fmt, command="bench", strategy=strategy) as session:
|
|
table = session.table(columns=["A", "B"])
|
|
for i in range(5000):
|
|
table.add_row({"A": str(i), "B": str(i)})
|
|
table.close()
|
|
for _ in range(10):
|
|
session.snapshot()
|
|
|
|
|
|
class DeepTreeLookupSuite:
|
|
"""Benchmark deep tree path lookups (N10)."""
|
|
|
|
params: list[str] = ["plain", "json"] # noqa: RUF012
|
|
param_names: list[str] = ["format"] # noqa: RUF012
|
|
|
|
def time_deep_tree_add_and_lookup(self, fmt: str) -> None:
|
|
strategy = _STRATEGY_MAP[fmt]()
|
|
with OutputSession(format=fmt, command="bench", strategy=strategy) as session:
|
|
tree = session.tree("Root")
|
|
path = "Root"
|
|
for i in range(50):
|
|
label = f"level-{i}"
|
|
path = tree.add_child(path, label)
|
|
# Now do lookups at various depths
|
|
tree.add_child("Root", "wide-child-1")
|
|
tree.add_child("Root", "wide-child-2")
|
|
tree.close()
|
|
strategy.get_output()
|
|
|
|
|
|
class ElementUpdatedOverheadSuite:
|
|
"""Benchmark ElementUpdated event dispatch overhead (N10)."""
|
|
|
|
params: list[str] = ["plain", "json"] # noqa: RUF012
|
|
param_names: list[str] = ["format"] # noqa: RUF012
|
|
|
|
def time_panel_many_updates(self, fmt: str) -> None:
|
|
strategy = _STRATEGY_MAP[fmt]()
|
|
with OutputSession(format=fmt, command="bench", strategy=strategy) as session:
|
|
panel = session.panel("Update Panel")
|
|
for i in range(500):
|
|
panel.set_entry(f"key-{i % 50}", f"value-{i}")
|
|
panel.close()
|
|
|
|
def time_progress_many_ticks(self, fmt: str) -> None:
|
|
strategy = _STRATEGY_MAP[fmt]()
|
|
with OutputSession(format=fmt, command="bench", strategy=strategy) as session:
|
|
prog = session.progress("Ticking", total=5000)
|
|
for _ in range(5000):
|
|
prog.tick()
|
|
prog.close()
|