Skip to content

Commit 4d23fb4

Browse files
authored
feat: add pagination for custom components (#178)
1 parent 38a1ebf commit 4d23fb4

6 files changed

Lines changed: 129 additions & 53 deletions

File tree

src/deepset_mcp/api/custom_components/models.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,3 @@ class CustomComponentInstallation(BaseModel):
1919
logs: list[dict[str, Any]]
2020
organization_id: str
2121
user_info: DeepsetUser | None = None
22-
23-
24-
class CustomComponentInstallationList(BaseModel):
25-
"""Model representing a list of custom component installations."""
26-
27-
data: list[CustomComponentInstallation]
28-
total: int
29-
has_more: bool

src/deepset_mcp/api/custom_components/protocols.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,16 @@
44

55
from typing import Protocol
66

7-
from deepset_mcp.api.custom_components.models import CustomComponentInstallationList
7+
from deepset_mcp.api.custom_components.models import CustomComponentInstallation
8+
from deepset_mcp.api.shared_models import PaginatedResponse
89

910

1011
class CustomComponentsProtocol(Protocol):
1112
"""Protocol defining the implementation for CustomComponentsResource."""
1213

1314
async def list_installations(
14-
self, limit: int = 20, page_number: int = 1, field: str = "created_at", order: str = "DESC"
15-
) -> CustomComponentInstallationList:
15+
self, limit: int = 20, after: str | None = None, field: str = "created_at", order: str = "DESC"
16+
) -> PaginatedResponse[CustomComponentInstallation]:
1617
"""List custom component installations."""
1718
...
1819

src/deepset_mcp/api/custom_components/resource.py

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33
# SPDX-License-Identifier: Apache-2.0
44

55
from typing import Any
6+
from urllib.parse import quote
67

7-
from deepset_mcp.api.custom_components.models import CustomComponentInstallationList
8+
from deepset_mcp.api.custom_components.models import CustomComponentInstallation
89
from deepset_mcp.api.custom_components.protocols import CustomComponentsProtocol
10+
from deepset_mcp.api.exceptions import UnexpectedAPIError
911
from deepset_mcp.api.protocols import AsyncClientProtocol
12+
from deepset_mcp.api.shared_models import PaginatedResponse
1013
from deepset_mcp.api.transport import raise_for_status
1114

1215

@@ -21,29 +24,52 @@ def __init__(self, client: AsyncClientProtocol) -> None:
2124
self._client = client
2225

2326
async def list_installations(
24-
self, limit: int = 20, page_number: int = 1, field: str = "created_at", order: str = "DESC"
25-
) -> CustomComponentInstallationList:
26-
"""List custom component installations.
27+
self, limit: int = 20, after: str | None = None, field: str = "created_at", order: str = "DESC"
28+
) -> PaginatedResponse[CustomComponentInstallation]:
29+
"""Lists custom component installations and returns the first page of results.
2730
28-
:param limit: Maximum number of installations to return.
29-
:param page_number: Page number for pagination.
31+
The returned object can be iterated over to fetch subsequent pages.
32+
33+
:param limit: Maximum number of installations to return per page.
34+
:param after: The cursor to fetch the next page of results.
3035
:param field: Field to sort by.
3136
:param order: Sort order (ASC or DESC).
32-
33-
:returns: List of custom component installations.
37+
:returns: A `PaginatedResponse` object containing the first page of installations.
3438
"""
39+
# 1. Prepare arguments for the initial API call
40+
# TODO: Pagination in the deepset API is currently implemented in an unintuitive way.
41+
# TODO: The cursor is always time based (created_at) and after signifies installations older than the
42+
# TODO: current cursor
43+
# TODO: while 'before' signals installations younger than the current cursor.
44+
# TODO: This is applied irrespective of any sort (e.g. name) that would conflict with this approach.
45+
# TODO: Change this to 'after' once the behaviour is fixed on the deepset API
46+
request_params = {"limit": limit, "field": field, "order": order, "before": after}
47+
request_params = {k: v for k, v in request_params.items() if v is not None}
48+
49+
# 2. Make the first API call using a private, stateless method
50+
page = await self._list_api_call(**request_params)
51+
52+
# 3. Inject the logic needed for subsequent fetches into the response object
53+
page._inject_paginator(
54+
fetch_func=self._list_api_call,
55+
# Base args for the *next* fetch don't include initial cursors
56+
base_args={"limit": limit, "field": field, "order": order},
57+
)
58+
return page
59+
60+
async def _list_api_call(self, **kwargs: Any) -> PaginatedResponse[CustomComponentInstallation]:
61+
"""A private, stateless method that performs the raw API call."""
62+
params = "&".join([f"{key}={quote(str(value), safe='')}" for key, value in kwargs.items()])
3563
resp = await self._client.request(
36-
endpoint=f"v2/custom_components?limit={limit}&page_number={page_number}&field={field}&order={order}",
64+
endpoint=f"v2/custom_components?{params}",
3765
method="GET",
3866
response_type=dict[str, Any],
3967
)
40-
4168
raise_for_status(resp)
42-
4369
if resp.json is None:
44-
return CustomComponentInstallationList(data=[], total=0, has_more=False)
70+
raise UnexpectedAPIError(status_code=resp.status_code, message="Empty response", detail=None)
4571

46-
return CustomComponentInstallationList(**resp.json)
72+
return PaginatedResponse[CustomComponentInstallation].create_with_cursor_field(resp.json, "custom_component_id")
4773

4874
async def get_latest_installation_logs(self) -> str | None:
4975
"""Get the logs from the latest custom component installation.

