Skip to content

fix: unwrap RootModel unions for consumer subclassing#156

Merged
bokelley merged 3 commits intoadcontextprotocol:mainfrom
KonstantinMirin:my/four-work
Mar 13, 2026
Merged

fix: unwrap RootModel unions for consumer subclassing#156
bokelley merged 3 commits intoadcontextprotocol:mainfrom
KonstantinMirin:my/four-work

Conversation

@KonstantinMirin
Copy link
Collaborator

@KonstantinMirin KonstantinMirin commented Mar 12, 2026

Summary

Fixes #155 — Pydantic 2 forbids model_config overrides on RootModel subclasses, blocking consumers who subclass library Request/Response types with extra="forbid", custom validators, or additional fields.

  • Unwrap all 28 Request/Response RootModel wrappers to plain Union type aliases during post-generation (e.g. GetSignalsRequest = GetSignalsRequest1 | GetSignalsRequest2)
  • Add CI guard test (test_no_request_response_rootmodels) that fails if any future schema adds a new Request/Response RootModel without unwrapping it
  • Add _make_request() helper in simple.py using cached TypeAdapter for union type aliases
  • Value-type RootModels (PricingOption, Destination, etc.) keep their wrapper + __getattr__ proxy — only Request/Response types are unwrapped

What changed

Layer Before After
Generated types class GetSignalsRequest(RootModel[V1 | V2]) GetSignalsRequest = V1 | V2
Construction GetSignalsRequest(field=val) TypeAdapter(GetSignalsRequest).validate_python({...})
Attribute access obj.root.field obj.field (direct on variant)
Consumer subclassing Blocked by Pydantic Works on any variant

Pipeline ordering

unwrap_rootmodel_unions() runs before add_rootmodel_getattr_proxy() in post_generate_fixes.py, so unwrapped types don't get the proxy while remaining RootModels still do.

buying_mode is now required

GetProductsRequest variants require buying_mode (v3 spec change). All examples, tests, README snippets, and docstrings have been updated. This is not a UX regression — v3 clients must include buying_mode, and servers receiving pre-v3 requests without it should default to "brief".

Review feedback addressed

All items from @bokelley's review have been addressed in 81d3fd3:

Blockers (fixed):

  • TypeAdapter caching via _get_adapter() with module-level dict cache
  • Safe __name__ access with getattr(x, "__name__", str(x)) on all 3 sites in response_parser.py

Should-fix (fixed):

  • _make_request() applied uniformly to all 16 SimpleAPI methods (only get_signals retains custom dispatch due to intentional keyword-based routing)
  • Any import cleanup simplified — removed convoluted outer condition, kept inner after_imports guard
  • buying_mode="brief" added to all get_products calls across tests, examples, README, and docstrings

Non-blocking (all addressed):

  • Replaced regex with AST in unwrap_rootmodel_unions() with bracket-depth counting for nested generics
  • Added validate_union() test helper in tests/conftest.py with caching; migrated all 7 test files
  • Verified Field metadata from removed RootModel wrappers — Request/Response types had no meaningful metadata; value-type RootModels are intentionally excluded

Fix mypy errors from union type aliases (b8eb472)

Unwrapped union type aliases (types.UnionType) are not callable and have no .model_validate(). Three call sites in the library itself were using these types as if they were still classes:

File Problem Fix
__main__.py Dispatch table stored raw types, called request_type(**payload) Store TypeAdapter[Any] instances, call adapter.validate_python(payload)
server/sponsored_intelligence.py SiSendMessageRequest.model_validate(params) Module-level TypeAdapter, call .validate_python(params)
utils/preview_cache.py PreviewCreativeRequest(...) constructor call TypeAdapter.validate_python({...})

Also added pre-commit quality gate docs to CLAUDE.md (ruff check src/, mypy src/adcp/, pytest tests/ -v).

Test plan

  • All 701 existing tests pass (8 skipped, 0 failures)
  • New CI guard test catches any Request/Response RootModel regressions
  • New subclassing tests verify model_config = ConfigDict(extra="forbid") works on variants
  • TypeAdapter validation tests for union type aliases
  • ruff: 0 errors on all changed files
  • mypy: 0 errors across all 377 source files
  • pytest: 701 passed, 8 skipped

