From 6145b3659945f533f66886a2114d1fc8d410fe3c Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 13 May 2026 05:59:12 -0400 Subject: [PATCH 1/4] feat(types): SchemaVariant marker + mypy plugin for cross-class entity overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #710 (option (a″) from the design discussion). Ships ``adcp.types.SchemaVariant`` — a marker type that adopters use on Pydantic field overrides where the child class substitutes a shape-compatible-but-distinct entity class for the parent's declared type. Paired with a dedicated mypy plugin (``adcp.types.mypy_plugin``) that rewrites ``SchemaVariant[T]`` annotations to ``Any`` for override-compat purposes, eliminating the ``# type: ignore[assignment]`` adopters previously stamped on every cross-class override. Usage:: class GetMediaBuyDeliveryResponse(LibraryGetMediaBuyDeliveryResponse): media_buy_deliveries: SchemaVariant[list[MediaBuyDeliveryData]] At runtime ``SchemaVariant[T]`` collapses to ``T`` via the metaclass — Pydantic uses the wrapped type for validation unchanged. The mypy plugin must be enabled in adopter projects via:: [tool.mypy] plugins = ["adcp.types.mypy_plugin"] (Already wired in this repo's pyproject.toml so the new type-check fixture passes ``mypy --strict``.) Tradeoff: inside the override site, mypy sees the field as ``Any`` — adopters lose precise inference. ``typing.cast(list[T], self.field)`` recovers it. This is the documented option (a) from #710; option (b) (Generic response types via codegen) is the long-term right answer and lives on a separate spike. Verification: - ``tests/test_schema_variant.py`` — 7 runtime tests pin the ``SchemaVariant[T] → T`` collapse, Pydantic validation against the wrapped type, and round-trip serialization parity with a non-marker field. - ``tests/type_checks/cross_class_override_with_schema_variant.py`` — three adopter-pattern cases (delivery-view class, same-name local class, inclusion/exclusion variants) pass ``mypy --strict`` with zero ``# type: ignore``. Without the plugin, the same fixture surfaces 9 mypy errors (3× ``[assignment]`` + 3× ``[type-arg]`` + 3× ``[arg-type]``) — confirms the plugin is doing real work. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 5 + src/adcp/types/__init__.py | 9 + src/adcp/types/mypy_plugin.py | 86 ++++++++++ src/adcp/types/variants.py | 98 +++++++++++ tests/test_schema_variant.py | 150 +++++++++++++++++ ...ross_class_override_with_schema_variant.py | 154 ++++++++++++++++++ 6 files changed, 502 insertions(+) create mode 100644 src/adcp/types/mypy_plugin.py create mode 100644 src/adcp/types/variants.py create mode 100644 tests/test_schema_variant.py create mode 100644 tests/type_checks/cross_class_override_with_schema_variant.py diff --git a/pyproject.toml b/pyproject.toml index bcb3cba2f..628156e92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -189,6 +189,11 @@ python_version = "3.10" strict = true warn_return_any = true warn_unused_configs = true +# adcp.types.SchemaVariant — see adcp.types.mypy_plugin for the marker +# semantics. Adopters that want the same override-compat behavior on +# their own cross-class field overrides should add this plugin to their +# own pyproject.toml under [tool.mypy]. +plugins = ["adcp.types.mypy_plugin"] [[tool.mypy.overrides]] module = "tests.*" diff --git a/src/adcp/types/__init__.py b/src/adcp/types/__init__.py index d8327599d..d629d2820 100644 --- a/src/adcp/types/__init__.py +++ b/src/adcp/types/__init__.py @@ -627,6 +627,14 @@ ) from adcp.types.registry import BrandSource +# Schema-variant marker for cross-class entity overrides (#710). Adopters +# annotate Pydantic field overrides with ``SchemaVariant[T]`` instead of +# ``# type: ignore[assignment]`` when substituting a sibling class for the +# parent's declared type. Activate the mypy plugin via +# ``[tool.mypy] plugins = ["adcp.types.mypy_plugin"]`` to suppress the +# override-compat check on those fields. +from adcp.types.variants import SchemaVariant + # Semantic aliases for auto-generated field enum names ListCreativesField = Field1 @@ -990,6 +998,7 @@ def __init__(self, *args: object, **kwargs: object) -> None: "Request", "Response", "Results", + "SchemaVariant", "Signal", "SignalFilters", "SignalPricingOption", diff --git a/src/adcp/types/mypy_plugin.py b/src/adcp/types/mypy_plugin.py new file mode 100644 index 000000000..95c7b4beb --- /dev/null +++ b/src/adcp/types/mypy_plugin.py @@ -0,0 +1,86 @@ +"""Mypy plugin that powers :data:`adcp.types.SchemaVariant`. + +Without this plugin, mypy reports ``SchemaVariant[X]`` as an unanalyzed +generic and raises override-compat errors. With it, every annotation +of the form ``SchemaVariant[X]`` resolves to ``Any`` at type-check +time, suppressing the Liskov check on cross-class field overrides +(see :mod:`adcp.types.variants`). + +**Activation**. Add the plugin to ``[tool.mypy]`` in your adopter +project's ``pyproject.toml``:: + + [tool.mypy] + plugins = ["adcp.types.mypy_plugin"] + +Or in ``mypy.ini``:: + + [mypy] + plugins = adcp.types.mypy_plugin + +The plugin is a no-op for code that doesn't reference +``adcp.types.SchemaVariant`` — adopters can enable it globally without +side effects on unrelated annotations. + +**Why the Any rewrite is safe**. The override-compat check fires on +Pydantic field overrides where the child substitutes a sibling class +for the parent's declared type. Mypy correctly flags this as a Liskov +violation under strict typing. Adopters who reach for ``SchemaVariant`` +are explicitly opting out of the LSP check for that field — the type +system semantics inside the override body widen to ``Any``, but the +runtime contract (Pydantic validation against the wrapped type) is +unchanged. The plugin makes the opt-out greppable and tied to a +specific named marker rather than scattered ``# type: ignore``. +""" + +from __future__ import annotations + +from collections.abc import Callable + +from mypy.plugin import AnalyzeTypeContext, Plugin +from mypy.types import AnyType, Type, TypeOfAny + +_SCHEMA_VARIANT_FULLNAME = "adcp.types.variants.SchemaVariant" + + +def _analyze_schema_variant(ctx: AnalyzeTypeContext) -> Type: + """Rewrite ``SchemaVariant[T]`` annotations to ``Any`` for mypy. + + The runtime collapses ``SchemaVariant[T]`` to ``T`` (see + :class:`adcp.types.variants.SchemaVariant`), but at type-check time + we want Liskov-permissive behavior on override sites — mypy treats + ``Any`` as bivariant with any concrete type, so the override-compat + check passes regardless of the parent field's declared type. + + The wrapped type ``T`` is intentionally dropped from the mypy view: + adopters using ``SchemaVariant`` have explicitly opted out of + static checking on the field. If they want precise inference back + inside the override body, the pattern is + ``typing.cast(list[MyT], self.field)``. + """ + return AnyType(TypeOfAny.from_omitted_generics) + + +class AdcpTypesPlugin(Plugin): + """Entry-point plugin class. + + Registers a single type-analyze hook for + ``adcp.types.variants.SchemaVariant``. All other types pass through + mypy's default analyzer unchanged. + """ + + def get_type_analyze_hook(self, fullname: str) -> Callable[[AnalyzeTypeContext], Type] | None: + if fullname == _SCHEMA_VARIANT_FULLNAME: + return _analyze_schema_variant + return None + + +def plugin(version: str) -> type[AdcpTypesPlugin]: + """Mypy plugin factory — returns the plugin class. + + ``version`` is mypy's reported plugin API version (a string like + ``"1.13.0"``). The plugin doesn't currently branch on it; if a + future mypy release changes ``AnalyzeTypeContext`` semantics in a + way we need to handle, branch here. + """ + del version # unused — kept for the mypy plugin protocol + return AdcpTypesPlugin diff --git a/src/adcp/types/variants.py b/src/adcp/types/variants.py new file mode 100644 index 000000000..0ee632941 --- /dev/null +++ b/src/adcp/types/variants.py @@ -0,0 +1,98 @@ +"""Schema-variant marker for cross-class entity overrides (#710). + +When an adopter subclasses an auto-generated response type and substitutes +a **shape-compatible but distinct** entity class for the canonical one, +mypy's Liskov substitution check rejects the assignment because the two +classes are siblings, not parent-child. The override is *semantically* +correct — every method that reads the field works against the adopter's +class — but mypy can't see that without explicit help. + +The historical workaround was ``# type: ignore[assignment]`` on every +override line. PR #644's docs codify the pattern as legitimate. This +module ships the typed escape hatch that retires the ignores. + +Usage:: + + from adcp.types import SchemaVariant + from adcp.types import GetMediaBuyDeliveryResponse as LibraryGetMediaBuyDeliveryResponse + + class GetMediaBuyDeliveryResponse(LibraryGetMediaBuyDeliveryResponse): + # No ``# type: ignore[assignment]`` needed — SchemaVariant marks + # this as an intentional cross-class override. + media_buy_deliveries: SchemaVariant[list[MediaBuyDeliveryData]] + +At runtime ``SchemaVariant[T]`` collapses to ``T`` — Pydantic sees the +wrapped type and validates against it unchanged. Static type-checkers +that load :mod:`adcp.types.mypy_plugin` treat the field as ``Any`` for +override-compat purposes, eliminating the LSP error. + +To activate the plugin, add:: + + # pyproject.toml + [tool.mypy] + plugins = ["adcp.types.mypy_plugin"] + +Then run ``mypy --strict`` over the adopter code — no ``# type: ignore`` +needed on the override line. + +**Tradeoff**: inside the override, the field's type is widened to +``Any`` for mypy's purposes. If precise inference matters (e.g. you +call entity-specific methods on each item), use :func:`typing.cast`:: + + from typing import cast + + for delivery in cast(list[MediaBuyDeliveryData], self.media_buy_deliveries): + delivery.local_method() # inference restored + +The runtime field type is the wrapped type, not ``Any`` — Pydantic +validation, ``model_dump``, and dataclass introspection all see the +true type. + +**When not to use this**: subclass overrides where the child IS a +proper subclass of the parent's field type. Those already type-check +cleanly via ``Sequence[T]`` covariance (PR #635); using +``SchemaVariant`` there obscures the fact that the override is +sub-typing, not substitution. +""" + +from __future__ import annotations + +from typing import Any, TypeVar + +__all__ = ["SchemaVariant"] + +T = TypeVar("T") + + +class _SchemaVariantMeta(type): + """Metaclass: ``SchemaVariant[X]`` returns ``X`` at runtime. + + Implementing the subscription via the metaclass rather than + :meth:`__class_getitem__` keeps the class non-Generic — adopters + can use ``SchemaVariant[X]`` exactly once per field annotation + without TypeVar binding complications. Pydantic introspects the + field's annotation through ``typing.get_type_hints`` which evaluates + ``SchemaVariant[X]`` and reads the returned ``X`` as the field type. + """ + + def __getitem__(cls, item: Any) -> Any: + return item + + +class SchemaVariant(metaclass=_SchemaVariantMeta): + """Marker type for intentional cross-class entity overrides. + + See the module docstring for the full rationale and usage. Two-line + contract: + + * Runtime: ``SchemaVariant[T]`` evaluates to ``T``. Pydantic uses + ``T`` for validation, serialization, and dump output. + * Static (with :mod:`adcp.types.mypy_plugin` active): ``SchemaVariant[T]`` + is treated as ``Any`` for assignment-compat purposes, suppressing + the override LSP error. + + Without the plugin, mypy sees ``SchemaVariant[T]`` as an unanalyzed + generic and may report ``Bracketed expression`` errors. The plugin is + not optional — adopters who want the override-compat benefit must + enable it in their mypy config. + """ diff --git a/tests/test_schema_variant.py b/tests/test_schema_variant.py new file mode 100644 index 000000000..c5ca9bcde --- /dev/null +++ b/tests/test_schema_variant.py @@ -0,0 +1,150 @@ +"""Runtime tests for :data:`adcp.types.SchemaVariant` (#710). + +Static-typing behavior is exercised separately via +``tests/type_checks/cross_class_override_with_schema_variant.py``, +which CI mypy-strict-checks. These tests pin the runtime contract: + +1. ``SchemaVariant[T]`` evaluates to ``T``. +2. Pydantic validates fields annotated with ``SchemaVariant[T]`` against + the wrapped ``T`` exactly as if ``T`` had been the annotation. +3. ``model_dump`` round-trips work unchanged. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from pydantic import BaseModel, ValidationError + +from adcp.types import SchemaVariant + + +def test_subscription_returns_wrapped_type() -> None: + """``SchemaVariant[X]`` must collapse to ``X`` at runtime. Pydantic + reads the annotation via ``get_type_hints`` which evaluates the + subscription — if the metaclass didn't return ``X``, Pydantic + would see a ``SchemaVariant`` instance and refuse to validate.""" + assert SchemaVariant[int] is int + assert SchemaVariant[list[str]] == list[str] + assert SchemaVariant[dict[str, int]] == dict[str, int] + + +def test_pydantic_validates_against_wrapped_type() -> None: + """A Pydantic field annotated ``SchemaVariant[list[Sub]]`` validates + every input list element as ``Sub``. This is the load-bearing + behavior — Pydantic must not see ``SchemaVariant``; it must see + the inner type.""" + + class Sub(BaseModel): + value: int + + class Container(BaseModel): + items: SchemaVariant[list[Sub]] + + ok = Container(items=[Sub(value=1), Sub(value=2)]) + assert ok.items[0].value == 1 + assert ok.items[1].value == 2 + + # Wrong type for an item → Pydantic rejects via the wrapped Sub. + with pytest.raises(ValidationError): + Container(items=[{"wrong_field": "x"}]) # type: ignore[list-item] + + +def test_model_dump_roundtrip() -> None: + """SchemaVariant doesn't interfere with serialization — model_dump + produces the same dict shape as if the wrapped type had been the + annotation directly.""" + + class Item(BaseModel): + name: str + + class WithVariant(BaseModel): + items: SchemaVariant[list[Item]] + + class WithoutVariant(BaseModel): + items: list[Item] + + variant = WithVariant(items=[Item(name="a"), Item(name="b")]) + plain = WithoutVariant(items=[Item(name="a"), Item(name="b")]) + + assert variant.model_dump() == plain.model_dump() + + +def test_cross_class_override_validates() -> None: + """The whole point of the marker: an adopter substitutes a sibling + class for the parent's declared element type. The override + validates against the sibling, not the parent — proves Pydantic + sees ``list[AdopterEntity]`` even though the parent declared + ``Sequence[LibraryEntity]``. + + Mypy's override-compat error on this pattern is what the bundled + plugin suppresses — see the static-test fixture for that side.""" + + class LibraryCreative(BaseModel): + creative_id: str + + class AdopterCreative(BaseModel): + # Same shape, different class — common pattern for adopters + # that carry extra internal fields and a different name. + creative_id: str + internal_state: str = "active" + + class LibraryResponse(BaseModel): + creatives: list[LibraryCreative] + + class AdopterResponse(LibraryResponse): + creatives: SchemaVariant[list[AdopterCreative]] + + resp = AdopterResponse( + creatives=[ + AdopterCreative(creative_id="c1"), + AdopterCreative(creative_id="c2", internal_state="paused"), + ] + ) + # Each item is the adopter's class, not the library's — proves + # validation went through the wrapped (adopter) type, not the + # parent's declared type. + assert isinstance(resp.creatives[0], AdopterCreative) + assert resp.creatives[1].internal_state == "paused" + + +def test_does_not_accept_unbound_use() -> None: + """``SchemaVariant`` is a marker — it should not be used as a bare + type annotation. Constructing a Pydantic model with a bare + ``SchemaVariant`` annotation degenerates to Pydantic's + arbitrary-types handling; the result is undefined and adopters + shouldn't rely on it. Document the contract here so anyone tempted + to do this hits a test telling them not to. + """ + + # We don't enforce a hard error — just document that the result is + # implementation-defined. The test asserts the marker exists and is + # subscriptable; bare use is out of scope. + assert callable(getattr(SchemaVariant, "__class_getitem__", None)) or hasattr( + type(SchemaVariant), "__getitem__" + ) + + +def test_nested_subscription_resolves() -> None: + """``SchemaVariant[dict[str, SchemaVariant[int]]]`` should fully + resolve to ``dict[str, int]`` — the metaclass evaluates each level + as Python evaluates the subscription left-to-right.""" + + inner = SchemaVariant[int] + outer = SchemaVariant[dict[str, inner]] # type: ignore[valid-type] + assert outer == dict[str, int] + + +def test_works_with_arbitrary_t() -> None: + """Regression: ``SchemaVariant[X]`` should accept any subscriptable + type, not just simple generics. Tuples, unions, callable types, + etc., all pass through unchanged.""" + + assert SchemaVariant[tuple[int, str]] == tuple[int, str] + assert SchemaVariant[int | None] == int | None + + callable_t: Any = type("Callable", (), {"__class_getitem__": staticmethod(lambda args: args)}) + # Use ``==`` not ``is`` — Callable[[int], str] constructs a fresh + # tuple on each invocation; identity won't hold but equality does. + assert SchemaVariant[callable_t[[int], str]] == callable_t[[int], str] diff --git a/tests/type_checks/cross_class_override_with_schema_variant.py b/tests/type_checks/cross_class_override_with_schema_variant.py new file mode 100644 index 000000000..03418c1ad --- /dev/null +++ b/tests/type_checks/cross_class_override_with_schema_variant.py @@ -0,0 +1,154 @@ +"""Adopter pattern: cross-class entity override via :data:`SchemaVariant`. + +Critical Pattern #2 — when an adopter subclasses a generated response +type and substitutes a **shape-compatible but distinct** entity class +for the parent's declared element type, mypy's Liskov check rejects +the assignment because the two classes are siblings, not parent-child. +The historical workaround was ``# type: ignore[assignment]``; this +fixture proves that :data:`adcp.types.SchemaVariant` plus the mypy +plugin (``adcp.types.mypy_plugin``, registered in this repo's +``pyproject.toml``) eliminates the ignore. + +Every override below must pass ``mypy --strict`` with **zero** ``# +type: ignore`` lines. The cases mirror the salesagent ignores listed +in #710 — different parent types, different child types, all +shape-compatible-but-not-subclasses. + +Run from the repo root:: + + mypy --strict tests/type_checks/ + +If this file regresses (mypy reports ``[assignment]`` here), the +plugin is misregistered or the marker semantics have drifted — +investigate :mod:`adcp.types.mypy_plugin` before relaxing the test. +""" + +from __future__ import annotations + +from typing import cast + +from pydantic import BaseModel + +from adcp.types import SchemaVariant + +# --- Case 1: distinct delivery-view class for media buys ----------------- + + +class MediaBuy(BaseModel): + """Library wire type.""" + + media_buy_id: str + status: str = "active" + + +class MediaBuyDeliveryData(BaseModel): + """Adopter delivery-context view — same shape, different class. + + Mirrors the salesagent ``MediaBuyDeliveryData`` case from #710. The + delivery context adds local fields (impressions, spend) that don't + belong on the canonical ``MediaBuy``.""" + + media_buy_id: str + status: str = "active" + impressions_delivered: int = 0 + spend_to_date_cents: int = 0 + + +class LibraryGetMediaBuysResponse(BaseModel): + media_buys: list[MediaBuy] = [] + + +class GetMediaBuysResponse(LibraryGetMediaBuysResponse): + # ``SchemaVariant`` marks the substitution as intentional. No + # ``# type: ignore[assignment]`` — the bundled mypy plugin + # rewrites the override annotation to ``Any`` so the LSP check + # passes. + media_buys: SchemaVariant[list[MediaBuyDeliveryData]] + + +# --- Case 2: same-name local class (salesagent's Creative pattern) ------- + + +class LibraryCreative(BaseModel): + creative_id: str + name: str = "" + + +class Creative(BaseModel): + """Adopter ``Creative`` — same name as library type but a different + class (carries local DB columns). The parent's field declares + ``list[LibraryCreative]``; the adopter wants + ``list[Creative]`` (local) without an ignore. + """ + + creative_id: str + name: str = "" + internal_state: str = "active" + + +class LibraryListCreativesResponse(BaseModel): + creatives: list[LibraryCreative] = [] + + +class ListCreativesResponse(LibraryListCreativesResponse): + creatives: SchemaVariant[list[Creative]] + + +# --- Case 3: inclusion vs exclusion variants of the same shape ----------- + + +class GeoCountry(BaseModel): + """Inclusion variant — what the spec uses for the include list.""" + + iso_code: str + + +class GeoCountriesExcludeItem(BaseModel): + """Exclusion variant — distinct named type with the same shape. + + Same problem as cases 1 and 2: the spec models inclusion and + exclusion as separate classes; adopters substitute the inclusion + type into an exclusion-typed parent field (or vice versa) + because the shape is identical and conversion between them is a + cast at the boundary.""" + + iso_code: str + + +class LibraryAudienceFilters(BaseModel): + excluded_countries: list[GeoCountriesExcludeItem] = [] + + +class AdopterAudienceFilters(LibraryAudienceFilters): + excluded_countries: SchemaVariant[list[GeoCountry]] + + +# --- Case 4: nested cast() to recover precise inference ------------------ + + +def consume_with_precise_inference(resp: GetMediaBuysResponse) -> int: + """Mypy sees ``resp.media_buys`` as ``Any`` because of the + SchemaVariant rewrite. To call entity-specific methods with full + inference, cast inside the override site. This is the documented + tradeoff — the marker buys override-compat at the cost of + inside-the-override inference. + """ + total = 0 + for delivery in cast(list[MediaBuyDeliveryData], resp.media_buys): + total += delivery.impressions_delivered + return total + + +# --- Construction proves the runtime side --------------------------------- + + +_r1 = GetMediaBuysResponse( + media_buys=[MediaBuyDeliveryData(media_buy_id="mb_1", impressions_delivered=100)] +) +_r2 = ListCreativesResponse(creatives=[Creative(creative_id="c_1", internal_state="paused")]) +_r3 = AdopterAudienceFilters(excluded_countries=[GeoCountry(iso_code="US")]) + +# All three constructions type-check; the runtime side is exercised by +# tests/test_schema_variant.py. This file's contract is purely the +# mypy --strict pass. +_ = _r1, _r2, _r3 From c5acc55e239daad076c7e402fb17baaf223d2fb8 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 13 May 2026 06:04:54 -0400 Subject: [PATCH 2/4] fix(types): address review feedback on SchemaVariant + mypy plugin (#718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change ``TypeOfAny.from_omitted_generics`` → ``TypeOfAny.special_form``. The marker is a typing primitive whose ``Any`` reduction is intentional design, not a missing annotation. Wrong flavor misclassifies under adopter ``--disallow-any-generics`` runs. - Extend type-check fixture with non-list shapes: ``Optional[Sibling]`` (case 4, mirrors salesagent's ``creative.py:677`` ``QuerySummary`` override) and ``dict[str, Sibling]`` (case 5). Proves the marker generalizes beyond ``list[Sibling]``. Negative-case error count goes from 9 → 15 without the plugin, all suppressed with it. - Trim verbose module docstrings to the minimum that conveys the contract — longer-form rationale lives in the PR / issue threads. - Drop ``test_does_not_accept_unbound_use``. The test asserted only that ``__getitem__`` exists; it didn't exercise any documented behavior. - Rename plugin factory ``version`` arg to ``_version`` (idiom for protocol-required unused params). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/adcp/types/mypy_plugin.py | 69 +++----------- src/adcp/types/variants.py | 92 ++++--------------- tests/test_schema_variant.py | 17 ---- ...ross_class_override_with_schema_variant.py | 56 ++++++++++- 4 files changed, 79 insertions(+), 155 deletions(-) diff --git a/src/adcp/types/mypy_plugin.py b/src/adcp/types/mypy_plugin.py index 95c7b4beb..d2db6e9db 100644 --- a/src/adcp/types/mypy_plugin.py +++ b/src/adcp/types/mypy_plugin.py @@ -1,35 +1,13 @@ -"""Mypy plugin that powers :data:`adcp.types.SchemaVariant`. +"""Mypy plugin that powers :data:`adcp.types.SchemaVariant` (#710). -Without this plugin, mypy reports ``SchemaVariant[X]`` as an unanalyzed -generic and raises override-compat errors. With it, every annotation -of the form ``SchemaVariant[X]`` resolves to ``Any`` at type-check -time, suppressing the Liskov check on cross-class field overrides -(see :mod:`adcp.types.variants`). +Rewrites every ``SchemaVariant[T]`` annotation to ``Any`` at type-check +time so cross-class Pydantic field overrides pass the Liskov check +without ``# type: ignore``. No-op for unrelated annotations. -**Activation**. Add the plugin to ``[tool.mypy]`` in your adopter -project's ``pyproject.toml``:: +Activate in adopter projects:: [tool.mypy] plugins = ["adcp.types.mypy_plugin"] - -Or in ``mypy.ini``:: - - [mypy] - plugins = adcp.types.mypy_plugin - -The plugin is a no-op for code that doesn't reference -``adcp.types.SchemaVariant`` — adopters can enable it globally without -side effects on unrelated annotations. - -**Why the Any rewrite is safe**. The override-compat check fires on -Pydantic field overrides where the child substitutes a sibling class -for the parent's declared type. Mypy correctly flags this as a Liskov -violation under strict typing. Adopters who reach for ``SchemaVariant`` -are explicitly opting out of the LSP check for that field — the type -system semantics inside the override body widen to ``Any``, but the -runtime contract (Pydantic validation against the wrapped type) is -unchanged. The plugin makes the opt-out greppable and tied to a -specific named marker rather than scattered ``# type: ignore``. """ from __future__ import annotations @@ -43,44 +21,19 @@ def _analyze_schema_variant(ctx: AnalyzeTypeContext) -> Type: - """Rewrite ``SchemaVariant[T]`` annotations to ``Any`` for mypy. - - The runtime collapses ``SchemaVariant[T]`` to ``T`` (see - :class:`adcp.types.variants.SchemaVariant`), but at type-check time - we want Liskov-permissive behavior on override sites — mypy treats - ``Any`` as bivariant with any concrete type, so the override-compat - check passes regardless of the parent field's declared type. - - The wrapped type ``T`` is intentionally dropped from the mypy view: - adopters using ``SchemaVariant`` have explicitly opted out of - static checking on the field. If they want precise inference back - inside the override body, the pattern is - ``typing.cast(list[MyT], self.field)``. - """ - return AnyType(TypeOfAny.from_omitted_generics) + # ``TypeOfAny.special_form`` is the right flavor — the marker is a + # typing primitive whose ``Any`` reduction is intentional design. + # ``from_omitted_generics`` would misclassify under adopter + # diagnostics like ``--disallow-any-generics``. + return AnyType(TypeOfAny.special_form) class AdcpTypesPlugin(Plugin): - """Entry-point plugin class. - - Registers a single type-analyze hook for - ``adcp.types.variants.SchemaVariant``. All other types pass through - mypy's default analyzer unchanged. - """ - def get_type_analyze_hook(self, fullname: str) -> Callable[[AnalyzeTypeContext], Type] | None: if fullname == _SCHEMA_VARIANT_FULLNAME: return _analyze_schema_variant return None -def plugin(version: str) -> type[AdcpTypesPlugin]: - """Mypy plugin factory — returns the plugin class. - - ``version`` is mypy's reported plugin API version (a string like - ``"1.13.0"``). The plugin doesn't currently branch on it; if a - future mypy release changes ``AnalyzeTypeContext`` semantics in a - way we need to handle, branch here. - """ - del version # unused — kept for the mypy plugin protocol +def plugin(_version: str) -> type[AdcpTypesPlugin]: return AdcpTypesPlugin diff --git a/src/adcp/types/variants.py b/src/adcp/types/variants.py index 0ee632941..a992cc53a 100644 --- a/src/adcp/types/variants.py +++ b/src/adcp/types/variants.py @@ -1,98 +1,38 @@ """Schema-variant marker for cross-class entity overrides (#710). -When an adopter subclasses an auto-generated response type and substitutes -a **shape-compatible but distinct** entity class for the canonical one, -mypy's Liskov substitution check rejects the assignment because the two -classes are siblings, not parent-child. The override is *semantically* -correct — every method that reads the field works against the adopter's -class — but mypy can't see that without explicit help. - -The historical workaround was ``# type: ignore[assignment]`` on every -override line. PR #644's docs codify the pattern as legitimate. This -module ships the typed escape hatch that retires the ignores. - -Usage:: - - from adcp.types import SchemaVariant - from adcp.types import GetMediaBuyDeliveryResponse as LibraryGetMediaBuyDeliveryResponse +When an adopter subclasses an auto-generated response type and assigns +a shape-compatible-but-distinct entity class to a parent field, mypy +flags the override as a Liskov violation. ``SchemaVariant[T]`` marks +the override as intentional — at runtime it collapses to ``T`` (Pydantic +validates against the wrapped type); with :mod:`adcp.types.mypy_plugin` +active it rewrites to ``Any`` for override-compat purposes, retiring +the ``# type: ignore[assignment]`` adopters used to stamp:: class GetMediaBuyDeliveryResponse(LibraryGetMediaBuyDeliveryResponse): - # No ``# type: ignore[assignment]`` needed — SchemaVariant marks - # this as an intentional cross-class override. media_buy_deliveries: SchemaVariant[list[MediaBuyDeliveryData]] -At runtime ``SchemaVariant[T]`` collapses to ``T`` — Pydantic sees the -wrapped type and validates against it unchanged. Static type-checkers -that load :mod:`adcp.types.mypy_plugin` treat the field as ``Any`` for -override-compat purposes, eliminating the LSP error. - -To activate the plugin, add:: - - # pyproject.toml - [tool.mypy] - plugins = ["adcp.types.mypy_plugin"] - -Then run ``mypy --strict`` over the adopter code — no ``# type: ignore`` -needed on the override line. - -**Tradeoff**: inside the override, the field's type is widened to -``Any`` for mypy's purposes. If precise inference matters (e.g. you -call entity-specific methods on each item), use :func:`typing.cast`:: - - from typing import cast +Inside the override the field's mypy type is ``Any``; cast to recover +inference:: - for delivery in cast(list[MediaBuyDeliveryData], self.media_buy_deliveries): - delivery.local_method() # inference restored + for d in cast(list[MediaBuyDeliveryData], self.media_buy_deliveries): + d.local_method() -The runtime field type is the wrapped type, not ``Any`` — Pydantic -validation, ``model_dump``, and dataclass introspection all see the -true type. - -**When not to use this**: subclass overrides where the child IS a -proper subclass of the parent's field type. Those already type-check -cleanly via ``Sequence[T]`` covariance (PR #635); using -``SchemaVariant`` there obscures the fact that the override is -sub-typing, not substitution. +Don't use ``SchemaVariant`` for subclass overrides — those already +type-check via ``Sequence[T]`` covariance (PR #635) and the marker +would obscure that the override is sub-typing, not substitution. """ from __future__ import annotations -from typing import Any, TypeVar +from typing import Any __all__ = ["SchemaVariant"] -T = TypeVar("T") - class _SchemaVariantMeta(type): - """Metaclass: ``SchemaVariant[X]`` returns ``X`` at runtime. - - Implementing the subscription via the metaclass rather than - :meth:`__class_getitem__` keeps the class non-Generic — adopters - can use ``SchemaVariant[X]`` exactly once per field annotation - without TypeVar binding complications. Pydantic introspects the - field's annotation through ``typing.get_type_hints`` which evaluates - ``SchemaVariant[X]`` and reads the returned ``X`` as the field type. - """ - def __getitem__(cls, item: Any) -> Any: return item class SchemaVariant(metaclass=_SchemaVariantMeta): - """Marker type for intentional cross-class entity overrides. - - See the module docstring for the full rationale and usage. Two-line - contract: - - * Runtime: ``SchemaVariant[T]`` evaluates to ``T``. Pydantic uses - ``T`` for validation, serialization, and dump output. - * Static (with :mod:`adcp.types.mypy_plugin` active): ``SchemaVariant[T]`` - is treated as ``Any`` for assignment-compat purposes, suppressing - the override LSP error. - - Without the plugin, mypy sees ``SchemaVariant[T]`` as an unanalyzed - generic and may report ``Bracketed expression`` errors. The plugin is - not optional — adopters who want the override-compat benefit must - enable it in their mypy config. - """ + """Marker for intentional cross-class entity overrides — see module docstring.""" diff --git a/tests/test_schema_variant.py b/tests/test_schema_variant.py index c5ca9bcde..f9a2000bc 100644 --- a/tests/test_schema_variant.py +++ b/tests/test_schema_variant.py @@ -109,23 +109,6 @@ class AdopterResponse(LibraryResponse): assert resp.creatives[1].internal_state == "paused" -def test_does_not_accept_unbound_use() -> None: - """``SchemaVariant`` is a marker — it should not be used as a bare - type annotation. Constructing a Pydantic model with a bare - ``SchemaVariant`` annotation degenerates to Pydantic's - arbitrary-types handling; the result is undefined and adopters - shouldn't rely on it. Document the contract here so anyone tempted - to do this hits a test telling them not to. - """ - - # We don't enforce a hard error — just document that the result is - # implementation-defined. The test asserts the marker exists and is - # subscriptable; bare use is out of scope. - assert callable(getattr(SchemaVariant, "__class_getitem__", None)) or hasattr( - type(SchemaVariant), "__getitem__" - ) - - def test_nested_subscription_resolves() -> None: """``SchemaVariant[dict[str, SchemaVariant[int]]]`` should fully resolve to ``dict[str, int]`` — the metaclass evaluates each level diff --git a/tests/type_checks/cross_class_override_with_schema_variant.py b/tests/type_checks/cross_class_override_with_schema_variant.py index 03418c1ad..6a02165d9 100644 --- a/tests/type_checks/cross_class_override_with_schema_variant.py +++ b/tests/type_checks/cross_class_override_with_schema_variant.py @@ -123,7 +123,53 @@ class AdopterAudienceFilters(LibraryAudienceFilters): excluded_countries: SchemaVariant[list[GeoCountry]] -# --- Case 4: nested cast() to recover precise inference ------------------ +# --- Case 4: non-list container — Optional[Sibling] --------------------- +# +# Proves the marker isn't limited to ``list[Sibling]``. Same Liskov +# violation at the type level on a different shape; same fix. + + +class LibraryQuerySummary(BaseModel): + query_id: str + + +class QuerySummary(BaseModel): + """Adopter scalar entity — same name as library, distinct class. + Mirrors the salesagent ``creative.py:677`` case.""" + + query_id: str + internal_cache_key: str = "" + + +class LibraryGetSignalsResponse(BaseModel): + summary: LibraryQuerySummary | None = None + + +class GetSignalsResponse(LibraryGetSignalsResponse): + summary: SchemaVariant[QuerySummary | None] + + +# --- Case 5: dict[str, Sibling] container ------------------------------- + + +class LibraryFeatureFlag(BaseModel): + flag_id: str + + +class FeatureFlag(BaseModel): + flag_id: str + rollout_pct: int = 0 + + +class LibraryFeatureBag(BaseModel): + flags_by_name: dict[str, LibraryFeatureFlag] = {} + + +class AdopterFeatureBag(LibraryFeatureBag): + flags_by_name: SchemaVariant[dict[str, FeatureFlag]] + + +# --- Inside-the-override: cast() to recover precise inference ----------- def consume_with_precise_inference(resp: GetMediaBuysResponse) -> int: @@ -139,7 +185,7 @@ def consume_with_precise_inference(resp: GetMediaBuysResponse) -> int: return total -# --- Construction proves the runtime side --------------------------------- +# --- Construction proves the runtime side ------------------------------- _r1 = GetMediaBuysResponse( @@ -147,8 +193,10 @@ def consume_with_precise_inference(resp: GetMediaBuysResponse) -> int: ) _r2 = ListCreativesResponse(creatives=[Creative(creative_id="c_1", internal_state="paused")]) _r3 = AdopterAudienceFilters(excluded_countries=[GeoCountry(iso_code="US")]) +_r4 = GetSignalsResponse(summary=QuerySummary(query_id="q1", internal_cache_key="x")) +_r5 = AdopterFeatureBag(flags_by_name={"beta": FeatureFlag(flag_id="beta", rollout_pct=10)}) -# All three constructions type-check; the runtime side is exercised by +# All five constructions type-check; the runtime side is exercised by # tests/test_schema_variant.py. This file's contract is purely the # mypy --strict pass. -_ = _r1, _r2, _r3 +_ = _r1, _r2, _r3, _r4, _r5 From fb457e06bd1f360dd1acc0e3f7df0e1306d8b6f3 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 13 May 2026 06:10:57 -0400 Subject: [PATCH 3/4] fix(types): regenerate public API snapshot for SchemaVariant (#718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI on PR #718 failed because the public-API snapshot test detected the new ``adcp.types.SchemaVariant`` export — which is correct behavior; the test exists exactly to catch unintended public-surface changes. Regenerated the snapshot via ``scripts/regenerate_public_api_snapshot.py`` to acknowledge the addition. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/fixtures/public_api_snapshot.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/fixtures/public_api_snapshot.json b/tests/fixtures/public_api_snapshot.json index 67ef99750..f8b1736ee 100644 --- a/tests/fixtures/public_api_snapshot.json +++ b/tests/fixtures/public_api_snapshot.json @@ -798,6 +798,7 @@ "Results", "RightsPricingOption", "RightsTerms", + "SchemaVariant", "Scheme", "Security", "SegmentIdActivationKey", From cc821fc54e506731a111edddaa0a6b3b78658805 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 13 May 2026 06:17:28 -0400 Subject: [PATCH 4/4] docs(types): note pyright/pylance asymmetry on SchemaVariant (#718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundled mypy plugin doesn't affect pyright — adopters using Pylance in VSCode will still see the LSP override flagged on ``SchemaVariant[T]`` fields even when their mypy CI is clean. Acknowledge the editor-warning asymmetry in the module docstring so adopters aren't surprised; the runtime contract holds either way. Raised by Brian during PR #718 review. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/adcp/types/variants.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/adcp/types/variants.py b/src/adcp/types/variants.py index a992cc53a..5df44264f 100644 --- a/src/adcp/types/variants.py +++ b/src/adcp/types/variants.py @@ -20,6 +20,13 @@ class GetMediaBuyDeliveryResponse(LibraryGetMediaBuyDeliveryResponse): Don't use ``SchemaVariant`` for subclass overrides — those already type-check via ``Sequence[T]`` covariance (PR #635) and the marker would obscure that the override is sub-typing, not substitution. + +**Pyright / Pylance**: the bundled mypy plugin doesn't affect pyright. +Adopters using Pylance in VSCode will still see the LSP override +flagged on ``SchemaVariant[T]`` fields even though their mypy CI +passes. The runtime contract holds either way; this is purely an +editor-warning asymmetry. A pyright-side suppression mechanism is +tracked as a follow-up. """ from __future__ import annotations