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,405 @@
1
+ """The Pydantic emitter FastAPI and Ninja share (ADR 0026 "Types and validation"): a `StrEnum` per enum and a strict,
2
+ frozen model per object schema (`protected_namespaces=()`, unknown keys refused), each member typed by the runtime's
3
+ type layer (`WholeNumber`, `WireDateTime`, `WireUuid`, ...), and the value rules as Pydantic's own `Field`
4
+ constraints under `rules: native` or as the runtime's validators under `seampact-consistent`. A member both optional
5
+ and nullable is a `Field` of `seampact.hooks`. A model holding a sensitive member gets a `__repr_args__` that prints it
6
+ as `[redacted]`, which Pydantic's `repr`, `str` and rich output read (ADR 0026 "Redaction"). Framework names are
7
+ imported under private aliases."""
8
+
9
+ from collections.abc import Sequence
10
+ from decimal import Decimal
11
+ from enum import StrEnum
12
+ from typing import Final
13
+
14
+ from seampact.codegen.names import ApiNames
15
+ from seampact.codegen.printer import INDENT, PythonModule, fits, string_literal
16
+ from seampact.codegen.redacted_members import REDACTED_CLASS, redacted_marker_block
17
+ from seampact.codegen.refusals import Delivery, IrPlace, Refusals
18
+ from seampact.ir.hydration import OMITTED
19
+ from seampact.ir.model import (
20
+ ArrayTypeUse,
21
+ ContractIR,
22
+ EnumTypeUse,
23
+ MapSchema,
24
+ ObjectSchema,
25
+ OpaqueTypeUse,
26
+ Property,
27
+ Refinements,
28
+ RefTypeUse,
29
+ ScalarType,
30
+ ScalarTypeUse,
31
+ TypeUse,
32
+ UnionSchema,
33
+ )
34
+
35
+ # The Pydantic the generated models are written for; the boot check refuses another major.
36
+ PYDANTIC_PACKAGE: Final = "pydantic"
37
+ PYDANTIC_MAJOR: Final = "2"
38
+
39
+
40
+ class RulesMode(StrEnum):
41
+ NATIVE = "native"
42
+ SEAMPACT_CONSISTENT = "seampact-consistent"
43
+
44
+
45
+ _MODEL_CONFIG: Final = "_MODEL_CONFIG"
46
+ _OR_NONE: Final = " | None"
47
+ _NATIVE_BOUNDS: Final = {"minimum": "ge", "maximum": "le", "exclusive_minimum": "gt", "exclusive_maximum": "lt"}
48
+ _FORMAT_TYPES: Final = {
49
+ "date-time": "WireDateTime",
50
+ "date": "WireDate",
51
+ "uuid": "WireUuid",
52
+ "decimal": "WireDecimal",
53
+ }
54
+
55
+
56
+ class _Emission:
57
+ def __init__(self, ir: ContractIR, names: ApiNames, rules: RulesMode, refusals: Refusals) -> None:
58
+ self.ir = ir
59
+ self.names = names
60
+ self.rules = rules
61
+ self.refusals = refusals
62
+ self.module = PythonModule(f"The schema types of api '{ir.api.id}'.")
63
+ self.pydantic = self.module.imported("pydantic", "_pydantic")
64
+ self.typing = self.module.imported("typing", "_typing")
65
+ self.seampact = self.module.imported("seampact.codegen.runtime.pydantic", "_seampact")
66
+ self._objects = {key: value for key, value in ir.schemas.items() if isinstance(value, ObjectSchema)}
67
+ self._defined: set[str] = set()
68
+ self._redacted_declared = False
69
+
70
+ def text(self) -> str:
71
+ self.module.add_block(
72
+ [
73
+ f"{_MODEL_CONFIG} = {self.pydantic}.ConfigDict(",
74
+ f"{INDENT}strict=True,",
75
+ f"{INDENT}frozen=True,",
76
+ f'{INDENT}extra="forbid",',
77
+ f"{INDENT}protected_namespaces=(),",
78
+ f'{INDENT}regex_engine="rust-regex",',
79
+ f"{INDENT}allow_inf_nan=False,",
80
+ f"{INDENT}hide_input_in_errors=True,",
81
+ ")",
82
+ ]
83
+ )
84
+
85
+ for enum_id in sorted(self.ir.enums):
86
+ self._enum(enum_id)
87
+
88
+ for schema_id in self._dependency_order():
89
+ self._model(schema_id, self._objects[schema_id], IrPlace(f"/schemas/{schema_id}"))
90
+
91
+ for schema_id, schema in sorted(self.ir.schemas.items()):
92
+ at = IrPlace(f"/schemas/{schema_id}")
93
+
94
+ if isinstance(schema, ObjectSchema):
95
+ continue
96
+
97
+ if isinstance(schema, MapSchema | UnionSchema):
98
+ self.refusals.waiting(f"schema.{schema.kind}", f"A {schema.kind} schema", at, Delivery.CONSUMER)
99
+ else:
100
+ self.refusals.unknown_value(schema.kind, at.below("kind"))
101
+
102
+ return self.module.text()
103
+
104
+ def _dependency_order(self) -> list[str]:
105
+ """Object schemas by id, each after the schemas its members name, so a model names only defined classes; a
106
+ cycle names the later class as a string."""
107
+ ordered: list[str] = []
108
+ visiting: set[str] = set()
109
+
110
+ def visit(schema_id: str) -> None:
111
+ if schema_id in ordered or schema_id in visiting:
112
+ return
113
+
114
+ visiting.add(schema_id)
115
+
116
+ for member in self._objects[schema_id].properties:
117
+ for referenced in _referenced_schemas(member.type):
118
+ if referenced in self._objects:
119
+ visit(referenced)
120
+
121
+ ordered.append(schema_id)
122
+
123
+ for schema_id in sorted(self._objects):
124
+ visit(schema_id)
125
+
126
+ return ordered
127
+
128
+ def _enum(self, enum_id: str) -> None:
129
+ enum_module = self.module.imported("enum", "_enum")
130
+ lines = [f"class {self.names.enum_classes[enum_id]}({enum_module}.StrEnum):"]
131
+
132
+ for value, member in self.names.enum_members[enum_id].items():
133
+ lines.append(f"{INDENT}{member} = {string_literal(value)}")
134
+
135
+ self.module.add_block(lines)
136
+
137
+ def _model(self, schema_id: str, schema: ObjectSchema, at: IrPlace) -> None:
138
+ lines = [f"class {self.names.schema_classes[schema_id]}({self.pydantic}.BaseModel):"]
139
+ lines.append(f"{INDENT}model_config = {_MODEL_CONFIG}")
140
+ members = self.names.members.get(schema_id, {})
141
+
142
+ if schema.properties:
143
+ lines.append("")
144
+
145
+ for index, member in enumerate(schema.properties):
146
+ attribute = members.get(member.name)
147
+
148
+ if attribute is not None:
149
+ lines.extend(self._member(attribute, member, at.below("properties", index)))
150
+
151
+ sensitive = [
152
+ members[member.name] for member in schema.properties if _is_sensitive(member) and member.name in members
153
+ ]
154
+
155
+ if sensitive:
156
+ lines.extend(self._redacted_repr_args(sensitive))
157
+
158
+ self._defined.add(schema_id)
159
+ self.module.add_block(lines)
160
+
161
+ def _redacted_repr_args(self, attributes: Sequence[str]) -> list[str]:
162
+ """The `__repr_args__` that prints each sensitive attribute as `[redacted]`; `model_dump` still shows it."""
163
+ abc = self.module.imported("collections.abc", "_abc")
164
+ self._declare_redacted()
165
+ names = ", ".join(string_literal(attribute) for attribute in attributes)
166
+ redacted = f"frozenset({{{names}}})"
167
+
168
+ return [
169
+ "",
170
+ f"{INDENT}def __repr_args__(self) -> {abc}.Iterator[tuple[str | None, object]]:",
171
+ f"{INDENT}{INDENT}for name, value in super().__repr_args__():",
172
+ f"{INDENT}{INDENT}{INDENT}yield name, {REDACTED_CLASS}() if name in {redacted} else value",
173
+ ]
174
+
175
+ def _declare_redacted(self) -> None:
176
+ if self._redacted_declared:
177
+ return
178
+
179
+ self._redacted_declared = True
180
+ self.module.add_block(redacted_marker_block())
181
+
182
+ def _member(self, attribute: str, member: Property, at: IrPlace) -> list[str]:
183
+ annotation = self.annotation(member.type, at.below("type"))
184
+ nullable = member.nullable or _use_nullable(member.type)
185
+
186
+ if member.default is not OMITTED:
187
+ self.refusals.waiting("member.default", "A member with a default", at.below("default"), Delivery.CONSUMER)
188
+
189
+ if member.optional and nullable:
190
+ hooks = self.module.imported("seampact.hooks", "_hooks")
191
+ return _wrapped(f"{INDENT}{attribute}: {hooks}.Field[", annotation, f" | None] = {hooks}.Absent.ABSENT")
192
+
193
+ if member.optional:
194
+ return _wrapped_in(f"{INDENT}{attribute}: {self.seampact}.Omittable[", annotation, "] = None")
195
+
196
+ return _wrapped(f"{INDENT}{attribute}: ", annotation, " | None" if nullable else "")
197
+
198
+ def annotation(self, type_use: TypeUse, at: IrPlace) -> list[str]:
199
+ """The annotation of a type use: one item, or the items of an `Annotated` that the printer may explode."""
200
+ if isinstance(type_use, ScalarTypeUse):
201
+ return self._scalar(type_use, at)
202
+
203
+ if isinstance(type_use, EnumTypeUse):
204
+ return self._enum_use(type_use, at)
205
+
206
+ if isinstance(type_use, RefTypeUse):
207
+ schema = self.ir.schemas.get(type_use.schema)
208
+ name = self.names.schema_classes.get(type_use.schema)
209
+
210
+ if not isinstance(schema, ObjectSchema) or name is None:
211
+ self.refusals.inconsistent("schema.ref.missing", f"No object schema '{type_use.schema}'", at)
212
+ return ["object"]
213
+
214
+ return [name if type_use.schema in self._defined else string_literal(name)]
215
+
216
+ if isinstance(type_use, ArrayTypeUse):
217
+ items = _joined(self.annotation(type_use.items, at.below("items")))
218
+ rules = self._length_rules(type_use.min_items, type_use.max_items)
219
+ return [f"list[{items}]"] if not rules else [f"{self.typing}.Annotated[", f"list[{items}]", *rules]
220
+
221
+ if isinstance(type_use, OpaqueTypeUse):
222
+ return [f"{self.seampact}.Opaque"]
223
+
224
+ self.refusals.waiting(f"type.{type_use.kind}", f"A type use of kind '{type_use.kind}'", at, Delivery.CONSUMER)
225
+ return ["object"]
226
+
227
+ def _enum_use(self, type_use: EnumTypeUse, at: IrPlace) -> list[str]:
228
+ if type_use.narrowed_to is not None:
229
+ self.refusals.waiting("enum.narrowed", "An enum narrowed at its use", at, Delivery.CONSUMER)
230
+
231
+ name = self.names.enum_classes.get(type_use.enum)
232
+
233
+ if name is None:
234
+ self.refusals.inconsistent("enum.missing", f"No enum '{type_use.enum}'", at)
235
+ return ["str"]
236
+
237
+ return [f"{self.typing}.Annotated[", name, f"{self.seampact}.enum_from_wire({name})"]
238
+
239
+ def _scalar(self, type_use: ScalarTypeUse, at: IrPlace) -> list[str]:
240
+ if not isinstance(type_use.type, ScalarType):
241
+ self.refusals.unknown_value(type_use.type.raw, at.below("type"))
242
+ return ["object"]
243
+
244
+ base = self._scalar_base(type_use, type_use.type, at)
245
+ rules = self._value_rules(type_use.refinements, type_use.type, at.below("refinements"))
246
+
247
+ return [base] if not rules else [f"{self.typing}.Annotated[", base, *rules]
248
+
249
+ def _scalar_base(self, type_use: ScalarTypeUse, scalar: ScalarType, at: IrPlace) -> str:
250
+ if type_use.format is not None:
251
+ wire_type = _FORMAT_TYPES.get(type_use.format)
252
+
253
+ if wire_type is None:
254
+ self.refusals.waiting(
255
+ f"format.{type_use.format}", f"The format '{type_use.format}'", at, Delivery.CONSUMER
256
+ )
257
+ return "str"
258
+
259
+ return f"{self.seampact}.{wire_type}"
260
+
261
+ if scalar is ScalarType.INTEGER:
262
+ return f"{self.seampact}.WholeNumber"
263
+
264
+ return {ScalarType.NUMBER: "float", ScalarType.BOOLEAN: "bool"}.get(scalar, "str")
265
+
266
+ def _value_rules(self, refinements: Refinements, scalar: ScalarType, at: IrPlace) -> list[str]:
267
+ bounds = [
268
+ (key, value)
269
+ for key, value in (
270
+ ("minimum", refinements.minimum),
271
+ ("maximum", refinements.maximum),
272
+ ("exclusive_minimum", refinements.exclusive_minimum),
273
+ ("exclusive_maximum", refinements.exclusive_maximum),
274
+ )
275
+ if value is not None
276
+ ]
277
+ pattern = None if refinements.pattern is None else string_literal(refinements.pattern.normalized)
278
+
279
+ if self.rules is RulesMode.NATIVE:
280
+ arguments = _length_arguments(refinements.min_length, refinements.max_length)
281
+ arguments += [f"{_NATIVE_BOUNDS[key]}={number_literal(value, scalar)}" for key, value in bounds]
282
+
283
+ if refinements.multiple_of is not None:
284
+ arguments.append(f"multiple_of={number_literal(refinements.multiple_of, scalar)}")
285
+
286
+ if pattern is not None:
287
+ arguments.append(f"pattern={pattern}")
288
+
289
+ return [f"{self.pydantic}.Field({', '.join(arguments)})"] if arguments else []
290
+
291
+ rules = self._length_rules(refinements.min_length, refinements.max_length)
292
+
293
+ if bounds:
294
+ bound_arguments = ", ".join(f"{key}={string_literal(value)}" for key, value in bounds)
295
+ rules.append(f"{self.seampact}.bounds({bound_arguments})")
296
+
297
+ if refinements.multiple_of is not None:
298
+ rules.append(f"{self.seampact}.multiple_of({string_literal(refinements.multiple_of)})")
299
+
300
+ if pattern is not None:
301
+ rules.append(f"{self.seampact}.pattern({pattern})")
302
+
303
+ return rules
304
+
305
+ def _length_rules(self, minimum: int | None, maximum: int | None) -> list[str]:
306
+ arguments = _length_arguments(minimum, maximum)
307
+
308
+ if not arguments:
309
+ return []
310
+
311
+ helper = f"{self.seampact}.length" if self.rules is RulesMode.SEAMPACT_CONSISTENT else f"{self.pydantic}.Field"
312
+
313
+ return [f"{helper}({', '.join(arguments)})"]
314
+
315
+
316
+ def pydantic_schemas_module(ir: ContractIR, names: ApiNames, rules: RulesMode, refusals: Refusals) -> str:
317
+ """The `schemas` module of an api: its enums and its object models."""
318
+ return _Emission(ir, names, rules, refusals).text()
319
+
320
+
321
+ def _joined(items: Sequence[str]) -> str:
322
+ if len(items) == 1:
323
+ return items[0]
324
+
325
+ return items[0] + ", ".join(items[1:]) + "]"
326
+
327
+
328
+ def _wrapped_in(prefix: str, annotation: Sequence[str], suffix: str) -> list[str]:
329
+ """A member whose annotation sits in one subscript (`prefix` ends with `[`, `suffix` starts with `]`), exploded as
330
+ ruff writes it: the annotation on its own line, else its `Annotated` items one per line with a trailing comma."""
331
+ joined = _joined(annotation)
332
+ single = f"{prefix}{joined}{suffix}"
333
+
334
+ if fits(single):
335
+ return [single]
336
+
337
+ # ruff parenthesizes the default instead when the annotation then fits on the member's line.
338
+ annotated, equals, default = suffix.partition(" = ")
339
+
340
+ if equals and fits(f"{prefix}{joined}{annotated} = ("):
341
+ return [f"{prefix}{joined}{annotated} = (", f"{INDENT * 2}{default}", f"{INDENT})"]
342
+
343
+ if fits(f"{INDENT * 2}{joined}") or len(annotation) == 1:
344
+ return [prefix, f"{INDENT * 2}{joined}", f"{INDENT}{suffix}"]
345
+
346
+ items = [f"{INDENT * 3}{item}," for item in annotation[1:]]
347
+
348
+ return [prefix, f"{INDENT * 2}{annotation[0]}", *items, f"{INDENT * 2}]", f"{INDENT}{suffix}"]
349
+
350
+
351
+ def _wrapped(prefix: str, annotation: Sequence[str], suffix: str) -> list[str]:
352
+ """The member line, or, past the line length, its `Annotated` items one per line with a trailing comma."""
353
+ single = f"{prefix}{_joined(annotation)}{suffix}"
354
+
355
+ if fits(single) or len(annotation) == 1:
356
+ return [single]
357
+
358
+ items = [f"{INDENT * 2}{item}," for item in annotation[1:]]
359
+
360
+ if not suffix.startswith(_OR_NONE):
361
+ return [f"{prefix}{annotation[0]}", *items, f"{INDENT}]{suffix}"]
362
+
363
+ # ruff parenthesizes an exploded annotation that a `| None` follows.
364
+ default = suffix.removeprefix(_OR_NONE)
365
+ nested = [f"{INDENT}{item}" for item in items]
366
+
367
+ return [
368
+ f"{prefix}(",
369
+ f"{INDENT * 2}{annotation[0]}",
370
+ *nested,
371
+ f"{INDENT * 2}]",
372
+ f"{INDENT * 2}| None",
373
+ f"{INDENT}){default}",
374
+ ]
375
+
376
+
377
+ def _referenced_schemas(type_use: TypeUse) -> list[str]:
378
+ if isinstance(type_use, RefTypeUse):
379
+ return [type_use.schema]
380
+
381
+ return _referenced_schemas(type_use.items) if isinstance(type_use, ArrayTypeUse) else []
382
+
383
+
384
+ def _is_sensitive(member: Property) -> bool:
385
+ """A sensitive property, or a property whose scalar use is sensitive (docs/ir section 5)."""
386
+ return member.sensitive or (isinstance(member.type, ScalarTypeUse) and member.type.sensitive is True)
387
+
388
+
389
+ def _use_nullable(type_use: TypeUse) -> bool:
390
+ nullable_uses = ScalarTypeUse | EnumTypeUse | RefTypeUse | ArrayTypeUse
391
+
392
+ return isinstance(type_use, nullable_uses) and type_use.nullable is True
393
+
394
+
395
+ def _length_arguments(minimum: int | None, maximum: int | None) -> list[str]:
396
+ return [f"{key}={value}" for key, value in (("min_length", minimum), ("max_length", maximum)) if value is not None]
397
+
398
+
399
+ def number_literal(literal: str, scalar: ScalarType) -> str:
400
+ exact = Decimal(literal)
401
+
402
+ if scalar is ScalarType.INTEGER and exact == exact.to_integral_value():
403
+ return str(int(exact))
404
+
405
+ return repr(float(exact))
@@ -0,0 +1,47 @@
1
+ """The generated marker that prints `[redacted]` in place of a sensitive value, and the `__repr__` and `__rich_repr__`
2
+ of a generated dataclass that print its sensitive members through it (ADR 0026 "Redaction")."""
3
+
4
+ from collections.abc import Collection, Sequence
5
+ from typing import Final
6
+
7
+ from seampact.codegen.printer import INDENT, string_literal
8
+
9
+ REDACTED_CLASS: Final = "_Redacted"
10
+ _REDACTED: Final = "[redacted]"
11
+
12
+
13
+ def redacted_marker_block() -> list[str]:
14
+ """The module-level marker class; a module declares it once, before its first use."""
15
+ return [
16
+ f"class {REDACTED_CLASS}:",
17
+ f'{INDENT}"""Stands for a sensitive value in a `repr`."""',
18
+ "",
19
+ f"{INDENT}def __repr__(self) -> str:",
20
+ f"{INDENT}{INDENT}return {string_literal(_REDACTED)}",
21
+ ]
22
+
23
+
24
+ def redacted_dataclass_reprs(
25
+ class_name: str, members: Sequence[str], redacted: Collection[str], abc_alias: str
26
+ ) -> list[str]:
27
+ """The `__repr__` and `__rich_repr__` methods of a generated dataclass: its `members` in order, each one in
28
+ `redacted` printed `[redacted]`, the others by their own `repr`."""
29
+ rendered = ", ".join(
30
+ f"{member}={_REDACTED}" if member in redacted else f"{member}={{self.{member}!r}}" for member in members
31
+ )
32
+ shown = any(member not in redacted for member in members)
33
+ returned = f'f"{class_name}({rendered})"' if shown else string_literal(f"{class_name}({rendered})")
34
+ rich_members = [
35
+ f"{INDENT}{INDENT}yield {string_literal(member)}, "
36
+ + (f"{REDACTED_CLASS}()" if member in redacted else f"self.{member}")
37
+ for member in members
38
+ ]
39
+
40
+ return [
41
+ "",
42
+ f"{INDENT}def __repr__(self) -> str:",
43
+ f"{INDENT}{INDENT}return {returned}",
44
+ "",
45
+ f"{INDENT}def __rich_repr__(self) -> {abc_alias}.Iterator[tuple[str, object]]:",
46
+ *rich_members,
47
+ ]
@@ -0,0 +1,84 @@
1
+ """Collects why a Python generator writes no file, each reason with its code, message, fix and place, so `generate`
2
+ lists every refusal at once; one form is refused once per place."""
3
+
4
+ from dataclasses import dataclass
5
+ from enum import StrEnum
6
+
7
+ from seampact.ir.config.problem import ConfigProblem
8
+ from seampact.ir.run.generator import GenerationRefusedError
9
+
10
+
11
+ class Delivery(StrEnum):
12
+ """Who brings a form the target does not generate yet."""
13
+
14
+ CONSUMER = "it comes when a consumer needs it"
15
+ AFTER_V1 = "it comes after 1.0"
16
+ LATER_THAN_V1 = "the 1.0 Python targets do not serve it, so it comes after 1.0"
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class IrPlace:
21
+ """A place in the IR: a JSON pointer, and the operation it sits in."""
22
+
23
+ pointer: str
24
+ operation: str | None = None
25
+
26
+ def below(self, *keys: str | int) -> "IrPlace":
27
+ return IrPlace(self.pointer + "".join(f"/{key}" for key in keys), self.operation)
28
+
29
+
30
+ def place_in_entry(entry_id: str, at: IrPlace, api: str | None = None) -> str:
31
+ """Where a refusal or a warning is, as `seampact` prints it."""
32
+ api_part = "" if api is None else f"api '{api}' "
33
+ where = f"entry '{entry_id}' {api_part}{at.pointer or '/'}"
34
+
35
+ if at.operation is not None:
36
+ where += f" (operation '{at.operation}')"
37
+
38
+ return where
39
+
40
+
41
+ class Refusals:
42
+ def __init__(self, entry_id: str, target: str, distribution: str) -> None:
43
+ """`target` is the target's short name in messages (`fastapi`), `distribution` its package
44
+ (`seampact-fastapi`)."""
45
+ self._entry_id = entry_id
46
+ self._target = target
47
+ self._distribution = distribution
48
+ self._problems: dict[tuple[str, str], ConfigProblem] = {}
49
+
50
+ def add(self, code: str, message: str, fix: str, at: IrPlace) -> None:
51
+ where = place_in_entry(self._entry_id, at)
52
+ self._problems.setdefault((code, where), ConfigProblem(code, message, fix, where))
53
+
54
+ def waiting(self, form: str, description: str, at: IrPlace, delivery: Delivery) -> None:
55
+ """A form a later release, or a consumer, brings: `<form>.unsupported`."""
56
+ self.add(
57
+ f"{form}.unsupported",
58
+ f"{description} is not generated by the {self._target} target yet: {delivery.value}.",
59
+ "Remove this form from the operations the entry keeps (its `operations` filter), or update "
60
+ f"{self._distribution} to a release that generates it.",
61
+ at,
62
+ )
63
+
64
+ def inconsistent(self, code: str, description: str, at: IrPlace) -> None:
65
+ """An IR that breaks a rule the compiler guarantees."""
66
+ self.add(code, f"{description}.", "Regenerate the IR with seampact: this document is not one it wrote.", at)
67
+
68
+ def unknown_value(self, value: str, at: IrPlace) -> None:
69
+ """A value this IR revision allows but this generator does not know (ADR 0005 section 3)."""
70
+ self.add(
71
+ "ir.value.unknown",
72
+ f"The value '{value}' is not one the {self._target} target knows.",
73
+ f"Update {self._distribution} to a release that reads this IR, or compile with the matching seampact "
74
+ "version.",
75
+ at,
76
+ )
77
+
78
+ def is_empty(self) -> bool:
79
+ return not self._problems
80
+
81
+ def raise_if_any(self) -> None:
82
+ """Raises `GenerationRefusedError` listing every refusal."""
83
+ if self._problems:
84
+ raise GenerationRefusedError(list(self._problems.values()))
@@ -0,0 +1,68 @@
1
+ """The `deadline` property of a generated `<Op>Request` on an operation that reads a deadline header (grammar section
2
+ 9): the instant the caller stops waiting, read from the header its input already holds, so every target fills it
3
+ alike and the handler asks it for the time left."""
4
+
5
+ from typing import Final
6
+
7
+ from seampact.codegen.names import member_identifier
8
+ from seampact.codegen.printer import INDENT, PythonModule
9
+ from seampact.codegen.refusals import Delivery, IrPlace, Refusals
10
+ from seampact.ir.model import Operation, Param, ScalarType, ScalarTypeUse
11
+
12
+ _DATE_TIME_FORMAT: Final = "date-time"
13
+
14
+
15
+ def deadline_property(module: PythonModule, operation: Operation, at: IrPlace, refusals: Refusals) -> list[str]:
16
+ """The property's lines, after a blank line; none when the operation reads no deadline header."""
17
+ header = operation.deadline_header
18
+
19
+ if header is None:
20
+ return []
21
+
22
+ param = next((param for param in operation.params.header if param.name == header), None)
23
+
24
+ if param is None:
25
+ refusals.inconsistent(
26
+ "deadline.header.missing",
27
+ f"The deadline header '{header}' of operation '{operation.id}' is not one of its header parameters",
28
+ at.below("deadlineHeader"),
29
+ )
30
+ return []
31
+
32
+ deadline = module.imported("seampact.codegen.runtime.deadline", "_deadline")
33
+ value = f"self.input.headers.{member_identifier(param.name)}"
34
+ read = _deadline_read(deadline, param, value)
35
+
36
+ if read is None:
37
+ refusals.waiting(
38
+ "deadline.header.type",
39
+ f"A deadline header '{header}' typed other than a plain string or a date-time",
40
+ at.below("deadlineHeader"),
41
+ Delivery.CONSUMER,
42
+ )
43
+ return []
44
+
45
+ return [
46
+ "",
47
+ f"{INDENT}@property",
48
+ f"{INDENT}def deadline(self) -> {deadline}.RequestDeadline | None:",
49
+ f'{INDENT * 2}"""When the caller stops waiting, from its \'{header}\' header; None when it sent none."""',
50
+ f"{INDENT * 2}return {read}",
51
+ ]
52
+
53
+
54
+ def _deadline_read(deadline: str, param: Param, value: str) -> str | None:
55
+ """The expression reading the deadline from the header's value: its text parsed, or its date-time wrapped as
56
+ the input already decoded it; None for another type."""
57
+ type_use = param.type
58
+
59
+ if not isinstance(type_use, ScalarTypeUse) or type_use.type != ScalarType.STRING or type_use.scalar is not None:
60
+ return None
61
+
62
+ if type_use.format is None:
63
+ return f"{deadline}.sent_deadline({value})"
64
+
65
+ if type_use.format == _DATE_TIME_FORMAT:
66
+ return f"None if {value} is None else {deadline}.RequestDeadline({value})"
67
+
68
+ return None
@@ -0,0 +1,73 @@
1
+ """The response headers of the kept operations no Python boundary can write, refused by name for every target alike:
2
+ a derived header the runtime's entries refuse (a successor in another api, an `ETag` whose `If-Match` writes the api
3
+ does not serve, an inconsistent IR), and a paging style's header written from an object or an array, which has no
4
+ header text."""
5
+
6
+ from typing import Final
7
+
8
+ from seampact.codegen.refusals import Delivery, IrPlace, Refusals
9
+ from seampact.codegen.runtime.ir_view import ir_maps
10
+ from seampact.codegen.runtime.response_header_entries import HeaderProblem, ProblemKind, response_header_entry
11
+ from seampact.ir.model import ContractIR, LiteralTemplate, SlotTemplate, Template, TextTemplate
12
+
13
+ # The page slots whose whole value is an object or an array: only a member of them is header text.
14
+ _STRUCTURED_PAGE_SLOTS: Final = frozenset({"items", "meta"})
15
+
16
+
17
+ def refuse_response_header_forms(ir: ContractIR, refusals: Refusals) -> None:
18
+ """Refuses by name each response header of the kept operations the boundary cannot write."""
19
+ operations = ir_maps(ir.to_json(), "operations")
20
+ read_styles: set[str] = set()
21
+
22
+ for index, operation in enumerate(operations):
23
+ at = IrPlace(f"/operations/{index}", ir.operations[index].id)
24
+
25
+ for position, response in enumerate(ir_maps(operation, "responses")):
26
+ for header_position, header in enumerate(ir_maps(response, "headers")):
27
+ entry = response_header_entry(ir.api.id, "", operations, operation, header)
28
+
29
+ if isinstance(entry, HeaderProblem):
30
+ _refuse(entry, at.below("responses", position, "headers", header_position), refusals)
31
+
32
+ paging = ir.operations[index].paging
33
+ style_id = None if paging is None else paging.style
34
+
35
+ if style_id is not None and style_id not in read_styles:
36
+ read_styles.add(style_id)
37
+ _refuse_structured_page_headers(ir, style_id, refusals)
38
+
39
+
40
+ def _refuse(problem: HeaderProblem, at: IrPlace, refusals: Refusals) -> None:
41
+ if problem.kind is ProblemKind.WAITING:
42
+ refusals.waiting(problem.code, problem.description, at, Delivery.AFTER_V1)
43
+ else:
44
+ refusals.inconsistent(problem.code, problem.description, at)
45
+
46
+
47
+ def _refuse_structured_page_headers(ir: ContractIR, style_id: str, refusals: Refusals) -> None:
48
+ style = ir.pagings.get(style_id)
49
+
50
+ if style is None:
51
+ return
52
+
53
+ for index, header in enumerate(style.response.headers):
54
+ if not _is_scalar_template(header.value):
55
+ refusals.waiting(
56
+ "paging.response.header",
57
+ f"The header '{header.name}' of the paging style '{style_id}', written from an object or an array,",
58
+ IrPlace(f"/pagings/{style_id}/response/headers/{index}/value"),
59
+ Delivery.AFTER_V1,
60
+ )
61
+
62
+
63
+ def _is_scalar_template(template: Template) -> bool:
64
+ if isinstance(template, TextTemplate):
65
+ return True
66
+
67
+ if isinstance(template, LiteralTemplate):
68
+ return not isinstance(template.value, dict | list)
69
+
70
+ if isinstance(template, SlotTemplate):
71
+ return bool(template.path) or template.slot not in _STRUCTURED_PAGE_SLOTS
72
+
73
+ return False