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 @@
1
+ """Code generator for Overture Schema Pydantic models."""
@@ -0,0 +1,228 @@
1
+ """CLI entrypoint for schema code generation."""
2
+
3
+ import json
4
+ import logging
5
+ from collections.abc import Mapping
6
+ from pathlib import Path, PurePosixPath
7
+
8
+ import click
9
+
10
+ from overture.schema.cli.tag_options import build_selector, tag_selection_options
11
+ from overture.schema.system.discovery import (
12
+ discover_models,
13
+ filter_models,
14
+ split_entry_point,
15
+ )
16
+
17
+ from .extraction.specs import ModelSpec, SupplementarySpec, TypeIdentity
18
+ from .layout.module_layout import (
19
+ OUTPUT_ROOT,
20
+ compute_schema_root,
21
+ entry_point_module,
22
+ )
23
+ from .markdown.pipeline import generate_markdown_pages
24
+ from .pyspark.pipeline import generate_pyspark_modules
25
+ from .spec_discovery import extract_alias_spec, extract_model_spec
26
+
27
+ log = logging.getLogger(__name__)
28
+
29
+ __all__ = ["cli"]
30
+
31
+ _OUTPUT_FORMATS = ("markdown", "pyspark")
32
+
33
+ _FEATURE_FRONTMATTER = "---\nsidebar_position: 1\n---\n\n"
34
+
35
+
36
+ def _write_output(
37
+ content: str,
38
+ output_dir: Path | None,
39
+ output_path: PurePosixPath,
40
+ ) -> None:
41
+ """Write content to a file under output_dir, or stdout."""
42
+ if output_dir:
43
+ file_path = output_dir / output_path
44
+ file_path.parent.mkdir(parents=True, exist_ok=True)
45
+ file_path.write_text(content)
46
+ else:
47
+ click.echo(content)
48
+ click.echo() # separate entries with a blank line in stdout mode
49
+
50
+
51
+ @click.group()
52
+ def cli() -> None:
53
+ """Overture Schema code generator.
54
+
55
+ Generate documentation and code from Pydantic schema models.
56
+ """
57
+
58
+
59
+ @cli.command("list")
60
+ def list_models() -> None:
61
+ """List all discovered models."""
62
+ models = discover_models()
63
+ # Name every entry from its entry point, not the loaded object: a
64
+ # discriminated union loads as an `Annotated[...]` alias with no
65
+ # `__name__`, so `str(model)` would print the whole type expression.
66
+ names = []
67
+ for key in models:
68
+ _, class_name = split_entry_point(key.entry_point)
69
+ names.append(class_name)
70
+ for name in sorted(names):
71
+ click.echo(name)
72
+
73
+
74
+ @cli.command()
75
+ @click.option(
76
+ "--format",
77
+ "output_format",
78
+ required=True,
79
+ type=click.Choice(_OUTPUT_FORMATS),
80
+ help="Output format",
81
+ )
82
+ @tag_selection_options
83
+ @click.option(
84
+ "--output-dir",
85
+ type=click.Path(path_type=Path),
86
+ default=None,
87
+ help="Write output files directly into this directory (default: stdout). "
88
+ "For pyspark, writes expression modules (*.py). "
89
+ "For markdown, writes theme subdirectories.",
90
+ )
91
+ @click.option(
92
+ "--test-output-dir",
93
+ type=click.Path(path_type=Path),
94
+ default=None,
95
+ help="Write test modules (test_*.py) into this directory (pyspark only).",
96
+ )
97
+ def generate(
98
+ output_format: str,
99
+ tags: tuple[str, ...],
100
+ filters: tuple[str, ...],
101
+ excludes: tuple[str, ...],
102
+ output_dir: Path | None,
103
+ test_output_dir: Path | None,
104
+ ) -> None:
105
+ """Generate code/docs from discovered models."""
106
+ if output_format != "pyspark" and test_output_dir is not None:
107
+ raise click.UsageError("--test-output-dir is only valid with --format pyspark")
108
+
109
+ all_models = discover_models()
110
+
111
+ models = filter_models(all_models, build_selector(tags, filters, excludes))
112
+
113
+ if output_dir:
114
+ output_dir.mkdir(parents=True, exist_ok=True)
115
+
116
+ model_specs: list[ModelSpec] = [
117
+ spec
118
+ for key, entry in models.items()
119
+ if (spec := extract_model_spec(key, entry)) is not None
120
+ ]
121
+
122
+ if output_format == "pyspark":
123
+ _generate_pyspark(model_specs, output_dir, test_output_dir)
124
+ else:
125
+ # RootModel entry points yield no ModelSpec, so they document as
126
+ # named aliases -- reachable no other way, since a RootModel field
127
+ # unwraps to its bare shape and names no type.
128
+ external_specs: dict[TypeIdentity, SupplementarySpec] = {
129
+ alias.identity: alias
130
+ for entry in models.values()
131
+ if (alias := extract_alias_spec(entry)) is not None
132
+ }
133
+ module_paths = [entry_point_module(k.entry_point) for k in all_models]
134
+ schema_root = compute_schema_root(module_paths)
135
+ _generate_markdown(model_specs, schema_root, output_dir, external_specs)
136
+
137
+
138
+ def _generate_markdown(
139
+ model_specs: list[ModelSpec],
140
+ schema_root: str,
141
+ output_dir: Path | None,
142
+ external_specs: Mapping[TypeIdentity, SupplementarySpec],
143
+ ) -> None:
144
+ """Generate markdown with directory layout and placement-aware links."""
145
+ pages = generate_markdown_pages(
146
+ model_specs, schema_root, external_specs=external_specs
147
+ )
148
+
149
+ for page in pages:
150
+ content = (
151
+ f"{_FEATURE_FRONTMATTER}{page.content}" if page.is_model else page.content
152
+ )
153
+ _write_output(content, output_dir, page.path)
154
+
155
+ if output_dir:
156
+ feature_paths = {page.path for page in pages if page.is_model}
157
+ all_paths = {page.path for page in pages}
158
+ _write_category_files(output_dir, all_paths, feature_paths)
159
+
160
+
161
+ def _generate_pyspark(
162
+ model_specs: list[ModelSpec],
163
+ output_dir: Path | None,
164
+ test_output_dir: Path | None = None,
165
+ ) -> None:
166
+ """Generate PySpark validation modules.
167
+
168
+ Output is syntactically valid Python; we assume a code formatter runs
169
+ over the written directories afterwards to match existing conventions.
170
+ """
171
+ modules = generate_pyspark_modules(model_specs)
172
+ for mod in modules.source:
173
+ _write_output(mod.content, output_dir, mod.path)
174
+ if test_output_dir is not None:
175
+ for mod in modules.test:
176
+ _write_output(mod.content, test_output_dir, mod.path)
177
+
178
+
179
+ def _ancestor_dirs(paths: set[PurePosixPath]) -> set[PurePosixPath]:
180
+ """Collect all ancestor directories for a set of file paths."""
181
+ dirs: set[PurePosixPath] = set()
182
+ for path in paths:
183
+ parent = path.parent
184
+ while parent != OUTPUT_ROOT:
185
+ dirs.add(parent)
186
+ parent = parent.parent
187
+ return dirs
188
+
189
+
190
+ def _top_level_positions(
191
+ dirs: set[PurePosixPath],
192
+ feature_paths: set[PurePosixPath],
193
+ ) -> dict[PurePosixPath, int]:
194
+ """Assign sidebar positions: feature dirs first, then non-feature, both alphabetical."""
195
+ feature_dir_names = {p.parts[0] for p in feature_paths}
196
+ top_level = sorted(d for d in dirs if d.parent == OUTPUT_ROOT)
197
+ feature_dirs = [d for d in top_level if d.name in feature_dir_names]
198
+ non_feature_dirs = [d for d in top_level if d.name not in feature_dir_names]
199
+ return {d: i for i, d in enumerate(feature_dirs + non_feature_dirs, start=1)}
200
+
201
+
202
+ def _write_category_files(
203
+ output_dir: Path,
204
+ all_paths: set[PurePosixPath],
205
+ feature_paths: set[PurePosixPath],
206
+ ) -> None:
207
+ """Write _category_.json files for Docusaurus sidebar navigation."""
208
+ dirs = _ancestor_dirs(all_paths)
209
+ positions = _top_level_positions(dirs, feature_paths)
210
+
211
+ for dir_path in sorted(dirs):
212
+ label = dir_path.name.replace("_", " ").title()
213
+ category: dict[str, object] = {"label": label}
214
+ if dir_path in positions:
215
+ category["position"] = positions[dir_path]
216
+
217
+ file_path = output_dir / dir_path / "_category_.json"
218
+ file_path.parent.mkdir(parents=True, exist_ok=True)
219
+ file_path.write_text(json.dumps(category, indent=2) + "\n")
220
+
221
+
222
+ def main() -> None:
223
+ """Run the CLI entry point."""
224
+ cli()
225
+
226
+
227
+ if __name__ == "__main__":
228
+ main()
File without changes
@@ -0,0 +1,46 @@
1
+ """Docstring extraction and cleaning utilities."""
2
+
3
+ import inspect
4
+ from enum import Enum
5
+ from typing import NewType
6
+
7
+ __all__ = ["clean_docstring", "first_docstring_line", "is_custom_docstring"]
8
+
9
+
10
+ # Probe auto-generated docstrings so we can distinguish them from explicit ones.
11
+ # Both Enum and NewType generate default docstrings that vary by Python version;
12
+ # capturing at import time adapts automatically if the format changes.
13
+ class _DocstringProbeEnum(Enum):
14
+ pass
15
+
16
+
17
+ _ENUM_DEFAULT_DOCSTRING = _DocstringProbeEnum.__doc__
18
+ del _DocstringProbeEnum
19
+ _NewtypeProbe = NewType("_NewtypeProbe", int)
20
+ _NEWTYPE_DEFAULT_DOCSTRING = _NewtypeProbe.__doc__
21
+ del _NewtypeProbe
22
+
23
+
24
+ def clean_docstring(doc: str | None) -> str | None:
25
+ """Return cleaned docstring, or None if absent or whitespace-only."""
26
+ if not doc:
27
+ return None
28
+ cleaned = inspect.cleandoc(doc)
29
+ return cleaned or None
30
+
31
+
32
+ def first_docstring_line(doc: str | None) -> str | None:
33
+ """Return the first line of a docstring, or None if absent."""
34
+ cleaned = clean_docstring(doc)
35
+ if not cleaned:
36
+ return None
37
+ return cleaned.split("\n")[0]
38
+
39
+
40
+ def is_custom_docstring(doc: str | None, inherited_doc: str | None = None) -> bool:
41
+ """Check if a docstring was explicitly written, not auto-generated or inherited."""
42
+ return bool(doc) and doc not in (
43
+ _ENUM_DEFAULT_DOCSTRING,
44
+ _NEWTYPE_DEFAULT_DOCSTRING,
45
+ inherited_doc,
46
+ )
@@ -0,0 +1,40 @@
1
+ """Enum extraction."""
2
+
3
+ from enum import Enum
4
+
5
+ from .docstring import clean_docstring, is_custom_docstring
6
+ from .specs import EnumMemberSpec, EnumSpec
7
+
8
+ __all__ = ["extract_enum"]
9
+
10
+
11
+ def extract_enum(enum_class: type[Enum]) -> EnumSpec:
12
+ """Extract enum specification from an Enum class.
13
+
14
+ Handles both simple str Enums and DocumentedEnums where members
15
+ have per-value descriptions via the __doc__ attribute.
16
+ """
17
+ class_doc = enum_class.__doc__
18
+ description = clean_docstring(class_doc) if is_custom_docstring(class_doc) else None
19
+
20
+ members: list[EnumMemberSpec] = []
21
+ for member in enum_class:
22
+ member_doc = getattr(member, "__doc__", None)
23
+ member_description = (
24
+ member_doc if is_custom_docstring(member_doc, class_doc) else None
25
+ )
26
+
27
+ members.append(
28
+ EnumMemberSpec(
29
+ name=member.name,
30
+ value=str(member.value),
31
+ description=member_description,
32
+ )
33
+ )
34
+
35
+ return EnumSpec(
36
+ name=enum_class.__name__,
37
+ description=description,
38
+ members=members,
39
+ source_type=enum_class,
40
+ )
@@ -0,0 +1,367 @@
1
+ """Load, validate, and flatten example data for schema documentation."""
2
+
3
+ import logging
4
+ import sys
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel, TypeAdapter, ValidationError
10
+ from pydantic.fields import FieldInfo
11
+
12
+ from .model_extraction import resolve_field_alias
13
+ from .type_analyzer import single_literal_value
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+ __all__ = [
18
+ "ExampleRecord",
19
+ "augment_missing_fields",
20
+ "flatten_model_instance",
21
+ "load_examples",
22
+ "validate_example",
23
+ ]
24
+
25
+ # tomllib is stdlib from 3.11+; tomli is the backport for 3.10.
26
+ try:
27
+ import tomllib # type: ignore[import-not-found]
28
+ except ModuleNotFoundError:
29
+ import tomli as tomllib # type: ignore[import-not-found]
30
+
31
+
32
+ @dataclass
33
+ class ExampleRecord:
34
+ """A flattened example with field-value pairs in documentation order."""
35
+
36
+ rows: list[tuple[str, Any]]
37
+
38
+
39
+ def _inject_literal_fields(
40
+ model_fields_dict: dict[str, FieldInfo], data: dict[str, Any]
41
+ ) -> dict[str, Any]:
42
+ """Inject single-value Literal field defaults missing from *data*.
43
+
44
+ Inspects *model_fields_dict* for fields with single-value `Literal`
45
+ annotations. For each field missing from *data*, injects the literal
46
+ value using the field's `validation_alias` (if set), falling back
47
+ to `alias`, then to the field name.
48
+
49
+ Returns a new dict; the original is not mutated.
50
+ """
51
+ result = data.copy()
52
+
53
+ for field_name, field_info in model_fields_dict.items():
54
+ key = resolve_field_alias(field_name, field_info)
55
+ if key in result:
56
+ continue
57
+
58
+ literal_value = single_literal_value(field_info.annotation)
59
+ if literal_value is not None:
60
+ result[key] = literal_value
61
+
62
+ return result
63
+
64
+
65
+ def _known_field_keys(model_fields_dict: dict[str, FieldInfo]) -> frozenset[str]:
66
+ """Alias-resolved field keys from a model_fields dict."""
67
+ return frozenset(
68
+ resolve_field_alias(name, info) for name, info in model_fields_dict.items()
69
+ )
70
+
71
+
72
+ def _strip_null_unknown_fields(
73
+ data: dict[str, Any], known_keys: frozenset[str]
74
+ ) -> dict[str, Any]:
75
+ """Drop null-valued fields not in *known_keys*.
76
+
77
+ For discriminated unions, *known_keys* contains only common base
78
+ fields. Variant-specific null fields from other arms (present in
79
+ flat parquet schemas) are stripped so the selected arm's validator
80
+ doesn't reject them as unknown extras.
81
+
82
+ Non-null fields are always kept so the arm's own validator can
83
+ accept or reject them normally.
84
+ """
85
+ return {k: v for k, v in data.items() if v is not None or k in known_keys}
86
+
87
+
88
+ def validate_example(
89
+ validation_type: object,
90
+ raw: dict[str, Any],
91
+ *,
92
+ model_fields: dict[str, FieldInfo] | None = None,
93
+ ) -> BaseModel:
94
+ """Validate example data against a model or union type.
95
+
96
+ Returns the validated model instance. Preprocesses *raw* data by:
97
+ 1. Injecting missing Literal fields for validation (if model_fields provided)
98
+ 2. Stripping null-valued fields not in *model_fields* (handles
99
+ flat-schema examples from discriminated unions)
100
+ """
101
+ if model_fields is None:
102
+ if isinstance(validation_type, type) and issubclass(validation_type, BaseModel):
103
+ model_fields = validation_type.model_fields
104
+ else:
105
+ model_fields = {}
106
+
107
+ known_keys = _known_field_keys(model_fields)
108
+ preprocessed = _inject_literal_fields(model_fields, raw)
109
+ preprocessed = _strip_null_unknown_fields(preprocessed, known_keys)
110
+ result: object = TypeAdapter(validation_type).validate_python(preprocessed)
111
+ if not isinstance(result, BaseModel):
112
+ raise TypeError(f"Expected BaseModel instance, got {type(result).__name__}")
113
+ return result
114
+
115
+
116
+ def extract_base_field(key: str) -> str:
117
+ """Extract the top-level field name from a flattened key.
118
+
119
+ >>> extract_base_field("sources[0].dataset")
120
+ 'sources'
121
+ >>> extract_base_field("names.primary")
122
+ 'names'
123
+ >>> extract_base_field("id")
124
+ 'id'
125
+ """
126
+ if "[" in key:
127
+ return key.split("[")[0]
128
+ if "." in key:
129
+ return key.split(".")[0]
130
+ return key
131
+
132
+
133
+ def order_example_rows(
134
+ flat_rows: list[tuple[str, Any]],
135
+ field_names: list[str],
136
+ ) -> list[tuple[str, Any]]:
137
+ """Order flattened rows by field position in documentation.
138
+
139
+ Sorts by position of base field name in *field_names*.
140
+ Fields with the same base maintain their original order (stable sort).
141
+ Unknown fields sort to end.
142
+ """
143
+ position = {name: i for i, name in enumerate(field_names)}
144
+ sentinel = len(field_names)
145
+
146
+ def sort_key(row: tuple[str, Any]) -> int:
147
+ return position.get(extract_base_field(row[0]), sentinel)
148
+
149
+ return sorted(flat_rows, key=sort_key)
150
+
151
+
152
+ def _structured_fields(value: object) -> list[tuple[str, Any]] | None:
153
+ """Extract named fields from `__slots__`-based types like BBox.
154
+
155
+ Returns a list of `(name, value)` pairs for types that expose
156
+ public properties backed by private slots (`_name` -> `name`).
157
+ Returns `None` for types without this pattern.
158
+ """
159
+ cls = type(value)
160
+ slots = getattr(cls, "__slots__", ())
161
+ if not slots:
162
+ return None
163
+ fields: list[tuple[str, Any]] = []
164
+ for slot in slots:
165
+ attr = slot.lstrip("_")
166
+ if attr != slot and isinstance(getattr(cls, attr, None), property):
167
+ fields.append((attr, getattr(value, attr)))
168
+ return fields if len(fields) >= 2 else None
169
+
170
+
171
+ def _needs_recursion(items: list[Any]) -> bool:
172
+ """Check whether list items contain models or nested lists."""
173
+ return bool(items) and isinstance(items[0], (BaseModel, list))
174
+
175
+
176
+ def _flatten_list_items(key: str, items: list[Any]) -> list[tuple[str, Any]]:
177
+ """Flatten list items, recursing into BaseModel and nested list items.
178
+
179
+ Returns the list as a single leaf value when no items need recursion.
180
+ Pydantic model fields produce homogeneous lists, so the first item's
181
+ type determines the flattening strategy.
182
+ """
183
+ if not _needs_recursion(items):
184
+ return [(key, items)]
185
+ rows: list[tuple[str, Any]] = []
186
+ for i, item in enumerate(items):
187
+ if isinstance(item, BaseModel):
188
+ rows.extend(flatten_model_instance(item, f"{key}[{i}]."))
189
+ elif isinstance(item, list):
190
+ rows.extend(_flatten_list_items(f"{key}[{i}]", item))
191
+ else:
192
+ rows.append((f"{key}[{i}]", item))
193
+ return rows
194
+
195
+
196
+ def flatten_model_instance(
197
+ instance: BaseModel,
198
+ prefix: str = "",
199
+ ) -> list[tuple[str, Any]]:
200
+ """Flatten a Pydantic model instance to dot-notation key-value pairs.
201
+
202
+ Walks model fields recursively. BaseModel values recurse with dot
203
+ notation, lists of BaseModel recurse with bracket notation, and
204
+ everything else (dicts, primitives, None) is a leaf value.
205
+
206
+ Parameters
207
+ ----------
208
+ instance
209
+ The Pydantic model instance to flatten.
210
+ prefix
211
+ Dot-notation prefix accumulated from parent fields.
212
+
213
+ Returns
214
+ -------
215
+ list[tuple[str, Any]]
216
+ Flattened key-value pairs in field declaration order.
217
+ """
218
+ rows: list[tuple[str, Any]] = []
219
+ for field_name, field_info in type(instance).model_fields.items():
220
+ key = resolve_field_alias(field_name, field_info)
221
+ value = getattr(instance, field_name)
222
+ full_key = f"{prefix}{key}" if prefix else key
223
+
224
+ if isinstance(value, BaseModel):
225
+ rows.extend(flatten_model_instance(value, f"{full_key}."))
226
+ elif isinstance(value, list):
227
+ rows.extend(_flatten_list_items(full_key, value))
228
+ elif (sub_fields := _structured_fields(value)) is not None:
229
+ for name, v in sub_fields:
230
+ rows.append((f"{full_key}.{name}", v))
231
+ else:
232
+ rows.append((full_key, value))
233
+ return rows
234
+
235
+
236
+ def augment_missing_fields(
237
+ rows: list[tuple[str, Any]],
238
+ field_names: list[str],
239
+ ) -> list[tuple[str, Any]]:
240
+ """Add (name, None) entries for fields absent from *rows*.
241
+
242
+ Compares base field names (via `extract_base_field`) against
243
+ *field_names*. Fields in *field_names* not represented in *rows*
244
+ are appended as `(name, None)`. Handles dot-notation and bracket-
245
+ notation keys correctly.
246
+
247
+ Parameters
248
+ ----------
249
+ rows
250
+ Flattened key-value pairs from a concrete model instance.
251
+ field_names
252
+ Merged field name list from the union spec.
253
+
254
+ Returns
255
+ -------
256
+ list[tuple[str, Any]]
257
+ Original rows with (name, None) entries appended for absent fields.
258
+ """
259
+ present = {extract_base_field(key) for key, _ in rows}
260
+ augmented = list(rows)
261
+ for name in field_names:
262
+ if name not in present:
263
+ augmented.append((name, None))
264
+ return augmented
265
+
266
+
267
+ def load_examples_from_toml(
268
+ pyproject_path: Path,
269
+ model_name: str,
270
+ ) -> list[dict[str, Any]]:
271
+ """Load `[examples.<model_name>]` from a pyproject.toml file."""
272
+ with pyproject_path.open("rb") as f:
273
+ data = tomllib.load(f)
274
+
275
+ examples: dict[str, list[dict[str, Any]]] = data.get("examples", {})
276
+ return examples.get(model_name, [])
277
+
278
+
279
+ def resolve_pyproject_path(model_class: type) -> Path | None:
280
+ """Find pyproject.toml by walking up from the model's module location."""
281
+ module_name = getattr(model_class, "__module__", None)
282
+ if not module_name:
283
+ return None
284
+
285
+ module = sys.modules.get(module_name)
286
+ if not module:
287
+ return None
288
+
289
+ module_file = getattr(module, "__file__", None)
290
+ if not module_file:
291
+ return None
292
+
293
+ # Walk up from module directory
294
+ current = Path(module_file).parent
295
+ while current != current.parent: # Stop at filesystem root
296
+ pyproject = current / "pyproject.toml"
297
+ if pyproject.exists():
298
+ return pyproject
299
+ current = current.parent
300
+
301
+ return None
302
+
303
+
304
+ def load_examples(
305
+ validation_type: object,
306
+ model_name: str,
307
+ field_names: list[str],
308
+ *,
309
+ pyproject_source: type | None = None,
310
+ model_fields: dict[str, FieldInfo] | None = None,
311
+ ) -> list[ExampleRecord]:
312
+ """Load examples for a model, flattened and ordered by *field_names*.
313
+
314
+ Validates each example against the validation type. Invalid examples
315
+ are skipped with a warning logged. Returns an empty list on any failure
316
+ (missing file, missing section, parse error).
317
+
318
+ Parameters
319
+ ----------
320
+ validation_type : type[BaseModel] | object
321
+ Model class or union alias to validate against.
322
+ model_name : str
323
+ Name of the model to load examples for.
324
+ field_names : list[str]
325
+ List of field names for ordering output.
326
+ pyproject_source : type or None
327
+ Type to use for finding pyproject.toml. If None,
328
+ uses validation_type if it's a class.
329
+ model_fields : dict[str, FieldInfo] or None
330
+ Field info dict for Literal injection. If None, infers
331
+ from validation_type if it's a BaseModel class.
332
+ """
333
+ source_type = pyproject_source if pyproject_source is not None else validation_type
334
+ if not isinstance(source_type, type):
335
+ return []
336
+
337
+ pyproject_path = resolve_pyproject_path(source_type)
338
+ if not pyproject_path:
339
+ return []
340
+
341
+ try:
342
+ raw_examples = load_examples_from_toml(pyproject_path, model_name)
343
+ except (OSError, tomllib.TOMLDecodeError):
344
+ log.debug("Failed to load examples for %s", model_name, exc_info=True)
345
+ return []
346
+
347
+ if not raw_examples:
348
+ return []
349
+
350
+ records = []
351
+ for raw in raw_examples:
352
+ try:
353
+ instance = validate_example(validation_type, raw, model_fields=model_fields)
354
+ except ValidationError as e:
355
+ log.warning(
356
+ "Skipping invalid example for %s in %s: %s",
357
+ model_name,
358
+ pyproject_path,
359
+ e,
360
+ )
361
+ continue
362
+ flat_rows = flatten_model_instance(instance)
363
+ flat_rows = augment_missing_fields(flat_rows, field_names)
364
+ ordered_rows = order_example_rows(flat_rows, field_names)
365
+ records.append(ExampleRecord(rows=ordered_rows))
366
+
367
+ return records