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,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
|
+
]
|