Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <scope>` (repeatable: `--scope read
Expand Down
33 changes: 29 additions & 4 deletions campus_cli/utils/output.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Output formatting utilities using Rich."""

import json
import os
from typing import Any, Optional

from rich.console import Console
Expand Down Expand Up @@ -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(
Expand Down
19 changes: 19 additions & 0 deletions tests/unit/test_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Expand Down
82 changes: 82 additions & 0 deletions tests/unit/test_output.py
Original file line number Diff line number Diff line change
@@ -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 == ""
Loading