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
2 changes: 2 additions & 0 deletions src/adcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@
CreateContentStandardsErrorResponse,
CreateContentStandardsSuccessResponse,
CreateMediaBuyErrorResponse,
CreateMediaBuySubmittedResponse,
CreateMediaBuySuccessResponse,
Deployment,
Destination,
Expand Down Expand Up @@ -890,6 +891,7 @@ def get_adcp_version() -> str:
"CreateContentStandardsErrorResponse",
"CreateMediaBuySuccessResponse",
"CreateMediaBuyErrorResponse",
"CreateMediaBuySubmittedResponse",
"Deployment",
"Destination",
"GetContentStandardsSuccessResponse",
Expand Down
6 changes: 3 additions & 3 deletions src/adcp/decisioning/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@
CreateContentStandardsRequest,
CreateContentStandardsResponse,
CreateMediaBuyRequest,
CreateMediaBuySuccessResponse,
CreateMediaBuyResponse,
CreatePropertyListRequest,
CreatePropertyListResponse,
DeleteCollectionListRequest,
Expand Down Expand Up @@ -1305,7 +1305,7 @@ async def create_media_buy( # type: ignore[override]
self,
params: CreateMediaBuyRequest,
context: ToolContext | None = None,
) -> CreateMediaBuySuccessResponse:
) -> CreateMediaBuyResponse:
from adcp.decisioning.types import AdcpError

tool_ctx = context or ToolContext()
Expand Down Expand Up @@ -1404,7 +1404,7 @@ async def _release_reservation_hook(_exc: BaseException) -> None:
on_failure=on_failure,
)
self._maybe_auto_emit_sync_completion("create_media_buy", params, result)
return cast("CreateMediaBuySuccessResponse", result)
return cast("CreateMediaBuyResponse", result)

async def update_media_buy( # type: ignore[override]
self,
Expand Down
2 changes: 2 additions & 0 deletions src/adcp/types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,7 @@
CreateContentStandardsErrorResponse,
CreateContentStandardsSuccessResponse,
CreateMediaBuyErrorResponse,
CreateMediaBuySubmittedResponse,
CreateMediaBuySuccessResponse,
CssFormatAsset,
CssFormatGroupAsset,
Expand Down Expand Up @@ -1118,6 +1119,7 @@ def __init__(self, *args: object, **kwargs: object) -> None:
"CreateContentStandardsErrorResponse",
"CreateContentStandardsSuccessResponse",
"CreateMediaBuyErrorResponse",
"CreateMediaBuySubmittedResponse",
"CreateMediaBuySuccessResponse",
"Deployment",
"Destination",
Expand Down
16 changes: 16 additions & 0 deletions src/adcp/types/aliases.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
# Create media buy responses
CreateMediaBuyResponse1,
CreateMediaBuyResponse2,
CreateMediaBuyResponse3,
# DAAST assets
DaastAsset1,
DaastAsset2,
Expand Down Expand Up @@ -291,6 +292,20 @@
CreateMediaBuyErrorResponse = CreateMediaBuyResponse2
"""Error response - media buy creation failed, no media buy created."""

CreateMediaBuySubmittedResponse = CreateMediaBuyResponse3
"""Submitted (async) envelope - operation accepted for asynchronous processing.

Returned when the seller has handed the request off to a long-running task
(e.g., HITL approval, governance review). The buyer receives a ``task_id`` to
poll via ``tasks/get`` or to correlate with push-notification callbacks; the
``media_buy_id`` is issued later on the completion artifact, not here.

Status discriminator: ``status == 'submitted'`` (task-level), distinguishing
this envelope from the synchronous success branch whose ``status`` field
carries a ``MediaBuyStatus`` value (``pending_creatives``, ``pending_start``,
``active``).
"""

# Performance Feedback Response Variants
ProvidePerformanceFeedbackSuccessResponse = ProvidePerformanceFeedbackResponse1
"""Success response - performance feedback accepted."""
Expand Down Expand Up @@ -1489,6 +1504,7 @@ def get_pricing(options: list[PricingOption]) -> None:
# Create media buy responses
"CreateMediaBuySuccessResponse",
"CreateMediaBuyErrorResponse",
"CreateMediaBuySubmittedResponse",
# Creative delivery requests
"GetCreativeDeliveryByMediaBuyRequest",
"GetCreativeDeliveryByBuyerRefRequest",
Expand Down
47 changes: 44 additions & 3 deletions src/adcp/types/guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ def is_adcp_success(response: Any) -> bool:
CalibrateContentErrorResponse,
CalibrateContentSuccessResponse,
CreateMediaBuyErrorResponse,
CreateMediaBuySubmittedResponse,
CreateMediaBuySuccessResponse,
GetAccountFinancialsErrorResponse,
GetAccountFinancialsSuccessResponse,
Expand All @@ -103,7 +104,9 @@ def is_adcp_success(response: Any) -> bool:
)

