fastapi-breakgate 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,160 @@
1
+ """Optional oasdiff integration — used when binary is available and preferred."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import subprocess
8
+ import tempfile
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from fastapi_contract.diff.classifier import (
13
+ Change,
14
+ ChangeSeverity,
15
+ DiffResult,
16
+ diff_openapi,
17
+ )
18
+ from fastapi_contract.diff.policy import ChangeKind
19
+ from fastapi_contract.models import PolicyName
20
+
21
+
22
+ class OasdiffNotFoundError(RuntimeError):
23
+ pass
24
+
25
+
26
+ def find_oasdiff(explicit: str | None = None) -> str | None:
27
+ if explicit:
28
+ path = Path(explicit)
29
+ if path.is_file():
30
+ return str(path)
31
+ found = shutil.which(explicit)
32
+ return found
33
+ return shutil.which("oasdiff")
34
+
35
+
36
+ def _map_oasdiff_id(change_id: str) -> ChangeKind:
37
+ lowered = change_id.lower()
38
+ mapping = {
39
+ "endpoint-removed": ChangeKind.OPERATION_REMOVED,
40
+ "api-path-removed-without-deprecation": ChangeKind.PATH_REMOVED,
41
+ "api-removed-without-deprecation": ChangeKind.OPERATION_REMOVED,
42
+ "request-property-became-required": ChangeKind.REQUEST_FIELD_OPTIONAL_TO_REQUIRED,
43
+ "new-required-request-property": ChangeKind.REQUIRED_REQUEST_FIELD_ADDED,
44
+ "response-property-removed": ChangeKind.RESPONSE_FIELD_REMOVED,
45
+ "response-required-property-removed": ChangeKind.RESPONSE_FIELD_REMOVED,
46
+ "response-media-type-removed": ChangeKind.MEDIA_TYPE_REMOVED,
47
+ "response-success-status-removed": ChangeKind.RESPONSE_STATUS_REMOVED,
48
+ "request-parameter-removed": ChangeKind.PARAMETER_REMOVED,
49
+ "new-required-request-parameter": ChangeKind.PARAMETER_ADDED_REQUIRED,
50
+ "request-parameter-became-required": ChangeKind.PARAMETER_BECAME_REQUIRED,
51
+ "request-body-became-required": ChangeKind.REQUIRED_REQUEST_FIELD_ADDED,
52
+ "api-security-added": ChangeKind.SECURITY_ADDED,
53
+ "response-property-enum-value-added": ChangeKind.ENUM_VALUE_ADDED,
54
+ "request-property-enum-value-removed": ChangeKind.ENUM_VALUE_REMOVED,
55
+ "response-property-type-changed": ChangeKind.TYPE_CHANGED,
56
+ "request-property-type-changed": ChangeKind.TYPE_CHANGED,
57
+ }
58
+ for key, kind in mapping.items():
59
+ if key in lowered:
60
+ return kind
61
+ return ChangeKind.OTHER
62
+
63
+
64
+ def diff_with_oasdiff(
65
+ base: dict[str, Any],
66
+ candidate: dict[str, Any],
67
+ *,
68
+ policy: PolicyName,
69
+ oasdiff_bin: str | None = None,
70
+ ) -> DiffResult:
71
+ binary = find_oasdiff(oasdiff_bin)
72
+ if not binary:
73
+ raise OasdiffNotFoundError("oasdiff binary not found on PATH")
74
+
75
+ with tempfile.TemporaryDirectory(prefix="fastapi-contract-") as tmp:
76
+ tmp_path = Path(tmp)
77
+ base_path = tmp_path / "base.json"
78
+ cand_path = tmp_path / "candidate.json"
79
+ base_path.write_text(json.dumps(base), encoding="utf-8")
80
+ cand_path.write_text(json.dumps(candidate), encoding="utf-8")
81
+
82
+ # Prefer JSON changelog output when available
83
+ proc = subprocess.run(
84
+ [
85
+ binary,
86
+ "changelog",
87
+ str(base_path),
88
+ str(cand_path),
89
+ "--format",
90
+ "json",
91
+ ],
92
+ capture_output=True,
93
+ text=True,
94
+ check=False,
95
+ )
96
+ if proc.returncode not in (0, 1) or not proc.stdout.strip():
97
+ # Fallback to python engine if oasdiff invocation fails
98
+ result = diff_openapi(base, candidate, policy=policy)
99
+ result.engine = "python-fallback"
100
+ return result
101
+
102
+ try:
103
+ payload = json.loads(proc.stdout)
104
+ except json.JSONDecodeError:
105
+ result = diff_openapi(base, candidate, policy=policy)
106
+ result.engine = "python-fallback"
107
+ return result
108
+
109
+ changes: list[Change] = []
110
+ entries = payload if isinstance(payload, list) else payload.get("changes", [])
111
+ if not isinstance(entries, list):
112
+ entries = []
113
+
114
+ from fastapi_contract.diff.policy import severity_for
115
+
116
+ for entry in entries:
117
+ if not isinstance(entry, dict):
118
+ continue
119
+ change_id = str(entry.get("id") or entry.get("text") or "other")
120
+ kind = _map_oasdiff_id(change_id)
121
+ level = str(entry.get("level") or entry.get("severity") or "").lower()
122
+ if level in {"error", "breaking"}:
123
+ severity = ChangeSeverity.BREAKING
124
+ elif level in {"warn", "warning", "risky"}:
125
+ severity = ChangeSeverity.RISKY
126
+ elif level in {"info", "safe"}:
127
+ severity = ChangeSeverity.SAFE
128
+ else:
129
+ severity = ChangeSeverity(severity_for(kind, policy))
130
+
131
+ # Policy overlay for enum additions etc.
132
+ if kind == ChangeKind.ENUM_VALUE_ADDED and policy == PolicyName.ADDITIVE_FRIENDLY:
133
+ severity = ChangeSeverity.SAFE
134
+
135
+ path = str(
136
+ entry.get("path")
137
+ or entry.get("operation")
138
+ or entry.get("section")
139
+ or "/"
140
+ )
141
+ method_raw = entry.get("method") or entry.get("operation")
142
+ method = method_raw.lower() if isinstance(method_raw, str) else None
143
+ message = str(entry.get("text") or entry.get("description") or change_id)
144
+ changes.append(
145
+ Change(
146
+ kind=kind,
147
+ severity=severity,
148
+ path=path,
149
+ method=method,
150
+ message=message,
151
+ details={"oasdiff_id": change_id, "raw": entry},
152
+ )
153
+ )
154
+
155
+ return DiffResult(
156
+ changes=changes,
157
+ base_paths=len(base.get("paths") or {}),
158
+ candidate_paths=len(candidate.get("paths") or {}),
159
+ engine="oasdiff",
160
+ )
@@ -0,0 +1,85 @@
1
+ """Map raw change kinds to severities under additive-friendly vs strict policies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+
7
+ from fastapi_contract.models import PolicyName
8
+
9
+
10
+ class ChangeKind(str, Enum):
11
+ PATH_REMOVED = "path_removed"
12
+ PATH_ADDED = "path_added"
13
+ OPERATION_REMOVED = "operation_removed"
14
+ OPERATION_ADDED = "operation_added"
15
+ OPERATION_DEPRECATED = "operation_deprecated"
16
+ REQUIRED_REQUEST_FIELD_ADDED = "required_request_field_added"
17
+ REQUEST_FIELD_REMOVED = "request_field_removed"
18
+ REQUEST_FIELD_OPTIONAL_TO_REQUIRED = "request_field_optional_to_required"
19
+ REQUEST_FIELD_REQUIRED_TO_OPTIONAL = "request_field_required_to_optional"
20
+ RESPONSE_FIELD_REMOVED = "response_field_removed"
21
+ RESPONSE_FIELD_ADDED = "response_field_added"
22
+ RESPONSE_REQUIRED_FIELD_ADDED = "response_required_field_added"
23
+ TYPE_CHANGED = "type_changed"
24
+ TYPE_NARROWED = "type_narrowed"
25
+ TYPE_WIDENED = "type_widened"
26
+ ENUM_VALUE_REMOVED = "enum_value_removed"
27
+ ENUM_VALUE_ADDED = "enum_value_added"
28
+ RESPONSE_STATUS_REMOVED = "response_status_removed"
29
+ RESPONSE_STATUS_ADDED = "response_status_added"
30
+ PARAMETER_REMOVED = "parameter_removed"
31
+ PARAMETER_ADDED_REQUIRED = "parameter_added_required"
32
+ PARAMETER_ADDED_OPTIONAL = "parameter_added_optional"
33
+ PARAMETER_BECAME_REQUIRED = "parameter_became_required"
34
+ SECURITY_ADDED = "security_added"
35
+ MEDIA_TYPE_REMOVED = "media_type_removed"
36
+ SCHEMA_REMOVED = "schema_removed"
37
+ OTHER = "other"
38
+
39
+
40
+ # Base severities before policy overlay: BREAKING / RISKY / SAFE encoded as strings
41
+ _BASE: dict[ChangeKind, str] = {
42
+ ChangeKind.PATH_REMOVED: "breaking",
43
+ ChangeKind.PATH_ADDED: "safe",
44
+ ChangeKind.OPERATION_REMOVED: "breaking",
45
+ ChangeKind.OPERATION_ADDED: "safe",
46
+ ChangeKind.OPERATION_DEPRECATED: "safe",
47
+ ChangeKind.REQUIRED_REQUEST_FIELD_ADDED: "breaking",
48
+ ChangeKind.REQUEST_FIELD_REMOVED: "risky",
49
+ ChangeKind.REQUEST_FIELD_OPTIONAL_TO_REQUIRED: "breaking",
50
+ ChangeKind.REQUEST_FIELD_REQUIRED_TO_OPTIONAL: "safe",
51
+ ChangeKind.RESPONSE_FIELD_REMOVED: "breaking",
52
+ ChangeKind.RESPONSE_FIELD_ADDED: "safe",
53
+ ChangeKind.RESPONSE_REQUIRED_FIELD_ADDED: "risky",
54
+ ChangeKind.TYPE_CHANGED: "breaking",
55
+ ChangeKind.TYPE_NARROWED: "breaking",
56
+ ChangeKind.TYPE_WIDENED: "safe",
57
+ ChangeKind.ENUM_VALUE_REMOVED: "breaking",
58
+ ChangeKind.ENUM_VALUE_ADDED: "breaking", # strict default; overlay may soften
59
+ ChangeKind.RESPONSE_STATUS_REMOVED: "breaking",
60
+ ChangeKind.RESPONSE_STATUS_ADDED: "safe",
61
+ ChangeKind.PARAMETER_REMOVED: "breaking",
62
+ ChangeKind.PARAMETER_ADDED_REQUIRED: "breaking",
63
+ ChangeKind.PARAMETER_ADDED_OPTIONAL: "safe",
64
+ ChangeKind.PARAMETER_BECAME_REQUIRED: "breaking",
65
+ ChangeKind.SECURITY_ADDED: "breaking",
66
+ ChangeKind.MEDIA_TYPE_REMOVED: "breaking",
67
+ ChangeKind.SCHEMA_REMOVED: "risky",
68
+ ChangeKind.OTHER: "risky",
69
+ }
70
+
71
+
72
+ def severity_for(kind: ChangeKind, policy: PolicyName) -> str:
73
+ base = _BASE.get(kind, "risky")
74
+ if policy == PolicyName.STRICT:
75
+ return base
76
+ # additive-friendly overlay
77
+ if kind == ChangeKind.ENUM_VALUE_ADDED:
78
+ return "safe"
79
+ if kind == ChangeKind.RESPONSE_REQUIRED_FIELD_ADDED:
80
+ return "safe"
81
+ if kind == ChangeKind.REQUEST_FIELD_REMOVED:
82
+ return "safe" # clients can omit removed optional request fields
83
+ if kind == ChangeKind.SCHEMA_REMOVED:
84
+ return "safe" # unused component removal often OK if unreferenced in paths
85
+ return base
@@ -0,0 +1,251 @@
1
+ """Diagnose FastAPI OpenAPI export readiness."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import os
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from fastapi_contract.config import ContractConfig
12
+ from fastapi_contract.export import (
13
+ ExportError,
14
+ _ensure_cwd_on_path,
15
+ _load_stub_env,
16
+ _parse_target,
17
+ export_openapi,
18
+ )
19
+
20
+
21
+ @dataclass
22
+ class DoctorCheck:
23
+ name: str
24
+ ok: bool
25
+ detail: str
26
+ hint: str | None = None
27
+
28
+
29
+ @dataclass
30
+ class DoctorReport:
31
+ ok: bool
32
+ checks: list[DoctorCheck] = field(default_factory=list)
33
+ suggested_env: dict[str, str] = field(default_factory=dict)
34
+
35
+ def to_markdown(self) -> str:
36
+ lines = ["# fastapi-contract doctor", ""]
37
+ lines.append(f"**Overall:** {'PASS' if self.ok else 'FAIL'}")
38
+ lines.append("")
39
+ for check in self.checks:
40
+ mark = "OK" if check.ok else "FAIL"
41
+ lines.append(f"- [{mark}] **{check.name}**: {check.detail}")
42
+ if check.hint:
43
+ lines.append(f" - hint: {check.hint}")
44
+ if self.suggested_env:
45
+ lines.append("")
46
+ lines.append("## Suggested `.env.contract`")
47
+ lines.append("```")
48
+ for key, value in sorted(self.suggested_env.items()):
49
+ lines.append(f"{key}={value}")
50
+ lines.append("```")
51
+ lines.append("")
52
+ return "\n".join(lines)
53
+
54
+
55
+ _SENSITIVE_HINTS = (
56
+ "DATABASE_URL",
57
+ "SQLALCHEMY_DATABASE_URI",
58
+ "REDIS_URL",
59
+ "SECRET_KEY",
60
+ "API_KEY",
61
+ "POSTGRES",
62
+ "MONGO",
63
+ )
64
+
65
+
66
+ def run_doctor(
67
+ config: ContractConfig | None = None,
68
+ *,
69
+ cwd: Path | None = None,
70
+ ) -> DoctorReport:
71
+ cfg = config or ContractConfig()
72
+ root = (cwd or Path.cwd()).resolve()
73
+ checks: list[DoctorCheck] = []
74
+ suggested_env: dict[str, str] = {
75
+ "DATABASE_URL": "sqlite+aiosqlite:///:memory:",
76
+ "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:",
77
+ "REDIS_URL": "redis://localhost:6379/15",
78
+ "ENVIRONMENT": "contract",
79
+ "TESTING": "1",
80
+ }
81
+
82
+ # Target format
83
+ try:
84
+ module_name, attr = _parse_target(cfg.app)
85
+ checks.append(
86
+ DoctorCheck("app target format", True, f"{module_name}:{attr}")
87
+ )
88
+ except ExportError as exc:
89
+ return DoctorReport(
90
+ ok=False,
91
+ checks=[DoctorCheck("app target format", False, str(exc))],
92
+ )
93
+
94
+ _ensure_cwd_on_path(root)
95
+
96
+ # Stub env file
97
+ stub_path = Path(cfg.stub_env_file) if cfg.stub_env_file else None
98
+ if stub_path and not stub_path.is_absolute():
99
+ stub_path = root / stub_path
100
+ if cfg.stub_env_file:
101
+ if stub_path and stub_path.is_file():
102
+ stub = _load_stub_env(stub_path)
103
+ checks.append(
104
+ DoctorCheck(
105
+ "stub env file",
106
+ True,
107
+ f"Found {stub_path} with {len(stub)} keys",
108
+ )
109
+ )
110
+ suggested_env.update(stub)
111
+ else:
112
+ checks.append(
113
+ DoctorCheck(
114
+ "stub env file",
115
+ True,
116
+ f"Optional {cfg.stub_env_file} not found (OK if app imports without it)",
117
+ hint=(
118
+ "Create .env.contract with CI-safe placeholders "
119
+ "when import needs secrets/DB URLs."
120
+ ),
121
+ )
122
+ )
123
+
124
+ # Import module
125
+ try:
126
+ module = importlib.import_module(module_name)
127
+ checks.append(DoctorCheck("import module", True, f"Imported {module_name}"))
128
+ except Exception as exc: # noqa: BLE001
129
+ msg = str(exc)
130
+ hint = None
131
+ upper = msg.upper()
132
+ for token in _SENSITIVE_HINTS:
133
+ if token in upper or token in msg:
134
+ hint = (
135
+ f"Import appears to require {token}. "
136
+ "Add it to .env.contract and use a create_app() factory."
137
+ )
138
+ break
139
+ if hint is None:
140
+ hint = (
141
+ "Prefer `create_app()` that accepts settings/env injection, "
142
+ "or set export_mode=snapshot with a committed openapi.snapshot.json."
143
+ )
144
+ checks.append(DoctorCheck("import module", False, msg, hint=hint))
145
+ # Still check snapshot fallback
146
+ snap = root / cfg.snapshot_path
147
+ if snap.is_file():
148
+ checks.append(
149
+ DoctorCheck(
150
+ "snapshot fallback",
151
+ True,
152
+ f"Found {snap} — AUTO mode can use snapshot when live fails",
153
+ )
154
+ )
155
+ else:
156
+ checks.append(
157
+ DoctorCheck(
158
+ "snapshot fallback",
159
+ False,
160
+ f"No snapshot at {snap}",
161
+ hint=(
162
+ "Run a local `fastapi-contract snapshot` once live "
163
+ "export works, and commit it."
164
+ ),
165
+ )
166
+ )
167
+ ok = any(c.ok and c.name == "snapshot fallback" for c in checks)
168
+ return DoctorReport(ok=ok, checks=checks, suggested_env=suggested_env)
169
+
170
+ if not hasattr(module, attr):
171
+ checks.append(
172
+ DoctorCheck(
173
+ "app attribute",
174
+ False,
175
+ f"{module_name} has no attribute {attr!r}",
176
+ hint="Check [tool.fastapi_contract].app in pyproject.toml",
177
+ )
178
+ )
179
+ else:
180
+ obj: Any = getattr(module, attr)
181
+ factory_like = callable(obj) and not hasattr(obj, "openapi")
182
+ kind = "factory" if factory_like or cfg.app_factory else "app instance"
183
+ checks.append(
184
+ DoctorCheck(
185
+ "app attribute",
186
+ True,
187
+ f"Found {attr} ({kind})",
188
+ hint=(
189
+ "Set app_factory = true if this is a create_app() callable."
190
+ if factory_like and not cfg.app_factory
191
+ else None
192
+ ),
193
+ )
194
+ )
195
+
196
+ # Snapshot presence
197
+ snap = root / cfg.snapshot_path
198
+ if snap.is_file():
199
+ checks.append(DoctorCheck("snapshot file", True, f"Present at {snap}"))
200
+ else:
201
+ checks.append(
202
+ DoctorCheck(
203
+ "snapshot file",
204
+ True,
205
+ f"Optional snapshot not present ({cfg.snapshot_path})",
206
+ hint="Recommended as CI fallback when live import needs infra.",
207
+ )
208
+ )
209
+
210
+ # Full export attempt
211
+ try:
212
+ result = export_openapi(cfg, cwd=root)
213
+ checks.append(
214
+ DoctorCheck(
215
+ "openapi export",
216
+ True,
217
+ f"Succeeded via {result.strategy.value} "
218
+ f"({len(result.schema.get('paths', {}))} paths)",
219
+ )
220
+ )
221
+ for warning in result.warnings:
222
+ checks.append(DoctorCheck("export warning", True, warning))
223
+ except ExportError as exc:
224
+ checks.append(
225
+ DoctorCheck(
226
+ "openapi export",
227
+ False,
228
+ str(exc),
229
+ hint="Fix import/settings or commit a snapshot for AUTO fallback.",
230
+ )
231
+ )
232
+
233
+ # Env visibility note
234
+ missing_common = [k for k in ("DATABASE_URL", "SECRET_KEY") if k not in os.environ]
235
+ if missing_common:
236
+ checks.append(
237
+ DoctorCheck(
238
+ "host environment",
239
+ True,
240
+ f"Common keys not in process env: {', '.join(missing_common)} "
241
+ "(stubs/defaults may still work)",
242
+ )
243
+ )
244
+
245
+ critical = {"app target format", "app attribute", "openapi export"}
246
+ ok = all(c.ok for c in checks if c.name in critical)
247
+ # Allow pass if export works even if stub file missing
248
+ export_ok = any(c.name == "openapi export" and c.ok for c in checks)
249
+ if export_ok:
250
+ ok = True
251
+ return DoctorReport(ok=ok, checks=checks, suggested_env=suggested_env)