Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
b18104c
Quiet pytest output for dev workflow
mojodna Feb 10, 2026
99b87aa
fix(core): add missing f-prefix to string continuation lines
mojodna Feb 25, 2026
efeb94c
fix(system): use dict instead of Mapping in test util type hints
mojodna Feb 25, 2026
6a11829
fix(cli): discover discriminator fields at runtime
mojodna Feb 25, 2026
113065e
refactor(cli): tighten type analysis contracts
mojodna Feb 25, 2026
8fec5c2
refactor(core,cli): rename ModelKey.class_name to entry_point
mojodna Feb 25, 2026
a203072
feat(codegen): add overture-schema-codegen package
mojodna Feb 25, 2026
568634b
feat(codegen): add type analysis, specs, and type registry
mojodna Feb 25, 2026
2289477
feat(codegen): add extraction modules
mojodna Feb 25, 2026
5f55337
feat(codegen): add constraint description modules
mojodna Feb 25, 2026
fb3e169
feat(codegen): add output layout modules
mojodna Feb 25, 2026
65a1ba4
feat(codegen): add example data to theme pyproject.toml files
mojodna Feb 25, 2026
4a93034
feat(codegen): add markdown renderers
mojodna Feb 25, 2026
6269c39
feat(codegen): add CLI and integration tests
mojodna Feb 25, 2026
7089802
docs(codegen): add design doc, walkthrough, and README
mojodna Feb 25, 2026
00bd616
fix(codegen): store all Literal args in TypeInfo
mojodna Feb 28, 2026
7020671
fix(codegen): track nested list depth in TypeInfo
mojodna Mar 2, 2026
2e2d556
fix(codegen): resolve type name collisions across themes
mojodna Mar 3, 2026
f2d81ab
fix: improve constraint description rendering
mojodna Mar 3, 2026
cccd29f
feat(codegen): generate pages and links for Pydantic built-in types
mojodna Mar 3, 2026
e16700e
fix(codegen): include bbox in examples
mojodna Mar 4, 2026
5b3c925
fix(codegen): stabilize Used By sort order
mojodna Mar 4, 2026
a76f170
fix(codegen): add visual break before constraints
mojodna Mar 4, 2026
3794ae6
fix(codegen): render list[NewType] as list<NewType>
mojodna Mar 4, 2026
50f4aad
fix(codegen): include ellipsis in truncation limit
mojodna Mar 4, 2026
667f9ad
fix(codegen): use repr() for list items in examples
mojodna Mar 4, 2026
9727209
fix(codegen): expand union member trees before collecting types
mojodna Mar 4, 2026
70b56fe
fix(codegen): treat dict-typed fields as leaf values in examples
mojodna Mar 5, 2026
a7e38a9
refactor(codegen): reorganize flat layout into sub-packages
mojodna Mar 5, 2026
4c33e07
feat(codegen): rewrite example pipeline with model instances
mojodna Mar 10, 2026
d8e1b9b
style: single backticks in docstrings
mojodna Mar 10, 2026
46ca5f0
refactor: rename primitive → numeric in extraction layer
mojodna Mar 10, 2026
86d864a
refactor: replace runtime asserts with proper checks
mojodna Mar 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ test-all: uv-sync
@uv run pytest -W error packages/

test: uv-sync
@uv run pytest -W error packages/ -x
@uv run pytest -W error packages/ -x -q --tb=short

test-only:
@uv run pytest -W error packages/ -x
@uv run pytest -W error packages/ -x -q --tb=short

coverage: uv-sync
@uv run pytest packages/ --cov overture.schema --cov-report=term --cov-report=html && open htmlcov/index.html
Expand Down
26 changes: 26 additions & 0 deletions packages/overture-schema-addresses-theme/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,29 @@ testpaths = ["tests"]

[project.entry-points."overture.models"]
"overture:addresses:address" = "overture.schema.addresses:Address"

[[examples.Address]]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❤️

id = "416ab01c-d836-4c4f-aedc-2f30941ce94d"
geometry = "POINT (-176.5637854 -43.9471955)"
country = "NZ"
street = "Tikitiki Hill Road"
number = "54"
version = 1
theme = "addresses"
type = "address"

[examples.Address.bbox]
xmin = -176.56381225585938
xmax = -176.56378173828125
ymin = -43.94719696044922
ymax = -43.94718933105469

