Skip to content

Commit d31c4d8

Browse files
bokelleyclaude
andauthored
feat: extend type ergonomics to response types (#106)
* feat: extend type ergonomics to response types 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 <noreply@anthropic.com> * refactor: improve test imports and documentation - 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 92a35a3 commit d31c4d8

File tree

4 files changed

+511
-22
lines changed

4 files changed

+511
-22
lines changed

CLAUDE.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,26 @@ FormatId = str
1717
PackageRequest = dict[str, Any]
1818
```
1919

20+
**Import Architecture for Generated Types**
21+
The type system has a strict layering to prevent brittleness:
22+
23+
```
24+
generated_poc/*.py (internal, auto-generated from schemas)
25+
26+
_generated.py (internal consolidation)
27+
28+
stable.py + aliases.py + _ergonomic.py (public API / internal infrastructure)
29+
30+
__init__.py (user-facing exports)
31+
```
32+
33+
Only these modules may import from `generated_poc/` or `_generated.py`:
34+
- `stable.py`: Re-exports base types with clean names
35+
- `aliases.py`: Creates semantic aliases for numbered discriminated union types
36+
- `_ergonomic.py`: Applies BeforeValidator coercion for type ergonomics
37+
38+
All other source code should import from `adcp.types` (the public API).
39+
2040
**Type Checking Best Practices**
2141
- Use `TYPE_CHECKING` for optional dependencies to avoid runtime import errors
2242
- Use `cast()` for JSON deserialization to satisfy mypy's `no-any-return` checks

scripts/generate_ergonomic_coercion.py

Lines changed: 88 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,15 @@
3636
"CreateMediaBuyRequest",
3737
]
3838

39+
# Response types to analyze for coercion
40+
RESPONSE_TYPES_TO_ANALYZE = [
41+
"GetProductsResponse",
42+
"ListCreativesResponse",
43+
"ListCreativeFormatsResponse",
44+
"CreateMediaBuyResponse1",
45+
"GetMediaBuyDeliveryResponse",
46+
]
47+
3948
# Nested types that also need coercion
4049
NESTED_TYPES_TO_ANALYZE = [
4150
("Sort", "media_buy.list_creatives_request"),
@@ -45,9 +54,18 @@
4554

4655
# Types that should get subclass_list coercion (for list variance)
4756
SUBCLASS_LIST_TYPES = {
57+
# Request list types
4858
"CreativeAsset",
4959
"CreativeAssignment",
5060
"PackageRequest",
61+
# Response list types
62+
"Product",
63+
"Creative",
64+
"Format",
65+
"Package",
66+
"MediaBuyDelivery",
67+
"Error",
68+
"CreativeAgent",
5169
}
5270

5371

@@ -180,14 +198,23 @@ def get_import_path(cls) -> str:
180198
def generate_code() -> str:
181199
"""Generate the _ergonomic.py module content."""
182200
# Import all the types we need to analyze
183-
from adcp.types.generated_poc.core.context import ContextObject
184-
from adcp.types.generated_poc.core.creative_asset import CreativeAsset
185-
from adcp.types.generated_poc.core.creative_assignment import CreativeAssignment
186-
from adcp.types.generated_poc.core.ext import ExtensionObject
187201
from adcp.types.generated_poc.media_buy.create_media_buy_request import CreateMediaBuyRequest
202+
from adcp.types.generated_poc.media_buy.create_media_buy_response import CreateMediaBuyResponse1
203+
from adcp.types.generated_poc.media_buy.get_media_buy_delivery_response import (
204+
GetMediaBuyDeliveryResponse,
205+
)
188206
from adcp.types.generated_poc.media_buy.get_products_request import GetProductsRequest
189-
from adcp.types.generated_poc.media_buy.list_creative_formats_request import ListCreativeFormatsRequest
207+
208+
# Response types
209+
from adcp.types.generated_poc.media_buy.get_products_response import GetProductsResponse
210+
from adcp.types.generated_poc.media_buy.list_creative_formats_request import (
211+
ListCreativeFormatsRequest,
212+
)
213+
from adcp.types.generated_poc.media_buy.list_creative_formats_response import (
214+
ListCreativeFormatsResponse,
215+
)
190216
from adcp.types.generated_poc.media_buy.list_creatives_request import ListCreativesRequest, Sort
217+
from adcp.types.generated_poc.media_buy.list_creatives_response import ListCreativesResponse
191218
from adcp.types.generated_poc.media_buy.package_request import PackageRequest
192219
from adcp.types.generated_poc.media_buy.update_media_buy_request import Packages, Packages1
193220

@@ -200,6 +227,14 @@ def generate_code() -> str:
200227
"CreateMediaBuyRequest": CreateMediaBuyRequest,
201228
}
202229

230+
response_classes = {
231+
"GetProductsResponse": GetProductsResponse,
232+
"ListCreativesResponse": ListCreativesResponse,
233+
"ListCreativeFormatsResponse": ListCreativeFormatsResponse,
234+
"CreateMediaBuyResponse1": CreateMediaBuyResponse1,
235+
"GetMediaBuyDeliveryResponse": GetMediaBuyDeliveryResponse,
236+
}
237+
203238
nested_classes = {
204239
"Sort": Sort,
205240
"Packages": Packages,
@@ -210,7 +245,7 @@ def generate_code() -> str:
210245
all_coercions = {}
211246
all_imports = set()
212247

213-
for name, cls in {**request_classes, **nested_classes}.items():
248+
for name, cls in {**request_classes, **response_classes, **nested_classes}.items():
214249
coercions = analyze_model(cls)
215250
if coercions:
216251
all_coercions[name] = (cls, coercions)
@@ -238,6 +273,10 @@ def generate_code() -> str:
238273
core_imports.append(("ExtensionObject", "core.ext"))
239274
core_imports.append(("CreativeAsset", "core.creative_asset"))
240275
core_imports.append(("CreativeAssignment", "core.creative_assignment"))
276+
core_imports.append(("Product", "core.product"))
277+
core_imports.append(("Format", "core.format"))
278+
core_imports.append(("Package", "core.package"))
279+
core_imports.append(("Error", "core.error"))
241280

242281
# Deduplicate
243282
enum_imports = sorted(set(enum_imports))
@@ -317,6 +356,26 @@ def generate_code() -> str:
317356
lines.append(' Packages1,')
318357
lines.append(')')
319358

359+
# Add response type imports
360+
lines.append('# Response types')
361+
lines.append('from adcp.types.generated_poc.media_buy.create_media_buy_response import (')
362+
lines.append(' CreateMediaBuyResponse1,')
363+
lines.append(')')
364+
lines.append('from adcp.types.generated_poc.media_buy.get_media_buy_delivery_response import (')
365+
lines.append(' GetMediaBuyDeliveryResponse,')
366+
lines.append(' MediaBuyDelivery,')
367+
lines.append(' NotificationType,')
368+
lines.append(')')
369+
lines.append('from adcp.types.generated_poc.media_buy.get_products_response import GetProductsResponse')
370+
lines.append('from adcp.types.generated_poc.media_buy.list_creative_formats_response import (')
371+
lines.append(' CreativeAgent,')
372+
lines.append(' ListCreativeFormatsResponse,')
373+
lines.append(')')
374+
lines.append('from adcp.types.generated_poc.media_buy.list_creatives_response import (')
375+
lines.append(' Creative,')
376+
lines.append(' ListCreativesResponse,')
377+
lines.append(')')
378+
320379
lines.append('')
321380
lines.append('')
322381
lines.append('def _apply_coercion() -> None:')
@@ -329,6 +388,7 @@ def generate_code() -> str:
329388
# Generate coercion code for each type
330389
# Process in a specific order for readability
331390
type_order = [
391+
# Request types
332392
"ListCreativeFormatsRequest",
333393
"ListCreativesRequest",
334394
"Sort",
@@ -337,6 +397,12 @@ def generate_code() -> str:
337397
"CreateMediaBuyRequest",
338398
"Packages",
339399
"Packages1",
400+
# Response types
401+
"GetProductsResponse",
402+
"ListCreativesResponse",
403+
"ListCreativeFormatsResponse",
404+
"CreateMediaBuyResponse1",
405+
"GetMediaBuyDeliveryResponse",
340406
]
341407

342408
for type_name in type_order:
@@ -368,47 +434,47 @@ def generate_code() -> str:
368434
field = c["field"]
369435
if c["type"] == "enum":
370436
target = c["target_class"].__name__
371-
lines.append(f' _patch_field_annotation(')
437+
lines.append(' _patch_field_annotation(')
372438
lines.append(f' {type_name},')
373439
lines.append(f' "{field}",')
374440
lines.append(f' Annotated[{target} | None, BeforeValidator(coerce_to_enum({target}))],')
375-
lines.append(f' )')
441+
lines.append(' )')
376442
elif c["type"] == "enum_list":
377443
target = c["target_class"].__name__
378-
lines.append(f' _patch_field_annotation(')
444+
lines.append(' _patch_field_annotation(')
379445
lines.append(f' {type_name},')
380446
lines.append(f' "{field}",')
381-
lines.append(f' Annotated[')
447+
lines.append(' Annotated[')
382448
lines.append(f' list[{target}] | None,')
383449
lines.append(f' BeforeValidator(coerce_to_enum_list({target})),')
384-
lines.append(f' ],')
385-
lines.append(f' )')
450+
lines.append(' ],')
451+
lines.append(' )')
386452
elif c["type"] == "context":
387-
lines.append(f' _patch_field_annotation(')
453+
lines.append(' _patch_field_annotation(')
388454
lines.append(f' {type_name},')
389455
lines.append(f' "{field}",')
390-
lines.append(f' Annotated[ContextObject | None, BeforeValidator(coerce_to_model(ContextObject))],')
391-
lines.append(f' )')
456+
lines.append(' Annotated[ContextObject | None, BeforeValidator(coerce_to_model(ContextObject))],')
457+
lines.append(' )')
392458
elif c["type"] == "ext":
393-
lines.append(f' _patch_field_annotation(')
459+
lines.append(' _patch_field_annotation(')
394460
lines.append(f' {type_name},')
395461
lines.append(f' "{field}",')
396-
lines.append(f' Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))],')
397-
lines.append(f' )')
462+
lines.append(' Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))],')
463+
lines.append(' )')
398464
elif c["type"] == "subclass_list":
399465
target = c["target_class"].__name__
400466
# Check if the field is required (no | None)
401467
field_info = cls.model_fields[field]
402468
is_optional = "None" in str(field_info.annotation)
403469
type_str = f'list[{target}] | None' if is_optional else f'list[{target}]'
404-
lines.append(f' _patch_field_annotation(')
470+
lines.append(' _patch_field_annotation(')
405471
lines.append(f' {type_name},')
406472
lines.append(f' "{field}",')
407-
lines.append(f' Annotated[')
473+
lines.append(' Annotated[')
408474
lines.append(f' {type_str},')
409475
lines.append(f' BeforeValidator(coerce_subclass_list({target})),')
410-
lines.append(f' ],')
411-
lines.append(f' )')
476+
lines.append(' ],')
477+
lines.append(' )')
412478

413479
lines.append(f' {type_name}.model_rebuild(force=True)')
414480
lines.append('')

0 commit comments

Comments
 (0)