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,816 @@
1
+ """Render Check / ModelCheck IR into complete Python modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass
8
+ from enum import Enum
9
+
10
+ from overture.schema.system.field_path import (
11
+ ArraySegment,
12
+ Direct,
13
+ FieldPath,
14
+ Iterated,
15
+ MapProjection,
16
+ MapSegment,
17
+ )
18
+ from overture.schema.system.geometric import GeometryType
19
+
20
+ from ._render_common import (
21
+ FieldCheckRow,
22
+ ModelCheckRow,
23
+ field_check_rows,
24
+ jinja_env,
25
+ map_runtime_helper,
26
+ model_check_rows,
27
+ py_literal,
28
+ sanitize_field_name,
29
+ schema_const_name,
30
+ tuple_literal,
31
+ )
32
+ from .check_ir import (
33
+ Check,
34
+ ColumnGuard,
35
+ ElementGuard,
36
+ ModelCheck,
37
+ )
38
+ from .constraint_dispatch import (
39
+ ExpressionDescriptor,
40
+ FieldEq,
41
+ ForbidIf,
42
+ MinFieldsSet,
43
+ RadioGroup,
44
+ RequireAnyOf,
45
+ RequireAnyTrue,
46
+ RequireIf,
47
+ model_constraint_function,
48
+ require_field_eq,
49
+ )
50
+ from .schema_builder import SHARED_TYPE_REFS, SchemaField
51
+
52
+ __all__ = [
53
+ "render_model_module",
54
+ ]
55
+
56
+ # Descriptor function names that resolve to helpers from the
57
+ # `column_patterns` runtime module (rather than `constraint_expressions`).
58
+ # Used to route imports to the correct module. Distinct from
59
+ # `_render_common.COLUMN_LEVEL_FUNCTIONS`, which classifies checks that
60
+ # emit one Check per field rather than per array element.
61
+ _COLUMN_PATTERN_HELPERS = frozenset(
62
+ {
63
+ "array_check",
64
+ "nested_array_check",
65
+ "map_keys_check",
66
+ "map_values_check",
67
+ "check_struct_unique",
68
+ }
69
+ )
70
+
71
+ _SHARED_STRUCT_REFS = frozenset(SHARED_TYPE_REFS.values())
72
+
73
+ _SPARK_TYPES = frozenset(
74
+ {
75
+ "ArrayType",
76
+ "BinaryType",
77
+ "BooleanType",
78
+ "ByteType",
79
+ "DateType",
80
+ "DoubleType",
81
+ "FloatType",
82
+ "IntegerType",
83
+ "LongType",
84
+ "MapType",
85
+ "ShortType",
86
+ "StringType",
87
+ "StructField",
88
+ "StructType",
89
+ "TimestampType",
90
+ }
91
+ )
92
+
93
+
94
+ def _render_condition_desc(parsed: FieldEq) -> str:
95
+ """Render a parsed condition to a human-readable error-message description."""
96
+ display = repr(
97
+ parsed.value.value if isinstance(parsed.value, Enum) else parsed.value
98
+ )
99
+ op = "!=" if parsed.negated else "="
100
+ return f"{parsed.field_name} {op} {display}"
101
+
102
+
103
+ def _render_condition(
104
+ parsed: FieldEq,
105
+ *,
106
+ in_array: bool = False,
107
+ struct_path: tuple[str, ...] = (),
108
+ var: str = "el",
109
+ column_prefix: tuple[str, ...] = (),
110
+ ) -> str:
111
+ """Render a parsed condition to a PySpark Column expression string.
112
+
113
+ `struct_path` is the leaf the constrained model was reached at; the
114
+ condition field lives beside the target field on that same model, so
115
+ its reference must navigate the same leaf (e.g. `el["inner"]["subtype"]`,
116
+ not `el["subtype"]`). `column_prefix` plays the same role for a
117
+ struct-nested (non-iterated) model, qualifying the condition to
118
+ `F.col("details.subtype")`.
119
+ """
120
+ ref = _render_field_ref(
121
+ parsed.field_name,
122
+ in_array=in_array,
123
+ struct_path=struct_path,
124
+ var=var,
125
+ column_prefix=column_prefix,
126
+ )
127
+ op = "!=" if parsed.negated else "=="
128
+ # A bare `== True` / `== False` -- from any boolean condition, whether
129
+ # require_if/forbid_if or require_any_true -- trips ruff's E712, which would
130
+ # rewrite the comparison away; wrap the boolean in `F.lit(...)` so the Column
131
+ # comparison survives post-generation ruff intact.
132
+ value_src = (
133
+ f"F.lit({py_literal(parsed.value)})"
134
+ if isinstance(parsed.value, bool)
135
+ else py_literal(parsed.value)
136
+ )
137
+ return f"{ref} {op} {value_src}"
138
+
139
+
140
+ def _render_field_ref(
141
+ field_name: str,
142
+ *,
143
+ in_array: bool,
144
+ struct_path: tuple[str, ...] = (),
145
+ var: str = "el",
146
+ column_prefix: tuple[str, ...] = (),
147
+ ) -> str:
148
+ """Render a field reference as F.col("x"), el["x"], or el["struct"]["x"].
149
+
150
+ `F.col` accepts dotted names directly so the top-level form keeps
151
+ `field_name` intact. `column_prefix` names the struct segments the model
152
+ was reached through (a struct-nested model constraint at
153
+ `Direct('details')`), so its fields resolve to `F.col("details.foo")`; it
154
+ is empty for a row-root constraint. The in-array form descends a struct via
155
+ `el[...]`, which requires the dotted name to be split into segments before
156
+ applying `struct_path` and the field's own parts.
157
+ """
158
+ if not in_array:
159
+ qualified = ".".join((*column_prefix, field_name))
160
+ return f'F.col("{qualified}")'
161
+ parts = (*struct_path, *field_name.split("."))
162
+ return _element_accessor(var, parts)
163
+
164
+
165
+ def _geometry_type_literal(g: GeometryType) -> str:
166
+ """Spell out `GeometryType.NAME` as valid Python source.
167
+
168
+ `repr(g)` yields `<GeometryType.POINT: 'point'>`, which is not a valid
169
+ expression.
170
+ """
171
+ return f"GeometryType.{g.name}"
172
+
173
+
174
+ # Check functions whose first positional arg is a list of allowed values.
175
+ # Descriptors store the values as a tuple for hashability; the renderer
176
+ # unwraps that one position to a list literal so the generated call matches
177
+ # the runtime signature.
178
+ _LIST_FIRST_ARG_FUNCTIONS = frozenset({"check_enum"})
179
+
180
+
181
+ def _render_arg(arg: object) -> str:
182
+ """Render a descriptor arg as a valid Python expression string."""
183
+ if isinstance(arg, GeometryType):
184
+ return _geometry_type_literal(arg)
185
+ return py_literal(arg)
186
+
187
+
188
+ def _render_expr_call(
189
+ desc: ExpressionDescriptor,
190
+ col_expr: str,
191
+ ) -> str:
192
+ """Render a single ExpressionDescriptor call with col injected."""
193
+ parts = [col_expr]
194
+ for idx, arg in enumerate(desc.args):
195
+ if (
196
+ idx == 0
197
+ and desc.function in _LIST_FIRST_ARG_FUNCTIONS
198
+ and isinstance(arg, tuple)
199
+ ):
200
+ parts.append(py_literal(list(arg)))
201
+ else:
202
+ parts.append(_render_arg(arg))
203
+ for k, v in desc.kwargs:
204
+ parts.append(f"{k}={py_literal(v)}")
205
+ if desc.check_nan is not None:
206
+ parts.append(f"check_nan={py_literal(desc.check_nan)}")
207
+ if desc.label is not None:
208
+ parts.append(f"label={py_literal(desc.label)}")
209
+ call = f"{desc.function}({', '.join(parts)})"
210
+ if desc.allow_literals:
211
+ literals = py_literal(list(desc.allow_literals))
212
+ return f"except_literals({col_expr}, {call}, {literals})"
213
+ return call
214
+
215
+
216
+ def _element_accessor(var: str, path: tuple[str, ...]) -> str:
217
+ """Build bracket-notation accessor like `el["foo"]["bar"]`."""
218
+ return var + "".join(f'["{p}"]' for p in path)
219
+
220
+
221
+ def _iter_var_name(idx: int, total: int) -> str:
222
+ """Lambda variable name at iteration depth `idx` (0..total-1) of `total`.
223
+
224
+ Single-iteration cases (`total == 1`) return `"el"` from the first
225
+ branch; the innermost frame of a nested iteration uses `"inner"`,
226
+ intermediate frames `"el2"`, `"el3"`, ...
227
+ """
228
+ if idx == 0:
229
+ return "el"
230
+ if idx == total - 1:
231
+ return "inner"
232
+ return f"el{idx + 1}"
233
+
234
+
235
+ def _wrap_element_gate(body: str, var: str, gate_parts: tuple[str, ...]) -> str:
236
+ """Wrap a lambda body in F.when(var[gate].isNotNull(), ...) for nullable parent gating."""
237
+ gate_accessor = _element_accessor(var, gate_parts)
238
+ return f"F.when({gate_accessor}.isNotNull(), {body})"
239
+
240
+
241
+ def _map_iter_var(projection: MapProjection) -> str:
242
+ """Lambda variable name for a map projection: `k` for keys, `v` for values."""
243
+ return "k" if projection is MapProjection.KEY else "v"
244
+
245
+
246
+ @dataclass(frozen=True, slots=True)
247
+ class RenderFrame:
248
+ """One iteration frame enriched with its lambda var and runtime helper.
249
+
250
+ Attributes
251
+ ----------
252
+ prefix_structs
253
+ Struct segment names between the previous iterating segment (or the
254
+ start of the path) and this one. For the outermost frame this is the
255
+ column struct prefix; joined with `segment.name` it forms the frame's
256
+ `F.col(...)` column. For an inner named frame it is the descent from
257
+ the previous element; for an anonymous frame it is empty.
258
+ segment
259
+ The iterating segment (`ArraySegment` or `MapSegment`) this frame
260
+ iterates. Anonymous when the parent element is itself the container.
261
+ is_innermost
262
+ Whether this is the leaf-most iteration (the base runtime helper is
263
+ used; outer frames use the `nested_` flattening helper).
264
+ var_name
265
+ The lambda parameter name (`el` / `el2` / `inner` for arrays,
266
+ `k` / `v` for maps).
267
+ helper_name
268
+ The `column_patterns` helper this frame calls.
269
+ """
270
+
271
+ prefix_structs: tuple[str, ...]
272
+ segment: ArraySegment | MapSegment
273
+ is_innermost: bool
274
+ var_name: str
275
+ helper_name: str
276
+
277
+ @property
278
+ def descent(self) -> tuple[str, ...]:
279
+ """Struct accessor from the previous element to this container.
280
+
281
+ Empty for an anonymous frame (the parent element already IS this
282
+ container); `prefix_structs + segment.name` for a named frame.
283
+ """
284
+ if self.segment.is_anonymous:
285
+ return ()
286
+ return (*self.prefix_structs, self.segment.name)
287
+
288
+ @property
289
+ def column(self) -> str:
290
+ """Dotted `F.col(...)` name for the outermost frame."""
291
+ return ".".join((*self.prefix_structs, self.segment.name))
292
+
293
+
294
+ def _render_frames(target: Iterated) -> tuple[RenderFrame, ...]:
295
+ """Enrich each iteration of *target* with its lambda var and runtime helper.
296
+
297
+ One `RenderFrame` per iteration -- every iterating segment, named and
298
+ anonymous, since each is its own `array_check` / `map_*_check` call.
299
+ Built once and consumed by the fold, `_pattern_imports_for`, and the
300
+ model-constraint context so var and helper names never drift (a hazard
301
+ with mixed nesting where two map frames both want `v`).
302
+
303
+ Array frames use `el` / `el2` / `inner` (indexed by overall iteration
304
+ position) with `array_check` (innermost) or `nested_array_check`; map
305
+ frames use `k` / `v` with `map_{keys,values}_check` (innermost) or the
306
+ `nested_map_*` flattening variant.
307
+ """
308
+ raw: list[tuple[tuple[str, ...], ArraySegment | MapSegment]] = []
309
+ prefix: list[str] = []
310
+ for seg in target.segments:
311
+ if isinstance(seg, (ArraySegment, MapSegment)):
312
+ raw.append((tuple(prefix), seg))
313
+ prefix = []
314
+ else:
315
+ prefix.append(seg.name)
316
+ total = len(raw)
317
+ frames: list[RenderFrame] = []
318
+ for i, (prefix_structs, seg) in enumerate(raw):
319
+ is_innermost = i == total - 1
320
+ if isinstance(seg, ArraySegment):
321
+ var = _iter_var_name(i, total)
322
+ helper = "array_check" if is_innermost else "nested_array_check"
323
+ else:
324
+ var = _map_iter_var(seg.projection)
325
+ helper = map_runtime_helper(seg.projection, flatten=not is_innermost)
326
+ frames.append(
327
+ RenderFrame(
328
+ prefix_structs=prefix_structs,
329
+ segment=seg,
330
+ is_innermost=is_innermost,
331
+ var_name=var,
332
+ helper_name=helper,
333
+ )
334
+ )
335
+ return tuple(frames)
336
+
337
+
338
+ def _wrap_in_iteration(
339
+ frames: tuple[RenderFrame, ...],
340
+ body: str,
341
+ *,
342
+ gate_parts: tuple[str, ...] = (),
343
+ ) -> str:
344
+ """Fold *frames* outermost->innermost into nested iteration helper calls.
345
+
346
+ The outermost frame targets its `column` (an `F.col` string); each inner
347
+ frame targets an element accessor built from the outer frame's var and the
348
+ inner frame's `descent`. The innermost frame carries `body`. `gate_parts`,
349
+ when set, wraps the OUTERMOST frame's body in a nullable-parent element
350
+ gate (`element_relative_gate` is relative to the outer array element);
351
+ element guards are applied to `body` at the innermost var by the caller,
352
+ the two wrap points staying distinct.
353
+ """
354
+
355
+ def build(i: int, accessor: str) -> str:
356
+ frame = frames[i]
357
+ if frame.is_innermost:
358
+ inner = body
359
+ else:
360
+ child = frames[i + 1]
361
+ inner = build(i + 1, _element_accessor(frame.var_name, child.descent))
362
+ if i == 0 and gate_parts:
363
+ inner = _wrap_element_gate(inner, frame.var_name, gate_parts)
364
+ return f"{frame.helper_name}({accessor}, lambda {frame.var_name}: {inner})"
365
+
366
+ return build(0, f'"{frames[0].column}"')
367
+
368
+
369
+ def _render_iterated_check_expr(
370
+ target: Iterated,
371
+ desc: ExpressionDescriptor,
372
+ *,
373
+ element_guards: tuple[ElementGuard, ...] = (),
374
+ gate_parts: tuple[str, ...] = (),
375
+ ) -> str:
376
+ """Render an `Iterated` target to a nested iteration-fold expression.
377
+
378
+ Element guards are applied at the innermost iteration variable. This
379
+ assumes each guard's discriminator lives on the same struct level as
380
+ the leaf accessor -- true today because `ElementGuard`s only arise from
381
+ a union variant whose discriminator field is the immediately enclosing
382
+ array element. A future case where a check is reached through further
383
+ iteration *inside* a discriminated union element would need per-guard
384
+ depth info to apply the guard at the correct frame.
385
+ """
386
+ frames = _render_frames(target)
387
+ innermost_var = frames[-1].var_name
388
+ leaf_accessor = _element_accessor(innermost_var, target.leaf)
389
+ body = _render_expr_call(desc, leaf_accessor)
390
+
391
+ for guard in reversed(element_guards):
392
+ body = _render_variant_expr(
393
+ body, guard.values, guard.discriminator, in_array=True, var=innermost_var
394
+ )
395
+
396
+ return _wrap_in_iteration(frames, body, gate_parts=gate_parts)
397
+
398
+
399
+ def _render_variant_expr(
400
+ inner_expr: str,
401
+ variant_values: tuple[str, ...],
402
+ discriminator_field: str,
403
+ *,
404
+ in_array: bool = False,
405
+ var: str = "el",
406
+ ) -> str:
407
+ """Wrap an expression in F.when(...).isin() gating for union variant fields."""
408
+ values_repr = py_literal(list(variant_values))
409
+ disc_ref = (
410
+ f'{var}["{discriminator_field}"]'
411
+ if in_array
412
+ else f'F.col("{discriminator_field}")'
413
+ )
414
+ return f"F.when({disc_ref}.isin({values_repr}), {inner_expr})"
415
+
416
+
417
+ def _render_column_gate(expr: str, gate: FieldPath) -> str:
418
+ """Wrap an expression in F.when(gate.isNotNull(), ...) for nullable parent gating."""
419
+ return f'F.when(F.col("{gate}").isNotNull(), {expr})'
420
+
421
+
422
+ def _model_check_func_name(check: ModelCheck, idx: int) -> str:
423
+ """Build the private function name for a model-constraint check.
424
+
425
+ An `Iterated` (array/map) target prefixes the column path -- using the
426
+ full encoded `FieldPath` when the check is reached via inner iteration or
427
+ leaf struct navigation, otherwise the outer column name alone -- so
428
+ collisions across nested contexts get distinct identifiers. Row-root
429
+ (`Direct`) targets emit `_{fn}_{idx}_check`.
430
+ """
431
+ fn = model_constraint_function(check.descriptor)
432
+ target = check.target
433
+ if isinstance(target, Iterated):
434
+ has_nested_path = bool(target.iter_struct_paths) or bool(target.leaf)
435
+ prefix_source = str(target) if has_nested_path else target.outer_column
436
+ prefix = sanitize_field_name(prefix_source)
437
+ return f"_{prefix}_{fn}_{idx}_check"
438
+ return f"_{fn}_{idx}_check"
439
+
440
+
441
+ def _check_shape_token(target: FieldPath) -> str:
442
+ """Token naming the runtime `CheckShape` member for a target path.
443
+
444
+ Mirrors the member names of `overture.schema.pyspark.check.CheckShape`;
445
+ the check-function template prefixes `CheckShape.` to the result. An
446
+ `Iterated` target renders to an `array<string>` expression (array
447
+ iteration, or a map helper iterating the projected keys/values), a
448
+ `Direct` target to a nullable string.
449
+ """
450
+ return "ARRAY" if isinstance(target, Iterated) else "SCALAR"
451
+
452
+
453
+ def _render_check_expr(check: Check, descriptor_idx: int) -> str:
454
+ """Render the PySpark expression for one descriptor of `check`."""
455
+ desc = check.descriptors[descriptor_idx]
456
+ column_guards = tuple(g for g in check.guards if isinstance(g, ColumnGuard))
457
+ element_guards = tuple(g for g in check.guards if isinstance(g, ElementGuard))
458
+
459
+ match check.target:
460
+ case Direct():
461
+ expr = _render_expr_call(desc, f'F.col("{check.target}")')
462
+ if desc.gate:
463
+ expr = _render_column_gate(expr, desc.gate)
464
+ case Iterated():
465
+ gate_parts: tuple[str, ...] = ()
466
+ if desc.gate is not None:
467
+ # check_builder zeros the nullable gate when descending into
468
+ # any iterated container (see `_recurse_into_model`), so a
469
+ # gate paired with an Iterated target should never occur
470
+ # today. If it does, the column-level fallback below would
471
+ # silently hide a codegen bug -- raise instead.
472
+ element_relative = check.target.element_relative_gate(desc.gate)
473
+ if element_relative is None:
474
+ raise AssertionError(
475
+ f"Iterated target with column-level gate is not "
476
+ f"produced by check_builder (gate={desc.gate!r}, "
477
+ f"target={check.target!r})"
478
+ )
479
+ gate_parts = element_relative
480
+ expr = _render_iterated_check_expr(
481
+ check.target,
482
+ desc,
483
+ element_guards=element_guards,
484
+ gate_parts=gate_parts,
485
+ )
486
+ case _:
487
+ raise TypeError(
488
+ f"Unhandled FieldPath variant: {type(check.target).__name__}"
489
+ )
490
+
491
+ for guard in reversed(column_guards):
492
+ expr = _render_variant_expr(expr, guard.values, guard.discriminator)
493
+ return expr
494
+
495
+
496
+ def _check_function_context(
497
+ *,
498
+ target: FieldPath,
499
+ func_name: str,
500
+ field: str,
501
+ name: str,
502
+ expr: str,
503
+ read_columns: frozenset[str],
504
+ ) -> dict[str, object]:
505
+ """Assemble the template context dict for one check function."""
506
+ return {
507
+ "func_name": func_name,
508
+ "field": field,
509
+ "check_name": name,
510
+ "expr": expr,
511
+ "shape": _check_shape_token(target),
512
+ "read_columns": read_columns,
513
+ }
514
+
515
+
516
+ def _render_check_function_context(row: FieldCheckRow) -> dict[str, object]:
517
+ """Build the template context for a per-field check function from a row.
518
+
519
+ The row carries the final `func_name`, `label`, and `name`; the
520
+ collisions that produce them are resolved once in `field_check_rows`.
521
+ """
522
+ return _check_function_context(
523
+ target=row.check.target,
524
+ func_name=row.func_name,
525
+ field=row.label,
526
+ name=row.name,
527
+ expr=_render_check_expr(row.check, row.descriptor_idx),
528
+ read_columns=row.check.read_columns,
529
+ )
530
+
531
+
532
+ def _render_model_constraint_function_context(row: ModelCheckRow) -> dict[str, object]:
533
+ """Build the template context for a model-constraint check function."""
534
+ check = row.check
535
+ desc = check.descriptor
536
+ target = check.target
537
+ # Build the render frames once; both the field-reference context (var /
538
+ # struct_path) and the iteration wrap below read from them so nothing drifts.
539
+ frames: tuple[RenderFrame, ...] = ()
540
+ column_prefix: tuple[str, ...] = ()
541
+ if isinstance(target, Iterated):
542
+ # The innermost element (array element or projected map value) is
543
+ # iterated, so field references use the element accessor
544
+ # (`inner["foo"]`, `v["foo"]`) under the innermost lambda variable.
545
+ frames = _render_frames(target)
546
+ in_array = True
547
+ var = frames[-1].var_name
548
+ struct_path: tuple[str, ...] = target.leaf
549
+ else:
550
+ # A struct-nested model constraint (`Direct` with segments) qualifies
551
+ # every field reference with the struct prefix (`F.col("details.foo")`);
552
+ # a row-root constraint (empty `Direct`) leaves the prefix empty.
553
+ in_array = False
554
+ var, struct_path = "el", ()
555
+ column_prefix = tuple(s.name for s in target.segments)
556
+
557
+ def _field_ref(field_name: str) -> str:
558
+ return _render_field_ref(
559
+ field_name,
560
+ in_array=in_array,
561
+ struct_path=struct_path,
562
+ var=var,
563
+ column_prefix=column_prefix,
564
+ )
565
+
566
+ def _condition_ref(parsed: FieldEq) -> str:
567
+ return _render_condition(
568
+ parsed,
569
+ in_array=in_array,
570
+ struct_path=struct_path,
571
+ var=var,
572
+ column_prefix=column_prefix,
573
+ )
574
+
575
+ fn = model_constraint_function(desc)
576
+
577
+ def _cols_and_names(field_names: tuple[str, ...]) -> tuple[str, str]:
578
+ cols_list = "[" + ", ".join(_field_ref(f) for f in field_names) + "]"
579
+ names_list = py_literal(list(field_names))
580
+ return cols_list, names_list
581
+
582
+ match desc:
583
+ case RequireAnyOf() | RadioGroup():
584
+ cols_list, names_list = _cols_and_names(desc.field_names)
585
+ inner_expr = f"{fn}({cols_list}, {names_list})"
586
+ case RequireAnyTrue():
587
+ parsed_conditions = [require_field_eq(c) for c in desc.conditions]
588
+ conds_list = (
589
+ "[" + ", ".join(_condition_ref(p) for p in parsed_conditions) + "]"
590
+ )
591
+ names_list = py_literal([p.field_name for p in parsed_conditions])
592
+ inner_expr = f"{fn}({conds_list}, {names_list})"
593
+ case RequireIf() | ForbidIf():
594
+ target_name = desc.field_names[0]
595
+ parsed = require_field_eq(desc.condition)
596
+ condition_expr = _condition_ref(parsed)
597
+ condition_desc = _render_condition_desc(parsed)
598
+ target_ref = _field_ref(target_name)
599
+ inner_expr = (
600
+ f"{fn}({target_ref}, {condition_expr}, {py_literal(condition_desc)})"
601
+ )
602
+ case MinFieldsSet():
603
+ cols_list, names_list = _cols_and_names(desc.field_names)
604
+ inner_expr = f"{fn}({cols_list}, {names_list}, {desc.count})"
605
+ case _:
606
+ raise TypeError(f"Unhandled model constraint descriptor: {desc!r}")
607
+
608
+ if isinstance(target, Iterated):
609
+ if check.gate is not None:
610
+ # A gate reaches only an array-first target: check_builder zeros
611
+ # the gate for any iterated container, so a map-reached model
612
+ # carries none, and `element_relative_gate` asserts the array-first
613
+ # precondition. The wrap assumes a single array level.
614
+ assert not target.iter_struct_paths, (
615
+ f"gated ModelCheck with a nested-array target ({target!r}) is unsupported; "
616
+ f"the element-gate wrap assumes a single array level"
617
+ )
618
+ element_relative = target.element_relative_gate(check.gate)
619
+ assert element_relative is not None, (
620
+ f"ModelCheck gate={check.gate!r} is not reachable as an element-level "
621
+ f"accessor on target={target!r}; gates on ModelChecks must be Iterated "
622
+ f"entering the same outer array as the target"
623
+ )
624
+ inner_expr = _wrap_element_gate(inner_expr, var, element_relative)
625
+ expr = _wrap_in_iteration(frames, inner_expr)
626
+ elif check.gate is not None:
627
+ # A struct-nested model reached through an optional ancestor: skip the
628
+ # constraint when that ancestor is null (accessing a field of a null
629
+ # struct yields null, which would otherwise trip the constraint on an
630
+ # absent model). The gate is a struct prefix of the target, so it reads
631
+ # the target's top-level column and `_render_column_gate` renders
632
+ # `F.when(F.col("details").isNotNull(), ...)`. A gate on an empty
633
+ # (row-root) `Direct` target is meaningless -- check_builder never emits
634
+ # one -- so guard it rather than render a nonsensical `F.when`.
635
+ assert isinstance(target, Direct) and target.segments, (
636
+ f"ModelCheck gate={check.gate!r} on a row-root Direct target={target!r}; "
637
+ f"a gate only pairs with a struct-nested or iterated model"
638
+ )
639
+ expr = _render_column_gate(inner_expr, check.gate)
640
+ else:
641
+ expr = inner_expr
642
+
643
+ return _check_function_context(
644
+ target=target,
645
+ func_name=_model_check_func_name(check, row.idx),
646
+ field=row.label,
647
+ name=row.name,
648
+ expr=expr,
649
+ read_columns=check.read_columns,
650
+ )
651
+
652
+
653
+ def _collect_constraint_expr_imports(
654
+ field_checks: list[Check],
655
+ model_checks: list[ModelCheck],
656
+ ) -> set[str]:
657
+ """Collect all constraint_expressions function names needed.
658
+
659
+ Field-descriptor names go through a `_COLUMN_PATTERN_HELPERS`
660
+ filter so column-pattern helpers route to their own import bucket.
661
+ Model-constraint function names (`check_require_any_of`,
662
+ `check_radio_group`, ...) are disjoint from that set, so they pass
663
+ through unfiltered.
664
+ """
665
+ names: set[str] = set()
666
+ for check in field_checks:
667
+ for desc in check.descriptors:
668
+ if desc.function not in _COLUMN_PATTERN_HELPERS:
669
+ names.add(desc.function)
670
+ if desc.allow_literals:
671
+ names.add("except_literals")
672
+ for mc in model_checks:
673
+ names.add(model_constraint_function(mc.descriptor))
674
+ return names
675
+
676
+
677
+ def _needs_geometry_type_import(field_checks: list[Check]) -> bool:
678
+ """Return True when any descriptor arg is a GeometryType instance."""
679
+ for check in field_checks:
680
+ for desc in check.descriptors:
681
+ if any(isinstance(a, GeometryType) for a in desc.args):
682
+ return True
683
+ return False
684
+
685
+
686
+ def _pattern_imports_for(target: FieldPath) -> set[str]:
687
+ """Column-pattern helpers needed to iterate `target`.
688
+
689
+ Reads the helper names off `_render_frames` -- the single source of the
690
+ frame->helper mapping the fold also consumes -- so the imports never drift
691
+ from the emitted calls. A `Direct` target needs none.
692
+ """
693
+ if isinstance(target, Iterated):
694
+ return {frame.helper_name for frame in _render_frames(target)}
695
+ return set()
696
+
697
+
698
+ def _collect_column_pattern_imports(
699
+ field_checks: list[Check],
700
+ model_checks: list[ModelCheck],
701
+ ) -> set[str]:
702
+ """Collect column_patterns function names needed."""
703
+ names: set[str] = set()
704
+ for check in field_checks:
705
+ names |= _pattern_imports_for(check.target)
706
+ for desc in check.descriptors:
707
+ if desc.function in _COLUMN_PATTERN_HELPERS:
708
+ names.add(desc.function)
709
+ for mc in model_checks:
710
+ names |= _pattern_imports_for(mc.target)
711
+ return names
712
+
713
+
714
+ _IDENTIFIER_TOKEN = re.compile(r"[A-Z][A-Za-z0-9_]*")
715
+
716
+
717
+ def _identifier_tokens(expr: str) -> set[str]:
718
+ """Tokenize a Spark type expression into capitalized identifiers."""
719
+ return set(_IDENTIFIER_TOKEN.findall(expr))
720
+
721
+
722
+ def _collect_spark_type_imports(schema_fields: list[SchemaField]) -> set[str]:
723
+ """Collect Spark type class names from schema field type expressions.
724
+
725
+ `StructType` and `StructField` are always included: the model module
726
+ template emits the schema constant as `StructType([...])` unconditionally,
727
+ so the import must be present even when there are no fields.
728
+ """
729
+ used: set[str] = {"StructType", "StructField"}
730
+ for sf in schema_fields:
731
+ used |= _identifier_tokens(sf.type_expr) & _SPARK_TYPES
732
+ return used
733
+
734
+
735
+ def _collect_schema_struct_imports(schema_fields: list[SchemaField]) -> set[str]:
736
+ """Collect _schema_structs constant names referenced in field type expressions."""
737
+ refs: set[str] = set()
738
+ for sf in schema_fields:
739
+ refs |= _identifier_tokens(sf.type_expr) & _SHARED_STRUCT_REFS
740
+ return refs
741
+
742
+
743
+ def _field_check_function_entries(
744
+ field_checks: list[Check],
745
+ ) -> list[dict[str, object]]:
746
+ """Build template contexts for field-level checks."""
747
+ return [
748
+ _render_check_function_context(row) for row in field_check_rows(field_checks)
749
+ ]
750
+
751
+
752
+ def _model_check_function_entries(
753
+ model_checks: list[ModelCheck],
754
+ ) -> list[dict[str, object]]:
755
+ """Build template contexts for model-level checks."""
756
+ return [
757
+ _render_model_constraint_function_context(row)
758
+ for row in model_check_rows(model_checks)
759
+ ]
760
+
761
+
762
+ def render_model_module(
763
+ model_name: str,
764
+ field_checks: list[Check],
765
+ model_checks: list[ModelCheck],
766
+ schema_fields: list[SchemaField],
767
+ geometry_types: tuple[GeometryType, ...] = (),
768
+ *,
769
+ entry_point: str = "tests.placeholder:Placeholder",
770
+ partitions: Mapping[str, str] | None = None,
771
+ ) -> str:
772
+ """Render a complete Python module for a model's checks and schema."""
773
+ constraint_expr_fns = sorted(
774
+ _collect_constraint_expr_imports(field_checks, model_checks)
775
+ )
776
+ column_pattern_fns = sorted(
777
+ _collect_column_pattern_imports(field_checks, model_checks)
778
+ )
779
+ spark_types = sorted(_collect_spark_type_imports(schema_fields))
780
+ schema_struct_refs = sorted(_collect_schema_struct_imports(schema_fields))
781
+ geometry_type = _needs_geometry_type_import(field_checks) or bool(geometry_types)
782
+ geometry_types_literal = (
783
+ _render_geometry_types(geometry_types) if geometry_types else None
784
+ )
785
+
786
+ check_functions = _field_check_function_entries(
787
+ field_checks
788
+ ) + _model_check_function_entries(model_checks)
789
+
790
+ model_title = model_name.replace("_", " ").title()
791
+
792
+ template = jinja_env().get_template("model_module.py.jinja2")
793
+ return template.render(
794
+ model_name=model_name,
795
+ model_title=model_title,
796
+ constraint_expr_fns=constraint_expr_fns,
797
+ column_pattern_fns=column_pattern_fns,
798
+ spark_types=spark_types,
799
+ schema_struct_refs=schema_struct_refs,
800
+ geometry_type=geometry_type,
801
+ check_functions=check_functions,
802
+ schema_const_name=schema_const_name(model_name),
803
+ schema_fields=schema_fields,
804
+ geometry_types_literal=geometry_types_literal,
805
+ entry_point=entry_point,
806
+ partitions=dict(partitions) if partitions else {},
807
+ )
808
+
809
+
810
+ def _render_geometry_types(geo: tuple[GeometryType, ...]) -> str:
811
+ """Render a `geometry_types` tuple literal.
812
+
813
+ `GeometryType` is an Enum, so `repr()` does not produce a valid
814
+ expression -- members need explicit `GeometryType.NAME` source.
815
+ """
816
+ return tuple_literal(_geometry_type_literal(g) for g in geo)