From ab911dbdc40cf7aec926d3a6d8cb495313a277be Mon Sep 17 00:00:00 2001 From: Luis Mendes Date: Fri, 13 Mar 2026 23:03:43 +0000 Subject: [PATCH] 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 --- .../steps/actor_cli_coverage_boost_steps.py | 6 ++- features/steps/cli_core_steps.py | 9 ++++- features/steps/cli_output_formats_steps.py | 23 ++++++++--- features/steps/context_cli_wiring_steps.py | 21 ++++++++++ features/steps/coverage_boost_extra_steps.py | 5 ++- .../coverage_security_template_boost_steps.py | 15 ++++++- .../error_recovery_coverage_boost_steps.py | 5 +++ features/steps/output_rendering_steps.py | 8 +++- .../plan_apply_service_coverage_steps.py | 8 +++- features/steps/plan_diff_artifacts_steps.py | 39 ++++++++++++++----- features/steps/plan_explain_steps.py | 19 +++++++-- features/steps/project_cli_commands_steps.py | 5 ++- features/steps/project_cli_steps.py | 22 ++++++++--- ...roject_context_cli_coverage_boost_steps.py | 20 ++++++++++ features/steps/project_context_cli_steps.py | 19 +++++++++ features/steps/resource_cli_steps.py | 5 ++- features/steps/resource_cli_tree_steps.py | 5 ++- .../steps/server_cli_coverage_boost_steps.py | 6 ++- robot/helper_output_rendering.py | 23 +++++++++-- robot/helper_plan_diff_artifacts.py | 21 ++++++++-- robot/helper_project_context_cli.py | 29 ++++++++++++++ src/cleveragents/cli/formatting.py | 37 +++++++++++++----- 22 files changed, 298 insertions(+), 52 deletions(-) diff --git a/features/steps/actor_cli_coverage_boost_steps.py b/features/steps/actor_cli_coverage_boost_steps.py index 327ffc264..fce72491e 100644 --- a/features/steps/actor_cli_coverage_boost_steps.py +++ b/features/steps/actor_cli_coverage_boost_steps.py @@ -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() diff --git a/features/steps/cli_core_steps.py b/features/steps/cli_core_steps.py index e5ba7188c..e0e3c14a1 100644 --- a/features/steps/cli_core_steps.py +++ b/features/steps/cli_core_steps.py @@ -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") # --------------------------------------------------------------------------- diff --git a/features/steps/cli_output_formats_steps.py b/features/steps/cli_output_formats_steps.py index efbcc4e21..4b748205b 100644 --- a/features/steps/cli_output_formats_steps.py +++ b/features/steps/cli_output_formats_steps.py @@ -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") diff --git a/features/steps/context_cli_wiring_steps.py b/features/steps/context_cli_wiring_steps.py index 6b4113b9d..7d9330099 100644 --- a/features/steps/context_cli_wiring_steps.py +++ b/features/steps/context_cli_wiring_steps.py @@ -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) diff --git a/features/steps/coverage_boost_extra_steps.py b/features/steps/coverage_boost_extra_steps.py index 5caac7364..31091b164 100644 --- a/features/steps/coverage_boost_extra_steps.py +++ b/features/steps/coverage_boost_extra_steps.py @@ -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: diff --git a/features/steps/coverage_security_template_boost_steps.py b/features/steps/coverage_security_template_boost_steps.py index 5bd7a2e67..be65b7c54 100644 --- a/features/steps/coverage_security_template_boost_steps.py +++ b/features/steps/coverage_security_template_boost_steps.py @@ -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 diff --git a/features/steps/error_recovery_coverage_boost_steps.py b/features/steps/error_recovery_coverage_boost_steps.py index e70f322da..d7fc369dc 100644 --- a/features/steps/error_recovery_coverage_boost_steps.py +++ b/features/steps/error_recovery_coverage_boost_steps.py @@ -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() diff --git a/features/steps/output_rendering_steps.py b/features/steps/output_rendering_steps.py index 73f6eca8e..f487708d3 100644 --- a/features/steps/output_rendering_steps.py +++ b/features/steps/output_rendering_steps.py @@ -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") diff --git a/features/steps/plan_apply_service_coverage_steps.py b/features/steps/plan_apply_service_coverage_steps.py index 97ed33d34..60b06e52c 100644 --- a/features/steps/plan_apply_service_coverage_steps.py +++ b/features/steps/plan_apply_service_coverage_steps.py @@ -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}"') diff --git a/features/steps/plan_diff_artifacts_steps.py b/features/steps/plan_diff_artifacts_steps.py index b3968051f..fb69294d8 100644 --- a/features/steps/plan_diff_artifacts_steps.py +++ b/features/steps/plan_diff_artifacts_steps.py @@ -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" ) diff --git a/features/steps/plan_explain_steps.py b/features/steps/plan_explain_steps.py index 944de48cb..124be3c71 100644 --- a/features/steps/plan_explain_steps.py +++ b/features/steps/plan_explain_steps.py @@ -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") # --------------------------------------------------------------------------- diff --git a/features/steps/project_cli_commands_steps.py b/features/steps/project_cli_commands_steps.py index 1c4b9941f..5bb2fac6c 100644 --- a/features/steps/project_cli_commands_steps.py +++ b/features/steps/project_cli_commands_steps.py @@ -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: diff --git a/features/steps/project_cli_steps.py b/features/steps/project_cli_steps.py index 8794c5936..3088f9d6a 100644 --- a/features/steps/project_cli_steps.py +++ b/features/steps/project_cli_steps.py @@ -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 diff --git a/features/steps/project_context_cli_coverage_boost_steps.py b/features/steps/project_context_cli_coverage_boost_steps.py index 92460f84b..4118e5254 100644 --- a/features/steps/project_context_cli_coverage_boost_steps.py +++ b/features/steps/project_context_cli_coverage_boost_steps.py @@ -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) diff --git a/features/steps/project_context_cli_steps.py b/features/steps/project_context_cli_steps.py index 295b86c79..4351a3f40 100644 --- a/features/steps/project_context_cli_steps.py +++ b/features/steps/project_context_cli_steps.py @@ -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) diff --git a/features/steps/resource_cli_steps.py b/features/steps/resource_cli_steps.py index d4d8e5be7..b679a5c34 100644 --- a/features/steps/resource_cli_steps.py +++ b/features/steps/resource_cli_steps.py @@ -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: diff --git a/features/steps/resource_cli_tree_steps.py b/features/steps/resource_cli_tree_steps.py index ccf0a4c16..8177a525f 100644 --- a/features/steps/resource_cli_tree_steps.py +++ b/features/steps/resource_cli_tree_steps.py @@ -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: diff --git a/features/steps/server_cli_coverage_boost_steps.py b/features/steps/server_cli_coverage_boost_steps.py index a34df72ba..1938745f7 100644 --- a/features/steps/server_cli_coverage_boost_steps.py +++ b/features/steps/server_cli_coverage_boost_steps.py @@ -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() diff --git a/robot/helper_output_rendering.py b/robot/helper_output_rendering.py index 2ad75fa64..487ab7a55 100644 --- a/robot/helper_output_rendering.py +++ b/robot/helper_output_rendering.py @@ -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") diff --git a/robot/helper_plan_diff_artifacts.py b/robot/helper_plan_diff_artifacts.py index b336b12ba..018b66e07 100644 --- a/robot/helper_plan_diff_artifacts.py +++ b/robot/helper_plan_diff_artifacts.py @@ -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 diff --git a/robot/helper_project_context_cli.py b/robot/helper_project_context_cli.py index 4e7c92aef..9d29bcecd 100644 --- a/robot/helper_project_context_cli.py +++ b/robot/helper_project_context_cli.py @@ -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) diff --git a/src/cleveragents/cli/formatting.py b/src/cleveragents/cli/formatting.py index 8f0ace443..dbba448cb 100644 --- a/src/cleveragents/cli/formatting.py +++ b/src/cleveragents/cli/formatting.py @@ -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: