Skip to content

Commit 4287bce

Browse files
committed
feat: add get_task_documents to retrieve a task's documents
Meilisearch v1.13 added `GET /tasks/{uid}/documents` to fetch the documents associated with a task. Add `Client.get_task_documents` (and the underlying `TaskHandler` method), backed by a streaming GET (`HttpRequests.get_stream`) and a parser that normalizes the JSON array / NDJSON / concatenated-JSON payload the endpoint can return. Adds unit tests for the parser, a request-shape test for the method, and a `get_task_documents_1` code sample. Closes #1221
1 parent f51bb17 commit 4287bce

7 files changed

Lines changed: 138 additions & 3 deletions

File tree

.code-samples.meilisearch.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ get_all_tasks_1: |-
8383
client.get_tasks()
8484
get_task_1: |-
8585
client.get_task(1)
86+
get_task_documents_1: |-
87+
client.get_task_documents(1)
8688
delete_tasks_1: |-
8789
client.delete_tasks({'uids': ['1', '2']})
8890
cancel_tasks_1: |-

meilisearch/_httprequests.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,32 @@ def post_stream(
220220

221221
raise MeilisearchCommunicationError(str(err)) from err
222222

223+
def get_stream(self, path: str) -> requests.Response:
224+
"""Send a GET request with streaming enabled.
225+
226+
Returns the raw response object for streaming consumption.
227+
"""
228+
try:
229+
request_path = self.config.url + "/" + path
230+
response = requests.get(
231+
request_path,
232+
timeout=self.config.timeout,
233+
headers=self.headers,
234+
stream=True,
235+
)
236+
237+
if not response.ok:
238+
response.raise_for_status()
239+
240+
return response
241+
242+
except requests.exceptions.Timeout as err:
243+
raise MeilisearchTimeoutError(str(err)) from err
244+
except requests.exceptions.ConnectionError as err:
245+
raise MeilisearchCommunicationError(str(err)) from err
246+
except requests.exceptions.HTTPError as err:
247+
raise MeilisearchApiError(str(err), response) from err
248+
223249
@staticmethod
224250
def __to_json(request: requests.Response) -> Any:
225251
if request.content == b"":

meilisearch/_utils.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1+
import json
2+
import re
13
from datetime import datetime
24
from functools import lru_cache
3-
from typing import Union
5+
from typing import Any, Dict, List, Union
46

57
import pydantic
68

9+
_CONCATENATED_JSON = re.compile(r"(?<=\})\s*(?=\{)")
10+
711

812
@lru_cache(maxsize=1)
913
def is_pydantic_2() -> bool:
@@ -41,3 +45,28 @@ def iso_to_date_time(iso_date: Union[datetime, str, None]) -> Union[datetime, No
4145
reduce = len(split[1]) - 6
4246
reduced = f"{split[0]}.{split[1][:-reduce]}Z"
4347
return datetime.strptime(reduced, "%Y-%m-%dT%H:%M:%S.%fZ")
48+
49+
50+
def parse_task_documents(raw_documents: str) -> List[Dict[str, Any]]:
51+
"""Parse the payload returned by ``GET /tasks/{uid}/documents``.
52+
53+
The endpoint may return a JSON array, a single JSON object, NDJSON, or
54+
several JSON objects concatenated without a separator. This normalizes all
55+
of those formats into a list of documents.
56+
"""
57+
payload = raw_documents.strip()
58+
if not payload:
59+
return []
60+
61+
try:
62+
parsed = json.loads(payload)
63+
except json.JSONDecodeError:
64+
documents: List[Dict[str, Any]] = []
65+
for line in payload.splitlines():
66+
for chunk in _CONCATENATED_JSON.split(line):
67+
stripped = chunk.strip()
68+
if stripped:
69+
documents.append(json.loads(stripped))
70+
return documents
71+
72+
return parsed if isinstance(parsed, list) else [parsed]

meilisearch/client.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -783,6 +783,29 @@ def get_task(self, uid: int) -> Task:
783783
"""
784784
return self.task_handler.get_task(uid)
785785

786+
def get_task_documents(self, uid: int) -> List[Dict[str, Any]]:
787+
"""Get the documents added or updated by a task.
788+
789+
This is an experimental feature; the ``getTaskDocumentsRoute`` experimental
790+
feature must be enabled on the Meilisearch instance.
791+
792+
Parameters
793+
----------
794+
uid:
795+
Identifier of the task.
796+
797+
Returns
798+
-------
799+
documents:
800+
List of the documents associated with the task.
801+
802+
Raises
803+
------
804+
MeilisearchApiError
805+
An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
806+
"""
807+
return self.task_handler.get_task_documents(uid)
808+
786809
def cancel_tasks(
787810
self, parameters: MutableMapping[str, Any], *, metadata: Optional[str] = None
788811
) -> TaskInfo:

meilisearch/task.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22

33
from datetime import datetime
44
from time import sleep
5-
from typing import Any, Mapping, MutableMapping, Optional
5+
from typing import Any, Dict, List, Mapping, MutableMapping, Optional
66
from urllib import parse
77

88
from meilisearch._httprequests import HttpRequests
9+
from meilisearch._utils import parse_task_documents
910
from meilisearch.config import Config
1011
from meilisearch.errors import MeilisearchTimeoutError
1112
from meilisearch.models.task import Batch, BatchResults, Task, TaskInfo, TaskResults
@@ -122,6 +123,30 @@ def get_task(self, uid: int) -> Task:
122123
task = self.http.get(f"{self.config.paths.task}/{uid}")
123124
return Task(**task)
124125

126+
def get_task_documents(self, uid: int) -> List[Dict[str, Any]]:
127+
"""Get the documents added or updated by a task.
128+
129+
This is an experimental feature; the ``getTaskDocumentsRoute`` experimental
130+
feature must be enabled on the Meilisearch instance.
131+
132+
Parameters
133+
----------
134+
uid:
135+
Identifier of the task.
136+
137+
Returns
138+
-------
139+
documents:
140+
List of the documents associated with the task.
141+
142+
Raises
143+
------
144+
MeilisearchApiError
145+
An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
146+
"""
147+
response = self.http.get_stream(f"{self.config.paths.task}/{uid}/documents")
148+
return parse_task_documents(response.text)
149+
125150
def cancel_tasks(
126151
self, parameters: MutableMapping[str, Any], *, metadata: Optional[str] = None
127152
) -> TaskInfo:

tests/client/test_client_task_meilisearch.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# pylint: disable=invalid-name
22

3+
from unittest.mock import MagicMock, patch
4+
35
import pytest
46

57
from meilisearch.models.task import TaskInfo
@@ -189,3 +191,16 @@ def test_get_batch(client):
189191
uid = batches.results[0].uid
190192
batch = client.get_batch(uid)
191193
assert batch.uid == uid
194+
195+
196+
def test_get_task_documents_calls_endpoint_and_parses(client):
197+
"""get_task_documents hits /tasks/{uid}/documents and parses the payload."""
198+
fake_response = MagicMock()
199+
fake_response.text = '{"id": 1}\n{"id": 2}'
200+
with patch.object(
201+
client.task_handler.http, "get_stream", return_value=fake_response
202+
) as mock_get:
203+
documents = client.get_task_documents(42)
204+
205+
mock_get.assert_called_once_with("tasks/42/documents")
206+
assert documents == [{"id": 1}, {"id": 2}]

tests/test_utils.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import pytest
44

5-
from meilisearch._utils import is_pydantic_2, iso_to_date_time
5+
from meilisearch._utils import is_pydantic_2, iso_to_date_time, parse_task_documents
66

77

88
def test_is_pydantic_2():
@@ -33,6 +33,21 @@ def test_iso_to_date_time_invalid_format():
3333
iso_to_date_time("2023-07-13T23:37:20Z")
3434

3535

36+
@pytest.mark.parametrize(
37+
"raw, expected",
38+
[
39+
('[{"id": 1}, {"id": 2}]', [{"id": 1}, {"id": 2}]), # JSON array
40+
('{"id": 1}', [{"id": 1}]), # single JSON object
41+
('{"id": 1}\n{"id": 2}', [{"id": 1}, {"id": 2}]), # NDJSON
42+
('{"id": 1}{"id": 2}', [{"id": 1}, {"id": 2}]), # concatenated, no separator
43+
("", []), # empty
44+
(" \n ", []), # whitespace only
45+
],
46+
)
47+
def test_parse_task_documents(raw, expected):
48+
assert parse_task_documents(raw) == expected
49+
50+
3651
# Refactor to use the unified API to toggle experimental features
3752
def reset_network_config(client):
3853
client.add_or_update_networks(body={"remotes": {}, "leader": None})

0 commit comments

Comments
 (0)