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