adcp 2.12.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. adcp/__init__.py +364 -0
  2. adcp/__main__.py +440 -0
  3. adcp/adagents.py +642 -0
  4. adcp/client.py +1057 -0
  5. adcp/config.py +82 -0
  6. adcp/exceptions.py +185 -0
  7. adcp/protocols/__init__.py +9 -0
  8. adcp/protocols/a2a.py +484 -0
  9. adcp/protocols/base.py +190 -0
  10. adcp/protocols/mcp.py +440 -0
  11. adcp/py.typed +0 -0
  12. adcp/simple.py +451 -0
  13. adcp/testing/__init__.py +53 -0
  14. adcp/testing/test_helpers.py +311 -0
  15. adcp/types/__init__.py +561 -0
  16. adcp/types/_generated.py +237 -0
  17. adcp/types/aliases.py +748 -0
  18. adcp/types/base.py +26 -0
  19. adcp/types/core.py +174 -0
  20. adcp/types/generated_poc/__init__.py +3 -0
  21. adcp/types/generated_poc/adagents.py +411 -0
  22. adcp/types/generated_poc/core/__init__.py +3 -0
  23. adcp/types/generated_poc/core/activation_key.py +30 -0
  24. adcp/types/generated_poc/core/assets/__init__.py +3 -0
  25. adcp/types/generated_poc/core/assets/audio_asset.py +26 -0
  26. adcp/types/generated_poc/core/assets/css_asset.py +20 -0
  27. adcp/types/generated_poc/core/assets/daast_asset.py +61 -0
  28. adcp/types/generated_poc/core/assets/html_asset.py +18 -0
  29. adcp/types/generated_poc/core/assets/image_asset.py +19 -0
  30. adcp/types/generated_poc/core/assets/javascript_asset.py +23 -0
  31. adcp/types/generated_poc/core/assets/text_asset.py +20 -0
  32. adcp/types/generated_poc/core/assets/url_asset.py +28 -0
  33. adcp/types/generated_poc/core/assets/vast_asset.py +63 -0
  34. adcp/types/generated_poc/core/assets/video_asset.py +24 -0
  35. adcp/types/generated_poc/core/assets/webhook_asset.py +53 -0
  36. adcp/types/generated_poc/core/brand_manifest.py +201 -0
  37. adcp/types/generated_poc/core/context.py +15 -0
  38. adcp/types/generated_poc/core/creative_asset.py +102 -0
  39. adcp/types/generated_poc/core/creative_assignment.py +27 -0
  40. adcp/types/generated_poc/core/creative_filters.py +86 -0
  41. adcp/types/generated_poc/core/creative_manifest.py +68 -0
  42. adcp/types/generated_poc/core/creative_policy.py +28 -0
  43. adcp/types/generated_poc/core/delivery_metrics.py +111 -0
  44. adcp/types/generated_poc/core/deployment.py +78 -0
  45. adcp/types/generated_poc/core/destination.py +43 -0
  46. adcp/types/generated_poc/core/dimensions.py +18 -0
  47. adcp/types/generated_poc/core/error.py +29 -0
  48. adcp/types/generated_poc/core/ext.py +15 -0
  49. adcp/types/generated_poc/core/format.py +260 -0
  50. adcp/types/generated_poc/core/format_id.py +50 -0
  51. adcp/types/generated_poc/core/frequency_cap.py +19 -0
  52. adcp/types/generated_poc/core/measurement.py +40 -0
  53. adcp/types/generated_poc/core/media_buy.py +40 -0
  54. adcp/types/generated_poc/core/package.py +68 -0
  55. adcp/types/generated_poc/core/performance_feedback.py +78 -0
  56. adcp/types/generated_poc/core/placement.py +37 -0
  57. adcp/types/generated_poc/core/product.py +164 -0
  58. adcp/types/generated_poc/core/product_filters.py +97 -0
  59. adcp/types/generated_poc/core/promoted_offerings.py +102 -0
  60. adcp/types/generated_poc/core/promoted_products.py +38 -0
  61. adcp/types/generated_poc/core/property.py +64 -0
  62. adcp/types/generated_poc/core/property_id.py +21 -0
  63. adcp/types/generated_poc/core/property_tag.py +21 -0
  64. adcp/types/generated_poc/core/protocol_envelope.py +61 -0
  65. adcp/types/generated_poc/core/publisher_property_selector.py +75 -0
  66. adcp/types/generated_poc/core/push_notification_config.py +51 -0
  67. adcp/types/generated_poc/core/reporting_capabilities.py +51 -0
  68. adcp/types/generated_poc/core/response.py +24 -0
  69. adcp/types/generated_poc/core/signal_filters.py +29 -0
  70. adcp/types/generated_poc/core/sub_asset.py +55 -0
  71. adcp/types/generated_poc/core/targeting.py +53 -0
  72. adcp/types/generated_poc/core/webhook_payload.py +96 -0
  73. adcp/types/generated_poc/creative/__init__.py +3 -0
  74. adcp/types/generated_poc/creative/list_creative_formats_request.py +88 -0
  75. adcp/types/generated_poc/creative/list_creative_formats_response.py +55 -0
  76. adcp/types/generated_poc/creative/preview_creative_request.py +153 -0
  77. adcp/types/generated_poc/creative/preview_creative_response.py +169 -0
  78. adcp/types/generated_poc/creative/preview_render.py +152 -0
  79. adcp/types/generated_poc/enums/__init__.py +3 -0
  80. adcp/types/generated_poc/enums/adcp_domain.py +12 -0
  81. adcp/types/generated_poc/enums/asset_content_type.py +23 -0
  82. adcp/types/generated_poc/enums/auth_scheme.py +12 -0
  83. adcp/types/generated_poc/enums/available_metric.py +19 -0
  84. adcp/types/generated_poc/enums/channels.py +19 -0
  85. adcp/types/generated_poc/enums/co_branding_requirement.py +13 -0
  86. adcp/types/generated_poc/enums/creative_action.py +15 -0
  87. adcp/types/generated_poc/enums/creative_agent_capability.py +14 -0
  88. adcp/types/generated_poc/enums/creative_sort_field.py +16 -0
  89. adcp/types/generated_poc/enums/creative_status.py +14 -0
  90. adcp/types/generated_poc/enums/daast_tracking_event.py +21 -0
  91. adcp/types/generated_poc/enums/daast_version.py +12 -0
  92. adcp/types/generated_poc/enums/delivery_type.py +12 -0
  93. adcp/types/generated_poc/enums/dimension_unit.py +14 -0
  94. adcp/types/generated_poc/enums/feed_format.py +13 -0
  95. adcp/types/generated_poc/enums/feedback_source.py +14 -0
  96. adcp/types/generated_poc/enums/format_category.py +17 -0
  97. adcp/types/generated_poc/enums/format_id_parameter.py +12 -0
  98. adcp/types/generated_poc/enums/frequency_cap_scope.py +16 -0
  99. adcp/types/generated_poc/enums/history_entry_type.py +12 -0
  100. adcp/types/generated_poc/enums/http_method.py +12 -0
  101. adcp/types/generated_poc/enums/identifier_types.py +29 -0
  102. adcp/types/generated_poc/enums/javascript_module_type.py +13 -0
  103. adcp/types/generated_poc/enums/landing_page_requirement.py +13 -0
  104. adcp/types/generated_poc/enums/markdown_flavor.py +12 -0
  105. adcp/types/generated_poc/enums/media_buy_status.py +14 -0
  106. adcp/types/generated_poc/enums/metric_type.py +18 -0
  107. adcp/types/generated_poc/enums/notification_type.py +14 -0
  108. adcp/types/generated_poc/enums/pacing.py +13 -0
  109. adcp/types/generated_poc/enums/preview_output_format.py +12 -0
  110. adcp/types/generated_poc/enums/pricing_model.py +17 -0
  111. adcp/types/generated_poc/enums/property_type.py +17 -0
  112. adcp/types/generated_poc/enums/publisher_identifier_types.py +15 -0
  113. adcp/types/generated_poc/enums/reporting_frequency.py +13 -0
  114. adcp/types/generated_poc/enums/signal_catalog_type.py +13 -0
  115. adcp/types/generated_poc/enums/sort_direction.py +12 -0
  116. adcp/types/generated_poc/enums/standard_format_ids.py +45 -0
  117. adcp/types/generated_poc/enums/task_status.py +19 -0
  118. adcp/types/generated_poc/enums/task_type.py +15 -0
  119. adcp/types/generated_poc/enums/update_frequency.py +14 -0
  120. adcp/types/generated_poc/enums/url_asset_type.py +13 -0
  121. adcp/types/generated_poc/enums/validation_mode.py +12 -0
  122. adcp/types/generated_poc/enums/vast_tracking_event.py +26 -0
  123. adcp/types/generated_poc/enums/vast_version.py +15 -0
  124. adcp/types/generated_poc/enums/webhook_response_type.py +14 -0
  125. adcp/types/generated_poc/enums/webhook_security_method.py +13 -0
  126. adcp/types/generated_poc/media_buy/__init__.py +3 -0
  127. adcp/types/generated_poc/media_buy/build_creative_request.py +41 -0
  128. adcp/types/generated_poc/media_buy/build_creative_response.py +51 -0
  129. adcp/types/generated_poc/media_buy/create_media_buy_request.py +94 -0
  130. adcp/types/generated_poc/media_buy/create_media_buy_response.py +56 -0
  131. adcp/types/generated_poc/media_buy/get_media_buy_delivery_request.py +47 -0
  132. adcp/types/generated_poc/media_buy/get_media_buy_delivery_response.py +235 -0
  133. adcp/types/generated_poc/media_buy/get_products_request.py +48 -0
  134. adcp/types/generated_poc/media_buy/get_products_response.py +28 -0
  135. adcp/types/generated_poc/media_buy/list_authorized_properties_request.py +38 -0
  136. adcp/types/generated_poc/media_buy/list_authorized_properties_response.py +84 -0
  137. adcp/types/generated_poc/media_buy/list_creative_formats_request.py +74 -0
  138. adcp/types/generated_poc/media_buy/list_creative_formats_response.py +56 -0
  139. adcp/types/generated_poc/media_buy/list_creatives_request.py +76 -0
  140. adcp/types/generated_poc/media_buy/list_creatives_response.py +214 -0
  141. adcp/types/generated_poc/media_buy/package_request.py +63 -0
  142. adcp/types/generated_poc/media_buy/provide_performance_feedback_request.py +125 -0
  143. adcp/types/generated_poc/media_buy/provide_performance_feedback_response.py +53 -0
  144. adcp/types/generated_poc/media_buy/sync_creatives_request.py +63 -0
  145. adcp/types/generated_poc/media_buy/sync_creatives_response.py +105 -0
  146. adcp/types/generated_poc/media_buy/update_media_buy_request.py +195 -0
  147. adcp/types/generated_poc/media_buy/update_media_buy_response.py +55 -0
  148. adcp/types/generated_poc/pricing_options/__init__.py +3 -0
  149. adcp/types/generated_poc/pricing_options/cpc_option.py +43 -0
  150. adcp/types/generated_poc/pricing_options/cpcv_option.py +45 -0
  151. adcp/types/generated_poc/pricing_options/cpm_auction_option.py +58 -0
  152. adcp/types/generated_poc/pricing_options/cpm_fixed_option.py +43 -0
  153. adcp/types/generated_poc/pricing_options/cpp_option.py +64 -0
  154. adcp/types/generated_poc/pricing_options/cpv_option.py +77 -0
  155. adcp/types/generated_poc/pricing_options/flat_rate_option.py +93 -0
  156. adcp/types/generated_poc/pricing_options/vcpm_auction_option.py +61 -0
  157. adcp/types/generated_poc/pricing_options/vcpm_fixed_option.py +47 -0
  158. adcp/types/generated_poc/protocols/__init__.py +3 -0
  159. adcp/types/generated_poc/protocols/adcp_extension.py +37 -0
  160. adcp/types/generated_poc/signals/__init__.py +3 -0
  161. adcp/types/generated_poc/signals/activate_signal_request.py +32 -0
  162. adcp/types/generated_poc/signals/activate_signal_response.py +51 -0
  163. adcp/types/generated_poc/signals/get_signals_request.py +53 -0
  164. adcp/types/generated_poc/signals/get_signals_response.py +59 -0
  165. adcp/utils/__init__.py +7 -0
  166. adcp/utils/operation_id.py +15 -0
  167. adcp/utils/preview_cache.py +491 -0
  168. adcp/utils/response_parser.py +171 -0
  169. adcp/validation.py +172 -0
  170. adcp-2.12.0.data/data/ADCP_VERSION +1 -0
  171. adcp-2.12.0.dist-info/METADATA +992 -0
  172. adcp-2.12.0.dist-info/RECORD +176 -0
  173. adcp-2.12.0.dist-info/WHEEL +5 -0
  174. adcp-2.12.0.dist-info/entry_points.txt +2 -0
  175. adcp-2.12.0.dist-info/licenses/LICENSE +17 -0
  176. adcp-2.12.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,491 @@
