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,26 @@
1
+ """Internal constraint recording a union's literal alternatives.
2
+
3
+ A field annotated `X | Literal[c, ...]` validates as "the concrete arm `X`'s
4
+ checks pass OR the value is one of `c, ...`". `type_analyzer._peel_union` keeps
5
+ the concrete arm as the field's shape (so downstream consumers still see a
6
+ `Primitive` / `NewTypeShape` rather than a union of scalar-and-literal) and
7
+ records the dropped literal values in this constraint on that shape's layer.
8
+
9
+ Consumers read it to let those literal values bypass the concrete arm's
10
+ constraints: the PySpark dispatch emits a value-exact bypass, and the markdown
11
+ renderer notes the accepted literals. Codegen-internal -- schema authors write
12
+ the plain `X | Literal[c]` union; nothing constructs this class directly.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+
19
+ __all__ = ["LiteralAlternatives"]
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class LiteralAlternatives:
24
+ """Literal values a union field accepts alongside its concrete arm."""
25
+
26
+ values: tuple[object, ...]
@@ -0,0 +1,252 @@
1
+ """Convert model-level constraints to human-readable prose.
2
+
3
+ Handles RequireAnyOf, RadioGroup, ForbidIf, RequireIf, and other
4
+ ModelConstraint types. Produces descriptions and per-field notes for
5
+ documentation rendering.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+ from overture.schema.system.model_constraint import (
13
+ FieldEqCondition,
14
+ ForbidIfConstraint,
15
+ MinFieldsSetConstraint,
16
+ ModelConstraint,
17
+ NoExtraFieldsConstraint,
18
+ Not,
19
+ RadioGroupConstraint,
20
+ RequireAnyOfConstraint,
21
+ RequireAnyTrueConstraint,
22
+ RequireIfConstraint,
23
+ )
24
+
25
+ __all__ = ["analyze_model_constraints"]
26
+
27
+ _ConditionalConstraint = RequireIfConstraint | ForbidIfConstraint
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class _ConstraintEntry:
32
+ """A constraint description paired with the field names it affects."""
33
+
34
+ description: str
35
+ field_names: frozenset[str]
36
+
37
+
38
+ def _backtick_join(names: tuple[str, ...]) -> str:
39
+ """Format field names as backtick-quoted, comma-separated list."""
40
+ return ", ".join(f"`{n}`" for n in names)
41
+
42
+
43
+ def _conditional_verb(constraint: _ConditionalConstraint) -> str:
44
+ """Return 'required' or 'forbidden' based on constraint type."""
45
+ return "required" if isinstance(constraint, RequireIfConstraint) else "forbidden"
46
+
47
+
48
+ def _plural_verb(names: tuple[str, ...]) -> str:
49
+ """Return 'is' or 'are' based on field count."""
50
+ return "are" if len(names) > 1 else "is"
51
+
52
+
53
+ def _unwrap_field_eq(condition: object) -> tuple[FieldEqCondition, bool] | None:
54
+ """Extract the FieldEqCondition from a condition, with negation flag.
55
+
56
+ Returns (field_eq, is_negated) or None for unrecognized conditions.
57
+ """
58
+ if isinstance(condition, Not) and isinstance(condition.inner, FieldEqCondition):
59
+ return condition.inner, True
60
+ if isinstance(condition, FieldEqCondition):
61
+ return condition, False
62
+ return None
63
+
64
+
65
+ def _describe_condition(condition: object) -> str:
66
+ """Render a Condition as human-readable text."""
67
+ unwrapped = _unwrap_field_eq(condition)
68
+ if unwrapped is not None:
69
+ field_eq, negated = unwrapped
70
+ op = "≠" if negated else "="
71
+ return f"`{field_eq.field_name}` {op} `{field_eq.value}`"
72
+ return str(condition)
73
+
74
+
75
+ def _unwrap_true_field_eq(condition: object) -> FieldEqCondition | None:
76
+ if isinstance(condition, FieldEqCondition) and condition.value is True:
77
+ return condition
78
+ return None
79
+
80
+
81
+ def _describe_conditional(constraint: _ConditionalConstraint) -> str:
82
+ """Describe a require_if or forbid_if constraint."""
83
+ fields = _backtick_join(constraint.field_names)
84
+ verb = _conditional_verb(constraint)
85
+ cond = _describe_condition(constraint.condition)
86
+ return f"{fields} {_plural_verb(constraint.field_names)} {verb} when {cond}"
87
+
88
+
89
+ def _consolidation_key(
90
+ constraint: _ConditionalConstraint,
91
+ ) -> tuple[type, tuple[str, ...], str] | None:
92
+ """Return a grouping key if the constraint is consolidatable, else None.
93
+
94
+ Consolidatable: same type, same field_names, plain FieldEqCondition
95
+ (not negated) on the same condition field.
96
+ """
97
+ cond = constraint.condition
98
+ if not isinstance(cond, FieldEqCondition):
99
+ return None
100
+ return (type(constraint), constraint.field_names, cond.field_name)
101
+
102
+
103
+ def _as_field_eq(constraint: _ConditionalConstraint) -> FieldEqCondition:
104
+ """Narrow a conditional constraint's condition to FieldEqCondition.
105
+
106
+ Only called on constraints that passed _consolidation_key, which
107
+ rejects non-FieldEqCondition conditions.
108
+ """
109
+ cond = constraint.condition
110
+ if not isinstance(cond, FieldEqCondition):
111
+ raise TypeError(f"Expected FieldEqCondition, got {type(cond).__name__}")
112
+ return cond
113
+
114
+
115
+ def _describe_consolidated(
116
+ constraints: list[_ConditionalConstraint],
117
+ ) -> str:
118
+ """Describe a group of consolidated conditional constraints."""
119
+ first = constraints[0]
120
+ fields = _backtick_join(first.field_names)
121
+ verb = _conditional_verb(first)
122
+ cond_field = _as_field_eq(first).field_name
123
+ values = ", ".join(f"`{_as_field_eq(c).value}`" for c in constraints)
124
+ return (
125
+ f"{fields} {_plural_verb(first.field_names)} {verb} "
126
+ f"when `{cond_field}` is one of: {values}"
127
+ )
128
+
129
+
130
+ def _condition_field_names(condition: object) -> frozenset[str]:
131
+ """Extract field names referenced by a condition."""
132
+ unwrapped = _unwrap_field_eq(condition)
133
+ if unwrapped is not None:
134
+ return frozenset({unwrapped[0].field_name})
135
+ return frozenset()
136
+
137
+
138
+ def _affected_field_names(constraint: ModelConstraint) -> frozenset[str]:
139
+ """Return all field names referenced by a constraint.
140
+
141
+ Includes both constrained field_names and condition trigger fields.
142
+ Returns empty set for constraints that don't reference specific fields
143
+ (NoExtraFieldsConstraint, MinFieldsSetConstraint).
144
+ """
145
+ if isinstance(constraint, (NoExtraFieldsConstraint, MinFieldsSetConstraint)):
146
+ return frozenset()
147
+ if isinstance(constraint, (RequireIfConstraint, ForbidIfConstraint)):
148
+ return frozenset(constraint.field_names) | _condition_field_names(
149
+ constraint.condition
150
+ )
151
+ if isinstance(constraint, (RequireAnyOfConstraint, RadioGroupConstraint)):
152
+ return frozenset(constraint.field_names)
153
+ if isinstance(constraint, RequireAnyTrueConstraint):
154
+ return frozenset().union(
155
+ *(_condition_field_names(condition) for condition in constraint.conditions)
156
+ )
157
+ return frozenset()
158
+
159
+
160
+ def _describe_one(constraint: ModelConstraint) -> str | None:
161
+ """Describe a single constraint, or None to skip it."""
162
+ if isinstance(constraint, NoExtraFieldsConstraint):
163
+ return None
164
+ if isinstance(constraint, RequireAnyOfConstraint):
165
+ return f"At least one of {_backtick_join(constraint.field_names)} must be set"
166
+ if isinstance(constraint, RequireAnyTrueConstraint):
167
+ true_field_conditions = [
168
+ field_eq
169
+ for condition in constraint.conditions
170
+ if (field_eq := _unwrap_true_field_eq(condition)) is not None
171
+ ]
172
+ if len(true_field_conditions) == len(constraint.conditions):
173
+ return (
174
+ "At least one of "
175
+ f"{_backtick_join(tuple(c.field_name for c in true_field_conditions))} "
176
+ "must be `true`"
177
+ )
178
+ return "At least one of these conditions must be true: " + ", ".join(
179
+ _describe_condition(condition) for condition in constraint.conditions
180
+ )
181
+ if isinstance(constraint, RadioGroupConstraint):
182
+ return f"Exactly one of {_backtick_join(constraint.field_names)} must be `true`"
183
+ if isinstance(constraint, MinFieldsSetConstraint):
184
+ return f"At least {constraint.count} fields must be set"
185
+ if isinstance(constraint, (RequireIfConstraint, ForbidIfConstraint)):
186
+ return _describe_conditional(constraint)
187
+ return f"`{constraint.name}`"
188
+
189
+
190
+ def _analyze_constraints(
191
+ constraints: tuple[ModelConstraint, ...],
192
+ ) -> list[_ConstraintEntry]:
193
+ """Analyze constraints into descriptions paired with affected fields.
194
+
195
+ Handles consolidation and filtering, preserving original declaration order.
196
+ """
197
+ groups: dict[
198
+ tuple[type, tuple[str, ...], str], list[tuple[int, _ConditionalConstraint]]
199
+ ] = {}
200
+ standalone: list[tuple[int, ModelConstraint]] = []
201
+
202
+ for i, c in enumerate(constraints):
203
+ if isinstance(c, (RequireIfConstraint, ForbidIfConstraint)):
204
+ key = _consolidation_key(c)
205
+ if key is not None:
206
+ groups.setdefault(key, []).append((i, c))
207
+ continue
208
+ standalone.append((i, c))
209
+
210
+ entries: list[tuple[int, _ConstraintEntry]] = []
211
+
212
+ for group_items in groups.values():
213
+ first_idx = group_items[0][0]
214
+ group_constraints = [c for _, c in group_items]
215
+ all_fields: frozenset[str] = frozenset().union(
216
+ *(_affected_field_names(c) for c in group_constraints)
217
+ )
218
+ if len(group_constraints) == 1:
219
+ desc = _describe_one(group_constraints[0])
220
+ else:
221
+ desc = _describe_consolidated(group_constraints)
222
+ if desc is not None:
223
+ entries.append((first_idx, _ConstraintEntry(desc, all_fields)))
224
+
225
+ for idx, c in standalone:
226
+ desc = _describe_one(c)
227
+ if desc is not None:
228
+ entries.append((idx, _ConstraintEntry(desc, _affected_field_names(c))))
229
+
230
+ entries.sort(key=lambda e: e[0])
231
+ return [entry for _, entry in entries]
232
+
233
+
234
+ def analyze_model_constraints(
235
+ constraints: tuple[ModelConstraint, ...],
236
+ ) -> tuple[list[str], dict[str, list[str]]]:
237
+ """Analyze constraints into descriptions and per-field notes in one pass.
238
+
239
+ Returns (descriptions, field_notes) where descriptions is the list of
240
+ human-readable constraint strings and field_notes maps field names to
241
+ constraint descriptions that reference them.
242
+ """
243
+ entries = _analyze_constraints(constraints)
244
+
245
+ descriptions = [entry.description for entry in entries]
246
+
247
+ field_notes: dict[str, list[str]] = {}
248
+ for entry in entries:
249
+ for name in entry.field_names:
250
+ field_notes.setdefault(name, []).append(entry.description)
251
+
252
+ return descriptions, field_notes
@@ -0,0 +1,240 @@
1
+ """Pydantic model extraction into `RecordSpec`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+
7
+ from pydantic import BaseModel
8
+ from pydantic.fields import FieldInfo
9
+ from pydantic_core import PydanticUndefined
10
+
11
+ from overture.schema.system.model_constraint import ModelConstraint
12
+
13
+ from .docstring import clean_docstring
14
+ from .field import (
15
+ ModelRef,
16
+ UnionRef,
17
+ )
18
+ from .specs import FieldSpec, RecordSpec, is_model_class
19
+ from .type_analyzer import (
20
+ ModelResolver,
21
+ UnionResolver,
22
+ analyze_type,
23
+ attach_field_metadata,
24
+ unwrap_list,
25
+ )
26
+
27
+ __all__ = [
28
+ "extract_model",
29
+ "resolve_field_alias",
30
+ ]
31
+
32
+
33
+ def resolve_field_alias(field_name: str, field_info: FieldInfo) -> str:
34
+ """Return the data-dict key for a Pydantic field.
35
+
36
+ Prefers `validation_alias`, falls back to `alias`, then the
37
+ Python field name. Only string aliases are supported; AliasPath
38
+ and AliasChoices are ignored.
39
+ """
40
+ validation_alias = field_info.validation_alias
41
+ if isinstance(validation_alias, str):
42
+ return validation_alias
43
+ alias = field_info.alias
44
+ if isinstance(alias, str):
45
+ return alias
46
+ return field_name
47
+
48
+
49
+ def _is_field_required(field_info: FieldInfo, is_optional: bool) -> bool:
50
+ """Determine whether a field is required (no default and not Optional)."""
51
+ has_default = (
52
+ field_info.default is not PydanticUndefined
53
+ or field_info.default_factory is not None
54
+ )
55
+ return not has_default and not is_optional
56
+
57
+
58
+ def _basemodel_bases(cls: type) -> list[type[BaseModel]]:
59
+ """Return direct BaseModel bases, excluding BaseModel itself."""
60
+ return [b for b in cls.__bases__ if is_model_class(b) and b is not BaseModel]
61
+
62
+
63
+ def _class_order(model_class: type[BaseModel]) -> list[type]:
64
+ """Return MRO classes in documentation order, recursively.
65
+
66
+ For single-inheritance: reversed MRO (base first, derived last).
67
+ For multiple-inheritance: primary chain → self → mixins, where
68
+ primary chain and each mixin are themselves recursively ordered.
69
+ """
70
+ bases = _basemodel_bases(model_class)
71
+
72
+ if len(bases) <= 1:
73
+ return [
74
+ cls
75
+ for cls in reversed(model_class.__mro__)
76
+ if issubclass(cls, BaseModel) and cls is not BaseModel
77
+ ]
78
+
79
+ primary = _class_order(bases[0])
80
+ mixins = [cls for base in bases[1:] for cls in _class_order(base)]
81
+ return primary + [model_class] + mixins
82
+
83
+
84
+ def _field_order(model_class: type[BaseModel]) -> list[str]:
85
+ """Return `model_fields` keys in documentation order.
86
+
87
+ Walks the class hierarchy recursively. At each level of multiple
88
+ inheritance, the first base is the primary chain and the rest are
89
+ mixins. Primary chain and own fields come first, then mixin fields
90
+ in declaration order. Single-inheritance levels use Pydantic's
91
+ default reversed-MRO order.
92
+ """
93
+ valid_names = set(model_class.model_fields.keys())
94
+ result: list[str] = []
95
+ seen: set[str] = set()
96
+ for cls in _class_order(model_class):
97
+ for name in getattr(cls, "__annotations__", {}):
98
+ if name not in seen and name in valid_names:
99
+ result.append(name)
100
+ seen.add(name)
101
+ return result
102
+
103
+
104
+ def extract_model(
105
+ model_class: type[BaseModel],
106
+ *,
107
+ entry_point: str | None = None,
108
+ partitions: Mapping[str, str] | None = None,
109
+ ) -> RecordSpec:
110
+ """Extract a fully-resolved `RecordSpec` from a Pydantic model class.
111
+
112
+ Recurses into sub-models and unions, producing `ModelRef` /
113
+ `UnionRef` terminals with their specs resolved. Cycles in the
114
+ model graph (a field whose source type is an ancestor on the
115
+ current extraction stack) produce a `ModelRef` pointing at the
116
+ in-progress ancestor spec with `starts_cycle=True` so consumers
117
+ stop recursion at the back-edge.
118
+
119
+ The caller must not pass a `RootModel`: it serializes as its bare
120
+ root value and has no record structure, so walking its fields yields
121
+ only a spurious `root` column. `extract_model_spec` filters RootModel
122
+ entry points before they reach here, and a RootModel reached as a
123
+ field is unwrapped to its bare shape by `analyze_type`.
124
+ """
125
+ return _extract_model_recursive(
126
+ model_class,
127
+ entry_point=entry_point,
128
+ partitions=partitions or {},
129
+ cache={},
130
+ ancestors=frozenset(),
131
+ )
132
+
133
+
134
+ def _extract_model_recursive(
135
+ model_class: type[BaseModel],
136
+ *,
137
+ entry_point: str | None,
138
+ partitions: Mapping[str, str],
139
+ cache: dict[type, RecordSpec],
140
+ ancestors: frozenset[type],
141
+ ) -> RecordSpec:
142
+ """Inner recursive helper for `extract_model`.
143
+
144
+ Inserts the (partial) `RecordSpec` into `cache` before populating
145
+ its fields so cycles can find it. `ancestors` is the set of types
146
+ currently on the recursion stack -- a sub-field whose source type
147
+ appears there is a back-edge and gets `starts_cycle=True`.
148
+ """
149
+ spec = RecordSpec(
150
+ name=model_class.__name__,
151
+ description=clean_docstring(model_class.__doc__),
152
+ fields=[],
153
+ source_type=model_class,
154
+ entry_point=entry_point,
155
+ partitions=partitions,
156
+ constraints=ModelConstraint.get_model_constraints(model_class),
157
+ )
158
+ cache[model_class] = spec
159
+ descendant_ancestors = ancestors | {model_class}
160
+
161
+ model_resolver, union_resolver = _make_resolvers(cache, descendant_ancestors)
162
+
163
+ fields: list[FieldSpec] = []
164
+ for field_name in _field_order(model_class):
165
+ field_info = model_class.model_fields[field_name]
166
+ annotation = field_info.annotation
167
+ if annotation is None:
168
+ continue
169
+ shape, is_optional, ti_description = analyze_type(
170
+ annotation,
171
+ owner=model_class,
172
+ model_resolver=model_resolver,
173
+ union_resolver=union_resolver,
174
+ )
175
+ # Pydantic strips the outermost Annotated wrapper from some fields
176
+ # (non-optional, non-union) and moves its metadata to
177
+ # `field_info.metadata`; `analyze_type` then sees a bare type and
178
+ # misses those constraints. Reattach them at the topmost
179
+ # constraint-bearing layer.
180
+ shape = attach_field_metadata(shape, field_info)
181
+ fields.append(
182
+ FieldSpec(
183
+ name=resolve_field_alias(field_name, field_info),
184
+ shape=shape,
185
+ description=field_info.description or ti_description,
186
+ is_required=_is_field_required(field_info, is_optional),
187
+ is_optional=is_optional,
188
+ )
189
+ )
190
+
191
+ spec.fields = fields
192
+ return spec
193
+
194
+
195
+ def _make_resolvers(
196
+ cache: dict[type, RecordSpec],
197
+ ancestors: frozenset[type],
198
+ ) -> tuple[ModelResolver, UnionResolver]:
199
+ """Build the resolvers that recursively extract sub-models / sub-unions.
200
+
201
+ `cache` shares already-extracted sub-specs across a single
202
+ extraction so sub-models referenced more than once share a
203
+ `RecordSpec`. `ancestors` carries the recursion stack for cycle
204
+ detection -- a back-edge produces a `ModelRef` pointing at the
205
+ in-progress ancestor spec with `starts_cycle=True`.
206
+ """
207
+
208
+ def resolve_model(cls: type[BaseModel]) -> ModelRef:
209
+ if cls in ancestors:
210
+ return ModelRef(model=cache[cls], starts_cycle=True)
211
+ cached = cache.get(cls)
212
+ if cached is not None:
213
+ return ModelRef(model=cached)
214
+ sub_spec = _extract_model_recursive(
215
+ cls,
216
+ entry_point=None,
217
+ partitions={},
218
+ cache=cache,
219
+ ancestors=ancestors,
220
+ )
221
+ return ModelRef(model=sub_spec)
222
+
223
+ def resolve_union(
224
+ annotation: object,
225
+ members: tuple[type[BaseModel], ...],
226
+ _description: str | None,
227
+ ) -> UnionRef:
228
+ # Late import: extract_union calls back into extract_model for
229
+ # member classes. A module-level import would be a cycle.
230
+ from .union_extraction import extract_union # noqa: PLC0415
231
+
232
+ # Recover the union alias name: `analyze_type` reaches the
233
+ # union via `members[0].__name__` when the alias name is lost
234
+ # (plain `Foo = Annotated[...]` doesn't preserve it pre-PEP-695).
235
+ # Convention: members extend `<Alias>Base`.
236
+ placeholder = members[0].__name__ if members else ""
237
+ sub_union = extract_union(placeholder, unwrap_list(annotation))
238
+ return UnionRef(union=sub_union)
239
+
240
+ return resolve_model, resolve_union
@@ -0,0 +1,73 @@
1
+ """Type-alias extraction: NewType and RootModel into `NewTypeSpec`.
2
+
3
+ Both a NewType and a `RootModel` are named aliases over an underlying
4
+ type -- a RootModel serializes as its bare root value -- so both document
5
+ as the same `NewTypeSpec` (a name plus the underlying shape).
6
+ """
7
+
8
+ from pydantic import RootModel
9
+
10
+ from .docstring import clean_docstring, is_custom_docstring
11
+ from .field import NewTypeShape
12
+ from .specs import NewTypeSpec
13
+ from .type_analyzer import analyze_type
14
+
15
+ __all__ = ["extract_newtype", "extract_rootmodel_alias"]
16
+
17
+
18
+ def extract_newtype(newtype_callable: object) -> NewTypeSpec:
19
+ """Extract a `NewTypeSpec` from a NewType callable.
20
+
21
+ `analyze_type(newtype_callable)` returns a shape whose outermost
22
+ layer is the NewType's own `NewTypeShape`. We strip that wrapper so
23
+ `NewTypeSpec.shape` describes the *underlying* type -- the NewType
24
+ isn't a self-reference on its own page.
25
+ """
26
+ shape, _, ti_description = analyze_type(newtype_callable)
27
+
28
+ name = getattr(newtype_callable, "__name__", None)
29
+ if isinstance(shape, NewTypeShape) and shape.name == name:
30
+ underlying = shape.inner
31
+ else:
32
+ underlying = shape
33
+
34
+ if name is None:
35
+ msg = f"Cannot determine name for NewType: {newtype_callable!r}"
36
+ raise ValueError(msg)
37
+
38
+ doc = getattr(newtype_callable, "__doc__", None)
39
+ description = clean_docstring(doc) if is_custom_docstring(doc) else ti_description
40
+
41
+ return NewTypeSpec(
42
+ name=name,
43
+ description=description,
44
+ shape=underlying,
45
+ source_type=newtype_callable,
46
+ )
47
+
48
+
49
+ def extract_rootmodel_alias(root_model: type[RootModel]) -> NewTypeSpec:
50
+ """Extract a `NewTypeSpec` documenting a `RootModel` as a named alias.
51
+
52
+ `analyze_type` already unwraps a RootModel to its bare root shape, so
53
+ there is no wrapper to strip -- unlike `extract_newtype`. A custom
54
+ class docstring names the alias; otherwise the root field's own
55
+ description stands in. Pydantic moves that description onto the root
56
+ `FieldInfo` (as it does for model fields), so read it there rather than
57
+ from `analyze_type`, which recurses past it.
58
+
59
+ No `is_custom_docstring` guard is needed (again unlike `extract_newtype`):
60
+ a RootModel subclass with no docstring has `__doc__ = None` -- classes do
61
+ not inherit `RootModel`'s base docstring -- so there is no auto-generated
62
+ text to filter out, and `clean_docstring(None)` falls through to the root
63
+ description.
64
+ """
65
+ shape, _, _ = analyze_type(root_model)
66
+ root = root_model.model_fields["root"]
67
+ description = clean_docstring(root_model.__doc__) or root.description
68
+ return NewTypeSpec(
69
+ name=root_model.__name__,
70
+ description=description,
71
+ shape=shape,
72
+ source_type=root_model,
73
+ )
@@ -0,0 +1,74 @@
1
+ """Numeric type extraction."""
2
+
3
+ from annotated_types import Interval
4
+
5
+ from .docstring import first_docstring_line
6
+ from .field import FieldShape, Scalar
7
+ from .field_walk import terminal_of
8
+ from .newtype_extraction import extract_newtype
9
+ from .specs import NumericSpec, TypeIdentity
10
+
11
+ __all__ = [
12
+ "extract_numeric_bounds",
13
+ "extract_numerics",
14
+ ]
15
+
16
+
17
+ # Bound attribute names on annotated_types constraints (Ge, Gt, Le, Lt, Interval).
18
+ _BOUND_ATTRS = ("ge", "gt", "le", "lt")
19
+
20
+
21
+ def extract_numeric_bounds(shape: FieldShape) -> Interval:
22
+ """Extract numeric bounds from the constraints on a shape's terminal scalar.
23
+
24
+ Walks `NewTypeShape` / `ArrayOf` wrappers to find the terminal
25
+ `Scalar`, then scans its constraints for `ge`, `gt`, `le`, and `lt`
26
+ attributes. Stops at the first constraint defining each bound.
27
+ """
28
+ terminal = terminal_of(shape)
29
+ if not isinstance(terminal, Scalar):
30
+ return Interval()
31
+ found: dict[str, int | float] = {}
32
+ for cs in terminal.constraints:
33
+ c = cs.constraint
34
+ for attr in _BOUND_ATTRS:
35
+ if attr not in found:
36
+ val = getattr(c, attr, None)
37
+ if val is not None:
38
+ found[attr] = val
39
+ return Interval(**found)
40
+
41
+
42
+ def extract_numerics(
43
+ numeric_ids: list[TypeIdentity],
44
+ ) -> list[NumericSpec]:
45
+ """Extract specifications for numeric types."""
46
+ specs: list[NumericSpec] = []
47
+ for tid in numeric_ids:
48
+ newtype_spec = extract_newtype(tid.obj)
49
+ # extract_newtype strips the outer NewTypeShape, so the spec's
50
+ # terminal scalar already carries the constraints the NewType
51
+ # contributed -- extract_numeric_bounds walks straight to it.
52
+ bounds = extract_numeric_bounds(newtype_spec.shape)
53
+ description = first_docstring_line(getattr(tid.obj, "__doc__", None))
54
+ float_bits = _extract_float_bits(tid.name)
55
+ specs.append(
56
+ NumericSpec(
57
+ name=tid.name,
58
+ description=description,
59
+ bounds=bounds,
60
+ float_bits=float_bits,
61
+ )
62
+ )
63
+ return specs
64
+
65
+
66
+ _FLOAT_BITS: dict[str, int] = {
67
+ "float32": 32,
68
+ "float64": 64,
69
+ }
70
+
71
+
72
+ def _extract_float_bits(name: str) -> int | None:
73
+ """Extract bit width from a float type name like `float32`."""
74
+ return _FLOAT_BITS.get(name)