Repository navigation
feat(types): make make_versioned_base bases visible to static type checkers #1309
Description
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:
- 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, soclass X(ListCreativesRequestBase)type-checks. - Give
make_versioned_base@overloads in a stub keyed onLiteralversion and model names, returning a stub-declared class per pair. The.pyigenerator that producesv31.pyialready has the field data. - Extend
adcp.types.mypy_pluginwith aget_dynamic_class_hookformake_versioned_basethat synthesizes the class from the correspondingvNN.pyientry. This only helps mypy users.
Activity
bokelley commentedon Oct 2, 2026
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:
adcp.types.v31.bases) is the richer primary fix, but the codegen must be driven from the pinned bundled schema viaget_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.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_CHECKINGredeclarations 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:
scripts/generate_versioned_stubs.pyto also emitsrc/adcp/types/versioned.pyialongside the existingv30.pyi/v31.pyi/v32.pyifiles; use the same_object_shapes/type_exprmachinery 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.versioned.pyi: declare eachXxxV{version}Baseclass as a_VersionedExtensionModelsubclass with all protocol fields typed to match the pinned schema; add@overloadentries formake_versioned_base(version: Literal["3.1"], model_name: Literal["ListCreativesRequest"]) -> type[ListCreativesRequestV3_1Base]etc., plus a fallback(str, str) -> type[BaseModel]overload.versioned.pyito the CI staleness check (the existing check validatesv3N.pyifreshness; extend it to coverversioned.pyiin the same pass) and topyproject.toml's ruffextend-excludelist to match the existing pattern for generated stubs.versioned.pyiis a new file; type checkers prefer.pyiover runtime inference so no existing correctly-typed code changes behaviour;make_versioned_base's runtime signature is unchanged; the fallback overload preserves the currenttype[BaseModel]result for non-literal calls.Triaged by Claude Code. Session: https://claude.ai/code/session_011pr4sWDPFVdWUxjuk1AbhR
Generated by Claude Code