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