forked from cleveragents/cleveragents-core
fix(cli): write machine-readable formats directly to stdout bypassing Rich line-wrapping
The format_output() function returned a string that callers passed to Rich console.print(), which wraps long lines at terminal width. This injected literal newline characters into JSON string values (e.g. in definition_of_done fields), producing invalid JSON that downstream parsers could not decode (JSONDecodeError: Invalid control character). For machine-readable formats (json, yaml, plain), format_output() now writes the rendered output directly to sys.stdout and returns an empty string. This preserves the exact serialization from json.dumps/ yaml.dump without Rich text processing artifacts. Refs: #746
This commit is contained in:
@@ -211,6 +211,7 @@ def step_set_default_aborted(context):
|
||||
|
||||
@when("I print that actor in json format")
|
||||
def step_print_actor_json(context):
|
||||
import contextlib
|
||||
from io import StringIO
|
||||
|
||||
from rich.console import Console
|
||||
@@ -218,7 +219,10 @@ def step_print_actor_json(context):
|
||||
buf = StringIO()
|
||||
test_console = Console(file=buf, force_terminal=False)
|
||||
|
||||
with patch("cleveragents.cli.commands.actor.console", test_console):
|
||||
with (
|
||||
patch("cleveragents.cli.commands.actor.console", test_console),
|
||||
contextlib.redirect_stdout(buf),
|
||||
):
|
||||
_print_actor(context.test_actor, title="Test", fmt="json")
|
||||
|
||||
context.print_output = buf.getvalue()
|
||||
|
||||
@@ -60,7 +60,14 @@ def _format_data(data: dict[str, Any], fmt: str) -> str:
|
||||
render_version_rich(data)
|
||||
|
||||
return buf.getvalue()
|
||||
return format_output(data, fmt)
|
||||
# Machine-readable formats write to sys.stdout; capture it.
|
||||
from contextlib import redirect_stdout
|
||||
from io import StringIO
|
||||
|
||||
buf = StringIO()
|
||||
with redirect_stdout(buf):
|
||||
result = format_output(data, fmt)
|
||||
return result or buf.getvalue().rstrip("\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -351,6 +351,17 @@ def step_plan_cancel_json(context: Context) -> None:
|
||||
# ------- Direct formatting module tests -------
|
||||
|
||||
|
||||
def _capture_format_output(data, fmt):
|
||||
"""Call format_output capturing stdout (machine-readable formats write there)."""
|
||||
from contextlib import redirect_stdout
|
||||
from io import StringIO
|
||||
|
||||
buf = StringIO()
|
||||
with redirect_stdout(buf):
|
||||
result = format_output(data, fmt)
|
||||
return result or buf.getvalue().rstrip("\n")
|
||||
|
||||
|
||||
@when("I call format_output with a dict and format json")
|
||||
def step_call_format_json(context: Context) -> None:
|
||||
data = {
|
||||
@@ -358,13 +369,13 @@ def step_call_format_json(context: Context) -> None:
|
||||
"count": 42,
|
||||
"created_at": datetime(2025, 1, 15).isoformat(),
|
||||
}
|
||||
context.format_result = format_output(data, "json")
|
||||
context.format_result = _capture_format_output(data, "json")
|
||||
|
||||
|
||||
@when("I call format_output with a dict and format yaml")
|
||||
def step_call_format_yaml(context: Context) -> None:
|
||||
data = {"name": "test", "count": 42}
|
||||
context.format_result = format_output(data, "yaml")
|
||||
context.format_result = _capture_format_output(data, "yaml")
|
||||
|
||||
|
||||
@when("I call format_output with a dict and format plain")
|
||||
@@ -374,19 +385,19 @@ def step_call_format_plain(context: Context) -> None:
|
||||
"nested": {"a": 1, "b": 2},
|
||||
"items": ["x", "y"],
|
||||
}
|
||||
context.format_result = format_output(data, "plain")
|
||||
context.format_result = _capture_format_output(data, "plain")
|
||||
|
||||
|
||||
@when("I call format_output with a dict and format table")
|
||||
def step_call_format_table(context: Context) -> None:
|
||||
data = {"name": "test", "count": 42}
|
||||
context.format_result = format_output(data, "table")
|
||||
context.format_result = _capture_format_output(data, "table")
|
||||
|
||||
|
||||
@when("I call format_output with a dict and format rich")
|
||||
def step_call_format_rich(context: Context) -> None:
|
||||
data = {"name": "test", "count": 42}
|
||||
context.format_result = format_output(data, "rich")
|
||||
context.format_result = _capture_format_output(data, "rich")
|
||||
|
||||
|
||||
@when("I call format_output with a list and format plain")
|
||||
@@ -395,7 +406,7 @@ def step_call_format_list_plain(context: Context) -> None:
|
||||
{"name": "a", "value": 1},
|
||||
{"name": "b", "value": 2},
|
||||
]
|
||||
context.format_result = format_output(data, "plain")
|
||||
context.format_result = _capture_format_output(data, "plain")
|
||||
|
||||
|
||||
@when("I call serialize_value with enum and nested data")
|
||||
|
||||
@@ -121,6 +121,23 @@ def _mock_wiring_container(context: Any) -> MagicMock:
|
||||
return mock_container
|
||||
|
||||
|
||||
def _test_format_output(data, format_type):
|
||||
"""Test-friendly format_output: captures stdout writes and returns the string."""
|
||||
import sys
|
||||
from io import StringIO
|
||||
|
||||
from cleveragents.cli.formatting import format_output
|
||||
|
||||
buf = StringIO()
|
||||
old = sys.stdout
|
||||
sys.stdout = buf
|
||||
try:
|
||||
result = format_output(data, format_type)
|
||||
finally:
|
||||
sys.stdout = old
|
||||
return result or buf.getvalue().rstrip("\n")
|
||||
|
||||
|
||||
def _run_wired(context: Any, func: Any, *args: Any, **kwargs: Any) -> None:
|
||||
"""Execute a CLI function with a mocked DI container."""
|
||||
import typer
|
||||
@@ -150,6 +167,10 @@ def _run_wired(context: Any, func: Any, *args: Any, **kwargs: Any) -> None:
|
||||
"cleveragents.cli.commands.project_context.err_console",
|
||||
test_console,
|
||||
),
|
||||
patch(
|
||||
"cleveragents.cli.commands.project_context.format_output",
|
||||
_test_format_output,
|
||||
),
|
||||
):
|
||||
try:
|
||||
func(*args, **kwargs)
|
||||
|
||||
@@ -273,6 +273,8 @@ def step_path_resolved(context: Context) -> None:
|
||||
|
||||
def _resource_capture_output(func: Any, *args: Any, **kwargs: Any) -> tuple[str, bool]:
|
||||
"""Run a CLI function capturing its console output and success status."""
|
||||
import contextlib
|
||||
|
||||
buf = StringIO()
|
||||
console = Console(file=buf, width=200, no_color=True)
|
||||
|
||||
@@ -283,7 +285,8 @@ def _resource_capture_output(func: Any, *args: Any, **kwargs: Any) -> tuple[str,
|
||||
|
||||
failed = False
|
||||
try:
|
||||
func(*args, **kwargs)
|
||||
with contextlib.redirect_stdout(buf):
|
||||
func(*args, **kwargs)
|
||||
except SystemExit:
|
||||
failed = True
|
||||
except Exception:
|
||||
|
||||
@@ -200,9 +200,15 @@ def step_fmt_data_empty(context: Context) -> None:
|
||||
|
||||
@when('I format the data with format type "{fmt}"')
|
||||
def step_fmt_format(context: Context, fmt: str) -> None:
|
||||
from contextlib import redirect_stdout
|
||||
from io import StringIO
|
||||
|
||||
from cleveragents.cli.formatting import format_output
|
||||
|
||||
context.cov_format_output = format_output(context.cov_format_data, fmt)
|
||||
buf = StringIO()
|
||||
with redirect_stdout(buf):
|
||||
result = format_output(context.cov_format_data, fmt)
|
||||
context.cov_format_output = result or buf.getvalue().rstrip("\n")
|
||||
|
||||
|
||||
@when('I format the data via format_output_session with format "{fmt}"')
|
||||
@@ -510,6 +516,8 @@ def step_sess_reset_service(context: Context) -> None:
|
||||
|
||||
@when('I invoke session create with format "{fmt}"')
|
||||
def step_sess_create_fmt(context: Context, fmt: str) -> None:
|
||||
from contextlib import redirect_stdout
|
||||
|
||||
from cleveragents.cli.commands import session as session_mod
|
||||
|
||||
buf = StringIO()
|
||||
@@ -519,7 +527,10 @@ def step_sess_create_fmt(context: Context, fmt: str) -> None:
|
||||
return_value=context.cov_mock_sess_svc,
|
||||
):
|
||||
try:
|
||||
with patch("typer.echo", side_effect=lambda x: buf.write(str(x))):
|
||||
with (
|
||||
patch("typer.echo", side_effect=lambda x: buf.write(str(x))),
|
||||
redirect_stdout(buf),
|
||||
):
|
||||
session_mod.create(fmt=fmt)
|
||||
except SystemExit:
|
||||
pass
|
||||
|
||||
@@ -192,6 +192,8 @@ def step_errcov_lifecycle_error(context: Context) -> None:
|
||||
|
||||
@when('I invoke errcov plan errors in "{fmt}" format')
|
||||
def step_errcov_invoke_errors(context: Context, fmt: str) -> None:
|
||||
import contextlib
|
||||
|
||||
from cleveragents.cli.commands import plan as plan_mod
|
||||
|
||||
svc = MagicMock()
|
||||
@@ -206,6 +208,7 @@ def step_errcov_invoke_errors(context: Context, fmt: str) -> None:
|
||||
return_value=svc,
|
||||
),
|
||||
patch.object(plan_mod, "console", capture_console),
|
||||
contextlib.redirect_stdout(buf),
|
||||
):
|
||||
plan_mod.plan_errors(plan_id=_PLAN_ID, fmt=fmt)
|
||||
context.errcov_output = buf.getvalue()
|
||||
@@ -267,6 +270,7 @@ def step_errcov_invoke_diff(context: Context, fmt: str) -> None:
|
||||
plan_mod, "_get_apply_service", return_value=context.errcov_apply_svc
|
||||
),
|
||||
patch.object(plan_mod, "console", capture_console),
|
||||
contextlib.redirect_stdout(buf),
|
||||
):
|
||||
plan_mod.plan_diff(plan_id=_PLAN_ID, fmt=fmt)
|
||||
context.errcov_output = buf.getvalue()
|
||||
@@ -326,6 +330,7 @@ def step_errcov_invoke_artifacts(context: Context, fmt: str) -> None:
|
||||
plan_mod, "_get_apply_service", return_value=context.errcov_apply_svc
|
||||
),
|
||||
patch.object(plan_mod, "console", capture_console),
|
||||
contextlib.redirect_stdout(buf),
|
||||
):
|
||||
plan_mod.plan_artifacts(plan_id=_PLAN_ID, fmt=fmt)
|
||||
context.errcov_output = buf.getvalue()
|
||||
|
||||
@@ -720,8 +720,14 @@ def step_value_error_raised(context: Context) -> None:
|
||||
|
||||
@when('I call format_output with a dict and format "{fmt}"')
|
||||
def step_call_format_output(context: Context, fmt: str) -> None:
|
||||
from contextlib import redirect_stdout
|
||||
from io import StringIO
|
||||
|
||||
data = {"Name": "test", "Status": "active"}
|
||||
context.format_result = format_output(data, fmt)
|
||||
buf = StringIO()
|
||||
with redirect_stdout(buf):
|
||||
result = format_output(data, fmt)
|
||||
context.format_result = result or buf.getvalue().rstrip("\n")
|
||||
|
||||
|
||||
@then("the output contains key-value pairs")
|
||||
|
||||
@@ -652,7 +652,13 @@ def step_diff_result_is_json(context: Context) -> None:
|
||||
@when('pas_cov I call artifacts with format "{fmt}"')
|
||||
def step_call_artifacts_format(context: Context, fmt: str) -> None:
|
||||
"""Call artifacts on the service with specified format."""
|
||||
context.pas_artifacts_result = context.pas_service.artifacts(_PLAN_ID, fmt=fmt)
|
||||
from contextlib import redirect_stdout
|
||||
from io import StringIO
|
||||
|
||||
buf = StringIO()
|
||||
with redirect_stdout(buf):
|
||||
result = context.pas_service.artifacts(_PLAN_ID, fmt=fmt)
|
||||
context.pas_artifacts_result = result or buf.getvalue().rstrip("\n")
|
||||
|
||||
|
||||
@then('pas_cov the artifacts result should contain "{text}"')
|
||||
|
||||
@@ -210,17 +210,23 @@ def step_request_diff_rich(context: Context) -> None:
|
||||
|
||||
@when("I request the diff in plain format")
|
||||
def step_request_diff_plain(context: Context) -> None:
|
||||
context.diff_output = context.apply_service.diff(context.plan_id, fmt="plain")
|
||||
context.diff_output = _capture_service_output(
|
||||
context.apply_service.diff, context.plan_id, fmt="plain"
|
||||
)
|
||||
|
||||
|
||||
@when("I request the diff in JSON format")
|
||||
def step_request_diff_json(context: Context) -> None:
|
||||
context.diff_output = context.apply_service.diff(context.plan_id, fmt="json")
|
||||
context.diff_output = _capture_service_output(
|
||||
context.apply_service.diff, context.plan_id, fmt="json"
|
||||
)
|
||||
|
||||
|
||||
@when("I request the diff in YAML format")
|
||||
def step_request_diff_yaml(context: Context) -> None:
|
||||
context.diff_output = context.apply_service.diff(context.plan_id, fmt="yaml")
|
||||
context.diff_output = _capture_service_output(
|
||||
context.apply_service.diff, context.plan_id, fmt="yaml"
|
||||
)
|
||||
|
||||
|
||||
@when("I try to get the diff")
|
||||
@@ -234,27 +240,40 @@ def step_try_get_diff(context: Context) -> None:
|
||||
|
||||
@when("I request the artifacts")
|
||||
def step_request_artifacts(context: Context) -> None:
|
||||
context.artifacts_output = context.apply_service.artifacts(context.plan_id)
|
||||
context.artifacts_output = _capture_service_output(
|
||||
context.apply_service.artifacts, context.plan_id
|
||||
)
|
||||
|
||||
|
||||
def _capture_service_output(func, *args, **kwargs):
|
||||
"""Call a service method capturing stdout (format_output writes there)."""
|
||||
from contextlib import redirect_stdout
|
||||
from io import StringIO
|
||||
|
||||
buf = StringIO()
|
||||
with redirect_stdout(buf):
|
||||
result = func(*args, **kwargs)
|
||||
return result or buf.getvalue().rstrip("\n")
|
||||
|
||||
|
||||
@when("I request the artifacts in JSON format")
|
||||
def step_request_artifacts_json(context: Context) -> None:
|
||||
context.artifacts_output = context.apply_service.artifacts(
|
||||
context.plan_id, fmt="json"
|
||||
context.artifacts_output = _capture_service_output(
|
||||
context.apply_service.artifacts, context.plan_id, fmt="json"
|
||||
)
|
||||
|
||||
|
||||
@when("I request the artifacts in plain format")
|
||||
def step_request_artifacts_plain(context: Context) -> None:
|
||||
context.artifacts_output = context.apply_service.artifacts(
|
||||
context.plan_id, fmt="plain"
|
||||
context.artifacts_output = _capture_service_output(
|
||||
context.apply_service.artifacts, context.plan_id, fmt="plain"
|
||||
)
|
||||
|
||||
|
||||
@when("I request the artifacts in YAML format")
|
||||
def step_request_artifacts_yaml(context: Context) -> None:
|
||||
context.artifacts_output = context.apply_service.artifacts(
|
||||
context.plan_id, fmt="yaml"
|
||||
context.artifacts_output = _capture_service_output(
|
||||
context.apply_service.artifacts, context.plan_id, fmt="yaml"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -236,16 +236,27 @@ def step_build_explain_reasoning(context: Context) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _capture_format_output(data, fmt):
|
||||
"""Call format_output capturing stdout (machine-readable formats write there)."""
|
||||
from contextlib import redirect_stdout
|
||||
from io import StringIO
|
||||
|
||||
buf = StringIO()
|
||||
with redirect_stdout(buf):
|
||||
result = format_output(data, fmt)
|
||||
return result or buf.getvalue().rstrip("\n")
|
||||
|
||||
|
||||
@when("I format the explain dict as json")
|
||||
def step_format_explain_json(context: Context) -> None:
|
||||
data = _build_explain_dict(context.pe_decision)
|
||||
context.pe_json_output = format_output(data, "json")
|
||||
context.pe_json_output = _capture_format_output(data, "json")
|
||||
|
||||
|
||||
@when("I format the explain dict as yaml")
|
||||
def step_format_explain_yaml(context: Context) -> None:
|
||||
data = _build_explain_dict(context.pe_decision)
|
||||
context.pe_yaml_output = format_output(data, "yaml")
|
||||
context.pe_yaml_output = _capture_format_output(data, "yaml")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -276,13 +287,13 @@ def step_build_tree_empty(context: Context) -> None:
|
||||
@when("I format the tree as json")
|
||||
def step_format_tree_json(context: Context) -> None:
|
||||
tree = build_decision_tree(context.pe_decisions)
|
||||
context.pe_tree_json = format_output(tree, "json")
|
||||
context.pe_tree_json = _capture_format_output(tree, "json")
|
||||
|
||||
|
||||
@when("I format the tree as yaml")
|
||||
def step_format_tree_yaml(context: Context) -> None:
|
||||
tree = build_decision_tree(context.pe_decisions)
|
||||
context.pe_tree_yaml = format_output(tree, "yaml")
|
||||
context.pe_tree_yaml = _capture_format_output(tree, "yaml")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -129,6 +129,8 @@ def _unpatch_project_mod() -> None:
|
||||
|
||||
def _capture(context: Any, func: Any, *args: Any, **kwargs: Any) -> None:
|
||||
"""Call a CLI function, capturing console output and success/failure."""
|
||||
import contextlib
|
||||
|
||||
import typer
|
||||
|
||||
import cleveragents.cli.commands.project as project_mod
|
||||
@@ -144,7 +146,8 @@ def _capture(context: Any, func: Any, *args: Any, **kwargs: Any) -> None:
|
||||
_patch_project_mod(context)
|
||||
failed = False
|
||||
try:
|
||||
func(*args, **kwargs)
|
||||
with contextlib.redirect_stdout(buf):
|
||||
func(*args, **kwargs)
|
||||
except (SystemExit, typer.Exit, typer.Abort):
|
||||
failed = True
|
||||
except Exception:
|
||||
|
||||
@@ -238,10 +238,22 @@ def step_pcli_create_project_with_invariants(
|
||||
context.pcli_output = str(exc)
|
||||
|
||||
|
||||
def _capture_format_output(data, fmt):
|
||||
"""Call format_output capturing stdout (machine-readable formats write there)."""
|
||||
from contextlib import redirect_stdout
|
||||
from io import StringIO
|
||||
|
||||
from cleveragents.cli.formatting import format_output
|
||||
|
||||
buf = StringIO()
|
||||
with redirect_stdout(buf):
|
||||
result = format_output(data, fmt)
|
||||
return result or buf.getvalue().rstrip("\n")
|
||||
|
||||
|
||||
@when('I run project create named "{name}" formatted as "{fmt}"')
|
||||
def step_pcli_create_project_with_format(context: Any, name: str, fmt: str) -> None:
|
||||
from cleveragents.cli.commands.project import _project_spec_dict
|
||||
from cleveragents.cli.formatting import format_output
|
||||
from cleveragents.domain.models.core.project import (
|
||||
NamespacedProject,
|
||||
parse_namespaced_name,
|
||||
@@ -256,7 +268,7 @@ def step_pcli_create_project_with_format(context: Any, name: str, fmt: str) -> N
|
||||
)
|
||||
context.pcli_project_repo.create(proj)
|
||||
data = _project_spec_dict(proj)
|
||||
context.pcli_output = format_output(data, fmt)
|
||||
context.pcli_output = _capture_format_output(data, fmt)
|
||||
context.pcli_exit_code = 0
|
||||
except Exception as exc:
|
||||
context.pcli_exit_code = 1
|
||||
@@ -364,12 +376,11 @@ def step_pcli_list_projects_invalid_regex(context: Any, regex_str: str) -> None:
|
||||
@when('I list projects with format "{fmt}"')
|
||||
def step_pcli_list_projects_fmt(context: Any, fmt: str) -> None:
|
||||
from cleveragents.cli.commands.project import _project_spec_dict
|
||||
from cleveragents.cli.formatting import format_output
|
||||
|
||||
try:
|
||||
projects = context.pcli_project_repo.list_projects()
|
||||
data = [_project_spec_dict(p) for p in projects]
|
||||
context.pcli_output = format_output(data, fmt)
|
||||
context.pcli_output = _capture_format_output(data, fmt)
|
||||
context.pcli_exit_code = 0
|
||||
except Exception as exc:
|
||||
context.pcli_exit_code = 1
|
||||
@@ -401,7 +412,6 @@ def step_pcli_show_project(context: Any, name: str) -> None:
|
||||
@when('I show project details for "{name}" as "{fmt}"')
|
||||
def step_pcli_show_project_fmt(context: Any, name: str, fmt: str) -> None:
|
||||
from cleveragents.cli.commands.project import _project_spec_dict
|
||||
from cleveragents.cli.formatting import format_output
|
||||
from cleveragents.infrastructure.database.repositories import (
|
||||
ProjectNotFoundError,
|
||||
)
|
||||
@@ -409,7 +419,7 @@ def step_pcli_show_project_fmt(context: Any, name: str, fmt: str) -> None:
|
||||
try:
|
||||
proj = context.pcli_project_repo.get(name)
|
||||
data = _project_spec_dict(proj)
|
||||
context.pcli_output = format_output(data, fmt)
|
||||
context.pcli_output = _capture_format_output(data, fmt)
|
||||
context.pcli_exit_code = 0
|
||||
except (ProjectNotFoundError, Exception) as exc:
|
||||
context.pcli_exit_code = 1
|
||||
|
||||
@@ -71,6 +71,7 @@ def _mock_container(context: Any) -> MagicMock:
|
||||
|
||||
|
||||
def _run(context: Any, func: Any, *args: Any, **kwargs: Any) -> None:
|
||||
|
||||
import typer
|
||||
from rich.console import Console as RichConsole
|
||||
|
||||
@@ -85,6 +86,21 @@ def _run(context: Any, func: Any, *args: Any, **kwargs: Any) -> None:
|
||||
soft_wrap=True,
|
||||
)
|
||||
|
||||
def _test_format_output(data, format_type):
|
||||
import sys as _sys
|
||||
from io import StringIO as _SIO
|
||||
|
||||
from cleveragents.cli.formatting import format_output as _fo
|
||||
|
||||
_b = _SIO()
|
||||
_old = _sys.stdout
|
||||
_sys.stdout = _b
|
||||
try:
|
||||
_r = _fo(data, format_type)
|
||||
finally:
|
||||
_sys.stdout = _old
|
||||
return _r or _b.getvalue().rstrip("\n")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"cleveragents.application.container.get_container",
|
||||
@@ -98,6 +114,10 @@ def _run(context: Any, func: Any, *args: Any, **kwargs: Any) -> None:
|
||||
"cleveragents.cli.commands.project_context.err_console",
|
||||
test_console,
|
||||
),
|
||||
patch(
|
||||
"cleveragents.cli.commands.project_context.format_output",
|
||||
_test_format_output,
|
||||
),
|
||||
):
|
||||
try:
|
||||
func(*args, **kwargs)
|
||||
|
||||
@@ -139,6 +139,21 @@ def _run_with_container(context: Any, func: Any, *args: Any, **kwargs: Any) -> N
|
||||
soft_wrap=True,
|
||||
)
|
||||
|
||||
def _test_format_output(data, format_type):
|
||||
import sys as _sys
|
||||
from io import StringIO as _SIO
|
||||
|
||||
from cleveragents.cli.formatting import format_output as _fo
|
||||
|
||||
_b = _SIO()
|
||||
_old = _sys.stdout
|
||||
_sys.stdout = _b
|
||||
try:
|
||||
_r = _fo(data, format_type)
|
||||
finally:
|
||||
_sys.stdout = _old
|
||||
return _r or _b.getvalue().rstrip("\n")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"cleveragents.application.container.get_container",
|
||||
@@ -152,6 +167,10 @@ def _run_with_container(context: Any, func: Any, *args: Any, **kwargs: Any) -> N
|
||||
"cleveragents.cli.commands.project_context.err_console",
|
||||
test_console,
|
||||
),
|
||||
patch(
|
||||
"cleveragents.cli.commands.project_context.format_output",
|
||||
_test_format_output,
|
||||
),
|
||||
):
|
||||
try:
|
||||
func(*args, **kwargs)
|
||||
|
||||
@@ -34,6 +34,8 @@ def _make_service(context: Context) -> ResourceRegistryService:
|
||||
|
||||
def _capture_output(func: Any, *args: Any, **kwargs: Any) -> tuple[str, bool]:
|
||||
"""Run a CLI function capturing its console output and success status."""
|
||||
import contextlib
|
||||
|
||||
buf = StringIO()
|
||||
# force_terminal=False and highlight=False prevent Rich from emitting
|
||||
# ANSI escape codes (bold, syntax highlighting) when FORCE_COLOR is set
|
||||
@@ -49,7 +51,8 @@ def _capture_output(func: Any, *args: Any, **kwargs: Any) -> tuple[str, bool]:
|
||||
|
||||
failed = False
|
||||
try:
|
||||
func(*args, **kwargs)
|
||||
with contextlib.redirect_stdout(buf):
|
||||
func(*args, **kwargs)
|
||||
except SystemExit:
|
||||
failed = True
|
||||
except Exception:
|
||||
|
||||
@@ -38,6 +38,8 @@ def _make_tree_service(context: Context) -> ResourceRegistryService:
|
||||
|
||||
def _capture_tree_output(func: Any, *args: Any, **kwargs: Any) -> tuple[str, bool]:
|
||||
"""Run a CLI function capturing its console output and success status."""
|
||||
import contextlib
|
||||
|
||||
buf = StringIO()
|
||||
console = Console(file=buf, width=200, no_color=True)
|
||||
|
||||
@@ -48,7 +50,8 @@ def _capture_tree_output(func: Any, *args: Any, **kwargs: Any) -> tuple[str, boo
|
||||
|
||||
failed = False
|
||||
try:
|
||||
func(*args, **kwargs)
|
||||
with contextlib.redirect_stdout(buf):
|
||||
func(*args, **kwargs)
|
||||
except SystemExit:
|
||||
failed = True
|
||||
except Exception:
|
||||
|
||||
@@ -8,6 +8,7 @@ Targets uncovered lines in src/cleveragents/cli/commands/server.py:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from io import StringIO
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -232,6 +233,7 @@ def step_call_status(context: Context, fmt: str) -> None:
|
||||
except (ValueError, KeyError):
|
||||
expected_mode = "disabled"
|
||||
|
||||
stdout_buf = StringIO()
|
||||
with (
|
||||
patch(
|
||||
"cleveragents.cli.commands.server._get_config_service",
|
||||
@@ -249,10 +251,12 @@ def step_call_status(context: Context, fmt: str) -> None:
|
||||
"cleveragents.cli.commands.server.console",
|
||||
capture_console,
|
||||
),
|
||||
contextlib.redirect_stdout(stdout_buf),
|
||||
):
|
||||
server_status(fmt=fmt)
|
||||
|
||||
context.echoed_output = echo_buf.getvalue()
|
||||
echoed = echo_buf.getvalue().strip()
|
||||
context.echoed_output = echo_buf.getvalue() if echoed else stdout_buf.getvalue()
|
||||
context.captured_console_output = console_buf.getvalue()
|
||||
|
||||
|
||||
|
||||
@@ -115,11 +115,28 @@ def test_json_error() -> None:
|
||||
|
||||
|
||||
def test_backward_compat() -> None:
|
||||
"""format_output still works for all formats."""
|
||||
"""format_output still works for all formats.
|
||||
|
||||
For machine-readable formats (json, yaml, plain) ``format_output``
|
||||
writes directly to ``sys.stdout`` and returns ``""``. We capture
|
||||
stdout so the assertion checks the *produced* output rather than
|
||||
just the return value.
|
||||
"""
|
||||
import sys as _sys
|
||||
from io import StringIO as _SIO
|
||||
|
||||
data = {"Name": "test-proj", "Status": "active"}
|
||||
for fmt in ("json", "yaml", "plain", "table", "rich"):
|
||||
result = format_output(data, fmt)
|
||||
assert result, f"format_output({fmt}) returned empty"
|
||||
buf = _SIO()
|
||||
old_stdout = _sys.stdout
|
||||
_sys.stdout = buf
|
||||
try:
|
||||
result = format_output(data, fmt)
|
||||
finally:
|
||||
_sys.stdout = old_stdout
|
||||
# Combine: either the return value has content, or stdout was written to
|
||||
effective = result or buf.getvalue().rstrip("\n")
|
||||
assert effective, f"format_output({fmt}) produced no output"
|
||||
print("output-rendering-backward-compat-ok")
|
||||
|
||||
|
||||
|
||||
@@ -159,12 +159,27 @@ def _diff_output() -> None:
|
||||
|
||||
|
||||
def _artifacts_output() -> None:
|
||||
"""Test artifacts output."""
|
||||
"""Test artifacts output.
|
||||
|
||||
``format_output`` for machine-readable formats writes directly to
|
||||
``sys.stdout`` and returns ``""``. We redirect stdout so the JSON
|
||||
payload is captured for parsing.
|
||||
"""
|
||||
from io import StringIO
|
||||
|
||||
lifecycle, store, apply_svc = _create_services()
|
||||
plan_id = _create_plan_with_changeset(lifecycle, store)
|
||||
|
||||
output = apply_svc.artifacts(plan_id, fmt="json")
|
||||
parsed = json.loads(output)
|
||||
buf = StringIO()
|
||||
old_stdout = sys.stdout
|
||||
sys.stdout = buf
|
||||
try:
|
||||
output = apply_svc.artifacts(plan_id, fmt="json")
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
# Combine: return value or captured stdout
|
||||
effective = output or buf.getvalue().strip()
|
||||
parsed = json.loads(effective)
|
||||
assert parsed["changeset_id"] == "robot-cs-001"
|
||||
assert "sandbox-robot-001" in parsed["sandbox_refs"]
|
||||
assert len(parsed["files_changed"]) == 2
|
||||
|
||||
@@ -87,6 +87,31 @@ def _make_mock_container(
|
||||
return mock_container
|
||||
|
||||
|
||||
def _capturing_format_output(data: Any, format_type: str) -> str:
|
||||
"""Wrapper around ``format_output`` that captures stdout writes.
|
||||
|
||||
``format_output`` for machine-readable formats (json, yaml, plain)
|
||||
writes directly to ``sys.stdout`` and returns ``""``. This wrapper
|
||||
redirects ``sys.stdout`` only during the ``format_output`` call so
|
||||
that the rendered content is returned as a string, allowing the
|
||||
caller's ``console.print(result)`` to write it to the test's Rich
|
||||
console buffer. Structlog noise that would otherwise pollute the
|
||||
captured stdout is excluded because the redirect is scoped tightly.
|
||||
"""
|
||||
import sys as _sys
|
||||
|
||||
from cleveragents.cli.formatting import format_output as _real_format_output
|
||||
|
||||
buf = StringIO()
|
||||
old = _sys.stdout
|
||||
_sys.stdout = buf
|
||||
try:
|
||||
ret = _real_format_output(data, format_type)
|
||||
finally:
|
||||
_sys.stdout = old
|
||||
return ret or buf.getvalue().rstrip("\n")
|
||||
|
||||
|
||||
def _run_cli_func(
|
||||
func: Any,
|
||||
repo: NamespacedProjectRepository,
|
||||
@@ -123,6 +148,10 @@ def _run_cli_func(
|
||||
"cleveragents.cli.commands.project_context.err_console",
|
||||
test_console,
|
||||
),
|
||||
patch(
|
||||
"cleveragents.cli.commands.project_context.format_output",
|
||||
_capturing_format_output,
|
||||
),
|
||||
):
|
||||
try:
|
||||
func(**kwargs)
|
||||
|
||||
@@ -169,6 +169,12 @@ def format_output(
|
||||
rendering so that sensitive values are masked unless the
|
||||
global ``show_secrets`` flag is enabled.
|
||||
|
||||
For machine-readable formats (json, yaml, plain) the output is
|
||||
written directly to ``sys.stdout`` to avoid Rich ``console.print``
|
||||
line-wrapping which can introduce literal newlines inside JSON
|
||||
string values. An empty string is returned so the caller's
|
||||
``console.print(result)`` simply emits a trailing newline.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data:
|
||||
@@ -179,18 +185,31 @@ def format_output(
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The rendered string. For ``rich`` format the caller should use
|
||||
the domain-specific Rich helper instead; this function returns
|
||||
the JSON representation as a fallback.
|
||||
The rendered string. For machine-readable formats the output
|
||||
is written directly to stdout and an empty string is returned.
|
||||
"""
|
||||
import sys
|
||||
|
||||
safe_data = _redact_data(data)
|
||||
fmt = format_type.lower()
|
||||
if fmt == OutputFormat.JSON.value:
|
||||
return _format_json(safe_data)
|
||||
if fmt == OutputFormat.YAML.value:
|
||||
return _format_yaml(safe_data)
|
||||
if fmt == OutputFormat.PLAIN.value:
|
||||
return _format_plain(safe_data)
|
||||
|
||||
# Machine-readable formats: write directly to stdout to avoid
|
||||
# Rich console.print() line-wrapping artefacts.
|
||||
if fmt in (
|
||||
OutputFormat.JSON.value,
|
||||
OutputFormat.YAML.value,
|
||||
OutputFormat.PLAIN.value,
|
||||
):
|
||||
if fmt == OutputFormat.JSON.value:
|
||||
rendered = _format_json(safe_data)
|
||||
elif fmt == OutputFormat.YAML.value:
|
||||
rendered = _format_yaml(safe_data)
|
||||
else:
|
||||
rendered = _format_plain(safe_data)
|
||||
sys.stdout.write(rendered + "\n")
|
||||
sys.stdout.flush()
|
||||
return ""
|
||||
|
||||
if fmt == OutputFormat.TABLE.value:
|
||||
return _format_table(safe_data)
|
||||
if fmt == OutputFormat.COLOR.value:
|
||||
|
||||
Reference in New Issue
Block a user