This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 76cc48587b [python] Use Native REST catalog branch operations (#10619)
76cc48587b is described below
commit 76cc48587b29d6294a38a4f86877cb23587c02ef
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Oct 17 11:32:32 2026 +0800
[python] Use Native REST catalog branch operations (#10619)
---
.github/workflows/ci-python.yml | 5 +-
paimon-python/README.md | 22 +
paimon-python/pypaimon/api/api_request.py | 7 -
paimon-python/pypaimon/api/resource_paths.py | 5 -
paimon-python/pypaimon/api/rest_api.py | 41 +-
paimon-python/pypaimon/branch/branch_manager.py | 4 +-
.../pypaimon/branch/catalog_branch_manager.py | 9 +-
.../pypaimon/catalog/rest/native_branch.py | 137 ++++++
.../pypaimon/catalog/rest/rest_catalog.py | 27 +-
.../pypaimon/tests/api/test_branch_dto_serde.py | 19 +-
.../tests/branch/catalog_branch_manager_test.py | 5 +
.../pypaimon/tests/native_branch_write_test.py | 16 +-
.../pypaimon/tests/native_rest_branch_test.py | 543 +++++++++++++++++++++
.../pypaimon/tests/rest/dlf_signer_test.py | 2 +-
.../pypaimon/tests/rest/rest_branch_test.py | 37 +-
paimon-python/pypaimon/tests/rest/rest_server.py | 25 -
16 files changed, 776 insertions(+), 128 deletions(-)
diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml
index b6a0fb9f81..00778ca3fc 100644
--- a/.github/workflows/ci-python.yml
+++ b/.github/workflows/ci-python.yml
@@ -231,8 +231,11 @@ jobs:
normalize_array_for_write,
read_arrow_schema,
)
- from pypaimon_rust.datafusion import ReadBuilder
+ from pypaimon_rust.datafusion import CatalogError, PaimonCatalog,
ReadBuilder
assert callable(getattr(ReadBuilder, 'with_partition_filter', None))
+ assert issubclass(CatalogError, ValueError)
+ for method in ('create_branch', 'drop_branch', 'fast_forward',
'list_branches'):
+ assert callable(getattr(PaimonCatalog, method, None))
PY
- name: Run Python tests with native planning, reading, writing,
updating and committing
diff --git a/paimon-python/README.md b/paimon-python/README.md
index ccb3cb5c18..0c88ad530e 100644
--- a/paimon-python/README.md
+++ b/paimon-python/README.md
@@ -139,6 +139,28 @@ table = table.copy({"parquet.filter.columnindex.enabled":
"false"})
Unsupported reads use the normal path. Reading fewer bytes may require more
object-store requests.
+# Native REST branch management
+
+REST catalogs delegate `list_branches` to Rust when the catalog option
+`read.native.enabled=true`, and `create_branch`, `drop_branch` and
`fast_forward`
+when `write.native.enabled=true`. Existing `table.branch_manager()` calls use
+the same route. Install the optional `pypaimon-rust` runtime to enable it.
+
+The bridge reuses the catalog's resolved prefix, endpoint, authentication and
+request headers, and preserves the public Python exception types. Custom
clients,
+authentication providers or error handlers use Python. Once a Native operation
+starts, failures propagate without replaying the request through Python.
+
+OpenAPI V4 (`dlf.signing-algorithm=openapi-v4`) is signed in Rust core,
including
+the action header. Session headers are merged case-insensitively, preserving an
+explicit User-Agent. Sessions with Cookie, custom authentication or TLS
settings
+use Python to retain their transport state. Native listing retries HTTP
429/503;
+mutations are sent once and accept successful responses without a JSON body.
+
+As in Java, creation without a tag produces an empty branch; creation from a
tag
+uses its snapshot. REST branch renaming is unsupported. Dropping branch
metadata
+does not remove data files shared with another branch or tag.
+
# Native write and commit
PyPaimon can write Arrow batches through the optional `pypaimon-rust` runtime.
diff --git a/paimon-python/pypaimon/api/api_request.py
b/paimon-python/pypaimon/api/api_request.py
index 7f06c789a3..c03b75319f 100644
--- a/paimon-python/pypaimon/api/api_request.py
+++ b/paimon-python/pypaimon/api/api_request.py
@@ -212,13 +212,6 @@ class CreateBranchRequest(RESTRequest):
from_tag: Optional[str] = json_field(FIELD_FROM_TAG, default=None)
-@dataclass
-class RenameBranchRequest(RESTRequest):
- FIELD_TO_BRANCH = "toBranch"
-
- to_branch: str = json_field(FIELD_TO_BRANCH)
-
-
@dataclass
class ForwardBranchRequest(RESTRequest):
"""Empty body request; serializes to ``{}`` per Java
ForwardBranchRequest."""
diff --git a/paimon-python/pypaimon/api/resource_paths.py
b/paimon-python/pypaimon/api/resource_paths.py
index 309d829d59..ac5e5dca95 100644
--- a/paimon-python/pypaimon/api/resource_paths.py
+++ b/paimon-python/pypaimon/api/resource_paths.py
@@ -33,7 +33,6 @@ class ResourcePaths:
FUNCTION_DETAILS = "function-details"
TAGS = "tags"
BRANCHES = "branches"
- RENAME = "rename"
FORWARD = "forward"
PERMISSIONS = "permissions"
POLICIES = "policies"
@@ -135,10 +134,6 @@ class ResourcePaths:
self.TABLES, RESTUtil.encode_string(table_name), self.BRANCHES,
RESTUtil.encode_string(branch_name))
- def rename_branch(self, database_name: str, table_name: str, branch_name:
str) -> str:
- return "{}/{}".format(
- self.branch(database_name, table_name, branch_name), self.RENAME)
-
def forward_branch(self, database_name: str, table_name: str, branch_name:
str) -> str:
return "{}/{}".format(
self.branch(database_name, table_name, branch_name), self.FORWARD)
diff --git a/paimon-python/pypaimon/api/rest_api.py
b/paimon-python/pypaimon/api/rest_api.py
index d441ea5cbd..b8148a1dfe 100755
--- a/paimon-python/pypaimon/api/rest_api.py
+++ b/paimon-python/pypaimon/api/rest_api.py
@@ -27,7 +27,7 @@ from pypaimon.api.api_request import (AlterDatabaseRequest,
AlterFunctionRequest
CreateTableRequest, CreateTagRequest,
DropPolicyRequest, ForwardBranchRequest,
GrantPermissionRequest, PolicyRequest,
- RenameBranchRequest, RenameTableRequest,
+ RenameTableRequest,
RevokePermissionRequest,
RollbackTableRequest)
from pypaimon.api.api_response import (CommitTableResponse, ConfigResponse,
@@ -128,6 +128,10 @@ class RESTApi:
self.options = options
self.resource_paths = ResourcePaths.for_catalog_properties(options)
+ @staticmethod
+ def _is_auth_option(key):
+ return key in ('uri', 'token.provider', 'token') or
key.startswith('dlf.')
+
def __build_paged_query_params(
self,
max_results: Optional[int],
@@ -545,13 +549,24 @@ class RESTApi:
)
# Branch CRUD wrappers — mirror Java RESTApi branch methods.
+ @staticmethod
+ def _validate_branch_path(identifier, branch_name=None):
+ database_name, table_name = RESTApi.__validate_identifier(identifier)
+ components = [(database_name, 'Database name'), (table_name, 'Table
name')]
+ if branch_name is not None:
+ components.append((branch_name, 'Branch name'))
+ for value, label in components:
+ if not value or not value.strip() or value in ('.', '..'):
+ raise ValueError('{} must be a non-empty resource path
component'.format(label))
+ return database_name, table_name
+
def create_branch(
self,
identifier: Identifier,
branch_name: str,
tag_name: Optional[str] = None,
) -> None:
- database_name, table_name = self.__validate_identifier(identifier)
+ database_name, table_name = self._validate_branch_path(identifier)
if not branch_name or not branch_name.strip():
raise ValueError("Branch name cannot be empty")
request = CreateBranchRequest(branch=branch_name, from_tag=tag_name)
@@ -562,30 +577,14 @@ class RESTApi:
)
def drop_branch(self, identifier: Identifier, branch_name: str) -> None:
- database_name, table_name = self.__validate_identifier(identifier)
+ database_name, table_name = self._validate_branch_path(identifier,
branch_name)
self.client.delete(
self.resource_paths.branch(database_name, table_name, branch_name),
self.rest_auth_function,
)
- def rename_branch(
- self,
- identifier: Identifier,
- from_branch: str,
- to_branch: str,
- ) -> None:
- database_name, table_name = self.__validate_identifier(identifier)
- if not to_branch or not to_branch.strip():
- raise ValueError("Target branch name cannot be empty")
- request = RenameBranchRequest(to_branch=to_branch)
- self.client.post(
- self.resource_paths.rename_branch(database_name, table_name,
from_branch),
- request,
- self.rest_auth_function,
- )
-
def fast_forward(self, identifier: Identifier, branch_name: str) -> None:
- database_name, table_name = self.__validate_identifier(identifier)
+ database_name, table_name = self._validate_branch_path(identifier,
branch_name)
self.client.post(
self.resource_paths.forward_branch(database_name, table_name,
branch_name),
ForwardBranchRequest(),
@@ -593,7 +592,7 @@ class RESTApi:
)
def list_branches(self, identifier: Identifier) -> List[str]:
- database_name, table_name = self.__validate_identifier(identifier)
+ database_name, table_name = self._validate_branch_path(identifier)
response = self.client.get(
self.resource_paths.branches(database_name, table_name),
ListBranchesResponse,
diff --git a/paimon-python/pypaimon/branch/branch_manager.py
b/paimon-python/pypaimon/branch/branch_manager.py
index ae0d160f17..8e9e249aeb 100644
--- a/paimon-python/pypaimon/branch/branch_manager.py
+++ b/paimon-python/pypaimon/branch/branch_manager.py
@@ -40,11 +40,11 @@ class BranchManager:
ignore_if_exists: bool = False
) -> None:
"""
- Create a branch from the current state or from a tag.
+ Create an empty branch or initialize it from a tag.
Args:
branch_name: Name of the branch to create
- tag_name: Optional tag name to create branch from, None for
current state
+ tag_name: Optional tag name to create branch from, None for an
empty branch
ignore_if_exists: If true, do nothing when branch already exists;
if false, throw exception
diff --git a/paimon-python/pypaimon/branch/catalog_branch_manager.py
b/paimon-python/pypaimon/branch/catalog_branch_manager.py
index 3c3009c88c..5cad0ab520 100644
--- a/paimon-python/pypaimon/branch/catalog_branch_manager.py
+++ b/paimon-python/pypaimon/branch/catalog_branch_manager.py
@@ -85,11 +85,11 @@ class CatalogBranchManager(BranchManager):
ignore_if_exists: bool = False
) -> None:
"""
- Create a branch from the current state or from a tag.
+ Create an empty branch or initialize it from a tag.
Args:
branch_name: Name of the branch to create
- tag_name: Optional tag name to create branch from, None for
current state
+ tag_name: Optional tag name to create branch from, None for an
empty branch
ignore_if_exists: If true, do nothing when branch already exists
Raises:
@@ -97,10 +97,7 @@ class CatalogBranchManager(BranchManager):
"""
def _create(catalog: Catalog):
BranchManager.validate_branch(branch_name)
- existing_branches = catalog.list_branches(self.identifier)
- if branch_name in existing_branches:
- if not ignore_if_exists:
- raise ValueError(f"Branch name '{branch_name}' already
exists.")
+ if ignore_if_exists and branch_name in
catalog.list_branches(self.identifier):
return
catalog.create_branch(self.identifier, branch_name, tag_name)
diff --git a/paimon-python/pypaimon/catalog/rest/native_branch.py
b/paimon-python/pypaimon/catalog/rest/native_branch.py
new file mode 100644
index 0000000000..4d46b19f18
--- /dev/null
+++ b/paimon-python/pypaimon/catalog/rest/native_branch.py
@@ -0,0 +1,137 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Transport REST branch operations and structured errors to/from Rust core."""
+
+import logging
+import os
+
+import requests
+from requests.structures import CaseInsensitiveDict
+
+from pypaimon.api.api_response import ErrorResponse
+from pypaimon.api.auth import RESTAuthFunction
+from pypaimon.api.auth.bearer import BearTokenAuthProvider
+from pypaimon.api.auth.dlf_provider import DLFAuthProvider
+from pypaimon.api.client import DefaultErrorHandler, HttpClient
+from pypaimon.api.rest_api import RESTApi
+from pypaimon.catalog.catalog_context import CatalogContext
+from pypaimon.common.options.core_options import CoreOptions
+
+logger = logging.getLogger(__name__)
+
+
+def _native_headers(api):
+ session = api.client.session
+ if (type(session) is not requests.Session or session.auth is not None
+ or session.cert is not None or session.verify is not True or
session.proxies or session.params
+ or not session.trust_env or any(session.hooks.values())
+ or set(session.adapters) != {'http://', 'https://'}
+ or any(type(adapter) is not requests.adapters.HTTPAdapter for
adapter in session.adapters.values())
+ or api.client._timeout != (180, 180)
+ or os.environ.get('REQUESTS_CA_BUNDLE') or
os.environ.get('CURL_CA_BUNDLE')):
+ # Keep custom transport credentials and policies in their owning
client.
+ return None
+ headers = CaseInsensitiveDict(session.headers)
+ # Let each HTTP transport advertise only the codecs it can decode and
manage
+ # its own connections. An explicitly customized transport header stays
Python.
+ for name in ('Accept-Encoding', 'Connection'):
+ if (headers.get(name) != requests.utils.default_headers().get(name)
+ or any(key.lower() == name.lower() for key in
api.rest_auth_function.init_header)):
+ return None
+ headers.pop(name, None)
+ headers.update(api.rest_auth_function.init_header)
+ return {key: value for key, value in headers.items() if value is not None}
+
+
+def _native_catalog(catalog, operation):
+ from pypaimon.catalog.rest.rest_catalog import RESTCatalog
+
+ if (type(catalog) is not RESTCatalog or type(catalog.context) is not
CatalogContext
+ or type(catalog.rest_api) is not RESTApi
+ or any(getattr(catalog.context, attr, None) is not None for attr
in (
+ 'hadoop_conf', 'prefer_io_loader', 'fallback_io_loader'))):
+ return None
+ options = CoreOptions(catalog.context.options)
+ enabled = options.native_read_enabled() if operation == 'list_branches'
else options.native_write_enabled()
+ if not enabled:
+ return None
+ api = catalog.rest_api
+ if (type(api.client) is not HttpClient
+ or type(api.client.error_handler) is not DefaultErrorHandler
+ or type(api.rest_auth_function) is not RESTAuthFunction
+ or type(api.rest_auth_function.auth_provider) not in
(BearTokenAuthProvider, DLFAuthProvider)):
+ return None
+ headers = _native_headers(api)
+ if headers is None:
+ return None
+ try:
+ from pypaimon_rust.datafusion import CatalogError, PaimonCatalog
+ except ImportError:
+ return None
+ from pypaimon.read.native_plan import _option_value_to_string,
_raise_if_native_fork_safety_error
+ resolved = {key: value for key, value in api.options.to_map().items()
+ if not RESTApi._is_auth_option(key) and not
key.startswith(RESTApi.HEADER_PREFIX)}
+ resolved['uri'] = api.client.uri
+ resolved.update({RESTApi.HEADER_PREFIX + key: value for key, value in
headers.items()})
+ resolved = {str(key): _option_value_to_string(value)
+ for key, value in resolved.items() if value is not None}
+ resolved['metastore'] = 'rest'
+ try:
+ provider = api.rest_auth_function.auth_provider
+ if type(provider) is DLFAuthProvider:
+ # The owning provider retains Java's STS cache/refresh policy. Pass
+ # its current credentials for this call; Rust still signs the
request.
+ token = provider.get_token()
+ resolved.update({
+ 'token.provider': 'dlf', 'dlf.region': provider.region,
+ 'dlf.signing-algorithm': provider.signing_algorithm,
+ 'dlf.access-key-id': token.access_key_id,
+ 'dlf.access-key-secret': token.access_key_secret,
+ })
+ if token.security_token is not None:
+ resolved['dlf.security-token'] = token.security_token
+ else:
+ resolved.update({'token.provider': 'bear', 'token':
provider.token})
+ # Reuse the resolved configuration, as RESTCatalogLoader does. Keep no
+ # opaque native state on the Python catalog across pickle or process
fork.
+ return PaimonCatalog(resolved, config_required=False), CatalogError
+ except Exception as error:
+ _raise_if_native_fork_safety_error(error)
+ logger.debug('Native branch preparation failed: %s', error)
+ return None
+
+
+def call_branch(catalog, operation, identifier, *args):
+ # Both backends must reject a path that HTTP clients normalize to another
+ # resource. CREATE's branch is in JSON; only DROP/FORWARD put it in the
URL.
+ path_branch = args[0] if operation in ('drop_branch', 'fast_forward') else
None
+ RESTApi._validate_branch_path(identifier, path_branch)
+ if operation == 'create_branch' and (not args[0] or not args[0].strip()):
+ raise ValueError('Branch name cannot be empty')
+ prepared = _native_catalog(catalog, operation)
+ if prepared is None:
+ return getattr(catalog.rest_api, operation)(identifier, *args)
+ native, error_type = prepared
+ try:
+ # Once an operation starts, never execute it again through Python:
+ # the REST server may have published it before returning an error.
+ return getattr(native, operation)((identifier.get_database_name(),
identifier.get_object_name()), *args)
+ except error_type as error:
+ DefaultErrorHandler.get_instance().accept(ErrorResponse(
+ resource_type=error.resource_type,
resource_name=error.resource_name,
+ message=error.message, code=error.code),
HttpClient.DEFAULT_REQUEST_ID)
diff --git a/paimon-python/pypaimon/catalog/rest/rest_catalog.py
b/paimon-python/pypaimon/catalog/rest/rest_catalog.py
index 2e0d5a046e..6ae9e6fd24 100644
--- a/paimon-python/pypaimon/catalog/rest/rest_catalog.py
+++ b/paimon-python/pypaimon/catalog/rest/rest_catalog.py
@@ -648,14 +648,17 @@ class RESTCatalog(Catalog):
# Branch CRUD: mirrors Java RESTCatalog branch handlers.
def create_branch(self, identifier: Union[str, Identifier], branch_name:
str,
tag_name: Optional[str] = None) -> None:
+ from pypaimon.catalog.rest.native_branch import call_branch
if not isinstance(identifier, Identifier):
identifier = Identifier.from_string(identifier)
try:
- self.rest_api.create_branch(identifier, branch_name, tag_name)
+ call_branch(self, 'create_branch', identifier, branch_name,
tag_name)
except NoSuchResourceException as e:
if e.resource_type == ErrorResponse.RESOURCE_TYPE_TAG:
raise TagNotExistException(tag_name) from e
- raise TableNotExistException(identifier) from e
+ if e.resource_type == ErrorResponse.RESOURCE_TYPE_TABLE:
+ raise TableNotExistException(identifier) from e
+ raise
except AlreadyExistsException as e:
raise BranchAlreadyExistException(branch_name) from e
except ForbiddenException as e:
@@ -664,10 +667,11 @@ class RESTCatalog(Catalog):
raise IllegalArgumentError(str(e)) from e
def drop_branch(self, identifier: Union[str, Identifier], branch_name:
str) -> None:
+ from pypaimon.catalog.rest.native_branch import call_branch
if not isinstance(identifier, Identifier):
identifier = Identifier.from_string(identifier)
try:
- self.rest_api.drop_branch(identifier, branch_name)
+ call_branch(self, 'drop_branch', identifier, branch_name)
except NoSuchResourceException as e:
raise BranchNotExistException(branch_name) from e
except ForbiddenException as e:
@@ -675,32 +679,25 @@ class RESTCatalog(Catalog):
def rename_branch(self, identifier: Union[str, Identifier], from_branch:
str,
to_branch: str) -> None:
- if not isinstance(identifier, Identifier):
- identifier = Identifier.from_string(identifier)
- try:
- self.rest_api.rename_branch(identifier, from_branch, to_branch)
- except NoSuchResourceException as e:
- raise BranchNotExistException(from_branch) from e
- except AlreadyExistsException as e:
- raise BranchAlreadyExistException(to_branch) from e
- except ForbiddenException as e:
- raise TableNoPermissionException(identifier) from e
+ raise NotImplementedError('REST branch rename is not supported,
matching Java RESTCatalog.')
def fast_forward(self, identifier: Union[str, Identifier], branch_name:
str) -> None:
+ from pypaimon.catalog.rest.native_branch import call_branch
if not isinstance(identifier, Identifier):
identifier = Identifier.from_string(identifier)
try:
- self.rest_api.fast_forward(identifier, branch_name)
+ call_branch(self, 'fast_forward', identifier, branch_name)
except NoSuchResourceException as e:
raise BranchNotExistException(branch_name) from e
except ForbiddenException as e:
raise TableNoPermissionException(identifier) from e
def list_branches(self, identifier: Union[str, Identifier]) -> List[str]:
+ from pypaimon.catalog.rest.native_branch import call_branch
if not isinstance(identifier, Identifier):
identifier = Identifier.from_string(identifier)
try:
- return self.rest_api.list_branches(identifier)
+ return call_branch(self, 'list_branches', identifier)
except NoSuchResourceException as e:
raise TableNotExistException(identifier) from e
except ForbiddenException as e:
diff --git a/paimon-python/pypaimon/tests/api/test_branch_dto_serde.py
b/paimon-python/pypaimon/tests/api/test_branch_dto_serde.py
index a211c7af69..2b32d0a393 100644
--- a/paimon-python/pypaimon/tests/api/test_branch_dto_serde.py
+++ b/paimon-python/pypaimon/tests/api/test_branch_dto_serde.py
@@ -19,7 +19,7 @@
These tests pin the Python wire format to the Java contract so review
against ``paimon-api/.../rest/requests/CreateBranchRequest.java``,
-``RenameBranchRequest.java``, ``ForwardBranchRequest.java``,
+``ForwardBranchRequest.java``,
``responses/ListBranchesResponse.java`` and ``ResourcePaths.java`` does
not need to inspect a running mock server. They explicitly assert the
*exact* JSON field names and URL templates Java uses, and that
@@ -30,8 +30,7 @@ returns plain ``List<String>`` — pypaimon mirrors this).
import json
import unittest
-from pypaimon.api.api_request import (CreateBranchRequest,
ForwardBranchRequest,
- RenameBranchRequest)
+from pypaimon.api.api_request import CreateBranchRequest, ForwardBranchRequest
from pypaimon.api.api_response import ListBranchesResponse
from pypaimon.api.resource_paths import ResourcePaths
from pypaimon.common.json_util import JSON
@@ -56,14 +55,6 @@ class CreateBranchRequestSerdeTest(unittest.TestCase):
self.assertIsNone(parsed.get("fromTag"))
-class RenameBranchRequestSerdeTest(unittest.TestCase):
-
- def test_uses_to_branch_field(self):
- request = RenameBranchRequest(to_branch="b2")
- parsed = json.loads(JSON.to_json(request))
- self.assertEqual(parsed, {"toBranch": "b2"})
-
-
class ForwardBranchRequestSerdeTest(unittest.TestCase):
def test_serializes_empty_object(self):
@@ -110,12 +101,6 @@ class ResourcePathsBranchesTest(unittest.TestCase):
"/v1/mock/databases/db/tables/tbl/branches/b1",
)
- def test_rename_branch_url(self):
- self.assertEqual(
- self.paths.rename_branch("db", "tbl", "b1"),
- "/v1/mock/databases/db/tables/tbl/branches/b1/rename",
- )
-
def test_forward_branch_url(self):
self.assertEqual(
self.paths.forward_branch("db", "tbl", "b1"),
diff --git a/paimon-python/pypaimon/tests/branch/catalog_branch_manager_test.py
b/paimon-python/pypaimon/tests/branch/catalog_branch_manager_test.py
index dda8a85fc4..51338d3a63 100644
--- a/paimon-python/pypaimon/tests/branch/catalog_branch_manager_test.py
+++ b/paimon-python/pypaimon/tests/branch/catalog_branch_manager_test.py
@@ -54,6 +54,11 @@ class TestCatalogBranchManager(unittest.TestCase):
self.identifier, "test-branch", "tag-1"
)
+ def test_strict_create_does_not_require_list_permission(self):
+ self.catalog.list_branches.side_effect = AssertionError('Strict create
must not list branches')
+ self.branch_manager.create_branch('new-branch')
+ self.catalog.create_branch.assert_called_once_with(self.identifier,
'new-branch', None)
+
def test_create_branch_ignore_if_exists(self):
"""Test creating branch with ignore_if_exists flag."""
self.catalog.list_branches.return_value = ["existing-branch"]
diff --git a/paimon-python/pypaimon/tests/native_branch_write_test.py
b/paimon-python/pypaimon/tests/native_branch_write_test.py
index 38de93fd4e..da0945ab13 100644
--- a/paimon-python/pypaimon/tests/native_branch_write_test.py
+++ b/paimon-python/pypaimon/tests/native_branch_write_test.py
@@ -410,24 +410,26 @@ def
test_native_branch_lifecycle_preserves_histories(tmp_path, native_rest_catal
_write(main, [seed])
branch = _branch(catalog, main)
_write(branch, [added])
- catalog.rename_branch(main.identifier, 'dev', 'renamed')
+ name = 'dev' if rest else 'renamed'
+ if not rest:
+ catalog.rename_branch(main.identifier, 'dev', name)
catalog.rename_table(main.identifier, Identifier('default', 'renamed'))
main = catalog.get_table('default.renamed')
- branch = catalog.get_table(Identifier('default', 'renamed',
branch='renamed'))
+ branch = catalog.get_table(Identifier('default', 'renamed', branch=name))
_assert_rows(main, [seed])
_assert_rows(branch, [seed, added])
- assert catalog.list_branches(main.identifier) == ['renamed']
+ assert catalog.list_branches(main.identifier) == [name]
assert catalog.get_tag(main.identifier, 'base').snapshot.id == 1
with pytest.raises(TableNotExistException):
catalog.get_table('default.t')
third = {'id': 3, 'value': 30, 'p': 'b'}
_write(branch, [third])
_assert_rows(branch, [seed, added, third])
- catalog.drop_branch(main.identifier, 'renamed')
+ catalog.drop_branch(main.identifier, name)
assert catalog.list_branches(main.identifier) == []
_assert_rows(main, [seed])
- catalog.create_branch(main.identifier, 'renamed')
- empty = catalog.get_table(Identifier('default', 'renamed',
branch='renamed'))
+ catalog.create_branch(main.identifier, name)
+ empty = catalog.get_table(Identifier('default', 'renamed', branch=name))
_assert_rows(empty, [])
_write(empty, [added])
_assert_rows(empty, [added])
@@ -449,7 +451,7 @@ def
test_native_rest_branch_invalid_names_preserve_main(tmp_path, native_rest_ca
operation()
_assert_rows(main, [seed])
branch = _branch(catalog, main)
- with pytest.raises((BadRequestException, IllegalArgumentError,
ValueError)):
+ with pytest.raises(NotImplementedError):
catalog.rename_branch(main.identifier, 'dev', name)
_assert_rows(branch, [seed])
_assert_rows(main, [seed])
diff --git a/paimon-python/pypaimon/tests/native_rest_branch_test.py
b/paimon-python/pypaimon/tests/native_rest_branch_test.py
new file mode 100644
index 0000000000..8782030d93
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_rest_branch_test.py
@@ -0,0 +1,543 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""REST branch operations retain PyPaimon's contracts when Rust executes
them."""
+
+from contextlib import ExitStack, contextmanager
+from http.server import BaseHTTPRequestHandler, HTTPServer
+import json
+import threading
+from unittest.mock import patch
+from urllib.parse import parse_qsl, urlsplit
+
+import pyarrow as pa
+import pytest
+import requests
+
+from pypaimon import Schema
+from pypaimon.catalog.catalog_context import CatalogContext
+from pypaimon.catalog.rest.rest_catalog import RESTCatalog
+from pypaimon.api.api_response import ErrorResponse
+from pypaimon.api.auth.dlf_signer import DLFOpenApiV4Signer
+from pypaimon.api.token_loader import DLFToken
+from pypaimon.api.typedef import RESTAuthParameter
+from pypaimon.api.rest_exception import (
+ NoSuchResourceException, NotImplementedException, RESTException,
ServiceFailureException,
+)
+from pypaimon.catalog.catalog_exception import (
+ BranchAlreadyExistException, BranchNotExistException, IllegalArgumentError,
+ TableNotExistException, TableNoPermissionException, TagNotExistException,
+)
+from pypaimon.common.identifier import Identifier
+from pypaimon.tests.native_branch_write_test import _assert_rows, _branch,
_table, _write
+from pypaimon.tests.rest.rest_server import RESTCatalogServer
+
+pytestmark = pytest.mark.native_plan
+_OPERATIONS = ('create_branch', 'drop_branch', 'fast_forward', 'list_branches')
+
+
[email protected]
+def transport_server():
+ class Handler(BaseHTTPRequestHandler):
+ def operation(self):
+ size = int(self.headers.get('Content-Length', 0))
+ data = self.rfile.read(size).decode() if size else None
+ self.server.requests.append((self.command, self.path,
self.headers))
+ status = 200
+ if self.path.startswith('/v1/config'):
+ body = json.dumps({'defaults': {'prefix': 'test'}})
+ elif self.server.require_v4 and not
self.headers.get('Authorization', '').startswith('ACS4-HMAC-SHA256 '):
+ status, body = 403, json.dumps({'message': 'wrong signature
algorithm'})
+ elif self.server.temporary_status and self.server.branch_requests
== 0:
+ status = self.server.temporary_status
+ body = json.dumps({'message': 'temporary failure'})
+ self.server.branch_requests += 1
+ else:
+ body = json.dumps({'branches': ['dev']}) if self.command ==
'GET' else 'OK'
+ if self.server.require_v4:
+ signed_headers = {key.lower(): value for key, value in
self.headers.items()
+ if key.lower().startswith('x-acs-') or
key.lower() in ('host', 'content-type')}
+ url = urlsplit(self.path)
+ expected = DLFOpenApiV4Signer('cn-hangzhou').authorization(
+ RESTAuthParameter(self.command, url.path, data,
dict(parse_qsl(url.query))),
+ self.server.expected_token,
+ self.headers['Host'], signed_headers)
+ if self.headers.get('Authorization') != expected:
+ status, body = 403, json.dumps({'message': 'signature
mismatch'})
+ output = body.encode()
+ self.send_response(status)
+ self.send_header('Content-Length', str(len(output)))
+ self.end_headers()
+ self.wfile.write(output)
+
+ do_GET = do_POST = do_DELETE = operation
+
+ def log_message(self, *args):
+ pass
+
+ server = HTTPServer(('localhost', 0), Handler)
+ server.requests = []
+ server.require_v4 = False
+ server.temporary_status = server.branch_requests = 0
+ server.expected_token = DLFToken('test-key', 'test-secret', 'test-sts',
None)
+ thread = threading.Thread(target=server.serve_forever)
+ thread.start()
+ try:
+ yield server
+ finally:
+ server.shutdown()
+ server.server_close()
+ thread.join()
+
+
+def transport_catalog(server, **extra):
+ options = {
+ 'metastore': 'rest', 'uri': 'http://localhost:%d' %
server.server_port, 'warehouse': 'w',
+ 'token.provider': 'bear', 'token': 'test-token',
+ 'read.native.enabled': 'true', 'write.native.enabled': 'true',
+ }
+ options.update(extra)
+ return RESTCatalog(CatalogContext.create_from_options(options))
+
+
[email protected]('operation', _OPERATIONS)
+def test_openapi_v4_uses_native_signer(transport_server, operation):
+ server = transport_server
+ server.require_v4 = True
+ catalog = transport_catalog(server, **{
+ 'token.provider': 'dlf', 'dlf.region': 'cn-hangzhou',
'dlf.signing-algorithm': 'openapi-v4',
+ 'dlf.access-key-id': 'test-key', 'dlf.access-key-secret':
'test-secret',
+ 'dlf.security-token': 'test-sts',
+ })
+ catalog.rest_api.client.session.headers.update({
+ 'HOST': 'stale.example', 'X-ACS-DATE': 'stale', 'authorization':
'stale',
+ })
+ if operation in ('create_branch', 'fast_forward'):
+ catalog.rest_api.client.session.headers['Content-Type'] = 'text/plain'
+ server.requests.clear()
+ args = () if operation == 'list_branches' else ('dev 一/?#',)
+ if operation == 'create_branch':
+ args += ('源 一',)
+ with native_only(catalog):
+ result = getattr(catalog, operation)(Identifier('db 一/space', 'table
?$branch_dev'), *args)
+ assert result == (['dev'] if operation == 'list_branches' else None)
+ assert len(server.requests) == 1
+ headers = server.requests[0][2]
+ assert headers['Authorization'].startswith('ACS4-HMAC-SHA256 ')
+ for name in ('Authorization', 'Host', 'x-acs-date'):
+ assert len(headers.get_all(name)) == 1
+ if operation in ('create_branch', 'fast_forward'):
+ assert headers.get_all('Content-Type') == ['application/json']
+ assert headers['x-acs-security-token'] == 'test-sts'
+ assert headers['x-acs-action'] == {
+ 'create_branch': 'CreateBranch', 'drop_branch': 'DropBranch',
+ 'fast_forward': 'FastForwardBranch', 'list_branches': 'ListBranches',
+ }[operation]
+
+
[email protected]('operation', _OPERATIONS)
+def test_native_dlf_reuses_cached_credentials(transport_server, tmp_path,
operation):
+ server = transport_server
+ server.require_v4 = True
+ token_file = tmp_path / 'credentials.json'
+ token_file.write_text(json.dumps({
+ 'AccessKeyId': 'test-key', 'AccessKeySecret': 'test-secret',
+ 'SecurityToken': 'test-sts', 'Expiration': '2099-01-01T00:00:00Z',
+ }))
+ catalog = transport_catalog(server, **{
+ 'token.provider': 'dlf', 'dlf.region': 'cn-hangzhou',
'dlf.signing-algorithm': 'openapi-v4',
+ 'dlf.token-loader': 'local_file', 'dlf.token-path': str(token_file),
+ })
+ provider = catalog.rest_api.rest_auth_function.auth_provider
+ assert provider.token is not None # /config has loaded a valid cached STS.
+ token_file.unlink()
+ server.requests.clear()
+ args = () if operation == 'list_branches' else ('dev',)
+ with patch.object(provider.token_loader, 'load_token',
side_effect=AssertionError('unnecessary refresh')), \
+ native_only(catalog):
+ result = getattr(catalog, operation)('db.t', *args)
+ assert result == (['dev'] if operation == 'list_branches' else None)
+ assert len(server.requests) == 1
+ assert server.requests[0][2]['x-acs-security-token'] == 'test-sts'
+
+
+def test_native_dlf_refreshes_through_owning_provider(transport_server,
tmp_path):
+ server = transport_server
+ server.require_v4 = True
+ token_file = tmp_path / 'credentials.json'
+
+ def write_token(sts):
+ token_file.write_text(json.dumps({
+ 'AccessKeyId': 'test-key', 'AccessKeySecret': 'test-secret',
+ 'SecurityToken': sts, 'Expiration': '2099-01-01T00:00:00Z',
+ }))
+
+ write_token('test-sts')
+ catalog = transport_catalog(server, **{
+ 'token.provider': 'dlf', 'dlf.region': 'cn-hangzhou',
'dlf.signing-algorithm': 'openapi-v4',
+ 'dlf.token-loader': 'local_file', 'dlf.token-path': str(token_file),
+ })
+ provider = catalog.rest_api.rest_auth_function.auth_provider
+ provider.token.expiration_at_millis = 0
+ write_token('refreshed-sts')
+ server.expected_token = DLFToken('test-key', 'test-secret',
'refreshed-sts', None)
+ server.requests.clear()
+ with patch.object(provider.signer, 'authorization',
side_effect=AssertionError('Python signing')), \
+ patch.object(provider.token_loader, 'load_token',
wraps=provider.token_loader.load_token) as load, \
+ native_only(catalog):
+ assert catalog.list_branches('db.t') == ['dev']
+ token_file.unlink()
+ assert catalog.list_branches('db.t') == ['dev']
+ assert load.call_count == 1
+ assert len(server.requests) == 2
+ assert all(request[2]['x-acs-security-token'] == 'refreshed-sts' for
request in server.requests)
+
+
[email protected]('customization', ['hook', 'adapter', 'trust_env',
'timeout', 'ca_bundle'])
+def test_custom_session_keeps_owning_transport(transport_server, monkeypatch,
customization):
+ from pypaimon_rust.datafusion import PaimonCatalog
+
+ catalog = transport_catalog(transport_server)
+ client = catalog.rest_api.client
+ called = []
+
+ def hook(response, **kwargs):
+ called.append('hook')
+
+ class Adapter(requests.adapters.HTTPAdapter):
+ def send(self, request, **kwargs):
+ called.append('adapter')
+ return super().send(request, **kwargs)
+
+ if customization == 'hook':
+ client.session.hooks['response'].append(hook)
+ elif customization == 'adapter':
+ client.session.mount('http://', Adapter())
+ elif customization == 'trust_env':
+ client.session.trust_env = False
+ elif customization == 'timeout':
+ client._timeout = (12, 34)
+ else:
+ monkeypatch.setenv('REQUESTS_CA_BUNDLE', '/custom/ca.pem')
+ with patch('pypaimon_rust.datafusion.PaimonCatalog', wraps=PaimonCatalog)
as construct:
+ assert catalog.list_branches('db.t') == ['dev']
+ construct.assert_not_called()
+ assert called == ([customization] if customization in ('hook', 'adapter')
else [])
+
+
[email protected]('agent_header', ['User-Agent', 'user-agent',
'USER-AGENT'])
+def
test_native_preserves_session_headers_and_one_explicit_user_agent(transport_server,
agent_header):
+ catalog = transport_catalog(transport_server, **{'header.' + agent_header:
'explicit-agent'})
+ catalog.rest_api.client.session.headers['X-Tenant'] = 'tenant-a'
+ catalog.rest_api.client.session.headers['authorization'] = 'Bearer stale'
+ catalog.rest_api.client.session.headers['x-priority'] = 'session'
+ catalog.rest_api.rest_auth_function.init_header['X-Priority'] = 'request'
+ transport_server.requests.clear()
+ with native_only(catalog):
+ assert catalog.list_branches('db.t') == ['dev']
+ assert len(transport_server.requests) == 1
+ headers = transport_server.requests[0][2]
+ assert headers['X-Tenant'] == 'tenant-a'
+ assert headers.get_all('X-Priority') == ['request']
+ assert headers.get_all('User-Agent') == ['explicit-agent']
+ assert headers.get_all('Authorization') == ['Bearer test-token']
+
+
[email protected]('operation', ['create_branch', 'drop_branch',
'fast_forward'])
+def test_native_mutations_accept_plain_text_success(transport_server,
operation):
+ catalog = transport_catalog(transport_server)
+ transport_server.requests.clear()
+ with native_only(catalog):
+ assert getattr(catalog, operation)('db.t', 'dev') is None
+ assert len(transport_server.requests) == 1
+
+
[email protected]('status', [429, 503])
+def test_native_branch_listing_retries_temporary_status(transport_server,
status):
+ catalog = transport_catalog(transport_server)
+ transport_server.requests.clear()
+ transport_server.temporary_status = status
+ with native_only(catalog):
+ assert catalog.list_branches('db.t') == ['dev']
+ assert len(transport_server.requests) == 2
+ assert all(request[0] == 'GET' for request in transport_server.requests)
+
+
[email protected]
+def catalog(native_rest_catalog):
+ native_rest_catalog.context.options.to_map().update({
+ 'read.native.enabled': 'true', 'write.native.enabled': 'true',
+ })
+ return native_rest_catalog
+
+
+@contextmanager
+def native_only(catalog):
+ with ExitStack() as guards:
+ for name in _OPERATIONS:
+ guards.enter_context(patch.object(
+ catalog.rest_api, name, side_effect=AssertionError('Python
branch fallback: ' + name)))
+ yield
+
+
+def test_native_rest_branch_lifecycle_with_shared_data(tmp_path, catalog):
+ catalog, main = _table(tmp_path, catalog, True)
+ seed = dict(id=1, value=10, p='a')
+ added = dict(id=2, value=20, p='b')
+ _write(main, [seed])
+ with native_only(catalog):
+ empty = _branch(catalog, main, from_tag=False, name='empty')
+ _assert_rows(empty, [])
+ branch = _branch(catalog, main)
+ _write(branch, [added])
+ _assert_rows(main, [seed])
+ _assert_rows(branch, [seed, added])
+ assert catalog.list_branches(main.identifier) == ['dev', 'empty']
+ paths = {path for path in tmp_path.rglob('*.parquet')}
+ assert paths
+ catalog.fast_forward(main.identifier, 'dev')
+ catalog.drop_branch(main.identifier, 'dev')
+ catalog.drop_branch(main.identifier, 'empty')
+ assert catalog.list_branches(main.identifier) == []
+ assert all(path.exists() for path in paths)
+ catalog.create_branch(main.identifier, 'restored', 'base')
+ restored = catalog.get_table(Identifier('default', 't',
branch='restored'))
+ _assert_rows(restored, [seed])
+
+
+def test_native_branch_errors_keep_public_python_types(tmp_path, catalog):
+ catalog, table = _table(tmp_path, catalog, True)
+ with native_only(catalog):
+ catalog.create_branch(table.identifier, 'dev')
+ cases = [
+ (TableNotExistException, lambda:
catalog.create_branch('default.missing', 'dev')),
+ (TableNotExistException, lambda:
catalog.list_branches('default.missing')),
+ (TagNotExistException, lambda:
catalog.create_branch(table.identifier, 'tagged', 'missing')),
+ (BranchAlreadyExistException, lambda:
catalog.create_branch(table.identifier, 'dev')),
+ (BranchNotExistException, lambda:
catalog.drop_branch(table.identifier, 'missing')),
+ (BranchNotExistException, lambda:
catalog.fast_forward(table.identifier, 'missing')),
+ (IllegalArgumentError, lambda:
catalog.create_branch(table.identifier, 'main')),
+ ]
+ for expected, invoke in cases:
+ with pytest.raises(expected):
+ invoke()
+ manager = table.branch_manager()
+ manager.create_branch('dev', ignore_if_exists=True)
+ with pytest.raises(ValueError, match='already exists'):
+ manager.create_branch('dev')
+ with pytest.raises(ValueError, match="doesn't exist"):
+ manager.drop_branch('missing')
+
+
[email protected]('operation', _OPERATIONS)
[email protected]('name', ['.', '..', '', ' '])
+def test_invalid_resource_paths_send_no_requests(catalog, operation, name):
+ args = () if operation == 'list_branches' else ('dev',)
+ with patch('pypaimon.catalog.rest.native_branch._native_catalog',
+ side_effect=AssertionError('Invalid paths must be rejected
before preparing a backend')):
+ for identifier in (Identifier(name, 't'), Identifier('default', name)):
+ with pytest.raises(ValueError):
+ getattr(catalog, operation)(identifier, *args)
+ if operation in ('drop_branch', 'fast_forward'):
+ with pytest.raises(ValueError):
+ getattr(catalog, operation)('default.t', name)
+
+
+def test_native_catalog_constructor_uses_resolved_configuration(catalog):
+ from pypaimon_rust.datafusion import PaimonCatalog
+
+ with patch('pypaimon_rust.datafusion.PaimonCatalog', wraps=PaimonCatalog)
as construct:
+ with native_only(catalog):
+ # A valid missing table reaches the catalog, rather than failing
construction.
+ with pytest.raises(TableNotExistException):
+ catalog.list_branches('default.missing')
+ options = construct.call_args.args[0]
+ assert options['prefix'] == 'native-test'
+ assert options['metastore'] == 'rest'
+ assert construct.call_args.kwargs == {'config_required': False}
+
+
+def test_preparation_failure_can_use_python_before_mutation(tmp_path, catalog):
+ catalog, table = _table(tmp_path, catalog, True)
+ with patch('pypaimon_rust.datafusion.PaimonCatalog',
side_effect=ValueError('preparation failed')):
+ catalog.create_branch(table.identifier, 'dev')
+ with native_only(catalog):
+ assert catalog.list_branches(table.identifier) == ['dev']
+
+
+def test_fork_safety_failure_never_uses_python(catalog):
+ from pypaimon_rust import ForkSafetyError
+
+ error = ForkSafetyError('inherited native state')
+ with patch('pypaimon_rust.datafusion.PaimonCatalog', side_effect=error),
native_only(catalog):
+ with pytest.raises(ForkSafetyError) as raised:
+ catalog.list_branches('default.t')
+ assert raised.value is error
+
+
[email protected]('status,body,exception', [
+ (403, '{"message":"denied"}', TableNoPermissionException),
+ (429, 'too many requests', RESTException),
+ (502, 'bad gateway', RESTException),
+ (504, 'gateway timeout', RESTException),
+])
+def test_real_http_errors_preserve_python_exceptions(catalog, status, body,
exception):
+ # POST is deliberately used so neither HTTP client retries status
responses.
+ with patch.object(RESTCatalogServer, '_branches_handle',
return_value=(body, status)) as handle, \
+ native_only(catalog):
+ with pytest.raises(exception):
+ catalog.create_branch('default.t', 'dev')
+ assert handle.call_count == 1
+
+
+def test_native_response_parse_failure_is_a_rest_exception(catalog):
+ with patch.object(RESTCatalogServer, '_branches_handle',
return_value=('not JSON', 200)) as handle, \
+ native_only(catalog):
+ with pytest.raises(RESTException):
+ catalog.list_branches('default.t')
+ assert handle.call_count == 1
+
+
+def test_native_connection_failure_is_a_rest_exception(catalog):
+ import socket
+
+ with socket.socket() as listener:
+ listener.bind(('localhost', 0))
+ # A bound, non-listening port rejects connections without racing port
allocation.
+ catalog.rest_api.client.uri = 'http://localhost:%d' %
listener.getsockname()[1]
+ with native_only(catalog), pytest.raises(RESTException):
+ catalog.drop_branch('default.t', 'dev')
+
+
+def test_published_branch_survives_uncertain_response(tmp_path, catalog):
+ catalog, table = _table(tmp_path, catalog, True)
+ original = RESTCatalogServer._branches_handle
+ published = []
+
+ def publish_then_fail(server, method, data, identifier):
+ response = original(server, method, data, identifier)
+ if method == 'POST':
+ published.append(identifier)
+ return server._mock_response(ErrorResponse(None, None, 'uncertain
result', 500), 500)
+ return response
+
+ with patch.object(RESTCatalogServer, '_branches_handle',
publish_then_fail), native_only(catalog):
+ with pytest.raises(ServiceFailureException):
+ catalog.create_branch(table.identifier, 'dev')
+ assert catalog.list_branches(table.identifier) == ['dev']
+ assert published == [table.identifier]
+
+
+def test_custom_error_handler_uses_python_before_request(catalog):
+ from pypaimon.api.client import ErrorHandler
+
+ class CustomFailure(RESTException):
+ pass
+
+ class Handler(ErrorHandler):
+ def accept(self, error, request_id):
+ raise CustomFailure('custom error handler')
+
+ catalog.rest_api.client.set_error_handler(Handler())
+ with patch('pypaimon_rust.datafusion.PaimonCatalog',
side_effect=AssertionError('custom handler bypassed')):
+ with pytest.raises(CustomFailure, match='custom error handler'):
+ catalog.list_branches('default.missing')
+
+
+def test_native_keeps_live_endpoint_and_original_authentication(catalog):
+ # Config merging changes options without rebuilding Python's existing
client
+ # and provider. Delegation must keep their endpoint, credentials and
headers.
+ from pypaimon_rust.datafusion import PaimonCatalog
+
+ original_token = catalog.rest_api.rest_auth_function.auth_provider.token
+ catalog.rest_api.options.to_map().update({
+ 'token.provider': 'dlf', 'token': 'server-token', 'dlf.token-loader':
'ecs',
+ 'uri': 'http://invalid.example', 'header.X-Test': 'stale',
+ })
+ catalog.rest_api.rest_auth_function.init_header['X-Test'] = 'current'
+ catalog.rest_api.client.set_user_agent('branch-client')
+ with patch('pypaimon_rust.datafusion.PaimonCatalog', wraps=PaimonCatalog)
as construct, native_only(catalog):
+ with pytest.raises(TableNotExistException):
+ catalog.list_branches('default.missing')
+ options = construct.call_args.args[0]
+ assert options['token.provider'] == 'bear'
+ assert options['token'] == original_token
+ assert options['uri'] == catalog.rest_api.client.uri
+ assert 'dlf.token-loader' not in options
+ assert options['header.X-Test'] == 'current'
+ assert options['header.User-Agent'] == 'branch-client'
+
+
[email protected]('code,exception', [
+ (403, TableNoPermissionException), (500, ServiceFailureException), (501,
NotImplementedException),
+ (404, NoSuchResourceException),
+])
+def test_native_errors_are_translated_without_python_retry(catalog, code,
exception):
+ from pypaimon_rust.datafusion import CatalogError
+
+ error = CatalogError('native error')
+ error.code, error.resource_type, error.resource_name, error.message =
code, 'OTHER', 'unknown', 'native error'
+
+ class Native:
+ calls = 0
+
+ def create_branch(self, *args):
+ self.calls += 1
+ raise error
+
+ native = Native()
+ with patch('pypaimon.catalog.rest.native_branch._native_catalog',
return_value=(native, CatalogError)), \
+ native_only(catalog):
+ with pytest.raises(exception):
+ catalog.create_branch('default.t', 'dev')
+ assert native.calls == 1
+
+
+def test_native_branch_blob_files_survive_drop(tmp_path, catalog):
+ schema = pa.schema([('id', pa.int32()), ('payload', pa.large_binary())])
+ catalog.create_table('default.blobs', Schema.from_pyarrow_schema(schema,
options={
+ 'data-evolution.enabled': 'true', 'row-tracking.enabled': 'true',
'blob-field': 'payload',
+ 'write.native.enabled': 'true', 'commit.native.enabled': 'true',
+ }), False)
+ main = catalog.get_table('default.blobs')
+
+ def write(table, value):
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ writer.write_arrow(pa.table({'id': [value], 'payload': [b'payload'
* value]}, schema=schema))
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+
+ write(main, 1)
+ catalog.create_tag(main.identifier, 'base')
+ with native_only(catalog):
+ catalog.create_branch(main.identifier, 'dev', 'base')
+ branch = catalog.get_table(Identifier('default', 'blobs',
branch='dev'))
+ write(branch, 2)
+ files = set(tmp_path.rglob('*.blob'))
+ assert files
+ catalog.drop_branch(main.identifier, 'dev')
+ assert all(path.exists() for path in files)
+ catalog.create_branch(main.identifier, 'restored', 'base')
+ restored = catalog.get_table(Identifier('default', 'blobs',
branch='restored'))
+ builder = restored.new_read_builder()
+ assert
builder.new_read().to_arrow(builder.new_scan().plan().splits()).to_pydict() == {
+ 'id': [1], 'payload': [b'payload'],
+ }
diff --git a/paimon-python/pypaimon/tests/rest/dlf_signer_test.py
b/paimon-python/pypaimon/tests/rest/dlf_signer_test.py
index fcbb1347ad..17e380c7ca 100644
--- a/paimon-python/pypaimon/tests/rest/dlf_signer_test.py
+++ b/paimon-python/pypaimon/tests/rest/dlf_signer_test.py
@@ -548,7 +548,7 @@ class DLFSignerTest(unittest.TestCase):
def test_openapi_v4_unregistered_requests_have_no_action(self):
paths = ResourcePaths("clg-paimon-1")
self.assertIsNone(resolve_action("GET", paths.tables()))
- self.assertIsNone(resolve_action("POST", paths.rename_branch("db",
"t", "b")))
+ self.assertIsNone(resolve_action("POST", paths.branch("db", "t", "b")
+ "/rename"))
self.assertIsNone(resolve_action("PUT", paths.table("db", "t")))
self.assertIsNone(resolve_action("GET", ResourcePaths.config() + "/"))
self.assertIsNone(resolve_action("GET", "/v1//databases"))
diff --git a/paimon-python/pypaimon/tests/rest/rest_branch_test.py
b/paimon-python/pypaimon/tests/rest/rest_branch_test.py
index 5b13d6f195..ea99508568 100644
--- a/paimon-python/pypaimon/tests/rest/rest_branch_test.py
+++ b/paimon-python/pypaimon/tests/rest/rest_branch_test.py
@@ -17,6 +17,7 @@
import json
import unittest
+from unittest.mock import patch
import pyarrow as pa
@@ -79,6 +80,16 @@ class RESTCatalogBranchCRUDTest(RESTBaseTest):
self.rest_catalog.create_branch(
self._identifier(), "b1", tag_name="missing")
+ def test_create_branch_preserves_unknown_missing_resource(self):
+ from pypaimon.api.rest_exception import NoSuchResourceException
+
+ error = NoSuchResourceException('OTHER', 'resource', 'unknown resource
missing')
+ with patch('pypaimon.catalog.rest.native_branch._native_catalog',
return_value=None), \
+ patch.object(self.rest_catalog.rest_api, 'create_branch',
side_effect=error):
+ with self.assertRaises(NoSuchResourceException) as raised:
+ self.rest_catalog.create_branch(self._identifier(), 'new')
+ self.assertIs(raised.exception, error)
+
def test_branch_table_uses_branch_schema_manager(self):
identifier = self._identifier()
self.rest_catalog.create_branch(identifier, "b1")
@@ -157,27 +168,11 @@ class RESTCatalogBranchCRUDTest(RESTBaseTest):
self.assertEqual(
sorted(self.rest_catalog.list_branches(identifier)), ["b1", "b2"])
- def test_rename_branch_happy(self):
- identifier = self._identifier()
- self.rest_catalog.create_branch(identifier, "b1")
- self.rest_catalog.rename_branch(identifier, "b1", "b2")
- result = self.rest_catalog.list_branches(identifier)
- self.assertNotIn("b1", result)
- self.assertIn("b2", result)
-
- def test_rename_branch_to_existing_raises(self):
- identifier = self._identifier()
- self.rest_catalog.create_branch(identifier, "b1")
- self.rest_catalog.create_branch(identifier, "b2")
- with self.assertRaises(BranchAlreadyExistException) as cm:
- self.rest_catalog.rename_branch(identifier, "b1", "b2")
- self.assertEqual(cm.exception.branch, "b2")
-
- def test_rename_branch_from_missing_raises(self):
- identifier = self._identifier()
- with self.assertRaises(BranchNotExistException) as cm:
- self.rest_catalog.rename_branch(identifier, "absent", "b2")
- self.assertEqual(cm.exception.branch, "absent")
+ def test_rename_branch_is_unsupported_without_a_request(self):
+ with patch.object(self.rest_catalog.rest_api.client, 'post',
+ side_effect=AssertionError('REST rename must not
send a request')):
+ with self.assertRaises(NotImplementedError):
+ self.rest_catalog.rename_branch(self._identifier(), 'old',
'new')
def test_drop_branch_happy(self):
identifier = self._identifier()
diff --git a/paimon-python/pypaimon/tests/rest/rest_server.py
b/paimon-python/pypaimon/tests/rest/rest_server.py
index db5a88c0a2..e61ea4388b 100755
--- a/paimon-python/pypaimon/tests/rest/rest_server.py
+++ b/paimon-python/pypaimon/tests/rest/rest_server.py
@@ -35,7 +35,6 @@ from pypaimon.api.api_request import (AlterDatabaseRequest,
AlterTableRequest,
CreatePartitionsRequest,
CreateTableRequest, CreateTagRequest,
GrantPermissionRequest,
- RenameBranchRequest,
RenameTableRequest,
RevokePermissionRequest)
from pypaimon.api.api_response import (ConfigResponse,
CreatePartitionsResponse,
@@ -662,8 +661,6 @@ class RESTCatalogServer:
elif len(path_parts) == 6 and path_parts[3] == ResourcePaths.BRANCHES:
branch_name = RESTUtil.decode_string(path_parts[4])
sub = path_parts[5]
- if sub == ResourcePaths.RENAME:
- return self._branch_rename_handle(method, data,
lookup_identifier, branch_name)
if sub == ResourcePaths.FORWARD:
return self._branch_forward_handle(method, lookup_identifier,
branch_name)
return self._mock_response(ErrorResponse(None, None, "Not Found",
404), 404)
@@ -1163,28 +1160,6 @@ class RESTCatalogServer:
return self._mock_response("", 200)
return self._mock_response(ErrorResponse(None, None, "Method Not
Allowed", 405), 405)
- def _branch_rename_handle(self, method: str, data: str, identifier:
Identifier,
- from_branch: str) -> Tuple[str, int]:
- if method != "POST":
- return self._mock_response(ErrorResponse(None, None, "Method Not
Allowed", 405), 405)
- if identifier.get_full_name() not in self.table_metadata_store:
- raise TableNotExistException(identifier)
-
- store = self.branch_store.setdefault(identifier.get_full_name(), set())
- if from_branch not in store:
- raise BranchNotExistException(from_branch)
- request = JSON.from_json(data, RenameBranchRequest)
- self._validate_branch_name(from_branch)
- self._validate_branch_name(request.to_branch)
- if request.to_branch in store:
- raise BranchAlreadyExistException(request.to_branch)
- source = self._schema_manager(identifier, from_branch)
- target = self._schema_manager(identifier, request.to_branch)
- Path(source.branch_path).rename(target.branch_path)
- store.discard(from_branch)
- store.add(request.to_branch)
- return self._mock_response("", 200)
-
def _branch_forward_handle(self, method: str, identifier: Identifier,
branch_name: str) -> Tuple[str, int]:
if method != "POST":