diff --git a/CHANGELOG.md b/CHANGELOG.md index d8eee37..bc138b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,72 @@ +## 0.13.56 (2026-08-04) + +### Fix + +- Add wikidata entities to Article DTO + +## 0.13.55 (2026-07-21) + +### Fix + +- Add gpt-5.6 and opus-4.8 +- Add claude-sonnet-5 for alert reports + +## 0.13.54 (2026-06-25) + +### Fix + +- Add ability to append date to email subject for alerts + +## 0.13.53 (2026-06-18) + +### Fix + +- Support Python 3.8 + +## 0.13.52 (2026-06-18) + +### Fix + +- Allow reports to define timezone + +## 0.13.51 (2026-05-30) + +### Fix + +- Add skip_dedup from the scrapedurl item submission + +## 0.13.50 (2026-05-24) + +### Fix + +- Add usage to searchresponse + +## 0.13.49 (2026-05-20) + +### Fix + +- Add api_key_id hint to Alert response + +## 0.13.48 (2026-05-20) + +### Fix + +- Add crawl_date and content_type to Article response + +## 0.13.47 (2026-05-18) + +### Fix + +- If newline chars cross multiple chunks + +## 0.13.46 (2026-05-18) + +### Fix + +- repsonse +- Switch to byte streaming to avoid line separators breaking pydantic validation +- Add byok methods + ## 0.13.45 (2026-05-04) ### Fix diff --git a/asknews_sdk/api/chat.py b/asknews_sdk/api/chat.py index 5731ac9..c285817 100644 --- a/asknews_sdk/api/chat.py +++ b/asknews_sdk/api/chat.py @@ -64,8 +64,12 @@ "claude-opus-4-6", "claude-opus-4-5-20251101", "claude-sonnet-4-6", + "claude-sonnet-5", "gemini-2.5-flash", "o3", + "gpt-5.6-sol", + "claude-opus-4-8", + "gpt-5.6-terra", ] @@ -171,7 +175,7 @@ def get_chat_completions( (CreateChatCompletionResponseStream.__content_type__, 1.0), ], stream=stream, - stream_type="lines", + stream_type="bytes", ) if stream: @@ -770,7 +774,7 @@ def get_deep_news( (CreateDeepNewsResponseStreamSource.__content_type__, 1.0), ], stream=stream, - stream_type="lines", + stream_type="bytes", ) if stream: @@ -936,7 +940,7 @@ async def get_chat_completions( (CreateChatCompletionResponseStream.__content_type__, 1.0), ], stream=stream, - stream_type="lines", + stream_type="bytes", ) if stream: @@ -1535,7 +1539,7 @@ async def get_deep_news( (CreateDeepNewsResponseStreamSource.__content_type__, 1.0), ], stream=stream, - stream_type="lines", + stream_type="bytes", ) if stream: diff --git a/asknews_sdk/dto/alert.py b/asknews_sdk/dto/alert.py index ae0dec0..2dd3d88 100644 --- a/asknews_sdk/dto/alert.py +++ b/asknews_sdk/dto/alert.py @@ -25,12 +25,16 @@ "gemini-2.5-pro", "gemini-3-pro", "claude-sonnet-4-6", + "claude-sonnet-5", "claude-opus-4-6", "claude-opus-4-5-20251101", "gemini-2.5-flash", "o3", "open-source-best", "gemini-3-flash", + "gpt-5.6-sol", + "claude-opus-4-8", + "gpt-5.6-terra", ] @@ -61,7 +65,10 @@ "claude-opus-4-5-20251101", "claude-opus-4-6", "claude-sonnet-4-6", - "meta-llama/Meta-Llama-3.1-405B-Instruct", + "claude-sonnet-5", + "gpt-5.6-sol", + "claude-opus-4-8", + "gpt-5.6-terra" "meta-llama/Meta-Llama-3.1-405B-Instruct", "meta-llama/Meta-Llama-3.3-70B-Instruct", ] AlertReportModelDefault: AlertReportModel = "claude-sonnet-4-6" @@ -130,7 +137,7 @@ class DeepNewsSourceParams(DeepNewsParams): description=( f"The model to use for DeepNews research. Defaults to {DeepNewsSourceModelDefault}" ), - examples=["claude-sonnet-4-5-20250929"], + examples=["claude-sonnet-4-6"], ) search_depth: Optional[int] = Field( default=1, @@ -160,7 +167,7 @@ class DeepNewsReportParams(DeepNewsParams): description=( f"The model to use for DeepNews research. Defaults to {DeepNewsReportModelDefault}" ), - examples=["claude-sonnet-4-5-20250929"], + examples=["claude-sonnet-4-6"], ) search_depth: Optional[int] = Field( default=2, @@ -281,6 +288,10 @@ class EmailParams(BaseModel): asknews_watermark: Optional[bool] = Field( default=True, description='Append "Generated by AskNews AI" watermark.' ) + subject_date: Optional[bool] = Field( + default=False, + description="Whether to include the date in the subject of the email. Defaults to False.", + ) class GoogleDocsParams(BaseModel): @@ -338,6 +349,13 @@ class ReportRequestParams(BaseModel): default=True, description='Append "Generated by AskNews AI" watermark.', ) + timezone: Optional[str] = Field( + default="UTC", + description=( + "The timezone to use for any timestamps in the report. Defaults to UTC. " + "Should be a valid tz database name, e.g. 'America/New_York'." + ), + ) class LegacyReportRequest(ReportRequestParams): @@ -675,3 +693,4 @@ class AlertResponse(BaseSchema): alert_type: Optional[AlertType] = None title: Optional[str] = None seat_id: Optional[UUID] = None + api_key_id: Optional[str] = None diff --git a/asknews_sdk/dto/base.py b/asknews_sdk/dto/base.py index d0a4471..d4a590c 100644 --- a/asknews_sdk/dto/base.py +++ b/asknews_sdk/dto/base.py @@ -69,6 +69,15 @@ class Entities(BaseModel): Science: Annotated[Optional[List[str]], Field([], title="Science")] +class WikidataEntity(BaseModel): + title: Annotated[str, Field(title="Title")] + qid: Annotated[str, Field(title="Qid")] + relevance: Annotated[float, Field(title="Relevance")] + description: Annotated[Optional[str], Field(None, title="Description")] + # Original GLiNER/graph surface form before Wikidata disambiguation. + source_mention: Annotated[Optional[str], Field(None, title="Source Mention")] + + class Author(BaseModel): email: Optional[str] = None name: Optional[str] = None @@ -85,6 +94,9 @@ class Article(BaseModel): domain_url: Annotated[str, Field(title="Domain Url")] eng_title: Annotated[str, Field(title="Eng Title")] entities: Annotated[Entities, Field(title="Entities")] + wikidata_entities: Annotated[ + Optional[Dict[str, List[WikidataEntity]]], Field(None, title="Wikidata Entities") + ] = None image_url: Annotated[Optional[str], Field(None, title="Image Url")] keywords: Annotated[List[str], Field(title="Keywords")] language: Annotated[str, Field(title="Language")] @@ -139,6 +151,25 @@ class Article(BaseModel): full_text: Optional[str] = None image_description: Optional[str] = None original_language_summary: Optional[str] = None + crawl_date: Optional[AwareDatetime] = None + content_type: Optional[ + Literal[ + "news", + "opinion", + "analysis", + "review", + "listicle", + "guide", + "interview", + "profile", + "forum", + "liveblog", + "fact-check", + "press_release", + "obituary", + "data_journalism", + ] + ] = None class PingResponse(BaseSchema): diff --git a/asknews_sdk/dto/news.py b/asknews_sdk/dto/news.py index 1e8baa1..ca7f4a5 100644 --- a/asknews_sdk/dto/news.py +++ b/asknews_sdk/dto/news.py @@ -14,11 +14,16 @@ class SearchResponseDictItem(Article): as_string_key: Annotated[str, Field(title="As String Key")] +class Usage(BaseModel): + credits: int + + class SearchResponse(BaseSchema): as_dicts: Annotated[Optional[List[SearchResponseDictItem]], Field(None, title="As Dicts")] as_string: Annotated[Optional[str], Field(None, title="As String")] offset: Annotated[Optional[Union[int, str]], Field(None, title="Offset")] hit_cache: Annotated[Optional[bool], Field(None, title="Hit Cache")] + usage: Optional[Usage] = None class SourceReportItem(BaseModel): @@ -81,6 +86,7 @@ class ScrapeDataItem(BaseModel): class ScrapedURLItem(BaseModel): url: Annotated[str, Field(title="URL")] data: Annotated[Optional[ScrapeDataItem], Field(title="Data")] = None + skip_dedupe: Annotated[bool, Field(title="Skip Dedupe check")] = False metadata: Annotated[Optional[Dict], Field(title="Metadata")] = None enrichments: Annotated[Optional[Dict], Field(title="Enrichments")] = None diff --git a/asknews_sdk/response.py b/asknews_sdk/response.py index f981700..b5c74db 100644 --- a/asknews_sdk/response.py +++ b/asknews_sdk/response.py @@ -1,5 +1,6 @@ from __future__ import annotations +import codecs from typing import Any, AsyncIterator, Dict, Generic, Iterator, TypeVar from httpx import Request, Response @@ -33,19 +34,13 @@ def __init__( self.headers = headers self.body: TResponseBody = body self.stream = stream - self.content_type, *_ = parse_content_type( - headers.get("content-type", "application/json") - ) + self.content_type, *_ = parse_content_type(headers.get("content-type", "application/json")) self.content: Any = self._deserialize_body() if not self.stream else self.body def _deserialize_body(self) -> Any: if self.content_type == "application/octet-stream": return self.body - elif ( - self.content_type == "application/json" - and self.body - and isinstance(self.body, bytes) - ): + elif self.content_type == "application/json" and self.body and isinstance(self.body, bytes): return deserialize(self.body) elif self.content_type == "text/plain" and isinstance(self.body, bytes): return self.body.decode("utf-8") @@ -57,6 +52,7 @@ class APIResponse(BaseAPIResponse[ResponseBody]): """ API Response object returned by the APIClient. """ + @classmethod def from_httpx_response( cls, @@ -103,6 +99,7 @@ class AsyncAPIResponse(BaseAPIResponse[AsyncResponseBody]): """ Async API Response object returned by the AsyncAPIClient. """ + @classmethod async def from_httpx_response( cls, @@ -146,14 +143,22 @@ async def from_httpx_response( class BaseEventSource(Generic[TResponseBodyStream]): - def __init__( - self, - iterator: TResponseBodyStream, - encoding: str = "utf-8" - ) -> None: + def __init__(self, iterator: TResponseBodyStream, encoding: str = "utf-8") -> None: self.iterator: TResponseBodyStream = iterator self.encoding = encoding self.current_event = ServerSentEvent() + self._decoder = codecs.getincrementaldecoder(encoding)(errors="replace") + + def _process_line(self, line: str) -> "ServerSentEvent | None": + line = line.rstrip("\r") + if line: + self.parse_line(line) + return None + elif self.current_event.data: + event = self.current_event + self.current_event = ServerSentEvent() + return event + return None def parse_line(self, line: str) -> None: if line.startswith(":"): @@ -183,20 +188,22 @@ class EventSource(BaseEventSource[ResponseBodyStream]): """ EventSource object for streaming Server-Sent Events. """ + def __iter__(self) -> Iterator[ServerSentEvent]: assert is_iterator(self.iterator), "Iterator must be an synchronous iterator" - for line in self.iterator: - if isinstance(line, bytes): - decoded_line = line.decode(self.encoding) - else: - decoded_line = str(line) - - if decoded_line := decoded_line.strip(): - self.parse_line(decoded_line) - elif self.current_event.data: - yield self.current_event - self.current_event = ServerSentEvent() + buffer = "" + for chunk in self.iterator: + text = self._decoder.decode(chunk) if isinstance(chunk, bytes) else str(chunk) + buffer += text + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + if event := self._process_line(line): + yield event + buffer += self._decoder.decode(b"", final=True) + if buffer: + if event := self._process_line(buffer): + yield event @classmethod def from_api_response(cls, response: APIResponse) -> EventSource: @@ -208,11 +215,9 @@ def from_api_response(cls, response: APIResponse) -> EventSource: :return: EventSource object :rtype: EventSource """ - assert response.content_type == "text/event-stream", \ - ( - "Response content type must be text/event-stream, " - f"got: {response.content_type}" - ) + assert response.content_type == "text/event-stream", ( + "Response content type must be text/event-stream, " f"got: {response.content_type}" + ) return cls(response.content) @@ -220,20 +225,22 @@ class AsyncEventSource(BaseEventSource[AsyncResponseBodyStream]): """ AsyncEventSource object for streaming Server-Sent Events. """ + async def __aiter__(self) -> AsyncIterator[ServerSentEvent]: assert is_async_iterator(self.iterator), "Iterator must be an asynchronous iterator" - async for line in self.iterator: - if isinstance(line, bytes): - decoded_line = line.decode(self.encoding) - else: - decoded_line = str(line) - - if decoded_line := decoded_line.strip(): - self.parse_line(decoded_line) - elif self.current_event.data: - yield self.current_event - self.current_event = ServerSentEvent() + buffer = "" + async for chunk in self.iterator: + text = self._decoder.decode(chunk) if isinstance(chunk, bytes) else str(chunk) + buffer += text + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + if event := self._process_line(line): + yield event + buffer += self._decoder.decode(b"", final=True) + if buffer: + if event := self._process_line(buffer): + yield event @classmethod def from_api_response(cls, response: AsyncAPIResponse) -> AsyncEventSource: @@ -245,13 +252,12 @@ def from_api_response(cls, response: AsyncAPIResponse) -> AsyncEventSource: :return: AsyncEventSource object :rtype: AsyncEventSource """ - assert response.content_type == "text/event-stream", \ - ( - "Response content type must be text/event-stream, " - f"got: {response.content_type}" - ) + assert response.content_type == "text/event-stream", ( + "Response content type must be text/event-stream, " f"got: {response.content_type}" + ) return cls(response.content) + # class EventSource(Generic[TResponseBodyStream]): # """ # EventSource object for streaming Server-Sent Events. diff --git a/pyproject.toml b/pyproject.toml index 1e4cdab..bf7d852 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "asknews" -version = "0.13.45" +version = "0.13.56" description = "Python SDK for AskNews" readme = "README.md" requires-python = ">=3.8" diff --git a/tests/api/test_byok.py b/tests/api/test_byok.py new file mode 100644 index 0000000..4ed10b1 --- /dev/null +++ b/tests/api/test_byok.py @@ -0,0 +1,126 @@ +import pytest +from polyfactory.factories.pydantic_factory import ModelFactory +from respx import MockRouter + +from asknews_sdk.api.byok import AsyncByokAPI, ByokAPI +from asknews_sdk.client import APIClient, AsyncAPIClient +from asknews_sdk.dto.byok import ApiKeyResponse +from asknews_sdk.utils import build_accept_header + + +class MockApiKeyResponse(ModelFactory[ApiKeyResponse]): + ... + + +@pytest.fixture +def sync_byok_api(sync_api_client: APIClient): + return ByokAPI(sync_api_client) + + +@pytest.fixture +def async_byok_api(async_api_client: AsyncAPIClient): + return AsyncByokAPI(async_api_client) + + +def test_sync_set_byok_key(sync_byok_api: ByokAPI, response_mock: MockRouter): + provider = "anthropic" + mock_response = MockApiKeyResponse.build() + + mocked_route = response_mock.put(f"/v1/byok/{provider}").respond( + content=mock_response.model_dump_json() + ) + + response = sync_byok_api.set_byok_key(provider=provider, api_key="sk-ant-test12345") + + assert isinstance(response, ApiKeyResponse) + assert response.model_dump() == mock_response.model_dump() + assert mocked_route.called + assert mocked_route.calls.last.request.url.path == f"/v1/byok/{provider}" + assert mocked_route.calls.last.request.method == "PUT" + assert mocked_route.calls.last.request.headers["accept"] == build_accept_header( + [(ApiKeyResponse.__content_type__, 1.0)] + ) + assert mocked_route.calls.last.response.status_code == 200 + + +def test_sync_get_byok_key(sync_byok_api: ByokAPI, response_mock: MockRouter): + provider = "anthropic" + mock_response = MockApiKeyResponse.build() + + mocked_route = response_mock.get(f"/v1/byok/{provider}").respond( + content=mock_response.model_dump_json() + ) + + response = sync_byok_api.get_byok_key(provider=provider) + + assert isinstance(response, ApiKeyResponse) + assert response.model_dump() == mock_response.model_dump() + assert mocked_route.called + assert mocked_route.calls.last.request.url.path == f"/v1/byok/{provider}" + assert mocked_route.calls.last.request.method == "GET" + assert mocked_route.calls.last.response.status_code == 200 + + +def test_sync_delete_byok_key(sync_byok_api: ByokAPI, response_mock: MockRouter): + provider = "anthropic" + + mocked_route = response_mock.delete(f"/v1/byok/{provider}").respond(status_code=204) + + sync_byok_api.delete_byok_key(provider=provider) + + assert mocked_route.called + assert mocked_route.calls.last.request.url.path == f"/v1/byok/{provider}" + assert mocked_route.calls.last.request.method == "DELETE" + assert mocked_route.calls.last.response.status_code == 204 + + +@pytest.mark.asyncio +async def test_async_set_byok_key(async_byok_api: AsyncByokAPI, response_mock: MockRouter): + provider = "google" + mock_response = MockApiKeyResponse.build() + + mocked_route = response_mock.put(f"/v1/byok/{provider}").respond( + content=mock_response.model_dump_json() + ) + + response = await async_byok_api.set_byok_key(provider=provider, api_key="AIzaSy-test12345") + + assert isinstance(response, ApiKeyResponse) + assert response.model_dump() == mock_response.model_dump() + assert mocked_route.called + assert mocked_route.calls.last.request.url.path == f"/v1/byok/{provider}" + assert mocked_route.calls.last.request.method == "PUT" + assert mocked_route.calls.last.response.status_code == 200 + + +@pytest.mark.asyncio +async def test_async_get_byok_key(async_byok_api: AsyncByokAPI, response_mock: MockRouter): + provider = "google" + mock_response = MockApiKeyResponse.build() + + mocked_route = response_mock.get(f"/v1/byok/{provider}").respond( + content=mock_response.model_dump_json() + ) + + response = await async_byok_api.get_byok_key(provider=provider) + + assert isinstance(response, ApiKeyResponse) + assert response.model_dump() == mock_response.model_dump() + assert mocked_route.called + assert mocked_route.calls.last.request.url.path == f"/v1/byok/{provider}" + assert mocked_route.calls.last.request.method == "GET" + assert mocked_route.calls.last.response.status_code == 200 + + +@pytest.mark.asyncio +async def test_async_delete_byok_key(async_byok_api: AsyncByokAPI, response_mock: MockRouter): + provider = "google" + + mocked_route = response_mock.delete(f"/v1/byok/{provider}").respond(status_code=204) + + await async_byok_api.delete_byok_key(provider=provider) + + assert mocked_route.called + assert mocked_route.calls.last.request.url.path == f"/v1/byok/{provider}" + assert mocked_route.calls.last.request.method == "DELETE" + assert mocked_route.calls.last.response.status_code == 204 diff --git a/tests/api/test_news.py b/tests/api/test_news.py index 46aa27b..be152ba 100644 --- a/tests/api/test_news.py +++ b/tests/api/test_news.py @@ -9,6 +9,7 @@ from asknews_sdk.dto.news import ArticleResponse, SearchResponse, SourceReportResponse from asknews_sdk.errors import ResourceNotFoundError from asknews_sdk.response import APIResponse, AsyncAPIResponse +from tests.test_wikidata_entities import build_article_payload class MockArticleResponse(ModelFactory[ArticleResponse]): @@ -171,6 +172,68 @@ async def test_async_news_api_search_news(async_news_api: AsyncNewsAPI, response assert mock_route.calls.last.response.status_code == 200 +@pytest.mark.parametrize( + "wikidata_entities", + [ + None, + {}, + { + "Person": [ + { + "title": "Ada Lovelace", + "qid": "Q7259", + "relevance": 0.97, + "description": "English mathematician and writer", + "source_mention": "Lovelace", + } + ], + "Organization": [{"title": "Analytical Engine", "qid": "Q332676", "relevance": 0.6}], + }, + ], +) +async def test_async_news_api_search_news_wikidata_entities( + async_news_api: AsyncNewsAPI, response_mock: MockRouter, wikidata_entities +): + """`wikidata_entities` survives parsing of a structured search_news response.""" + mock_search_response = MockSearchResponse.build(as_string=None) + payload = mock_search_response.model_dump(mode="json") + payload["as_dicts"] = [build_article_payload(wikidata_entities=wikidata_entities)] + + mock_route = response_mock.get("/v1/news/search").respond(json=payload) + + response = await async_news_api.search_news("query", return_type="dicts") + + assert isinstance(response, SearchResponse) + dumped_article = response.model_dump(mode="json", exclude_none=True)["as_dicts"][0] + + if wikidata_entities is None: + assert response.as_dicts[0].wikidata_entities is None + assert "wikidata_entities" not in dumped_article + else: + assert dumped_article["wikidata_entities"] == wikidata_entities + + assert mock_route.called + + +def test_sync_news_api_search_news_omitted_wikidata_entities( + sync_news_api: NewsAPI, response_mock: MockRouter +): + """An API response predating the property still parses, yielding None.""" + mock_search_response = MockSearchResponse.build(as_string=None) + payload = mock_search_response.model_dump(mode="json") + article_payload = build_article_payload() + article_payload.pop("wikidata_entities", None) + payload["as_dicts"] = [article_payload] + + mock_route = response_mock.get("/v1/news/search").respond(json=payload) + + response = sync_news_api.search_news("query", return_type="dicts") + + assert isinstance(response, SearchResponse) + assert response.as_dicts[0].wikidata_entities is None + assert mock_route.called + + def test_sync_news_api_source_report(sync_news_api: NewsAPI, response_mock: MockRouter): mock_source_report_response = MockSourceReportResponse.build() diff --git a/tests/test_response.py b/tests/test_response.py index 798fc37..2c38e14 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -105,7 +105,7 @@ def __iter__(self): headers={"content-type": "text/event-stream"}, stream=SSEResponseStream(), ) - api_response = APIResponse.from_httpx_response(response, stream=True, stream_type="lines") + api_response = APIResponse.from_httpx_response(response, stream=True, stream_type="bytes") event_source = EventSource.from_api_response(api_response) events = list(event_source) @@ -126,7 +126,7 @@ def __iter__(self): headers={"content-type": "application/json"}, stream=SSEResponseStream(), ) - api_response = APIResponse.from_httpx_response(response, stream=True, stream_type="lines") + api_response = APIResponse.from_httpx_response(response, stream=True, stream_type="bytes") with pytest.raises(AssertionError): EventSource.from_api_response(api_response) @@ -184,7 +184,7 @@ async def __aiter__(self): stream=SSEResponseStream(), ) api_response = await AsyncAPIResponse.from_httpx_response( - response, stream=True, stream_type="lines" + response, stream=True, stream_type="bytes" ) event_source = AsyncEventSource.from_api_response(api_response) diff --git a/tests/test_wikidata_entities.py b/tests/test_wikidata_entities.py new file mode 100644 index 0000000..28945ae --- /dev/null +++ b/tests/test_wikidata_entities.py @@ -0,0 +1,168 @@ +"""Tests for the optional `wikidata_entities` article property on structured search results.""" +import copy +from uuid import uuid4 + +import pytest + +from asknews_sdk.dto.base import WikidataEntity +from asknews_sdk.dto.news import SearchResponse, SearchResponseDictItem + + +def build_article_payload(**overrides): + """A minimal, valid structured search_news article payload.""" + payload = { + "as_string_key": "0", + "article_url": "https://example.com/article", + "article_id": str(uuid4()), + "classification": ["Politics"], + "country": "US", + "source_id": "example", + "page_rank": 3, + "domain_url": "example.com", + "eng_title": "Example title", + "entities": {"Person": ["Ada Lovelace"], "Organization": []}, + "keywords": ["example"], + "language": "en", + "pub_date": "2026-08-04T12:00:00+00:00", + "summary": "Example summary.", + "title": "Example title", + "sentiment": 0, + } + payload.update(overrides) + return payload + + +def build_search_payload(**overrides): + return {"as_dicts": [build_article_payload(**overrides)], "as_string": None} + + +POPULATED_WIKIDATA_ENTITIES = { + "Person": [ + { + "title": "Ada Lovelace", + "qid": "Q7259", + "relevance": 0.97, + "description": "English mathematician and writer", + "source_mention": "Lovelace", + }, + { + "title": "Charles Babbage", + "qid": "Q46633", + "relevance": 0.81, + "description": "English mathematician and inventor", + "source_mention": "Babbage", + }, + ], + "Organization": [ + { + "title": "Analytical Engine", + "qid": "Q332676", + "relevance": 0.65, + "description": "mechanical general-purpose computer", + "source_mention": "the Engine", + } + ], +} + + +def test_missing_wikidata_entities_parses_to_none(): + response = SearchResponse.model_validate(build_search_payload()) + + article = response.as_dicts[0] + assert isinstance(article, SearchResponseDictItem) + assert article.wikidata_entities is None + + +def test_explicit_null_wikidata_entities_parses_to_none(): + response = SearchResponse.model_validate(build_search_payload(wikidata_entities=None)) + + assert response.as_dicts[0].wikidata_entities is None + + +def test_empty_mapping_is_preserved_and_not_coerced_to_none(): + response = SearchResponse.model_validate(build_search_payload(wikidata_entities={})) + + article = response.as_dicts[0] + assert article.wikidata_entities == {} + assert article.wikidata_entities is not None + + +def test_populated_mapping_with_multiple_groups_and_entities(): + response = SearchResponse.model_validate( + build_search_payload(wikidata_entities=copy.deepcopy(POPULATED_WIKIDATA_ENTITIES)) + ) + + wikidata_entities = response.as_dicts[0].wikidata_entities + assert set(wikidata_entities) == {"Person", "Organization"} + assert len(wikidata_entities["Person"]) == 2 + assert len(wikidata_entities["Organization"]) == 1 + + ada = wikidata_entities["Person"][0] + assert isinstance(ada, WikidataEntity) + assert ada.title == "Ada Lovelace" + assert ada.qid == "Q7259" + assert ada.relevance == pytest.approx(0.97) + assert ada.description == "English mathematician and writer" + assert ada.source_mention == "Lovelace" + + +def test_optional_description_and_source_mention_may_be_omitted(): + response = SearchResponse.model_validate( + build_search_payload( + wikidata_entities={"Person": [{"title": "Ada Lovelace", "qid": "Q7259", "relevance": 1.0}]} + ) + ) + + entity = response.as_dicts[0].wikidata_entities["Person"][0] + assert entity.description is None + assert entity.source_mention is None + + +def test_empty_entity_group_list_is_preserved(): + response = SearchResponse.model_validate(build_search_payload(wikidata_entities={"Person": []})) + + assert response.as_dicts[0].wikidata_entities == {"Person": []} + + +def test_round_trip_serialization_preserves_populated_mapping(): + payload = build_search_payload(wikidata_entities=copy.deepcopy(POPULATED_WIKIDATA_ENTITIES)) + + response = SearchResponse.model_validate(payload) + dumped = response.model_dump(mode="json") + reparsed = SearchResponse.model_validate_json(response.model_dump_json()) + + assert dumped["as_dicts"][0]["wikidata_entities"] == POPULATED_WIKIDATA_ENTITIES + assert reparsed.as_dicts[0].wikidata_entities == response.as_dicts[0].wikidata_entities + + +@pytest.mark.parametrize("value", [None, {}, POPULATED_WIKIDATA_ENTITIES]) +def test_round_trip_serialization_is_lossless(value): + payload = build_search_payload(wikidata_entities=copy.deepcopy(value)) + + response = SearchResponse.model_validate(payload) + reparsed = SearchResponse.model_validate(response.model_dump(mode="json")) + + assert reparsed.as_dicts[0].wikidata_entities == response.as_dicts[0].wikidata_entities + assert reparsed.model_dump(mode="json")["as_dicts"][0]["wikidata_entities"] == value + + +def test_absent_field_is_not_synthesized_on_dump(): + """A response without the property must not gain fabricated entity groups.""" + response = SearchResponse.model_validate(build_search_payload()) + + dumped = response.model_dump(mode="json") + assert dumped["as_dicts"][0]["wikidata_entities"] is None + assert ( + "wikidata_entities" not in response.model_dump(mode="json", exclude_none=True)["as_dicts"][0] + ) + + +def test_other_article_fields_are_unaffected(): + response = SearchResponse.model_validate( + build_search_payload(wikidata_entities=copy.deepcopy(POPULATED_WIKIDATA_ENTITIES)) + ) + + article = response.as_dicts[0] + assert article.entities.Person == ["Ada Lovelace"] + assert article.title == "Example title" + assert article.as_string_key == "0" diff --git a/uv.lock b/uv.lock index d1cd3e6..f528de7 100644 --- a/uv.lock +++ b/uv.lock @@ -64,7 +64,7 @@ wheels = [ [[package]] name = "asknews" -version = "0.13.45" +version = "0.13.56" source = { editable = "." } dependencies = [ { name = "asgiref" },