seampact-fastapi 1.0.0__tar.gz

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.
Files changed (24) hide show
  1. seampact_fastapi-1.0.0/.gitignore +6 -0
  2. seampact_fastapi-1.0.0/LICENSE +21 -0
  3. seampact_fastapi-1.0.0/PKG-INFO +20 -0
  4. seampact_fastapi-1.0.0/README.md +1 -0
  5. seampact_fastapi-1.0.0/pyproject.toml +44 -0
  6. seampact_fastapi-1.0.0/src/seampact/fastapi/__init__.py +2 -0
  7. seampact_fastapi-1.0.0/src/seampact/fastapi/generator.py +85 -0
  8. seampact_fastapi-1.0.0/src/seampact/fastapi/options.py +241 -0
  9. seampact_fastapi-1.0.0/src/seampact/fastapi/placement_module.py +287 -0
  10. seampact_fastapi-1.0.0/src/seampact/fastapi/py.typed +0 -0
  11. seampact_fastapi-1.0.0/src/seampact/fastapi/runtime/__init__.py +63 -0
  12. seampact_fastapi-1.0.0/src/seampact/fastapi/runtime/api_binding.py +178 -0
  13. seampact_fastapi-1.0.0/src/seampact/fastapi/runtime/binding.py +23 -0
  14. seampact_fastapi-1.0.0/src/seampact/fastapi/runtime/boot_check.py +336 -0
  15. seampact_fastapi-1.0.0/src/seampact/fastapi/runtime/boundary.py +472 -0
  16. seampact_fastapi-1.0.0/src/seampact/fastapi/runtime/exception_map.py +111 -0
  17. seampact_fastapi-1.0.0/src/seampact/fastapi/runtime/exception_roles.py +71 -0
  18. seampact_fastapi-1.0.0/src/seampact/fastapi/runtime/framework_document.py +54 -0
  19. seampact_fastapi-1.0.0/src/seampact/fastapi/runtime/handler_call.py +16 -0
  20. seampact_fastapi-1.0.0/src/seampact/fastapi/runtime/input_features.py +54 -0
  21. seampact_fastapi-1.0.0/src/seampact/fastapi/runtime/native_docs.py +12 -0
  22. seampact_fastapi-1.0.0/src/seampact/fastapi/runtime/placement.py +113 -0
  23. seampact_fastapi-1.0.0/src/seampact/fastapi/runtime/request_state.py +69 -0
  24. seampact_fastapi-1.0.0/src/seampact/fastapi/runtime/routes.py +297 -0
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ .mypy_cache/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Julien Metral
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,20 @@
1
+ Metadata-Version: 2.5
2
+ Name: seampact-fastapi
3
+ Version: 1.0.0
4
+ Summary: The FastAPI target of seampact: its generator and the placement runtime of the generated code.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Classifier: Framework :: FastAPI
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Classifier: Typing :: Typed
11
+ Requires-Python: >=3.12
12
+ Requires-Dist: fastapi>=0.162
13
+ Requires-Dist: seampact-codegen[pydantic]==1.0.0
14
+ Requires-Dist: seampact-hooks==1.0.0
15
+ Provides-Extra: generate
16
+ Requires-Dist: seampact-codegen[generate]==1.0.0; extra == 'generate'
17
+ Requires-Dist: seampact-ir==1.0.0; extra == 'generate'
18
+ Description-Content-Type: text/markdown
19
+
20
+ The FastAPI target of seampact: the generator that `seampact generate` finds through its entry point, and the runtime the generated placement imports.
@@ -0,0 +1 @@
1
+ The FastAPI target of seampact: the generator that `seampact generate` finds through its entry point, and the runtime the generated placement imports.
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["hatchling==1.33.0"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "seampact-fastapi"
7
+ version = "1.0.0"
8
+ description = "The FastAPI target of seampact: its generator and the placement runtime of the generated code."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.12"
13
+ # FastAPI breaks in minors: 0.162 is the matrix floor, a later minor is untested and refused at boot unless the app
14
+ # config allows it. Starlette and Pydantic come through FastAPI's own ranges; Pydantic 2.9 is FastAPI 0.162's floor.
15
+ dependencies = [
16
+ "fastapi>=0.162",
17
+ "seampact-codegen[pydantic]==1.0.0",
18
+ "seampact-hooks==1.0.0",
19
+ ]
20
+ classifiers = [
21
+ "Framework :: FastAPI",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3 :: Only",
24
+ "Typing :: Typed",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ # What the generator itself needs; a production install of an app leaves it out.
29
+ generate = ["seampact-codegen[generate]==1.0.0", "seampact-ir==1.0.0"]
30
+
31
+ [project.entry-points."seampact.generators"]
32
+ fastapi = "seampact.fastapi.generator:FastApiGenerator"
33
+
34
+ # PEP 420: `seampact` is a namespace shared by every seampact distribution, so it has no `__init__.py`.
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/seampact"]
37
+
38
+ [tool.hatch.build.targets.sdist]
39
+ include = ["src/seampact", "LICENSE"]
40
+
41
+ [tool.uv.sources]
42
+ seampact-codegen = { workspace = true }
43
+ seampact-hooks = { workspace = true }
44
+ seampact-ir = { workspace = true }
@@ -0,0 +1,2 @@
1
+ """The FastAPI target of seampact (ADR 0026): the generator `seampact generate` finds through the `seampact.generators`
2
+ entry point, behind the `generate` extra, and the placement runtime the generated code imports."""
@@ -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