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,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."""
|