Skip to content

feat(types): make make_versioned_base bases visible to static type checkers #1309

Closed

Description

@bokelley

Summary

adcp.types.versioned.make_versioned_base(version, model_name) (added for #1050) is the supported way to extend a model pinned to one protocol version with exclude=True internal fields. It builds the class at runtime with create_model and is annotated -> type[BaseModel] (adcp/types/versioned.py:443-513). Type checkers can't use the result as a base class, so every adopter subclass loses static typing for the protocol fields.

The typed version modules don't fill the gap. adcp.types.v31 has full .pyi stubs (#1048), but its runtime classes are RootModel[dict] and can't be subclassed with fields.

Reproduction (adcp 8.0.0)

from pydantic import Field

from adcp.types.v31 import ListCreativesRequest as ListCreatives31
from adcp.types.versioned import make_versioned_base

ListCreatives31Base = make_versioned_base("3.1", "ListCreativesRequest")


class SellerListCreatives(ListCreatives31Base):
    tenant_id: str | None = Field(default=None, exclude=True)


def use(req: SellerListCreatives, boundary: ListCreatives31) -> None:
    reveal_type(req.include_assignments)
    reveal_type(boundary.include_assignments)

mypy 1.19.1 with plugins = pydantic.mypy:

issue25.py:9: error: Variable "issue25.ListCreatives31Base" is not valid as a type  [valid-type]
issue25.py:9: note: See https://mypy.readthedocs.io/en/stable/common_issues.html#variables-vs-type-aliases
issue25.py:9: error: Invalid base class "ListCreatives31Base"  [misc]
issue25.py:14: note: Revealed type is "Any"
issue25.py:15: note: Revealed type is "bool"

At runtime, the typed boundary model can't be extended, and the extensible one is untyped:

subclass adcp.types.v31.ListCreativesRequest: NameError | Unexpected field with name 'tenant_id'; only 'root' is allowed as a field of a `RootModel`
make_versioned_base returns ListCreativesRequestV3_1Base with 17 runtime fields; annotated as type[BaseModel]

Why an adopter hits this

Embedded Sales Agent pins ListCreativesRequest and PackageRequest to 3.1 via make_versioned_base (src/core/adcp_version.py). Because mypy rejects them as bases, ESA annotates the aliases as Any. It then redeclares the inherited protocol fields it uses under if TYPE_CHECKING: in its subclasses (PackageRequest in src/core/schemas/_base.py, ListCreativesRequest in src/core/schemas/creative.py).

Those hand-written annotations aren't checked against the schema. ESA already writes include_assignments: bool | None, while the 3.1 stub says bool. When a version bump renames or retypes a field, nothing catches it statically.

Ask

Any of these would work:

  1. Ship generated static bases per version, next to the existing stubs, e.g. adcp.types.v31.bases.ListCreativesRequestBase. Those classes would be real Pydantic models at runtime and typed in .pyi, so class X(ListCreativesRequestBase) type-checks.
  2. Give make_versioned_base @overloads in a stub keyed on Literal version and model names, returning a stub-declared class per pair. The .pyi generator that produces v31.pyi already has the field data.
  3. Extend adcp.types.mypy_plugin with a get_dynamic_class_hook for make_versioned_base that synthesizes the class from the corresponding vNN.pyi entry. This only helps mypy users.

Activity

  1. added
    enhancementNew feature or request
    claude-triagingTriage routine is actively working on this issue (1-3 min)
    on Oct 2, 2026
  2. bokelley commented on Oct 2, 2026

    @bokelley
    ContributorAuthor

    Triage

    Classification: Feature request
    Bucket(s): types (client-side type-system surface)
    Status: ready-to-implement
    Milestone: (none — no target version specified)

    What the experts said:

    • ad-tech-protocol-expert: conditional-endorse — Approach 1 (generated bases in adcp.types.v31.bases) is the richer primary fix, but the codegen must be driven from the pinned bundled schema via get_portable_schema, not current-model annotations, to prevent static/runtime divergence; Approach 2 is a sound thin complement; Approach 3 is mypy-only and redundant once Approach 1 ships.
    • dx-expert: conditional-endorse — Approach 2 (overloads in versioned.pyi) is the right first implementation: it works on both mypy and pyright, requires no new import paths, and the stub generator already has all the field data; Option 1 defers well as a follow-on if runtime go-to-definition becomes a pain point; Option 3 is the weakest choice.

    My take: Both experts confirm non-breaking and agree the pain is real — ESA's Any + TYPE_CHECKING redeclarations are a type-safety anti-pattern and an agent-tax item. The cleaner sequencing is Option 2 first (universal, invisible to adopters, purely additive) then Option 1 as a follow-on.

    Implementation brief:

    • Step 1 — extend scripts/generate_versioned_stubs.py to also emit src/adcp/types/versioned.pyi alongside the existing v30.pyi/v31.pyi/v32.pyi files; use the same _object_shapes / type_expr machinery already in the script, naming each base class {ModelName}V{version_token}Base (e.g. ListCreativesRequestV3_1Base) keyed on the camelCase model name (not traversal order) to avoid alias-churn per the CLAUDE.md codegen guidance.
    • Step 2 — in versioned.pyi: declare each XxxV{version}Base class as a _VersionedExtensionModel subclass with all protocol fields typed to match the pinned schema; add @overload entries for make_versioned_base(version: Literal["3.1"], model_name: Literal["ListCreativesRequest"]) -> type[ListCreativesRequestV3_1Base] etc., plus a fallback (str, str) -> type[BaseModel] overload.
    • Step 3 — add versioned.pyi to the CI staleness check (the existing check validates v3N.pyi freshness; extend it to cover versioned.pyi in the same pass) and to pyproject.toml's ruff extend-exclude list to match the existing pattern for generated stubs.
    • Non-breaking: versioned.pyi is a new file; type checkers prefer .pyi over runtime inference so no existing correctly-typed code changes behaviour; make_versioned_base's runtime signature is unchanged; the fallback overload preserves the current type[BaseModel] result for non-literal calls.

    Triaged by Claude Code. Session: https://claude.ai/code/session_011pr4sWDPFVdWUxjuk1AbhR


    Generated by Claude Code

  3. added and removed
    claude-triagingTriage routine is actively working on this issue (1-3 min)
    on Oct 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions