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.
- seampact/codegen/__init__.py +2 -0
- seampact/codegen/boot_check_hooks.py +38 -0
- seampact/codegen/contract_module.py +146 -0
- seampact/codegen/credentials_classes.py +99 -0
- seampact/codegen/handler_forms.py +132 -0
- seampact/codegen/input_schemas.py +130 -0
- seampact/codegen/kept_forms.py +522 -0
- seampact/codegen/matrix_row.py +23 -0
- seampact/codegen/names.py +146 -0
- seampact/codegen/native_docs_file.py +169 -0
- seampact/codegen/operation_types.py +425 -0
- seampact/codegen/placement_lines.py +79 -0
- seampact/codegen/printer.py +196 -0
- seampact/codegen/py.typed +0 -0
- seampact/codegen/pydantic_forms.py +53 -0
- seampact/codegen/pydantic_layout.py +178 -0
- seampact/codegen/pydantic_models.py +405 -0
- seampact/codegen/redacted_members.py +47 -0
- seampact/codegen/refusals.py +84 -0
- seampact/codegen/request_deadline.py +68 -0
- seampact/codegen/response_header_forms.py +73 -0
- seampact/codegen/restricted_sort_keys.py +18 -0
- seampact/codegen/route_helpers.py +109 -0
- seampact/codegen/routes.py +139 -0
- seampact/codegen/runtime/__init__.py +7 -0
- seampact/codegen/runtime/access_kinds.py +24 -0
- seampact/codegen/runtime/access_steps.py +520 -0
- seampact/codegen/runtime/body_reader.py +64 -0
- seampact/codegen/runtime/cache_headers.py +89 -0
- seampact/codegen/runtime/contract.py +232 -0
- seampact/codegen/runtime/cors_preflight.py +20 -0
- seampact/codegen/runtime/deadline.py +72 -0
- seampact/codegen/runtime/digests.py +98 -0
- seampact/codegen/runtime/failures.py +33 -0
- seampact/codegen/runtime/field_rights.py +193 -0
- seampact/codegen/runtime/framework_routes.py +36 -0
- seampact/codegen/runtime/framework_versions.py +91 -0
- seampact/codegen/runtime/handler_call.py +31 -0
- seampact/codegen/runtime/handler_reply.py +148 -0
- seampact/codegen/runtime/hinted_values.py +48 -0
- seampact/codegen/runtime/hook_check.py +77 -0
- seampact/codegen/runtime/idempotency.py +279 -0
- seampact/codegen/runtime/input_decoding.py +226 -0
- seampact/codegen/runtime/ir_view.py +47 -0
- seampact/codegen/runtime/native_docs.py +130 -0
- seampact/codegen/runtime/page_facts.py +33 -0
- seampact/codegen/runtime/page_reply.py +396 -0
- seampact/codegen/runtime/pattern.py +69 -0
- seampact/codegen/runtime/principal_members.py +41 -0
- seampact/codegen/runtime/public_origin.py +102 -0
- seampact/codegen/runtime/pydantic/__init__.py +165 -0
- seampact/codegen/runtime/pydantic/checked_input.py +52 -0
- seampact/codegen/runtime/pydantic/contract_input.py +183 -0
- seampact/codegen/runtime/pydantic/issues.py +142 -0
- seampact/codegen/runtime/raw_target.py +103 -0
- seampact/codegen/runtime/replies.py +144 -0
- seampact/codegen/runtime/reply_verdict.py +215 -0
- seampact/codegen/runtime/requirement_params.py +170 -0
- seampact/codegen/runtime/response_header_entries.py +226 -0
- seampact/codegen/runtime/response_headers.py +114 -0
- seampact/codegen/runtime/route_match.py +88 -0
- seampact/codegen/runtime/run_settings.py +35 -0
- seampact/codegen/runtime/signature_header.py +92 -0
- seampact/codegen/runtime/sync_driver.py +35 -0
- seampact/codegen/runtime/templates.py +132 -0
- seampact/codegen/runtime/uuid_text.py +7 -0
- seampact/codegen/runtime/value_rules.py +218 -0
- seampact/codegen/runtime/version.py +59 -0
- seampact/codegen/runtime/version_token.py +108 -0
- seampact/codegen/runtime/wire_codec.py +229 -0
- seampact/codegen/runtime/wire_values.py +260 -0
- seampact/codegen/source_lines.py +35 -0
- seampact_codegen-1.0.0.dist-info/METADATA +18 -0
- seampact_codegen-1.0.0.dist-info/RECORD +76 -0
- seampact_codegen-1.0.0.dist-info/WHEEL +4 -0
- 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
|