Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions docs/extending-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,38 @@ This guide shows how to extend ADCP types safely while maintaining protocol comp
> overrides to walk children — Pydantic does the walking; this guide covers the two seams
> (`Field(exclude=True)` and `@model_serializer`) that hook into it.

## Object Unions Are Type Aliases

Object unions such as `AccountReference`, `PostalArea`, `SignalRef`, and
`PricingOption` are public union aliases, not Pydantic `RootModel` classes.
This keeps their object arms composable and subclassable when an adopter needs
a stricter `model_config` or internal excluded fields, without imposing another
public wrapper around the constituent schema arms.

Construct a known arm directly. For example:

```python
from adcp.types import AccountReferenceById

account = AccountReferenceById(account_id="acct-1")
```

For untrusted data where the arm is not known in advance, use Pydantic's
`TypeAdapter`:

```python
from pydantic import TypeAdapter
from adcp.types import AccountReference

account = TypeAdapter(AccountReference).validate_python(raw_account)
```

Code written against the former wrappers should replace
`AccountReference.model_validate(raw)` (and the corresponding calls on
`PostalArea` or `SignalRef`) with `TypeAdapter(...).validate_python(raw)`.
The validated wire shapes are unchanged; only the unnecessary outer public
wrapper is removed, and validation returns the selected schema arm directly.

## Picking the Right Base Class — Context-Specific Schema Variants

Several entity names (`Creative`, `Package`, `MediaBuy`, etc.) appear in multiple spec slices with **genuinely different shapes**. The bare name resolves to one specific variant — typically not the one you want when extending response types. The creative inside `ListCreativesResponse.creatives` is a different class from the creative inside `GetCreativeDeliveryResponse.creatives`, even though both are spelled `Creative` in the spec. Subclassing the wrong variant produces silent type drift: construction works, but `mypy` flags `[assignment]` when you wire your subclass into the response that expects a different variant, and runtime serialization may drop fields the consuming code expects.
Expand Down
7 changes: 4 additions & 3 deletions src/adcp/compat/purchase_continuation.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
from urllib.parse import unquote_plus, urlsplit

import rfc8785
from pydantic import BaseModel, ValidationError
from pydantic import BaseModel, TypeAdapter, ValidationError

from adcp.types import AccountReference, CompatibilityPurchaseCoordinatorInput
from adcp.types.core import TaskResult, TaskStatus
Expand All @@ -40,6 +40,7 @@
)

