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,203 @@
|
|
|
1
|
+
"""Paired valid/invalid value generation for string-constraint and numeric-bound checks.
|
|
2
|
+
|
|
3
|
+
Each entry in `CONSTRAINT_VALUES` carries both sides of the pair:
|
|
4
|
+
`valid` is accepted by the constraint; `invalid` violates it.
|
|
5
|
+
Both sides are mandatory — partial entries are not allowed.
|
|
6
|
+
|
|
7
|
+
Consumed by `base_row` (uses the `valid` side to produce valid base rows)
|
|
8
|
+
and `invalid_value` (uses the `invalid` side to produce scenario mutations).
|
|
9
|
+
|
|
10
|
+
`valid_bound` and `invalid_bound` are analogous functions for numeric
|
|
11
|
+
bound descriptors, placed here so both sides of every constraint kind
|
|
12
|
+
live in one module.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
from overture.schema.system.field_constraint.string import (
|
|
20
|
+
CountryCodeAlpha2Constraint,
|
|
21
|
+
HexColorConstraint,
|
|
22
|
+
JsonPointerConstraint,
|
|
23
|
+
LanguageTagConstraint,
|
|
24
|
+
NoWhitespaceConstraint,
|
|
25
|
+
PhoneNumberConstraint,
|
|
26
|
+
RegionCodeConstraint,
|
|
27
|
+
SnakeCaseConstraint,
|
|
28
|
+
StrippedConstraint,
|
|
29
|
+
WikidataIdConstraint,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
from ..constraint_dispatch import ExpressionDescriptor, normalize_anchor
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"CONSTRAINT_VALUES",
|
|
36
|
+
"PATTERN_VALUES",
|
|
37
|
+
"ConstraintValues",
|
|
38
|
+
"curated_pattern_values",
|
|
39
|
+
"invalid_bound",
|
|
40
|
+
"uncurated_pattern_error",
|
|
41
|
+
"valid_bound",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class ConstraintValues:
|
|
47
|
+
"""A paired valid/invalid value for one constraint type."""
|
|
48
|
+
|
|
49
|
+
valid: object
|
|
50
|
+
invalid: object
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
CONSTRAINT_VALUES: dict[type, ConstraintValues] = {
|
|
54
|
+
CountryCodeAlpha2Constraint: ConstraintValues(valid="US", invalid="99"),
|
|
55
|
+
HexColorConstraint: ConstraintValues(valid="#aabbcc", invalid="not-hex"),
|
|
56
|
+
JsonPointerConstraint: ConstraintValues(valid="/valid/pointer", invalid="no-slash"),
|
|
57
|
+
LanguageTagConstraint: ConstraintValues(valid="en", invalid="123"),
|
|
58
|
+
NoWhitespaceConstraint: ConstraintValues(
|
|
59
|
+
valid="nowhitespace", invalid="has whitespace"
|
|
60
|
+
),
|
|
61
|
+
PhoneNumberConstraint: ConstraintValues(
|
|
62
|
+
valid="+1 555-555-5555", invalid="1234567890"
|
|
63
|
+
),
|
|
64
|
+
RegionCodeConstraint: ConstraintValues(valid="US-CA", invalid="99-999"),
|
|
65
|
+
SnakeCaseConstraint: ConstraintValues(valid="snake_case", invalid="HAS SPACES"),
|
|
66
|
+
StrippedConstraint: ConstraintValues(valid="clean", invalid=" has spaces "),
|
|
67
|
+
WikidataIdConstraint: ConstraintValues(valid="Q42", invalid="P999"),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# Curated valid/invalid pairs for fields whose only string constraint is a
|
|
72
|
+
# raw pydantic `Field(pattern=...)` (a `_PydanticGeneralMetadata`, not a
|
|
73
|
+
# schema constraint class -- so it has no `CONSTRAINT_VALUES` type key).
|
|
74
|
+
# Keyed by the anchor-normalized pattern that lands in the generated
|
|
75
|
+
# `check_pattern` descriptor's `args`, so both `base_row` and
|
|
76
|
+
# `invalid_value` look it up via `desc.args[0]`. An uncurated raw pattern
|
|
77
|
+
# fails loud on both sides rather than guessing a value.
|
|
78
|
+
#
|
|
79
|
+
# Generation-principle gap: this table is hand-maintained and keyed by the
|
|
80
|
+
# literal regex, so it drifts from the schema -- a renamed or retuned
|
|
81
|
+
# `Field(pattern=)` silently loses its entry until the next regeneration
|
|
82
|
+
# fails loud. The principled fix is to derive both sides from the regex
|
|
83
|
+
# itself (e.g. a matching/non-matching string generator), removing the
|
|
84
|
+
# hand-keyed table entirely. Out of scope here; tracked separately.
|
|
85
|
+
PATTERN_VALUES: dict[str, ConstraintValues] = {
|
|
86
|
+
# Sources.license_priority key (LicenseShortname): `^[A-Za-z0-9._+\-]+$`.
|
|
87
|
+
normalize_anchor(r"^[A-Za-z0-9._+\-]+$"): ConstraintValues(
|
|
88
|
+
valid="ODbL-1.0", invalid="bad license!"
|
|
89
|
+
),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def curated_pattern_values(desc: ExpressionDescriptor) -> ConstraintValues | None:
|
|
94
|
+
"""Curated valid/invalid pair for a raw-pattern `check_pattern` descriptor.
|
|
95
|
+
|
|
96
|
+
The pattern key is the descriptor's first arg (the anchor-normalized
|
|
97
|
+
regex). Returns None when the pattern is not curated in `PATTERN_VALUES`
|
|
98
|
+
-- named constraints resolve via `CONSTRAINT_VALUES` instead, and an
|
|
99
|
+
uncurated raw pattern has no values.
|
|
100
|
+
"""
|
|
101
|
+
pattern = desc.args[0] if desc.args else None
|
|
102
|
+
if isinstance(pattern, str):
|
|
103
|
+
return PATTERN_VALUES.get(pattern)
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def uncurated_pattern_error(desc: ExpressionDescriptor, *, side: str) -> ValueError:
|
|
108
|
+
"""Build the error for a `check_pattern` descriptor with no curated value.
|
|
109
|
+
|
|
110
|
+
Raised symmetrically by `base_row` (valid side) and `invalid_value`
|
|
111
|
+
(invalid side) when a raw `Field(pattern=)` has no `PATTERN_VALUES`
|
|
112
|
+
entry: both name the table to update rather than guessing a value.
|
|
113
|
+
|
|
114
|
+
Parameters
|
|
115
|
+
----------
|
|
116
|
+
desc
|
|
117
|
+
The uncurated `check_pattern` descriptor.
|
|
118
|
+
side
|
|
119
|
+
Which value could not be produced -- `"valid"` or `"invalid"`.
|
|
120
|
+
"""
|
|
121
|
+
return ValueError(
|
|
122
|
+
f"No {side} value defined for check_pattern with "
|
|
123
|
+
f"constraint_type={desc.constraint_type!r}, pattern={desc.args!r}. "
|
|
124
|
+
"Add an entry to CONSTRAINT_VALUES (named constraint) or "
|
|
125
|
+
"PATTERN_VALUES (raw pydantic pattern) in constraint_values.py."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def valid_bound(desc: ExpressionDescriptor) -> object:
|
|
130
|
+
"""Produce a value satisfying a bounds check for base row generation.
|
|
131
|
+
|
|
132
|
+
Prefers inclusive boundaries: if `ge` is present it is already a valid
|
|
133
|
+
value; if `le` is present and `ge` is absent, `le` is valid. When only
|
|
134
|
+
exclusive bounds remain, a strictly-interior value is computed: midpoint
|
|
135
|
+
for both-exclusive, or a type-aware step away from a single bound.
|
|
136
|
+
|
|
137
|
+
Parameters
|
|
138
|
+
----------
|
|
139
|
+
desc
|
|
140
|
+
A `check_bounds` descriptor with at least one bound kwarg.
|
|
141
|
+
|
|
142
|
+
Returns
|
|
143
|
+
-------
|
|
144
|
+
object
|
|
145
|
+
A value on the valid side of all bounds. Falls back to `0` when
|
|
146
|
+
no recognised bound key is present.
|
|
147
|
+
"""
|
|
148
|
+
kwargs = dict(desc.kwargs)
|
|
149
|
+
if "ge" in kwargs:
|
|
150
|
+
return kwargs["ge"]
|
|
151
|
+
if "le" in kwargs:
|
|
152
|
+
return kwargs["le"]
|
|
153
|
+
gt = kwargs.get("gt")
|
|
154
|
+
lt = kwargs.get("lt")
|
|
155
|
+
if gt is not None and lt is not None:
|
|
156
|
+
# Midpoint: integer midpoint for int bounds, float midpoint for float.
|
|
157
|
+
if isinstance(gt, float) or isinstance(lt, float):
|
|
158
|
+
return (float(gt) + float(lt)) / 2.0 # type: ignore[arg-type,operator]
|
|
159
|
+
mid = (gt + lt) // 2 # type: ignore[operator]
|
|
160
|
+
if not (gt < mid < lt): # type: ignore[operator]
|
|
161
|
+
raise ValueError(
|
|
162
|
+
f"No valid integer strictly between gt={gt!r} and lt={lt!r}"
|
|
163
|
+
)
|
|
164
|
+
return mid
|
|
165
|
+
if gt is not None:
|
|
166
|
+
step: object = 1.0 if isinstance(gt, float) else 1
|
|
167
|
+
return gt + step # type: ignore[operator]
|
|
168
|
+
if lt is not None:
|
|
169
|
+
step = 1.0 if isinstance(lt, float) else 1
|
|
170
|
+
return lt - step # type: ignore[operator]
|
|
171
|
+
return 0
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def invalid_bound(desc: ExpressionDescriptor) -> object:
|
|
175
|
+
"""Produce a value violating a bounds check for invalid-value generation.
|
|
176
|
+
|
|
177
|
+
The `ge` / `le` branches return one below / above the bound. For
|
|
178
|
+
`ge=0` this returns `-1`, which violates the bound but would also
|
|
179
|
+
underflow an unsigned base type. No schema today combines `ge=0` with
|
|
180
|
+
an unsigned terminal -- if that ever changes, the caller will need to
|
|
181
|
+
consult the base type and pick a sentinel (e.g. a string or null) for
|
|
182
|
+
the violating value.
|
|
183
|
+
|
|
184
|
+
Parameters
|
|
185
|
+
----------
|
|
186
|
+
desc
|
|
187
|
+
A `check_bounds` descriptor with at least one bound kwarg.
|
|
188
|
+
|
|
189
|
+
Raises
|
|
190
|
+
------
|
|
191
|
+
ValueError
|
|
192
|
+
When no recognised bound key is found.
|
|
193
|
+
"""
|
|
194
|
+
kwargs = dict(desc.kwargs)
|
|
195
|
+
if "ge" in kwargs:
|
|
196
|
+
return kwargs["ge"] - 1 # type: ignore[operator]
|
|
197
|
+
if "gt" in kwargs:
|
|
198
|
+
return kwargs["gt"]
|
|
199
|
+
if "le" in kwargs:
|
|
200
|
+
return kwargs["le"] + 1 # type: ignore[operator]
|
|
201
|
+
if "lt" in kwargs:
|
|
202
|
+
return kwargs["lt"]
|
|
203
|
+
raise ValueError(f"No recognised bound key in kwargs: {kwargs!r}")
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Generate constraint-violating values for the rendered conformance tests.
|
|
2
|
+
|
|
3
|
+
`invalid_value` returns a concrete value that violates a given check. The
|
|
4
|
+
generated tests inject these into otherwise-valid rows to confirm that
|
|
5
|
+
each constraint produces the expected violation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from overture.schema.system.geometric.geom import GeometryType
|
|
11
|
+
|
|
12
|
+
from ..constraint_dispatch import ExpressionDescriptor
|
|
13
|
+
from .constraint_values import (
|
|
14
|
+
CONSTRAINT_VALUES,
|
|
15
|
+
curated_pattern_values,
|
|
16
|
+
invalid_bound,
|
|
17
|
+
uncurated_pattern_error,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = ["invalid_value"]
|
|
21
|
+
|
|
22
|
+
# Ordered candidates for the invalid geometry side (first not in allowed set wins)
|
|
23
|
+
_INVALID_GEOMETRY_CANDIDATES: tuple[tuple[GeometryType, str], ...] = (
|
|
24
|
+
(GeometryType.POINT, "POINT (0 0)"),
|
|
25
|
+
(GeometryType.LINE_STRING, "LINESTRING (0 0, 1 1)"),
|
|
26
|
+
(GeometryType.GEOMETRY_COLLECTION, "GEOMETRYCOLLECTION EMPTY"),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Direct lookup: check function name -> invalid value (no descriptor inspection).
|
|
31
|
+
# Reserved for checks with no associated constraint type (url/email, linear_range,
|
|
32
|
+
# bbox, required, enum, and min-length literals).
|
|
33
|
+
_INVALID_LITERALS: dict[str, object] = {
|
|
34
|
+
"check_required": None,
|
|
35
|
+
"check_enum": "__INVALID__",
|
|
36
|
+
"check_url_format": "not-a-url",
|
|
37
|
+
"check_url_length": "https://" + "x" * 2076,
|
|
38
|
+
"check_email": "not-an-email",
|
|
39
|
+
"check_array_min_length": [],
|
|
40
|
+
"check_string_min_length": "",
|
|
41
|
+
"check_linear_range_length": [0.5],
|
|
42
|
+
"check_linear_range_bounds": [1.5, 2.0],
|
|
43
|
+
"check_linear_range_order": [0.8, 0.2],
|
|
44
|
+
"check_bbox_completeness": {"xmin": 0.0, "xmax": 1.0, "ymin": None, "ymax": 1.0},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def invalid_value(desc: ExpressionDescriptor) -> object:
|
|
49
|
+
"""Return a Python value that violates `desc`'s check function.
|
|
50
|
+
|
|
51
|
+
Parameters
|
|
52
|
+
----------
|
|
53
|
+
desc
|
|
54
|
+
The expression descriptor to produce an invalid value for.
|
|
55
|
+
|
|
56
|
+
Raises
|
|
57
|
+
------
|
|
58
|
+
ValueError
|
|
59
|
+
For unrecognised check function names, unknown `constraint_type`
|
|
60
|
+
on `check_pattern` descriptors, or when all geometry candidates
|
|
61
|
+
are in the allowed set.
|
|
62
|
+
"""
|
|
63
|
+
fn = desc.function
|
|
64
|
+
# Constraint-type lookup precedes function-name lookup: any type present in
|
|
65
|
+
# CONSTRAINT_VALUES resolves via the table even when its check function also
|
|
66
|
+
# appears in _INVALID_LITERALS (e.g. check_stripped, check_json_pointer).
|
|
67
|
+
if desc.constraint_type in CONSTRAINT_VALUES:
|
|
68
|
+
return CONSTRAINT_VALUES[desc.constraint_type].invalid
|
|
69
|
+
if fn in _INVALID_LITERALS:
|
|
70
|
+
return _INVALID_LITERALS[fn]
|
|
71
|
+
if fn == "check_bounds":
|
|
72
|
+
return invalid_bound(desc)
|
|
73
|
+
if fn == "check_multiple_of":
|
|
74
|
+
# A non-multiple of the divisor: `divisor * 1.5` leaves a remainder of
|
|
75
|
+
# `divisor / 2`. For divisor=1 this is 1.5, kept inside a typical
|
|
76
|
+
# [1, N] bound range so a co-located bounds check does not also fire
|
|
77
|
+
# and the scenario isolates the multiple-of check. Isolation is a
|
|
78
|
+
# convenience, not a requirement: the harness asserts the expected
|
|
79
|
+
# check is among those raised, so an extra bounds violation is
|
|
80
|
+
# tolerated.
|
|
81
|
+
return float(desc.args[0]) * 1.5 # type: ignore[arg-type]
|
|
82
|
+
if fn == "check_pattern":
|
|
83
|
+
if (curated := curated_pattern_values(desc)) is not None:
|
|
84
|
+
return curated.invalid
|
|
85
|
+
raise uncurated_pattern_error(desc, side="invalid")
|
|
86
|
+
if fn == "check_array_max_length":
|
|
87
|
+
max_len = int(desc.args[0]) # type: ignore[call-overload]
|
|
88
|
+
return [{}] * (max_len + 1)
|
|
89
|
+
if fn == "check_string_max_length":
|
|
90
|
+
max_len = int(desc.args[0]) # type: ignore[call-overload]
|
|
91
|
+
return "x" * (max_len + 1)
|
|
92
|
+
if fn == "check_geometry_type":
|
|
93
|
+
return _invalid_geometry(desc)
|
|
94
|
+
raise ValueError(f"No invalid value defined for check function: {fn!r}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _invalid_geometry(desc: ExpressionDescriptor) -> str:
|
|
98
|
+
allowed = set(desc.args)
|
|
99
|
+
for geom_type, wkt in _INVALID_GEOMETRY_CANDIDATES:
|
|
100
|
+
if geom_type not in allowed:
|
|
101
|
+
return wkt
|
|
102
|
+
raise ValueError(
|
|
103
|
+
f"All geometry candidates are in the allowed set: {allowed!r}. "
|
|
104
|
+
"Cannot produce an invalid geometry value."
|
|
105
|
+
)
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""Generate sparse path scaffolds for the rendered conformance tests.
|
|
2
|
+
|
|
3
|
+
`generate_scaffold` builds a sparse dict that, when merged with a base
|
|
4
|
+
row, supplies the nested intermediates (optional structs, arrays) the
|
|
5
|
+
base row lacks but a check's field path requires.
|
|
6
|
+
`generate_model_scaffold` does the same for model-level constraints.
|
|
7
|
+
`leaf_list_depth` reports unaccounted-for list depth on a target field.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from overture.schema.system.field_path import (
|
|
16
|
+
ArraySegment,
|
|
17
|
+
FieldPath,
|
|
18
|
+
FieldSegment,
|
|
19
|
+
Iterated,
|
|
20
|
+
MapSegment,
|
|
21
|
+
terminal_run_start,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from ...extraction.field_walk import (
|
|
25
|
+
has_array_layer,
|
|
26
|
+
list_depth,
|
|
27
|
+
terminal_model_ref,
|
|
28
|
+
terminal_union_ref,
|
|
29
|
+
)
|
|
30
|
+
from ...extraction.specs import FieldSpec, ModelSpec, RecordSpec
|
|
31
|
+
from ..check_ir import (
|
|
32
|
+
Check,
|
|
33
|
+
ElementGuard,
|
|
34
|
+
ModelCheck,
|
|
35
|
+
)
|
|
36
|
+
from .base_row import (
|
|
37
|
+
condition_overrides_for_present_field,
|
|
38
|
+
generate_base_row,
|
|
39
|
+
resolve_arm_spec,
|
|
40
|
+
value_for_field,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"generate_model_scaffold",
|
|
45
|
+
"generate_scaffold",
|
|
46
|
+
"leaf_list_depth",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
# Sentinel for "no leaf override": the terminal field keeps its synthesized
|
|
50
|
+
# value. A `None` / `""` leaf override is meaningful, so it cannot be the
|
|
51
|
+
# default.
|
|
52
|
+
_UNSET: object = object()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _nest_leaf_value(value: object, field_spec: FieldSpec) -> object:
|
|
56
|
+
"""Wrap a scalar leaf override to the field's list nesting depth.
|
|
57
|
+
|
|
58
|
+
`value_for_field` returns a list for a list-typed field, so a bare scalar
|
|
59
|
+
override (e.g. a literal alternative) is wrapped to the same depth: `[v]`
|
|
60
|
+
for `list[T]`, `[[v]]` for `list[list[T]]`, and `v` for a scalar field.
|
|
61
|
+
"""
|
|
62
|
+
for _ in range(list_depth(field_spec.shape)):
|
|
63
|
+
value = [value]
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class _ElementDiscriminator:
|
|
69
|
+
"""Discriminator value to seed at one nesting depth of the scaffold."""
|
|
70
|
+
|
|
71
|
+
field: str
|
|
72
|
+
value: str
|
|
73
|
+
depth: int
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _is_anonymous_iter(seg: FieldSegment) -> bool:
|
|
77
|
+
"""Return True when *seg* iterates a container nested directly inside another.
|
|
78
|
+
|
|
79
|
+
In a run of nested containers the first takes the field's name; each
|
|
80
|
+
further level is *anonymous*, because no field name introduces it -- the
|
|
81
|
+
parent element is itself the next container. For `grid: list[list[int]]`
|
|
82
|
+
the path `grid[][]` is a named `ArraySegment("grid")` followed by an
|
|
83
|
+
anonymous `ArraySegment("")`; this returns False for the first and True
|
|
84
|
+
for the second, the "extra" iteration past the named `grid`.
|
|
85
|
+
"""
|
|
86
|
+
return isinstance(seg, (ArraySegment, MapSegment)) and seg.is_anonymous
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _find_field_spec(fields: list[FieldSpec], name: str) -> FieldSpec | None:
|
|
90
|
+
"""Find a FieldSpec by name in a list."""
|
|
91
|
+
for f in fields:
|
|
92
|
+
if f.name == name:
|
|
93
|
+
return f
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def leaf_list_depth(field_path: FieldPath, spec: ModelSpec) -> int:
|
|
98
|
+
"""Return the unaccounted-for list depth of the leaf field.
|
|
99
|
+
|
|
100
|
+
Walks the spec's field tree along *field_path* and returns the leaf's
|
|
101
|
+
`list_depth(shape)` minus the path's own trailing iteration depth at
|
|
102
|
+
the leaf. The leaf is the last *named* segment -- any anonymous
|
|
103
|
+
`ArraySegment`s after it are further list-nesting of that same field,
|
|
104
|
+
not a lookup of their own, and are skipped both when descending the
|
|
105
|
+
field tree and when counting how much depth the path already covers.
|
|
106
|
+
Paths whose terminal segment is itself an array target the array's
|
|
107
|
+
elements, so the mutation already operates one level deep. Returns 0
|
|
108
|
+
when *field_path* is empty or when any segment fails to resolve
|
|
109
|
+
against *spec* (e.g. union arms that don't share the path's
|
|
110
|
+
intermediate fields).
|
|
111
|
+
"""
|
|
112
|
+
segments = field_path.segments
|
|
113
|
+
if not segments:
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
leaf_index = terminal_run_start(segments)
|
|
117
|
+
leaf_seg = segments[leaf_index]
|
|
118
|
+
terminal_iter = (
|
|
119
|
+
(len(segments) - leaf_index) if isinstance(leaf_seg, ArraySegment) else 0
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
fields = list(spec.fields)
|
|
123
|
+
for seg in segments[:leaf_index]:
|
|
124
|
+
if isinstance(seg, ArraySegment) and seg.is_anonymous:
|
|
125
|
+
continue
|
|
126
|
+
field = _find_field_spec(fields, seg.name)
|
|
127
|
+
if field is None:
|
|
128
|
+
return 0
|
|
129
|
+
model_ref = terminal_model_ref(field.shape)
|
|
130
|
+
if model_ref is None:
|
|
131
|
+
return 0
|
|
132
|
+
fields = model_ref.model.fields
|
|
133
|
+
|
|
134
|
+
leaf = _find_field_spec(fields, leaf_seg.name)
|
|
135
|
+
if leaf is None:
|
|
136
|
+
return 0
|
|
137
|
+
return max(0, list_depth(leaf.shape) - terminal_iter)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _child_container_spec(
|
|
141
|
+
field_spec: FieldSpec, discriminator_value: object | None
|
|
142
|
+
) -> RecordSpec | None:
|
|
143
|
+
"""Resolve the model a path field descends into.
|
|
144
|
+
|
|
145
|
+
Returns the field's terminal `ModelRef` model, or -- for a discriminated
|
|
146
|
+
union -- the member arm the `discriminator_value` selects (the first
|
|
147
|
+
member when the check is not arm-gated). `None` when the field has neither
|
|
148
|
+
a model nor a union terminal.
|
|
149
|
+
"""
|
|
150
|
+
model_ref = terminal_model_ref(field_spec.shape)
|
|
151
|
+
if model_ref is not None:
|
|
152
|
+
return model_ref.model
|
|
153
|
+
union_ref = terminal_union_ref(field_spec.shape)
|
|
154
|
+
if union_ref is not None:
|
|
155
|
+
return resolve_arm_spec(union_ref.union, discriminator_value)
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _walk_to_target(
|
|
160
|
+
segments: tuple[FieldSegment, ...],
|
|
161
|
+
fields: list[FieldSpec],
|
|
162
|
+
spec_name: str,
|
|
163
|
+
*,
|
|
164
|
+
discriminator: _ElementDiscriminator | None,
|
|
165
|
+
current_depth: int = 0,
|
|
166
|
+
leaf_value: object = _UNSET,
|
|
167
|
+
) -> dict[str, Any]:
|
|
168
|
+
"""Recursively build a constraint-satisfying scaffold along the path.
|
|
169
|
+
|
|
170
|
+
Each container model on the path is built as a valid base row
|
|
171
|
+
(`generate_base_row` -- required fields populated and model constraints
|
|
172
|
+
such as `require_any_of` satisfied), then the on-path child overrides its
|
|
173
|
+
field. A discriminated-union element resolves to the arm the seeded
|
|
174
|
+
discriminator selects (or the first member when the check is not
|
|
175
|
+
arm-gated), so the element is a valid instance of a concrete arm rather
|
|
176
|
+
than an untagged `{}`.
|
|
177
|
+
|
|
178
|
+
Accepts any `FieldSegment`: struct steps recurse, an `ArraySegment`
|
|
179
|
+
wraps its inner value in lists, and a trailing `MapSegment` resolves
|
|
180
|
+
via `value_for_field` (which populates the map with a valid entry),
|
|
181
|
+
so a map-projection target scaffolds the same way as a struct terminal.
|
|
182
|
+
|
|
183
|
+
`leaf_value`, when set, replaces the synthesized value at the terminal
|
|
184
|
+
field -- used to seed a specific valid value (e.g. a literal alternative)
|
|
185
|
+
at the check's target.
|
|
186
|
+
"""
|
|
187
|
+
if not segments:
|
|
188
|
+
return {}
|
|
189
|
+
|
|
190
|
+
seg = segments[0]
|
|
191
|
+
remaining = segments[1:]
|
|
192
|
+
field_spec = _find_field_spec(fields, seg.name)
|
|
193
|
+
|
|
194
|
+
# A path segment that resolves to no field, or that tries to descend into a
|
|
195
|
+
# non-container, would leave the scaffold short of its target -- the
|
|
196
|
+
# `::valid` row would then assert nothing (the vacuous-valid-row bug this
|
|
197
|
+
# generator exists to prevent). Fail loud at generation time instead.
|
|
198
|
+
if field_spec is None:
|
|
199
|
+
raise ValueError(
|
|
200
|
+
f"scaffold path segment {seg.name!r} matches no field "
|
|
201
|
+
f"(available: {sorted(f.name for f in fields)})"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# Anonymous iterating segments immediately after `seg` are further
|
|
205
|
+
# container-nesting of THIS SAME field (`list[list[...]]`,
|
|
206
|
+
# `dict[K, dict[K2, ...]]`, no intervening field name), not separate
|
|
207
|
+
# lookups -- `generate_base_row`/`value_for_field` resolve straight
|
|
208
|
+
# through every list and map layer, so peeling the run here (rather than
|
|
209
|
+
# recursing anonymous-segment-by-segment) keeps the base-row merge below
|
|
210
|
+
# anchored on the field `seg` actually names. `extra_iter` carries only the
|
|
211
|
+
# ARRAY levels to the wrap step; anonymous map levels need no manual
|
|
212
|
+
# wrapping, since `value_for_field` nests the map itself.
|
|
213
|
+
extra_iter = 0
|
|
214
|
+
while remaining and _is_anonymous_iter(remaining[0]):
|
|
215
|
+
if isinstance(remaining[0], ArraySegment):
|
|
216
|
+
extra_iter += 1
|
|
217
|
+
remaining = remaining[1:]
|
|
218
|
+
|
|
219
|
+
inner: Any
|
|
220
|
+
if remaining:
|
|
221
|
+
discriminator_value = (
|
|
222
|
+
discriminator.value
|
|
223
|
+
if discriminator is not None and current_depth == discriminator.depth
|
|
224
|
+
else None
|
|
225
|
+
)
|
|
226
|
+
child_spec = _child_container_spec(field_spec, discriminator_value)
|
|
227
|
+
if child_spec is None:
|
|
228
|
+
raise ValueError(
|
|
229
|
+
f"scaffold cannot descend into non-container field {seg.name!r} "
|
|
230
|
+
f"with path remaining {[s.name for s in remaining]!r}"
|
|
231
|
+
)
|
|
232
|
+
recursed = _walk_to_target(
|
|
233
|
+
remaining,
|
|
234
|
+
child_spec.fields,
|
|
235
|
+
spec_name,
|
|
236
|
+
discriminator=discriminator,
|
|
237
|
+
current_depth=current_depth + 1 + extra_iter,
|
|
238
|
+
leaf_value=leaf_value,
|
|
239
|
+
)
|
|
240
|
+
inner = {**generate_base_row(child_spec), **recursed}
|
|
241
|
+
elif leaf_value is not _UNSET:
|
|
242
|
+
inner = _nest_leaf_value(leaf_value, field_spec)
|
|
243
|
+
else:
|
|
244
|
+
inner = value_for_field(field_spec, spec_name)
|
|
245
|
+
|
|
246
|
+
if (
|
|
247
|
+
isinstance(inner, dict)
|
|
248
|
+
and discriminator is not None
|
|
249
|
+
and current_depth == discriminator.depth
|
|
250
|
+
):
|
|
251
|
+
inner[discriminator.field] = discriminator.value
|
|
252
|
+
|
|
253
|
+
# When the terminal segment is an array and the field itself is a list,
|
|
254
|
+
# `value_for_field` already wrapped the value -- skip extra wrapping.
|
|
255
|
+
if isinstance(seg, ArraySegment):
|
|
256
|
+
if not remaining and has_array_layer(field_spec.shape):
|
|
257
|
+
return {seg.name: inner}
|
|
258
|
+
# A single-level array (extra_iter == 0) gets a constraint-valid list;
|
|
259
|
+
# nested `list[list[...]]` levels (extra_iter > 0) carry no min_length>1
|
|
260
|
+
# or uniqueness constraint in any current schema, so minimal nesting
|
|
261
|
+
# suffices. Add per-level constraint handling here if one ever does --
|
|
262
|
+
# the row would otherwise be short on the unmutated `::valid` row.
|
|
263
|
+
if extra_iter == 0:
|
|
264
|
+
return {seg.name: _array_with_target(inner, field_spec, spec_name)}
|
|
265
|
+
wrapped: Any = inner
|
|
266
|
+
for _ in range(1 + extra_iter):
|
|
267
|
+
wrapped = [wrapped]
|
|
268
|
+
return {seg.name: wrapped}
|
|
269
|
+
if remaining and has_array_layer(field_spec.shape):
|
|
270
|
+
return {seg.name: _array_with_target(inner, field_spec, spec_name)}
|
|
271
|
+
return {seg.name: inner}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _array_with_target(
|
|
275
|
+
target_element: object, field_spec: FieldSpec, spec_name: str
|
|
276
|
+
) -> list[Any]:
|
|
277
|
+
"""Return a constraint-valid single-level list holding the target element.
|
|
278
|
+
|
|
279
|
+
`value_for_field` builds a list that satisfies the field's array
|
|
280
|
+
constraints (min length, unique items); the target-reaching element
|
|
281
|
+
replaces the first slot. A min_length>1 or uniqueness constraint then
|
|
282
|
+
holds on the unmutated `::valid` row -- a bare `[target_element]` would
|
|
283
|
+
leave the row short or, after `deep_merge` replaces the base row's list,
|
|
284
|
+
drop the elements that satisfied the constraint.
|
|
285
|
+
"""
|
|
286
|
+
full = value_for_field(field_spec, spec_name)
|
|
287
|
+
if isinstance(full, list) and full:
|
|
288
|
+
full[0] = target_element
|
|
289
|
+
return full
|
|
290
|
+
return [target_element]
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _element_discriminator(check: Check) -> _ElementDiscriminator | None:
|
|
294
|
+
"""Return the element-level discriminator for a Check, or None.
|
|
295
|
+
|
|
296
|
+
Bundles the discriminator field, the value to seed, and the depth at
|
|
297
|
+
which to seed it (the innermost array segment in the target path).
|
|
298
|
+
The check_ir invariant is that nested-union gating composes at most
|
|
299
|
+
one `ElementGuard` per Check; more than one would mean the gate
|
|
300
|
+
composition rule changed without updating the scaffold, so raise to
|
|
301
|
+
surface the gap rather than silently dropping guards.
|
|
302
|
+
"""
|
|
303
|
+
element_guards = [g for g in check.guards if isinstance(g, ElementGuard)]
|
|
304
|
+
if len(element_guards) > 1:
|
|
305
|
+
raise NotImplementedError(
|
|
306
|
+
f"Check carries {len(element_guards)} ElementGuards "
|
|
307
|
+
f"({element_guards!r}); the scaffold only seeds one. Update "
|
|
308
|
+
"the scaffold builder when the gate composition rule changes."
|
|
309
|
+
)
|
|
310
|
+
if not element_guards or not element_guards[0].values:
|
|
311
|
+
return None
|
|
312
|
+
guard = element_guards[0]
|
|
313
|
+
segments = check.target.segments
|
|
314
|
+
for i in range(len(segments) - 1, -1, -1):
|
|
315
|
+
if isinstance(segments[i], ArraySegment):
|
|
316
|
+
return _ElementDiscriminator(
|
|
317
|
+
field=guard.discriminator, value=guard.values[0], depth=i
|
|
318
|
+
)
|
|
319
|
+
return None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def generate_scaffold(
|
|
323
|
+
check: Check, spec: ModelSpec, *, leaf_value: object = _UNSET
|
|
324
|
+
) -> dict[str, Any]:
|
|
325
|
+
"""Build a sparse dict from null to the target field of a Check.
|
|
326
|
+
|
|
327
|
+
`leaf_value`, when set, seeds that value at the target instead of the
|
|
328
|
+
field's synthesized value -- used to place a known-valid value (e.g. a
|
|
329
|
+
literal alternative) at the check's target for the `::valid` row.
|
|
330
|
+
"""
|
|
331
|
+
segments = check.target.segments
|
|
332
|
+
if not segments:
|
|
333
|
+
return {}
|
|
334
|
+
|
|
335
|
+
if len(segments) == 1:
|
|
336
|
+
seg0 = segments[0]
|
|
337
|
+
field_spec = _find_field_spec(spec.fields, seg0.name)
|
|
338
|
+
if field_spec is None:
|
|
339
|
+
return {}
|
|
340
|
+
# A `forbid_if` the base row triggers forbids this field; disable the
|
|
341
|
+
# condition so the field can be set without invalidating the row.
|
|
342
|
+
overrides = condition_overrides_for_present_field(spec, seg0.name)
|
|
343
|
+
if leaf_value is not _UNSET:
|
|
344
|
+
return {**overrides, seg0.name: _nest_leaf_value(leaf_value, field_spec)}
|
|
345
|
+
if field_spec.is_required:
|
|
346
|
+
return {}
|
|
347
|
+
return {**overrides, seg0.name: value_for_field(field_spec, spec.name)}
|
|
348
|
+
|
|
349
|
+
return _walk_to_target(
|
|
350
|
+
segments,
|
|
351
|
+
spec.fields,
|
|
352
|
+
spec.name,
|
|
353
|
+
discriminator=_element_discriminator(check),
|
|
354
|
+
leaf_value=leaf_value,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def generate_model_scaffold(check: ModelCheck, spec: ModelSpec) -> dict[str, Any]:
|
|
359
|
+
"""Build a constraint-satisfying scaffold for a model-level check.
|
|
360
|
+
|
|
361
|
+
Two target shapes need no scaffold and return `{}`:
|
|
362
|
+
|
|
363
|
+
- a `Direct` target -- a top-level model constraint, whose fields live
|
|
364
|
+
at the row root;
|
|
365
|
+
- a map-first `Iterated` target -- a `dict[K, Model]` value-model
|
|
366
|
+
constraint. The mutation (`map_path=`) owns map navigation: it corrupts
|
|
367
|
+
the base row's single map entry in place, or stubs one when the map is
|
|
368
|
+
absent. Unlike an array, a dict scaffold can't replace a base-row map
|
|
369
|
+
entry under `deep_merge`'s recursive dict merge, so there is nothing to
|
|
370
|
+
add here.
|
|
371
|
+
|
|
372
|
+
An array-first `Iterated` target walks the path with `_walk_to_target`:
|
|
373
|
+
every model on the way -- including the constrained model at the leaf --
|
|
374
|
+
is built as a valid base row, so the constraint under test (e.g. a
|
|
375
|
+
scope's `require_any_of`) is satisfied on the unmutated `::valid` row and
|
|
376
|
+
the only violation is the one the mutation introduces.
|
|
377
|
+
|
|
378
|
+
An `Iterated` target is array-first exactly when its outermost frame is
|
|
379
|
+
an `ArraySegment` (guaranteed named, so it heads `iter_frames`); a
|
|
380
|
+
map-first target's mutation owns navigation, matching the former
|
|
381
|
+
map-path case.
|
|
382
|
+
"""
|
|
383
|
+
target = check.target
|
|
384
|
+
if isinstance(target, Iterated) and isinstance(
|
|
385
|
+
target.iter_frames[0][1], ArraySegment
|
|
386
|
+
):
|
|
387
|
+
return _walk_to_target(
|
|
388
|
+
target.segments, spec.fields, spec.name, discriminator=None
|
|
389
|
+
)
|
|
390
|
+
return {}
|