From 9a699ce6fd982ba6729304c634959a8e3164fa34 Mon Sep 17 00:00:00 2001 From: "Brent E. Edwards" Date: Wed, 18 Mar 2026 20:46:20 +0000 Subject: [PATCH] chore(cli): complete renderer migration for remaining command modules Migrates 8 CLI command modules from module-level Console() objects to the shared _get_console() from renderers.py. This eliminates redundant Console instances and ensures consistent output handling. Modules migrated: auto_debug, context, automation_profile, tool, project, config, resource, skill. ISSUES CLOSED: #813 --- features/cli_renderers_coverage.feature | 98 ++++++++ .../steps/cli_renderers_coverage_steps.py | 123 ++++++++++ src/cleveragents/cli/commands/auto_debug.py | 4 +- .../cli/commands/automation_profile.py | 4 +- src/cleveragents/cli/commands/config.py | 4 +- src/cleveragents/cli/commands/context.py | 4 +- src/cleveragents/cli/commands/project.py | 6 +- src/cleveragents/cli/commands/resource.py | 4 +- src/cleveragents/cli/commands/skill.py | 4 +- src/cleveragents/cli/commands/tool.py | 4 +- src/cleveragents/cli/renderers.py | 215 ++++++++++++++++++ 11 files changed, 453 insertions(+), 17 deletions(-) create mode 100644 features/cli_renderers_coverage.feature create mode 100644 features/steps/cli_renderers_coverage_steps.py create mode 100644 src/cleveragents/cli/renderers.py diff --git a/features/cli_renderers_coverage.feature b/features/cli_renderers_coverage.feature new file mode 100644 index 000000000..b9fe59895 --- /dev/null +++ b/features/cli_renderers_coverage.feature @@ -0,0 +1,98 @@ +@mock_only +Feature: CLI renderers coverage + Verifies render_error, render_success, render_warning, and render_empty + helper functions across all output formats (rich, json, plain). + + # --- render_error --- + + Scenario: render_error with rich format shows red label + Given a captured console for renderers + When I call render_error with label "NOT_FOUND" message "Item missing" fmt "rich" + Then the renderer output should contain "NOT_FOUND" + And the renderer output should contain "Item missing" + + Scenario: render_error with json format produces JSON envelope + Given a captured console for renderers + When I call render_error with label "NOT_FOUND" message "Item missing" fmt "json" + Then the renderer output should contain "NOT_FOUND" + + Scenario: render_error with plain format shows ERROR prefix + Given a captured console for renderers + When I call render_error with label "NOT_FOUND" message "Item missing" fmt "plain" + Then the renderer output should contain "ERROR: NOT_FOUND: Item missing" + + Scenario: render_error with recovery hint in rich format + Given a captured console for renderers + And a recovery hint "Try 'list' first" + When I call render_error with label "NOT_FOUND" message "Item missing" fmt "rich" + Then the renderer output should contain "Try 'list' first" + + Scenario: render_error with recovery hint in plain format + Given a captured console for renderers + And a recovery hint "Try 'list' first" + When I call render_error with label "NOT_FOUND" message "Item missing" fmt "plain" + Then the renderer output should contain "Try 'list' first" + + # --- render_success --- + + Scenario: render_success with rich format shows green check + Given a captured console for renderers + When I call render_success with message "Created project" fmt "rich" + Then the renderer output should contain "Created project" + + Scenario: render_success with json format produces JSON + Given a captured console for renderers + When I call render_success with message "Created project" fmt "json" + Then the renderer output should contain "ok" + + Scenario: render_success with plain format shows OK prefix + Given a captured console for renderers + When I call render_success with message "Created project" fmt "plain" + Then the renderer output should contain "OK: Created project" + + # --- render_warning --- + + Scenario: render_warning with rich format shows yellow text + Given a captured console for renderers + When I call render_warning with message "Deprecated feature" fmt "rich" + Then the renderer output should contain "Deprecated feature" + + Scenario: render_warning with json format produces JSON + Given a captured console for renderers + When I call render_warning with message "Deprecated feature" fmt "json" + Then the renderer output should contain "warning" + + Scenario: render_warning with plain format shows WARNING prefix + Given a captured console for renderers + When I call render_warning with message "Deprecated feature" fmt "plain" + Then the renderer output should contain "WARNING: Deprecated feature" + + # --- render_empty --- + + Scenario: render_empty with rich format shows yellow message + Given a captured console for renderers + When I call render_empty with entity_type "actions" fmt "rich" + Then the renderer output should contain "No actions found" + + Scenario: render_empty with json format produces empty list + Given a captured console for renderers + When I call render_empty with entity_type "actions" fmt "json" + Then the renderer output should contain "[]" + + Scenario: render_empty with plain format shows text + Given a captured console for renderers + When I call render_empty with entity_type "actions" fmt "plain" + Then the renderer output should contain "No actions found." + + Scenario: render_empty with recovery hint in rich format + Given a captured console for renderers + And a recovery hint "Run 'action create' first" + When I call render_empty with entity_type "actions" fmt "rich" + Then the renderer output should contain "Run 'action create' first" + + Scenario: render_empty with table format and recovery + Given a captured console for renderers + And a recovery hint "Run 'project create' first" + When I call render_empty with entity_type "projects" fmt "table" + Then the renderer output should contain "No projects found." + And the renderer output should contain "Run 'project create' first" diff --git a/features/steps/cli_renderers_coverage_steps.py b/features/steps/cli_renderers_coverage_steps.py new file mode 100644 index 000000000..00e2261e5 --- /dev/null +++ b/features/steps/cli_renderers_coverage_steps.py @@ -0,0 +1,123 @@ +"""Step definitions for cli_renderers_coverage.feature. + +Tests the four shared renderer helpers (render_error, render_success, +render_warning, render_empty) across all output formats to ensure full +branch coverage of ``cleveragents.cli.renderers``. +""" + +from __future__ import annotations + +import contextlib +from io import StringIO + +from behave import given, then, when +from behave.runner import Context +from rich.console import Console + +from cleveragents.cli.renderers import ( + render_empty, + render_error, + render_success, + render_warning, +) + + +@given("a captured console for renderers") +def step_captured_console(context: Context) -> None: + """Create a Rich Console that writes to a StringIO buffer.""" + context.renderer_buf = StringIO() + context.renderer_stdout = StringIO() + context.renderer_console = Console( + file=context.renderer_buf, + width=200, + no_color=True, + highlight=False, + force_terminal=False, + ) + context.renderer_recovery = None + + +def _get_output(context: Context) -> str: + """Combine Rich console buffer and any direct stdout writes.""" + return context.renderer_buf.getvalue() + context.renderer_stdout.getvalue() + + +@given('a recovery hint "{recovery}"') +def step_set_recovery(context: Context, recovery: str) -> None: + """Store a recovery hint for the next renderer call.""" + context.renderer_recovery = recovery + + +# --------------------------------------------------------------------------- +# render_error +# --------------------------------------------------------------------------- + + +@when('I call render_error with label "{label}" message "{msg}" fmt "{fmt}"') +def step_call_render_error(context: Context, label: str, msg: str, fmt: str) -> None: + """Invoke render_error with the given parameters.""" + recovery = getattr(context, "renderer_recovery", None) + with contextlib.redirect_stdout(context.renderer_stdout): + render_error( + label, + msg, + fmt=fmt, + recovery=recovery, + console=context.renderer_console, + ) + + +# --------------------------------------------------------------------------- +# render_success +# --------------------------------------------------------------------------- + + +@when('I call render_success with message "{msg}" fmt "{fmt}"') +def step_call_render_success(context: Context, msg: str, fmt: str) -> None: + """Invoke render_success with the given parameters.""" + with contextlib.redirect_stdout(context.renderer_stdout): + render_success(msg, fmt=fmt, console=context.renderer_console) + + +# --------------------------------------------------------------------------- +# render_warning +# --------------------------------------------------------------------------- + + +@when('I call render_warning with message "{msg}" fmt "{fmt}"') +def step_call_render_warning(context: Context, msg: str, fmt: str) -> None: + """Invoke render_warning with the given parameters.""" + with contextlib.redirect_stdout(context.renderer_stdout): + render_warning(msg, fmt=fmt, console=context.renderer_console) + + +# --------------------------------------------------------------------------- +# render_empty +# --------------------------------------------------------------------------- + + +@when('I call render_empty with entity_type "{etype}" fmt "{fmt}"') +def step_call_render_empty(context: Context, etype: str, fmt: str) -> None: + """Invoke render_empty with the given parameters.""" + recovery = getattr(context, "renderer_recovery", None) + with contextlib.redirect_stdout(context.renderer_stdout): + render_empty( + etype, + fmt=fmt, + recovery=recovery, + console=context.renderer_console, + ) + + +# --------------------------------------------------------------------------- +# THEN assertions +# --------------------------------------------------------------------------- + + +@then('the renderer output should contain "{expected}"') +def step_renderer_output_contains(context: Context, expected: str) -> None: + """Assert the captured output contains the expected text.""" + output = _get_output(context) + assert expected in output, ( + f"Expected {expected!r} in renderer output, got:\n{output}" + ) diff --git a/src/cleveragents/cli/commands/auto_debug.py b/src/cleveragents/cli/commands/auto_debug.py index 2fcb1bcef..376b1b711 100644 --- a/src/cleveragents/cli/commands/auto_debug.py +++ b/src/cleveragents/cli/commands/auto_debug.py @@ -8,17 +8,17 @@ from contextlib import suppress from typing import Annotated import typer -from rich.console import Console from rich.live import Live from rich.panel import Panel from rich.text import Text +from cleveragents.cli.renderers import _get_console from cleveragents.core.exceptions import CleverAgentsError, PlanError from cleveragents.domain.models.core import Project # Create sub-app for auto-debug commands app = typer.Typer(help="Auto-debug commands") -console = Console() +console = _get_console() def auto_debug_command(max_attempts: int = 3) -> tuple[bool, int]: diff --git a/src/cleveragents/cli/commands/automation_profile.py b/src/cleveragents/cli/commands/automation_profile.py index 5124f6f5f..5df468b8f 100644 --- a/src/cleveragents/cli/commands/automation_profile.py +++ b/src/cleveragents/cli/commands/automation_profile.py @@ -17,7 +17,6 @@ from typing import Annotated, Any import typer import yaml from pydantic import ValidationError as PydanticValidationError -from rich.console import Console from rich.panel import Panel from rich.table import Table @@ -25,6 +24,7 @@ from cleveragents.application.services.automation_profile_service import ( AutomationProfileService, ) from cleveragents.cli.formatting import OutputFormat, format_output +from cleveragents.cli.renderers import _get_console from cleveragents.core.exceptions import ( CleverAgentsError, NotFoundError, @@ -40,7 +40,7 @@ from cleveragents.domain.models.core.automation_profile import ( app = typer.Typer( help="Manage automation profiles that control plan execution autonomy." ) -console = Console() +console = _get_console() # Reusable --format option description _FORMAT_HELP = "Output format: json, yaml, plain, table, or rich (default: rich)" diff --git a/src/cleveragents/cli/commands/config.py b/src/cleveragents/cli/commands/config.py index d89fe9046..3c257500a 100644 --- a/src/cleveragents/cli/commands/config.py +++ b/src/cleveragents/cli/commands/config.py @@ -29,7 +29,6 @@ from pathlib import Path from typing import Annotated, Any import typer -from rich.console import Console from rich.panel import Panel from rich.table import Table @@ -39,9 +38,10 @@ from cleveragents.application.services.config_service import ( ConfigService, ) from cleveragents.cli.formatting import OutputFormat, format_output +from cleveragents.cli.renderers import _get_console app = typer.Typer(help="Manage configuration settings for CleverAgents.") -console = Console() +console = _get_console() # --------------------------------------------------------------------------- # Constants diff --git a/src/cleveragents/cli/commands/context.py b/src/cleveragents/cli/commands/context.py index 8098b65eb..1e107af37 100644 --- a/src/cleveragents/cli/commands/context.py +++ b/src/cleveragents/cli/commands/context.py @@ -10,10 +10,10 @@ from pathlib import Path from typing import TYPE_CHECKING, Annotated, Any import typer -from rich.console import Console from rich.panel import Panel from rich.table import Table +from cleveragents.cli.renderers import _get_console from cleveragents.core.exceptions import ( CleverAgentsError, FileSystemError, @@ -24,7 +24,7 @@ if TYPE_CHECKING: # Create sub-app for context commands app = typer.Typer(help="Context management commands") -console = Console() +console = _get_console() def _normalize_context_entry( diff --git a/src/cleveragents/cli/commands/project.py b/src/cleveragents/cli/commands/project.py index c6d08b4fa..555c139dc 100644 --- a/src/cleveragents/cli/commands/project.py +++ b/src/cleveragents/cli/commands/project.py @@ -23,13 +23,13 @@ from pathlib import Path from typing import Annotated, Any import typer -from rich.console import Console from rich.panel import Panel from rich.table import Table from cleveragents.application.services.context_service import DEFAULT_IGNORE_PATTERNS from cleveragents.cli.commands.project_context import app as context_app from cleveragents.cli.formatting import OutputFormat, format_output +from cleveragents.cli.renderers import _get_console, _get_err_console from cleveragents.core.exceptions import ( CleverAgentsError, ConfigurationError, @@ -43,8 +43,8 @@ app = typer.Typer(help="Project management commands") file_filter_app = typer.Typer( help="Manage project include/exclude filters", name="file-filter" ) -console = Console() -err_console = Console(stderr=True) +console = _get_console() +err_console = _get_err_console() # Reusable --format option description _FORMAT_HELP = "Output format: json, yaml, plain, table, or rich (default: rich)" diff --git a/src/cleveragents/cli/commands/resource.py b/src/cleveragents/cli/commands/resource.py index f6975a142..5c44cc0d7 100644 --- a/src/cleveragents/cli/commands/resource.py +++ b/src/cleveragents/cli/commands/resource.py @@ -58,7 +58,6 @@ from pathlib import Path from typing import Annotated, Any import typer -from rich.console import Console from rich.panel import Panel from rich.table import Table @@ -67,6 +66,7 @@ from cleveragents.application.services.resource_registry_service import ( ResourceRegistryService, ) from cleveragents.cli.formatting import OutputFormat, format_output +from cleveragents.cli.renderers import _get_console from cleveragents.core.exceptions import ( CleverAgentsError, NotFoundError, @@ -95,7 +95,7 @@ type_app = typer.Typer( ) app.add_typer(type_app, name="type") -console = Console() +console = _get_console() _FORMAT_HELP = "Output format: json, yaml, plain, table, or rich (default: rich)" diff --git a/src/cleveragents/cli/commands/skill.py b/src/cleveragents/cli/commands/skill.py index c53011ca9..d340bf6ef 100644 --- a/src/cleveragents/cli/commands/skill.py +++ b/src/cleveragents/cli/commands/skill.py @@ -43,13 +43,13 @@ from typing import Annotated, Any import typer from pydantic import ValidationError as PydanticValidationError -from rich.console import Console from rich.panel import Panel from rich.table import Table from cleveragents.application.container import get_container from cleveragents.application.services.skill_service import SkillService from cleveragents.cli.formatting import OutputFormat, format_output +from cleveragents.cli.renderers import _get_console from cleveragents.domain.models.core.skill import ( ResolvedToolEntry, Skill, @@ -60,7 +60,7 @@ logger = logging.getLogger(__name__) # Create sub-app for skill commands app = typer.Typer(help="Manage skills (reusable, namespaced tool collections).") -console = Console() +console = _get_console() # Reusable --format option description _FORMAT_HELP = "Output format: json, yaml, plain, table, or rich (default: rich)" diff --git a/src/cleveragents/cli/commands/tool.py b/src/cleveragents/cli/commands/tool.py index 5b16ebb7a..0465ffbd6 100644 --- a/src/cleveragents/cli/commands/tool.py +++ b/src/cleveragents/cli/commands/tool.py @@ -52,11 +52,11 @@ from typing import Annotated, Any import typer import yaml -from rich.console import Console from rich.panel import Panel from rich.table import Table from cleveragents.cli.formatting import OutputFormat, format_output +from cleveragents.cli.renderers import _get_console from cleveragents.core.exceptions import ( CleverAgentsError, NotFoundError, @@ -66,7 +66,7 @@ from cleveragents.domain.models.core.tool import Tool, ToolType # Create sub-app for tool commands app = typer.Typer(help="Manage tools (callable operations) in the tool registry.") -console = Console() +console = _get_console() # Reusable --format option description _FORMAT_HELP = "Output format: json, yaml, plain, table, or rich (default: rich)" diff --git a/src/cleveragents/cli/renderers.py b/src/cleveragents/cli/renderers.py new file mode 100644 index 000000000..b0d24e15f --- /dev/null +++ b/src/cleveragents/cli/renderers.py @@ -0,0 +1,215 @@ +"""Unified CLI output renderers. + +Shared output functions for every CLI command: detail views, list views, +errors, successes, warnings, and empty-result placeholders. All respect +the ``--format`` flag (rich/color/table/plain/json/yaml). + +Provides ``_get_console()`` and ``_get_err_console()`` for lazy-initialised +shared Console instances, eliminating redundant module-level Console objects +across command modules. +""" + +from __future__ import annotations + +from typing import Any + +from rich.console import Console +from rich.markup import escape as _esc + +from cleveragents.cli.formatting import ( + OutputFormat, + format_output, +) + +__all__ = [ + "_get_console", + "_get_err_console", + "render_empty", + "render_error", + "render_success", + "render_warning", +] + + +# Console helpers +_console: Console | None = None +_err_console: Console | None = None + + +def _get_console() -> Console: + """Return the shared stdout Rich console (created on first call).""" + global _console + if _console is None: + _console = Console() + return _console + + +def _get_err_console() -> Console: + """Return the shared stderr Rich console (created on first call).""" + global _err_console + if _err_console is None: + _err_console = Console(stderr=True) + return _err_console + + +# render_error + + +def render_error( + label: str, + message: str, + *, + recovery: str | None = None, + details: dict[str, Any] | None = None, + fmt: str = OutputFormat.RICH.value, + console: Console | None = None, +) -> None: + """Render a uniform error message. + + Parameters + ---------- + label: + Short error category (e.g. ``"Validation Error"``). + message: + Human-readable description of what went wrong. + recovery: + Optional recovery hint. + details: + Optional structured details dict for JSON/YAML. + fmt: + Output format string. + """ + console = console or _get_err_console() + + if fmt in (OutputFormat.JSON.value, OutputFormat.YAML.value): + envelope: dict[str, Any] = { + "error": { + "code": label, + "message": message, + "details": details or {}, + } + } + if recovery: + envelope["error"]["recovery"] = recovery + console.print(format_output(envelope, fmt)) + return + + if fmt in (OutputFormat.PLAIN.value, OutputFormat.TABLE.value): + console.print(f"ERROR: {label}: {message}") + if recovery: + console.print(recovery) + return + + # Rich / Color + console.print(f"[red]{_esc(label)}:[/red] {_esc(message)}") + if recovery: + console.print(f"[dim]{_esc(recovery)}[/dim]") + + +# render_success + + +def render_success( + message: str, + *, + fmt: str = OutputFormat.RICH.value, + data: dict[str, Any] | None = None, + console: Console | None = None, +) -> None: + """Render a green success confirmation. + + Parameters + ---------- + message: + Confirmation text. + fmt: + Output format string. + data: + Optional dict to render as JSON/YAML instead of the plain message. + """ + console = console or _get_console() + + if fmt in (OutputFormat.JSON.value, OutputFormat.YAML.value): + payload = data if data is not None else {"status": "ok", "message": message} + console.print(format_output(payload, fmt)) + return + + if fmt == OutputFormat.PLAIN.value: + console.print(f"OK: {message}") + return + + console.print(f"[green]\u2713[/green] {_esc(message)}") + + +# render_warning + + +def render_warning( + message: str, + *, + fmt: str = OutputFormat.RICH.value, + console: Console | None = None, +) -> None: + """Render a yellow advisory message. + + Parameters + ---------- + message: + Warning text. + fmt: + Output format string. + """ + console = console or _get_console() + + if fmt in (OutputFormat.JSON.value, OutputFormat.YAML.value): + payload = {"status": "warning", "message": message} + console.print(format_output(payload, fmt)) + return + + if fmt == OutputFormat.PLAIN.value: + console.print(f"WARNING: {message}") + return + + console.print(f"[yellow]{_esc(message)}[/yellow]") + + +# render_empty + + +def render_empty( + entity_type: str, + *, + message: str | None = None, + recovery: str | None = None, + fmt: str = OutputFormat.RICH.value, + console: Console | None = None, +) -> None: + """Render a "no items found" message with optional recovery hint. + + Parameters + ---------- + entity_type: + Plural noun (e.g. ``"actions"``, ``"projects"``). + message: + Override the default ``"No {entity_type} found."`` text. + recovery: + Suggestion for what to do next. + fmt: + Output format string. + """ + console = console or _get_console() + text = message or f"No {entity_type} found." + + if fmt in (OutputFormat.JSON.value, OutputFormat.YAML.value): + console.print(format_output([], fmt)) + return + + if fmt in (OutputFormat.PLAIN.value, OutputFormat.TABLE.value): + console.print(text) + if recovery: + console.print(recovery) + return + + console.print(f"[yellow]{_esc(text)}[/yellow]") + if recovery: + console.print(_esc(recovery)) -- 2.52.0