Skip to content

Commit 9bfb0fb

Browse files
bokelleyclaude
andauthored
feat: add brand and property registry lookup methods (#130)
Add RegistryClient class for resolving domains to brands and properties via the AdCP registry API at agenticadvertising.org. - lookup_brand/lookup_brands for brand resolution (single + bulk) - lookup_property/lookup_properties for property resolution (single + bulk) - Auto-chunking for bulk requests exceeding 100 domains - Connection pooling with optional httpx.AsyncClient injection - ResolvedBrand and ResolvedProperty Pydantic response types - RegistryError exception for registry API failures Closes #129 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 24ee6e8 commit 9bfb0fb

6 files changed

Lines changed: 841 additions & 2 deletions

File tree

src/adcp/__init__.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@
3232
ADCPToolNotFoundError,
3333
ADCPWebhookError,
3434
ADCPWebhookSignatureError,
35+
RegistryError,
3536
)
37+
from adcp.registry import RegistryClient
3638

3739
# Test helpers
3840
from adcp.testing import (
@@ -203,7 +205,15 @@
203205
ValidateContentDeliveryErrorResponse,
204206
ValidateContentDeliverySuccessResponse,
205207
)
206-
from adcp.types.core import AgentConfig, Protocol, TaskResult, TaskStatus, WebhookMetadata
208+
from adcp.types.core import (
209+
AgentConfig,
210+
Protocol,
211+
ResolvedBrand,
212+
ResolvedProperty,
213+
TaskResult,
214+
TaskStatus,
215+
WebhookMetadata,
216+
)
207217
from adcp.utils import (
208218
get_asset_count,
209219
get_format_assets,
@@ -259,9 +269,12 @@ def get_adcp_version() -> str:
259269
# Client classes
260270
"ADCPClient",
261271
"ADCPMultiAgentClient",
272+
"RegistryClient",
262273
# Core types
263274
"AgentConfig",
264275
"Protocol",
276+
"ResolvedBrand",
277+
"ResolvedProperty",
265278
"TaskResult",
266279
"TaskStatus",
267280
"WebhookMetadata",
@@ -376,6 +389,7 @@ def get_adcp_version() -> str:
376389
"AdagentsValidationError",
377390
"AdagentsNotFoundError",
378391
"AdagentsTimeoutError",
392+
"RegistryError",
379393
# Validation utilities
380394
"ValidationError",
381395
"validate_adagents",

src/adcp/exceptions.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,16 @@ def __init__(
155155
super().__init__(message, agent_id, None, suggestion)
156156

157157

158+
class RegistryError(ADCPError):
159+
"""Error from AdCP registry API operations (brand/property lookups)."""
160+
161+
def __init__(self, message: str, status_code: int | None = None):
162+
"""Initialize registry error."""
163+
self.status_code = status_code
164+
suggestion = "Check that the registry API is accessible and the domain is valid."
165+
super().__init__(message, suggestion=suggestion)
166+
167+
158168
class AdagentsValidationError(ADCPError):
159169
"""Base error for adagents.json validation issues."""
160170

src/adcp/registry.py

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
from __future__ import annotations
2+
3+
"""Client for the AdCP registry API (brand and property lookups)."""
4+
5+
import asyncio
6+
from typing import Any
7+
8+
import httpx
9+
from pydantic import ValidationError
10+
11+
from adcp.exceptions import RegistryError
12+
from adcp.types.core import ResolvedBrand, ResolvedProperty
13+
14+
DEFAULT_REGISTRY_URL = "https://agenticadvertising.org"
15+
MAX_BULK_DOMAINS = 100
16+
17+
18+
class RegistryClient:
19+
"""Client for the AdCP registry API.
20+
21+
Provides brand and property lookups against the central AdCP registry.
22+
23+
Args:
24+
base_url: Registry API base URL.
25+
timeout: Request timeout in seconds.
26+
client: Optional httpx.AsyncClient for connection pooling.
27+
If provided, caller is responsible for client lifecycle.
28+
user_agent: User-Agent header for requests.
29+
"""
30+
31+
def __init__(
32+
self,
33+
base_url: str = DEFAULT_REGISTRY_URL,
34+
timeout: float = 10.0,
35+
client: httpx.AsyncClient | None = None,
36+
user_agent: str = "adcp-client-python",
37+
):
38+
self._base_url = base_url.rstrip("/")
39+
self._timeout = timeout
40+
self._external_client = client
41+
self._owned_client: httpx.AsyncClient | None = None
42+
self._user_agent = user_agent
43+
44+
async def _get_client(self) -> httpx.AsyncClient:
45+
"""Get or create httpx client."""
46+
if self._external_client is not None:
47+
return self._external_client
48+
if self._owned_client is None:
49+
self._owned_client = httpx.AsyncClient(
50+
limits=httpx.Limits(
51+
max_keepalive_connections=10,
52+
max_connections=20,
53+
),
54+
)
55+
return self._owned_client
56+
57+
async def close(self) -> None:
58+
"""Close owned HTTP client. No-op if using external client."""
59+
if self._owned_client is not None:
60+
await self._owned_client.aclose()
61+
self._owned_client = None
62+
63+
async def __aenter__(self) -> RegistryClient:
64+
return self
65+
66+
async def __aexit__(self, *args: Any) -> None:
67+
await self.close()
68+
69+
async def lookup_brand(self, domain: str) -> ResolvedBrand | None:
70+
"""Resolve a single domain to its canonical brand identity.
71+
72+
Args:
73+
domain: Domain to resolve (e.g., "nike.com").
74+
75+
Returns:
76+
ResolvedBrand if found, None if the domain is not in the registry.
77+
78+
Raises:
79+
RegistryError: On HTTP or parsing errors.
80+
"""
81+
client = await self._get_client()
82+
try:
83+
response = await client.get(
84+
f"{self._base_url}/api/brands/resolve",
85+
params={"domain": domain},
86+
headers={"User-Agent": self._user_agent},
87+
timeout=self._timeout,
88+
)
89+
if response.status_code == 404:
90+
return None
91+
if response.status_code != 200:
92+
raise RegistryError(
93+
f"Brand lookup failed: HTTP {response.status_code}",
94+
status_code=response.status_code,
95+
)
96+
data = response.json()
97+
if data is None:
98+
return None
99+
return ResolvedBrand.model_validate(data)
100+
except RegistryError:
101+
raise
102+
except httpx.TimeoutException as e:
103+
raise RegistryError(f"Brand lookup timed out after {self._timeout}s") from e
104+
except httpx.HTTPError as e:
105+
raise RegistryError(f"Brand lookup failed: {e}") from e
106+
except (ValidationError, ValueError) as e:
107+
raise RegistryError(f"Brand lookup failed: invalid response: {e}") from e
108+
109+
async def lookup_brands(
110+
self, domains: list[str]
111+
) -> dict[str, ResolvedBrand | None]:
112+
"""Bulk resolve domains to brand identities.
113+
114+
Automatically chunks requests exceeding 100 domains.
115+
116+
Args:
117+
domains: List of domains to resolve.
118+
119+
Returns:
120+
Dict mapping each domain to its ResolvedBrand, or None if not found.
121+
122+
Raises:
123+
RegistryError: On HTTP or parsing errors.
124+
"""
125+
if not domains:
126+
return {}
127+
128+
chunks = [
129+
domains[i : i + MAX_BULK_DOMAINS]
130+
for i in range(0, len(domains), MAX_BULK_DOMAINS)
131+
]
132+
133+
chunk_results = await asyncio.gather(
134+
*[self._lookup_brands_chunk(chunk) for chunk in chunks],
135+
return_exceptions=True,
136+
)
137+
138+
merged: dict[str, ResolvedBrand | None] = {}
139+
for result in chunk_results:
140+
if isinstance(result, BaseException):
141+
raise result
142+
merged.update(result)
143+
return merged
144+
145+
async def _lookup_brands_chunk(
146+
self, domains: list[str]
147+
) -> dict[str, ResolvedBrand | None]:
148+
"""Resolve a single chunk of brand domains (max 100)."""
149+
client = await self._get_client()
150+
try:
151+
response = await client.post(
152+
f"{self._base_url}/api/brands/resolve/bulk",
153+
json={"domains": domains},
154+
headers={"User-Agent": self._user_agent},
155+
timeout=self._timeout,
156+
)
157+
if response.status_code != 200:
158+
raise RegistryError(
159+
f"Bulk brand lookup failed: HTTP {response.status_code}",
160+
status_code=response.status_code,
161+
)
162+
data = response.json()
163+
results_raw = data.get("results", {})
164+
results: dict[str, ResolvedBrand | None] = {d: None for d in domains}
165+
for domain, brand_data in results_raw.items():
166+
if brand_data is not None:
167+
results[domain] = ResolvedBrand.model_validate(brand_data)
168+
return results
169+
except RegistryError:
170+
raise
171+
except httpx.TimeoutException as e:
172+
raise RegistryError(
173+
f"Bulk brand lookup timed out after {self._timeout}s"
174+
) from e
175+
except httpx.HTTPError as e:
176+
raise RegistryError(f"Bulk brand lookup failed: {e}") from e
177+
except (ValidationError, ValueError) as e:
178+
raise RegistryError(f"Bulk brand lookup failed: invalid response: {e}") from e
179+
180+
async def lookup_property(self, domain: str) -> ResolvedProperty | None:
181+
"""Resolve a publisher domain to its property info.
182+
183+
Args:
184+
domain: Publisher domain to resolve (e.g., "nytimes.com").
185+
186+
Returns:
187+
ResolvedProperty if found, None if the domain is not in the registry.
188+
189+
Raises:
190+
RegistryError: On HTTP or parsing errors.
191+
"""
192+
client = await self._get_client()
193+
try:
194+
response = await client.get(
195+
f"{self._base_url}/api/properties/resolve",
196+
params={"domain": domain},
197+
headers={"User-Agent": self._user_agent},
198+
timeout=self._timeout,
199+
)
200+
if response.status_code == 404:
201+
return None
202+
if response.status_code != 200:
203+
raise RegistryError(
204+
f"Property lookup failed: HTTP {response.status_code}",
205+
status_code=response.status_code,
206+
)
207+
data = response.json()
208+
if data is None:
209+
return None
210+
return ResolvedProperty.model_validate(data)
211+
except RegistryError:
212+
raise
213+
except httpx.TimeoutException as e:
214+
raise RegistryError(
215+
f"Property lookup timed out after {self._timeout}s"
216+
) from e
217+
except httpx.HTTPError as e:
218+
raise RegistryError(f"Property lookup failed: {e}") from e
219+
except (ValidationError, ValueError) as e:
220+
raise RegistryError(f"Property lookup failed: invalid response: {e}") from e
221+
222+
async def lookup_properties(
223+
self, domains: list[str]
224+
) -> dict[str, ResolvedProperty | None]:
225+
"""Bulk resolve publisher domains to property info.
226+
227+
Automatically chunks requests exceeding 100 domains.
228+
229+
Args:
230+
domains: List of publisher domains to resolve.
231+
232+
Returns:
233+
Dict mapping each domain to its ResolvedProperty, or None if not found.
234+
235+
Raises:
236+
RegistryError: On HTTP or parsing errors.
237+
"""
238+
if not domains:
239+
return {}
240+
241+
chunks = [
242+
domains[i : i + MAX_BULK_DOMAINS]
243+
for i in range(0, len(domains), MAX_BULK_DOMAINS)
244+
]
245+
246+
chunk_results = await asyncio.gather(
247+
*[self._lookup_properties_chunk(chunk) for chunk in chunks],
248+
return_exceptions=True,
249+
)
250+
251+
merged: dict[str, ResolvedProperty | None] = {}
252+
for result in chunk_results:
253+
if isinstance(result, BaseException):
254+
raise result
255+
merged.update(result)
256+
return merged
257+
258+
async def _lookup_properties_chunk(
259+
self, domains: list[str]
260+
) -> dict[str, ResolvedProperty | None]:
261+
"""Resolve a single chunk of property domains (max 100)."""
262+
client = await self._get_client()
263+
try:
264+
response = await client.post(
265+
f"{self._base_url}/api/properties/resolve/bulk",
266+
json={"domains": domains},
267+
headers={"User-Agent": self._user_agent},
268+
timeout=self._timeout,
269+
)
270+
if response.status_code != 200:
271+
raise RegistryError(
272+
f"Bulk property lookup failed: HTTP {response.status_code}",
273+
status_code=response.status_code,
274+
)
275+
data = response.json()
276+
results_raw = data.get("results", {})
277+
results: dict[str, ResolvedProperty | None] = {d: None for d in domains}
278+
for domain, prop_data in results_raw.items():
279+
if prop_data is not None:
280+
results[domain] = ResolvedProperty.model_validate(prop_data)
281+
return results
282+
except RegistryError:
283+
raise
284+
except httpx.TimeoutException as e:
285+
raise RegistryError(
286+
f"Bulk property lookup timed out after {self._timeout}s"
287+
) from e
288+
except httpx.HTTPError as e:
289+
raise RegistryError(f"Bulk property lookup failed: {e}") from e
290+
except (ValidationError, ValueError) as e:
291+
raise RegistryError(
292+
f"Bulk property lookup failed: invalid response: {e}"
293+
) from e

src/adcp/types/__init__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,14 @@
354354
# Re-export core types (not in generated, but part of public API)
355355
# Note: We don't import TaskStatus here to avoid shadowing GeneratedTaskStatus
356356
# Users should import TaskStatus from adcp.types.core directly if they need the core enum
357-
from adcp.types.core import AgentConfig, Protocol, TaskResult, WebhookMetadata
357+
from adcp.types.core import (
358+
AgentConfig,
359+
Protocol,
360+
ResolvedBrand,
361+
ResolvedProperty,
362+
TaskResult,
363+
WebhookMetadata,
364+
)
358365

359366
# Re-export webhook payload type for webhook handling
360367
from adcp.types.generated_poc.core.mcp_webhook_payload import McpWebhookPayload
@@ -637,6 +644,8 @@
637644
# Core types
638645
"AgentConfig",
639646
"Protocol",
647+
"ResolvedBrand",
648+
"ResolvedProperty",
640649
"TaskResult",
641650
"WebhookMetadata",
642651
# Webhook types

0 commit comments

Comments
 (0)