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
fastapi_contract/cli.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Typer CLI for fastapi-contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.markdown import Markdown
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
from fastapi_contract.__version__ import __version__
|
|
16
|
+
from fastapi_contract.check import run_check
|
|
17
|
+
from fastapi_contract.comment import github_check_annotation_lines, render_text_summary
|
|
18
|
+
from fastapi_contract.config import load_config
|
|
19
|
+
from fastapi_contract.diff.classifier import diff_openapi
|
|
20
|
+
from fastapi_contract.doctor import run_doctor
|
|
21
|
+
from fastapi_contract.export import ExportError, export_openapi, write_snapshot
|
|
22
|
+
from fastapi_contract.models import ExportMode
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(
|
|
25
|
+
name="fastapi-contract",
|
|
26
|
+
help="Production merge gate for FastAPI OpenAPI breaking changes.",
|
|
27
|
+
add_completion=False,
|
|
28
|
+
no_args_is_help=True,
|
|
29
|
+
)
|
|
30
|
+
console = Console(stderr=True)
|
|
31
|
+
out = Console()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _version_callback(value: bool) -> None:
|
|
35
|
+
if value:
|
|
36
|
+
out.print(__version__)
|
|
37
|
+
raise typer.Exit(0)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.callback()
|
|
41
|
+
def main(
|
|
42
|
+
version: bool = typer.Option(
|
|
43
|
+
False,
|
|
44
|
+
"--version",
|
|
45
|
+
"-V",
|
|
46
|
+
help="Show version and exit.",
|
|
47
|
+
callback=_version_callback,
|
|
48
|
+
is_eager=True,
|
|
49
|
+
),
|
|
50
|
+
) -> None:
|
|
51
|
+
"""fastapi-contract CLI."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.command("doctor")
|
|
55
|
+
def doctor_cmd(
|
|
56
|
+
config: Path | None = typer.Option(None, "--config", "-c", help="Path to TOML config"),
|
|
57
|
+
app_path: str | None = typer.Option(None, "--app", help="module:attr FastAPI target"),
|
|
58
|
+
json_out: bool = typer.Option(False, "--json", help="Emit JSON"),
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Diagnose OpenAPI export readiness."""
|
|
61
|
+
cfg = load_config(config, overrides={"app": app_path})
|
|
62
|
+
report = run_doctor(cfg)
|
|
63
|
+
if json_out:
|
|
64
|
+
payload = {
|
|
65
|
+
"ok": report.ok,
|
|
66
|
+
"checks": [
|
|
67
|
+
{
|
|
68
|
+
"name": c.name,
|
|
69
|
+
"ok": c.ok,
|
|
70
|
+
"detail": c.detail,
|
|
71
|
+
"hint": c.hint,
|
|
72
|
+
}
|
|
73
|
+
for c in report.checks
|
|
74
|
+
],
|
|
75
|
+
"suggested_env": report.suggested_env,
|
|
76
|
+
}
|
|
77
|
+
out.print_json(data=payload)
|
|
78
|
+
else:
|
|
79
|
+
table = Table(title="fastapi-contract doctor")
|
|
80
|
+
table.add_column("Status")
|
|
81
|
+
table.add_column("Check")
|
|
82
|
+
table.add_column("Detail")
|
|
83
|
+
for check in report.checks:
|
|
84
|
+
table.add_row(
|
|
85
|
+
"[green]OK[/green]" if check.ok else "[red]FAIL[/red]",
|
|
86
|
+
check.name,
|
|
87
|
+
check.detail + (f"\nhint: {check.hint}" if check.hint else ""),
|
|
88
|
+
)
|
|
89
|
+
console.print(table)
|
|
90
|
+
console.print(
|
|
91
|
+
f"[bold]{'PASS' if report.ok else 'FAIL'}[/bold] — overall doctor status"
|
|
92
|
+
)
|
|
93
|
+
raise typer.Exit(0 if report.ok else 1)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@app.command("export")
|
|
97
|
+
def export_cmd(
|
|
98
|
+
output: Path = typer.Option(Path("openapi.json"), "--output", "-o"),
|
|
99
|
+
config: Path | None = typer.Option(None, "--config", "-c"),
|
|
100
|
+
app_path: str | None = typer.Option(None, "--app"),
|
|
101
|
+
export_mode: str | None = typer.Option(None, "--mode"),
|
|
102
|
+
app_factory: bool | None = typer.Option(None, "--factory/--no-factory"),
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Export live (or snapshot) OpenAPI JSON."""
|
|
105
|
+
cfg = load_config(
|
|
106
|
+
config,
|
|
107
|
+
overrides={
|
|
108
|
+
"app": app_path,
|
|
109
|
+
"export_mode": export_mode,
|
|
110
|
+
"app_factory": app_factory,
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
try:
|
|
114
|
+
result = export_openapi(cfg)
|
|
115
|
+
except ExportError as exc:
|
|
116
|
+
console.print(f"[red]Export failed:[/red] {exc}")
|
|
117
|
+
raise typer.Exit(2) from exc
|
|
118
|
+
output.write_text(result.to_json(), encoding="utf-8")
|
|
119
|
+
for warning in result.warnings:
|
|
120
|
+
console.print(f"[yellow]warning:[/yellow] {warning}")
|
|
121
|
+
console.print(
|
|
122
|
+
f"[green]Wrote[/green] {output} via {result.strategy.value} "
|
|
123
|
+
f"({len(result.schema.get('paths', {}))} paths)"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@app.command("snapshot")
|
|
128
|
+
def snapshot_cmd(
|
|
129
|
+
config: Path | None = typer.Option(None, "--config", "-c"),
|
|
130
|
+
app_path: str | None = typer.Option(None, "--app"),
|
|
131
|
+
snapshot_path: Path | None = typer.Option(None, "--path"),
|
|
132
|
+
app_factory: bool | None = typer.Option(None, "--factory/--no-factory"),
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Write/update committed OpenAPI snapshot from live export."""
|
|
135
|
+
cfg = load_config(
|
|
136
|
+
config,
|
|
137
|
+
overrides={
|
|
138
|
+
"app": app_path,
|
|
139
|
+
"app_factory": app_factory,
|
|
140
|
+
"export_mode": ExportMode.LIVE.value,
|
|
141
|
+
},
|
|
142
|
+
)
|
|
143
|
+
try:
|
|
144
|
+
result = export_openapi(cfg)
|
|
145
|
+
except ExportError as exc:
|
|
146
|
+
console.print(f"[red]Export failed:[/red] {exc}")
|
|
147
|
+
raise typer.Exit(2) from exc
|
|
148
|
+
path = snapshot_path or Path(cfg.snapshot_path)
|
|
149
|
+
write_snapshot(result.schema, path)
|
|
150
|
+
console.print(f"[green]Snapshot written[/green] {path}")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@app.command("diff")
|
|
154
|
+
def diff_cmd(
|
|
155
|
+
base_file: Path = typer.Option(..., "--base-file", help="Base OpenAPI JSON"),
|
|
156
|
+
candidate_file: Path | None = typer.Option(
|
|
157
|
+
None, "--candidate-file", help="Candidate OpenAPI JSON (default: live export)"
|
|
158
|
+
),
|
|
159
|
+
config: Path | None = typer.Option(None, "--config", "-c"),
|
|
160
|
+
app_path: str | None = typer.Option(None, "--app"),
|
|
161
|
+
policy: str | None = typer.Option(None, "--policy"),
|
|
162
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
163
|
+
) -> None:
|
|
164
|
+
"""Diff two OpenAPI documents (or live app vs base file)."""
|
|
165
|
+
cfg = load_config(config, overrides={"app": app_path, "policy": policy})
|
|
166
|
+
base = json.loads(base_file.read_text(encoding="utf-8"))
|
|
167
|
+
if candidate_file is not None:
|
|
168
|
+
candidate = json.loads(candidate_file.read_text(encoding="utf-8"))
|
|
169
|
+
else:
|
|
170
|
+
try:
|
|
171
|
+
candidate = export_openapi(cfg).schema
|
|
172
|
+
except ExportError as exc:
|
|
173
|
+
console.print(f"[red]Export failed:[/red] {exc}")
|
|
174
|
+
raise typer.Exit(2) from exc
|
|
175
|
+
result = diff_openapi(base, candidate, policy=cfg.policy)
|
|
176
|
+
if json_out:
|
|
177
|
+
out.print_json(
|
|
178
|
+
data={
|
|
179
|
+
"engine": result.engine,
|
|
180
|
+
"breaking": [c.to_dict() for c in result.breaking],
|
|
181
|
+
"risky": [c.to_dict() for c in result.risky],
|
|
182
|
+
"safe": [c.to_dict() for c in result.safe],
|
|
183
|
+
}
|
|
184
|
+
)
|
|
185
|
+
else:
|
|
186
|
+
console.print(render_text_summary(result))
|
|
187
|
+
for change in result.changes:
|
|
188
|
+
color = {
|
|
189
|
+
"breaking": "red",
|
|
190
|
+
"risky": "yellow",
|
|
191
|
+
"safe": "green",
|
|
192
|
+
}[change.severity.value]
|
|
193
|
+
method = f"{change.method.upper()} " if change.method else ""
|
|
194
|
+
console.print(
|
|
195
|
+
f"[{color}]{change.severity.value}[/{color}] "
|
|
196
|
+
f"{method}{change.path} — {change.message}"
|
|
197
|
+
)
|
|
198
|
+
raise typer.Exit(1 if result.breaking else 0)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@app.command("check")
|
|
202
|
+
def check_cmd(
|
|
203
|
+
config: Path | None = typer.Option(None, "--config", "-c"),
|
|
204
|
+
app_path: str | None = typer.Option(None, "--app"),
|
|
205
|
+
base_file: Path | None = typer.Option(None, "--base-file"),
|
|
206
|
+
policy: str | None = typer.Option(None, "--policy"),
|
|
207
|
+
fail_on: str | None = typer.Option(None, "--fail-on"),
|
|
208
|
+
pr_title: str | None = typer.Option(
|
|
209
|
+
None, "--pr-title", help="PR title for intentional protocol"
|
|
210
|
+
),
|
|
211
|
+
pr_labels: str | None = typer.Option(
|
|
212
|
+
None, "--pr-labels", help="Comma-separated PR labels"
|
|
213
|
+
),
|
|
214
|
+
report_file: Path | None = typer.Option(
|
|
215
|
+
None, "--report", help="Write markdown report to file"
|
|
216
|
+
),
|
|
217
|
+
annotations: bool = typer.Option(
|
|
218
|
+
False, "--annotations", help="Emit GitHub Actions annotations"
|
|
219
|
+
),
|
|
220
|
+
app_factory: bool | None = typer.Option(None, "--factory/--no-factory"),
|
|
221
|
+
) -> None:
|
|
222
|
+
"""CI entrypoint: fail on accidental breaking changes."""
|
|
223
|
+
# Allow GitHub Actions env defaults
|
|
224
|
+
if pr_title is None:
|
|
225
|
+
pr_title = os.environ.get("FAC_PR_TITLE") or os.environ.get("PR_TITLE")
|
|
226
|
+
labels_list: list[str] = []
|
|
227
|
+
raw_labels = pr_labels or os.environ.get("FAC_PR_LABELS") or os.environ.get("PR_LABELS")
|
|
228
|
+
if raw_labels:
|
|
229
|
+
labels_list = [part.strip() for part in raw_labels.split(",") if part.strip()]
|
|
230
|
+
|
|
231
|
+
cfg = load_config(
|
|
232
|
+
config,
|
|
233
|
+
overrides={
|
|
234
|
+
"app": app_path,
|
|
235
|
+
"policy": policy,
|
|
236
|
+
"fail_on": fail_on,
|
|
237
|
+
"app_factory": app_factory,
|
|
238
|
+
},
|
|
239
|
+
)
|
|
240
|
+
try:
|
|
241
|
+
result = run_check(
|
|
242
|
+
cfg,
|
|
243
|
+
base_schema_path=base_file,
|
|
244
|
+
pr_title=pr_title,
|
|
245
|
+
pr_labels=labels_list,
|
|
246
|
+
)
|
|
247
|
+
except (ExportError, FileNotFoundError, ValueError) as exc:
|
|
248
|
+
console.print(f"[red]Check failed to run:[/red] {exc}")
|
|
249
|
+
raise typer.Exit(2) from exc
|
|
250
|
+
|
|
251
|
+
out.print(Markdown(result.report_markdown))
|
|
252
|
+
if report_file is not None:
|
|
253
|
+
report_file.write_text(result.report_markdown, encoding="utf-8")
|
|
254
|
+
console.print(f"Report written to {report_file}")
|
|
255
|
+
if annotations:
|
|
256
|
+
for line in github_check_annotation_lines(result.diff):
|
|
257
|
+
# annotations must go to stdout for GHA
|
|
258
|
+
sys.stdout.write(line + "\n")
|
|
259
|
+
for message in result.messages:
|
|
260
|
+
console.print(f"[dim]{message}[/dim]")
|
|
261
|
+
raise typer.Exit(result.exit_code)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@app.command("comment")
|
|
265
|
+
def comment_cmd(
|
|
266
|
+
config: Path | None = typer.Option(None, "--config", "-c"),
|
|
267
|
+
app_path: str | None = typer.Option(None, "--app"),
|
|
268
|
+
base_file: Path | None = typer.Option(None, "--base-file"),
|
|
269
|
+
policy: str | None = typer.Option(None, "--policy"),
|
|
270
|
+
output: Path = typer.Option(Path("contract-comment.md"), "--output", "-o"),
|
|
271
|
+
pr_title: str | None = typer.Option(None, "--pr-title"),
|
|
272
|
+
pr_labels: str | None = typer.Option(None, "--pr-labels"),
|
|
273
|
+
) -> None:
|
|
274
|
+
"""Render PR markdown comment without enforcing exit semantics beyond generation."""
|
|
275
|
+
labels_list = [
|
|
276
|
+
part.strip() for part in (pr_labels or "").split(",") if part.strip()
|
|
277
|
+
]
|
|
278
|
+
cfg = load_config(config, overrides={"app": app_path, "policy": policy})
|
|
279
|
+
try:
|
|
280
|
+
result = run_check(
|
|
281
|
+
cfg,
|
|
282
|
+
base_schema_path=base_file,
|
|
283
|
+
pr_title=pr_title,
|
|
284
|
+
pr_labels=labels_list,
|
|
285
|
+
)
|
|
286
|
+
except (ExportError, FileNotFoundError, ValueError) as exc:
|
|
287
|
+
console.print(f"[red]Comment generation failed:[/red] {exc}")
|
|
288
|
+
raise typer.Exit(2) from exc
|
|
289
|
+
output.write_text(result.report_markdown, encoding="utf-8")
|
|
290
|
+
console.print(f"[green]Wrote[/green] {output}")
|
|
291
|
+
raise typer.Exit(0)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
if __name__ == "__main__":
|
|
295
|
+
app()
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Render human-readable PR / terminal reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi_contract.diff.classifier import ChangeSeverity, DiffResult
|
|
6
|
+
from fastapi_contract.intentional import IntentionalStatus, suggest_semver_bump
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def render_markdown_report(
|
|
10
|
+
diff: DiffResult,
|
|
11
|
+
*,
|
|
12
|
+
intentional: IntentionalStatus | None = None,
|
|
13
|
+
failed: bool = False,
|
|
14
|
+
title: str = "fastapi-contract report",
|
|
15
|
+
) -> str:
|
|
16
|
+
breaking = diff.breaking
|
|
17
|
+
risky = diff.risky
|
|
18
|
+
safe = diff.safe
|
|
19
|
+
bump = suggest_semver_bump(bool(breaking), bool(safe or risky))
|
|
20
|
+
|
|
21
|
+
status = "FAILED" if failed else "PASSED"
|
|
22
|
+
lines = [
|
|
23
|
+
f"## {title}",
|
|
24
|
+
"",
|
|
25
|
+
f"**Status:** {status} ",
|
|
26
|
+
f"**Engine:** `{diff.engine}` ",
|
|
27
|
+
f"**Paths:** base={diff.base_paths}, candidate={diff.candidate_paths} ",
|
|
28
|
+
f"**Suggested SemVer bump:** `{bump}`",
|
|
29
|
+
"",
|
|
30
|
+
"| Severity | Count |",
|
|
31
|
+
"|----------|------:|",
|
|
32
|
+
f"| Breaking | {len(breaking)} |",
|
|
33
|
+
f"| Risky | {len(risky)} |",
|
|
34
|
+
f"| Safe | {len(safe)} |",
|
|
35
|
+
"",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
def section(name: str, items: list) -> None:
|
|
39
|
+
if not items:
|
|
40
|
+
return
|
|
41
|
+
lines.append(f"### {name}")
|
|
42
|
+
lines.append("")
|
|
43
|
+
for change in items:
|
|
44
|
+
method = f"{change.method.upper()} " if change.method else ""
|
|
45
|
+
lines.append(
|
|
46
|
+
f"- **{change.severity.value}** `{method}{change.path}` — {change.message}"
|
|
47
|
+
)
|
|
48
|
+
lines.append("")
|
|
49
|
+
|
|
50
|
+
section("Breaking changes", breaking)
|
|
51
|
+
section("Risky changes", risky)
|
|
52
|
+
section("Safe / additive changes", safe)
|
|
53
|
+
|
|
54
|
+
if intentional is not None:
|
|
55
|
+
lines.append("### Intentional break protocol")
|
|
56
|
+
lines.append("")
|
|
57
|
+
if intentional.allowed:
|
|
58
|
+
lines.append("Protocol **satisfied** — treat as reviewed intentional break.")
|
|
59
|
+
else:
|
|
60
|
+
lines.append("Protocol **not satisfied**:")
|
|
61
|
+
for reason in intentional.reasons:
|
|
62
|
+
lines.append(f"- {reason}")
|
|
63
|
+
lines.append("")
|
|
64
|
+
|
|
65
|
+
lines.append("### Next steps")
|
|
66
|
+
lines.append("")
|
|
67
|
+
if breaking and (intentional is None or not intentional.allowed):
|
|
68
|
+
lines.append(
|
|
69
|
+
"1. Revert accidental breaks, **or** complete the intentional protocol "
|
|
70
|
+
"(label/title marker + changelog `### Breaking` bullets)."
|
|
71
|
+
)
|
|
72
|
+
lines.append(
|
|
73
|
+
"2. Regenerate frontend clients if applicable "
|
|
74
|
+
"(`openapi-typescript`, hey-api, etc.)."
|
|
75
|
+
)
|
|
76
|
+
elif breaking and intentional and intentional.allowed:
|
|
77
|
+
lines.append("1. Confirm consumers are notified / versioned.")
|
|
78
|
+
lines.append("2. Regenerate generated clients and bump major version.")
|
|
79
|
+
else:
|
|
80
|
+
lines.append("1. No accidental breaking changes detected.")
|
|
81
|
+
lines.append("")
|
|
82
|
+
return "\n".join(lines)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def render_text_summary(diff: DiffResult) -> str:
|
|
86
|
+
return (
|
|
87
|
+
f"{len(diff.breaking)} breaking, {len(diff.risky)} risky, "
|
|
88
|
+
f"{len(diff.safe)} safe ({diff.engine})"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def github_check_annotation_lines(diff: DiffResult) -> list[str]:
|
|
93
|
+
"""Simple annotation-style lines for CI logs."""
|
|
94
|
+
lines: list[str] = []
|
|
95
|
+
for change in diff.changes:
|
|
96
|
+
if change.severity == ChangeSeverity.SAFE:
|
|
97
|
+
continue
|
|
98
|
+
level = "error" if change.severity == ChangeSeverity.BREAKING else "warning"
|
|
99
|
+
method = f"{change.method.upper()} " if change.method else ""
|
|
100
|
+
lines.append(f"::{level} title=fastapi-contract::{method}{change.path}: {change.message}")
|
|
101
|
+
return lines
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Configuration loading from pyproject.toml / fastapi-contract.toml / CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass, field, replace
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from fastapi_contract.models import ExportMode, FailOn, PolicyName
|
|
11
|
+
|
|
12
|
+
if sys.version_info >= (3, 11):
|
|
13
|
+
import tomllib
|
|
14
|
+
else:
|
|
15
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class ContractConfig:
|
|
20
|
+
app: str = "main:app"
|
|
21
|
+
app_factory: bool = False
|
|
22
|
+
base_ref: str = "origin/main"
|
|
23
|
+
fail_on: FailOn = FailOn.BREAKING
|
|
24
|
+
policy: PolicyName = PolicyName.ADDITIVE_FRIENDLY
|
|
25
|
+
intentional_label: str = "breaking-change"
|
|
26
|
+
intentional_title_marker: str = "[breaking-change]"
|
|
27
|
+
changelog_path: str = "CHANGELOG.md"
|
|
28
|
+
changelog_required_heading: str = "### Breaking"
|
|
29
|
+
snapshot_path: str = "openapi.snapshot.json"
|
|
30
|
+
export_mode: ExportMode = ExportMode.AUTO
|
|
31
|
+
stub_env_file: str | None = ".env.contract"
|
|
32
|
+
skip_lifespan: bool = True
|
|
33
|
+
extra_env: dict[str, str] = field(default_factory=dict)
|
|
34
|
+
oasdiff_bin: str | None = None
|
|
35
|
+
prefer_oasdiff: bool = False
|
|
36
|
+
|
|
37
|
+
def with_overrides(self, **kwargs: Any) -> ContractConfig:
|
|
38
|
+
cleaned = {k: v for k, v in kwargs.items() if v is not None}
|
|
39
|
+
if "fail_on" in cleaned and not isinstance(cleaned["fail_on"], FailOn):
|
|
40
|
+
cleaned["fail_on"] = FailOn(str(cleaned["fail_on"]))
|
|
41
|
+
if "policy" in cleaned and not isinstance(cleaned["policy"], PolicyName):
|
|
42
|
+
cleaned["policy"] = PolicyName(str(cleaned["policy"]))
|
|
43
|
+
if "export_mode" in cleaned and not isinstance(cleaned["export_mode"], ExportMode):
|
|
44
|
+
cleaned["export_mode"] = ExportMode(str(cleaned["export_mode"]))
|
|
45
|
+
if "app_factory" in cleaned:
|
|
46
|
+
cleaned["app_factory"] = _as_bool(cleaned["app_factory"], self.app_factory)
|
|
47
|
+
if "skip_lifespan" in cleaned:
|
|
48
|
+
cleaned["skip_lifespan"] = _as_bool(cleaned["skip_lifespan"], self.skip_lifespan)
|
|
49
|
+
if "prefer_oasdiff" in cleaned:
|
|
50
|
+
cleaned["prefer_oasdiff"] = _as_bool(cleaned["prefer_oasdiff"], self.prefer_oasdiff)
|
|
51
|
+
return replace(self, **cleaned)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_TRUE = {"1", "true", "yes", "on"}
|
|
55
|
+
_FALSE = {"0", "false", "no", "off"}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _as_bool(value: Any, default: bool) -> bool:
|
|
59
|
+
if value is None:
|
|
60
|
+
return default
|
|
61
|
+
if isinstance(value, bool):
|
|
62
|
+
return value
|
|
63
|
+
if isinstance(value, str):
|
|
64
|
+
lowered = value.strip().lower()
|
|
65
|
+
if lowered in _TRUE:
|
|
66
|
+
return True
|
|
67
|
+
if lowered in _FALSE:
|
|
68
|
+
return False
|
|
69
|
+
return bool(value)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _as_enum(enum_cls: type[Any], value: Any, default: Any) -> Any:
|
|
73
|
+
if value is None:
|
|
74
|
+
return default
|
|
75
|
+
if isinstance(value, enum_cls):
|
|
76
|
+
return value
|
|
77
|
+
return enum_cls(str(value))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _find_config_file(start: Path | None = None) -> Path | None:
|
|
81
|
+
cwd = (start or Path.cwd()).resolve()
|
|
82
|
+
for directory in [cwd, *cwd.parents]:
|
|
83
|
+
for name in ("fastapi-contract.toml", "pyproject.toml"):
|
|
84
|
+
candidate = directory / name
|
|
85
|
+
if candidate.is_file():
|
|
86
|
+
return candidate
|
|
87
|
+
if (directory / ".git").exists():
|
|
88
|
+
break
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _section_from_toml(path: Path) -> dict[str, Any]:
|
|
93
|
+
raw = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
94
|
+
if path.name == "fastapi-contract.toml":
|
|
95
|
+
return dict(raw.get("tool", {}).get("fastapi_contract", raw.get("fastapi_contract", raw)))
|
|
96
|
+
tool = raw.get("tool", {})
|
|
97
|
+
section = tool.get("fastapi_contract") or tool.get("fastapi-contract")
|
|
98
|
+
return dict(section or {})
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def load_config(
|
|
102
|
+
path: Path | None = None,
|
|
103
|
+
*,
|
|
104
|
+
overrides: dict[str, Any] | None = None,
|
|
105
|
+
) -> ContractConfig:
|
|
106
|
+
"""Load config from TOML, then apply optional CLI overrides."""
|
|
107
|
+
cfg = ContractConfig()
|
|
108
|
+
config_path = path or _find_config_file()
|
|
109
|
+
data: dict[str, Any] = {}
|
|
110
|
+
if config_path is not None:
|
|
111
|
+
data = _section_from_toml(config_path)
|
|
112
|
+
|
|
113
|
+
if data:
|
|
114
|
+
extra_env = data.get("extra_env") or data.get("env") or {}
|
|
115
|
+
if not isinstance(extra_env, dict):
|
|
116
|
+
extra_env = {}
|
|
117
|
+
cfg = ContractConfig(
|
|
118
|
+
app=str(data.get("app", cfg.app)),
|
|
119
|
+
app_factory=_as_bool(data.get("app_factory"), cfg.app_factory),
|
|
120
|
+
base_ref=str(data.get("base_ref", cfg.base_ref)),
|
|
121
|
+
fail_on=_as_enum(FailOn, data.get("fail_on"), cfg.fail_on),
|
|
122
|
+
policy=_as_enum(PolicyName, data.get("policy"), cfg.policy),
|
|
123
|
+
intentional_label=str(data.get("intentional_label", cfg.intentional_label)),
|
|
124
|
+
intentional_title_marker=str(
|
|
125
|
+
data.get("intentional_title_marker", cfg.intentional_title_marker)
|
|
126
|
+
),
|
|
127
|
+
changelog_path=str(data.get("changelog_path", cfg.changelog_path)),
|
|
128
|
+
changelog_required_heading=str(
|
|
129
|
+
data.get("changelog_required_heading", cfg.changelog_required_heading)
|
|
130
|
+
),
|
|
131
|
+
snapshot_path=str(data.get("snapshot_path", cfg.snapshot_path)),
|
|
132
|
+
export_mode=_as_enum(ExportMode, data.get("export_mode"), cfg.export_mode),
|
|
133
|
+
stub_env_file=(
|
|
134
|
+
None
|
|
135
|
+
if data.get("stub_env_file") in (None, "", False)
|
|
136
|
+
else str(data.get("stub_env_file", cfg.stub_env_file))
|
|
137
|
+
),
|
|
138
|
+
skip_lifespan=_as_bool(data.get("skip_lifespan"), cfg.skip_lifespan),
|
|
139
|
+
extra_env={str(k): str(v) for k, v in extra_env.items()},
|
|
140
|
+
oasdiff_bin=(
|
|
141
|
+
str(data["oasdiff_bin"]) if data.get("oasdiff_bin") else cfg.oasdiff_bin
|
|
142
|
+
),
|
|
143
|
+
prefer_oasdiff=_as_bool(data.get("prefer_oasdiff"), cfg.prefer_oasdiff),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if overrides:
|
|
147
|
+
mapped = dict(overrides)
|
|
148
|
+
if "fail_on" in mapped and mapped["fail_on"] is not None:
|
|
149
|
+
mapped["fail_on"] = _as_enum(FailOn, mapped["fail_on"], cfg.fail_on)
|
|
150
|
+
if "policy" in mapped and mapped["policy"] is not None:
|
|
151
|
+
mapped["policy"] = _as_enum(PolicyName, mapped["policy"], cfg.policy)
|
|
152
|
+
if "export_mode" in mapped and mapped["export_mode"] is not None:
|
|
153
|
+
mapped["export_mode"] = _as_enum(ExportMode, mapped["export_mode"], cfg.export_mode)
|
|
154
|
+
if "app_factory" in mapped and mapped["app_factory"] is not None:
|
|
155
|
+
mapped["app_factory"] = _as_bool(mapped["app_factory"], cfg.app_factory)
|
|
156
|
+
if "skip_lifespan" in mapped and mapped["skip_lifespan"] is not None:
|
|
157
|
+
mapped["skip_lifespan"] = _as_bool(mapped["skip_lifespan"], cfg.skip_lifespan)
|
|
158
|
+
if "prefer_oasdiff" in mapped and mapped["prefer_oasdiff"] is not None:
|
|
159
|
+
mapped["prefer_oasdiff"] = _as_bool(mapped["prefer_oasdiff"], cfg.prefer_oasdiff)
|
|
160
|
+
cfg = cfg.with_overrides(**mapped)
|
|
161
|
+
return cfg
|