diff --git a/src/linkup/__init__.py b/src/linkup/__init__.py index b47680f..c572c31 100644 --- a/src/linkup/__init__.py +++ b/src/linkup/__init__.py @@ -38,6 +38,7 @@ LinkupTaskMetadata, LinkupTaskQuota, LinkupTasksPage, + LinkupUnsupportedTask, ) from ._version import __version__ @@ -78,6 +79,7 @@ TimeoutError = LinkupTimeoutError # noqa: A001 TooManyRequestsError = LinkupTooManyRequestsError UnknownError = LinkupUnknownError +UnsupportedTask = LinkupUnsupportedTask __all__ = [ "AuthenticationError", @@ -130,6 +132,7 @@ "LinkupTimeoutError", "LinkupTooManyRequestsError", "LinkupUnknownError", + "LinkupUnsupportedTask", "NoResultError", "PaymentRequiredError", "ResearchTask", @@ -153,5 +156,6 @@ "TimeoutError", "TooManyRequestsError", "UnknownError", + "UnsupportedTask", "__version__", ] diff --git a/src/linkup/_client.py b/src/linkup/_client.py index f42e55c..b21444e 100644 --- a/src/linkup/_client.py +++ b/src/linkup/_client.py @@ -42,6 +42,7 @@ LinkupTask, LinkupTaskInput, LinkupTasksPage, + LinkupUnsupportedTask, ) from ._version import __version__ @@ -1692,7 +1693,9 @@ def _parse_task(self, task_data: dict[str, Any]) -> LinkupTask: if task_type == "research": return self._parse_research_task(task_data) - raise ValueError(f"Unexpected task type value: '{task_type}'") + return LinkupUnsupportedTask.model_validate( + {**task_data, "rawType": task_type, "type": "unsupported"} + ) def _parse_research_tasks_page(self, response_data: dict[str, Any]) -> LinkupResearchTasksPage: return LinkupResearchTasksPage.model_validate( diff --git a/src/linkup/_types.py b/src/linkup/_types.py index 1736d47..38a7ba9 100644 --- a/src/linkup/_types.py +++ b/src/linkup/_types.py @@ -343,7 +343,33 @@ class LinkupResearchTask(_LinkupBaseModel): updated_at: str = pydantic.Field(validation_alias="updatedAt") -LinkupTask = LinkupSearchTask | LinkupFetchTask | LinkupResearchTask +class LinkupUnsupportedTask(_LinkupBaseModel): + """A task returned by the API that this SDK does not model explicitly. + + Attributes: + created_at: The task creation timestamp. + error: The task error message, if the task failed. + id: The task identifier. + input: The raw task input payload returned by the API. + output: The raw task output payload, if available. + raw_type: The original task type returned by the API. + status: The current task status. + type: The normalized task type, always "unsupported". + updated_at: The last task update timestamp. + """ + + created_at: str = pydantic.Field(validation_alias="createdAt") + error: str | None = None + id: str + input: JSONObject + output: Any | None = None + raw_type: str = pydantic.Field(validation_alias="rawType") + status: Literal["pending", "processing", "completed", "failed"] + type: Literal["unsupported"] + updated_at: str = pydantic.Field(validation_alias="updatedAt") + + +LinkupTask = LinkupSearchTask | LinkupFetchTask | LinkupResearchTask | LinkupUnsupportedTask class LinkupResearchTasksPage(_LinkupBaseModel): diff --git a/tests/unit/client_test.py b/tests/unit/client_test.py index 50ec541..7b1d73a 100644 --- a/tests/unit/client_test.py +++ b/tests/unit/client_test.py @@ -1492,6 +1492,62 @@ def test_list_tasks(mocker: MockerFixture, client: linkup.Client) -> None: assert tasks_page.quota.in_flight == 1 +def test_list_tasks_preserves_unsupported_task_variants( + mocker: MockerFixture, client: linkup.Client +) -> None: + mocker.patch( + "httpx.Client.request", + return_value=Response( + status_code=200, + content=b""" + { + "data": [ + { + "createdAt": "2026-05-18T00:00:00.000Z", + "error": null, + "id": "extract-task-1", + "input": { + "q": "companies founded in paris", + "schema": { + "type": "object" + } + }, + "output": { + "rows": [ + { + "company": "Linkup" + } + ], + "rowsReturned": 1 + }, + "status": "completed", + "type": "extract", + "updatedAt": "2026-05-18T00:00:00.000Z" + } + ], + "metadata": { + "page": 1, + "pageSize": 10, + "total": 1, + "totalPages": 1 + }, + "quota": { + "inFlight": 1, + "limit": 100 + } + } + """, + ), + ) + + tasks_page = client.list_tasks() + + assert isinstance(tasks_page.data[0], linkup.UnsupportedTask) + assert tasks_page.data[0].type == "unsupported" + assert tasks_page.data[0].raw_type == "extract" + assert tasks_page.data[0].output == {"rows": [{"company": "Linkup"}], "rowsReturned": 1} + + def test_get_task_structured_search_output_keeps_search_results_shape_raw( mocker: MockerFixture, client: linkup.Client ) -> None: @@ -1567,6 +1623,38 @@ def test_get_task_structured_search_output_raw( assert task.output == {"summary": "done"} +def test_get_task_preserves_unsupported_task_variant( + mocker: MockerFixture, client: linkup.Client +) -> None: + mocker.patch( + "httpx.Client.request", + return_value=Response( + status_code=200, + content=b""" + { + "createdAt": "2026-05-18T00:00:00.000Z", + "error": null, + "id": "extract-task-2", + "input": { + "q": "companies founded in paris" + }, + "output": null, + "status": "processing", + "type": "extract", + "updatedAt": "2026-05-18T00:00:00.000Z" + } + """, + ), + ) + + task = client.get_task("extract-task-2") + + assert isinstance(task, linkup.UnsupportedTask) + assert task.type == "unsupported" + assert task.raw_type == "extract" + assert task.input == {"q": "companies founded in paris"} + + def test_list_tasks_with_multiple_filters(mocker: MockerFixture, client: linkup.Client) -> None: request_mock = mocker.patch( "httpx.Client.request",