From 4e7b40405931549eae1dda88e1e8b98626c0754a Mon Sep 17 00:00:00 2001 From: Dhruv Gupta Date: Thu, 15 Oct 2026 12:46:59 -0400 Subject: [PATCH] REST: Stop double-encoding the parent namespace in list_namespaces list_namespaces passes the parent namespace through requests' params, which URL-encodes query values. The namespace parts were already quoted with _encode_namespace_path, so any part containing a space, '/', '%' or non-ASCII character was encoded twice and the server received e.g. "my%20namespace" instead of "my namespace". Join the raw parts with the namespace separator instead, matching RESTUtil.namespaceToQueryParam in the Java client. --- pyiceberg/catalog/rest/__init__.py | 3 ++- tests/catalog/test_rest.py | 37 ++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) 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(