Skip to content

[FEATURE] Add an extension mechanism with discovery and codegen support - #655

Draft
Roel Bollens (RoelBollens-TomTom) wants to merge 1 commit into
mainfrom
extensions
Draft

[FEATURE] Add an extension mechanism with discovery and codegen support#655
Roel Bollens (RoelBollens-TomTom) wants to merge 1 commit into
mainfrom
extensions

Conversation

@RoelBollens-TomTom

Copy link
Copy Markdown
Collaborator

closes #634

This PR adds a model extension mechanism that lets packages contribute optional fields to models they do not own.

Extensions use the existing overture.models entry-point group. Discovery identifies them through Extends metadata.

A model extension uses @extends:

from overture.schema.system.extension import extends

@extends(Place)
class OperatingHours(BaseModel):
    primary: list[HourSet]
    rules: list[Rule] | None = None

Non-model types use Extends inside Annotated, optionally behind a NewType:

Capacity = NewType(
    "Capacity",
    Annotated[uint8, Field(description="..."), Extends(Place, Building)],
)

Both are registered like ordinary models:

[project.entry-points."overture.models"]
operating_hours = "overture.schema.extensions.operating_hours:OperatingHours"

During discovery, each extension becomes a standalone one-field wrapper model. This wrapper can validate extension-only payloads when extension data is stored separately from the base feature.

By default, discover_models() also adds the optional extension field—named after its entry point—to every matching registered model.

Target resolution

Targets can be model classes or model-bearing unions, Annotated, NewType, and RootModel expressions.

  • Every arm of a union must resolve to a model, so Place | int is rejected.
  • A RootModel is treated as an alias for its root annotation. Targeting it targets the models in its root, while extending a registered RootModel rebuilds the root with the extended models.
  • A RootModel over a scalar does not resolve to a model and cannot be used as a target.
  • Containers such as list[Place] and dict[str, Place] are not traversed.
  • Self-referential roots are rejected.

Stacked @extends decorators and multiple Extends declarations in one Annotated layer merge their targets. A subclass’s own declaration shadows an inherited declaration. Field-name collisions are skipped with a warning, while invalid entry-point names are rejected during wrapping.

Codegen changes

Extracted fields record whether they came from an extension. Generated Markdown marks these fields with (extension), and union deduplication keeps native and extension fields separate even when their shapes match.

Extends declarations are metadata rather than validation constraints, so they are excluded from constraint collection.

Follow-up branches

Two branches build on this one:

  • extensions-examples adds two example packages: overture-schema-extensions-operating-hours, a model extension targeting Place, and overture-schema-extensions-capacity, a scalar NewType extension targeting Place and Building. Both include valid and invalid GeoJSON examples and tests covering the complete wrap-and-merge path.
  • extensions-typing-refactor types the declaration API with ExtensionTarget: TypeAlias = TypeForm[BaseModel]. It also raises the mypy floor to 2.2.0, the first release that supports TypeForm without an experimental flag.

Trying the examples

Extensions are ordinary packages connected through entry points, so they can be installed without changing application code:

uv pip install \
  "overture-schema-extensions-operating-hours @ git+https://github.com/OvertureMaps/schema.git@extensions-examples#subdirectory=packages/overture-schema-extensions-operating-hours" \
  "overture-schema-extensions-capacity @ git+https://github.com/OvertureMaps/schema.git@extensions-examples#subdirectory=packages/overture-schema-extensions-capacity"

This installs them into the current environment only.

The extension entries and extended Place model are then available through the existing CLI:

$ uv run overture-schema list-types
capacity           extension
operating_hours    extension
place              feature  overture:theme=places
...

$ uv run overture-schema validate --type place - <<'EOF'
{
  "id": "overture:places:place:example-1",
  "type": "Feature",
  "geometry": {"type": "Point", "coordinates": [4.4025, 51.2194]},
  "properties": {
    "theme": "places",
    "type": "place",
    "version": 1,
    "names": {"primary": "Example Diner"},
    "capacity": 25,
    "operating_hours": {
      "primary": [
        {
          "days": ["Monday"],
          "status": "Open",
          "open": "09:00",
          "close": "17:00"
        }
      ]
    }
  }
}
EOF
✓ Successfully validated <stdin>

Changing capacity to 300 violates the uint8 range. Changing open to "9:00" violates the time pattern. Extension fields are validated in the same way as native fields.

Each example package also includes ready-made valid and invalid GeoJSON files. Their tests cover the complete wrap-and-merge path, including validation of extension-only payloads against the standalone wrappers.

Signed-off-by: Roel <75250264+RoelBollens-TomTom@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown

🗺️ Schema reference docs preview is live!

🌍 Preview https://staging.overturemaps.org/schema/pr/655/schema/index.html
🕐 Updated Aug 12, 2026 15:18 UTC
📝 Commit ce2911c
🔧 env SCHEMA_PREVIEW true

Note

♻️ This preview updates automatically with each push to this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

change type - cosmetic 🌹 Cosmetic change enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support extensions for existing Overture models

1 participant