seampact-django 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_django-1.0.0/.gitignore +6 -0
- seampact_django-1.0.0/LICENSE +21 -0
- seampact_django-1.0.0/PKG-INFO +21 -0
- seampact_django-1.0.0/README.md +1 -0
- seampact_django-1.0.0/pyproject.toml +36 -0
- seampact_django-1.0.0/src/seampact/django/__init__.py +2 -0
- seampact_django-1.0.0/src/seampact/django/apps.py +21 -0
- seampact_django-1.0.0/src/seampact/django/checks.py +17 -0
- seampact_django-1.0.0/src/seampact/django/emitters/__init__.py +22 -0
- seampact_django-1.0.0/src/seampact/django/emitters/boundary_module.py +145 -0
- seampact_django-1.0.0/src/seampact/django/emitters/settings_module.py +78 -0
- seampact_django-1.0.0/src/seampact/django/emitters/target_options.py +212 -0
- seampact_django-1.0.0/src/seampact/django/emitters/urls_module.py +78 -0
- seampact_django-1.0.0/src/seampact/django/management/__init__.py +1 -0
- seampact_django-1.0.0/src/seampact/django/management/commands/__init__.py +1 -0
- seampact_django-1.0.0/src/seampact/django/management/commands/seampact_check.py +22 -0
- seampact_django-1.0.0/src/seampact/django/management/commands/seampact_generate.py +34 -0
- seampact_django-1.0.0/src/seampact/django/py.typed +0 -0
- seampact_django-1.0.0/src/seampact/django/runtime/__init__.py +18 -0
- seampact_django-1.0.0/src/seampact/django/runtime/api_configuration.py +50 -0
- seampact_django-1.0.0/src/seampact/django/runtime/api_settings.py +225 -0
- seampact_django-1.0.0/src/seampact/django/runtime/boot_check.py +616 -0
- seampact_django-1.0.0/src/seampact/django/runtime/boundary.py +561 -0
- seampact_django-1.0.0/src/seampact/django/runtime/contract_replies.py +109 -0
- seampact_django-1.0.0/src/seampact/django/runtime/error_handlers.py +99 -0
- seampact_django-1.0.0/src/seampact/django/runtime/handler_factories.py +23 -0
- seampact_django-1.0.0/src/seampact/django/runtime/idempotency_records.py +97 -0
- seampact_django-1.0.0/src/seampact/django/runtime/request_state.py +81 -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,21 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: seampact-django
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: The Django layer the seampact DRF and Ninja targets share: the URLconf and settings emitters and the Django runtime of 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>=5.2
|
|
16
|
+
Requires-Dist: seampact-codegen==1.0.0
|
|
17
|
+
Provides-Extra: generate
|
|
18
|
+
Requires-Dist: seampact-codegen[generate]==1.0.0; extra == 'generate'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
The Django layer the seampact DRF and Ninja targets share: the URLconf, settings and boundary emitters, the `seampact.django` app and the Django runtime of generated code.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
The Django layer the seampact DRF and Ninja targets share: the URLconf, settings and boundary emitters, the `seampact.django` app and the Django runtime of generated code.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling==1.33.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "seampact-django"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "The Django layer the seampact DRF and Ninja targets share: the URLconf and settings emitters and the Django runtime of generated code."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.12"
|
|
13
|
+
dependencies = ["django>=5.2", "seampact-codegen==1.0.0"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Framework :: Django",
|
|
16
|
+
"Framework :: Django :: 5.2",
|
|
17
|
+
"Framework :: Django :: 6.0",
|
|
18
|
+
"Framework :: Django :: 6.1",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
# What the emitters need; a production install of an app leaves it out.
|
|
26
|
+
generate = ["seampact-codegen[generate]==1.0.0"]
|
|
27
|
+
|
|
28
|
+
# PEP 420: `seampact` is a namespace shared by every seampact distribution, so it has no `__init__.py`.
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["src/seampact"]
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.sdist]
|
|
33
|
+
include = ["src/seampact", "LICENSE"]
|
|
34
|
+
|
|
35
|
+
[tool.uv.sources]
|
|
36
|
+
seampact-codegen = { workspace = true }
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""The `seampact.django` app, which an app lists in `INSTALLED_APPS` beside the generated boundaries (ADR 0027
|
|
2
|
+
"Distributions"); it registers the system check and carries the `seampact_check` and `seampact_generate`
|
|
3
|
+
commands."""
|
|
4
|
+
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
from django.apps import AppConfig
|
|
8
|
+
from django.core.checks import register
|
|
9
|
+
|
|
10
|
+
APP_LABEL: Final = "seampact_django"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SeampactDjangoConfig(AppConfig):
|
|
14
|
+
name = "seampact.django"
|
|
15
|
+
label = APP_LABEL
|
|
16
|
+
verbose_name = "seampact"
|
|
17
|
+
|
|
18
|
+
def ready(self) -> None:
|
|
19
|
+
from seampact.django.checks import seampact_check
|
|
20
|
+
|
|
21
|
+
register(seampact_check)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""The Django system check of the seampact apis (ADR 0027 "The system check and the boot check"): it runs on
|
|
2
|
+
`check`, `runserver`, `migrate` and `test`, and reports each problem of the boot check as a `Warning` whose id is
|
|
3
|
+
`seampact.<api>`, so a broken api never stops `migrate` or `shell`."""
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
from django.apps import AppConfig
|
|
8
|
+
from django.core.checks import CheckMessage, Warning
|
|
9
|
+
|
|
10
|
+
from seampact.django.runtime.boot_check import every_problem
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def seampact_check(app_configs: Sequence[AppConfig] | None = None, **kwargs: object) -> list[CheckMessage]:
|
|
14
|
+
return [
|
|
15
|
+
Warning(problem, hint="Run `python manage.py seampact_check` in CI.", id=f"seampact.{api}")
|
|
16
|
+
for api, problem in every_problem()
|
|
17
|
+
]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""The emitters of the modules every Django target writes per api: the URLconf of the contract paths, the typed settings
|
|
2
|
+
the api reads from `settings.SEAMPACT`, the boundary middleware's declaration, and the options every Django target
|
|
3
|
+
reads. They need the `generate` extra."""
|
|
4
|
+
|
|
5
|
+
from seampact.django.emitters.boundary_module import BoundaryBase, boundary_module
|
|
6
|
+
from seampact.django.emitters.settings_module import SettingsKey, settings_module
|
|
7
|
+
from seampact.django.emitters.target_options import DjangoTargetOptions, OptionReader, django_target_options
|
|
8
|
+
from seampact.django.emitters.urls_module import ContractPath, contract_paths, routes_named_by_path, urls_module
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"BoundaryBase",
|
|
12
|
+
"ContractPath",
|
|
13
|
+
"DjangoTargetOptions",
|
|
14
|
+
"OptionReader",
|
|
15
|
+
"SettingsKey",
|
|
16
|
+
"boundary_module",
|
|
17
|
+
"contract_paths",
|
|
18
|
+
"django_target_options",
|
|
19
|
+
"routes_named_by_path",
|
|
20
|
+
"settings_module",
|
|
21
|
+
"urls_module",
|
|
22
|
+
]
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""The `django` module of one api: the declaration of its boundary middleware (ADR 0027 "The boundary middleware"),
|
|
2
|
+
which the app lists first in `MIDDLEWARE` as `<package>.django.<Api>Boundary`. It records the api, the version of
|
|
3
|
+
the generator, the api's contract and settings keys, its handler resources, the framework majors `generate`
|
|
4
|
+
accepted, the entry's `frameworkRoutes`, and the target options the boundary reads at run time, each a Python
|
|
5
|
+
literal."""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Final
|
|
10
|
+
|
|
11
|
+
from seampact.codegen.placement_lines import tuple_literal
|
|
12
|
+
from seampact.codegen.printer import INDENT, PythonModule, bracketed, string_literal
|
|
13
|
+
from seampact.ir.run.generator import FrameworkRoute, TargetEntry
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class BoundaryBase:
|
|
18
|
+
"""The runtime class a generated boundary extends, and the alias of its module."""
|
|
19
|
+
|
|
20
|
+
module: str
|
|
21
|
+
alias: str
|
|
22
|
+
name: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_DJANGO_RUNTIME: Final = "_django_runtime"
|
|
26
|
+
SETTINGS_MODULE: Final = ".settings"
|
|
27
|
+
DJANGO_BOUNDARY_BASE: Final = BoundaryBase("seampact.django.runtime", _DJANGO_RUNTIME, "ContractBoundary")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def boundary_module(
|
|
31
|
+
api: str,
|
|
32
|
+
api_class: str,
|
|
33
|
+
generated_by: str,
|
|
34
|
+
handler_resources: Mapping[str, Sequence[str]],
|
|
35
|
+
run_time_options: Sequence[tuple[str, str]],
|
|
36
|
+
entry: TargetEntry,
|
|
37
|
+
base: BoundaryBase = DJANGO_BOUNDARY_BASE,
|
|
38
|
+
settings_module: str | None = SETTINGS_MODULE,
|
|
39
|
+
model_framework: tuple[str, str] | None = None,
|
|
40
|
+
) -> str:
|
|
41
|
+
"""`base` is the target's own boundary class when the target adds settings keys the boot check reads.
|
|
42
|
+
`settings_module` is None for a target that takes the api's handlers and hooks in code rather than from
|
|
43
|
+
`settings.SEAMPACT`; an empty `handler_resources` is left out likewise. `model_framework` is the package and major
|
|
44
|
+
the generated models are written for (Pydantic 2 on Ninja), accepted by that major alone."""
|
|
45
|
+
module = PythonModule(f"The Django boundary of api '{api}': list {api_class}Boundary first in MIDDLEWARE.")
|
|
46
|
+
base_module = module.imported(base.module, base.alias)
|
|
47
|
+
frameworks = _accepted_frameworks(_DJANGO_RUNTIME, entry) + _model_framework(_DJANGO_RUNTIME, model_framework)
|
|
48
|
+
framework_routes = _framework_routes(_DJANGO_RUNTIME, entry.framework_routes)
|
|
49
|
+
|
|
50
|
+
if frameworks or framework_routes:
|
|
51
|
+
module.imported(DJANGO_BOUNDARY_BASE.module, _DJANGO_RUNTIME)
|
|
52
|
+
contract = module.imported(".contract", "_contract")
|
|
53
|
+
settings_lines = (
|
|
54
|
+
[f"{INDENT}settings_keys = {module.imported(settings_module, '_settings')}.SETTINGS_KEYS"]
|
|
55
|
+
if settings_module is not None
|
|
56
|
+
else []
|
|
57
|
+
)
|
|
58
|
+
module.add_block(
|
|
59
|
+
[
|
|
60
|
+
f"class {api_class}Boundary({base_module}.{base.name}):",
|
|
61
|
+
f'{INDENT}"""The boundary middleware of api \'{api}\'."""',
|
|
62
|
+
"",
|
|
63
|
+
f"{INDENT}api = {string_literal(api)}",
|
|
64
|
+
f"{INDENT}generated_by = {string_literal(generated_by)}",
|
|
65
|
+
f"{INDENT}contract = {contract}.CONTRACT",
|
|
66
|
+
*settings_lines,
|
|
67
|
+
*(_handler_resources(module, handler_resources) if handler_resources else []),
|
|
68
|
+
*(f"{INDENT}{attribute} = {literal}" for attribute, literal in run_time_options),
|
|
69
|
+
*_tuple_attribute("frameworks", frameworks),
|
|
70
|
+
*_tuple_attribute("framework_routes", framework_routes),
|
|
71
|
+
]
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return module.text()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _handler_resources(module: PythonModule, handler_resources: Mapping[str, Sequence[str]]) -> list[str]:
|
|
78
|
+
"""Each handler resource's settings key and the operations it serves, so a missing factory names them."""
|
|
79
|
+
typing = module.imported("typing", "_typing")
|
|
80
|
+
abc = module.imported("collections.abc", "_abc")
|
|
81
|
+
lines = [f"{INDENT}handler_resources: {typing}.ClassVar[{abc}.Mapping[str, {abc}.Sequence[str]]] = {{"]
|
|
82
|
+
|
|
83
|
+
for resource, operation_ids in handler_resources.items():
|
|
84
|
+
head = f"{string_literal(resource)}: "
|
|
85
|
+
|
|
86
|
+
if len(operation_ids) == 1:
|
|
87
|
+
lines.append(f"{INDENT * 2}{head}{tuple_literal(operation_ids)},")
|
|
88
|
+
else:
|
|
89
|
+
lines += bracketed(INDENT * 2, head, [string_literal(item) for item in operation_ids], ",")
|
|
90
|
+
|
|
91
|
+
return [*lines, f"{INDENT}}}"]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _accepted_frameworks(runtime: str, entry: TargetEntry) -> list[str]:
|
|
95
|
+
"""The matrix row's frameworks, each by its unit, with the entry's `allowUntested` units and the tested minors."""
|
|
96
|
+
lines: list[str] = []
|
|
97
|
+
|
|
98
|
+
for framework in entry.matrix.frameworks if entry.matrix is not None else ():
|
|
99
|
+
arguments = [
|
|
100
|
+
f"package={string_literal(framework.package)}",
|
|
101
|
+
f"unit={runtime}.VersionUnit.{framework.unit.name}",
|
|
102
|
+
f"accepted={tuple_literal(framework.accepted_units(entry.allow_untested))}",
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
if framework.tested_minors:
|
|
106
|
+
arguments.append(f"tested_minors={tuple_literal(framework.tested_minors)}")
|
|
107
|
+
|
|
108
|
+
lines += bracketed(INDENT * 2, f"{runtime}.AcceptedFramework", arguments, ",")
|
|
109
|
+
|
|
110
|
+
return lines
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _model_framework(runtime: str, model_framework: tuple[str, str] | None) -> list[str]:
|
|
114
|
+
if model_framework is None:
|
|
115
|
+
return []
|
|
116
|
+
|
|
117
|
+
package, major = model_framework
|
|
118
|
+
arguments = [
|
|
119
|
+
f"package={string_literal(package)}",
|
|
120
|
+
f"unit={runtime}.VersionUnit.MAJOR",
|
|
121
|
+
f"accepted={tuple_literal((major,))}",
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
return bracketed(INDENT * 2, f"{runtime}.AcceptedFramework", arguments, ",")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _framework_routes(runtime: str, routes: Sequence[FrameworkRoute]) -> list[str]:
|
|
128
|
+
lines: list[str] = []
|
|
129
|
+
|
|
130
|
+
for allowed in routes:
|
|
131
|
+
arguments = [
|
|
132
|
+
f"path={string_literal(allowed.path)}",
|
|
133
|
+
f"method={'None' if allowed.method is None else string_literal(allowed.method)}",
|
|
134
|
+
f"reason={string_literal(allowed.reason)}",
|
|
135
|
+
]
|
|
136
|
+
lines += bracketed(INDENT * 2, f"{runtime}.FrameworkRouteRule", arguments, ",")
|
|
137
|
+
|
|
138
|
+
return lines
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _tuple_attribute(name: str, items: list[str]) -> list[str]:
|
|
142
|
+
if not items:
|
|
143
|
+
return [f"{INDENT}{name} = ()"]
|
|
144
|
+
|
|
145
|
+
return [f"{INDENT}{name} = (", *items, f"{INDENT})"]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""The `settings` module of one api (ADR 0027 "Settings"): the typed shape of `settings.SEAMPACT["<api>"]`, whose
|
|
2
|
+
keys mirror `bind_<api>()`'s arguments, hooks and handler factories given as dotted paths, and `SETTINGS_KEYS`, the
|
|
3
|
+
keys the boot check accepts. A target adds its own keys (DRF's `access`); an annotation may use the module's
|
|
4
|
+
`_abc` (`collections.abc`) and `_typing` aliases."""
|
|
5
|
+
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from seampact.codegen.printer import INDENT, PythonModule, string_literal
|
|
10
|
+
|
|
11
|
+
_RESPONSES_MODES = '_typing.Literal["strict", "warn"]'
|
|
12
|
+
_DOTTED_PATHS_BY_NAME = "_abc.Mapping[str, str]"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class SettingsKey:
|
|
17
|
+
name: str
|
|
18
|
+
annotation: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_SHARED_KEYS = (
|
|
22
|
+
SettingsKey("authenticate", _DOTTED_PATHS_BY_NAME),
|
|
23
|
+
SettingsKey("deciders", _DOTTED_PATHS_BY_NAME),
|
|
24
|
+
SettingsKey("scope_resolvers", _DOTTED_PATHS_BY_NAME),
|
|
25
|
+
SettingsKey("idempotency_store", "str"),
|
|
26
|
+
SettingsKey("on_unexpected_error", "str"),
|
|
27
|
+
SettingsKey("responses", _RESPONSES_MODES),
|
|
28
|
+
SettingsKey("body_validation", "bool"),
|
|
29
|
+
SettingsKey("public_origin", "str"),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def settings_module(api: str, api_class: str, handler_owners: Sequence[str], target_keys: Sequence[SettingsKey]) -> str:
|
|
34
|
+
"""`handler_owners` are the attribute names of the api's handler groups, one factory each."""
|
|
35
|
+
module = PythonModule(f"The settings of api '{api}', read from settings.SEAMPACT[\"{api}\"].")
|
|
36
|
+
typing = module.imported("typing", "_typing")
|
|
37
|
+
module.imported("collections.abc", "_abc")
|
|
38
|
+
operations = SettingsKey("operations", f"_abc.Mapping[str, {api_class}OperationSettings]")
|
|
39
|
+
keys = [
|
|
40
|
+
SettingsKey("handlers", f"{typing}.Required[{api_class}HandlerSettings]"),
|
|
41
|
+
*_SHARED_KEYS,
|
|
42
|
+
operations,
|
|
43
|
+
*target_keys,
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
module.add_block(
|
|
47
|
+
[
|
|
48
|
+
f"class {api_class}HandlerSettings({typing}.TypedDict):",
|
|
49
|
+
f'{INDENT}"""The dotted path of each handler factory, which builds the handlers from the HttpRequest."""',
|
|
50
|
+
"",
|
|
51
|
+
*(f"{INDENT}{owner}: str" for owner in handler_owners),
|
|
52
|
+
]
|
|
53
|
+
)
|
|
54
|
+
module.add_block(
|
|
55
|
+
[
|
|
56
|
+
f"class {api_class}OperationSettings({typing}.TypedDict, total=False):",
|
|
57
|
+
f'{INDENT}"""What one operation sets over the api\'s settings."""',
|
|
58
|
+
"",
|
|
59
|
+
f"{INDENT}responses: {_RESPONSES_MODES}",
|
|
60
|
+
]
|
|
61
|
+
)
|
|
62
|
+
module.add_block(
|
|
63
|
+
[
|
|
64
|
+
f"class {api_class}Settings({typing}.TypedDict, total=False):",
|
|
65
|
+
f'{INDENT}"""The value of settings.SEAMPACT["{api}"]."""',
|
|
66
|
+
"",
|
|
67
|
+
*(f"{INDENT}{key.name}: {key.annotation}" for key in keys),
|
|
68
|
+
]
|
|
69
|
+
)
|
|
70
|
+
module.add_block(
|
|
71
|
+
[
|
|
72
|
+
f"SETTINGS_KEYS: {typing}.Final = (",
|
|
73
|
+
*(f"{INDENT}{string_literal(key.name)}," for key in keys),
|
|
74
|
+
")",
|
|
75
|
+
]
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return module.text()
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""The options and artefact switches every Django target reads (ADR 0026 "Options and registration", ADR 0027 "Options
|
|
2
|
+
and registration"), each unknown key and each value the target does not take refused by name. The run-time defaults
|
|
3
|
+
(`responses`, `bodyValidation`, `frameworkEtag`, `catchAll`, the boot check's parts) go into the boundary's
|
|
4
|
+
declaration; `routeHelpers` decides the `routes` module. A target reads its own keys (`rules`, Ninja's `views`) with
|
|
5
|
+
`OptionReader`."""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
from typing import Final
|
|
12
|
+
|
|
13
|
+
from seampact.codegen.boot_check_hooks import HOOKS_ERROR, read_boot_check_hooks
|
|
14
|
+
from seampact.codegen.refusals import Delivery, IrPlace, Refusals
|
|
15
|
+
from seampact.codegen.runtime.reply_verdict import FrameworkEtagMode, ResponsesMode
|
|
16
|
+
from seampact.ir.config.json_value import JsonValue
|
|
17
|
+
|
|
18
|
+
_HANDLERS: Final = "handlers"
|
|
19
|
+
_PER_RESOURCE: Final = "perResource"
|
|
20
|
+
_INDENT: Final = "indent"
|
|
21
|
+
_INDENT_SPACES: Final = 4
|
|
22
|
+
_NAMING_KEYS: Final = ("suffixes", "escape", "enumCase")
|
|
23
|
+
_LAYOUT: Final = "layout"
|
|
24
|
+
_SHARED_OPTIONS: Final = ("responses", "bodyValidation", "frameworkEtag", "catchAll")
|
|
25
|
+
_ROUTE_HELPERS: Final = "routeHelpers"
|
|
26
|
+
_BOOT_CHECK: Final = "bootCheck"
|
|
27
|
+
_ACCESS_HOOKS: Final = "accessHooks"
|
|
28
|
+
_BOOT_CHECK_PARTS: Final = ("frameworkRoutes", "hooks")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class BootCheck:
|
|
33
|
+
"""The boot check, on unless `bootCheck: false`; its framework-route and hook parts each turned off alone."""
|
|
34
|
+
|
|
35
|
+
enabled: bool = True
|
|
36
|
+
framework_routes: bool = True
|
|
37
|
+
hooks: bool = True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class DjangoTargetOptions:
|
|
42
|
+
responses: ResponsesMode = ResponsesMode.STRICT
|
|
43
|
+
body_validation: bool = False
|
|
44
|
+
framework_etag: FrameworkEtagMode = FrameworkEtagMode.STRIP
|
|
45
|
+
catch_all: bool = True
|
|
46
|
+
route_helpers: bool = True
|
|
47
|
+
boot_check: BootCheck = BootCheck()
|
|
48
|
+
|
|
49
|
+
def run_time_options(self) -> list[tuple[str, str]]:
|
|
50
|
+
"""Each run-time default as the boundary declaration's attribute and its Python literal."""
|
|
51
|
+
return [
|
|
52
|
+
("responses", json.dumps(self.responses.value)),
|
|
53
|
+
("body_validation", repr(self.body_validation)),
|
|
54
|
+
("framework_etag", json.dumps(self.framework_etag.value)),
|
|
55
|
+
("catch_all", repr(self.catch_all)),
|
|
56
|
+
("boot_check", repr(self.boot_check.enabled)),
|
|
57
|
+
("boot_check_framework_routes", repr(self.boot_check.framework_routes)),
|
|
58
|
+
("boot_check_hooks", repr(self.boot_check.hooks)),
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def django_target_options(
|
|
63
|
+
target: str,
|
|
64
|
+
own_options: Sequence[str],
|
|
65
|
+
options: Mapping[str, JsonValue],
|
|
66
|
+
artefacts: Mapping[str, JsonValue],
|
|
67
|
+
refusals: Refusals,
|
|
68
|
+
*,
|
|
69
|
+
own_artefacts: Sequence[str] = (),
|
|
70
|
+
) -> DjangoTargetOptions:
|
|
71
|
+
"""The shared options and artefacts of `target`; `own_options` are the keys the target reads itself, and
|
|
72
|
+
`own_artefacts` the `true` or `false` switches it reads itself."""
|
|
73
|
+
known_options = (_HANDLERS, _INDENT, *own_options, *_SHARED_OPTIONS)
|
|
74
|
+
reader = OptionReader(options, refusals)
|
|
75
|
+
|
|
76
|
+
for key in options:
|
|
77
|
+
if key in _NAMING_KEYS:
|
|
78
|
+
reader.refuse(
|
|
79
|
+
"options.naming.unsupported",
|
|
80
|
+
key,
|
|
81
|
+
f"The naming option '{key}' is not taken by the {target} target: it names Python with the defaults of "
|
|
82
|
+
"ADR 0007, which the shared naming vectors test.",
|
|
83
|
+
f"Remove '{key}' from the target's `options`.",
|
|
84
|
+
)
|
|
85
|
+
elif key == _LAYOUT:
|
|
86
|
+
reader.refuse(
|
|
87
|
+
"options.layout.unsupported",
|
|
88
|
+
key,
|
|
89
|
+
f"The option 'layout' is set; the {target} target writes one package per api, the only layout it "
|
|
90
|
+
"takes.",
|
|
91
|
+
"Remove 'layout' from the target's `options`.",
|
|
92
|
+
)
|
|
93
|
+
elif key not in known_options:
|
|
94
|
+
reader.refuse(
|
|
95
|
+
"options.unknown",
|
|
96
|
+
key,
|
|
97
|
+
f"The option '{key}' is not one the {target} target knows; it takes '{"', '".join(known_options)}'.",
|
|
98
|
+
f"Remove '{key}' from the target's `options`.",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
if options.get(_HANDLERS, _PER_RESOURCE) != _PER_RESOURCE:
|
|
102
|
+
refusals.waiting(
|
|
103
|
+
"options.handlers",
|
|
104
|
+
f"The option 'handlers: {_shown(options[_HANDLERS])}'",
|
|
105
|
+
_at(_HANDLERS),
|
|
106
|
+
Delivery.CONSUMER,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if options.get(_INDENT, _INDENT_SPACES) != _INDENT_SPACES:
|
|
110
|
+
refusals.waiting(
|
|
111
|
+
"options.indent", f"The option 'indent: {_shown(options[_INDENT])}'", _at(_INDENT), Delivery.CONSUMER
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
return DjangoTargetOptions(
|
|
115
|
+
responses=reader.choice("responses", ResponsesMode.STRICT),
|
|
116
|
+
body_validation=reader.flag("bodyValidation", default=False),
|
|
117
|
+
framework_etag=reader.choice("frameworkEtag", FrameworkEtagMode.STRIP),
|
|
118
|
+
catch_all=reader.flag("catchAll", default=True),
|
|
119
|
+
route_helpers=_artefacts(target, artefacts, own_artefacts, refusals),
|
|
120
|
+
boot_check=_boot_check(artefacts.get(_BOOT_CHECK, True), refusals),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _artefacts(
|
|
125
|
+
target: str, artefacts: Mapping[str, JsonValue], own_artefacts: Sequence[str], refusals: Refusals
|
|
126
|
+
) -> bool:
|
|
127
|
+
"""Refuses each switch the target does not take, and returns `routeHelpers`."""
|
|
128
|
+
for key, value in artefacts.items():
|
|
129
|
+
if key in own_artefacts and isinstance(value, bool):
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
at = IrPlace(f"/artefacts/{key}")
|
|
133
|
+
|
|
134
|
+
if key == _ACCESS_HOOKS and value is False:
|
|
135
|
+
refusals.waiting(
|
|
136
|
+
"artefacts.accessHooks", "Native access instead of hooks ('accessHooks: false')", at, Delivery.CONSUMER
|
|
137
|
+
)
|
|
138
|
+
elif key not in (_ROUTE_HELPERS, _BOOT_CHECK, _ACCESS_HOOKS) or not _is_switch(key, value):
|
|
139
|
+
refusals.add(
|
|
140
|
+
"artefacts.unknown",
|
|
141
|
+
f"The artefact switch '{key}: {_shown(value)}' is not one the {target} target takes.",
|
|
142
|
+
f"Remove '{key}' from the entry's `artefacts`; the {target} target takes "
|
|
143
|
+
f"'{"', '".join((_ROUTE_HELPERS, _BOOT_CHECK, *own_artefacts))}' and '{_ACCESS_HOOKS}: true'.",
|
|
144
|
+
at,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
return artefacts.get(_ROUTE_HELPERS, True) is not False
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _is_switch(key: str, value: JsonValue) -> bool:
|
|
151
|
+
if key == _BOOT_CHECK and isinstance(value, Mapping):
|
|
152
|
+
return all(part in _BOOT_CHECK_PARTS for part in value)
|
|
153
|
+
|
|
154
|
+
return isinstance(value, bool)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _boot_check(value: JsonValue, refusals: Refusals) -> BootCheck:
|
|
158
|
+
if not isinstance(value, Mapping):
|
|
159
|
+
return BootCheck(enabled=value is not False)
|
|
160
|
+
|
|
161
|
+
hooks = read_boot_check_hooks(value.get("hooks", HOOKS_ERROR), IrPlace("/artefacts/bootCheck/hooks"), refusals)
|
|
162
|
+
|
|
163
|
+
return BootCheck(framework_routes=value.get("frameworkRoutes", True) is not False, hooks=hooks)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class OptionReader:
|
|
167
|
+
"""Reads one option of an entry, refusing by name a value outside its set."""
|
|
168
|
+
|
|
169
|
+
def __init__(self, options: Mapping[str, JsonValue], refusals: Refusals) -> None:
|
|
170
|
+
self._options = options
|
|
171
|
+
self._refusals = refusals
|
|
172
|
+
|
|
173
|
+
def refuse(self, code: str, key: str, message: str, fix: str) -> None:
|
|
174
|
+
self._refusals.add(code, message, fix, _at(key))
|
|
175
|
+
|
|
176
|
+
def choice[E: StrEnum](self, key: str, default: E) -> E:
|
|
177
|
+
value = self._options.get(key, default.value)
|
|
178
|
+
chosen = next((member for member in type(default) if member.value == value), None)
|
|
179
|
+
|
|
180
|
+
if chosen is not None:
|
|
181
|
+
return chosen
|
|
182
|
+
|
|
183
|
+
allowed = "', '".join(member.value for member in type(default))
|
|
184
|
+
self.refuse(
|
|
185
|
+
f"options.{key}.invalid",
|
|
186
|
+
key,
|
|
187
|
+
f"The option '{key}' is {_shown(value)}, not one of '{allowed}'.",
|
|
188
|
+
f"Set '{key}' to '{default.value}' (the default) or another listed value.",
|
|
189
|
+
)
|
|
190
|
+
return default
|
|
191
|
+
|
|
192
|
+
def flag(self, key: str, default: bool) -> bool:
|
|
193
|
+
value = self._options.get(key, default)
|
|
194
|
+
|
|
195
|
+
if isinstance(value, bool):
|
|
196
|
+
return value
|
|
197
|
+
|
|
198
|
+
self.refuse(
|
|
199
|
+
f"options.{key}.invalid",
|
|
200
|
+
key,
|
|
201
|
+
f"The option '{key}' is {_shown(value)}, not true or false.",
|
|
202
|
+
f"Set '{key}' to true or false (the default is {json.dumps(default)}).",
|
|
203
|
+
)
|
|
204
|
+
return default
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _at(key: str) -> IrPlace:
|
|
208
|
+
return IrPlace(f"/options/{key}")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _shown(value: JsonValue) -> str:
|
|
212
|
+
return json.dumps(value, ensure_ascii=False)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""The URLconf of one api (ADR 0027 "Routes"): one `path()` entry per contract path, which the app includes at `""`
|
|
2
|
+
in its root URLconf. Each placeholder uses Django's `str` converter, so it takes one whole segment, and is named
|
|
3
|
+
`path_<attribute>`, so a parameter named `request` or `self` never meets the view's own arguments. Per path depth,
|
|
4
|
+
literal segments come before placeholders, as the route table orders them. An entry is named
|
|
5
|
+
`seampact.<api>.<operationId>` after the path's first operation, and points to the view the target names."""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Callable, Sequence
|
|
8
|
+
from dataclasses import dataclass, replace
|
|
9
|
+
from typing import Final
|
|
10
|
+
|
|
11
|
+
from seampact.codegen.printer import INDENT, PythonModule, fits, string_literal
|
|
12
|
+
from seampact.codegen.routes import RouteEntry
|
|
13
|
+
|
|
14
|
+
PATH_ARGUMENT_PREFIX: Final = "path_"
|
|
15
|
+
"""The prefix of each converter name: the view receives the value of parameter attribute `a` as `path_a`."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class ContractPath:
|
|
20
|
+
"""A contract path and the route entries of the operations it serves, in the api's operation order."""
|
|
21
|
+
|
|
22
|
+
entries: tuple[RouteEntry, ...]
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def first(self) -> RouteEntry:
|
|
26
|
+
return self.entries[0]
|
|
27
|
+
|
|
28
|
+
def django_route(self) -> str:
|
|
29
|
+
"""The `path()` route: no leading slash, `<str:path_attribute>` for each placeholder."""
|
|
30
|
+
return "/".join(
|
|
31
|
+
segment.text if segment.attribute is None else f"<str:{PATH_ARGUMENT_PREFIX}{segment.attribute}>"
|
|
32
|
+
for segment in self.first.segments
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def contract_paths(routes: Sequence[RouteEntry], operation_order: Sequence[str]) -> list[ContractPath]:
|
|
37
|
+
"""The api's paths in route order, each with its operations in `operation_order`."""
|
|
38
|
+
position = {operation_id: index for index, operation_id in enumerate(operation_order)}
|
|
39
|
+
grouped: dict[str, list[RouteEntry]] = {}
|
|
40
|
+
|
|
41
|
+
for route in routes:
|
|
42
|
+
grouped.setdefault(route.contract_path, []).append(route)
|
|
43
|
+
|
|
44
|
+
return [
|
|
45
|
+
ContractPath(tuple(sorted(entries, key=lambda entry: position[entry.operation_id])))
|
|
46
|
+
for entries in grouped.values()
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def routes_named_by_path(paths: Sequence[ContractPath]) -> list[RouteEntry]:
|
|
51
|
+
"""Each operation's route under the name the URLconf registers for its path, for `<Api>RouteNames`."""
|
|
52
|
+
return [replace(entry, name=contract_path.first.name) for contract_path in paths for entry in contract_path.entries]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def urls_module(api: str, paths: Sequence[ContractPath], view_of: Callable[[PythonModule, ContractPath], str]) -> str:
|
|
56
|
+
"""`view_of` imports the target's views module into the URLconf and returns the view expression of a path."""
|
|
57
|
+
module = PythonModule(f"The URL patterns of api '{api}': include them at \"\" in the root URLconf.")
|
|
58
|
+
urls = module.imported("django.urls", "_urls")
|
|
59
|
+
lines = ["urlpatterns = ["]
|
|
60
|
+
|
|
61
|
+
for contract_path in paths:
|
|
62
|
+
arguments = [
|
|
63
|
+
string_literal(contract_path.django_route()),
|
|
64
|
+
view_of(module, contract_path),
|
|
65
|
+
f"name={string_literal(contract_path.first.name)}",
|
|
66
|
+
]
|
|
67
|
+
single = f"{INDENT}{urls}.path({', '.join(arguments)}),"
|
|
68
|
+
|
|
69
|
+
if fits(single):
|
|
70
|
+
lines.append(single)
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
lines += [f"{INDENT}{urls}.path(", *(f"{INDENT * 2}{argument}," for argument in arguments), f"{INDENT}),"]
|
|
74
|
+
|
|
75
|
+
lines.append("]")
|
|
76
|
+
module.add_block(lines)
|
|
77
|
+
|
|
78
|
+
return module.text()
|