Skip to content
Draft
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.
33 changes: 24 additions & 9 deletions src/api_contract_guardian/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,23 @@ 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 _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 +128,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,7 +209,7 @@ 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)
Expand All @@ -203,22 +218,22 @@ def diff(
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)
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)
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 @@ -302,7 +317,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,7 +358,7 @@ 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)
Expand Down
19 changes: 19 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,22 @@ 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
Loading