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.
Files changed (76) hide show
  1. seampact/codegen/__init__.py +2 -0
  2. seampact/codegen/boot_check_hooks.py +38 -0
  3. seampact/codegen/contract_module.py +146 -0
  4. seampact/codegen/credentials_classes.py +99 -0
  5. seampact/codegen/handler_forms.py +132 -0
  6. seampact/codegen/input_schemas.py +130 -0
  7. seampact/codegen/kept_forms.py +522 -0
  8. seampact/codegen/matrix_row.py +23 -0
  9. seampact/codegen/names.py +146 -0
  10. seampact/codegen/native_docs_file.py +169 -0
  11. seampact/codegen/operation_types.py +425 -0
  12. seampact/codegen/placement_lines.py +79 -0
  13. seampact/codegen/printer.py +196 -0
  14. seampact/codegen/py.typed +0 -0
  15. seampact/codegen/pydantic_forms.py +53 -0
  16. seampact/codegen/pydantic_layout.py +178 -0
  17. seampact/codegen/pydantic_models.py +405 -0
  18. seampact/codegen/redacted_members.py +47 -0
  19. seampact/codegen/refusals.py +84 -0
  20. seampact/codegen/request_deadline.py +68 -0
  21. seampact/codegen/response_header_forms.py +73 -0
  22. seampact/codegen/restricted_sort_keys.py +18 -0
  23. seampact/codegen/route_helpers.py +109 -0
  24. seampact/codegen/routes.py +139 -0
  25. seampact/codegen/runtime/__init__.py +7 -0
  26. seampact/codegen/runtime/access_kinds.py +24 -0
  27. seampact/codegen/runtime/access_steps.py +520 -0
  28. seampact/codegen/runtime/body_reader.py +64 -0
  29. seampact/codegen/runtime/cache_headers.py +89 -0
  30. seampact/codegen/runtime/contract.py +232 -0
  31. seampact/codegen/runtime/cors_preflight.py +20 -0
  32. seampact/codegen/runtime/deadline.py +72 -0
  33. seampact/codegen/runtime/digests.py +98 -0
  34. seampact/codegen/runtime/failures.py +33 -0
  35. seampact/codegen/runtime/field_rights.py +193 -0
  36. seampact/codegen/runtime/framework_routes.py +36 -0
  37. seampact/codegen/runtime/framework_versions.py +91 -0
  38. seampact/codegen/runtime/handler_call.py +31 -0
  39. seampact/codegen/runtime/handler_reply.py +148 -0
  40. seampact/codegen/runtime/hinted_values.py +48 -0
  41. seampact/codegen/runtime/hook_check.py +77 -0
  42. seampact/codegen/runtime/idempotency.py +279 -0
  43. seampact/codegen/runtime/input_decoding.py +226 -0
  44. seampact/codegen/runtime/ir_view.py +47 -0
  45. seampact/codegen/runtime/native_docs.py +130 -0
  46. seampact/codegen/runtime/page_facts.py +33 -0
  47. seampact/codegen/runtime/page_reply.py +396 -0
  48. seampact/codegen/runtime/pattern.py +69 -0
  49. seampact/codegen/runtime/principal_members.py +41 -0
  50. seampact/codegen/runtime/public_origin.py +102 -0
  51. seampact/codegen/runtime/pydantic/__init__.py +165 -0
  52. seampact/codegen/runtime/pydantic/checked_input.py +52 -0
  53. seampact/codegen/runtime/pydantic/contract_input.py +183 -0
  54. seampact/codegen/runtime/pydantic/issues.py +142 -0
  55. seampact/codegen/runtime/raw_target.py +103 -0
  56. seampact/codegen/runtime/replies.py +144 -0
  57. seampact/codegen/runtime/reply_verdict.py +215 -0
  58. seampact/codegen/runtime/requirement_params.py +170 -0
  59. seampact/codegen/runtime/response_header_entries.py +226 -0
  60. seampact/codegen/runtime/response_headers.py +114 -0
  61. seampact/codegen/runtime/route_match.py +88 -0
  62. seampact/codegen/runtime/run_settings.py +35 -0
  63. seampact/codegen/runtime/signature_header.py +92 -0
  64. seampact/codegen/runtime/sync_driver.py +35 -0
  65. seampact/codegen/runtime/templates.py +132 -0
  66. seampact/codegen/runtime/uuid_text.py +7 -0
  67. seampact/codegen/runtime/value_rules.py +218 -0
  68. seampact/codegen/runtime/version.py +59 -0
  69. seampact/codegen/runtime/version_token.py +108 -0
  70. seampact/codegen/runtime/wire_codec.py +229 -0
  71. seampact/codegen/runtime/wire_values.py +260 -0
  72. seampact/codegen/source_lines.py +35 -0
  73. seampact_codegen-1.0.0.dist-info/METADATA +18 -0
  74. seampact_codegen-1.0.0.dist-info/RECORD +76 -0
  75. seampact_codegen-1.0.0.dist-info/WHEEL +4 -0
  76. 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)