src/deepset_mcp/tools/custom_components.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,28 @@
22
#
33
# SPDX-License-Identifier: Apache-2.0
44

5-
from deepset_mcp.api.custom_components.models import CustomComponentInstallationList
5+
from deepset_mcp.api.custom_components.models import CustomComponentInstallation
66
from deepset_mcp.api.protocols import AsyncClientProtocol
7+
from deepset_mcp.api.shared_models import PaginatedResponse
78

89

910
async def list_custom_component_installations(
10-
*, client: AsyncClientProtocol, workspace: str
11-
) -> CustomComponentInstallationList | str:
11+
*, client: AsyncClientProtocol, workspace: str, limit: int = 20, after: str | None = None
12+
) -> PaginatedResponse[CustomComponentInstallation] | str:
1213
"""List custom component installations.
1314
1415
:param client: The API client to use.
1516
:param workspace: The workspace to operate in.
17+
:param limit: Maximum number of installations to return per page.
18+
:param after: The cursor to fetch the next page of results.
1619
1720
:returns: Custom component installations or error message.
1821
"""
1922
custom_components = client.custom_components(workspace)
2023
users = client.users()
2124

2225
try:
23-
installations = await custom_components.list_installations()
26+
installations = await custom_components.list_installations(limit=limit, after=after)
2427
except Exception as e:
2528
return f"Failed to retrieve custom component installations: {e}"
2629

test/unit/api/custom_components/test_custom_components_resource.py

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,8 @@
44

55
import pytest
66

7-
from deepset_mcp.api.custom_components.models import (
8-
CustomComponentInstallationList,
9-
)
107
from deepset_mcp.api.custom_components.resource import CustomComponentsResource
8+
from deepset_mcp.api.shared_models import PaginatedResponse
119
from test.unit.conftest import BaseFakeClient
1210

1311

@@ -29,9 +27,7 @@ async def test_list_installations() -> None:
2927
"has_more": False,
3028
}
3129

32-
fake_client = BaseFakeClient(
33-
responses={"v2/custom_components?limit=20&page_number=1&field=created_at&order=DESC": mock_data}
34-
)
30+
fake_client = BaseFakeClient(responses={"v2/custom_components?limit=20&field=created_at&order=DESC": mock_data})
3531

3632
def custom_components(workspace: str) -> CustomComponentsResource:
3733
return CustomComponentsResource(client=fake_client)
@@ -41,7 +37,7 @@ def custom_components(workspace: str) -> CustomComponentsResource:
4137
resource = fake_client.custom_components("test-workspace")
4238
result = await resource.list_installations()
4339

44-
assert isinstance(result, CustomComponentInstallationList)
40+
assert isinstance(result, PaginatedResponse)
4541
assert len(result.data) == 1
4642
assert result.total == 1
4743
assert not result.has_more
@@ -63,9 +59,7 @@ async def test_list_installations_empty() -> None:
6359
"has_more": False,
6460
}
6561

66-
fake_client = BaseFakeClient(
67-
responses={"v2/custom_components?limit=20&page_number=1&field=created_at&order=DESC": mock_data}
68-
)
62+
fake_client = BaseFakeClient(responses={"v2/custom_components?limit=20&field=created_at&order=DESC": mock_data})
6963

