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,23 @@
1
+ """Shared fill-value table for non-string scalar Spark categories.
2
+
3
+ Maps each SparkCategory that requires an explicit fill value to a
4
+ `(source_literal, runtime_value)` pair. The source literal is a valid
5
+ Python expression string; the runtime value is the corresponding Python
6
+ object.
7
+
8
+ Consumers
9
+ ---------
10
+ - `constraint_dispatch._needs_explicit_fill`: category in PRIMITIVE_FILL_TABLE
11
+ - `test_renderer._fill_value_literal`: PRIMITIVE_FILL_TABLE[category][0]
12
+ - `test_data.base_row._primitive_default`: PRIMITIVE_FILL_TABLE[category][1]
13
+
14
+ Adding a new numeric category here automatically wires it into all three.
15
+ """
16
+
17
+ from ..extraction.type_registry import SparkCategory
18
+
19
+ PRIMITIVE_FILL_TABLE: dict[SparkCategory, tuple[str, object]] = {
20
+ "int": ("0", 0),
21
+ "float": ("0.0", 0.0),
22
+ "bool": ("False", False),
23
+ }
@@ -0,0 +1,477 @@
1
+ """Shared rendering primitives used by `renderer` and `test_renderer`.
2
+
3
+ Concerns:
4
+
5
+ - `jinja_env` -- the cached Jinja2 environment.
6
+ - `py_literal` / `tuple_literal` -- render Python values back to source code.
7
+ - schema constant naming -- `schema_const_name` (the cross-module contract
8
+ between expression and test modules).
9
+ - check/label naming -- `check_name`, `field_label`, `column_level_suffix`,
10
+ `sanitize_field_name`, `COLUMN_LEVEL_FUNCTIONS` (membership), and
11
+ `_COLUMN_LEVEL_SUFFIXES` (label suffix lookup).
12
+ - emission rows -- `field_check_rows` and `model_check_rows` flatten a
13
+ check list into ordered rows carrying each row's final label, check
14
+ name, and (for field checks) disambiguated function name. The renderer
15
+ and test renderer both consume these rows, so the flatten-and-suffix
16
+ logic lives here once rather than in two positionally-coupled passes.
17
+ `disambiguate` (asymmetric, function-name keyed) and `_occurrence_indices`
18
+ (the shared collision primitive) back them.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import functools
24
+ import re
25
+ from collections import Counter
26
+ from collections.abc import Hashable, Iterable
27
+ from dataclasses import dataclass
28
+ from enum import Enum
29
+ from pathlib import Path
30
+ from typing import TypeVar
31
+
32
+ from jinja2 import Environment, FileSystemLoader
33
+
34
+ from overture.schema.system.field_path import Iterated, MapProjection
35
+
36
+ from .check_ir import Check, Guard, ModelCheck
37
+ from .constraint_dispatch import ForbidIf, RequireIf, model_constraint_function
38
+
39
+ __all__ = [
40
+ "COLUMN_LEVEL_FUNCTIONS",
41
+ "FieldCheckRow",
42
+ "ModelCheckRow",
43
+ "check_name",
44
+ "column_level_suffix",
45
+ "disambiguate",
46
+ "field_check_rows",
47
+ "field_label",
48
+ "jinja_env",
49
+ "map_runtime_helper",
50
+ "model_check_rows",
51
+ "py_literal",
52
+ "sanitize_field_name",
53
+ "schema_const_name",
54
+ "tuple_literal",
55
+ ]
56
+
57
+ _K = TypeVar("_K", bound=Hashable)
58
+
59
+ # Constraint functions that emit a column-level check (one per field
60
+ # rather than per element), used by the check builder to split them
61
+ # into their own `Check` IR nodes.
62
+ COLUMN_LEVEL_FUNCTIONS: frozenset[str] = frozenset(
63
+ {
64
+ "check_required",
65
+ "check_array_min_length",
66
+ "check_array_max_length",
67
+ "check_struct_unique",
68
+ }
69
+ )
70
+
71
+ # Violation label suffix per column-level check that shares its
72
+ # field's structural path. `check_required` lands on its field's own
73
+ # path, so it stays absent from this table.
74
+ _COLUMN_LEVEL_SUFFIXES: dict[str, str] = {
75
+ "check_array_min_length": "_min_length",
76
+ "check_array_max_length": "_max_length",
77
+ "check_struct_unique": "_unique",
78
+ }
79
+
80
+ _MAP_RUNTIME_HELPERS: dict[tuple[MapProjection, bool], str] = {
81
+ (MapProjection.KEY, False): "map_keys_check",
82
+ (MapProjection.VALUE, False): "map_values_check",
83
+ (MapProjection.KEY, True): "nested_map_keys_check",
84
+ (MapProjection.VALUE, True): "nested_map_values_check",
85
+ }
86
+
87
+
88
+ def map_runtime_helper(projection: MapProjection, *, flatten: bool = False) -> str:
89
+ """Map a projection to its PySpark column-patterns helper name.
90
+
91
+ `MapProjection.KEY` -> `map_keys_check`;
92
+ `MapProjection.VALUE` -> `map_values_check`. When *flatten* is set (the
93
+ map holds further iteration, e.g. `dict[K, list]`, so the projected
94
+ element check returns an `array<string>`), the flattening variant is
95
+ named instead (`nested_map_keys_check` / `nested_map_values_check`) --
96
+ the map analogue of `nested_array_check`. This is a pyspark-layer
97
+ concern; the mapping lives here rather than on `MapProjection` itself
98
+ (a system-package enum) to avoid a layering violation.
99
+ """
100
+ return _MAP_RUNTIME_HELPERS[(projection, flatten)]
101
+
102
+
103
+ _TEMPLATES_DIR = Path(__file__).parent / "templates"
104
+
105
+
106
+ @functools.lru_cache(maxsize=1)
107
+ def jinja_env() -> Environment:
108
+ """Return the shared Jinja2 environment for PySpark code generation templates."""
109
+ env = Environment(
110
+ loader=FileSystemLoader(_TEMPLATES_DIR),
111
+ trim_blocks=True,
112
+ lstrip_blocks=True,
113
+ keep_trailing_newline=True,
114
+ autoescape=False,
115
+ )
116
+ env.filters["py_literal"] = py_literal
117
+ return env
118
+
119
+
120
+ def schema_const_name(model_name: str) -> str:
121
+ """Name of the generated `MODELNAME_SCHEMA` StructType constant.
122
+
123
+ A cross-module contract: the generated test module imports this
124
+ constant by name from the generated expression module.
125
+ """
126
+ return f"{model_name.upper()}_SCHEMA"
127
+
128
+
129
+ _CHECK_PREFIX = "check_"
130
+
131
+
132
+ def tuple_literal(rendered_items: Iterable[str]) -> str:
133
+ """Wrap pre-rendered items as a Python tuple literal source.
134
+
135
+ A single-element tuple needs a trailing comma; this helper applies
136
+ that rule so callers rendering enum-like values that don't fit
137
+ `py_literal` can still share its tuple-formatting behaviour.
138
+ """
139
+ items = list(rendered_items)
140
+ joined = ", ".join(items)
141
+ return f"({joined},)" if len(items) == 1 else f"({joined})"
142
+
143
+
144
+ def py_literal(value: object) -> str:
145
+ """Render a Python value as source code.
146
+
147
+ Recurses into containers to extract `Enum.value` (since `repr()` of
148
+ an Enum member is not valid Python). Quote style and line wrapping
149
+ are left to `ruff format`.
150
+ """
151
+ if isinstance(value, Enum):
152
+ return py_literal(value.value)
153
+ if isinstance(value, dict):
154
+ items = ", ".join(f"{py_literal(k)}: {py_literal(v)}" for k, v in value.items())
155
+ return "{" + items + "}"
156
+ if isinstance(value, list):
157
+ return "[" + ", ".join(py_literal(v) for v in value) + "]"
158
+ if isinstance(value, tuple):
159
+ return tuple_literal(py_literal(v) for v in value)
160
+ if isinstance(value, frozenset):
161
+ if not value:
162
+ return "frozenset()"
163
+ # Sort the rendered items so regenerated source is stable across runs
164
+ # (set iteration order is not).
165
+ return "frozenset({" + ", ".join(sorted(py_literal(v) for v in value)) + "})"
166
+ return repr(value)
167
+
168
+
169
+ def check_name(function: str, override: str | None = None) -> str:
170
+ """Strip the `check_` prefix to produce a human-readable check name."""
171
+ if override is not None:
172
+ return override
173
+ return function.removeprefix(_CHECK_PREFIX)
174
+
175
+
176
+ # Collapses runs of path punctuation (`.`, `[`, `]`, `{`, `}`, `_`) to a
177
+ # single `_` for identifier sanitization (e.g. `names.common{key}` ->
178
+ # `names_common_key`).
179
+ _PATH_SEPARATOR_RUN = re.compile(r"[.\[\]{}_]+")
180
+
181
+
182
+ def sanitize_field_name(field: str) -> str:
183
+ """Convert an encoded field-path string to a valid Python identifier fragment."""
184
+ return _PATH_SEPARATOR_RUN.sub("_", field).strip("_")
185
+
186
+
187
+ def column_level_suffix(check: Check) -> str:
188
+ """Return the column-level label suffix for `check`, or empty string.
189
+
190
+ Column-level checks (`check_array_min_length`, `check_struct_unique`,
191
+ etc.) share their structural path with the field they constrain; the
192
+ suffix differentiates the violation label so each check reports a
193
+ distinct `Check.field`.
194
+ """
195
+ if not check.descriptors:
196
+ return ""
197
+ return _COLUMN_LEVEL_SUFFIXES.get(check.descriptors[0].function, "")
198
+
199
+
200
+ def field_label(check: Check) -> str:
201
+ """Render the violation label for a Check.
202
+
203
+ Combines the structural field path with any column-level suffix
204
+ (`_min_length`, `_unique`, etc.) so each check reports a distinct
205
+ `Check.field` even when several share a structural path.
206
+ """
207
+ return f"{check.target}{column_level_suffix(check)}"
208
+
209
+
210
+ def _model_check_base_label(check: ModelCheck) -> str:
211
+ """Compute the violation field label sans collision suffix.
212
+
213
+ - `require_if` / `forbid_if` produce a per-target label
214
+ (`field_required` / `path.field_forbidden`); each descriptor
215
+ carries a single target field (multi-field decorators split at
216
+ dispatch time).
217
+ - Other kinds (`require_any_of`, `radio_group`, `min_fields_set`)
218
+ name the whole constraint; on `Iterated` targets they use the
219
+ path itself so anchors are distinguishable across nestings.
220
+
221
+ Every `Iterated` target (array, map, or mixed) uses the iterated
222
+ formula -- the anchor-disambiguation reason that motivates it for
223
+ arrays applies identically to maps and mixed paths. `Direct` targets
224
+ keep the row-root formula.
225
+ """
226
+ match check.descriptor:
227
+ case RequireIf():
228
+ kind_suffix = "_required"
229
+ case ForbidIf():
230
+ kind_suffix = "_forbidden"
231
+ case _:
232
+ if isinstance(check.target, Iterated):
233
+ return str(check.target)
234
+ return check_name(model_constraint_function(check.descriptor))
235
+ target = check.descriptor.field_names[0]
236
+ if not isinstance(check.target, Iterated):
237
+ return f"{target}{kind_suffix}"
238
+ return f"{check.target}.{target}{kind_suffix}"
239
+
240
+
241
+ def _occurrence_indices(keys: list[_K]) -> list[tuple[int, int]]:
242
+ """Pair each key with `(occurrence_index, total_count)`.
243
+
244
+ `occurrence_index` is the 0-based position of the key among its
245
+ equal siblings; `total_count` is how many times the key appears in
246
+ `keys`. Both collision styles -- `disambiguate` (function names) and
247
+ the symmetric label suffixing in the row builders -- need this
248
+ "where am I within my collision group" view.
249
+ """
250
+ counts: Counter[_K] = Counter(keys)
251
+ seen: Counter[_K] = Counter()
252
+ result: list[tuple[int, int]] = []
253
+ for key in keys:
254
+ result.append((seen[key], counts[key]))
255
+ seen[key] += 1
256
+ return result
257
+
258
+
259
+ def disambiguate(names: list[str]) -> list[str]:
260
+ """Make a list of names unique by appending `_N` to repeated entries.
261
+
262
+ The first occurrence of a name is left bare; the second becomes
263
+ `name_1`, the third `name_2`, and so on. Names that appear once are
264
+ untouched. This is the asymmetric style, keyed on the function-name
265
+ string: leaving the first occurrence bare keeps readable identifiers
266
+ for the common no-collision case.
267
+
268
+ Assumes no input name already matches a generated `name_N` form; a
269
+ collision there would reintroduce a duplicate. Field names in
270
+ practice never carry that suffix, so the assumption holds.
271
+ """
272
+ return [
273
+ f"{name}_{idx}" if total > 1 and idx > 0 else name
274
+ for name, (idx, total) in zip(names, _occurrence_indices(names), strict=True)
275
+ ]
276
+
277
+
278
+ def _symmetric_label_suffixes(keys: list[_K]) -> list[str]:
279
+ """Per-key violation-label collision suffixes, symmetric across a group.
280
+
281
+ Every member of a colliding group receives a `_N` suffix including
282
+ the first (`_0`, `_1`, ...); unique keys stay bare. Symmetric unlike
283
+ `disambiguate` because violation labels in a colliding group all
284
+ share the same base name, so each needs an explicit index to stay a
285
+ distinct `Check.field` identity (which keys `suppress` matching,
286
+ `explain_errors` metadata, and the test's `expected_field`).
287
+ """
288
+ return [f"_{idx}" if total > 1 else "" for idx, total in _occurrence_indices(keys)]
289
+
290
+
291
+ def _field_label_suffixes(
292
+ keys: list[tuple[str, str, tuple[Guard, ...]]],
293
+ ) -> list[str]:
294
+ """Per-row field-label collision suffixes.
295
+
296
+ `keys` carries `(base_label, check_name, guards)` per emitted row. A
297
+ field label collides for two reasons, resolved differently:
298
+
299
+ - Across union arms -- the same field appears in several discriminator
300
+ arms, each carrying its own guard tuple. Every check gated to one
301
+ arm shares that arm's `_N` suffix (`N` its first-appearance order
302
+ among the label's arms), so a split field reports one consistent
303
+ label per arm. This includes a check unique to one arm (the axle
304
+ arm's `integer` check, absent from the dimension arms), which would
305
+ otherwise escape suffixing and report the bare label alongside its
306
+ `_N`-suffixed siblings.
307
+ - Within a single arm -- one field carries two same-named checks (a
308
+ lower- and upper-`bounds` pair emitted as separate checks). These
309
+ take a per-occurrence `_N` suffix keyed on `(label, name)`; a field
310
+ whose check names are all distinct stays bare.
311
+
312
+ A label reached by a single arm uses the occurrence rule (leaving
313
+ unsplit fields untouched); a label reached by several uses the arm
314
+ rule.
315
+ """
316
+ arms_by_label: dict[str, list[tuple[Guard, ...]]] = {}
317
+ for label, _name, guards in keys:
318
+ arms = arms_by_label.setdefault(label, [])
319
+ if guards not in arms:
320
+ arms.append(guards)
321
+ occurrences = _occurrence_indices([(label, name) for label, name, _ in keys])
322
+ suffixes: list[str] = []
323
+ for (label, _name, guards), (occ_idx, occ_total) in zip(
324
+ keys, occurrences, strict=True
325
+ ):
326
+ arms = arms_by_label[label]
327
+ if len(arms) > 1:
328
+ suffixes.append(f"_{arms.index(guards)}")
329
+ elif occ_total > 1:
330
+ suffixes.append(f"_{occ_idx}")
331
+ else:
332
+ suffixes.append("")
333
+ return suffixes
334
+
335
+
336
+ @dataclass(frozen=True, slots=True)
337
+ class FieldCheckRow:
338
+ """One emitted field-check row, with its final derived strings.
339
+
340
+ The renderer emits one row per descriptor of each `Check`.
341
+ `field_check_rows` flattens the check list into these rows once,
342
+ computing both the arm-grouped `label` collision suffix and the
343
+ asymmetric `func_name` disambiguation, so the renderer and test
344
+ renderer agree without each re-deriving them by a positional index.
345
+
346
+ Attributes
347
+ ----------
348
+ check
349
+ The originating field check.
350
+ descriptor_idx
351
+ Index of this row's descriptor within `check.descriptors`.
352
+ label
353
+ The violation `field=` label, including any collision suffix.
354
+ name
355
+ The check name (`check_name(desc.function, desc.check_name)`).
356
+ func_name
357
+ The disambiguated private `_..._check` function name.
358
+ """
359
+
360
+ check: Check
361
+ descriptor_idx: int
362
+ label: str
363
+ name: str
364
+ func_name: str
365
+
366
+
367
+ def field_check_rows(field_checks: list[Check]) -> list[FieldCheckRow]:
368
+ """Flatten field checks into emission rows with final derived strings.
369
+
370
+ Computes both collision passes over the *unfiltered* list, so the
371
+ expression module (rendered once across every arm) and a per-arm test
372
+ module agree: a per-arm subset could otherwise hide a collision the
373
+ shared module still carries, emitting an `expected_field` the module
374
+ never produces. Callers filter the returned rows to an arm afterward
375
+ rather than computing suffixes over a subset.
376
+
377
+ Parameters
378
+ ----------
379
+ field_checks
380
+ The complete field-check list for one generated module, before
381
+ any per-arm filtering.
382
+
383
+ Returns
384
+ -------
385
+ list
386
+ One `FieldCheckRow` per emitted `(check, descriptor)`, in
387
+ flattened emission order.
388
+ """
389
+ flattened: list[tuple[Check, int, str, str]] = []
390
+ raw_func_names: list[str] = []
391
+ for check in field_checks:
392
+ label = field_label(check)
393
+ multi = len(check.descriptors) > 1
394
+ for desc_idx, desc in enumerate(check.descriptors):
395
+ name = check_name(desc.function, desc.check_name)
396
+ func_suffix = f"_{name}" if multi else ""
397
+ raw_func_names.append(f"_{sanitize_field_name(label)}{func_suffix}_check")
398
+ flattened.append((check, desc_idx, label, name))
399
+ func_names = disambiguate(raw_func_names)
400
+ label_suffixes = _field_label_suffixes(
401
+ [(label, name, check.guards) for check, _idx, label, name in flattened]
402
+ )
403
+ rows = [
404
+ FieldCheckRow(check, desc_idx, f"{label}{label_suffix}", name, func_name)
405
+ for (check, desc_idx, label, name), label_suffix, func_name in zip(
406
+ flattened, label_suffixes, func_names, strict=True
407
+ )
408
+ ]
409
+ # Arm-grouped suffixing cannot distinguish two same-name checks that
410
+ # land in one arm of a split field; fail generation loudly if a schema
411
+ # ever produces that instead of emitting indistinguishable violations.
412
+ identities = [(row.label, row.name) for row in rows]
413
+ duplicates = {i for i in identities if identities.count(i) > 1}
414
+ if duplicates:
415
+ raise ValueError(
416
+ f"Duplicate violation identities in generated checks: {sorted(duplicates)}"
417
+ )
418
+ return rows
419
+
420
+
421
+ @dataclass(frozen=True, slots=True)
422
+ class ModelCheckRow:
423
+ """One emitted model-check row, with its final derived strings.
424
+
425
+ Model function names embed `idx` and are unique by construction, so
426
+ a row carries no `func_name` -- the renderer builds it from `idx`.
427
+
428
+ Attributes
429
+ ----------
430
+ check
431
+ The originating model check.
432
+ idx
433
+ Position of this check in the unfiltered model-check list; the
434
+ renderer embeds it in the private function name.
435
+ label
436
+ The violation `field=` label, including any collision suffix.
437
+ name
438
+ The check name (`check_name(model_constraint_function(...))`).
439
+ """
440
+
441
+ check: ModelCheck
442
+ idx: int
443
+ label: str
444
+ name: str
445
+
446
+
447
+ def model_check_rows(model_checks: list[ModelCheck]) -> list[ModelCheckRow]:
448
+ """Flatten model checks into emission rows with final derived strings.
449
+
450
+ Like `field_check_rows`, label collision suffixes are computed over
451
+ the *unfiltered* list so the expression module and per-arm test
452
+ modules agree; callers filter the returned rows to an arm afterward.
453
+
454
+ Parameters
455
+ ----------
456
+ model_checks
457
+ The complete model-check list for one generated module, before
458
+ any per-arm filtering.
459
+
460
+ Returns
461
+ -------
462
+ list
463
+ One `ModelCheckRow` per model check, in list order.
464
+ """
465
+ base_labels = [_model_check_base_label(check) for check in model_checks]
466
+ label_suffixes = _symmetric_label_suffixes(base_labels)
467
+ return [
468
+ ModelCheckRow(
469
+ check,
470
+ idx,
471
+ f"{base_label}{label_suffix}",
472
+ check_name(model_constraint_function(check.descriptor)),
473
+ )
474
+ for idx, (check, base_label, label_suffix) in enumerate(
475
+ zip(model_checks, base_labels, label_suffixes, strict=True)
476
+ )
477
+ ]