[[examples.Address.address_levels]]
value = "Chatham Islands"

[[examples.Address.address_levels]]
value = "Chatham Island"

[[examples.Address.sources]]
property = ""
dataset = "OpenAddresses/LINZ"
168 changes: 168 additions & 0 deletions packages/overture-schema-base-theme/pyproject.toml

Large diffs are not rendered by default.

43 changes: 43 additions & 0 deletions packages/overture-schema-buildings-theme/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,46 @@ packages = ["src/overture"]
[project.entry-points."overture.models"]
"overture:buildings:building" = "overture.schema.buildings:Building"
"overture:buildings:building_part" = "overture.schema.buildings:BuildingPart"

[[examples.Building]]
id = "148f35b1-7bc1-4180-9280-10d39b13883b"
geometry = "POLYGON ((-176.6435004 -43.9938042, -176.6435738 -43.9937107, -176.6437726 -43.9937913, -176.6436992 -43.9938849, -176.6435004 -43.9938042))"
version = 1
has_parts = false
is_underground = false
theme = "buildings"
type = "building"

[examples.Building.bbox]
xmin = -176.643798828125
xmax = -176.64349365234375
ymin = -43.9938850402832
ymax = -43.993709564208984

[[examples.Building.sources]]
property = ""
dataset = "OpenStreetMap"
record_id = "w519166507@1"
update_time = "2017-08-27T21:39:50.000Z"

[[examples.BuildingPart]]
id = "19412d64-51ac-3d6a-ac2f-8a8c8b91bb60"
geometry = "POLYGON ((-73.2462509 -39.8108937, -73.2462755 -39.8109047, -73.246291 -39.8109182, -73.2463022 -39.8109382, -73.2463039 -39.810959, -73.2462962 -39.81098, -73.2462796 -39.8109977, -73.2462674 -39.8110052, -73.2462281 -39.8110153, -73.2461998 -39.811013, -73.2461743 -39.8110034, -73.2461566 -39.8109898, -73.246144 -39.8109702, -73.2461418 -39.8109427, -73.2461511 -39.8109221, -73.2461669 -39.8109066, -73.2461908 -39.8108947, -73.2462184 -39.8108898, -73.2462509 -39.8108937))"
version = 0
level = 3
is_underground = false
building_id = "bd663bd4-1844-4d7d-a400-114de051cf49"
theme = "buildings"
type = "building_part"

[examples.BuildingPart.bbox]
xmin = -73.24630737304688
xmax = -73.24613952636719
ymin = -39.81101608276367
ymax = -39.81088638305664

