diff --git a/tests/test_dummy.py b/_archive/retired-tests/test_dummy.py similarity index 100% rename from tests/test_dummy.py rename to _archive/retired-tests/test_dummy.py diff --git a/src/api_contract_guardian/cli.py b/src/api_contract_guardian/cli.py index 248e4a2..b77c389 100644 --- a/src/api_contract_guardian/cli.py +++ b/src/api_contract_guardian/cli.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import sys from pathlib import Path from typing import Any @@ -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.", @@ -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 @@ -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}") @@ -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(), @@ -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 = { @@ -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) @@ -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() diff --git a/tests/test_cli.py b/tests/test_cli.py index ec6f322..af76f89 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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