seampact-fastapi 1.0.0__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.
- seampact/fastapi/__init__.py +2 -0
- seampact/fastapi/generator.py +85 -0
- seampact/fastapi/options.py +241 -0
- seampact/fastapi/placement_module.py +287 -0
- seampact/fastapi/py.typed +0 -0
- seampact/fastapi/runtime/__init__.py +63 -0
- seampact/fastapi/runtime/api_binding.py +178 -0
- seampact/fastapi/runtime/binding.py +23 -0
- seampact/fastapi/runtime/boot_check.py +336 -0
- seampact/fastapi/runtime/boundary.py +472 -0
- seampact/fastapi/runtime/exception_map.py +111 -0
- seampact/fastapi/runtime/exception_roles.py +71 -0
- seampact/fastapi/runtime/framework_document.py +54 -0
- seampact/fastapi/runtime/handler_call.py +16 -0
- seampact/fastapi/runtime/input_features.py +54 -0
- seampact/fastapi/runtime/native_docs.py +12 -0
- seampact/fastapi/runtime/placement.py +113 -0
- seampact/fastapi/runtime/request_state.py +69 -0
- seampact/fastapi/runtime/routes.py +297 -0
- seampact_fastapi-1.0.0.dist-info/METADATA +20 -0
- seampact_fastapi-1.0.0.dist-info/RECORD +24 -0
- seampact_fastapi-1.0.0.dist-info/WHEEL +4 -0
- seampact_fastapi-1.0.0.dist-info/entry_points.txt +2 -0
- seampact_fastapi-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""The fastapi target (ADR 0026), which `seampact generate` finds through the `seampact.generators` entry point of
|
|
2
|
+
`seampact-fastapi`. Per api it writes one package: `schemas.py` (the schema models and each operation's input model,
|
|
3
|
+
through the shared Pydantic emitter), `operations.py` (the operation types), `contract.py` (the contract the runtime
|
|
4
|
+
reads, shared with every Python target), `routes.py` (the route names and URL
|
|
5
|
+
builders, unless `routeHelpers: false`), `fastapi.py` (the placement and `bind_<api>()`) and, with `nativeDocs`,
|
|
6
|
+
`native_docs.json` (seampact's operation objects for FastAPI's document). It refuses by name every
|
|
7
|
+
option, artefact and contract form it does not serve, all at once."""
|
|
8
|
+
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from typing import Final
|
|
11
|
+
|
|
12
|
+
from seampact.codegen.contract_module import contract_module
|
|
13
|
+
from seampact.codegen.handler_forms import handler_warnings
|
|
14
|
+
from seampact.codegen.input_schemas import proof_models, with_input_schemas
|
|
15
|
+
from seampact.codegen.kept_forms import refuse_kept_forms
|
|
16
|
+
from seampact.codegen.matrix_row import refuse_missing_matrix_row
|
|
17
|
+
from seampact.codegen.names import api_names
|
|
18
|
+
from seampact.codegen.native_docs_file import FrameworkDocument, native_docs_file
|
|
19
|
+
from seampact.codegen.operation_types import operation_types_module
|
|
20
|
+
from seampact.codegen.printer import PythonModule
|
|
21
|
+
from seampact.codegen.pydantic_forms import refuse_unserved_forms
|
|
22
|
+
from seampact.codegen.pydantic_layout import api_layout
|
|
23
|
+
from seampact.codegen.pydantic_models import pydantic_schemas_module
|
|
24
|
+
from seampact.codegen.refusals import Refusals
|
|
25
|
+
from seampact.codegen.route_helpers import route_helpers_module
|
|
26
|
+
from seampact.codegen.routes import route_table
|
|
27
|
+
from seampact.fastapi.options import read_options
|
|
28
|
+
from seampact.fastapi.placement_module import placement_module
|
|
29
|
+
from seampact.ir.config.problem import ConfigProblem
|
|
30
|
+
from seampact.ir.model import ContractIR
|
|
31
|
+
from seampact.ir.read import IrSupport
|
|
32
|
+
from seampact.ir.run.generator import TargetEntry
|
|
33
|
+
from seampact.ir.write import GeneratedFile
|
|
34
|
+
|
|
35
|
+
TARGET: Final = "fastapi"
|
|
36
|
+
DISTRIBUTION: Final = "seampact-fastapi"
|
|
37
|
+
_HTTP_OPERATION: Final = "httpOperation"
|
|
38
|
+
_FASTAPI_DOCUMENT: Final = FrameworkDocument(TARGET, "FastAPI", ("ValidationError", "HTTPValidationError"))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class FastApiGenerator:
|
|
42
|
+
def ir_support(self) -> IrSupport:
|
|
43
|
+
return IrSupport.of_model(kinds=[_HTTP_OPERATION])
|
|
44
|
+
|
|
45
|
+
def framework_docs_off_fix(self) -> str | None:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
def handler_warnings(self, ir: ContractIR, entry: TargetEntry) -> Sequence[ConfigProblem]:
|
|
49
|
+
return handler_warnings(ir, entry.id, entry.api)
|
|
50
|
+
|
|
51
|
+
def generate(self, ir: ContractIR, entry: TargetEntry) -> Sequence[GeneratedFile]:
|
|
52
|
+
refusals = Refusals(entry.id, TARGET, DISTRIBUTION)
|
|
53
|
+
options = read_options(entry.options, entry.artefacts, refusals)
|
|
54
|
+
|
|
55
|
+
refuse_kept_forms(ir, refusals)
|
|
56
|
+
|
|
57
|
+
refuse_missing_matrix_row(entry, refusals, "FastAPI and Starlette")
|
|
58
|
+
refuse_unserved_forms(ir, refusals)
|
|
59
|
+
error_codes = sorted({error.code for operation in ir.operations for error in operation.errors})
|
|
60
|
+
layout = api_layout(ir, error_codes, refusals)
|
|
61
|
+
with_inputs = with_input_schemas(ir, layout)
|
|
62
|
+
names = api_names(with_inputs, refusals)
|
|
63
|
+
routes = route_table(ir, entry.mount or "", refusals)
|
|
64
|
+
files = [
|
|
65
|
+
GeneratedFile("schemas.py", pydantic_schemas_module(with_inputs, names, options.rules, refusals)),
|
|
66
|
+
GeneratedFile(
|
|
67
|
+
"__init__.py", PythonModule(f"The generated code of api '{ir.api.id}' for the FastAPI target.").text()
|
|
68
|
+
),
|
|
69
|
+
GeneratedFile("operations.py", operation_types_module(with_inputs, names, layout, refusals)),
|
|
70
|
+
GeneratedFile("contract.py", contract_module(ir, names, entry.mount or "", refusals)),
|
|
71
|
+
GeneratedFile(
|
|
72
|
+
"fastapi.py", placement_module(ir, layout, proof_models(layout, names), routes, options, entry)
|
|
73
|
+
),
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
if options.route_helpers:
|
|
77
|
+
files.append(GeneratedFile("routes.py", route_helpers_module(ir, routes, refusals)))
|
|
78
|
+
|
|
79
|
+
if options.native_docs:
|
|
80
|
+
native_docs = native_docs_file(entry.native_docs, layout.operations, _FASTAPI_DOCUMENT, refusals)
|
|
81
|
+
files.extend([] if native_docs is None else [native_docs])
|
|
82
|
+
|
|
83
|
+
refusals.raise_if_any()
|
|
84
|
+
|
|
85
|
+
return sorted(files, key=lambda generated: generated.path)
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""The options and artefact switches of a fastapi target entry (ADR 0026 "Options and registration" and "What the
|
|
2
|
+
shape-imposing features turn off with"), each unknown key or value outside its set refused by name: `handlers`
|
|
3
|
+
(`perResource` only), `indent` (the four spaces ruff writes), `rules`, and the run-time defaults `responses`,
|
|
4
|
+
`bodyValidation`, `frameworkEtag` and `catchAll`; the naming options and `layout` are refused. The artefacts are
|
|
5
|
+
`bootCheck`, `accessHooks` (`false` waits for a consumer), `routeHelpers` and `nativeDocs`."""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
from typing import Final
|
|
11
|
+
|
|
12
|
+
from seampact.codegen.boot_check_hooks import HOOKS_ERROR, read_boot_check_hooks
|
|
13
|
+
from seampact.codegen.pydantic_models import RulesMode
|
|
14
|
+
from seampact.codegen.refusals import Delivery, IrPlace, Refusals
|
|
15
|
+
from seampact.codegen.runtime.reply_verdict import FrameworkEtagMode, ResponsesMode
|
|
16
|
+
from seampact.fastapi.runtime.placement import BootCheckSwitches, FrameworkRoutesCheck, HooksCheck, TargetDefaults
|
|
17
|
+
from seampact.ir.config.json_value import JsonValue
|
|
18
|
+
|
|
19
|
+
INDENT_SPACES: Final = 4
|
|
20
|
+
_HANDLERS: Final = "handlers"
|
|
21
|
+
_PER_RESOURCE: Final = "perResource"
|
|
22
|
+
_INDENT: Final = "indent"
|
|
23
|
+
_RULES: Final = "rules"
|
|
24
|
+
_RESPONSES: Final = "responses"
|
|
25
|
+
_BODY_VALIDATION: Final = "bodyValidation"
|
|
26
|
+
_FRAMEWORK_ETAG: Final = "frameworkEtag"
|
|
27
|
+
_CATCH_ALL: Final = "catchAll"
|
|
28
|
+
_LAYOUT: Final = "layout"
|
|
29
|
+
_NAMING_KEYS: Final = ("suffixes", "escape", "enumCase")
|
|
30
|
+
_KNOWN_KEYS: Final = (
|
|
31
|
+
_HANDLERS,
|
|
32
|
+
_INDENT,
|
|
33
|
+
_RULES,
|
|
34
|
+
_RESPONSES,
|
|
35
|
+
_BODY_VALIDATION,
|
|
36
|
+
_FRAMEWORK_ETAG,
|
|
37
|
+
_CATCH_ALL,
|
|
38
|
+
_LAYOUT,
|
|
39
|
+
*_NAMING_KEYS,
|
|
40
|
+
)
|
|
41
|
+
_BOOT_CHECK: Final = "bootCheck"
|
|
42
|
+
_ACCESS_HOOKS: Final = "accessHooks"
|
|
43
|
+
_ROUTE_HELPERS: Final = "routeHelpers"
|
|
44
|
+
_NATIVE_DOCS: Final = "nativeDocs"
|
|
45
|
+
_ARTEFACTS: Final = (_BOOT_CHECK, _ACCESS_HOOKS, _ROUTE_HELPERS, _NATIVE_DOCS)
|
|
46
|
+
_STRICT_ROUTES: Final = "strict"
|
|
47
|
+
_FRAMEWORK_ROUTES: Final = "frameworkRoutes"
|
|
48
|
+
_HOOKS: Final = "hooks"
|
|
49
|
+
_BOOT_CHECK_KEYS: Final = (_FRAMEWORK_ROUTES, _HOOKS)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class FastApiOptions:
|
|
54
|
+
rules: RulesMode
|
|
55
|
+
defaults: TargetDefaults
|
|
56
|
+
boot_check: BootCheckSwitches
|
|
57
|
+
route_helpers: bool
|
|
58
|
+
native_docs: bool
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def read_options(
|
|
62
|
+
options: Mapping[str, JsonValue], artefacts: Mapping[str, JsonValue], refusals: Refusals
|
|
63
|
+
) -> FastApiOptions:
|
|
64
|
+
"""The entry's options and artefacts; every refusal goes to `refusals`, and a refused value reads as its default."""
|
|
65
|
+
values = _OptionValues(options, refusals)
|
|
66
|
+
values.refuse_unknown()
|
|
67
|
+
values.refuse_unsupported()
|
|
68
|
+
|
|
69
|
+
return FastApiOptions(
|
|
70
|
+
rules=values.choice(_RULES, RulesMode.NATIVE),
|
|
71
|
+
defaults=TargetDefaults(
|
|
72
|
+
responses=values.choice(_RESPONSES, ResponsesMode.STRICT),
|
|
73
|
+
body_validation=values.flag(_BODY_VALIDATION, default=False),
|
|
74
|
+
framework_etag=values.choice(_FRAMEWORK_ETAG, FrameworkEtagMode.STRIP),
|
|
75
|
+
catch_all=values.flag(_CATCH_ALL, default=True),
|
|
76
|
+
),
|
|
77
|
+
boot_check=_boot_check(artefacts.get(_BOOT_CHECK, True), refusals),
|
|
78
|
+
route_helpers=_read_artefacts(artefacts, refusals),
|
|
79
|
+
native_docs=artefacts.get(_NATIVE_DOCS) is True,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class _OptionValues:
|
|
84
|
+
def __init__(self, options: Mapping[str, JsonValue], refusals: Refusals) -> None:
|
|
85
|
+
self._options = options
|
|
86
|
+
self._refusals = refusals
|
|
87
|
+
|
|
88
|
+
def refuse_unknown(self) -> None:
|
|
89
|
+
for key in self._options:
|
|
90
|
+
if key not in _KNOWN_KEYS:
|
|
91
|
+
self._refuse(
|
|
92
|
+
"options.unknown",
|
|
93
|
+
key,
|
|
94
|
+
f"The option '{key}' is not one the fastapi target knows; it takes "
|
|
95
|
+
f"'{"', '".join(_KNOWN_KEYS[:7])}'.",
|
|
96
|
+
f"Remove '{key}' from the target's `options`.",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def refuse_unsupported(self) -> None:
|
|
100
|
+
handlers = self._options.get(_HANDLERS, _PER_RESOURCE)
|
|
101
|
+
|
|
102
|
+
if handlers != _PER_RESOURCE:
|
|
103
|
+
self._refuse(
|
|
104
|
+
"options.handlers.unsupported",
|
|
105
|
+
_HANDLERS,
|
|
106
|
+
f"The option 'handlers' is {handlers!r}; the fastapi target groups operations per resource only (ADR "
|
|
107
|
+
"0007 'perResource'), and 'perOperation' waits for an app that needs it.",
|
|
108
|
+
"Set 'handlers' to 'perResource', or leave it out.",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
indent = self._options.get(_INDENT, INDENT_SPACES)
|
|
112
|
+
|
|
113
|
+
if indent != INDENT_SPACES or isinstance(indent, bool):
|
|
114
|
+
self._refuse(
|
|
115
|
+
"options.indent.unsupported",
|
|
116
|
+
_INDENT,
|
|
117
|
+
f"The option 'indent' is {indent!r}; the fastapi target writes the four spaces ruff formats with.",
|
|
118
|
+
f"Set 'indent' to {INDENT_SPACES}, or leave it out.",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if _LAYOUT in self._options:
|
|
122
|
+
self._refuse(
|
|
123
|
+
"options.layout.unsupported",
|
|
124
|
+
_LAYOUT,
|
|
125
|
+
"The option 'layout' is set; the fastapi target writes one package per api, the only layout it takes.",
|
|
126
|
+
"Remove 'layout' from the target's `options`.",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
for key in _NAMING_KEYS:
|
|
130
|
+
if key in self._options:
|
|
131
|
+
self._refuse(
|
|
132
|
+
"options.naming.unsupported",
|
|
133
|
+
key,
|
|
134
|
+
f"The naming option '{key}' is not taken by the fastapi target yet: it names Python with the "
|
|
135
|
+
"defaults of ADR 0007, which the shared naming vectors test.",
|
|
136
|
+
f"Remove '{key}' from the target's `options`.",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def choice[E: StrEnum](self, key: str, default: E) -> E:
|
|
140
|
+
value = self._options.get(key, default.value)
|
|
141
|
+
|
|
142
|
+
for case in type(default):
|
|
143
|
+
if case.value == value:
|
|
144
|
+
return case
|
|
145
|
+
|
|
146
|
+
allowed = "', '".join(case.value for case in type(default))
|
|
147
|
+
self._refuse(
|
|
148
|
+
f"options.{key}.invalid",
|
|
149
|
+
key,
|
|
150
|
+
f"The option '{key}' is {value!r}, not one of '{allowed}'.",
|
|
151
|
+
f"Set '{key}' to '{default.value}' (the default) or another listed value.",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return default
|
|
155
|
+
|
|
156
|
+
def flag(self, key: str, *, default: bool) -> bool:
|
|
157
|
+
value = self._options.get(key, default)
|
|
158
|
+
|
|
159
|
+
if isinstance(value, bool):
|
|
160
|
+
return value
|
|
161
|
+
|
|
162
|
+
self._refuse(
|
|
163
|
+
f"options.{key}.invalid",
|
|
164
|
+
key,
|
|
165
|
+
f"The option '{key}' is {value!r}, not true or false.",
|
|
166
|
+
f"Set '{key}' to true or false (the default is {str(default).lower()}).",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
return default
|
|
170
|
+
|
|
171
|
+
def _refuse(self, code: str, key: str, message: str, fix: str) -> None:
|
|
172
|
+
self._refusals.add(code, message, fix, IrPlace(f"/options/{key}"))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _boot_check(value: JsonValue, refusals: Refusals) -> BootCheckSwitches:
|
|
176
|
+
"""`bootCheck`: a boolean, or `{ frameworkRoutes, hooks }`; a value outside its set is refused and reads as the
|
|
177
|
+
default."""
|
|
178
|
+
at = IrPlace(f"/artefacts/{_BOOT_CHECK}")
|
|
179
|
+
|
|
180
|
+
if not isinstance(value, Mapping):
|
|
181
|
+
if not isinstance(value, bool):
|
|
182
|
+
_refuse_boot_check_value(_BOOT_CHECK, value, "true, false or an object", at, refusals)
|
|
183
|
+
|
|
184
|
+
enabled = value is not False
|
|
185
|
+
on = FrameworkRoutesCheck.ON if enabled else FrameworkRoutesCheck.OFF
|
|
186
|
+
|
|
187
|
+
return BootCheckSwitches(enabled=enabled, framework_routes=on, hooks=HooksCheck.ERROR)
|
|
188
|
+
|
|
189
|
+
for key in value:
|
|
190
|
+
if key not in _BOOT_CHECK_KEYS:
|
|
191
|
+
_refuse_boot_check_value(f"{_BOOT_CHECK}.{key}", value[key], "absent", at.below(key), refusals)
|
|
192
|
+
|
|
193
|
+
framework_routes = value.get(_FRAMEWORK_ROUTES, True)
|
|
194
|
+
routes_check = _framework_routes_check(framework_routes)
|
|
195
|
+
hooks_error = read_boot_check_hooks(value.get(_HOOKS, HOOKS_ERROR), at.below(_HOOKS), refusals)
|
|
196
|
+
hooks_check = HooksCheck.ERROR if hooks_error else HooksCheck.OFF
|
|
197
|
+
|
|
198
|
+
if routes_check is None:
|
|
199
|
+
allowed = f"true, false or '{_STRICT_ROUTES}'"
|
|
200
|
+
_refuse_boot_check_value(
|
|
201
|
+
f"{_BOOT_CHECK}.{_FRAMEWORK_ROUTES}", framework_routes, allowed, at.below(_FRAMEWORK_ROUTES), refusals
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
return BootCheckSwitches(enabled=True, framework_routes=routes_check or FrameworkRoutesCheck.ON, hooks=hooks_check)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _framework_routes_check(value: JsonValue) -> FrameworkRoutesCheck | None:
|
|
208
|
+
if value is True:
|
|
209
|
+
return FrameworkRoutesCheck.ON
|
|
210
|
+
|
|
211
|
+
if value is False:
|
|
212
|
+
return FrameworkRoutesCheck.OFF
|
|
213
|
+
|
|
214
|
+
return FrameworkRoutesCheck.STRICT if value == _STRICT_ROUTES else None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _refuse_boot_check_value(name: str, value: JsonValue, allowed: str, at: IrPlace, refusals: Refusals) -> None:
|
|
218
|
+
refusals.add(
|
|
219
|
+
f"artefacts.{name}.invalid",
|
|
220
|
+
f"The artefact '{name}' is {value!r}, not {allowed}.",
|
|
221
|
+
f"Set '{name}' to one of the listed values, or remove it for its default.",
|
|
222
|
+
at,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _read_artefacts(artefacts: Mapping[str, JsonValue], refusals: Refusals) -> bool:
|
|
227
|
+
"""Refuses the artefacts the target does not take yet; returns whether the route helpers are on."""
|
|
228
|
+
for key, value in artefacts.items():
|
|
229
|
+
at = IrPlace(f"/artefacts/{key}")
|
|
230
|
+
|
|
231
|
+
if key not in _ARTEFACTS:
|
|
232
|
+
refusals.add(
|
|
233
|
+
"artefacts.unknown",
|
|
234
|
+
f"The artefact '{key}' is not one the fastapi target declares; it takes '{"', '".join(_ARTEFACTS)}'.",
|
|
235
|
+
f"Remove '{key}' from the entry's `artefacts`.",
|
|
236
|
+
at,
|
|
237
|
+
)
|
|
238
|
+
elif key == _ACCESS_HOOKS and value is False:
|
|
239
|
+
refusals.waiting("artefacts.accessHooks", "The artefact switch 'accessHooks: false'", at, Delivery.CONSUMER)
|
|
240
|
+
|
|
241
|
+
return artefacts.get(_ROUTE_HELPERS, True) is not False
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""The `fastapi` module of an api's package (ADR 0026 "Placement" and "Routes"): `GENERATED_BY`, the api's placement
|
|
2
|
+
(its contract routes in registration order, literal segments first, under the entry's `mount`; its contract exception
|
|
3
|
+
base; the run-time defaults of the target options; the boot check switches and the entry's framework routes), the
|
|
4
|
+
`<Api>HandlerFactories` the app fills with one FastAPI dependency per endpoints protocol, one coroutine per operation
|
|
5
|
+
calling its handler method, and `bind_<api>()`, which declares each contract route on the api's router and hands it
|
|
6
|
+
to the runtime."""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from typing import Final
|
|
10
|
+
|
|
11
|
+
from seampact.codegen.native_docs_file import NATIVE_DOCS_FILE
|
|
12
|
+
from seampact.codegen.placement_lines import (
|
|
13
|
+
add_operation_call,
|
|
14
|
+
credentials_types,
|
|
15
|
+
operation_types_arguments,
|
|
16
|
+
tuple_literal,
|
|
17
|
+
unexpected_error_listener,
|
|
18
|
+
)
|
|
19
|
+
from seampact.codegen.printer import INDENT, PythonModule, bracketed, string_literal
|
|
20
|
+
from seampact.codegen.pydantic_layout import ApiLayout
|
|
21
|
+
from seampact.codegen.pydantic_models import PYDANTIC_MAJOR, PYDANTIC_PACKAGE
|
|
22
|
+
from seampact.codegen.routes import RouteEntry
|
|
23
|
+
from seampact.codegen.runtime import RUNTIME_VERSION
|
|
24
|
+
from seampact.codegen.source_lines import with_local_imports
|
|
25
|
+
from seampact.fastapi.options import FastApiOptions
|
|
26
|
+
from seampact.ir.model import ContractIR
|
|
27
|
+
from seampact.ir.run.generator import FrameworkDocs, TargetEntry
|
|
28
|
+
|
|
29
|
+
_RUNTIME: Final = "_runtime"
|
|
30
|
+
_HOOKS: Final = "_hooks"
|
|
31
|
+
_ABC: Final = "_abc"
|
|
32
|
+
# Each operation's coroutine: no module alias starts so, whatever the operation's name.
|
|
33
|
+
_CALL_PREFIX: Final = "_call_"
|
|
34
|
+
_CONTRACT_MODULE: Final = "contract"
|
|
35
|
+
_SCHEMAS_MODULE: Final = "schemas"
|
|
36
|
+
_BIND_DOC: Final = "Places api '{api}' in the app; call it last, after its own middleware and exception handlers."
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def placement_module(
|
|
40
|
+
ir: ContractIR,
|
|
41
|
+
layout: ApiLayout,
|
|
42
|
+
proof_models: Mapping[str, str],
|
|
43
|
+
routes: Sequence[RouteEntry],
|
|
44
|
+
options: FastApiOptions,
|
|
45
|
+
entry: TargetEntry,
|
|
46
|
+
) -> str:
|
|
47
|
+
"""`proof_models` names the `<Op>Proofs` class of the schemas module for each operation whose body has proofs."""
|
|
48
|
+
module = PythonModule(f"The FastAPI placement of api '{ir.api.id}'.")
|
|
49
|
+
module.imported("collections.abc", _ABC)
|
|
50
|
+
module.imported("dataclasses", "_dataclasses")
|
|
51
|
+
|
|
52
|
+
if options.native_docs:
|
|
53
|
+
module.imported("pathlib", "_pathlib")
|
|
54
|
+
|
|
55
|
+
module.imported("typing", "_typing")
|
|
56
|
+
module.imported("seampact.fastapi.runtime", _RUNTIME)
|
|
57
|
+
module.imported("seampact.hooks", _HOOKS)
|
|
58
|
+
root_names = [layout.contract_error, *(credentials.class_name for credentials in layout.credentials.values())]
|
|
59
|
+
operations = [operation for operation in ir.operations if operation.id in layout.operations]
|
|
60
|
+
|
|
61
|
+
for operation in operations:
|
|
62
|
+
names = layout.operations[operation.id]
|
|
63
|
+
root_names += [names.endpoints, names.request, names.result, names.errors, *names.responses.values()]
|
|
64
|
+
|
|
65
|
+
head = [f"GENERATED_BY: _typing.Final = {string_literal(RUNTIME_VERSION)}"]
|
|
66
|
+
local_modules = [_CONTRACT_MODULE, _SCHEMAS_MODULE] if proof_models else [_CONTRACT_MODULE]
|
|
67
|
+
module.add_block(with_local_imports(local_modules, root_names, head))
|
|
68
|
+
module.add_block(_placement(ir, layout, routes, options, entry))
|
|
69
|
+
module.add_block(_handler_factories(layout))
|
|
70
|
+
module.add_block(unexpected_error_listener(ir.api.id, _HOOKS, layout))
|
|
71
|
+
|
|
72
|
+
for operation in operations:
|
|
73
|
+
names = layout.operations[operation.id]
|
|
74
|
+
module.add_block(
|
|
75
|
+
[
|
|
76
|
+
*bracketed(
|
|
77
|
+
"",
|
|
78
|
+
f"async def {_CALL_PREFIX}{names.method}",
|
|
79
|
+
[f"endpoints: {names.endpoints}", f"request: {names.request}"],
|
|
80
|
+
f" -> {names.result}:",
|
|
81
|
+
),
|
|
82
|
+
f"{INDENT}return await {_RUNTIME}.call_handler(endpoints.{names.method}, request)",
|
|
83
|
+
]
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
module.add_block(_bind(ir, layout, proof_models, routes))
|
|
87
|
+
|
|
88
|
+
return module.text()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _placement(
|
|
92
|
+
ir: ContractIR, layout: ApiLayout, routes: Sequence[RouteEntry], options: FastApiOptions, entry: TargetEntry
|
|
93
|
+
) -> list[str]:
|
|
94
|
+
route_lines: list[str] = []
|
|
95
|
+
|
|
96
|
+
for route in routes:
|
|
97
|
+
methods = tuple_literal(route.methods)
|
|
98
|
+
arguments = [
|
|
99
|
+
string_literal(route.operation_id),
|
|
100
|
+
string_literal(route.name),
|
|
101
|
+
string_literal(route.path()),
|
|
102
|
+
methods,
|
|
103
|
+
]
|
|
104
|
+
route_lines += bracketed(INDENT * 2, f"{_RUNTIME}.OperationRoute", arguments, ",")
|
|
105
|
+
|
|
106
|
+
framework_routes: list[str] = []
|
|
107
|
+
|
|
108
|
+
for allowed in entry.framework_routes:
|
|
109
|
+
arguments = [
|
|
110
|
+
f"path={string_literal(allowed.path)}",
|
|
111
|
+
f"method={'None' if allowed.method is None else string_literal(allowed.method)}",
|
|
112
|
+
f"reason={string_literal(allowed.reason)}",
|
|
113
|
+
]
|
|
114
|
+
framework_routes += bracketed(INDENT * 2, f"{_RUNTIME}.FrameworkRouteRule", arguments, ",")
|
|
115
|
+
|
|
116
|
+
boot = options.boot_check
|
|
117
|
+
defaults = options.defaults
|
|
118
|
+
|
|
119
|
+
return [
|
|
120
|
+
f"PLACEMENT: _typing.Final = {_RUNTIME}.ApiPlacement(",
|
|
121
|
+
f"{INDENT}api={string_literal(ir.api.id)},",
|
|
122
|
+
f"{INDENT}generated_by=GENERATED_BY,",
|
|
123
|
+
f"{INDENT}mount={string_literal(entry.mount or '')},",
|
|
124
|
+
f"{INDENT}routes=(",
|
|
125
|
+
*route_lines,
|
|
126
|
+
f"{INDENT}),",
|
|
127
|
+
f"{INDENT}error_base={layout.contract_error},",
|
|
128
|
+
*bracketed(
|
|
129
|
+
INDENT,
|
|
130
|
+
f"defaults={_RUNTIME}.TargetDefaults",
|
|
131
|
+
[
|
|
132
|
+
f"responses={_RUNTIME}.ResponsesMode.{defaults.responses.name}",
|
|
133
|
+
f"body_validation={defaults.body_validation}",
|
|
134
|
+
f"framework_etag={_RUNTIME}.FrameworkEtagMode.{defaults.framework_etag.name}",
|
|
135
|
+
f"catch_all={defaults.catch_all}",
|
|
136
|
+
],
|
|
137
|
+
",",
|
|
138
|
+
),
|
|
139
|
+
*bracketed(
|
|
140
|
+
INDENT,
|
|
141
|
+
f"boot_check={_RUNTIME}.BootCheckSwitches",
|
|
142
|
+
[
|
|
143
|
+
f"enabled={boot.enabled}",
|
|
144
|
+
f"framework_routes={_RUNTIME}.FrameworkRoutesCheck.{boot.framework_routes.name}",
|
|
145
|
+
f"hooks={_RUNTIME}.HooksCheck.{boot.hooks.name}",
|
|
146
|
+
],
|
|
147
|
+
",",
|
|
148
|
+
),
|
|
149
|
+
*(
|
|
150
|
+
[f"{INDENT}framework_routes=(),"]
|
|
151
|
+
if not framework_routes
|
|
152
|
+
else [f"{INDENT}framework_routes=(", *framework_routes, f"{INDENT}),"]
|
|
153
|
+
),
|
|
154
|
+
f"{INDENT}frameworks=(",
|
|
155
|
+
*_accepted_frameworks(entry),
|
|
156
|
+
f"{INDENT}),",
|
|
157
|
+
*credentials_types(_RUNTIME, layout),
|
|
158
|
+
*_framework_document(options, entry),
|
|
159
|
+
")",
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _framework_document(options: FastApiOptions, entry: TargetEntry) -> list[str]:
|
|
164
|
+
"""`frameworkDocs: off` and `nativeDocs`, each written only when the entry asks for it."""
|
|
165
|
+
lines: list[str] = []
|
|
166
|
+
|
|
167
|
+
if entry.framework_docs is FrameworkDocs.OFF:
|
|
168
|
+
lines.append(f"{INDENT}framework_docs={_RUNTIME}.FrameworkDocs.OFF,")
|
|
169
|
+
|
|
170
|
+
if options.native_docs:
|
|
171
|
+
native_docs_path = f"_pathlib.Path(__file__).with_name({string_literal(NATIVE_DOCS_FILE)})"
|
|
172
|
+
lines.append(f"{INDENT}native_docs={_RUNTIME}.read_native_docs({native_docs_path}),")
|
|
173
|
+
|
|
174
|
+
return lines
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _accepted_frameworks(entry: TargetEntry) -> list[str]:
|
|
178
|
+
"""The matrix row's frameworks (FastAPI by minor, Starlette by major) with the entry's `allowUntested` units, then
|
|
179
|
+
Pydantic 2, which the generated models are written for."""
|
|
180
|
+
accepted = [
|
|
181
|
+
(framework.package, framework.unit.name, framework.accepted_units(entry.allow_untested))
|
|
182
|
+
for framework in (entry.matrix.frameworks if entry.matrix is not None else ())
|
|
183
|
+
]
|
|
184
|
+
accepted.append((PYDANTIC_PACKAGE, "MAJOR", (PYDANTIC_MAJOR,)))
|
|
185
|
+
lines: list[str] = []
|
|
186
|
+
|
|
187
|
+
for package, unit, units in accepted:
|
|
188
|
+
arguments = [
|
|
189
|
+
f"package={string_literal(package)}",
|
|
190
|
+
f"unit={_RUNTIME}.VersionUnit.{unit}",
|
|
191
|
+
f"accepted={tuple_literal(units)}",
|
|
192
|
+
]
|
|
193
|
+
lines += bracketed(INDENT * 2, f"{_RUNTIME}.AcceptedFramework", arguments, ",")
|
|
194
|
+
|
|
195
|
+
return lines
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _handler_factories(layout: ApiLayout) -> list[str]:
|
|
199
|
+
lines = [
|
|
200
|
+
"@_dataclasses.dataclass(frozen=True, slots=True, kw_only=True)",
|
|
201
|
+
f"class {layout.handler_factories}:",
|
|
202
|
+
f'{INDENT}"""The FastAPI dependency that builds each endpoints protocol\'s handlers; None fails the boot."""',
|
|
203
|
+
"",
|
|
204
|
+
]
|
|
205
|
+
lines += [
|
|
206
|
+
f"{INDENT}{attribute}: {_RUNTIME}.Factory[{endpoints}] | None"
|
|
207
|
+
for endpoints, attribute in layout.endpoints_owners().items()
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
return lines
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _bind(
|
|
214
|
+
ir: ContractIR, layout: ApiLayout, proof_models: Mapping[str, str], routes: Sequence[RouteEntry]
|
|
215
|
+
) -> list[str]:
|
|
216
|
+
def factories(hook: str) -> str:
|
|
217
|
+
return f"{_ABC}.Mapping[str, {_RUNTIME}.Factory[{_HOOKS}.{hook}]] | None = None"
|
|
218
|
+
|
|
219
|
+
parameters = [
|
|
220
|
+
f"target: {_RUNTIME}.BindTarget",
|
|
221
|
+
"*",
|
|
222
|
+
f"handlers: {layout.handler_factories}",
|
|
223
|
+
f"app: {_RUNTIME}.App | None = None",
|
|
224
|
+
f"authenticate: {factories('AuthenticateHook')}",
|
|
225
|
+
f"deciders: {factories('DecideHook')}",
|
|
226
|
+
f"scope_resolvers: {factories('ResolveScopeHook')}",
|
|
227
|
+
f"idempotency_store: {_RUNTIME}.Factory[{_HOOKS}.IdempotencyStore] | None = None",
|
|
228
|
+
f"on_unexpected_error: {_RUNTIME}.Factory[{layout.unexpected_error_listener}] | None = None",
|
|
229
|
+
f"responses: {_RUNTIME}.ResponsesMode | None = None",
|
|
230
|
+
"body_validation: bool | None = None",
|
|
231
|
+
f"operations: {_ABC}.Mapping[str, {_RUNTIME}.OperationSettings] | None = None",
|
|
232
|
+
"public_origin: str | None = None",
|
|
233
|
+
]
|
|
234
|
+
head = [
|
|
235
|
+
*bracketed("", f"def {layout.bind_function}", parameters, " -> None:"),
|
|
236
|
+
f'{INDENT}"""{_BIND_DOC.format(api=ir.api.id)}"""',
|
|
237
|
+
]
|
|
238
|
+
lines: list[str] = []
|
|
239
|
+
|
|
240
|
+
for route in routes:
|
|
241
|
+
lines += _add_operation(layout, route.operation_id, proof_models.get(route.operation_id))
|
|
242
|
+
|
|
243
|
+
hooks = [
|
|
244
|
+
"authenticate=authenticate or {}",
|
|
245
|
+
"deciders=deciders or {}",
|
|
246
|
+
"scope_resolvers=scope_resolvers or {}",
|
|
247
|
+
"idempotency_store=idempotency_store",
|
|
248
|
+
"on_unexpected_error=on_unexpected_error",
|
|
249
|
+
]
|
|
250
|
+
settings = [
|
|
251
|
+
"responses=responses",
|
|
252
|
+
"body_validation=body_validation",
|
|
253
|
+
"operations=operations or {}",
|
|
254
|
+
"public_origin=public_origin",
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
return [
|
|
258
|
+
*head,
|
|
259
|
+
f"{INDENT}binding = {_RUNTIME}.api_binding(",
|
|
260
|
+
f"{INDENT * 2}PLACEMENT,",
|
|
261
|
+
f"{INDENT * 2}_{_CONTRACT_MODULE}.CONTRACT,",
|
|
262
|
+
f"{INDENT * 2}target,",
|
|
263
|
+
f"{INDENT * 2}app=app,",
|
|
264
|
+
*bracketed(INDENT * 2, f"hooks={_RUNTIME}.HookFactories", hooks, ","),
|
|
265
|
+
*bracketed(INDENT * 2, f"settings={_RUNTIME}.RunSettings", settings, ","),
|
|
266
|
+
f"{INDENT})",
|
|
267
|
+
"",
|
|
268
|
+
*lines,
|
|
269
|
+
"",
|
|
270
|
+
f"{INDENT}{_RUNTIME}.bind_api(binding)",
|
|
271
|
+
]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _add_operation(layout: ApiLayout, operation_id: str, proof_model: str | None) -> list[str]:
|
|
275
|
+
"""One `add_operation` call, split as ruff splits it: the call on one line when it fits, else one argument per
|
|
276
|
+
line, the operation's types a nested call split the same way."""
|
|
277
|
+
names = layout.operations[operation_id]
|
|
278
|
+
proofs = None if proof_model is None else f"_{_SCHEMAS_MODULE}.{proof_model}"
|
|
279
|
+
types = operation_types_arguments(layout, operation_id, proofs)
|
|
280
|
+
arguments = [
|
|
281
|
+
"binding",
|
|
282
|
+
string_literal(operation_id),
|
|
283
|
+
f"handlers.{names.owner_attribute}",
|
|
284
|
+
f"{_CALL_PREFIX}{names.method}",
|
|
285
|
+
]
|
|
286
|
+
|
|
287
|
+
return add_operation_call(_RUNTIME, arguments, types)
|
|
File without changes
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""The FastAPI placement runtime the generated code imports (ADR 0026 "What the generated code depends on"): the
|
|
2
|
+
declarations of one api's placement, its binding, its contract routes with the access and input dependencies, the
|
|
3
|
+
boundary middleware, the exception map and the boot check. The framework-neutral steps come from
|
|
4
|
+
`seampact.codegen.runtime`."""
|
|
5
|
+
|
|
6
|
+
from seampact.codegen.runtime.access_steps import CredentialsType
|
|
7
|
+
from seampact.codegen.runtime.framework_routes import FrameworkRouteRule
|
|
8
|
+
from seampact.codegen.runtime.framework_versions import AcceptedFramework, VersionUnit
|
|
9
|
+
from seampact.codegen.runtime.native_docs import NativeDocsError, read_native_docs
|
|
10
|
+
from seampact.codegen.runtime.reply_verdict import FrameworkEtagMode, ResponsesMode
|
|
11
|
+
from seampact.codegen.runtime.run_settings import OperationSettings, OperationTypes, RunSettings
|
|
12
|
+
from seampact.fastapi.runtime.api_binding import ApiBinding, BindingError, api_binding
|
|
13
|
+
from seampact.fastapi.runtime.binding import bind_api
|
|
14
|
+
from seampact.fastapi.runtime.boot_check import BootCheckError
|
|
15
|
+
from seampact.fastapi.runtime.boundary import ContractBoundary
|
|
16
|
+
from seampact.fastapi.runtime.handler_call import call_handler
|
|
17
|
+
from seampact.fastapi.runtime.native_docs import FrameworkDocs
|
|
18
|
+
from seampact.fastapi.runtime.placement import (
|
|
19
|
+
ApiPlacement,
|
|
20
|
+
App,
|
|
21
|
+
BindTarget,
|
|
22
|
+
BootCheckSwitches,
|
|
23
|
+
Factory,
|
|
24
|
+
FrameworkRoutesCheck,
|
|
25
|
+
HookFactories,
|
|
26
|
+
HooksCheck,
|
|
27
|
+
OperationRoute,
|
|
28
|
+
TargetDefaults,
|
|
29
|
+
)
|
|
30
|
+
from seampact.fastapi.runtime.routes import add_operation
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"AcceptedFramework",
|
|
34
|
+
"ApiBinding",
|
|
35
|
+
"ApiPlacement",
|
|
36
|
+
"App",
|
|
37
|
+
"BindTarget",
|
|
38
|
+
"BindingError",
|
|
39
|
+
"BootCheckError",
|
|
40
|
+
"BootCheckSwitches",
|
|
41
|
+
"ContractBoundary",
|
|
42
|
+
"CredentialsType",
|
|
43
|
+
"Factory",
|
|
44
|
+
"FrameworkDocs",
|
|
45
|
+
"FrameworkEtagMode",
|
|
46
|
+
"FrameworkRouteRule",
|
|
47
|
+
"FrameworkRoutesCheck",
|
|
48
|
+
"HookFactories",
|
|
49
|
+
"HooksCheck",
|
|
50
|
+
"NativeDocsError",
|
|
51
|
+
"OperationRoute",
|
|
52
|
+
"OperationSettings",
|
|
53
|
+
"OperationTypes",
|
|
54
|
+
"ResponsesMode",
|
|
55
|
+
"RunSettings",
|
|
56
|
+
"TargetDefaults",
|
|
57
|
+
"VersionUnit",
|
|
58
|
+
"add_operation",
|
|
59
|
+
"api_binding",
|
|
60
|
+
"bind_api",
|
|
61
|
+
"call_handler",
|
|
62
|
+
"read_native_docs",
|
|
63
|
+
]
|