refactor: route CLI→Application communication through A2A boundary #10787

Merged
HAL9000 merged 4 commits from refactor/auto-guard-1-cli-a2a-boundary into master 2026-06-07 00:03:29 +00:00
9 changed files with 688 additions and 6 deletions
+41
View File
@@ -0,0 +1,41 @@
[importlinter]
root_package = cleveragents
include_external_packages = True
[importlinter:contract:cli-no-application-direct]
name = CLI must not import Application services directly
type = forbidden
source_modules =
cleveragents.cli
forbidden_modules =
cleveragents.application.services
ignore_imports =
cleveragents.cli.commands.plan -> cleveragents.application.services.plan_apply_service
cleveragents.cli.commands.plan -> cleveragents.application.services.plan_lifecycle_service
cleveragents.cli.commands.plan -> cleveragents.application.services.plan_service
cleveragents.cli.commands.plan -> cleveragents.application.services.project_service
cleveragents.cli.commands.plan -> cleveragents.application.services.execute_phase_context_assembler
cleveragents.cli.commands.plan -> cleveragents.application.services.llm_actors
cleveragents.cli.commands.plan -> cleveragents.application.services.plan_executor
cleveragents.cli.commands.plan -> cleveragents.application.services.strategy_actor
cleveragents.cli.commands.plan -> cleveragents.application.services.plan_preflight_guardrail
cleveragents.cli.commands.plan -> cleveragents.application.services.plan_resume_service
cleveragents.cli.commands.plan -> cleveragents.application.services.decision_service
cleveragents.cli.commands.plan -> cleveragents.application.container
cleveragents.cli.commands.context -> cleveragents.application.services.context_service
cleveragents.cli.commands.context -> cleveragents.application.services.project_service
cleveragents.cli.commands.context -> cleveragents.application.container
cleveragents.cli.commands.resource -> cleveragents.application.services.resource_registry_service
cleveragents.cli.commands.resource -> cleveragents.application.container
cleveragents.cli.commands.skill -> cleveragents.application.services.skill_service
cleveragents.cli.commands.skill -> cleveragents.application.services.config_service
cleveragents.cli.commands.skill -> cleveragents.application.container
cleveragents.cli.commands.invariant -> cleveragents.application.services.invariant_service
[importlinter:contract:application-no-presentation]
name = Application layer must not import from Presentation (CLI) layer
type = forbidden
source_modules =
cleveragents.application
forbidden_modules =
cleveragents.cli
+1
View File
@@ -7,6 +7,7 @@ Changed `wf10_batch.robot` to be less likely to create files, and
## [Unreleased]
- **feat(resources): resource type extension interface** (#9998): New `cleveragents.resources` package providing the stable public API third-party developers use to add custom resource types without modifying core code. Includes `ResourceType` ABC with five abstract lifecycle methods (`provision`, `deprovision`, `status`, `validate_config`, `to_dict`), a `ResourceConfig` Pydantic model (`name`, `resource_type`, `properties`), a `ResourceStatus` StrEnum (`PENDING`, `ACTIVE`, `FAILED`, `DEPROVISIONED`), and registry functions `register_resource_type` / `get_resource_type` / `list_resource_types`. Custom types are registered under namespaced names (e.g. `myorg/database`); registration raises `TypeError` for non-`ResourceType` subclasses and `ValueError` for duplicate names. 25 BDD scenarios in `features/resource_type_extension_interface.feature` cover enum values, config instantiation, ABC enforcement, all lifecycle method return types, and registry CRUD + error paths.
- **refactor(a2a): route CLI→Application communication through A2A boundary** (Refs #9962, #4253): Introduced `cleveragents.shared.output_format` as a layer-neutral serialiser (`format_data` supporting `json`/`yaml`/`plain`/`table`) with no dependency on `cleveragents.cli.*`, eliminating a reverse dependency from `PlanApplyService.artifacts()` on the CLI presentation layer. The shared formatter returns raw payloads with no CLI envelope wrapping (`{"data": ..., "command": ..., "status": ...}`); callers that previously parsed `parsed["data"]` from `apply_service.artifacts(fmt="json")` output now read fields at the top level. Updated `features/steps/plan_diff_artifacts_steps.py` (`step_artifacts_json_validation`, `step_artifacts_json_apply_summary`) to drop the stale envelope unwrap that caused `KeyError: 'data'` under the new boundary. Removed stale `@tdd_expected_fail` tag from `WF02 Mocked Generation Produces Test Artifacts Only` in `robot/wf02_test_generation_integration.robot` — the scenario now passes naturally through the A2A facade dispatch path (`_cleveragents/plan/artifacts`) introduced by this refactor.
- **fix(test): move advanced context strategy test doubles to features/mocks** (#7574): Extracted `FakeEmbeddings`, `RelevanceScoringStrategy`, `AdaptiveContextSelector`, `ContextFusionStrategy`, and `_pack_budget` from `features/steps/advanced_context_strategies_steps.py` into a new `features/mocks/advanced_context_strategies_mocks.py` file per CONTRIBUTING.md mock-placement rules. Updated the Robot Framework helper `robot/helper_advanced_context_strategies.py` to import directly from `features.mocks` rather than manipulating `sys.path` to reach the Behave steps file. Added `None` guard in `step_assemble_context_query` before calling `selected.assemble()`, and added explicit `ValueError` for unknown strategy types in both `step_load_yaml_strategy` and `load_strategy_from_yaml_impl`.
- **fix(a2a): regression tests for stale cleveragents.acp removal** (#5566): Added two Behave BDD scenarios verifying that `cleveragents.acp` is not importable (raises `ImportError`) and that `src/cleveragents/acp/` does not exist in the source tree. These guard against regression of the `__pycache__`-based import that allowed the removed ACP module to still be loaded from bytecode after the v3.6.0 rename to `a2a`.
- **Virtual Resource Type Base Class** (#8610): Implemented `VirtualResource` base class with two example concrete implementations (`MetricResource`, `APIEndpointResource`) for abstract/computed resources that are derived rather than mapped to physical files. Virtual resources are computed on demand via a `compute_fn` callable. Includes Behave BDD scenarios in `features/resource_virtual_types.feature` exercising construction, computation, name validation, kwargs passthrough, exception handling, string representation, and subclassing. Resource names are validated against `^[a-zA-Z][a-zA-Z0-9_-]*$` (must start with a letter; alphanumeric, hyphens, and underscores otherwise).
+91
View File
@@ -0,0 +1,91 @@
Feature: A2A architectural boundary enforcement
As a developer
I want to verify that the CLI layer does not bypass the A2A boundary
So that the architecture remains clean and maintainable
Background:
Given the source directory exists at "src/cleveragents"
Scenario: Application layer does not import from CLI (Presentation) layer
Given the application services directory exists
When I scan all application service modules for CLI imports
Then no application service module should import from "cleveragents.cli"
Scenario: plan_apply_service uses shared output_format instead of CLI formatting
Given the plan_apply_service module exists
When I inspect plan_apply_service imports
Then it should not import from "cleveragents.cli.formatting"
And it should import from "cleveragents.shared.output_format"
Scenario: shared output_format module has no CLI dependencies
Given the shared output_format module exists
When I inspect shared output_format imports
Then it should not import from "cleveragents.cli"
Scenario: format_data function handles JSON format
Given the shared output_format module is imported
When I call format_data with a dict and format "json"
Then the result should be valid JSON
And the result should contain the expected keys
Scenario: format_data function handles YAML format
Given the shared output_format module is imported
When I call format_data with a dict and format "yaml"
Then the result should be valid YAML
Scenario: format_data function handles plain format
Given the shared output_format module is imported
When I call format_data with a dict and format "plain"
Then the result should contain key-value pairs
Scenario: format_data function handles table format
Given the shared output_format module is imported
When I call format_data with a list of dicts and format "table"
Then the result should contain column headers
Scenario: format_data function falls back to JSON for unknown format
Given the shared output_format module is imported
When I call format_data with a dict and format "unknown"
Then the result should be valid JSON
Scenario: format_data handles empty list for table format
Given the shared output_format module is imported
When I call format_data with an empty list and format "table"
Then the result should be "(empty)"
Scenario: format_data handles nested dict values
Given the shared output_format module is imported
When I call format_data with a nested dict and format "plain"
Then the result should contain nested key-value pairs
Scenario: format_data handles list of dicts in plain format
Given the shared output_format module is imported
When I call format_data with a list of dicts and format "plain"
Then the result should contain multiple entries separated by dashes
Scenario: format_data handles dict with list values in plain format
Given the shared output_format module is imported
When I call format_data with a dict containing list values and format "plain"
Then the result should contain list items with dash prefix
Scenario: format_data handles datetime values in JSON format
Given the shared output_format module is imported
When I call format_data with a dict containing a datetime value and format "json"
Then the result should be valid JSON
And the datetime value should be serialized as ISO string
Scenario: format_data handles enum values in JSON format
Given the shared output_format module is imported
When I call format_data with a dict containing an enum value and format "json"
Then the result should be valid JSON
And the enum value should be serialized as its value
Scenario: format_data handles single dict in table format
Given the shared output_format module is imported
When I call format_data with a single dict and format "table"
Then the result should contain column headers
Scenario: format_data handles list with dict values in table format
Given the shared output_format module is imported
When I call format_data with a list containing dict values and format "table"
Then the result should contain serialized dict values
@@ -0,0 +1,385 @@
"""Step definitions for A2A architectural boundary enforcement tests."""
from __future__ import annotations
import ast
import json
from pathlib import Path
from typing import Any
import yaml
from behave import given, then, when
@given("the application services directory exists")
def step_application_services_dir_exists(context: Any) -> None:
"""Verify the application services directory exists."""
context.app_services_dir = Path("src/cleveragents/application/services")
assert context.app_services_dir.exists(), (
f"Application services directory not found: {context.app_services_dir}"
)
@when("I scan all application service modules for CLI imports")
def step_scan_application_services_for_cli_imports(context: Any) -> None:
"""Scan all application service modules for CLI imports."""
context.cli_violations: list[str] = []
for py_file in context.app_services_dir.rglob("*.py"):
try:
tree = ast.parse(py_file.read_text())
except SyntaxError:
continue
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module:
if node.module.startswith("cleveragents.cli"):
context.cli_violations.append(
f"{py_file}:{node.lineno}: from {node.module} import ..."
)
elif isinstance(node, ast.Import):
for alias in node.names:
if alias.name.startswith("cleveragents.cli"):
context.cli_violations.append(
f"{py_file}:{node.lineno}: import {alias.name}"
)
@then('no application service module should import from "cleveragents.cli"')
def step_no_cli_imports_in_application(context: Any) -> None:
"""Assert no application service imports from CLI layer."""
violations = getattr(context, "cli_violations", [])
assert not violations, (
"Application services must not import from CLI (Presentation) layer.\n"
"Violations found:\n" + "\n".join(violations)
)
@given("the plan_apply_service module exists")
def step_plan_apply_service_exists(context: Any) -> None:
"""Verify plan_apply_service.py exists."""
context.plan_apply_service_path = Path(
"src/cleveragents/application/services/plan_apply_service.py"
)
assert context.plan_apply_service_path.exists(), "plan_apply_service.py not found"
@when("I inspect plan_apply_service imports")
def step_inspect_plan_apply_service_imports(context: Any) -> None:
"""Parse imports from plan_apply_service.py."""
content = context.plan_apply_service_path.read_text()
context.plan_apply_service_content = content
try:
tree = ast.parse(content)
except SyntaxError as exc:
raise AssertionError(f"Syntax error in plan_apply_service.py: {exc}") from exc
context.plan_apply_imports: list[str] = []
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module:
context.plan_apply_imports.append(node.module)
elif isinstance(node, ast.Import):
for alias in node.names:
context.plan_apply_imports.append(alias.name)
@then('it should not import from "cleveragents.cli.formatting"')
def step_plan_apply_service_no_cli_formatting(context: Any) -> None:
"""Assert plan_apply_service does not import from cli.formatting."""
imports = getattr(context, "plan_apply_imports", [])
cli_formatting_imports = [
imp for imp in imports if imp == "cleveragents.cli.formatting"
]
assert not cli_formatting_imports, (
"plan_apply_service.py must not import from cleveragents.cli.formatting. "
"Use cleveragents.shared.output_format instead."
)
@then('it should import from "cleveragents.shared.output_format"')
def step_plan_apply_service_uses_shared_output(context: Any) -> None:
"""Assert plan_apply_service imports from shared.output_format."""
content = getattr(context, "plan_apply_service_content", "")
assert "cleveragents.shared.output_format" in content, (
"plan_apply_service.py should import from "
"cleveragents.shared.output_format for output formatting."
)
@given("the shared output_format module exists")
def step_shared_output_format_exists(context: Any) -> None:
"""Verify shared/output_format.py exists."""
context.output_format_path = Path("src/cleveragents/shared/output_format.py")
assert context.output_format_path.exists(), "shared/output_format.py not found"
@when("I inspect shared output_format imports")
def step_inspect_shared_output_format_imports(context: Any) -> None:
"""Parse imports from shared/output_format.py."""
content = context.output_format_path.read_text()
try:
tree = ast.parse(content)
except SyntaxError as exc:
raise AssertionError(f"Syntax error in shared/output_format.py: {exc}") from exc
context.output_format_imports: list[str] = []
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module:
context.output_format_imports.append(node.module)
elif isinstance(node, ast.Import):
for alias in node.names:
context.output_format_imports.append(alias.name)
@then('it should not import from "cleveragents.cli"')
def step_shared_output_format_no_cli_imports(context: Any) -> None:
"""Assert shared/output_format.py has no CLI imports."""
imports = getattr(context, "output_format_imports", [])
cli_imports = [imp for imp in imports if imp.startswith("cleveragents.cli")]
assert not cli_imports, (
"shared/output_format.py must not import from cleveragents.cli. "
f"Found: {cli_imports}"
)
@given("the shared output_format module is imported")
def step_import_shared_output_format(context: Any) -> None:
"""Import the shared output_format module."""
from cleveragents.shared.output_format import format_data
context.format_data = format_data
@when('I call format_data with a dict and format "json"')
def step_call_format_data_json(context: Any) -> None:
"""Call format_data with JSON format."""
context.sample_data: dict[str, Any] = {
"plan_id": "01HXYZ",
"status": "applied",
"count": 42,
}
context.format_result = context.format_data(context.sample_data, "json")
@when('I call format_data with a dict and format "yaml"')
def step_call_format_data_yaml(context: Any) -> None:
"""Call format_data with YAML format."""
context.sample_data = {"plan_id": "01HXYZ", "status": "applied", "count": 42}
context.format_result = context.format_data(context.sample_data, "yaml")
@when('I call format_data with a dict and format "plain"')
def step_call_format_data_plain(context: Any) -> None:
"""Call format_data with plain format."""
context.sample_data = {"plan_id": "01HXYZ", "status": "applied", "count": 42}
context.format_result = context.format_data(context.sample_data, "plain")
@when('I call format_data with a list of dicts and format "table"')
def step_call_format_data_table(context: Any) -> None:
"""Call format_data with table format."""
context.sample_data = [
{"plan_id": "01HXYZ", "status": "applied"},
{"plan_id": "01HABC", "status": "pending"},
]
context.format_result = context.format_data(context.sample_data, "table")
@when('I call format_data with a dict and format "unknown"')
def step_call_format_data_unknown(context: Any) -> None:
"""Call format_data with unknown format (should fall back to JSON)."""
context.sample_data = {"plan_id": "01HXYZ", "status": "applied"}
context.format_result = context.format_data(context.sample_data, "unknown")
@when('I call format_data with an empty list and format "table"')
def step_call_format_data_empty_table(context: Any) -> None:
"""Call format_data with empty list and table format."""
context.format_result = context.format_data([], "table")
@when('I call format_data with a nested dict and format "plain"')
def step_call_format_data_nested_plain(context: Any) -> None:
"""Call format_data with nested dict and plain format."""
context.sample_data = {
"plan_id": "01HXYZ",
"metadata": {"author": "test", "version": "1.0"},
}
context.format_result = context.format_data(context.sample_data, "plain")
@then("the result should be valid JSON")
def step_result_is_valid_json(context: Any) -> None:
"""Assert the result is valid JSON."""
result = context.format_result
assert isinstance(result, str), f"Expected str, got {type(result)}"
try:
json.loads(result)
except json.JSONDecodeError as exc:
raise AssertionError(
f"Result is not valid JSON: {exc}\nResult: {result!r}"
) from exc
@then("the result should contain the expected keys")
def step_result_contains_expected_keys(context: Any) -> None:
"""Assert the JSON result contains expected keys."""
result = json.loads(context.format_result)
assert "plan_id" in result, f"Missing 'plan_id' in result: {result}"
assert "status" in result, f"Missing 'status' in result: {result}"
@then("the result should be valid YAML")
def step_result_is_valid_yaml(context: Any) -> None:
"""Assert the result is valid YAML."""
result = context.format_result
assert isinstance(result, str), f"Expected str, got {type(result)}"
try:
parsed = yaml.safe_load(result)
assert parsed is not None, "YAML parsed to None"
except yaml.YAMLError as exc:
raise AssertionError(
f"Result is not valid YAML: {exc}\nResult: {result!r}"
) from exc
@then("the result should contain key-value pairs")
def step_result_contains_key_value_pairs(context: Any) -> None:
"""Assert the plain result contains key-value pairs."""
result = context.format_result
assert "plan_id" in result, f"Missing 'plan_id' in plain output: {result!r}"
assert "status" in result, f"Missing 'status' in plain output: {result!r}"
@then("the result should contain column headers")
def step_result_contains_column_headers(context: Any) -> None:
"""Assert the table result contains column headers."""
result = context.format_result
assert "plan_id" in result, f"Missing 'plan_id' column in table: {result!r}"
assert "status" in result, f"Missing 'status' column in table: {result!r}"
@then('the result should be "(empty)"')
def step_result_is_empty(context: Any) -> None:
"""Assert the result is the empty placeholder."""
assert context.format_result == "(empty)", (
f"Expected '(empty)', got: {context.format_result!r}"
)
@then("the result should contain nested key-value pairs")
def step_result_contains_nested_pairs(context: Any) -> None:
"""Assert the plain result contains nested key-value pairs."""
result = context.format_result
assert "plan_id" in result, f"Missing 'plan_id' in plain output: {result!r}"
assert "metadata" in result, f"Missing 'metadata' in plain output: {result!r}"
@when('I call format_data with a list of dicts and format "plain"')
def step_call_format_data_list_plain(context: Any) -> None:
"""Call format_data with list of dicts and plain format."""
context.sample_data = [
{"plan_id": "01HXYZ", "status": "applied"},
{"plan_id": "01HABC", "status": "pending"},
]
context.format_result = context.format_data(context.sample_data, "plain")
@then("the result should contain multiple entries separated by dashes")
def step_result_contains_multiple_entries(context: Any) -> None:
"""Assert the plain result contains multiple entries separated by ---."""
result = context.format_result
assert "plan_id" in result, f"Missing 'plan_id' in plain output: {result!r}"
assert "---" in result, f"Missing separator '---' in plain output: {result!r}"
@when('I call format_data with a dict containing list values and format "plain"')
def step_call_format_data_dict_with_list_plain(context: Any) -> None:
"""Call format_data with dict containing list values and plain format."""
context.sample_data = {
"plan_id": "01HXYZ",
"tags": ["tag1", "tag2", "tag3"],
}
context.format_result = context.format_data(context.sample_data, "plain")
@then("the result should contain list items with dash prefix")
def step_result_contains_list_items(context: Any) -> None:
"""Assert the plain result contains list items with dash prefix."""
result = context.format_result
assert "plan_id" in result, f"Missing 'plan_id' in plain output: {result!r}"
assert "tags" in result, f"Missing 'tags' in plain output: {result!r}"
assert "- tag1" in result, f"Missing '- tag1' in plain output: {result!r}"
@when('I call format_data with a dict containing a datetime value and format "json"')
def step_call_format_data_datetime_json(context: Any) -> None:
"""Call format_data with dict containing datetime value and JSON format."""
from datetime import UTC, datetime
context.sample_datetime = datetime(2026, 4, 24, 12, 0, 0, tzinfo=UTC)
context.sample_data = {
"plan_id": "01HXYZ",
"created_at": context.sample_datetime,
}
context.format_result = context.format_data(context.sample_data, "json")
@then("the datetime value should be serialized as ISO string")
def step_datetime_serialized_as_iso(context: Any) -> None:
"""Assert the datetime value is serialized as ISO string."""
result = json.loads(context.format_result)
assert "created_at" in result, f"Missing 'created_at' in result: {result}"
assert isinstance(result["created_at"], str), (
f"Expected str for datetime, got {type(result['created_at'])}"
)
assert "2026-04-24" in result["created_at"], (
f"Expected ISO date in 'created_at': {result['created_at']}"
)
@when('I call format_data with a dict containing an enum value and format "json"')
def step_call_format_data_enum_json(context: Any) -> None:
"""Call format_data with dict containing enum value and JSON format."""
from enum import Enum
class StatusEnum(Enum):
APPLIED = "applied"
PENDING = "pending"
context.sample_data = {
"plan_id": "01HXYZ",
"status": StatusEnum.APPLIED,
}
context.format_result = context.format_data(context.sample_data, "json")
@then("the enum value should be serialized as its value")
def step_enum_serialized_as_value(context: Any) -> None:
"""Assert the enum value is serialized as its .value."""
result = json.loads(context.format_result)
assert "status" in result, f"Missing 'status' in result: {result}"
assert result["status"] == "applied", (
f"Expected 'applied' for enum value, got {result['status']!r}"
)
@when('I call format_data with a single dict and format "table"')
def step_call_format_data_single_dict_table(context: Any) -> None:
"""Call format_data with single dict and table format."""
context.sample_data = {"plan_id": "01HXYZ", "status": "applied"}
context.format_result = context.format_data(context.sample_data, "table")
@when('I call format_data with a list containing dict values and format "table"')
def step_call_format_data_list_with_dict_values_table(context: Any) -> None:
"""Call format_data with list containing dict values and table format."""
context.sample_data = [
{"plan_id": "01HXYZ", "metadata": {"key": "value"}},
{"plan_id": "01HABC", "metadata": {"key": "other"}},
]
context.format_result = context.format_data(context.sample_data, "table")
@then("the result should contain serialized dict values")
def step_result_contains_serialized_dict_values(context: Any) -> None:
"""Assert the table result contains serialized dict values."""
result = context.format_result
assert "plan_id" in result, f"Missing 'plan_id' in table output: {result!r}"
assert "metadata" in result, f"Missing 'metadata' in table output: {result!r}"
+2 -4
View File
@@ -434,8 +434,7 @@ def step_artifacts_has_files(context: Context) -> None:
@then("the artifacts JSON should contain validation summary")
def step_artifacts_json_validation(context: Context) -> None:
parsed = json.loads(context.artifacts_output)
data = parsed["data"]
data = json.loads(context.artifacts_output)
assert "validation_summary" in data
assert data["validation_summary"]["total"] == 3
@@ -565,8 +564,7 @@ def step_plan_with_changeset_id_but_no_store_entry(context: Context) -> None:
@then("the artifacts JSON should contain apply summary")
def step_artifacts_json_apply_summary(context: Context) -> None:
parsed = json.loads(context.artifacts_output)
data = parsed["data"]
data = json.loads(context.artifacts_output)
assert "apply_summary" in data
assert data["apply_summary"]["files_changed"] == "7"
assert data["apply_summary"]["validations_run"] == "4"
+2
View File
@@ -84,6 +84,8 @@ dev = [
"vulture>=2.10",
# Complexity metrics
"radon>=6.0.1",
# Import boundary enforcement
"import-linter>=2.0",
]
tests = [
"behave==1.3.3",
+1 -1
View File
@@ -28,7 +28,7 @@ WF02 Mocked Generation Produces Test Artifacts Only
[Documentation] Verify mocked LLM responses generate deterministic test
... files, assert user-facing plan artifacts command path,
... and artifacts list only test paths (no prod edits).
[Tags] tdd_issue tdd_issue_4296 tdd_expected_fail mocking artifacts invariants
[Tags] tdd_issue tdd_issue_4296 mocking artifacts invariants
${result}= Run Process ${PYTHON} ${HELPER} mocked-generation-artifacts cwd=${WORKSPACE} timeout=120s on_timeout=kill
Log ${result.stdout}
Log ${result.stderr}
@@ -646,7 +646,7 @@ class PlanApplyService:
Returns:
Rendered artifacts string.
"""
from cleveragents.cli.formatting import format_output
from cleveragents.shared.output_format import format_data as format_output
plan = self._lifecycle.get_plan(plan_id)
changeset = self._resolve_changeset(plan)
+164
View File
@@ -0,0 +1,164 @@
"""Shared output formatting utilities for cross-layer use.
Provides lightweight serialisation helpers (JSON, YAML, plain text, ASCII
table) that can be used by any layer without importing from the CLI
presentation layer.
This module intentionally has **no** dependencies on ``cleveragents.cli.*``
so that Application and Domain layer code can format data for output without
creating a reverse dependency on the Presentation layer.
For full-featured CLI output (Rich console, colour, envelope wrapping, etc.)
use ``cleveragents.cli.formatting`` instead.
"""
from __future__ import annotations
import json
from datetime import datetime
from enum import Enum
from io import StringIO
from typing import Any
import yaml
from rich.console import Console
from rich.table import Table
def _serialize_value(value: Any) -> Any:
"""Recursively normalise values for JSON/YAML serialisation.
* ``datetime`` -> ISO-8601 string
* ``Enum`` -> ``.value``
* ``dict`` -> recursed
* ``list`` -> recursed
* everything else -> unchanged
"""
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, Enum):
return value.value
if isinstance(value, dict):
return {k: _serialize_value(v) for k, v in value.items()}
if isinstance(value, list):
return [_serialize_value(item) for item in value]
return value
def format_as_json(data: dict[str, Any] | list[dict[str, Any]]) -> str:
"""Render *data* as indented JSON."""
return json.dumps(_serialize_value(data), indent=2, default=str)
def format_as_yaml(data: dict[str, Any] | list[dict[str, Any]]) -> str:
"""Render *data* as YAML."""
return yaml.dump(
_serialize_value(data),
default_flow_style=False,
sort_keys=False,
allow_unicode=True,
).rstrip("\n")
def format_as_plain(data: dict[str, Any] | list[dict[str, Any]]) -> str:
"""Render *data* as plain ``key: value`` lines."""
if isinstance(data, list):
parts: list[str] = []
for idx, item in enumerate(data):
if idx > 0:
parts.append("---")
parts.append(_format_plain_dict(item))
return "\n".join(parts)
return _format_plain_dict(data)
def _format_plain_dict(data: dict[str, Any]) -> str:
lines: list[str] = []
for key, value in data.items():
serialised = _serialize_value(value)
if isinstance(serialised, dict):
lines.append(f"{key}:")
for k2, v2 in serialised.items():
lines.append(f" {k2}: {v2}")
elif isinstance(serialised, list):
lines.append(f"{key}:")
for item in serialised:
if isinstance(item, dict):
lines.append(f" - {json.dumps(item, default=str)}")
else:
lines.append(f" - {item}")
else:
lines.append(f"{key}: {serialised}")
return "\n".join(lines)
def format_as_table(data: dict[str, Any] | list[dict[str, Any]]) -> str:
"""Render *data* as an ASCII table (no Rich styling)."""
rows: list[dict[str, Any]] = [data] if isinstance(data, dict) else data
if not rows:
return "(empty)"
columns: list[str] = list(rows[0].keys())
for row in rows[1:]:
for key in row:
if key not in columns:
columns.append(key)
table = Table(show_header=True, show_edge=True)
for col in columns:
table.add_column(col)
for row in rows:
cells: list[str] = []
for col in columns:
val = row.get(col, "")
serialised = _serialize_value(val)
if isinstance(serialised, (dict, list)):
cells.append(json.dumps(serialised, default=str))
else:
cells.append(str(serialised))
table.add_row(*cells)
buf = StringIO()
console = Console(file=buf, width=200, no_color=True)
console.print(table)
return buf.getvalue().rstrip("\n")
def format_data(
data: dict[str, Any] | list[dict[str, Any]],
format_type: str,
) -> str:
"""Format *data* according to *format_type*.
Supported format types: ``json``, ``yaml``, ``plain``, ``table``.
Any other value falls back to JSON.
This function is intentionally minimal -- it does **not** wrap output in
the CLI envelope (``command``, ``status``, ``exit_code``, etc.) and does
**not** write to ``sys.stdout`` directly. It simply returns the rendered
string so the caller can decide what to do with it.
For full CLI output with envelope and Rich console support use
``cleveragents.cli.formatting.format_output`` instead.
"""
fmt = format_type.lower()
if fmt == "json":
return format_as_json(data)
if fmt == "yaml":
return format_as_yaml(data)
if fmt == "plain":
return format_as_plain(data)
if fmt == "table":
return format_as_table(data)
# Fallback: JSON
return format_as_json(data)
__all__ = [
"format_as_json",
"format_as_plain",
"format_as_table",
"format_as_yaml",
"format_data",
]