seampact-ninja 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.
@@ -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,23 @@
1
+ Metadata-Version: 2.5
2
+ Name: seampact-ninja
3
+ Version: 1.0.0
4
+ Summary: The Django Ninja target of seampact: its generator and the placement runtime of the generated code.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Classifier: Framework :: Django
8
+ Classifier: Framework :: Django :: 5.2
9
+ Classifier: Framework :: Django :: 6.0
10
+ Classifier: Framework :: Django :: 6.1
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Typing :: Typed
14
+ Requires-Python: >=3.12
15
+ Requires-Dist: django-ninja>=1.5.3
16
+ Requires-Dist: seampact-codegen[pydantic]==1.0.0
17
+ Requires-Dist: seampact-django==1.0.0
18
+ Requires-Dist: seampact-hooks==1.0.0
19
+ Provides-Extra: generate
20
+ Requires-Dist: seampact-django[generate]==1.0.0; extra == 'generate'
21
+ Description-Content-Type: text/markdown
22
+
23
+ The seampact Django Ninja target: the `seampact-ninja` generator, found through the `seampact.generators` entry point, and the Ninja runtime of the code it generates.
@@ -0,0 +1 @@
1
+ The seampact Django Ninja target: the `seampact-ninja` generator, found through the `seampact.generators` entry point, and the Ninja runtime of the code it generates.
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["hatchling==1.33.0"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "seampact-ninja"
7
+ version = "1.0.0"
8
+ description = "The Django Ninja 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
+ # The matrix holds major.minor and its Ninja floor is 1.5; 1.5.3 is the first release whose metadata declares Django
14
+ # 6.0, so an older 1.5 would not install beside Django 6. Pydantic comes through Ninja's own range.
15
+ dependencies = [
16
+ "django-ninja>=1.5.3",
17
+ "seampact-codegen[pydantic]==1.0.0",
18
+ "seampact-django==1.0.0",
19
+ "seampact-hooks==1.0.0",
20
+ ]
21
+ classifiers = [
22
+ "Framework :: Django",
23
+ "Framework :: Django :: 5.2",
24
+ "Framework :: Django :: 6.0",
25
+ "Framework :: Django :: 6.1",
26
+ "Programming Language :: Python :: 3",
27
+ "Programming Language :: Python :: 3 :: Only",
28
+ "Typing :: Typed",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ # What the generator needs; a production install of an app leaves it out.
33
+ generate = ["seampact-django[generate]==1.0.0"]
34
+
35
+ [project.entry-points."seampact.generators"]
36
+ ninja = "seampact.ninja.generator:NinjaGenerator"
37
+
38
+ # PEP 420: `seampact` is a namespace shared by every seampact distribution, so it has no `__init__.py`.
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/seampact"]
41
+
42
+ [tool.hatch.build.targets.sdist]
43
+ include = ["src/seampact", "LICENSE"]
44
+
45
+ [tool.uv.sources]
46
+ seampact-codegen = { workspace = true }
47
+ seampact-django = { workspace = true }
48
+ seampact-hooks = { workspace = true }
@@ -0,0 +1 @@
1
+ """The Django Ninja target of seampact: the `seampact-ninja` generator and the runtime of the code it generates."""
@@ -0,0 +1,104 @@
1
+ """The `seampact-ninja` generator (ADR 0027 "Ninja"), the class the `seampact.generators` entry point names. Per api it
2
+ writes one package: `schemas.py` (the schema models and each operation's input parts, through the shared Pydantic
3
+ emitter), `operations.py` (the operation types and endpoints protocols), `contract.py` (the contract the runtime
4
+ reads), `routes.py` (the route names and URL builders, unless `routeHelpers: false`), `django.py` (the boundary
5
+ middleware's declaration), `ninja.py` (the placement and `bind_<api>()`) and, with `nativeDocs`, `native_docs.json`
6
+ (seampact's operation objects for Ninja's document). Every refusal is listed at once, and no
7
+ file is written when there is one."""
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_MAJOR, PYDANTIC_PACKAGE, 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.codegen.runtime.version import RUNTIME_VERSION
28
+ from seampact.django.emitters import BoundaryBase, boundary_module, contract_paths, routes_named_by_path
29
+ from seampact.ir.config.problem import ConfigProblem
30
+ from seampact.ir.model import ContractIR
31
+ from seampact.ir.naming import IrName, NamingRole, python_identifier
32
+ from seampact.ir.read import IrSupport
33
+ from seampact.ir.run.generator import TargetEntry
34
+ from seampact.ir.write import GeneratedFile
35
+ from seampact.ninja.options import ninja_options
36
+ from seampact.ninja.placement_module import placement_module
37
+
38
+ TARGET: Final = "ninja"
39
+ DISTRIBUTION: Final = "seampact-ninja"
40
+ _HTTP_OPERATION: Final = "httpOperation"
41
+ _NINJA_DOCUMENT: Final = FrameworkDocument(TARGET, "Django Ninja")
42
+ _NAMES_REGISTERED: Final = (
43
+ 'The route name of each operation, under the NinjaAPI\'s urls_namespace: reverse "<namespace>:<name>".'
44
+ )
45
+ _BOUNDARY_BASE: Final = BoundaryBase("seampact.ninja.runtime", "_ninja_runtime", "NinjaContractBoundary")
46
+
47
+
48
+ class NinjaGenerator:
49
+ def ir_support(self) -> IrSupport:
50
+ return IrSupport.of_model(kinds=[_HTTP_OPERATION])
51
+
52
+ def framework_docs_off_fix(self) -> str | None:
53
+ return (
54
+ "Remove `frameworkDocs: off` from the entry and switch Ninja's own docs off in the app: "
55
+ "`NinjaAPI(docs_url=None, openapi_url=None)`."
56
+ )
57
+
58
+ def handler_warnings(self, ir: ContractIR, entry: TargetEntry) -> Sequence[ConfigProblem]:
59
+ return handler_warnings(ir, entry.id, entry.api)
60
+
61
+ def generate(self, ir: ContractIR, entry: TargetEntry) -> Sequence[GeneratedFile]:
62
+ refusals = Refusals(entry.id, TARGET, DISTRIBUTION)
63
+ options = ninja_options(TARGET, entry.options, entry.artefacts, refusals)
64
+ mount = entry.mount or ""
65
+
66
+ refuse_kept_forms(ir, refusals)
67
+ refuse_missing_matrix_row(entry, refusals, "Django and Django Ninja")
68
+ refuse_unserved_forms(ir, refusals)
69
+ error_codes = sorted({error.code for operation in ir.operations for error in operation.errors})
70
+ layout = api_layout(ir, error_codes, refusals)
71
+ with_inputs = with_input_schemas(ir, layout)
72
+ names = api_names(with_inputs, refusals)
73
+ paths = contract_paths(route_table(ir, mount, refusals), [operation.id for operation in ir.operations])
74
+ routes = routes_named_by_path(paths)
75
+ api_class = python_identifier(IrName(tuple(ir.api.name.segments), NamingRole.SCHEMA))
76
+ files = {
77
+ "__init__.py": PythonModule(f"The generated code of api '{ir.api.id}' for the Ninja target.").text(),
78
+ "schemas.py": pydantic_schemas_module(with_inputs, names, options.rules, refusals),
79
+ "operations.py": operation_types_module(with_inputs, names, layout, refusals),
80
+ "contract.py": contract_module(ir, names, mount, refusals),
81
+ "django.py": boundary_module(
82
+ ir.api.id,
83
+ api_class,
84
+ RUNTIME_VERSION,
85
+ {},
86
+ options.shared.run_time_options(),
87
+ entry,
88
+ base=_BOUNDARY_BASE,
89
+ settings_module=None,
90
+ model_framework=(PYDANTIC_PACKAGE, PYDANTIC_MAJOR),
91
+ ),
92
+ "ninja.py": placement_module(ir, layout, proof_models(layout, names), routes, mount, options),
93
+ }
94
+
95
+ if options.shared.route_helpers:
96
+ files["routes.py"] = route_helpers_module(ir, routes, refusals, _NAMES_REGISTERED)
97
+
98
+ if options.native_docs:
99
+ native_docs = native_docs_file(entry.native_docs, layout.operations, _NINJA_DOCUMENT, refusals)
100
+ files.update({} if native_docs is None else {native_docs.path: native_docs.contents})
101
+
102
+ refusals.raise_if_any()
103
+
104
+ return [GeneratedFile(path, contents) for path, contents in sorted(files.items())]
@@ -0,0 +1,40 @@
1
+ """The options and artefact switches of a ninja entry (ADR 0027 "Options and registration"): the Django targets'
2
+ shared set, `rules` for the shared Pydantic models, `views`, `sync` (the default) or `async`, and the `nativeDocs`
3
+ artefact switch."""
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass
7
+ from typing import Final
8
+
9
+ from seampact.codegen.pydantic_models import RulesMode
10
+ from seampact.codegen.refusals import Refusals
11
+ from seampact.django.emitters import DjangoTargetOptions, OptionReader, django_target_options
12
+ from seampact.ir.config.json_value import JsonValue
13
+ from seampact.ninja.views_mode import ViewsMode
14
+
15
+ _RULES: Final = "rules"
16
+ _VIEWS: Final = "views"
17
+ _NATIVE_DOCS: Final = "nativeDocs"
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class NinjaOptions:
22
+ shared: DjangoTargetOptions
23
+ rules: RulesMode
24
+ views: ViewsMode
25
+ native_docs: bool
26
+
27
+
28
+ def ninja_options(
29
+ target: str, options: Mapping[str, JsonValue], artefacts: Mapping[str, JsonValue], refusals: Refusals
30
+ ) -> NinjaOptions:
31
+ own_options = (_RULES, _VIEWS)
32
+ shared = django_target_options(target, own_options, options, artefacts, refusals, own_artefacts=(_NATIVE_DOCS,))
33
+ reader = OptionReader(options, refusals)
34
+
35
+ return NinjaOptions(
36
+ shared,
37
+ reader.choice(_RULES, RulesMode.NATIVE),
38
+ reader.choice(_VIEWS, ViewsMode.SYNC),
39
+ native_docs=artefacts.get(_NATIVE_DOCS) is True,
40
+ )
@@ -0,0 +1,267 @@
1
+ """The `ninja` module of an api's package (ADR 0027 "Ninja"): `GENERATED_BY`, the api's placement (each contract
2
+ operation's route below the router's prefix, `GET` with `HEAD`, under the URL name of its path; its contract exception
3
+ base; how its views run; its credentials types), the `<Api>HandlerFactories` the app fills, one function per
4
+ operation calling its handler method, one view per operation taking the request and one `str` per path placeholder,
5
+ and `bind_<api>()`, which hands each operation to the runtime with its view. The views are plain functions under
6
+ `views: sync` and `async def` under `views: async`."""
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.routes import RouteEntry
22
+ from seampact.codegen.runtime import RUNTIME_VERSION
23
+ from seampact.codegen.source_lines import with_local_imports
24
+ from seampact.django.emitters.urls_module import PATH_ARGUMENT_PREFIX
25
+ from seampact.ir.model import ContractIR
26
+ from seampact.ninja.options import NinjaOptions
27
+ from seampact.ninja.views_mode import ViewsMode
28
+
29
+ _RUNTIME: Final = "_runtime"
30
+ _HOOKS: Final = "_hooks"
31
+ _ABC: Final = "_abc"
32
+ _HTTP: Final = "_http"
33
+ _NINJA: Final = "_ninja"
34
+ # Each operation's handler call and view: no module alias starts so, whatever the operation's name.
35
+ _CALL_PREFIX: Final = "_call_"
36
+ _VIEW_PREFIX: Final = "_view_"
37
+ _CONTRACT_MODULE: Final = "contract"
38
+ _SCHEMAS_MODULE: Final = "schemas"
39
+ _BIND_DOC: Final = (
40
+ "Adds the api's Router to `api`; the URLconf prefix of `api.urls` plus `prefix` must equal the entry's mount."
41
+ )
42
+
43
+
44
+ def placement_module(
45
+ ir: ContractIR,
46
+ layout: ApiLayout,
47
+ proof_models: Mapping[str, str],
48
+ routes: Sequence[RouteEntry],
49
+ mount: str,
50
+ options: NinjaOptions,
51
+ ) -> str:
52
+ """`routes` are named by path, in registration order; `proof_models` names the `<Op>Proofs` class of each operation
53
+ whose body has proofs."""
54
+ module = PythonModule(f"The Django Ninja placement of api '{ir.api.id}'.")
55
+ module.imported("collections.abc", _ABC)
56
+ module.imported("dataclasses", "_dataclasses")
57
+ module.imported("typing", "_typing")
58
+
59
+ if options.native_docs:
60
+ module.imported("pathlib", "_pathlib")
61
+
62
+ module.imported("django.http", _HTTP)
63
+ module.imported("ninja", _NINJA)
64
+ module.imported("seampact.ninja.runtime", _RUNTIME)
65
+ module.imported("seampact.hooks", _HOOKS)
66
+ root_names = [layout.contract_error, *(credentials.class_name for credentials in layout.credentials.values())]
67
+ operations = [operation for operation in ir.operations if operation.id in layout.operations]
68
+
69
+ for operation in operations:
70
+ names = layout.operations[operation.id]
71
+ root_names += [names.endpoints, names.request, names.result, names.errors, *names.responses.values()]
72
+
73
+ head = [f"GENERATED_BY: _typing.Final = {string_literal(RUNTIME_VERSION)}"]
74
+ local_modules = [_CONTRACT_MODULE, _SCHEMAS_MODULE] if proof_models else [_CONTRACT_MODULE]
75
+ module.add_block(with_local_imports(local_modules, root_names, head))
76
+ module.add_block(_placement(ir, layout, routes, mount, options))
77
+ module.add_block(_handler_factories(layout))
78
+ module.add_block(unexpected_error_listener(ir.api.id, _HOOKS, layout))
79
+
80
+ for operation in operations:
81
+ module.add_block(_handler_call(layout, operation.id, options.views))
82
+
83
+ for route in routes:
84
+ module.add_block(_view(layout, route, options.views))
85
+
86
+ module.add_block(_bind(ir, layout, proof_models, routes))
87
+
88
+ return module.text()
89
+
90
+
91
+ def router_path(route: RouteEntry, mount: str) -> str:
92
+ """The route's path below the router's prefix, which with the URLconf's prefix makes `mount`."""
93
+ mount_depth = len(mount.split("/")) - 1 if mount else 0
94
+
95
+ return "".join(
96
+ "/" + (segment.text if segment.attribute is None else f"{{{PATH_ARGUMENT_PREFIX}{segment.attribute}}}")
97
+ for segment in route.segments[mount_depth:]
98
+ )
99
+
100
+
101
+ def _placement(
102
+ ir: ContractIR, layout: ApiLayout, routes: Sequence[RouteEntry], mount: str, options: NinjaOptions
103
+ ) -> list[str]:
104
+ route_lines: list[str] = []
105
+
106
+ for route in routes:
107
+ arguments = [
108
+ string_literal(route.operation_id),
109
+ string_literal(route.name),
110
+ string_literal(router_path(route, mount)),
111
+ tuple_literal(route.methods),
112
+ ]
113
+ route_lines += bracketed(INDENT * 2, f"{_RUNTIME}.OperationRoute", arguments, ",")
114
+
115
+ return [
116
+ f"PLACEMENT: _typing.Final = {_RUNTIME}.ApiPlacement(",
117
+ f"{INDENT}api={string_literal(ir.api.id)},",
118
+ f"{INDENT}generated_by=GENERATED_BY,",
119
+ f"{INDENT}mount={string_literal(mount)},",
120
+ f"{INDENT}routes=(",
121
+ *route_lines,
122
+ f"{INDENT}),",
123
+ f"{INDENT}error_base={layout.contract_error},",
124
+ f"{INDENT}views={_RUNTIME}.ViewsMode.{options.views.name},",
125
+ *credentials_types(_RUNTIME, layout),
126
+ *_native_docs(options),
127
+ ")",
128
+ ]
129
+
130
+
131
+ def _native_docs(options: NinjaOptions) -> list[str]:
132
+ """With `nativeDocs`, seampact's operation objects for Ninja's document, read from the generated file."""
133
+ if not options.native_docs:
134
+ return []
135
+
136
+ native_docs_path = f"_pathlib.Path(__file__).with_name({string_literal(NATIVE_DOCS_FILE)})"
137
+
138
+ return [f"{INDENT}native_docs={_RUNTIME}.read_native_docs({native_docs_path}),"]
139
+
140
+
141
+ def _handler_factories(layout: ApiLayout) -> list[str]:
142
+ lines = [
143
+ "@_dataclasses.dataclass(frozen=True, slots=True, kw_only=True)",
144
+ f"class {layout.handler_factories}:",
145
+ f'{INDENT}"""Builds each endpoints protocol\'s handlers from the HttpRequest; None fails the boot."""',
146
+ "",
147
+ ]
148
+ lines += [
149
+ f"{INDENT}{attribute}: {_RUNTIME}.Factory[{endpoints}] | None"
150
+ for endpoints, attribute in layout.endpoints_owners().items()
151
+ ]
152
+
153
+ return lines
154
+
155
+
156
+ def _handler_call(layout: ApiLayout, operation_id: str, views: ViewsMode) -> list[str]:
157
+ """A sync call narrows the method's result itself: the checkers cannot infer the result union through a generic
158
+ helper."""
159
+ names = layout.operations[operation_id]
160
+ parameters = [f"endpoints: {names.endpoints}", f"request: {names.request}"]
161
+
162
+ if views is ViewsMode.ASYNC:
163
+ return [
164
+ *bracketed("", f"async def {_CALL_PREFIX}{names.method}", parameters, f" -> {names.result}:"),
165
+ f"{INDENT}return await {_RUNTIME}.call_handler_async(endpoints.{names.method}, request)",
166
+ ]
167
+
168
+ return [
169
+ *bracketed("", f"def {_CALL_PREFIX}{names.method}", parameters, f" -> {names.result}:"),
170
+ f"{INDENT}result = endpoints.{names.method}(request)",
171
+ "",
172
+ f"{INDENT}if isinstance(result, {_ABC}.Awaitable):",
173
+ f"{INDENT * 2}raise {_RUNTIME}.async_handler_error(endpoints.{names.method}, result)",
174
+ "",
175
+ f"{INDENT}return result",
176
+ ]
177
+
178
+
179
+ def _view(layout: ApiLayout, route: RouteEntry, views: ViewsMode) -> list[str]:
180
+ """Ninja reads one `str` parameter per placeholder, which it cannot refuse; the view leaves them to the boundary's
181
+ raw decoding."""
182
+ names = layout.operations[route.operation_id]
183
+ parameters = [
184
+ f"request: {_HTTP}.HttpRequest",
185
+ *(f"{PATH_ARGUMENT_PREFIX}{segment.attribute}: str" for segment in route.segments if segment.attribute),
186
+ ]
187
+ operation = string_literal(route.operation_id)
188
+
189
+ if views is ViewsMode.ASYNC:
190
+ return [
191
+ *bracketed("", f"async def {_VIEW_PREFIX}{names.method}", parameters, f" -> {_HTTP}.HttpResponse:"),
192
+ f"{INDENT}return await {_RUNTIME}.serve_operation_async(request, PLACEMENT, {operation})",
193
+ ]
194
+
195
+ return [
196
+ *bracketed("", f"def {_VIEW_PREFIX}{names.method}", parameters, f" -> {_HTTP}.HttpResponse:"),
197
+ f"{INDENT}return {_RUNTIME}.serve_operation(request, PLACEMENT, {operation})",
198
+ ]
199
+
200
+
201
+ def _bind(
202
+ ir: ContractIR, layout: ApiLayout, proof_models: Mapping[str, str], routes: Sequence[RouteEntry]
203
+ ) -> list[str]:
204
+ def factories(hook: str) -> str:
205
+ return f"{_ABC}.Mapping[str, {_RUNTIME}.Factory[{_HOOKS}.{hook}]] | None = None"
206
+
207
+ parameters = [
208
+ f"api: {_NINJA}.NinjaAPI",
209
+ "*",
210
+ "prefix: str",
211
+ f"handlers: {layout.handler_factories}",
212
+ f"authenticate: {factories('AuthenticateHook')}",
213
+ f"deciders: {factories('DecideHook')}",
214
+ f"scope_resolvers: {factories('ResolveScopeHook')}",
215
+ f"idempotency_store: {_RUNTIME}.Factory[{_HOOKS}.IdempotencyStore] | None = None",
216
+ f"on_unexpected_error: {_RUNTIME}.Factory[{layout.unexpected_error_listener}] | None = None",
217
+ f"access: {_ABC}.Mapping[str, {_RUNTIME}.AccessSettings] | None = None",
218
+ f"responses: {_RUNTIME}.ResponsesMode | None = None",
219
+ "body_validation: bool | None = None",
220
+ f"operations: {_ABC}.Mapping[str, {_RUNTIME}.OperationSettings] | None = None",
221
+ "public_origin: str | None = None",
222
+ ]
223
+ settings = [
224
+ "responses=responses",
225
+ "body_validation=body_validation",
226
+ "operations=operations or {}",
227
+ "public_origin=public_origin",
228
+ ]
229
+ lines = [
230
+ *bracketed("", f"def {layout.bind_function}", parameters, " -> None:"),
231
+ f'{INDENT}"""{_BIND_DOC}"""',
232
+ f"{INDENT}binding = {_RUNTIME}.api_binding(",
233
+ f"{INDENT * 2}PLACEMENT,",
234
+ f"{INDENT * 2}_{_CONTRACT_MODULE}.CONTRACT,",
235
+ f"{INDENT * 2}api,",
236
+ f"{INDENT * 2}prefix=prefix,",
237
+ f"{INDENT * 2}authenticate=authenticate or {{}},",
238
+ f"{INDENT * 2}deciders=deciders or {{}},",
239
+ f"{INDENT * 2}scope_resolvers=scope_resolvers or {{}},",
240
+ f"{INDENT * 2}idempotency_store=idempotency_store,",
241
+ f"{INDENT * 2}on_unexpected_error=on_unexpected_error,",
242
+ f"{INDENT * 2}access=access or {{}},",
243
+ *bracketed(INDENT * 2, f"settings={_RUNTIME}.RunSettings", settings, ","),
244
+ f"{INDENT})",
245
+ "",
246
+ ]
247
+
248
+ for route in routes:
249
+ lines += _add_operation(layout, route.operation_id, proof_models.get(route.operation_id))
250
+
251
+ return [*lines, "", f"{INDENT}{_RUNTIME}.bind_api(binding)"]
252
+
253
+
254
+ def _add_operation(layout: ApiLayout, operation_id: str, proof_model: str | None) -> list[str]:
255
+ """One `add_operation` call, split as ruff splits it."""
256
+ names = layout.operations[operation_id]
257
+ proofs = None if proof_model is None else f"_{_SCHEMAS_MODULE}.{proof_model}"
258
+ types = operation_types_arguments(layout, operation_id, proofs)
259
+ arguments = [
260
+ "binding",
261
+ string_literal(operation_id),
262
+ f"{_VIEW_PREFIX}{names.method}",
263
+ f"handlers.{names.owner_attribute}",
264
+ f"{_CALL_PREFIX}{names.method}",
265
+ ]
266
+
267
+ return add_operation_call(_RUNTIME, arguments, types)
File without changes
@@ -0,0 +1,44 @@
1
+ """The Ninja placement runtime the generated code imports (ADR 0027 "Ninja"): the declarations of one api's placement,
2
+ its binding and Router, its boundary middleware, the body of its views and their handler calls. The
3
+ framework-neutral steps come from `seampact.codegen.runtime` and the shared boundary from `seampact.django.runtime`."""
4
+
5
+ from seampact.codegen.runtime.access_steps import CredentialsType
6
+ from seampact.codegen.runtime.native_docs import NativeDocsError, read_native_docs
7
+ from seampact.codegen.runtime.reply_verdict import ResponsesMode
8
+ from seampact.codegen.runtime.run_settings import OperationSettings, OperationTypes, RunSettings
9
+ from seampact.ninja.runtime.binding import ApiBinding, BindingError, add_operation, api_binding, bind_api
10
+ from seampact.ninja.runtime.boundary import NinjaContractBoundary
11
+ from seampact.ninja.runtime.placement import AccessSettings, ApiPlacement, Factory, NinjaAuth, OperationRoute
12
+ from seampact.ninja.runtime.views import (
13
+ async_handler_error,
14
+ call_handler_async,
15
+ serve_operation,
16
+ serve_operation_async,
17
+ )
18
+ from seampact.ninja.views_mode import ViewsMode
19
+
20
+ __all__ = [
21
+ "AccessSettings",
22
+ "ApiBinding",
23
+ "ApiPlacement",
24
+ "BindingError",
25
+ "CredentialsType",
26
+ "Factory",
27
+ "NativeDocsError",
28
+ "NinjaAuth",
29
+ "NinjaContractBoundary",
30
+ "OperationRoute",
31
+ "OperationSettings",
32
+ "OperationTypes",
33
+ "ResponsesMode",
34
+ "RunSettings",
35
+ "ViewsMode",
36
+ "add_operation",
37
+ "api_binding",
38
+ "async_handler_error",
39
+ "bind_api",
40
+ "call_handler_async",
41
+ "read_native_docs",
42
+ "serve_operation",
43
+ "serve_operation_async",
44
+ ]