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.

@vcschapp

Copy link
Copy Markdown
Collaborator

My high-level discussion points.

Claim: Extensions need to be useful standing alone.

I think there's a requirement that extensions should be useful standing alone. Because the typical delivery model for extension on the data side will not be as a giant superparquet that clumps it all together; but instead probably just the extra fields keyed by GERS ID.

The above claim at a high level is a Q for: TristanDiet-TomTom Albi (@wiedersberg) Dana Bauer (@danabauer).

If the above claim is right, I think it leads to further questions:

  1. Should there be an ID requirement for extensions?
  2. Should extensions be merged onto base models as the default with opt-out, or should the default be to not merge with requirement to opt in to that?

Should there be an ID requirement for extensions?

In my mind there should be an ID requirement for extensions. (I think this tracks Albi (@wiedersberg)'s product vision that the primary extension mechanism from an Overture standpoint is by GERS ID.)

What if:

  1. The model referenced via @extends and Extends is required to be Identified?
  2. In standalone mode, the model produced by @extends and Extends receives an automatic id: Id column. Maybe you can override this like @extends(Place, id_field="place_id") and Annotated[..., Extends(Place, Building, id_field="place_or_building_id")]...

Merge to base model as default on with opt out, or default off with opt in?

If I'm right in my claim that supporting data for extensions will often be delivered standalone, I think the merging should be default off, but with opt in. Maybe both granular opt in ("apply this extension") plus blanket opt in ("apply all extensions").

@danabauer

Dana Bauer (danabauer) commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Victor Schappert (@vcschapp) To confirm from the product side: yes, the design assumption is that extensions are useful standing alone. The framework the SC signed off on is to "enable the schema, don't host the data." For verified extensions, Overture standardizes and verifies the schema, but the data stays owned, hosted, and published by the provider, because it might be proprietary and/or on a refresh cycle faster than our release cadence (opening hours). The consumer does the join. The MVP path is literally "download the extension data, column-join to the reference map on GERS."

On the ID requirement: from the verified-extension criteria Albi put together, the framework already assumes the extension schema itself includes a GERS ID field, with generated UUIDs filling in for features of the same type that aren't GERS-enabled. Requiring the target be Identified is enforcing what we've already committed to. It's your call (and Seth and Roel's call) about how to implement it.

One more thing to keep in mind: this mechanism is columns on existing GERS features. Adding rows (new features without GERS ID) is the supplemental datasets path, which has its own process and hosting story.

@danabauer

Dana Bauer (danabauer) commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Victor Schappert (@vcschapp) On merge default I don't have a strong preference, but here's context that might be helpful:

Part of our vision is for the UX to be "seamless, as if the extension's data is an integrated part of the core data, without requiring extra work from the developer to merge them." I don't think that's an argument for merge by default. We are trying to remove the burden on the user of figuring out which of their rows corresponds to which of ours. Installing an extension and applying it to a model are two different things. Some people will install a package just to check extension data on its own — that's the standalone case from my earlier comment — and they haven't asked for an extra field to appear on their Place model.

Also, I'd want per extension opt in to exist no matter what the default is. We have two tiers, and community extensions are unreviewed on purpose and we tell people to check the data themselves before relying on it. If installing a package silently adds fields to every matching model, that check happens at install time instead of when someone decides whether to trust the data.

None of that settles default-on vs. default-off for a single verified extension, which is your call. But I think per extension opt in should exist alongside any apply-all option, and merge behavior should be the same for verified and community extensions. Verified means Overture has reviewed the schema; it shouldn't change what the code does.

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

3 participants