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,2 @@
1
+ """The neutral code generation of the Python targets: the plan every target shares (names, layout, refusals by name,
2
+ the route table and the route helpers), the line printer, and the Pydantic emitter FastAPI and Ninja share."""
@@ -0,0 +1,38 @@
1
+ """The `artefacts.bootCheck.hooks` value, read the same on every Python target: `error` (default) or `off`; `warn`
2
+ is refused by name until a consumer needs it, any other value as invalid."""
3
+
4
+ from typing import Final
5
+
6
+ from seampact.codegen.refusals import Delivery, IrPlace, Refusals
7
+ from seampact.ir.config.json_value import JsonValue
8
+
9
+ HOOKS_ERROR: Final = "error"
10
+ HOOKS_OFF: Final = "off"
11
+ _HOOKS_WARN: Final = "warn"
12
+ _NAME: Final = "bootCheck.hooks"
13
+
14
+
15
+ def read_boot_check_hooks(value: JsonValue, at: IrPlace, refusals: Refusals) -> bool:
16
+ """Whether a missing hook fails the boot; a refused value reads as the default, `error`."""
17
+ if value == HOOKS_OFF:
18
+ return False
19
+
20
+ if value == HOOKS_ERROR:
21
+ return True
22
+
23
+ if value == _HOOKS_WARN:
24
+ refusals.waiting(
25
+ f"artefacts.{_NAME}.{_HOOKS_WARN}",
26
+ "'bootCheck.hooks: warn' (start with hooks missing)",
27
+ at,
28
+ Delivery.CONSUMER,
29
+ )
30
+ return True
31
+
32
+ refusals.add(
33
+ f"artefacts.{_NAME}.invalid",
34
+ f"The artefact '{_NAME}' is {value!r}, not '{HOOKS_ERROR}', '{HOOKS_OFF}' or '{_HOOKS_WARN}'.",
35
+ f"Set '{_NAME}' to one of the listed values, or remove it for its default.",
36
+ at,
37
+ )
38
+ return True
@@ -0,0 +1,146 @@
1
+ """The `contract` module of an api's package, the same on every Python target (ADR 0026 "Placement", ADR 0027): the
2
+ `ApiContract` the runtime reads per operation. It holds the IR of the kept operations whole (security and access,
3
+ idempotency, paging, relay, cache, the unknown-key and unknown-query policies, the body's rule proofs) and the
4
+ declarations they read, then the Python attribute of each member's wire name: per object schema, and per operation for
5
+ its path, query and header parameters and its body's rule proofs."""
6
+
7
+ from collections.abc import Mapping, Sequence
8
+ from typing import Final
9
+
10
+ from seampact.codegen.kept_forms import handler_headers
11
+ from seampact.codegen.names import ApiNames, member_identifier, part_attributes
12
+ from seampact.codegen.printer import INDENT, PythonModule, literal_lines, string_literal
13
+ from seampact.codegen.refusals import IrPlace, Refusals
14
+ from seampact.codegen.runtime.contract import HEADER_PART, PATH_PART, PROOFS_PART, QUERY_PART
15
+ from seampact.codegen.runtime.version import RUNTIME_VERSION
16
+ from seampact.ir.model import ContractIR, Operation, Param
17
+
18
+ # The IR members the runtime reads beside the operations; the others stay out of the module.
19
+ IR_VIEW_MEMBERS: Final = (
20
+ "api",
21
+ "errors",
22
+ "formats",
23
+ "identities",
24
+ "schemes",
25
+ "scopes",
26
+ "rights",
27
+ "rules",
28
+ "buckets",
29
+ "pagings",
30
+ "schemas",
31
+ "enums",
32
+ )
33
+ CONTRACT_NAME: Final = "CONTRACT"
34
+ _SCHEMAS_MODULE: Final = ".schemas"
35
+ _SCHEMAS_ALIAS: Final = "_schemas"
36
+
37
+
38
+ def contract_module(ir: ContractIR, names: ApiNames, mount: str, refusals: Refusals) -> str:
39
+ """`names` may hold more schemas than `ir` (a target's own input models): only the IR's schemas are written."""
40
+ module = PythonModule(f"The contract of api '{ir.api.id}' as its boundary reads it.")
41
+ typing = module.imported("typing", "_typing")
42
+ runtime = module.imported("seampact.codegen.runtime.contract", "_contract")
43
+ arguments: list[tuple[str, object]] = [
44
+ ("api", ir.api.id),
45
+ ("generated_by", RUNTIME_VERSION),
46
+ ("mount", mount),
47
+ ("ir_view", ir_view(ir)),
48
+ ("members", {schema_id: names.members[schema_id] for schema_id in ir.schemas if schema_id in names.members}),
49
+ ("operation_members", _operation_members(ir, refusals)),
50
+ ]
51
+ lines = [f"{CONTRACT_NAME}: {typing}.Final = {runtime}.ApiContract("]
52
+
53
+ for name, value in arguments:
54
+ lines += literal_lines(INDENT, f"{name}=", value, ",")
55
+
56
+ lines += _params_models(module, _params_classes(ir, names, refusals))
57
+ module.add_block([*lines, ")"])
58
+
59
+ return module.text()
60
+
61
+
62
+ def ir_view(ir: ContractIR) -> dict[str, object]:
63
+ """The IR members the runtime reads, as the IR's JSON writes them."""
64
+ document = ir.to_json()
65
+ view: dict[str, object] = {member: document.get(member, {}) for member in IR_VIEW_MEMBERS}
66
+ view["operations"] = document["operations"]
67
+
68
+ return view
69
+
70
+
71
+ def _params_classes(ir: ContractIR, names: ApiNames, refusals: Refusals) -> dict[str, str]:
72
+ """The generated class of each params schema a right or rule declares, by schema id."""
73
+ declared = [(f"/rights/{name}/params", right.params) for name, right in sorted(ir.rights.items())]
74
+ declared += [(f"/rules/{name}/params", rule.params) for name, rule in sorted(ir.rules.items())]
75
+ classes: dict[str, str] = {}
76
+
77
+ for pointer, schema_id in declared:
78
+ if schema_id is None:
79
+ continue
80
+
81
+ class_name = names.schema_classes.get(schema_id)
82
+
83
+ if class_name is None:
84
+ refusals.inconsistent(
85
+ "params.class.missing", f"The params '{schema_id}' have no generated class", IrPlace(pointer)
86
+ )
87
+ continue
88
+
89
+ classes[schema_id] = class_name
90
+
91
+ return dict(sorted(classes.items()))
92
+
93
+
94
+ def _params_models(module: PythonModule, classes: Mapping[str, str]) -> list[str]:
95
+ """The `params_models=` argument; none when no right or rule declares params."""
96
+ if not classes:
97
+ return []
98
+
99
+ schemas = module.imported(_SCHEMAS_MODULE, _SCHEMAS_ALIAS)
100
+ entries = [f"{INDENT * 2}{string_literal(schema_id)}: {schemas}.{name}," for schema_id, name in classes.items()]
101
+
102
+ return [f"{INDENT}params_models={{", *entries, f"{INDENT}}},"]
103
+
104
+
105
+ def _operation_members(ir: ContractIR, refusals: Refusals) -> dict[str, dict[str, Mapping[str, str]]]:
106
+ members: dict[str, dict[str, Mapping[str, str]]] = {}
107
+
108
+ for index, operation in enumerate(ir.operations):
109
+ at = IrPlace(f"/operations/{index}", operation.id)
110
+ parts = {
111
+ PATH_PART: _part(operation.params.path, at.below("params", "path"), refusals),
112
+ QUERY_PART: _part(operation.params.query, at.below("params", "query"), refusals),
113
+ HEADER_PART: _part(handler_headers(operation), at.below("params", "header"), refusals),
114
+ PROOFS_PART: _proofs(operation, at.below("input", "proofs"), refusals),
115
+ }
116
+ members[operation.id] = {part: attributes for part, attributes in parts.items() if attributes}
117
+
118
+ return members
119
+
120
+
121
+ def _part(params: Sequence[Param], at: IrPlace, refusals: Refusals) -> dict[str, str]:
122
+ return {param.name: attribute for _, param, attribute in part_attributes(params, at, refusals)}
123
+
124
+
125
+ def _proofs(operation: Operation, at: IrPlace, refusals: Refusals) -> dict[str, str]:
126
+ proofs = operation.input.proofs if operation.input is not None else None
127
+ attributes: dict[str, str] = {}
128
+
129
+ for index, proof in enumerate(proofs or ()):
130
+ attribute = member_identifier(proof.name)
131
+
132
+ if attribute is None:
133
+ refusals.inconsistent("name.segment.missing", f"The body proof '{proof.name}' has no name segment", at)
134
+ continue
135
+
136
+ if attribute in attributes.values():
137
+ refusals.add(
138
+ "name.collision",
139
+ f"Two body proofs of operation '{operation.id}' give the Python name '{attribute}'.",
140
+ "Rename one of the proofs in the contract.",
141
+ at.below(index),
142
+ )
143
+
144
+ attributes[proof.name] = attribute
145
+
146
+ return attributes
@@ -0,0 +1,99 @@
1
+ """The credentials class of each security identity, shared by the Python targets: its name, its attribute per scheme
2
+ the kept operations read, and the frozen class itself, one `str | None` per scheme, hidden from `repr`. The authenticate
3
+ hook receives an instance; the runtime builds it through `CredentialsType`."""
4
+
5
+ from collections.abc import Callable, Collection, Mapping
6
+ from dataclasses import dataclass
7
+ from typing import Final
8
+
9
+ from seampact.codegen.names import member_identifier
10
+ from seampact.codegen.printer import INDENT, string_literal
11
+ from seampact.codegen.refusals import IrPlace, Refusals
12
+ from seampact.ir.model import ContractIR, IdentityAccess, OptionalAccess
13
+ from seampact.ir.naming import IrName, NameSource, NamingRole, python_identifier, segments_of
14
+
15
+ _REDACTED: Final = "[redacted]"
16
+ _CREDENTIALS_DOC: Final = (
17
+ "The credentials a request presents for identity '{identity}', one per scheme, None when absent."
18
+ )
19
+
20
+ # How a generator claims a class name of its module: the name, what takes it, and where; it answers the name.
21
+ type ClaimName = Callable[[str, str, IrPlace], str]
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class CredentialsNames:
26
+ """The credentials class of one identity and its attribute per scheme, in scheme order."""
27
+
28
+ class_name: str
29
+ attributes: Mapping[str, str]
30
+
31
+
32
+ def identity_credentials(
33
+ ir: ContractIR, kept_operations: Collection[str], claim: ClaimName, refusals: Refusals
34
+ ) -> dict[str, CredentialsNames]:
35
+ """Per identity the kept operations read, its credentials class and the schemes those operations read it through."""
36
+ schemes_by_identity: dict[str, dict[str, IrPlace]] = {}
37
+
38
+ for index, operation in enumerate(ir.operations):
39
+ access = operation.access
40
+
41
+ if operation.id in kept_operations and isinstance(access, IdentityAccess | OptionalAccess):
42
+ at = IrPlace(f"/operations/{index}", operation.id).below("access")
43
+ schemes = schemes_by_identity.setdefault(access.identity, {})
44
+ schemes.update({scheme: at.below("schemes") for scheme in access.schemes if scheme not in schemes})
45
+
46
+ credentials: dict[str, CredentialsNames] = {}
47
+
48
+ for identity, schemes in sorted(schemes_by_identity.items()):
49
+ segments = segments_of(identity, NameSource.NAME) or [identity]
50
+ class_name = claim(
51
+ python_identifier(IrName((*segments, "credentials"), NamingRole.SCHEMA)),
52
+ f"the credentials of identity '{identity}'",
53
+ IrPlace(f"/identities/{identity}"),
54
+ )
55
+ credentials[identity] = CredentialsNames(class_name, _scheme_attributes(identity, schemes, refusals))
56
+
57
+ return credentials
58
+
59
+
60
+ def credentials_class(decorator: str, identity: str, names: CredentialsNames) -> list[str]:
61
+ """The class block, under the generator's frozen dataclass `decorator` line."""
62
+ lines = [decorator, f"class {names.class_name}:", f'{INDENT}"""{_CREDENTIALS_DOC.format(identity=identity)}"""']
63
+ attributes = list(names.attributes.values())
64
+
65
+ if not attributes:
66
+ return lines
67
+
68
+ members = [f"{INDENT}{attribute}: str | None = None" for attribute in attributes]
69
+ rendered = ", ".join(f"{attribute}={_REDACTED}" for attribute in attributes)
70
+ redacted_repr = [
71
+ f"{INDENT}def __repr__(self) -> str:",
72
+ f"{INDENT * 2}return {string_literal(f'{names.class_name}({rendered})')}",
73
+ ]
74
+
75
+ return [*lines, "", *members, "", *redacted_repr]
76
+
77
+
78
+ def _scheme_attributes(identity: str, schemes: Mapping[str, IrPlace], refusals: Refusals) -> dict[str, str]:
79
+ attributes: dict[str, str] = {}
80
+
81
+ for scheme, at in sorted(schemes.items()):
82
+ attribute = member_identifier(scheme)
83
+
84
+ if attribute is None:
85
+ refusals.inconsistent("scheme.no-segment", f"The scheme '{scheme}' yields no name segment", at)
86
+ continue
87
+
88
+ if attribute in attributes.values():
89
+ refusals.add(
90
+ "name.collision",
91
+ f"The credentials of identity '{identity}' would hold two schemes as '{attribute}'.",
92
+ "Rename one of the schemes in the contract.",
93
+ at,
94
+ )
95
+ continue
96
+
97
+ attributes[scheme] = attribute
98
+
99
+ return attributes
@@ -0,0 +1,132 @@
1
+ """The forms the generated boundary validates but does not enforce, which the handler owes: each is named with a
2
+ warning at generation, as on the Node servers (ADR 0016 "Left to the handler"), never dropped silently. An identity's
3
+ request `headers` are validated as declared headers only, so the handler reads them. An identity header the server
4
+ would write on a response (a versionToken, or a header declared `in: response`) has no value a handler can write, a
5
+ reply carrying only a body, so it is refused by name at its declaration rather than dropped."""
6
+
7
+ from dataclasses import dataclass
8
+ from typing import Final
9
+
10
+ from seampact.codegen.refusals import Delivery, IrPlace, Refusals, place_in_entry
11
+ from seampact.ir.config.problem import ConfigProblem
12
+ from seampact.ir.model import (
13
+ ContractIR,
14
+ CredentialIdentity,
15
+ HeaderDecl,
16
+ HeaderSemantic,
17
+ HeaderSide,
18
+ HeaderUseFor,
19
+ IdentityAccess,
20
+ IdentityHeaderUse,
21
+ Operation,
22
+ OptionalAccess,
23
+ SignedIdentity,
24
+ )
25
+
26
+ IDENTITY_HEADERS_HANDLER: Final = "identity.headers.handler"
27
+ IDENTITY_RESPONSE_HEADER: Final = "identity.response.header"
28
+ _IDENTITY_HEADERS_FIX: Final = "Read the identity headers the boundary validated in the handler."
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class _ResponseHeader:
33
+ header_id: str
34
+ name: str
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class _IdentityHeaders:
39
+ identity_id: str
40
+ request_side_uses: int
41
+ response_side: tuple[_ResponseHeader, ...]
42
+
43
+
44
+ def handler_warnings(ir: ContractIR, entry_id: str, api: str) -> list[ConfigProblem]:
45
+ """One warning per operation whose identity declares request headers."""
46
+ warnings: list[ConfigProblem] = []
47
+
48
+ for index, operation in enumerate(ir.operations):
49
+ headers = _identity_headers(ir, operation)
50
+
51
+ if headers is None or headers.request_side_uses == 0:
52
+ continue
53
+
54
+ identity_id = headers.identity_id
55
+
56
+ place = IrPlace(f"/operations/{index}", operation.id).below("access")
57
+ warnings.append(
58
+ ConfigProblem(
59
+ IDENTITY_HEADERS_HANDLER,
60
+ f"The headers of the identity '{identity_id}' in operation '{operation.id}' at {place.pointer} is not "
61
+ "enforced by the generated boundary.",
62
+ _IDENTITY_HEADERS_FIX,
63
+ place_in_entry(entry_id, place, api),
64
+ )
65
+ )
66
+
67
+ return warnings
68
+
69
+
70
+ def refuse_identity_response_headers(ir: ContractIR, refusals: Refusals) -> None:
71
+ """Refuses by name each identity header an operation would write on its responses, at the header's declaration."""
72
+ for operation in ir.operations:
73
+ headers = _identity_headers(ir, operation)
74
+
75
+ if headers is None:
76
+ continue
77
+
78
+ for header in headers.response_side:
79
+ refusals.waiting(
80
+ IDENTITY_RESPONSE_HEADER,
81
+ f"The response header '{header.name}' of the identity '{headers.identity_id}'",
82
+ IrPlace(f"/headers/{header.header_id}", operation.id),
83
+ Delivery.AFTER_V1,
84
+ )
85
+
86
+
87
+ def _identity_headers(ir: ContractIR, operation: Operation) -> _IdentityHeaders | None:
88
+ access = operation.access
89
+
90
+ if not isinstance(access, IdentityAccess | OptionalAccess):
91
+ return None
92
+
93
+ identity = ir.identities.get(access.identity)
94
+
95
+ if not isinstance(identity, CredentialIdentity | SignedIdentity) or not identity.headers:
96
+ return None
97
+
98
+ request_side_uses = 0
99
+ response_side: list[_ResponseHeader] = []
100
+
101
+ for use in identity.headers:
102
+ declaration = ir.headers.get(use.header)
103
+
104
+ if declaration is None or not _is_response_side(declaration):
105
+ request_side_uses += 1
106
+ continue
107
+
108
+ name = _response_header_name(declaration, use, operation)
109
+
110
+ if name is not None:
111
+ response_side.append(_ResponseHeader(use.header, name))
112
+
113
+ return _IdentityHeaders(access.identity, request_side_uses, tuple(response_side))
114
+
115
+
116
+ def _is_response_side(declaration: HeaderDecl) -> bool:
117
+ return declaration.semantic == HeaderSemantic.VERSION_TOKEN or declaration.in_ == HeaderSide.RESPONSE
118
+
119
+
120
+ def _response_header_name(declaration: HeaderDecl, use: IdentityHeaderUse, operation: Operation) -> str | None:
121
+ """A versionToken answers on every success of the identity; another semantic only where its `for` applies. An
122
+ unknown `for` is taken to apply, so nothing is dropped unnamed."""
123
+ if declaration.semantic == HeaderSemantic.VERSION_TOKEN:
124
+ return declaration.response
125
+
126
+ if use.for_ == HeaderUseFor.READS and not operation.safe:
127
+ return None
128
+
129
+ if use.for_ == HeaderUseFor.WRITES and operation.safe:
130
+ return None
131
+
132
+ return declaration.name
@@ -0,0 +1,130 @@
1
+ """The parameter parts of each operation's input (ADR 0026 "Types and validation"), as object schemas the shared
2
+ Pydantic emitter writes beside the api's own: `<Op>Path`, `<Op>Query` and `<Op>Headers` hold the parameters the
3
+ handler reads, and `<Op>Proofs` the body's rule proofs, which the handler never reads. The placement runtime validates
4
+ each part of the boundary's decoded tree with its model in strict mode, and the runtime renames members through the
5
+ contract module's per-operation tables. Their ids (`<operationId>.path`) cannot be the id of a declared schema; a
6
+ class name taken twice is refused as `name.collision`."""
7
+
8
+ import dataclasses
9
+ from collections.abc import Sequence
10
+
11
+ from seampact.codegen.kept_forms import handler_headers
12
+ from seampact.codegen.names import ApiNames
13
+ from seampact.codegen.pydantic_layout import ApiLayout
14
+ from seampact.ir.model import (
15
+ ContractIR,
16
+ Direction,
17
+ Docs,
18
+ Name,
19
+ NameRole,
20
+ ObjectSchema,
21
+ Operation,
22
+ Origin,
23
+ Param,
24
+ Property,
25
+ Schema,
26
+ TypeUse,
27
+ )
28
+
29
+ INPUT_PARTS = (("path", NameRole.PATH), ("query", NameRole.QUERY), ("headers", NameRole.HEADERS))
30
+ PROOFS_PART = "proofs"
31
+
32
+
33
+ def input_schema_id(operation_id: str, part: str) -> str:
34
+ return f"{operation_id}.{part}"
35
+
36
+
37
+ def with_input_schemas(ir: ContractIR, layout: ApiLayout) -> ContractIR:
38
+ """A copy of `ir` whose schemas also hold each operation's parameter parts."""
39
+ schemas: dict[str, Schema] = dict(ir.schemas)
40
+
41
+ for index, operation in enumerate(ir.operations):
42
+ if operation.id not in layout.operations:
43
+ continue
44
+
45
+ origin = Origin(layer="api", pointer=f"/operations/{index}")
46
+
47
+ for part, role in INPUT_PARTS:
48
+ params = _part_params(operation, part)
49
+
50
+ if params:
51
+ part_id = input_schema_id(operation.id, part)
52
+ members = [_param_member(param, origin) for param in params]
53
+ schemas[part_id] = _object(part_id, operation, role, members, origin)
54
+
55
+ proofs = _proofs_object(operation, origin)
56
+
57
+ if proofs is not None:
58
+ schemas[proofs.id] = proofs
59
+
60
+ return dataclasses.replace(ir, schemas=schemas)
61
+
62
+
63
+ def _proofs_object(operation: Operation, origin: Origin) -> ObjectSchema | None:
64
+ """`<Op>Proofs`: the body's rule proofs, which step 6 validates beside the typed body and the handler never reads.
65
+ Every proof is required and sensitive (docs/ir section 13)."""
66
+ proofs = operation.input.proofs if operation.input is not None else None
67
+
68
+ if not proofs:
69
+ return None
70
+
71
+ members = [
72
+ dataclasses.replace(_member(proof.name, proof.schema, origin, optional=False), sensitive=True)
73
+ for proof in proofs
74
+ ]
75
+ proofs_id = input_schema_id(operation.id, PROOFS_PART)
76
+ name = Name(segments=(*operation.name.segments, PROOFS_PART), role=NameRole.SCHEMA)
77
+
78
+ return dataclasses.replace(_object(proofs_id, operation, NameRole.SCHEMA, members, origin), name=name)
79
+
80
+
81
+ def _part_params(operation: Operation, part: str) -> Sequence[Param]:
82
+ if part == "path":
83
+ return operation.params.path
84
+
85
+ return operation.params.query if part == "query" else handler_headers(operation)
86
+
87
+
88
+ def _object(
89
+ schema_id: str, operation: Operation, role: NameRole, members: list[Property], origin: Origin
90
+ ) -> ObjectSchema:
91
+ return ObjectSchema(
92
+ properties=tuple(members),
93
+ id=schema_id,
94
+ name=Name(segments=tuple(operation.name.segments), role=role),
95
+ directions=(Direction.INPUT,),
96
+ examples=(),
97
+ docs=Docs(),
98
+ extensions={},
99
+ origin=origin,
100
+ )
101
+
102
+
103
+ def _param_member(param: Param, origin: Origin) -> Property:
104
+ # A parameter's default goes to the emitter, which refuses a member default by name.
105
+ return dataclasses.replace(
106
+ _member(param.name, param.type, origin, optional=not param.required), default=param.default
107
+ )
108
+
109
+
110
+ def _member(name: str, type_use: TypeUse, origin: Origin, *, optional: bool) -> Property:
111
+ return Property(
112
+ name=name,
113
+ type=type_use,
114
+ optional=optional,
115
+ nullable=False,
116
+ sensitive=False,
117
+ deprecated=False,
118
+ docs=Docs(),
119
+ extensions={},
120
+ origin=origin,
121
+ )
122
+
123
+
124
+ def proof_models(layout: ApiLayout, names: ApiNames) -> dict[str, str]:
125
+ """The `<Op>Proofs` class of each kept operation whose body carries rule proofs."""
126
+ return {
127
+ operation_id: names.schema_classes[input_schema_id(operation_id, PROOFS_PART)]
128
+ for operation_id in layout.operations
129
+ if input_schema_id(operation_id, PROOFS_PART) in names.schema_classes
130
+ }