1
+ """Helper utilities for generating creative preview URLs for grid rendering."""
2
+
3
+ # mypy: disable-error-code="arg-type,attr-defined,call-arg,unused-ignore,union-attr"
4
+
5
+ from __future__ import annotations
6
+
7
+ import hashlib
8
+ import logging
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ if TYPE_CHECKING:
12
+ from adcp.client import ADCPClient
13
+ from adcp.types import CreativeManifest, Format, FormatId, Product
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def _make_manifest_cache_key(format_id: FormatId | str, manifest_dict: dict[str, Any]) -> str:
19
+ """
20
+ Create a cache key for a format_id and manifest.
21
+
22
+ Args:
23
+ format_id: Format identifier (FormatId object or string)
24
+ manifest_dict: Creative manifest dict
25
+
26
+ Returns:
27
+ Cache key string
28
+ """
29
+ # Convert FormatId to string representation
30
+ if isinstance(format_id, str):
31
+ format_id_str = format_id
32
+ else:
33
+ # FormatId is a Pydantic model with agent_url and id
34
+ format_id_str = f"{format_id.agent_url}:{format_id.id}"
35
+
36
+ manifest_str = str(sorted(manifest_dict.items()))
37
+ combined = f"{format_id_str}:{manifest_str}"
38
+ return hashlib.sha256(combined.encode()).hexdigest()[:16]
39
+
40
+
41
+ class PreviewURLGenerator:
42
+ """Helper class for generating preview URLs from creative agents."""
43
+
44
+ def __init__(self, creative_agent_client: ADCPClient):
45
+ """
46
+ Initialize preview URL generator.
47
+
48
+ Args:
49
+ creative_agent_client: ADCPClient configured to talk to a creative agent
50
+ """
51
+ self.creative_agent_client = creative_agent_client
52
+ self._preview_cache: dict[str, dict[str, Any]] = {}
53
+
54
+ async def get_preview_data_for_manifest(
55
+ self, format_id: FormatId, manifest: CreativeManifest
56
+ ) -> dict[str, Any] | None:
57
+ """
58
+ Generate preview data for a creative manifest.
59
+
60
+ Returns preview data with URLs suitable for embedding in
61
+ <rendered-creative> web components or iframes.
62
+
63
+ Args:
64
+ format_id: Format identifier
65
+ manifest: Creative manifest
66
+
67
+ Returns:
68
+ Preview data with preview_url and metadata, or None if generation fails
69
+ """
70
+ from adcp.types.aliases import PreviewCreativeFormatRequest
71
+
72
+ cache_key = _make_manifest_cache_key(format_id, manifest.model_dump(exclude_none=True))
73
+
74
+ if cache_key in self._preview_cache:
75
+ return self._preview_cache[cache_key]
76
+
77
+ try:
78
+ request = PreviewCreativeFormatRequest(
79
+ request_type="single",
80
+ format_id=format_id,
81
+ creative_manifest=manifest,
82
+ )
83
+ result = await self.creative_agent_client.preview_creative(request)
84
+
85
+ if result.success and result.data and result.data.previews:
86
+ preview = result.data.previews[0]
87
+ first_render = preview.renders[0] if preview.renders else None
88
+
89
+ if first_render:
90
+ has_url = hasattr(first_render, "preview_url")
91
+ preview_url = str(first_render.preview_url) if has_url else None
92
+ preview_data = {
93
+ "preview_id": preview.preview_id,
94
+ "preview_url": preview_url,
95
+ "preview_html": getattr(first_render, "preview_html", None),
96
+ "render_id": first_render.render_id,
97
+ "input": preview.input.model_dump(),
98
+ "expires_at": str(result.data.expires_at),
99
+ }
100
+
101
+ self._preview_cache[cache_key] = preview_data
102
+ return preview_data
103
+
104
+ except Exception as e:
105
+ logger.warning(f"Failed to generate preview for format {format_id}: {e}", exc_info=True)
106
+
107
+ return None
108
+
109
+ async def get_preview_data_batch(
110
+ self,
111
+ requests: list[tuple[FormatId, CreativeManifest]],
112
+ output_format: str = "url",
113
+ ) -> list[dict[str, Any] | None]:
114
+ """
115
+ Generate preview data for multiple manifests in one API call (batch mode).
116
+
117
+ This is 5-10x faster than individual requests for multiple previews.
118
+
119
+ Args:
120
+ requests: List of (format_id, manifest) tuples to preview
121
+ output_format: "url" for iframe URLs, "html" for direct embedding
122
+
123
+ Returns:
124
+ List of preview data dicts (or None for failures), in same order as requests
125
+ """
126
+ from adcp.types import PreviewCreativeRequest
127
+
128
+ if not requests:
129
+ return []
130
+
131
+ # Check cache first
132
+ cache_keys = [
133
+ _make_manifest_cache_key(fid, manifest.model_dump(exclude_none=True))
134
+ for fid, manifest in requests
135
+ ]
136
+
137
+ # Separate cached vs uncached requests
138
+ uncached_indices: list[int] = []
139
+ uncached_requests: list[dict[str, Any]] = []
140
+ results: list[dict[str, Any] | None] = [None] * len(requests)
141
+
142
+ for idx, (cache_key, (format_id, manifest)) in enumerate(zip(cache_keys, requests)):
143
+ if cache_key in self._preview_cache:
144
+ results[idx] = self._preview_cache[cache_key]
145
+ else:
146
+ uncached_indices.append(idx)
147
+ fid_dict = format_id.model_dump() if hasattr(format_id, "model_dump") else format_id
148
+ uncached_requests.append(
149
+ {
150
+ "format_id": fid_dict,
151
+ "creative_manifest": manifest.model_dump(exclude_none=True),
152
+ }
153
+ )
154
+
155
+ # If everything was cached, return early
156
+ if not uncached_requests:
157
+ return results
158
+
159
+ # Make batch API call for uncached items
160
+ try:
161
+ # Batch requests in chunks of 50 (API limit)
162
+ batch_size = 50
163
+ for chunk_start in range(0, len(uncached_requests), batch_size):
164
+ chunk_end = min(chunk_start + batch_size, len(uncached_requests))
165
+ chunk_requests = uncached_requests[chunk_start:chunk_end]
166
+ chunk_indices = uncached_indices[chunk_start:chunk_end]
167
+
168
+ batch_request = PreviewCreativeRequest(
169
+ requests=chunk_requests,
170
+ output_format=output_format, # type: ignore[arg-type]
171
+ context=None,
172
+ )
173
+ result = await self.creative_agent_client.preview_creative(batch_request)
174
+
175
+ if result.success and result.data and result.data.results:
176
+ # Process batch results
177
+ for result_idx, batch_result in enumerate(result.data.results):
178
+ original_idx = chunk_indices[result_idx]
179
+ cache_key = cache_keys[original_idx]
180
+
181
+ if batch_result.get("success") and batch_result.get("response"):
182
+ response = batch_result["response"]
183
+ if response.get("previews"):
184
+ preview = response["previews"][0]
185
+ renders = preview.get("renders", [])
186
+ first_render = renders[0] if renders else {}
187
+ preview_data = {
188
+ "preview_id": preview.get("preview_id"),
189
+ "preview_url": first_render.get("preview_url"),
190
+ "preview_html": first_render.get("preview_html"),
191
+ "render_id": first_render.get("render_id"),
192
+ "input": preview.get("input", {}),
193
+ "expires_at": response.get("expires_at"),
194
+ }
195
+ # Cache and store
196
+ self._preview_cache[cache_key] = preview_data
197
+ results[original_idx] = preview_data
198
+ else:
199
+ # Request failed
200
+ error = batch_result.get("error", {})
201
+ logger.warning(
202
+ f"Batch preview failed for request {original_idx}: "
203
+ f"{error.get('message', 'Unknown error')}"
204
+ )
205
+
206
+ except Exception as e:
207
+ logger.warning(f"Batch preview generation failed: {e}", exc_info=True)
208
+
209
+ return results
210
+
211
+
212
+ async def add_preview_urls_to_formats(
213
+ formats: list[Format],
214
+ creative_agent_client: ADCPClient,
215
+ use_batch: bool = True,
216
+ output_format: str = "url",
217
+ ) -> list[dict[str, Any]]:
218
+ """
219
+ Add preview URLs to each format by generating sample manifests.
220
+
221
+ Uses batch API for 5-10x better performance when previewing multiple formats.
222
+
223
+ Args:
224
+ formats: List of Format objects
225
+ creative_agent_client: Client for the creative agent
226
+ use_batch: If True, use batch API (default). Set False to use individual requests.
227
+ output_format: "url" for iframe URLs, "html" for direct embedding
228
+
229
+ Returns:
230
+ List of format dicts with added preview_data fields
231
+ """
232
+ if not formats:
233
+ return []
234
+
235
+ generator = PreviewURLGenerator(creative_agent_client)
236
+
237
+ # Prepare all requests
238
+ format_requests = []
239
+ for fmt in formats:
240
+ sample_manifest = _create_sample_manifest_for_format(fmt)
241
+ if sample_manifest:
242
+ format_requests.append((fmt, sample_manifest))
243
+
244
+ if not format_requests:
245
+ return [fmt.model_dump(exclude_none=True) for fmt in formats]
246
+
247
+ # Use batch API if requested and we have multiple formats
248
+ if use_batch and len(format_requests) > 1:
249
+ # Batch mode - much faster!
250
+ batch_requests = [(fmt.format_id, manifest) for fmt, manifest in format_requests]
251
+ preview_data_list = await generator.get_preview_data_batch(
252
+ batch_requests, output_format=output_format
253
+ )
254
+
255
+ # Merge preview data back with formats
256
+ result = []
257
+ preview_idx = 0
258
+ for fmt in formats:
259
+ format_dict = fmt.model_dump(exclude_none=True)
260
+ # Check if this format had a manifest
261
+ if preview_idx < len(format_requests) and format_requests[preview_idx][0] == fmt:
262
+ preview_data = preview_data_list[preview_idx]
263
+ if preview_data:
264
+ format_dict["preview_data"] = preview_data
265
+ preview_idx += 1
266
+ result.append(format_dict)
267
+ return result
268
+ else:
269
+ # Fallback to individual requests (for single format or when batch disabled)
270
+ import asyncio
271
+
272
+ async def process_format(fmt: Format) -> dict[str, Any]:
273
+ """Process a single format and add preview data."""
274
+ format_dict = fmt.model_dump(exclude_none=True)
275
+
276
+ try:
277
+ sample_manifest = _create_sample_manifest_for_format(fmt)
278
+ if sample_manifest:
279
+ preview_data = await generator.get_preview_data_for_manifest(
280
+ fmt.format_id, sample_manifest
281
+ )
282
+ if preview_data:
283
+ format_dict["preview_data"] = preview_data
284
+ except Exception as e:
285
+ logger.warning(f"Failed to add preview data for format {fmt.format_id}: {e}")
286
+
287
+ return format_dict
288
+
289
+ return await asyncio.gather(*[process_format(fmt) for fmt in formats])
290
+
291
+
292
+ async def add_preview_urls_to_products(
293
+ products: list[Product],
294
+ creative_agent_client: ADCPClient,
295
+ use_batch: bool = True,
296
+ output_format: str = "url",
297
+ ) -> list[dict[str, Any]]:
298
+ """
299
+ Add preview URLs to products for their supported formats.
300
+
301
+ Uses batch API for 5-10x better performance when previewing many product formats.
302
+
303
+ Args:
304
+ products: List of Product objects
305
+ creative_agent_client: Client for the creative agent
306
+ use_batch: If True, use batch API (default). Set False to use individual requests.
307
+ output_format: "url" for iframe URLs, "html" for direct embedding
308
+
309
+ Returns:
310
+ List of product dicts with added format_previews field
311
+ """
312
+ if not products:
313
+ return []
314
+
315
+ generator = PreviewURLGenerator(creative_agent_client)
316
+
317
+ # Collect all unique format_id + manifest combinations across all products
318
+ all_requests: list[tuple[Product, FormatId, CreativeManifest]] = []
319
+ for product in products:
320
+ for format_id in product.format_ids:
321
+ sample_manifest = _create_sample_manifest_for_format_id(format_id, product)
322
+ if sample_manifest:
323
+ all_requests.append((product, format_id, sample_manifest))
324
+
325
+ if not all_requests:
326
+ return [p.model_dump(exclude_none=True) for p in products]
327
+
328
+ # Use batch API if requested and we have multiple requests
329
+ if use_batch and len(all_requests) > 1:
330
+ # Batch mode - much faster!
331
+ batch_requests = [(format_id, manifest) for _, format_id, manifest in all_requests]
332
+ preview_data_list = await generator.get_preview_data_batch(
333
+ batch_requests, output_format=output_format
334
+ )
335
+
336
+ # Map results back to products
337
+ # Build a mapping from product_id -> format_id -> preview_data
338
+ product_previews: dict[str, dict[str, dict[str, Any]]] = {}
339
+ for (product, format_id, _), preview_data in zip(all_requests, preview_data_list):
340
+ if preview_data:
341
+ if product.product_id not in product_previews:
342
+ product_previews[product.product_id] = {}
343
+ product_previews[product.product_id][format_id.id] = preview_data
344
+
345
+ # Add preview data to products
346
+ result = []
347
+ for product in products:
348
+ product_dict = product.model_dump(exclude_none=True)
349
+ if product.product_id in product_previews:
350
+ product_dict["format_previews"] = product_previews[product.product_id]
351
+ result.append(product_dict)
352
+ return result
353
+ else:
354
+ # Fallback to individual requests (for single product/format or when batch disabled)
355
+ import asyncio
356
+
357
+ async def process_product(product: Product) -> dict[str, Any]:
358
+ """Process a single product and add preview data for all its formats."""
359
+ product_dict = product.model_dump(exclude_none=True)
360
+
361
+ async def process_format(format_id: FormatId) -> tuple[str, dict[str, Any] | None]:
362
+ """Process a single format for this product."""
363
+ try:
364
+ sample_manifest = _create_sample_manifest_for_format_id(format_id, product)
365
+ if sample_manifest:
366
+ preview_data = await generator.get_preview_data_for_manifest(
367
+ format_id, sample_manifest
368
+ )
369
+ return (format_id.id, preview_data)
370
+ except Exception as e:
371
+ logger.warning(
372
+ f"Failed to generate preview for product {product.product_id}, "
373
+ f"format {format_id}: {e}"
374
+ )
375
+ return (format_id.id, None)
376
+
377
+ format_tasks = [process_format(fid) for fid in product.format_ids]
378
+ format_results = await asyncio.gather(*format_tasks)
379
+ format_previews = {fid: data for fid, data in format_results if data is not None}
380
+
381
+ if format_previews:
382
+ product_dict["format_previews"] = format_previews
383
+
384
+ return product_dict
385
+
386
+ return await asyncio.gather(*[process_product(product) for product in products])
387
+
388
+
389
+ def _create_sample_manifest_for_format(fmt: Format) -> CreativeManifest | None:
390
+ """
391
+ Create a sample manifest for a format.
392
+
393
+ Args:
394
+ fmt: Format object
395
+
396
+ Returns:
397
+ Sample CreativeManifest, or None if unable to create one
398
+ """
399
+ from adcp.types import CreativeManifest
400
+
401
+ if not fmt.assets_required:
402
+ return None
403
+
404
+ assets: dict[str, Any] = {}
405
+
406
+ for asset in fmt.assets_required:
407
+ if isinstance(asset, dict):
408
+ asset_id = asset.get("asset_id")
409
+ asset_type = asset.get("asset_type")
410
+
411
+ if asset_id:
412
+ assets[asset_id] = _create_sample_asset(asset_type)
413
+ else:
414
+ # Handle Pydantic model
415
+ asset_id = asset.asset_id
416
+ has_value = hasattr(asset.asset_type, "value")
417
+ asset_type = asset.asset_type.value if has_value else str(asset.asset_type)
418
+ assets[asset_id] = _create_sample_asset(asset_type)
419
+
420
+ if not assets:
421
+ return None
422
+
423
+ return CreativeManifest(format_id=fmt.format_id, assets=assets, promoted_offering=None)
424
+
425
+
426
+ def _create_sample_manifest_for_format_id(
427
+ format_id: FormatId, product: Product
428
+ ) -> CreativeManifest | None:
429
+ """
430
+ Create a sample manifest for a format ID referenced by a product.
431
+
432
+ Args:
433
+ format_id: Format identifier
434
+ product: Product that references this format
435
+
436
+ Returns:
437
+ Sample CreativeManifest with placeholder assets
438
+ """
439
+ from adcp.types import CreativeManifest, ImageAsset, UrlAsset
440
+
441
+ assets = {
442
+ "primary_asset": ImageAsset(
443
+ url="https://example.com/sample-image.jpg",
444
+ width=300,
445
+ height=250,
446
+ ),
447
+ "clickthrough_url": UrlAsset(url="https://example.com"),
448
+ }
449
+
450
+ return CreativeManifest(format_id=format_id, promoted_offering=product.name, assets=assets)
451
+
452
+
453
+ def _create_sample_asset(asset_type: str | None) -> Any:
454
+ """
455
+ Create a sample asset value based on asset type.
456
+
457
+ Args:
458
+ asset_type: Type of asset (image, video, text, url, etc.)
459
+
460
+ Returns:
461
+ Sample asset object (Pydantic model)
462
+ """
463
+ from adcp.types import (
464
+ HtmlAsset,
465
+ ImageAsset,
466
+ TextAsset,
467
+ UrlAsset,
468
+ VideoAsset,
469
+ )
470
+
471
+ if asset_type == "image":
472
+ return ImageAsset(
473
+ url="https://via.placeholder.com/300x250.png",
474
+ width=300,
475
+ height=250,
476
+ )
477
+ elif asset_type == "video":
478
+ return VideoAsset(
479
+ url="https://example.com/sample-video.mp4",
480
+ width=1920,
481
+ height=1080,
482
+ )
483
+ elif asset_type == "text":
484
+ return TextAsset(content="Sample advertising text")
485
+ elif asset_type == "url":
486
+ return UrlAsset(url="https://example.com")
487
+ elif asset_type == "html":
488
+ return HtmlAsset(content="<div>Sample HTML</div>")
489
+ else:
490
+ # Default to URL asset for unknown types
491
+ return UrlAsset(url="https://example.com/sample-asset")
@@ -0,0 +1,171 @@
1
+ from __future__ import annotations
2
+
3
+ """Utilities for parsing protocol responses into structured types."""
4
+
5
+ import json
6
+ import logging
7
+ from typing import Any, TypeVar, Union, cast, get_args, get_origin
8
+
9
+ from pydantic import BaseModel, ValidationError
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ T = TypeVar("T", bound=BaseModel)
14
+
15
+
16
+ def _validate_union_type(data: dict[str, Any], response_type: type[T]) -> T:
17
+ """
18
+ Validate data against a Union type by trying each variant.
19
+
20
+ Args:
21
+ data: Data to validate
22
+ response_type: Union type to validate against
23
+
24
+ Returns:
25
+ Validated model instance
26
+
27
+ Raises:
28
+ ValidationError: If data doesn't match any Union variant
29
+ """
30
+ # Check if this is a Union type (handles both typing.Union and types.UnionType)
31
+ origin = get_origin(response_type)
32
+
33
+ # In Python 3.10+, X | Y creates a types.UnionType, not typing.Union
34
+ # We need to check both the origin and the type itself
35
+ is_union = origin is Union or str(type(response_type).__name__) == "UnionType"
36
+
37
+ if is_union:
38
+ # Get union args - works for both typing.Union and types.UnionType
39
+ args = get_args(response_type)
40
+ if not args: # types.UnionType case
41
+ # For types.UnionType, we need to access __args__ directly
42
+ args = getattr(response_type, "__args__", ())
43
+
44
+ errors = []
45
+ for variant in args:
46
+ try:
47
+ return cast(T, variant.model_validate(data))
48
+ except ValidationError as e:
49
+ errors.append((variant.__name__, e))
50
+ continue
51
+
52
+ # If we get here, none of the variants worked
53
+ error_msgs = [f"{name}: {str(e)}" for name, e in errors]
54
+ # Raise a ValueError instead of ValidationError for better error messages
55
+ raise ValueError(
56
+ f"Data doesn't match any Union variant. "
57
+ f"Attempted variants: {', '.join([e[0] for e in errors])}. "
58
+ f"Errors: {'; '.join(error_msgs)}"
59
+ )
60
+
61
+ # Not a Union type, use regular validation
62
+ # Cast is needed because response_type is typed as type[T] | Any
63
+ return cast(T, response_type.model_validate(data)) # type: ignore[redundant-cast]
64
+
65
+
66
+ def parse_mcp_content(content: list[dict[str, Any]], response_type: type[T]) -> T:
67
+ """
68
+ Parse MCP content array into structured response type.
69
+
70
+ MCP tools return content as a list of content items:
71
+ [{"type": "text", "text": "..."}, {"type": "resource", ...}]
72
+
73
+ The MCP adapter is responsible for serializing MCP SDK Pydantic objects
74
+ to plain dicts before calling this function.
75
+
76
+ For AdCP, we expect JSON data in text content items.
77
+
78
+ Args:
79
+ content: MCP content array (list of plain dicts)
80
+ response_type: Expected Pydantic model type
81
+
82
+ Returns:
83
+ Parsed and validated response object
84
+
85
+ Raises:
86
+ ValueError: If content cannot be parsed into expected type
87
+ """
88
+ if not content:
89
+ raise ValueError("Empty MCP content array")
90
+
91
+ # Look for text content items that might contain JSON
92
+ for item in content:
93
+ if item.get("type") == "text":
94
+ text = item.get("text", "")
95
+ if not text:
96
+ continue
97
+
98
+ try:
99
+ # Try parsing as JSON
100
+ data = json.loads(text)
101
+ # Validate against expected schema (handles Union types)
102
+ return _validate_union_type(data, response_type)
103
+ except json.JSONDecodeError:
104
+ # Not JSON, try next item
105
+ continue
106
+ except ValidationError as e:
107
+ logger.warning(
108
+ f"MCP content doesn't match expected schema {response_type.__name__}: {e}"
109
+ )
110
+ raise ValueError(f"MCP response doesn't match expected schema: {e}") from e
111
+ elif item.get("type") == "resource":
112
+ # Resource content might have structured data
113
+ try:
114
+ return _validate_union_type(item, response_type)
115
+ except ValidationError:
116
+ # Try next item
117
+ continue
118
+
119
+ # If we get here, no content item could be parsed
120
+ # Include content preview for debugging (first 2 items, max 500 chars each)
121
+ content_preview = json.dumps(content[:2], indent=2, default=str)
122
+ if len(content_preview) > 500:
123
+ content_preview = content_preview[:500] + "..."
124
+
125
+ raise ValueError(
126
+ f"No valid {response_type.__name__} data found in MCP content. "
127
+ f"Content types: {[item.get('type') for item in content]}. "
128
+ f"Content preview:\n{content_preview}"
129
+ )
130
+
131
+
132
+ def parse_json_or_text(data: Any, response_type: type[T]) -> T:
133
+ """
134
+ Parse data that might be JSON string, dict, or other format.
135
+
136
+ Used by A2A adapter for flexible response parsing.
137
+
138
+ Args:
139
+ data: Response data (string, dict, or other)
140
+ response_type: Expected Pydantic model type
141
+
142
+ Returns:
143
+ Parsed and validated response object
144
+
145
+ Raises:
146
+ ValueError: If data cannot be parsed into expected type
147
+ """
148
+ # If already a dict, try direct validation
149
+ if isinstance(data, dict):
150
+ try:
151
+ return _validate_union_type(data, response_type)
152
+ except ValidationError as e:
153
+ # Get the type name, handling Union types
154
+ type_name = getattr(response_type, "__name__", str(response_type))
155
+ raise ValueError(f"Response doesn't match expected schema {type_name}: {e}") from e
156
+
157
+ # If string, try JSON parsing
158
+ if isinstance(data, str):
159
+ try:
160
+ parsed = json.loads(data)
161
+ return _validate_union_type(parsed, response_type)
162
+ except json.JSONDecodeError as e:
163
+ raise ValueError(f"Response is not valid JSON: {e}") from e
164
+ except ValidationError as e:
165
+ # Get the type name, handling Union types
166
+ type_name = getattr(response_type, "__name__", str(response_type))
167
+ raise ValueError(f"Response doesn't match expected schema {type_name}: {e}") from e
168
+
169
+ # Unsupported type
170
+ type_name = getattr(response_type, "__name__", str(response_type))
171
+ raise ValueError(f"Cannot parse response of type {type(data).__name__} into {type_name}")