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.
- seampact_ninja-1.0.0/.gitignore +6 -0
- seampact_ninja-1.0.0/LICENSE +21 -0
- seampact_ninja-1.0.0/PKG-INFO +23 -0
- seampact_ninja-1.0.0/README.md +1 -0
- seampact_ninja-1.0.0/pyproject.toml +48 -0
- seampact_ninja-1.0.0/src/seampact/ninja/__init__.py +1 -0
- seampact_ninja-1.0.0/src/seampact/ninja/generator.py +104 -0
- seampact_ninja-1.0.0/src/seampact/ninja/options.py +40 -0
- seampact_ninja-1.0.0/src/seampact/ninja/placement_module.py +267 -0
- seampact_ninja-1.0.0/src/seampact/ninja/py.typed +0 -0
- seampact_ninja-1.0.0/src/seampact/ninja/runtime/__init__.py +44 -0
- seampact_ninja-1.0.0/src/seampact/ninja/runtime/binding.py +219 -0
- seampact_ninja-1.0.0/src/seampact/ninja/runtime/boot_check.py +269 -0
- seampact_ninja-1.0.0/src/seampact/ninja/runtime/boundary.py +71 -0
- seampact_ninja-1.0.0/src/seampact/ninja/runtime/exceptions.py +82 -0
- seampact_ninja-1.0.0/src/seampact/ninja/runtime/native_docs.py +36 -0
- seampact_ninja-1.0.0/src/seampact/ninja/runtime/placement.py +63 -0
- seampact_ninja-1.0.0/src/seampact/ninja/runtime/preflight.py +36 -0
- seampact_ninja-1.0.0/src/seampact/ninja/runtime/views.py +230 -0
- seampact_ninja-1.0.0/src/seampact/ninja/views_mode.py +11 -0
|
@@ -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
|
+
]
|