7064
def custom_components(workspace: str) -> CustomComponentsResource:
7165
return CustomComponentsResource(client=fake_client)
@@ -75,7 +69,7 @@ def custom_components(workspace: str) -> CustomComponentsResource:
7569
resource = fake_client.custom_components("test-workspace")
7670
result = await resource.list_installations()
7771

78-
assert isinstance(result, CustomComponentInstallationList)
72+
assert isinstance(result, PaginatedResponse)
7973
assert len(result.data) == 0
8074
assert result.total == 0
8175
assert not result.has_more
@@ -91,7 +85,7 @@ async def test_list_installations_with_params() -> None:
9185
}
9286

9387
fake_client = BaseFakeClient(
94-
responses={"v2/custom_components?limit=50&page_number=2&field=status&order=ASC": mock_data}
88+
responses={"v2/custom_components?limit=50&field=status&order=ASC&before=cursor_123": mock_data}
9589
)
9690

9791
def custom_components(workspace: str) -> CustomComponentsResource:
@@ -100,13 +94,13 @@ def custom_components(workspace: str) -> CustomComponentsResource:
10094
fake_client.custom_components = custom_components # type: ignore[method-assign]
10195

10296
resource = fake_client.custom_components("test-workspace")
103-
await resource.list_installations(limit=50, page_number=2, field="status", order="ASC")
97+
await resource.list_installations(limit=50, after="cursor_123", field="status", order="ASC")
10498

10599
# Check that the request was made with the correct parameters
106100
assert len(fake_client.requests) == 1
107101
request = fake_client.requests[0]
108102
assert "limit=50" in request["endpoint"]
109-
assert "page_number=2" in request["endpoint"]
103+
assert "before=cursor_123" in request["endpoint"] # after becomes before due to API quirk
110104
assert "field=status" in request["endpoint"]
111105
assert "order=ASC" in request["endpoint"]
112106

test/unit/tools/test_custom_components.py

Lines changed: 71 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22
#
33
# SPDX-License-Identifier: Apache-2.0
44

5+
from typing import Any
6+
57
import pytest
68

