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,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