JsonObject: TypeAlias = dict[str, Any]
_ACCOUNT_REFERENCE_ADAPTER: TypeAdapter[AccountReference] = TypeAdapter(AccountReference)
LegacyPurchaseResult: TypeAlias = Mapping[str, Any] | BaseModel | TaskResult[Any]
LegacyPurchaseExecutor: TypeAlias = Callable[
["LegacyPurchaseExecution"], LegacyPurchaseResult | Awaitable[LegacyPurchaseResult]
Expand Down Expand Up @@ -1515,10 +1516,10 @@ def _account_payload(value: Mapping[str, Any] | Any) -> JsonObject:
try:
source = (
value.model_dump(mode="python", by_alias=True)
if isinstance(value, AccountReference)
if isinstance(value, BaseModel)
else value
)
model = AccountReference.model_validate(source)
model = _ACCOUNT_REFERENCE_ADAPTER.validate_python(source)
payload = model.model_dump(mode="json", by_alias=True, exclude_none=True)
except ValidationError as exc:
raise _invalid("account must be a valid beta.4 AccountReference") from exc
Expand Down
7 changes: 3 additions & 4 deletions src/adcp/decisioning/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,8 @@ def ref_account_id(
value = ref.get("account_id")
return value if isinstance(value, str) else None

# AccountReference is a RootModel wrapping AccountReference1 |
# AccountReference2. Its __getattr__ proxies to .root, so a direct
# ``ref.account_id`` raises AttributeError on the natural-key arm.
# getattr() with a default is the cleanest cross-arm read.
# getattr() with a default is the cleanest cross-arm read and also keeps
# compatibility with request models parsed by older SDK releases, where
# AccountReference was represented by a proxying RootModel wrapper.
value = getattr(ref, "account_id", None)
return value if isinstance(value, str) else None
6 changes: 3 additions & 3 deletions src/adcp/types/_eager.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@
Account,
AccountAuthorization,
AccountMovedDetails,
AccountReference,
AccountScope,
AccountSetupRequiredDetails,
AccountWithAuthorization,
Expand Down Expand Up @@ -273,7 +272,6 @@
PlacementPresentationReference,
PlacementReference,
PolicyViolationDetails,
PostalArea,
PreviewOutputFormat,
PreviewRender,
PreviewRendererMetadata,
Expand Down Expand Up @@ -345,7 +343,6 @@
SignalFilters,
SignalListing,
SignalPricingOption,
SignalRef,
SignalTargeting,
SignalTargetingExpression,
SignalTargetingRules,
Expand Down Expand Up @@ -478,6 +475,7 @@
# Import semantic aliases for discriminated unions
from adcp.types.aliases import (
AccountIdReference,
AccountReference,
AccountReferenceById,
AccountReferenceByNaturalKey,
AcquireRightsAcquiredResponse,
Expand Down Expand Up @@ -649,6 +647,7 @@
PixelTrackerMethod,
PlatformDeployment,
PlatformDestination,
PostalArea,
PreviewRenderingOrigin,
PricingOption,
ProductAllocation,
Expand Down Expand Up @@ -684,6 +683,7 @@
SegmentIdActivationKey,
SignalCoverageForecast,
SignalCoverageRange,
SignalRef,
SiSendActionResponseRequest,
SiSendTextMessageRequest,
SiSponsoredContextDeclaredBy,
Expand Down
29 changes: 28 additions & 1 deletion src/adcp/types/aliases.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
from typing import Annotated as _Annotated
from typing import Any, Literal, TypeAlias

from pydantic import ConfigDict, Discriminator, Tag
from pydantic import BeforeValidator, ConfigDict, Discriminator, Tag

from adcp.types import _generated as _g
from adcp.types._generated import (
Expand Down Expand Up @@ -77,12 +77,19 @@
PreviewRender1, # output_format='url'
PreviewRender2, # output_format='html'
PreviewRender3, # output_format='both'
# Postal area variants
PostalArea1,
PostalArea2,
# Publisher properties types
PropertyId,
PropertyTag,
ProvidePerformanceFeedbackRequest,
# (SignalPricingOption is now a single RootModel wrapping VendorPricingOption.)
SiSendMessageRequest,
# Signal reference variants
SignalRef1,
SignalRef2,
SignalRef3,
TimeBasedPricingOption,
UpdateMediaBuyRequest,
VcpmPricingOption,
Expand Down Expand Up @@ -532,6 +539,14 @@ def _generated_alias(name: str, fallback_name: str) -> Any:
# - Use when the seller resolves accounts internally from brand identity
# - Requires brand reference + operator domain

AccountReference = AccountReference1 | AccountReference2

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

MUST FIX: Breaking public-surface change under a non-breaking prefix. AccountReference, PostalArea, and SignalRef were public RootModel classes; this commit reshapes them into a union alias (542) and two Annotated aliases (597, 603). That changes the type signature of three public adcp.types exports: AccountReference.model_validate(...) / AccountReference(root=...) stop working, and isinstance(x, PostalArea) / isinstance(x, SignalRef) now raise TypeError on the Annotated form. The migrated call sites in this PR (AccountReference(root=...)AccountReferenceById(...), .model_validateTypeAdapter(...)) are exactly the adopter code that breaks.

Commit is fix(types): expose object unions without RootModel with an empty body — no !, no BREAKING CHANGE: footer. release-please cuts a patch from fix:, so the break ships without a major/breaking beta signal. Per repo policy this is a high. Carry fix!: or add a BREAKING CHANGE: footer. The PR body already has the migration note; only the commit signal is missing.

"""Account reference union without a generated ``RootModel`` wrapper.

Validate untrusted data with ``TypeAdapter(AccountReference)``. Construct a
known arm with :class:`AccountReferenceById` or
:class:`AccountReferenceByNaturalKey`.
"""

AccountReferenceById = AccountReference1
"""Account reference using a seller-assigned account ID.

Expand Down Expand Up @@ -576,6 +591,18 @@ def _generated_alias(name: str, fallback_name: str) -> Any:
AccountIdReference = AccountReference1
InlineAccountReference = AccountReference2

# These public names intentionally expose the schema unions directly instead
# of the generator's outer RootModel wrappers. They compose cleanly in adopter
# annotations without imposing another wrapper around their constituent arms.
PostalArea = _Annotated[
PostalArea1 | PostalArea2,
BeforeValidator(_g.PostalArea._validate_country_system_pairing),
]
"""Postal-area union; validate raw values with ``TypeAdapter(PostalArea)``."""

SignalRef = _Annotated[SignalRef1 | SignalRef2 | SignalRef3, Discriminator("scope")]
"""Signal-reference union; validate raw values with ``TypeAdapter(SignalRef)``."""

# ============================================================================
# RESPONSE TYPE ALIASES - Success/Error Discriminated Unions
# ============================================================================
Expand Down
12 changes: 6 additions & 6 deletions tests/test_account_v3_wire.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
from adcp.server.helpers import STANDARD_ERROR_CODES, TERMINAL_CODES
from adcp.types import (
AccountAuthorization,
AccountReference,
AccountReferenceById,
AccountScope,
Authentication,
AuthorizationRequiredDetails,
Expand Down Expand Up @@ -709,7 +709,7 @@ def test_to_wire_sync_governance_row_strips_authentication() -> None:
calls. The framework strips it at the wire boundary so it never
reaches the buyer OR the idempotency replay cache."""
row = SyncGovernanceResultRow(
account=AccountReference(root={"account_id": "acct_1"}),
account=AccountReferenceById(account_id="acct_1"),
status="synced",
governance_agents=[
{
Expand All @@ -734,7 +734,7 @@ def test_to_wire_sync_governance_row_handles_empty_clear() -> None:
"""An entry whose governance_agents is empty clears the binding
for that account (replace semantics per spec)."""
row = SyncGovernanceResultRow(
account=AccountReference(root={"account_id": "acct_1"}),
account=AccountReferenceById(account_id="acct_1"),
status="synced",
governance_agents=[],
)
Expand All @@ -747,7 +747,7 @@ def test_to_wire_sync_governance_row_per_entry_failure() -> None:
"""Per-entry rejection (not operation-level throw) so a single
bad entry doesn't fail the whole batch."""
row = SyncGovernanceResultRow(
account=AccountReference(root={"account_id": "acct_1"}),
account=AccountReferenceById(account_id="acct_1"),
status="failed",
errors=[
{
Expand Down Expand Up @@ -868,7 +868,7 @@ def upsert_with_billing_gate(
):
raise AdcpError(
"BILLING_NOT_PERMITTED_FOR_AGENT",
message=(f"Agent {ctx.agent.agent_url!r} cannot bill " f"as {requested!r}"),
message=(f"Agent {ctx.agent.agent_url!r} cannot bill as {requested!r}"),
field="billing",
recovery="terminal",
)
Expand Down Expand Up @@ -915,7 +915,7 @@ def test_sync_governance_entry_carries_authentication_on_input() -> None:
only — input shape preserves credentials for the persistence
step."""
entry = SyncGovernanceEntry(
account=AccountReference(root={"account_id": "acct_1"}),
account=AccountReferenceById(account_id="acct_1"),
governance_agents=[
{
"url": "https://gov.example.com/",
Expand Down
7 changes: 5 additions & 2 deletions tests/test_adcp_31_beta4_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ def test_structured_placement_references_round_trip_on_assignments() -> None:


def test_signal_refs_and_targeting_validate_new_grouped_shapes() -> None:
from pydantic import TypeAdapter

from adcp import (
PackageRequest,
PackageSignalTargetingGroups,
Expand All @@ -106,8 +108,9 @@ def test_signal_refs_and_targeting_validate_new_grouped_shapes() -> None:
"signal_id": "auto_intenders",
}

assert SignalRef.model_validate(signal_ref).scope == "product"
assert SignalRef.model_validate(data_provider_ref).data_provider_domain == (
adapter = TypeAdapter(SignalRef)
assert adapter.validate_python(signal_ref).scope == "product"
assert adapter.validate_python(data_provider_ref).data_provider_domain == (
"signals.example.com"
)

Expand Down
10 changes: 5 additions & 5 deletions tests/test_capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import pytest

from adcp import AccountReference, ADCPClient, SyncEventSourcesRequest
from adcp import AccountReferenceById, ADCPClient, SyncEventSourcesRequest
from adcp.capabilities import FeatureResolver, validate_capabilities
from adcp.exceptions import ADCPError, ADCPFeatureUnsupportedError
from adcp.server.base import ADCPHandler
Expand Down Expand Up @@ -442,7 +442,7 @@ async def test_sync_event_sources_requires_property_list_filtering(self):

request = SyncEventSourcesRequest(
idempotency_key="test-idempotency-key",
account=AccountReference(account_id="acc1"),
account=AccountReferenceById(account_id="acc1"),
)

with pytest.raises(ADCPFeatureUnsupportedError, match="property_list_filtering"):
Expand Down Expand Up @@ -475,7 +475,7 @@ async def test_validation_passes_when_feature_supported(self):
result = await client.sync_event_sources(
SyncEventSourcesRequest(
idempotency_key="test-idempotency-key",
account=AccountReference(account_id="acc1"),
account=AccountReferenceById(account_id="acc1"),
)
)
assert result is not None
Expand All @@ -497,7 +497,7 @@ async def test_validation_skipped_when_not_opted_in(self):
result = await client.sync_event_sources(
SyncEventSourcesRequest(
idempotency_key="test-idempotency-key",
account=AccountReference(account_id="acc1"),
account=AccountReferenceById(account_id="acc1"),
)
)
assert result is not None
Expand All @@ -518,7 +518,7 @@ async def test_validation_skipped_when_no_capabilities(self):
result = await client.sync_event_sources(
SyncEventSourcesRequest(
idempotency_key="test-idempotency-key",
account=AccountReference(account_id="acc1"),
account=AccountReferenceById(account_id="acc1"),
)
)
assert result is not None
Expand Down
10 changes: 5 additions & 5 deletions tests/test_decisioning_handler_shims.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,9 @@ def test_handler_shim_method_exists(tool_name: str) -> None:
"""Every advertised non-sales tool has a corresponding shim method
on PlatformHandler. Without this, ``tools/list`` advertises tools
the handler can't actually dispatch — buyer-facing 404."""
assert hasattr(PlatformHandler, tool_name), (
f"PlatformHandler is missing the {tool_name!r} shim — " "advertised but undispatchable."
)
assert hasattr(
PlatformHandler, tool_name
), f"PlatformHandler is missing the {tool_name!r} shim — advertised but undispatchable."


# ---- Shim dispatch via stub platforms ----
Expand Down Expand Up @@ -1170,7 +1170,7 @@ def update_rights(self, req, ctx):
async def test_rights_mutations_preserve_explicit_account_context(executor) -> None:
"""Both beta.5 rights mutations route the request's account reference."""
from adcp.decisioning.types import Account
from adcp.types import AccountReference, AcquireRightsRequest, UpdateRightsRequest
from adcp.types import AccountReferenceById, AcquireRightsRequest, UpdateRightsRequest

resolved_refs = []
invoked = []
Expand Down Expand Up @@ -1206,7 +1206,7 @@ def update_rights(self, req, ctx):
invoked.append(("update", ctx.account.id))
return {"rights_id": "r_1", "status": "updated"}

account_ref = AccountReference(root={"account_id": "acct-brand"})
account_ref = AccountReferenceById(account_id="acct-brand")
handler = PlatformHandler(
_BrandRightsAgent(),
executor=executor,
Expand Down
34 changes: 34 additions & 0 deletions tests/test_discriminated_unions.py
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,40 @@ def test_create_media_buy_response_is_union_alias(self):

assert isinstance(CreateMediaBuyResponse, types.UnionType)

@pytest.mark.parametrize("name", ["AccountReference", "PostalArea", "SignalRef"])
def test_public_object_unions_are_not_rootmodel_wrappers(self, name: str):
"""Composable object unions are exposed as aliases, not RootModel classes."""
import adcp.types

public_alias = getattr(adcp.types, name)
assert not isinstance(public_alias, type)

def test_account_reference_union_validates_and_arms_remain_subclassable(self):
"""Consumers can validate the union and customize a concrete arm."""
from pydantic import ConfigDict, TypeAdapter

from adcp.types import AccountReference, AccountReferenceById

parsed = TypeAdapter(AccountReference).validate_python({"account_id": "acct-1"})
assert isinstance(parsed, AccountReferenceById)

class StrictAccountReference(AccountReferenceById):
model_config = ConfigDict(extra="forbid")

with pytest.raises(ValidationError):
StrictAccountReference(account_id="acct-1", legacy_id="old")

def test_signal_reference_alias_preserves_required_discriminator(self):
"""Removing the wrapper must not weaken SignalRef's wire validation."""
from pydantic import TypeAdapter

from adcp.types import SignalRef

with pytest.raises(ValidationError, match="union_tag_not_found"):
TypeAdapter(SignalRef).validate_python(
{"signal_source_url": "https://signals.example", "signal_id": "sig-1"}
)

def test_subclass_get_signals_request_with_extra_forbid(self):
"""Consumer can subclass GetSignalsRequest directly with extra='forbid'."""
from pydantic import ConfigDict
Expand Down
4 changes: 2 additions & 2 deletions tests/test_feed_mirror.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,9 +206,9 @@ async def test_bootstrap_loads_products_and_signals() -> None:
async def test_bootstrap_sends_wholesale_mode_and_account() -> None:
account = {"account_id": "acc_acme"}
client = StubClient(products=[{"products": []}], signals=[{"signals": []}])
from adcp.types import AccountReference
from adcp.types import AccountReferenceById

mirror = FeedMirror(client, account=AccountReference.model_validate(account))
mirror = FeedMirror(client, account=AccountReferenceById.model_validate(account))

await mirror.bootstrap()

Expand Down
Loading
Loading