Code Generator Design
July 23, 2026 ยท View on GitHub
Code generator that produces documentation and code from Overture Maps Pydantic schema definitions.
Problem
Overture Maps schema definitions live in Pydantic models across theme packages. Each model carries type annotations, field constraints, docstrings, and relationships (inheritance, composition, discriminated unions). Generating documentation or code from these models requires introspecting all of that structure and rendering it into output formats.
Pydantic's internal representation is JSON-schema-oriented and discards the vocabulary
the code generator needs to preserve. model_json_schema() flattens FeatureVersion (a
NewType wrapping int32 wrapping Annotated[int, Field(ge=0, le=$2^{31}$-1)]) to {"type": "integer", "minimum": 0} -- the NewType names FeatureVersion and int32 are gone,
custom constraint classes (GeometryTypeConstraint, UniqueItemsConstraint) are gone,
Python class references are gone, and constraint provenance (which NewType contributed
which bound) is gone. FieldInfo.annotation gives the raw annotation, but Pydantic does
not unwrap NewType chains or track multi-depth constraint provenance.
The schema's domain language -- custom primitives (int32, float64), semantic
NewTypes (FeatureVersion, Sources), and custom constraint classes -- needs to
survive extraction intact. A single field annotation like NewType("Foo", Annotated[list[SomeModel] | None, Field(ge=0)]) encodes optionality, collection type,
element type, constraints, and semantic naming in nested Python typing constructs. Type
definitions regularly nest Annotated inside NewType inside Annotated --
FeatureVersion = NewType("FeatureVersion", int32) where int32 = NewType("int32", Annotated[int, Field(ge=...)]) -- and constraints at each depth need to be tagged with
the NewType that contributed them.
The code generator solves this by extracting type information once into a tree-shaped
FieldShape IR, then passing that to renderers that produce output without touching
Python's type system.
Inputs and Outputs
Inputs: Pydantic BaseModel subclasses discovered via overture.models entry
points, plus example data from theme pyproject.toml files. Examples serve two
purposes: rendered examples in documentation pages, and a starting point for generating
tests that verify behavior of generated code.
Outputs:
- Markdown documentation pages with field tables, cross-page links, constraint descriptions, and examples.
- PySpark validation modules: per-model expression builders, StructType schemas, a feature registry, and generated conformance test modules.
Architecture
Four layers with strict downward imports -- no layer references the one above it:
Rendering Output formatting, all presentation decisions
^
Output Layout What to generate, where it goes, how outputs link
^
Extraction FieldShape, FieldSpec, RecordSpec, EnumSpec, ...
^
Discovery discover_models() from overture-schema-common
Each output format has its own pipeline module that orchestrates without I/O:
markdown/pipeline.pyexpands feature trees, collects supplementary types, builds placement registries, computes reverse references, and calls renderers -- returningRenderedPageobjects.pyspark/pipeline.pyexpands feature trees, builds checks and schemas, renders expression modules and test modules -- returningGeneratedModuleobjects.
The CLI (cli.py) is a thin Click wrapper that dispatches to the appropriate pipeline
and writes files to disk.
graph TD
subgraph Discovery
DM["discover_models()"]
end
DM -->|"dict[ModelKey, type]"| EX
subgraph Extraction
EX["extraction/type_analyzer / extractors"]
end
EX -->|"ModelSpec[]"| OL
EX -->|"ModelSpec[]"| PS
subgraph "Output Layout (Markdown)"
OL["layout/type_collection"]
OL -->|"SupplementarySpec{}"| PA["markdown/path_assignment"]
PA -->|"dict[str, Path]"| LC["markdown/link_computation"]
RR["markdown/reverse_references"]
end
subgraph "Markdown Rendering"
R["markdown/renderer"]
TR["extraction/type_registry"] -.->|"type name resolution"| R
end
subgraph "Markdown Orchestration"
MP["markdown/pipeline"]
end
OL --> MP
LC --> MP
RR --> MP
MP --> R
R -->|"RenderedPage[]"| MP
subgraph "PySpark Pipeline"
PS["pyspark/pipeline"]
CD["constraint_dispatch"] -->|"ExpressionDescriptor"| CB
CB["check_builder"] -->|"Check, ModelCheck"| PR
SB["schema_builder"] -->|"SchemaField[]"| PR
CB -->|"Check, ModelCheck"| PTR
SY["test_data/"] -->|"BASE_ROW, scaffold, invalid_value"| PTR
PR["renderer"]
PTR["test_renderer"]
end
PS --> CD
PS --> CB
PS --> SB
PS --> PR
PS --> PTR
MP -->|"list[RenderedPage]"| CLI["cli.py โ disk"]
PS -->|"list[GeneratedModule]"| CLI
Extraction
analyze_type -- recursive type unwrapping
analyze_type(annotation) recurses through a Python type annotation, peeling one layer
per call frame via the internal _unwrap function:
- NewType: Constructs
_NewTypeCtxwith the NewType's name, recurses into__supertype__, then wraps the result inNewTypeShape._erase_inner_newtypesstrips every innerNewTypeShapereached throughArrayOflayers, so each spine keeps only its outermostNewTypeShape(inner NewType names survive on the terminalPrimitive.base_type). - Annotated: Collects constraints from metadata as
ConstraintSourceobjects, each tagged with the active_NewTypeCtx. ExtractsField.descriptionwhen present. Recurses into the inner annotation, then attaches constraints to the result viaattach_constraints, which prepends them to the outermost structural layer. - Union: Delegates to
_peel_union, which filtersNone(marks optional),Sentinel, andLiteralsentinel arms. Multiple concreteBaseModelarms invokeunion_resolver; a single arm continues with_unwrap. - list / dict:
list[X]recurses intoXand wraps inArrayOf. Nested lists produce nestedArrayOfinstances -- no numeric depth counter.dict[K, V]recurses for key and value independently and returnsMapOf. - Terminal: Classifies as
Primitive,LiteralScalar,AnyScalar,ModelRef, orUnionRef.
The result is tuple[FieldShape, bool, str | None] -- the structural shape describing
the type as a nested tree, whether the field accepts None, and the first
FieldInfo.description found during unwrapping. FieldShape is a discriminated union
of eight variants (Primitive, LiteralScalar, AnyScalar, ModelRef, UnionRef,
ArrayOf, MapOf, NewTypeShape) nested to describe arbitrary collection and NewType
wrapping.
Constraints from each Annotated layer attach to the shape layer they annotate --
attach_constraints walks past any NewTypeShape wrappers to prepend constraints on
the first ArrayOf, MapOf, or scalar node. This means array-level and element-level
constraints land on structurally distinct nodes without any numeric bookkeeping.
Extractors by domain
Extraction is split by entity kind:
extraction/model_extraction.py: Pydantic model ->RecordSpec(fields in MRO-aware documentation order, alias-resolved names, model-level constraints)extraction/enum_extraction.py: Enum class ->EnumSpecextraction/newtype_extraction.py: NewType ->NewTypeSpecextraction/union_extraction.py: Discriminated union alias ->UnionSpecextraction/numeric_extraction.py: Numeric types ->NumericSpecextraction/pydantic_extraction.py: Pydantic built-in type ->PydanticTypeSpec
Each calls analyze_type() for field types. extract_model recurses into sub-models
and sub-unions during extraction, building ModelRef/UnionRef terminals with their
specs resolved. A shared cache and cycle detection (starts_cycle=True) prevent
infinite recursion and duplicate extraction.
Unions and ModelSpec
Discriminated unions (e.g. Segment = Annotated[Union[RoadSegment, ...], Discriminator(...)]) are type aliases, not classes. UnionSpec captures the union
structure: member types, discriminator field and value mapping, and a merged field list.
Fields shared across all variants appear once; fields present in some variants are
wrapped in AnnotatedField with variant_sources indicating which members contribute
them. The common base class is identified so shared fields can be deduplicated.
ModelSpec is a type alias RecordSpec | UnionSpec. Code that operates on "any
top-level model" -- supplementary type collection, rendering dispatch -- uses
ModelSpec so records and unions flow through the same pipeline. Consumers
narrow with isinstance when arm-specific attributes are needed.
Constraints
Field-level constraints come from Annotated metadata -- Ge, Le, Interval, custom
constraint classes. Each is tagged with the NewType that contributed it via
ConstraintSource.
Model-level constraints come from decorators (@require_any_of, @require_if,
@forbid_if) and are extracted via ModelConstraint.get_model_constraints().
Output Layout
Determines the full set of artifacts to generate, where each lives on disk, and how they reference each other.
Supplementary type collection
collect_all_supplementary_types() walks the field trees of all feature specs to extract
the supplementary types that need their own output: enums, semantic NewTypes, sub-models,
and Pydantic built-in types (HttpUrl, EmailStr). Returns dict[TypeIdentity, SupplementarySpec], where SupplementarySpec = EnumSpec | NewTypeSpec | RecordSpec | PydanticTypeSpec. TypeIdentity pairs a unique Python object with its display name so
registry lookups remain stable when two distinct types share a name.
Module-mirrored output paths
Output paths derive from the source Python module path relative to a computed schema
root (compute_schema_root() finds the longest common prefix of all entry point module
paths). compute_output_dir() maps a Python module to an output directory. Feature
models land in their module-derived directory. Supplementary types land at their own
module-derived path, with a types/ segment inserted when they fall under a feature
directory.
Link computation
LinkContext carries the current output's path and the full dict[TypeIdentity, PurePosixPath] registry. When a renderer formats a type reference, it looks up the
target by TypeIdentity and computes a relative path. Links exist only for types with
registry entries, avoiding broken references to ungenerated outputs.
Reverse references
compute_reverse_references() walks feature specs to build dict[TypeIdentity, list[UsedByEntry]] for "Used By" sections.
Rendering
Renderers consume specs and own all presentation decisions -- formatting, casing, link syntax. Extraction and the type registry carry no presentation logic.
Type registry
extraction/type_registry.py maps type names to per-target string representations via
TypeMapping. resolve_type_name() looks up the registry and returns the display
string for a given target. is_semantic_newtype() distinguishes NewTypes that deserve
their own identity (like FeatureVersion wrapping int32) from pass-through aliases
to registered primitives.
Markdown renderer
Jinja2 templates for feature, enum, NewType, numeric, and geometry pages.
render_model() walks each field's FieldShape tree and expands ModelRef
terminals inline with dot-notation (e.g., sources[].dataset), stopping at
ModelRef.starts_cycle. format_type() in markdown/type_format.py converts a
FieldShape into link-aware display strings using LinkContext.
Constraint prose
extraction/field_constraints.py and extraction/model_constraints.py convert
constraint objects into human-readable descriptions. Field constraints produce inline
text. Model constraints produce section-level descriptions and per-field notes, with
consolidation for related conditional constraints (require_if / forbid_if grouped by
trigger).
Example loader
Loads example data from theme pyproject.toml files, validates against Pydantic models,
and flattens to dot-notation rows for display in feature pages. Also provides a starting
point for generated test data.
validate_example returns a Pydantic model instance. flatten_model_instance walks the
instance recursively using isinstance(value, BaseModel) to distinguish model fields
(recurse with dot notation) from dict fields (keep as leaf values). This eliminates the
need for external schema information -- the model instance itself encodes the type
structure. augment_missing_fields appends (name, None) entries for union cross-arm
fields absent from the concrete variant instance.
PySpark Pipeline
The PySpark codegen transforms extracted ModelSpec trees into validation expression
modules and generated conformance test modules. pyspark/pipeline.py exposes
generate_pyspark_module (single spec) and generate_pyspark_modules (all specs).
Constraint Dispatch
pyspark/constraint_dispatch.py maps constraint objects to expression descriptors.
Four dispatch mechanisms:
-
dispatch_constraint-- field constraints (bounds, min/max length, pattern, stripped, geometry type, unique items, JSON pointer). ReturnsExpressionDescriptorwith function name, args, kwargs. Returns None for skipped constraints (Reference, Strict). -
dispatch_newtype-- NewType-level overrides:LinearlyReferencedRange-> three range checks.CountryCodeAlpha2andRegionCodedecompose normally via theirPatternConstraintsubclasses and return None here. -
dispatch_base_type-- base-type overrides for types with noAnnotatedconstraints:HttpUrl->check_url_format+check_url_length,EmailStr->check_email,BBox->check_bbox_completeness. -
dispatch_model_constraint-- model constraints:RequireAnyOfConstraint,RadioGroupConstraint,RequireIfConstraint,ForbidIfConstraint,MinFieldsSetConstraint. ReturnsModelConstraintDescriptor. Returns None forNoExtraFieldsConstraint.
Check Builder
pyspark/check_builder.py walks FieldSpec trees to produce Check and ModelCheck
IR. Resolves the mapping from nested field paths to PySpark iteration patterns,
producing a FieldPath (Direct or Iterated) on each Check. A Direct locates
a value reached with no iteration; an Iterated mixes struct segments with one or
more iterating segments (ArraySegment for a list, MapSegment for a dict[K, V] projected to its keys or values); at render time each iterating segment --
named or anonymous alike -- becomes its own render frame (one array_check/
map_*_check call). A container nested directly inside another with no field
name between them (list[list[X]], dict[K, list[X]], list[dict], a map
projected inside an array element, an array inside a map value) is an
anonymous iterating segment (is_anonymous) -- its render frame carries no
struct descent from the previous one. promote_terminal performs the entry
into a container at the point the walker reaches it, replacing a struct
terminal with a named iterating segment or appending an anonymous one when the
terminal already iterates:
- Scalar field --
Direct; renders asF.col("field") - Top-level array or map --
Iteratedwith one named iterating segment; renders asarray_check("field", lambda el: ...)ormap_keys_check/map_values_check("field", lambda k_or_v: ...) - Field inside an array or map element -- struct segments after the iterating
segment; renders as
array_check("col", lambda el: el["field"]) list[list[X]],dict[K, list[X]], a nested map, or a map projected inside an array -- an anonymous or named second iterating segment adds another render frame, rendered by folding the flattening variant (nested_array_check,nested_map_{keys,values}_check) around the inner render frame's helper- Multiple nesting levels -- chained flattening helpers with struct segments navigating between render frames
Union handling: variant-specific fields are annotated with ColumnGuard or
ElementGuard discriminator gates. Check.guards is AND-composed at render time.
Nested unions (a union field within a union) produce a ColumnGuard and an
ElementGuard in sequence on the same check.
COLUMN_LEVEL_FUNCTIONS (frozenset) selects checks that split into a
separate Check; _COLUMN_LEVEL_SUFFIXES (dict) supplies the label
suffix for each: check_required (no suffix), check_array_min_length
(_min_length), check_array_max_length (_max_length),
check_struct_unique (_unique).
Schema Builder
pyspark/schema_builder.py converts FieldSpec trees to SchemaField lists for
StructType source generation. Maps types to Spark type expressions via the type registry.
SHARED_TYPE_REFS reserves a few base-type names for _schema_structs.py constants
when the codegen cannot walk the type -- currently just BBox -> BBOX_STRUCT (BBox
is a plain class, not a Pydantic BaseModel). Pydantic models are inlined into the
StructType expression. Union fields are deduplicated by name with type widening (the
wider Spark numeric type wins).
Renderer
pyspark/renderer.py emits per-model Python modules containing:
- Private
_fieldname_check()functions returningCheck(field=, name=, expr=, shape=, root_field=) - A public
<model>_checks() -> list[Check]function calling all of them - A per-model
MODELNAME_SCHEMAStructType constant (e.g.ADDRESS_SCHEMA,SEGMENT_SCHEMA) - An
ENTRY_POINTstring, aPARTITIONSdict describing the feature's Hive partition layout (empty when not partitioned), and aMODEL_VALIDATIONconstant pairing the schema and checks
The registry is not generated. _registry.py lives hand-written in the
overture-schema-pyspark package and walks the expressions.generated namespace at
import time, collecting every module that exposes ENTRY_POINT and MODEL_VALIDATION
into a dict[str, ModelValidation]. Modules that also expose PARTITIONS populate a
parallel partition map keyed by entry point.
Expression rendering handles scalar expressions, array_check/nested_array_check chains,
variant gating (F.when(discriminator.isin(...))), nullable parent gating
(F.when(gate.isNotNull(), ...)), and nested lambda variable naming for deep nesting.
Output is formatted with ruff.
Test Renderer
pyspark/test_renderer.py emits per-model pytest modules containing:
BASE_ROW_SPARSE/BASE_ROW_POPULATED-- valid synthetic rowsSCENARIOS: list[Scenario]-- generated test cases, each carrying amutatecallable that produces an invalid row from a merged base- Fixtures:
checks,sparse_results,populated_results - Tests:
test_baseline_sparse,test_baseline_populated,test_scenario_sparse,test_scenario_populated(parametrized). Schema coverage runs insiderun_validation_pipelineviaassert_schema_covers_checks, not in a separate test.
Union specs with multiple discriminator arms produce one test module per arm.
Test Data Generator
pyspark/test_data/ is a subpackage with three modules:
base_row.py--generate_base_row/generate_populated_rowproduce sparse (required only) and fully populated valid rows from aModelSpec. Consults field constraints to produce constraint-satisfying values (country codes, geometry WKT, bounds-respecting numbers).generate_arm_rows/generate_populated_arm_rowsproduce one row per discriminator arm for union specs.scaffold.py--generate_scaffold/generate_model_scaffoldbuild sparse dicts that provide nested structure (optional structs, arrays) needed for test scenarios.invalid_value.py--invalid_valueproduces a concrete value that violates each check function.
Known Semantic Gaps
PySpark validation diverges from Pydantic validation in two documented areas:
-
UniqueItemsConstraintuses Spark'sarray_distinct, which compares whole elements with structural equality (struct- and nested-array-aware) on the raw stored values. Pydantic compares normalized Python objects -- e.g.,list[HttpUrl]is compared after URL normalization (trailing slash, lowercased scheme/host) -- so it catches duplicates that differ only in normalization. The PySpark check catches exact duplicates only. -
require_any_ofchecksisNotNullas a proxy for Pydantic'smodel_fields_set. Parquet has no equivalent of "explicitly provided";isNotNullis stricter (it rejects fields explicitly set to null).
Extension Points
Adding a new output target: Add a column to TypeMapping in
extraction/type_registry.py for type-name resolution. Write a pipeline module that
consumes ModelSpec trees and a renderer that produces output. The extraction layer is
target-independent. Register the format in cli.py.
Adding a new type kind: Add a variant to FieldShape in extraction/field.py.
Handle it in the terminal classification of analyze_type(). Add an extraction function
and spec dataclass if needed. Update extraction/field_walk.py traversal helpers and
all renderers to handle the new variant.
Adding a new constraint type: _unwrap collects it automatically (any Annotated
metadata becomes a ConstraintSource). Add a case to
describe_field_constraint() for prose and to dispatch_constraint() for PySpark
expression mapping.
Adding a new PySpark check function: Add a case in dispatch_constraint,
dispatch_newtype, or dispatch_base_type in constraint_dispatch.py. Add an
invalid_value case in test_data/invalid_value.py for test generation. The check builder and
renderer handle the new descriptor automatically.