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.
- fastapi_breakgate-1.0.1.dist-info/METADATA +191 -0
- fastapi_breakgate-1.0.1.dist-info/RECORD +22 -0
- fastapi_breakgate-1.0.1.dist-info/WHEEL +4 -0
- fastapi_breakgate-1.0.1.dist-info/entry_points.txt +6 -0
- fastapi_breakgate-1.0.1.dist-info/licenses/LICENSE +21 -0
- fastapi_contract/__init__.py +26 -0
- fastapi_contract/__main__.py +4 -0
- fastapi_contract/__version__.py +1 -0
- fastapi_contract/check.py +167 -0
- fastapi_contract/cli.py +295 -0
- fastapi_contract/comment.py +101 -0
- fastapi_contract/config.py +161 -0
- fastapi_contract/diff/__init__.py +5 -0
- fastapi_contract/diff/classifier.py +779 -0
- fastapi_contract/diff/oasdiff.py +160 -0
- fastapi_contract/diff/policy.py +85 -0
- fastapi_contract/doctor.py +251 -0
- fastapi_contract/export.py +283 -0
- fastapi_contract/intentional.py +109 -0
- fastapi_contract/models.py +33 -0
- fastapi_contract/py.typed +0 -0
- fastapi_contract/pytest_plugin.py +98 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Reliable FastAPI OpenAPI export with live / factory / snapshot ladder."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from fastapi_contract.config import ContractConfig
|
|
16
|
+
from fastapi_contract.models import ExportMode, ExportStrategy
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ExportResult:
|
|
21
|
+
schema: dict[str, Any]
|
|
22
|
+
strategy: ExportStrategy
|
|
23
|
+
app_path: str
|
|
24
|
+
warnings: list[str] = field(default_factory=list)
|
|
25
|
+
drift_detected: bool = False
|
|
26
|
+
|
|
27
|
+
def to_json(self, *, indent: int = 2) -> str:
|
|
28
|
+
return json.dumps(self.schema, indent=indent, sort_keys=True) + "\n"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ExportError(RuntimeError):
|
|
32
|
+
"""Raised when OpenAPI cannot be exported."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parse_target(target: str) -> tuple[str, str]:
|
|
36
|
+
if ":" not in target:
|
|
37
|
+
raise ExportError(
|
|
38
|
+
f"Invalid app target {target!r}. Expected 'module.path:attribute' "
|
|
39
|
+
"(e.g. 'myapp.main:app' or 'myapp.main:create_app')."
|
|
40
|
+
)
|
|
41
|
+
module_name, attr = target.rsplit(":", 1)
|
|
42
|
+
module_name = module_name.strip()
|
|
43
|
+
attr = attr.strip()
|
|
44
|
+
if not module_name or not attr:
|
|
45
|
+
raise ExportError(f"Invalid app target {target!r}.")
|
|
46
|
+
return module_name, attr
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _load_stub_env(path: Path | None) -> dict[str, str]:
|
|
50
|
+
if path is None or not path.is_file():
|
|
51
|
+
return {}
|
|
52
|
+
env: dict[str, str] = {}
|
|
53
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
54
|
+
stripped = line.strip()
|
|
55
|
+
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
|
56
|
+
continue
|
|
57
|
+
key, _, value = stripped.partition("=")
|
|
58
|
+
key = key.strip()
|
|
59
|
+
value = value.strip().strip("'").strip('"')
|
|
60
|
+
if key:
|
|
61
|
+
env[key] = value
|
|
62
|
+
return env
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@contextmanager
|
|
66
|
+
def _temporary_env(updates: dict[str, str]) -> Iterator[None]:
|
|
67
|
+
previous: dict[str, str | None] = {}
|
|
68
|
+
try:
|
|
69
|
+
for key, value in updates.items():
|
|
70
|
+
previous[key] = os.environ.get(key)
|
|
71
|
+
os.environ[key] = value
|
|
72
|
+
yield
|
|
73
|
+
finally:
|
|
74
|
+
for key, old in previous.items():
|
|
75
|
+
if old is None:
|
|
76
|
+
os.environ.pop(key, None)
|
|
77
|
+
else:
|
|
78
|
+
os.environ[key] = old
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _ensure_cwd_on_path(cwd: Path) -> None:
|
|
82
|
+
cwd_str = str(cwd.resolve())
|
|
83
|
+
if cwd_str not in sys.path:
|
|
84
|
+
sys.path.insert(0, cwd_str)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _is_fastapi_app(obj: Any) -> bool:
|
|
88
|
+
return callable(getattr(obj, "openapi", None)) and hasattr(obj, "routes")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _resolve_app(obj: Any, *, app_factory: bool, target: str) -> Any:
|
|
92
|
+
if app_factory or callable(obj) and not _is_fastapi_app(obj):
|
|
93
|
+
if not callable(obj):
|
|
94
|
+
raise ExportError(f"{target!r} is not callable; cannot use as app factory.")
|
|
95
|
+
app = obj()
|
|
96
|
+
else:
|
|
97
|
+
app = obj
|
|
98
|
+
if not _is_fastapi_app(app):
|
|
99
|
+
raise ExportError(
|
|
100
|
+
f"{target!r} did not resolve to a FastAPI-like app with .openapi()."
|
|
101
|
+
)
|
|
102
|
+
return app
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _call_openapi(app: Any, *, skip_lifespan: bool) -> dict[str, Any]:
|
|
106
|
+
# FastAPI generates OpenAPI without requiring lifespan/startup in normal cases.
|
|
107
|
+
# skip_lifespan is retained for compatibility and future ASGI lifespan guards.
|
|
108
|
+
_ = skip_lifespan
|
|
109
|
+
schema = app.openapi()
|
|
110
|
+
if not isinstance(schema, dict):
|
|
111
|
+
raise ExportError("app.openapi() did not return a dict.")
|
|
112
|
+
return schema
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _live_export(
|
|
116
|
+
config: ContractConfig, cwd: Path
|
|
117
|
+
) -> tuple[dict[str, Any], ExportStrategy, list[str]]:
|
|
118
|
+
warnings: list[str] = []
|
|
119
|
+
module_name, attr = _parse_target(config.app)
|
|
120
|
+
_ensure_cwd_on_path(cwd)
|
|
121
|
+
|
|
122
|
+
stub_path = Path(config.stub_env_file) if config.stub_env_file else None
|
|
123
|
+
if stub_path and not stub_path.is_absolute():
|
|
124
|
+
stub_path = cwd / stub_path
|
|
125
|
+
env_updates = {**_load_stub_env(stub_path), **config.extra_env}
|
|
126
|
+
# Common CI-safe defaults when not already set / stubbed
|
|
127
|
+
defaults = {
|
|
128
|
+
"DATABASE_URL": "sqlite+aiosqlite:///:memory:",
|
|
129
|
+
"SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:",
|
|
130
|
+
"REDIS_URL": "redis://localhost:6379/15",
|
|
131
|
+
"ENVIRONMENT": "contract",
|
|
132
|
+
"ENV": "contract",
|
|
133
|
+
"TESTING": "1",
|
|
134
|
+
}
|
|
135
|
+
for key, value in defaults.items():
|
|
136
|
+
env_updates.setdefault(key, value)
|
|
137
|
+
|
|
138
|
+
with _temporary_env(env_updates):
|
|
139
|
+
try:
|
|
140
|
+
# Always re-import so repeated checks in the same process (pytest / REPL)
|
|
141
|
+
# see the latest source. Drop the module and its submodules first.
|
|
142
|
+
to_drop = [
|
|
143
|
+
name
|
|
144
|
+
for name in list(sys.modules)
|
|
145
|
+
if name == module_name or name.startswith(module_name + ".")
|
|
146
|
+
]
|
|
147
|
+
for name in to_drop:
|
|
148
|
+
del sys.modules[name]
|
|
149
|
+
module = importlib.import_module(module_name)
|
|
150
|
+
except Exception as exc: # noqa: BLE001 - surface full import failure
|
|
151
|
+
raise ExportError(f"Failed to import module {module_name!r}: {exc}") from exc
|
|
152
|
+
|
|
153
|
+
if not hasattr(module, attr):
|
|
154
|
+
raise ExportError(f"Module {module_name!r} has no attribute {attr!r}.")
|
|
155
|
+
obj = getattr(module, attr)
|
|
156
|
+
|
|
157
|
+
factory_hint = config.app_factory or attr.startswith("create_") or attr in {
|
|
158
|
+
"create_app",
|
|
159
|
+
"get_app",
|
|
160
|
+
"make_app",
|
|
161
|
+
"build_app",
|
|
162
|
+
}
|
|
163
|
+
strategy = ExportStrategy.FACTORY if factory_hint else ExportStrategy.LIVE
|
|
164
|
+
try:
|
|
165
|
+
app = _resolve_app(obj, app_factory=factory_hint, target=config.app)
|
|
166
|
+
schema = _call_openapi(app, skip_lifespan=config.skip_lifespan)
|
|
167
|
+
except Exception as exc: # noqa: BLE001
|
|
168
|
+
raise ExportError(f"Failed to export OpenAPI from {config.app!r}: {exc}") from exc
|
|
169
|
+
|
|
170
|
+
if env_updates:
|
|
171
|
+
warnings.append(
|
|
172
|
+
"Used stub/default environment variables during export "
|
|
173
|
+
f"({', '.join(sorted(env_updates)[:8])}{'…' if len(env_updates) > 8 else ''})."
|
|
174
|
+
)
|
|
175
|
+
return schema, strategy, warnings
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _load_snapshot(path: Path) -> dict[str, Any]:
|
|
179
|
+
if not path.is_file():
|
|
180
|
+
raise ExportError(f"Snapshot not found: {path}")
|
|
181
|
+
try:
|
|
182
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
183
|
+
except json.JSONDecodeError as exc:
|
|
184
|
+
raise ExportError(f"Invalid snapshot JSON at {path}: {exc}") from exc
|
|
185
|
+
if not isinstance(data, dict):
|
|
186
|
+
raise ExportError(f"Snapshot at {path} must be a JSON object.")
|
|
187
|
+
return data
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def export_openapi(
|
|
191
|
+
config: ContractConfig | None = None,
|
|
192
|
+
*,
|
|
193
|
+
cwd: Path | None = None,
|
|
194
|
+
snapshot_path: Path | None = None,
|
|
195
|
+
) -> ExportResult:
|
|
196
|
+
"""Export OpenAPI using the configured strategy ladder."""
|
|
197
|
+
cfg = config or ContractConfig()
|
|
198
|
+
root = (cwd or Path.cwd()).resolve()
|
|
199
|
+
snap = snapshot_path or (root / cfg.snapshot_path)
|
|
200
|
+
warnings: list[str] = []
|
|
201
|
+
|
|
202
|
+
mode = cfg.export_mode
|
|
203
|
+
live_error: Exception | None = None
|
|
204
|
+
live_schema: dict[str, Any] | None = None
|
|
205
|
+
live_strategy = ExportStrategy.LIVE
|
|
206
|
+
|
|
207
|
+
if mode in (ExportMode.AUTO, ExportMode.LIVE):
|
|
208
|
+
try:
|
|
209
|
+
live_schema, live_strategy, live_warnings = _live_export(cfg, root)
|
|
210
|
+
warnings.extend(live_warnings)
|
|
211
|
+
if mode == ExportMode.LIVE:
|
|
212
|
+
return ExportResult(
|
|
213
|
+
schema=live_schema,
|
|
214
|
+
strategy=live_strategy,
|
|
215
|
+
app_path=cfg.app,
|
|
216
|
+
warnings=warnings,
|
|
217
|
+
)
|
|
218
|
+
# AUTO: optionally compare drift against snapshot
|
|
219
|
+
if snap.is_file():
|
|
220
|
+
try:
|
|
221
|
+
snap_schema = _load_snapshot(snap)
|
|
222
|
+
if snap_schema != live_schema:
|
|
223
|
+
warnings.append(
|
|
224
|
+
f"Snapshot drift detected: live OpenAPI differs from {snap}."
|
|
225
|
+
)
|
|
226
|
+
return ExportResult(
|
|
227
|
+
schema=live_schema,
|
|
228
|
+
strategy=live_strategy,
|
|
229
|
+
app_path=cfg.app,
|
|
230
|
+
warnings=warnings,
|
|
231
|
+
drift_detected=True,
|
|
232
|
+
)
|
|
233
|
+
except ExportError as exc:
|
|
234
|
+
warnings.append(str(exc))
|
|
235
|
+
return ExportResult(
|
|
236
|
+
schema=live_schema,
|
|
237
|
+
strategy=live_strategy,
|
|
238
|
+
app_path=cfg.app,
|
|
239
|
+
warnings=warnings,
|
|
240
|
+
)
|
|
241
|
+
except ExportError as exc:
|
|
242
|
+
live_error = exc
|
|
243
|
+
if mode == ExportMode.LIVE:
|
|
244
|
+
raise
|
|
245
|
+
warnings.append(f"Live export failed: {exc}")
|
|
246
|
+
|
|
247
|
+
# SNAPSHOT or AUTO fallback
|
|
248
|
+
if mode in (ExportMode.AUTO, ExportMode.SNAPSHOT):
|
|
249
|
+
try:
|
|
250
|
+
schema = _load_snapshot(snap)
|
|
251
|
+
if mode == ExportMode.AUTO:
|
|
252
|
+
warnings.append(
|
|
253
|
+
"Live export unavailable — using committed snapshot; "
|
|
254
|
+
"drift vs live could not be verified."
|
|
255
|
+
)
|
|
256
|
+
return ExportResult(
|
|
257
|
+
schema=schema,
|
|
258
|
+
strategy=ExportStrategy.SNAPSHOT,
|
|
259
|
+
app_path=cfg.app,
|
|
260
|
+
warnings=warnings,
|
|
261
|
+
)
|
|
262
|
+
except ExportError as snap_exc:
|
|
263
|
+
if live_error is not None:
|
|
264
|
+
raise ExportError(
|
|
265
|
+
f"Could not export OpenAPI.\n"
|
|
266
|
+
f"Live error: {live_error}\n"
|
|
267
|
+
f"Snapshot error: {snap_exc}\n"
|
|
268
|
+
"Run `fastapi-contract doctor` for diagnosis."
|
|
269
|
+
) from snap_exc
|
|
270
|
+
raise
|
|
271
|
+
|
|
272
|
+
raise ExportError(f"Unsupported export mode: {mode}")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def write_snapshot(
|
|
276
|
+
schema: dict[str, Any],
|
|
277
|
+
path: Path,
|
|
278
|
+
*,
|
|
279
|
+
indent: int = 2,
|
|
280
|
+
) -> Path:
|
|
281
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
282
|
+
path.write_text(json.dumps(schema, indent=indent, sort_keys=True) + "\n", encoding="utf-8")
|
|
283
|
+
return path
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Intentional breaking-change protocol enforcement."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from fastapi_contract.config import ContractConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class IntentionalStatus:
|
|
13
|
+
marker_present: bool
|
|
14
|
+
changelog_ok: bool
|
|
15
|
+
allowed: bool
|
|
16
|
+
reasons: tuple[str, ...]
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def complete(self) -> bool:
|
|
20
|
+
return self.allowed
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _read_text(path: Path) -> str:
|
|
24
|
+
if not path.is_file():
|
|
25
|
+
return ""
|
|
26
|
+
return path.read_text(encoding="utf-8")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _section_has_bullet(changelog_text: str, heading: str) -> bool:
|
|
30
|
+
if heading not in changelog_text:
|
|
31
|
+
return False
|
|
32
|
+
after = changelog_text.split(heading, 1)[1]
|
|
33
|
+
for line in after.splitlines()[1:]:
|
|
34
|
+
stripped = line.strip()
|
|
35
|
+
if stripped.startswith("#"):
|
|
36
|
+
# reached next markdown heading
|
|
37
|
+
break
|
|
38
|
+
if stripped.startswith(("-", "*", "+")) and len(stripped) > 2:
|
|
39
|
+
return True
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def evaluate_intentional(
|
|
44
|
+
config: ContractConfig,
|
|
45
|
+
*,
|
|
46
|
+
pr_title: str | None = None,
|
|
47
|
+
pr_labels: list[str] | None = None,
|
|
48
|
+
cwd: Path | None = None,
|
|
49
|
+
changelog_text: str | None = None,
|
|
50
|
+
) -> IntentionalStatus:
|
|
51
|
+
"""Return whether an intentional break protocol is complete.
|
|
52
|
+
|
|
53
|
+
Protocol requires:
|
|
54
|
+
1. Label match OR title marker
|
|
55
|
+
2. Changelog contains required heading with at least one bullet
|
|
56
|
+
"""
|
|
57
|
+
root = (cwd or Path.cwd()).resolve()
|
|
58
|
+
labels = {label.strip().lower() for label in (pr_labels or []) if label}
|
|
59
|
+
title = pr_title or ""
|
|
60
|
+
marker = config.intentional_title_marker
|
|
61
|
+
label_ok = config.intentional_label.lower() in labels
|
|
62
|
+
title_ok = bool(marker) and marker.lower() in title.lower()
|
|
63
|
+
marker_present = label_ok or title_ok
|
|
64
|
+
|
|
65
|
+
reasons: list[str] = []
|
|
66
|
+
if not marker_present:
|
|
67
|
+
reasons.append(
|
|
68
|
+
f"Missing intentional marker: add label "
|
|
69
|
+
f"'{config.intentional_label}' or put '{marker}' in the PR title."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if changelog_text is None:
|
|
73
|
+
changelog_text = _read_text(root / config.changelog_path)
|
|
74
|
+
|
|
75
|
+
heading = config.changelog_required_heading
|
|
76
|
+
changelog_ok = False
|
|
77
|
+
if not changelog_text.strip():
|
|
78
|
+
reasons.append(f"Changelog missing or empty at {config.changelog_path}.")
|
|
79
|
+
elif heading not in changelog_text:
|
|
80
|
+
reasons.append(
|
|
81
|
+
f"Changelog must include heading '{heading}' describing the break."
|
|
82
|
+
)
|
|
83
|
+
elif not _section_has_bullet(changelog_text, heading):
|
|
84
|
+
reasons.append(
|
|
85
|
+
f"Heading '{heading}' found but no bullet points describing the break."
|
|
86
|
+
)
|
|
87
|
+
else:
|
|
88
|
+
changelog_ok = True
|
|
89
|
+
|
|
90
|
+
allowed = marker_present and changelog_ok
|
|
91
|
+
if allowed:
|
|
92
|
+
reasons = (
|
|
93
|
+
"Intentional breaking-change protocol satisfied "
|
|
94
|
+
"(marker + changelog).",
|
|
95
|
+
)
|
|
96
|
+
return IntentionalStatus(
|
|
97
|
+
marker_present=marker_present,
|
|
98
|
+
changelog_ok=changelog_ok,
|
|
99
|
+
allowed=allowed,
|
|
100
|
+
reasons=tuple(reasons),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def suggest_semver_bump(has_breaking: bool, has_additive: bool) -> str:
|
|
105
|
+
if has_breaking:
|
|
106
|
+
return "major"
|
|
107
|
+
if has_additive:
|
|
108
|
+
return "minor"
|
|
109
|
+
return "patch"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Shared enums and typed structures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PolicyName(str, Enum):
|
|
9
|
+
"""Severity policy for classifying OpenAPI changes."""
|
|
10
|
+
|
|
11
|
+
ADDITIVE_FRIENDLY = "additive-friendly"
|
|
12
|
+
STRICT = "strict"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FailOn(str, Enum):
|
|
16
|
+
"""Which severities cause a non-zero exit."""
|
|
17
|
+
|
|
18
|
+
BREAKING = "breaking"
|
|
19
|
+
RISKY = "risky"
|
|
20
|
+
ANY = "any"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ExportMode(str, Enum):
|
|
24
|
+
AUTO = "auto"
|
|
25
|
+
LIVE = "live"
|
|
26
|
+
SNAPSHOT = "snapshot"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ExportStrategy(str, Enum):
|
|
30
|
+
LIVE = "live"
|
|
31
|
+
FACTORY = "factory"
|
|
32
|
+
SNAPSHOT = "snapshot"
|
|
33
|
+
FAILED = "failed"
|
|
File without changes
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Pytest plugin: assert OpenAPI compatibility against a snapshot/base schema."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from fastapi_contract.check import compare_schemas, run_check
|
|
11
|
+
from fastapi_contract.config import ContractConfig, load_config
|
|
12
|
+
from fastapi_contract.diff.classifier import DiffResult
|
|
13
|
+
from fastapi_contract.export import export_openapi
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
17
|
+
group = parser.getgroup("fastapi-contract")
|
|
18
|
+
group.addoption(
|
|
19
|
+
"--fastapi-contract-app",
|
|
20
|
+
action="store",
|
|
21
|
+
default=None,
|
|
22
|
+
help="Override FastAPI app target (module:attr)",
|
|
23
|
+
)
|
|
24
|
+
group.addoption(
|
|
25
|
+
"--fastapi-contract-base",
|
|
26
|
+
action="store",
|
|
27
|
+
default=None,
|
|
28
|
+
help="Path to base OpenAPI JSON snapshot",
|
|
29
|
+
)
|
|
30
|
+
group.addoption(
|
|
31
|
+
"--fastapi-contract-policy",
|
|
32
|
+
action="store",
|
|
33
|
+
default=None,
|
|
34
|
+
help="additive-friendly | strict",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture
|
|
39
|
+
def fastapi_contract_config(pytestconfig: pytest.Config, tmp_path_factory: Any) -> ContractConfig:
|
|
40
|
+
overrides: dict[str, Any] = {}
|
|
41
|
+
app = pytestconfig.getoption("--fastapi-contract-app")
|
|
42
|
+
policy = pytestconfig.getoption("--fastapi-contract-policy")
|
|
43
|
+
if app:
|
|
44
|
+
overrides["app"] = app
|
|
45
|
+
if policy:
|
|
46
|
+
overrides["policy"] = policy
|
|
47
|
+
return load_config(overrides=overrides)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class FastAPIContractFixture:
|
|
51
|
+
def __init__(self, config: ContractConfig, base_path: Path | None) -> None:
|
|
52
|
+
self.config = config
|
|
53
|
+
self.base_path = base_path
|
|
54
|
+
|
|
55
|
+
def export(self) -> dict[str, Any]:
|
|
56
|
+
return export_openapi(self.config).schema
|
|
57
|
+
|
|
58
|
+
def diff(self, base: dict[str, Any] | None = None) -> DiffResult:
|
|
59
|
+
candidate = self.export()
|
|
60
|
+
if base is None:
|
|
61
|
+
path = self.base_path or Path(self.config.snapshot_path)
|
|
62
|
+
base = __import__("json").loads(path.read_text(encoding="utf-8"))
|
|
63
|
+
return compare_schemas(
|
|
64
|
+
base,
|
|
65
|
+
candidate,
|
|
66
|
+
policy=self.config.policy,
|
|
67
|
+
prefer_oasdiff=self.config.prefer_oasdiff,
|
|
68
|
+
oasdiff_bin=self.config.oasdiff_bin,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def assert_compatible(self, base: dict[str, Any] | None = None) -> DiffResult:
|
|
72
|
+
result = run_check(
|
|
73
|
+
self.config,
|
|
74
|
+
base_schema=base,
|
|
75
|
+
base_schema_path=None if base is not None else self.base_path,
|
|
76
|
+
)
|
|
77
|
+
if not result.passed:
|
|
78
|
+
pytest.fail(
|
|
79
|
+
"OpenAPI contract check failed:\n"
|
|
80
|
+
+ "\n".join(
|
|
81
|
+
f"- [{c.severity.value}] {c.message}" for c in result.diff.breaking
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
return result.diff
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@pytest.fixture
|
|
88
|
+
def fastapi_contract(
|
|
89
|
+
fastapi_contract_config: ContractConfig, pytestconfig: pytest.Config
|
|
90
|
+
) -> FastAPIContractFixture:
|
|
91
|
+
base = pytestconfig.getoption("--fastapi-contract-base")
|
|
92
|
+
base_path = Path(base) if base else None
|
|
93
|
+
return FastAPIContractFixture(fastapi_contract_config, base_path)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@pytest.fixture
|
|
97
|
+
def assert_openapi_compatible(fastapi_contract: FastAPIContractFixture):
|
|
98
|
+
return fastapi_contract.assert_compatible
|