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,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 {}