Skip to content

Commit d9f1a65

Browse files
bokelleyclaude
andcommitted
feat: improve type ergonomics for library consumers
Add flexible input coercion for request types that reduces boilerplate when constructing API requests. All changes are backward compatible. Improvements: - Enum fields accept string values (e.g., type="video") - List[Enum] fields accept string lists (e.g., asset_types=["image", "video"]) - Context/Ext fields accept dicts (e.g., context={"key": "value"}) - FieldModel lists accept strings (e.g., fields=["creative_id", "name"]) - Sort fields accept string enums (e.g., field="name", direction="asc") - Subclass lists accepted without cast() for all major list fields Affected types: - ListCreativeFormatsRequest (type, asset_types, context, ext) - ListCreativesRequest (fields, context, ext, sort) - GetProductsRequest (context, ext) - PackageRequest (creatives, ext) - CreateMediaBuyRequest (packages, context, ext) - UpdateMediaBuyRequest.Packages (creatives, creative_assignments) The list variance issue is now fully resolved - users can pass list[Subclass] where list[BaseClass] is expected without needing cast(). Closes #102 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent ff82519 commit d9f1a65

4 files changed

Lines changed: 844 additions & 1 deletion

File tree

src/adcp/types/__init__.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,33 @@
66
Examples:
77
from adcp.types import Product, CreativeFilters
88
from adcp import Product, CreativeFilters
9+
10+
Type Coercion:
11+
For developer ergonomics, request types accept flexible input:
12+
13+
- Enum fields accept string values:
14+
ListCreativeFormatsRequest(type="video") # Works!
15+
ListCreativeFormatsRequest(type=FormatCategory.video) # Also works
16+
17+
- Context fields accept dicts:
18+
GetProductsRequest(context={"key": "value"}) # Works!
19+
20+
- FieldModel lists accept strings:
21+
ListCreativesRequest(fields=["creative_id", "name"]) # Works!
22+
23+
See adcp.types.coercion for implementation details.
924
"""
1025

1126
from __future__ import annotations
1227

28+
# Apply type coercion to generated types (must be imported before other types)
29+
from adcp.types import (
30+
_ergonomic, # noqa: F401
31+
aliases, # noqa: F401
32+
)
33+
1334
# Also make submodules available for advanced use
1435
from adcp.types import _generated as generated # noqa: F401
15-
from adcp.types import aliases # noqa: F401
1636

1737
# Import all types from generated code
1838
from adcp.types._generated import (

src/adcp/types/_ergonomic.py

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
"""Apply type coercion to generated types for better ergonomics.
2+
3+
This module patches the generated types to accept more flexible input types
4+
while maintaining type safety. It uses Pydantic's model_rebuild() to add
5+
BeforeValidator annotations to fields.
6+
7+
The coercion is applied at module load time, so imports from adcp.types
8+
will automatically have the coercion applied.
9+
10+
Coercion rules applied:
11+
1. Enum fields accept string values (e.g., "video" for FormatCategory.video)
12+
2. List[Enum] fields accept list of strings (e.g., ["image", "video"])
13+
3. ContextObject fields accept dict values
14+
4. ExtensionObject fields accept dict values
15+
5. FieldModel (enum) lists accept string lists
16+
17+
Note: List variance issues (list[Subclass] not assignable to list[BaseClass])
18+
are a fundamental Python typing limitation. Users extending library types
19+
should use Sequence[T] in their own code or cast() for type checker appeasement.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
from typing import Annotated, Any
25+
26+
from pydantic import BeforeValidator
27+
28+
from adcp.types.coercion import (
29+
coerce_subclass_list,
30+
coerce_to_enum,
31+
coerce_to_enum_list,
32+
coerce_to_model,
33+
)
34+
35+
# Import types that need coercion
36+
from adcp.types.generated_poc.core.context import ContextObject
37+
from adcp.types.generated_poc.core.creative_asset import CreativeAsset
38+
from adcp.types.generated_poc.core.creative_assignment import CreativeAssignment
39+
from adcp.types.generated_poc.core.ext import ExtensionObject
40+
from adcp.types.generated_poc.enums.asset_content_type import AssetContentType
41+
from adcp.types.generated_poc.enums.creative_sort_field import CreativeSortField
42+
from adcp.types.generated_poc.enums.format_category import FormatCategory
43+
from adcp.types.generated_poc.enums.sort_direction import SortDirection
44+
from adcp.types.generated_poc.media_buy.create_media_buy_request import (
45+
CreateMediaBuyRequest,
46+
)
47+
from adcp.types.generated_poc.media_buy.get_products_request import GetProductsRequest
48+
from adcp.types.generated_poc.media_buy.list_creative_formats_request import (
49+
ListCreativeFormatsRequest,
50+
)
51+
from adcp.types.generated_poc.media_buy.list_creatives_request import (
52+
FieldModel,
53+
ListCreativesRequest,
54+
Sort,
55+
)
56+
from adcp.types.generated_poc.media_buy.package_request import PackageRequest
57+
from adcp.types.generated_poc.media_buy.update_media_buy_request import (
58+
Packages,
59+
Packages1,
60+
)
61+
62+
63+
def _apply_coercion() -> None:
64+
"""Apply coercion validators to generated types.
65+
66+
This function modifies the generated types in-place to accept
67+
more flexible input types.
68+
"""
69+
# Apply coercion to ListCreativeFormatsRequest
70+
# - type: FormatCategory | str | None
71+
# - asset_types: list[AssetContentType | str] | None
72+
# - context: ContextObject | dict | None
73+
# - ext: ExtensionObject | dict | None
74+
_patch_field_annotation(
75+
ListCreativeFormatsRequest,
76+
"type",
77+
Annotated[FormatCategory | None, BeforeValidator(coerce_to_enum(FormatCategory))],
78+
)
79+
_patch_field_annotation(
80+
ListCreativeFormatsRequest,
81+
"asset_types",
82+
Annotated[
83+
list[AssetContentType] | None,
84+
BeforeValidator(coerce_to_enum_list(AssetContentType)),
85+
],
86+
)
87+
_patch_field_annotation(
88+
ListCreativeFormatsRequest,
89+
"context",
90+
Annotated[ContextObject | None, BeforeValidator(coerce_to_model(ContextObject))],
91+
)
92+
_patch_field_annotation(
93+
ListCreativeFormatsRequest,
94+
"ext",
95+
Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))],
96+
)
97+
ListCreativeFormatsRequest.model_rebuild(force=True)
98+
99+
# Apply coercion to ListCreativesRequest
100+
# - fields: list[FieldModel | str] | None
101+
# - context: ContextObject | dict | None
102+
# - ext: ExtensionObject | dict | None
103+
_patch_field_annotation(
104+
ListCreativesRequest,
105+
"fields",
106+
Annotated[list[FieldModel] | None, BeforeValidator(coerce_to_enum_list(FieldModel))],
107+
)
108+
_patch_field_annotation(
109+
ListCreativesRequest,
110+
"context",
111+
Annotated[ContextObject | None, BeforeValidator(coerce_to_model(ContextObject))],
112+
)
113+
_patch_field_annotation(
114+
ListCreativesRequest,
115+
"ext",
116+
Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))],
117+
)
118+
ListCreativesRequest.model_rebuild(force=True)
119+
120+
# Apply coercion to Sort (nested in ListCreativesRequest)
121+
# - field: CreativeSortField | str | None
122+
# - direction: SortDirection | str | None
123+
_patch_field_annotation(
124+
Sort,
125+
"field",
126+
Annotated[
127+
CreativeSortField | None,
128+
BeforeValidator(coerce_to_enum(CreativeSortField)),
129+
],
130+
)
131+
_patch_field_annotation(
132+
Sort,
133+
"direction",
134+
Annotated[SortDirection | None, BeforeValidator(coerce_to_enum(SortDirection))],
135+
)
136+
Sort.model_rebuild(force=True)
137+
138+
# Apply coercion to GetProductsRequest
139+
# - context: ContextObject | dict | None
140+
# - ext: ExtensionObject | dict | None
141+
_patch_field_annotation(
142+
GetProductsRequest,
143+
"context",
144+
Annotated[ContextObject | None, BeforeValidator(coerce_to_model(ContextObject))],
145+
)
146+
_patch_field_annotation(
147+
GetProductsRequest,
148+
"ext",
149+
Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))],
150+
)
151+
GetProductsRequest.model_rebuild(force=True)
152+
153+
# Apply coercion to PackageRequest
154+
# - creatives: list[CreativeAsset] | None (accepts subclass instances without cast)
155+
# - ext: ExtensionObject | dict | None
156+
_patch_field_annotation(
157+
PackageRequest,
158+
"creatives",
159+
Annotated[
160+
list[CreativeAsset] | None,
161+
BeforeValidator(coerce_subclass_list(CreativeAsset)),
162+
],
163+
)
164+
_patch_field_annotation(
165+
PackageRequest,
166+
"ext",
167+
Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))],
168+
)
169+
PackageRequest.model_rebuild(force=True)
170+
171+
# Apply coercion to CreateMediaBuyRequest
172+
# - packages: list[PackageRequest] (accepts subclass instances without cast)
173+
# - context: ContextObject | dict | None
174+
# - ext: ExtensionObject | dict | None
175+
_patch_field_annotation(
176+
CreateMediaBuyRequest,
177+
"packages",
178+
Annotated[
179+
list[PackageRequest],
180+
BeforeValidator(coerce_subclass_list(PackageRequest)),
181+
],
182+
)
183+
_patch_field_annotation(
184+
CreateMediaBuyRequest,
185+
"context",
186+
Annotated[ContextObject | None, BeforeValidator(coerce_to_model(ContextObject))],
187+
)
188+
_patch_field_annotation(
189+
CreateMediaBuyRequest,
190+
"ext",
191+
Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))],
192+
)
193+
CreateMediaBuyRequest.model_rebuild(force=True)
194+
195+
# Apply coercion to UpdateMediaBuyRequest nested Packages types
196+
# - creatives: list[CreativeAsset] | None (accepts subclass instances without cast)
197+
# - creative_assignments: list[CreativeAssignment] | None (accepts subclass instances)
198+
for packages_cls in [Packages, Packages1]:
199+
_patch_field_annotation(
200+
packages_cls,
201+
"creatives",
202+
Annotated[
203+
list[CreativeAsset] | None,
204+
BeforeValidator(coerce_subclass_list(CreativeAsset)),
205+
],
206+
)
207+
_patch_field_annotation(
208+
packages_cls,
209+
"creative_assignments",
210+
Annotated[
211+
list[CreativeAssignment] | None,
212+
BeforeValidator(coerce_subclass_list(CreativeAssignment)),
213+
],
214+
)
215+
packages_cls.model_rebuild(force=True)
216+
217+
218+
def _patch_field_annotation(
219+
model: type,
220+
field_name: str,
221+
new_annotation: Any,
222+
) -> None:
223+
"""Patch a field annotation on a Pydantic model.
224+
225+
This modifies the model's __annotations__ dict to add
226+
BeforeValidator coercion.
227+
"""
228+
if hasattr(model, "__annotations__"):
229+
model.__annotations__[field_name] = new_annotation
230+
231+
232+
# Apply coercion when module is imported
233+
_apply_coercion()

0 commit comments

Comments
 (0)