Skip to content

Commit ac554d8

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() (e.g., creatives=[ExtendedCreative()]) Affected types: - ListCreativeFormatsRequest (type, asset_types, context, ext) - ListCreativesRequest (fields, context, ext, sort) - GetProductsRequest (context, ext) - PackageRequest (creatives, ext) 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 <[email protected]>
1 parent ff82519 commit ac554d8

File tree

4 files changed

+727
-1
lines changed

4 files changed

+727
-1
lines changed

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: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
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.ext import ExtensionObject
39+
from adcp.types.generated_poc.enums.asset_content_type import AssetContentType
40+
from adcp.types.generated_poc.enums.creative_sort_field import CreativeSortField
41+
from adcp.types.generated_poc.enums.format_category import FormatCategory
42+
from adcp.types.generated_poc.enums.sort_direction import SortDirection
43+
from adcp.types.generated_poc.media_buy.get_products_request import GetProductsRequest
44+
from adcp.types.generated_poc.media_buy.list_creative_formats_request import (
45+
ListCreativeFormatsRequest,
46+
)
47+
from adcp.types.generated_poc.media_buy.list_creatives_request import (
48+
FieldModel,
49+
ListCreativesRequest,
50+
Sort,
51+
)
52+
from adcp.types.generated_poc.media_buy.package_request import PackageRequest
53+
54+
55+
def _apply_coercion() -> None:
56+
"""Apply coercion validators to generated types.
57+
58+
This function modifies the generated types in-place to accept
59+
more flexible input types.
60+
"""
61+
# Apply coercion to ListCreativeFormatsRequest
62+
# - type: FormatCategory | str | None
63+
# - asset_types: list[AssetContentType | str] | None
64+
# - context: ContextObject | dict | None
65+
# - ext: ExtensionObject | dict | None
66+
_patch_field_annotation(
67+
ListCreativeFormatsRequest,
68+
"type",
69+
Annotated[FormatCategory | None, BeforeValidator(coerce_to_enum(FormatCategory))],
70+
)
71+
_patch_field_annotation(
72+
ListCreativeFormatsRequest,
73+
"asset_types",
74+
Annotated[
75+
list[AssetContentType] | None,
76+
BeforeValidator(coerce_to_enum_list(AssetContentType)),
77+
],
78+
)
79+
_patch_field_annotation(
80+
ListCreativeFormatsRequest,
81+
"context",
82+
Annotated[ContextObject | None, BeforeValidator(coerce_to_model(ContextObject))],
83+
)
84+
_patch_field_annotation(
85+
ListCreativeFormatsRequest,
86+
"ext",
87+
Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))],
88+
)
89+
ListCreativeFormatsRequest.model_rebuild(force=True)
90+
91+
# Apply coercion to ListCreativesRequest
92+
# - fields: list[FieldModel | str] | None
93+
# - context: ContextObject | dict | None
94+
# - ext: ExtensionObject | dict | None
95+
_patch_field_annotation(
96+
ListCreativesRequest,
97+
"fields",
98+
Annotated[list[FieldModel] | None, BeforeValidator(coerce_to_enum_list(FieldModel))],
99+
)
100+
_patch_field_annotation(
101+
ListCreativesRequest,
102+
"context",
103+
Annotated[ContextObject | None, BeforeValidator(coerce_to_model(ContextObject))],
104+
)
105+
_patch_field_annotation(
106+
ListCreativesRequest,
107+
"ext",
108+
Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))],
109+
)
110+
ListCreativesRequest.model_rebuild(force=True)
111+
112+
# Apply coercion to Sort (nested in ListCreativesRequest)
113+
# - field: CreativeSortField | str | None
114+
# - direction: SortDirection | str | None
115+
_patch_field_annotation(
116+
Sort,
117+
"field",
118+
Annotated[
119+
CreativeSortField | None,
120+
BeforeValidator(coerce_to_enum(CreativeSortField)),
121+
],
122+
)
123+
_patch_field_annotation(
124+
Sort,
125+
"direction",
126+
Annotated[SortDirection | None, BeforeValidator(coerce_to_enum(SortDirection))],
127+
)
128+
Sort.model_rebuild(force=True)
129+
130+
# Apply coercion to GetProductsRequest
131+
# - context: ContextObject | dict | None
132+
# - ext: ExtensionObject | dict | None
133+
_patch_field_annotation(
134+
GetProductsRequest,
135+
"context",
136+
Annotated[ContextObject | None, BeforeValidator(coerce_to_model(ContextObject))],
137+
)
138+
_patch_field_annotation(
139+
GetProductsRequest,
140+
"ext",
141+
Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))],
142+
)
143+
GetProductsRequest.model_rebuild(force=True)
144+
145+
# Apply coercion to PackageRequest
146+
# - creatives: list[CreativeAsset] | None (accepts subclass instances without cast)
147+
# - ext: ExtensionObject | dict | None
148+
_patch_field_annotation(
149+
PackageRequest,
150+
"creatives",
151+
Annotated[
152+
list[CreativeAsset] | None,
153+
BeforeValidator(coerce_subclass_list(CreativeAsset)),
154+
],
155+
)
156+
_patch_field_annotation(
157+
PackageRequest,
158+
"ext",
159+
Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))],
160+
)
161+
PackageRequest.model_rebuild(force=True)
162+
163+
164+
def _patch_field_annotation(
165+
model: type,
166+
field_name: str,
167+
new_annotation: Any,
168+
) -> None:
169+
"""Patch a field annotation on a Pydantic model.
170+
171+
This modifies the model's __annotations__ dict to add
172+
BeforeValidator coercion.
173+
"""
174+
if hasattr(model, "__annotations__"):
175+
model.__annotations__[field_name] = new_annotation
176+
177+
178+
# Apply coercion when module is imported
179+
_apply_coercion()

0 commit comments

Comments
 (0)