…assing (adcontextprotocol#155)

Pydantic 2 forbids model_config overrides on RootModel subclasses, which
blocks consumers who need extra='forbid', custom validators, or additional
fields on Request/Response types.

All 28 Request/Response RootModel wrappers are now unwrapped to Union type
aliases (e.g. `GetSignalsRequest = GetSignalsRequest1 | GetSignalsRequest2`)
during post-generation. Value-type RootModels (PricingOption, Destination,
etc.) keep their wrapper + __getattr__ proxy since they are not subclassed.

Changes:
- Add unwrap_rootmodel_unions() to post_generate_fixes.py with _UNWRAP_TO_UNION set
- Run unwrap before __getattr__ proxy so only non-unwrapped RootModels get proxied
- Add _make_request() helper in simple.py using TypeAdapter for union types
- Add CI guard test (test_no_request_response_rootmodels) to catch regressions
- Add subclassing + TypeAdapter validation tests
- Update all test files to use TypeAdapter instead of .model_validate()
@github-actions
Copy link
Contributor

github-actions bot commented Mar 12, 2026

All contributors have agreed to the IPR Policy. Thank you!
Posted by the CLA Assistant Lite bot.

@KonstantinMirin
Copy link
Collaborator Author

I have read the IPR Policy

@KonstantinMirin
Copy link
Collaborator Author

recheck

Copy link
Contributor

@bokelley bokelley left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work on this PR — the approach is sound and the CI guard test is a great addition. A few items to address before we merge:

Must Fix

_make_request() should cache TypeAdapter instances (src/adcp/simple.py)

TypeAdapter() compiles the validation schema on construction — creating one per call is expensive. Please cache with lru_cache:

from functools import lru_cache

@lru_cache(maxsize=None)
def _get_adapter(request_type: type) -> TypeAdapter:
    return TypeAdapter(request_type)

def _make_request(request_type: type, kwargs: dict[str, Any]) -> Any:
    if isinstance(request_type, UnionType):
        return _get_adapter(request_type).validate_python(kwargs)
    return request_type(**kwargs)

response_type.__name__ will crash on UnionType (src/adcp/utils/response_parser.py, lines ~108 and ~126)

UnionType objects don't have __name__. Use the safe pattern already used on line 221:

getattr(response_type, "__name__", str(response_type))

Should Fix

  1. Apply _make_request() uniformly to all request construction sites in simple.py, not just the 5 currently-unwrapped types. If future schema updates unwrap more types, the others will break with TypeError: cannot instantiate UnionType.

  2. buying_mode added to integration tests — the old test was GetProductsRequest(brief="Coffee brands") without specifying buying_mode. The new code requires it. Is this an expected UX change for consumers? Worth a note in the PR description.

  3. Any import cleanup logic (lines ~130-138 of unwrap_rootmodel_unions()) — the outer condition is hard to reason about. The inner if "Any" not in after_imports check is the real safety guard. Consider simplifying.

Consider (non-blocking)

  • The regex [^\]]+ in class_pattern can't handle nested brackets in type annotations. Works today but fragile — AST-based approach (like add_rootmodel_getattr_proxy already uses) would be more robust.
  • A test helper for TypeAdapter(T).validate_python(data) would reduce verbosity across test files.
  • Verify that Field(description=..., examples=[...]) metadata from the removed RootModel wrappers isn't needed for MCP tool schema generation.

The TypeAdapter caching and __name__ safety are the two blockers — the rest can be follow-ups. Looking forward to merging this!

Copy link
Contributor

@bokelley bokelley left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work on this PR — the approach is sound and the CI guard test is a great addition. A few items to address before we merge:

Must Fix

_make_request() should cache TypeAdapter instances (src/adcp/simple.py)

TypeAdapter() compiles the validation schema on construction — creating one per call is expensive. Please cache with lru_cache:

from functools import lru_cache

@lru_cache(maxsize=None)
def _get_adapter(request_type: type) -> TypeAdapter:
    return TypeAdapter(request_type)

def _make_request(request_type: type, kwargs: dict[str, Any]) -> Any:
    if isinstance(request_type, UnionType):
        return _get_adapter(request_type).validate_python(kwargs)
    return request_type(**kwargs)

response_type.__name__ will crash on UnionType (src/adcp/utils/response_parser.py, lines ~108 and ~126)

UnionType objects don't have __name__. Use the safe pattern already used on line 221:

getattr(response_type, "__name__", str(response_type))

Should Fix

  1. Apply _make_request() uniformly to all request construction sites in simple.py, not just the 5 currently-unwrapped types. If future schema updates unwrap more types, the others will break with TypeError: cannot instantiate UnionType.

  2. buying_mode added to integration tests — the old test was GetProductsRequest(brief="Coffee brands") without specifying buying_mode. The new code requires it. Is this an expected UX change for consumers? Worth a note in the PR description.

  3. Any import cleanup logic (lines ~130-138 of unwrap_rootmodel_unions()) — the outer condition is hard to reason about. The inner if "Any" not in after_imports check is the real safety guard. Consider simplifying.

Consider (non-blocking)

  • The regex in class_pattern can't handle nested brackets in type annotations. Works today but fragile — AST-based approach (like add_rootmodel_getattr_proxy already uses) would be more robust.
  • A test helper for TypeAdapter(T).validate_python(data) would reduce verbosity across test files.
  • Verify that Field(description=..., examples=[...]) metadata from the removed RootModel wrappers isn't needed for MCP tool schema generation.

The TypeAdapter caching and __name__ safety are the two blockers — the rest can be follow-ups. Looking forward to merging this!

