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,641 @@
|
|
|
1
|
+
"""Markdown renderer for Pydantic model documentation."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import functools
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Callable, Iterable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TypedDict, cast
|
|
11
|
+
|
|
12
|
+
from annotated_types import Interval
|
|
13
|
+
from jinja2 import Environment, FileSystemLoader
|
|
14
|
+
from typing_extensions import NotRequired
|
|
15
|
+
|
|
16
|
+
from ..extraction.examples import ExampleRecord
|
|
17
|
+
from ..extraction.field import ConstraintSource
|
|
18
|
+
from ..extraction.field_constraints import constraint_display_text
|
|
19
|
+
from ..extraction.field_walk import (
|
|
20
|
+
all_constraints,
|
|
21
|
+
list_depth,
|
|
22
|
+
map_key_value_constraints,
|
|
23
|
+
terminal_model_ref,
|
|
24
|
+
)
|
|
25
|
+
from ..extraction.model_constraints import analyze_model_constraints
|
|
26
|
+
from ..extraction.specs import (
|
|
27
|
+
AnnotatedField,
|
|
28
|
+
EnumSpec,
|
|
29
|
+
FieldSpec,
|
|
30
|
+
ModelSpec,
|
|
31
|
+
NewTypeSpec,
|
|
32
|
+
NumericSpec,
|
|
33
|
+
PydanticTypeSpec,
|
|
34
|
+
RecordSpec,
|
|
35
|
+
TypeIdentity,
|
|
36
|
+
UnionSpec,
|
|
37
|
+
)
|
|
38
|
+
from .link_computation import LinkContext
|
|
39
|
+
from .reverse_references import UsedByEntry
|
|
40
|
+
from .type_format import (
|
|
41
|
+
format_type,
|
|
42
|
+
format_underlying_type,
|
|
43
|
+
resolve_type_link,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"render_enum",
|
|
48
|
+
"render_model",
|
|
49
|
+
"render_geometry_from_values",
|
|
50
|
+
"render_newtype",
|
|
51
|
+
"render_numeric_from_specs",
|
|
52
|
+
"render_pydantic_type",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
_LinkFn = Callable[[TypeIdentity], str]
|
|
57
|
+
|
|
58
|
+
_TEMPLATES_DIR = Path(__file__).parent / "templates"
|
|
59
|
+
|
|
60
|
+
_BARE_URL_RE = re.compile(
|
|
61
|
+
r"(?<!\]\()" # not preceded by ]( (already a Markdown link target)
|
|
62
|
+
r"(https?://[^\s<>)]+|www\.[^\s<>)]+)"
|
|
63
|
+
)
|
|
64
|
+
_TRAILING_PUNCT_RE = re.compile(r"[.,;:!?]+$")
|
|
65
|
+
# (.+?) deliberately does not match newlines -- CommonMark code spans are inline.
|
|
66
|
+
_CODE_SPAN_RE = re.compile(r"(`+)(.+?)\1")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _linkify_bare_urls(text: str) -> str:
|
|
70
|
+
"""Wrap bare URLs in Markdown link syntax.
|
|
71
|
+
|
|
72
|
+
Turns `www.example.com` into `[www.example.com](https://www.example.com)`
|
|
73
|
+
and `https://example.com` into `[https://example.com](https://example.com)`.
|
|
74
|
+
URLs already inside `[text](url)` or backtick code spans are left
|
|
75
|
+
untouched. Trailing sentence punctuation (`.`, `,`, etc.) is excluded
|
|
76
|
+
from the link.
|
|
77
|
+
|
|
78
|
+
Two-pass approach: extract code spans first, linkify the remaining
|
|
79
|
+
text, then restore code spans.
|
|
80
|
+
"""
|
|
81
|
+
# Extract code spans, replacing with placeholders
|
|
82
|
+
spans: list[str] = []
|
|
83
|
+
|
|
84
|
+
def _stash_span(m: re.Match[str]) -> str:
|
|
85
|
+
spans.append(m.group(0))
|
|
86
|
+
return f"\x00CODESPAN{len(spans) - 1}\x00"
|
|
87
|
+
|
|
88
|
+
text = _CODE_SPAN_RE.sub(_stash_span, text)
|
|
89
|
+
|
|
90
|
+
# Linkify bare URLs in non-code text
|
|
91
|
+
def _to_link(m: re.Match[str]) -> str:
|
|
92
|
+
raw = m.group(0)
|
|
93
|
+
url = _TRAILING_PUNCT_RE.sub("", raw)
|
|
94
|
+
trailing = raw[len(url) :]
|
|
95
|
+
href = url if url.startswith("http") else f"https://{url}"
|
|
96
|
+
return f"[{url}]({href}){trailing}"
|
|
97
|
+
|
|
98
|
+
text = _BARE_URL_RE.sub(_to_link, text)
|
|
99
|
+
|
|
100
|
+
# Restore code spans
|
|
101
|
+
for i, span in enumerate(spans):
|
|
102
|
+
text = text.replace(f"\x00CODESPAN{i}\x00", span)
|
|
103
|
+
|
|
104
|
+
return text
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@functools.lru_cache(maxsize=1)
|
|
108
|
+
def _get_jinja_env() -> Environment:
|
|
109
|
+
"""Return the Jinja2 environment, creating it on first use."""
|
|
110
|
+
env = Environment(
|
|
111
|
+
loader=FileSystemLoader(_TEMPLATES_DIR),
|
|
112
|
+
trim_blocks=True,
|
|
113
|
+
lstrip_blocks=True,
|
|
114
|
+
)
|
|
115
|
+
env.filters["linkify_urls"] = _linkify_bare_urls
|
|
116
|
+
return env
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
_EXAMPLE_TRUNCATION_LIMIT = 100
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class _FieldRow(TypedDict):
|
|
123
|
+
"""Template context for a single field table row.
|
|
124
|
+
|
|
125
|
+
`pre_formatted` indicates the `name` already contains backticks
|
|
126
|
+
and variant tags, so the template should render it verbatim.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
name: str
|
|
130
|
+
type_str: str
|
|
131
|
+
description: str | None
|
|
132
|
+
pre_formatted: NotRequired[bool]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
_PARAGRAPH_BREAK_RE = re.compile(r"\n(?:[ \t]*\n)+")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _unwrap_paragraphs(text: str) -> str:
|
|
139
|
+
r"""Unwrap hard-wrapped lines within paragraphs, preserving paragraph breaks.
|
|
140
|
+
|
|
141
|
+
Splits on blank lines (paragraph boundaries), replaces single newlines
|
|
142
|
+
within each paragraph with spaces, then rejoins with `\n\n`.
|
|
143
|
+
Matches markdown's treatment of newlines within paragraphs.
|
|
144
|
+
"""
|
|
145
|
+
paragraphs = _PARAGRAPH_BREAK_RE.split(text)
|
|
146
|
+
return "\n\n".join(p.replace("\n", " ") for p in paragraphs)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _sanitize_for_table_cell(text: str) -> str:
|
|
150
|
+
"""Sanitize text for embedding in a markdown table cell.
|
|
151
|
+
|
|
152
|
+
Unwraps within-paragraph newlines to spaces, then converts paragraph
|
|
153
|
+
breaks to `<br/><br/>`. Escapes pipe characters for table safety.
|
|
154
|
+
Uses `<br/>` (not `<br>`) for MDX/Docusaurus compatibility.
|
|
155
|
+
"""
|
|
156
|
+
text = text.strip()
|
|
157
|
+
text = _unwrap_paragraphs(text)
|
|
158
|
+
text = text.replace("\n\n", "<br/><br/>")
|
|
159
|
+
return text.replace("|", "\\|")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _truncate(text: str) -> str:
|
|
163
|
+
"""Truncate text to `_EXAMPLE_TRUNCATION_LIMIT` chars, adding ellipsis."""
|
|
164
|
+
if len(text) > _EXAMPLE_TRUNCATION_LIMIT:
|
|
165
|
+
return text[: _EXAMPLE_TRUNCATION_LIMIT - 3] + "..."
|
|
166
|
+
return text
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _format_example_value(value: object) -> str:
|
|
170
|
+
"""Format an example value for display in a markdown Column | Value table.
|
|
171
|
+
|
|
172
|
+
All non-empty values render in backticks for consistent monospace
|
|
173
|
+
formatting. Long representations are truncated before wrapping.
|
|
174
|
+
"""
|
|
175
|
+
if value is None:
|
|
176
|
+
return "`null`"
|
|
177
|
+
|
|
178
|
+
if isinstance(value, bool):
|
|
179
|
+
return "`true`" if value else "`false`"
|
|
180
|
+
|
|
181
|
+
if isinstance(value, datetime.date):
|
|
182
|
+
return f"`{value.isoformat()}`"
|
|
183
|
+
|
|
184
|
+
if isinstance(value, str):
|
|
185
|
+
if value == "":
|
|
186
|
+
return ""
|
|
187
|
+
return f"`{_truncate(value)}`"
|
|
188
|
+
|
|
189
|
+
if isinstance(value, list):
|
|
190
|
+
items = ", ".join(json.dumps(item, default=str) for item in value)
|
|
191
|
+
return f"`{_truncate(f'[{items}]')}`"
|
|
192
|
+
|
|
193
|
+
if isinstance(value, dict):
|
|
194
|
+
pairs = ", ".join(
|
|
195
|
+
f"{json.dumps(k, default=str)}: {json.dumps(v, default=str)}"
|
|
196
|
+
for k, v in value.items()
|
|
197
|
+
)
|
|
198
|
+
return f"`{_truncate(f'{{{pairs}}}')}`"
|
|
199
|
+
|
|
200
|
+
return f"`{_truncate(str(value))}`"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _field_template_context(
|
|
204
|
+
field: FieldSpec,
|
|
205
|
+
ctx: LinkContext | None = None,
|
|
206
|
+
) -> _FieldRow:
|
|
207
|
+
"""Build template context dict for a field."""
|
|
208
|
+
description = (
|
|
209
|
+
_sanitize_for_table_cell(field.description) if field.description else None
|
|
210
|
+
)
|
|
211
|
+
return _FieldRow(
|
|
212
|
+
name=field.name,
|
|
213
|
+
type_str=format_type(field, ctx),
|
|
214
|
+
description=description,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _annotate_constraint_notes(
|
|
219
|
+
row: _FieldRow,
|
|
220
|
+
notes: list[str],
|
|
221
|
+
) -> None:
|
|
222
|
+
"""Append italic constraint descriptions to a field's description cell."""
|
|
223
|
+
formatted = "<br/>".join(f"*{note}*" for note in notes)
|
|
224
|
+
if row["description"]:
|
|
225
|
+
row["description"] = f"{row['description']}<br/><br/>{formatted}"
|
|
226
|
+
else:
|
|
227
|
+
row["description"] = formatted
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _link_fn_from_ctx(ctx: LinkContext | None) -> _LinkFn:
|
|
231
|
+
r"""Build a TypeIdentity-to-markdown-link resolver from a LinkContext.
|
|
232
|
+
|
|
233
|
+
Returns a function that resolves a TypeIdentity to ``[`Name`](href)``
|
|
234
|
+
when the identity has a page in the registry, or plain ``\`Name\``` otherwise.
|
|
235
|
+
"""
|
|
236
|
+
return functools.partial(resolve_type_link, ctx=ctx)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _annotate_field_constraints(
|
|
240
|
+
row: _FieldRow, field: FieldSpec, ctx: LinkContext | None
|
|
241
|
+
) -> None:
|
|
242
|
+
"""Annotate a field row with constraints from the field's own annotation.
|
|
243
|
+
|
|
244
|
+
Shows constraints where source is None -- those applied directly to
|
|
245
|
+
the field, not inherited from NewType chains. NewType-inherited
|
|
246
|
+
constraints appear on the NewType's own page instead.
|
|
247
|
+
"""
|
|
248
|
+
link_fn = _link_fn_from_ctx(ctx)
|
|
249
|
+
|
|
250
|
+
def directly_applied(prefix: str, sources: Iterable[ConstraintSource]) -> list[str]:
|
|
251
|
+
return [
|
|
252
|
+
f"{prefix}{constraint_display_text(cs, link_fn=link_fn)}"
|
|
253
|
+
for cs in sources
|
|
254
|
+
if cs.source_ref is None
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
key_constraints, value_constraints = map_key_value_constraints(field.shape)
|
|
258
|
+
notes = directly_applied("", all_constraints(field.shape))
|
|
259
|
+
notes += directly_applied("key: ", key_constraints)
|
|
260
|
+
notes += directly_applied("value: ", value_constraints)
|
|
261
|
+
if notes:
|
|
262
|
+
_annotate_constraint_notes(row, notes)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _expandable_list_suffix(field_spec: FieldSpec) -> str:
|
|
266
|
+
"""Return `"[]"` per nesting level for list-of-model fields expanded inline."""
|
|
267
|
+
model_ref = terminal_model_ref(field_spec.shape)
|
|
268
|
+
if model_ref is None or model_ref.starts_cycle:
|
|
269
|
+
return ""
|
|
270
|
+
depth = list_depth(field_spec.shape)
|
|
271
|
+
return "[]" * depth if depth > 0 else ""
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _expand_sub_model(
|
|
275
|
+
field_spec: FieldSpec,
|
|
276
|
+
name: str,
|
|
277
|
+
ctx: LinkContext | None,
|
|
278
|
+
result: list[_FieldRow],
|
|
279
|
+
) -> None:
|
|
280
|
+
"""Expand sub-model fields inline, appending child rows to *result*."""
|
|
281
|
+
model_ref = terminal_model_ref(field_spec.shape)
|
|
282
|
+
if model_ref is None or model_ref.starts_cycle:
|
|
283
|
+
return
|
|
284
|
+
child_prefix = f"{name}{_expandable_list_suffix(field_spec)}."
|
|
285
|
+
result.extend(
|
|
286
|
+
_expand_model_fields(model_ref.model.fields, ctx, prefix=child_prefix)
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _annotate_top_level_constraints(
|
|
291
|
+
rows: list[_FieldRow],
|
|
292
|
+
constraint_notes: dict[str, list[str]] | None,
|
|
293
|
+
) -> None:
|
|
294
|
+
"""Annotate top-level field rows with model-constraint notes.
|
|
295
|
+
|
|
296
|
+
Top-level rows are those without dot-notation prefixes.
|
|
297
|
+
"""
|
|
298
|
+
if not constraint_notes:
|
|
299
|
+
return
|
|
300
|
+
for row in rows:
|
|
301
|
+
name = row["name"]
|
|
302
|
+
if "." in name:
|
|
303
|
+
continue
|
|
304
|
+
field_name = name.split("[")[0]
|
|
305
|
+
if field_name in constraint_notes:
|
|
306
|
+
_annotate_constraint_notes(row, constraint_notes[field_name])
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _expand_model_fields(
|
|
310
|
+
fields: list[FieldSpec],
|
|
311
|
+
ctx: LinkContext | None,
|
|
312
|
+
prefix: str = "",
|
|
313
|
+
) -> list[_FieldRow]:
|
|
314
|
+
"""Flatten nested model fields into dot-notation rows for display.
|
|
315
|
+
|
|
316
|
+
Walks the pre-populated FieldSpec.model tree. Stops recursion at
|
|
317
|
+
fields marked with starts_cycle.
|
|
318
|
+
"""
|
|
319
|
+
result: list[_FieldRow] = []
|
|
320
|
+
for field_spec in fields:
|
|
321
|
+
row = _field_template_context(field_spec, ctx)
|
|
322
|
+
name = f"{prefix}{field_spec.name}" if prefix else field_spec.name
|
|
323
|
+
row["name"] = f"{name}{_expandable_list_suffix(field_spec)}"
|
|
324
|
+
if not prefix:
|
|
325
|
+
_annotate_field_constraints(row, field_spec, ctx)
|
|
326
|
+
result.append(row)
|
|
327
|
+
|
|
328
|
+
_expand_sub_model(field_spec, name, ctx, result)
|
|
329
|
+
return result
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _short_variant_name(class_name: str, union_name: str) -> str:
|
|
333
|
+
"""Strip common suffix to produce short variant name.
|
|
334
|
+
|
|
335
|
+
Examples
|
|
336
|
+
--------
|
|
337
|
+
>>> _short_variant_name("RoadSegment", "Segment")
|
|
338
|
+
'Road'
|
|
339
|
+
>>> _short_variant_name("WaterSegment", "Segment")
|
|
340
|
+
'Water'
|
|
341
|
+
>>> _short_variant_name("Building", "Building")
|
|
342
|
+
'Building'
|
|
343
|
+
"""
|
|
344
|
+
if class_name.endswith(union_name):
|
|
345
|
+
short = class_name[: -len(union_name)]
|
|
346
|
+
if short:
|
|
347
|
+
return short
|
|
348
|
+
return class_name
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _variant_tag(annotated: AnnotatedField, union_name: str) -> str | None:
|
|
352
|
+
"""Return an italic variant tag like `*(Road, Water)*`, or None for shared fields."""
|
|
353
|
+
if annotated.variant_sources is None:
|
|
354
|
+
return None
|
|
355
|
+
short_names = [
|
|
356
|
+
_short_variant_name(v.__name__, union_name) for v in annotated.variant_sources
|
|
357
|
+
]
|
|
358
|
+
return f" *({', '.join(short_names)})*"
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _expand_union_fields(
|
|
362
|
+
spec: UnionSpec,
|
|
363
|
+
ctx: LinkContext | None,
|
|
364
|
+
constraint_notes: dict[str, list[str]] | None = None,
|
|
365
|
+
) -> list[_FieldRow]:
|
|
366
|
+
"""Expand UnionSpec fields with inline variant tags.
|
|
367
|
+
|
|
368
|
+
Shared fields (variant_sources=None) render normally. Variant-specific
|
|
369
|
+
fields get *(ShortName)* tag after the field name.
|
|
370
|
+
"""
|
|
371
|
+
result: list[_FieldRow] = []
|
|
372
|
+
for annotated in spec.annotated_fields:
|
|
373
|
+
field_spec = annotated.field_spec
|
|
374
|
+
row = _field_template_context(field_spec, ctx)
|
|
375
|
+
name = field_spec.name
|
|
376
|
+
suffix = _expandable_list_suffix(field_spec)
|
|
377
|
+
|
|
378
|
+
_annotate_field_constraints(row, field_spec, ctx)
|
|
379
|
+
if constraint_notes and field_spec.name in constraint_notes:
|
|
380
|
+
_annotate_constraint_notes(row, constraint_notes[field_spec.name])
|
|
381
|
+
|
|
382
|
+
tag = _variant_tag(annotated, spec.name)
|
|
383
|
+
if tag is not None:
|
|
384
|
+
row["name"] = f"`{name}{suffix}`{tag}"
|
|
385
|
+
row["pre_formatted"] = True
|
|
386
|
+
else:
|
|
387
|
+
row["name"] = f"{name}{suffix}"
|
|
388
|
+
|
|
389
|
+
result.append(row)
|
|
390
|
+
_expand_sub_model(field_spec, name, ctx, result)
|
|
391
|
+
return result
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def render_model(
|
|
395
|
+
spec: ModelSpec,
|
|
396
|
+
link_ctx: LinkContext | None = None,
|
|
397
|
+
examples: list[ExampleRecord] | None = None,
|
|
398
|
+
used_by: list[UsedByEntry] | None = None,
|
|
399
|
+
) -> str:
|
|
400
|
+
"""Render a feature spec as Markdown documentation.
|
|
401
|
+
|
|
402
|
+
For UnionSpec, adds inline variant tags to variant-specific fields.
|
|
403
|
+
"""
|
|
404
|
+
template = _get_jinja_env().get_template("feature.md.jinja2")
|
|
405
|
+
|
|
406
|
+
constraint_descriptions, field_notes = analyze_model_constraints(spec.constraints)
|
|
407
|
+
|
|
408
|
+
if isinstance(spec, UnionSpec):
|
|
409
|
+
fields = _expand_union_fields(spec, link_ctx, constraint_notes=field_notes)
|
|
410
|
+
elif isinstance(spec, RecordSpec):
|
|
411
|
+
fields = _expand_model_fields(spec.fields, link_ctx)
|
|
412
|
+
_annotate_top_level_constraints(fields, field_notes)
|
|
413
|
+
else:
|
|
414
|
+
raise TypeError(f"Unsupported spec type: {type(spec).__name__}")
|
|
415
|
+
|
|
416
|
+
formatted_examples: list[list[dict[str, str]]] | None = None
|
|
417
|
+
if examples:
|
|
418
|
+
formatted_examples = [
|
|
419
|
+
[
|
|
420
|
+
{"column": key, "value": _format_example_value(val)}
|
|
421
|
+
for key, val in record.rows
|
|
422
|
+
]
|
|
423
|
+
for record in examples
|
|
424
|
+
]
|
|
425
|
+
|
|
426
|
+
return template.render(
|
|
427
|
+
model=spec,
|
|
428
|
+
fields=fields,
|
|
429
|
+
constraints=constraint_descriptions,
|
|
430
|
+
examples=formatted_examples,
|
|
431
|
+
used_by=_build_used_by_context(used_by, link_ctx),
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def render_enum(
|
|
436
|
+
enum_spec: EnumSpec,
|
|
437
|
+
link_ctx: LinkContext | None = None,
|
|
438
|
+
used_by: list[UsedByEntry] | None = None,
|
|
439
|
+
) -> str:
|
|
440
|
+
"""Render an EnumSpec as Markdown documentation."""
|
|
441
|
+
template = _get_jinja_env().get_template("enum.md.jinja2")
|
|
442
|
+
return template.render(
|
|
443
|
+
enum=enum_spec, used_by=_build_used_by_context(used_by, link_ctx)
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
@dataclass
|
|
448
|
+
class _NewTypeConstraintRow:
|
|
449
|
+
"""Rendered constraint for template."""
|
|
450
|
+
|
|
451
|
+
display: str
|
|
452
|
+
source: str | None = None
|
|
453
|
+
source_link: str | None = None
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _format_constraint(
|
|
457
|
+
cs: ConstraintSource,
|
|
458
|
+
newtype_ref: object,
|
|
459
|
+
ctx: LinkContext | None = None,
|
|
460
|
+
) -> _NewTypeConstraintRow:
|
|
461
|
+
"""Format a ConstraintSource for display in a NewType page."""
|
|
462
|
+
display = constraint_display_text(cs)
|
|
463
|
+
|
|
464
|
+
if cs.source_ref is None or cs.source_ref is newtype_ref:
|
|
465
|
+
return _NewTypeConstraintRow(display=display)
|
|
466
|
+
|
|
467
|
+
# source_ref and source_name are always set together
|
|
468
|
+
if cs.source_name is None:
|
|
469
|
+
return _NewTypeConstraintRow(display=display)
|
|
470
|
+
source_identity = TypeIdentity(cs.source_ref, cs.source_name)
|
|
471
|
+
source_link = ctx.resolve_link(source_identity) if ctx else None
|
|
472
|
+
return _NewTypeConstraintRow(
|
|
473
|
+
display=display, source=cs.source_name, source_link=source_link
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
class _UsedByContext(TypedDict):
|
|
478
|
+
"""Template context for a used-by entry."""
|
|
479
|
+
|
|
480
|
+
name: str
|
|
481
|
+
link: str | None
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _build_used_by_context(
|
|
485
|
+
used_by: list[UsedByEntry] | None,
|
|
486
|
+
link_ctx: LinkContext | None,
|
|
487
|
+
) -> list[_UsedByContext] | None:
|
|
488
|
+
"""Build template context for used-by entries."""
|
|
489
|
+
if not used_by:
|
|
490
|
+
return None
|
|
491
|
+
return [
|
|
492
|
+
{
|
|
493
|
+
"name": entry.identity.name,
|
|
494
|
+
"link": link_ctx.resolve_link(entry.identity) if link_ctx else None,
|
|
495
|
+
}
|
|
496
|
+
for entry in used_by
|
|
497
|
+
]
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def render_newtype(
|
|
501
|
+
newtype_spec: NewTypeSpec,
|
|
502
|
+
link_ctx: LinkContext | None = None,
|
|
503
|
+
used_by: list[UsedByEntry] | None = None,
|
|
504
|
+
) -> str:
|
|
505
|
+
"""Render a `NewTypeSpec` as Markdown documentation."""
|
|
506
|
+
template = _get_jinja_env().get_template("newtype.md.jinja2")
|
|
507
|
+
shape = newtype_spec.shape
|
|
508
|
+
underlying = format_underlying_type(shape, link_ctx)
|
|
509
|
+
constraints = [
|
|
510
|
+
_format_constraint(cs, newtype_spec.source_type, link_ctx)
|
|
511
|
+
for cs in all_constraints(shape)
|
|
512
|
+
]
|
|
513
|
+
|
|
514
|
+
return template.render(
|
|
515
|
+
newtype=newtype_spec,
|
|
516
|
+
underlying_type=underlying,
|
|
517
|
+
constraints=constraints,
|
|
518
|
+
used_by=_build_used_by_context(used_by, link_ctx),
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def render_pydantic_type(
|
|
523
|
+
spec: PydanticTypeSpec,
|
|
524
|
+
link_ctx: LinkContext | None = None,
|
|
525
|
+
used_by: list[UsedByEntry] | None = None,
|
|
526
|
+
) -> str:
|
|
527
|
+
"""Render a PydanticTypeSpec as Markdown documentation."""
|
|
528
|
+
template = _get_jinja_env().get_template("pydantic_type.md.jinja2")
|
|
529
|
+
return template.render(
|
|
530
|
+
pydantic_type=spec,
|
|
531
|
+
used_by=_build_used_by_context(used_by, link_ctx),
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
# Matches the ge/le bounds of the int64 NewType in overture.schema.system.numeric.
|
|
536
|
+
_INT64_MIN = -(2**63)
|
|
537
|
+
_INT64_MAX = 2**63 - 1
|
|
538
|
+
|
|
539
|
+
_NumericBound = int | float | None
|
|
540
|
+
|
|
541
|
+
# IEEE 754 precision by bit width — formatting knowledge, not schema data.
|
|
542
|
+
_FLOAT_PRECISION: dict[int, str] = {32: "~7 decimal digits", 64: "~15 decimal digits"}
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _format_bound(value: int | float) -> str:
|
|
546
|
+
"""Format a numeric bound for display.
|
|
547
|
+
|
|
548
|
+
Uses `2^63` notation for int64-scale values to avoid unreadable
|
|
549
|
+
numbers; otherwise formats with thousands separators for ints.
|
|
550
|
+
"""
|
|
551
|
+
if value == _INT64_MIN:
|
|
552
|
+
return "-2^63"
|
|
553
|
+
if value == _INT64_MAX:
|
|
554
|
+
return "2^63-1"
|
|
555
|
+
if isinstance(value, float):
|
|
556
|
+
return str(value)
|
|
557
|
+
return f"{value:,}"
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _format_interval(bounds: Interval) -> str:
|
|
561
|
+
"""Format an Interval as a range string, or empty if unconstrained.
|
|
562
|
+
|
|
563
|
+
Two inclusive bounds render as `lower to upper`. All other
|
|
564
|
+
combinations use explicit comparison operators so the
|
|
565
|
+
inclusivity/exclusivity is unambiguous.
|
|
566
|
+
"""
|
|
567
|
+
# Interval fields are typed as Supports* protocols; narrow to numeric
|
|
568
|
+
# since we only encounter int/float constraints from the schema.
|
|
569
|
+
ge = cast(_NumericBound, bounds.ge)
|
|
570
|
+
gt = cast(_NumericBound, bounds.gt)
|
|
571
|
+
le = cast(_NumericBound, bounds.le)
|
|
572
|
+
lt = cast(_NumericBound, bounds.lt)
|
|
573
|
+
|
|
574
|
+
# Both bounds inclusive: compact "lower to upper" form
|
|
575
|
+
if ge is not None and le is not None:
|
|
576
|
+
return f"{_format_bound(ge)} to {_format_bound(le)}"
|
|
577
|
+
|
|
578
|
+
# Any other two-bound combination: use explicit operators
|
|
579
|
+
parts: list[str] = []
|
|
580
|
+
if ge is not None:
|
|
581
|
+
parts.append(f">= {_format_bound(ge)}")
|
|
582
|
+
elif gt is not None:
|
|
583
|
+
parts.append(f"> {_format_bound(gt)}")
|
|
584
|
+
|
|
585
|
+
if le is not None:
|
|
586
|
+
parts.append(f"<= {_format_bound(le)}")
|
|
587
|
+
elif lt is not None:
|
|
588
|
+
parts.append(f"< {_format_bound(lt)}")
|
|
589
|
+
|
|
590
|
+
return ", ".join(parts)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _bit_width_key(name: str) -> tuple[str, int]:
|
|
594
|
+
"""Sort key: prefix then numeric bit width."""
|
|
595
|
+
prefix = name.rstrip("0123456789")
|
|
596
|
+
digits = name[len(prefix) :]
|
|
597
|
+
return (prefix, int(digits) if digits else 0)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def render_numeric_from_specs(specs: list[NumericSpec]) -> str:
|
|
601
|
+
"""Render the numeric.md page from pre-extracted NumericSpecs."""
|
|
602
|
+
template = _get_jinja_env().get_template("numeric.md.jinja2")
|
|
603
|
+
|
|
604
|
+
signed_ints: list[dict[str, str | None]] = []
|
|
605
|
+
unsigned_ints: list[dict[str, str | None]] = []
|
|
606
|
+
floats: list[dict[str, str | None]] = []
|
|
607
|
+
|
|
608
|
+
for spec in sorted(specs, key=lambda s: _bit_width_key(s.name)):
|
|
609
|
+
if spec.name.startswith(("int", "uint")):
|
|
610
|
+
target = signed_ints if spec.name.startswith("int") else unsigned_ints
|
|
611
|
+
target.append(
|
|
612
|
+
{
|
|
613
|
+
"name": spec.name,
|
|
614
|
+
"range": _format_interval(spec.bounds),
|
|
615
|
+
"description": _sanitize_for_table_cell(spec.description or ""),
|
|
616
|
+
}
|
|
617
|
+
)
|
|
618
|
+
elif spec.name.startswith("float"):
|
|
619
|
+
precision = (
|
|
620
|
+
_FLOAT_PRECISION.get(spec.float_bits, "") if spec.float_bits else ""
|
|
621
|
+
)
|
|
622
|
+
floats.append(
|
|
623
|
+
{
|
|
624
|
+
"name": spec.name,
|
|
625
|
+
"precision": precision,
|
|
626
|
+
"description": _sanitize_for_table_cell(spec.description or ""),
|
|
627
|
+
}
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
return template.render(
|
|
631
|
+
signed_ints=signed_ints,
|
|
632
|
+
unsigned_ints=unsigned_ints,
|
|
633
|
+
floats=floats,
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def render_geometry_from_values(geometry_type_values: list[str]) -> str:
|
|
638
|
+
"""Render the geometric.md page from pre-extracted geometry type values."""
|
|
639
|
+
template = _get_jinja_env().get_template("geometric.md.jinja2")
|
|
640
|
+
geometry_types = ", ".join(f"`{v}`" for v in geometry_type_values)
|
|
641
|
+
return template.render(geometry_types=geometry_types)
|