79
from deepset_mcp.api.custom_components.models import (
810
CustomComponentInstallation,
9-
CustomComponentInstallationList,
1011
)
1112
from deepset_mcp.api.exceptions import UnexpectedAPIError
12-
from deepset_mcp.api.shared_models import DeepsetUser
13+
from deepset_mcp.api.shared_models import DeepsetUser, PaginatedResponse
1314
from deepset_mcp.tools.custom_components import (
1415
get_latest_custom_component_installation_logs,
1516
list_custom_component_installations,
@@ -20,7 +21,7 @@
2021
class FakeCustomComponentsResource:
2122
def __init__(
2223
self,
23-
installations_response: CustomComponentInstallationList | None = None,
24+
installations_response: PaginatedResponse[CustomComponentInstallation] | None = None,
2425
latest_logs_response: str | None = None,
2526
exception: Exception | None = None,
2627
):
@@ -29,8 +30,8 @@ def __init__(
2930
self._exception = exception
3031

3132
async def list_installations(
32-
self, limit: int = 20, page_number: int = 1, field: str = "created_at", order: str = "DESC"
33-
) -> CustomComponentInstallationList:
33+
self, limit: int = 20, after: str | None = None, field: str = "created_at", order: str = "DESC"
34+
) -> PaginatedResponse[CustomComponentInstallation]:
3435
if self._exception:
3536
raise self._exception
3637
if self._installations_response is not None:
@@ -84,7 +85,7 @@ def users(self) -> FakeUserResource:
8485
@pytest.mark.asyncio
8586
async def test_list_custom_component_installations() -> None:
8687
"""Test listing custom component installations."""
87-
mock_installations = CustomComponentInstallationList(
88+
mock_installations = PaginatedResponse[CustomComponentInstallation](
8889
data=[
8990
CustomComponentInstallation(
9091
custom_component_id="comp_123",
@@ -134,7 +135,7 @@ async def test_list_custom_component_installations() -> None:
134135

135136
result = await list_custom_component_installations(client=client, workspace="test-workspace")
136137

137-
assert isinstance(result, CustomComponentInstallationList)
138+
assert isinstance(result, PaginatedResponse)
138139
assert len(result.data) == 2
139140
assert result.total == 2
140141
assert result.has_more is False
@@ -168,7 +169,7 @@ async def test_list_custom_component_installations() -> None:
168169
@pytest.mark.asyncio
169170
async def test_list_custom_component_installations_empty() -> None:
170171
"""Test listing custom component installations when none exist."""
171-
mock_installations = CustomComponentInstallationList(
172+
mock_installations = PaginatedResponse[CustomComponentInstallation](
172173
data=[],
173174
total=0,
174175
has_more=False,
@@ -183,7 +184,7 @@ async def test_list_custom_component_installations_empty() -> None:
183184

184185
result = await list_custom_component_installations(client=client, workspace="test-workspace")
185186

186-
assert isinstance(result, CustomComponentInstallationList)
187+
assert isinstance(result, PaginatedResponse)
187188
assert len(result.data) == 0
188189
assert result.total == 0
189190
assert result.has_more is False
@@ -192,7 +193,7 @@ async def test_list_custom_component_installations_empty() -> None:
192193
@pytest.mark.asyncio
193194
async def test_list_custom_component_installations_user_fetch_error() -> None:
194195
"""Test listing custom component installations when user fetch fails."""
195-
mock_installations = CustomComponentInstallationList(
196+
mock_installations = PaginatedResponse[CustomComponentInstallation](
196197
data=[
197198
CustomComponentInstallation(
198199
custom_component_id="comp_123",
@@ -216,7 +217,7 @@ async def test_list_custom_component_installations_user_fetch_error() -> None:
216217

217218
result = await list_custom_component_installations(client=client, workspace="test-workspace")
218219

219-
assert isinstance(result, CustomComponentInstallationList)
220+
assert isinstance(result, PaginatedResponse)
220221
assert len(result.data) == 1
221222
assert result.data[0].created_by_user_id == "user_unknown"
222223
assert result.data[0].user_info is None # User fetch failed, so user_info should be None
@@ -271,3 +272,62 @@ async def test_get_latest_custom_component_installation_logs_api_error() -> None
271272

272273
result = await get_latest_custom_component_installation_logs(client=client, workspace="test-workspace")
273274
assert result == "Failed to retrieve latest installation logs: API Error (Status Code: 500)"
275+
276+
277+
@pytest.mark.asyncio
278+
async def test_list_custom_component_installations_with_pagination_params() -> None:
279+
"""Test listing custom component installations with pagination parameters."""
280+
mock_installations = PaginatedResponse[CustomComponentInstallation](
281+
data=[
282+
CustomComponentInstallation(
283+
custom_component_id="comp_123",
284+
status="installed",
285+
version="1.0.0",
286+
created_by_user_id="user_123",
287+
organization_id="org-123",
288+
logs=[{"level": "INFO", "msg": "Installation complete"}],
289+
)
290+
],
291+
total=1,
292+
has_more=False,
293+
)
294+
295+
# Create a custom resource that tracks the parameters passed
296+
class TrackingCustomComponentsResource(FakeCustomComponentsResource):
297+
def __init__(self, installations_response: PaginatedResponse[CustomComponentInstallation]) -> None:
298+
super().__init__(installations_response=installations_response)
299+
self.called_with: dict[str, Any] = {}
300+
301+
async def list_installations(
302+
self, limit: int = 20, after: str | None = None, field: str = "created_at", order: str = "DESC"
303+
) -> PaginatedResponse[CustomComponentInstallation]:
304+
self.called_with = {"limit": limit, "after": after, "field": field, "order": order}
305+
return await super().list_installations(limit, after, field, order)
306+
307+
custom_components_resource = TrackingCustomComponentsResource(installations_response=mock_installations)
308+
user_resource = FakeUserResource(
309+
users={
310+
"user_123": DeepsetUser(
311+
user_id="user_123", given_name="John", family_name="Doe", email="john.doe@example.com"
312+
)
313+
}
314+
)
315+
client = FakeClient(
316+
custom_components_resource=custom_components_resource,
317+
user_resource=user_resource,
318+
)
319+
320+
# Test with custom parameters
321+
result = await list_custom_component_installations(
322+
client=client, workspace="test-workspace", limit=50, after="cursor_123"
323+
)
324+
325+
# Verify the parameters were passed correctly
326+
assert custom_components_resource.called_with["limit"] == 50
327+
assert custom_components_resource.called_with["after"] == "cursor_123"
328+
assert custom_components_resource.called_with["field"] == "created_at" # default
329+
assert custom_components_resource.called_with["order"] == "DESC" # default
330+
331+
# Verify the result
332+
assert isinstance(result, PaginatedResponse)
333+
assert len(result.data) == 1

0 commit comments

Comments
 (0)