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,232 @@
1
+ """The api's contract as the boundary reads it: the IR view of the kept operations and the declarations they read,
2
+ which the generated code holds as JSON values, the Python names of each schema's members, and the routes, built once
3
+ per process."""
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass, field
7
+ from typing import Final
8
+
9
+ from seampact.codegen.runtime.ir_view import IrJson, IrMap, ir_int, ir_map, ir_maps, ir_string
10
+ from seampact.codegen.runtime.response_header_entries import HeaderEntry, HeaderProblem, response_header_entry
11
+ from seampact.codegen.runtime.route_match import ContractRoute, RouteMatch, most_specific_first
12
+
13
+ NOT_FOUND_ROLE: Final = "notFound"
14
+ # The role a framework's HTTP error takes on a contract route, by its status; another status has no role.
15
+ ROLE_OF_STATUS: Final[Mapping[int, str]] = {
16
+ 401: "unauthenticated",
17
+ 403: "forbidden",
18
+ 404: NOT_FOUND_ROLE,
19
+ 413: "payloadTooLarge",
20
+ 429: "rateLimited",
21
+ 503: "serviceUnavailable",
22
+ }
23
+ UNDECLARED_STATUS_ROLE: Final = "undeclaredStatus"
24
+ INTERNAL_STATUS: Final = 500
25
+
26
+ PATH_PART: Final = "path"
27
+ QUERY_PART: Final = "query"
28
+ HEADER_PART: Final = "header"
29
+ PROOFS_PART: Final = "proofs"
30
+ """The parts of an operation's input whose wire names `operation_members` maps: its path, query and header
31
+ parameters (the credential and CSRF headers stay the boundary's), and the rule proofs read in its body. The header
32
+ part is spelled as the IR spells it."""
33
+ _KNOWN_PARTS: Final = (PATH_PART, QUERY_PART, HEADER_PART, PROOFS_PART)
34
+
35
+ _IGNORE_POLICY: Final = "ignore"
36
+ _REFUSE_POLICY: Final = "refuse"
37
+ _HEAD: Final = "HEAD"
38
+ _GET: Final = "GET"
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class ApiContract:
43
+ """`ir_view` is the IR's JSON subset the generator wrote (`api`, `formats`, `errors`, `schemas`, `enums`,
44
+ `operations`, and the access declarations `identities`, `schemes`, `scopes`, `rights`, `rules`, `buckets` and
45
+ `pagings`); `members` maps each schema id to its members' wire names and Python attribute names;
46
+ `operation_members` maps each operation id, then each part of its input, to the same; `params_models` maps the
47
+ params schema id of each right and rule to its generated class; `mount` is the entry's prefix of every contract
48
+ path, empty for none."""
49
+
50
+ api: str
51
+ generated_by: str
52
+ mount: str
53
+ ir_view: Mapping[str, IrJson]
54
+ members: Mapping[str, Mapping[str, str]]
55
+ operation_members: Mapping[str, Mapping[str, Mapping[str, str]]]
56
+ params_models: Mapping[str, type] = field(default_factory=dict[str, type])
57
+ _operations: dict[str, IrMap] = field(init=False, repr=False)
58
+ _routes: tuple[ContractRoute, ...] = field(init=False, repr=False)
59
+ _base_paths: tuple[str, ...] = field(init=False, repr=False)
60
+ _header_entries: dict[tuple[str, int], tuple[HeaderEntry | HeaderProblem, ...]] = field(init=False, repr=False)
61
+
62
+ def __post_init__(self) -> None:
63
+ operations: dict[str, IrMap] = {}
64
+ routes: list[ContractRoute] = []
65
+ base_paths: list[str] = []
66
+ surfaces = ir_map(ir_map(self.ir_view, "api"), "surfaces")
67
+
68
+ for operation in ir_maps(self.ir_view, "operations"):
69
+ operation_id = ir_string(operation, "id") or ""
70
+ base_path = self.mount + (
71
+ ir_string(ir_map(surfaces, ir_string(operation, "surface") or ""), "prefix") or ""
72
+ )
73
+ template = self.mount + (ir_string(operation, "path") or "")
74
+ operations[operation_id] = operation
75
+ routes.append(ContractRoute.of(operation_id, ir_string(operation, "method") or "", template, base_path))
76
+
77
+ if base_path not in base_paths:
78
+ base_paths.append(base_path)
79
+
80
+ object.__setattr__(self, "_operations", operations)
81
+ object.__setattr__(self, "_routes", tuple(most_specific_first(routes)))
82
+ object.__setattr__(self, "_base_paths", tuple(base_paths))
83
+ object.__setattr__(self, "_header_entries", self._resolved_header_entries())
84
+
85
+ def _resolved_header_entries(self) -> dict[tuple[str, int], tuple[HeaderEntry | HeaderProblem, ...]]:
86
+ """The derived header entries of each response, by operation and status, resolved once: a read's ETag looks
87
+ through every operation for its `If-Match` writes."""
88
+ operations = list(self._operations.values())
89
+ resolved: dict[tuple[str, int], tuple[HeaderEntry | HeaderProblem, ...]] = {}
90
+
91
+ for operation_id, operation in self._operations.items():
92
+ for response in ir_maps(operation, "responses"):
93
+ entries = (
94
+ response_header_entry(self.api, self.mount, operations, operation, header)
95
+ for header in ir_maps(response, "headers")
96
+ )
97
+ resolved[(operation_id, ir_int(response, "status") or 0)] = tuple(
98
+ entry for entry in entries if entry is not None
99
+ )
100
+
101
+ return resolved
102
+
103
+ def response_header_entries(self, operation_id: str, status: int) -> tuple[HeaderEntry | HeaderProblem, ...]:
104
+ """The entries the boundary writes the derived headers of the operation's `status` response from, or why it
105
+ cannot; empty for a response that derives none."""
106
+ return self._header_entries.get((operation_id, status), ())
107
+
108
+ def operation(self, operation_id: str) -> IrMap:
109
+ """Raises `LookupError` for an operation the api does not keep."""
110
+ operation = self._operations.get(operation_id)
111
+
112
+ if operation is None:
113
+ raise LookupError(f"The api '{self.api}' has no operation '{operation_id}'.")
114
+
115
+ return operation
116
+
117
+ def part_members(self, operation_id: str, part: str) -> Mapping[str, str]:
118
+ """The wire names of one part of the operation's input and their Python attributes, empty for a part it does not
119
+ have. Raises `ValueError` for a part name that is none of the known parts."""
120
+ if part not in _KNOWN_PARTS:
121
+ raise ValueError(
122
+ f"Unknown part '{part}' of an operation's input; the known parts are {', '.join(_KNOWN_PARTS)}."
123
+ )
124
+
125
+ self.operation(operation_id)
126
+
127
+ return self.operation_members.get(operation_id, {}).get(part, {})
128
+
129
+ def ignores_unknown_query(self, operation_id: str) -> bool:
130
+ """Whether the operation ignores the query keys it does not declare, rather than refusing them."""
131
+ return ir_string(ir_map(self.operation(operation_id), "unknownQuery"), "policy") == _IGNORE_POLICY
132
+
133
+ def unknown_keys_policy(self, operation_id: str) -> str:
134
+ """`refuse`, `ignore` or `keep`: what the operation's body does with a key its schema does not declare; the
135
+ names of the body's rule proofs are declared keys."""
136
+ input_use = ir_map(ir_map(self.operation(operation_id), "input"), "schema")
137
+
138
+ return ir_string(ir_map(input_use, "unknownKeys"), "policy") or _REFUSE_POLICY
139
+
140
+ def operation_ids(self) -> list[str]:
141
+ return list(self._operations)
142
+
143
+ def base_paths(self) -> tuple[str, ...]:
144
+ return self._base_paths
145
+
146
+ def match(self, method: str, raw_path: str) -> RouteMatch | None:
147
+ """The operation the raw path matches; an unserved match for a path under a base path that no route serves
148
+ with this method; None for a path under none of the api's base paths."""
149
+ if not any(_under(raw_path, base_path) for base_path in self._base_paths):
150
+ return None
151
+
152
+ route_method = _GET if method.upper() == _HEAD else method.upper()
153
+ segments = raw_path.split("/")[1:]
154
+
155
+ for route in self._routes:
156
+ parameters = route.match(segments)
157
+
158
+ if parameters is not None and route.method == route_method:
159
+ return RouteMatch(route.operation_id, parameters)
160
+
161
+ return RouteMatch(None, {})
162
+
163
+ def route_name(self, operation_id: str) -> str:
164
+ """The name the generated route declaration gives an operation."""
165
+ return f"seampact.{self.api}.{operation_id}"
166
+
167
+ def role_code(self, operation_id: str | None, role: str) -> str | None:
168
+ """The code an operation declares for `role`, else a catalogued code whose default role it is and that the
169
+ operation declares; with no operation, the first code any operation declares for the role."""
170
+ operations = list(self._operations.values()) if operation_id is None else [self.operation(operation_id)]
171
+
172
+ for operation in operations:
173
+ for error in ir_maps(operation, "errors"):
174
+ if any(ir_string(declared, "role") == role for declared in ir_maps(error, "roles")):
175
+ return ir_string(error, "code")
176
+
177
+ if operation_id is None:
178
+ return None
179
+
180
+ for code, catalogued in ir_map(self.ir_view, "errors").items():
181
+ if ir_string(catalogued, "default") == role and self.declares(operation_id, code):
182
+ return code
183
+
184
+ return None
185
+
186
+ def role_params(self, operation_id: str, code: str, role: str) -> object:
187
+ """The params template a role binds on one of the operation's codes, None when it binds none."""
188
+ for declared in ir_maps(self.error_declaration(operation_id, code), "roles"):
189
+ if ir_string(declared, "role") == role:
190
+ return declared.get("params")
191
+
192
+ return None
193
+
194
+ def error_declaration(self, operation_id: str, code: str) -> IrMap:
195
+ """The operation's entry for `code` (its roles and retry header), empty when it declares none."""
196
+ for error in ir_maps(self.operation(operation_id), "errors"):
197
+ if ir_string(error, "code") == code:
198
+ return error
199
+
200
+ return {}
201
+
202
+ def declares(self, operation_id: str, code: str) -> bool:
203
+ return bool(self.error_declaration(operation_id, code))
204
+
205
+ def catalog_entry(self, code: str) -> IrMap:
206
+ return ir_map(ir_map(self.ir_view, "errors"), code)
207
+
208
+ def status_of(self, code: str) -> int:
209
+ return ir_int(ir_map(self.catalog_entry(code), "http"), "status") or INTERNAL_STATUS
210
+
211
+ def declared_codes(self, operation_id: str, status: int) -> list[str]:
212
+ """The operation's codes answered with `status`."""
213
+ codes = [ir_string(error, "code") for error in ir_maps(self.operation(operation_id), "errors")]
214
+
215
+ return [code for code in codes if code is not None and self.status_of(code) == status]
216
+
217
+ def format(self, operation_id: str | None) -> IrMap:
218
+ """The wire format an operation answers in; the api's first format when no operation matched."""
219
+ formats = ir_map(self.ir_view, "formats")
220
+ format_id = None if operation_id is None else ir_string(self.operation(operation_id), "format")
221
+
222
+ if format_id is None:
223
+ format_id = next(iter(formats), "")
224
+
225
+ return ir_map(formats, format_id)
226
+
227
+ def declaration(self, kind: str, declaration_id: str) -> IrMap:
228
+ return ir_map(ir_map(self.ir_view, kind), declaration_id)
229
+
230
+
231
+ def _under(raw_path: str, base_path: str) -> bool:
232
+ return base_path == "" or raw_path == base_path or raw_path.startswith(base_path + "/")
@@ -0,0 +1,20 @@
1
+ """A CORS preflight: an `OPTIONS` request with `Origin` and `Access-Control-Request-Method`, which no contract
2
+ operation declares. A boundary leaves it to the app's CORS layer, as the PHP boundaries do, rather than answering the
3
+ path's 404 for a method the path does not serve."""
4
+
5
+ from collections.abc import Iterable
6
+ from typing import Final
7
+
8
+ _PREFLIGHT_METHOD: Final = "OPTIONS"
9
+ _ORIGIN: Final = "origin"
10
+ _REQUESTED_METHOD: Final = "access-control-request-method"
11
+
12
+
13
+ def is_cors_preflight(method: str, header_names: Iterable[str]) -> bool:
14
+ """`header_names` in any case; the method as sent, since HTTP methods are case-sensitive."""
15
+ if method != _PREFLIGHT_METHOD:
16
+ return False
17
+
18
+ names = {name.lower() for name in header_names}
19
+
20
+ return _ORIGIN in names and _REQUESTED_METHOD in names
@@ -0,0 +1,72 @@
1
+ """The request deadline (grammar section 9, a header of semantic `deadline`): an RFC 3339 instant the caller sends in
2
+ the operation's `deadlineHeader`. The boundary refuses a malformed one with `schemaInvalid` and a passed one with the
3
+ `deadlineExceeded` role's code; the generated request's `deadline` gives the handler the time left. An operation's
4
+ `budgetMs` alone is the client's default timeout: the server receives nothing of it beside this header."""
5
+
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+ from typing import Final
9
+
10
+ from seampact.codegen.runtime.contract import ApiContract
11
+ from seampact.codegen.runtime.failures import ContractRefusal
12
+ from seampact.codegen.runtime.input_decoding import DecodedInput, Issue, role_refusal, schema_invalid
13
+ from seampact.codegen.runtime.ir_view import ir_string
14
+ from seampact.codegen.runtime.value_rules import ValueRuleError, date_time_from_text
15
+
16
+ DEADLINE_EXCEEDED_ROLE: Final = "deadlineExceeded"
17
+
18
+ _HEADER_PART: Final = "header"
19
+ _FORMAT_RULE: Final = "format"
20
+ _MILLISECONDS_PER_SECOND: Final = 1000
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class RequestDeadline:
25
+ """The instant the caller stops waiting, aware of its offset."""
26
+
27
+ at: datetime
28
+
29
+ def remaining_ms(self, now: datetime) -> int:
30
+ """The whole milliseconds left at `now`, 0 once the deadline has passed."""
31
+ return max(int((self.at - now).total_seconds() * _MILLISECONDS_PER_SECOND), 0)
32
+
33
+ def has_passed(self, now: datetime) -> bool:
34
+ return now >= self.at
35
+
36
+
37
+ def request_deadline(
38
+ contract: ApiContract, operation_id: str, decoded: DecodedInput, now: datetime
39
+ ) -> RequestDeadline | None:
40
+ """The deadline the request carries, None when the operation reads none or the caller sent none (a required
41
+ header was refused by the input step).
42
+
43
+ Raises `ContractRefusal` for a malformed deadline, or one passed at `now`."""
44
+ header = ir_string(contract.operation(operation_id), "deadlineHeader")
45
+ sent = None if header is None else decoded.headers.get(header)
46
+
47
+ if header is None or sent is None:
48
+ return None
49
+
50
+ try:
51
+ deadline = RequestDeadline(date_time_from_text(str(sent)))
52
+ except ValueRuleError:
53
+ raise schema_invalid(
54
+ contract, operation_id, [Issue(_HEADER_PART, f"/{header.lower()}", _FORMAT_RULE)]
55
+ ) from None
56
+
57
+ if deadline.has_passed(now):
58
+ raise deadline_exceeded(contract, operation_id)
59
+
60
+ return deadline
61
+
62
+
63
+ def sent_deadline(text: str | None) -> RequestDeadline | None:
64
+ """The deadline a header's text carries, None when the caller sent none.
65
+
66
+ Raises `ValueRuleError` for a text that is not an RFC 3339 instant with its offset."""
67
+ return None if text is None else RequestDeadline(date_time_from_text(text))
68
+
69
+
70
+ def deadline_exceeded(contract: ApiContract, operation_id: str) -> ContractRefusal:
71
+ """The refusal a handler raises when the deadline passes while it works."""
72
+ return role_refusal(contract, operation_id, DEADLINE_EXCEEDED_ROLE)
@@ -0,0 +1,98 @@
1
+ """The SHA-256 digests of the boundary (the throttle bucket key, and later the idempotency key and the strong ETag),
2
+ over the text `JSON.stringify` writes with object members sorted by name, so every target derives the same key from
3
+ the same values (ADR 0023 "Query and bounded values")."""
4
+
5
+ import hashlib
6
+ import json
7
+ import math
8
+ import re
9
+ from collections.abc import Mapping, Sequence
10
+ from typing import Final
11
+
12
+ # `JSON.stringify` writes an exponent from 1e21 up and below 1e-6.
13
+ _PLAIN_UPPER_EXPONENT: Final = 21
14
+ _PLAIN_LOWER_EXPONENT: Final = -6
15
+ # 2^32 - 1, the first integer that is not an array index in JavaScript.
16
+ _NOT_AN_INDEX: Final = 4294967295
17
+ _INDEX_NAME: Final = re.compile(r"(?:0|[1-9][0-9]{0,9})")
18
+ _DECIMAL_TEXT: Final = re.compile(r"(-?)([0-9]+)(?:\.([0-9]+))?(?:[eE]([+-]?[0-9]+))?")
19
+ _UTF16: Final = "utf-16-be"
20
+
21
+
22
+ def hex_of(parts: Sequence[object]) -> str:
23
+ """The SHA-256 of the canonical JSON of `parts`, as lowercase hex."""
24
+ return hashlib.sha256(canonical(list(parts)).encode("utf-8", errors="surrogatepass")).hexdigest()
25
+
26
+
27
+ def canonical(value: object) -> str:
28
+ """`value` as `JSON.stringify` writes it, object members sorted by name. Raises `TypeError` for a value JSON
29
+ cannot hold."""
30
+ if value is None:
31
+ return "null"
32
+
33
+ if isinstance(value, bool):
34
+ return "true" if value else "false"
35
+
36
+ if isinstance(value, int):
37
+ return str(value)
38
+
39
+ if isinstance(value, float):
40
+ return _number(value)
41
+
42
+ if isinstance(value, str):
43
+ return json.dumps(value, ensure_ascii=False)
44
+
45
+ if isinstance(value, Mapping):
46
+ names = sorted((str(name) for name in value), key=_name_order)
47
+ members = {str(name): member for name, member in value.items()}
48
+
49
+ return (
50
+ "{" + ",".join(f"{json.dumps(name, ensure_ascii=False)}:{canonical(members[name])}" for name in names) + "}"
51
+ )
52
+
53
+ if isinstance(value, list | tuple):
54
+ return "[" + ",".join(canonical(item) for item in value) + "]"
55
+
56
+ raise TypeError(f"seampact: a digest cannot read a {type(value).__qualname__}.")
57
+
58
+
59
+ def _name_order(name: str) -> tuple[int, bytes]:
60
+ """Array-index names first in numeric order, as every JavaScript object orders its own keys, then the others by
61
+ UTF-16 code units, as JavaScript's default sort compares them."""
62
+ index = min(int(name), _NOT_AN_INDEX) if _INDEX_NAME.fullmatch(name) else _NOT_AN_INDEX
63
+
64
+ return index, name.encode(_UTF16, errors="surrogatepass")
65
+
66
+
67
+ def _number(value: float) -> str:
68
+ """A number as JavaScript prints its double: the shortest digits, plain between 1e-6 and 1e21, an exponent
69
+ outside."""
70
+ if not math.isfinite(value):
71
+ return "null"
72
+
73
+ if value == 0:
74
+ return "0"
75
+
76
+ parts = _DECIMAL_TEXT.fullmatch(repr(value))
77
+
78
+ if parts is None:
79
+ raise ValueError(f"seampact: the number {value!r} has no decimal form.")
80
+
81
+ sign, integral, fraction, exponent_text = parts.group(1), parts.group(2), parts.group(3) or "", parts.group(4)
82
+ digits = (integral + fraction).lstrip("0")
83
+ leading_zeros = len(integral + fraction) - len(digits)
84
+ exponent = int(exponent_text or "0") + len(integral) - 1 - leading_zeros
85
+ digits = digits.rstrip("0")
86
+
87
+ if exponent >= _PLAIN_UPPER_EXPONENT or exponent < _PLAIN_LOWER_EXPONENT:
88
+ mantissa = digits if len(digits) == 1 else f"{digits[0]}.{digits[1:]}"
89
+
90
+ return f"{sign}{mantissa}e{'-' if exponent < 0 else '+'}{abs(exponent)}"
91
+
92
+ if exponent < 0:
93
+ return f"{sign}0.{'0' * (-exponent - 1)}{digits}"
94
+
95
+ whole = digits[: exponent + 1].ljust(exponent + 1, "0")
96
+ decimals = digits[exponent + 1 :]
97
+
98
+ return f"{sign}{whole}{'.' + decimals if decimals else ''}"
@@ -0,0 +1,33 @@
1
+ """What stops a contract request before its handler's reply: a refusal answered with one of the api's codes, and a
2
+ failure of the boundary itself, answered with the api's 500; and how a log line names a failure."""
3
+
4
+ import traceback
5
+
6
+ from seampact.codegen.runtime.ir_view import IrMap
7
+
8
+
9
+ class ContractRefusal(Exception): # noqa: N818 - a refusal is an answer, not an error of the app
10
+ """A request the boundary answers with `code` of `api`, its params rendered from the code's role binding."""
11
+
12
+ def __init__(self, api: str, code: str, params: IrMap | None = None) -> None:
13
+ super().__init__(f"seampact: the '{api}' boundary refuses the request with '{code}'.")
14
+ self.api = api
15
+ self.code = code
16
+ self.params = params
17
+
18
+
19
+ class BoundaryFailureError(RuntimeError):
20
+ """A fault of the generated code or the handler that the boundary answers with the api's 500, logged with the
21
+ cause."""
22
+
23
+
24
+ def failure_place(failure: BaseException) -> str:
25
+ """The exception's class and where it was raised; the boundary's own failures name no input and keep their
26
+ message. Any other message may carry an input value, so it is left to the caller's debug mode."""
27
+ if isinstance(failure, BoundaryFailureError):
28
+ return str(failure)
29
+
30
+ frames = traceback.extract_tb(failure.__traceback__)
31
+ where = f"{frames[-1].filename}:{frames[-1].lineno}" if frames else "an unknown place"
32
+
33
+ return f"{type(failure).__qualname__} raised at {where}"
@@ -0,0 +1,193 @@
1
+ """Field rights (ADR 0012 "Field rights"): step 7 refuses a restricted top-level input member sent without its right,
2
+ or leaves it out under `inputPolicy: strip`; before a success is written, each restricted output member whose right
3
+ the decider denies for its holder is left out, one decision per right, use-site params, pointer and subject, and a
4
+ member the contract never sends on the operation (`always: absent`) is left out without a decision. Each returns a
5
+ copy; the tree it reads is never changed."""
6
+
7
+ import copy
8
+ import json
9
+ from collections.abc import Mapping
10
+ from typing import Final
11
+
12
+ from seampact.codegen.runtime.access_steps import (
13
+ AccessHooks,
14
+ AccessState,
15
+ HookCaller,
16
+ decided,
17
+ extensions_of,
18
+ refusal_of,
19
+ subject_of,
20
+ subject_type,
21
+ )
22
+ from seampact.codegen.runtime.failures import ContractRefusal
23
+ from seampact.codegen.runtime.ir_view import IrMap, ir_map, ir_maps, ir_string
24
+ from seampact.codegen.runtime.replies import role_params
25
+ from seampact.codegen.runtime.requirement_params import requirement_params
26
+ from seampact.hooks import FieldDirection, RequirementKind, RequirementRequest, SubjectRef, decision, purpose
27
+
28
+ _INPUT: Final = "input"
29
+ _OUTPUT: Final = "output"
30
+ _STRIP: Final = "strip"
31
+ _ABSENT: Final = "absent"
32
+ _FORBIDDEN_ROLE: Final = "forbidden"
33
+ _HOLDER_FIELD: Final = "holderField"
34
+ _WILDCARD: Final = "*"
35
+ _TABLE_SHAPE: Final = "table"
36
+ _ITEMS_SHAPES: Final = frozenset({"list", "page"})
37
+
38
+
39
+ async def checked_input_fields(state: AccessState, hooks: AccessHooks, call: HookCaller) -> object:
40
+ """The decoded body with each restricted member the decider denies left out under `strip`.
41
+
42
+ Raises `ContractRefusal` with the denial's code, its params joined by the `forbidden` role's `pointer`, for a
43
+ denied member under `reject`."""
44
+ if not isinstance(state.body, Mapping):
45
+ return state.body
46
+
47
+ body = copy.deepcopy(dict(state.body))
48
+
49
+ for field_right in ir_maps(state.contract.operation(state.operation_id), "fieldRights"):
50
+ if ir_string(field_right, "direction") != _INPUT:
51
+ continue
52
+
53
+ pointer = ir_string(field_right, "pointer") or ""
54
+ member = "/".join(_pointer_segments(pointer))
55
+
56
+ if member not in body:
57
+ continue
58
+
59
+ right = ir_string(field_right, "right") or ""
60
+ subject = subject_of(state, ir_map(field_right, "subject"), right)
61
+ answer = await decided(hooks, call, _request(state, field_right, subject, FieldDirection.INPUT))
62
+
63
+ if isinstance(answer, decision.Allowed):
64
+ continue
65
+
66
+ if ir_string(field_right, "inputPolicy") == _STRIP:
67
+ del body[member]
68
+ continue
69
+
70
+ raise _forbidden_field(state, answer, pointer)
71
+
72
+ return body
73
+
74
+
75
+ async def visible_output(
76
+ state: AccessState, hooks: AccessHooks, call: HookCaller, response: IrMap, wire_body: object
77
+ ) -> object:
78
+ """The success body as the wire writes it, without each restricted member its holder may not see."""
79
+ body = copy.deepcopy(wire_body)
80
+ decisions: dict[tuple[str, str, str, str], bool] = {}
81
+ body_schema = ir_string(ir_map(response, "body"), "schema")
82
+
83
+ for field_right in ir_maps(state.contract.operation(state.operation_id), "fieldRights"):
84
+ if ir_string(field_right, "direction") != _OUTPUT or ir_string(field_right, "schema") != body_schema:
85
+ continue
86
+
87
+ segments = _pointer_segments(ir_string(field_right, "pointer") or "")
88
+ member = segments.pop()
89
+
90
+ for instance in _instances(body, ir_string(response, "shape")):
91
+ for holder in _holders(instance, segments):
92
+ if member in holder and not await _allowed(state, hooks, call, field_right, holder, decisions):
93
+ del holder[member]
94
+
95
+ return body
96
+
97
+
98
+ async def _allowed(
99
+ state: AccessState,
100
+ hooks: AccessHooks,
101
+ call: HookCaller,
102
+ field_right: IrMap,
103
+ holder: Mapping[str, object],
104
+ decisions: dict[tuple[str, str, str, str], bool],
105
+ ) -> bool:
106
+ if ir_string(field_right, "always") == _ABSENT:
107
+ return False
108
+
109
+ right = ir_string(field_right, "right") or ""
110
+ source = ir_map(ir_map(field_right, "subject"), "source")
111
+ subject = None
112
+
113
+ if ir_string(source, "in") == _HOLDER_FIELD:
114
+ holder_id = holder.get(ir_string(source, "name") or "")
115
+
116
+ # A holder without a usable id names no subject: the decider is asked with none.
117
+ if isinstance(holder_id, str | int) and not isinstance(holder_id, bool):
118
+ subject = SubjectRef(type=subject_type(state.contract, right), id=holder_id)
119
+ elif source:
120
+ subject = subject_of(state, ir_map(field_right, "subject"), right)
121
+
122
+ # The decider sees the field's params and pointer, so both join the key.
123
+ use_site_params = json.dumps(field_right.get("params"), sort_keys=True)
124
+ pointer = ir_string(field_right, "pointer") or ""
125
+ key = (right, use_site_params, pointer, "" if subject is None else repr(subject.id))
126
+
127
+ if key not in decisions:
128
+ answer = await decided(hooks, call, _request(state, field_right, subject, FieldDirection.OUTPUT))
129
+ decisions[key] = isinstance(answer, decision.Allowed)
130
+
131
+ return decisions[key]
132
+
133
+
134
+ def _pointer_segments(pointer: str) -> list[str]:
135
+ """The member names a JSON pointer walks, `~1` and `~0` unescaped."""
136
+ return [segment.replace("~1", "/").replace("~0", "~") for segment in pointer[1:].split("/")]
137
+
138
+
139
+ def _instances(body: object, shape: str | None) -> list[object]:
140
+ """A table's values and a list's or page's items are each an instance of the body's schema."""
141
+ if shape == _TABLE_SHAPE:
142
+ return list(body.values()) if isinstance(body, dict) else []
143
+
144
+ if shape in _ITEMS_SHAPES:
145
+ return list(body) if isinstance(body, list) else []
146
+
147
+ return [body]
148
+
149
+
150
+ def _holders(instance: object, segments: list[str]) -> list[dict[str, object]]:
151
+ """The objects a pointer's parent segments reach, `*` standing for every item of an array."""
152
+ current = [instance]
153
+
154
+ for segment in segments:
155
+ reached: list[object] = []
156
+
157
+ for node in current:
158
+ if segment == _WILDCARD and isinstance(node, list):
159
+ reached.extend(node)
160
+ elif isinstance(node, dict) and segment in node:
161
+ reached.append(node[segment])
162
+
163
+ current = reached
164
+
165
+ return [node for node in current if isinstance(node, dict)]
166
+
167
+
168
+ def _request(
169
+ state: AccessState, field_right: IrMap, subject: SubjectRef | None, direction: FieldDirection
170
+ ) -> RequirementRequest:
171
+ name = ir_string(field_right, "right") or ""
172
+ operation = state.contract.operation(state.operation_id)
173
+
174
+ return RequirementRequest(
175
+ kind=RequirementKind.RIGHT,
176
+ name=name,
177
+ params=requirement_params(state.contract, RequirementKind.RIGHT, name, field_right),
178
+ principal=state.principal,
179
+ subject=subject,
180
+ scopes=tuple(state.scopes),
181
+ purpose=purpose.Field(pointer=ir_string(field_right, "pointer") or "", direction=direction),
182
+ operation=state.operation,
183
+ request=state.request,
184
+ extensions=RequirementRequest.Extensions(requirement={}, operation=extensions_of(operation)),
185
+ )
186
+
187
+
188
+ def _forbidden_field(state: AccessState, denied: decision.Denied, pointer: str) -> ContractRefusal:
189
+ refusal = refusal_of(state, denied.deny)
190
+ rendered = role_params(state.contract, state.operation_id, refusal.code, _FORBIDDEN_ROLE, {"pointer": pointer})
191
+ params = {**(refusal.params or {}), **(rendered or {})}
192
+
193
+ return ContractRefusal(state.contract.api, refusal.code, params)