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,137 @@
|
|
|
1
|
+
"""Type registry mapping Python types to target representations."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from .field import FieldShape
|
|
7
|
+
from .field_walk import newtype_name, terminal_primitive
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"SparkCategory",
|
|
11
|
+
"TypeMapping",
|
|
12
|
+
"PRIMITIVE_TYPES",
|
|
13
|
+
"get_type_mapping",
|
|
14
|
+
"is_semantic_newtype",
|
|
15
|
+
"primitive_spark_category",
|
|
16
|
+
"resolve_type_name",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
SparkCategory = Literal["string", "int", "float", "bool", "other"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class TypeMapping:
|
|
24
|
+
"""Maps a type to its representation in different targets."""
|
|
25
|
+
|
|
26
|
+
markdown: str
|
|
27
|
+
spark: str | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
PRIMITIVE_TYPES: dict[str, TypeMapping] = {
|
|
31
|
+
# Signed integers
|
|
32
|
+
"int8": TypeMapping(markdown="int8", spark="IntegerType()"),
|
|
33
|
+
"int16": TypeMapping(markdown="int16", spark="IntegerType()"),
|
|
34
|
+
"int32": TypeMapping(markdown="int32", spark="IntegerType()"),
|
|
35
|
+
"int64": TypeMapping(markdown="int64", spark="LongType()"),
|
|
36
|
+
# Unsigned integers
|
|
37
|
+
"uint8": TypeMapping(markdown="uint8", spark="IntegerType()"),
|
|
38
|
+
"uint16": TypeMapping(markdown="uint16", spark="IntegerType()"),
|
|
39
|
+
"uint32": TypeMapping(markdown="uint32", spark="IntegerType()"),
|
|
40
|
+
# Floating point
|
|
41
|
+
"float32": TypeMapping(markdown="float32", spark="FloatType()"),
|
|
42
|
+
"float64": TypeMapping(markdown="float64", spark="DoubleType()"),
|
|
43
|
+
# Basic types
|
|
44
|
+
"str": TypeMapping(markdown="string", spark="StringType()"),
|
|
45
|
+
"bool": TypeMapping(markdown="boolean", spark="BooleanType()"),
|
|
46
|
+
# Python builtins (aliases to their portable equivalents)
|
|
47
|
+
"int": TypeMapping(markdown="int64", spark="LongType()"),
|
|
48
|
+
"float": TypeMapping(markdown="float64", spark="DoubleType()"),
|
|
49
|
+
# Geometry types
|
|
50
|
+
"Geometry": TypeMapping(markdown="geometry", spark="BinaryType()"),
|
|
51
|
+
"BBox": TypeMapping(markdown="bbox"),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_semantic_newtype(shape: FieldShape) -> bool:
|
|
56
|
+
"""Whether a shape's outermost NewType should be displayed by name.
|
|
57
|
+
|
|
58
|
+
Returns True for unregistered NewTypes (HexColor, Sources) and
|
|
59
|
+
NewTypes that wrap a different base type (FeatureVersion wrapping
|
|
60
|
+
int32, Id wrapping NoWhitespaceString). Returns False for
|
|
61
|
+
registered primitives (int32, Geometry).
|
|
62
|
+
"""
|
|
63
|
+
nt_name = newtype_name(shape)
|
|
64
|
+
if nt_name is None:
|
|
65
|
+
return False
|
|
66
|
+
terminal = terminal_primitive(shape)
|
|
67
|
+
if terminal is None:
|
|
68
|
+
return True
|
|
69
|
+
if nt_name != terminal.base_type:
|
|
70
|
+
return True
|
|
71
|
+
return get_type_mapping(terminal.base_type) is None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_type_mapping(type_name: str) -> TypeMapping | None:
|
|
75
|
+
"""Look up a type mapping by name.
|
|
76
|
+
|
|
77
|
+
Accepts portable type names (`int32`, `str`, `Geometry`) and Python
|
|
78
|
+
builtin names (`int` -> int64, `float` -> float64).
|
|
79
|
+
"""
|
|
80
|
+
return PRIMITIVE_TYPES.get(type_name)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# BinaryType() is intentionally absent: geometry maps to BinaryType() in
|
|
84
|
+
# PRIMITIVE_TYPES but falls through to "other" here, not a numeric/string/bool scalar.
|
|
85
|
+
_SPARK_TYPE_CATEGORIES: dict[str, SparkCategory] = {
|
|
86
|
+
"StringType()": "string",
|
|
87
|
+
"IntegerType()": "int",
|
|
88
|
+
"LongType()": "int",
|
|
89
|
+
"FloatType()": "float",
|
|
90
|
+
"DoubleType()": "float",
|
|
91
|
+
"BooleanType()": "bool",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def primitive_spark_category(base_type: str) -> SparkCategory:
|
|
96
|
+
"""Return the Spark category for a primitive base type name.
|
|
97
|
+
|
|
98
|
+
Parameters
|
|
99
|
+
----------
|
|
100
|
+
base_type
|
|
101
|
+
A primitive type name (`"int32"`, `"float64"`, `"bool"`, `"str"`, ...).
|
|
102
|
+
|
|
103
|
+
Returns
|
|
104
|
+
-------
|
|
105
|
+
SparkCategory
|
|
106
|
+
`"string"` for string-valued types, `"int"` for integer types,
|
|
107
|
+
`"float"` for floating-point types, `"bool"` for boolean types,
|
|
108
|
+
`"other"` for binary, geometry, or unregistered types. Unknown
|
|
109
|
+
types fall back to `"other"`, preserving string-default behavior
|
|
110
|
+
for any future unregistered type.
|
|
111
|
+
"""
|
|
112
|
+
mapping = get_type_mapping(base_type)
|
|
113
|
+
if mapping is None or mapping.spark is None:
|
|
114
|
+
return "other"
|
|
115
|
+
return _SPARK_TYPE_CATEGORIES.get(mapping.spark, "other")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def resolve_type_name(shape: FieldShape) -> str:
|
|
119
|
+
"""Resolve a shape to its markdown base type name string.
|
|
120
|
+
|
|
121
|
+
Looks up the terminal scalar's `base_type` in the registry first,
|
|
122
|
+
falling back to `source_type.__name__`. Semantic NewTypes wrapping
|
|
123
|
+
unregistered types resolve to the underlying class name (e.g.
|
|
124
|
+
`Sources` wrapping `SourceItem` -> `SourceItem`).
|
|
125
|
+
"""
|
|
126
|
+
terminal = terminal_primitive(shape)
|
|
127
|
+
if terminal is None:
|
|
128
|
+
return "?"
|
|
129
|
+
mapping = get_type_mapping(terminal.base_type)
|
|
130
|
+
if mapping is None and terminal.source_type is not None:
|
|
131
|
+
mapping = get_type_mapping(terminal.source_type.__name__)
|
|
132
|
+
if mapping is not None:
|
|
133
|
+
return mapping.markdown
|
|
134
|
+
|
|
135
|
+
if newtype_name(shape) and terminal.source_type is not None:
|
|
136
|
+
return terminal.source_type.__name__
|
|
137
|
+
return terminal.base_type
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Union extraction and discriminator handling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Annotated, get_args, get_origin
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
from pydantic.fields import FieldInfo
|
|
11
|
+
|
|
12
|
+
from overture.schema.system.feature import resolve_discriminator_field_name
|
|
13
|
+
|
|
14
|
+
from .field import (
|
|
15
|
+
AnyScalar,
|
|
16
|
+
ArrayOf,
|
|
17
|
+
FieldShape,
|
|
18
|
+
LiteralScalar,
|
|
19
|
+
MapOf,
|
|
20
|
+
ModelRef,
|
|
21
|
+
NewTypeShape,
|
|
22
|
+
Primitive,
|
|
23
|
+
UnionRef,
|
|
24
|
+
)
|
|
25
|
+
from .field_walk import list_depth, terminal_of, walk_shape
|
|
26
|
+
from .model_extraction import extract_model, resolve_field_alias
|
|
27
|
+
from .specs import AnnotatedField, FieldSpec, MemberSpec, UnionSpec, is_model_class
|
|
28
|
+
from .type_analyzer import (
|
|
29
|
+
capture_union_members,
|
|
30
|
+
single_literal_value,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__all__ = ["extract_discriminator", "extract_union"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _find_common_base(members: list[type[BaseModel]]) -> type[BaseModel]:
|
|
37
|
+
"""Find the most-derived common BaseModel ancestor of all members."""
|
|
38
|
+
if not members:
|
|
39
|
+
raise ValueError("Cannot find common base of empty members list")
|
|
40
|
+
filtered_mros = [
|
|
41
|
+
[c for c in cls.__mro__ if is_model_class(c) and c is not BaseModel]
|
|
42
|
+
for cls in members
|
|
43
|
+
]
|
|
44
|
+
common = set(filtered_mros[0])
|
|
45
|
+
for mro in filtered_mros[1:]:
|
|
46
|
+
common &= set(mro)
|
|
47
|
+
if not common:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"No common BaseModel ancestor for {[m.__name__ for m in members]}"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def max_mro_index(cls: type) -> int:
|
|
53
|
+
return max(mro.index(cls) for mro in filtered_mros)
|
|
54
|
+
|
|
55
|
+
return min(common, key=lambda c: (max_mro_index(c), c.__module__, c.__qualname__))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _find_field_by_alias(model: type[BaseModel], alias: str) -> FieldInfo | None:
|
|
59
|
+
"""Find a field in `model_fields` by alias-resolved name."""
|
|
60
|
+
direct = model.model_fields.get(alias)
|
|
61
|
+
if direct is not None:
|
|
62
|
+
return direct
|
|
63
|
+
for py_name, fi in model.model_fields.items():
|
|
64
|
+
if resolve_field_alias(py_name, fi) == alias:
|
|
65
|
+
return fi
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def extract_discriminator(
|
|
70
|
+
annotation: object,
|
|
71
|
+
members: list[type[BaseModel]],
|
|
72
|
+
) -> tuple[str | None, dict[str, type[BaseModel]] | None]:
|
|
73
|
+
"""Extract discriminator field name and value-to-type mapping."""
|
|
74
|
+
if get_origin(annotation) is not Annotated:
|
|
75
|
+
return None, None
|
|
76
|
+
|
|
77
|
+
disc_field_name: str | None = None
|
|
78
|
+
for metadata in get_args(annotation)[1:]:
|
|
79
|
+
if isinstance(metadata, FieldInfo):
|
|
80
|
+
disc_field_name = resolve_discriminator_field_name(metadata.discriminator)
|
|
81
|
+
if disc_field_name is not None:
|
|
82
|
+
break
|
|
83
|
+
|
|
84
|
+
if disc_field_name is None:
|
|
85
|
+
return None, None
|
|
86
|
+
|
|
87
|
+
mapping: dict[str, type[BaseModel]] = {}
|
|
88
|
+
for member in members:
|
|
89
|
+
field_info = _find_field_by_alias(member, disc_field_name)
|
|
90
|
+
if field_info and field_info.annotation is not None:
|
|
91
|
+
lit_val = single_literal_value(field_info.annotation)
|
|
92
|
+
if lit_val is not None:
|
|
93
|
+
key = lit_val.value if isinstance(lit_val, Enum) else str(lit_val)
|
|
94
|
+
mapping[key] = member
|
|
95
|
+
|
|
96
|
+
return disc_field_name, mapping or None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_TypeShape = tuple[object, ...]
|
|
100
|
+
_FieldKey = tuple[str, _TypeShape, frozenset[object]]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _structural_fingerprint(spec: FieldSpec) -> _TypeShape:
|
|
104
|
+
"""Structural shape for dedup: ignores per-variant source_type variation.
|
|
105
|
+
|
|
106
|
+
Two fields with the same name and same `(terminal_base_type,
|
|
107
|
+
terminal_kind, is_optional, list_depth)` collapse to a single
|
|
108
|
+
`AnnotatedField` whose `variant_sources` lists the contributing
|
|
109
|
+
members.
|
|
110
|
+
|
|
111
|
+
`terminal_of` unwraps `ArrayOf` / `NewTypeShape`, so the terminal is
|
|
112
|
+
always one of the six leaf variants below; an unrecognized one
|
|
113
|
+
raises instead of silently collapsing into a shared fingerprint.
|
|
114
|
+
"""
|
|
115
|
+
depth = list_depth(spec.shape)
|
|
116
|
+
base_type: object
|
|
117
|
+
terminal = terminal_of(spec.shape)
|
|
118
|
+
match terminal:
|
|
119
|
+
case Primitive(base_type=bt):
|
|
120
|
+
base_type, kind = bt, "scalar"
|
|
121
|
+
case LiteralScalar(values=values):
|
|
122
|
+
base_type, kind = ("Literal", values), "scalar"
|
|
123
|
+
case AnyScalar():
|
|
124
|
+
base_type, kind = "Any", "scalar"
|
|
125
|
+
case ModelRef(model=model):
|
|
126
|
+
base_type, kind = model.name, "model"
|
|
127
|
+
case UnionRef(union=union):
|
|
128
|
+
base_type, kind = union.name, "union"
|
|
129
|
+
case MapOf():
|
|
130
|
+
base_type, kind = "dict", "map"
|
|
131
|
+
case _:
|
|
132
|
+
raise TypeError(f"Unexpected terminal shape: {terminal!r}")
|
|
133
|
+
return (base_type, kind, spec.is_optional, depth)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _fingerprint_key(constraint: object) -> object:
|
|
137
|
+
"""Return a value-stable set key for a single constraint.
|
|
138
|
+
|
|
139
|
+
Constraints with value equality -- every `FieldConstraint`, the
|
|
140
|
+
`annotated_types` dataclasses, `GeometryTypeConstraint` -- key as
|
|
141
|
+
themselves. Foreign metadata that falls back to identity equality, namely
|
|
142
|
+
pydantic's internal `Field(...)` metadata, keys on its value-stable `repr`
|
|
143
|
+
so two equal-valued instances still collapse.
|
|
144
|
+
"""
|
|
145
|
+
if type(constraint).__eq__ is object.__eq__:
|
|
146
|
+
return repr(constraint)
|
|
147
|
+
return constraint
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _constraints_fingerprint(spec: FieldSpec) -> frozenset[object]:
|
|
151
|
+
"""Constraints declared anywhere in *spec*'s shape tree, as a comparable set.
|
|
152
|
+
|
|
153
|
+
`_structural_fingerprint` deliberately ignores constraints so that
|
|
154
|
+
members declaring the same field with per-variant `Annotated`
|
|
155
|
+
metadata still collapse to one `AnnotatedField`. This captures what
|
|
156
|
+
that ignores, so collisions with diverging constraints fail loudly
|
|
157
|
+
instead of silently keeping the last member's `FieldSpec`.
|
|
158
|
+
|
|
159
|
+
Constraint identity lives on the constraints themselves: `FieldConstraint`
|
|
160
|
+
subclasses define value equality and hashing, so equal rules collapse in
|
|
161
|
+
the set. `_fingerprint_key` covers the lone foreign holdout that still
|
|
162
|
+
compares by identity.
|
|
163
|
+
"""
|
|
164
|
+
keys: list[object] = []
|
|
165
|
+
|
|
166
|
+
def collect(shape: FieldShape) -> None:
|
|
167
|
+
match shape:
|
|
168
|
+
case (
|
|
169
|
+
Primitive(constraints=cs)
|
|
170
|
+
| LiteralScalar(constraints=cs)
|
|
171
|
+
| AnyScalar(constraints=cs)
|
|
172
|
+
| ArrayOf(constraints=cs)
|
|
173
|
+
| MapOf(constraints=cs)
|
|
174
|
+
):
|
|
175
|
+
for source in cs:
|
|
176
|
+
keys.append(_fingerprint_key(source.constraint))
|
|
177
|
+
case ModelRef() | UnionRef() | NewTypeShape():
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
walk_shape(spec.shape, collect)
|
|
181
|
+
return frozenset(keys)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def extract_union(
|
|
185
|
+
name: str,
|
|
186
|
+
annotation: object,
|
|
187
|
+
*,
|
|
188
|
+
entry_point: str | None = None,
|
|
189
|
+
partitions: Mapping[str, str] | None = None,
|
|
190
|
+
) -> UnionSpec:
|
|
191
|
+
"""Extract a `UnionSpec` from a discriminated union type alias."""
|
|
192
|
+
extracted = capture_union_members(annotation)
|
|
193
|
+
if extracted is None:
|
|
194
|
+
raise TypeError(f"{name} is not a union type alias")
|
|
195
|
+
member_tuple, description = extracted
|
|
196
|
+
members = list(member_tuple)
|
|
197
|
+
|
|
198
|
+
common_base = _find_common_base(members)
|
|
199
|
+
|
|
200
|
+
# Plain Python type aliases (`Foo = Annotated[...]`) don't preserve
|
|
201
|
+
# the alias name in the annotation. The nested-union path (called
|
|
202
|
+
# from extract_model for UNION-kind fields) passes `members[0].__name__`
|
|
203
|
+
# as the placeholder name. Recover the alias by convention: members
|
|
204
|
+
# extend `<Alias>Base`, so stripping that suffix yields the alias.
|
|
205
|
+
# Top-level unions go through the CLI, which supplies the real name
|
|
206
|
+
# and skips this fallback.
|
|
207
|
+
#
|
|
208
|
+
# PEP 695 (`type Foo = Annotated[...]`) preserves `__name__` as
|
|
209
|
+
# `"Foo"` on 3.12+; after migrating, the placeholder hack can go.
|
|
210
|
+
member_names = {m.__name__ for m in members}
|
|
211
|
+
if name in member_names:
|
|
212
|
+
base_name = common_base.__name__
|
|
213
|
+
name = (
|
|
214
|
+
base_name.removesuffix("Base") if base_name.endswith("Base") else base_name
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
base_spec = extract_model(common_base)
|
|
218
|
+
shared_field_names = {f.name for f in base_spec.fields}
|
|
219
|
+
|
|
220
|
+
member_specs = [MemberSpec(m, extract_model(m)) for m in members]
|
|
221
|
+
|
|
222
|
+
annotated_fields: list[AnnotatedField] = []
|
|
223
|
+
|
|
224
|
+
for fs in base_spec.fields:
|
|
225
|
+
annotated_fields.append(AnnotatedField(field_spec=fs, variant_sources=None))
|
|
226
|
+
|
|
227
|
+
seen: dict[_FieldKey, AnnotatedField] = {}
|
|
228
|
+
|
|
229
|
+
for member in member_specs:
|
|
230
|
+
member_cls = member.member_cls
|
|
231
|
+
for fs in member.spec.fields:
|
|
232
|
+
if fs.name in shared_field_names:
|
|
233
|
+
continue
|
|
234
|
+
# The key includes the constraints fingerprint alongside the
|
|
235
|
+
# structural one: two arms with the same name and shape but
|
|
236
|
+
# different constraints (e.g. VehicleAxleCountSelector's
|
|
237
|
+
# `ge=1, le=100, multiple_of=1` vs the other selectors' `ge=0`)
|
|
238
|
+
# must not collapse into one `AnnotatedField` sharing a single
|
|
239
|
+
# constraint set -- that would silently drop one arm's rules.
|
|
240
|
+
# Keeping them as separate rows, each gated to its own
|
|
241
|
+
# `variant_sources`, reuses the same per-arm `Guard` mechanism
|
|
242
|
+
# that already handles a field present on only some arms
|
|
243
|
+
# (`check_builder._field_checks_for_union`), and the renderer's
|
|
244
|
+
# collision resolver already disambiguates multiple `Check`s
|
|
245
|
+
# landing on the same field label.
|
|
246
|
+
key = (fs.name, _structural_fingerprint(fs), _constraints_fingerprint(fs))
|
|
247
|
+
existing = seen.get(key)
|
|
248
|
+
prior_sources = existing.variant_sources or () if existing else ()
|
|
249
|
+
seen[key] = AnnotatedField(
|
|
250
|
+
field_spec=fs,
|
|
251
|
+
variant_sources=(*prior_sources, member_cls),
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
annotated_fields.extend(seen.values())
|
|
255
|
+
|
|
256
|
+
disc_field, disc_mapping = extract_discriminator(annotation, members)
|
|
257
|
+
|
|
258
|
+
return UnionSpec(
|
|
259
|
+
name=name,
|
|
260
|
+
description=description,
|
|
261
|
+
annotated_fields=annotated_fields,
|
|
262
|
+
members=members,
|
|
263
|
+
member_specs=member_specs,
|
|
264
|
+
discriminator_field=disc_field,
|
|
265
|
+
discriminator_mapping=disc_mapping,
|
|
266
|
+
source_annotation=annotation,
|
|
267
|
+
common_base=common_base,
|
|
268
|
+
entry_point=entry_point,
|
|
269
|
+
partitions=partitions or {},
|
|
270
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Output directory layout from Python module paths.
|
|
2
|
+
|
|
3
|
+
Translates dotted module paths into output directory paths by mirroring
|
|
4
|
+
the source package structure.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Iterable, Mapping
|
|
11
|
+
from pathlib import PurePosixPath
|
|
12
|
+
|
|
13
|
+
from overture.schema.system.discovery import split_entry_point
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"OUTPUT_ROOT",
|
|
17
|
+
"compute_output_dir",
|
|
18
|
+
"compute_schema_root",
|
|
19
|
+
"entry_point_class",
|
|
20
|
+
"entry_point_module",
|
|
21
|
+
"is_package_module",
|
|
22
|
+
"module_relpath",
|
|
23
|
+
"output_dir_for_entry_point",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
OUTPUT_ROOT = PurePosixPath(".")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def entry_point_module(entry_point_path: str) -> str:
|
|
30
|
+
"""Extract module path from entry-point-style path.
|
|
31
|
+
|
|
32
|
+
>>> entry_point_module("overture.schema.buildings:Building")
|
|
33
|
+
'overture.schema.buildings'
|
|
34
|
+
"""
|
|
35
|
+
return split_entry_point(entry_point_path)[0]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def entry_point_class(entry_point_path: str) -> str:
|
|
39
|
+
"""Extract class name from entry-point-style path.
|
|
40
|
+
|
|
41
|
+
>>> entry_point_class("overture.schema.buildings:Building")
|
|
42
|
+
'Building'
|
|
43
|
+
"""
|
|
44
|
+
return split_entry_point(entry_point_path)[1]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def compute_schema_root(module_paths: Iterable[str]) -> str:
|
|
48
|
+
"""Find the longest common dotted prefix of module paths.
|
|
49
|
+
|
|
50
|
+
Deduplicates inputs first. For a single unique path, drops the
|
|
51
|
+
last component (the module itself).
|
|
52
|
+
"""
|
|
53
|
+
paths = sorted(set(module_paths))
|
|
54
|
+
if not paths:
|
|
55
|
+
msg = "No module paths provided"
|
|
56
|
+
raise ValueError(msg)
|
|
57
|
+
|
|
58
|
+
segments = [p.split(".") for p in paths]
|
|
59
|
+
if len(segments) == 1:
|
|
60
|
+
return ".".join(segments[0][:-1])
|
|
61
|
+
|
|
62
|
+
common: list[str] = []
|
|
63
|
+
for parts in zip(*segments, strict=False):
|
|
64
|
+
if len(set(parts)) == 1:
|
|
65
|
+
common.append(parts[0])
|
|
66
|
+
else:
|
|
67
|
+
break
|
|
68
|
+
return ".".join(common)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def module_relpath(module: str, root: str) -> str:
|
|
72
|
+
"""Strip the schema root prefix from a dotted module path."""
|
|
73
|
+
if not root:
|
|
74
|
+
return module
|
|
75
|
+
if module == root:
|
|
76
|
+
return ""
|
|
77
|
+
prefix = root + "."
|
|
78
|
+
if not module.startswith(prefix):
|
|
79
|
+
msg = f"Module {module!r} does not start with root {root!r}"
|
|
80
|
+
raise ValueError(msg)
|
|
81
|
+
return module[len(prefix) :]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def is_package_module(
|
|
85
|
+
module: str,
|
|
86
|
+
module_registry: Mapping[str, object] | None = None,
|
|
87
|
+
) -> bool:
|
|
88
|
+
"""Check whether a module is a package (directory) or a file module.
|
|
89
|
+
|
|
90
|
+
Packages have `__path__`; file modules do not (PEP 302).
|
|
91
|
+
"""
|
|
92
|
+
registry: Mapping[str, object] = (
|
|
93
|
+
module_registry if module_registry is not None else sys.modules
|
|
94
|
+
)
|
|
95
|
+
mod = registry.get(module)
|
|
96
|
+
if mod is None:
|
|
97
|
+
msg = f"Module {module!r} not found in registry"
|
|
98
|
+
raise ValueError(msg)
|
|
99
|
+
return hasattr(mod, "__path__")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def output_dir_for_entry_point(
|
|
103
|
+
entry_point_path: str | None,
|
|
104
|
+
schema_root: str,
|
|
105
|
+
module_registry: Mapping[str, object] | None = None,
|
|
106
|
+
) -> PurePosixPath:
|
|
107
|
+
"""Compute output directory from an entry-point-style path.
|
|
108
|
+
|
|
109
|
+
Raises ValueError if *entry_point_path* is None.
|
|
110
|
+
"""
|
|
111
|
+
if entry_point_path is None:
|
|
112
|
+
msg = "entry_point_path must not be None"
|
|
113
|
+
raise ValueError(msg)
|
|
114
|
+
module = entry_point_module(entry_point_path)
|
|
115
|
+
return compute_output_dir(module, schema_root, module_registry)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def compute_output_dir(
|
|
119
|
+
module: str,
|
|
120
|
+
schema_root: str,
|
|
121
|
+
module_registry: Mapping[str, object] | None = None,
|
|
122
|
+
) -> PurePosixPath:
|
|
123
|
+
"""Compute output directory for a module, mirroring package structure.
|
|
124
|
+
|
|
125
|
+
File modules drop their last component (the .py filename).
|
|
126
|
+
Packages keep all components. Returns `PurePosixPath(".")` for
|
|
127
|
+
the root directory.
|
|
128
|
+
"""
|
|
129
|
+
relpath = module_relpath(module, schema_root)
|
|
130
|
+
if not relpath:
|
|
131
|
+
return OUTPUT_ROOT
|
|
132
|
+
|
|
133
|
+
parts = relpath.split(".")
|
|
134
|
+
if not is_package_module(module, module_registry):
|
|
135
|
+
parts = parts[:-1]
|
|
136
|
+
|
|
137
|
+
if not parts:
|
|
138
|
+
return OUTPUT_ROOT
|
|
139
|
+
return PurePosixPath(*parts)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Supplementary type discovery by walking feature trees.
|
|
2
|
+
|
|
3
|
+
Walks `FieldShape` trees to extract referenced enums, NewTypes,
|
|
4
|
+
Pydantic built-ins, and union member sub-models. `ModelRef` and
|
|
5
|
+
`UnionRef` carry their resolved specs structurally, so recursion
|
|
6
|
+
follows the shape directly.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import Annotated, get_args, get_origin
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from ..extraction.enum_extraction import extract_enum
|
|
16
|
+
from ..extraction.field import (
|
|
17
|
+
FieldShape,
|
|
18
|
+
ModelRef,
|
|
19
|
+
NewTypeShape,
|
|
20
|
+
Primitive,
|
|
21
|
+
UnionRef,
|
|
22
|
+
)
|
|
23
|
+
from ..extraction.field_walk import walk_shape
|
|
24
|
+
from ..extraction.newtype_extraction import extract_newtype
|
|
25
|
+
from ..extraction.pydantic_extraction import extract_pydantic_type
|
|
26
|
+
from ..extraction.specs import (
|
|
27
|
+
FieldSpec,
|
|
28
|
+
ModelSpec,
|
|
29
|
+
RecordSpec,
|
|
30
|
+
SupplementarySpec,
|
|
31
|
+
TypeIdentity,
|
|
32
|
+
is_pydantic_sourced,
|
|
33
|
+
)
|
|
34
|
+
from ..extraction.type_analyzer import analyze_type, is_newtype
|
|
35
|
+
from ..extraction.type_registry import is_semantic_newtype
|
|
36
|
+
|
|
37
|
+
__all__ = ["collect_all_supplementary_types"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def collect_all_supplementary_types(
|
|
41
|
+
model_specs: Sequence[ModelSpec],
|
|
42
|
+
) -> dict[TypeIdentity, SupplementarySpec]:
|
|
43
|
+
"""Collect supplementary types by walking expanded feature trees.
|
|
44
|
+
|
|
45
|
+
Walks `ModelRef` references for sub-models (already extracted),
|
|
46
|
+
and extracts enums and NewTypes on first encounter. Two types
|
|
47
|
+
with the same class name from different modules are keyed
|
|
48
|
+
separately.
|
|
49
|
+
"""
|
|
50
|
+
feature_objs: set[object] = {spec.identity.obj for spec in model_specs}
|
|
51
|
+
all_specs: dict[TypeIdentity, SupplementarySpec] = {}
|
|
52
|
+
visited_models: set[object] = set()
|
|
53
|
+
|
|
54
|
+
def _register_newtype(newtype_ref: object, name: str) -> bool:
|
|
55
|
+
nt_id = TypeIdentity(newtype_ref, name)
|
|
56
|
+
if nt_id in all_specs:
|
|
57
|
+
return False
|
|
58
|
+
all_specs[nt_id] = extract_newtype(newtype_ref)
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
def _collect_from_model(model_spec: RecordSpec) -> None:
|
|
62
|
+
if (
|
|
63
|
+
model_spec.source_type in visited_models
|
|
64
|
+
or model_spec.source_type in feature_objs
|
|
65
|
+
):
|
|
66
|
+
return
|
|
67
|
+
visited_models.add(model_spec.source_type)
|
|
68
|
+
all_specs[model_spec.identity] = model_spec
|
|
69
|
+
_collect_from_fields(model_spec.fields)
|
|
70
|
+
|
|
71
|
+
def _collect_inner_newtypes(newtype_ref: object) -> None:
|
|
72
|
+
"""Walk a NewType's `__supertype__` chain for nested semantic NewTypes."""
|
|
73
|
+
annotation = getattr(newtype_ref, "__supertype__", None)
|
|
74
|
+
while annotation is not None:
|
|
75
|
+
if get_origin(annotation) is Annotated:
|
|
76
|
+
annotation = get_args(annotation)[0]
|
|
77
|
+
continue
|
|
78
|
+
if is_newtype(annotation):
|
|
79
|
+
inner_shape, _, _ = analyze_type(annotation)
|
|
80
|
+
if isinstance(inner_shape, NewTypeShape) and is_semantic_newtype(
|
|
81
|
+
inner_shape
|
|
82
|
+
):
|
|
83
|
+
_register_newtype(inner_shape.ref, inner_shape.name)
|
|
84
|
+
annotation = getattr(annotation, "__supertype__", None)
|
|
85
|
+
continue
|
|
86
|
+
break
|
|
87
|
+
|
|
88
|
+
def _collect_from_shape(shape: FieldShape) -> None:
|
|
89
|
+
"""Walk *shape* and register every supplementary type it touches."""
|
|
90
|
+
|
|
91
|
+
def _visit(node: FieldShape) -> None:
|
|
92
|
+
match node:
|
|
93
|
+
case NewTypeShape(name=name, ref=ref) if is_semantic_newtype(node):
|
|
94
|
+
if _register_newtype(ref, name):
|
|
95
|
+
_collect_inner_newtypes(ref)
|
|
96
|
+
case UnionRef(union=u):
|
|
97
|
+
for member in u.member_specs:
|
|
98
|
+
_collect_from_model(member.spec)
|
|
99
|
+
case ModelRef(model=m, starts_cycle=False):
|
|
100
|
+
_collect_from_model(m)
|
|
101
|
+
case Primitive(source_type=cls) if cls is not None and isinstance(
|
|
102
|
+
cls, type
|
|
103
|
+
):
|
|
104
|
+
if issubclass(cls, Enum):
|
|
105
|
+
eid = TypeIdentity.of(cls)
|
|
106
|
+
if eid not in all_specs:
|
|
107
|
+
all_specs[eid] = extract_enum(cls)
|
|
108
|
+
elif is_pydantic_sourced(cls) and not issubclass(cls, BaseModel):
|
|
109
|
+
pid = TypeIdentity.of(cls)
|
|
110
|
+
if pid not in all_specs:
|
|
111
|
+
all_specs[pid] = extract_pydantic_type(cls)
|
|
112
|
+
|
|
113
|
+
walk_shape(shape, _visit)
|
|
114
|
+
|
|
115
|
+
def _collect_from_fields(fields: list[FieldSpec]) -> None:
|
|
116
|
+
for field_spec in fields:
|
|
117
|
+
_collect_from_shape(field_spec.shape)
|
|
118
|
+
|
|
119
|
+
for spec in model_specs:
|
|
120
|
+
_collect_from_fields(spec.fields)
|
|
121
|
+
|
|
122
|
+
return all_specs
|
|
File without changes
|