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.
- adcp/__init__.py +364 -0
- adcp/__main__.py +440 -0
- adcp/adagents.py +642 -0
- adcp/client.py +1057 -0
- adcp/config.py +82 -0
- adcp/exceptions.py +185 -0
- adcp/protocols/__init__.py +9 -0
- adcp/protocols/a2a.py +484 -0
- adcp/protocols/base.py +190 -0
- adcp/protocols/mcp.py +440 -0
- adcp/py.typed +0 -0
- adcp/simple.py +451 -0
- adcp/testing/__init__.py +53 -0
- adcp/testing/test_helpers.py +311 -0
- adcp/types/__init__.py +561 -0
- adcp/types/_generated.py +237 -0
- adcp/types/aliases.py +748 -0
- adcp/types/base.py +26 -0
- adcp/types/core.py +174 -0
- adcp/types/generated_poc/__init__.py +3 -0
- adcp/types/generated_poc/adagents.py +411 -0
- adcp/types/generated_poc/core/__init__.py +3 -0
- adcp/types/generated_poc/core/activation_key.py +30 -0
- adcp/types/generated_poc/core/assets/__init__.py +3 -0
- adcp/types/generated_poc/core/assets/audio_asset.py +26 -0
- adcp/types/generated_poc/core/assets/css_asset.py +20 -0
- adcp/types/generated_poc/core/assets/daast_asset.py +61 -0
- adcp/types/generated_poc/core/assets/html_asset.py +18 -0
- adcp/types/generated_poc/core/assets/image_asset.py +19 -0
- adcp/types/generated_poc/core/assets/javascript_asset.py +23 -0
- adcp/types/generated_poc/core/assets/text_asset.py +20 -0
- adcp/types/generated_poc/core/assets/url_asset.py +28 -0
- adcp/types/generated_poc/core/assets/vast_asset.py +63 -0
- adcp/types/generated_poc/core/assets/video_asset.py +24 -0
- adcp/types/generated_poc/core/assets/webhook_asset.py +53 -0
- adcp/types/generated_poc/core/brand_manifest.py +201 -0
- adcp/types/generated_poc/core/context.py +15 -0
- adcp/types/generated_poc/core/creative_asset.py +102 -0
- adcp/types/generated_poc/core/creative_assignment.py +27 -0
- adcp/types/generated_poc/core/creative_filters.py +86 -0
- adcp/types/generated_poc/core/creative_manifest.py +68 -0
- adcp/types/generated_poc/core/creative_policy.py +28 -0
- adcp/types/generated_poc/core/delivery_metrics.py +111 -0
- adcp/types/generated_poc/core/deployment.py +78 -0
- adcp/types/generated_poc/core/destination.py +43 -0
- adcp/types/generated_poc/core/dimensions.py +18 -0
- adcp/types/generated_poc/core/error.py +29 -0
- adcp/types/generated_poc/core/ext.py +15 -0
- adcp/types/generated_poc/core/format.py +260 -0
- adcp/types/generated_poc/core/format_id.py +50 -0
- adcp/types/generated_poc/core/frequency_cap.py +19 -0
- adcp/types/generated_poc/core/measurement.py +40 -0
- adcp/types/generated_poc/core/media_buy.py +40 -0
- adcp/types/generated_poc/core/package.py +68 -0
- adcp/types/generated_poc/core/performance_feedback.py +78 -0
- adcp/types/generated_poc/core/placement.py +37 -0
- adcp/types/generated_poc/core/product.py +164 -0
- adcp/types/generated_poc/core/product_filters.py +97 -0
- adcp/types/generated_poc/core/promoted_offerings.py +102 -0
- adcp/types/generated_poc/core/promoted_products.py +38 -0
- adcp/types/generated_poc/core/property.py +64 -0
- adcp/types/generated_poc/core/property_id.py +21 -0
- adcp/types/generated_poc/core/property_tag.py +21 -0
- adcp/types/generated_poc/core/protocol_envelope.py +61 -0
- adcp/types/generated_poc/core/publisher_property_selector.py +75 -0
- adcp/types/generated_poc/core/push_notification_config.py +51 -0
- adcp/types/generated_poc/core/reporting_capabilities.py +51 -0
- adcp/types/generated_poc/core/response.py +24 -0
- adcp/types/generated_poc/core/signal_filters.py +29 -0
- adcp/types/generated_poc/core/sub_asset.py +55 -0
- adcp/types/generated_poc/core/targeting.py +53 -0
- adcp/types/generated_poc/core/webhook_payload.py +96 -0
- adcp/types/generated_poc/creative/__init__.py +3 -0
- adcp/types/generated_poc/creative/list_creative_formats_request.py +88 -0
- adcp/types/generated_poc/creative/list_creative_formats_response.py +55 -0
- adcp/types/generated_poc/creative/preview_creative_request.py +153 -0
- adcp/types/generated_poc/creative/preview_creative_response.py +169 -0
- adcp/types/generated_poc/creative/preview_render.py +152 -0
- adcp/types/generated_poc/enums/__init__.py +3 -0
- adcp/types/generated_poc/enums/adcp_domain.py +12 -0
- adcp/types/generated_poc/enums/asset_content_type.py +23 -0
- adcp/types/generated_poc/enums/auth_scheme.py +12 -0
- adcp/types/generated_poc/enums/available_metric.py +19 -0
- adcp/types/generated_poc/enums/channels.py +19 -0
- adcp/types/generated_poc/enums/co_branding_requirement.py +13 -0
- adcp/types/generated_poc/enums/creative_action.py +15 -0
- adcp/types/generated_poc/enums/creative_agent_capability.py +14 -0
- adcp/types/generated_poc/enums/creative_sort_field.py +16 -0
- adcp/types/generated_poc/enums/creative_status.py +14 -0
- adcp/types/generated_poc/enums/daast_tracking_event.py +21 -0
- adcp/types/generated_poc/enums/daast_version.py +12 -0
- adcp/types/generated_poc/enums/delivery_type.py +12 -0
- adcp/types/generated_poc/enums/dimension_unit.py +14 -0
- adcp/types/generated_poc/enums/feed_format.py +13 -0
- adcp/types/generated_poc/enums/feedback_source.py +14 -0
- adcp/types/generated_poc/enums/format_category.py +17 -0
- adcp/types/generated_poc/enums/format_id_parameter.py +12 -0
- adcp/types/generated_poc/enums/frequency_cap_scope.py +16 -0
- adcp/types/generated_poc/enums/history_entry_type.py +12 -0
- adcp/types/generated_poc/enums/http_method.py +12 -0
- adcp/types/generated_poc/enums/identifier_types.py +29 -0
- adcp/types/generated_poc/enums/javascript_module_type.py +13 -0
- adcp/types/generated_poc/enums/landing_page_requirement.py +13 -0
- adcp/types/generated_poc/enums/markdown_flavor.py +12 -0
- adcp/types/generated_poc/enums/media_buy_status.py +14 -0
- adcp/types/generated_poc/enums/metric_type.py +18 -0
- adcp/types/generated_poc/enums/notification_type.py +14 -0
- adcp/types/generated_poc/enums/pacing.py +13 -0
- adcp/types/generated_poc/enums/preview_output_format.py +12 -0
- adcp/types/generated_poc/enums/pricing_model.py +17 -0
- adcp/types/generated_poc/enums/property_type.py +17 -0
- adcp/types/generated_poc/enums/publisher_identifier_types.py +15 -0
- adcp/types/generated_poc/enums/reporting_frequency.py +13 -0
- adcp/types/generated_poc/enums/signal_catalog_type.py +13 -0
- adcp/types/generated_poc/enums/sort_direction.py +12 -0
- adcp/types/generated_poc/enums/standard_format_ids.py +45 -0
- adcp/types/generated_poc/enums/task_status.py +19 -0
- adcp/types/generated_poc/enums/task_type.py +15 -0
- adcp/types/generated_poc/enums/update_frequency.py +14 -0
- adcp/types/generated_poc/enums/url_asset_type.py +13 -0
- adcp/types/generated_poc/enums/validation_mode.py +12 -0
- adcp/types/generated_poc/enums/vast_tracking_event.py +26 -0
- adcp/types/generated_poc/enums/vast_version.py +15 -0
- adcp/types/generated_poc/enums/webhook_response_type.py +14 -0
- adcp/types/generated_poc/enums/webhook_security_method.py +13 -0
- adcp/types/generated_poc/media_buy/__init__.py +3 -0
- adcp/types/generated_poc/media_buy/build_creative_request.py +41 -0
- adcp/types/generated_poc/media_buy/build_creative_response.py +51 -0
- adcp/types/generated_poc/media_buy/create_media_buy_request.py +94 -0
- adcp/types/generated_poc/media_buy/create_media_buy_response.py +56 -0
- adcp/types/generated_poc/media_buy/get_media_buy_delivery_request.py +47 -0
- adcp/types/generated_poc/media_buy/get_media_buy_delivery_response.py +235 -0
- adcp/types/generated_poc/media_buy/get_products_request.py +48 -0
- adcp/types/generated_poc/media_buy/get_products_response.py +28 -0
- adcp/types/generated_poc/media_buy/list_authorized_properties_request.py +38 -0
- adcp/types/generated_poc/media_buy/list_authorized_properties_response.py +84 -0
- adcp/types/generated_poc/media_buy/list_creative_formats_request.py +74 -0
- adcp/types/generated_poc/media_buy/list_creative_formats_response.py +56 -0
- adcp/types/generated_poc/media_buy/list_creatives_request.py +76 -0
- adcp/types/generated_poc/media_buy/list_creatives_response.py +214 -0
- adcp/types/generated_poc/media_buy/package_request.py +63 -0
- adcp/types/generated_poc/media_buy/provide_performance_feedback_request.py +125 -0
- adcp/types/generated_poc/media_buy/provide_performance_feedback_response.py +53 -0
- adcp/types/generated_poc/media_buy/sync_creatives_request.py +63 -0
- adcp/types/generated_poc/media_buy/sync_creatives_response.py +105 -0
- adcp/types/generated_poc/media_buy/update_media_buy_request.py +195 -0
- adcp/types/generated_poc/media_buy/update_media_buy_response.py +55 -0
- adcp/types/generated_poc/pricing_options/__init__.py +3 -0
- adcp/types/generated_poc/pricing_options/cpc_option.py +43 -0
- adcp/types/generated_poc/pricing_options/cpcv_option.py +45 -0
- adcp/types/generated_poc/pricing_options/cpm_auction_option.py +58 -0
- adcp/types/generated_poc/pricing_options/cpm_fixed_option.py +43 -0
- adcp/types/generated_poc/pricing_options/cpp_option.py +64 -0
- adcp/types/generated_poc/pricing_options/cpv_option.py +77 -0
- adcp/types/generated_poc/pricing_options/flat_rate_option.py +93 -0
- adcp/types/generated_poc/pricing_options/vcpm_auction_option.py +61 -0
- adcp/types/generated_poc/pricing_options/vcpm_fixed_option.py +47 -0
- adcp/types/generated_poc/protocols/__init__.py +3 -0
- adcp/types/generated_poc/protocols/adcp_extension.py +37 -0
- adcp/types/generated_poc/signals/__init__.py +3 -0
- adcp/types/generated_poc/signals/activate_signal_request.py +32 -0
- adcp/types/generated_poc/signals/activate_signal_response.py +51 -0
- adcp/types/generated_poc/signals/get_signals_request.py +53 -0
- adcp/types/generated_poc/signals/get_signals_response.py +59 -0
- adcp/utils/__init__.py +7 -0
- adcp/utils/operation_id.py +15 -0
- adcp/utils/preview_cache.py +491 -0
- adcp/utils/response_parser.py +171 -0
- adcp/validation.py +172 -0
- adcp-2.12.0.data/data/ADCP_VERSION +1 -0
- adcp-2.12.0.dist-info/METADATA +992 -0
- adcp-2.12.0.dist-info/RECORD +176 -0
- adcp-2.12.0.dist-info/WHEEL +5 -0
- adcp-2.12.0.dist-info/entry_points.txt +2 -0
- adcp-2.12.0.dist-info/licenses/LICENSE +17 -0
- 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}")
|