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,708 @@
1
+ """Render Check / ModelCheck IR into generated conformance test modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, NamedTuple
6
+
7
+ from typing_extensions import assert_never
8
+
9
+ from overture.schema.system.field_path import (
10
+ ArraySegment,
11
+ Direct,
12
+ FieldPath,
13
+ Iterated,
14
+ MapProjection,
15
+ MapSegment,
16
+ )
17
+
18
+ from ..extraction.field import FieldShape, Primitive
19
+ from ..extraction.field_walk import has_array_layer, terminal_of
20
+ from ..extraction.specs import ModelSpec
21
+ from ..extraction.type_registry import primitive_spark_category
22
+ from ._primitive_fill import PRIMITIVE_FILL_TABLE
23
+ from ._render_common import (
24
+ disambiguate,
25
+ field_check_rows,
26
+ jinja_env,
27
+ model_check_rows,
28
+ py_literal,
29
+ schema_const_name,
30
+ )
31
+ from .check_ir import (
32
+ Check,
33
+ ColumnGuard,
34
+ ModelCheck,
35
+ )
36
+ from .constraint_dispatch import (
37
+ ExpressionDescriptor,
38
+ ForbidIf,
39
+ MinFieldsSet,
40
+ ModelConstraintDescriptor,
41
+ RadioGroup,
42
+ RequireAnyOf,
43
+ RequireAnyTrue,
44
+ RequireIf,
45
+ model_constraint_function,
46
+ model_mutation_function,
47
+ parse_field_eq,
48
+ require_bool_field_eq,
49
+ )
50
+ from .test_data.invalid_value import invalid_value
51
+ from .test_data.scaffold import (
52
+ generate_model_scaffold,
53
+ generate_scaffold,
54
+ leaf_list_depth,
55
+ )
56
+
57
+ __all__ = ["render_test_module"]
58
+
59
+
60
+ def _check_belongs_to_arm(check: Check, arm: str) -> bool:
61
+ """Return True when a Check applies to a given union arm.
62
+
63
+ The outermost union's discriminator surfaces as `ColumnGuard`s; inner
64
+ unions use `ElementGuard`s on a different discriminator field and are
65
+ irrelevant to arm filtering. A check belongs to *arm* when every
66
+ `ColumnGuard` admits it (guards are AND-composed).
67
+ """
68
+ return all(arm in g.values for g in check.guards if isinstance(g, ColumnGuard))
69
+
70
+
71
+ def _model_check_belongs_to_arm(check: ModelCheck, arm: str) -> bool:
72
+ """Return True when a ModelCheck applies to a given union arm.
73
+
74
+ `ModelCheck.arm` is `None` for union-level constraints (which apply
75
+ regardless of discriminator) and set to a discriminator value for
76
+ constraints contributed by one specific member class.
77
+ """
78
+ return check.arm is None or check.arm == arm
79
+
80
+
81
+ def _innermost_iter_segment(target: Iterated) -> ArraySegment | MapSegment:
82
+ """Return the innermost (leaf-most) named iterating segment of *target*."""
83
+ return target.iter_frames[-1][1]
84
+
85
+
86
+ def _first_iter_segment(target: Iterated) -> ArraySegment | MapSegment:
87
+ """Return the outermost (first) named iterating segment of *target*."""
88
+ return target.iter_frames[0][1]
89
+
90
+
91
+ def _is_map_target(target: FieldPath) -> bool:
92
+ """True when *target* reaches its value map-first: no array precedes the map.
93
+
94
+ Reads the FIRST iterating frame, matching `check_builder`
95
+ (`_model_constraint_target` and `generate_model_scaffold` both key off
96
+ the outermost frame). A bare or struct-prefixed map projection
97
+ (`names.common{key}`, `sources.license_priority{value}`) is a map target,
98
+ corrupted in place. A map reached only after array iteration
99
+ (`items[].tags{value}`) is array-first, not a map target: its mutation
100
+ descends the array via `set_at_path`'s map grammar. Reading the innermost
101
+ frame instead would misroute the array-first case to a top-level map
102
+ mutation on the array column.
103
+ """
104
+ return isinstance(target, Iterated) and isinstance(
105
+ _first_iter_segment(target), MapSegment
106
+ )
107
+
108
+
109
+ def _is_sole_map_projection(target: Iterated) -> bool:
110
+ """True when *target* is a bare map projection with nothing trailing.
111
+
112
+ `names.common{key}`, `sources.license_priority{value}` -- one map frame,
113
+ no struct leaf, no further iteration. These corrupt the map's single
114
+ entry in place via `mutate_map_key` / `mutate_map_value`.
115
+ """
116
+ return not target.leaf and not target.iter_struct_paths
117
+
118
+
119
+ def _map_trailing_iteration_only(target: Iterated) -> bool:
120
+ """True when a map projection is followed only by anonymous iteration.
121
+
122
+ `subs{value}[]` (dict[K, list[X]]) and `subs{value}{value}`
123
+ (dict[K, dict[K2, X]]) descend the map value into a container and reach
124
+ the constrained scalar through anonymous `[]` / `{value}` peels alone --
125
+ no named struct navigation, no struct leaf. `set_at_path` with the full
126
+ path peels each trailing container, so the mutation writes the scalar at
127
+ the located slot. A struct leaf (`subs{value}.label`) or a named further
128
+ container (`subs{value}.items[]`) is excluded: it needs navigation
129
+ `set_at_path`'s map-first routing does not cover here.
130
+ """
131
+ return (
132
+ not target.leaf
133
+ and bool(target.iter_struct_paths)
134
+ and all(not prefix for prefix in target.iter_struct_paths)
135
+ )
136
+
137
+
138
+ def render_test_module(
139
+ model_name: str,
140
+ field_checks: list[Check],
141
+ model_checks: list[ModelCheck],
142
+ *,
143
+ expression_import: str,
144
+ base_row_sparse: dict[str, Any] | None = None,
145
+ base_row_populated: dict[str, Any] | None = None,
146
+ arm: str | None = None,
147
+ spec: ModelSpec | None = None,
148
+ ) -> str:
149
+ """Render a complete pytest test file for a model's validation checks.
150
+
151
+ Arm filtering uses two complementary signals. A field check's
152
+ `ColumnGuard`s identify the arms it belongs to. A model check's `arm`
153
+ attribute is set for member-specific constraints and `None` for
154
+ union-level constraints (which apply to every arm).
155
+
156
+ Both label-collision passes run over the *unfiltered* check lists so
157
+ they agree with the expression module, which `renderer` emits once
158
+ across every arm. Each scenario builder takes `arm` and drops rows
159
+ that fall outside it after their suffixes are assigned; computing a
160
+ suffix over an arm subset would let it hide a collision the shared
161
+ module still carries, producing an `expected_field` the module never
162
+ emits.
163
+ """
164
+ model_scenarios, used_mutation_fns = _render_model_scenarios(
165
+ model_name, model_checks, spec, arm
166
+ )
167
+ field_scenarios, field_helpers = _render_field_check_scenarios(
168
+ model_name, field_checks, spec, arm
169
+ )
170
+ used_mutation_fns |= field_helpers - {"set_at_path"}
171
+
172
+ sparse_repr = py_literal(base_row_sparse) if base_row_sparse is not None else "{}"
173
+ populated_repr = (
174
+ py_literal(base_row_populated) if base_row_populated is not None else "{}"
175
+ )
176
+
177
+ all_scenarios = field_scenarios + model_scenarios
178
+
179
+ template = jinja_env().get_template("test_module.py.jinja2")
180
+ return template.render(
181
+ model_name=model_name,
182
+ schema_name=schema_const_name(model_name),
183
+ mutation_imports=sorted(used_mutation_fns),
184
+ needs_set_at_path="set_at_path" in field_helpers,
185
+ base_row_sparse=sparse_repr,
186
+ base_row_populated=populated_repr,
187
+ scenarios=all_scenarios,
188
+ expression_import=expression_import,
189
+ )
190
+
191
+
192
+ def _scenario_entry(
193
+ *,
194
+ scenario_id: str,
195
+ scaffold: dict[str, Any],
196
+ mutate_expr: str,
197
+ expected_field: str,
198
+ expected_check: str,
199
+ valid_scaffold: dict[str, Any] | None = None,
200
+ ) -> list[tuple[str, str]]:
201
+ """Build a rendered Scenario kwargs list for the test_module template.
202
+
203
+ `valid_scaffold` is emitted only when set, so scenarios without one keep
204
+ the dataclass default (a vacuous base-row copy for the `::valid` row).
205
+ """
206
+ entry = [
207
+ ("id", py_literal(scenario_id)),
208
+ ("scaffold", py_literal(scaffold)),
209
+ ("mutate", mutate_expr),
210
+ ("expected_field", py_literal(expected_field)),
211
+ ("expected_check", py_literal(expected_check)),
212
+ ]
213
+ if valid_scaffold is not None:
214
+ entry.append(("valid_scaffold", py_literal(valid_scaffold)))
215
+ return entry
216
+
217
+
218
+ class _MutateExpr(NamedTuple):
219
+ """One rendered `mutate=` expression and the helper it imports.
220
+
221
+ `helper` is `None` when the expression is a literal `set_at_path`
222
+ call (the default), and otherwise names a `mutate_*` helper from
223
+ `tests/_support/mutations.py` to import.
224
+ """
225
+
226
+ expr: str
227
+ helper: str | None
228
+
229
+
230
+ def _field_mutate_expr(
231
+ check: Check, desc: ExpressionDescriptor, spec: ModelSpec | None
232
+ ) -> _MutateExpr:
233
+ """Render the `mutate=` expression for one field-check descriptor.
234
+
235
+ A sole map projection corrupts the map's single valid entry via
236
+ `mutate_map_key` / `mutate_map_value`; `check_struct_unique` calls
237
+ `mutate_unique_items` at the target path; every other descriptor --
238
+ including a map value that is itself an iterated container
239
+ (`subs{value}[]`, `subs{value}{value}`) -- injects a constraint-violating
240
+ literal via `set_at_path`, whose path grammar peels each trailing
241
+ container to the constrained scalar.
242
+ """
243
+ target = check.target
244
+ if _is_map_target(target):
245
+ assert isinstance(target, Iterated)
246
+ if _is_sole_map_projection(target):
247
+ return _map_field_mutate_expr(target, desc)
248
+ if not _map_trailing_iteration_only(target):
249
+ raise NotImplementedError(
250
+ f"map-first field check {target!r} descends a struct leaf or a "
251
+ f"named container after the map; no conformance mutation covers it"
252
+ )
253
+ # Iteration-only trailing: fall through to set_at_path with the full
254
+ # path, which descends the map value and peels the trailing containers.
255
+ target_repr = py_literal(str(target))
256
+ if desc.function == "check_struct_unique":
257
+ return _MutateExpr(
258
+ f"lambda row: mutate_unique_items(row, {target_repr})",
259
+ "mutate_unique_items",
260
+ )
261
+ iv_val = _wrap_for_list_leaf(invalid_value(desc), check, spec)
262
+ return _MutateExpr(f"set_at_path({target_repr}, {py_literal(iv_val)})", None)
263
+
264
+
265
+ def _map_field_mutate_expr(target: Iterated, desc: ExpressionDescriptor) -> _MutateExpr:
266
+ """Render the `mutate=` for a sole map-projection field check.
267
+
268
+ `mutate_map_key` / `mutate_map_value` corrupt the map's single valid entry
269
+ in place (`names.common{key}`, `sources.license_priority{value}`). The
270
+ caller guarantees a sole projection (`_is_sole_map_projection`); a map
271
+ value that iterates further routes to `set_at_path` instead.
272
+ """
273
+ seg = _first_iter_segment(target)
274
+ assert isinstance(seg, MapSegment)
275
+ helper = (
276
+ "mutate_map_key" if seg.projection is MapProjection.KEY else "mutate_map_value"
277
+ )
278
+ col_repr = py_literal(target.outer_column)
279
+ iv_repr = py_literal(invalid_value(desc))
280
+ return _MutateExpr(f"lambda row: {helper}(row, {col_repr}, {iv_repr})", helper)
281
+
282
+
283
+ def _render_field_check_scenarios(
284
+ model_name: str,
285
+ field_checks: list[Check],
286
+ spec: ModelSpec | None,
287
+ arm: str | None,
288
+ ) -> tuple[list[list[tuple[str, str]]], set[str]]:
289
+ """Render Scenario entries for field-level checks.
290
+
291
+ Returns the entries and the set of mutation helper names referenced
292
+ by them, mirroring `_render_model_scenarios`. `field_check_rows`
293
+ assigns collision suffixes over the unfiltered list; this drops rows
294
+ outside `arm` afterward so per-arm modules carry the labels the shared
295
+ expression module emits. Pass `None` to include all arms.
296
+ """
297
+ rows = [
298
+ row
299
+ for row in field_check_rows(field_checks)
300
+ if arm is None or _check_belongs_to_arm(row.check, arm)
301
+ ]
302
+ scenario_ids = disambiguate(
303
+ [f"{model_name}::{row.label}:{row.name}" for row in rows]
304
+ )
305
+
306
+ entries: list[list[tuple[str, str]]] = []
307
+ used_helpers: set[str] = set()
308
+ for row, scenario_id in zip(rows, scenario_ids, strict=True):
309
+ desc = row.check.descriptors[row.descriptor_idx]
310
+ scaffold = generate_scaffold(row.check, spec) if spec is not None else {}
311
+ # For an `X | Literal[c]` field, seed the literal alternative at the
312
+ # target so the `::valid` row proves the check accepts it.
313
+ valid_scaffold: dict[str, Any] | None = None
314
+ if desc.allow_literals and spec is not None:
315
+ # generate_scaffold shapes the bare literal to the field's list
316
+ # nesting, so pass it unwrapped.
317
+ valid_scaffold = generate_scaffold(
318
+ row.check, spec, leaf_value=desc.allow_literals[0]
319
+ )
320
+ try:
321
+ mutate = _field_mutate_expr(row.check, desc, spec)
322
+ except ValueError as exc:
323
+ raise ValueError(
324
+ f"Cannot render mutate expression for {scenario_id}: {exc}"
325
+ ) from exc
326
+ used_helpers.add(mutate.helper or "set_at_path")
327
+ entries.append(
328
+ _scenario_entry(
329
+ scenario_id=scenario_id,
330
+ scaffold=scaffold,
331
+ mutate_expr=mutate.expr,
332
+ expected_field=row.label,
333
+ expected_check=row.name,
334
+ valid_scaffold=valid_scaffold,
335
+ )
336
+ )
337
+
338
+ return entries, used_helpers
339
+
340
+
341
+ def _checks_array_element(check: Check) -> bool:
342
+ """True when the check fires on each element of an array target directly.
343
+
344
+ The check target ends at the array (`leaf=()`), so the mutation
345
+ replaces an array element rather than a struct field on one. For
346
+ these checks, a `None` invalid value still needs list wrapping; for
347
+ nested struct fields, `None` already sits at the right level.
348
+ """
349
+ target = check.target
350
+ return (
351
+ isinstance(target, Iterated)
352
+ and isinstance(_innermost_iter_segment(target), ArraySegment)
353
+ and not target.leaf
354
+ )
355
+
356
+
357
+ def _wrap_for_list_leaf(
358
+ value: object,
359
+ check: Check,
360
+ spec: ModelSpec | None,
361
+ ) -> object:
362
+ """Wrap a scalar invalid value to match the field's list nesting depth."""
363
+ if spec is None or isinstance(value, list):
364
+ return value
365
+ if value is None and not _checks_array_element(check):
366
+ return value
367
+ depth = leaf_list_depth(check.target, spec)
368
+ for _ in range(depth):
369
+ value = [value]
370
+ return value
371
+
372
+
373
+ def _render_model_scenarios(
374
+ model_name: str,
375
+ model_checks: list[ModelCheck],
376
+ spec: ModelSpec | None,
377
+ arm: str | None,
378
+ ) -> tuple[list[list[tuple[str, str]]], set[str]]:
379
+ """Render Scenario entries for model-level checks.
380
+
381
+ Returns the entries and the set of mutation helper names referenced
382
+ by them, so the caller can scope the test module's imports.
383
+ `model_check_rows` assigns collision suffixes over the unfiltered
384
+ list; this drops rows outside `arm` afterward so per-arm modules carry
385
+ the labels the shared expression module emits. Pass `None` to include
386
+ all arms.
387
+
388
+ The scenario id's trailing index counts surviving rows within the arm
389
+ (`enumerate` after the filter), not the row's position in the
390
+ unfiltered list -- it is a test-internal disambiguator with no
391
+ cross-module contract, kept contiguous per arm.
392
+ """
393
+ entries: list[list[tuple[str, str]]] = []
394
+ used_mutation_fns: set[str] = set()
395
+
396
+ rows = [
397
+ row
398
+ for row in model_check_rows(model_checks)
399
+ if arm is None or _model_check_belongs_to_arm(row.check, arm)
400
+ ]
401
+ for scenario_idx, row in enumerate(rows):
402
+ mc = row.check
403
+ desc = mc.descriptor
404
+ mutation_fn = model_mutation_function(desc)
405
+ scenario_id = f"{model_name}::model:{row.name}:{scenario_idx}"
406
+ scaffold = generate_model_scaffold(mc, spec) if spec is not None else {}
407
+
408
+ try:
409
+ call = _render_mutation_call(mutation_fn, desc, mc)
410
+ except ValueError as exc:
411
+ raise ValueError(
412
+ f"Cannot render mutation call for {scenario_id}: {exc}"
413
+ ) from exc
414
+ mutate_expr = f"lambda row: {call}"
415
+ used_mutation_fns.add(mutation_fn)
416
+ entries.append(
417
+ _scenario_entry(
418
+ scenario_id=scenario_id,
419
+ scaffold=scaffold,
420
+ mutate_expr=mutate_expr,
421
+ expected_field=row.label,
422
+ expected_check=row.name,
423
+ )
424
+ )
425
+
426
+ return entries, used_mutation_fns
427
+
428
+
429
+ def _reject_non_row_root_target(target: FieldPath, mutation_fn: str) -> None:
430
+ """Raise unless *target* is the row root (empty `Direct`).
431
+
432
+ `mutate_radio_group` and `mutate_require_any_true` take no navigation
433
+ kwarg, so they only reach fields at the row root. An `Iterated` target
434
+ (array/map) or a struct-nested `Direct` target (a model reached through a
435
+ plain struct field) would need the constraint's fields nulled/set at a
436
+ nested node the mutation can't reach, so it raises rather than silently
437
+ corrupting top-level columns. No live schema declares `radio_group` or
438
+ `require_any_true` on a nested submodel; supporting one means teaching
439
+ these mutations an `element_path` descent.
440
+ """
441
+ if isinstance(target, Iterated) or (isinstance(target, Direct) and target.segments):
442
+ raise ValueError(
443
+ f"{mutation_fn} does not support a nested target "
444
+ f"(target={target!r}); it reaches only row-root fields"
445
+ )
446
+
447
+
448
+ def _render_mutation_call(
449
+ mutation_fn: str,
450
+ desc: ModelConstraintDescriptor,
451
+ check: ModelCheck,
452
+ ) -> str:
453
+ """Render a model mutation helper function call."""
454
+ fields_repr = py_literal(list(desc.field_names))
455
+
456
+ match desc:
457
+ case RequireAnyTrue():
458
+ # Carries `conditions`, not `field_names`: the mutation disables
459
+ # every condition via a per-field `{field: value}` dict rather than
460
+ # the shared field-name list the other descriptors pass.
461
+ _reject_non_row_root_target(check.target, "mutate_require_any_true")
462
+ return _render_require_any_true_mutation_call(mutation_fn, desc)
463
+ case RequireIf() | ForbidIf():
464
+ return _render_conditional_mutation_call(
465
+ mutation_fn, desc, check, fields_repr
466
+ )
467
+ case RadioGroup():
468
+ _reject_non_row_root_target(check.target, "mutate_radio_group")
469
+ return f"{mutation_fn}(row, {fields_repr})"
470
+ case RequireAnyOf() | MinFieldsSet():
471
+ parts = _iter_kwargs_leaf(check, mutation_fn)
472
+ suffix = ", " + ", ".join(parts) if parts else ""
473
+ return f"{mutation_fn}(row, {fields_repr}{suffix})"
474
+ assert_never(desc)
475
+
476
+
477
+ def _render_conditional_mutation_call(
478
+ mutation_fn: str,
479
+ desc: RequireIf | ForbidIf,
480
+ check: ModelCheck,
481
+ fields_repr: str,
482
+ ) -> str:
483
+ """Render a mutate_require_if or mutate_forbid_if call."""
484
+ parsed = parse_field_eq(desc.condition)
485
+ fn = model_constraint_function(desc)
486
+ if parsed is None:
487
+ raise ValueError(
488
+ f"{fn} condition {desc.condition!r} is not a "
489
+ "FieldEqCondition or Not(FieldEqCondition); cannot render "
490
+ f"{mutation_fn} call"
491
+ )
492
+ fill = _render_fill_values(desc) if isinstance(desc, ForbidIf) else None
493
+ kwarg_parts: list[str] = []
494
+ if parsed.negated:
495
+ kwarg_parts.append("negate=True")
496
+ if fill:
497
+ kwarg_parts.append(f"fill_values={fill}")
498
+ kwarg_parts.extend(_iter_kwargs_inner(check, mutation_fn))
499
+ suffix = ", " + ", ".join(kwarg_parts) if kwarg_parts else ""
500
+ return (
501
+ f"{mutation_fn}(row, {fields_repr}, "
502
+ f"{py_literal(parsed.field_name)}, {py_literal(parsed.value)}{suffix})"
503
+ )
504
+
505
+
506
+ def _render_require_any_true_mutation_call(
507
+ mutation_fn: str, desc: RequireAnyTrue
508
+ ) -> str:
509
+ """Render a `mutate_require_any_true` call.
510
+
511
+ Passes a `{field: disabling_value}` dict that makes every condition false,
512
+ so the invalid row violates `require_any_true` and nothing else. Conditions
513
+ are positive boolean equalities (`require_bool_field_eq`), so each field's
514
+ disabling value is the negation of the boolean the condition tests for.
515
+ """
516
+ parsed = [require_bool_field_eq(c) for c in desc.conditions]
517
+ items = ", ".join(
518
+ f"{py_literal(p.field_name)}: {py_literal(not p.value)}" for p in parsed
519
+ )
520
+ return f"{mutation_fn}(row, {{{items}}})"
521
+
522
+
523
+ def _fill_value_literal(shape: FieldShape) -> str:
524
+ """Return a Python source literal for a type-appropriate non-null fill value."""
525
+ if has_array_layer(shape):
526
+ return "[{}]"
527
+ terminal = terminal_of(shape)
528
+ if isinstance(terminal, Primitive):
529
+ category = primitive_spark_category(terminal.base_type)
530
+ if category in PRIMITIVE_FILL_TABLE:
531
+ return PRIMITIVE_FILL_TABLE[category][0]
532
+ raise ValueError(f"unhandled Primitive base_type: {terminal.base_type!r}")
533
+ return "{}"
534
+
535
+
536
+ def _render_fill_values(desc: ForbidIf) -> str | None:
537
+ """Render a `fill_values` dict literal for non-string ForbidIf targets."""
538
+ if not desc.field_shapes:
539
+ return None
540
+ items = [
541
+ f"{py_literal(name)}: {_fill_value_literal(shape)}"
542
+ for name, shape in desc.field_shapes
543
+ ]
544
+ return "{" + ", ".join(items) + "}"
545
+
546
+
547
+ def _composite_element_path_kwargs(target: Iterated) -> list[str]:
548
+ """The `element_path=` kwarg carrying *target*'s full mixed map/array descent.
549
+
550
+ No scalar `array_path` / `map_path` expresses a container-after-container
551
+ boundary (a map value that is a list, or a map nested under array
552
+ iteration). The mutation helpers walk the full path generically, so emit
553
+ it verbatim. Every map frame must be a VALUE projection -- a model can't sit
554
+ on a map key -- so a KEY frame raises here rather than emitting a path the
555
+ walker would reject only at runtime (matching `_map_kwargs`'s codegen-time
556
+ guard for the map-first case).
557
+ """
558
+ for _prefix, seg in target.iter_frames:
559
+ if isinstance(seg, MapSegment) and seg.projection is not MapProjection.VALUE:
560
+ raise ValueError(
561
+ f"element_path cannot target a map key (target={target!r}); a "
562
+ "model-level constraint on a map key is not representable as a row"
563
+ )
564
+ return [f'element_path="{target}"']
565
+
566
+
567
+ def _array_first_map_kwargs(target: Iterated) -> list[str] | None:
568
+ """Composite kwargs when a map value sits under array iteration, else None.
569
+
570
+ Called on the array-first branch (the first frame is an `ArraySegment`).
571
+ A `MapSegment` anywhere in the frames means the target reaches a
572
+ `dict[K, Model]` value nested under array iteration (e.g.
573
+ `items[].configs{value}`); no scalar array/inner kwarg expresses the map
574
+ boundary, so emit the composite descent path. A pure-array target has no
575
+ map frame and keeps its existing scalar kwargs (returns None).
576
+ """
577
+ if any(isinstance(seg, MapSegment) for _prefix, seg in target.iter_frames):
578
+ return _composite_element_path_kwargs(target)
579
+ return None
580
+
581
+
582
+ def _map_kwargs(target: Iterated, mutation_fn: str, *, allow_leaf: bool) -> list[str]:
583
+ """Mutation kwargs for a `dict[K, Model]` value-model constraint.
584
+
585
+ Emits `map_path=...` (the map column) and, when `allow_leaf`, an
586
+ optional single-segment `struct_path=...` for a sub-model reached
587
+ through one struct field inside the value model -- the map analogue of
588
+ `_iter_kwargs_leaf`'s array `struct_path`. A KEY projection is
589
+ unrepresentable (a model can't be a dict key) and raises. A map value that
590
+ is itself iterated (`dict[K, list[Model]]`, target `subs{value}[]`) folds
591
+ its trailing container into this same named frame, so `iter_struct_paths`
592
+ is non-empty; the map value is a container, not the model, so emit the
593
+ composite descent path the mutation walks instead of `map_path=...`. A
594
+ multi-segment leaf, or any leaf when `allow_leaf` is False, raises.
595
+ """
596
+ seg = _first_iter_segment(target)
597
+ assert isinstance(seg, MapSegment)
598
+ if seg.projection is not MapProjection.VALUE:
599
+ raise ValueError(
600
+ f"{mutation_fn} cannot target a map key (target={target!r}); a "
601
+ "model-level constraint on a map key is not representable as a row"
602
+ )
603
+ if target.iter_struct_paths:
604
+ return _composite_element_path_kwargs(target)
605
+ kwargs = [f'map_path="{target.outer_column}"']
606
+ leaf = target.leaf
607
+ if leaf:
608
+ if not allow_leaf:
609
+ raise ValueError(
610
+ f"{mutation_fn} does not accept a map-value leaf (leaf={leaf!r})"
611
+ )
612
+ if len(leaf) > 1:
613
+ raise ValueError(
614
+ f"multi-segment map-value leaf {leaf!r} not supported by "
615
+ f"{mutation_fn} (struct_path must be a single segment)"
616
+ )
617
+ kwargs.append(f'struct_path="{leaf[0]}"')
618
+ return kwargs
619
+
620
+
621
+ def _struct_nested_kwargs(target: Direct) -> list[str]:
622
+ """Container kwargs for a model constraint on a struct-nested submodel.
623
+
624
+ A row-root constraint (empty `Direct`) needs no navigation and yields no
625
+ kwargs. A model reached through one or more plain struct fields yields
626
+ `element_path=...` -- the pure-struct descent (`_descend_to_targets` in
627
+ `mutations.py`) that scaffolds each struct on the way and applies the
628
+ mutation to the constrained model, mirroring how iterated targets pass
629
+ `array_path` / `map_path`.
630
+ """
631
+ return [f'element_path="{target}"'] if target.segments else []
632
+
633
+
634
+ def _iter_kwargs_leaf(check: ModelCheck, mutation_fn: str) -> list[str]:
635
+ """Container kwargs for mutations accepting `struct_path` (a trailing leaf).
636
+
637
+ For an array target, yields `array_path=...` and optionally
638
+ `struct_path=...`; inner array iteration is rejected -- these mutations
639
+ consume only the outermost array level. For a map target (a
640
+ `dict[K, Model]` value-model constraint), delegates to `_map_kwargs`,
641
+ which yields `map_path=...` and an optional single-segment `struct_path`.
642
+ A struct-nested `Direct` target (a model reached through a plain struct
643
+ field) yields `element_path=...`, the pure-struct descent the mutation
644
+ walks to reach the constrained model.
645
+ """
646
+ target = check.target
647
+ if isinstance(target, Direct):
648
+ return _struct_nested_kwargs(target)
649
+ if isinstance(_first_iter_segment(target), MapSegment):
650
+ return _map_kwargs(target, mutation_fn, allow_leaf=True)
651
+ composite = _array_first_map_kwargs(target)
652
+ if composite is not None:
653
+ return composite
654
+ if target.iter_struct_paths:
655
+ raise ValueError(
656
+ f"{mutation_fn} does not accept inner_array_path "
657
+ f"(inner struct paths={target.iter_struct_paths!r})"
658
+ )
659
+
660
+ kwargs = [f'array_path="{target.outer_column}"']
661
+ if target.leaf:
662
+ if len(target.leaf) > 1:
663
+ raise ValueError(
664
+ f"multi-segment leaf_path {target.leaf!r} not supported by "
665
+ f"{mutation_fn} (struct_path must be a single segment)"
666
+ )
667
+ kwargs.append(f'struct_path="{target.leaf[0]}"')
668
+ return kwargs
669
+
670
+
671
+ def _iter_kwargs_inner(check: ModelCheck, mutation_fn: str) -> list[str]:
672
+ """Container kwargs for mutations accepting `inner_array_path`.
673
+
674
+ For an array target, yields `array_path=...` and optionally
675
+ `inner_array_path=...`; a trailing leaf path is rejected -- these
676
+ mutations target an inner array directly, not a struct field on its
677
+ elements. For a map target, delegates to `_map_kwargs` (no leaf: a map
678
+ value has no inner array layer to address). A struct-nested `Direct`
679
+ target yields `element_path=...` (the pure-struct descent to the model).
680
+ """
681
+ target = check.target
682
+ if isinstance(target, Direct):
683
+ return _struct_nested_kwargs(target)
684
+ if isinstance(_first_iter_segment(target), MapSegment):
685
+ return _map_kwargs(target, mutation_fn, allow_leaf=False)
686
+ composite = _array_first_map_kwargs(target)
687
+ if composite is not None:
688
+ return composite
689
+ if target.leaf:
690
+ raise ValueError(
691
+ f"{mutation_fn} does not accept struct_path (leaf_path={target.leaf!r})"
692
+ )
693
+
694
+ kwargs = [f'array_path="{target.outer_column}"']
695
+ if target.iter_struct_paths:
696
+ if len(target.iter_struct_paths) > 1:
697
+ raise ValueError(
698
+ f"multi-level inner struct paths {target.iter_struct_paths!r} not "
699
+ f"supported by {mutation_fn} (inner_array_path consumes one iteration)"
700
+ )
701
+ if not target.iter_struct_paths[0]:
702
+ raise ValueError(
703
+ f"empty inner struct path not supported by {mutation_fn} "
704
+ f"(target={target!r}); nested-iteration arrays without "
705
+ f"intermediate struct fields cannot be addressed via inner_array_path"
706
+ )
707
+ kwargs.append(f'inner_array_path="{".".join(target.iter_struct_paths[0])}"')
708
+ return kwargs