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