diff --git a/README.md b/README.md index 1fc4d66..b31509b 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,15 @@ campus vault list --vault myvault campus client new --name "My App" --description "My application" --dry-run ``` +### Machine-readable output + +`--json` always emits raw, spec-valid JSON: string values are +byte-exact and never soft-wrapped to the terminal width (#69). On an +interactive terminal the same output is syntax-highlighted for +reading — its line wrapping is display-only, so pipe through `cat` +or `jq` (or redirect to a file) for the raw form. Agent tools that +allocate a pty can force the raw form with `CAMPUS_CLI_AGENT=1`. + ### Token scopes `campus auth login --scope ` (repeatable: `--scope read diff --git a/campus_cli/utils/output.py b/campus_cli/utils/output.py index 5107a10..56d4b33 100644 --- a/campus_cli/utils/output.py +++ b/campus_cli/utils/output.py @@ -1,6 +1,7 @@ """Output formatting utilities using Rich.""" import json +import os from typing import Any, Optional from rich.console import Console @@ -94,18 +95,42 @@ def print_table( console.print(table) -def print_json(data: Any, pretty: bool = True) -> None: +def _agent_mode() -> bool: """ - Print data as formatted JSON. + Whether CAMPUS_CLI_AGENT opts out of display-only formatting. + + Agent tools often allocate a pty, so terminal detection alone cannot + be trusted to pick raw machine-readable output (#69). + """ + return os.environ.get("CAMPUS_CLI_AGENT", "").strip().lower() not in ( + "", + "0", + "false", + ) + + +def print_json(data: Any, pretty: Optional[bool] = None) -> None: + """ + Print data as JSON. + + Raw output is the default: plain ``json.dumps`` written straight to + stdout, byte-exact in every string value and always spec-valid. The + Rich highlighted renderable is display-only — it soft-wraps long + values at the console width, inserting literal newlines into the + data — so it is reserved for interactive terminals; pipes, scripts + and CAMPUS_CLI_AGENT consumers always get the raw form (#69). Args: data: The data to print (must be JSON-serializable). - pretty: Whether to pretty-print the JSON. + pretty: Force the display renderable (True) or raw output + (False); by default, pretty only when stdout is a TTY. """ + if pretty is None: + pretty = console.is_terminal and not _agent_mode() if pretty: console.print(JSON(json.dumps(data, indent=2))) else: - console.print(json.dumps(data)) + print(json.dumps(data, indent=2)) def print_python_api( diff --git a/tests/unit/test_audit.py b/tests/unit/test_audit.py index 08f0639..8419821 100644 --- a/tests/unit/test_audit.py +++ b/tests/unit/test_audit.py @@ -737,6 +737,25 @@ def test_traces_list_json_keeps_the_envelope(self): assert result.exit_code == 0, result.output assert json.loads(result.stdout) == server_response + def test_json_values_longer_than_the_console_width_round_trip(self): + """--json never soft-wraps values to the console width (#69). + + Rich's display renderable used to insert literal newlines into + long string values, corrupting machine-readable output. + """ + long_name = "a" * 110 + server_response = { + "api_keys": [{"id": "k1", "name": long_name}], + "count": 1, + } + api = mock.Mock() + api.apikeys.list.return_value = server_response + with mock.patch("campus_cli.audit.keys.get_audit_client", return_value=api): + result = CliRunner().invoke(app, ["audit", "keys", "list", "--json"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == server_response + assert "\n" not in json.loads(result.stdout)["api_keys"][0]["name"] + class TestTracesSearchJourney: """traces search forwards --journey as the journey_id kwarg (#63).""" diff --git a/tests/unit/test_output.py b/tests/unit/test_output.py new file mode 100644 index 0000000..ef79633 --- /dev/null +++ b/tests/unit/test_output.py @@ -0,0 +1,82 @@ +"""Unit tests for output formatting (#69: raw machine-readable --json).""" + +import io +import json + +from rich.console import Console + +from campus_cli.utils import output + + +def _capturing_terminal_console(width=40) -> Console: + """A Rich console that reports itself as a TTY and captures its output.""" + return Console(file=io.StringIO(), force_terminal=True, width=width) + + +class TestPrintJsonRawByDefault: + """print_json must never mutate string values on the machine path (#69).""" + + def test_long_values_round_trip_byte_exact(self, capsys): + """A value longer than the console width comes back unmodified.""" + long_value = "a" * 110 + output.print_json({"vault": "v", "key": "k", "value": long_value}) + + payload = json.loads(capsys.readouterr().out) + assert payload["value"] == long_value + assert "\n" not in payload["value"] + + def test_raw_output_is_spec_valid_json(self, capsys): + output.print_json({"values": ["x" * 200, {"nested": "y" * 90}]}) + parsed = json.loads(capsys.readouterr().out) + assert parsed["values"][0] == "x" * 200 + assert parsed["values"][1]["nested"] == "y" * 90 + + def test_pretty_false_forces_raw_even_on_a_tty(self, capsys, monkeypatch): + monkeypatch.setattr(output, "console", _capturing_terminal_console()) + + output.print_json({"value": "z" * 200}, pretty=False) + + payload = json.loads(capsys.readouterr().out) + assert payload["value"] == "z" * 200 + + def test_agent_mode_forces_raw_even_on_a_tty(self, capsys, monkeypatch): + """CAMPUS_CLI_AGENT opts out of display rendering (#69).""" + terminal = _capturing_terminal_console() + monkeypatch.setattr(output, "console", terminal) + monkeypatch.setenv("CAMPUS_CLI_AGENT", "1") + + output.print_json({"value": "w" * 200}) + + assert terminal.file.getvalue() == "" + payload = json.loads(capsys.readouterr().out) + assert payload["value"] == "w" * 200 + + def test_agent_mode_ignores_falsy_values(self, capsys, monkeypatch): + monkeypatch.setattr(output, "console", _capturing_terminal_console()) + monkeypatch.setenv("CAMPUS_CLI_AGENT", "0") + + output.print_json({"tty": True}) + assert capsys.readouterr().out == "" + + +class TestPrintJsonPrettyOnTty: + """The Rich renderable is display-only, for interactive terminals.""" + + def test_tty_gets_the_highlighted_renderable(self, capsys, monkeypatch): + terminal = _capturing_terminal_console() + monkeypatch.setattr(output, "console", terminal) + + output.print_json({"value": "v" * 200}) + + # Pretty output goes through the Rich console, not raw stdout. + assert terminal.file.getvalue() != "" + assert capsys.readouterr().out == "" + + def test_pretty_true_forces_the_renderable(self, capsys, monkeypatch): + terminal = _capturing_terminal_console() + monkeypatch.setattr(output, "console", terminal) + + output.print_json({"value": "v"}, pretty=True) + + assert terminal.file.getvalue() != "" + assert capsys.readouterr().out == ""