From 628295ba6598bd130bbdf5c246394b8eb11630d4 Mon Sep 17 00:00:00 2001 From: Devops Bot Date: Tue, 4 Aug 2026 20:38:57 +0000 Subject: [PATCH 1/3] feat: Add publisher domain metrics --- asknews_sdk/api/__init__.py | 3 + asknews_sdk/api/distribution.py | 101 ++++++++++++++++++++++++++ asknews_sdk/dto/__init__.py | 8 +++ asknews_sdk/dto/distribution.py | 25 +++++++ asknews_sdk/sdk.py | 4 ++ tests/api/test_distribution.py | 121 ++++++++++++++++++++++++++++++++ 6 files changed, 262 insertions(+) create mode 100644 asknews_sdk/api/distribution.py create mode 100644 asknews_sdk/dto/distribution.py create mode 100644 tests/api/test_distribution.py 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 + } From cb6fcc59dbba2b8bd7d8d8f87f0a077c13c3da79 Mon Sep 17 00:00:00 2001 From: Devops Bot Date: Thu, 20 Aug 2026 13:18:59 +0000 Subject: [PATCH 2/3] fix: Add podcasts filter to news search --- asknews_sdk/api/news.py | 17 +++++++++++++++-- tests/api/test_news.py | 8 +++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/asknews_sdk/api/news.py b/asknews_sdk/api/news.py index 30298ff..3c4ea37 100644 --- a/asknews_sdk/api/news.py +++ b/asknews_sdk/api/news.py @@ -114,6 +114,7 @@ def search_news( reporting_voice: Optional[Union[List[str], str]] = None, domain_url: Optional[Union[List[str], str]] = None, bad_domain_url: Optional[Union[List[str], str]] = None, + podcasts: Literal["include", "only", "none"] = "include", page_rank: Optional[int] = None, diversify_sources: Optional[bool] = False, strategy: Literal["latest news", "news knowledge", "default"] = "default", @@ -195,6 +196,10 @@ def search_news( :type reporting_voice: Optional[str] :param domain_url: Domain URL, defaults to None :type domain_url: Optional[str] + :param podcasts: Control whether podcasts are included in search results. 'include' + searches news and podcasts, 'only' searches podcasts only, and 'none' + excludes podcasts. Defaults to 'include'. + :type podcasts: Literal["include", "only", "none"] :param page_rank: Page rank, defaults to None :type page_rank: Optional[int] :param http_headers: Additional HTTP headers. @@ -223,6 +228,7 @@ def search_news( "reporting_voice": reporting_voice, "domain_url": domain_url, "bad_domain_url": bad_domain_url, + "podcasts": podcasts, "page_rank": page_rank, "diversify_sources": diversify_sources, "strategy": strategy, @@ -602,6 +608,7 @@ async def search_news( reporting_voice: Optional[Union[List[str], str]] = None, domain_url: Optional[Union[List[str], str]] = None, bad_domain_url: Optional[Union[List[str], str]] = None, + podcasts: Literal["include", "only", "none"] = "include", page_rank: Optional[int] = None, diversify_sources: Optional[bool] = False, strategy: Literal["latest news", "news knowledge", "default"] = "default", @@ -631,9 +638,14 @@ async def search_news( http_headers: Optional[Dict] = None, ) -> SearchResponse: """ - Get time-series counts for a filter + Search for news articles given a query. - https://docs.asknews.app/en/reference#get-/v1/index_counts + https://docs.asknews.app/en/reference#get-/v1/news/search + + :param podcasts: Control whether podcasts are included in search results. 'include' + searches news and podcasts, 'only' searches podcasts only, and 'none' + excludes podcasts. Defaults to 'include'. + :type podcasts: Literal["include", "only", "none"] """ response = await self.client.request( method="GET", @@ -656,6 +668,7 @@ async def search_news( "reporting_voice": reporting_voice, "domain_url": domain_url, "bad_domain_url": bad_domain_url, + "podcasts": podcasts, "page_rank": page_rank, "diversify_sources": diversify_sources, "strategy": strategy, diff --git a/tests/api/test_news.py b/tests/api/test_news.py index be152ba..7651f0e 100644 --- a/tests/api/test_news.py +++ b/tests/api/test_news.py @@ -120,7 +120,10 @@ async def test_async_news_api_get_article(async_news_api: AsyncNewsAPI, response assert mock_route.calls.last.response.status_code == 404 -def test_sync_news_api_search_news(sync_news_api: NewsAPI, response_mock: MockRouter): +@pytest.mark.parametrize("podcasts", ["include", "only", "none"]) +def test_sync_news_api_search_news( + sync_news_api: NewsAPI, response_mock: MockRouter, podcasts: str +): mock_search_response = MockSearchResponse.build() mock_route = response_mock.get("/v1/news/search").respond( @@ -129,6 +132,7 @@ def test_sync_news_api_search_news(sync_news_api: NewsAPI, response_mock: MockRo response = sync_news_api.search_news( "query", + podcasts=podcasts, http_headers={ "custom-header": "custom-value", } @@ -140,6 +144,7 @@ def test_sync_news_api_search_news(sync_news_api: NewsAPI, response_mock: MockRo assert mock_route.called assert mock_route.calls.last.request.url.path == "/v1/news/search" + assert mock_route.calls.last.request.url.params["podcasts"] == podcasts assert mock_route.calls.last.request.method == "GET" assert mock_route.calls.last.request.headers["accept"] == SearchResponse.__content_type__ assert mock_route.calls.last.request.headers["custom-header"] == "custom-value" @@ -166,6 +171,7 @@ async def test_async_news_api_search_news(async_news_api: AsyncNewsAPI, response assert mock_route.called assert mock_route.calls.last.request.url.path == "/v1/news/search" + assert mock_route.calls.last.request.url.params["podcasts"] == "include" assert mock_route.calls.last.request.method == "GET" assert mock_route.calls.last.request.headers["accept"] == SearchResponse.__content_type__ assert mock_route.calls.last.request.headers["custom-header"] == "custom-value" From ccb92c6b5c55047a994c64fce0f3a3e75f79ee73 Mon Sep 17 00:00:00 2001 From: Devops Bot Date: Thu, 20 Aug 2026 13:38:57 +0000 Subject: [PATCH 3/3] fix: Preserve news search positional arguments --- asknews_sdk/api/news.py | 8 ++++---- tests/api/test_news.py | 8 ++++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/asknews_sdk/api/news.py b/asknews_sdk/api/news.py index 3c4ea37..dccb1b8 100644 --- a/asknews_sdk/api/news.py +++ b/asknews_sdk/api/news.py @@ -114,7 +114,6 @@ def search_news( reporting_voice: Optional[Union[List[str], str]] = None, domain_url: Optional[Union[List[str], str]] = None, bad_domain_url: Optional[Union[List[str], str]] = None, - podcasts: Literal["include", "only", "none"] = "include", page_rank: Optional[int] = None, diversify_sources: Optional[bool] = False, strategy: Literal["latest news", "news knowledge", "default"] = "default", @@ -140,6 +139,7 @@ def search_news( geo_radius: Optional[float] = None, geo_polygon: Optional[str] = None, sort_by: Optional[Literal["relevance", "pub_date"]] = None, + podcasts: Literal["include", "only", "none"] = "include", *, http_headers: Optional[Dict] = None, ) -> SearchResponse: @@ -196,12 +196,12 @@ def search_news( :type reporting_voice: Optional[str] :param domain_url: Domain URL, defaults to None :type domain_url: Optional[str] + :param page_rank: Page rank, defaults to None + :type page_rank: Optional[int] :param podcasts: Control whether podcasts are included in search results. 'include' searches news and podcasts, 'only' searches podcasts only, and 'none' excludes podcasts. Defaults to 'include'. :type podcasts: Literal["include", "only", "none"] - :param page_rank: Page rank, defaults to None - :type page_rank: Optional[int] :param http_headers: Additional HTTP headers. :type http_headers: Optional[Dict] :return: The search response. @@ -608,7 +608,6 @@ async def search_news( reporting_voice: Optional[Union[List[str], str]] = None, domain_url: Optional[Union[List[str], str]] = None, bad_domain_url: Optional[Union[List[str], str]] = None, - podcasts: Literal["include", "only", "none"] = "include", page_rank: Optional[int] = None, diversify_sources: Optional[bool] = False, strategy: Literal["latest news", "news knowledge", "default"] = "default", @@ -634,6 +633,7 @@ async def search_news( geo_radius: Optional[float] = None, geo_polygon: Optional[str] = None, sort_by: Optional[Literal["relevance", "pub_date"]] = None, + podcasts: Literal["include", "only", "none"] = "include", *, http_headers: Optional[Dict] = None, ) -> SearchResponse: diff --git a/tests/api/test_news.py b/tests/api/test_news.py index 7651f0e..11a71e1 100644 --- a/tests/api/test_news.py +++ b/tests/api/test_news.py @@ -151,7 +151,10 @@ def test_sync_news_api_search_news( assert mock_route.calls.last.response.status_code == 200 -async def test_async_news_api_search_news(async_news_api: AsyncNewsAPI, response_mock: MockRouter): +@pytest.mark.parametrize("podcasts", ["include", "only", "none"]) +async def test_async_news_api_search_news( + async_news_api: AsyncNewsAPI, response_mock: MockRouter, podcasts: str +): mock_search_response = MockSearchResponse.build() mock_route = response_mock.get("/v1/news/search").respond( @@ -160,6 +163,7 @@ async def test_async_news_api_search_news(async_news_api: AsyncNewsAPI, response response = await async_news_api.search_news( "query", + podcasts=podcasts, http_headers={ "custom-header": "custom-value", } @@ -171,7 +175,7 @@ async def test_async_news_api_search_news(async_news_api: AsyncNewsAPI, response assert mock_route.called assert mock_route.calls.last.request.url.path == "/v1/news/search" - assert mock_route.calls.last.request.url.params["podcasts"] == "include" + assert mock_route.calls.last.request.url.params["podcasts"] == podcasts assert mock_route.calls.last.request.method == "GET" assert mock_route.calls.last.request.headers["accept"] == SearchResponse.__content_type__ assert mock_route.calls.last.request.headers["custom-header"] == "custom-value"