Skip to content

Commit 62e393f

Browse files
ebyhrkevinjqliu
andauthored
REST: Add pagination support for list_namespaces (#3347)
<!-- Thanks for opening a pull request! --> <!-- In the case this PR will resolve an issue, please replace ${GITHUB_ISSUE_ID} below with the actual Github issue id. --> <!-- Closes #${GITHUB_ISSUE_ID} --> # Rationale for this change Follows REST catalog spec: - /v1/{prefix}/namespaces: https://github.com/apache/iceberg/blob/e7a5a87f26f9de5b200254155aa037368b13a29c/open-api/rest-catalog-open-api.yaml#L250-L306 - ListNamespacesResponse: https://github.com/apache/iceberg/blob/e7a5a87f26f9de5b200254155aa037368b13a29c/open-api/rest-catalog-open-api.yaml#L4221-L4230 - PageToken: https://github.com/apache/iceberg/blob/e7a5a87f26f9de5b200254155aa037368b13a29c/open-api/rest-catalog-open-api.yaml#L2233-L2256 ## Are these changes tested? Yes, includes unit tests for paginated cases. ## Are there any user-facing changes? - Before: returned incomplete namespace list when server paginated (only first page) - After: returns complete namespace list (fetches all pages) <!-- In the case of user-facing changes, please add the changelog label. --> --------- Co-authored-by: Kevin Liu <kevinjqliu@users.noreply.github.com>
1 parent 1d31eff commit 62e393f

2 files changed

Lines changed: 124 additions & 12 deletions

File tree

pyiceberg/catalog/rest/__init__.py

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,7 @@ def _parse_endpoints(cls, v: list[str] | None) -> set[Endpoint] | None:
350350

351351
class ListNamespaceResponse(IcebergBaseModel):
352352
namespaces: list[Identifier] = Field()
353+
next_page_token: str | None = Field(default=None, alias="next-page-token")
353354

354355

355356
class NamespaceResponse(IcebergBaseModel):
@@ -1243,19 +1244,31 @@ def drop_namespace(self, namespace: str | Identifier) -> None:
12431244
def list_namespaces(self, namespace: str | Identifier = ()) -> list[Identifier]:
12441245
self._check_endpoint(Capability.V1_LIST_NAMESPACES)
12451246
namespace_tuple = self.identifier_to_tuple(namespace)
1246-
response = self._session.get(
1247-
self.url(
1248-
f"{Endpoints.list_namespaces}?parent={self._encode_namespace_path(namespace_tuple)}"
1249-
if namespace_tuple
1250-
else Endpoints.list_namespaces
1251-
),
1252-
)
1253-
try:
1254-
response.raise_for_status()
1255-
except HTTPError as exc:
1256-
_handle_non_200_response(exc, {404: NoSuchNamespaceError})
12571247

1258-
return ListNamespaceResponse.model_validate_json(response.text).namespaces
1248+
namespaces: list[Identifier] = []
1249+
page_token: str | None = None
1250+
1251+
while True:
1252+
params: dict[str, str] = {}
1253+
if namespace_tuple:
1254+
params["parent"] = self._encode_namespace_path(namespace_tuple)
1255+
if page_token:
1256+
params["pageToken"] = page_token
1257+
response = self._session.get(self.url(Endpoints.list_namespaces), params=params)
1258+
1259+
try:
1260+
response.raise_for_status()
1261+
except HTTPError as exc:
1262+
_handle_non_200_response(exc, {404: NoSuchNamespaceError})
1263+
1264+
parsed = ListNamespaceResponse.model_validate_json(response.text)
1265+
namespaces.extend(parsed.namespaces)
1266+
1267+
if not parsed.next_page_token:
1268+
break
1269+
page_token = parsed.next_page_token
1270+
1271+
return namespaces
12591272

12601273
@retry(**_RETRY_ARGS)
12611274
@override

tests/catalog/test_rest.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -823,6 +823,105 @@ def test_list_namespace_with_parent_200(rest_mock: Mocker) -> None:
823823
]
824824

825825

826+
def test_list_namespaces_paginated_200(rest_mock: Mocker) -> None:
827+
# First page with next-page-token
828+
rest_mock.get(
829+
f"{TEST_URI}v1/namespaces",
830+
json={
831+
"namespaces": [["ns1"], ["ns2"]],
832+
"next-page-token": "page2token",
833+
},
834+
status_code=200,
835+
request_headers=TEST_HEADERS,
836+
)
837+
# Second page with next-page-token
838+
rest_mock.get(
839+
f"{TEST_URI}v1/namespaces?pageToken=page2token",
840+
json={
841+
"namespaces": [["ns3"], ["ns4"]],
842+
"next-page-token": "page3token",
843+
},
844+
status_code=200,
845+
request_headers=TEST_HEADERS,
846+
)
847+
# Third page without next-page-token (last page)
848+
rest_mock.get(
849+
f"{TEST_URI}v1/namespaces?pageToken=page3token",
850+
json={
851+
"namespaces": [["ns5"]],
852+
},
853+
status_code=200,
854+
request_headers=TEST_HEADERS,
855+
)
856+
857+
result = RestCatalog("rest", uri=TEST_URI, token=TEST_TOKEN).list_namespaces()
858+
assert result == [
859+
("ns1",),
860+
("ns2",),
861+
("ns3",),
862+
("ns4",),
863+
("ns5",),
864+
]
865+
866+
867+
def test_list_namespaces_with_parent_paginated_200(rest_mock: Mocker) -> None:
868+
# First page
869+
rest_mock.get(
870+
f"{TEST_URI}v1/namespaces?parent=accounting",
871+
json={
872+
"namespaces": [["accounting", "tax"]],
873+
"next-page-token": "page2",
874+
},
875+
status_code=200,
876+
request_headers=TEST_HEADERS,
877+
)
878+
# Second page (last)
879+
rest_mock.get(
880+
f"{TEST_URI}v1/namespaces?parent=accounting&pageToken=page2",
881+
json={
882+
"namespaces": [["accounting", "payroll"]],
883+
},
884+
status_code=200,
885+
request_headers=TEST_HEADERS,
886+
)
887+
888+
result = RestCatalog("rest", uri=TEST_URI, token=TEST_TOKEN).list_namespaces(("accounting",))
889+
assert result == [
890+
("accounting", "tax"),
891+
("accounting", "payroll"),
892+
]
893+
894+
895+
def test_list_namespaces_paginated_200_none_next_page_token(rest_mock: Mocker) -> None:
896+
# First page with next-page-token
897+
rest_mock.get(
898+
f"{TEST_URI}v1/namespaces",
899+
json={
900+
"namespaces": [["ns1"], ["ns2"]],
901+
"next-page-token": "page2token",
902+
},
903+
status_code=200,
904+
request_headers=TEST_HEADERS,
905+
)
906+
# The last page with None next-page-token
907+
rest_mock.get(
908+
f"{TEST_URI}v1/namespaces?pageToken=page2token",
909+
json={
910+
"namespaces": [["ns3"]],
911+
"next-page-token": None,
912+
},
913+
status_code=200,
914+
request_headers=TEST_HEADERS,
915+
)
916+
917+
result = RestCatalog("rest", uri=TEST_URI, token=TEST_TOKEN).list_namespaces()
918+
assert result == [
919+
("ns1",),
920+
("ns2",),
921+
("ns3",),
922+
]
923+
924+
826925
def test_list_namespace_with_parent_404(rest_mock: Mocker) -> None:
827926
rest_mock.get(
828927
f"{TEST_URI}v1/namespaces?parent=some_namespace",

0 commit comments

Comments
 (0)