adcp 1.5.0__py3-none-any.whl → 1.6.1__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 +26 -1
- adcp/adagents.py +521 -0
- adcp/exceptions.py +30 -0
- adcp/types/__init__.py +2 -0
- adcp/types/base.py +26 -0
- adcp/types/generated.py +14 -6
- adcp/utils/preview_cache.py +6 -1
- {adcp-1.5.0.dist-info → adcp-1.6.1.dist-info}/METADATA +45 -1
- {adcp-1.5.0.dist-info → adcp-1.6.1.dist-info}/RECORD +13 -11
- {adcp-1.5.0.dist-info → adcp-1.6.1.dist-info}/WHEEL +0 -0
- {adcp-1.5.0.dist-info → adcp-1.6.1.dist-info}/entry_points.txt +0 -0
- {adcp-1.5.0.dist-info → adcp-1.6.1.dist-info}/licenses/LICENSE +0 -0
- {adcp-1.5.0.dist-info → adcp-1.6.1.dist-info}/top_level.txt +0 -0
adcp/__init__.py
CHANGED
|
@@ -7,8 +7,21 @@ Official Python client for the Ad Context Protocol (AdCP).
|
|
|
7
7
|
Supports both A2A and MCP protocols with full type safety.
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
+
from adcp.adagents import (
|
|
11
|
+
domain_matches,
|
|
12
|
+
fetch_adagents,
|
|
13
|
+
get_all_properties,
|
|
14
|
+
get_all_tags,
|
|
15
|
+
get_properties_by_agent,
|
|
16
|
+
identifiers_match,
|
|
17
|
+
verify_agent_authorization,
|
|
18
|
+
verify_agent_for_property,
|
|
19
|
+
)
|
|
10
20
|
from adcp.client import ADCPClient, ADCPMultiAgentClient
|
|
11
21
|
from adcp.exceptions import (
|
|
22
|
+
AdagentsNotFoundError,
|
|
23
|
+
AdagentsTimeoutError,
|
|
24
|
+
AdagentsValidationError,
|
|
12
25
|
ADCPAuthenticationError,
|
|
13
26
|
ADCPConnectionError,
|
|
14
27
|
ADCPError,
|
|
@@ -150,7 +163,7 @@ from adcp.types.generated import (
|
|
|
150
163
|
TaskStatus as GeneratedTaskStatus,
|
|
151
164
|
)
|
|
152
165
|
|
|
153
|
-
__version__ = "1.
|
|
166
|
+
__version__ = "1.6.1"
|
|
154
167
|
|
|
155
168
|
__all__ = [
|
|
156
169
|
# Client classes
|
|
@@ -162,6 +175,15 @@ __all__ = [
|
|
|
162
175
|
"TaskResult",
|
|
163
176
|
"TaskStatus",
|
|
164
177
|
"WebhookMetadata",
|
|
178
|
+
# Adagents validation
|
|
179
|
+
"fetch_adagents",
|
|
180
|
+
"verify_agent_authorization",
|
|
181
|
+
"verify_agent_for_property",
|
|
182
|
+
"domain_matches",
|
|
183
|
+
"identifiers_match",
|
|
184
|
+
"get_all_properties",
|
|
185
|
+
"get_all_tags",
|
|
186
|
+
"get_properties_by_agent",
|
|
165
187
|
# Test helpers
|
|
166
188
|
"test_agent",
|
|
167
189
|
"test_agent_a2a",
|
|
@@ -185,6 +207,9 @@ __all__ = [
|
|
|
185
207
|
"ADCPToolNotFoundError",
|
|
186
208
|
"ADCPWebhookError",
|
|
187
209
|
"ADCPWebhookSignatureError",
|
|
210
|
+
"AdagentsValidationError",
|
|
211
|
+
"AdagentsNotFoundError",
|
|
212
|
+
"AdagentsTimeoutError",
|
|
188
213
|
# Request/Response types
|
|
189
214
|
"ActivateSignalRequest",
|
|
190
215
|
"ActivateSignalResponse",
|
adcp/adagents.py
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Utilities for fetching, parsing, and validating adagents.json files per the AdCP specification.
|
|
5
|
+
|
|
6
|
+
Publishers declare authorized sales agents via adagents.json files hosted at
|
|
7
|
+
https://{publisher_domain}/.well-known/adagents.json. This module provides utilities
|
|
8
|
+
for sales agents to verify they are authorized for specific properties.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import urlparse
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from adcp.exceptions import AdagentsNotFoundError, AdagentsTimeoutError, AdagentsValidationError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _normalize_domain(domain: str) -> str:
|
|
20
|
+
"""Normalize domain for comparison - strip, lowercase, remove trailing dots/slashes.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
domain: Domain to normalize
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Normalized domain string
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
AdagentsValidationError: If domain contains invalid patterns
|
|
30
|
+
"""
|
|
31
|
+
domain = domain.strip().lower()
|
|
32
|
+
# Remove both trailing slashes and dots iteratively
|
|
33
|
+
while domain.endswith("/") or domain.endswith("."):
|
|
34
|
+
domain = domain.rstrip("/").rstrip(".")
|
|
35
|
+
|
|
36
|
+
# Check for invalid patterns
|
|
37
|
+
if not domain or ".." in domain:
|
|
38
|
+
raise AdagentsValidationError(f"Invalid domain format: {domain!r}")
|
|
39
|
+
|
|
40
|
+
return domain
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _validate_publisher_domain(domain: str) -> str:
|
|
44
|
+
"""Validate and sanitize publisher domain for security.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
domain: Publisher domain to validate
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Validated and normalized domain
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
AdagentsValidationError: If domain is invalid or contains suspicious characters
|
|
54
|
+
"""
|
|
55
|
+
# Check for suspicious characters BEFORE stripping (to catch injection attempts)
|
|
56
|
+
suspicious_chars = ["\\", "@", "\n", "\r", "\t"]
|
|
57
|
+
for char in suspicious_chars:
|
|
58
|
+
if char in domain:
|
|
59
|
+
raise AdagentsValidationError(
|
|
60
|
+
f"Invalid character in publisher domain: {char!r}"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
domain = domain.strip()
|
|
64
|
+
|
|
65
|
+
# Check basic constraints
|
|
66
|
+
if not domain:
|
|
67
|
+
raise AdagentsValidationError("Publisher domain cannot be empty")
|
|
68
|
+
if len(domain) > 253: # DNS maximum length
|
|
69
|
+
raise AdagentsValidationError(f"Publisher domain too long: {len(domain)} chars (max 253)")
|
|
70
|
+
|
|
71
|
+
# Check for spaces after stripping leading/trailing whitespace
|
|
72
|
+
if " " in domain:
|
|
73
|
+
raise AdagentsValidationError(
|
|
74
|
+
"Invalid character in publisher domain: ' '"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Remove protocol if present (common user error) - do this BEFORE checking for slashes
|
|
78
|
+
if "://" in domain:
|
|
79
|
+
domain = domain.split("://", 1)[1]
|
|
80
|
+
|
|
81
|
+
# Remove path if present (should only be domain) - do this BEFORE checking for slashes
|
|
82
|
+
if "/" in domain:
|
|
83
|
+
domain = domain.split("/", 1)[0]
|
|
84
|
+
|
|
85
|
+
# Normalize
|
|
86
|
+
domain = _normalize_domain(domain)
|
|
87
|
+
|
|
88
|
+
# Final validation - must look like a domain
|
|
89
|
+
if "." not in domain:
|
|
90
|
+
raise AdagentsValidationError(
|
|
91
|
+
f"Publisher domain must contain at least one dot: {domain!r}"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return domain
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def normalize_url(url: str) -> str:
|
|
98
|
+
"""Normalize URL by removing protocol and trailing slash.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
url: URL to normalize
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Normalized URL (domain/path without protocol or trailing slash)
|
|
105
|
+
"""
|
|
106
|
+
parsed = urlparse(url)
|
|
107
|
+
normalized = parsed.netloc + parsed.path
|
|
108
|
+
return normalized.rstrip("/")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def domain_matches(property_domain: str, agent_domain_pattern: str) -> bool:
|
|
112
|
+
"""Check if domains match per AdCP rules.
|
|
113
|
+
|
|
114
|
+
Rules:
|
|
115
|
+
- Exact match always succeeds
|
|
116
|
+
- 'example.com' matches www.example.com, m.example.com (common subdomains)
|
|
117
|
+
- 'subdomain.example.com' matches that specific subdomain only
|
|
118
|
+
- '*.example.com' matches all subdomains
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
property_domain: Domain from property
|
|
122
|
+
agent_domain_pattern: Domain pattern from adagents.json
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
True if domains match per AdCP rules
|
|
126
|
+
"""
|
|
127
|
+
# Normalize both domains for comparison
|
|
128
|
+
try:
|
|
129
|
+
property_domain = _normalize_domain(property_domain)
|
|
130
|
+
agent_domain_pattern = _normalize_domain(agent_domain_pattern)
|
|
131
|
+
except AdagentsValidationError:
|
|
132
|
+
# Invalid domain format - no match
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
# Exact match
|
|
136
|
+
if property_domain == agent_domain_pattern:
|
|
137
|
+
return True
|
|
138
|
+
|
|
139
|
+
# Wildcard pattern (*.example.com)
|
|
140
|
+
if agent_domain_pattern.startswith("*."):
|
|
141
|
+
base_domain = agent_domain_pattern[2:]
|
|
142
|
+
return property_domain.endswith(f".{base_domain}")
|
|
143
|
+
|
|
144
|
+
# Bare domain matches common subdomains (www, m)
|
|
145
|
+
# If agent pattern is a bare domain (no subdomain), match www/m subdomains
|
|
146
|
+
if "." in agent_domain_pattern and not agent_domain_pattern.startswith("www."):
|
|
147
|
+
# Check if this looks like a bare domain (e.g., example.com)
|
|
148
|
+
parts = agent_domain_pattern.split(".")
|
|
149
|
+
if len(parts) == 2: # Looks like bare domain
|
|
150
|
+
common_subdomains = ["www", "m"]
|
|
151
|
+
for subdomain in common_subdomains:
|
|
152
|
+
if property_domain == f"{subdomain}.{agent_domain_pattern}":
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def identifiers_match(
|
|
159
|
+
property_identifiers: list[dict[str, str]],
|
|
160
|
+
agent_identifiers: list[dict[str, str]],
|
|
161
|
+
) -> bool:
|
|
162
|
+
"""Check if any property identifier matches agent's authorized identifiers.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
property_identifiers: Identifiers from property
|
|
166
|
+
(e.g., [{"type": "domain", "value": "cnn.com"}])
|
|
167
|
+
agent_identifiers: Identifiers from adagents.json
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
True if any identifier matches
|
|
171
|
+
|
|
172
|
+
Notes:
|
|
173
|
+
- Domain identifiers use AdCP domain matching rules
|
|
174
|
+
- Other identifiers (bundle_id, roku_store_id, etc.) require exact match
|
|
175
|
+
"""
|
|
176
|
+
for prop_id in property_identifiers:
|
|
177
|
+
prop_type = prop_id.get("type", "")
|
|
178
|
+
prop_value = prop_id.get("value", "")
|
|
179
|
+
|
|
180
|
+
for agent_id in agent_identifiers:
|
|
181
|
+
agent_type = agent_id.get("type", "")
|
|
182
|
+
agent_value = agent_id.get("value", "")
|
|
183
|
+
|
|
184
|
+
# Type must match
|
|
185
|
+
if prop_type != agent_type:
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
# Domain identifiers use special matching rules
|
|
189
|
+
if prop_type == "domain":
|
|
190
|
+
if domain_matches(prop_value, agent_value):
|
|
191
|
+
return True
|
|
192
|
+
else:
|
|
193
|
+
# Other identifier types require exact match
|
|
194
|
+
if prop_value == agent_value:
|
|
195
|
+
return True
|
|
196
|
+
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def verify_agent_authorization(
|
|
201
|
+
adagents_data: dict[str, Any],
|
|
202
|
+
agent_url: str,
|
|
203
|
+
property_type: str | None = None,
|
|
204
|
+
property_identifiers: list[dict[str, str]] | None = None,
|
|
205
|
+
) -> bool:
|
|
206
|
+
"""Check if agent is authorized for a property.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
adagents_data: Parsed adagents.json data
|
|
210
|
+
agent_url: URL of the sales agent to verify
|
|
211
|
+
property_type: Type of property (website, app, etc.) - optional
|
|
212
|
+
property_identifiers: List of identifiers to match - optional
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
True if agent is authorized, False otherwise
|
|
216
|
+
|
|
217
|
+
Raises:
|
|
218
|
+
AdagentsValidationError: If adagents_data is malformed
|
|
219
|
+
|
|
220
|
+
Notes:
|
|
221
|
+
- If property_type/identifiers are None, checks if agent is authorized
|
|
222
|
+
for ANY property on this domain
|
|
223
|
+
- Implements AdCP domain matching rules
|
|
224
|
+
- Agent URLs are matched ignoring protocol and trailing slash
|
|
225
|
+
"""
|
|
226
|
+
# Validate structure
|
|
227
|
+
if not isinstance(adagents_data, dict):
|
|
228
|
+
raise AdagentsValidationError("adagents_data must be a dictionary")
|
|
229
|
+
|
|
230
|
+
authorized_agents = adagents_data.get("authorized_agents")
|
|
231
|
+
if not isinstance(authorized_agents, list):
|
|
232
|
+
raise AdagentsValidationError("adagents.json must have 'authorized_agents' array")
|
|
233
|
+
|
|
234
|
+
# Normalize the agent URL for comparison
|
|
235
|
+
normalized_agent_url = normalize_url(agent_url)
|
|
236
|
+
|
|
237
|
+
# Check each authorized agent
|
|
238
|
+
for agent in authorized_agents:
|
|
239
|
+
if not isinstance(agent, dict):
|
|
240
|
+
continue
|
|
241
|
+
|
|
242
|
+
agent_url_from_json = agent.get("url", "")
|
|
243
|
+
if not agent_url_from_json:
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
# Match agent URL (protocol-agnostic)
|
|
247
|
+
if normalize_url(agent_url_from_json) != normalized_agent_url:
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
# Found matching agent - now check properties
|
|
251
|
+
properties = agent.get("properties")
|
|
252
|
+
|
|
253
|
+
# If properties field is missing or empty, agent is authorized for all properties
|
|
254
|
+
if properties is None or (isinstance(properties, list) and len(properties) == 0):
|
|
255
|
+
return True
|
|
256
|
+
|
|
257
|
+
# If no property filters specified, we found the agent - authorized
|
|
258
|
+
if property_type is None and property_identifiers is None:
|
|
259
|
+
return True
|
|
260
|
+
|
|
261
|
+
# Check specific property authorization
|
|
262
|
+
if isinstance(properties, list):
|
|
263
|
+
for prop in properties:
|
|
264
|
+
if not isinstance(prop, dict):
|
|
265
|
+
continue
|
|
266
|
+
|
|
267
|
+
# Check property type if specified
|
|
268
|
+
if property_type is not None:
|
|
269
|
+
prop_type = prop.get("property_type", "")
|
|
270
|
+
if prop_type != property_type:
|
|
271
|
+
continue
|
|
272
|
+
|
|
273
|
+
# Check identifiers if specified
|
|
274
|
+
if property_identifiers is not None:
|
|
275
|
+
prop_identifiers = prop.get("identifiers", [])
|
|
276
|
+
if not isinstance(prop_identifiers, list):
|
|
277
|
+
continue
|
|
278
|
+
|
|
279
|
+
if identifiers_match(property_identifiers, prop_identifiers):
|
|
280
|
+
return True
|
|
281
|
+
else:
|
|
282
|
+
# Property type matched and no identifier check needed
|
|
283
|
+
return True
|
|
284
|
+
|
|
285
|
+
return False
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
async def fetch_adagents(
|
|
289
|
+
publisher_domain: str,
|
|
290
|
+
timeout: float = 10.0,
|
|
291
|
+
user_agent: str = "AdCP-Client/1.0",
|
|
292
|
+
client: httpx.AsyncClient | None = None,
|
|
293
|
+
) -> dict[str, Any]:
|
|
294
|
+
"""Fetch and parse adagents.json from publisher domain.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
publisher_domain: Domain hosting the adagents.json file
|
|
298
|
+
timeout: Request timeout in seconds
|
|
299
|
+
user_agent: User-Agent header for HTTP request
|
|
300
|
+
client: Optional httpx.AsyncClient for connection pooling.
|
|
301
|
+
If provided, caller is responsible for client lifecycle.
|
|
302
|
+
If None, a new client is created for this request.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
Parsed adagents.json data
|
|
306
|
+
|
|
307
|
+
Raises:
|
|
308
|
+
AdagentsNotFoundError: If adagents.json not found (404)
|
|
309
|
+
AdagentsValidationError: If JSON is invalid or malformed
|
|
310
|
+
AdagentsTimeoutError: If request times out
|
|
311
|
+
|
|
312
|
+
Notes:
|
|
313
|
+
For production use with multiple requests, pass a shared httpx.AsyncClient
|
|
314
|
+
to enable connection pooling and improve performance.
|
|
315
|
+
"""
|
|
316
|
+
# Validate and normalize domain for security
|
|
317
|
+
publisher_domain = _validate_publisher_domain(publisher_domain)
|
|
318
|
+
|
|
319
|
+
# Construct URL
|
|
320
|
+
url = f"https://{publisher_domain}/.well-known/adagents.json"
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
# Use provided client or create a new one
|
|
324
|
+
if client is not None:
|
|
325
|
+
# Reuse provided client (connection pooling)
|
|
326
|
+
response = await client.get(
|
|
327
|
+
url,
|
|
328
|
+
headers={"User-Agent": user_agent},
|
|
329
|
+
timeout=timeout,
|
|
330
|
+
follow_redirects=True,
|
|
331
|
+
)
|
|
332
|
+
else:
|
|
333
|
+
# Create new client for single request
|
|
334
|
+
async with httpx.AsyncClient() as new_client:
|
|
335
|
+
response = await new_client.get(
|
|
336
|
+
url,
|
|
337
|
+
headers={"User-Agent": user_agent},
|
|
338
|
+
timeout=timeout,
|
|
339
|
+
follow_redirects=True,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# Process response (same for both paths)
|
|
343
|
+
if response.status_code == 404:
|
|
344
|
+
raise AdagentsNotFoundError(publisher_domain)
|
|
345
|
+
|
|
346
|
+
if response.status_code != 200:
|
|
347
|
+
raise AdagentsValidationError(
|
|
348
|
+
f"Failed to fetch adagents.json: HTTP {response.status_code}"
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
# Parse JSON
|
|
352
|
+
try:
|
|
353
|
+
data = response.json()
|
|
354
|
+
except Exception as e:
|
|
355
|
+
raise AdagentsValidationError(f"Invalid JSON in adagents.json: {e}") from e
|
|
356
|
+
|
|
357
|
+
# Validate basic structure
|
|
358
|
+
if not isinstance(data, dict):
|
|
359
|
+
raise AdagentsValidationError("adagents.json must be a JSON object")
|
|
360
|
+
|
|
361
|
+
if "authorized_agents" not in data:
|
|
362
|
+
raise AdagentsValidationError(
|
|
363
|
+
"adagents.json must have 'authorized_agents' field"
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
if not isinstance(data["authorized_agents"], list):
|
|
367
|
+
raise AdagentsValidationError("'authorized_agents' must be an array")
|
|
368
|
+
|
|
369
|
+
return data
|
|
370
|
+
|
|
371
|
+
except httpx.TimeoutException as e:
|
|
372
|
+
raise AdagentsTimeoutError(publisher_domain, timeout) from e
|
|
373
|
+
except httpx.RequestError as e:
|
|
374
|
+
raise AdagentsValidationError(f"Failed to fetch adagents.json: {e}") from e
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
async def verify_agent_for_property(
|
|
378
|
+
publisher_domain: str,
|
|
379
|
+
agent_url: str,
|
|
380
|
+
property_identifiers: list[dict[str, str]],
|
|
381
|
+
property_type: str | None = None,
|
|
382
|
+
timeout: float = 10.0,
|
|
383
|
+
client: httpx.AsyncClient | None = None,
|
|
384
|
+
) -> bool:
|
|
385
|
+
"""Convenience wrapper to fetch adagents.json and verify authorization in one call.
|
|
386
|
+
|
|
387
|
+
Args:
|
|
388
|
+
publisher_domain: Domain hosting the adagents.json file
|
|
389
|
+
agent_url: URL of the sales agent to verify
|
|
390
|
+
property_identifiers: List of identifiers to match
|
|
391
|
+
property_type: Type of property (website, app, etc.) - optional
|
|
392
|
+
timeout: Request timeout in seconds
|
|
393
|
+
client: Optional httpx.AsyncClient for connection pooling
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
True if agent is authorized, False otherwise
|
|
397
|
+
|
|
398
|
+
Raises:
|
|
399
|
+
AdagentsNotFoundError: If adagents.json not found (404)
|
|
400
|
+
AdagentsValidationError: If JSON is invalid or malformed
|
|
401
|
+
AdagentsTimeoutError: If request times out
|
|
402
|
+
"""
|
|
403
|
+
adagents_data = await fetch_adagents(publisher_domain, timeout=timeout, client=client)
|
|
404
|
+
return verify_agent_authorization(
|
|
405
|
+
adagents_data=adagents_data,
|
|
406
|
+
agent_url=agent_url,
|
|
407
|
+
property_type=property_type,
|
|
408
|
+
property_identifiers=property_identifiers,
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def get_all_properties(adagents_data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
413
|
+
"""Extract all properties from adagents.json data.
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
adagents_data: Parsed adagents.json data
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
List of all properties across all authorized agents, with agent_url added
|
|
420
|
+
|
|
421
|
+
Raises:
|
|
422
|
+
AdagentsValidationError: If adagents_data is malformed
|
|
423
|
+
"""
|
|
424
|
+
if not isinstance(adagents_data, dict):
|
|
425
|
+
raise AdagentsValidationError("adagents_data must be a dictionary")
|
|
426
|
+
|
|
427
|
+
authorized_agents = adagents_data.get("authorized_agents")
|
|
428
|
+
if not isinstance(authorized_agents, list):
|
|
429
|
+
raise AdagentsValidationError("adagents.json must have 'authorized_agents' array")
|
|
430
|
+
|
|
431
|
+
properties = []
|
|
432
|
+
for agent in authorized_agents:
|
|
433
|
+
if not isinstance(agent, dict):
|
|
434
|
+
continue
|
|
435
|
+
|
|
436
|
+
agent_url = agent.get("url", "")
|
|
437
|
+
if not agent_url:
|
|
438
|
+
continue
|
|
439
|
+
|
|
440
|
+
agent_properties = agent.get("properties", [])
|
|
441
|
+
if not isinstance(agent_properties, list):
|
|
442
|
+
continue
|
|
443
|
+
|
|
444
|
+
# Add each property with the agent URL for reference
|
|
445
|
+
for prop in agent_properties:
|
|
446
|
+
if isinstance(prop, dict):
|
|
447
|
+
# Create a copy and add agent_url
|
|
448
|
+
prop_with_agent = {**prop, "agent_url": agent_url}
|
|
449
|
+
properties.append(prop_with_agent)
|
|
450
|
+
|
|
451
|
+
return properties
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def get_all_tags(adagents_data: dict[str, Any]) -> set[str]:
|
|
455
|
+
"""Extract all unique tags from properties in adagents.json data.
|
|
456
|
+
|
|
457
|
+
Args:
|
|
458
|
+
adagents_data: Parsed adagents.json data
|
|
459
|
+
|
|
460
|
+
Returns:
|
|
461
|
+
Set of all unique tags across all properties
|
|
462
|
+
|
|
463
|
+
Raises:
|
|
464
|
+
AdagentsValidationError: If adagents_data is malformed
|
|
465
|
+
"""
|
|
466
|
+
properties = get_all_properties(adagents_data)
|
|
467
|
+
tags = set()
|
|
468
|
+
|
|
469
|
+
for prop in properties:
|
|
470
|
+
prop_tags = prop.get("tags", [])
|
|
471
|
+
if isinstance(prop_tags, list):
|
|
472
|
+
for tag in prop_tags:
|
|
473
|
+
if isinstance(tag, str):
|
|
474
|
+
tags.add(tag)
|
|
475
|
+
|
|
476
|
+
return tags
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def get_properties_by_agent(adagents_data: dict[str, Any], agent_url: str) -> list[dict[str, Any]]:
|
|
480
|
+
"""Get all properties authorized for a specific agent.
|
|
481
|
+
|
|
482
|
+
Args:
|
|
483
|
+
adagents_data: Parsed adagents.json data
|
|
484
|
+
agent_url: URL of the agent to filter by
|
|
485
|
+
|
|
486
|
+
Returns:
|
|
487
|
+
List of properties for the specified agent (empty if agent not found or no properties)
|
|
488
|
+
|
|
489
|
+
Raises:
|
|
490
|
+
AdagentsValidationError: If adagents_data is malformed
|
|
491
|
+
"""
|
|
492
|
+
if not isinstance(adagents_data, dict):
|
|
493
|
+
raise AdagentsValidationError("adagents_data must be a dictionary")
|
|
494
|
+
|
|
495
|
+
authorized_agents = adagents_data.get("authorized_agents")
|
|
496
|
+
if not isinstance(authorized_agents, list):
|
|
497
|
+
raise AdagentsValidationError("adagents.json must have 'authorized_agents' array")
|
|
498
|
+
|
|
499
|
+
# Normalize the agent URL for comparison
|
|
500
|
+
normalized_agent_url = normalize_url(agent_url)
|
|
501
|
+
|
|
502
|
+
for agent in authorized_agents:
|
|
503
|
+
if not isinstance(agent, dict):
|
|
504
|
+
continue
|
|
505
|
+
|
|
506
|
+
agent_url_from_json = agent.get("url", "")
|
|
507
|
+
if not agent_url_from_json:
|
|
508
|
+
continue
|
|
509
|
+
|
|
510
|
+
# Match agent URL (protocol-agnostic)
|
|
511
|
+
if normalize_url(agent_url_from_json) != normalized_agent_url:
|
|
512
|
+
continue
|
|
513
|
+
|
|
514
|
+
# Found the agent - return their properties
|
|
515
|
+
properties = agent.get("properties", [])
|
|
516
|
+
if not isinstance(properties, list):
|
|
517
|
+
return []
|
|
518
|
+
|
|
519
|
+
return [p for p in properties if isinstance(p, dict)]
|
|
520
|
+
|
|
521
|
+
return []
|
adcp/exceptions.py
CHANGED
|
@@ -153,3 +153,33 @@ class ADCPSimpleAPIError(ADCPError):
|
|
|
153
153
|
f" # Handle error with full TaskResult context"
|
|
154
154
|
)
|
|
155
155
|
super().__init__(message, agent_id, None, suggestion)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class AdagentsValidationError(ADCPError):
|
|
159
|
+
"""Base error for adagents.json validation issues."""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class AdagentsNotFoundError(AdagentsValidationError):
|
|
163
|
+
"""adagents.json file not found (404)."""
|
|
164
|
+
|
|
165
|
+
def __init__(self, publisher_domain: str):
|
|
166
|
+
"""Initialize not found error."""
|
|
167
|
+
message = f"adagents.json not found for domain: {publisher_domain}"
|
|
168
|
+
suggestion = (
|
|
169
|
+
"Verify that the publisher has deployed adagents.json to:\n"
|
|
170
|
+
f" https://{publisher_domain}/.well-known/adagents.json"
|
|
171
|
+
)
|
|
172
|
+
super().__init__(message, None, None, suggestion)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class AdagentsTimeoutError(AdagentsValidationError):
|
|
176
|
+
"""Request for adagents.json timed out."""
|
|
177
|
+
|
|
178
|
+
def __init__(self, publisher_domain: str, timeout: float):
|
|
179
|
+
"""Initialize timeout error."""
|
|
180
|
+
message = f"Request to fetch adagents.json timed out after {timeout}s"
|
|
181
|
+
suggestion = (
|
|
182
|
+
"The publisher's server may be slow or unresponsive.\n"
|
|
183
|
+
" Try increasing the timeout value or check the domain is correct."
|
|
184
|
+
)
|
|
185
|
+
super().__init__(message, None, None, suggestion)
|
adcp/types/__init__.py
CHANGED
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
"""Type definitions for AdCP client."""
|
|
4
4
|
|
|
5
|
+
from adcp.types.base import AdCPBaseModel
|
|
5
6
|
from adcp.types.core import (
|
|
6
7
|
Activity,
|
|
7
8
|
ActivityType,
|
|
@@ -14,6 +15,7 @@ from adcp.types.core import (
|
|
|
14
15
|
)
|
|
15
16
|
|
|
16
17
|
__all__ = [
|
|
18
|
+
"AdCPBaseModel",
|
|
17
19
|
"AgentConfig",
|
|
18
20
|
"Protocol",
|
|
19
21
|
"TaskResult",
|
adcp/types/base.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Base model for AdCP types with spec-compliant serialization."""
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AdCPBaseModel(BaseModel):
|
|
11
|
+
"""Base model for AdCP types with spec-compliant serialization.
|
|
12
|
+
|
|
13
|
+
AdCP JSON schemas use additionalProperties: false and do not allow null
|
|
14
|
+
for optional fields. Therefore, optional fields must be omitted entirely
|
|
15
|
+
when not present (not sent as null).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
|
|
19
|
+
if "exclude_none" not in kwargs:
|
|
20
|
+
kwargs["exclude_none"] = True
|
|
21
|
+
return super().model_dump(**kwargs)
|
|
22
|
+
|
|
23
|
+
def model_dump_json(self, **kwargs: Any) -> str:
|
|
24
|
+
if "exclude_none" not in kwargs:
|
|
25
|
+
kwargs["exclude_none"] = True
|
|
26
|
+
return super().model_dump_json(**kwargs)
|
adcp/types/generated.py
CHANGED
|
@@ -14,7 +14,9 @@ from __future__ import annotations
|
|
|
14
14
|
import re
|
|
15
15
|
from typing import Any, Literal
|
|
16
16
|
|
|
17
|
-
from pydantic import
|
|
17
|
+
from pydantic import ConfigDict, Field, field_validator
|
|
18
|
+
|
|
19
|
+
from adcp.types.base import AdCPBaseModel as BaseModel
|
|
18
20
|
|
|
19
21
|
|
|
20
22
|
|
|
@@ -945,6 +947,7 @@ class TasksListResponse(BaseModel):
|
|
|
945
947
|
# The simple code generator produces type aliases (e.g., PreviewCreativeRequest = Any)
|
|
946
948
|
# for complex schemas that use oneOf. We override them here with proper Pydantic classes
|
|
947
949
|
# to maintain type safety and enable batch API support.
|
|
950
|
+
# Note: All classes inherit from BaseModel (which is aliased to AdCPBaseModel for exclude_none).
|
|
948
951
|
|
|
949
952
|
|
|
950
953
|
class FormatId(BaseModel):
|
|
@@ -978,7 +981,7 @@ class PreviewCreativeRequest(BaseModel):
|
|
|
978
981
|
|
|
979
982
|
# Output format (applies to both modes)
|
|
980
983
|
output_format: Literal["url", "html"] | None = Field(default="url", description="Output format: 'url' for iframe URLs, 'html' for direct embedding")
|
|
981
|
-
|
|
984
|
+
context: dict[str, Any] | None = Field(None, description="Initiator-provided context echoed inside the task payload. Opaque metadata such as UI/session hints, correlation tokens, or tracking identifiers.")
|
|
982
985
|
|
|
983
986
|
class PreviewCreativeResponse(BaseModel):
|
|
984
987
|
"""Response containing preview links for one or more creatives. Format matches the request: single preview response for single requests, batch results for batch requests."""
|
|
@@ -990,6 +993,7 @@ class PreviewCreativeResponse(BaseModel):
|
|
|
990
993
|
|
|
991
994
|
# Batch mode field
|
|
992
995
|
results: list[dict[str, Any]] | None = Field(default=None, description="Array of preview results for batch processing")
|
|
996
|
+
context: dict[str, Any] | None = Field(None, description="Initiator-provided context echoed inside the task payload. Opaque metadata such as UI/session hints, correlation tokens, or tracking identifiers.")
|
|
993
997
|
|
|
994
998
|
|
|
995
999
|
# ============================================================================
|
|
@@ -1007,13 +1011,13 @@ class ActivateSignalSuccess(BaseModel):
|
|
|
1007
1011
|
)
|
|
1008
1012
|
estimated_activation_duration_minutes: float | None = None
|
|
1009
1013
|
deployed_at: str | None = None
|
|
1010
|
-
|
|
1014
|
+
context: dict[str, Any] | None = Field(None, description="Initiator-provided context echoed inside the task payload. Opaque metadata such as UI/session hints, correlation tokens, or tracking identifiers.")
|
|
1011
1015
|
|
|
1012
1016
|
class ActivateSignalError(BaseModel):
|
|
1013
1017
|
"""Failed signal activation response"""
|
|
1014
1018
|
|
|
1015
1019
|
errors: list[Error] = Field(description="Task-specific errors and warnings")
|
|
1016
|
-
|
|
1020
|
+
context: dict[str, Any] | None = Field(None, description="Initiator-provided context echoed inside the task payload. Opaque metadata such as UI/session hints, correlation tokens, or tracking identifiers.")
|
|
1017
1021
|
|
|
1018
1022
|
# Override the generated ActivateSignalResponse type alias
|
|
1019
1023
|
ActivateSignalResponse = ActivateSignalSuccess | ActivateSignalError
|
|
@@ -1031,12 +1035,14 @@ class CreateMediaBuySuccess(BaseModel):
|
|
|
1031
1035
|
None,
|
|
1032
1036
|
description="ISO 8601 date when creatives must be provided for launch",
|
|
1033
1037
|
)
|
|
1038
|
+
context: dict[str, Any] | None = Field(None, description="Initiator-provided context echoed inside the task payload. Opaque metadata such as UI/session hints, correlation tokens, or tracking identifiers.")
|
|
1034
1039
|
|
|
1035
1040
|
|
|
1036
1041
|
class CreateMediaBuyError(BaseModel):
|
|
1037
1042
|
"""Failed media buy creation response"""
|
|
1038
1043
|
|
|
1039
1044
|
errors: list[Error] = Field(description="Task-specific errors and warnings")
|
|
1045
|
+
context: dict[str, Any] | None = Field(None, description="Initiator-provided context echoed inside the task payload. Opaque metadata such as UI/session hints, correlation tokens, or tracking identifiers.")
|
|
1040
1046
|
|
|
1041
1047
|
|
|
1042
1048
|
# Override the generated CreateMediaBuyResponse type alias
|
|
@@ -1051,13 +1057,14 @@ class UpdateMediaBuySuccess(BaseModel):
|
|
|
1051
1057
|
packages: list[Package] = Field(
|
|
1052
1058
|
description="Array of updated packages reflecting the changes"
|
|
1053
1059
|
)
|
|
1060
|
+
context: dict[str, Any] | None = Field(None, description="Initiator-provided context echoed inside the task payload. Opaque metadata such as UI/session hints, correlation tokens, or tracking identifiers.")
|
|
1054
1061
|
|
|
1055
1062
|
|
|
1056
1063
|
class UpdateMediaBuyError(BaseModel):
|
|
1057
1064
|
"""Failed media buy update response"""
|
|
1058
1065
|
|
|
1059
1066
|
errors: list[Error] = Field(description="Task-specific errors and warnings")
|
|
1060
|
-
|
|
1067
|
+
context: dict[str, Any] | None = Field(None, description="Initiator-provided context echoed inside the task payload. Opaque metadata such as UI/session hints, correlation tokens, or tracking identifiers.")
|
|
1061
1068
|
|
|
1062
1069
|
# Override the generated UpdateMediaBuyResponse type alias
|
|
1063
1070
|
UpdateMediaBuyResponse = UpdateMediaBuySuccess | UpdateMediaBuyError
|
|
@@ -1069,13 +1076,14 @@ class SyncCreativesSuccess(BaseModel):
|
|
|
1069
1076
|
assignments: list[CreativeAssignment] = Field(
|
|
1070
1077
|
description="Array of creative assignments with updated status"
|
|
1071
1078
|
)
|
|
1079
|
+
context: dict[str, Any] | None = Field(None, description="Initiator-provided context echoed inside the task payload. Opaque metadata such as UI/session hints, correlation tokens, or tracking identifiers.")
|
|
1072
1080
|
|
|
1073
1081
|
|
|
1074
1082
|
class SyncCreativesError(BaseModel):
|
|
1075
1083
|
"""Failed creative sync response"""
|
|
1076
1084
|
|
|
1077
1085
|
errors: list[Error] = Field(description="Task-specific errors and warnings")
|
|
1078
|
-
|
|
1086
|
+
context: dict[str, Any] | None = Field(None, description="Initiator-provided context echoed inside the task payload. Opaque metadata such as UI/session hints, correlation tokens, or tracking identifiers.")
|
|
1079
1087
|
|
|
1080
1088
|
# Override the generated SyncCreativesResponse type alias
|
|
1081
1089
|
SyncCreativesResponse = SyncCreativesSuccess | SyncCreativesError
|
adcp/utils/preview_cache.py
CHANGED
|
@@ -74,7 +74,11 @@ class PreviewURLGenerator:
|
|
|
74
74
|
|
|
75
75
|
try:
|
|
76
76
|
request = PreviewCreativeRequest(
|
|
77
|
-
format_id=format_id,
|
|
77
|
+
format_id=format_id,
|
|
78
|
+
creative_manifest=manifest,
|
|
79
|
+
inputs=None,
|
|
80
|
+
template_id=None,
|
|
81
|
+
context=None
|
|
78
82
|
)
|
|
79
83
|
result = await self.creative_agent_client.preview_creative(request)
|
|
80
84
|
|
|
@@ -164,6 +168,7 @@ class PreviewURLGenerator:
|
|
|
164
168
|
batch_request = PreviewCreativeRequest(
|
|
165
169
|
requests=chunk_requests,
|
|
166
170
|
output_format=output_format, # type: ignore[arg-type]
|
|
171
|
+
context=None,
|
|
167
172
|
)
|
|
168
173
|
result = await self.creative_agent_client.preview_creative(batch_request)
|
|
169
174
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: adcp
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.6.1
|
|
4
4
|
Summary: Official Python client for the Ad Context Protocol (AdCP)
|
|
5
5
|
Author-email: AdCP Community <maintainers@adcontextprotocol.org>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -461,6 +461,50 @@ auth = index.get_agent_authorizations("https://agent-x.com")
|
|
|
461
461
|
premium = index.find_agents_by_property_tags(["premium", "ctv"])
|
|
462
462
|
```
|
|
463
463
|
|
|
464
|
+
## Publisher Authorization Validation
|
|
465
|
+
|
|
466
|
+
Verify sales agents are authorized to sell publisher properties via adagents.json:
|
|
467
|
+
|
|
468
|
+
```python
|
|
469
|
+
from adcp import (
|
|
470
|
+
fetch_adagents,
|
|
471
|
+
verify_agent_authorization,
|
|
472
|
+
verify_agent_for_property,
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
# Fetch and parse adagents.json from publisher
|
|
476
|
+
adagents_data = await fetch_adagents("publisher.com")
|
|
477
|
+
|
|
478
|
+
# Verify agent authorization for a property
|
|
479
|
+
is_authorized = verify_agent_authorization(
|
|
480
|
+
adagents_data=adagents_data,
|
|
481
|
+
agent_url="https://sales-agent.example.com",
|
|
482
|
+
property_type="website",
|
|
483
|
+
property_identifiers=[{"type": "domain", "value": "publisher.com"}]
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
# Or use convenience wrapper (fetch + verify in one call)
|
|
487
|
+
is_authorized = await verify_agent_for_property(
|
|
488
|
+
publisher_domain="publisher.com",
|
|
489
|
+
agent_url="https://sales-agent.example.com",
|
|
490
|
+
property_identifiers=[{"type": "domain", "value": "publisher.com"}],
|
|
491
|
+
property_type="website"
|
|
492
|
+
)
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
**Domain Matching Rules:**
|
|
496
|
+
- Exact match: `example.com` matches `example.com`
|
|
497
|
+
- Common subdomains: `www.example.com` matches `example.com`
|
|
498
|
+
- Wildcards: `api.example.com` matches `*.example.com`
|
|
499
|
+
- Protocol-agnostic: `http://agent.com` matches `https://agent.com`
|
|
500
|
+
|
|
501
|
+
**Use Cases:**
|
|
502
|
+
- Sales agents verify authorization before accepting media buys
|
|
503
|
+
- Publishers test their adagents.json files
|
|
504
|
+
- Developer tools build authorization validators
|
|
505
|
+
|
|
506
|
+
See `examples/adagents_validation.py` for complete examples.
|
|
507
|
+
|
|
464
508
|
## CLI Tool
|
|
465
509
|
|
|
466
510
|
The `adcp` command-line tool provides easy interaction with AdCP agents without writing code.
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
adcp/__init__.py,sha256=
|
|
1
|
+
adcp/__init__.py,sha256=g3x4xqEGJgRcHZB6Oso3XAxs8uwNtEhk8l9TGFVcN1c,7612
|
|
2
2
|
adcp/__main__.py,sha256=Avy_C71rruh2lOuojvuXDj09tkFOaek74nJ-dbx25Sw,12838
|
|
3
|
+
adcp/adagents.py,sha256=thq6qZScRHLWrRR4DjGlZzHpC1ZssQqhc5l0pbKSa-Q,17483
|
|
3
4
|
adcp/client.py,sha256=4qoFNDT5swzi4w5bnWJ5nVTG5JIL0xnLJOJMGeMyci4,28412
|
|
4
5
|
adcp/config.py,sha256=Vsy7ZPOI8G3fB_i5Nk-CHbC7wdasCUWuKlos0fwA0kY,2017
|
|
5
|
-
adcp/exceptions.py,sha256=
|
|
6
|
+
adcp/exceptions.py,sha256=1aZEWpaM92OxD2jl9yKsqJp5ReSWaj0S0DFhxChhLlA,6732
|
|
6
7
|
adcp/simple.py,sha256=FgPYWT32BNXkQz07r2x2gXgOmOikWLi88SzN5UIVSiU,10440
|
|
7
8
|
adcp/protocols/__init__.py,sha256=6UFwACQ0QadBUzy17wUROHqsJDp8ztPW2jzyl53Zh_g,262
|
|
8
9
|
adcp/protocols/a2a.py,sha256=FHgc6G_eU2qD0vH7_RyS1eZvUFSb2j3-EsceoHPi384,12467
|
|
@@ -10,17 +11,18 @@ adcp/protocols/base.py,sha256=vBHD23Fzl_CCk_Gy9nvSbBYopcJlYkYyzoz-rhI8wHg,5214
|
|
|
10
11
|
adcp/protocols/mcp.py,sha256=d9uSpGd0BKvQ0JxztkfDvHwoDrDYhuiw5oivpYOAbmM,16647
|
|
11
12
|
adcp/testing/__init__.py,sha256=ZWp_floWjVZfy8RBG5v_FUXQ8YbN7xjXvVcX-_zl_HU,1416
|
|
12
13
|
adcp/testing/test_helpers.py,sha256=4n8fZYy1cVpjZpFW2SxBzpC8fmY-MBFrzY4tIPqe4rQ,10028
|
|
13
|
-
adcp/types/__init__.py,sha256=
|
|
14
|
+
adcp/types/__init__.py,sha256=FXm4210pkzOIQQEgpe-EeLLd7mxofzEgKLGl1r8fj4o,465
|
|
15
|
+
adcp/types/base.py,sha256=QoEuVfI4yzefup0dc2KN11AcJTbcGxRep7xOw5hXfs8,837
|
|
14
16
|
adcp/types/core.py,sha256=RXkKCWCXS9BVJTNpe3Opm5O1I_LaQPMUuVwa-ipvS1Q,4839
|
|
15
|
-
adcp/types/generated.py,sha256=
|
|
17
|
+
adcp/types/generated.py,sha256=os26c6yVrCzfSJXJkMsNUODRgp4UrozMkc19OPYcxls,83621
|
|
16
18
|
adcp/types/tasks.py,sha256=Ae9TSwG2F7oWXTcl4TvLhAzinbQkHNGF1Pc0q8RMNNM,23424
|
|
17
19
|
adcp/utils/__init__.py,sha256=uetvSJB19CjQbtwEYZiTnumJG11GsafQmXm5eR3hL7E,153
|
|
18
20
|
adcp/utils/operation_id.py,sha256=wQX9Bb5epXzRq23xoeYPTqzu5yLuhshg7lKJZihcM2k,294
|
|
19
|
-
adcp/utils/preview_cache.py,sha256=
|
|
21
|
+
adcp/utils/preview_cache.py,sha256=8BqbGtilEWD-2ZIIcXCxVar6hs1uMLqTs9DND05RMh8,17685
|
|
20
22
|
adcp/utils/response_parser.py,sha256=uPk2vIH-RYZmq7y3i8lC4HTMQ3FfKdlgXKTjgJ1955M,6253
|
|
21
|
-
adcp-1.
|
|
22
|
-
adcp-1.
|
|
23
|
-
adcp-1.
|
|
24
|
-
adcp-1.
|
|
25
|
-
adcp-1.
|
|
26
|
-
adcp-1.
|
|
23
|
+
adcp-1.6.1.dist-info/licenses/LICENSE,sha256=PF39NR3Ae8PLgBhg3Uxw6ju7iGVIf8hfv9LRWQdii_U,629
|
|
24
|
+
adcp-1.6.1.dist-info/METADATA,sha256=OgqXYiJzf7oW6Hta0L_sH3VJq2EYWMypqJzu9wBx_Qs,21345
|
|
25
|
+
adcp-1.6.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
26
|
+
adcp-1.6.1.dist-info/entry_points.txt,sha256=DQKpcGsJX8DtVI_SGApQ7tNvqUB4zkTLaTAEpFgmi3U,44
|
|
27
|
+
adcp-1.6.1.dist-info/top_level.txt,sha256=T1_NF0GefncFU9v_k56oDwKSJREyCqIM8lAwNZf0EOs,5
|
|
28
|
+
adcp-1.6.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|