# Type aliases for response unions
CreateMediaBuyResponse = CreateMediaBuySuccessResponse | CreateMediaBuyErrorResponse
CreateMediaBuyResponse = (
CreateMediaBuySuccessResponse | CreateMediaBuyErrorResponse | CreateMediaBuySubmittedResponse
)
UpdateMediaBuyResponse = UpdateMediaBuySuccessResponse | UpdateMediaBuyErrorResponse
ActivateSignalResponse = ActivateSignalSuccessResponse | ActivateSignalErrorResponse
BuildCreativeResponse = BuildCreativeSuccessResponse | BuildCreativeErrorResponse
Expand All @@ -116,22 +119,49 @@ def is_adcp_success(response: Any) -> bool:

# --- Create Media Buy ---


def is_create_media_buy_submitted(
response: CreateMediaBuyResponse,
) -> TypeGuard[CreateMediaBuySubmittedResponse]:
"""Check if a CreateMediaBuyResponse is the async submitted envelope.

The submitted branch carries ``status == 'submitted'`` and a ``task_id``
the buyer uses to poll ``tasks/get`` (or correlate with push-notification
callbacks). It is neither a synchronous success nor a terminal error.
"""
return getattr(response, "status", None) == "submitted" and hasattr(response, "task_id")


def is_create_media_buy_success(
response: CreateMediaBuyResponse,
) -> TypeGuard[CreateMediaBuySuccessResponse]:
"""Check if a CreateMediaBuyResponse is a success."""
"""Check if a CreateMediaBuyResponse is a synchronous success.

Returns False for the submitted (async) envelope — use
``is_create_media_buy_submitted`` for that branch.
"""
if is_create_media_buy_submitted(response):
return False
return not is_adcp_error(response)


def is_create_media_buy_error(
response: CreateMediaBuyResponse,
) -> TypeGuard[CreateMediaBuyErrorResponse]:
"""Check if a CreateMediaBuyResponse is an error."""
"""Check if a CreateMediaBuyResponse is an error.

Returns False for the submitted (async) envelope, even if it carries
advisory (non-blocking) errors. Use ``is_create_media_buy_submitted``
for that branch.
"""
if is_create_media_buy_submitted(response):
return False
return is_adcp_error(response)


# --- Update Media Buy ---


