overture-schema-codegen 0.1.1.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- overture/schema/codegen/__init__.py +1 -0
- overture/schema/codegen/cli.py +228 -0
- overture/schema/codegen/extraction/__init__.py +0 -0
- overture/schema/codegen/extraction/docstring.py +46 -0
- overture/schema/codegen/extraction/enum_extraction.py +40 -0
- overture/schema/codegen/extraction/examples.py +367 -0
- overture/schema/codegen/extraction/field.py +172 -0
- overture/schema/codegen/extraction/field_constraints.py +185 -0
- overture/schema/codegen/extraction/field_walk.py +275 -0
- overture/schema/codegen/extraction/length_constraints.py +49 -0
- overture/schema/codegen/extraction/literal_alternatives.py +26 -0
- overture/schema/codegen/extraction/model_constraints.py +252 -0
- overture/schema/codegen/extraction/model_extraction.py +240 -0
- overture/schema/codegen/extraction/newtype_extraction.py +73 -0
- overture/schema/codegen/extraction/numeric_extraction.py +74 -0
- overture/schema/codegen/extraction/pydantic_extraction.py +33 -0
- overture/schema/codegen/extraction/specs.py +295 -0
- overture/schema/codegen/extraction/type_analyzer.py +693 -0
- overture/schema/codegen/extraction/type_registry.py +137 -0
- overture/schema/codegen/extraction/union_extraction.py +270 -0
- overture/schema/codegen/layout/__init__.py +0 -0
- overture/schema/codegen/layout/module_layout.py +139 -0
- overture/schema/codegen/layout/type_collection.py +122 -0
- overture/schema/codegen/markdown/__init__.py +0 -0
- overture/schema/codegen/markdown/link_computation.py +70 -0
- overture/schema/codegen/markdown/path_assignment.py +114 -0
- overture/schema/codegen/markdown/pipeline.py +198 -0
- overture/schema/codegen/markdown/renderer.py +641 -0
- overture/schema/codegen/markdown/reverse_references.py +169 -0
- overture/schema/codegen/markdown/templates/_used_by.md.jinja2 +10 -0
- overture/schema/codegen/markdown/templates/enum.md.jinja2 +13 -0
- overture/schema/codegen/markdown/templates/feature.md.jinja2 +45 -0
- overture/schema/codegen/markdown/templates/geometric.md.jinja2 +11 -0
- overture/schema/codegen/markdown/templates/newtype.md.jinja2 +17 -0
- overture/schema/codegen/markdown/templates/numeric.md.jinja2 +27 -0
- overture/schema/codegen/markdown/templates/pydantic_type.md.jinja2 +8 -0
- overture/schema/codegen/markdown/type_format.py +383 -0
- overture/schema/codegen/py.typed +0 -0
- overture/schema/codegen/pyspark/__init__.py +1 -0
- overture/schema/codegen/pyspark/_primitive_fill.py +23 -0
- overture/schema/codegen/pyspark/_render_common.py +477 -0
- overture/schema/codegen/pyspark/check_builder.py +961 -0
- overture/schema/codegen/pyspark/check_ir.py +223 -0
- overture/schema/codegen/pyspark/constraint_dispatch.py +753 -0
- overture/schema/codegen/pyspark/pipeline.py +220 -0
- overture/schema/codegen/pyspark/renderer.py +816 -0
- overture/schema/codegen/pyspark/schema_builder.py +187 -0
- overture/schema/codegen/pyspark/templates/_check_function.py.jinja2 +10 -0
- overture/schema/codegen/pyspark/templates/model_module.py.jinja2 +83 -0
- overture/schema/codegen/pyspark/templates/test_module.py.jinja2 +129 -0
- overture/schema/codegen/pyspark/test_data/__init__.py +9 -0
- overture/schema/codegen/pyspark/test_data/base_row.py +835 -0
- overture/schema/codegen/pyspark/test_data/constraint_values.py +203 -0
- overture/schema/codegen/pyspark/test_data/invalid_value.py +105 -0
- overture/schema/codegen/pyspark/test_data/scaffold.py +390 -0
- overture/schema/codegen/pyspark/test_renderer.py +708 -0
- overture/schema/codegen/spec_discovery.py +66 -0
- overture_schema_codegen-0.1.1.dev0.dist-info/METADATA +13 -0
- overture_schema_codegen-0.1.1.dev0.dist-info/RECORD +61 -0
- overture_schema_codegen-0.1.1.dev0.dist-info/WHEEL +4 -0
- overture_schema_codegen-0.1.1.dev0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,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
|