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,693 @@
|
|
|
1
|
+
"""Annotation-to-`FieldShape` analysis.
|
|
2
|
+
|
|
3
|
+
`analyze_type` recurses through a Python type annotation, peeling
|
|
4
|
+
`NewType`, `Annotated`, `Optional`, `list`, and `dict` layers one frame
|
|
5
|
+
at a time, and produces a `FieldShape` describing the structure with
|
|
6
|
+
constraints attached to the layer they target.
|
|
7
|
+
|
|
8
|
+
Forward references encountered along the way are resolved against the
|
|
9
|
+
`owner` model's namespace before classification. Builtin generics store
|
|
10
|
+
`list["Node"]`'s element as a bare `str` (not a `ForwardRef`), which
|
|
11
|
+
neither Pydantic nor `typing.get_type_hints` resolves; resolving it here
|
|
12
|
+
lets a self-referential field reach its model terminal so the cycle
|
|
13
|
+
guard in `extract_model` engages.
|
|
14
|
+
|
|
15
|
+
Each `Annotated` frame attaches its metadata to the shape its inner
|
|
16
|
+
annotation unwraps to, so that, e.g., the inner and outer `MinLen` in
|
|
17
|
+
`Annotated[list[Annotated[str, MinLen(2)]], MinLen(3)]` land on
|
|
18
|
+
different layers as different typed variants: `ArrayMinLen(3)` on the
|
|
19
|
+
`ArrayOf`, `ScalarMinLen(2)` on the `Primitive`.
|
|
20
|
+
|
|
21
|
+
MODEL and UNION terminals are resolved via optional callbacks. When
|
|
22
|
+
no resolver is supplied a MODEL terminal falls back to
|
|
23
|
+
`Primitive(source_type=cls)`; a multi-arm UNION raises
|
|
24
|
+
`UnsupportedUnionError`. Callers that need to recurse into sub-models
|
|
25
|
+
pass resolvers that build a `ModelRef`/`UnionRef` with the resolved
|
|
26
|
+
spec.
|
|
27
|
+
|
|
28
|
+
A `RootModel` never reaches those terminals: it serializes as its bare
|
|
29
|
+
root value, so it is unwrapped to the root type's shape (with any root
|
|
30
|
+
metadata reattached) before terminal classification -- resolver or not.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import types
|
|
36
|
+
from collections.abc import Callable
|
|
37
|
+
from dataclasses import dataclass, replace
|
|
38
|
+
from typing import (
|
|
39
|
+
Annotated,
|
|
40
|
+
Any,
|
|
41
|
+
ForwardRef,
|
|
42
|
+
Literal,
|
|
43
|
+
NoReturn,
|
|
44
|
+
Union,
|
|
45
|
+
get_args,
|
|
46
|
+
get_origin,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
from annotated_types import MaxLen, MinLen
|
|
50
|
+
from pydantic import BaseModel, RootModel
|
|
51
|
+
from pydantic.fields import FieldInfo
|
|
52
|
+
from typing_extensions import Sentinel, assert_never, evaluate_forward_ref
|
|
53
|
+
|
|
54
|
+
from .docstring import clean_docstring
|
|
55
|
+
from .field import (
|
|
56
|
+
AnyScalar,
|
|
57
|
+
ArrayOf,
|
|
58
|
+
ConstraintSource,
|
|
59
|
+
FieldShape,
|
|
60
|
+
LiteralScalar,
|
|
61
|
+
MapOf,
|
|
62
|
+
ModelRef,
|
|
63
|
+
NewTypeShape,
|
|
64
|
+
Primitive,
|
|
65
|
+
UnionRef,
|
|
66
|
+
)
|
|
67
|
+
from .field_walk import terminal_of
|
|
68
|
+
from .length_constraints import ArrayMaxLen, ArrayMinLen, ScalarMaxLen, ScalarMinLen
|
|
69
|
+
from .literal_alternatives import LiteralAlternatives
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class _ContinueWith:
|
|
74
|
+
"""`_peel_union` result: next annotation to keep peeling.
|
|
75
|
+
|
|
76
|
+
`literal_alternatives` carries the values of any `Literal[...]` arms
|
|
77
|
+
dropped in favor of a single concrete arm, so the caller can attach a
|
|
78
|
+
`LiteralAlternatives` constraint to the recursed shape.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
annotation: object
|
|
82
|
+
is_optional: bool
|
|
83
|
+
literal_alternatives: tuple[object, ...] = ()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True, slots=True)
|
|
87
|
+
class _Resolved:
|
|
88
|
+
"""`_peel_union` result: finished shape, short-circuit the unwrap."""
|
|
89
|
+
|
|
90
|
+
shape: FieldShape
|
|
91
|
+
is_optional: bool
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True, slots=True)
|
|
95
|
+
class _NewTypeCtx:
|
|
96
|
+
"""The innermost NewType currently in scope."""
|
|
97
|
+
|
|
98
|
+
name: str
|
|
99
|
+
ref: object
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
__all__ = [
|
|
103
|
+
"ConstraintSource",
|
|
104
|
+
"ModelResolver",
|
|
105
|
+
"UnionResolver",
|
|
106
|
+
"UnresolvedForwardRefError",
|
|
107
|
+
"UnsupportedUnionError",
|
|
108
|
+
"analyze_type",
|
|
109
|
+
"attach_constraints",
|
|
110
|
+
"attach_field_metadata",
|
|
111
|
+
"capture_union_members",
|
|
112
|
+
"is_newtype",
|
|
113
|
+
"single_literal_value",
|
|
114
|
+
"unwrap_list",
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class UnsupportedUnionError(TypeError):
|
|
119
|
+
"""Raised when `analyze_type` encounters a multi-type union it cannot represent."""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class UnresolvedForwardRefError(TypeError):
|
|
123
|
+
"""Raised when a forward-reference annotation cannot be resolved to a type.
|
|
124
|
+
|
|
125
|
+
Subclasses `TypeError` so callers that already guard `analyze_type`
|
|
126
|
+
with `except (TypeError, UnsupportedUnionError)` treat an unresolvable
|
|
127
|
+
forward ref as the analyzable-shape failure it is.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
ModelResolver = Callable[[type[BaseModel]], FieldShape]
|
|
132
|
+
"""Resolver invoked when `analyze_type` reaches a `BaseModel` terminal."""
|
|
133
|
+
|
|
134
|
+
UnionResolver = Callable[[object, tuple[type[BaseModel], ...], str | None], FieldShape]
|
|
135
|
+
"""Resolver invoked at a multi-arm union terminal.
|
|
136
|
+
|
|
137
|
+
Receives the original union annotation, the tuple of member classes,
|
|
138
|
+
and the description accumulated from enclosing `Annotated` layers.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def is_newtype(annotation: object) -> bool:
|
|
143
|
+
"""Check whether *annotation* is a `typing.NewType`.
|
|
144
|
+
|
|
145
|
+
NewType creates a callable with a `__supertype__` attribute pointing
|
|
146
|
+
to the wrapped type. No public API exists for this check.
|
|
147
|
+
"""
|
|
148
|
+
return callable(annotation) and hasattr(annotation, "__supertype__")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class _UnionCaptured(Exception): # noqa: N818 - control flow, not a true error
|
|
152
|
+
"""Raised by the capturing union resolver to short-circuit analyze_type."""
|
|
153
|
+
|
|
154
|
+
def __init__(
|
|
155
|
+
self, members: tuple[type[BaseModel], ...], description: str | None
|
|
156
|
+
) -> None:
|
|
157
|
+
self.members = members
|
|
158
|
+
self.description = description
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def capture_union_members(
|
|
162
|
+
annotation: object,
|
|
163
|
+
) -> tuple[tuple[type[BaseModel], ...], str | None] | None:
|
|
164
|
+
"""Peel wrappers from *annotation* and return its union members.
|
|
165
|
+
|
|
166
|
+
Returns `(members, description)` when *annotation* (possibly wrapped
|
|
167
|
+
in `Annotated`) terminates in a multi-arm union of `BaseModel`
|
|
168
|
+
subclasses, otherwise `None`. Internally drives `analyze_type` with
|
|
169
|
+
a capturing resolver and unwinds via an exception once the union
|
|
170
|
+
terminal is reached. The resolver fires only after every enclosing
|
|
171
|
+
`Annotated` layer is peeled, so the captured description matches what
|
|
172
|
+
`analyze_type` would return.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
def _capture(
|
|
176
|
+
_ann: object,
|
|
177
|
+
members: tuple[type[BaseModel], ...],
|
|
178
|
+
description: str | None,
|
|
179
|
+
) -> NoReturn:
|
|
180
|
+
raise _UnionCaptured(members, description)
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
analyze_type(annotation, union_resolver=_capture)
|
|
184
|
+
except _UnionCaptured as captured:
|
|
185
|
+
return captured.members, captured.description
|
|
186
|
+
except (TypeError, UnsupportedUnionError):
|
|
187
|
+
return None
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _is_union(origin: object) -> bool:
|
|
192
|
+
"""Whether an origin represents a union type (`X | Y` or `Union[X, Y]`)."""
|
|
193
|
+
return origin in (types.UnionType, Union)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _filter_sentinel_arms(args: tuple[object, ...]) -> list[object]:
|
|
197
|
+
"""Remove `NoneType` and `Sentinel` arms from union type arguments."""
|
|
198
|
+
return [a for a in args if a is not types.NoneType and not isinstance(a, Sentinel)]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def analyze_type(
|
|
202
|
+
annotation: object,
|
|
203
|
+
*,
|
|
204
|
+
owner: type | None = None,
|
|
205
|
+
model_resolver: ModelResolver | None = None,
|
|
206
|
+
union_resolver: UnionResolver | None = None,
|
|
207
|
+
) -> tuple[FieldShape, bool, str | None]:
|
|
208
|
+
"""Analyze an annotation into a `FieldShape` plus field-level metadata.
|
|
209
|
+
|
|
210
|
+
Parameters
|
|
211
|
+
----------
|
|
212
|
+
annotation
|
|
213
|
+
The annotation to analyze.
|
|
214
|
+
owner
|
|
215
|
+
The model class these annotations belong to. Supplies the
|
|
216
|
+
namespace for resolving forward references (`list["Node"]`
|
|
217
|
+
stores `"Node"` as a bare string). When None, an unresolvable
|
|
218
|
+
forward ref raises `UnresolvedForwardRefError`.
|
|
219
|
+
model_resolver
|
|
220
|
+
Optional callback invoked when the terminal is a `BaseModel`
|
|
221
|
+
subclass. Returns the `FieldShape` to use at that position --
|
|
222
|
+
typically a `ModelRef` with a resolved `RecordSpec`. Defaults to
|
|
223
|
+
a `Scalar` carrying the class as `source_type` for callers that
|
|
224
|
+
cannot resolve sub-models (e.g. dict key/value analysis).
|
|
225
|
+
union_resolver
|
|
226
|
+
Optional callback invoked when the terminal is a multi-arm
|
|
227
|
+
union of `BaseModel` subclasses. Returns the `FieldShape` to
|
|
228
|
+
use -- typically a `UnionRef` with a resolved `UnionSpec`.
|
|
229
|
+
Required to support unions; raises otherwise.
|
|
230
|
+
|
|
231
|
+
Returns
|
|
232
|
+
-------
|
|
233
|
+
tuple[FieldShape, bool, str | None]
|
|
234
|
+
The structural shape, whether the field accepts `None`, and
|
|
235
|
+
the first `FieldInfo.description` encountered during unwrapping.
|
|
236
|
+
"""
|
|
237
|
+
return _unwrap(
|
|
238
|
+
annotation,
|
|
239
|
+
newtype_ctx=None,
|
|
240
|
+
owner=owner,
|
|
241
|
+
model_resolver=model_resolver,
|
|
242
|
+
union_resolver=union_resolver,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _unwrap(
|
|
247
|
+
annotation: object,
|
|
248
|
+
*,
|
|
249
|
+
newtype_ctx: _NewTypeCtx | None,
|
|
250
|
+
owner: type | None,
|
|
251
|
+
model_resolver: ModelResolver | None,
|
|
252
|
+
union_resolver: UnionResolver | None,
|
|
253
|
+
seen_rootmodels: frozenset[type] = frozenset(),
|
|
254
|
+
) -> tuple[FieldShape, bool, str | None]:
|
|
255
|
+
"""Recurse one annotation layer, returning its `FieldShape` subtree.
|
|
256
|
+
|
|
257
|
+
Parameters
|
|
258
|
+
----------
|
|
259
|
+
newtype_ctx
|
|
260
|
+
The innermost `NewType` currently in scope, or None. Sets the
|
|
261
|
+
terminal `Primitive.base_type` and tags constraints with their
|
|
262
|
+
contributing `NewType`.
|
|
263
|
+
owner
|
|
264
|
+
The model class supplying the namespace for forward-ref
|
|
265
|
+
resolution; invariant across the walk.
|
|
266
|
+
|
|
267
|
+
Returns
|
|
268
|
+
-------
|
|
269
|
+
tuple
|
|
270
|
+
The shape subtree, whether this layer or any descendant accepts
|
|
271
|
+
`None`, and the first `FieldInfo.description` found.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
def _recurse(
|
|
275
|
+
annotation: object,
|
|
276
|
+
newtype_ctx: _NewTypeCtx | None,
|
|
277
|
+
seen_rootmodels: frozenset[type] = seen_rootmodels,
|
|
278
|
+
) -> tuple[FieldShape, bool, str | None]:
|
|
279
|
+
"""Recurse into a child annotation, carrying the invariant resolvers.
|
|
280
|
+
|
|
281
|
+
`seen_rootmodels` defaults to the current frame's set, so ordinary
|
|
282
|
+
descents thread it unchanged; the RootModel branch passes an
|
|
283
|
+
augmented set to detect a self-referential root.
|
|
284
|
+
"""
|
|
285
|
+
return _unwrap(
|
|
286
|
+
annotation,
|
|
287
|
+
newtype_ctx=newtype_ctx,
|
|
288
|
+
owner=owner,
|
|
289
|
+
model_resolver=model_resolver,
|
|
290
|
+
union_resolver=union_resolver,
|
|
291
|
+
seen_rootmodels=seen_rootmodels,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
if isinstance(annotation, (str, ForwardRef)):
|
|
295
|
+
annotation = _resolve_forward_ref(annotation, owner)
|
|
296
|
+
|
|
297
|
+
origin = get_origin(annotation)
|
|
298
|
+
|
|
299
|
+
if is_newtype(annotation):
|
|
300
|
+
ctx = _NewTypeCtx(annotation.__name__, annotation) # type: ignore[attr-defined]
|
|
301
|
+
inner, opt, desc = _recurse(annotation.__supertype__, ctx) # type: ignore[attr-defined]
|
|
302
|
+
inner = _erase_inner_newtypes(inner)
|
|
303
|
+
return NewTypeShape(name=ctx.name, ref=ctx.ref, inner=inner), opt, desc
|
|
304
|
+
|
|
305
|
+
if origin is Annotated:
|
|
306
|
+
args = get_args(annotation)
|
|
307
|
+
inner_annotation = args[0]
|
|
308
|
+
own_desc: str | None = None
|
|
309
|
+
collected: list[ConstraintSource] = []
|
|
310
|
+
for c in args[1:]:
|
|
311
|
+
if isinstance(c, FieldInfo):
|
|
312
|
+
if c.description is not None and own_desc is None:
|
|
313
|
+
own_desc = clean_docstring(c.description)
|
|
314
|
+
for m in c.metadata:
|
|
315
|
+
collected.append(_constraint_source(m, newtype_ctx))
|
|
316
|
+
else:
|
|
317
|
+
collected.append(_constraint_source(c, newtype_ctx))
|
|
318
|
+
|
|
319
|
+
# Pick the annotation to recurse into and the optionality this
|
|
320
|
+
# Annotated layer contributes. A directly-wrapped union is peeled
|
|
321
|
+
# here so the resolver still sees the Annotated form; a `_Resolved`
|
|
322
|
+
# union short-circuits with the constraints attached.
|
|
323
|
+
next_annotation = inner_annotation
|
|
324
|
+
layer_optional = False
|
|
325
|
+
literal_alts: tuple[object, ...] = ()
|
|
326
|
+
if _is_union(get_origin(inner_annotation)):
|
|
327
|
+
result = _peel_union(
|
|
328
|
+
inner_annotation,
|
|
329
|
+
union_resolver,
|
|
330
|
+
resolver_annotation=annotation,
|
|
331
|
+
description=own_desc,
|
|
332
|
+
)
|
|
333
|
+
match result:
|
|
334
|
+
case _Resolved(shape):
|
|
335
|
+
return (
|
|
336
|
+
attach_constraints(shape, tuple(collected)),
|
|
337
|
+
result.is_optional,
|
|
338
|
+
own_desc,
|
|
339
|
+
)
|
|
340
|
+
case _ContinueWith(next_annotation, layer_optional, literal_alts):
|
|
341
|
+
pass
|
|
342
|
+
case _:
|
|
343
|
+
assert_never(result)
|
|
344
|
+
|
|
345
|
+
if literal_alts:
|
|
346
|
+
collected.append(_literal_alternatives_source(literal_alts))
|
|
347
|
+
inner, opt, desc = _recurse(next_annotation, newtype_ctx)
|
|
348
|
+
inner = attach_constraints(inner, tuple(collected))
|
|
349
|
+
return (
|
|
350
|
+
inner,
|
|
351
|
+
opt or layer_optional,
|
|
352
|
+
own_desc if own_desc is not None else desc,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
if _is_union(origin):
|
|
356
|
+
result = _peel_union(annotation, union_resolver)
|
|
357
|
+
match result:
|
|
358
|
+
case _Resolved(shape):
|
|
359
|
+
return shape, result.is_optional, None
|
|
360
|
+
case _ContinueWith(next_annotation, is_optional, literal_alts):
|
|
361
|
+
inner, opt, desc = _recurse(next_annotation, newtype_ctx)
|
|
362
|
+
if literal_alts:
|
|
363
|
+
inner = attach_constraints(
|
|
364
|
+
inner, (_literal_alternatives_source(literal_alts),)
|
|
365
|
+
)
|
|
366
|
+
return inner, opt or is_optional, desc
|
|
367
|
+
case _:
|
|
368
|
+
assert_never(result)
|
|
369
|
+
|
|
370
|
+
if origin is list:
|
|
371
|
+
args = get_args(annotation)
|
|
372
|
+
if not args:
|
|
373
|
+
raise TypeError("Bare list without type argument is not supported")
|
|
374
|
+
element, _, desc = _recurse(args[0], newtype_ctx)
|
|
375
|
+
# A list field is never optional on account of element nullability,
|
|
376
|
+
# so the element's `is_optional` is dropped; its description is the
|
|
377
|
+
# field's fallback prose when no field-level description exists.
|
|
378
|
+
return ArrayOf(element=element, constraints=()), False, desc
|
|
379
|
+
|
|
380
|
+
if origin is dict:
|
|
381
|
+
args = get_args(annotation)
|
|
382
|
+
if not args:
|
|
383
|
+
raise TypeError("Bare dict without type arguments is not supported")
|
|
384
|
+
key_shape, _, _ = _recurse(args[0], None)
|
|
385
|
+
value_shape, _, _ = _recurse(args[1], None)
|
|
386
|
+
return MapOf(key=key_shape, value=value_shape, constraints=()), False, None
|
|
387
|
+
|
|
388
|
+
if isinstance(annotation, type) and issubclass(annotation, RootModel):
|
|
389
|
+
# A RootModel serializes as its bare root value, so unwrap to the
|
|
390
|
+
# root type's shape. Root-level constraints reattach exactly as
|
|
391
|
+
# field metadata does, so a constrained root
|
|
392
|
+
# (`RootModel[Annotated[list, MaxLen]]`) keeps its length-wrapped
|
|
393
|
+
# variant on the unwrapped layer.
|
|
394
|
+
#
|
|
395
|
+
# Unwrapping erases the RootModel identity, so -- unlike a
|
|
396
|
+
# self-referential BaseModel, which the resolver terminates with a
|
|
397
|
+
# `starts_cycle` back-edge -- a self-referential root has no node to
|
|
398
|
+
# carry one and no finite bare-shape form. `seen_rootmodels` detects
|
|
399
|
+
# the re-entry and raises; without it the recurse below never returns.
|
|
400
|
+
if annotation in seen_rootmodels:
|
|
401
|
+
raise TypeError(
|
|
402
|
+
f"Self-referential RootModel {annotation.__name__} is not supported"
|
|
403
|
+
)
|
|
404
|
+
root = annotation.model_fields["root"]
|
|
405
|
+
inner, opt, desc = _recurse(
|
|
406
|
+
root.annotation, newtype_ctx, seen_rootmodels | {annotation}
|
|
407
|
+
)
|
|
408
|
+
return attach_field_metadata(inner, root), opt, desc
|
|
409
|
+
|
|
410
|
+
return _terminal(annotation, newtype_ctx, model_resolver), False, None
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _resolve_forward_ref(annotation: str | ForwardRef, owner: type | None) -> object:
|
|
414
|
+
"""Resolve a string / `ForwardRef` annotation to its type object.
|
|
415
|
+
|
|
416
|
+
Resolves against *owner*'s module and class namespaces, plus *owner*
|
|
417
|
+
bound to its own name. The class namespace lets a forward ref to a
|
|
418
|
+
nested model (`Outer.Inner`) resolve; the self-name binding lets a
|
|
419
|
+
self-referential model defined in a local scope (e.g. a test body)
|
|
420
|
+
resolve `"Owner"` even when it is absent from the module globals.
|
|
421
|
+
Raises `UnresolvedForwardRefError` for a name not in scope
|
|
422
|
+
(`NameError`), a missing attribute on a dotted reference
|
|
423
|
+
(`AttributeError`), or a string that is not a valid type expression
|
|
424
|
+
(`SyntaxError`) -- a clean, named failure in place of the opaque
|
|
425
|
+
`TypeError` the terminal classifier would otherwise raise on a bare
|
|
426
|
+
string.
|
|
427
|
+
"""
|
|
428
|
+
if owner is not None:
|
|
429
|
+
localns = {**vars(owner), owner.__name__: owner}
|
|
430
|
+
else:
|
|
431
|
+
localns = None
|
|
432
|
+
try:
|
|
433
|
+
ref = ForwardRef(annotation) if isinstance(annotation, str) else annotation
|
|
434
|
+
return evaluate_forward_ref(ref, owner=owner, locals=localns)
|
|
435
|
+
except (NameError, SyntaxError, AttributeError) as exc:
|
|
436
|
+
target = (
|
|
437
|
+
annotation if isinstance(annotation, str) else annotation.__forward_arg__
|
|
438
|
+
)
|
|
439
|
+
context = f" while extracting {owner.__qualname__}" if owner is not None else ""
|
|
440
|
+
raise UnresolvedForwardRefError(
|
|
441
|
+
f"Cannot resolve forward reference {target!r}{context}"
|
|
442
|
+
) from exc
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _constraint_source(
|
|
446
|
+
constraint: object, newtype_ctx: _NewTypeCtx | None
|
|
447
|
+
) -> ConstraintSource:
|
|
448
|
+
return ConstraintSource(
|
|
449
|
+
source_ref=newtype_ctx.ref if newtype_ctx else None,
|
|
450
|
+
source_name=newtype_ctx.name if newtype_ctx else None,
|
|
451
|
+
constraint=constraint,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _literal_alternatives_source(values: tuple[object, ...]) -> ConstraintSource:
|
|
456
|
+
"""Wrap dropped union `Literal` values as a `LiteralAlternatives` source."""
|
|
457
|
+
return ConstraintSource(
|
|
458
|
+
source_ref=None, source_name=None, constraint=LiteralAlternatives(values)
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _erase_inner_newtypes(shape: FieldShape) -> FieldShape:
|
|
463
|
+
"""Drop every `NewTypeShape` reachable through `ArrayOf` layers.
|
|
464
|
+
|
|
465
|
+
A `NewType` chain — including NewTypes nested as list elements —
|
|
466
|
+
collapses to a single `NewTypeShape` (the outermost), with inner
|
|
467
|
+
NewType names surviving only as the terminal `Primitive.base_type`.
|
|
468
|
+
Each `NewType` frame calls this on its recursion result so that by
|
|
469
|
+
the time the outermost frame returns, exactly one `NewTypeShape`
|
|
470
|
+
remains per spine.
|
|
471
|
+
|
|
472
|
+
Recurses through `ArrayOf.element` but stops at `MapOf` — `dict`
|
|
473
|
+
key/value are independent spines, each keeping its own outermost
|
|
474
|
+
`NewTypeShape` — and at scalar / `ModelRef` / `UnionRef` terminals.
|
|
475
|
+
"""
|
|
476
|
+
match shape:
|
|
477
|
+
case NewTypeShape(inner=inner):
|
|
478
|
+
return _erase_inner_newtypes(inner)
|
|
479
|
+
case ArrayOf(element=element):
|
|
480
|
+
return replace(shape, element=_erase_inner_newtypes(element))
|
|
481
|
+
case _:
|
|
482
|
+
return shape
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def attach_constraints(
|
|
486
|
+
shape: FieldShape, constraints: tuple[ConstraintSource, ...]
|
|
487
|
+
) -> FieldShape:
|
|
488
|
+
"""Prepend `constraints` to the outermost non-`NewTypeShape` layer.
|
|
489
|
+
|
|
490
|
+
Skips any number of leading `NewTypeShape` wrappers, then prepends
|
|
491
|
+
to the `.constraints` of the first `ArrayOf`, `MapOf`, `Primitive`,
|
|
492
|
+
`LiteralScalar`, or `AnyScalar` reached. Does not descend into
|
|
493
|
+
`ArrayOf.element` or `MapOf.key` / `.value`. `ModelRef` / `UnionRef`
|
|
494
|
+
carry no constraints, so a constraint destined for a model/union
|
|
495
|
+
terminal (`Annotated[SomeModel, SomeConstraint()]`) raises
|
|
496
|
+
`NotImplementedError` rather than vanishing from both docs and
|
|
497
|
+
validation -- no current schema field does this, and silently
|
|
498
|
+
dropping it would diverge the generated output from the source.
|
|
499
|
+
|
|
500
|
+
Length constraints (`annotated_types.MinLen` / `MaxLen`) are wrapped
|
|
501
|
+
into the typed `length_constraints` variants matching the
|
|
502
|
+
attachment layer: `ArrayMinLen` / `ArrayMaxLen` on `ArrayOf`,
|
|
503
|
+
`ScalarMinLen` / `ScalarMaxLen` on scalar layers. `MapOf` raises:
|
|
504
|
+
map-length constraints have no current schema use and would
|
|
505
|
+
otherwise silently take the scalar path.
|
|
506
|
+
"""
|
|
507
|
+
if not constraints:
|
|
508
|
+
return shape
|
|
509
|
+
match shape:
|
|
510
|
+
case NewTypeShape(inner=inner):
|
|
511
|
+
return replace(shape, inner=attach_constraints(inner, constraints))
|
|
512
|
+
case ArrayOf():
|
|
513
|
+
wrapped = tuple(_wrap_length_for_array(cs) for cs in constraints)
|
|
514
|
+
return replace(shape, constraints=wrapped + shape.constraints)
|
|
515
|
+
case MapOf():
|
|
516
|
+
_reject_length_on_map(constraints)
|
|
517
|
+
return replace(shape, constraints=constraints + shape.constraints)
|
|
518
|
+
case Primitive() | LiteralScalar() | AnyScalar():
|
|
519
|
+
wrapped = tuple(_wrap_length_for_scalar(cs) for cs in constraints)
|
|
520
|
+
return replace(shape, constraints=wrapped + shape.constraints)
|
|
521
|
+
case ModelRef() | UnionRef():
|
|
522
|
+
names = ", ".join(type(cs.constraint).__name__ for cs in constraints)
|
|
523
|
+
raise NotImplementedError(
|
|
524
|
+
f"Constraints ({names}) on a model/union terminal are not "
|
|
525
|
+
f"supported; attach them to a scalar or array layer instead"
|
|
526
|
+
)
|
|
527
|
+
case _:
|
|
528
|
+
assert_never(shape)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def attach_field_metadata(shape: FieldShape, field_info: FieldInfo) -> FieldShape:
|
|
532
|
+
"""Merge constraints from `field_info.metadata` onto *shape*.
|
|
533
|
+
|
|
534
|
+
Routes the metadata through `attach_constraints` so length-constraint
|
|
535
|
+
wrapping applies here just as it does during normal annotation
|
|
536
|
+
unwrapping: the constraints anchor at the topmost constraint-bearing
|
|
537
|
+
layer. Returns *shape* unchanged when there is no metadata.
|
|
538
|
+
"""
|
|
539
|
+
if not field_info.metadata:
|
|
540
|
+
return shape
|
|
541
|
+
extra = tuple(ConstraintSource(None, None, m) for m in field_info.metadata)
|
|
542
|
+
return attach_constraints(shape, extra)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _wrap_length_for_array(cs: ConstraintSource) -> ConstraintSource:
|
|
546
|
+
"""Replace a raw `MinLen`/`MaxLen` with its `ArrayOf`-layer variant.
|
|
547
|
+
|
|
548
|
+
Uses exact-type checks so already-wrapped variants (`ArrayMinLen`,
|
|
549
|
+
`ScalarMinLen`, etc.) are returned unchanged.
|
|
550
|
+
"""
|
|
551
|
+
if type(cs.constraint) is MinLen:
|
|
552
|
+
return replace(cs, constraint=ArrayMinLen(min_length=cs.constraint.min_length))
|
|
553
|
+
if type(cs.constraint) is MaxLen:
|
|
554
|
+
return replace(cs, constraint=ArrayMaxLen(max_length=cs.constraint.max_length))
|
|
555
|
+
return cs
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _wrap_length_for_scalar(cs: ConstraintSource) -> ConstraintSource:
|
|
559
|
+
"""Replace a raw `MinLen`/`MaxLen` with its scalar-layer variant.
|
|
560
|
+
|
|
561
|
+
Uses exact-type checks so already-wrapped variants (`ArrayMinLen`,
|
|
562
|
+
`ScalarMinLen`, etc.) are returned unchanged.
|
|
563
|
+
"""
|
|
564
|
+
if type(cs.constraint) is MinLen:
|
|
565
|
+
return replace(cs, constraint=ScalarMinLen(min_length=cs.constraint.min_length))
|
|
566
|
+
if type(cs.constraint) is MaxLen:
|
|
567
|
+
return replace(cs, constraint=ScalarMaxLen(max_length=cs.constraint.max_length))
|
|
568
|
+
return cs
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _reject_length_on_map(constraints: tuple[ConstraintSource, ...]) -> None:
|
|
572
|
+
"""Raise on `MinLen`/`MaxLen` attached to a `MapOf` layer."""
|
|
573
|
+
for cs in constraints:
|
|
574
|
+
if isinstance(cs.constraint, (MinLen, MaxLen)):
|
|
575
|
+
raise NotImplementedError(
|
|
576
|
+
f"{type(cs.constraint).__name__} on a Map type is not supported"
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _terminal(
|
|
581
|
+
annotation: object,
|
|
582
|
+
newtype_ctx: _NewTypeCtx | None,
|
|
583
|
+
model_resolver: ModelResolver | None,
|
|
584
|
+
) -> FieldShape:
|
|
585
|
+
"""Classify a fully-unwrapped terminal annotation into a shape."""
|
|
586
|
+
if annotation is Any:
|
|
587
|
+
return AnyScalar(constraints=())
|
|
588
|
+
if get_origin(annotation) is Literal:
|
|
589
|
+
return LiteralScalar(values=tuple(get_args(annotation)), constraints=())
|
|
590
|
+
if not isinstance(annotation, type):
|
|
591
|
+
raise TypeError(f"Unsupported annotation type: {type(annotation)}")
|
|
592
|
+
if issubclass(annotation, list):
|
|
593
|
+
raise TypeError("Bare list without type argument is not supported")
|
|
594
|
+
if issubclass(annotation, dict):
|
|
595
|
+
raise TypeError("Bare dict without type arguments is not supported")
|
|
596
|
+
if issubclass(annotation, BaseModel) and model_resolver is not None:
|
|
597
|
+
return model_resolver(annotation)
|
|
598
|
+
base_type = newtype_ctx.name if newtype_ctx else annotation.__name__
|
|
599
|
+
return Primitive(base_type=base_type, source_type=annotation, constraints=())
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _peel_union(
|
|
603
|
+
annotation: object,
|
|
604
|
+
union_resolver: UnionResolver | None,
|
|
605
|
+
*,
|
|
606
|
+
resolver_annotation: object | None = None,
|
|
607
|
+
description: str | None = None,
|
|
608
|
+
) -> _ContinueWith | _Resolved:
|
|
609
|
+
"""Process one union layer.
|
|
610
|
+
|
|
611
|
+
Filters out `None` / `Sentinel` arms (recording `is_optional`), then
|
|
612
|
+
drops `Literal[...]` arms when a concrete (non-Literal) arm exists.
|
|
613
|
+
A single remaining arm is returned as `_ContinueWith`; multiple arms
|
|
614
|
+
invoke `union_resolver` and the result is returned as `_Resolved`
|
|
615
|
+
(raising `UnsupportedUnionError` when no resolver is supplied).
|
|
616
|
+
|
|
617
|
+
`resolver_annotation` is passed to `union_resolver` instead of
|
|
618
|
+
`annotation` when set. This lets the `Annotated` branch forward the
|
|
619
|
+
full `Annotated[X | Y, ...]` form so resolvers can recover
|
|
620
|
+
discriminator metadata that the `Annotated` peeling step consumed.
|
|
621
|
+
"""
|
|
622
|
+
args = get_args(annotation)
|
|
623
|
+
is_optional = any(a is types.NoneType for a in args)
|
|
624
|
+
|
|
625
|
+
non_none_args = _filter_sentinel_arms(args)
|
|
626
|
+
concrete_args = [a for a in non_none_args if get_origin(a) is not Literal]
|
|
627
|
+
real_args = concrete_args if concrete_args else non_none_args
|
|
628
|
+
|
|
629
|
+
# A single concrete arm alongside `Literal[...]` arms keeps the concrete arm
|
|
630
|
+
# as the shape; the literal values ride along as a LiteralAlternatives
|
|
631
|
+
# constraint so they bypass the concrete arm's checks. Multi-arm and
|
|
632
|
+
# no-concrete-arm unions are unchanged (the literals stay dropped).
|
|
633
|
+
literal_alternatives: tuple[object, ...] = ()
|
|
634
|
+
if len(concrete_args) == 1:
|
|
635
|
+
literal_args = [a for a in non_none_args if get_origin(a) is Literal]
|
|
636
|
+
literal_alternatives = tuple(v for a in literal_args for v in get_args(a))
|
|
637
|
+
|
|
638
|
+
if len(real_args) > 1:
|
|
639
|
+
members: list[type[BaseModel]] = []
|
|
640
|
+
for arg in real_args:
|
|
641
|
+
inner = arg
|
|
642
|
+
if get_origin(inner) is Annotated:
|
|
643
|
+
inner = get_args(inner)[0]
|
|
644
|
+
if isinstance(inner, type) and issubclass(inner, BaseModel):
|
|
645
|
+
members.append(inner)
|
|
646
|
+
else:
|
|
647
|
+
raise UnsupportedUnionError(
|
|
648
|
+
f"Multi-type unions not supported: {annotation}"
|
|
649
|
+
)
|
|
650
|
+
if union_resolver is None:
|
|
651
|
+
raise UnsupportedUnionError(
|
|
652
|
+
f"No union_resolver supplied for multi-arm union: {annotation}"
|
|
653
|
+
)
|
|
654
|
+
return _Resolved(
|
|
655
|
+
union_resolver(
|
|
656
|
+
resolver_annotation or annotation, tuple(members), description
|
|
657
|
+
),
|
|
658
|
+
is_optional,
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
if not real_args:
|
|
662
|
+
raise UnsupportedUnionError(f"Union with no concrete types: {annotation}")
|
|
663
|
+
|
|
664
|
+
return _ContinueWith(real_args[0], is_optional, literal_alternatives)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def unwrap_list(annotation: object) -> object:
|
|
668
|
+
"""Strip `| None`, `Sentinel`, and outermost `list[]` wrappers."""
|
|
669
|
+
if _is_union(get_origin(annotation)):
|
|
670
|
+
args = _filter_sentinel_arms(get_args(annotation))
|
|
671
|
+
if len(args) == 1:
|
|
672
|
+
annotation = args[0]
|
|
673
|
+
|
|
674
|
+
while get_origin(annotation) is list:
|
|
675
|
+
annotation = get_args(annotation)[0]
|
|
676
|
+
return annotation
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def single_literal_value(annotation: object) -> object | None:
|
|
680
|
+
"""Extract a single literal value from a type annotation, or `None`.
|
|
681
|
+
|
|
682
|
+
Returns `None` for multi-value Literals -- callers needing all
|
|
683
|
+
values should use `analyze_type` and inspect the terminal
|
|
684
|
+
`LiteralScalar`'s `values`.
|
|
685
|
+
"""
|
|
686
|
+
try:
|
|
687
|
+
shape, _, _ = analyze_type(annotation)
|
|
688
|
+
except (TypeError, UnsupportedUnionError):
|
|
689
|
+
return None
|
|
690
|
+
terminal = terminal_of(shape)
|
|
691
|
+
if isinstance(terminal, LiteralScalar) and len(terminal.values) == 1:
|
|
692
|
+
return terminal.values[0]
|
|
693
|
+
return None
|