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,522 @@
1
+ """The contract forms no Python target serves, read from the operations the entry keeps and refused by name for every
2
+ target alike (ADR 0026 "Refused by name or waiting"): the IR forms absent from the reference contract wait for a
3
+ consumer after 1.0, as on the PHP targets. The forms of the reference contract a target brings in a later release,
4
+ and the forms its own runtime cannot serve, stay with the target; the shared Pydantic emitter refuses the schema forms
5
+ it does not write (unions, maps, member defaults, unknown formats)."""
6
+
7
+ import re
8
+ from collections.abc import Sequence
9
+ from typing import Final
10
+
11
+ from seampact.codegen.handler_forms import refuse_identity_response_headers
12
+ from seampact.codegen.refusals import Delivery, IrPlace, Refusals
13
+ from seampact.codegen.response_header_forms import refuse_response_header_forms
14
+ from seampact.ir.hydration import OMITTED, JsonValue
15
+ from seampact.ir.model import (
16
+ AccessUnknown,
17
+ ArrayTypeUse,
18
+ BasicScheme,
19
+ ContractIR,
20
+ CredentialIdentity,
21
+ Direction,
22
+ EnumTypeUse,
23
+ FieldRight,
24
+ IdentityAccess,
25
+ ObjectSchema,
26
+ Operation,
27
+ OptionalAccess,
28
+ PagingPosition,
29
+ Param,
30
+ ProofPlacement,
31
+ QueryScheme,
32
+ QueryStyle,
33
+ QueryWireKind,
34
+ RefTypeUse,
35
+ RequirementUnknown,
36
+ Response,
37
+ ResponseShape,
38
+ RuleRequirement,
39
+ ScalarTypeUse,
40
+ SchemaPrincipal,
41
+ SignatureScheme,
42
+ SignedIdentity,
43
+ TemplateSignature,
44
+ TypeUse,
45
+ UnknownKeysPolicy,
46
+ UnknownQueryUnknown,
47
+ ValueSchemeKey,
48
+ ValueSourceIn,
49
+ )
50
+
51
+ _JSON_MEDIA: Final = "application/json"
52
+ _PAGING_PARAMS_DERIVATION: Final = "paging.params"
53
+ _QUERY_PART: Final = "query"
54
+ _PAYLOAD_SLOT: Final = "payload"
55
+ # The slots a success envelope may write beside the payload, which the boundary fills.
56
+ _ENVELOPE_SLOTS: Final = frozenset({_PAYLOAD_SLOT, "status", "path", "meta"})
57
+ # The slots a page template may write that the page reply fills: the response roles, the request path and status, and
58
+ # the format's `$meta` (ADR 0002 section 14). `$resource`, `$collection` and the item id slots are not yet.
59
+ _PAGE_SLOT: Final = "page"
60
+ _RENDERED_PAGE_SLOTS: Final = frozenset(
61
+ {
62
+ "items", "nextCursor", "previousCursor", "more", _PAGE_SLOT, "offset", "limit", "from", "to", "total",
63
+ "totalPages", "self", "first", "previous", "next", "last", "nextSince", "complete", "path", "status", "meta",
64
+ }
65
+ ) # fmt: skip
66
+ # The slot `$each` binds to each item inside its `$as` (ADR 0002 section 13): the renderer fills it.
67
+ _EACH_ITEM_SLOT: Final = "item"
68
+ _TOP_LEVEL_POINTER: Final = re.compile(r"/[^/]+")
69
+ _BUCKET_KEY_SOURCES: Final = frozenset({ValueSourceIn.PATH, ValueSourceIn.SCOPE, ValueSourceIn.PRINCIPAL})
70
+ # Header params the boundary consumes itself: the credentials and the CSRF token never reach the handler.
71
+ _CREDENTIAL_DERIVATION: Final = "identity.authenticate"
72
+ _CSRF_DERIVATION_PREFIX: Final = "csrf."
73
+
74
+
75
+ def refuse_kept_forms(ir: ContractIR, refusals: Refusals) -> None:
76
+ """Refuses by name each form of the kept operations that no Python target serves."""
77
+ _KeptForms(ir, refusals).read()
78
+ refuse_response_header_forms(ir, refusals)
79
+ refuse_identity_response_headers(ir, refusals)
80
+
81
+
82
+ def handler_headers(operation: Operation) -> list[Param]:
83
+ """The header parameters that reach the handler: the credential and CSRF headers stay the boundary's."""
84
+ return [param for param in operation.params.header if _reaches_the_handler(param)]
85
+
86
+
87
+ def _reaches_the_handler(param: Param) -> bool:
88
+ if param.derived_from is None:
89
+ return True
90
+
91
+ derivation = param.derived_from.derivation
92
+
93
+ return derivation != _CREDENTIAL_DERIVATION and not derivation.startswith(_CSRF_DERIVATION_PREFIX)
94
+
95
+
96
+ class _KeptForms:
97
+ def __init__(self, ir: ContractIR, refusals: Refusals) -> None:
98
+ self.ir = ir
99
+ self.refusals = refusals
100
+ self._read: set[str] = set()
101
+
102
+ def read(self) -> None:
103
+ if self.ir.extension_keys:
104
+ self._after_v1("extensionKeys", "Typed extension values", IrPlace("/extensionKeys"))
105
+
106
+ for index, operation in enumerate(self.ir.operations):
107
+ self._operation(operation, IrPlace(f"/operations/{index}", operation.id))
108
+
109
+ def _after_v1(self, form: str, description: str, at: IrPlace) -> None:
110
+ self.refusals.waiting(form, description, at, Delivery.AFTER_V1)
111
+
112
+ def _first_time(self, key: str) -> bool:
113
+ if key in self._read:
114
+ return False
115
+
116
+ self._read.add(key)
117
+
118
+ return True
119
+
120
+ def _operation(self, operation: Operation, at: IrPlace) -> None:
121
+ if isinstance(operation.unknown_query, UnknownQueryUnknown):
122
+ self.refusals.unknown_value(operation.unknown_query.policy, at.below("unknownQuery", "policy"))
123
+
124
+ self._access(operation, at)
125
+ self._format(operation)
126
+ self._page_templates(operation)
127
+ self._requirements(operation, at)
128
+ self._members_after_v1(operation, at)
129
+ parts = {"path": operation.params.path, "query": operation.params.query, "header": handler_headers(operation)}
130
+
131
+ for part, params in parts.items():
132
+ for position, param in enumerate(params):
133
+ self._param(part, param, at.below("params", part, position))
134
+
135
+ if operation.input is not None:
136
+ input_at = at.below("input")
137
+
138
+ if operation.input.fields:
139
+ self._after_v1("input.fields", "Body fields beside the input schema", input_at.below("fields"))
140
+
141
+ self._type(operation.input.schema, input_at.below("schema"))
142
+
143
+ for position, response in enumerate(operation.responses):
144
+ self._response(response, at.below("responses", position))
145
+
146
+ for position, error in enumerate(operation.errors):
147
+ self._error(error.code, at.below("errors", position))
148
+
149
+ def _access(self, operation: Operation, at: IrPlace) -> None:
150
+ access = operation.access
151
+
152
+ if isinstance(access, AccessUnknown):
153
+ self.refusals.unknown_value(access.kind, at.below("access", "kind"))
154
+ elif isinstance(access, IdentityAccess | OptionalAccess):
155
+ self._identity(access.identity, at.below("access", "identity"))
156
+ self._credential_schemes(access.schemes, at.below("access"))
157
+
158
+ def _credential_schemes(self, scheme_ids: Sequence[str], at: IrPlace) -> None:
159
+ """The query and HTTP Basic schemes no Python boundary reads a credential from, refused as on Node servers."""
160
+ for scheme_id in scheme_ids:
161
+ scheme = self.ir.schemes.get(scheme_id)
162
+
163
+ if isinstance(scheme, QueryScheme | BasicScheme):
164
+ self.refusals.waiting(
165
+ f"scheme.{scheme.kind}", f"A '{scheme.kind}' credential scheme", at, Delivery.CONSUMER
166
+ )
167
+
168
+ def _identity(self, identity_id: str, at: IrPlace) -> None:
169
+ if not self._first_time(f"identity/{identity_id}"):
170
+ return
171
+
172
+ identity = self.ir.identities.get(identity_id)
173
+ identity_at = IrPlace(f"/identities/{identity_id}")
174
+
175
+ if identity is None:
176
+ self.refusals.inconsistent("identity.undeclared", f"The identity '{identity_id}' is not declared", at)
177
+ return
178
+
179
+ if isinstance(identity, SignedIdentity):
180
+ self._signature_scheme(identity.signed, identity_at.below("signed"))
181
+ elif not isinstance(identity, CredentialIdentity):
182
+ self.refusals.unknown_value(identity.kind, identity_at.below("kind"))
183
+ return
184
+
185
+ if not isinstance(identity.principal, SchemaPrincipal):
186
+ self._after_v1("principal.variants", "A principal of several variants", identity_at.below("principal"))
187
+
188
+ def _signature_scheme(self, scheme_id: str, at: IrPlace) -> None:
189
+ scheme = self.ir.schemes.get(scheme_id)
190
+
191
+ if not isinstance(scheme, SignatureScheme):
192
+ self.refusals.inconsistent("scheme.unresolved", f"The signature scheme '{scheme_id}' is not declared", at)
193
+ return
194
+
195
+ signature_at = IrPlace(f"/schemes/{scheme_id}/signature")
196
+
197
+ if not isinstance(scheme.signature, TemplateSignature):
198
+ self._after_v1("signature.application", "A signature verified by the application", signature_at)
199
+ elif not isinstance(scheme.signature.key, ValueSchemeKey):
200
+ self._after_v1("signature.key.source", "A signature key read from the request", signature_at.below("key"))
201
+
202
+ def _format(self, operation: Operation) -> None:
203
+ format_id = operation.format
204
+ wire_format = self.ir.formats.get(format_id)
205
+ format_at = IrPlace(f"/formats/{format_id}")
206
+
207
+ if wire_format is None:
208
+ self.refusals.inconsistent("format.undeclared", f"The format '{format_id}' is not declared", format_at)
209
+ return
210
+
211
+ if wire_format.meta and self._first_time(f"format/{format_id}"):
212
+ self._after_v1("format.meta", f"The meta members of the format '{format_id}'", format_at.below("meta"))
213
+
214
+ shapes = {
215
+ ResponseShape.ITEM: wire_format.success.item,
216
+ ResponseShape.TABLE: wire_format.success.table,
217
+ ResponseShape.LIST: wire_format.success.list,
218
+ ResponseShape.PAGE: wire_format.success.page,
219
+ }
220
+
221
+ for response in operation.responses:
222
+ shape_name = response.shape if isinstance(response.shape, ResponseShape) else None
223
+ shape = None if shape_name is None else shapes.get(shape_name)
224
+
225
+ if response.body is None or shape_name is None or shape is None:
226
+ continue
227
+
228
+ if not self._first_time(f"format/{format_id}/{shape_name.value}"):
229
+ continue
230
+
231
+ shape_at = format_at.below("success", shape_name.value)
232
+
233
+ if shape.headers:
234
+ self._after_v1("format.success.headers", "A success format header", shape_at.below("headers"))
235
+
236
+ if shape_name is not ResponseShape.PAGE:
237
+ self._envelope(shape.body.to_json(), shape_at.below("body"))
238
+
239
+ def _page_templates(self, operation: Operation) -> None:
240
+ """The page body (the style's own, or the format's page), the style's meta and headers, each read once: a slot
241
+ the page reply does not fill would be dropped from the body, so it is refused by name."""
242
+ style_id = operation.paging.style if operation.paging is not None else None
243
+ style = None if style_id is None else self.ir.pagings.get(style_id)
244
+
245
+ if style_id is None or style is None:
246
+ return
247
+
248
+ templates: list[tuple[JsonValue, IrPlace]] = []
249
+
250
+ if self._first_time(f"paging/{style_id}"):
251
+ response_at = IrPlace(f"/pagings/{style_id}/response")
252
+
253
+ if style.response.body is not None:
254
+ templates.append((style.response.body.to_json(), response_at.below("body")))
255
+
256
+ for index, member in enumerate(style.response.meta):
257
+ templates.append((member.value.to_json(), response_at.below("meta", index, "value")))
258
+
259
+ for index, header in enumerate(style.response.headers):
260
+ templates.append((header.value.to_json(), response_at.below("headers", index, "value")))
261
+
262
+ page_format = self.ir.formats.get(operation.format)
263
+
264
+ # The format's page is read once per style too: whether `$page` is filled depends on the style's position.
265
+ format_page_key = f"page/{operation.format}/{style_id}"
266
+
267
+ if style.response.body is None and page_format is not None and self._first_time(format_page_key):
268
+ format_page_at = IrPlace(f"/formats/{operation.format}/success/page/body")
269
+ templates.append((page_format.success.page.body.to_json(), format_page_at))
270
+
271
+ # An offset style has no page number of its own: its base is not declared, so the page reply leaves `$page` out.
272
+ rendered_slots = (
273
+ _RENDERED_PAGE_SLOTS - {_PAGE_SLOT} if style.position == PagingPosition.OFFSET else _RENDERED_PAGE_SLOTS
274
+ )
275
+
276
+ for template, template_at in templates:
277
+ unrendered = sorted(set(_template_slots(template)) - rendered_slots)
278
+
279
+ if unrendered:
280
+ named = ", ".join(f"'${slot}'" for slot in unrendered)
281
+ self._after_v1(
282
+ "paging.response.slot", f"The page slots {named} of the paging style '{style_id}'", template_at
283
+ )
284
+
285
+ def _envelope(self, body: JsonValue, at: IrPlace) -> None:
286
+ payload_path = _slot_path(body, _PAYLOAD_SLOT)
287
+
288
+ if payload_path is None:
289
+ self._after_v1(
290
+ "format.success.envelope", "A success envelope that does not write `$payload` under named members", at
291
+ )
292
+ return
293
+
294
+ if not payload_path:
295
+ return
296
+
297
+ for slot in sorted(set(_template_slots(body)) - _ENVELOPE_SLOTS):
298
+ self._after_v1("format.success.slot", f"The success envelope slot '${slot}'", at)
299
+
300
+ def _requirements(self, operation: Operation, at: IrPlace) -> None:
301
+ for position, requirement in enumerate(operation.requires):
302
+ requirement_at = at.below("requires", position)
303
+
304
+ if isinstance(requirement, RequirementUnknown):
305
+ self.refusals.unknown_value(requirement.kind, requirement_at.below("kind"))
306
+ continue
307
+
308
+ if not isinstance(requirement, RuleRequirement):
309
+ continue
310
+
311
+ if requirement.bucket is not None:
312
+ self._bucket(requirement.bucket, requirement_at.below("bucket"))
313
+
314
+ rule = self.ir.rules.get(requirement.rule)
315
+ proof = None if rule is None else rule.proof
316
+
317
+ if proof is not None and proof.in_ is not ProofPlacement.BODY:
318
+ self._after_v1(
319
+ "rule.proof.placement",
320
+ f"A rule proof in '{proof.in_}'",
321
+ IrPlace(f"/rules/{requirement.rule}/proof/in"),
322
+ )
323
+
324
+ def _bucket(self, bucket_id: str, at: IrPlace) -> None:
325
+ bucket = self.ir.buckets.get(bucket_id)
326
+
327
+ if bucket is None:
328
+ self.refusals.inconsistent("bucket.undeclared", f"The bucket '{bucket_id}' is not declared", at)
329
+ return
330
+
331
+ for position, source in enumerate(bucket.key):
332
+ if source.in_ not in _BUCKET_KEY_SOURCES or source.name is None:
333
+ self._after_v1(
334
+ "bucket.key",
335
+ f"A bucket key read from '{source.in_}'",
336
+ IrPlace(f"/buckets/{bucket_id}/key/{position}"),
337
+ )
338
+
339
+ def _members_after_v1(self, operation: Operation, at: IrPlace) -> None:
340
+ members: Sequence[tuple[str, bool, str]] = (
341
+ ("principalRequires", bool(operation.principal_requires), "A principal requirement"),
342
+ ("bindings", bool(operation.bindings), "A principal binding"),
343
+ ("upstream", bool(operation.upstream), "An upstream service call"),
344
+ ("features", bool(operation.features), "An operation feature"),
345
+ ("degradable", operation.degradable is not None, "A degradable operation"),
346
+ )
347
+
348
+ for member, present, description in members:
349
+ if present:
350
+ self._after_v1(member, description, at.below(member))
351
+
352
+ for position, field_right in enumerate(operation.field_rights):
353
+ if (
354
+ field_right.direction is not Direction.OUTPUT
355
+ and _TOP_LEVEL_POINTER.fullmatch(field_right.pointer) is None
356
+ ):
357
+ self._after_v1(
358
+ "fieldRight.nestedPointer",
359
+ "An input field right below the top level",
360
+ at.below("fieldRights", position, "pointer"),
361
+ )
362
+
363
+ for position, scope in enumerate(operation.scopes):
364
+ if scope.source.in_ is not ValueSourceIn.PATH:
365
+ self._after_v1(
366
+ "scope.source",
367
+ f"A scope id read from '{scope.source.in_}'",
368
+ at.below("scopes", position, "source"),
369
+ )
370
+
371
+ def _param(self, part: str, param: Param, at: IrPlace) -> None:
372
+ if param.default is not OMITTED:
373
+ self._after_v1("param.default", "A parameter default", at.below("default"))
374
+
375
+ if param.wire is not None and param.wire.kind is QueryWireKind.LIST and param.wire.brackets:
376
+ self._after_v1("query.list.brackets", "A query list in brackets form", at.below("wire", "brackets"))
377
+
378
+ if isinstance(param.type, ScalarTypeUse | EnumTypeUse):
379
+ self._type(param.type, at.below("type"))
380
+ return
381
+
382
+ if part == _QUERY_PART and _is_form_scalar_list(param):
383
+ self._type(param.type, at.below("type"))
384
+ return
385
+
386
+ if param.derived_from is not None and param.derived_from.derivation == _PAGING_PARAMS_DERIVATION:
387
+ return
388
+
389
+ # Only a query parameter may be a list, so only its text names lists.
390
+ description = (
391
+ "A query parameter that is not a scalar, an enum or a list of them in the form style"
392
+ if part == _QUERY_PART
393
+ else f"A {part} parameter that is not a scalar or an enum"
394
+ )
395
+ self.refusals.waiting(
396
+ f"param.{part}.structured",
397
+ description,
398
+ at.below("type"),
399
+ Delivery.CONSUMER,
400
+ )
401
+
402
+ def _response(self, response: Response, at: IrPlace) -> None:
403
+ for position, header in enumerate(response.headers):
404
+ if header.derived_from is None:
405
+ self._after_v1(
406
+ "response.header",
407
+ f"The response header '{header.name}', written by the route,",
408
+ at.below("headers", position),
409
+ )
410
+
411
+ if response.body is None:
412
+ return
413
+
414
+ if response.media != _JSON_MEDIA:
415
+ self._after_v1("response.media", f"A '{response.media or 'missing'}' response body", at.below("media"))
416
+
417
+ self._type(response.body, at.below("body"))
418
+
419
+ def _error(self, code: str, at: IrPlace) -> None:
420
+ if not self._first_time(f"error/{code}"):
421
+ return
422
+
423
+ declared = self.ir.errors.get(code)
424
+
425
+ if declared is None:
426
+ self.refusals.inconsistent(
427
+ "error.code.undeclared", f"The error code '{code}' is not in the errors registry", at
428
+ )
429
+ return
430
+
431
+ if declared.params is not None:
432
+ self._schema(declared.params)
433
+
434
+ def _type(self, type_use: TypeUse, at: IrPlace) -> None:
435
+ if isinstance(type_use, ScalarTypeUse) and type_use.content is not None:
436
+ self._after_v1("scalar.content", "A JSON string content", at.below("content"))
437
+ elif isinstance(type_use, EnumTypeUse):
438
+ self._enum(type_use.enum)
439
+ elif isinstance(type_use, ArrayTypeUse):
440
+ self._type(type_use.items, at.below("items"))
441
+ elif isinstance(type_use, RefTypeUse):
442
+ if type_use.unknown_keys is not None and type_use.unknown_keys.policy is UnknownKeysPolicy.KEEP:
443
+ self._after_v1("unknownKeys.keep", "Unknown keys kept on a value", at.below("unknownKeys"))
444
+
445
+ self._schema(type_use.schema)
446
+
447
+ def _enum(self, enum_id: str) -> None:
448
+ enum = self.ir.enums.get(enum_id)
449
+
450
+ if enum is not None and enum.fallback is not None and self._first_time(f"enum/{enum_id}"):
451
+ self._after_v1("enum.open", f"The open enum '{enum_id}'", IrPlace(f"/enums/{enum_id}/fallback"))
452
+
453
+ def _schema(self, schema_id: str) -> None:
454
+ schema = self.ir.schemas.get(schema_id)
455
+
456
+ if not isinstance(schema, ObjectSchema) or not self._first_time(f"schema/{schema_id}"):
457
+ return
458
+
459
+ for position, member in enumerate(schema.properties):
460
+ self._type(member.type, IrPlace(f"/schemas/{schema_id}/properties/{position}/type"))
461
+
462
+
463
+ def _input_right_below_the_top_level(field_right: FieldRight) -> bool:
464
+ return field_right.direction is not Direction.OUTPUT and _TOP_LEVEL_POINTER.fullmatch(field_right.pointer) is None
465
+
466
+
467
+ def _slot_path(template: JsonValue, slot: str) -> list[str] | None:
468
+ """The member names down to the slot, reached through named object members only."""
469
+ if not isinstance(template, dict):
470
+ return None
471
+
472
+ if template.get("kind") == "slot":
473
+ return [] if template.get("slot") == slot and template.get("path") == [] else None
474
+
475
+ members = template.get("members")
476
+
477
+ if template.get("kind") != "object" or not isinstance(members, list):
478
+ return None
479
+
480
+ for member in members:
481
+ name = member.get("name") if isinstance(member, dict) else None
482
+
483
+ if not isinstance(member, dict) or not isinstance(name, dict) or name.get("kind") != "text":
484
+ continue
485
+
486
+ rest = _slot_path(member.get("value"), slot)
487
+ text = name.get("text")
488
+
489
+ if rest is not None and isinstance(text, str):
490
+ return [text, *rest]
491
+
492
+ return None
493
+
494
+
495
+ def _template_slots(template: JsonValue) -> list[str]:
496
+ """The slot names a template writes, at any depth, without the item each `$each` binds."""
497
+ if isinstance(template, dict) and template.get("kind") == "each":
498
+ source = template.get("of")
499
+ bound = [slot for slot in _template_slots(template.get("as")) if slot != _EACH_ITEM_SLOT]
500
+ return ([source] if isinstance(source, str) else []) + bound
501
+
502
+ if isinstance(template, list):
503
+ return [slot for value in template for slot in _template_slots(value)]
504
+
505
+ if not isinstance(template, dict):
506
+ return []
507
+
508
+ own = template.get("slot")
509
+ slots = [own] if template.get("kind") == "slot" and isinstance(own, str) else []
510
+
511
+ return slots + [slot for value in template.values() for slot in _template_slots(value)]
512
+
513
+
514
+ def _is_form_scalar_list(param: Param) -> bool:
515
+ """A query list every target decodes: a `list` wire in `form` style (repeated keys or one comma-separated value),
516
+ each item a scalar or an enum."""
517
+ wire = param.wire
518
+
519
+ if wire is None or wire.kind is not QueryWireKind.LIST or wire.style is not QueryStyle.FORM:
520
+ return False
521
+
522
+ return isinstance(param.type, ArrayTypeUse) and isinstance(param.type.items, ScalarTypeUse | EnumTypeUse)
@@ -0,0 +1,23 @@
1
+ """The refusal of an entry whose target has no row in the compatibility matrix: without it the boot check would not
2
+ know the framework versions `generate` accepted, so every Python target fails closed rather than serve unchecked."""
3
+
4
+ from typing import Final
5
+
6
+ from seampact.codegen.refusals import IrPlace, Refusals
7
+ from seampact.ir.run.generator import TargetEntry
8
+
9
+ MATRIX_ROW_MISSING: Final = "config.matrix.row-missing"
10
+
11
+
12
+ def refuse_missing_matrix_row(entry: TargetEntry, refusals: Refusals, frameworks: str) -> None:
13
+ """`frameworks` names what the row pins, as messages read it (`FastAPI and Starlette`)."""
14
+ if entry.matrix is not None:
15
+ return
16
+
17
+ refusals.add(
18
+ MATRIX_ROW_MISSING,
19
+ f"The compatibility matrix has no row for {entry.use}, so the boot check would not know the {frameworks} "
20
+ "versions generate accepted.",
21
+ f"Reinstall seampact-ir: its copy of the compatibility matrix lacks the {entry.use} row.",
22
+ IrPlace(""),
23
+ )