From 00b665cf04fed91be2b427f519336414dad5d264 Mon Sep 17 00:00:00 2001 From: Seth Fitzsimmons Date: Tue, 28 Jul 2026 14:13:40 -0700 Subject: [PATCH] refactor(schema): extract overture-schema-validation package validate() and validate_json() lived in the overture-schema umbrella's namespace __init__ (src/overture/schema/__init__.py), so the shared overture.schema namespace root carried real code owned by one distribution. Move them into their own overture.schema.validation module in a new overture-schema-validation package; the umbrella now depends on it and its namespace __init__ is a bare pkgutil shim. This separates the aggregator role from the validation API and prepares the namespace root to become code-free -- a precondition for the uv_build migration, where a distribution cannot own code at a shared namespace root. Signed-off-by: Seth Fitzsimmons --- .../overture-schema-validation/pyproject.toml | 30 ++++ .../src/overture/__init__.py | 1 + .../src/overture/schema/__init__.py | 1 + .../overture/schema/validation/__init__.py | 169 +++++++++++++++++ .../src/overture/schema/validation/py.typed | 0 packages/overture-schema/pyproject.toml | 2 + .../src/overture/schema/__init__.py | 170 ------------------ .../tests/test_schema_validation.py | 2 +- uv.lock | 20 +++ 9 files changed, 224 insertions(+), 171 deletions(-) create mode 100644 packages/overture-schema-validation/pyproject.toml create mode 100644 packages/overture-schema-validation/src/overture/__init__.py create mode 100644 packages/overture-schema-validation/src/overture/schema/__init__.py create mode 100644 packages/overture-schema-validation/src/overture/schema/validation/__init__.py create mode 100644 packages/overture-schema-validation/src/overture/schema/validation/py.typed diff --git a/packages/overture-schema-validation/pyproject.toml b/packages/overture-schema-validation/pyproject.toml new file mode 100644 index 000000000..fe4700941 --- /dev/null +++ b/packages/overture-schema-validation/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +maintainers = [ + {name = "Overture Maps Schema Working Group"}, +] +name = "overture-schema-validation" +version = "0.1.1" +description = "Validation helpers for the union of all discovered Overture models" +requires-python = ">=3.10" +license = "MIT" +dependencies = [ + "overture-schema-common", + "overture-schema-system", + "pydantic>=2.12.0", +] + +[project.urls] +Homepage = "https://overturemaps.org" +Source = "https://github.com/OvertureMaps/schema" +Issues = "https://github.com/OvertureMaps/schema/issues" + +[tool.uv.sources] +overture-schema-common = { workspace = true } +overture-schema-system = { workspace = true } + +[tool.hatch.build.targets.wheel] +packages = ["src/overture"] diff --git a/packages/overture-schema-validation/src/overture/__init__.py b/packages/overture-schema-validation/src/overture/__init__.py new file mode 100644 index 000000000..8db66d3d0 --- /dev/null +++ b/packages/overture-schema-validation/src/overture/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/packages/overture-schema-validation/src/overture/schema/__init__.py b/packages/overture-schema-validation/src/overture/schema/__init__.py new file mode 100644 index 000000000..8db66d3d0 --- /dev/null +++ b/packages/overture-schema-validation/src/overture/schema/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/packages/overture-schema-validation/src/overture/schema/validation/__init__.py b/packages/overture-schema-validation/src/overture/schema/validation/__init__.py new file mode 100644 index 000000000..57236d43a --- /dev/null +++ b/packages/overture-schema-validation/src/overture/schema/validation/__init__.py @@ -0,0 +1,169 @@ +from collections.abc import Generator +from functools import reduce +from operator import or_ +from types import UnionType +from typing import Annotated, Any, Literal, cast, get_args, get_origin + +from pydantic import BaseModel, Field, Tag, TypeAdapter + +from overture.schema.common import OvertureFeature +from overture.schema.system.discovery import discover_models +from overture.schema.system.feature import Feature + + +def validate(data: object) -> BaseModel: + """ + Validate a Python object, which can be a dictionary or model instance, using the union of all + discovered Overture models. + + Parameters + ---------- + data : object + Python object to validate against the model. + + Returns + ------- + BaseModel + Validated model class + + Raises + ------ + ValidationError + If `data` is not valid according to one of the discovered Overture models + """ + tap = _union_type_adapter() + + return cast(BaseModel, tap.validate_python(data)) + + +def validate_json(json_data: str | bytes | bytearray) -> BaseModel: + """ + Validate JSON data using the union of all discovered Overture models. + + Parameters + ---------- + data : str | bytes | bytearray + JSON data to validate + + Returns + ------- + BaseModel + Validated model class + + Raises + ------ + ValidationError + If `json_data` is not valid according to one of the discovered Overture models + """ + tap = _union_type_adapter() + + return cast(BaseModel, tap.validate_json(json_data)) + + +__all__ = [ + "validate", + "validate_json", +] + + +def _union_type_adapter() -> TypeAdapter: + """ + Return a Pydantic type adapter that can validate the union of all models discovered using entry + points. + """ + models = discover_models() + if not models: + raise RuntimeError("no registered models found via entry points") + + discriminated_models: tuple[type[OvertureFeature], ...] = tuple( + cast(type[OvertureFeature], m) for m in models.values() if _can_discriminate(m) + ) + discriminated_union: UnionType | None = _discriminated_union(discriminated_models) + + non_discriminated_models: Generator[type[BaseModel], None, None] = ( + m for m in models.values() if not _can_discriminate(m) + ) + non_discriminated_union: UnionType | None = reduce( + or_, non_discriminated_models, None + ) + + if discriminated_union and non_discriminated_union: + model_union = discriminated_union | non_discriminated_union + elif discriminated_union: + model_union = discriminated_union + elif non_discriminated_union: + model_union = non_discriminated_union + else: + raise RuntimeError("logic error: unreachable code") + + return TypeAdapter(model_union) + + +def _discriminated_union( + feature_classes: tuple[type[OvertureFeature], ...], +) -> Any: # noqa: ANN401 + """ + Create a discriminated union of the Overture features since they can be discriminated on the + `type` field. This is just a performance optimization, and the union will work even if no models + are discriminated. + """ + if not feature_classes: + return None + else: + return Annotated[ + reduce( + or_, + ( + Annotated[f, Tag(cast(str, _typeliteral(f)))] + for f in feature_classes + ), + ), + Field(discriminator=Feature.field_discriminator("type", *feature_classes)), + ] + + +def _can_discriminate(model_class: object) -> bool: + """ + Return true if given value can participate in a discriminated union on the `type` field because + it is an Overture feature with where the `type` field has a single literal value. + """ + return ( + isinstance(model_class, type) + and issubclass(model_class, OvertureFeature) + and _typeliteral(cast(type[OvertureFeature], model_class)) is not None + ) + + +def _typeliteral(feature_class: type[OvertureFeature]) -> object: + """ + Return the literal value of the Overture Feature model's `type` field, if it has one, or `None` + if it does not. + + Parameters + ---------- + feature_class : type[OvertureFeature] + Overture feature model class + + Returns + ------- + object + The literal constrained value of the model class' `type` field, or `None` if the `type` + field does not have a literal value + + Raises + ------ + TypeError + If the `type` field is constrained to `Literal[None]`, as this is absurd + """ + type_type = feature_class.model_fields["type"].annotation + while get_origin(type_type) is Annotated: + type_type = get_args(Annotated)[0] + if get_origin(type_type) is not Literal: + return None + literal = get_args(type_type)[0] + if literal is None: + raise TypeError( + f"literal value of `type` field for `{OvertureFeature.__name__}` class " + f"`{feature_class.__name__}` is constrained to `None`" + ) + return literal diff --git a/packages/overture-schema-validation/src/overture/schema/validation/py.typed b/packages/overture-schema-validation/src/overture/schema/validation/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/packages/overture-schema/pyproject.toml b/packages/overture-schema/pyproject.toml index bb748c46e..aa5857206 100644 --- a/packages/overture-schema/pyproject.toml +++ b/packages/overture-schema/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "overture-schema-places-theme", "overture-schema-transportation-theme", "overture-schema-common", + "overture-schema-validation", "pydantic>=2.12.0", "pyyaml>=6.0.2", "overture-schema-cli", @@ -32,6 +33,7 @@ overture-schema-base-theme = { workspace = true } overture-schema-buildings-theme = { workspace = true } overture-schema-cli = { workspace = true } overture-schema-common = { workspace = true } +overture-schema-validation = { workspace = true } overture-schema-divisions-theme = { workspace = true } overture-schema-places-theme = { workspace = true } overture-schema-transportation-theme = { workspace = true } diff --git a/packages/overture-schema/src/overture/schema/__init__.py b/packages/overture-schema/src/overture/schema/__init__.py index 3728b389a..8db66d3d0 100644 --- a/packages/overture-schema/src/overture/schema/__init__.py +++ b/packages/overture-schema/src/overture/schema/__init__.py @@ -1,171 +1 @@ __path__ = __import__("pkgutil").extend_path(__path__, __name__) - -from collections.abc import Generator -from functools import reduce -from operator import or_ -from types import UnionType -from typing import Annotated, Any, Literal, cast, get_args, get_origin - -from pydantic import BaseModel, Field, Tag, TypeAdapter - -from overture.schema.common import OvertureFeature -from overture.schema.system.discovery import discover_models -from overture.schema.system.feature import Feature - - -def validate(data: object) -> BaseModel: - """ - Validate a Python object, which can be a dictionary or model instance, using the union of all - discovered Overture models. - - Parameters - ---------- - data : object - Python object to validate against the model. - - Returns - ------- - BaseModel - Validated model class - - Raises - ------ - ValidationError - If `data` is not valid according to one of the discovered Overture models - """ - tap = _union_type_adapter() - - return cast(BaseModel, tap.validate_python(data)) - - -def validate_json(json_data: str | bytes | bytearray) -> BaseModel: - """ - Validate JSON data using the union of all discovered Overture models. - - Parameters - ---------- - data : str | bytes | bytearray - JSON data to validate - - Returns - ------- - BaseModel - Validated model class - - Raises - ------ - ValidationError - If `json_data` is not valid according to one of the discovered Overture models - """ - tap = _union_type_adapter() - - return cast(BaseModel, tap.validate_json(json_data)) - - -__all__ = [ - "validate", - "validate_json", -] - - -def _union_type_adapter() -> TypeAdapter: - """ - Return a Pydantic type adapter that can validate the union of all models discovered using entry - points. - """ - models = discover_models() - if not models: - raise RuntimeError("no registered models found via entry points") - - discriminated_models: tuple[type[OvertureFeature], ...] = tuple( - cast(type[OvertureFeature], m) for m in models.values() if _can_discriminate(m) - ) - discriminated_union: UnionType | None = _discriminated_union(discriminated_models) - - non_discriminated_models: Generator[type[BaseModel], None, None] = ( - m for m in models.values() if not _can_discriminate(m) - ) - non_discriminated_union: UnionType | None = reduce( - or_, non_discriminated_models, None - ) - - if discriminated_union and non_discriminated_union: - model_union = discriminated_union | non_discriminated_union - elif discriminated_union: - model_union = discriminated_union - elif non_discriminated_union: - model_union = non_discriminated_union - else: - raise RuntimeError("logic error: unreachable code") - - return TypeAdapter(model_union) - - -def _discriminated_union( - feature_classes: tuple[type[OvertureFeature], ...], -) -> Any: # noqa: ANN401 - """ - Create a discriminated union of the Overture features since they can be discriminated on the - `type` field. This is just a performance optimization, and the union will work even if no models - are discriminated. - """ - if not feature_classes: - return None - else: - return Annotated[ - reduce( - or_, - ( - Annotated[f, Tag(cast(str, _typeliteral(f)))] - for f in feature_classes - ), - ), - Field(discriminator=Feature.field_discriminator("type", *feature_classes)), - ] - - -def _can_discriminate(model_class: object) -> bool: - """ - Return true if given value can participate in a discriminated union on the `type` field because - it is an Overture feature with where the `type` field has a single literal value. - """ - return ( - isinstance(model_class, type) - and issubclass(model_class, OvertureFeature) - and _typeliteral(cast(type[OvertureFeature], model_class)) is not None - ) - - -def _typeliteral(feature_class: type[OvertureFeature]) -> object: - """ - Return the literal value of the Overture Feature model's `type` field, if it has one, or `None` - if it does not. - - Parameters - ---------- - feature_class : type[OvertureFeature] - Overture feature model class - - Returns - ------- - object - The literal constrained value of the model class' `type` field, or `None` if the `type` - field does not have a literal value - - Raises - ------ - TypeError - If the `type` field is constrained to `Literal[None]`, as this is absurd - """ - type_type = feature_class.model_fields["type"].annotation - while get_origin(type_type) is Annotated: - type_type = get_args(Annotated)[0] - if get_origin(type_type) is not Literal: - return None - literal = get_args(type_type)[0] - if literal is None: - raise TypeError( - f"literal value of `type` field for `{OvertureFeature.__name__}` class " - f"`{feature_class.__name__}` is constrained to `None`" - ) - return literal diff --git a/packages/overture-schema/tests/test_schema_validation.py b/packages/overture-schema/tests/test_schema_validation.py index b3ec4399f..13702a5a9 100644 --- a/packages/overture-schema/tests/test_schema_validation.py +++ b/packages/overture-schema/tests/test_schema_validation.py @@ -5,7 +5,7 @@ import pytest import yaml -from overture.schema import validate, validate_json +from overture.schema.validation import validate, validate_json from pydantic import ValidationError from yamlcore import CoreLoader # type: ignore diff --git a/uv.lock b/uv.lock index 65b5c0639..2020fbf33 100644 --- a/uv.lock +++ b/uv.lock @@ -27,6 +27,7 @@ members = [ "overture-schema-pyspark", "overture-schema-system", "overture-schema-transportation-theme", + "overture-schema-validation", "overture-schema-workspace", ] @@ -904,6 +905,7 @@ dependencies = [ { name = "overture-schema-divisions-theme" }, { name = "overture-schema-places-theme" }, { name = "overture-schema-transportation-theme" }, + { name = "overture-schema-validation" }, { name = "pydantic" }, { name = "pyyaml" }, ] @@ -925,6 +927,7 @@ requires-dist = [ { name = "overture-schema-divisions-theme", editable = "packages/overture-schema-divisions-theme" }, { name = "overture-schema-places-theme", editable = "packages/overture-schema-places-theme" }, { name = "overture-schema-transportation-theme", editable = "packages/overture-schema-transportation-theme" }, + { name = "overture-schema-validation", editable = "packages/overture-schema-validation" }, { name = "pydantic", specifier = ">=2.12.0" }, { name = "pyyaml", specifier = ">=6.0.2" }, ] @@ -1218,6 +1221,23 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.12.0" }, ] +[[package]] +name = "overture-schema-validation" +version = "0.1.1" +source = { editable = "packages/overture-schema-validation" } +dependencies = [ + { name = "overture-schema-common" }, + { name = "overture-schema-system" }, + { name = "pydantic" }, +] + +[package.metadata] +requires-dist = [ + { name = "overture-schema-common", editable = "packages/overture-schema-common" }, + { name = "overture-schema-system", editable = "packages/overture-schema-system" }, + { name = "pydantic", specifier = ">=2.12.0" }, +] + [[package]] name = "overture-schema-workspace" version = "0.0.0"