Blockers:
- Cache TypeAdapter instances in _get_adapter() to avoid per-call schema
  compilation; uses plain dict (UnionType not hashable for lru_cache)
- Replace bare .__name__ with getattr(x, "__name__", str(x)) in
  response_parser.py to prevent crash on UnionType

Should-fix:
- Apply _make_request() uniformly to all 16 SimpleAPI methods (only
  get_signals retains custom dispatch)
- Simplify Any import cleanup logic in post_generate_fixes.py
- Add buying_mode="brief" to all get_products calls in tests, examples,
  README, and docstrings

Non-blocking improvements:
- Replace regex with AST in unwrap_rootmodel_unions() for robustness
  with nested brackets; use bracket-depth counting for type extraction
- Add validate_union() test helper in tests/conftest.py with caching;
  migrate all 7 test files from raw TypeAdapter usage
- Fix basic_usage.py to use GetProductsRequest object instead of kwargs
- Fix type annotations: dict[type | UnionType, TypeAdapter[Any]]
- Fix import sorting and line length for ruff compliance
@KonstantinMirin
Copy link
Collaborator Author

Thanks for the thorough review @bokelley! All items addressed in 81d3fd3.

Blockers:

  • TypeAdapter caching — Done. Used a plain dict cache instead of lru_cache since UnionType isn't hashable with functools.lru_cache in all Python versions, but works fine as a dict key. Same O(1) lookup, no size limit.
  • Safe __name__ — Fixed all 3 sites in response_parser.py with getattr(x, "__name__", str(x)). Also removed a dead get_args() fallback (lines 40–42) that was unreachable since Python 3.10.

Should-fix:

  1. Uniform _make_request() — Applied to all 16 methods. Only get_signals keeps custom dispatch (it routes by "signal_ids" in kwargs which TypeAdapter can't infer).
  2. buying_mode — This is a v3 spec requirement, not a UX regression from this PR. Updated all examples, tests, README, and docstrings. Added a note to the PR description.
  3. Any cleanup — Dropped the outer condition entirely; the inner after_imports check is the only guard needed.

Non-blocking (all done):

  • AST rewriteunwrap_rootmodel_unions() now uses ast.parse() + ast.walk() like add_rootmodel_getattr_proxy(). Uses bracket-depth counting for the RootModel[...] extraction to handle nested generics.
  • Test helper — Added validate_union() in tests/conftest.py with its own adapter cache. Migrated all 7 test files.
  • Field metadata — Verified: all types in _UNWRAP_TO_UNION are Request/Response types whose root: fields had no meaningful Field(description=..., examples=[...]) metadata. Added a docstring note.

ruff and mypy both pass clean on all changed files.

Union type aliases (e.g. GetProductsRequest = Req1 | Req2 | Req3) are
not callable and have no .model_validate() — they are types.UnionType
objects, not classes. Pydantic's TypeAdapter provides the equivalent
runtime validation for union types.

- __main__.py: dispatch table stores TypeAdapter instances; call site
  uses adapter.validate_python(payload) instead of type(**payload)
- sponsored_intelligence.py: replace SiSendMessageRequest.model_validate()
  with TypeAdapter.validate_python()
- preview_cache.py: replace PreviewCreativeRequest() constructor with
  TypeAdapter.validate_python()
- CLAUDE.md: document pre-commit quality gates (ruff, mypy, pytest)
@bokelley bokelley merged commit 8fd7b92 into adcontextprotocol:main Mar 13, 2026
8 checks passed
KonstantinMirin added a commit to KonstantinMirin/adcp-client-python that referenced this pull request Mar 14, 2026
…sses

Follow-up to adcontextprotocol#156. The previous fix unwrapped RootModel unions to Union
type aliases, but Union aliases are also non-subclassable — consumers
that do `class MyType(LibraryType)` still break.

Root cause: JSON Schema uses anyOf/oneOf with required-only branches to
express "at least one of these fields must be set." datamodel-code-generator
misinterprets this as a type union, generating separate variant classes
(e.g., FrequencyCap1/2/3) plus a RootModel wrapper. Neither RootModels
nor Union type aliases can be subclassed by consumers.

Fix: Added flatten_validation_oneof() to generate_types.py that detects
required-only anyOf/oneOf patterns in schemas and removes them before
code generation. This produces a single BaseModel class per type —
restoring the v3.6 API surface where all types were subclassable.

10 types flattened:
- FrequencyCap, UserMatch, AudienceMember, Catchment (value types)
- GetSignalsRequest, UpdateMediaBuyRequest, GetCreativeDeliveryRequest,
  ProvidePerformanceFeedbackRequest, SiSendMessageRequest,
  PackageUpdate (request types)

Also adds a consumer subclassability contract test that guards 27
consumer-facing types against becoming non-subclassable in future
schema updates.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RootModel unions block consumer subclassing — propose post-generation unwrap to Union aliases

2 participants