Skip to content

feat(types): cross-class entity overrides on response schemas — Protocol bounds or Generic response types #710

@bokelley

Description

@bokelley

Problem

PR #635 widened response-only list fields to Sequence[T], which solved subclass overrides — adopters subclassing Creative with MyCreative can now assign list[MyCreative] to Sequence[Creative] cleanly.

But the more common adopter pattern in salesagent (and likely other production deployments) is cross-class overrides: substituting a shape-compatible but distinct entity class for the canonical one. Sequence[T] doesn't help these because MyDeliveryView is not a subclass of Creative.

Verified cases in salesagent after 5.3.0 bump

Stripped every # type: ignore[assignment] from the schema modules and re-ran mypy against adcp 5.3.0. 12 errors came back. All 12 are cross-class, not subclass.

File:Line Override Parent expects Cross-class because
_base.py:874-877 list[GeoCountry] (+ 3 siblings) Sequence[GeoCountriesExcludeItem] GeoCountry is the inclusion variant; spec uses distinct GeoCountriesExcludeItem for exclusion
_base.py:1322 list[Creative] (salesagent) Sequence[CreativeAsset] Creative extends LibraryCreative, not CreativeAsset
_base.py:1939 list[SignalDeployment] Sequence[Deployments | Deployments3] SignalDeployment extends PlatformDeployment, not Deployments
_base.py:2407 list[GetMediaBuysMediaBuy] Sequence[MediaBuy] GetMediaBuysMediaBuy is a delivery-context view, not a MediaBuy subclass
delivery.py:251 list[MediaBuyDeliveryData] Sequence[MediaBuyDelivery] Different class, same shape
delivery.py:440 list[CreativeDeliveryData] Sequence[Creative] Different class, same shape
creative.py:407 list[CreativeAsset] (local) list[CreativeAsset] (library) Same name, different class — local wraps with internal fields
creative.py:677 QuerySummary (local) QuerySummary (library) Same name, different class

This is exactly the pattern your own docs at #644 ("Picking the Right Base Class — Context-Specific Schema Variants") explicitly acknowledges as legitimate and documents how to type:ignore correctly. So the SDK has admitted these aren't bugs in adopter code — they're the intended pattern. But the type system has no way to express "this field accepts any class with the shape of X" without giving up on the safety entirely.

Proposed

Pick one. Listed in order of escalating invasiveness.

(a) Document the pattern + provide a typed escape hatch

Smallest change. Ship a SchemaVariant mixin / decorator that marks the override as intentional and silences mypy without # type: ignore. Effectively codifies what PR #644's docs already say.

class GetMediaBuyDeliveryResponse(LibraryGetMediaBuyDeliveryResponse):
    media_buy_deliveries: SchemaVariant[list[MediaBuyDeliveryData]]

The type alias resolves to the field type at runtime; mypy treats it as Any-bounded. Loses precise inference inside the override but kills the ignores.

(b) Generate response types as Generic over their entity items

class GetCreativeDeliveryResponse(Generic[CreativeT], ...):
    creatives: Sequence[CreativeT]

Then:

class GetCreativeDeliveryResponse(LibraryGetCreativeDeliveryResponse[CreativeDeliveryData]):
    ...  # no override needed; the Generic specialization handles it

Mechanically clean, fully type-safe, but requires the codegen path to emit Generic classes — bigger change.

(c) Use structural Protocol bounds in response types

class CreativeLike(Protocol):
    creative_id: str
    # ... minimum shape

class GetCreativeDeliveryResponse(...):
    creatives: Sequence[CreativeLike]

Any class with the right shape satisfies the type. No subclass relation needed. Closest to what the adopter pattern actually means. But: structural typing in Pydantic-generated types is unusual and requires careful codegen.

What I'd push for

(b) is the cleanest if the codegen pipeline can absorb it. Generic response types specialize cleanly, give adopters full inference inside their overrides, and don't require the SDK to predict which fields adopters will override.

(a) is the smallest reasonable step if (b) is too invasive — it at least removes the visual noise and centralizes the pattern so reviewers know what they're seeing.

Acceptance

Real-world demand

Same audit as #709. This was the single largest category of "workarounds we hoped 5.3.0 would let us delete" that turned out not to be addressed by PR #635.

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