def is_update_media_buy_success(
response: UpdateMediaBuyResponse,
) -> TypeGuard[UpdateMediaBuySuccessResponse]:
Expand All @@ -148,6 +178,7 @@ def is_update_media_buy_error(

# --- Activate Signal ---


def is_activate_signal_success(
response: ActivateSignalResponse,
) -> TypeGuard[ActivateSignalSuccessResponse]:
Expand All @@ -164,6 +195,7 @@ def is_activate_signal_error(

# --- Build Creative ---


def is_build_creative_success(
response: BuildCreativeResponse,
) -> TypeGuard[BuildCreativeSuccessResponse]:
Expand All @@ -180,6 +212,7 @@ def is_build_creative_error(

# --- Sync Creatives ---


def is_sync_creatives_success(
response: SyncCreativesResponse,
) -> TypeGuard[SyncCreativesSuccessResponse]:
Expand All @@ -196,6 +229,7 @@ def is_sync_creatives_error(

# --- Performance Feedback ---


def is_performance_feedback_success(
response: ProvidePerformanceFeedbackSuccessResponse | ProvidePerformanceFeedbackErrorResponse,
) -> TypeGuard[ProvidePerformanceFeedbackSuccessResponse]:
Expand All @@ -212,6 +246,7 @@ def is_performance_feedback_error(

# --- Sync Accounts ---


def is_sync_accounts_success(
response: SyncAccountsResponse,
) -> TypeGuard[SyncAccountsSuccessResponse]:
Expand All @@ -228,6 +263,7 @@ def is_sync_accounts_error(

# --- Log Event ---


def is_log_event_success(
response: LogEventResponse,
) -> TypeGuard[LogEventSuccessResponse]:
Expand All @@ -244,6 +280,7 @@ def is_log_event_error(

# --- Sync Catalogs ---


def is_sync_catalogs_success(
response: SyncCatalogsResponse,
) -> TypeGuard[SyncCatalogsSuccessResponse]:
Expand All @@ -260,6 +297,7 @@ def is_sync_catalogs_error(

# --- Get Account Financials ---


def is_get_account_financials_success(
response: GetAccountFinancialsSuccessResponse | GetAccountFinancialsErrorResponse,
) -> TypeGuard[GetAccountFinancialsSuccessResponse]:
Expand All @@ -276,6 +314,7 @@ def is_get_account_financials_error(

# --- Content Standards ---


def is_calibrate_content_success(
response: CalibrateContentSuccessResponse | CalibrateContentErrorResponse,
) -> TypeGuard[CalibrateContentSuccessResponse]:
Expand All @@ -292,6 +331,7 @@ def is_validate_content_delivery_success(

# --- Creative Features ---


def is_get_creative_features_success(
response: GetCreativeFeaturesSuccessResponse | GetCreativeFeaturesErrorResponse,
) -> TypeGuard[GetCreativeFeaturesSuccessResponse]:
Expand All @@ -310,6 +350,7 @@ def is_get_creative_features_success(
# Media buy guards
"is_create_media_buy_success",
"is_create_media_buy_error",
"is_create_media_buy_submitted",
"is_update_media_buy_success",
"is_update_media_buy_error",
# Signal guards
Expand Down
2 changes: 2 additions & 0 deletions tests/fixtures/public_api_snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
"CreateMediaBuyErrorResponse",
"CreateMediaBuyRequest",
"CreateMediaBuyResponse",
"CreateMediaBuySubmittedResponse",
"CreateMediaBuySuccessResponse",
"Creative",
"CreativeApproval",
Expand Down Expand Up @@ -492,6 +493,7 @@
"CreateMediaBuyErrorResponse",
"CreateMediaBuyRequest",
"CreateMediaBuyResponse",
"CreateMediaBuySubmittedResponse",
"CreateMediaBuySuccessResponse",
"CreatePropertyListRequest",
"CreatePropertyListResponse",
Expand Down
84 changes: 84 additions & 0 deletions tests/test_create_media_buy_response_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""Tests for CreateMediaBuyResponse 3-branch discriminated union.

The wire union has three legitimate shapes:

1. ``CreateMediaBuySuccessResponse`` (sync success): ``media_buy_id`` + ``packages``.
2. ``CreateMediaBuyErrorResponse`` (sync error): ``errors[]``.
3. ``CreateMediaBuySubmittedResponse`` (async accepted): ``status='submitted'``
+ ``task_id`` — buyer polls via tasks/get; ``media_buy_id`` is issued later
on the completion artifact.

These tests validate that the public alias surface covers all three branches
and that the submitted-branch payload validates against its alias.
"""

from __future__ import annotations

import typing

import pytest


def test_submitted_alias_resolves_to_third_branch() -> None:
"""The submitted alias must point at CreateMediaBuyResponse3 — the
branch with status='submitted' + task_id."""
from adcp.types import CreateMediaBuySubmittedResponse
from adcp.types._generated import CreateMediaBuyResponse3

assert CreateMediaBuySubmittedResponse is CreateMediaBuyResponse3


def test_submitted_payload_validates() -> None:
"""A spec-compliant submitted payload validates via model_validate."""
from adcp.types import CreateMediaBuySubmittedResponse

payload = {
"status": "submitted",
"task_id": "task_abc123",
}
resp = CreateMediaBuySubmittedResponse.model_validate(payload)
assert resp.status == "submitted"
assert resp.task_id == "task_abc123"


def test_submitted_payload_with_optional_message_and_errors() -> None:
"""Optional advisory fields on the submitted envelope are accepted."""
from adcp.types import CreateMediaBuySubmittedResponse

payload = {
"status": "submitted",
"task_id": "task_xyz",
"message": "Awaiting IO signature; typical turnaround 2-4 hours.",
}
resp = CreateMediaBuySubmittedResponse.model_validate(payload)
assert resp.message is not None
assert "IO signature" in resp.message


def test_submitted_payload_missing_task_id_rejected() -> None:
"""task_id is required — a malformed submitted envelope must fail.

The triage of issue #570 traced the original FastMCP error to a
submitted-branch payload missing task_id. The schema is correct;
this test pins that the missing-field validation works.
"""
from pydantic import ValidationError

from adcp.types import CreateMediaBuySubmittedResponse

with pytest.raises(ValidationError):
CreateMediaBuySubmittedResponse.model_validate({"status": "submitted"})


def test_handler_create_media_buy_return_type_is_union() -> None:
"""The PlatformHandler.create_media_buy annotation must be the
3-branch union (CreateMediaBuyResponse), not the success branch
alone — adopters legitimately return any of the three shapes.
"""
from adcp.decisioning.handler import PlatformHandler
from adcp.types import CreateMediaBuyResponse

# handler.py uses ``from __future__ import annotations`` (PEP 563),
# so signatures carry strings — resolve to runtime objects.
hints = typing.get_type_hints(PlatformHandler.create_media_buy)
assert hints["return"] == CreateMediaBuyResponse
29 changes: 29 additions & 0 deletions tests/test_type_guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
is_adcp_error,
is_adcp_success,
is_create_media_buy_error,
is_create_media_buy_submitted,
is_create_media_buy_success,
is_update_media_buy_error,
is_update_media_buy_success,
Expand Down Expand Up @@ -93,6 +94,34 @@ def test_create_media_buy_error_guard(self) -> None:
)
assert is_create_media_buy_success(resp) is False
assert is_create_media_buy_error(resp) is True
assert is_create_media_buy_submitted(resp) is False

def test_create_media_buy_submitted_guard(self) -> None:
"""Submitted (async) envelope is neither success nor error."""
from adcp.types.aliases import (
CreateMediaBuyErrorResponse,
CreateMediaBuySubmittedResponse,
CreateMediaBuySuccessResponse,
)

submitted = CreateMediaBuySubmittedResponse.model_validate(
{"status": "submitted", "task_id": "task_abc"}
)
assert is_create_media_buy_submitted(submitted) is True
assert is_create_media_buy_success(submitted) is False
assert is_create_media_buy_error(submitted) is False

# Negative cases: success and error payloads must not be classified
# as submitted.
success = CreateMediaBuySuccessResponse.model_validate(
{"media_buy_id": "mb_123", "packages": []}
)
assert is_create_media_buy_submitted(success) is False

error = CreateMediaBuyErrorResponse.model_validate(
{"errors": [{"message": "fail", "code": "INVALID_REQUEST"}]}
)
assert is_create_media_buy_submitted(error) is False

def test_update_media_buy_guards(self) -> None:
from adcp.types.aliases import (
Expand Down
Loading