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