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,172 @@
1
+ """Tree-shaped IR for model field types.
2
+
3
+ `FieldShape` is a discriminated union -- `Primitive`, `LiteralScalar`,
4
+ `AnyScalar`, `ModelRef`, `UnionRef`, `ArrayOf`, `MapOf`, `NewTypeShape`
5
+ -- nested to describe arbitrary list / dict / NewType wrapping. Each
6
+ variant carries its own constraints (where meaningful), and walkers
7
+ encounter each constraint at the layer it targets.
8
+
9
+ The three terminal scalar variants (`Primitive`, `LiteralScalar`,
10
+ `AnyScalar`) are grouped under the `Scalar` type alias for consumers
11
+ that only need to ask "is this a leaf?".
12
+
13
+ `NewTypeShape` wraps an inner shape, so its position relative to
14
+ `ArrayOf` is structural: `NewTypeShape(inner=ArrayOf(...))` is a
15
+ NewType over `list[X]`, while `ArrayOf(element=NewTypeShape(...))`
16
+ is a list of NewType-wrapped values. Consumers pattern-match on
17
+ shape to distinguish the two.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass
23
+ from typing import TYPE_CHECKING, TypeAlias
24
+
25
+ if TYPE_CHECKING:
26
+ from .specs import RecordSpec, UnionSpec
27
+
28
+ __all__ = [
29
+ "AnyScalar",
30
+ "ArrayOf",
31
+ "ConstraintSource",
32
+ "FieldShape",
33
+ "LiteralScalar",
34
+ "MapOf",
35
+ "ModelRef",
36
+ "NewTypeShape",
37
+ "Primitive",
38
+ "Scalar",
39
+ "UnionRef",
40
+ ]
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class ConstraintSource:
45
+ """A constraint paired with the NewType that contributed it.
46
+
47
+ `source_ref` and `source_name` identify the NewType that declared
48
+ the constraint; both are `None` for constraints contributed directly
49
+ on a field annotation rather than through a NewType. `constraint`
50
+ is the raw metadata object from `Annotated[..., constraint]`.
51
+ """
52
+
53
+ source_ref: object | None
54
+ source_name: str | None
55
+ constraint: object
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class Primitive:
60
+ """Terminal type with a registry lookup key.
61
+
62
+ Covers primitives (`int32`, `str`), enums, Pydantic built-ins
63
+ (`HttpUrl`, `EmailStr`), and `BaseModel` subclasses that weren't
64
+ resolved to a `ModelRef` (e.g. when no `model_resolver` was
65
+ supplied).
66
+ """
67
+
68
+ base_type: str
69
+ source_type: type | None = None
70
+ constraints: tuple[ConstraintSource, ...] = ()
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class LiteralScalar:
75
+ """`Literal[X, ...]` terminal."""
76
+
77
+ values: tuple[object, ...]
78
+ constraints: tuple[ConstraintSource, ...] = ()
79
+
80
+
81
+ @dataclass(frozen=True, slots=True)
82
+ class AnyScalar:
83
+ """`typing.Any` terminal."""
84
+
85
+ constraints: tuple[ConstraintSource, ...] = ()
86
+
87
+
88
+ Scalar: TypeAlias = Primitive | LiteralScalar | AnyScalar
89
+ """Terminal shape: a value that doesn't wrap another shape.
90
+
91
+ Consumers that just need "is this a leaf?" check `isinstance(x, Scalar)`;
92
+ consumers that need terminal-specific data narrow to a variant.
93
+ """
94
+
95
+
96
+ @dataclass(frozen=True, slots=True)
97
+ class ModelRef:
98
+ """Reference to a Pydantic sub-model.
99
+
100
+ `starts_cycle` marks the back-edge of a cycle in the model graph;
101
+ consumers that recurse into models must stop at cycle starts.
102
+ """
103
+
104
+ model: RecordSpec
105
+ starts_cycle: bool = False
106
+
107
+
108
+ @dataclass(frozen=True, slots=True)
109
+ class UnionRef:
110
+ """Reference to a discriminated union of models."""
111
+
112
+ union: UnionSpec
113
+
114
+
115
+ @dataclass(frozen=True, slots=True)
116
+ class ArrayOf:
117
+ """Sequence of values sharing a single element shape.
118
+
119
+ Nested arrays are nested `ArrayOf` instances; there is no numeric
120
+ depth field. `constraints` carries array-level validation rules
121
+ (length, uniqueness). Per-element constraints live on `element`
122
+ and its descendants.
123
+ """
124
+
125
+ element: FieldShape
126
+ constraints: tuple[ConstraintSource, ...] = ()
127
+
128
+
129
+ @dataclass(frozen=True, slots=True)
130
+ class MapOf:
131
+ """Mapping from a key shape to a value shape.
132
+
133
+ `constraints` carries map-level validation rules. Per-key and
134
+ per-value constraints live on `key` / `value` respectively.
135
+ """
136
+
137
+ key: FieldShape
138
+ value: FieldShape
139
+ constraints: tuple[ConstraintSource, ...] = ()
140
+
141
+
142
+ @dataclass(frozen=True, slots=True)
143
+ class NewTypeShape:
144
+ """A NewType wrapper around an inner shape.
145
+
146
+ Position relative to other wrappers is meaningful:
147
+ `NewTypeShape(inner=ArrayOf(...))` is a NewType over `list[X]`;
148
+ `ArrayOf(element=NewTypeShape(...))` is a list of NewType-wrapped
149
+ values. Consumers distinguish the two by pattern, not a numeric
150
+ offset.
151
+
152
+ Constraints contributed by the NewType chain attach to the
153
+ `Scalar` / `ArrayOf` / `MapOf` layer they target, not to the
154
+ wrapper itself. `name` and `ref` identify the NewType for linking
155
+ without owning constraint state.
156
+ """
157
+
158
+ name: str
159
+ ref: object
160
+ inner: FieldShape
161
+
162
+
163
+ FieldShape: TypeAlias = (
164
+ Primitive
165
+ | LiteralScalar
166
+ | AnyScalar
167
+ | ModelRef
168
+ | UnionRef
169
+ | ArrayOf
170
+ | MapOf
171
+ | NewTypeShape
172
+ )
@@ -0,0 +1,185 @@
1
+ """Convert field-level constraints to display text.
2
+
3
+ Handles constraints from Annotated metadata and NewType wrappers:
4
+ Ge, Gt, Interval, Le, Lt, ArrayMinLen, ArrayMaxLen, ScalarMinLen,
5
+ ScalarMaxLen, GeometryTypeConstraint, Reference, and custom constraint
6
+ classes.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from collections.abc import Callable
13
+
14
+ from annotated_types import Ge, Gt, Interval, Le, Lt, MultipleOf
15
+
16
+ from overture.schema.system.geometric import GeometryTypeConstraint
17
+ from overture.schema.system.ref import Reference
18
+
19
+ from .docstring import first_docstring_line
20
+ from .length_constraints import ArrayMaxLen, ArrayMinLen, ScalarMaxLen, ScalarMinLen
21
+ from .literal_alternatives import LiteralAlternatives
22
+ from .specs import TypeIdentity
23
+ from .type_analyzer import ConstraintSource
24
+
25
+ __all__ = [
26
+ "constraint_display_text",
27
+ "describe_field_constraint",
28
+ ]
29
+
30
+ # Bound attribute -> mathematical symbol for prose rendering.
31
+ _BOUND_OPS: tuple[tuple[str, str], ...] = (
32
+ ("ge", "≥"),
33
+ ("gt", ">"),
34
+ ("le", "≤"),
35
+ ("lt", "<"),
36
+ )
37
+
38
+
39
+ def _first_bound(obj: object) -> str | None:
40
+ """Return backticked notation for the first set bound, or None."""
41
+ for attr, op in _BOUND_OPS:
42
+ val = getattr(obj, attr, None)
43
+ if val is not None:
44
+ return f"`{op} {val}`"
45
+ return None
46
+
47
+
48
+ def _describe_interval(iv: Interval) -> str:
49
+ """Format an Interval as readable bound notation."""
50
+ lower_val = iv.ge if iv.ge is not None else iv.gt
51
+ lower_op = "≤" if iv.ge is not None else "<"
52
+ upper_val = iv.le if iv.le is not None else iv.lt
53
+ upper_op = "≤" if iv.le is not None else "<"
54
+
55
+ if lower_val is not None and upper_val is not None:
56
+ return f"`{lower_val} {lower_op} x {upper_op} {upper_val}`"
57
+
58
+ return _first_bound(iv) or ""
59
+
60
+
61
+ def _is_opaque_constraint(constraint: object) -> bool:
62
+ """Check whether the constraint has no custom __repr__ (renders as just its class name)."""
63
+ return type(constraint).__repr__ is object.__repr__
64
+
65
+
66
+ def _geometry_type_label(value: str) -> str:
67
+ """Convert a GeometryType value to PascalCase display name.
68
+
69
+ >>> _geometry_type_label("line_string")
70
+ 'LineString'
71
+ """
72
+ return "".join(part.title() for part in value.split("_"))
73
+
74
+
75
+ def describe_field_constraint(
76
+ constraint: object,
77
+ link_fn: Callable[[TypeIdentity], str] | None = None,
78
+ ) -> str:
79
+ """Return a display string for a field-level constraint object.
80
+
81
+ *link_fn* resolves a TypeIdentity to a markdown link string (e.g.
82
+ `` [`Name`](path) ``). When None, names render as inline code.
83
+ """
84
+ if isinstance(constraint, GeometryTypeConstraint):
85
+ labels = ", ".join(
86
+ _geometry_type_label(gt.value) for gt in constraint.allowed_types
87
+ )
88
+ return f"Allowed geometry types: {labels}"
89
+ if isinstance(constraint, Reference):
90
+ rel_value: str = constraint.relationship.value # type: ignore[assignment]
91
+ rel_label = rel_value.replace("_", " ")
92
+ target = constraint.relatee
93
+ target_id = TypeIdentity.of(target)
94
+ target_str = link_fn(target_id) if link_fn else f"`{target.__name__}`"
95
+ if constraint.role:
96
+ role_label = constraint.role.replace("_", " ")
97
+ return f"References {target_str} ({rel_label}, {role_label})"
98
+ return f"References {target_str} ({rel_label})"
99
+ if isinstance(constraint, Interval):
100
+ desc = _describe_interval(constraint)
101
+ if desc:
102
+ return desc
103
+ elif isinstance(constraint, (Ge, Gt, Le, Lt)):
104
+ result = _first_bound(constraint)
105
+ if result is not None:
106
+ return result
107
+ if isinstance(constraint, MultipleOf):
108
+ if constraint.multiple_of == 1:
109
+ return "Must be a whole number"
110
+ return f"Must be a multiple of {constraint.multiple_of}"
111
+ if isinstance(constraint, (ArrayMinLen, ScalarMinLen)):
112
+ return f"Minimum length: {constraint.min_length}"
113
+ if isinstance(constraint, (ArrayMaxLen, ScalarMaxLen)):
114
+ return f"Maximum length: {constraint.max_length}"
115
+ if isinstance(constraint, LiteralAlternatives):
116
+ return "Also accepts: " + ", ".join(f"`{v!r}`" for v in constraint.values)
117
+
118
+ if _is_opaque_constraint(constraint):
119
+ return f"`{type(constraint).__name__}`"
120
+ return f"`{constraint}`"
121
+
122
+
123
+ def _constraint_class_description(constraint: object) -> str | None:
124
+ """Extract the first docstring line from a custom constraint class.
125
+
126
+ Returns None for builtins and classes without docstrings.
127
+ """
128
+ constraint_type = type(constraint)
129
+ if constraint_type.__module__ == "builtins":
130
+ return None
131
+ line = first_docstring_line(constraint_type.__doc__)
132
+ return line or None
133
+
134
+
135
+ # re.UNICODE is the implicit default on compiled `str` patterns; rendering it
136
+ # would stamp a noise `(?u)` group onto every pattern. Every other flag with a
137
+ # visible matching effect is surfaced in the documented pattern. Unlike the
138
+ # pyspark dispatch (`compiled_pattern_source`) -- which must reject flags
139
+ # Spark's rlike cannot honor -- display is faithful for known flags and never
140
+ # fails: a flag absent from this table is dropped from the rendered group, not
141
+ # raised on. A new flag added to pyspark's supported set with a visible effect
142
+ # belongs here too, or docs will hide that pattern's real behavior.
143
+ _DISPLAY_FLAG_LETTERS: tuple[tuple[re.RegexFlag, str], ...] = (
144
+ (re.IGNORECASE, "i"),
145
+ (re.MULTILINE, "m"),
146
+ (re.DOTALL, "s"),
147
+ (re.VERBOSE, "x"),
148
+ (re.ASCII, "a"),
149
+ )
150
+
151
+
152
+ def _inline_flag_prefix(flags: int) -> str:
153
+ """Render set regex flags as an inline group like `(?im)`, or "" if none."""
154
+ letters = "".join(c for flag, c in _DISPLAY_FLAG_LETTERS if flags & flag)
155
+ return f"(?{letters})" if letters else ""
156
+
157
+
158
+ def _constraint_pattern(constraint: object) -> str | None:
159
+ """Return a constraint's compiled regex as displayable source, or None.
160
+
161
+ Prepends an inline-flag group (e.g. `(?i)` for case-insensitivity) so a
162
+ flagged pattern reads as the regex that actually matches rather than its
163
+ bare, misleading source. Returns None when `constraint.pattern` is not a
164
+ compiled `re.Pattern`.
165
+ """
166
+ compiled = getattr(constraint, "pattern", None)
167
+ if not isinstance(compiled, re.Pattern):
168
+ return None
169
+ return f"{_inline_flag_prefix(compiled.flags)}{compiled.pattern}"
170
+
171
+
172
+ def constraint_display_text(
173
+ cs: ConstraintSource,
174
+ link_fn: Callable[[TypeIdentity], str] | None = None,
175
+ ) -> str:
176
+ """Build display text for a constraint, combining description/pattern when available."""
177
+ description = _constraint_class_description(cs.constraint)
178
+ if _is_opaque_constraint(cs.constraint) and description:
179
+ cls_name = type(cs.constraint).__name__
180
+ pattern = _constraint_pattern(cs.constraint)
181
+ if pattern:
182
+ return f"{description} (`{cls_name}`, pattern: `{pattern}`)"
183
+ return f"{description} (`{cls_name}`)"
184
+
185
+ return describe_field_constraint(cs.constraint, link_fn=link_fn)
@@ -0,0 +1,275 @@
1
+ """Generic traversal helpers over `FieldShape` trees.
2
+
3
+ `shape_children` (one-level child enumeration) and `walk_shape`
4
+ (pre-order DFS) cover open-ended traversals; `terminal_of`,
5
+ `terminal_scalar`, `list_depth`, `newtype_name`, and `all_constraints`
6
+ cover the most common derived views. `ModelRef` and `UnionRef` are
7
+ leaves -- the walker does not cross model or union boundaries
8
+ automatically; that's a per-consumer decision.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Callable, Iterator
14
+ from enum import Enum
15
+
16
+ from typing_extensions import assert_never
17
+
18
+ from .field import (
19
+ AnyScalar,
20
+ ArrayOf,
21
+ ConstraintSource,
22
+ FieldShape,
23
+ LiteralScalar,
24
+ MapOf,
25
+ ModelRef,
26
+ NewTypeShape,
27
+ Primitive,
28
+ Scalar,
29
+ UnionRef,
30
+ )
31
+
32
+ __all__ = [
33
+ "all_constraints",
34
+ "enum_source",
35
+ "has_array_layer",
36
+ "list_depth",
37
+ "map_key_value_constraints",
38
+ "newtype_name",
39
+ "shape_children",
40
+ "terminal_model_ref",
41
+ "terminal_of",
42
+ "terminal_primitive",
43
+ "terminal_scalar",
44
+ "terminal_union_ref",
45
+ "walk_shape",
46
+ ]
47
+
48
+
49
+ def terminal_of(shape: FieldShape) -> FieldShape:
50
+ """Unwrap `ArrayOf` and `NewTypeShape` layers to find the terminal shape.
51
+
52
+ Returns the innermost shape that isn't a sequence or NewType wrapper.
53
+ `Scalar`, `ModelRef`, `UnionRef`, and `MapOf` count as terminals.
54
+ """
55
+ while True:
56
+ match shape:
57
+ case ArrayOf(element=inner) | NewTypeShape(inner=inner):
58
+ shape = inner
59
+ case (
60
+ Primitive()
61
+ | LiteralScalar()
62
+ | AnyScalar()
63
+ | ModelRef()
64
+ | UnionRef()
65
+ | MapOf()
66
+ ):
67
+ return shape
68
+ case _:
69
+ assert_never(shape)
70
+
71
+
72
+ def terminal_scalar(shape: FieldShape) -> Scalar | None:
73
+ """Return the terminal `Scalar`, or `None` for non-scalar terminals."""
74
+ terminal = terminal_of(shape)
75
+ return terminal if isinstance(terminal, Scalar) else None
76
+
77
+
78
+ def terminal_primitive(shape: FieldShape) -> Primitive | None:
79
+ """Return the terminal `Primitive`, or `None` for non-primitive terminals.
80
+
81
+ Like `terminal_scalar`, but returns `None` for `LiteralScalar` and
82
+ `AnyScalar` — use this when the caller needs `base_type` or
83
+ `source_type`, which only exist on `Primitive`.
84
+ """
85
+ terminal = terminal_of(shape)
86
+ return terminal if isinstance(terminal, Primitive) else None
87
+
88
+
89
+ def terminal_model_ref(shape: FieldShape) -> ModelRef | None:
90
+ """Return the terminal `ModelRef`, or `None` for non-model terminals."""
91
+ terminal = terminal_of(shape)
92
+ return terminal if isinstance(terminal, ModelRef) else None
93
+
94
+
95
+ def terminal_union_ref(shape: FieldShape) -> UnionRef | None:
96
+ """Return the terminal `UnionRef`, or `None` for non-union terminals."""
97
+ terminal = terminal_of(shape)
98
+ return terminal if isinstance(terminal, UnionRef) else None
99
+
100
+
101
+ def enum_source(shape: FieldShape) -> type[Enum] | None:
102
+ """Return the `Enum` class backing a `Primitive`, or `None`.
103
+
104
+ Returns the `Enum` subclass stored in `Primitive.source_type` when
105
+ `shape` is a `Primitive` and `source_type` is an `Enum` subclass.
106
+ Returns `None` for every other shape, including wrappers: a
107
+ `NewTypeShape` wrapping an enum-backed `Primitive` returns `None`,
108
+ not the inner enum.
109
+
110
+ Parameters
111
+ ----------
112
+ shape
113
+ The shape to inspect.
114
+
115
+ Returns
116
+ -------
117
+ type[Enum] or None
118
+ The `Enum` class when `shape` is a `Primitive` backed by one,
119
+ `None` otherwise.
120
+ """
121
+ if not isinstance(shape, Primitive):
122
+ return None
123
+ src = shape.source_type
124
+ if isinstance(src, type) and issubclass(src, Enum):
125
+ return src
126
+ return None
127
+
128
+
129
+ def shape_children(shape: FieldShape) -> Iterator[FieldShape]:
130
+ """Yield direct child shapes within *shape* (one level deep).
131
+
132
+ `Scalar`, `ModelRef`, and `UnionRef` have no children.
133
+ """
134
+ match shape:
135
+ case ArrayOf(element=element):
136
+ yield element
137
+ case MapOf(key=key, value=value):
138
+ yield key
139
+ yield value
140
+ case NewTypeShape(inner=inner):
141
+ yield inner
142
+ case Primitive() | LiteralScalar() | AnyScalar() | ModelRef() | UnionRef():
143
+ return
144
+ case _:
145
+ assert_never(shape)
146
+
147
+
148
+ def walk_shape(shape: FieldShape, visit: Callable[[FieldShape], None]) -> None:
149
+ """Pre-order traversal of a `FieldShape` tree.
150
+
151
+ Visits *shape*, then descends into each direct child via
152
+ `shape_children`. Stops at `ModelRef` / `UnionRef` -- recursion
153
+ across model boundaries is the caller's choice.
154
+ """
155
+ visit(shape)
156
+ for child in shape_children(shape):
157
+ walk_shape(child, visit)
158
+
159
+
160
+ def list_depth(shape: FieldShape) -> int:
161
+ """Total number of `ArrayOf` layers in *shape*, looking through `NewTypeShape`.
162
+
163
+ A NewType wrapping a list counts the same as a list wrapping a
164
+ NewType.
165
+ """
166
+ depth = 0
167
+ cur = shape
168
+ while True:
169
+ match cur:
170
+ case ArrayOf(element=element):
171
+ depth += 1
172
+ cur = element
173
+ case NewTypeShape(inner=inner):
174
+ cur = inner
175
+ case (
176
+ Primitive()
177
+ | LiteralScalar()
178
+ | AnyScalar()
179
+ | ModelRef()
180
+ | UnionRef()
181
+ | MapOf()
182
+ ):
183
+ return depth
184
+ case _:
185
+ assert_never(cur)
186
+
187
+
188
+ def has_array_layer(shape: FieldShape) -> bool:
189
+ """Whether *shape* has any `ArrayOf` layer, looking through `NewTypeShape`.
190
+
191
+ Prefer this over `list_depth(shape) > 0` -- callers that only need
192
+ "is this array-shaped" don't need to count layers.
193
+ """
194
+ cur = shape
195
+ while isinstance(cur, NewTypeShape):
196
+ cur = cur.inner
197
+ return isinstance(cur, ArrayOf)
198
+
199
+
200
+ def newtype_name(shape: FieldShape) -> str | None:
201
+ """Return the outermost `NewTypeShape` name, looking through `ArrayOf` layers."""
202
+ cur: FieldShape = shape
203
+ while isinstance(cur, ArrayOf):
204
+ cur = cur.element
205
+ match cur:
206
+ case NewTypeShape(name=name):
207
+ return name
208
+ case (
209
+ Primitive()
210
+ | LiteralScalar()
211
+ | AnyScalar()
212
+ | ModelRef()
213
+ | UnionRef()
214
+ | MapOf()
215
+ ):
216
+ return None
217
+ case _:
218
+ assert_never(cur)
219
+
220
+
221
+ def all_constraints(shape: FieldShape) -> tuple[ConstraintSource, ...]:
222
+ """Concatenate the field's own constraints from every layer of *shape*.
223
+
224
+ Walks `NewTypeShape` and `ArrayOf` wrappers to gather constraints
225
+ that apply to this field. Stops at `MapOf` (key/value constraints
226
+ belong to nested key/value shapes, not to the enclosing field) and
227
+ at `ModelRef` / `UnionRef` (which carry no constraints). Constraints
228
+ from outer `ArrayOf` layers appear before constraints from inner
229
+ layers, matching the structural order of the shape tree.
230
+ """
231
+ collected: list[ConstraintSource] = []
232
+ cur = shape
233
+ while True:
234
+ match cur:
235
+ case ArrayOf(element=inner, constraints=cs):
236
+ collected.extend(cs)
237
+ cur = inner
238
+ case NewTypeShape(inner=inner):
239
+ cur = inner
240
+ case (
241
+ Primitive(constraints=cs)
242
+ | LiteralScalar(constraints=cs)
243
+ | AnyScalar(constraints=cs)
244
+ ):
245
+ collected.extend(cs)
246
+ return tuple(collected)
247
+ case MapOf(constraints=cs):
248
+ collected.extend(cs)
249
+ return tuple(collected)
250
+ case ModelRef() | UnionRef():
251
+ return tuple(collected)
252
+ case _:
253
+ assert_never(cur)
254
+
255
+
256
+ def map_key_value_constraints(
257
+ shape: FieldShape,
258
+ ) -> tuple[tuple[ConstraintSource, ...], tuple[ConstraintSource, ...]]:
259
+ """Return a `MapOf` terminal's (key_constraints, value_constraints), or `((), ())`.
260
+
261
+ Looks through `NewTypeShape` / `ArrayOf` wrappers to find a `MapOf`,
262
+ then gathers each side's constraints with `all_constraints`. This
263
+ surfaces per-key and per-value rules that `all_constraints` on the
264
+ enclosing field deliberately stops short of (it treats `MapOf` as a
265
+ terminal). Returns `((), ())` when *shape* has no `MapOf` terminal.
266
+ """
267
+ cur = shape
268
+ while True:
269
+ match cur:
270
+ case NewTypeShape(inner=inner) | ArrayOf(element=inner):
271
+ cur = inner
272
+ case MapOf(key=key, value=value):
273
+ return all_constraints(key), all_constraints(value)
274
+ case _:
275
+ return (), ()
@@ -0,0 +1,49 @@
1
+ """Internal typed length-constraint classes.
2
+
3
+ `annotated_types.MaxLen` and `annotated_types.MinLen` are polysemous:
4
+ `MaxLen(10)` on a `str` constrains character count, while `MaxLen(10)`
5
+ on a `list[X]` constrains cardinality. The codegen extractor splits
6
+ them by attachment layer so each variant carries its own dispatch:
7
+ `ArrayMinLen` / `ArrayMaxLen` for `ArrayOf` layers, `ScalarMinLen` /
8
+ `ScalarMaxLen` for scalar layers.
9
+
10
+ These are codegen-internal classes -- schema authors continue to write
11
+ the normal Pydantic form (`Field(min_length=n)` / `Field(max_length=n)`),
12
+ which Pydantic lowers into the `annotated_types.MinLen` / `MaxLen`
13
+ metadata described above. The wrapping into these layer-typed variants
14
+ happens inside `type_analyzer.attach_constraints` when the constraint
15
+ reaches its target layer.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+
22
+ from annotated_types import MaxLen, MinLen
23
+
24
+ __all__ = [
25
+ "ArrayMaxLen",
26
+ "ArrayMinLen",
27
+ "ScalarMaxLen",
28
+ "ScalarMinLen",
29
+ ]
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class ArrayMinLen(MinLen):
34
+ """Cardinality lower bound for an `ArrayOf` layer."""
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class ArrayMaxLen(MaxLen):
39
+ """Cardinality upper bound for an `ArrayOf` layer."""
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class ScalarMinLen(MinLen):
44
+ """Character-count lower bound for a scalar layer."""
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class ScalarMaxLen(MaxLen):
49
+ """Character-count upper bound for a scalar layer."""