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
File renamed without changes.
64 changes: 48 additions & 16 deletions src/api_contract_guardian/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import os
import sys
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -65,6 +66,34 @@ def _validate_output_format(
return format_name


def _stderr_console() -> Any:
"""A Rich console bound to stderr (errors must never pollute stdout,
which CI pipes consume for --format json/yaml output)."""
from rich.console import Console

return Console(stderr=True)


def _echo_raw(text: str) -> None:
"""Emit machine-readable payload (json/yaml) unwrapped to stdout.

Rich's ``Console.print`` soft-wraps long lines at the detected console
width (default 80 columns even when piped), which corrupts JSON/YAML
consumed by CI pipes. Machine formats must go out byte-exact.
"""
sys.stdout.write(f"{text}\n")
sys.stdout.flush()


def _write_output(output: str, content: str) -> None:
"""Write CLI --output content, creating missing parent directories
instead of crashing with an unhandled FileNotFoundError traceback."""
out_path = Path(output)
if out_path.parent and not out_path.parent.exists():
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(content, encoding="utf-8")


app = typer.Typer(
name="api-contract-guardian",
help="Detect breaking changes in OpenAPI specs and gate CI pipelines.",
Expand Down Expand Up @@ -111,9 +140,7 @@ def _load_and_validate(path: str) -> dict:
validate_openapi_version(spec)
return spec
except SpecLoadError as e:
from rich.console import Console

Console().print(f"[red]Error loading: {e}[/red]")
_stderr_console().print(f"[red]Error loading: {e}[/red]")
raise typer.Exit(code=1) from e


Expand Down Expand Up @@ -194,31 +221,31 @@ def diff(
if format == "json":
output_data = json.dumps(result.to_dict(), indent=2)
if output:
Path(output).write_text(output_data, encoding="utf-8")
_write_output(output, output_data)
console.print(f"Written to {output}")
else:
console.print(output_data)
_echo_raw(output_data)
elif format == "yaml":
output_data = yaml.safe_dump(
result.to_dict(), sort_keys=False, default_flow_style=False
)
if output:
Path(output).write_text(output_data, encoding="utf-8")
_write_output(output, output_data)
console.print(f"Written to {output}")
else:
console.print(output_data)
_echo_raw(output_data)
elif format == "markdown":
guide = generate_migration_guide(result)
if output:
Path(output).write_text(guide, encoding="utf-8")
_write_output(output, guide)
console.print(f"Written to {output}")
else:
console.print(guide)
_echo_raw(guide)
else:
_print_result(result)
if output:
output_data = json.dumps(result.to_dict(), indent=2)
Path(output).write_text(output_data, encoding="utf-8")
_write_output(output, output_data)
console.print(f"\nJSON output written to {output}")


Expand Down Expand Up @@ -271,14 +298,19 @@ def check(
console = _get_console()

if gate_result.passed:
console.print(f"[green bold]{gate_result.message}[/green bold]")
message = f"[green bold]{gate_result.message}[/green bold]"
else:
console.print(f"[red bold]{gate_result.message}[/red bold]")
message = f"[red bold]{gate_result.message}[/red bold]"

if format == "rich":
# Human output: status plus summary table on stdout.
console.print(message)
# Still show the summary for human-friendly output.
_print_result(result)
else:
# Machine-readable run: the human status line goes to stderr so
# stdout stays a parseable json/yaml document for CI pipes.
_stderr_console().print(message)
payload = {
"gate": gate_result.to_dict(),
"diff": result.to_dict(),
Expand All @@ -289,7 +321,7 @@ def check(
)
else:
output_data = json.dumps(payload, indent=2)
console.print(output_data)
_echo_raw(output_data)

if output:
payload = {
Expand All @@ -302,7 +334,7 @@ def check(
)
else:
output_data = json.dumps(payload, indent=2)
Path(output).write_text(output_data, encoding="utf-8")
_write_output(output, output_data)
console.print(f"\nWritten to {output}")

raise typer.Exit(code=gate_result.exit_code)
Expand Down Expand Up @@ -343,10 +375,10 @@ def migrate(
content = generate_migration_guide(result)

if output:
Path(output).write_text(content, encoding="utf-8")
_write_output(output, content)
console.print(f"Migration guide written to {output}")
else:
console.print(content)
_echo_raw(content)


@app.command()
Expand Down
64 changes: 64 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,67 @@ def test_migrate_valid_specs(self, tmp_path) -> None:
assert out.exists()
text = out.read_text(encoding="utf-8")
assert "Migration Guide" in text


class TestErrorObservability:
"""Errors go to stderr and --output creates missing parent dirs."""

def test_load_error_goes_to_stderr(self) -> None:
result = _run("diff", "does-not-exist.yaml", "also-missing.yaml")
assert result.returncode == 1
assert "Error loading" in result.stderr
assert "Error" not in result.stdout

def test_diff_output_creates_parent_dirs(self, tmp_path: Path) -> None:
out = tmp_path / "nested" / "dir" / "report.json"
result = _run(
"diff", str(SPEC_V1), str(SPEC_V2), "--format", "json", "--output", str(out)
)
assert result.returncode == 0
assert out.exists()
assert "Written to" in result.stdout


class TestMachineReadableOutput:
"""--format json/yaml stdout must parse even with very long lines.

Rich's Console soft-wraps long lines at the console width (80 columns
when piped), which corrupts JSON/YAML piped into CI. Machine formats are
emitted raw via click.echo and must never be wrapped.
"""

@pytest.mark.skipif(
not SPEC_V1.exists() or not SPEC_V2.exists(),
reason="fixture specs missing",
)
def test_piped_json_stdout_parses(self) -> None:
import json

result = _run("diff", str(SPEC_V1), str(SPEC_V2), "--format", "json")
assert result.returncode == 0
payload = json.loads(result.stdout) # raises if rich wrapped any line
assert "changes" in payload

@pytest.mark.skipif(
not SPEC_V1.exists() or not SPEC_V2.exists(),
reason="fixture specs missing",
)
def test_piped_yaml_stdout_parses(self) -> None:
import yaml

result = _run("diff", str(SPEC_V1), str(SPEC_V2), "--format", "yaml")
assert result.returncode == 0
payload = yaml.safe_load(result.stdout)
assert isinstance(payload, dict) and "changes" in payload

@pytest.mark.skipif(
not SPEC_V1.exists() or not SPEC_V2.exists(),
reason="fixture specs missing",
)
def test_check_json_stdout_parses(self) -> None:
import json

result = _run("check", str(SPEC_V1), str(SPEC_V2), "--format", "json")
assert result.returncode in (0, 1)
payload = json.loads(result.stdout)
assert "gate" in payload and "diff" in payload
Loading