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,191 @@
1
+ Metadata-Version: 2.5
2
+ Name: fastapi-breakgate
3
+ Version: 1.0.1
4
+ Summary: Production merge gate for FastAPI: export live OpenAPI, detect breaking changes, enforce intentional-break protocol.
5
+ Project-URL: Homepage, https://github.com/prakash888kp/fastapi-breakgate
6
+ Project-URL: Documentation, https://github.com/prakash888kp/fastapi-breakgate#readme
7
+ Project-URL: Repository, https://github.com/prakash888kp/fastapi-breakgate
8
+ Project-URL: Issues, https://github.com/prakash888kp/fastapi-breakgate/issues
9
+ Project-URL: Changelog, https://github.com/prakash888kp/fastapi-breakgate/blob/main/CHANGELOG.md
10
+ Author-email: prakash888kp <prakash888kp@users.noreply.github.com>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: api-compatibility,breaking-changes,ci,contract-testing,fastapi,openapi
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Environment :: Console
16
+ Classifier: Framework :: FastAPI
17
+ Classifier: Framework :: Pytest
18
+ Classifier: Intended Audience :: Developers
19
+ Classifier: License :: OSI Approved :: MIT License
20
+ Classifier: Operating System :: OS Independent
21
+ Classifier: Programming Language :: Python :: 3
22
+ Classifier: Programming Language :: Python :: 3.10
23
+ Classifier: Programming Language :: Python :: 3.11
24
+ Classifier: Programming Language :: Python :: 3.12
25
+ Classifier: Programming Language :: Python :: 3.13
26
+ Classifier: Programming Language :: Python :: 3.14
27
+ Classifier: Topic :: Software Development :: Quality Assurance
28
+ Classifier: Topic :: Software Development :: Testing
29
+ Classifier: Typing :: Typed
30
+ Requires-Python: >=3.10
31
+ Requires-Dist: fastapi>=0.110
32
+ Requires-Dist: rich>=13.7
33
+ Requires-Dist: tomli>=2.0.1; python_version < '3.11'
34
+ Requires-Dist: typer>=0.12
35
+ Requires-Dist: typing-extensions>=4.8; python_version < '3.11'
36
+ Provides-Extra: dev
37
+ Requires-Dist: build>=1.2; extra == 'dev'
38
+ Requires-Dist: httpx>=0.27; extra == 'dev'
39
+ Requires-Dist: mypy>=1.11; extra == 'dev'
40
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
41
+ Requires-Dist: pytest>=8.0; extra == 'dev'
42
+ Requires-Dist: ruff>=0.6; extra == 'dev'
43
+ Requires-Dist: twine>=5.0; extra == 'dev'
44
+ Provides-Extra: oasdiff
45
+ Description-Content-Type: text/markdown
46
+
47
+ # fastapi-breakgate
48
+
49
+ **Production merge gate for FastAPI.** Export live OpenAPI in CI, fail on accidental breaking changes, and make intentional breaks an explicit, changelog-backed review event.
50
+
51
+ > Status: **Beta (v1.0.1)** — core gate is solid; dogfood on real apps welcome.
52
+ > Not a fuzzer (that’s Schemathesis). Not a visual OpenAPI browser. A **hard merge gate** with FastAPI-aware DX.
53
+
54
+ > **Note:** The PyPI name is `fastapi-breakgate` because `fastapi-contract` is already taken by an unrelated project. The import package remains `fastapi_contract`.
55
+
56
+ ## Install
57
+
58
+ ```bash
59
+ pip install fastapi-breakgate
60
+ ```
61
+
62
+ CLI commands (either entry point works):
63
+
64
+ ```bash
65
+ fastapi-breakgate --help
66
+ fastapi-contract --help
67
+ ```
68
+
69
+ ## 5-minute setup
70
+
71
+ ### 1. Configure
72
+
73
+ ```toml
74
+ # pyproject.toml
75
+ [tool.fastapi_contract]
76
+ app = "myapp.main:app"
77
+ policy = "additive-friendly" # or "strict"
78
+ fail_on = "breaking"
79
+ snapshot_path = "openapi.snapshot.json"
80
+ intentional_label = "breaking-change"
81
+ intentional_title_marker = "[breaking-change]"
82
+ changelog_required_heading = "### Breaking"
83
+ ```
84
+
85
+ ### 2. Commit a base snapshot
86
+
87
+ ```bash
88
+ fastapi-breakgate doctor --app myapp.main:app
89
+ fastapi-breakgate snapshot --app myapp.main:app
90
+ git add openapi.snapshot.json && git commit -m "chore: add OpenAPI contract snapshot"
91
+ ```
92
+
93
+ ### 3. Gate PRs (GitHub Action)
94
+
95
+ ```yaml
96
+ # .github/workflows/contract.yml
97
+ name: API contract
98
+ on:
99
+ pull_request:
100
+ jobs:
101
+ contract:
102
+ runs-on: ubuntu-latest
103
+ steps:
104
+ - uses: actions/checkout@v4
105
+ - uses: actions/setup-python@v5
106
+ with:
107
+ python-version: "3.12"
108
+ - name: Install project
109
+ run: pip install -e ".[dev]"
110
+ - uses: prakash888kp/fastapi-breakgate/action@v1
111
+ with:
112
+ app: myapp.main:app
113
+ fail-on: breaking
114
+ policy: additive-friendly
115
+ install-command: ""
116
+ ```
117
+
118
+ Or call the CLI directly:
119
+
120
+ ```bash
121
+ pip install fastapi-breakgate
122
+ fastapi-breakgate check --app myapp.main:app --annotations
123
+ ```
124
+
125
+ ## CLI
126
+
127
+ | Command | Purpose |
128
+ |---------|---------|
129
+ | `fastapi-breakgate doctor` | Diagnose import/export readiness |
130
+ | `fastapi-breakgate export -o openapi.json` | Export live OpenAPI |
131
+ | `fastapi-breakgate snapshot` | Write committed snapshot |
132
+ | `fastapi-breakgate diff --base-file openapi.snapshot.json` | Classify changes |
133
+ | `fastapi-breakgate check` | CI entrypoint (exit 1 on accidental breaks) |
134
+ | `fastapi-breakgate comment -o pr.md` | Render PR markdown |
135
+
136
+ ## Intentional breaking changes
137
+
138
+ Accidental breaks fail CI. Planned breaks must complete the protocol:
139
+
140
+ 1. PR label `breaking-change` **or** title contains `[breaking-change]`
141
+ 2. `CHANGELOG.md` includes `### Breaking` with at least one bullet
142
+
143
+ Then the check passes with a visible warning and SemVer **major** suggestion.
144
+
145
+ ## Pytest plugin
146
+
147
+ ```python
148
+ def test_openapi_contract(fastapi_contract):
149
+ fastapi_contract.assert_compatible()
150
+ ```
151
+
152
+ ```bash
153
+ pytest --fastapi-contract-app=myapp.main:app --fastapi-contract-base=openapi.snapshot.json
154
+ ```
155
+
156
+ ## Policy
157
+
158
+ | Change | `additive-friendly` (default) | `strict` |
159
+ |--------|-------------------------------|----------|
160
+ | Add optional response field | safe | safe |
161
+ | Add response enum value | **safe** | breaking |
162
+ | Remove path / field | breaking | breaking |
163
+ | Add required request field | breaking | breaking |
164
+
165
+ ## Export reliability
166
+
167
+ Real apps often need DB/env at import time. Strategy ladder:
168
+
169
+ 1. **Live** import with stub/default env + optional `.env.contract`
170
+ 2. **Factory** (`create_app()`) when configured/detected
171
+ 3. **Snapshot** fallback in `export_mode = "auto"` with an explicit warning
172
+
173
+ Run `fastapi-breakgate doctor` when CI import fails.
174
+
175
+ ## Development
176
+
177
+ ```bash
178
+ pip install -e ".[dev]"
179
+ export PYTHONPATH=src:examples
180
+ python -m coverage run -m pytest
181
+ python -m coverage report --fail-under=75
182
+ ruff check src tests
183
+ ```
184
+
185
+ ## Release / PyPI
186
+
187
+ Tagging `vX.Y.Z` (must match `pyproject.toml`) runs CI tests, publishes to PyPI via Trusted Publishing, and creates a GitHub Release.
188
+
189
+ ## License
190
+
191
+ MIT
@@ -0,0 +1,22 @@
1
+ fastapi_contract/__init__.py,sha256=j8qt2RigbXF_9F-doZm7axdLWJUqzz28w0PPvzV9PEs,772
2
+ fastapi_contract/__main__.py,sha256=Du_1KCfuMIJD4QsCmXjK7s7cJv9-6D5rIiAFZw4rgJg,75
3
+ fastapi_contract/__version__.py,sha256=d4QHYmS_30j0hPN8NmNPnQ_Z0TphDRbu4MtQj9cT9e8,22
4
+ fastapi_contract/check.py,sha256=FfuKW7NLDq4qQq32COiAiX9XmyWjTkz03JQGEpy-QjE,5131
5
+ fastapi_contract/cli.py,sha256=6z_l1VloRjVRi9B7AsmpXZ1STJ6pUlE6wcrc2voUwUA,10415
6
+ fastapi_contract/comment.py,sha256=_a0eq2r9CBGPJlsHBohyERasCZm1WDTbH1oUVhhiqek,3550
7
+ fastapi_contract/config.py,sha256=urfETO8TqXhVACbOfwhXDNmEYXntdQ5vcA_jBvuvA1U,6749
8
+ fastapi_contract/doctor.py,sha256=2Cqrh1Lj_HLwzrINuOD7Ky1RY_wor8-MQoim4Kj0e2I,8057
9
+ fastapi_contract/export.py,sha256=y9DaHlTpcK5W6YgOCtUmI9KVUGiCZhGj-J8bRseksbU,9930
10
+ fastapi_contract/intentional.py,sha256=O2xuDyISVVasfh8zQc7ML8STsxfeQwJrZQtglAyi7wg,3214
11
+ fastapi_contract/models.py,sha256=93y2kKw2ofBOz0AgA2w9eow9-dIp-tI9Z7CRSXpLp0U,615
12
+ fastapi_contract/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ fastapi_contract/pytest_plugin.py,sha256=W_EHilInPPKsb9f9IbqWAxu4z7I-eWgA6aXxyY_P4l8,3170
14
+ fastapi_contract/diff/__init__.py,sha256=k6NbibV_zvlMZ7sjCIBWDxwy10rckH8sB1UmNrEoDJc,211
15
+ fastapi_contract/diff/classifier.py,sha256=-EvQYZ-W8rmE6bUSy4Z1-k3owqG8_w9C3q-fYIoI4qw,24296
16
+ fastapi_contract/diff/oasdiff.py,sha256=h5cEMrQrHUW2e1lKj0KOkIIeDQswo0f3IfANLQ-ushw,5914
17
+ fastapi_contract/diff/policy.py,sha256=xBP8M4-0qyYlf-PtmLwOWcoW3aBgaxsh28Qq3I3AyZo,3544
18
+ fastapi_breakgate-1.0.1.dist-info/METADATA,sha256=fo-b_lnpgAtMesvpOBdzKaiXXx92HHOn6tK2yNjEZc4,6126
19
+ fastapi_breakgate-1.0.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
20
+ fastapi_breakgate-1.0.1.dist-info/entry_points.txt,sha256=7i7o8b8whc2AXCJWQEFOTt2naL5HgNpIv9Xns1xTREM,169
21
+ fastapi_breakgate-1.0.1.dist-info/licenses/LICENSE,sha256=-XknleU--HhRvKSoQX-LZcNUtDP-PpMphuIOh0lnAv0,1102
22
+ fastapi_breakgate-1.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,6 @@
1
+ [console_scripts]
2
+ fastapi-breakgate = fastapi_contract.cli:app
3
+ fastapi-contract = fastapi_contract.cli:app
4
+
5
+ [pytest11]
6
+ fastapi_contract = fastapi_contract.pytest_plugin
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 prakash888kp / fastapi-breakgate contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,26 @@
1
+ """fastapi-contract — production merge gate for FastAPI OpenAPI breaking changes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi_contract.__version__ import __version__
6
+ from fastapi_contract.check import CheckResult, run_check
7
+ from fastapi_contract.config import ContractConfig, load_config
8
+ from fastapi_contract.diff.classifier import Change, ChangeSeverity, DiffResult, diff_openapi
9
+ from fastapi_contract.export import ExportResult, export_openapi
10
+ from fastapi_contract.models import FailOn, PolicyName
11
+
12
+ __all__ = [
13
+ "__version__",
14
+ "Change",
15
+ "ChangeSeverity",
16
+ "CheckResult",
17
+ "ContractConfig",
18
+ "DiffResult",
19
+ "ExportResult",
20
+ "FailOn",
21
+ "PolicyName",
22
+ "diff_openapi",
23
+ "export_openapi",
24
+ "load_config",
25
+ "run_check",
26
+ ]
@@ -0,0 +1,4 @@
1
+ from fastapi_contract.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
@@ -0,0 +1 @@
1
+ __version__ = "1.0.1"
@@ -0,0 +1,167 @@
1
+ """Orchestrate export → diff → intentional protocol → exit decision."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from fastapi_contract.comment import render_markdown_report
11
+ from fastapi_contract.config import ContractConfig
12
+ from fastapi_contract.diff.classifier import DiffResult, diff_openapi
13
+ from fastapi_contract.diff.oasdiff import OasdiffNotFoundError, diff_with_oasdiff, find_oasdiff
14
+ from fastapi_contract.export import ExportResult, export_openapi
15
+ from fastapi_contract.intentional import IntentionalStatus, evaluate_intentional
16
+ from fastapi_contract.models import ExportStrategy, FailOn, PolicyName
17
+
18
+
19
+ @dataclass
20
+ class CheckResult:
21
+ passed: bool
22
+ exit_code: int
23
+ diff: DiffResult
24
+ candidate: ExportResult
25
+ base: dict[str, Any]
26
+ intentional: IntentionalStatus | None = None
27
+ report_markdown: str = ""
28
+ messages: list[str] = field(default_factory=list)
29
+
30
+ @property
31
+ def accidental_breaking(self) -> bool:
32
+ return bool(self.diff.breaking) and not (
33
+ self.intentional is not None and self.intentional.allowed
34
+ )
35
+
36
+
37
+ def _load_schema_file(path: Path) -> dict[str, Any]:
38
+ data = json.loads(path.read_text(encoding="utf-8"))
39
+ if not isinstance(data, dict):
40
+ raise ValueError(f"Schema at {path} must be a JSON object")
41
+ return data
42
+
43
+
44
+ def compare_schemas(
45
+ base: dict[str, Any],
46
+ candidate: dict[str, Any],
47
+ *,
48
+ policy: PolicyName,
49
+ prefer_oasdiff: bool = False,
50
+ oasdiff_bin: str | None = None,
51
+ ) -> DiffResult:
52
+ use_oasdiff = prefer_oasdiff or bool(oasdiff_bin and find_oasdiff(oasdiff_bin))
53
+ if use_oasdiff:
54
+ try:
55
+ return diff_with_oasdiff(
56
+ base,
57
+ candidate,
58
+ policy=policy,
59
+ oasdiff_bin=oasdiff_bin,
60
+ )
61
+ except OasdiffNotFoundError:
62
+ pass
63
+ except Exception: # noqa: BLE001 - always fall back to Python engine
64
+ pass
65
+ return diff_openapi(base, candidate, policy=policy)
66
+
67
+
68
+ def _should_fail(diff: DiffResult, fail_on: FailOn) -> bool:
69
+ if fail_on == FailOn.ANY:
70
+ return bool(diff.changes)
71
+ if fail_on == FailOn.RISKY:
72
+ return bool(diff.breaking or diff.risky)
73
+ return bool(diff.breaking)
74
+
75
+
76
+ def run_check(
77
+ config: ContractConfig | None = None,
78
+ *,
79
+ cwd: Path | None = None,
80
+ base_schema: dict[str, Any] | None = None,
81
+ base_schema_path: Path | None = None,
82
+ candidate_schema: dict[str, Any] | None = None,
83
+ pr_title: str | None = None,
84
+ pr_labels: list[str] | None = None,
85
+ changelog_text: str | None = None,
86
+ ) -> CheckResult:
87
+ """Run a full contract check.
88
+
89
+ Base schema resolution order:
90
+ 1. Explicit ``base_schema``
91
+ 2. Explicit ``base_schema_path``
92
+ 3. Committed snapshot at ``config.snapshot_path`` (common local/CI mode)
93
+ """
94
+ cfg = config or ContractConfig()
95
+ root = (cwd or Path.cwd()).resolve()
96
+ messages: list[str] = []
97
+
98
+ if candidate_schema is not None:
99
+ candidate = ExportResult(
100
+ schema=candidate_schema,
101
+ strategy=ExportStrategy.LIVE,
102
+ app_path=cfg.app,
103
+ )
104
+ else:
105
+ candidate = export_openapi(cfg, cwd=root)
106
+ messages.extend(candidate.warnings)
107
+
108
+ if base_schema is not None:
109
+ base = base_schema
110
+ elif base_schema_path is not None:
111
+ base = _load_schema_file(base_schema_path)
112
+ else:
113
+ snap = root / cfg.snapshot_path
114
+ if not snap.is_file():
115
+ raise FileNotFoundError(
116
+ f"Base schema not provided and snapshot missing at {snap}. "
117
+ "Run `fastapi-contract snapshot` on main, or pass --base-file."
118
+ )
119
+ base = _load_schema_file(snap)
120
+ messages.append(f"Using base schema from snapshot {snap}")
121
+
122
+ diff = compare_schemas(
123
+ base,
124
+ candidate.schema,
125
+ policy=cfg.policy,
126
+ prefer_oasdiff=cfg.prefer_oasdiff,
127
+ oasdiff_bin=cfg.oasdiff_bin,
128
+ )
129
+
130
+ intentional: IntentionalStatus | None = None
131
+ would_fail = _should_fail(diff, cfg.fail_on)
132
+ if would_fail and diff.breaking:
133
+ intentional = evaluate_intentional(
134
+ cfg,
135
+ pr_title=pr_title,
136
+ pr_labels=pr_labels,
137
+ cwd=root,
138
+ changelog_text=changelog_text,
139
+ )
140
+ if intentional.allowed:
141
+ messages.append("Intentional breaking-change protocol satisfied.")
142
+ failed = False
143
+ exit_code = 0
144
+ else:
145
+ messages.extend(intentional.reasons)
146
+ failed = True
147
+ exit_code = 1
148
+ elif would_fail:
149
+ failed = True
150
+ exit_code = 1
151
+ else:
152
+ failed = False
153
+ exit_code = 0
154
+
155
+ report = render_markdown_report(
156
+ diff, intentional=intentional, failed=failed
157
+ )
158
+ return CheckResult(
159
+ passed=not failed,
160
+ exit_code=exit_code,
161
+ diff=diff,
162
+ candidate=candidate,
163
+ base=base,
164
+ intentional=intentional,
165
+ report_markdown=report,
166
+ messages=messages,
167
+ )