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