diff --git a/asknews_sdk/api/__init__.py b/asknews_sdk/api/__init__.py index 47c05e0..2ad51c4 100644 --- a/asknews_sdk/api/__init__.py +++ b/asknews_sdk/api/__init__.py @@ -1,6 +1,7 @@ from asknews_sdk.api.analytics import AnalyticsAPI, AsyncAnalyticsAPI from asknews_sdk.api.byok import AsyncByokAPI, ByokAPI from asknews_sdk.api.chat import AsyncChatAPI, ChatAPI +from asknews_sdk.api.distribution import AsyncDistributionAPI, DistributionAPI from asknews_sdk.api.news import AsyncNewsAPI, NewsAPI from asknews_sdk.api.stories import AsyncStoriesAPI, StoriesAPI from asknews_sdk.api.wiki import AsyncWikiAPI, WikiAPI @@ -17,6 +18,8 @@ "AsyncNewsAPI", "ChatAPI", "AsyncChatAPI", + "DistributionAPI", + "AsyncDistributionAPI", "WikiAPI", "AsyncWikiAPI", ) diff --git a/asknews_sdk/api/distribution.py b/asknews_sdk/api/distribution.py new file mode 100644 index 0000000..abc8654 --- /dev/null +++ b/asknews_sdk/api/distribution.py @@ -0,0 +1,101 @@ +from typing import Dict, List, Optional + +from asknews_sdk.api.base import BaseAPI +from asknews_sdk.client import APIClient, AsyncAPIClient +from asknews_sdk.dto.distribution import DomainMetricsResponse, DomainMetricsTimeWindowResponse + + +class DistributionAPI(BaseAPI[APIClient]): + """Distribution API.""" + + def get_domain_metrics( + self, + domain_names: List[str], + start_date: Optional[int] = None, + end_date: Optional[int] = None, + *, + http_headers: Optional[Dict] = None, + ) -> DomainMetricsResponse: + """Get raw publisher metric event counts for domains.""" + response = self.client.request( + method="GET", + endpoint="/v1/distribution/stats/metrics", + query={ + "domain_names": domain_names, + "start_date": start_date, + "end_date": end_date, + }, + headers=http_headers, + accept=[(DomainMetricsResponse.__content_type__, 1.0)], + ) + return DomainMetricsResponse.model_validate(response.content) + + def get_domain_metrics_timeseries( + self, + domain_names: List[str], + start_date: Optional[int] = None, + end_date: Optional[int] = None, + *, + http_headers: Optional[Dict] = None, + ) -> DomainMetricsTimeWindowResponse: + """Get raw publisher metric event counts per day for domains.""" + response = self.client.request( + method="GET", + endpoint="/v1/distribution/stats/metrics_timeseries", + query={ + "domain_names": domain_names, + "start_date": start_date, + "end_date": end_date, + }, + headers=http_headers, + accept=[(DomainMetricsTimeWindowResponse.__content_type__, 1.0)], + ) + return DomainMetricsTimeWindowResponse.model_validate(response.content) + + +class AsyncDistributionAPI(BaseAPI[AsyncAPIClient]): + """Distribution API (async).""" + + async def get_domain_metrics( + self, + domain_names: List[str], + start_date: Optional[int] = None, + end_date: Optional[int] = None, + *, + http_headers: Optional[Dict] = None, + ) -> DomainMetricsResponse: + """Get raw publisher metric event counts for domains.""" + response = await self.client.request( + method="GET", + endpoint="/v1/distribution/stats/metrics", + query={ + "domain_names": domain_names, + "start_date": start_date, + "end_date": end_date, + }, + headers=http_headers, + accept=[(DomainMetricsResponse.__content_type__, 1.0)], + ) + return DomainMetricsResponse.model_validate(response.content) + + async def get_domain_metrics_timeseries( + self, + domain_names: List[str], + start_date: Optional[int] = None, + end_date: Optional[int] = None, + *, + http_headers: Optional[Dict] = None, + ) -> DomainMetricsTimeWindowResponse: + """Get raw publisher metric event counts per day for domains.""" + response = await self.client.request( + method="GET", + endpoint="/v1/distribution/stats/metrics_timeseries", + query={ + "domain_names": domain_names, + "start_date": start_date, + "end_date": end_date, + }, + headers=http_headers, + accept=[(DomainMetricsTimeWindowResponse.__content_type__, 1.0)], + ) + return DomainMetricsTimeWindowResponse.model_validate(response.content) diff --git a/asknews_sdk/dto/__init__.py b/asknews_sdk/dto/__init__.py index 1107e7f..4187618 100644 --- a/asknews_sdk/dto/__init__.py +++ b/asknews_sdk/dto/__init__.py @@ -12,6 +12,11 @@ ) from asknews_sdk.dto.byok import ApiKeyResponse, Provider, UpsertApiKeyRequest from asknews_sdk.dto.common import FilterParams +from asknews_sdk.dto.distribution import ( + DomainMetricsDayItem, + DomainMetricsResponse, + DomainMetricsTimeWindowResponse, +) from asknews_sdk.dto.error import APIErrorModel, HTTPValidationError, ValidationError from asknews_sdk.dto.news import ( ReferralItem, @@ -47,6 +52,9 @@ "WebhookAction", "WebhookParams", "FilterParams", + "DomainMetricsDayItem", + "DomainMetricsResponse", + "DomainMetricsTimeWindowResponse", "APIErrorModel", "ValidationError", "HTTPValidationError", diff --git a/asknews_sdk/dto/distribution.py b/asknews_sdk/dto/distribution.py new file mode 100644 index 0000000..1edd58f --- /dev/null +++ b/asknews_sdk/dto/distribution.py @@ -0,0 +1,25 @@ +from typing import List + +from pydantic import BaseModel + +from asknews_sdk.dto.base import BaseSchema + + +class DomainMetricsDayItem(BaseModel): + day: str + surfaces: int + citations: int + full_text: int + + +class DomainMetricsResponse(BaseSchema): + surfaces: int + citations: int + full_text: int + + +class DomainMetricsTimeWindowResponse(BaseSchema): + data: List[DomainMetricsDayItem] + total_surfaces: int + total_citations: int + total_full_text: int diff --git a/asknews_sdk/sdk.py b/asknews_sdk/sdk.py index d174bed..990df15 100644 --- a/asknews_sdk/sdk.py +++ b/asknews_sdk/sdk.py @@ -9,11 +9,13 @@ AsyncAnalyticsAPI, AsyncByokAPI, AsyncChatAPI, + AsyncDistributionAPI, AsyncNewsAPI, AsyncStoriesAPI, AsyncWikiAPI, ByokAPI, ChatAPI, + DistributionAPI, NewsAPI, StoriesAPI, WikiAPI, @@ -121,6 +123,7 @@ def __init__( self.chat = ChatAPI(self.client) self.wiki = WikiAPI(self.client) self.byok = ByokAPI(self.client) + self.distribution = DistributionAPI(self.client) def __enter__(self) -> AskNewsSDK: return self @@ -232,6 +235,7 @@ def __init__( self.chat = AsyncChatAPI(self.client) self.wiki = AsyncWikiAPI(self.client) self.byok = AsyncByokAPI(self.client) + self.distribution = AsyncDistributionAPI(self.client) async def __aenter__(self) -> AsyncAskNewsSDK: return self diff --git a/tests/api/test_distribution.py b/tests/api/test_distribution.py new file mode 100644 index 0000000..606086e --- /dev/null +++ b/tests/api/test_distribution.py @@ -0,0 +1,121 @@ +from urllib.parse import parse_qs + +import pytest +from respx import MockRouter + +from asknews_sdk.api.distribution import AsyncDistributionAPI, DistributionAPI +from asknews_sdk.client import APIClient, AsyncAPIClient +from asknews_sdk.dto.distribution import DomainMetricsResponse, DomainMetricsTimeWindowResponse +from asknews_sdk.sdk import AskNewsSDK, AsyncAskNewsSDK + + +DOMAIN_NAMES = ["example.com", "example.org"] +START_DATE = 1_700_000_000 +END_DATE = 1_700_086_400 + + +@pytest.fixture +def sync_distribution_api(sync_api_client: APIClient): + return DistributionAPI(sync_api_client) + + +@pytest.fixture +def async_distribution_api(async_api_client: AsyncAPIClient): + return AsyncDistributionAPI(async_api_client) + + +def test_sync_sdk_exposes_distribution_api(): + with AskNewsSDK(auth=None) as sdk: + assert isinstance(sdk.distribution, DistributionAPI) + + +@pytest.mark.asyncio +async def test_async_sdk_exposes_distribution_api(): + async with AsyncAskNewsSDK(auth=None) as sdk: + assert isinstance(sdk.distribution, AsyncDistributionAPI) + + +def test_sync_get_domain_metrics(sync_distribution_api: DistributionAPI, response_mock: MockRouter): + payload = {"surfaces": 12, "citations": 7, "full_text": 3} + mock_route = response_mock.get("/v1/distribution/stats/metrics").respond(json=payload) + + response = sync_distribution_api.get_domain_metrics( + DOMAIN_NAMES, + start_date=START_DATE, + end_date=END_DATE, + http_headers={"custom-header": "custom-value"}, + ) + + assert response == DomainMetricsResponse(**payload) + request = mock_route.calls.last.request + assert request.method == "GET" + assert request.headers["accept"] == DomainMetricsResponse.__content_type__ + assert request.headers["custom-header"] == "custom-value" + assert parse_qs(request.url.query.decode()) == { + "domain_names": DOMAIN_NAMES, + "start_date": [str(START_DATE)], + "end_date": [str(END_DATE)], + } + + +def test_sync_get_domain_metrics_timeseries( + sync_distribution_api: DistributionAPI, response_mock: MockRouter +): + payload = { + "data": [{"day": "2026-08-01", "surfaces": 5, "citations": 3, "full_text": 1}], + "total_surfaces": 5, + "total_citations": 3, + "total_full_text": 1, + } + mock_route = response_mock.get("/v1/distribution/stats/metrics_timeseries").respond( + json=payload + ) + + response = sync_distribution_api.get_domain_metrics_timeseries(DOMAIN_NAMES) + + assert response == DomainMetricsTimeWindowResponse(**payload) + request = mock_route.calls.last.request + assert request.method == "GET" + assert request.headers["accept"] == DomainMetricsTimeWindowResponse.__content_type__ + assert parse_qs(request.url.query.decode()) == {"domain_names": DOMAIN_NAMES} + + +@pytest.mark.asyncio +async def test_async_get_domain_metrics( + async_distribution_api: AsyncDistributionAPI, response_mock: MockRouter +): + payload = {"surfaces": 12, "citations": 7, "full_text": 3} + mock_route = response_mock.get("/v1/distribution/stats/metrics").respond(json=payload) + + response = await async_distribution_api.get_domain_metrics( + DOMAIN_NAMES, start_date=START_DATE, end_date=END_DATE + ) + + assert response == DomainMetricsResponse(**payload) + assert parse_qs(mock_route.calls.last.request.url.query.decode()) == { + "domain_names": DOMAIN_NAMES, + "start_date": [str(START_DATE)], + "end_date": [str(END_DATE)], + } + + +@pytest.mark.asyncio +async def test_async_get_domain_metrics_timeseries( + async_distribution_api: AsyncDistributionAPI, response_mock: MockRouter +): + payload = { + "data": [{"day": "2026-08-01", "surfaces": 5, "citations": 3, "full_text": 1}], + "total_surfaces": 5, + "total_citations": 3, + "total_full_text": 1, + } + mock_route = response_mock.get("/v1/distribution/stats/metrics_timeseries").respond( + json=payload + ) + + response = await async_distribution_api.get_domain_metrics_timeseries(DOMAIN_NAMES) + + assert response == DomainMetricsTimeWindowResponse(**payload) + assert parse_qs(mock_route.calls.last.request.url.query.decode()) == { + "domain_names": DOMAIN_NAMES + }