seampact-codegen 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- seampact/codegen/__init__.py +2 -0
- seampact/codegen/boot_check_hooks.py +38 -0
- seampact/codegen/contract_module.py +146 -0
- seampact/codegen/credentials_classes.py +99 -0
- seampact/codegen/handler_forms.py +132 -0
- seampact/codegen/input_schemas.py +130 -0
- seampact/codegen/kept_forms.py +522 -0
- seampact/codegen/matrix_row.py +23 -0
- seampact/codegen/names.py +146 -0
- seampact/codegen/native_docs_file.py +169 -0
- seampact/codegen/operation_types.py +425 -0
- seampact/codegen/placement_lines.py +79 -0
- seampact/codegen/printer.py +196 -0
- seampact/codegen/py.typed +0 -0
- seampact/codegen/pydantic_forms.py +53 -0
- seampact/codegen/pydantic_layout.py +178 -0
- seampact/codegen/pydantic_models.py +405 -0
- seampact/codegen/redacted_members.py +47 -0
- seampact/codegen/refusals.py +84 -0
- seampact/codegen/request_deadline.py +68 -0
- seampact/codegen/response_header_forms.py +73 -0
- seampact/codegen/restricted_sort_keys.py +18 -0
- seampact/codegen/route_helpers.py +109 -0
- seampact/codegen/routes.py +139 -0
- seampact/codegen/runtime/__init__.py +7 -0
- seampact/codegen/runtime/access_kinds.py +24 -0
- seampact/codegen/runtime/access_steps.py +520 -0
- seampact/codegen/runtime/body_reader.py +64 -0
- seampact/codegen/runtime/cache_headers.py +89 -0
- seampact/codegen/runtime/contract.py +232 -0
- seampact/codegen/runtime/cors_preflight.py +20 -0
- seampact/codegen/runtime/deadline.py +72 -0
- seampact/codegen/runtime/digests.py +98 -0
- seampact/codegen/runtime/failures.py +33 -0
- seampact/codegen/runtime/field_rights.py +193 -0
- seampact/codegen/runtime/framework_routes.py +36 -0
- seampact/codegen/runtime/framework_versions.py +91 -0
- seampact/codegen/runtime/handler_call.py +31 -0
- seampact/codegen/runtime/handler_reply.py +148 -0
- seampact/codegen/runtime/hinted_values.py +48 -0
- seampact/codegen/runtime/hook_check.py +77 -0
- seampact/codegen/runtime/idempotency.py +279 -0
- seampact/codegen/runtime/input_decoding.py +226 -0
- seampact/codegen/runtime/ir_view.py +47 -0
- seampact/codegen/runtime/native_docs.py +130 -0
- seampact/codegen/runtime/page_facts.py +33 -0
- seampact/codegen/runtime/page_reply.py +396 -0
- seampact/codegen/runtime/pattern.py +69 -0
- seampact/codegen/runtime/principal_members.py +41 -0
- seampact/codegen/runtime/public_origin.py +102 -0
- seampact/codegen/runtime/pydantic/__init__.py +165 -0
- seampact/codegen/runtime/pydantic/checked_input.py +52 -0
- seampact/codegen/runtime/pydantic/contract_input.py +183 -0
- seampact/codegen/runtime/pydantic/issues.py +142 -0
- seampact/codegen/runtime/raw_target.py +103 -0
- seampact/codegen/runtime/replies.py +144 -0
- seampact/codegen/runtime/reply_verdict.py +215 -0
- seampact/codegen/runtime/requirement_params.py +170 -0
- seampact/codegen/runtime/response_header_entries.py +226 -0
- seampact/codegen/runtime/response_headers.py +114 -0
- seampact/codegen/runtime/route_match.py +88 -0
- seampact/codegen/runtime/run_settings.py +35 -0
- seampact/codegen/runtime/signature_header.py +92 -0
- seampact/codegen/runtime/sync_driver.py +35 -0
- seampact/codegen/runtime/templates.py +132 -0
- seampact/codegen/runtime/uuid_text.py +7 -0
- seampact/codegen/runtime/value_rules.py +218 -0
- seampact/codegen/runtime/version.py +59 -0
- seampact/codegen/runtime/version_token.py +108 -0
- seampact/codegen/runtime/wire_codec.py +229 -0
- seampact/codegen/runtime/wire_values.py +260 -0
- seampact/codegen/source_lines.py +35 -0
- seampact_codegen-1.0.0.dist-info/METADATA +18 -0
- seampact_codegen-1.0.0.dist-info/RECORD +76 -0
- seampact_codegen-1.0.0.dist-info/WHEEL +4 -0
- seampact_codegen-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""The entry's `frameworkRoutes` allow-list as the Python boot checks read it (FR-25): an app route at or under one of
|
|
2
|
+
the api's base paths is refused unless an item lists it, by a path pattern where `*` takes one segment and `**` any
|
|
3
|
+
number, and a method or every method."""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
11
|
+
class FrameworkRouteRule:
|
|
12
|
+
"""An item of the entry's `frameworkRoutes`: `path` a pattern where `*` takes one segment and `**` any number,
|
|
13
|
+
`method` None for every method."""
|
|
14
|
+
|
|
15
|
+
path: str
|
|
16
|
+
method: str | None
|
|
17
|
+
reason: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def allow_listed(rules: Sequence[FrameworkRouteRule], path: str, method: str) -> bool:
|
|
21
|
+
return any(_path_rule(rule.path).fullmatch(path) and rule.method in (None, method) for rule in rules)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def base_path_of(path: str, base_paths: Sequence[str]) -> str | None:
|
|
25
|
+
"""The first base path `path` sits at or under, None when it sits under none."""
|
|
26
|
+
return next((base for base in base_paths if _under(path, base)), None)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _path_rule(pattern: str) -> re.Pattern[str]:
|
|
30
|
+
parts = [re.escape(part).replace(r"\*", "[^/]*") for part in pattern.split("**")]
|
|
31
|
+
|
|
32
|
+
return re.compile(".*".join(parts))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _under(path: str, base: str) -> bool:
|
|
36
|
+
return base == "" or path == base or path.startswith(base + "/") or path.startswith(base + "{")
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""The framework versions `generate` accepted (ADR 0026 "The boot check"): the generated code records, per framework
|
|
2
|
+
the target serves or runs on, the units of the compatibility matrix (a major, or a `<major>.<minor>` for a 0.x
|
|
3
|
+
framework) plus the entry's `allowUntested` ones, and the boot check refuses an installed version outside them, so an
|
|
4
|
+
upgrade after `generate` fails the boot by name. A framework checked by major may also record the matrix's tested
|
|
5
|
+
minors: an installed minor outside them, within a tested major, is only warned of (ADR 0027 "Matrix")."""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
from importlib import metadata
|
|
12
|
+
from typing import Final
|
|
13
|
+
|
|
14
|
+
_VERSION: Final = re.compile(r"(\d+)\.(\d+)")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class VersionUnit(StrEnum):
|
|
18
|
+
MAJOR = "major"
|
|
19
|
+
MINOR = "minor"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
23
|
+
class AcceptedFramework:
|
|
24
|
+
"""A distribution (`fastapi`) and the units `generate` accepted for it, compared by `unit`."""
|
|
25
|
+
|
|
26
|
+
package: str
|
|
27
|
+
unit: VersionUnit
|
|
28
|
+
accepted: tuple[str, ...]
|
|
29
|
+
tested_minors: tuple[str, ...] = ()
|
|
30
|
+
|
|
31
|
+
def unit_of(self, version: str) -> str | None:
|
|
32
|
+
match = _VERSION.match(version)
|
|
33
|
+
|
|
34
|
+
if match is None:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
return match.group(1) if self.unit is VersionUnit.MAJOR else f"{match.group(1)}.{match.group(2)}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def framework_version_problems(frameworks: Sequence[AcceptedFramework], api: str) -> list[str]:
|
|
41
|
+
return [problem for framework in frameworks if (problem := _version_problem(framework, api)) is not None]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def framework_version_warnings(frameworks: Sequence[AcceptedFramework], api: str) -> list[str]:
|
|
45
|
+
"""One line per installed framework whose minor is outside the tested minors of its major."""
|
|
46
|
+
return [warning for framework in frameworks if (warning := _untested_minor(framework, api)) is not None]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _untested_minor(framework: AcceptedFramework, api: str) -> str | None:
|
|
50
|
+
if not framework.tested_minors:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
installed = metadata.version(framework.package)
|
|
55
|
+
except metadata.PackageNotFoundError:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
match = _VERSION.match(installed)
|
|
59
|
+
tested_majors = {minor.partition(".")[0] for minor in framework.tested_minors}
|
|
60
|
+
|
|
61
|
+
if match is None or match.group(1) not in tested_majors:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
if f"{match.group(1)}.{match.group(2)}" in framework.tested_minors:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
tested = ", ".join(framework.tested_minors)
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
f"{framework.package} {installed} is installed, and the api '{api}' is tested on {framework.package} "
|
|
71
|
+
f"{tested} only, so this minor is untested; install a tested minor to serve on a tested version"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _version_problem(framework: AcceptedFramework, api: str) -> str | None:
|
|
76
|
+
try:
|
|
77
|
+
installed = metadata.version(framework.package)
|
|
78
|
+
except metadata.PackageNotFoundError:
|
|
79
|
+
return f"{framework.package} is not installed; the api '{api}' was generated for {_units(framework)}"
|
|
80
|
+
|
|
81
|
+
if framework.unit_of(installed) in framework.accepted:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
f"{framework.package} {installed} is installed, but the api '{api}' was generated for {_units(framework)}; "
|
|
86
|
+
f"install one of those, or run seampact generate again (with `allowUntested` for an untested {framework.unit})"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _units(framework: AcceptedFramework) -> str:
|
|
91
|
+
return ", ".join(f"{framework.package} {unit}" for unit in framework.accepted)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""How an async placement calls a handler or hook method (ADR 0026 "Sync and async"): an `async def` on the event
|
|
2
|
+
loop, a plain `def` through the framework's thread runner (Starlette's threadpool, Django's `sync_to_async`), then
|
|
3
|
+
what a `def` returns awaited when it is awaitable."""
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ThreadRunner(Protocol):
|
|
11
|
+
"""Runs a plain `def` off the event loop and gives back its result."""
|
|
12
|
+
|
|
13
|
+
def __call__[A, R](self, method: Callable[[A], R], argument: A, /) -> Awaitable[R]: ...
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def call_handler_in[A, T](method: Callable[[A], T | Awaitable[T]], argument: A, run_in_thread: ThreadRunner) -> T:
|
|
17
|
+
if inspect.iscoroutinefunction(method):
|
|
18
|
+
# `iscoroutinefunction` narrows `method` to a coroutine function returning Any; its declared arm is the
|
|
19
|
+
# awaitable one, so the awaited value is taken as T.
|
|
20
|
+
coroutine: Awaitable[T] = method(argument)
|
|
21
|
+
return await coroutine
|
|
22
|
+
|
|
23
|
+
result: T | Awaitable[T] = await run_in_thread(method, argument)
|
|
24
|
+
|
|
25
|
+
if isinstance(result, Awaitable):
|
|
26
|
+
# The checkers read `Awaitable[Any]` here, since T might be awaitable itself: a plain `def` returning an
|
|
27
|
+
# awaitable takes the `Awaitable[T]` arm its protocol declares, so the awaited value is taken as that T.
|
|
28
|
+
awaited: T = await result
|
|
29
|
+
return awaited
|
|
30
|
+
|
|
31
|
+
return result
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""The reply of a handler's result or of a raised contract exception (ADR 0026 "The reply path"): a declared success
|
|
2
|
+
written in its format's envelope with the boundary's own JSON encoding, less the restricted output members the caller
|
|
3
|
+
may not see, with its page parts, its cache headers and the headers its response derives (deprecation, version tag);
|
|
4
|
+
or a declared error code with its params in wire names. A result the operation does not declare is a failure of the
|
|
5
|
+
boundary, answered with the api's 500."""
|
|
6
|
+
|
|
7
|
+
import dataclasses
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Final
|
|
11
|
+
|
|
12
|
+
from seampact.codegen.runtime.access_steps import AccessHooks, AccessState, HookCaller
|
|
13
|
+
from seampact.codegen.runtime.cache_headers import with_cache_headers
|
|
14
|
+
from seampact.codegen.runtime.contract import ApiContract
|
|
15
|
+
from seampact.codegen.runtime.failures import BoundaryFailureError
|
|
16
|
+
from seampact.codegen.runtime.field_rights import visible_output
|
|
17
|
+
from seampact.codegen.runtime.ir_view import IrMap, ir_maps, ir_string
|
|
18
|
+
from seampact.codegen.runtime.page_facts import page_facts
|
|
19
|
+
from seampact.codegen.runtime.page_reply import PageParts, page_parts
|
|
20
|
+
from seampact.codegen.runtime.replies import CONTENT_TYPE, JSON_MEDIA, Reply, error_reply, json_bytes, success_body
|
|
21
|
+
from seampact.codegen.runtime.response_headers import derived_response_headers, with_entity_tag, with_success_headers
|
|
22
|
+
from seampact.codegen.runtime.run_settings import OperationTypes
|
|
23
|
+
from seampact.codegen.runtime.wire_codec import WireCodec
|
|
24
|
+
|
|
25
|
+
_ITEM_SHAPE: Final = "item"
|
|
26
|
+
_PAGE_SHAPE: Final = "page"
|
|
27
|
+
_ITEMS: Final = "items"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
31
|
+
class ReplyContext:
|
|
32
|
+
"""What a success reads of its request: the access steps' state and hooks (the output field rights ask the
|
|
33
|
+
deciders, through `call`), the query the input step read and the raw one page links keep, the request path,
|
|
34
|
+
`If-None-Match`, and the origin absolute page links start with (None under relative URLs)."""
|
|
35
|
+
|
|
36
|
+
access: AccessState
|
|
37
|
+
hooks: AccessHooks
|
|
38
|
+
call: HookCaller
|
|
39
|
+
query: Mapping[str, object]
|
|
40
|
+
raw_query: str
|
|
41
|
+
path: str
|
|
42
|
+
if_none_match: str | None
|
|
43
|
+
public_origin: str | None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def result_reply(
|
|
47
|
+
contract: ApiContract, operation_id: str, types: OperationTypes, result: object, context: ReplyContext
|
|
48
|
+
) -> Reply:
|
|
49
|
+
"""A declared error, or a success with its restricted output members left out, its page parts and its cache
|
|
50
|
+
headers (the ETag's 304 when `If-None-Match` names it)."""
|
|
51
|
+
if isinstance(result, types.errors):
|
|
52
|
+
return code_reply(contract, operation_id, getattr(result, "_code", ""), getattr(result, "_params", None))
|
|
53
|
+
|
|
54
|
+
status = next((status for status, success in types.responses if isinstance(result, success)), None)
|
|
55
|
+
|
|
56
|
+
if status is None:
|
|
57
|
+
raise BoundaryFailureError(
|
|
58
|
+
f"seampact: the handler of operation '{operation_id}' returned a {type(result).__qualname__}, which is "
|
|
59
|
+
"none of the operation's generated results; return one of them."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
response = next(
|
|
63
|
+
(
|
|
64
|
+
declared
|
|
65
|
+
for declared in ir_maps(contract.operation(operation_id), "responses")
|
|
66
|
+
if declared.get("status") == status
|
|
67
|
+
),
|
|
68
|
+
None,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if response is None:
|
|
72
|
+
return Reply(status, (), b"")
|
|
73
|
+
|
|
74
|
+
raw_parameters = context.access.raw_parameters
|
|
75
|
+
|
|
76
|
+
if not hasattr(result, "body"):
|
|
77
|
+
derived = derived_response_headers(contract, operation_id, response, None, raw_parameters)
|
|
78
|
+
|
|
79
|
+
return with_success_headers(Reply(status, (), b""), derived, status)
|
|
80
|
+
|
|
81
|
+
shape = ir_string(response, "shape") or _ITEM_SHAPE
|
|
82
|
+
media_headers = ((CONTENT_TYPE, ir_string(response, "media") or JSON_MEDIA),)
|
|
83
|
+
|
|
84
|
+
if shape == _PAGE_SHAPE:
|
|
85
|
+
derived = derived_response_headers(contract, operation_id, response, None, raw_parameters)
|
|
86
|
+
page = await _page(contract, operation_id, response, result.body, status, context)
|
|
87
|
+
reply = Reply(status, (*media_headers, *page.headers), json_bytes(page.body))
|
|
88
|
+
else:
|
|
89
|
+
payload = WireCodec(contract).body_to_wire(response, result.body)
|
|
90
|
+
derived = derived_response_headers(contract, operation_id, response, payload, raw_parameters)
|
|
91
|
+
visible = await visible_output(context.access, context.hooks, context.call, response, payload)
|
|
92
|
+
body = success_body(contract.format(operation_id), shape, visible, status, context.path)
|
|
93
|
+
reply = Reply(status, with_entity_tag(media_headers, derived), json_bytes(body))
|
|
94
|
+
|
|
95
|
+
cached = with_cache_headers(contract, operation_id, reply, context.access.principal, context.if_none_match)
|
|
96
|
+
|
|
97
|
+
return with_success_headers(cached, derived, status)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def _page(
|
|
101
|
+
contract: ApiContract, operation_id: str, response: IrMap, page: object, status: int, context: ReplyContext
|
|
102
|
+
) -> PageParts:
|
|
103
|
+
"""The page's items encoded and projected, then the body, headers and links of its paging style."""
|
|
104
|
+
if not dataclasses.is_dataclass(page) or isinstance(page, type):
|
|
105
|
+
raise BoundaryFailureError(
|
|
106
|
+
f"seampact: the handler of operation '{operation_id}' answered its page with a {type(page).__qualname__}, "
|
|
107
|
+
"not its generated page class; return the generated page."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
attributes = {field.name: getattr(page, field.name) for field in dataclasses.fields(page)}
|
|
111
|
+
facts = page_facts(operation_id, page, attributes)
|
|
112
|
+
items = WireCodec(contract).body_to_wire(response, facts.pop(_ITEMS, []))
|
|
113
|
+
visible = await visible_output(context.access, context.hooks, context.call, response, items)
|
|
114
|
+
|
|
115
|
+
if not isinstance(visible, list):
|
|
116
|
+
raise BoundaryFailureError(
|
|
117
|
+
f"seampact: the items of operation '{operation_id}' encoded to a {type(visible).__qualname__}, not a "
|
|
118
|
+
"list; return the page's items as a list."
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return page_parts(
|
|
122
|
+
contract,
|
|
123
|
+
operation_id,
|
|
124
|
+
context.query,
|
|
125
|
+
context.raw_query,
|
|
126
|
+
visible,
|
|
127
|
+
facts,
|
|
128
|
+
status,
|
|
129
|
+
context.path,
|
|
130
|
+
public_origin=context.public_origin,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def code_reply(contract: ApiContract, operation_id: str, code: str, params: object) -> Reply:
|
|
135
|
+
"""A declared code of the operation, its params model written in wire names."""
|
|
136
|
+
if not contract.declares(operation_id, code):
|
|
137
|
+
raise BoundaryFailureError(
|
|
138
|
+
f"seampact: operation '{operation_id}' answered '{code}', which it does not declare; answer one of its "
|
|
139
|
+
"declared codes."
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
params_schema = contract.catalog_entry(code).get("params")
|
|
143
|
+
wire_params: object = None
|
|
144
|
+
|
|
145
|
+
if params is not None and isinstance(params_schema, str):
|
|
146
|
+
wire_params = WireCodec(contract).to_wire({"kind": "ref", "schema": params_schema}, params)
|
|
147
|
+
|
|
148
|
+
return error_reply(contract, operation_id, code, wire_params if isinstance(wire_params, dict) else None)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""A generated frozen dataclass built from a tree keyed by Python attribute whose scalars are already converted, each
|
|
2
|
+
member by its type hint: nested dataclasses, lists and mappings walked, and an enum member's wire text turned into the
|
|
3
|
+
member. A member already of its hinted type is kept, so a tree a serializer converted builds the same."""
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import typing
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import TypeAliasType, get_args, get_origin, get_type_hints
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def built_dataclass(cls: type, data: Mapping[str, object]) -> object:
|
|
13
|
+
hints = get_type_hints(cls)
|
|
14
|
+
values = {
|
|
15
|
+
field.name: _converted(hints[field.name], data[field.name])
|
|
16
|
+
for field in dataclasses.fields(cls)
|
|
17
|
+
if field.name in data
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return cls(**values)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _converted(hint: object, value: object) -> object:
|
|
24
|
+
if value is None:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
origin = get_origin(hint)
|
|
28
|
+
|
|
29
|
+
if isinstance(origin, TypeAliasType) or isinstance(hint, TypeAliasType):
|
|
30
|
+
arguments = get_args(hint)
|
|
31
|
+
return _converted(arguments[0] if arguments else typing.Any, value)
|
|
32
|
+
|
|
33
|
+
if origin in (list, Sequence) and isinstance(value, list):
|
|
34
|
+
item = get_args(hint)[0]
|
|
35
|
+
return [_converted(item, element) for element in value]
|
|
36
|
+
|
|
37
|
+
if origin in (dict, Mapping) and isinstance(value, Mapping):
|
|
38
|
+
item = get_args(hint)[1]
|
|
39
|
+
return {key: _converted(item, element) for key, element in value.items()}
|
|
40
|
+
|
|
41
|
+
for arm in get_args(hint) if origin in (typing.Union, type(int | None)) else (hint,):
|
|
42
|
+
if isinstance(arm, type) and dataclasses.is_dataclass(arm) and isinstance(value, Mapping):
|
|
43
|
+
return built_dataclass(arm, value)
|
|
44
|
+
|
|
45
|
+
if isinstance(arm, type) and issubclass(arm, Enum) and isinstance(value, str) and not isinstance(value, arm):
|
|
46
|
+
return arm(value)
|
|
47
|
+
|
|
48
|
+
return value
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""The hook problems of the boot check (ADR 0016 "The boot check"): an identity without its authenticate hook, a scope
|
|
2
|
+
without its resolver, a right or rule without a decider. Each problem names the hook and ends with the fix the
|
|
3
|
+
placement gives, since each framework registers hooks its own way."""
|
|
4
|
+
|
|
5
|
+
from collections.abc import Collection
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from seampact.codegen.runtime.contract import ApiContract
|
|
9
|
+
from seampact.codegen.runtime.ir_view import ir_map, ir_maps, ir_string
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
13
|
+
class HookFixes:
|
|
14
|
+
"""How the app registers each kind of hook, ending a problem line."""
|
|
15
|
+
|
|
16
|
+
authenticate: str
|
|
17
|
+
decider: str
|
|
18
|
+
scope_resolver: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
22
|
+
class HookNeeds:
|
|
23
|
+
"""The hook names the api's operations call, in first-use order."""
|
|
24
|
+
|
|
25
|
+
identities: list[str]
|
|
26
|
+
scopes: list[str]
|
|
27
|
+
deciders: list[str]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def hook_needs(contract: ApiContract, operation_ids: Collection[str] | None = None) -> HookNeeds:
|
|
31
|
+
"""The hooks the given operations call, every operation of the api by default."""
|
|
32
|
+
needs = HookNeeds(identities=[], scopes=[], deciders=[])
|
|
33
|
+
|
|
34
|
+
for operation_id in contract.operation_ids() if operation_ids is None else operation_ids:
|
|
35
|
+
operation = contract.operation(operation_id)
|
|
36
|
+
_add(needs.identities, ir_string(ir_map(operation, "access"), "identity"))
|
|
37
|
+
|
|
38
|
+
for scope in ir_maps(operation, "scopes"):
|
|
39
|
+
_add(needs.scopes, ir_string(scope, "scope"))
|
|
40
|
+
|
|
41
|
+
for requirement in ir_maps(operation, "requires"):
|
|
42
|
+
_add(needs.deciders, ir_string(requirement, "right") or ir_string(requirement, "rule"))
|
|
43
|
+
|
|
44
|
+
for field_right in ir_maps(operation, "fieldRights"):
|
|
45
|
+
_add(needs.deciders, ir_string(field_right, "right"))
|
|
46
|
+
|
|
47
|
+
return needs
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def hook_problems(
|
|
51
|
+
contract: ApiContract,
|
|
52
|
+
*,
|
|
53
|
+
identities: Collection[str],
|
|
54
|
+
scopes: Collection[str],
|
|
55
|
+
deciders: Collection[str],
|
|
56
|
+
fixes: HookFixes,
|
|
57
|
+
) -> list[str]:
|
|
58
|
+
"""The problem lines of the hooks the app did not register, given the names it registered."""
|
|
59
|
+
needs = hook_needs(contract)
|
|
60
|
+
problems = [
|
|
61
|
+
f"no authenticate hook for the identity '{identity}': {fixes.authenticate}"
|
|
62
|
+
for identity in needs.identities
|
|
63
|
+
if identity not in identities
|
|
64
|
+
]
|
|
65
|
+
problems += [
|
|
66
|
+
f"no scope resolver for the scope '{scope}': {fixes.scope_resolver}"
|
|
67
|
+
for scope in needs.scopes
|
|
68
|
+
if scope not in scopes
|
|
69
|
+
]
|
|
70
|
+
problems += [f"no decider for '{name}': {fixes.decider}" for name in needs.deciders if name not in deciders]
|
|
71
|
+
|
|
72
|
+
return problems
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _add(names: list[str], name: str | None) -> None:
|
|
76
|
+
if name is not None and name not in names:
|
|
77
|
+
names.append(name)
|