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