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.
Files changed (61) hide show
  1. overture/schema/codegen/__init__.py +1 -0
  2. overture/schema/codegen/cli.py +228 -0
  3. overture/schema/codegen/extraction/__init__.py +0 -0
  4. overture/schema/codegen/extraction/docstring.py +46 -0
  5. overture/schema/codegen/extraction/enum_extraction.py +40 -0
  6. overture/schema/codegen/extraction/examples.py +367 -0
  7. overture/schema/codegen/extraction/field.py +172 -0
  8. overture/schema/codegen/extraction/field_constraints.py +185 -0
  9. overture/schema/codegen/extraction/field_walk.py +275 -0
  10. overture/schema/codegen/extraction/length_constraints.py +49 -0
  11. overture/schema/codegen/extraction/literal_alternatives.py +26 -0
  12. overture/schema/codegen/extraction/model_constraints.py +252 -0
  13. overture/schema/codegen/extraction/model_extraction.py +240 -0
  14. overture/schema/codegen/extraction/newtype_extraction.py +73 -0
  15. overture/schema/codegen/extraction/numeric_extraction.py +74 -0
  16. overture/schema/codegen/extraction/pydantic_extraction.py +33 -0
  17. overture/schema/codegen/extraction/specs.py +295 -0
  18. overture/schema/codegen/extraction/type_analyzer.py +693 -0
  19. overture/schema/codegen/extraction/type_registry.py +137 -0
  20. overture/schema/codegen/extraction/union_extraction.py +270 -0
  21. overture/schema/codegen/layout/__init__.py +0 -0
  22. overture/schema/codegen/layout/module_layout.py +139 -0
  23. overture/schema/codegen/layout/type_collection.py +122 -0
  24. overture/schema/codegen/markdown/__init__.py +0 -0
  25. overture/schema/codegen/markdown/link_computation.py +70 -0
  26. overture/schema/codegen/markdown/path_assignment.py +114 -0
  27. overture/schema/codegen/markdown/pipeline.py +198 -0
  28. overture/schema/codegen/markdown/renderer.py +641 -0
  29. overture/schema/codegen/markdown/reverse_references.py +169 -0
  30. overture/schema/codegen/markdown/templates/_used_by.md.jinja2 +10 -0
  31. overture/schema/codegen/markdown/templates/enum.md.jinja2 +13 -0
  32. overture/schema/codegen/markdown/templates/feature.md.jinja2 +45 -0
  33. overture/schema/codegen/markdown/templates/geometric.md.jinja2 +11 -0
  34. overture/schema/codegen/markdown/templates/newtype.md.jinja2 +17 -0
  35. overture/schema/codegen/markdown/templates/numeric.md.jinja2 +27 -0
  36. overture/schema/codegen/markdown/templates/pydantic_type.md.jinja2 +8 -0
  37. overture/schema/codegen/markdown/type_format.py +383 -0
  38. overture/schema/codegen/py.typed +0 -0
  39. overture/schema/codegen/pyspark/__init__.py +1 -0
  40. overture/schema/codegen/pyspark/_primitive_fill.py +23 -0
  41. overture/schema/codegen/pyspark/_render_common.py +477 -0
  42. overture/schema/codegen/pyspark/check_builder.py +961 -0
  43. overture/schema/codegen/pyspark/check_ir.py +223 -0
  44. overture/schema/codegen/pyspark/constraint_dispatch.py +753 -0
  45. overture/schema/codegen/pyspark/pipeline.py +220 -0
  46. overture/schema/codegen/pyspark/renderer.py +816 -0
  47. overture/schema/codegen/pyspark/schema_builder.py +187 -0
  48. overture/schema/codegen/pyspark/templates/_check_function.py.jinja2 +10 -0
  49. overture/schema/codegen/pyspark/templates/model_module.py.jinja2 +83 -0
  50. overture/schema/codegen/pyspark/templates/test_module.py.jinja2 +129 -0
  51. overture/schema/codegen/pyspark/test_data/__init__.py +9 -0
  52. overture/schema/codegen/pyspark/test_data/base_row.py +835 -0
  53. overture/schema/codegen/pyspark/test_data/constraint_values.py +203 -0
  54. overture/schema/codegen/pyspark/test_data/invalid_value.py +105 -0
  55. overture/schema/codegen/pyspark/test_data/scaffold.py +390 -0
  56. overture/schema/codegen/pyspark/test_renderer.py +708 -0
  57. overture/schema/codegen/spec_discovery.py +66 -0
  58. overture_schema_codegen-0.1.1.dev0.dist-info/METADATA +13 -0
  59. overture_schema_codegen-0.1.1.dev0.dist-info/RECORD +61 -0
  60. overture_schema_codegen-0.1.1.dev0.dist-info/WHEEL +4 -0
  61. 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)