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,169 @@
1
+ """Compute reverse references from types to their referrers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from dataclasses import dataclass
7
+ from enum import Enum
8
+
9
+ from pydantic import BaseModel
10
+
11
+ from ..extraction.field import (
12
+ FieldShape,
13
+ ModelRef,
14
+ NewTypeShape,
15
+ Primitive,
16
+ UnionRef,
17
+ )
18
+ from ..extraction.field_walk import all_constraints, walk_shape
19
+ from ..extraction.specs import (
20
+ FieldSpec,
21
+ ModelSpec,
22
+ NewTypeSpec,
23
+ RecordSpec,
24
+ SupplementarySpec,
25
+ TypeIdentity,
26
+ UnionSpec,
27
+ is_pydantic_sourced,
28
+ )
29
+
30
+ __all__ = [
31
+ "UsedByEntry",
32
+ "UsedByKind",
33
+ "compute_reverse_references",
34
+ ]
35
+
36
+
37
+ class UsedByKind(Enum):
38
+ """Kind of referrer in a 'used by' entry."""
39
+
40
+ MODEL = 0
41
+ NEWTYPE = 1
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class UsedByEntry:
46
+ """A single 'used by' entry pointing to a referrer."""
47
+
48
+ identity: TypeIdentity
49
+ kind: UsedByKind
50
+
51
+
52
+ def compute_reverse_references(
53
+ model_specs: Sequence[ModelSpec],
54
+ all_specs: Mapping[TypeIdentity, SupplementarySpec],
55
+ ) -> dict[TypeIdentity, list[UsedByEntry]]:
56
+ """Compute reverse references from types to their referrers.
57
+
58
+ Returns a dict mapping TypeIdentity to lists of UsedByEntry, sorted with
59
+ models before NewTypes, alphabetical within each group.
60
+
61
+ Parameters
62
+ ----------
63
+ model_specs
64
+ Feature-level specs (RecordSpec or UnionSpec).
65
+ all_specs
66
+ Supplementary types (enums, newtypes, sub-models).
67
+ """
68
+ # An insertion-ordered set (dict keys) per target: dedups like a set but
69
+ # iterates deterministically, so sorted()'s stable order breaks ties by
70
+ # insertion rather than by nondeterministic set-hash order.
71
+ references: dict[TypeIdentity, dict[UsedByEntry, None]] = {}
72
+
73
+ def add_reference(
74
+ target: TypeIdentity, referrer: TypeIdentity, kind: UsedByKind
75
+ ) -> None:
76
+ if target == referrer or target not in all_specs:
77
+ return
78
+ references.setdefault(target, {})[UsedByEntry(referrer, kind)] = None
79
+
80
+ def collect_from_shape(
81
+ shape: FieldShape,
82
+ referrer: TypeIdentity,
83
+ referrer_kind: UsedByKind,
84
+ ) -> None:
85
+ """Walk a shape and add references for every type it touches."""
86
+
87
+ def _visit(node: FieldShape) -> None:
88
+ match node:
89
+ case NewTypeShape(name=name, ref=ref):
90
+ add_reference(TypeIdentity(ref, name), referrer, referrer_kind)
91
+ case ModelRef(model=m) if m.source_type is not None:
92
+ add_reference(
93
+ TypeIdentity.of(m.source_type), referrer, referrer_kind
94
+ )
95
+ case UnionRef(union=u):
96
+ for member_cls in u.members:
97
+ add_reference(
98
+ TypeIdentity.of(member_cls), referrer, referrer_kind
99
+ )
100
+ case Primitive(source_type=cls) if cls is not None:
101
+ if isinstance(cls, type) and (
102
+ issubclass(cls, Enum)
103
+ or issubclass(cls, BaseModel)
104
+ or is_pydantic_sourced(cls)
105
+ ):
106
+ add_reference(TypeIdentity.of(cls), referrer, referrer_kind)
107
+
108
+ walk_shape(shape, _visit)
109
+
110
+ def collect_from_fields(
111
+ fields: list[FieldSpec],
112
+ referrer: TypeIdentity,
113
+ referrer_kind: UsedByKind,
114
+ ) -> None:
115
+ """Collect references from each field's shape."""
116
+ for field_spec in fields:
117
+ collect_from_shape(field_spec.shape, referrer, referrer_kind)
118
+
119
+ def collect_from_model_spec(spec: RecordSpec, referrer: TypeIdentity) -> None:
120
+ collect_from_fields(spec.fields, referrer, UsedByKind.MODEL)
121
+
122
+ def collect_from_union_spec(spec: UnionSpec) -> None:
123
+ referrer = spec.identity
124
+ # Union features reference their members
125
+ for member_cls in spec.members:
126
+ add_reference(TypeIdentity.of(member_cls), referrer, UsedByKind.MODEL)
127
+ collect_from_fields(spec.fields, referrer, UsedByKind.MODEL)
128
+
129
+ def collect_from_newtype_spec(spec: NewTypeSpec, referrer: TypeIdentity) -> None:
130
+ # The NewType's own identity isn't added here (self-reference).
131
+ # spec.shape already has the outer NewTypeShape stripped.
132
+ collect_from_shape(spec.shape, referrer, UsedByKind.NEWTYPE)
133
+
134
+ # Inherited NewTypes from constraint sources at every layer
135
+ # (array / map / scalar), not just the terminal scalar -- a
136
+ # NewType chaining through an array NewType carries the inner
137
+ # NewType's provenance on the array layer.
138
+ for cs in all_constraints(spec.shape):
139
+ if cs.source_ref is not None and cs.source_name is not None:
140
+ add_reference(
141
+ TypeIdentity(cs.source_ref, cs.source_name),
142
+ referrer,
143
+ UsedByKind.NEWTYPE,
144
+ )
145
+
146
+ # Collect from features
147
+ for spec in model_specs:
148
+ if isinstance(spec, RecordSpec):
149
+ collect_from_model_spec(spec, spec.identity)
150
+ elif isinstance(spec, UnionSpec):
151
+ collect_from_union_spec(spec)
152
+
153
+ # Collect from supplementary specs (enums have no outgoing references)
154
+ for tid, supp_spec in all_specs.items():
155
+ if isinstance(supp_spec, NewTypeSpec):
156
+ collect_from_newtype_spec(supp_spec, tid)
157
+ elif isinstance(supp_spec, RecordSpec):
158
+ collect_from_model_spec(supp_spec, tid)
159
+
160
+ # Sort into deterministic lists.
161
+ result: dict[TypeIdentity, list[UsedByEntry]] = {}
162
+ for target, ref_map in references.items():
163
+ entries = sorted(
164
+ ref_map,
165
+ key=lambda e: (e.kind.value, e.identity.name, e.identity.module),
166
+ )
167
+ result[target] = entries
168
+
169
+ return result
@@ -0,0 +1,10 @@
1
+ {% if used_by %}
2
+
3
+ ## Used By
4
+
5
+ {% for entry in used_by -%}
6
+ {% if entry.link %}- [`{{ entry.name }}`]({{ entry.link }})
7
+ {% else %}- `{{ entry.name }}`
8
+ {% endif %}
9
+ {% endfor %}
10
+ {% endif %}
@@ -0,0 +1,13 @@
1
+ # {{ enum.name }}
2
+ {% if enum.description %}
3
+
4
+ {{ enum.description | linkify_urls }}
5
+ {% endif %}
6
+
7
+ ## Values
8
+
9
+ {% for member in enum.members -%}
10
+ - `{{ member.value }}`{% if member.description %} - {{ member.description }}{% endif %}
11
+
12
+ {% endfor %}
13
+ {% include '_used_by.md.jinja2' %}
@@ -0,0 +1,45 @@
1
+ # {{ model.name }}
2
+ {% if model.description %}
3
+
4
+ {{ model.description | linkify_urls }}
5
+ {% endif %}
6
+
7
+ ## Fields
8
+
9
+ | Name | Type | Description |
10
+ | -----: | :----: | ------------- |
11
+ {% for field in fields -%}
12
+ | {% if field.pre_formatted %}{{ field.name }}{% else %}`{{ field.name }}`{% endif %} | {{ field.type_str }} | {% if field.description %}{{ field.description }} {% endif %}|
13
+ {% endfor %}
14
+ {% if constraints %}
15
+
16
+ ## Constraints
17
+
18
+ {% for c in constraints %}
19
+ - {{ c }}
20
+ {% endfor %}
21
+ {% endif %}
22
+ {% if examples %}
23
+
24
+ ## Examples
25
+ {% if examples|length == 1 %}
26
+
27
+ | Column | Value |
28
+ | -------: | ------- |
29
+ {% for row in examples[0] -%}
30
+ | `{{ row.column }}` | {{ row.value }} |
31
+ {% endfor %}
32
+ {% else %}
33
+ {% for example in examples %}
34
+
35
+ ### Example {{ loop.index }}
36
+
37
+ | Column | Value |
38
+ | -------: | ------- |
39
+ {% for row in example -%}
40
+ | `{{ row.column }}` | {{ row.value }} |
41
+ {% endfor %}
42
+ {% endfor %}
43
+ {% endif %}
44
+ {% endif %}
45
+ {% include '_used_by.md.jinja2' %}
@@ -0,0 +1,11 @@
1
+ # Geometry Types
2
+
3
+ Spatial types for representing geographic features.
4
+
5
+ ## Types
6
+
7
+ | Type | Description |
8
+ | -----: | ------------- |
9
+ | `Geometry` | GeoJSON geometry value (Point, LineString, Polygon, etc.) |
10
+ | `BBox` | Bounding box as 4 or 6 coordinate values: [west, south, east, north] or [west, south, min-altitude, east, north, max-altitude] |
11
+ | `GeometryType` | Enumeration of geometry types: {{ geometry_types }} |
@@ -0,0 +1,17 @@
1
+ # {{ newtype.name }}
2
+ {% if newtype.description %}
3
+
4
+ {{ newtype.description | linkify_urls }}
5
+ {% endif %}
6
+
7
+ Underlying type: {{ underlying_type }}
8
+ {% if constraints %}
9
+
10
+ ## Constraints
11
+
12
+ {% for c in constraints -%}
13
+ - {{ c.display }}{% if c.source_link %} (from [`{{ c.source }}`]({{ c.source_link }})){% endif %}
14
+
15
+ {% endfor %}
16
+ {% endif %}
17
+ {% include '_used_by.md.jinja2' %}
@@ -0,0 +1,27 @@
1
+ # Numeric Types
2
+
3
+ Numeric types used for schema field definitions.
4
+
5
+ ## Integer Types
6
+
7
+ | Type | Range | Description |
8
+ | -----: | :-----: | ------------- |
9
+ {% for t in signed_ints -%}
10
+ | `{{ t.name }}` | {{ t.range }} | {{ t.description }} |
11
+ {% endfor %}
12
+
13
+ ## Unsigned Integer Types
14
+
15
+ | Type | Range | Description |
16
+ | -----: | :-----: | ------------- |
17
+ {% for t in unsigned_ints -%}
18
+ | `{{ t.name }}` | {{ t.range }} | {{ t.description }} |
19
+ {% endfor %}
20
+
21
+ ## Floating Point Types
22
+
23
+ | Type | Precision | Description |
24
+ | -----: | :---------: | ------------- |
25
+ {% for t in floats -%}
26
+ | `{{ t.name }}` | {{ t.precision }} | {{ t.description }} |
27
+ {% endfor %}
@@ -0,0 +1,8 @@
1
+ # {{ pydantic_type.name }}
2
+ {% if pydantic_type.description %}
3
+
4
+ {{ pydantic_type.description | linkify_urls }}
5
+ {% endif %}
6
+
7
+ See: [Pydantic docs]({{ pydantic_type.docs_url }})
8
+ {% include '_used_by.md.jinja2' %}
@@ -0,0 +1,383 @@
1
+ """Format `FieldShape` trees as markdown type strings with cross-page links."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from enum import Enum
7
+
8
+ from pydantic import BaseModel
9
+ from typing_extensions import assert_never
10
+
11
+ from ..extraction.field import (
12
+ AnyScalar,
13
+ ArrayOf,
14
+ FieldShape,
15
+ LiteralScalar,
16
+ MapOf,
17
+ ModelRef,
18
+ NewTypeShape,
19
+ Primitive,
20
+ Scalar,
21
+ UnionRef,
22
+ )
23
+ from ..extraction.specs import FieldSpec, TypeIdentity, is_pydantic_sourced
24
+ from ..extraction.type_registry import (
25
+ get_type_mapping,
26
+ is_semantic_newtype,
27
+ resolve_type_name,
28
+ )
29
+ from .link_computation import LinkContext
30
+
31
+ __all__ = [
32
+ "format_type",
33
+ "format_underlying_type",
34
+ "resolve_type_link",
35
+ ]
36
+
37
+
38
+ def _code_link(name: str, href: str) -> str:
39
+ """Format a markdown link with inline-code text: `[``name``](href)`."""
40
+ return f"[`{name}`]({href})"
41
+
42
+
43
+ def resolve_type_link(identity: TypeIdentity, ctx: LinkContext | None = None) -> str:
44
+ """Resolve a `TypeIdentity` to a linked or plain code span.
45
+
46
+ With `ctx`, links only to types in the registry (types without
47
+ pages render as inline code). Without context, renders as inline
48
+ code -- producing a link requires a placement registry to compute
49
+ correct relative paths.
50
+ """
51
+ if ctx:
52
+ href = ctx.resolve_link(identity)
53
+ if href:
54
+ return _code_link(identity.name, href)
55
+ return f"`{identity.name}`"
56
+
57
+
58
+ def _wrap_list_n(inner: str, depth: int) -> str:
59
+ """Wrap an inner type string in `list<...>` markdown syntax *depth* times.
60
+
61
+ Builds a single broken-backtick wrapper rather than nesting
62
+ iteratively, since iterative nesting creates adjacent backticks
63
+ that CommonMark interprets as multi-backtick code span delimiters.
64
+ """
65
+ return f"`{'list<' * depth}`{inner}`{'>' * depth}`"
66
+
67
+
68
+ def _plain_list_type(base: str, depth: int) -> str:
69
+ """Format a plain (unlinked) list type string for *depth* nesting levels."""
70
+ return f"`{'list<' * depth}{base}{'>' * depth}`"
71
+
72
+
73
+ def _peel_arrays(shape: FieldShape) -> tuple[int, FieldShape]:
74
+ """Strip outer `ArrayOf` layers; return (count, inner)."""
75
+ depth = 0
76
+ while isinstance(shape, ArrayOf):
77
+ depth += 1
78
+ shape = shape.element
79
+ return depth, shape
80
+
81
+
82
+ def _format_literal(values: tuple[object, ...]) -> str:
83
+ """Format Literal values for display."""
84
+ if len(values) == 1:
85
+ return f'`"{values[0]}"`'
86
+ return r" \| ".join(f'`"{v}"`' for v in values)
87
+
88
+
89
+ def _format_union_members(
90
+ members: Sequence[type[BaseModel]],
91
+ ctx: LinkContext | None,
92
+ separator: str = r" \| ",
93
+ ) -> str:
94
+ r"""Format union members as individually linked / backticked names.
95
+
96
+ Each member is resolved independently so members with pages get
97
+ linked while others render as plain code spans. `separator` is
98
+ inserted between members (default is `\|` for table-cell safety).
99
+ """
100
+ return separator.join(resolve_type_link(TypeIdentity.of(m), ctx) for m in members)
101
+
102
+
103
+ def _model_ref_identity(model_ref: ModelRef) -> TypeIdentity | None:
104
+ """Return a linkable identity for a `ModelRef`, or None when unsourced.
105
+
106
+ A `ModelRef` links by its `source_type` (the original Python class)
107
+ paired with the model name. Returns None when `source_type` is absent
108
+ -- a synthesized spec with no backing class has no page to link to.
109
+ """
110
+ src = model_ref.model.source_type
111
+ if src is None:
112
+ return None
113
+ return TypeIdentity(src, model_ref.model.name)
114
+
115
+
116
+ def _model_link(model_ref: ModelRef, ctx: LinkContext | None) -> str:
117
+ """Resolve a `ModelRef` to a markdown link or fallback code span."""
118
+ identity = _model_ref_identity(model_ref)
119
+ if identity is not None:
120
+ return resolve_type_link(identity, ctx)
121
+ return f"`{model_ref.model.name}`"
122
+
123
+
124
+ def _scalar_identity(scalar: Primitive) -> TypeIdentity | None:
125
+ """Return a linkable identity for a `Primitive`'s `source_type`, if any.
126
+
127
+ Enum / BaseModel / Pydantic-sourced types link by their own
128
+ identity and class name. Class-based registered primitives
129
+ (`Geometry`, `BBox`) are plain classes -- not BaseModel, not
130
+ Pydantic-sourced -- so they link by object identity to their
131
+ aggregate page under the markdown registry name (`geometry`, `bbox`).
132
+ """
133
+ src = scalar.source_type
134
+ if not isinstance(src, type):
135
+ return None
136
+ if issubclass(src, Enum) or issubclass(src, BaseModel) or is_pydantic_sourced(src):
137
+ return TypeIdentity.of(src)
138
+ if get_type_mapping(src.__name__) is not None:
139
+ return TypeIdentity(src, _registry_name(scalar))
140
+ return None
141
+
142
+
143
+ def _scalar_display(scalar: Scalar, ctx: LinkContext | None) -> tuple[str, bool]:
144
+ """Render a `Scalar` variant as a markdown string; second value is True if linked.
145
+
146
+ Linked when the scalar is a `Primitive` with an Enum / BaseModel /
147
+ Pydantic-sourced `source_type` whose identity resolves to a page.
148
+ Otherwise renders as the registry-resolved markdown name.
149
+ """
150
+ if isinstance(scalar, Primitive):
151
+ identity = _scalar_identity(scalar)
152
+ if identity is not None and ctx:
153
+ href = ctx.resolve_link(identity)
154
+ if href:
155
+ return _code_link(identity.name, href), True
156
+ if identity is not None:
157
+ return f"`{identity.name}`", False
158
+ return f"`{_registry_name(scalar)}`", False
159
+
160
+
161
+ def _registry_name(scalar: Scalar) -> str:
162
+ """Resolve a scalar to its markdown registry name (e.g. `int64`)."""
163
+ if isinstance(scalar, LiteralScalar):
164
+ return "Literal"
165
+ if isinstance(scalar, AnyScalar):
166
+ return "Any"
167
+ mapping = get_type_mapping(scalar.base_type)
168
+ if mapping is None and scalar.source_type is not None:
169
+ mapping = get_type_mapping(scalar.source_type.__name__)
170
+ if mapping is not None:
171
+ return mapping.markdown
172
+ return scalar.base_type
173
+
174
+
175
+ def _format_map(shape: MapOf, ctx: LinkContext | None) -> str:
176
+ """Format a `MapOf` as a `map<K, V>` code span, linking key/value types.
177
+
178
+ Semantic NewTypes and Enum / BaseModel-sourced key/value types link
179
+ to their pages; primitives stay bare. Output is identical whether the
180
+ map is rendered in a field cell or as a NewType's underlying type --
181
+ both paths route through here.
182
+
183
+ A link has to break out of the surrounding code span, so any bare side
184
+ is folded into the adjacent `map<...>` span rather than wrapped in its
185
+ own backticks. Two backtick spans must never abut: CommonMark reads the
186
+ resulting `` as a two-backtick delimiter and swallows the link.
187
+ """
188
+ key_str, key_linked = _map_side(shape.key, ctx)
189
+ val_str, val_linked = _map_side(shape.value, ctx)
190
+ if not key_linked and not val_linked:
191
+ return f"`map<{key_str}, {val_str}>`"
192
+ if key_linked and val_linked:
193
+ return f"`map<`{key_str}`,`{val_str}`>`"
194
+ if key_linked:
195
+ return f"`map<`{key_str}`,{val_str}>`"
196
+ return f"`map<{key_str},`{val_str}`>`"
197
+
198
+
199
+ def _map_side(shape: FieldShape, ctx: LinkContext | None) -> tuple[str, bool]:
200
+ """Render one map key/value as (text, is_link).
201
+
202
+ Returns a page link when the side resolves to one, else its
203
+ container-aware bare name (so a `list<...>` / `map<...>` wrapper
204
+ survives instead of collapsing to its element). The flag tells
205
+ `_format_map` whether the side breaks out of the surrounding code span.
206
+ """
207
+ link = _map_side_link(shape, ctx)
208
+ if link is not None:
209
+ return link, True
210
+ return _bare_map_side_name(shape), False
211
+
212
+
213
+ def _map_side_link(shape: FieldShape, ctx: LinkContext | None) -> str | None:
214
+ """Return a markdown link for a map key/value that has its own page.
215
+
216
+ Links a semantic NewType, a model (`ModelRef`), or a primitive whose
217
+ `source_type` is a linkable identity (`_scalar_identity`), when `ctx`
218
+ resolves a page for it. NewType and primitive sides link through
219
+ `list<...>` layers; a model side links only when it is the direct map
220
+ side (`depth == 0`), so a `list<Model>`-valued map keeps its `list<...>`
221
+ wrapper from `_bare_map_side_name` rather than collapsing to a bare model
222
+ link. Returns None when the side has no page; the caller renders a bare
223
+ name instead.
224
+ """
225
+ identity: TypeIdentity | None = None
226
+ depth, cur = _peel_arrays(shape)
227
+ if isinstance(cur, NewTypeShape) and is_semantic_newtype(shape):
228
+ identity = TypeIdentity(cur.ref, cur.name)
229
+ elif depth == 0 and isinstance(cur, ModelRef):
230
+ identity = _model_ref_identity(cur)
231
+ elif isinstance(cur, Primitive):
232
+ identity = _scalar_identity(cur)
233
+ if identity and ctx:
234
+ href = ctx.resolve_link(identity)
235
+ if href:
236
+ return _code_link(identity.name, href)
237
+ return None
238
+
239
+
240
+ def _bare_map_side_name(shape: FieldShape) -> str:
241
+ r"""Bare markdown name for a map key/value, recursing through containers.
242
+
243
+ Every variant resolves to a real name: `list<...>` / `map<...>`
244
+ wrappers recurse, scalars use their registry name (so `Any` is `Any`,
245
+ not `?`), semantic NewTypes and models use their type name, and a
246
+ pass-through NewType resolves through the registry like the scalar it
247
+ aliases. There is no `?` fallback -- a side that can't be named is a
248
+ bug, not a placeholder.
249
+
250
+ A union-valued map is the one shape left unrendered: no schema field
251
+ uses one, and its `\|`-separated members do not compose cleanly into
252
+ a bare `map<...>` span. It raises so the gap surfaces loudly when a
253
+ field first needs it, rather than shipping a half-rendered value.
254
+ """
255
+ match shape:
256
+ case ArrayOf(element=element):
257
+ return f"list<{_bare_map_side_name(element)}>"
258
+ case MapOf(key=key, value=value):
259
+ return f"map<{_bare_map_side_name(key)}, {_bare_map_side_name(value)}>"
260
+ case NewTypeShape(name=name) if is_semantic_newtype(shape):
261
+ return name
262
+ case NewTypeShape():
263
+ return resolve_type_name(shape)
264
+ case ModelRef(model=model):
265
+ return model.name
266
+ case Primitive() | LiteralScalar() | AnyScalar():
267
+ return _registry_name(shape)
268
+ case UnionRef():
269
+ raise NotImplementedError(
270
+ "union-typed map key/value is not rendered in markdown; "
271
+ "add handling here when a schema field first needs one"
272
+ )
273
+ case _:
274
+ assert_never(shape)
275
+
276
+
277
+ def format_type(field: FieldSpec, ctx: LinkContext | None = None) -> str:
278
+ """Format a field's type for markdown display, with links and qualifiers."""
279
+ qualifiers: list[str] = []
280
+ display = _format_shape(field.shape, ctx, qualifiers)
281
+ if not field.is_required:
282
+ qualifiers.append("optional")
283
+ if qualifiers:
284
+ return f"{display} ({', '.join(qualifiers)})"
285
+ return display
286
+
287
+
288
+ def _format_shape(
289
+ shape: FieldShape, ctx: LinkContext | None, qualifiers: list[str]
290
+ ) -> str:
291
+ """Format a `FieldShape`, possibly appending qualifiers like `list`, `map`."""
292
+ outer_depth, inner = _peel_arrays(shape)
293
+
294
+ match inner:
295
+ case LiteralScalar(values=values):
296
+ if outer_depth > 0:
297
+ inside = " | ".join(f'"{v}"' for v in values)
298
+ return _plain_list_type(inside, outer_depth)
299
+ return _format_literal(values)
300
+
301
+ case UnionRef(union=u):
302
+ if outer_depth > 0:
303
+ qualifiers.append("list")
304
+ return _format_union_members(u.members, ctx)
305
+
306
+ case MapOf() as m:
307
+ map_str = _format_map(m, ctx)
308
+ if outer_depth > 0:
309
+ return _wrap_list_n(map_str.strip("`"), outer_depth)
310
+ return map_str
311
+
312
+ case ModelRef() as m:
313
+ link = _model_link(m, ctx)
314
+ if outer_depth > 0:
315
+ return _wrap_list_n(link, outer_depth)
316
+ return link
317
+
318
+ case NewTypeShape(name=name, ref=ref, inner=nt_inner):
319
+ link = resolve_type_link(TypeIdentity(ref, name), ctx)
320
+ if outer_depth > 0:
321
+ return _wrap_list_n(link, outer_depth)
322
+ if isinstance(nt_inner, ArrayOf):
323
+ qualifiers.append("list")
324
+ elif isinstance(nt_inner, MapOf):
325
+ qualifiers.append("map")
326
+ return link
327
+
328
+ case Primitive() | AnyScalar() as s:
329
+ text, linked = _scalar_display(s, ctx)
330
+ if outer_depth > 0:
331
+ if linked:
332
+ return _wrap_list_n(text, outer_depth)
333
+ return _plain_list_type(text.strip("`"), outer_depth)
334
+ return text
335
+
336
+ raise TypeError(f"Unhandled FieldShape: {shape!r}")
337
+
338
+
339
+ # ---- Underlying-type rendering for NewType pages ----
340
+
341
+
342
+ def _peel_to_terminal(shape: FieldShape) -> FieldShape:
343
+ """Strip `NewTypeShape` / `ArrayOf` layers to find the terminal shape."""
344
+ while True:
345
+ if isinstance(shape, NewTypeShape):
346
+ shape = shape.inner
347
+ elif isinstance(shape, ArrayOf):
348
+ shape = shape.element
349
+ else:
350
+ return shape
351
+
352
+
353
+ def format_underlying_type(shape: FieldShape, ctx: LinkContext | None = None) -> str:
354
+ """Format a NewType's underlying type for the page header, with links."""
355
+ terminal = _peel_to_terminal(shape)
356
+ if isinstance(terminal, UnionRef):
357
+ return _format_union_members(terminal.union.members, ctx, separator=" | ")
358
+
359
+ if isinstance(terminal, MapOf):
360
+ return _format_map(terminal, ctx)
361
+
362
+ # Link by the terminal primitive's identity, not the enclosing NewType's:
363
+ # this shape belongs to the NewType being rendered, so linking its own
364
+ # identity would self-link. The terminal's identity is always its
365
+ # underlying primitive (Geometry/BBox, a pydantic type, etc.).
366
+ identity: TypeIdentity | None = None
367
+ if isinstance(terminal, Primitive):
368
+ identity = _scalar_identity(terminal)
369
+
370
+ depth, _ = _peel_arrays(shape)
371
+
372
+ if identity and ctx:
373
+ href = ctx.resolve_link(identity)
374
+ if href:
375
+ linked = _code_link(identity.name, href)
376
+ if depth > 0:
377
+ return _wrap_list_n(linked, depth)
378
+ return linked
379
+
380
+ base = identity.name if identity else resolve_type_name(shape)
381
+ if depth > 0:
382
+ return _plain_list_type(base, depth)
383
+ return f"`{base}`"
File without changes
@@ -0,0 +1 @@
1
+ """PySpark codegen pipeline: ModelSpec to expression and test modules."""