From 7df2abf3c8eaf1b05288343c5e320fb7a7f74b50 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 20 Dec 2025 22:15:24 -0500 Subject: [PATCH 1/2] feat: extend type ergonomics to response types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the same BeforeValidator coercion pattern from PR #103 to response types. This eliminates the need for cast() calls when constructing response objects with subclass instances or dict coercion. Response types now support: - Dict coercion for context/ext fields - Subclass list acceptance for products, creatives, formats, packages, errors, and media_buy_deliveries Closes #105 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- scripts/generate_ergonomic_coercion.py | 110 ++++++++++++--- src/adcp/types/_ergonomic.py | 185 +++++++++++++++++++++++++ tests/test_type_coercion.py | 152 ++++++++++++++++++++ 3 files changed, 425 insertions(+), 22 deletions(-) diff --git a/scripts/generate_ergonomic_coercion.py b/scripts/generate_ergonomic_coercion.py index b341f6a..9bd5fe7 100644 --- a/scripts/generate_ergonomic_coercion.py +++ b/scripts/generate_ergonomic_coercion.py @@ -36,6 +36,15 @@ "CreateMediaBuyRequest", ] +# Response types to analyze for coercion +RESPONSE_TYPES_TO_ANALYZE = [ + "GetProductsResponse", + "ListCreativesResponse", + "ListCreativeFormatsResponse", + "CreateMediaBuyResponse1", + "GetMediaBuyDeliveryResponse", +] + # Nested types that also need coercion NESTED_TYPES_TO_ANALYZE = [ ("Sort", "media_buy.list_creatives_request"), @@ -45,9 +54,18 @@ # Types that should get subclass_list coercion (for list variance) SUBCLASS_LIST_TYPES = { + # Request list types "CreativeAsset", "CreativeAssignment", "PackageRequest", + # Response list types + "Product", + "Creative", + "Format", + "Package", + "MediaBuyDelivery", + "Error", + "CreativeAgent", } @@ -180,14 +198,23 @@ def get_import_path(cls) -> str: def generate_code() -> str: """Generate the _ergonomic.py module content.""" # Import all the types we need to analyze - from adcp.types.generated_poc.core.context import ContextObject - from adcp.types.generated_poc.core.creative_asset import CreativeAsset - from adcp.types.generated_poc.core.creative_assignment import CreativeAssignment - from adcp.types.generated_poc.core.ext import ExtensionObject from adcp.types.generated_poc.media_buy.create_media_buy_request import CreateMediaBuyRequest + from adcp.types.generated_poc.media_buy.create_media_buy_response import CreateMediaBuyResponse1 + from adcp.types.generated_poc.media_buy.get_media_buy_delivery_response import ( + GetMediaBuyDeliveryResponse, + ) from adcp.types.generated_poc.media_buy.get_products_request import GetProductsRequest - from adcp.types.generated_poc.media_buy.list_creative_formats_request import ListCreativeFormatsRequest + + # Response types + from adcp.types.generated_poc.media_buy.get_products_response import GetProductsResponse + from adcp.types.generated_poc.media_buy.list_creative_formats_request import ( + ListCreativeFormatsRequest, + ) + from adcp.types.generated_poc.media_buy.list_creative_formats_response import ( + ListCreativeFormatsResponse, + ) from adcp.types.generated_poc.media_buy.list_creatives_request import ListCreativesRequest, Sort + from adcp.types.generated_poc.media_buy.list_creatives_response import ListCreativesResponse from adcp.types.generated_poc.media_buy.package_request import PackageRequest from adcp.types.generated_poc.media_buy.update_media_buy_request import Packages, Packages1 @@ -200,6 +227,14 @@ def generate_code() -> str: "CreateMediaBuyRequest": CreateMediaBuyRequest, } + response_classes = { + "GetProductsResponse": GetProductsResponse, + "ListCreativesResponse": ListCreativesResponse, + "ListCreativeFormatsResponse": ListCreativeFormatsResponse, + "CreateMediaBuyResponse1": CreateMediaBuyResponse1, + "GetMediaBuyDeliveryResponse": GetMediaBuyDeliveryResponse, + } + nested_classes = { "Sort": Sort, "Packages": Packages, @@ -210,7 +245,7 @@ def generate_code() -> str: all_coercions = {} all_imports = set() - for name, cls in {**request_classes, **nested_classes}.items(): + for name, cls in {**request_classes, **response_classes, **nested_classes}.items(): coercions = analyze_model(cls) if coercions: all_coercions[name] = (cls, coercions) @@ -238,6 +273,10 @@ def generate_code() -> str: core_imports.append(("ExtensionObject", "core.ext")) core_imports.append(("CreativeAsset", "core.creative_asset")) core_imports.append(("CreativeAssignment", "core.creative_assignment")) + core_imports.append(("Product", "core.product")) + core_imports.append(("Format", "core.format")) + core_imports.append(("Package", "core.package")) + core_imports.append(("Error", "core.error")) # Deduplicate enum_imports = sorted(set(enum_imports)) @@ -317,6 +356,26 @@ def generate_code() -> str: lines.append(' Packages1,') lines.append(')') + # Add response type imports + lines.append('# Response types') + lines.append('from adcp.types.generated_poc.media_buy.create_media_buy_response import (') + lines.append(' CreateMediaBuyResponse1,') + lines.append(')') + lines.append('from adcp.types.generated_poc.media_buy.get_media_buy_delivery_response import (') + lines.append(' GetMediaBuyDeliveryResponse,') + lines.append(' MediaBuyDelivery,') + lines.append(' NotificationType,') + lines.append(')') + lines.append('from adcp.types.generated_poc.media_buy.get_products_response import GetProductsResponse') + lines.append('from adcp.types.generated_poc.media_buy.list_creative_formats_response import (') + lines.append(' CreativeAgent,') + lines.append(' ListCreativeFormatsResponse,') + lines.append(')') + lines.append('from adcp.types.generated_poc.media_buy.list_creatives_response import (') + lines.append(' Creative,') + lines.append(' ListCreativesResponse,') + lines.append(')') + lines.append('') lines.append('') lines.append('def _apply_coercion() -> None:') @@ -329,6 +388,7 @@ def generate_code() -> str: # Generate coercion code for each type # Process in a specific order for readability type_order = [ + # Request types "ListCreativeFormatsRequest", "ListCreativesRequest", "Sort", @@ -337,6 +397,12 @@ def generate_code() -> str: "CreateMediaBuyRequest", "Packages", "Packages1", + # Response types + "GetProductsResponse", + "ListCreativesResponse", + "ListCreativeFormatsResponse", + "CreateMediaBuyResponse1", + "GetMediaBuyDeliveryResponse", ] for type_name in type_order: @@ -368,47 +434,47 @@ def generate_code() -> str: field = c["field"] if c["type"] == "enum": target = c["target_class"].__name__ - lines.append(f' _patch_field_annotation(') + lines.append(' _patch_field_annotation(') lines.append(f' {type_name},') lines.append(f' "{field}",') lines.append(f' Annotated[{target} | None, BeforeValidator(coerce_to_enum({target}))],') - lines.append(f' )') + lines.append(' )') elif c["type"] == "enum_list": target = c["target_class"].__name__ - lines.append(f' _patch_field_annotation(') + lines.append(' _patch_field_annotation(') lines.append(f' {type_name},') lines.append(f' "{field}",') - lines.append(f' Annotated[') + lines.append(' Annotated[') lines.append(f' list[{target}] | None,') lines.append(f' BeforeValidator(coerce_to_enum_list({target})),') - lines.append(f' ],') - lines.append(f' )') + lines.append(' ],') + lines.append(' )') elif c["type"] == "context": - lines.append(f' _patch_field_annotation(') + lines.append(' _patch_field_annotation(') lines.append(f' {type_name},') lines.append(f' "{field}",') - lines.append(f' Annotated[ContextObject | None, BeforeValidator(coerce_to_model(ContextObject))],') - lines.append(f' )') + lines.append(' Annotated[ContextObject | None, BeforeValidator(coerce_to_model(ContextObject))],') + lines.append(' )') elif c["type"] == "ext": - lines.append(f' _patch_field_annotation(') + lines.append(' _patch_field_annotation(') lines.append(f' {type_name},') lines.append(f' "{field}",') - lines.append(f' Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))],') - lines.append(f' )') + lines.append(' Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))],') + lines.append(' )') elif c["type"] == "subclass_list": target = c["target_class"].__name__ # Check if the field is required (no | None) field_info = cls.model_fields[field] is_optional = "None" in str(field_info.annotation) type_str = f'list[{target}] | None' if is_optional else f'list[{target}]' - lines.append(f' _patch_field_annotation(') + lines.append(' _patch_field_annotation(') lines.append(f' {type_name},') lines.append(f' "{field}",') - lines.append(f' Annotated[') + lines.append(' Annotated[') lines.append(f' {type_str},') lines.append(f' BeforeValidator(coerce_subclass_list({target})),') - lines.append(f' ],') - lines.append(f' )') + lines.append(' ],') + lines.append(' )') lines.append(f' {type_name}.model_rebuild(force=True)') lines.append('') diff --git a/src/adcp/types/_ergonomic.py b/src/adcp/types/_ergonomic.py index c94d302..f828aba 100644 --- a/src/adcp/types/_ergonomic.py +++ b/src/adcp/types/_ergonomic.py @@ -44,7 +44,11 @@ from adcp.types.generated_poc.core.context import ContextObject from adcp.types.generated_poc.core.creative_asset import CreativeAsset from adcp.types.generated_poc.core.creative_assignment import CreativeAssignment +from adcp.types.generated_poc.core.error import Error from adcp.types.generated_poc.core.ext import ExtensionObject +from adcp.types.generated_poc.core.format import Format +from adcp.types.generated_poc.core.package import Package +from adcp.types.generated_poc.core.product import Product from adcp.types.generated_poc.enums.asset_content_type import AssetContentType from adcp.types.generated_poc.enums.creative_sort_field import CreativeSortField from adcp.types.generated_poc.enums.format_category import FormatCategory @@ -53,15 +57,34 @@ from adcp.types.generated_poc.media_buy.create_media_buy_request import ( CreateMediaBuyRequest, ) + +# Response types +from adcp.types.generated_poc.media_buy.create_media_buy_response import ( + CreateMediaBuyResponse1, +) +from adcp.types.generated_poc.media_buy.get_media_buy_delivery_response import ( + GetMediaBuyDeliveryResponse, + MediaBuyDelivery, + NotificationType, +) from adcp.types.generated_poc.media_buy.get_products_request import GetProductsRequest +from adcp.types.generated_poc.media_buy.get_products_response import GetProductsResponse from adcp.types.generated_poc.media_buy.list_creative_formats_request import ( ListCreativeFormatsRequest, ) +from adcp.types.generated_poc.media_buy.list_creative_formats_response import ( + CreativeAgent, + ListCreativeFormatsResponse, +) from adcp.types.generated_poc.media_buy.list_creatives_request import ( FieldModel, ListCreativesRequest, Sort, ) +from adcp.types.generated_poc.media_buy.list_creatives_response import ( + Creative, + ListCreativesResponse, +) from adcp.types.generated_poc.media_buy.package_request import PackageRequest from adcp.types.generated_poc.media_buy.update_media_buy_request import ( Packages, @@ -261,6 +284,168 @@ def _apply_coercion() -> None: ) Packages1.model_rebuild(force=True) + # Apply coercion to GetProductsResponse + # - context: ContextObject | dict | None + # - errors: list[Error] (accepts subclass instances) + # - ext: ExtensionObject | dict | None + # - products: list[Product] (accepts subclass instances) + _patch_field_annotation( + GetProductsResponse, + "context", + Annotated[ContextObject | None, BeforeValidator(coerce_to_model(ContextObject))], + ) + _patch_field_annotation( + GetProductsResponse, + "errors", + Annotated[ + list[Error] | None, + BeforeValidator(coerce_subclass_list(Error)), + ], + ) + _patch_field_annotation( + GetProductsResponse, + "ext", + Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))], + ) + _patch_field_annotation( + GetProductsResponse, + "products", + Annotated[ + list[Product], + BeforeValidator(coerce_subclass_list(Product)), + ], + ) + GetProductsResponse.model_rebuild(force=True) + + # Apply coercion to ListCreativesResponse + # - context: ContextObject | dict | None + # - creatives: list[Creative] (accepts subclass instances) + # - ext: ExtensionObject | dict | None + _patch_field_annotation( + ListCreativesResponse, + "context", + Annotated[ContextObject | None, BeforeValidator(coerce_to_model(ContextObject))], + ) + _patch_field_annotation( + ListCreativesResponse, + "creatives", + Annotated[ + list[Creative], + BeforeValidator(coerce_subclass_list(Creative)), + ], + ) + _patch_field_annotation( + ListCreativesResponse, + "ext", + Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))], + ) + ListCreativesResponse.model_rebuild(force=True) + + # Apply coercion to ListCreativeFormatsResponse + # - context: ContextObject | dict | None + # - creative_agents: list[CreativeAgent] (accepts subclass instances) + # - errors: list[Error] (accepts subclass instances) + # - ext: ExtensionObject | dict | None + # - formats: list[Format] (accepts subclass instances) + _patch_field_annotation( + ListCreativeFormatsResponse, + "context", + Annotated[ContextObject | None, BeforeValidator(coerce_to_model(ContextObject))], + ) + _patch_field_annotation( + ListCreativeFormatsResponse, + "creative_agents", + Annotated[ + list[CreativeAgent] | None, + BeforeValidator(coerce_subclass_list(CreativeAgent)), + ], + ) + _patch_field_annotation( + ListCreativeFormatsResponse, + "errors", + Annotated[ + list[Error] | None, + BeforeValidator(coerce_subclass_list(Error)), + ], + ) + _patch_field_annotation( + ListCreativeFormatsResponse, + "ext", + Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))], + ) + _patch_field_annotation( + ListCreativeFormatsResponse, + "formats", + Annotated[ + list[Format], + BeforeValidator(coerce_subclass_list(Format)), + ], + ) + ListCreativeFormatsResponse.model_rebuild(force=True) + + # Apply coercion to CreateMediaBuyResponse1 + # - context: ContextObject | dict | None + # - ext: ExtensionObject | dict | None + # - packages: list[Package] (accepts subclass instances) + _patch_field_annotation( + CreateMediaBuyResponse1, + "context", + Annotated[ContextObject | None, BeforeValidator(coerce_to_model(ContextObject))], + ) + _patch_field_annotation( + CreateMediaBuyResponse1, + "ext", + Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))], + ) + _patch_field_annotation( + CreateMediaBuyResponse1, + "packages", + Annotated[ + list[Package], + BeforeValidator(coerce_subclass_list(Package)), + ], + ) + CreateMediaBuyResponse1.model_rebuild(force=True) + + # Apply coercion to GetMediaBuyDeliveryResponse + # - context: ContextObject | dict | None + # - errors: list[Error] (accepts subclass instances) + # - ext: ExtensionObject | dict | None + # - media_buy_deliveries: list[MediaBuyDelivery] (accepts subclass instances) + # - notification_type: NotificationType | str | None + _patch_field_annotation( + GetMediaBuyDeliveryResponse, + "context", + Annotated[ContextObject | None, BeforeValidator(coerce_to_model(ContextObject))], + ) + _patch_field_annotation( + GetMediaBuyDeliveryResponse, + "errors", + Annotated[ + list[Error] | None, + BeforeValidator(coerce_subclass_list(Error)), + ], + ) + _patch_field_annotation( + GetMediaBuyDeliveryResponse, + "ext", + Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))], + ) + _patch_field_annotation( + GetMediaBuyDeliveryResponse, + "media_buy_deliveries", + Annotated[ + list[MediaBuyDelivery], + BeforeValidator(coerce_subclass_list(MediaBuyDelivery)), + ], + ) + _patch_field_annotation( + GetMediaBuyDeliveryResponse, + "notification_type", + Annotated[NotificationType | None, BeforeValidator(coerce_to_enum(NotificationType))], + ) + GetMediaBuyDeliveryResponse.model_rebuild(force=True) + def _patch_field_annotation( model: type, diff --git a/tests/test_type_coercion.py b/tests/test_type_coercion.py index 7ba6f00..9bc1809 100644 --- a/tests/test_type_coercion.py +++ b/tests/test_type_coercion.py @@ -394,3 +394,155 @@ def test_full_request_roundtrip(self): assert restored.asset_types == [AssetContentType.image, AssetContentType.html] assert restored.context.key == "value" assert restored.name_search == "test" + + +class TestResponseTypeCoercion: + """Test that response types accept flexible input types. + + These tests verify the ergonomics improvements from GitHub issue #105, + which extends type coercion from request types to response types. + """ + + def test_list_creative_formats_response_accepts_dict_context(self): + """ListCreativeFormatsResponse.context accepts dict.""" + from adcp.types import Format, FormatCategory + from adcp.types.generated_poc.media_buy.list_creative_formats_response import ( + ListCreativeFormatsResponse, + ) + + format_obj = Format( + format_id={"agent_url": "https://example.com", "id": "banner-300x250"}, + name="Banner 300x250", + type=FormatCategory.display, + ) + + response = ListCreativeFormatsResponse( + formats=[format_obj], + context={"request_id": "456"}, + ) + assert isinstance(response.context, ContextObject) + assert response.context.request_id == "456" + + def test_list_creative_formats_response_accepts_format_subclass(self): + """ListCreativeFormatsResponse.formats accepts Format subclass instances.""" + from pydantic import Field + + from adcp.types import Format, FormatCategory + from adcp.types.generated_poc.media_buy.list_creative_formats_response import ( + ListCreativeFormatsResponse, + ) + + class ExtendedFormat(Format): + """Extended with internal tracking fields.""" + + internal_id: str | None = Field(None, exclude=True) + + format_obj = ExtendedFormat( + format_id={"agent_url": "https://example.com", "id": "banner-300x250"}, + name="Banner 300x250", + type=FormatCategory.display, + internal_id="format-internal-123", + ) + + # No cast() needed! + response = ListCreativeFormatsResponse( + formats=[format_obj], # type: ignore[list-item] + ) + + assert len(response.formats) == 1 + assert response.formats[0].name == "Banner 300x250" + # Internal field is preserved at runtime + assert response.formats[0].internal_id == "format-internal-123" # type: ignore[attr-defined] + + def test_create_media_buy_response_accepts_package_subclass(self): + """CreateMediaBuyResponse1.packages accepts Package subclass instances.""" + from pydantic import Field + + from adcp.types.generated_poc.core.package import Package + from adcp.types.generated_poc.media_buy.create_media_buy_response import ( + CreateMediaBuyResponse1, + ) + + class ExtendedPackage(Package): + """Extended with internal tracking fields.""" + + campaign_id: str | None = Field(None, exclude=True) + + package = ExtendedPackage( + package_id="pkg1", + campaign_id="campaign-456", + ) + + # No cast() needed! + response = CreateMediaBuyResponse1( + media_buy_id="mb1", + buyer_ref="buyer-ref", + packages=[package], # type: ignore[list-item] + ) + + assert len(response.packages) == 1 + assert response.packages[0].package_id == "pkg1" + # Internal field is preserved at runtime + assert response.packages[0].campaign_id == "campaign-456" # type: ignore[attr-defined] + + def test_get_media_buy_delivery_response_accepts_dict_context(self): + """GetMediaBuyDeliveryResponse.context accepts dict.""" + from datetime import datetime, timezone + + from adcp.types.generated_poc.media_buy.get_media_buy_delivery_response import ( + GetMediaBuyDeliveryResponse, + MediaBuyDelivery, + ) + + delivery = MediaBuyDelivery( + media_buy_id="mb1", + status="active", + by_package=[ + { + "package_id": "pkg1", + "currency": "USD", + "pricing_model": "cpm", + "rate": 10.0, + "impressions": 1000, + "spend": 10.0, + } + ], + totals={"impressions": 1000, "spend": 10.0}, + ) + + response = GetMediaBuyDeliveryResponse( + currency="USD", + reporting_period={ + "start": datetime(2024, 1, 1, tzinfo=timezone.utc), + "end": datetime(2024, 1, 31, tzinfo=timezone.utc), + }, + media_buy_deliveries=[delivery], + context={"request_id": "789"}, + ) + assert isinstance(response.context, ContextObject) + assert response.context.request_id == "789" + + def test_response_serialization_roundtrip(self): + """Response types with coerced values can roundtrip through JSON.""" + from adcp.types import Format, FormatCategory + from adcp.types.generated_poc.media_buy.list_creative_formats_response import ( + ListCreativeFormatsResponse, + ) + + format_obj = Format( + format_id={"agent_url": "https://example.com", "id": "banner-300x250"}, + name="Banner 300x250", + type=FormatCategory.display, + ) + + response = ListCreativeFormatsResponse( + formats=[format_obj], + context={"key": "value"}, + ) + + json_str = response.model_dump_json() + restored = ListCreativeFormatsResponse.model_validate_json(json_str) + + assert len(restored.formats) == 1 + assert restored.formats[0].name == "Banner 300x250" + assert restored.context.key == "value" From 4c9f089677ca9b0895751d649ad1552494ed5fd6 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 20 Dec 2025 22:33:29 -0500 Subject: [PATCH 2/2] refactor: improve test imports and documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update response type tests to import from public API (adcp.types) instead of internal generated_poc modules - Use semantic alias CreateMediaBuySuccessResponse instead of CreateMediaBuyResponse1 for clearer intent - Add clarifying comments for # type: ignore annotations explaining Python list covariance limitation - Add tests for GetProductsResponse.products subclass acceptance - Add tests for errors field coercion - Document _ergonomic.py in CLAUDE.md import architecture section 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 20 +++++++ tests/test_type_coercion.py | 114 ++++++++++++++++++++++++++++-------- 2 files changed, 110 insertions(+), 24 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 05abf77..aaa25e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,26 @@ FormatId = str PackageRequest = dict[str, Any] ``` +**Import Architecture for Generated Types** +The type system has a strict layering to prevent brittleness: + +``` +generated_poc/*.py (internal, auto-generated from schemas) + ↓ +_generated.py (internal consolidation) + ↓ +stable.py + aliases.py + _ergonomic.py (public API / internal infrastructure) + ↓ +__init__.py (user-facing exports) +``` + +Only these modules may import from `generated_poc/` or `_generated.py`: +- `stable.py`: Re-exports base types with clean names +- `aliases.py`: Creates semantic aliases for numbered discriminated union types +- `_ergonomic.py`: Applies BeforeValidator coercion for type ergonomics + +All other source code should import from `adcp.types` (the public API). + **Type Checking Best Practices** - Use `TYPE_CHECKING` for optional dependencies to avoid runtime import errors - Use `cast()` for JSON deserialization to satisfy mypy's `no-any-return` checks diff --git a/tests/test_type_coercion.py b/tests/test_type_coercion.py index 9bc1809..09755e7 100644 --- a/tests/test_type_coercion.py +++ b/tests/test_type_coercion.py @@ -405,10 +405,7 @@ class TestResponseTypeCoercion: def test_list_creative_formats_response_accepts_dict_context(self): """ListCreativeFormatsResponse.context accepts dict.""" - from adcp.types import Format, FormatCategory - from adcp.types.generated_poc.media_buy.list_creative_formats_response import ( - ListCreativeFormatsResponse, - ) + from adcp.types import Format, FormatCategory, ListCreativeFormatsResponse format_obj = Format( format_id={"agent_url": "https://example.com", "id": "banner-300x250"}, @@ -427,10 +424,7 @@ def test_list_creative_formats_response_accepts_format_subclass(self): """ListCreativeFormatsResponse.formats accepts Format subclass instances.""" from pydantic import Field - from adcp.types import Format, FormatCategory - from adcp.types.generated_poc.media_buy.list_creative_formats_response import ( - ListCreativeFormatsResponse, - ) + from adcp.types import Format, FormatCategory, ListCreativeFormatsResponse class ExtendedFormat(Format): """Extended with internal tracking fields.""" @@ -446,7 +440,7 @@ class ExtendedFormat(Format): # No cast() needed! response = ListCreativeFormatsResponse( - formats=[format_obj], # type: ignore[list-item] + formats=[format_obj], # type: ignore[list-item] # Ignoring due to Python list covariance limitation ) assert len(response.formats) == 1 @@ -455,13 +449,10 @@ class ExtendedFormat(Format): assert response.formats[0].internal_id == "format-internal-123" # type: ignore[attr-defined] def test_create_media_buy_response_accepts_package_subclass(self): - """CreateMediaBuyResponse1.packages accepts Package subclass instances.""" + """CreateMediaBuySuccessResponse.packages accepts Package subclass instances.""" from pydantic import Field - from adcp.types.generated_poc.core.package import Package - from adcp.types.generated_poc.media_buy.create_media_buy_response import ( - CreateMediaBuyResponse1, - ) + from adcp.types import CreateMediaBuySuccessResponse, Package class ExtendedPackage(Package): """Extended with internal tracking fields.""" @@ -474,10 +465,10 @@ class ExtendedPackage(Package): ) # No cast() needed! - response = CreateMediaBuyResponse1( + response = CreateMediaBuySuccessResponse( media_buy_id="mb1", buyer_ref="buyer-ref", - packages=[package], # type: ignore[list-item] + packages=[package], # type: ignore[list-item] # Ignoring due to Python list covariance limitation ) assert len(response.packages) == 1 @@ -489,10 +480,7 @@ def test_get_media_buy_delivery_response_accepts_dict_context(self): """GetMediaBuyDeliveryResponse.context accepts dict.""" from datetime import datetime, timezone - from adcp.types.generated_poc.media_buy.get_media_buy_delivery_response import ( - GetMediaBuyDeliveryResponse, - MediaBuyDelivery, - ) + from adcp.types import GetMediaBuyDeliveryResponse, MediaBuyDelivery delivery = MediaBuyDelivery( media_buy_id="mb1", @@ -524,10 +512,7 @@ def test_get_media_buy_delivery_response_accepts_dict_context(self): def test_response_serialization_roundtrip(self): """Response types with coerced values can roundtrip through JSON.""" - from adcp.types import Format, FormatCategory - from adcp.types.generated_poc.media_buy.list_creative_formats_response import ( - ListCreativeFormatsResponse, - ) + from adcp.types import Format, FormatCategory, ListCreativeFormatsResponse format_obj = Format( format_id={"agent_url": "https://example.com", "id": "banner-300x250"}, @@ -546,3 +531,84 @@ def test_response_serialization_roundtrip(self): assert len(restored.formats) == 1 assert restored.formats[0].name == "Banner 300x250" assert restored.context.key == "value" + + def test_get_products_response_accepts_product_subclass(self): + """GetProductsResponse.products accepts Product subclass instances.""" + from pydantic import Field + + from adcp.types import ( + CpmFixedRatePricingOption, + DeliveryType, + FormatId, + GetProductsResponse, + Product, + PublisherPropertiesAll, + ) + from adcp.types.generated_poc.core.product import DeliveryMeasurement + + class ExtendedProduct(Product): + """Extended with internal tracking fields.""" + + internal_sku: str | None = Field(None, exclude=True) + + product = ExtendedProduct( + product_id="prod-123", + name="Premium Display", + description="A premium display product", + delivery_type=DeliveryType.guaranteed, + delivery_measurement=DeliveryMeasurement(provider="Test Provider"), + format_ids=[FormatId(agent_url="https://example.com", id="banner-300x250")], + pricing_options=[ + CpmFixedRatePricingOption( + currency="USD", + pricing_option_id="opt-1", + rate=5.0, + is_fixed=True, + pricing_model="cpm", + ) + ], + publisher_properties=[ + PublisherPropertiesAll( + publisher_domain="example.com", + selection_type="all", + ) + ], + internal_sku="SKU-12345", + ) + + # No cast() needed! + response = GetProductsResponse( + products=[product], # type: ignore[list-item] # Ignoring due to Python list covariance limitation + ) + + assert len(response.products) == 1 + assert response.products[0].product_id == "prod-123" + # Internal field is preserved at runtime + assert response.products[0].internal_sku == "SKU-12345" # type: ignore[attr-defined] + + def test_response_errors_accepts_error_subclass(self): + """Response types with errors field accept Error subclass instances.""" + from pydantic import Field + + from adcp.types import Error, GetProductsResponse + + class ExtendedError(Error): + """Extended with internal tracking fields.""" + + internal_trace_id: str | None = Field(None, exclude=True) + + error = ExtendedError( + code="INVALID_REQUEST", + message="Product ID is required", + internal_trace_id="trace-abc-123", + ) + + response = GetProductsResponse( + products=[], + errors=[error], # type: ignore[list-item] # Ignoring due to Python list covariance limitation + ) + + assert len(response.errors) == 1 + assert response.errors[0].code == "INVALID_REQUEST" + # Internal field is preserved at runtime + assert response.errors[0].internal_trace_id == "trace-abc-123" # type: ignore[attr-defined]