diff --git a/asknews_sdk/api/distribution.py b/asknews_sdk/api/distribution.py index abc8654..8b5b0da 100644 --- a/asknews_sdk/api/distribution.py +++ b/asknews_sdk/api/distribution.py @@ -1,8 +1,13 @@ -from typing import Dict, List, Optional +from typing import Dict, List, Literal, Optional from asknews_sdk.api.base import BaseAPI from asknews_sdk.client import APIClient, AsyncAPIClient -from asknews_sdk.dto.distribution import DomainMetricsResponse, DomainMetricsTimeWindowResponse +from asknews_sdk.dto.distribution import ( + DomainMetricsResponse, + DomainMetricsTimeWindowResponse, + HitShareResponse, + TopNArticlesForDomainResponse, +) class DistributionAPI(BaseAPI[APIClient]): @@ -52,6 +57,61 @@ def get_domain_metrics_timeseries( ) return DomainMetricsTimeWindowResponse.model_validate(response.content) + def get_domain_hit_share( + self, + domain_names: List[str], + start_date: Optional[int] = None, + end_date: Optional[int] = None, + is_publisher: bool = True, + *, + http_headers: Optional[Dict] = None, + ) -> HitShareResponse: + """Get domain hit shares, optionally bounded by Unix timestamps in seconds.""" + response = self.client.request( + method="GET", + endpoint="/v1/distribution/stats/hit_share", + query={ + "domain_names": domain_names, + "start_date": start_date, + "end_date": end_date, + "is_publisher": is_publisher, + }, + headers=http_headers, + accept=[(HitShareResponse.__content_type__, 1.0)], + ) + return HitShareResponse.model_validate(response.content) + + def get_top_n_articles_for_domains( + self, + domain_names: List[str], + limit: int = 10, + page: int = 1, + start_date: Optional[int] = None, + end_date: Optional[int] = None, + metric: Literal["surface", "citation", "grounded"] = "surface", + *, + http_headers: Optional[Dict] = None, + ) -> TopNArticlesForDomainResponse: + """Get domain articles ranked by raw event count for the selected metric. + + Dates are Unix timestamps in seconds. Limit is 1–100; page is 1-based. + """ + response = self.client.request( + method="GET", + endpoint="/v1/distribution/articles/top_n_for_domains", + query={ + "domain_names": domain_names, + "limit": limit, + "page": page, + "start_date": start_date, + "end_date": end_date, + "metric": metric, + }, + headers=http_headers, + accept=[(TopNArticlesForDomainResponse.__content_type__, 1.0)], + ) + return TopNArticlesForDomainResponse.model_validate(response.content) + class AsyncDistributionAPI(BaseAPI[AsyncAPIClient]): """Distribution API (async).""" @@ -99,3 +159,58 @@ async def get_domain_metrics_timeseries( accept=[(DomainMetricsTimeWindowResponse.__content_type__, 1.0)], ) return DomainMetricsTimeWindowResponse.model_validate(response.content) + + async def get_domain_hit_share( + self, + domain_names: List[str], + start_date: Optional[int] = None, + end_date: Optional[int] = None, + is_publisher: bool = True, + *, + http_headers: Optional[Dict] = None, + ) -> HitShareResponse: + """Get domain hit shares, optionally bounded by Unix timestamps in seconds.""" + response = await self.client.request( + method="GET", + endpoint="/v1/distribution/stats/hit_share", + query={ + "domain_names": domain_names, + "start_date": start_date, + "end_date": end_date, + "is_publisher": is_publisher, + }, + headers=http_headers, + accept=[(HitShareResponse.__content_type__, 1.0)], + ) + return HitShareResponse.model_validate(response.content) + + async def get_top_n_articles_for_domains( + self, + domain_names: List[str], + limit: int = 10, + page: int = 1, + start_date: Optional[int] = None, + end_date: Optional[int] = None, + metric: Literal["surface", "citation", "grounded"] = "surface", + *, + http_headers: Optional[Dict] = None, + ) -> TopNArticlesForDomainResponse: + """Get domain articles ranked by raw event count for the selected metric. + + Dates are Unix timestamps in seconds. Limit is 1–100; page is 1-based. + """ + response = await self.client.request( + method="GET", + endpoint="/v1/distribution/articles/top_n_for_domains", + query={ + "domain_names": domain_names, + "limit": limit, + "page": page, + "start_date": start_date, + "end_date": end_date, + "metric": metric, + }, + headers=http_headers, + accept=[(TopNArticlesForDomainResponse.__content_type__, 1.0)], + ) + return TopNArticlesForDomainResponse.model_validate(response.content) diff --git a/asknews_sdk/dto/distribution.py b/asknews_sdk/dto/distribution.py index 1edd58f..3cd4446 100644 --- a/asknews_sdk/dto/distribution.py +++ b/asknews_sdk/dto/distribution.py @@ -1,4 +1,4 @@ -from typing import List +from typing import List, Optional from pydantic import BaseModel @@ -23,3 +23,26 @@ class DomainMetricsTimeWindowResponse(BaseSchema): total_surfaces: int total_citations: int total_full_text: int + + +class HitShareItem(BaseModel): + domain: str + hit_share: float + + +class HitShareResponse(BaseSchema): + data: List[HitShareItem] + total_share: float + + +class TopNArticlesForDomainItem(BaseModel): + article_url: str + article_id: Optional[str] = None + hit_count: int + + +class TopNArticlesForDomainResponse(BaseSchema): + data: List[TopNArticlesForDomainItem] + total_count: int + page: int = 1 + next_page: Optional[int] = None diff --git a/tests/api/test_distribution.py b/tests/api/test_distribution.py index 606086e..41e7684 100644 --- a/tests/api/test_distribution.py +++ b/tests/api/test_distribution.py @@ -5,7 +5,10 @@ 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.dto.distribution import ( + DomainMetricsResponse, DomainMetricsTimeWindowResponse, + HitShareResponse, TopNArticlesForDomainResponse, +) from asknews_sdk.sdk import AskNewsSDK, AsyncAskNewsSDK @@ -119,3 +122,63 @@ async def test_async_get_domain_metrics_timeseries( assert parse_qs(mock_route.calls.last.request.url.query.decode()) == { "domain_names": DOMAIN_NAMES } + + +@pytest.mark.parametrize("is_async", [False, True]) +@pytest.mark.parametrize("custom", [False, True]) +async def test_get_domain_hit_share( + is_async, custom, sync_distribution_api, async_distribution_api, response_mock +): + payload = {"data": [{"domain": "example.com", "hit_share": 0.25}], "total_share": 0.25} + route = response_mock.get("/v1/distribution/stats/hit_share").respond(json=payload) + api = async_distribution_api if is_async else sync_distribution_api + kwargs = {"start_date": START_DATE, "end_date": END_DATE, "is_publisher": False} if custom else {} + result = api.get_domain_hit_share( + DOMAIN_NAMES, **kwargs, http_headers={"custom-header": "custom-value"} + ) + response = await result if is_async else result + assert isinstance(response, HitShareResponse) + assert response == HitShareResponse(**payload) + assert response.data[0].domain == "example.com" + assert response.data[0].hit_share == 0.25 + request = route.calls.last.request + assert request.method == "GET" + assert request.headers["accept"] == "application/json" + assert request.headers["custom-header"] == "custom-value" + expected = {"domain_names": DOMAIN_NAMES, "is_publisher": ["False" if custom else "True"]} + if custom: + expected.update(start_date=[str(START_DATE)], end_date=[str(END_DATE)]) + assert parse_qs(request.url.query.decode()) == expected + + +@pytest.mark.parametrize("is_async", [False, True]) +@pytest.mark.parametrize("metric", [None, "surface", "citation", "grounded"]) +async def test_get_top_n_articles_for_domains( + is_async, metric, sync_distribution_api, async_distribution_api, response_mock +): + payload = { + "data": [{"article_url": "https://example.com/story", "article_id": "article-1", "hit_count": 42}], + "total_count": 80, "page": 2, "next_page": 3, + } + route = response_mock.get("/v1/distribution/articles/top_n_for_domains").respond(json=payload) + api = async_distribution_api if is_async else sync_distribution_api + kwargs = {} if metric is None else { + "limit": 25, "page": 2, "start_date": START_DATE, "end_date": END_DATE, "metric": metric, + } + result = api.get_top_n_articles_for_domains( + DOMAIN_NAMES, **kwargs, http_headers={"custom-header": "custom-value"} + ) + response = await result if is_async else result + assert isinstance(response, TopNArticlesForDomainResponse) + assert response == TopNArticlesForDomainResponse(**payload) + assert response.data[0].article_id == "article-1" + assert response.data[0].hit_count == 42 + request = route.calls.last.request + assert request.method == "GET" + assert request.headers["accept"] == "application/json" + assert request.headers["custom-header"] == "custom-value" + expected = {"domain_names": DOMAIN_NAMES, "limit": ["10"], "page": ["1"], "metric": ["surface"]} + if metric is not None: + expected.update(limit=["25"], page=["2"], metric=[metric], + start_date=[str(START_DATE)], end_date=[str(END_DATE)]) + assert parse_qs(request.url.query.decode()) == expected diff --git a/tests/dto/test_distribution_models.py b/tests/dto/test_distribution_models.py new file mode 100644 index 0000000..e11c4fc --- /dev/null +++ b/tests/dto/test_distribution_models.py @@ -0,0 +1,51 @@ +import pytest +from pydantic import ValidationError + +from asknews_sdk.dto.distribution import ( + HitShareItem, + HitShareResponse, + TopNArticlesForDomainItem, + TopNArticlesForDomainResponse, +) + + +def test_top_articles_optional_fields_and_defaults(): + response = TopNArticlesForDomainResponse.model_validate( + { + "data": [{"article_url": "https://example.com/story", "hit_count": 1}], + "total_count": 1, + } + ) + assert response.page == 1 + assert response.next_page is None + assert response.data[0].article_id is None + assert ( + TopNArticlesForDomainItem(article_url="url", hit_count=0, article_id=None).article_id + is None + ) + assert TopNArticlesForDomainResponse(data=[], total_count=0, next_page=None).next_page is None + + +@pytest.mark.parametrize( + "model,payload", + [ + (HitShareResponse, {"data": [], "total_share": 0.0}), + (TopNArticlesForDomainResponse, {"data": [], "total_count": 0}), + ], +) +def test_empty_responses(model, payload): + assert model.model_validate(payload).data == [] + + +@pytest.mark.parametrize( + "model,payload", + [ + (HitShareItem, {"domain": "example.com"}), + (HitShareResponse, {"data": []}), + (TopNArticlesForDomainItem, {"article_url": "https://example.com/story"}), + (TopNArticlesForDomainResponse, {"data": []}), + ], +) +def test_required_counts_and_shares(model, payload): + with pytest.raises(ValidationError): + model.model_validate(payload)