[[examples.BuildingPart.sources]]
property = ""
dataset = "OpenStreetMap"
record_id = "w223076787@2"
update_time = "2014-10-31T22:55:36.000Z"
Original file line number Diff line number Diff line change
Expand Up @@ -798,7 +798,7 @@ def dump_namespace(
sorted_types = sorted(theme_types[theme], key=lambda x: x[0].type)
for key, model_class in sorted_types:
stdout.print(
f" [bright_black]→[/bright_black] [bold cyan]{key.type}[/bold cyan] [dim magenta]({key.class_name})[/dim magenta]"
f" [bright_black]→[/bright_black] [bold cyan]{key.type}[/bold cyan] [dim magenta]({key.entry_point})[/dim magenta]"
)
docstring = get_model_docstring(model_class)
if docstring:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
from pydantic import BaseModel
from pydantic.fields import FieldInfo

from overture.schema.system.feature import resolve_discriminator_field_name

from .types import ErrorLocation, ValidationErrorDict

# Type aliases for structural tuple elements
Expand All @@ -29,11 +31,23 @@ class UnionMetadata:
nested_unions: dict[str, "UnionMetadata"]


def _extract_literal_value(model: type[BaseModel], field_name: str) -> str | None:
"""Extract the single Literal value from a model field as a string, if present."""
field_info = model.model_fields.get(field_name)
if field_info is None or field_info.annotation is None:
return None
if get_origin(field_info.annotation) is Literal:
args = get_args(field_info.annotation)
return str(args[0]) if args else None
return None


def _process_union_member(
member: Any, # noqa: ANN401
discriminator_to_model: dict[str, type[BaseModel]],
model_name_to_model: dict[str, type[BaseModel]],
nested_unions: dict[str, UnionMetadata],
discriminator_field: str | None = None,
) -> None:
"""Process a single union member, handling nesting recursively.

Expand All @@ -43,6 +57,7 @@ def _process_union_member(
discriminator_to_model: Dict to populate with discriminator value mappings
model_name_to_model: Dict to populate with model name mappings
nested_unions: Dict to populate with nested union metadata
discriminator_field: The discriminator field name from the parent union annotation
"""
member_origin = get_origin(member)

Expand All @@ -63,30 +78,35 @@ def _process_union_member(
nested_metadata = introspect_union(member)
nested_unions[str(member)] = nested_metadata
discriminator_to_model.update(nested_metadata.discriminator_to_model)
# The nested union's discriminator_to_model uses the nested discriminator
# field (e.g. "subtype"). Re-extract using the parent discriminator field
# (e.g. "type") so leaf models are also reachable by the parent's values.
if discriminator_field is not None:
for model in nested_metadata.model_name_to_model.values():
value = _extract_literal_value(model, discriminator_field)
if value is not None:
discriminator_to_model[value] = model
return

# Unwrap Annotated to get the actual type (e.g., Annotated[Building, Tag('building')])
# and process it recursively
_process_union_member(
member_args[0], discriminator_to_model, model_name_to_model, nested_unions
member_args[0],
discriminator_to_model,
model_name_to_model,
nested_unions,
discriminator_field,
)
return

# Case 2: BaseModel class
if inspect.isclass(member) and issubclass(member, BaseModel):
model_name_to_model[member.__name__] = member

# Extract discriminator values from known discriminator fields only
# Restrict to known discriminator names to avoid false positives from other Literal fields
discriminator_fields = ("type", "theme", "subtype")
for field_name, field_info in member.model_fields.items():
if field_name not in discriminator_fields:
continue
annotation = field_info.annotation
if get_origin(annotation) is Literal:
literal_args = get_args(annotation)
if literal_args:
discriminator_to_model[literal_args[0]] = member
if discriminator_field is not None:
value = _extract_literal_value(member, discriminator_field)
if value is not None:
discriminator_to_model[value] = member


def introspect_union(union_type: Any) -> UnionMetadata: # noqa: ANN401
Expand Down Expand Up @@ -163,9 +183,9 @@ def introspect_union(union_type: Any) -> UnionMetadata: # noqa: ANN401
if isinstance(metadata, FieldInfo) and hasattr(
metadata, "discriminator"
):
disc = metadata.discriminator
# discriminator can be a string or Discriminator object
discriminator_field = str(disc) if disc is not None else None
discriminator_field = resolve_discriminator_field_name(
metadata.discriminator
)
break

# Get union members
Expand All @@ -183,7 +203,11 @@ def introspect_union(union_type: Any) -> UnionMetadata: # noqa: ANN401
# Process each union member
for member in union_members:
_process_union_member(
member, discriminator_to_model, model_name_to_model, nested_unions
member,
discriminator_to_model,
model_name_to_model,
nested_unions,
discriminator_field,
)

return UnionMetadata(
Expand Down
92 changes: 55 additions & 37 deletions packages/overture-schema-cli/tests/test_type_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,9 @@ class ModelB(BaseModel):

UnionType = Annotated[ModelA | ModelB, Field(discriminator="type")]

# Test simple discriminated union error path
loc = ("a", "required_a")
metadata = introspect_union(UnionType)
structural = create_structural_tuple(loc, metadata)
print(f"\nloc: {loc}")
print(f"structural: {structural}")
assert len(structural) == len(loc)
# First element should be discriminator, second should be field
assert structural == ("discriminator", "field")

def test_mixed_union_structural_tuple(self) -> None:
Expand All @@ -56,17 +51,11 @@ class Sources(BaseModel):
# Test discriminated side
loc1 = ("tagged-union[ModelA]", "a", "required_a")
structural1 = create_structural_tuple(loc1, metadata)
print("\nDiscriminated side:")
print(f"loc: {loc1}")
print(f"structural: {structural1}")
assert structural1 == ("union", "discriminator", "field")

# Test non-discriminated side
loc2 = ("Sources", "datasets")
structural2 = create_structural_tuple(loc2, metadata)
print("\nNon-discriminated side:")
print(f"loc: {loc2}")
print(f"structural: {structural2}")
assert structural2 == ("model", "field")

def test_list_context_structural_tuple(self) -> None:
Expand All @@ -78,13 +67,9 @@ class ModelA(BaseModel):

UnionType = Annotated[ModelA, Field(discriminator="type")]

# Test list context
loc = (1, "a", "required_a")
metadata = introspect_union(list[UnionType])
structural = create_structural_tuple(loc, metadata)
print("\nList context:")
print(f"loc: {loc}")
print(f"structural: {structural}")
assert structural == ("list_index", "discriminator", "field")

def test_nested_discriminated_structural_tuple(self) -> None:
Expand Down Expand Up @@ -114,13 +99,9 @@ class Sources(BaseModel):
FeatureUnion = Annotated[Building | SegmentUnion, Field(discriminator="type")]
MixedUnion = FeatureUnion | Sources

# Test nested discriminator path (type=segment, subtype=road)
loc = ("tagged-union[SegmentUnion]", "segment", "road", "road_class")
metadata = introspect_union(MixedUnion)
structural = create_structural_tuple(loc, metadata)
print("\nNested discriminated:")
print(f"loc: {loc}")
print(f"structural: {structural}")
assert structural == ("union", "discriminator", "discriminator", "field")


Expand Down Expand Up @@ -253,34 +234,71 @@ class ModelA(BaseModel):
assert metadata.discriminator_field == "type"
assert "a" in metadata.discriminator_to_model

@pytest.mark.parametrize(
"literal_value,expected_in_mapping",
[
pytest.param("building", True, id="literal_building"),
pytest.param("place", True, id="literal_place"),
pytest.param("nonexistent", False, id="not_present"),
],
)
def test_introspect_extracts_all_literals(
self, literal_value: str, expected_in_mapping: bool
) -> None:
"""Test that introspect_union extracts all Literal field values."""

class TestDiscriminatorDiscovery:
"""Tests for runtime discriminator field discovery (not hardcoded)."""

def test_nonstandard_discriminator_field_name(self) -> None:
"""Discriminator field not named type/theme/subtype is discovered at runtime."""

class Cat(BaseModel):
kind: Literal["cat"]
indoor: bool

class Dog(BaseModel):
kind: Literal["dog"]
breed: str

UnionType = Annotated[Cat | Dog, Field(discriminator="kind")]
metadata = introspect_union(UnionType)

assert metadata.is_discriminated is True
assert metadata.discriminator_field == "kind"
assert metadata.discriminator_to_model["cat"] == Cat
assert metadata.discriminator_to_model["dog"] == Dog

def test_non_discriminator_literal_fields_excluded(self) -> None:
"""Literal fields that aren't the discriminator are not in the mapping."""

class Building(BaseModel):
type: Literal["building"]
subtype: Literal["residential"]
status: Literal["active"]

class Place(BaseModel):
type: Literal["place"]
category: Literal["restaurant"]
status: Literal["active"]

UnionType = Annotated[Building | Place, Field(discriminator="type")]
metadata = introspect_union(UnionType)

if expected_in_mapping:
assert literal_value in metadata.discriminator_to_model
else:
assert literal_value not in metadata.discriminator_to_model
assert "building" in metadata.discriminator_to_model
assert "place" in metadata.discriminator_to_model
assert "active" not in metadata.discriminator_to_model

def test_callable_discriminator_extracts_field_name(self) -> None:
"""Callable discriminators (Feature.field_discriminator) are supported."""
from pydantic import Discriminator

class ModelA(BaseModel):
kind: Literal["a"]

class ModelB(BaseModel):
kind: Literal["b"]

def get_kind(data: object) -> str | None:
return data.get("kind") if isinstance(data, dict) else None

get_kind._field_name = "kind" # type: ignore[attr-defined]

UnionType = Annotated[
ModelA | ModelB, Field(discriminator=Discriminator(get_kind))
]
metadata = introspect_union(UnionType)

assert metadata.is_discriminated is True
assert metadata.discriminator_field == "kind"
assert metadata.discriminator_to_model["a"] == ModelA
assert metadata.discriminator_to_model["b"] == ModelB


class TestStructuralTupleCaching:
Expand Down
Loading
Loading