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..390dc95 100644 --- a/src/api_contract_guardian/cli.py +++ b/src/api_contract_guardian/cli.py @@ -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.", @@ -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 @@ -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) @@ -203,14 +218,14 @@ 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) @@ -218,7 +233,7 @@ def diff( _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}") @@ -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) @@ -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) diff --git a/tests/test_cli.py b/tests/test_cli.py index ec6f322..dd41202 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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