overture-schema-codegen 0.1.1.dev0__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.
- overture/schema/codegen/__init__.py +1 -0
- overture/schema/codegen/cli.py +228 -0
- overture/schema/codegen/extraction/__init__.py +0 -0
- overture/schema/codegen/extraction/docstring.py +46 -0
- overture/schema/codegen/extraction/enum_extraction.py +40 -0
- overture/schema/codegen/extraction/examples.py +367 -0
- overture/schema/codegen/extraction/field.py +172 -0
- overture/schema/codegen/extraction/field_constraints.py +185 -0
- overture/schema/codegen/extraction/field_walk.py +275 -0
- overture/schema/codegen/extraction/length_constraints.py +49 -0
- overture/schema/codegen/extraction/literal_alternatives.py +26 -0
- overture/schema/codegen/extraction/model_constraints.py +252 -0
- overture/schema/codegen/extraction/model_extraction.py +240 -0
- overture/schema/codegen/extraction/newtype_extraction.py +73 -0
- overture/schema/codegen/extraction/numeric_extraction.py +74 -0
- overture/schema/codegen/extraction/pydantic_extraction.py +33 -0
- overture/schema/codegen/extraction/specs.py +295 -0
- overture/schema/codegen/extraction/type_analyzer.py +693 -0
- overture/schema/codegen/extraction/type_registry.py +137 -0
- overture/schema/codegen/extraction/union_extraction.py +270 -0
- overture/schema/codegen/layout/__init__.py +0 -0
- overture/schema/codegen/layout/module_layout.py +139 -0
- overture/schema/codegen/layout/type_collection.py +122 -0
- overture/schema/codegen/markdown/__init__.py +0 -0
- overture/schema/codegen/markdown/link_computation.py +70 -0
- overture/schema/codegen/markdown/path_assignment.py +114 -0
- overture/schema/codegen/markdown/pipeline.py +198 -0
- overture/schema/codegen/markdown/renderer.py +641 -0
- overture/schema/codegen/markdown/reverse_references.py +169 -0
- overture/schema/codegen/markdown/templates/_used_by.md.jinja2 +10 -0
- overture/schema/codegen/markdown/templates/enum.md.jinja2 +13 -0
- overture/schema/codegen/markdown/templates/feature.md.jinja2 +45 -0
- overture/schema/codegen/markdown/templates/geometric.md.jinja2 +11 -0
- overture/schema/codegen/markdown/templates/newtype.md.jinja2 +17 -0
- overture/schema/codegen/markdown/templates/numeric.md.jinja2 +27 -0
- overture/schema/codegen/markdown/templates/pydantic_type.md.jinja2 +8 -0
- overture/schema/codegen/markdown/type_format.py +383 -0
- overture/schema/codegen/py.typed +0 -0
- overture/schema/codegen/pyspark/__init__.py +1 -0
- overture/schema/codegen/pyspark/_primitive_fill.py +23 -0
- overture/schema/codegen/pyspark/_render_common.py +477 -0
- overture/schema/codegen/pyspark/check_builder.py +961 -0
- overture/schema/codegen/pyspark/check_ir.py +223 -0
- overture/schema/codegen/pyspark/constraint_dispatch.py +753 -0
- overture/schema/codegen/pyspark/pipeline.py +220 -0
- overture/schema/codegen/pyspark/renderer.py +816 -0
- overture/schema/codegen/pyspark/schema_builder.py +187 -0
- overture/schema/codegen/pyspark/templates/_check_function.py.jinja2 +10 -0
- overture/schema/codegen/pyspark/templates/model_module.py.jinja2 +83 -0
- overture/schema/codegen/pyspark/templates/test_module.py.jinja2 +129 -0
- overture/schema/codegen/pyspark/test_data/__init__.py +9 -0
- overture/schema/codegen/pyspark/test_data/base_row.py +835 -0
- overture/schema/codegen/pyspark/test_data/constraint_values.py +203 -0
- overture/schema/codegen/pyspark/test_data/invalid_value.py +105 -0
- overture/schema/codegen/pyspark/test_data/scaffold.py +390 -0
- overture/schema/codegen/pyspark/test_renderer.py +708 -0
- overture/schema/codegen/spec_discovery.py +66 -0
- overture_schema_codegen-0.1.1.dev0.dist-info/METADATA +13 -0
- overture_schema_codegen-0.1.1.dev0.dist-info/RECORD +61 -0
- overture_schema_codegen-0.1.1.dev0.dist-info/WHEEL +4 -0
- overture_schema_codegen-0.1.1.dev0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,961 @@
|
|
|
1
|
+
"""Walk FieldSpec trees to produce Check/ModelCheck IR for rendering.
|
|
2
|
+
|
|
3
|
+
Consults the constraint dispatch table to map each constraint to a
|
|
4
|
+
descriptor, then applies composition rules the dispatch table can't see:
|
|
5
|
+
|
|
6
|
+
- Coalesce ordering: gather descriptors for the same field into one
|
|
7
|
+
`Check` (required first, then enum, then dispatched constraints),
|
|
8
|
+
deduplicate, and split column-level checks into separate suffixed checks.
|
|
9
|
+
- Target resolution: a shape walker descends each field's `FieldShape`
|
|
10
|
+
tree, building the `Direct` or `Iterated` target by appending
|
|
11
|
+
segments as it goes -- so the path read in the code is the path that
|
|
12
|
+
lands in the IR. Entering a `list[...]` or `dict[K, V]` layer promotes
|
|
13
|
+
the path's terminal segment to an iterated `ArraySegment` / `MapSegment`.
|
|
14
|
+
- Subtype gating: annotate variant-specific fields with discriminator
|
|
15
|
+
`Guard`s, synthesize forbid_if/require_if for absent or required
|
|
16
|
+
variants, and gate check_required under nullable struct ancestors.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections import defaultdict
|
|
22
|
+
from dataclasses import dataclass, replace
|
|
23
|
+
|
|
24
|
+
from pydantic import BaseModel
|
|
25
|
+
from typing_extensions import assert_never
|
|
26
|
+
|
|
27
|
+
from overture.schema.system.field_path import (
|
|
28
|
+
ArraySegment,
|
|
29
|
+
Direct,
|
|
30
|
+
FieldPath,
|
|
31
|
+
Iterated,
|
|
32
|
+
MapProjection,
|
|
33
|
+
MapSegment,
|
|
34
|
+
promote_terminal,
|
|
35
|
+
)
|
|
36
|
+
from overture.schema.system.model_constraint import (
|
|
37
|
+
FieldEqCondition,
|
|
38
|
+
ModelConstraint,
|
|
39
|
+
Not,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
from ..extraction.field import (
|
|
43
|
+
AnyScalar,
|
|
44
|
+
ArrayOf,
|
|
45
|
+
ConstraintSource,
|
|
46
|
+
FieldShape,
|
|
47
|
+
LiteralScalar,
|
|
48
|
+
MapOf,
|
|
49
|
+
ModelRef,
|
|
50
|
+
NewTypeShape,
|
|
51
|
+
Primitive,
|
|
52
|
+
Scalar,
|
|
53
|
+
UnionRef,
|
|
54
|
+
)
|
|
55
|
+
from ..extraction.field_walk import (
|
|
56
|
+
enum_source,
|
|
57
|
+
terminal_primitive,
|
|
58
|
+
)
|
|
59
|
+
from ..extraction.literal_alternatives import LiteralAlternatives
|
|
60
|
+
from ..extraction.specs import FieldSpec, ModelSpec, RecordSpec, UnionSpec
|
|
61
|
+
from ..extraction.type_registry import PRIMITIVE_TYPES
|
|
62
|
+
from ._render_common import COLUMN_LEVEL_FUNCTIONS
|
|
63
|
+
from .check_ir import (
|
|
64
|
+
Check,
|
|
65
|
+
ColumnGuard,
|
|
66
|
+
ElementGuard,
|
|
67
|
+
Guard,
|
|
68
|
+
ModelCheck,
|
|
69
|
+
)
|
|
70
|
+
from .constraint_dispatch import (
|
|
71
|
+
ExpressionDescriptor,
|
|
72
|
+
ForbidIf,
|
|
73
|
+
RequireIf,
|
|
74
|
+
dispatch_base_type,
|
|
75
|
+
dispatch_constraint,
|
|
76
|
+
dispatch_model_constraint,
|
|
77
|
+
dispatch_newtype,
|
|
78
|
+
forbid_if_field_shapes,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
__all__ = [
|
|
82
|
+
"build_checks",
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
_BOUND_ORDER = ("ge", "gt", "le", "lt")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _coalesce_bounds(
|
|
90
|
+
descriptors: list[ExpressionDescriptor],
|
|
91
|
+
) -> list[ExpressionDescriptor]:
|
|
92
|
+
"""Merge multiple `check_bounds` descriptors into a single one.
|
|
93
|
+
|
|
94
|
+
A field with both a lower and an upper bound (`Field(ge=1, le=100)`)
|
|
95
|
+
yields separate `Ge` and `Le` constraints, each dispatched to its own
|
|
96
|
+
`check_bounds`. They target the same column and describe one range, so
|
|
97
|
+
they collapse into one `check_bounds(ge=1, le=100)`: one violation
|
|
98
|
+
identity instead of two same-name checks -- which a split union arm
|
|
99
|
+
cannot otherwise label distinctly (see `_render_common.field_check_rows`).
|
|
100
|
+
|
|
101
|
+
Merges only bounds of distinct kinds. Two constraints of the *same* kind
|
|
102
|
+
with different values (`ge=1` from a NewType, `ge=5` at the field) have no
|
|
103
|
+
unambiguous merge, so this raises rather than silently keeping one -- the
|
|
104
|
+
author should state the single intended bound.
|
|
105
|
+
|
|
106
|
+
Raises
|
|
107
|
+
------
|
|
108
|
+
ValueError
|
|
109
|
+
When two bounds of the same kind carry different values.
|
|
110
|
+
"""
|
|
111
|
+
bound_descs = [d for d in descriptors if d.function == "check_bounds"]
|
|
112
|
+
if len(bound_descs) <= 1:
|
|
113
|
+
return descriptors
|
|
114
|
+
merged: dict[str, object] = {}
|
|
115
|
+
for d in bound_descs:
|
|
116
|
+
for key, value in d.kwargs:
|
|
117
|
+
if key in merged and merged[key] != value:
|
|
118
|
+
raise ValueError(
|
|
119
|
+
f"conflicting {key} bounds on one field: "
|
|
120
|
+
f"{merged[key]!r} vs {value!r}; declare a single {key}"
|
|
121
|
+
)
|
|
122
|
+
merged[key] = value
|
|
123
|
+
merged_desc = ExpressionDescriptor(
|
|
124
|
+
function="check_bounds",
|
|
125
|
+
kwargs=tuple((k, merged[k]) for k in _BOUND_ORDER if k in merged),
|
|
126
|
+
check_nan=bound_descs[0].check_nan,
|
|
127
|
+
)
|
|
128
|
+
result: list[ExpressionDescriptor] = []
|
|
129
|
+
placed = False
|
|
130
|
+
for d in descriptors:
|
|
131
|
+
if d.function != "check_bounds":
|
|
132
|
+
result.append(d)
|
|
133
|
+
elif not placed:
|
|
134
|
+
result.append(merged_desc)
|
|
135
|
+
placed = True
|
|
136
|
+
return result
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _dispatch_layer_constraints(
|
|
140
|
+
constraints: tuple[ConstraintSource, ...],
|
|
141
|
+
base_type: str | None,
|
|
142
|
+
) -> list[ExpressionDescriptor]:
|
|
143
|
+
"""Dispatch one shape layer's constraints, skipping primitive-inherent ones."""
|
|
144
|
+
descriptors: list[ExpressionDescriptor] = []
|
|
145
|
+
for cs in constraints:
|
|
146
|
+
if cs.source_name is not None and cs.source_name in PRIMITIVE_TYPES:
|
|
147
|
+
continue
|
|
148
|
+
desc = dispatch_constraint(cs.constraint, base_type=base_type)
|
|
149
|
+
if desc is not None:
|
|
150
|
+
descriptors.append(desc)
|
|
151
|
+
return _coalesce_bounds(descriptors)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _literal_alternatives(shape: Scalar | MapOf) -> tuple[object, ...]:
|
|
155
|
+
"""Return the allowed literal values from a `LiteralAlternatives` constraint, or `()`.
|
|
156
|
+
|
|
157
|
+
`MapOf` returns `()`: a map column carries no literal alternative of its
|
|
158
|
+
own (its key/value projections reach their own `Scalar` shapes, which are
|
|
159
|
+
handled there).
|
|
160
|
+
"""
|
|
161
|
+
if isinstance(shape, MapOf):
|
|
162
|
+
return ()
|
|
163
|
+
for cs in shape.constraints:
|
|
164
|
+
if isinstance(cs.constraint, LiteralAlternatives):
|
|
165
|
+
return cs.constraint.values
|
|
166
|
+
return ()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _apply_literal_bypass(
|
|
170
|
+
descriptors: list[ExpressionDescriptor],
|
|
171
|
+
allow_literals: tuple[object, ...],
|
|
172
|
+
) -> list[ExpressionDescriptor]:
|
|
173
|
+
"""Stamp `allow_literals` onto each content descriptor.
|
|
174
|
+
|
|
175
|
+
Content descriptors are the non-required checks: enum, pattern,
|
|
176
|
+
bounds, base-type checks. `check_required` is excluded by callers
|
|
177
|
+
who pass only the content portion of the descriptor list.
|
|
178
|
+
"""
|
|
179
|
+
if not allow_literals:
|
|
180
|
+
return descriptors
|
|
181
|
+
return [replace(desc, allow_literals=allow_literals) for desc in descriptors]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _enum_values(scalar: Scalar) -> list[object] | None:
|
|
185
|
+
"""Return enum/literal values for a terminal `Scalar`, or `None`."""
|
|
186
|
+
if isinstance(scalar, LiteralScalar):
|
|
187
|
+
return list(scalar.values)
|
|
188
|
+
src = enum_source(scalar)
|
|
189
|
+
if src is not None:
|
|
190
|
+
return [m.value for m in src]
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _required_descriptor(gate: FieldPath | None) -> ExpressionDescriptor:
|
|
195
|
+
return ExpressionDescriptor(function="check_required", gate=gate)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@dataclass(frozen=True, slots=True)
|
|
199
|
+
class _ShapeTerminal:
|
|
200
|
+
"""A `ModelRef`/`UnionRef` terminal and the path the walker reached it at.
|
|
201
|
+
|
|
202
|
+
The `FieldSpec` recursion uses `path` directly as the prefix for the
|
|
203
|
+
sub-model's or sub-union's fields. The walker returns `None` instead
|
|
204
|
+
of a `_ShapeTerminal` for terminals it fully handles itself (scalars,
|
|
205
|
+
maps, and NewTypes with a dispatch override).
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
ref: ModelRef | UnionRef
|
|
209
|
+
path: FieldPath
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _walk_field_shape(
|
|
213
|
+
shape: FieldShape,
|
|
214
|
+
path: FieldPath,
|
|
215
|
+
*,
|
|
216
|
+
base_type: str | None,
|
|
217
|
+
required: bool,
|
|
218
|
+
required_gate: FieldPath | None,
|
|
219
|
+
carried_element: list[ExpressionDescriptor],
|
|
220
|
+
) -> tuple[list[Check], _ShapeTerminal | None]:
|
|
221
|
+
"""Descend a `FieldShape`, emitting the field's own Checks.
|
|
222
|
+
|
|
223
|
+
Builds the `FieldPath` target structurally: `ArrayOf` promotes the
|
|
224
|
+
path's terminal segment, `NewTypeShape` passes the path through,
|
|
225
|
+
terminals emit at the path reached. Returns the emitted Checks plus,
|
|
226
|
+
at a `ModelRef`/`UnionRef` terminal, a `_ShapeTerminal` for the
|
|
227
|
+
`FieldSpec` recursion (`None` for terminals the walker fully handles).
|
|
228
|
+
|
|
229
|
+
Parameters
|
|
230
|
+
----------
|
|
231
|
+
path
|
|
232
|
+
The path reached so far, promoted once per `ArrayOf` layer
|
|
233
|
+
crossed. `required` and `path` move together: a field's path
|
|
234
|
+
starts as a plain struct path and is promoted exactly when the
|
|
235
|
+
first `ArrayOf` clears `required`, so while `required` holds the
|
|
236
|
+
path is still the plain struct path -- a standalone
|
|
237
|
+
`check_required` always lands there.
|
|
238
|
+
required
|
|
239
|
+
Whether the field still needs a `check_required`. Cleared by the
|
|
240
|
+
first `ArrayOf`: before it, `check_required` merges into the
|
|
241
|
+
terminal Check; from it on, it is a standalone column-level Check.
|
|
242
|
+
carried_element
|
|
243
|
+
Element-level descriptors from `ArrayOf` layers above, prepended
|
|
244
|
+
to the terminal's own element-level descriptors.
|
|
245
|
+
"""
|
|
246
|
+
match shape:
|
|
247
|
+
case NewTypeShape(name=name, inner=inner):
|
|
248
|
+
nt_descriptors = dispatch_newtype(name)
|
|
249
|
+
if nt_descriptors is not None:
|
|
250
|
+
descriptors = list(nt_descriptors)
|
|
251
|
+
if required:
|
|
252
|
+
descriptors.insert(0, _required_descriptor(required_gate))
|
|
253
|
+
return [Check(descriptors=tuple(descriptors), target=path)], None
|
|
254
|
+
return _walk_field_shape(
|
|
255
|
+
inner,
|
|
256
|
+
path,
|
|
257
|
+
base_type=base_type,
|
|
258
|
+
required=required,
|
|
259
|
+
required_gate=required_gate,
|
|
260
|
+
carried_element=carried_element,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
case ArrayOf(element=element, constraints=constraints):
|
|
264
|
+
layer_descriptors = _dispatch_layer_constraints(
|
|
265
|
+
constraints,
|
|
266
|
+
base_type,
|
|
267
|
+
)
|
|
268
|
+
column_descriptors = list(
|
|
269
|
+
dict.fromkeys(
|
|
270
|
+
d for d in layer_descriptors if d.function in COLUMN_LEVEL_FUNCTIONS
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
element_descriptors = [
|
|
274
|
+
d for d in layer_descriptors if d.function not in COLUMN_LEVEL_FUNCTIONS
|
|
275
|
+
]
|
|
276
|
+
checks: list[Check] = []
|
|
277
|
+
if required:
|
|
278
|
+
checks.append(
|
|
279
|
+
Check(
|
|
280
|
+
descriptors=(_required_descriptor(required_gate),),
|
|
281
|
+
target=path,
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
checks.extend(
|
|
285
|
+
Check(descriptors=(d,), target=path) for d in column_descriptors
|
|
286
|
+
)
|
|
287
|
+
sub_checks, terminal = _walk_field_shape(
|
|
288
|
+
element,
|
|
289
|
+
promote_terminal(path),
|
|
290
|
+
base_type=base_type,
|
|
291
|
+
required=False,
|
|
292
|
+
required_gate=required_gate,
|
|
293
|
+
carried_element=[*carried_element, *element_descriptors],
|
|
294
|
+
)
|
|
295
|
+
return [*checks, *sub_checks], terminal
|
|
296
|
+
|
|
297
|
+
case UnionRef() | ModelRef():
|
|
298
|
+
# A union or model reached under any array/map nesting (including
|
|
299
|
+
# `list[list[Union]]`) descends the same way: the fold wraps each
|
|
300
|
+
# variant-gated field check at the innermost element, where the
|
|
301
|
+
# `ElementGuard`'s discriminator co-locates with the leaf accessor.
|
|
302
|
+
return _ref_terminal_checks(shape, path, required, required_gate)
|
|
303
|
+
|
|
304
|
+
case Primitive() | LiteralScalar() | AnyScalar():
|
|
305
|
+
return _terminal_scalar_checks(
|
|
306
|
+
shape,
|
|
307
|
+
path,
|
|
308
|
+
base_type=base_type,
|
|
309
|
+
required=required,
|
|
310
|
+
required_gate=required_gate,
|
|
311
|
+
carried_element=carried_element,
|
|
312
|
+
), None
|
|
313
|
+
|
|
314
|
+
case MapOf(key=key_shape, value=value_shape):
|
|
315
|
+
# A map is itself a terminal column: its own value carries the
|
|
316
|
+
# required check and any map-level constraints (currently always
|
|
317
|
+
# empty -- map-level length constraints are rejected at
|
|
318
|
+
# extraction). The key and value layers are walked separately so
|
|
319
|
+
# their per-key/per-value constraints land on `Iterated` targets.
|
|
320
|
+
# A `ModelRef`/`UnionRef` projection hands back a `_ShapeTerminal`
|
|
321
|
+
# for the caller to descend into, exactly as a `list[Model]`
|
|
322
|
+
# element does.
|
|
323
|
+
field_checks = _terminal_scalar_checks(
|
|
324
|
+
shape,
|
|
325
|
+
path,
|
|
326
|
+
base_type=base_type,
|
|
327
|
+
required=required,
|
|
328
|
+
required_gate=required_gate,
|
|
329
|
+
carried_element=carried_element,
|
|
330
|
+
)
|
|
331
|
+
key_checks, key_terminal = _map_projection_checks(
|
|
332
|
+
key_shape, path, MapProjection.KEY
|
|
333
|
+
)
|
|
334
|
+
value_checks, value_terminal = _map_projection_checks(
|
|
335
|
+
value_shape, path, MapProjection.VALUE
|
|
336
|
+
)
|
|
337
|
+
if key_terminal is not None and value_terminal is not None:
|
|
338
|
+
# Not a representational limit: the taxonomy encodes
|
|
339
|
+
# `a{key}.kfield` and `a{value}.vfield` independently. The
|
|
340
|
+
# barrier is that `_walk_field_shape` returns a single
|
|
341
|
+
# `_ShapeTerminal`, so the FieldSpec recursion can descend only
|
|
342
|
+
# one projection's sub-model -- a `dict[Model, Model]` needs
|
|
343
|
+
# both descended. Lifting it means returning two terminals.
|
|
344
|
+
raise NotImplementedError(
|
|
345
|
+
"dict[Model, Model] reaches a sub-model through both its "
|
|
346
|
+
"key and value projection, but _walk_field_shape returns a "
|
|
347
|
+
"single terminal, so only one projection's sub-model can be "
|
|
348
|
+
"descended"
|
|
349
|
+
)
|
|
350
|
+
terminal = value_terminal if value_terminal is not None else key_terminal
|
|
351
|
+
return [*field_checks, *key_checks, *value_checks], terminal
|
|
352
|
+
|
|
353
|
+
assert_never(shape)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _terminal_scalar_checks(
|
|
357
|
+
shape: Scalar | MapOf,
|
|
358
|
+
path: FieldPath,
|
|
359
|
+
*,
|
|
360
|
+
base_type: str | None,
|
|
361
|
+
required: bool,
|
|
362
|
+
required_gate: FieldPath | None,
|
|
363
|
+
carried_element: list[ExpressionDescriptor],
|
|
364
|
+
) -> list[Check]:
|
|
365
|
+
"""Build the Check(s) for a terminal value: enum, constraints, base type.
|
|
366
|
+
|
|
367
|
+
Shared by the scalar-terminal arm and a map field's own value -- a
|
|
368
|
+
`MapOf` is itself a terminal column, distinct from its key/value layers.
|
|
369
|
+
"""
|
|
370
|
+
element_descriptors = list(carried_element)
|
|
371
|
+
enum_values = _enum_values(shape) if isinstance(shape, Scalar) else None
|
|
372
|
+
if enum_values is not None:
|
|
373
|
+
element_descriptors.append(
|
|
374
|
+
ExpressionDescriptor(function="check_enum", args=(tuple(enum_values),))
|
|
375
|
+
)
|
|
376
|
+
element_descriptors.extend(
|
|
377
|
+
_dispatch_layer_constraints(shape.constraints, base_type)
|
|
378
|
+
)
|
|
379
|
+
if base_type is not None:
|
|
380
|
+
base_descriptors = dispatch_base_type(base_type)
|
|
381
|
+
if base_descriptors is not None:
|
|
382
|
+
element_descriptors.extend(base_descriptors)
|
|
383
|
+
element_descriptors = list(dict.fromkeys(element_descriptors))
|
|
384
|
+
element_descriptors = _apply_literal_bypass(
|
|
385
|
+
element_descriptors, _literal_alternatives(shape)
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
if required:
|
|
389
|
+
return [
|
|
390
|
+
Check(
|
|
391
|
+
descriptors=(_required_descriptor(required_gate), *element_descriptors),
|
|
392
|
+
target=path,
|
|
393
|
+
)
|
|
394
|
+
]
|
|
395
|
+
if element_descriptors:
|
|
396
|
+
return [Check(descriptors=tuple(element_descriptors), target=path)]
|
|
397
|
+
return []
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _map_projection_checks(
|
|
401
|
+
sub_shape: FieldShape,
|
|
402
|
+
map_path: FieldPath,
|
|
403
|
+
projection: MapProjection,
|
|
404
|
+
) -> tuple[list[Check], _ShapeTerminal | None]:
|
|
405
|
+
"""Walk a map's key or value shape, emitting checks on an `Iterated` target.
|
|
406
|
+
|
|
407
|
+
Promotes *map_path*'s terminal into a `MapSegment` projecting the chosen
|
|
408
|
+
side, then walks the projected shape unconditionally. Every map/array
|
|
409
|
+
nesting -- `dict[K, scalar]` (per-key/value constraints on a bare map
|
|
410
|
+
frame), `dict[K, Model]` (the returned `_ShapeTerminal` lets the caller
|
|
411
|
+
descend the value model on a map leaf), `dict[K, list]`, `dict[K, dict]`,
|
|
412
|
+
and a map reached through an array -- is now representable, so the walk
|
|
413
|
+
needs no representability gate: an unconstrained shape simply yields no
|
|
414
|
+
checks.
|
|
415
|
+
"""
|
|
416
|
+
primitive = terminal_primitive(sub_shape)
|
|
417
|
+
return _walk_field_shape(
|
|
418
|
+
sub_shape,
|
|
419
|
+
promote_terminal(map_path, projection=projection),
|
|
420
|
+
base_type=primitive.base_type if primitive is not None else None,
|
|
421
|
+
required=False,
|
|
422
|
+
required_gate=None,
|
|
423
|
+
carried_element=[],
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _ref_terminal_checks(
|
|
428
|
+
ref: ModelRef | UnionRef,
|
|
429
|
+
path: FieldPath,
|
|
430
|
+
required: bool,
|
|
431
|
+
required_gate: FieldPath | None,
|
|
432
|
+
) -> tuple[list[Check], _ShapeTerminal]:
|
|
433
|
+
"""Handle a `ModelRef`/`UnionRef` terminal: emit `check_required`, hand back the ref.
|
|
434
|
+
|
|
435
|
+
A required model or union field always gets a standalone
|
|
436
|
+
`check_required` Check; `required` holds only before any `ArrayOf`,
|
|
437
|
+
so `path` is the field's plain struct path. The sub-fields are the
|
|
438
|
+
caller's job, reached via the returned `_ShapeTerminal`.
|
|
439
|
+
"""
|
|
440
|
+
checks: list[Check] = []
|
|
441
|
+
if required:
|
|
442
|
+
checks.append(
|
|
443
|
+
Check(
|
|
444
|
+
descriptors=(_required_descriptor(required_gate),),
|
|
445
|
+
target=path,
|
|
446
|
+
)
|
|
447
|
+
)
|
|
448
|
+
return checks, _ShapeTerminal(ref=ref, path=path)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _build_field_checks(
|
|
452
|
+
field_spec: FieldSpec,
|
|
453
|
+
prefix: FieldPath = Direct(),
|
|
454
|
+
*,
|
|
455
|
+
nullable_gate: FieldPath | None = None,
|
|
456
|
+
arm: str | None = None,
|
|
457
|
+
) -> tuple[list[Check], list[ModelCheck]]:
|
|
458
|
+
"""Build Checks for a single field by walking its shape tree.
|
|
459
|
+
|
|
460
|
+
`arm` is the singleton union-arm discriminator value the field belongs
|
|
461
|
+
to (when it lives in exactly one arm), or `None` when the field is
|
|
462
|
+
shared. It propagates to any model constraints discovered through this
|
|
463
|
+
field's sub-models so per-arm test modules can filter them correctly.
|
|
464
|
+
"""
|
|
465
|
+
# `prefix` is a `Direct` or an `Iterated` (the latter when descending
|
|
466
|
+
# into a list element or a `dict[K, Model]` value model) -- both define
|
|
467
|
+
# `append_struct`, which extends the path's struct leaf with this field's
|
|
468
|
+
# name.
|
|
469
|
+
path = prefix.append_struct(field_spec.name)
|
|
470
|
+
checks, terminal = _walk_field_shape(
|
|
471
|
+
field_spec.shape,
|
|
472
|
+
path,
|
|
473
|
+
base_type=(
|
|
474
|
+
p.base_type
|
|
475
|
+
if (p := terminal_primitive(field_spec.shape)) is not None
|
|
476
|
+
else None
|
|
477
|
+
),
|
|
478
|
+
required=field_spec.is_required,
|
|
479
|
+
required_gate=nullable_gate,
|
|
480
|
+
carried_element=[],
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
model_checks: list[ModelCheck] = []
|
|
484
|
+
match terminal:
|
|
485
|
+
case None:
|
|
486
|
+
pass
|
|
487
|
+
case _ShapeTerminal(ref=UnionRef(union=union_spec), path=terminal_path):
|
|
488
|
+
sub_field_checks, sub_model_checks = _recurse_into_union(
|
|
489
|
+
union_spec, terminal_path, arm=arm
|
|
490
|
+
)
|
|
491
|
+
checks.extend(sub_field_checks)
|
|
492
|
+
model_checks.extend(sub_model_checks)
|
|
493
|
+
case _ShapeTerminal(ref=ModelRef(model=model_spec), path=terminal_path):
|
|
494
|
+
sub_field_checks, sub_model_checks = _recurse_into_model(
|
|
495
|
+
model_spec,
|
|
496
|
+
terminal_path,
|
|
497
|
+
field_spec.is_optional,
|
|
498
|
+
nullable_gate,
|
|
499
|
+
arm=arm,
|
|
500
|
+
)
|
|
501
|
+
checks.extend(sub_field_checks)
|
|
502
|
+
model_checks.extend(sub_model_checks)
|
|
503
|
+
case _ShapeTerminal(ref=ref):
|
|
504
|
+
raise AssertionError(
|
|
505
|
+
f"unhandled _ShapeTerminal.ref variant: {type(ref).__name__}"
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
return checks, model_checks
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _recurse_into_model(
|
|
512
|
+
model_spec: RecordSpec,
|
|
513
|
+
prefix: FieldPath = Direct(),
|
|
514
|
+
is_optional: bool = False,
|
|
515
|
+
nullable_gate: FieldPath | None = None,
|
|
516
|
+
*,
|
|
517
|
+
arm: str | None = None,
|
|
518
|
+
) -> tuple[list[Check], list[ModelCheck]]:
|
|
519
|
+
"""Walk a MODEL-kind field's children plus its model-level constraints.
|
|
520
|
+
|
|
521
|
+
`prefix` is the terminal path the shape walker reached the `ModelRef`
|
|
522
|
+
at, defaulting to the empty `Direct()` at the row root. Its terminal
|
|
523
|
+
segment is an `ArraySegment` (the field is a list) or a `MapSegment`
|
|
524
|
+
(the field is a `dict[K, Model]` reached through its key/value
|
|
525
|
+
projection) exactly when the model is reached through iteration, which
|
|
526
|
+
resets the nullable gate (the iteration itself handles per-element
|
|
527
|
+
nullability).
|
|
528
|
+
|
|
529
|
+
`arm` propagates from the union arm whose variant-specific field led
|
|
530
|
+
here, so model constraints declared on the sub-model are tagged with
|
|
531
|
+
that arm rather than `None` (which would route them to every per-arm
|
|
532
|
+
test).
|
|
533
|
+
"""
|
|
534
|
+
last_seg = prefix.segments[-1] if prefix.segments else None
|
|
535
|
+
field_is_iterated = isinstance(last_seg, (ArraySegment, MapSegment))
|
|
536
|
+
if field_is_iterated:
|
|
537
|
+
child_gate: FieldPath | None = None
|
|
538
|
+
else:
|
|
539
|
+
child_gate = prefix if is_optional else nullable_gate
|
|
540
|
+
|
|
541
|
+
field_checks: list[Check] = []
|
|
542
|
+
model_checks: list[ModelCheck] = []
|
|
543
|
+
for sub_field in model_spec.fields:
|
|
544
|
+
sub_field_checks, sub_model_checks = _build_field_checks(
|
|
545
|
+
sub_field,
|
|
546
|
+
prefix=prefix,
|
|
547
|
+
nullable_gate=child_gate,
|
|
548
|
+
arm=arm,
|
|
549
|
+
)
|
|
550
|
+
field_checks.extend(sub_field_checks)
|
|
551
|
+
model_checks.extend(sub_model_checks)
|
|
552
|
+
|
|
553
|
+
if model_spec.constraints:
|
|
554
|
+
# The constraint applies wherever the model is reached, so it inherits
|
|
555
|
+
# the same nullable gate as the model's fields: `child_gate` is the
|
|
556
|
+
# optional-ancestor path (or the model's own optional prefix) that must
|
|
557
|
+
# be non-null for the constraint to apply, and `None` once inside any
|
|
558
|
+
# iterated container (the fold handles per-element nullability). The
|
|
559
|
+
# renderer wraps a Direct-target constraint in `F.when(gate.isNotNull())`
|
|
560
|
+
# and an Iterated-target one element-relatively.
|
|
561
|
+
sub_model_constraint_checks = _dispatch_model_constraints(
|
|
562
|
+
model_spec.constraints,
|
|
563
|
+
model_spec.fields,
|
|
564
|
+
target=_model_constraint_target(prefix),
|
|
565
|
+
arm=arm,
|
|
566
|
+
gate=child_gate,
|
|
567
|
+
)
|
|
568
|
+
model_checks.extend(sub_model_constraint_checks)
|
|
569
|
+
return field_checks, model_checks
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _is_struct_only_prefix(prefix: FieldPath) -> bool:
|
|
573
|
+
"""Non-root struct path with no iteration.
|
|
574
|
+
|
|
575
|
+
True when `prefix` has one or more struct segments but no array/map
|
|
576
|
+
iteration -- meaning discriminator column access and model-constraint
|
|
577
|
+
targeting cannot use the prefix without resolving it into a
|
|
578
|
+
struct-qualified path, which the current renderer does not support. A
|
|
579
|
+
`Direct` with segments is the only struct-only prefix; any `Iterated`
|
|
580
|
+
(array- or map-reached) is a valid anchor.
|
|
581
|
+
"""
|
|
582
|
+
return isinstance(prefix, Direct) and bool(prefix.segments)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _reject_struct_only_prefix(prefix: FieldPath, message: str) -> None:
|
|
586
|
+
"""Raise `NotImplementedError(message)` when `prefix` is struct-only.
|
|
587
|
+
|
|
588
|
+
Shared mechanism for the struct-nested guards: the renderer supports
|
|
589
|
+
neither model-constraint anchoring nor column-level discriminator
|
|
590
|
+
gating at a struct-only prefix, so reaching one with a real check is a
|
|
591
|
+
renderer gap rather than a normal case.
|
|
592
|
+
"""
|
|
593
|
+
if _is_struct_only_prefix(prefix):
|
|
594
|
+
raise NotImplementedError(message)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _guard_struct_nested_anchor(prefix: FieldPath, name: str) -> None:
|
|
598
|
+
"""Raise when a struct-nested UNION emits union-level or exclusivity checks.
|
|
599
|
+
|
|
600
|
+
A plain model constraint at a struct-only prefix is supported: the target
|
|
601
|
+
is the struct prefix and the renderer qualifies field references
|
|
602
|
+
(`F.col("details.foo")`, see `_model_constraint_target`). A discriminated
|
|
603
|
+
UNION reached through a plain struct is not: its synthesized exclusivity
|
|
604
|
+
checks and union-level constraints interlock with the variant-field
|
|
605
|
+
`ColumnGuard`s (`_guard_struct_nested_variant_fields`), which render the
|
|
606
|
+
discriminator as a top-level column rather than a struct-qualified path.
|
|
607
|
+
Gating the whole union case loudly keeps that mis-columning from shipping.
|
|
608
|
+
A map-reached `Iterated` prefix is a valid anchor, so `_is_struct_only_prefix`
|
|
609
|
+
-- `False` for any `Iterated` -- exempts it automatically.
|
|
610
|
+
"""
|
|
611
|
+
_reject_struct_only_prefix(
|
|
612
|
+
prefix,
|
|
613
|
+
f"Model constraint on struct-nested union {name!r} "
|
|
614
|
+
f"(reached at {prefix!r}) -- the discriminator gating renders "
|
|
615
|
+
"as a top-level column, not a struct-qualified path.",
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _guard_struct_nested_variant_fields(prefix: FieldPath, name: str) -> None:
|
|
620
|
+
"""Raise when emitting variant-gated field checks at a struct-only prefix.
|
|
621
|
+
|
|
622
|
+
A `ColumnGuard` carries a bare discriminator name that renders as
|
|
623
|
+
`F.col("<discriminator>")` -- a top-level column access. When the
|
|
624
|
+
union is reached through a plain struct field, the discriminator lives
|
|
625
|
+
at `<prefix>.<discriminator>`, so the rendered gate reads the wrong
|
|
626
|
+
column. Raising loudly is safer than emitting a mis-gated check; no
|
|
627
|
+
current schema nests a discriminated union under a plain struct.
|
|
628
|
+
"""
|
|
629
|
+
_reject_struct_only_prefix(
|
|
630
|
+
prefix,
|
|
631
|
+
f"Discriminated union {name!r} with variant-gated field checks "
|
|
632
|
+
f"at struct-nested prefix {prefix!r} -- `ColumnGuard` would "
|
|
633
|
+
"render the discriminator as a top-level column, not a "
|
|
634
|
+
"struct-qualified path.",
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _iteration_depth(path: FieldPath) -> int:
|
|
639
|
+
"""Return the number of iteration frames (`Array`/`Map` segments) in *path*.
|
|
640
|
+
|
|
641
|
+
Each iterating segment -- named or anonymous -- is one lambda frame in
|
|
642
|
+
the renderer's fold, so the count is the depth at which the innermost
|
|
643
|
+
element variable is bound. A `Direct` path binds no element variable
|
|
644
|
+
and has depth 0.
|
|
645
|
+
"""
|
|
646
|
+
if isinstance(path, Iterated):
|
|
647
|
+
return sum(
|
|
648
|
+
1 for s in path.segments if isinstance(s, (ArraySegment, MapSegment))
|
|
649
|
+
)
|
|
650
|
+
return 0
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _guard_variant_field_past_element(
|
|
654
|
+
prefix: FieldPath, checks: list[Check], name: str
|
|
655
|
+
) -> None:
|
|
656
|
+
"""Raise when an `ElementGuard`'d variant field iterates past its discriminator.
|
|
657
|
+
|
|
658
|
+
An `ElementGuard` carries the discriminator of a union reached at
|
|
659
|
+
*prefix*, and the renderer applies it at the innermost iteration
|
|
660
|
+
variable (`_render_iterated_check_expr`). That placement is correct only
|
|
661
|
+
when the guarded check binds the same element as the discriminator --
|
|
662
|
+
i.e. the check iterates no further than *prefix*. When a variant field is
|
|
663
|
+
itself an iterated container (e.g. `list[list[Union{codes: list[int]}]]`,
|
|
664
|
+
where `codes[]` adds a third iteration past the union element), the
|
|
665
|
+
innermost variable is that deeper element, where the discriminator does
|
|
666
|
+
not live. The renderer has no per-guard depth info to place the guard at
|
|
667
|
+
the discriminator's shallower iteration level, so the render would
|
|
668
|
+
silently gate on the wrong element. Raise instead.
|
|
669
|
+
"""
|
|
670
|
+
prefix_depth = _iteration_depth(prefix)
|
|
671
|
+
for ck in checks:
|
|
672
|
+
if _iteration_depth(ck.target) > prefix_depth:
|
|
673
|
+
raise NotImplementedError(
|
|
674
|
+
f"Discriminated union {name!r}: variant field check reaches "
|
|
675
|
+
f"its value through iteration beyond the discriminator's "
|
|
676
|
+
f"element (discriminator element at {prefix!r}, check target "
|
|
677
|
+
f"{ck.target!r}). The ElementGuard is applied at the innermost "
|
|
678
|
+
f"iteration variable, so it cannot be placed at the "
|
|
679
|
+
f"discriminator's iteration level when the variant field "
|
|
680
|
+
f"iterates further."
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _recurse_into_union(
|
|
685
|
+
union_spec: UnionSpec,
|
|
686
|
+
prefix: FieldPath = Direct(),
|
|
687
|
+
*,
|
|
688
|
+
arm: str | None = None,
|
|
689
|
+
) -> tuple[list[Check], list[ModelCheck]]:
|
|
690
|
+
"""Walk a UNION-kind field's variants, gathering Checks and ModelChecks.
|
|
691
|
+
|
|
692
|
+
`prefix` is the terminal path the shape walker reached the `UnionRef`
|
|
693
|
+
at; the union's variant fields live directly under it. An `Iterated`
|
|
694
|
+
prefix means the union is reached through array or map iteration, so
|
|
695
|
+
variant gates are element-level and model constraints target that path.
|
|
696
|
+
|
|
697
|
+
`arm` is the outer union arm whose variant-specific field reached this
|
|
698
|
+
inner union. It tags any model constraints discovered here so they
|
|
699
|
+
aren't propagated to other arms' test modules.
|
|
700
|
+
"""
|
|
701
|
+
mapping = union_spec.discriminator_mapping or {}
|
|
702
|
+
value_by_class = {cls: value for value, cls in mapping.items()}
|
|
703
|
+
union_target = _model_constraint_target(prefix)
|
|
704
|
+
|
|
705
|
+
field_checks, field_model_checks = _field_checks_for_union(
|
|
706
|
+
union_spec, value_by_class, prefix=prefix, arm=arm
|
|
707
|
+
)
|
|
708
|
+
union_level_checks = _model_checks_for_union(
|
|
709
|
+
union_spec, value_by_class, union_target, arm=arm
|
|
710
|
+
)
|
|
711
|
+
exclusivity_checks = _exclusivity_checks_for_union(
|
|
712
|
+
union_spec, value_by_class, union_target, arm=arm
|
|
713
|
+
)
|
|
714
|
+
if union_level_checks or exclusivity_checks:
|
|
715
|
+
_guard_struct_nested_anchor(prefix, union_spec.name)
|
|
716
|
+
return field_checks, union_level_checks + field_model_checks + exclusivity_checks
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _model_constraint_target(prefix: FieldPath) -> FieldPath:
|
|
720
|
+
"""Where a model constraint's check should be anchored -- the prefix itself.
|
|
721
|
+
|
|
722
|
+
The check anchors exactly where the shape walker reached the constrained
|
|
723
|
+
model, so this is the identity on `prefix`:
|
|
724
|
+
|
|
725
|
+
- `Iterated` -- a sub-model reached through array or map iteration; the
|
|
726
|
+
renderer wraps the check in the iteration fold (`array_check` for an
|
|
727
|
+
array-reached model, `map_values_check` for a `dict[K, Model]` value
|
|
728
|
+
model), and field references become element-relative accessors.
|
|
729
|
+
- Struct-only `Direct` (e.g. `Details` reached at `Direct('details')`) --
|
|
730
|
+
a sub-model reached through a plain struct field; the renderer qualifies
|
|
731
|
+
every field reference with the struct prefix (`F.col("details.foo")`).
|
|
732
|
+
- Empty `Direct` -- a row-root constraint; field references are top-level
|
|
733
|
+
columns.
|
|
734
|
+
"""
|
|
735
|
+
return prefix
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _dispatch_model_constraints(
|
|
739
|
+
constraints: tuple[ModelConstraint, ...],
|
|
740
|
+
fields: list[FieldSpec],
|
|
741
|
+
*,
|
|
742
|
+
target: FieldPath = Direct(),
|
|
743
|
+
arm: str | None = None,
|
|
744
|
+
gate: FieldPath | None = None,
|
|
745
|
+
) -> list[ModelCheck]:
|
|
746
|
+
"""Dispatch model constraints to ModelChecks."""
|
|
747
|
+
return [
|
|
748
|
+
ModelCheck(descriptor=desc, target=target, arm=arm, gate=gate)
|
|
749
|
+
for mc in constraints
|
|
750
|
+
for desc in dispatch_model_constraint(mc, fields)
|
|
751
|
+
]
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _singleton_arm(values: tuple[str, ...]) -> str | None:
|
|
755
|
+
"""Return the sole arm in `values`, or None when there isn't exactly one.
|
|
756
|
+
|
|
757
|
+
No real schema today has a variant-specific field belonging to a
|
|
758
|
+
proper subset of arms (2-of-N): every variant-specific field is
|
|
759
|
+
declared on exactly one arm. If a future schema introduces a 2-of-N
|
|
760
|
+
field whose sub-model declares model constraints, this collapse
|
|
761
|
+
would broadcast those constraints to every arm (including the ones
|
|
762
|
+
the field doesn't belong to). `TestMultiArmVariantSourcesPolicy`
|
|
763
|
+
pins the current behaviour as a tombstone.
|
|
764
|
+
"""
|
|
765
|
+
return values[0] if len(values) == 1 else None
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _field_checks_for_union(
|
|
769
|
+
spec: UnionSpec,
|
|
770
|
+
value_by_class: dict[type[BaseModel], str],
|
|
771
|
+
prefix: FieldPath = Direct(),
|
|
772
|
+
*,
|
|
773
|
+
arm: str | None = None,
|
|
774
|
+
) -> tuple[list[Check], list[ModelCheck]]:
|
|
775
|
+
"""Build field checks for a union spec's annotated fields.
|
|
776
|
+
|
|
777
|
+
`arm` is the outer-union arm threaded through from an enclosing
|
|
778
|
+
`_recurse_into_union`. When present, every sub-model constraint
|
|
779
|
+
reached from here inherits that arm -- the inner union's own
|
|
780
|
+
discriminator is irrelevant to per-arm test filtering, which always
|
|
781
|
+
keys on the outermost union's discriminator.
|
|
782
|
+
"""
|
|
783
|
+
# A union reached through any iterated container (array or map element)
|
|
784
|
+
# is element-gated; only a row-/struct-level union uses a column gate.
|
|
785
|
+
guard_cls: type[Guard] = (
|
|
786
|
+
ElementGuard if isinstance(prefix, Iterated) else ColumnGuard
|
|
787
|
+
)
|
|
788
|
+
field_checks: list[Check] = []
|
|
789
|
+
model_checks: list[ModelCheck] = []
|
|
790
|
+
discriminator = spec.discriminator_field
|
|
791
|
+
for af in spec.annotated_fields:
|
|
792
|
+
values: tuple[str, ...] = ()
|
|
793
|
+
if af.variant_sources is not None and discriminator is not None:
|
|
794
|
+
values = tuple(
|
|
795
|
+
value_by_class[src]
|
|
796
|
+
for src in af.variant_sources
|
|
797
|
+
if src in value_by_class
|
|
798
|
+
)
|
|
799
|
+
# Outer arm dominates: when this is a nested union, every sub-model
|
|
800
|
+
# constraint discovered here belongs to the outer arm. Only the
|
|
801
|
+
# outermost union picks a `field_arm` from its own variant sources,
|
|
802
|
+
# and only when the field is variant-specific to a single arm.
|
|
803
|
+
field_arm = arm if arm is not None else _singleton_arm(values)
|
|
804
|
+
checks, sub_model_checks = _build_field_checks(
|
|
805
|
+
af.field_spec, prefix=prefix, arm=field_arm
|
|
806
|
+
)
|
|
807
|
+
model_checks.extend(sub_model_checks)
|
|
808
|
+
if values and discriminator is not None:
|
|
809
|
+
_guard_struct_nested_variant_fields(prefix, spec.name)
|
|
810
|
+
# Outer guards land first so the renderer composes
|
|
811
|
+
# outer-then-inner (e.g. a `ColumnGuard` from a parent union,
|
|
812
|
+
# then an `ElementGuard` from the nested union the field
|
|
813
|
+
# lives in).
|
|
814
|
+
guard: Guard = guard_cls(discriminator=discriminator, values=values)
|
|
815
|
+
if isinstance(guard, ElementGuard):
|
|
816
|
+
_guard_variant_field_past_element(prefix, checks, spec.name)
|
|
817
|
+
checks = [replace(ck, guards=(guard, *ck.guards)) for ck in checks]
|
|
818
|
+
field_checks.extend(checks)
|
|
819
|
+
return field_checks, model_checks
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _model_checks_for_union(
|
|
823
|
+
spec: UnionSpec,
|
|
824
|
+
arm_by_class: dict[type[BaseModel], str],
|
|
825
|
+
target: FieldPath = Direct(),
|
|
826
|
+
*,
|
|
827
|
+
arm: str | None = None,
|
|
828
|
+
) -> list[ModelCheck]:
|
|
829
|
+
"""Build ModelChecks for the union itself plus each member's own constraints.
|
|
830
|
+
|
|
831
|
+
When `arm` is None (top-level union): union-level constraints carry
|
|
832
|
+
`arm=None` because they apply regardless of which arm matches.
|
|
833
|
+
Member-class constraints (e.g. `@radio_group` on `RoadSegment`) are
|
|
834
|
+
tagged with the discriminator value mapped to that class so the test
|
|
835
|
+
renderer can confine them to the right per-arm test module.
|
|
836
|
+
|
|
837
|
+
When `arm` is set (nested union reached from an outer arm): every
|
|
838
|
+
check produced -- union-level and member-level -- inherits that outer
|
|
839
|
+
arm. The inner union's own discriminator is irrelevant to per-arm
|
|
840
|
+
test filtering, which always keys on the outermost union's
|
|
841
|
+
discriminator.
|
|
842
|
+
"""
|
|
843
|
+
model_checks = _dispatch_model_constraints(
|
|
844
|
+
spec.constraints,
|
|
845
|
+
spec.fields,
|
|
846
|
+
target=target,
|
|
847
|
+
arm=arm,
|
|
848
|
+
)
|
|
849
|
+
for member in spec.member_specs:
|
|
850
|
+
member_constraints = ModelConstraint.get_model_constraints(member.member_cls)
|
|
851
|
+
member_arm = arm if arm is not None else arm_by_class.get(member.member_cls)
|
|
852
|
+
model_checks.extend(
|
|
853
|
+
_dispatch_model_constraints(
|
|
854
|
+
member_constraints,
|
|
855
|
+
member.spec.fields,
|
|
856
|
+
target=target,
|
|
857
|
+
arm=member_arm,
|
|
858
|
+
)
|
|
859
|
+
)
|
|
860
|
+
return model_checks
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def _exclusivity_checks_for_union(
|
|
864
|
+
spec: UnionSpec,
|
|
865
|
+
value_by_class: dict[type[BaseModel], str],
|
|
866
|
+
target: FieldPath = Direct(),
|
|
867
|
+
*,
|
|
868
|
+
arm: str | None = None,
|
|
869
|
+
) -> list[ModelCheck]:
|
|
870
|
+
"""Generate forbid_if/require_if checks from union variant structure.
|
|
871
|
+
|
|
872
|
+
Unlike `dispatch_model_constraint` (which maps user-declared
|
|
873
|
+
`ModelConstraint` objects to descriptors), this synthesizes
|
|
874
|
+
`ForbidIf`/`RequireIf` descriptors directly from the union's variant
|
|
875
|
+
grouping. The input is a structural property of the union, not a
|
|
876
|
+
declared constraint, so there is no source `ModelConstraint` to
|
|
877
|
+
dispatch from.
|
|
878
|
+
|
|
879
|
+
`arm` is the outer-union arm threaded through when this union is
|
|
880
|
+
nested inside another. Inner exclusivity checks belong to that outer
|
|
881
|
+
arm rather than being broadcast to every arm.
|
|
882
|
+
"""
|
|
883
|
+
if spec.discriminator_mapping is None or spec.discriminator_field is None:
|
|
884
|
+
return []
|
|
885
|
+
|
|
886
|
+
all_values = set(spec.discriminator_mapping)
|
|
887
|
+
|
|
888
|
+
grouped: dict[str, set[type[BaseModel]]] = defaultdict(set)
|
|
889
|
+
required_by_field: dict[str, set[type[BaseModel]]] = defaultdict(set)
|
|
890
|
+
shape_by_field: dict[str, FieldShape] = {}
|
|
891
|
+
for af in spec.annotated_fields:
|
|
892
|
+
if af.variant_sources is None:
|
|
893
|
+
continue
|
|
894
|
+
name = af.field_spec.name
|
|
895
|
+
shape_by_field[name] = af.field_spec.shape
|
|
896
|
+
for src in af.variant_sources:
|
|
897
|
+
if src in value_by_class:
|
|
898
|
+
grouped[name].add(src)
|
|
899
|
+
if af.field_spec.is_required:
|
|
900
|
+
required_by_field[name].add(src)
|
|
901
|
+
|
|
902
|
+
def forbid_check(field_name: str, condition: FieldEqCondition | Not) -> ModelCheck:
|
|
903
|
+
return ModelCheck(
|
|
904
|
+
descriptor=ForbidIf(
|
|
905
|
+
field_names=(field_name,),
|
|
906
|
+
condition=condition,
|
|
907
|
+
field_shapes=forbid_if_field_shapes((field_name,), shape_by_field),
|
|
908
|
+
),
|
|
909
|
+
target=target,
|
|
910
|
+
arm=arm,
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
def require_check(field_name: str, condition: FieldEqCondition | Not) -> ModelCheck:
|
|
914
|
+
return ModelCheck(
|
|
915
|
+
descriptor=RequireIf(field_names=(field_name,), condition=condition),
|
|
916
|
+
target=target,
|
|
917
|
+
arm=arm,
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
checks: list[ModelCheck] = []
|
|
921
|
+
disc_field = spec.discriminator_field
|
|
922
|
+
for field_name, variant_classes in grouped.items():
|
|
923
|
+
variant_values = {value_by_class[cls] for cls in variant_classes}
|
|
924
|
+
excluded_values = all_values - variant_values
|
|
925
|
+
if not excluded_values:
|
|
926
|
+
continue
|
|
927
|
+
|
|
928
|
+
if len(variant_values) == 1 and len(excluded_values) > 1:
|
|
929
|
+
(sole_value,) = variant_values
|
|
930
|
+
checks.append(
|
|
931
|
+
forbid_check(field_name, Not(FieldEqCondition(disc_field, sole_value)))
|
|
932
|
+
)
|
|
933
|
+
else:
|
|
934
|
+
for exc_val in sorted(excluded_values):
|
|
935
|
+
checks.append(
|
|
936
|
+
forbid_check(field_name, FieldEqCondition(disc_field, exc_val))
|
|
937
|
+
)
|
|
938
|
+
|
|
939
|
+
required_classes = required_by_field[field_name]
|
|
940
|
+
required_values = {value_by_class[cls] for cls in required_classes}
|
|
941
|
+
for req_val in sorted(required_values):
|
|
942
|
+
checks.append(
|
|
943
|
+
require_check(field_name, FieldEqCondition(disc_field, req_val))
|
|
944
|
+
)
|
|
945
|
+
|
|
946
|
+
return checks
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def build_checks(
|
|
950
|
+
spec: ModelSpec,
|
|
951
|
+
) -> tuple[list[Check], list[ModelCheck]]:
|
|
952
|
+
"""Build all check IR for a feature spec.
|
|
953
|
+
|
|
954
|
+
Roots the walk at the empty `Direct()` and delegates to the same
|
|
955
|
+
helpers used at every nested level (`_recurse_into_union` for unions,
|
|
956
|
+
`_recurse_into_model` for models), so the row-root and nested cases
|
|
957
|
+
share one path.
|
|
958
|
+
"""
|
|
959
|
+
if isinstance(spec, UnionSpec):
|
|
960
|
+
return _recurse_into_union(spec)
|
|
961
|
+
return _recurse_into_model(spec)
|