diff --git a/pyiceberg/catalog/rest/__init__.py b/pyiceberg/catalog/rest/__init__.py index 88bbd29bef..2b8b4496f8 100644 --- a/pyiceberg/catalog/rest/__init__.py +++ b/pyiceberg/catalog/rest/__init__.py @@ -1676,7 +1676,8 @@ def list_namespaces(self, namespace: str | Identifier = ()) -> list[Identifier]: while True: if namespace_tuple: - params["parent"] = self._encode_namespace_path(namespace_tuple) + # Query parameters are URL-encoded by requests, so the parts must not be quoted here as well + params["parent"] = self._namespace_separator.join(namespace_tuple) if page_token: params["pageToken"] = page_token response = self._session.get(self.url(Endpoints.list_namespaces), params=params) diff --git a/tests/catalog/test_rest.py b/tests/catalog/test_rest.py index b06281bcef..4877ee2f73 100644 --- a/tests/catalog/test_rest.py +++ b/tests/catalog/test_rest.py @@ -23,6 +23,7 @@ from contextlib import contextmanager from typing import Any, cast from unittest import mock +from urllib.parse import parse_qs, urlsplit import pytest from requests import Request @@ -972,6 +973,42 @@ def test_list_namespace_with_parent_200(rest_mock: Mocker) -> None: ] +@pytest.mark.parametrize( + "parent", + [ + ("accounting",), + ("accounting", "tax"), + ("my namespace",), + ("dept/finance", "tax"), + ("100%",), + ("\u65e5\u672c",), + ], +) +def test_list_namespace_with_parent_is_encoded_once(rest_mock: Mocker, parent: tuple[str, ...]) -> None: + rest_mock.get( + f"{TEST_URI}v1/namespaces", + json={"namespaces": [[*parent, "child"]]}, + status_code=200, + request_headers=TEST_HEADERS, + ) + assert RestCatalog("rest", uri=TEST_URI, token=TEST_TOKEN).list_namespaces(parent) == [(*parent, "child")] + query = parse_qs(urlsplit(rest_mock.last_request.url).query) + assert query["parent"] == ["\x1f".join(parent)] + + +def test_list_namespace_with_parent_custom_separator(rest_mock: Mocker) -> None: + rest_mock.get( + f"{TEST_URI}v1/namespaces", + json={"namespaces": [["my namespace", "tax", "child"]]}, + status_code=200, + request_headers=TEST_HEADERS, + ) + catalog = RestCatalog("rest", uri=TEST_URI, token=TEST_TOKEN, **{"namespace-separator": "."}) + assert catalog.list_namespaces(("my namespace", "tax")) == [("my namespace", "tax", "child")] + query = parse_qs(urlsplit(rest_mock.last_request.url).query) + assert query["parent"] == ["my namespace.tax"] + + def test_list_namespaces_paginated_200(rest_mock: Mocker) -> None: # First page with next-page-token rest_mock.get(