Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 117 additions & 2 deletions asknews_sdk/api/distribution.py
Original file line number Diff line number Diff line change
@@ -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]):
Expand Down Expand Up @@ -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)."""
Expand Down Expand Up @@ -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)
25 changes: 24 additions & 1 deletion asknews_sdk/dto/distribution.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import List
from typing import List, Optional

from pydantic import BaseModel

Expand All @@ -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
65 changes: 64 additions & 1 deletion tests/api/test_distribution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
51 changes: 51 additions & 0 deletions tests/dto/test_distribution_models.py
Original file line number Diff line number Diff line change
@@ -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)
Loading