From d700ff50b559bf5977d51f775b9500112973906f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 13 Feb 2026 05:37:03 -0500 Subject: [PATCH] feat: add brand and property registry lookup methods 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 --- src/adcp/__init__.py | 16 +- src/adcp/exceptions.py | 10 + src/adcp/registry.py | 293 ++++++++++++++++++++++ src/adcp/types/__init__.py | 11 +- src/adcp/types/core.py | 30 +++ tests/test_registry.py | 483 +++++++++++++++++++++++++++++++++++++ 6 files changed, 841 insertions(+), 2 deletions(-) create mode 100644 src/adcp/registry.py create mode 100644 tests/test_registry.py diff --git a/src/adcp/__init__.py b/src/adcp/__init__.py index d4a209ff..0d4cc113 100644 --- a/src/adcp/__init__.py +++ b/src/adcp/__init__.py @@ -32,7 +32,9 @@ ADCPToolNotFoundError, ADCPWebhookError, ADCPWebhookSignatureError, + RegistryError, ) +from adcp.registry import RegistryClient # Test helpers from adcp.testing import ( @@ -203,7 +205,15 @@ ValidateContentDeliveryErrorResponse, ValidateContentDeliverySuccessResponse, ) -from adcp.types.core import AgentConfig, Protocol, TaskResult, TaskStatus, WebhookMetadata +from adcp.types.core import ( + AgentConfig, + Protocol, + ResolvedBrand, + ResolvedProperty, + TaskResult, + TaskStatus, + WebhookMetadata, +) from adcp.utils import ( get_asset_count, get_format_assets, @@ -259,9 +269,12 @@ def get_adcp_version() -> str: # Client classes "ADCPClient", "ADCPMultiAgentClient", + "RegistryClient", # Core types "AgentConfig", "Protocol", + "ResolvedBrand", + "ResolvedProperty", "TaskResult", "TaskStatus", "WebhookMetadata", @@ -376,6 +389,7 @@ def get_adcp_version() -> str: "AdagentsValidationError", "AdagentsNotFoundError", "AdagentsTimeoutError", + "RegistryError", # Validation utilities "ValidationError", "validate_adagents", diff --git a/src/adcp/exceptions.py b/src/adcp/exceptions.py index 753a46dd..07bce01b 100644 --- a/src/adcp/exceptions.py +++ b/src/adcp/exceptions.py @@ -155,6 +155,16 @@ def __init__( super().__init__(message, agent_id, None, suggestion) +class RegistryError(ADCPError): + """Error from AdCP registry API operations (brand/property lookups).""" + + def __init__(self, message: str, status_code: int | None = None): + """Initialize registry error.""" + self.status_code = status_code + suggestion = "Check that the registry API is accessible and the domain is valid." + super().__init__(message, suggestion=suggestion) + + class AdagentsValidationError(ADCPError): """Base error for adagents.json validation issues.""" diff --git a/src/adcp/registry.py b/src/adcp/registry.py new file mode 100644 index 00000000..f5493f39 --- /dev/null +++ b/src/adcp/registry.py @@ -0,0 +1,293 @@ +from __future__ import annotations + +"""Client for the AdCP registry API (brand and property lookups).""" + +import asyncio +from typing import Any + +import httpx +from pydantic import ValidationError + +from adcp.exceptions import RegistryError +from adcp.types.core import ResolvedBrand, ResolvedProperty + +DEFAULT_REGISTRY_URL = "https://agenticadvertising.org" +MAX_BULK_DOMAINS = 100 + + +class RegistryClient: + """Client for the AdCP registry API. + + Provides brand and property lookups against the central AdCP registry. + + Args: + base_url: Registry API base URL. + timeout: Request timeout in seconds. + client: Optional httpx.AsyncClient for connection pooling. + If provided, caller is responsible for client lifecycle. + user_agent: User-Agent header for requests. + """ + + def __init__( + self, + base_url: str = DEFAULT_REGISTRY_URL, + timeout: float = 10.0, + client: httpx.AsyncClient | None = None, + user_agent: str = "adcp-client-python", + ): + self._base_url = base_url.rstrip("/") + self._timeout = timeout + self._external_client = client + self._owned_client: httpx.AsyncClient | None = None + self._user_agent = user_agent + + async def _get_client(self) -> httpx.AsyncClient: + """Get or create httpx client.""" + if self._external_client is not None: + return self._external_client + if self._owned_client is None: + self._owned_client = httpx.AsyncClient( + limits=httpx.Limits( + max_keepalive_connections=10, + max_connections=20, + ), + ) + return self._owned_client + + async def close(self) -> None: + """Close owned HTTP client. No-op if using external client.""" + if self._owned_client is not None: + await self._owned_client.aclose() + self._owned_client = None + + async def __aenter__(self) -> RegistryClient: + return self + + async def __aexit__(self, *args: Any) -> None: + await self.close() + + async def lookup_brand(self, domain: str) -> ResolvedBrand | None: + """Resolve a single domain to its canonical brand identity. + + Args: + domain: Domain to resolve (e.g., "nike.com"). + + Returns: + ResolvedBrand if found, None if the domain is not in the registry. + + Raises: + RegistryError: On HTTP or parsing errors. + """ + client = await self._get_client() + try: + response = await client.get( + f"{self._base_url}/api/brands/resolve", + params={"domain": domain}, + headers={"User-Agent": self._user_agent}, + timeout=self._timeout, + ) + if response.status_code == 404: + return None + if response.status_code != 200: + raise RegistryError( + f"Brand lookup failed: HTTP {response.status_code}", + status_code=response.status_code, + ) + data = response.json() + if data is None: + return None + return ResolvedBrand.model_validate(data) + except RegistryError: + raise + except httpx.TimeoutException as e: + raise RegistryError(f"Brand lookup timed out after {self._timeout}s") from e + except httpx.HTTPError as e: + raise RegistryError(f"Brand lookup failed: {e}") from e + except (ValidationError, ValueError) as e: + raise RegistryError(f"Brand lookup failed: invalid response: {e}") from e + + async def lookup_brands( + self, domains: list[str] + ) -> dict[str, ResolvedBrand | None]: + """Bulk resolve domains to brand identities. + + Automatically chunks requests exceeding 100 domains. + + Args: + domains: List of domains to resolve. + + Returns: + Dict mapping each domain to its ResolvedBrand, or None if not found. + + Raises: + RegistryError: On HTTP or parsing errors. + """ + if not domains: + return {} + + chunks = [ + domains[i : i + MAX_BULK_DOMAINS] + for i in range(0, len(domains), MAX_BULK_DOMAINS) + ] + + chunk_results = await asyncio.gather( + *[self._lookup_brands_chunk(chunk) for chunk in chunks], + return_exceptions=True, + ) + + merged: dict[str, ResolvedBrand | None] = {} + for result in chunk_results: + if isinstance(result, BaseException): + raise result + merged.update(result) + return merged + + async def _lookup_brands_chunk( + self, domains: list[str] + ) -> dict[str, ResolvedBrand | None]: + """Resolve a single chunk of brand domains (max 100).""" + client = await self._get_client() + try: + response = await client.post( + f"{self._base_url}/api/brands/resolve/bulk", + json={"domains": domains}, + headers={"User-Agent": self._user_agent}, + timeout=self._timeout, + ) + if response.status_code != 200: + raise RegistryError( + f"Bulk brand lookup failed: HTTP {response.status_code}", + status_code=response.status_code, + ) + data = response.json() + results_raw = data.get("results", {}) + results: dict[str, ResolvedBrand | None] = {d: None for d in domains} + for domain, brand_data in results_raw.items(): + if brand_data is not None: + results[domain] = ResolvedBrand.model_validate(brand_data) + return results + except RegistryError: + raise + except httpx.TimeoutException as e: + raise RegistryError( + f"Bulk brand lookup timed out after {self._timeout}s" + ) from e + except httpx.HTTPError as e: + raise RegistryError(f"Bulk brand lookup failed: {e}") from e + except (ValidationError, ValueError) as e: + raise RegistryError(f"Bulk brand lookup failed: invalid response: {e}") from e + + async def lookup_property(self, domain: str) -> ResolvedProperty | None: + """Resolve a publisher domain to its property info. + + Args: + domain: Publisher domain to resolve (e.g., "nytimes.com"). + + Returns: + ResolvedProperty if found, None if the domain is not in the registry. + + Raises: + RegistryError: On HTTP or parsing errors. + """ + client = await self._get_client() + try: + response = await client.get( + f"{self._base_url}/api/properties/resolve", + params={"domain": domain}, + headers={"User-Agent": self._user_agent}, + timeout=self._timeout, + ) + if response.status_code == 404: + return None + if response.status_code != 200: + raise RegistryError( + f"Property lookup failed: HTTP {response.status_code}", + status_code=response.status_code, + ) + data = response.json() + if data is None: + return None + return ResolvedProperty.model_validate(data) + except RegistryError: + raise + except httpx.TimeoutException as e: + raise RegistryError( + f"Property lookup timed out after {self._timeout}s" + ) from e + except httpx.HTTPError as e: + raise RegistryError(f"Property lookup failed: {e}") from e + except (ValidationError, ValueError) as e: + raise RegistryError(f"Property lookup failed: invalid response: {e}") from e + + async def lookup_properties( + self, domains: list[str] + ) -> dict[str, ResolvedProperty | None]: + """Bulk resolve publisher domains to property info. + + Automatically chunks requests exceeding 100 domains. + + Args: + domains: List of publisher domains to resolve. + + Returns: + Dict mapping each domain to its ResolvedProperty, or None if not found. + + Raises: + RegistryError: On HTTP or parsing errors. + """ + if not domains: + return {} + + chunks = [ + domains[i : i + MAX_BULK_DOMAINS] + for i in range(0, len(domains), MAX_BULK_DOMAINS) + ] + + chunk_results = await asyncio.gather( + *[self._lookup_properties_chunk(chunk) for chunk in chunks], + return_exceptions=True, + ) + + merged: dict[str, ResolvedProperty | None] = {} + for result in chunk_results: + if isinstance(result, BaseException): + raise result + merged.update(result) + return merged + + async def _lookup_properties_chunk( + self, domains: list[str] + ) -> dict[str, ResolvedProperty | None]: + """Resolve a single chunk of property domains (max 100).""" + client = await self._get_client() + try: + response = await client.post( + f"{self._base_url}/api/properties/resolve/bulk", + json={"domains": domains}, + headers={"User-Agent": self._user_agent}, + timeout=self._timeout, + ) + if response.status_code != 200: + raise RegistryError( + f"Bulk property lookup failed: HTTP {response.status_code}", + status_code=response.status_code, + ) + data = response.json() + results_raw = data.get("results", {}) + results: dict[str, ResolvedProperty | None] = {d: None for d in domains} + for domain, prop_data in results_raw.items(): + if prop_data is not None: + results[domain] = ResolvedProperty.model_validate(prop_data) + return results + except RegistryError: + raise + except httpx.TimeoutException as e: + raise RegistryError( + f"Bulk property lookup timed out after {self._timeout}s" + ) from e + except httpx.HTTPError as e: + raise RegistryError(f"Bulk property lookup failed: {e}") from e + except (ValidationError, ValueError) as e: + raise RegistryError( + f"Bulk property lookup failed: invalid response: {e}" + ) from e diff --git a/src/adcp/types/__init__.py b/src/adcp/types/__init__.py index 07062b45..a134eb6f 100644 --- a/src/adcp/types/__init__.py +++ b/src/adcp/types/__init__.py @@ -354,7 +354,14 @@ # Re-export core types (not in generated, but part of public API) # Note: We don't import TaskStatus here to avoid shadowing GeneratedTaskStatus # Users should import TaskStatus from adcp.types.core directly if they need the core enum -from adcp.types.core import AgentConfig, Protocol, TaskResult, WebhookMetadata +from adcp.types.core import ( + AgentConfig, + Protocol, + ResolvedBrand, + ResolvedProperty, + TaskResult, + WebhookMetadata, +) # Re-export webhook payload type for webhook handling from adcp.types.generated_poc.core.mcp_webhook_payload import McpWebhookPayload @@ -637,6 +644,8 @@ # Core types "AgentConfig", "Protocol", + "ResolvedBrand", + "ResolvedProperty", "TaskResult", "WebhookMetadata", # Webhook types diff --git a/src/adcp/types/core.py b/src/adcp/types/core.py index be7ad397..525591b9 100644 --- a/src/adcp/types/core.py +++ b/src/adcp/types/core.py @@ -172,3 +172,33 @@ class WebhookMetadata(BaseModel): sequence_number: int | None = None notification_type: Literal["scheduled", "final", "delayed"] | None = None timestamp: str + + +class ResolvedBrand(BaseModel): + """Brand identity resolved from the AdCP registry.""" + + model_config = ConfigDict(extra="allow") + + canonical_id: str + canonical_domain: str + brand_name: str + names: list[dict[str, str]] | None = None + keller_type: str | None = None + parent_brand: str | None = None + house_domain: str | None = None + house_name: str | None = None + brand_agent_url: str | None = None + brand_manifest: dict[str, Any] | None = None + source: str + + +class ResolvedProperty(BaseModel): + """Property information resolved from the AdCP registry.""" + + model_config = ConfigDict(extra="allow") + + publisher_domain: str + source: str + authorized_agents: list[dict[str, Any]] + properties: list[dict[str, Any]] + verified: bool diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 00000000..a865bc9a --- /dev/null +++ b/tests/test_registry.py @@ -0,0 +1,483 @@ +from __future__ import annotations + +"""Tests for AdCP registry client.""" + +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +from adcp.exceptions import RegistryError +from adcp.registry import DEFAULT_REGISTRY_URL, MAX_BULK_DOMAINS, RegistryClient +from adcp.types.core import ResolvedBrand, ResolvedProperty + +BRAND_DATA = { + "canonical_id": "nike.com", + "canonical_domain": "nike.com", + "brand_name": "Nike", + "keller_type": "master", + "source": "brand_json", + "brand_manifest": {"name": "Nike"}, +} + +PROPERTY_DATA = { + "publisher_domain": "nytimes.com", + "source": "adagents_json", + "authorized_agents": [{"url": "https://agent.example.com"}], + "properties": [{"id": "nyt_main", "type": "website", "name": "NYT Main"}], + "verified": True, +} + + +def _mock_response(status_code: int = 200, json_data: object = None) -> MagicMock: + resp = MagicMock() + resp.status_code = status_code + resp.json.return_value = json_data + return resp + + +class TestRegistryClientLifecycle: + """Test RegistryClient lifecycle management.""" + + @pytest.mark.asyncio + async def test_uses_external_client(self): + external = MagicMock() + external.get = AsyncMock(return_value=_mock_response(404)) + rc = RegistryClient(client=external) + await rc.lookup_brand("test.com") + external.get.assert_called_once() + + @pytest.mark.asyncio + async def test_creates_owned_client(self): + rc = RegistryClient() + client = await rc._get_client() + assert client is not None + assert rc._owned_client is client + await rc.close() + assert rc._owned_client is None + + @pytest.mark.asyncio + async def test_close_noop_for_external_client(self): + external = MagicMock() + rc = RegistryClient(client=external) + await rc.close() + # external client should not be closed by RegistryClient + + @pytest.mark.asyncio + async def test_context_manager(self): + async with RegistryClient() as rc: + client = await rc._get_client() + assert client is not None + assert rc._owned_client is None + + def test_default_base_url(self): + rc = RegistryClient() + assert rc._base_url == DEFAULT_REGISTRY_URL + + def test_custom_base_url_strips_trailing_slash(self): + rc = RegistryClient(base_url="https://example.com/") + assert rc._base_url == "https://example.com" + + +class TestLookupBrand: + """Test single brand lookup.""" + + @pytest.mark.asyncio + async def test_resolves_known_domain(self): + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=_mock_response(200, BRAND_DATA)) + + rc = RegistryClient(client=mock_client) + result = await rc.lookup_brand("nike.com") + + assert result is not None + assert isinstance(result, ResolvedBrand) + assert result.canonical_id == "nike.com" + assert result.brand_name == "Nike" + assert result.source == "brand_json" + + @pytest.mark.asyncio + async def test_returns_none_for_404(self): + mock_client = MagicMock() + mock_client.get = AsyncMock( + return_value=_mock_response(404, {"error": "Brand not found"}) + ) + + rc = RegistryClient(client=mock_client) + result = await rc.lookup_brand("unknown.com") + assert result is None + + @pytest.mark.asyncio + async def test_raises_on_server_error(self): + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=_mock_response(500)) + + rc = RegistryClient(client=mock_client) + with pytest.raises(RegistryError) as exc_info: + await rc.lookup_brand("nike.com") + assert exc_info.value.status_code == 500 + + @pytest.mark.asyncio + async def test_raises_on_timeout(self): + mock_client = MagicMock() + mock_client.get = AsyncMock(side_effect=httpx.ReadTimeout("timeout")) + + rc = RegistryClient(client=mock_client) + with pytest.raises(RegistryError, match="timed out"): + await rc.lookup_brand("nike.com") + + @pytest.mark.asyncio + async def test_raises_on_connection_error(self): + mock_client = MagicMock() + mock_client.get = AsyncMock( + side_effect=httpx.ConnectError("Connection refused") + ) + + rc = RegistryClient(client=mock_client) + with pytest.raises(RegistryError, match="failed"): + await rc.lookup_brand("nike.com") + + @pytest.mark.asyncio + async def test_sends_correct_params(self): + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=_mock_response(404)) + + rc = RegistryClient( + base_url="https://test.example.com", + client=mock_client, + user_agent="test-agent", + ) + await rc.lookup_brand("nike.com") + + mock_client.get.assert_called_once_with( + "https://test.example.com/api/brands/resolve", + params={"domain": "nike.com"}, + headers={"User-Agent": "test-agent"}, + timeout=10.0, + ) + + @pytest.mark.asyncio + async def test_returns_none_for_null_body(self): + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=_mock_response(200, None)) + + rc = RegistryClient(client=mock_client) + result = await rc.lookup_brand("empty.com") + assert result is None + + @pytest.mark.asyncio + async def test_extra_fields_preserved(self): + data = {**BRAND_DATA, "extra_field": "extra_value"} + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=_mock_response(200, data)) + + rc = RegistryClient(client=mock_client) + result = await rc.lookup_brand("nike.com") + assert result is not None + assert result.extra_field == "extra_value" # type: ignore[attr-defined] + + @pytest.mark.asyncio + async def test_raises_on_invalid_response_data(self): + mock_client = MagicMock() + mock_client.get = AsyncMock( + return_value=_mock_response(200, {"unexpected": "data"}) + ) + + rc = RegistryClient(client=mock_client) + with pytest.raises(RegistryError, match="invalid response"): + await rc.lookup_brand("nike.com") + + +class TestLookupBrands: + """Test bulk brand lookup.""" + + @pytest.mark.asyncio + async def test_resolves_multiple_domains(self): + mock_client = MagicMock() + mock_client.post = AsyncMock( + return_value=_mock_response( + 200, + { + "results": { + "nike.com": BRAND_DATA, + "unknown.com": None, + } + }, + ) + ) + + rc = RegistryClient(client=mock_client) + results = await rc.lookup_brands(["nike.com", "unknown.com"]) + + assert len(results) == 2 + assert isinstance(results["nike.com"], ResolvedBrand) + assert results["unknown.com"] is None + + @pytest.mark.asyncio + async def test_empty_list_returns_empty_dict(self): + rc = RegistryClient(client=MagicMock()) + results = await rc.lookup_brands([]) + assert results == {} + + @pytest.mark.asyncio + async def test_auto_chunks_over_limit(self): + domains = [f"domain-{i}.com" for i in range(150)] + + call_count = 0 + + async def mock_post(url, json, headers, timeout): + nonlocal call_count + call_count += 1 + chunk_domains = json["domains"] + results = {d: None for d in chunk_domains} + return _mock_response(200, {"results": results}) + + mock_client = MagicMock() + mock_client.post = mock_post + + rc = RegistryClient(client=mock_client) + results = await rc.lookup_brands(domains) + + assert call_count == 2 # 100 + 50 + assert len(results) == 150 + + @pytest.mark.asyncio + async def test_raises_on_server_error(self): + mock_client = MagicMock() + mock_client.post = AsyncMock(return_value=_mock_response(500)) + + rc = RegistryClient(client=mock_client) + with pytest.raises(RegistryError) as exc_info: + await rc.lookup_brands(["nike.com"]) + assert exc_info.value.status_code == 500 + + +class TestLookupProperty: + """Test single property lookup.""" + + @pytest.mark.asyncio + async def test_resolves_known_domain(self): + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=_mock_response(200, PROPERTY_DATA)) + + rc = RegistryClient(client=mock_client) + result = await rc.lookup_property("nytimes.com") + + assert result is not None + assert isinstance(result, ResolvedProperty) + assert result.publisher_domain == "nytimes.com" + assert result.source == "adagents_json" + assert result.verified is True + assert len(result.authorized_agents) == 1 + + @pytest.mark.asyncio + async def test_returns_none_for_404(self): + mock_client = MagicMock() + mock_client.get = AsyncMock( + return_value=_mock_response(404, {"error": "Property not found"}) + ) + + rc = RegistryClient(client=mock_client) + result = await rc.lookup_property("unknown.com") + assert result is None + + @pytest.mark.asyncio + async def test_raises_on_server_error(self): + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=_mock_response(500)) + + rc = RegistryClient(client=mock_client) + with pytest.raises(RegistryError) as exc_info: + await rc.lookup_property("nytimes.com") + assert exc_info.value.status_code == 500 + + @pytest.mark.asyncio + async def test_raises_on_timeout(self): + mock_client = MagicMock() + mock_client.get = AsyncMock(side_effect=httpx.ReadTimeout("timeout")) + + rc = RegistryClient(client=mock_client) + with pytest.raises(RegistryError, match="timed out"): + await rc.lookup_property("nytimes.com") + + @pytest.mark.asyncio + async def test_sends_correct_params(self): + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=_mock_response(404)) + + rc = RegistryClient( + base_url="https://test.example.com", + client=mock_client, + user_agent="test-agent", + ) + await rc.lookup_property("nytimes.com") + + mock_client.get.assert_called_once_with( + "https://test.example.com/api/properties/resolve", + params={"domain": "nytimes.com"}, + headers={"User-Agent": "test-agent"}, + timeout=10.0, + ) + + @pytest.mark.asyncio + async def test_raises_on_invalid_response_data(self): + mock_client = MagicMock() + mock_client.get = AsyncMock( + return_value=_mock_response(200, {"unexpected": "data"}) + ) + + rc = RegistryClient(client=mock_client) + with pytest.raises(RegistryError, match="invalid response"): + await rc.lookup_property("nytimes.com") + + +class TestLookupProperties: + """Test bulk property lookup.""" + + @pytest.mark.asyncio + async def test_resolves_multiple_domains(self): + mock_client = MagicMock() + mock_client.post = AsyncMock( + return_value=_mock_response( + 200, + { + "results": { + "nytimes.com": PROPERTY_DATA, + "unknown.com": None, + } + }, + ) + ) + + rc = RegistryClient(client=mock_client) + results = await rc.lookup_properties(["nytimes.com", "unknown.com"]) + + assert len(results) == 2 + assert isinstance(results["nytimes.com"], ResolvedProperty) + assert results["unknown.com"] is None + + @pytest.mark.asyncio + async def test_empty_list_returns_empty_dict(self): + rc = RegistryClient(client=MagicMock()) + results = await rc.lookup_properties([]) + assert results == {} + + @pytest.mark.asyncio + async def test_auto_chunks_over_limit(self): + domains = [f"pub-{i}.com" for i in range(250)] + + call_count = 0 + + async def mock_post(url, json, headers, timeout): + nonlocal call_count + call_count += 1 + chunk_domains = json["domains"] + results = {d: None for d in chunk_domains} + return _mock_response(200, {"results": results}) + + mock_client = MagicMock() + mock_client.post = mock_post + + rc = RegistryClient(client=mock_client) + results = await rc.lookup_properties(domains) + + assert call_count == 3 # 100 + 100 + 50 + assert len(results) == 250 + + @pytest.mark.asyncio + async def test_raises_on_server_error(self): + mock_client = MagicMock() + mock_client.post = AsyncMock(return_value=_mock_response(500)) + + rc = RegistryClient(client=mock_client) + with pytest.raises(RegistryError) as exc_info: + await rc.lookup_properties(["nytimes.com"]) + assert exc_info.value.status_code == 500 + + +class TestRegistryTypes: + """Test ResolvedBrand and ResolvedProperty Pydantic models.""" + + def test_resolved_brand_validates(self): + brand = ResolvedBrand.model_validate(BRAND_DATA) + assert brand.canonical_id == "nike.com" + assert brand.brand_name == "Nike" + + def test_resolved_brand_optional_fields(self): + minimal = { + "canonical_id": "x.com", + "canonical_domain": "x.com", + "brand_name": "X", + "source": "community", + } + brand = ResolvedBrand.model_validate(minimal) + assert brand.keller_type is None + assert brand.brand_manifest is None + assert brand.house_domain is None + + def test_resolved_property_validates(self): + prop = ResolvedProperty.model_validate(PROPERTY_DATA) + assert prop.publisher_domain == "nytimes.com" + assert prop.verified is True + + def test_resolved_property_all_fields(self): + prop = ResolvedProperty.model_validate(PROPERTY_DATA) + assert prop.source == "adagents_json" + assert len(prop.authorized_agents) == 1 + assert len(prop.properties) == 1 + + +class TestPublicApiExports: + """Test that registry types are exported from the adcp package.""" + + def test_registry_client_exported(self): + import adcp + + assert adcp.RegistryClient is RegistryClient + + def test_registry_error_exported(self): + import adcp + + assert adcp.RegistryError is RegistryError + + def test_resolved_brand_exported_from_types(self): + import adcp.types + + assert adcp.types.ResolvedBrand is ResolvedBrand + + def test_resolved_property_exported_from_types(self): + import adcp.types + + assert adcp.types.ResolvedProperty is ResolvedProperty + + def test_resolved_brand_exported_from_root(self): + import adcp + + assert adcp.ResolvedBrand is ResolvedBrand + + def test_resolved_property_exported_from_root(self): + import adcp + + assert adcp.ResolvedProperty is ResolvedProperty + + +class TestRegistryError: + """Test RegistryError exception.""" + + def test_basic_error(self): + err = RegistryError("something failed") + assert "something failed" in str(err) + assert err.status_code is None + + def test_error_with_status_code(self): + err = RegistryError("HTTP 500", status_code=500) + assert err.status_code == 500 + + def test_inherits_from_adcp_error(self): + from adcp.exceptions import ADCPError + + err = RegistryError("test") + assert isinstance(err, ADCPError) + + def test_max_bulk_domains_constant(self): + assert MAX_BULK_DOMAINS == 100