fix(cli): fix project context show JSON/YAML output fields #6680
@@ -162,6 +162,10 @@ ensuring data is stored with proper parameter values.
|
||||
LLM streaming output. A `SessionActorNotConfiguredError` is raised with exit
|
||||
code 1 when no actor is configured.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`agents project context show` JSON/YAML output** (#6323): Fixed structured output to include spec-required envelope (`command`, `status`, `exit_code`, `data`, `timing`, `messages`) and all four data sections (`context_policy`, `limits`, `summarization`, `current_usage`). Rich output now renders four panels including the new `Current Usage` panel.
|
||||
|
||||
### Added
|
||||
|
||||
- **A2A module rename BDD test suite** (#8615): Comprehensive Behave tests validating that the ACP→A2A module rename is complete — verifying all 22 A2A symbols are properly exported, no legacy ACP references remain in `.py` files under `cleveragents.a2a/`, and the module docstring uses current A2A naming. The step definitions include self-contained symbol lookups to avoid cross-scenario dependency failures.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Contributors
|
||||
|
||||
* HAL9000 <HAL9000@cleverthis.com>
|
||||
* Aditya Chhabra <aditya.chhabra@cleverthis.com>
|
||||
* Brent E. Edwards <brent.edwards@cleverthis.com>
|
||||
* HAL 9000 <hal9000@cleverthis.com>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
Feature: Issue 6323 coverage — _format_size edge cases and formatting helper paths
|
||||
|
||||
Background:
|
||||
Given a project context CLI in-memory database is initialized
|
||||
|
||||
@tdd_issue @tdd_issue_6323
|
||||
Scenario Outline: _format_size covers all size display branches
|
||||
When I invoke the _format_size helper with "<input>"
|
||||
Then the _format_size result should be "<expected>"
|
||||
|
||||
Examples:
|
||||
| input | expected |
|
||||
| None | (no limit) |
|
||||
| 0 | 0 bytes |
|
||||
| 1 | 1 byte |
|
||||
| 500 | 500 bytes |
|
||||
| 1024 | 1 KB |
|
||||
| 1536 | 1.5 KB |
|
||||
| 1572864 | 1.5 MB |
|
||||
| 1073741824 | 1 GB |
|
||||
| 2684354560 | 2.5 GB |
|
||||
| 1099511627776 | 1 TB |
|
||||
| 1649267441664 | 1.5 TB |
|
||||
|
||||
@tdd_issue @tdd_issue_6323
|
||||
Scenario: format_output wraps a dict message correctly in the json envelope
|
||||
When I invoke format_output json with dict message level "warn" text "dict-msg-test"
|
||||
Then the json envelope contains dict message with level "warn" text "dict-msg-test"
|
||||
|
||||
@tdd_issue @tdd_issue_6323
|
||||
Scenario: format_output raises ValueError for a dict message missing required keys
|
||||
When I invoke format_output json with malformed dict message
|
||||
Then a ValueError is raised for the malformed message
|
||||
|
||||
@tdd_issue @tdd_issue_6323
|
||||
Scenario: format_output plain output with empty-level dict message omits bracket prefix
|
||||
When I invoke format_output plain with empty-level dict message text "no-prefix-plain"
|
||||
Then the plain output contains "no-prefix-plain" without bracket prefix
|
||||
|
||||
@tdd_issue @tdd_issue_6323
|
||||
Scenario: context show rich renders Execution Environment panel when policy blob has env set
|
||||
Given a project "local/cov-ee" exists for context CLI
|
||||
And issue 6323 project context sample is configured for "local/cov-ee"
|
||||
And the context tier service has sample usage data for "local/cov-ee"
|
||||
When I run context show rich on "local/cov-ee" with execution environment and integer depth gradient
|
||||
Then the context show command should succeed
|
||||
And the context show output contains "Execution Environment"
|
||||
|
||||
@tdd_issue @tdd_issue_6323
|
||||
Scenario: context show rich handles depth gradient keys that cannot be parsed as integers
|
||||
Given a project "local/cov-dg" exists for context CLI
|
||||
And issue 6323 project context sample is configured for "local/cov-dg"
|
||||
And the context tier service has sample usage data for "local/cov-dg"
|
||||
When I run context show rich on "local/cov-dg" with non-integer depth gradient keys
|
||||
Then the context show command should succeed
|
||||
And the context show output contains "ACMS Pipeline"
|
||||
@@ -0,0 +1,24 @@
|
||||
Feature: Issue 6323 - project context show JSON/YAML output matches specification
|
||||
In order to comply with the CLI output specification
|
||||
As a developer
|
||||
I want agents project context show to emit the required envelope and data sections
|
||||
|
||||
Background:
|
||||
Given a project context CLI in-memory database is initialized
|
||||
And a project "local/ctx-app" exists for context CLI
|
||||
And issue 6323 project context sample is configured for "local/ctx-app"
|
||||
And the context tier service has sample usage data for "local/ctx-app"
|
||||
|
||||
@tdd_issue @tdd_issue_6323
|
||||
Scenario: JSON output includes spec-required sections and current usage data
|
||||
And the context show output format is "json"
|
||||
When I run context show on "local/ctx-app" with view "strategize"
|
||||
Then the context show command should succeed
|
||||
And the context show json output should match issue 6323 spec for "local/ctx-app" and view "strategize"
|
||||
|
||||
@tdd_issue @tdd_issue_6323
|
||||
Scenario: YAML output includes spec-required sections and current usage data
|
||||
And the context show output format is "yaml"
|
||||
When I run context show on "local/ctx-app" with view "strategize"
|
||||
Then the context show command should succeed
|
||||
And the context show yaml output should match issue 6323 spec for "local/ctx-app" and view "strategize"
|
||||
@@ -440,6 +440,7 @@ def step_m5_project_with_policy(context: Context) -> None:
|
||||
|
||||
@when("I m5 smoke invoke project context show")
|
||||
def step_m5_invoke_project_context_show(context: Context) -> None:
|
||||
from cleveragents.application.services.context_tiers import ContextTierService
|
||||
from cleveragents.cli.commands.project_context import _default_acms_config
|
||||
|
||||
mock_repo = MagicMock()
|
||||
@@ -447,6 +448,7 @@ def step_m5_invoke_project_context_show(context: Context) -> None:
|
||||
|
||||
mock_container = MagicMock()
|
||||
mock_container.session_factory.return_value = MagicMock()
|
||||
mock_container.context_tier_service.return_value = ContextTierService()
|
||||
|
||||
with (
|
||||
patch(
|
||||
@@ -465,6 +467,10 @@ def step_m5_invoke_project_context_show(context: Context) -> None:
|
||||
"cleveragents.cli.commands.project_context._read_acms_config",
|
||||
return_value=_default_acms_config(),
|
||||
),
|
||||
patch(
|
||||
"cleveragents.cli.commands.project_context._load_policy_json",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
from cleveragents.cli.commands.project_context import app as pc_app
|
||||
|
||||
|
||||
@@ -7,10 +7,12 @@ CLI commands using an in-memory SQLite database.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from contextlib import suppress
|
||||
from io import StringIO
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import yaml
|
||||
from behave import given, then, when # type: ignore[import-untyped]
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
@@ -139,7 +141,7 @@ def _run_with_container(context: Any, func: Any, *args: Any, **kwargs: Any) -> N
|
||||
soft_wrap=True,
|
||||
)
|
||||
|
||||
def _test_format_output(data, format_type):
|
||||
def _test_format_output(data, format_type, **kwargs):
|
||||
import sys as _sys
|
||||
from io import StringIO as _SIO
|
||||
|
||||
@@ -149,7 +151,7 @@ def _run_with_container(context: Any, func: Any, *args: Any, **kwargs: Any) -> N
|
||||
_old = _sys.stdout
|
||||
_sys.stdout = _b
|
||||
try:
|
||||
_r = _fo(data, format_type)
|
||||
_r = _fo(data, format_type, **kwargs)
|
||||
finally:
|
||||
_sys.stdout = _old
|
||||
return _r or _b.getvalue().rstrip("\n")
|
||||
@@ -329,7 +331,13 @@ def step_ctx_show(context: Any, project: str) -> None:
|
||||
context_show,
|
||||
)
|
||||
|
||||
_run_with_container(context, context_show, project=project)
|
||||
kwargs: dict[str, Any] = {}
|
||||
fmt_override = getattr(context, "ctx_show_format", None)
|
||||
if fmt_override is not None:
|
||||
kwargs["output_format"] = fmt_override
|
||||
del context.ctx_show_format
|
||||
|
||||
_run_with_container(context, context_show, project=project, **kwargs)
|
||||
|
||||
|
||||
@when('I run context show on "{project}" with view "{view}"')
|
||||
@@ -338,7 +346,13 @@ def step_ctx_show_view(context: Any, project: str, view: str) -> None:
|
||||
context_show,
|
||||
)
|
||||
|
||||
_run_with_container(context, context_show, project=project, view=view)
|
||||
kwargs: dict[str, Any] = {}
|
||||
fmt_override = getattr(context, "ctx_show_format", None)
|
||||
if fmt_override is not None:
|
||||
kwargs["output_format"] = fmt_override
|
||||
del context.ctx_show_format
|
||||
|
||||
_run_with_container(context, context_show, project=project, view=view, **kwargs)
|
||||
|
||||
|
||||
@when('I run context show on "{project}" with format "{fmt}"')
|
||||
@@ -443,6 +457,11 @@ def step_given_execute_view_res(context: Any, project: str, res: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
@given('the context show output format is "{fmt}"')
|
||||
def step_set_ctx_show_format(context: Any, fmt: str) -> None:
|
||||
context.ctx_show_format = fmt
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Then assertions
|
||||
# ------------------------------------------------------------------
|
||||
@@ -595,3 +614,382 @@ def step_ctx_output_valid_json(context: Any) -> None:
|
||||
raise AssertionError(
|
||||
f"Output is not valid JSON: {exc}\nOutput: {context.ctx_output!r}"
|
||||
) from exc
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Issue #6323 spec compliance helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
@given('issue 6323 project context sample is configured for "{project}"')
|
||||
def step_issue6323_configure_policy(context: Any, project: str) -> None:
|
||||
from cleveragents.cli.commands.project_context import (
|
||||
_format_size as _format_size_helper,
|
||||
)
|
||||
from cleveragents.cli.commands.project_context import context_set
|
||||
|
||||
max_file = 1_048_576
|
||||
max_total = 50 * 1_048_576
|
||||
_run_with_container(
|
||||
context,
|
||||
context_set,
|
||||
project=project,
|
||||
view="strategize",
|
||||
include_resource=["repo"],
|
||||
exclude_path=["**/node_modules/**"],
|
||||
hot_max_tokens=12_000,
|
||||
warm_max_decisions=50,
|
||||
cold_max_decisions=200,
|
||||
query_limit=20,
|
||||
summarize=True,
|
||||
summary_max_tokens=800,
|
||||
max_file_size=max_file,
|
||||
max_total_size=max_total,
|
||||
)
|
||||
|
||||
context.issue6323_expected = {
|
||||
"context_policy": {
|
||||
"project": project,
|
||||
"view": "strategize",
|
||||
"include_resources": ["repo"],
|
||||
"exclude_resources": [],
|
||||
"include_paths": [],
|
||||
"exclude_paths": ["**/node_modules/**"],
|
||||
},
|
||||
"limits": {
|
||||
"hot_max_tokens": 12_000,
|
||||
"warm_max_decisions": 50,
|
||||
"cold_max_decisions": 200,
|
||||
"query_limit": 20,
|
||||
"max_file_size": _format_size_helper(max_file),
|
||||
"max_total_size": _format_size_helper(max_total),
|
||||
},
|
||||
"summarization": {
|
||||
"enabled": True,
|
||||
"max_tokens": 800,
|
||||
},
|
||||
"current_usage": {
|
||||
"hot_context_tokens": 0,
|
||||
"hot_context_limit": 12_000,
|
||||
"warm_entries": 0,
|
||||
"warm_limit": 50,
|
||||
"cold_entries": 0,
|
||||
"cold_limit": 200,
|
||||
"indexed_resources": 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@given('the context tier service has sample usage data for "{project}"')
|
||||
def step_issue6323_seed_usage(context: Any, project: str) -> None:
|
||||
from cleveragents.application.services.context_tiers import ContextTierService
|
||||
from cleveragents.domain.models.acms.tiers import ContextTier, TieredFragment
|
||||
|
||||
svc = getattr(context, "ctx_tier_service", ContextTierService())
|
||||
context.ctx_tier_service = svc
|
||||
|
||||
svc._hot.clear()
|
||||
svc._warm.clear()
|
||||
svc._cold.clear()
|
||||
with suppress(Exception):
|
||||
svc._budget.max_tokens_hot = 12_000 # pragma: no cover - fallback guard
|
||||
|
||||
svc.store(
|
||||
TieredFragment(
|
||||
fragment_id="hot-frag",
|
||||
content="hot context fragment",
|
||||
tier=ContextTier.HOT,
|
||||
project_name=project,
|
||||
resource_id="res:repo",
|
||||
token_count=8_420,
|
||||
)
|
||||
)
|
||||
|
||||
for idx in range(12):
|
||||
svc.store(
|
||||
TieredFragment(
|
||||
fragment_id=f"warm-{idx}",
|
||||
content="warm fragment",
|
||||
tier=ContextTier.WARM,
|
||||
project_name=project,
|
||||
resource_id="res:repo",
|
||||
token_count=10,
|
||||
)
|
||||
)
|
||||
|
||||
for idx in range(47):
|
||||
svc.store(
|
||||
TieredFragment(
|
||||
fragment_id=f"cold-{idx}",
|
||||
content="cold fragment",
|
||||
tier=ContextTier.COLD,
|
||||
project_name=project,
|
||||
resource_id="res:repo",
|
||||
token_count=5,
|
||||
)
|
||||
)
|
||||
|
||||
context.issue6323_expected["current_usage"].update(
|
||||
{
|
||||
"hot_context_tokens": "***REDACTED***",
|
||||
"warm_entries": 12,
|
||||
"cold_entries": 47,
|
||||
"indexed_resources": 1,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _issue6323_parse_json(context: Any) -> dict[str, Any]:
|
||||
raw = context.ctx_output.strip()
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def _issue6323_parse_yaml(context: Any) -> dict[str, Any]:
|
||||
raw = context.ctx_output.strip()
|
||||
return yaml.safe_load(raw)
|
||||
|
||||
|
||||
def _issue6323_assert_payload(
|
||||
parsed: dict[str, Any], expected: dict[str, Any], project: str, view: str
|
||||
) -> None:
|
||||
assert parsed["command"] == "project context show"
|
||||
assert parsed["status"] == "ok"
|
||||
assert parsed["exit_code"] == 0
|
||||
assert "timing" in parsed and "duration_ms" in parsed["timing"]
|
||||
assert parsed["messages"] == ["Context policy loaded"]
|
||||
|
||||
payload = parsed.get("data")
|
||||
assert payload is not None, "Expected 'data' field in output payload"
|
||||
|
||||
context_policy = payload.get("context_policy")
|
||||
assert context_policy == expected["context_policy"], (
|
||||
f"Unexpected context_policy payload. Expected {expected['context_policy']}, got {context_policy}"
|
||||
)
|
||||
|
||||
limits = payload.get("limits")
|
||||
assert limits == expected["limits"], (
|
||||
f"Unexpected limits payload. Expected {expected['limits']}, got {limits}"
|
||||
)
|
||||
|
||||
summarization = payload.get("summarization")
|
||||
assert summarization == expected["summarization"], (
|
||||
"Unexpected summarization payload. "
|
||||
f"Expected {expected['summarization']}, got {summarization}"
|
||||
)
|
||||
|
||||
usage = payload.get("current_usage")
|
||||
assert usage == expected["current_usage"], (
|
||||
f"Unexpected current_usage payload. Expected {expected['current_usage']}, got {usage}"
|
||||
)
|
||||
|
||||
|
||||
@then(
|
||||
'the context show json output should match issue 6323 spec for "{project}" and view "{view}"'
|
||||
)
|
||||
def step_issue6323_assert_json(context: Any, project: str, view: str) -> None:
|
||||
parsed = _issue6323_parse_json(context)
|
||||
_issue6323_assert_payload(parsed, context.issue6323_expected, project, view)
|
||||
|
||||
|
||||
@then(
|
||||
'the context show yaml output should match issue 6323 spec for "{project}" and view "{view}"'
|
||||
)
|
||||
def step_issue6323_assert_yaml(context: Any, project: str, view: str) -> None:
|
||||
parsed = _issue6323_parse_yaml(context)
|
||||
_issue6323_assert_payload(parsed, context.issue6323_expected, project, view)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Issue 6323 coverage boost — _format_size edge cases
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I invoke the _format_size helper with "{input_val}"')
|
||||
def step_invoke_format_size_helper(context: Any, input_val: str) -> None:
|
||||
from cleveragents.cli.commands.project_context import _format_size
|
||||
|
||||
val: int | None = None if input_val == "None" else int(input_val)
|
||||
context.format_size_result = _format_size(val)
|
||||
|
||||
|
||||
@then('the _format_size result should be "{expected}"')
|
||||
def step_assert_format_size_helper_result(context: Any, expected: str) -> None:
|
||||
assert context.format_size_result == expected, (
|
||||
f"Expected {expected!r}, got {context.format_size_result!r}"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Issue 6323 coverage boost — format_output dict message paths
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I invoke format_output json with dict message level "{level}" text "{text}"')
|
||||
def step_invoke_format_output_dict_msg(context: Any, level: str, text: str) -> None:
|
||||
import io
|
||||
import sys
|
||||
|
||||
from cleveragents.cli.formatting import format_output
|
||||
|
||||
buf = io.StringIO()
|
||||
old = sys.stdout
|
||||
sys.stdout = buf
|
||||
try:
|
||||
format_output(
|
||||
{"k": "v"},
|
||||
"json",
|
||||
command="cov-test",
|
||||
status="ok",
|
||||
exit_code=0,
|
||||
messages=[{"level": level, "text": text}],
|
||||
)
|
||||
finally:
|
||||
sys.stdout = old
|
||||
context.cov_json_output = buf.getvalue()
|
||||
|
||||
|
||||
@then('the json envelope contains dict message with level "{level}" text "{text}"')
|
||||
def step_assert_json_envelope_dict_msg(context: Any, level: str, text: str) -> None:
|
||||
parsed = json.loads(context.cov_json_output.strip())
|
||||
msgs = parsed.get("messages", [])
|
||||
assert any(
|
||||
isinstance(m, dict) and m.get("level") == level and m.get("text") == text
|
||||
for m in msgs
|
||||
), f"Expected dict message with level={level!r} text={text!r} in {msgs!r}"
|
||||
|
||||
|
||||
@when("I invoke format_output json with malformed dict message")
|
||||
def step_invoke_format_output_malformed_dict(context: Any) -> None:
|
||||
import io
|
||||
import sys
|
||||
|
||||
from cleveragents.cli.formatting import format_output
|
||||
|
||||
buf = io.StringIO()
|
||||
old = sys.stdout
|
||||
sys.stdout = buf
|
||||
context.cov_raised = None
|
||||
try:
|
||||
format_output(
|
||||
{"k": "v"},
|
||||
"json",
|
||||
command="cov-test",
|
||||
status="ok",
|
||||
exit_code=0,
|
||||
messages=[{"bad_key": "missing level and text"}],
|
||||
)
|
||||
except ValueError as exc:
|
||||
context.cov_raised = str(exc)
|
||||
finally:
|
||||
sys.stdout = old
|
||||
|
||||
|
||||
@then("a ValueError is raised for the malformed message")
|
||||
def step_assert_value_error_malformed_msg(context: Any) -> None:
|
||||
assert context.cov_raised is not None, "Expected ValueError but none was raised"
|
||||
|
||||
|
||||
@when('I invoke format_output plain with empty-level dict message text "{text}"')
|
||||
def step_invoke_format_output_plain_empty_level(context: Any, text: str) -> None:
|
||||
import io
|
||||
import sys
|
||||
|
||||
from cleveragents.cli.formatting import format_output
|
||||
|
||||
buf = io.StringIO()
|
||||
old = sys.stdout
|
||||
sys.stdout = buf
|
||||
try:
|
||||
format_output(
|
||||
{},
|
||||
"plain",
|
||||
command="cov-test",
|
||||
status="ok",
|
||||
exit_code=0,
|
||||
messages=[{"level": "", "text": text}],
|
||||
)
|
||||
finally:
|
||||
sys.stdout = old
|
||||
context.cov_plain_output = buf.getvalue()
|
||||
|
||||
|
||||
@then('the plain output contains "{text}" without bracket prefix')
|
||||
def step_assert_plain_no_bracket_prefix(context: Any, text: str) -> None:
|
||||
output = context.cov_plain_output
|
||||
assert text in output, f"Expected {text!r} in plain output {output!r}"
|
||||
for line in output.strip().split("\n"):
|
||||
if text in line:
|
||||
assert not line.startswith("["), f"Line should not start with '[': {line!r}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Issue 6323 coverage boost — rich output paths (execution env, depth gradient)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
_COV_ACMS_BASE: dict[str, Any] = {
|
||||
"hot_max_tokens": 8192,
|
||||
"warm_max_decisions": 50,
|
||||
"cold_max_decisions": 200,
|
||||
"summarize": True,
|
||||
"temporal_scope": "current",
|
||||
"auto_refresh": True,
|
||||
"default_breadth": 3,
|
||||
"default_depth": 3,
|
||||
"skeleton_ratio": 0.15,
|
||||
"strategies": [],
|
||||
}
|
||||
|
||||
|
||||
@when(
|
||||
'I run context show rich on "{project}" with execution environment'
|
||||
" and integer depth gradient"
|
||||
)
|
||||
def step_ctx_show_rich_ee_int_gradient(context: Any, project: str) -> None:
|
||||
from cleveragents.cli.commands.project_context import context_show
|
||||
|
||||
acms = dict(_COV_ACMS_BASE)
|
||||
acms["depth_gradient"] = {"1": 0.8, "2": 0.5}
|
||||
with (
|
||||
patch(
|
||||
"cleveragents.cli.commands.project_context._load_policy_json",
|
||||
return_value={
|
||||
"execution_environment": "host",
|
||||
"execution_env_priority": "high",
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"cleveragents.cli.commands.project_context._read_acms_config",
|
||||
return_value=acms,
|
||||
),
|
||||
):
|
||||
_run_with_container(
|
||||
context, context_show, project=project, view=None, output_format="rich"
|
||||
)
|
||||
|
||||
|
||||
@when('I run context show rich on "{project}" with non-integer depth gradient keys')
|
||||
def step_ctx_show_rich_str_gradient(context: Any, project: str) -> None:
|
||||
from cleveragents.cli.commands.project_context import context_show
|
||||
|
||||
acms = dict(_COV_ACMS_BASE)
|
||||
acms["depth_gradient"] = {"hop_a": 0.8, "hop_b": 0.5}
|
||||
with (
|
||||
patch(
|
||||
"cleveragents.cli.commands.project_context._load_policy_json",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"cleveragents.cli.commands.project_context._read_acms_config",
|
||||
return_value=acms,
|
||||
),
|
||||
):
|
||||
_run_with_container(
|
||||
context, context_show, project=project, view=None, output_format="rich"
|
||||
)
|
||||
|
||||
|
||||
@then('the context show output contains "{text}"')
|
||||
def step_ctx_show_output_contains(context: Any, text: str) -> None:
|
||||
assert text in context.ctx_output, (
|
||||
f"Expected {text!r} in context show output.\nGot:\n{context.ctx_output}"
|
||||
)
|
||||
|
||||
@@ -357,14 +357,41 @@ def test_context_show_acms_config() -> None:
|
||||
if exit_code != 0:
|
||||
raise AssertionError(f"show failed with exit {exit_code}: {output}")
|
||||
|
||||
data = json.loads(output.strip())
|
||||
if "acms_config" not in data:
|
||||
raise AssertionError(f"Missing acms_config in output: {data.keys()}")
|
||||
if data["acms_config"]["hot_max_tokens"] != 5000:
|
||||
envelope = json.loads(output.strip())
|
||||
if envelope.get("command") != "project context show":
|
||||
raise AssertionError(f"Unexpected command value: {envelope.get('command')}")
|
||||
|
||||
payload = envelope.get("data")
|
||||
if not isinstance(payload, dict):
|
||||
raise AssertionError(f"Expected data payload dict, got: {type(payload)!r}")
|
||||
|
||||
context_policy = payload.get("context_policy")
|
||||
if not isinstance(context_policy, dict):
|
||||
raise AssertionError(f"Missing context_policy in payload: {payload.keys()}")
|
||||
if context_policy.get("project") != "local/ctx-robot":
|
||||
raise AssertionError(
|
||||
f"hot_max_tokens mismatch: {data['acms_config']['hot_max_tokens']}"
|
||||
f"context_policy project mismatch: {context_policy.get('project')}"
|
||||
)
|
||||
|
||||
limits = payload.get("limits")
|
||||
if not isinstance(limits, dict):
|
||||
raise AssertionError(f"Missing limits in payload: {payload.keys()}")
|
||||
if limits.get("hot_max_tokens") != 5000:
|
||||
raise AssertionError(f"hot_max_tokens mismatch: {limits.get('hot_max_tokens')}")
|
||||
|
||||
if "summarization" not in payload:
|
||||
raise AssertionError(
|
||||
f"Missing summarization section in payload: {payload.keys()}"
|
||||
)
|
||||
if "current_usage" not in payload:
|
||||
raise AssertionError(
|
||||
f"Missing current_usage section in payload: {payload.keys()}"
|
||||
)
|
||||
|
||||
messages = envelope.get("messages")
|
||||
if messages != ["Context policy loaded"]:
|
||||
raise AssertionError(f"Unexpected messages payload: {messages}")
|
||||
|
||||
print("context-show-acms-ok")
|
||||
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ from __future__ import annotations
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json as _json
|
||||
from typing import Annotated, Any
|
||||
from typing import Annotated, Any, cast
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
@@ -108,7 +108,18 @@ def _load_policy_json(
|
||||
).fetchone()
|
||||
if row is None or row[0] is None:
|
||||
return None
|
||||
return _json.loads(row[0]) # type: ignore[no-any-return]
|
||||
blob = row[0]
|
||||
if isinstance(blob, (bytes, bytearray)):
|
||||
raw = blob.decode("utf-8")
|
||||
else:
|
||||
raw = str(blob)
|
||||
loaded = _json.loads(raw)
|
||||
if not isinstance(loaded, dict):
|
||||
raise TypeError(
|
||||
"Stored context_policy_json must be a JSON object, "
|
||||
f"got {type(loaded).__name__}"
|
||||
)
|
||||
return cast(dict[str, Any], loaded)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@@ -195,6 +206,32 @@ def _default_acms_config() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _format_size(value: int | None) -> str:
|
||||
"""Return a human-readable string for *value* in bytes."""
|
||||
|
||||
if value is None:
|
||||
return "(no limit)"
|
||||
if value <= 0:
|
||||
return "0 bytes"
|
||||
|
||||
units: tuple[tuple[str, int], ...] = (
|
||||
("TB", 1024**4),
|
||||
("GB", 1024**3),
|
||||
("MB", 1024**2),
|
||||
("KB", 1024),
|
||||
)
|
||||
for unit, factor in units:
|
||||
if value >= factor:
|
||||
quotient = value / factor
|
||||
if quotient.is_integer():
|
||||
return f"{int(quotient)} {unit}"
|
||||
return f"{quotient:.1f} {unit}"
|
||||
|
||||
if value == 1:
|
||||
return "1 byte"
|
||||
return f"{value} bytes"
|
||||
|
||||
|
||||
def _write_policy(
|
||||
session_factory: Any,
|
||||
namespaced_name: str,
|
||||
@@ -794,125 +831,214 @@ def context_show(
|
||||
policy = _read_policy(session_factory, project)
|
||||
acms = _read_acms_config(session_factory, project)
|
||||
|
||||
if view is not None:
|
||||
if view not in VALID_PHASES:
|
||||
err_console.print(
|
||||
f"[red]Invalid view '{view}': must be one of {PHASE_NAMES}[/red]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
resolved = policy.resolve_view(view)
|
||||
data: dict[str, Any] = {
|
||||
"phase": view,
|
||||
"resolved_view": resolved.model_dump(mode="json"),
|
||||
"acms_config": acms,
|
||||
}
|
||||
else:
|
||||
data = _policy_to_dict(policy)
|
||||
data["acms_config"] = acms
|
||||
# Include execution environment fields from raw persisted blob
|
||||
try:
|
||||
raw_blob = _load_policy_json(session_factory, project)
|
||||
except Exception:
|
||||
raw_blob = None
|
||||
if raw_blob:
|
||||
for key in ("execution_environment", "execution_env_priority"):
|
||||
if key in raw_blob:
|
||||
data[key] = raw_blob[key]
|
||||
active_view = view or "default"
|
||||
if active_view not in VALID_PHASES:
|
||||
err_console.print(
|
||||
f"[red]Invalid view '{active_view}': must be one of {PHASE_NAMES}[/red]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
if output_format.lower() == OutputFormat.RICH:
|
||||
title = f"Context Policy: {project}"
|
||||
if view:
|
||||
title += f" (phase: {view})"
|
||||
lines: list[str] = []
|
||||
if view:
|
||||
rv = policy.resolve_view(view)
|
||||
lines.append(f"[bold]Phase:[/bold] {view}")
|
||||
lines.append(
|
||||
f"[bold]Include resources:[/bold] {rv.include_resources or '(all)'}"
|
||||
)
|
||||
lines.append(
|
||||
f"[bold]Exclude resources:[/bold] {rv.exclude_resources or '(none)'}"
|
||||
)
|
||||
lines.append(f"[bold]Include paths:[/bold] {rv.include_paths or '(all)'}")
|
||||
lines.append(f"[bold]Exclude paths:[/bold] {rv.exclude_paths or '(none)'}")
|
||||
lines.append(
|
||||
f"[bold]Max file size:[/bold] {rv.max_file_size or '(no limit)'}"
|
||||
)
|
||||
lines.append(
|
||||
f"[bold]Max total size:[/bold] {rv.max_total_size or '(no limit)'}"
|
||||
)
|
||||
else:
|
||||
for phase in [
|
||||
"default",
|
||||
"strategize",
|
||||
"execute",
|
||||
"apply",
|
||||
]:
|
||||
view_obj = getattr(policy, f"{phase}_view")
|
||||
if view_obj is not None:
|
||||
lines.append(f"[bold]{phase}:[/bold] configured")
|
||||
else:
|
||||
lines.append(f"[bold]{phase}:[/bold] (inherits)")
|
||||
# Execution environment section
|
||||
try:
|
||||
raw_blob = _load_policy_json(session_factory, project)
|
||||
except Exception:
|
||||
raw_blob = None
|
||||
ee = (raw_blob or {}).get("execution_environment")
|
||||
eep = (raw_blob or {}).get("execution_env_priority")
|
||||
if ee or eep:
|
||||
lines.append("")
|
||||
lines.append("[bold]Execution Environment:[/bold]")
|
||||
if ee:
|
||||
lines.append(f" Environment: {ee}")
|
||||
if eep:
|
||||
lines.append(f" Priority: {eep}")
|
||||
resolved_view = policy.resolve_view(active_view)
|
||||
include_resources = list(resolved_view.include_resources or [])
|
||||
exclude_resources = list(resolved_view.exclude_resources or [])
|
||||
include_paths = list(resolved_view.include_paths or [])
|
||||
exclude_paths = list(resolved_view.exclude_paths or [])
|
||||
|
||||
# ACMS pipeline config section
|
||||
lines.append("")
|
||||
lines.append("[bold]ACMS Pipeline Config:[/bold]")
|
||||
lines.append(
|
||||
f" Hot max tokens: {acms.get('hot_max_tokens', _DEFAULT_HOT_MAX_TOKENS)}"
|
||||
)
|
||||
lines.append(
|
||||
f" Warm max decisions: "
|
||||
f"{acms.get('warm_max_decisions', _DEFAULT_WARM_MAX_DECISIONS)}"
|
||||
)
|
||||
lines.append(
|
||||
f" Cold max decisions: "
|
||||
f"{acms.get('cold_max_decisions', _DEFAULT_COLD_MAX_DECISIONS)}"
|
||||
)
|
||||
lines.append(f" Summarize: {acms.get('summarize', True)}")
|
||||
lines.append(f" Temporal scope: {acms.get('temporal_scope', 'current')}")
|
||||
lines.append(f" Auto-refresh: {acms.get('auto_refresh', True)}")
|
||||
strategies = acms.get("strategies", [])
|
||||
lines.append(
|
||||
f" Strategies: {', '.join(strategies) if strategies else '(default)'}"
|
||||
)
|
||||
lines.append(
|
||||
f" Default breadth: {acms.get('default_breadth', _DEFAULT_BREADTH)}"
|
||||
)
|
||||
lines.append(f" Default depth: {acms.get('default_depth', _DEFAULT_DEPTH)}")
|
||||
dg = acms.get("depth_gradient", {})
|
||||
if dg:
|
||||
hot_limit = int(acms.get("hot_max_tokens") or _DEFAULT_HOT_MAX_TOKENS)
|
||||
warm_limit = int(acms.get("warm_max_decisions") or _DEFAULT_WARM_MAX_DECISIONS)
|
||||
cold_limit = int(acms.get("cold_max_decisions") or _DEFAULT_COLD_MAX_DECISIONS)
|
||||
query_limit = acms.get("query_limit")
|
||||
summarize_enabled = bool(acms.get("summarize", True))
|
||||
summary_tokens = acms.get("summary_max_tokens")
|
||||
|
||||
tier_service = _get_context_tier_service()
|
||||
metrics: TierMetrics = tier_service.get_scoped_metrics([project])
|
||||
fragments: list[TieredFragment] = tier_service.get_scoped_view([project])
|
||||
|
||||
hot_tokens = sum(
|
||||
fragment.token_count
|
||||
for fragment in fragments
|
||||
if fragment.tier == ContextTier.HOT
|
||||
)
|
||||
indexed_resources = len({f.resource_id for f in fragments if f.resource_id})
|
||||
|
||||
structured_data: dict[str, Any] = {
|
||||
"context_policy": {
|
||||
"project": project,
|
||||
"view": active_view,
|
||||
"include_resources": include_resources,
|
||||
"exclude_resources": exclude_resources,
|
||||
"include_paths": include_paths,
|
||||
"exclude_paths": exclude_paths,
|
||||
},
|
||||
"limits": {
|
||||
"hot_max_tokens": hot_limit,
|
||||
"warm_max_decisions": warm_limit,
|
||||
"cold_max_decisions": cold_limit,
|
||||
"query_limit": query_limit,
|
||||
"max_file_size": _format_size(resolved_view.max_file_size),
|
||||
"max_total_size": _format_size(resolved_view.max_total_size),
|
||||
},
|
||||
"summarization": {
|
||||
"enabled": summarize_enabled,
|
||||
"max_tokens": summary_tokens,
|
||||
},
|
||||
"current_usage": {
|
||||
"hot_context_tokens": hot_tokens,
|
||||
"hot_context_limit": hot_limit,
|
||||
"warm_entries": metrics.warm_count,
|
||||
"warm_limit": warm_limit,
|
||||
"cold_entries": metrics.cold_count,
|
||||
"cold_limit": cold_limit,
|
||||
"indexed_resources": indexed_resources,
|
||||
},
|
||||
}
|
||||
|
||||
fmt = output_format.lower()
|
||||
command_name = "project context show"
|
||||
success_message = "Context policy loaded"
|
||||
|
||||
include_resources_display = (
|
||||
", ".join(include_resources) if include_resources else "(all)"
|
||||
)
|
||||
exclude_paths_display = ", ".join(exclude_paths) if exclude_paths else "(none)"
|
||||
include_paths_display = ", ".join(include_paths)
|
||||
exclude_resources_display = ", ".join(exclude_resources)
|
||||
query_limit_display = str(query_limit) if query_limit is not None else "(no limit)"
|
||||
summary_tokens_display = (
|
||||
str(summary_tokens) if summary_tokens is not None else "(auto)"
|
||||
)
|
||||
|
||||
if fmt == OutputFormat.RICH:
|
||||
policy_lines = [
|
||||
f"[cyan]Project:[/cyan] {project}",
|
||||
f"[blue]View:[/blue] {active_view}",
|
||||
f"[green]Include:[/green] {include_resources_display}",
|
||||
f"[yellow]Exclude:[/yellow] {exclude_paths_display}",
|
||||
]
|
||||
if include_paths:
|
||||
policy_lines.append(
|
||||
f"[green]Include paths:[/green] {include_paths_display}"
|
||||
)
|
||||
if exclude_resources:
|
||||
policy_lines.append(
|
||||
f"[yellow]Exclude resources:[/yellow] {exclude_resources_display}"
|
||||
)
|
||||
|
||||
limits = structured_data["limits"]
|
||||
limits_lines = [
|
||||
f"[magenta]Hot Tokens:[/magenta] {limits['hot_max_tokens']:,}",
|
||||
f"[magenta]Warm Decisions:[/magenta] {limits['warm_max_decisions']:,}",
|
||||
f"[magenta]Cold Decisions:[/magenta] {limits['cold_max_decisions']:,}",
|
||||
f"[cyan]Query Limit:[/cyan] {query_limit_display}",
|
||||
f"[cyan]Max File Size:[/cyan] {limits['max_file_size']}",
|
||||
f"[cyan]Max Total Size:[/cyan] {limits['max_total_size']}",
|
||||
]
|
||||
|
||||
summarization = structured_data["summarization"]
|
||||
summarization_lines = [
|
||||
"[green]Enabled:[/green] yes"
|
||||
if summarization["enabled"]
|
||||
else "[green]Enabled:[/green] no",
|
||||
f"[blue]Max Tokens:[/blue] {summary_tokens_display}",
|
||||
]
|
||||
|
||||
usage = structured_data["current_usage"]
|
||||
usage_lines = [
|
||||
(
|
||||
f"[blue]Hot Context:[/blue] "
|
||||
f"{usage['hot_context_tokens']:,} / {usage['hot_context_limit']:,}"
|
||||
),
|
||||
(
|
||||
f"[blue]Warm Entries:[/blue] "
|
||||
f"{usage['warm_entries']:,} / {usage['warm_limit']:,}"
|
||||
),
|
||||
(
|
||||
f"[blue]Cold Entries:[/blue] "
|
||||
f"{usage['cold_entries']:,} / {usage['cold_limit']:,}"
|
||||
),
|
||||
f"[green]Indexed Resources:[/green] {usage['indexed_resources']:,}",
|
||||
]
|
||||
|
||||
raw_policy_blob = _load_policy_json(session_factory, project) or {}
|
||||
execution_environment = raw_policy_blob.get("execution_environment")
|
||||
env_priority = raw_policy_blob.get("execution_env_priority")
|
||||
execution_lines: list[str] = []
|
||||
if execution_environment:
|
||||
execution_lines.append(f"[cyan]Environment:[/cyan] {execution_environment}")
|
||||
if env_priority:
|
||||
execution_lines.append(f"[cyan]Priority:[/cyan] {env_priority}")
|
||||
|
||||
temporal_scope = acms.get("temporal_scope", "current")
|
||||
auto_refresh = "yes" if acms.get("auto_refresh", True) else "no"
|
||||
strategies = acms.get("strategies") or []
|
||||
strategies_display = ", ".join(strategies) if strategies else "(default)"
|
||||
default_breadth = acms.get("default_breadth", _DEFAULT_BREADTH)
|
||||
default_depth = acms.get("default_depth", _DEFAULT_DEPTH)
|
||||
depth_gradient = acms.get("depth_gradient") or {}
|
||||
skeleton_ratio = acms.get("skeleton_ratio", _DEFAULT_SKELETON_RATIO)
|
||||
|
||||
def _gradient_key(entry: tuple[Any, Any]) -> int | str:
|
||||
hop, _ = entry
|
||||
try:
|
||||
return int(str(hop))
|
||||
except (TypeError, ValueError):
|
||||
return str(hop)
|
||||
|
||||
if depth_gradient:
|
||||
gradient_parts = [
|
||||
f"{h}:{v}" for h, v in sorted(dg.items(), key=lambda x: int(x[0]))
|
||||
f"{hop}:{value}"
|
||||
for hop, value in sorted(depth_gradient.items(), key=_gradient_key)
|
||||
]
|
||||
lines.append(f" Depth gradient: {', '.join(gradient_parts)}")
|
||||
gradient_display = ", ".join(gradient_parts)
|
||||
else:
|
||||
lines.append(" Depth gradient: (none)")
|
||||
lines.append(
|
||||
f" Skeleton ratio: {acms.get('skeleton_ratio', _DEFAULT_SKELETON_RATIO)}"
|
||||
gradient_display = "(none)"
|
||||
|
||||
pipeline_lines = [
|
||||
f"[cyan]Temporal Scope:[/cyan] {temporal_scope}",
|
||||
f"[cyan]Auto Refresh:[/cyan] {auto_refresh}",
|
||||
f"[cyan]Strategies:[/cyan] {strategies_display}",
|
||||
f"[cyan]Default Breadth:[/cyan] {default_breadth}",
|
||||
f"[cyan]Default Depth:[/cyan] {default_depth}",
|
||||
f"[cyan]Depth gradient:[/cyan] {gradient_display}",
|
||||
f"[cyan]Skeleton Ratio:[/cyan] {skeleton_ratio}",
|
||||
]
|
||||
|
||||
console.print(
|
||||
Panel("\n".join(policy_lines), title="Context Policy", expand=False)
|
||||
)
|
||||
console.print(Panel("\n".join(limits_lines), title="Limits", expand=False))
|
||||
console.print(
|
||||
Panel("\n".join(summarization_lines), title="Summarization", expand=False)
|
||||
)
|
||||
console.print(
|
||||
Panel(
|
||||
"\n".join(lines),
|
||||
title=title,
|
||||
expand=False,
|
||||
)
|
||||
Panel("\n".join(usage_lines), title="Current Usage", expand=False)
|
||||
)
|
||||
else:
|
||||
console.print(format_output(data, output_format))
|
||||
if execution_lines:
|
||||
console.print(
|
||||
Panel(
|
||||
"\n".join(execution_lines),
|
||||
title="Execution Environment",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
console.print(
|
||||
Panel("\n".join(pipeline_lines), title="ACMS Pipeline", expand=False)
|
||||
)
|
||||
console.print("[green]✓ OK[/green] Context policy loaded")
|
||||
return
|
||||
|
||||
rendered = format_output(
|
||||
structured_data,
|
||||
output_format,
|
||||
command=command_name,
|
||||
status="ok",
|
||||
exit_code=0,
|
||||
messages=[success_message],
|
||||
)
|
||||
|
||||
if rendered:
|
||||
console.print(rendered)
|
||||
|
||||
|
||||
@app.command(name="inspect")
|
||||
|
||||
@@ -16,6 +16,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime
|
||||
from enum import Enum, StrEnum
|
||||
from io import StringIO
|
||||
@@ -216,74 +217,65 @@ def _redact_data(
|
||||
_VALID_STATUSES: frozenset[str] = frozenset({"ok", "warn", "error"})
|
||||
|
||||
|
||||
def _prepare_messages(
|
||||
messages: Sequence[str | Mapping[str, Any]] | None,
|
||||
command: str,
|
||||
status: str,
|
||||
) -> tuple[list[str | dict[str, str]], list[tuple[str, str]]]:
|
||||
"""Normalise messages for envelope and plain output rendering."""
|
||||
if not messages:
|
||||
text_msg = f"{command} completed" if command else "ok"
|
||||
return ([{"level": status, "text": text_msg}], [(status, text_msg)])
|
||||
|
||||
envelope_messages: list[str | dict[str, str]] = []
|
||||
plain_pairs: list[tuple[str, str]] = []
|
||||
for msg in messages:
|
||||
if isinstance(msg, str):
|
||||
envelope_messages.append(msg)
|
||||
plain_pairs.append((status, msg))
|
||||
continue
|
||||
if "level" not in msg or "text" not in msg:
|
||||
raise ValueError(
|
||||
f"Each message must have 'level' and 'text' keys, got {msg!r}"
|
||||
)
|
||||
level = str(msg["level"])
|
||||
text_value = msg["text"]
|
||||
text_str = str(text_value)
|
||||
envelope_messages.append({"level": level, "text": text_str})
|
||||
plain_pairs.append((level, text_str))
|
||||
return envelope_messages, plain_pairs
|
||||
|
||||
|
||||
def _format_plain_messages(messages: Sequence[tuple[str, str]]) -> list[str]:
|
||||
"""Render message pairs for plain output."""
|
||||
lines: list[str] = []
|
||||
for level, text_value in messages:
|
||||
level_str = level.upper() if level else ""
|
||||
prefix = f"[{level_str}] " if level_str else ""
|
||||
lines.append(f"{prefix}{text_value}")
|
||||
return lines
|
||||
|
||||
|
||||
def _build_envelope(
|
||||
data: dict[str, Any] | list[dict[str, Any]],
|
||||
command: str,
|
||||
status: str,
|
||||
exit_code: int,
|
||||
duration_ms: int,
|
||||
messages: list[dict[str, Any]] | None,
|
||||
messages: Sequence[str | dict[str, str]],
|
||||
) -> dict[str, Any]:
|
||||
"""Build the spec-required JSON/YAML output envelope.
|
||||
|
||||
The envelope wraps command-specific payload in the structure required
|
||||
by the specification (§Output Rendering Framework):
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"command": "<command that was run>",
|
||||
"status": "ok",
|
||||
"exit_code": 0,
|
||||
"data": { ... },
|
||||
"timing": { "duration_ms": 123 },
|
||||
"messages": [{ "level": "ok", "text": "..." }]
|
||||
}
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data:
|
||||
The command-specific payload to wrap.
|
||||
command:
|
||||
The CLI command string that produced this output.
|
||||
status:
|
||||
Envelope status: must be one of ``"ok"``, ``"warn"``, or ``"error"``.
|
||||
exit_code:
|
||||
Envelope exit code; must be a non-negative integer.
|
||||
duration_ms:
|
||||
Elapsed time in milliseconds.
|
||||
messages:
|
||||
Optional list of message dicts; each must have ``"level"`` and
|
||||
``"text"`` keys. If *None*, a default single-entry list is generated.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If *status* is not one of the allowed values, *exit_code* is negative,
|
||||
or any entry in *messages* is missing the required ``"level"`` or
|
||||
``"text"`` keys.
|
||||
"""
|
||||
"""Build the spec-required JSON/YAML output envelope."""
|
||||
if status not in _VALID_STATUSES:
|
||||
raise ValueError(f"status must be one of {_VALID_STATUSES!r}, got {status!r}")
|
||||
if exit_code < 0:
|
||||
raise ValueError(f"exit_code must be non-negative, got {exit_code!r}")
|
||||
if messages is not None:
|
||||
for msg in messages:
|
||||
if "level" not in msg or "text" not in msg:
|
||||
raise ValueError(
|
||||
f"Each message must have 'level' and 'text' keys, got {msg!r}"
|
||||
)
|
||||
if messages is None:
|
||||
messages = [
|
||||
{"level": status, "text": f"{command} completed" if command else "ok"}
|
||||
]
|
||||
return {
|
||||
"command": command,
|
||||
"status": status,
|
||||
"exit_code": exit_code,
|
||||
"data": data,
|
||||
"timing": {"duration_ms": duration_ms},
|
||||
"messages": messages,
|
||||
"messages": list(messages),
|
||||
}
|
||||
|
||||
|
||||
@@ -294,7 +286,7 @@ def format_output(
|
||||
command: str = "",
|
||||
status: str = "ok",
|
||||
exit_code: int = 0,
|
||||
messages: list[dict[str, Any]] | None = None,
|
||||
messages: Sequence[str | Mapping[str, Any]] | None = None,
|
||||
) -> str:
|
||||
"""Format *data* according to *format_type*.
|
||||
|
||||
@@ -305,7 +297,8 @@ def format_output(
|
||||
For machine-readable formats (json, yaml) the output is wrapped in
|
||||
the spec-required envelope (``command``, ``status``, ``exit_code``,
|
||||
``data``, ``timing``, ``messages``) before serialisation. Plain
|
||||
format renders the raw data without an envelope.
|
||||
format renders the raw data without an envelope but still appends
|
||||
the human-readable messages block.
|
||||
|
||||
For machine-readable formats (json, yaml, plain) the output is
|
||||
written directly to ``sys.stdout`` to avoid Rich ``console.print``
|
||||
@@ -327,8 +320,9 @@ def format_output(
|
||||
exit_code:
|
||||
Envelope exit_code field (integer, typically 0 for success).
|
||||
messages:
|
||||
Envelope messages array. If *None*, a default single-entry list
|
||||
is generated from *command* and *status*.
|
||||
Envelope messages array. Entries may be plain strings or dicts
|
||||
with ``level``/``text`` keys. If *None*, a default single-entry
|
||||
list is generated from *command* and *status*.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -340,8 +334,8 @@ def format_output(
|
||||
safe_data = _redact_data(data)
|
||||
fmt = format_type.lower()
|
||||
|
||||
# Machine-readable formats: write directly to stdout to avoid
|
||||
# Rich console.print() line-wrapping artefacts.
|
||||
envelope_messages, plain_pairs = _prepare_messages(messages, command, status)
|
||||
|
||||
if fmt in (
|
||||
OutputFormat.JSON.value,
|
||||
OutputFormat.YAML.value,
|
||||
@@ -350,17 +344,32 @@ def format_output(
|
||||
if fmt == OutputFormat.JSON.value:
|
||||
duration_ms = int((time.monotonic() - t_start) * 1000)
|
||||
envelope = _build_envelope(
|
||||
safe_data, command, status, exit_code, duration_ms, messages
|
||||
safe_data,
|
||||
command,
|
||||
status,
|
||||
exit_code,
|
||||
duration_ms,
|
||||
envelope_messages,
|
||||
)
|
||||
rendered = _format_json(envelope)
|
||||
elif fmt == OutputFormat.YAML.value:
|
||||
duration_ms = int((time.monotonic() - t_start) * 1000)
|
||||
envelope = _build_envelope(
|
||||
safe_data, command, status, exit_code, duration_ms, messages
|
||||
safe_data,
|
||||
command,
|
||||
status,
|
||||
exit_code,
|
||||
duration_ms,
|
||||
envelope_messages,
|
||||
)
|
||||
rendered = _format_yaml(envelope)
|
||||
else:
|
||||
rendered = _format_plain(safe_data)
|
||||
message_lines = _format_plain_messages(plain_pairs)
|
||||
if message_lines:
|
||||
prefix = f"{rendered}\n" if rendered else ""
|
||||
message_block = "\n".join(message_lines)
|
||||
rendered = f"{prefix}{message_block}"
|
||||
sys.stdout.write(rendered + "\n")
|
||||
sys.stdout.flush()
|
||||
return ""
|
||||
|
||||
Reference in New Issue
Block a user