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 220f0fe5fc [python] Keep REST catalog operations on Python (#10622)
220f0fe5fc is described below
commit 220f0fe5fc8fde017adf2e3f23e6075707b6c3c5
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Oct 17 13:32:36 2026 +0800
[python] Keep REST catalog operations on Python (#10622)
---
.github/workflows/ci-python.yml | 5 +-
paimon-python/README.md | 27 +-
paimon-python/pypaimon/api/api_request.py | 2 +-
paimon-python/pypaimon/api/rest_api.py | 38 +-
.../pypaimon/catalog/rest/native_branch.py | 137 ------
.../pypaimon/catalog/rest/rest_catalog.py | 12 +-
paimon-python/pypaimon/common/json_util.py | 2 +
.../pypaimon/tests/native_rest_branch_test.py | 543 ---------------------
.../pypaimon/tests/rest/rest_branch_test.py | 3 +-
.../tests/rest/rest_branch_transport_test.py | 266 ++++++++++
.../pypaimon/tests/rest/rest_version_test.py | 303 ++++++++++++
.../pypaimon/tests/rest/rollback_protocol_test.py | 20 +-
12 files changed, 613 insertions(+), 745 deletions(-)
diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml
index 00778ca3fc..b6a0fb9f81 100644
--- a/.github/workflows/ci-python.yml
+++ b/.github/workflows/ci-python.yml
@@ -231,11 +231,8 @@ jobs:
normalize_array_for_write,
read_arrow_schema,
)
- from pypaimon_rust.datafusion import CatalogError, PaimonCatalog,
ReadBuilder
+ from pypaimon_rust.datafusion import 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 0c88ad530e..7ea5a3c127 100644
--- a/paimon-python/README.md
+++ b/paimon-python/README.md
@@ -139,27 +139,12 @@ 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.
+# REST catalog management
+
+PyPaimon executes REST catalog operations through its Python REST client,
+including branch and tag management, snapshot loading and rollback. The client
+retains its authentication provider, request headers and transport settings.
+Native planning, reading, writing and committing remain available for table
data.
# Native write and commit
diff --git a/paimon-python/pypaimon/api/api_request.py
b/paimon-python/pypaimon/api/api_request.py
index c03b75319f..6ff6e08934 100644
--- a/paimon-python/pypaimon/api/api_request.py
+++ b/paimon-python/pypaimon/api/api_request.py
@@ -109,7 +109,7 @@ class RollbackTableRequest(RESTRequest):
FIELD_TABLE_ID = "tableId"
instant: Instant = json_field(FIELD_INSTANT)
- from_snapshot: Optional[int] = json_field(FIELD_FROM_SNAPSHOT)
+ from_snapshot: Optional[int] = optional_json_field(FIELD_FROM_SNAPSHOT,
"non_null")
table_id: Optional[str] = optional_json_field(FIELD_TABLE_ID, "non_null")
diff --git a/paimon-python/pypaimon/api/rest_api.py
b/paimon-python/pypaimon/api/rest_api.py
index b8148a1dfe..8564795490 100755
--- a/paimon-python/pypaimon/api/rest_api.py
+++ b/paimon-python/pypaimon/api/rest_api.py
@@ -128,10 +128,6 @@ 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],
@@ -142,11 +138,11 @@ class RESTApi:
if max_results is not None and max_results > 0:
query_params[RESTApi.MAX_RESULTS] = str(max_results)
- if page_token is not None and page_token.strip():
+ if page_token is not None:
query_params[RESTApi.PAGE_TOKEN] = page_token
for key, value in name_patterns.items():
- if key and value and key.strip() and value.strip():
+ if key and value:
query_params[key] = value
return query_params
@@ -423,7 +419,7 @@ class RESTApi:
NoSuchResourceException: If the table, snapshot or tag does not
exist.
ForbiddenException: If no permission to access this table.
"""
- database_name, table_name = self.__validate_identifier(identifier)
+ database_name, table_name = self._validate_resource_path(identifier)
request = RollbackTableRequest(
instant=instant, from_snapshot=from_snapshot, table_id=table_uuid)
self.client.post(
@@ -441,7 +437,7 @@ class RESTApi:
Returns:
TableSnapshot instance or None if snapshot not found.
"""
- database_name, table_name = self.__validate_identifier(identifier)
+ database_name, table_name = self._validate_resource_path(identifier)
response = self.client.get(
self.resource_paths.table_snapshot(database_name, table_name),
GetTableSnapshotResponse,
@@ -500,7 +496,7 @@ class RESTApi:
snapshot_id: Optional[int] = None,
time_retained: Optional[str] = None,
) -> None:
- database_name, table_name = self.__validate_identifier(identifier)
+ database_name, table_name = self._validate_resource_path(identifier)
request = CreateTagRequest(
tag_name=tag_name,
snapshot_id=snapshot_id,
@@ -513,7 +509,7 @@ class RESTApi:
)
def get_tag(self, identifier: Identifier, tag_name: str) -> GetTagResponse:
- database_name, table_name = self.__validate_identifier(identifier)
+ database_name, table_name = self._validate_resource_path(identifier,
tag_name)
return self.client.get(
self.resource_paths.tag(database_name, table_name, tag_name),
GetTagResponse,
@@ -527,7 +523,7 @@ class RESTApi:
page_token: Optional[str] = None,
tag_name_prefix: Optional[str] = None,
) -> PagedList[str]:
- database_name, table_name = self.__validate_identifier(identifier)
+ database_name, table_name = self._validate_resource_path(identifier)
response = self.client.get_with_params(
self.resource_paths.tags(database_name, table_name),
self.__build_paged_query_params(
@@ -538,11 +534,11 @@ class RESTApi:
ListTagsResponse,
self.rest_auth_function,
)
- tags = response.data() or []
- return PagedList(tags, response.get_next_page_token())
+ tags = response.data()
+ return PagedList([], None) if tags is None else PagedList(tags,
response.get_next_page_token())
def delete_tag(self, identifier: Identifier, tag_name: str) -> None:
- database_name, table_name = self.__validate_identifier(identifier)
+ database_name, table_name = self._validate_resource_path(identifier,
tag_name)
self.client.delete(
self.resource_paths.tag(database_name, table_name, tag_name),
self.rest_auth_function,
@@ -550,11 +546,11 @@ class RESTApi:
# Branch CRUD wrappers — mirror Java RESTApi branch methods.
@staticmethod
- def _validate_branch_path(identifier, branch_name=None):
+ def _validate_resource_path(identifier, resource_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'))
+ if resource_name is not None:
+ components.append((resource_name, 'Resource 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))
@@ -566,7 +562,7 @@ class RESTApi:
branch_name: str,
tag_name: Optional[str] = None,
) -> None:
- database_name, table_name = self._validate_branch_path(identifier)
+ database_name, table_name = self._validate_resource_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)
@@ -577,14 +573,14 @@ class RESTApi:
)
def drop_branch(self, identifier: Identifier, branch_name: str) -> None:
- database_name, table_name = self._validate_branch_path(identifier,
branch_name)
+ database_name, table_name = self._validate_resource_path(identifier,
branch_name)
self.client.delete(
self.resource_paths.branch(database_name, table_name, branch_name),
self.rest_auth_function,
)
def fast_forward(self, identifier: Identifier, branch_name: str) -> None:
- database_name, table_name = self._validate_branch_path(identifier,
branch_name)
+ database_name, table_name = self._validate_resource_path(identifier,
branch_name)
self.client.post(
self.resource_paths.forward_branch(database_name, table_name,
branch_name),
ForwardBranchRequest(),
@@ -592,7 +588,7 @@ class RESTApi:
)
def list_branches(self, identifier: Identifier) -> List[str]:
- database_name, table_name = self._validate_branch_path(identifier)
+ database_name, table_name = self._validate_resource_path(identifier)
response = self.client.get(
self.resource_paths.branches(database_name, table_name),
ListBranchesResponse,
diff --git a/paimon-python/pypaimon/catalog/rest/native_branch.py
b/paimon-python/pypaimon/catalog/rest/native_branch.py
deleted file mode 100644
index 4d46b19f18..0000000000
--- a/paimon-python/pypaimon/catalog/rest/native_branch.py
+++ /dev/null
@@ -1,137 +0,0 @@
-# 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 6ae9e6fd24..a7069d5345 100644
--- a/paimon-python/pypaimon/catalog/rest/rest_catalog.py
+++ b/paimon-python/pypaimon/catalog/rest/rest_catalog.py
@@ -648,11 +648,10 @@ 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:
- call_branch(self, 'create_branch', identifier, branch_name,
tag_name)
+ self.rest_api.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
@@ -667,11 +666,10 @@ 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:
- call_branch(self, 'drop_branch', identifier, branch_name)
+ self.rest_api.drop_branch(identifier, branch_name)
except NoSuchResourceException as e:
raise BranchNotExistException(branch_name) from e
except ForbiddenException as e:
@@ -682,22 +680,20 @@ class RESTCatalog(Catalog):
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:
- call_branch(self, 'fast_forward', identifier, branch_name)
+ self.rest_api.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 call_branch(self, 'list_branches', identifier)
+ return self.rest_api.list_branches(identifier)
except NoSuchResourceException as e:
raise TableNotExistException(identifier) from e
except ForbiddenException as e:
diff --git a/paimon-python/pypaimon/common/json_util.py
b/paimon-python/pypaimon/common/json_util.py
index df9c95427c..15e8d61632 100644
--- a/paimon-python/pypaimon/common/json_util.py
+++ b/paimon-python/pypaimon/common/json_util.py
@@ -70,6 +70,8 @@ class JSON:
def from_json(json_str: str, target_class: Type[T]) -> T:
"""Create instance from JSON string"""
data = json.loads(json_str)
+ if data is None:
+ return None
return JSON.__from_dict(data, target_class)
@staticmethod
diff --git a/paimon-python/pypaimon/tests/native_rest_branch_test.py
b/paimon-python/pypaimon/tests/native_rest_branch_test.py
deleted file mode 100644
index 8782030d93..0000000000
--- a/paimon-python/pypaimon/tests/native_rest_branch_test.py
+++ /dev/null
@@ -1,543 +0,0 @@
-# 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/rest_branch_test.py
b/paimon-python/pypaimon/tests/rest/rest_branch_test.py
index ea99508568..cebfcd49b5 100644
--- a/paimon-python/pypaimon/tests/rest/rest_branch_test.py
+++ b/paimon-python/pypaimon/tests/rest/rest_branch_test.py
@@ -84,8 +84,7 @@ class RESTCatalogBranchCRUDTest(RESTBaseTest):
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 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)
diff --git a/paimon-python/pypaimon/tests/rest/rest_branch_transport_test.py
b/paimon-python/pypaimon/tests/rest/rest_branch_transport_test.py
new file mode 100644
index 0000000000..304979fd53
--- /dev/null
+++ b/paimon-python/pypaimon/tests/rest/rest_branch_transport_test.py
@@ -0,0 +1,266 @@
+# 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 catalog metadata stays on Python even when Native table execution is
enabled."""
+
+from contextlib import contextmanager
+from http.server import BaseHTTPRequestHandler, HTTPServer
+import builtins
+import json
+import threading
+from unittest.mock import patch
+from urllib.parse import parse_qsl, urlsplit
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import Schema
+from pypaimon.api.api_response import ErrorResponse
+from pypaimon.api.auth.dlf_signer import DLFOpenApiV4Signer
+from pypaimon.api.rest_exception import ServiceFailureException
+from pypaimon.api.token_loader import DLFToken
+from pypaimon.api.typedef import RESTAuthParameter
+from pypaimon.catalog.catalog_context import CatalogContext
+from pypaimon.catalog.rest.rest_catalog import RESTCatalog
+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
+
+
[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))
+ self.server.bodies.append(json.loads(data) if data else None)
+ 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
+ elif self.server.respond is not None:
+ status, body = self.server.respond(self.command, self.path,
data)
+ 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, keep_blank_values=True))),
+ 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.bodies = []
+ server.respond = None
+ 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))
+
+
+@contextmanager
+def python_rest_only():
+ original_import = builtins.__import__
+
+ def checked_import(name, *args, **kwargs):
+ if name == 'pypaimon_rust' or name.startswith('pypaimon_rust.'):
+ raise AssertionError('REST catalog operations must use the Python
client')
+ return original_import(name, *args, **kwargs)
+
+ with patch('builtins.__import__', side_effect=checked_import):
+ yield
+
+
[email protected]('operation', ['create_branch', 'drop_branch',
'fast_forward', 'list_branches'])
+def
test_branch_operations_use_python_with_native_options_enabled(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',
+ })
+ args = () if operation == 'list_branches' else ('dev 一/?#',)
+ server.requests.clear()
+ with python_rest_only(), \
+ patch.object(catalog.rest_api, operation,
wraps=getattr(catalog.rest_api, operation)) as execute:
+ result = getattr(catalog, operation)(Identifier('db 一/space',
'table'), *args)
+ execute.assert_called_once()
+ assert result == (['dev'] if operation == 'list_branches' else None)
+ assert len(server.requests) == 1
+ assert server.requests[0][2]['x-acs-action'] == {
+ 'create_branch': 'CreateBranch', 'drop_branch': 'DropBranch',
+ 'fast_forward': 'FastForwardBranch', 'list_branches': 'ListBranches',
+ }[operation]
+
+
[email protected]('operation', ['create_branch', 'drop_branch',
'fast_forward'])
+def test_failed_branch_mutations_are_not_replayed(transport_server, operation):
+ server = transport_server
+ catalog = transport_catalog(server)
+ server.respond = lambda *args: (500, '{"message":"uncertain publication"}')
+ server.requests.clear()
+ with python_rest_only(), pytest.raises(ServiceFailureException):
+ getattr(catalog, operation)('db.table', 'dev')
+ assert len(server.requests) == 1
+
+
[email protected]('status', [429, 503])
+def test_branch_listing_retries_temporary_status(transport_server, status):
+ catalog = transport_catalog(transport_server)
+ transport_server.requests.clear()
+ transport_server.temporary_status = status
+ with python_rest_only():
+ assert catalog.list_branches('db.table') == ['dev']
+ assert len(transport_server.requests) == 2
+ assert all(request[0] == 'GET' for request in transport_server.requests)
+
+
[email protected]('operation', ['create_branch', 'drop_branch',
'fast_forward', 'list_branches'])
[email protected]('name', ['.', '..', '', ' '])
+def test_invalid_branch_resource_paths_send_no_requests(transport_server,
operation, name):
+ catalog = transport_catalog(transport_server)
+ transport_server.requests.clear()
+ args = () if operation == 'list_branches' else ('dev',)
+ for identifier in (Identifier(name, 'table'), Identifier('db', name)):
+ with pytest.raises(ValueError):
+ getattr(catalog, operation)(identifier, *args)
+ if operation in ('drop_branch', 'fast_forward'):
+ with pytest.raises(ValueError):
+ getattr(catalog, operation)('db.table', name)
+ assert transport_server.requests == []
+
+
[email protected]_plan
+def test_python_rest_branches_preserve_native_shared_data(tmp_path,
native_rest_catalog):
+ catalog = native_rest_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])
+ 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])
+
+
[email protected]_plan
+def test_published_branch_survives_uncertain_response(tmp_path,
native_rest_catalog):
+ catalog = native_rest_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):
+ with pytest.raises(ServiceFailureException):
+ catalog.create_branch(table.identifier, 'dev')
+ assert catalog.list_branches(table.identifier) == ['dev']
+ assert published == [table.identifier]
+
+
[email protected]_plan
+def test_python_rest_branch_drop_preserves_native_blob_files(tmp_path,
native_rest_catalog):
+ catalog = native_rest_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')
+ 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/rest_version_test.py
b/paimon-python/pypaimon/tests/rest/rest_version_test.py
new file mode 100644
index 0000000000..19bff6433f
--- /dev/null
+++ b/paimon-python/pypaimon/tests/rest/rest_version_test.py
@@ -0,0 +1,303 @@
+# 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 version management uses the Python client and Java wire semantics."""
+
+import json
+from unittest.mock import patch
+from urllib.parse import parse_qs, unquote_plus, urlsplit
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import Schema
+from pypaimon.api.rest_exception import BadRequestException,
NotImplementedException, ServiceFailureException
+from pypaimon.catalog.catalog_exception import (
+ TableNotExistException, TableNoPermissionException,
TagAlreadyExistException, TagNotExistException,
+)
+from pypaimon.common.identifier import Identifier
+from pypaimon.common.json_util import JSON
+from pypaimon.table.instant import Instant
+from pypaimon.tests.rest import rest_branch_transport_test as branch_fixtures
+
+transport_server = branch_fixtures.transport_server
+_OPERATIONS = ('create_tag', 'get_tag', 'list_tags_paged', 'delete_tag',
'load_snapshot', 'rollback_to')
+_SNAPSHOT = dict(version=3, id=7, schemaId=0, baseManifestList='base',
deltaManifestList='delta',
+ commitUser='writer', commitIdentifier=1, commitKind='APPEND',
timeMillis=123,
+ uuid='snapshot-uuid', totalRecordCount=8, deltaRecordCount=1,
properties={'source': 'test'},
+ baseManifestListSize=321, deltaManifestListSize=0)
+
+
[email protected]
+def version_server(transport_server, tmp_path):
+ server = transport_server
+ server.require_v4 = True
+ server.uuid, server.head, server.status = 'incarnation-A', 9, None
+ server.tags, server.next_token = ['z', 'a', 'z'], 'next +/%一'
+ server.error_resource, server.error_name = None, None
+ server.snapshot_response = dict(snapshot=dict(
+ snapshot=_SNAPSHOT, recordCount=0, fileCount=2, fileSizeInBytes=123,
lastFileCreationTime=456))
+ schema = Schema.from_pyarrow_schema(pa.schema([('id', pa.int64())]))
+
+ def respond(method, path, data):
+ if server.status is not None:
+ return server.status, json.dumps(dict(
+ resourceType=server.error_resource,
resourceName=server.error_name, message='rejected'))
+ parsed = urlsplit(path)
+ if parsed.path.endswith('/rollback'):
+ body = json.loads(data)
+ if 'tableId' in body and body['tableId'] != server.uuid:
+ return 400, json.dumps({'message': 'table UUID mismatch'})
+ if 'fromSnapshot' in body and body['fromSnapshot'] != server.head:
+ return 400, json.dumps({'message': 'snapshot head mismatch'})
+ instant = body['instant']
+ server.head = instant['snapshotId'] if instant['type'] ==
'snapshot' else 7
+ return 200, 'rolled back'
+ if parsed.path.endswith('/snapshot'):
+ return 200, json.dumps(server.snapshot_response)
+ if parsed.path.endswith('/tags'):
+ return (200, json.dumps(dict(tags=server.tags,
nextPageToken=server.next_token))) \
+ if method == 'GET' else (200, 'created')
+ if '/tags/' in parsed.path:
+ return (200, json.dumps(dict(
+ tagName=unquote_plus(parsed.path.rsplit('/', 1)[1]),
snapshot=_SNAPSHOT,
+ tagCreateTime=123, tagTimeRetained='2 d'))) if method == 'GET'
else (200, 'deleted')
+ return 200, json.dumps(dict(
+ id=server.uuid, name=unquote_plus(parsed.path.rsplit('/', 1)[1]),
+ schema=json.loads(JSON.to_json(schema)), schemaId=0,
path=tmp_path.as_uri(), isExternal=False))
+
+ server.respond = respond
+ return server
+
+
+def make_catalog(server, native=True):
+ return branch_fixtures.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',
+ 'read.native.enabled': str(native).lower(), 'write.native.enabled':
str(native).lower(),
+ })
+
+
+def invoke(catalog, operation, identifier=Identifier('db /一', 'table',
branch='dev /一')):
+ if operation == 'create_tag':
+ return catalog.create_tag(identifier, 'release /一', 7, '2 d')
+ if operation in ('get_tag', 'delete_tag'):
+ return getattr(catalog, operation)(identifier, 'release /一')
+ if operation == 'list_tags_paged':
+ return catalog.list_tags_paged(identifier, 3, 'next +/%一', 'prefix
+/%一')
+ if operation == 'rollback_to':
+ return catalog.rollback_to(identifier, Instant.snapshot(7), 9,
table_uuid='incarnation-A')
+ return catalog.load_snapshot(identifier)
+
+
[email protected]('operation', _OPERATIONS)
+def
test_version_operations_use_python_with_v4_and_native_options(version_server,
operation):
+ server = version_server
+ catalog = make_catalog(server)
+ server.requests.clear()
+ server.bodies.clear()
+ with branch_fixtures.python_rest_only(), \
+ patch.object(catalog.rest_api, operation,
wraps=getattr(catalog.rest_api, operation)) as execute:
+ result = invoke(catalog, operation)
+ execute.assert_called_once()
+ assert len(server.requests) == 1
+ method, path, headers = server.requests[0]
+ assert headers['Authorization'].startswith('ACS4-HMAC-SHA256 ')
+ assert headers['x-acs-action'] == {
+ 'create_tag': 'CreateTag', 'get_tag': 'GetTag', 'list_tags_paged':
'ListTags',
+ 'delete_tag': 'DropTag', 'load_snapshot': 'GetTableSnapshot',
'rollback_to': 'RollbackToSnapshot',
+ }[operation]
+ assert '/tables/table%24branch_dev+%2F%E4%B8%80/' in path
+ if operation == 'create_tag':
+ assert server.bodies == [dict(tagName='release /一', snapshotId=7,
timeRetained='2 d')]
+ elif operation == 'rollback_to':
+ assert server.bodies == [dict(
+ instant=dict(type='snapshot', snapshotId=7), fromSnapshot=9,
tableId='incarnation-A')]
+ assert server.head == 7
+ elif operation == 'get_tag':
+ assert result.tag_name == 'release /一'
+ assert result.snapshot.id == 7
+ assert result.snapshot.uuid == 'snapshot-uuid'
+ assert (result.snapshot.base_manifest_list_size,
result.snapshot.delta_manifest_list_size) == (321, 0)
+ assert result.tag_create_time == 123 and result.tag_time_retained ==
'2 d'
+ elif operation == 'list_tags_paged':
+ assert result.elements == ['z', 'a', 'z'] and result.next_page_token
== 'next +/%一'
+ assert parse_qs(urlsplit(path).query) == {
+ 'maxResults': ['3'], 'pageToken': ['next +/%一'], 'tagNamePrefix':
['prefix +/%一']}
+ elif operation == 'load_snapshot':
+ assert result.snapshot.id == 7 and result.snapshot.uuid ==
'snapshot-uuid'
+ assert (result.snapshot.base_manifest_list_size,
result.snapshot.delta_manifest_list_size) == (321, 0)
+ assert (result.record_count, result.file_count,
result.file_size_in_bytes,
+ result.last_file_creation_time) == (0, 2, 123, 456)
+ else:
+ assert result is None and method == 'DELETE'
+
+
[email protected]('native', [False, True])
[email protected]('tags,expected_token', [(None, None), ([], 'next
+/%一')])
+def
test_null_tag_page_ends_paging_but_empty_page_preserves_token(version_server,
native, tags, expected_token):
+ server = version_server
+ catalog = make_catalog(server, native)
+ server.tags = tags
+ server.requests.clear()
+ page = catalog.list_tags_paged('db.table', 0, '', ' ')
+ assert page.elements == [] and page.next_page_token == expected_token
+ assert parse_qs(urlsplit(server.requests[0][1]).query,
keep_blank_values=True) == {
+ 'pageToken': [''], 'tagNamePrefix': [' ']}
+
+
[email protected]('native', [False, True])
[email protected]('instant', [Instant.snapshot(7), Instant.tag('release
/一')])
+def test_optional_rollback_guards_are_omitted_on_both_paths(version_server,
native, instant):
+ server = version_server
+ catalog = make_catalog(server, native)
+ server.bodies.clear()
+ catalog.rollback_to('db.table', instant)
+ assert server.bodies == [{'instant': instant.to_dict()}]
+
+
[email protected]('operation', _OPERATIONS)
[email protected]('status', [403, 404])
+def test_version_errors_keep_public_exception_types(version_server, operation,
status):
+ server = version_server
+ catalog = make_catalog(server)
+ server.requests.clear()
+ server.status, server.error_resource, server.error_name = status, 'TABLE',
'db.table'
+ expected = TableNoPermissionException if status == 403 else
TableNotExistException
+ with branch_fixtures.python_rest_only():
+ with pytest.raises(expected):
+ invoke(catalog, operation)
+ assert len(server.requests) == 1
+
+
[email protected]('operation,resource,expected', [
+ ('create_tag', 'TAG', TagAlreadyExistException),
+ ('create_tag', 'SNAPSHOT', ValueError),
+ ('get_tag', 'TAG', TagNotExistException),
+ ('delete_tag', 'TAG', TagNotExistException),
+ ('rollback_to', 'TAG', ValueError),
+ ('rollback_to', 'SNAPSHOT', ValueError),
+])
+def test_missing_tag_and_snapshot_errors_remain_distinct(version_server,
operation, resource, expected):
+ server = version_server
+ catalog = make_catalog(server)
+ server.status = 409 if expected is TagAlreadyExistException else 404
+ server.error_resource, server.error_name = resource, '7' if resource ==
'SNAPSHOT' else 'release /一'
+ with pytest.raises(expected):
+ invoke(catalog, operation)
+
+
[email protected]('operation', ['create_tag', 'delete_tag',
'rollback_to'])
+def
test_failure_after_publication_has_no_request_replay_or_local_cleanup(version_server,
operation, tmp_path):
+ server = version_server
+ catalog = make_catalog(server)
+ sentinel = tmp_path / 'shared-data.parquet'
+ sentinel.write_bytes(b'existing data')
+ server.status = 500
+ server.requests.clear()
+ with branch_fixtures.python_rest_only():
+ with pytest.raises(ServiceFailureException):
+ invoke(catalog, operation)
+ assert len(server.requests) == 1
+ assert sentinel.read_bytes() == b'existing data'
+
+
[email protected]('rejection', ['identity', 'head'])
+def
test_loaded_table_rollback_preserves_identity_and_never_reloads_on_rejection(version_server,
rejection):
+ server = version_server
+ catalog = make_catalog(server)
+ table = catalog.get_table('db.table')
+ server.requests.clear()
+ server.bodies.clear()
+ if rejection == 'identity':
+ server.uuid = 'incarnation-B'
+ with patch.object(table, 'rollback_helper',
side_effect=AssertionError('local cleanup')):
+ if rejection == 'identity':
+ with pytest.raises(RuntimeError, match='table UUID mismatch'):
+ table.rollback_to(7)
+ else:
+ with pytest.raises(BadRequestException, match='snapshot head
mismatch'):
+ catalog.rollback_to('db.table', Instant.snapshot(7), 8,
table_uuid='incarnation-A')
+ assert len(server.requests) == 1
+ assert server.bodies[0]['tableId'] == 'incarnation-A'
+ assert server.head == 9
+
+
[email protected]('native', [False, True])
[email protected]('response', [None, {}, {'snapshot': None}])
+def test_empty_snapshot_responses_are_empty_views(version_server, native,
response):
+ server = version_server
+ catalog = make_catalog(server, native)
+ server.snapshot_response = response
+ assert catalog.load_snapshot('db.table') is None
+
+
+def test_empty_snapshot_error_is_not_a_missing_table(version_server):
+ server = version_server
+ catalog = make_catalog(server)
+ server.status, server.error_resource = 404, 'SNAPSHOT'
+ assert catalog.load_snapshot('db.table') is None
+
+
[email protected]('operation', _OPERATIONS)
+def
test_unimplemented_version_endpoints_keep_rest_exception_and_never_replay(version_server,
operation):
+ server = version_server
+ catalog = make_catalog(server)
+ server.status = 501
+ server.requests.clear()
+ with branch_fixtures.python_rest_only():
+ with pytest.raises(NotImplementedException):
+ invoke(catalog, operation)
+ assert len(server.requests) == 1
+
+
[email protected]('native', [False, True])
[email protected]('snapshot_name', ['null', None])
+def test_create_tag_on_empty_table_preserves_missing_snapshot(version_server,
native, snapshot_name):
+ server = version_server
+ catalog = make_catalog(server, native)
+ server.status, server.error_resource, server.error_name = 404, 'SNAPSHOT',
snapshot_name
+ server.requests.clear()
+ with pytest.raises(ValueError, match='Snapshot'):
+ catalog.create_tag('db.table', 'release')
+ assert len(server.requests) == 1
+
+
[email protected]('native', [False, True])
[email protected]('operation', ['create_tag', 'get_tag', 'delete_tag'])
[email protected]('resource', ['tag', 'snapshot'])
+def test_resource_types_use_java_case_sensitive_names(version_server, native,
operation, resource):
+ server = version_server
+ catalog = make_catalog(server, native)
+ server.status, server.error_resource, server.error_name = 404, resource,
'7'
+ with pytest.raises(TableNotExistException):
+ invoke(catalog, operation)
+
+
[email protected]('operation', _OPERATIONS)
+def test_invalid_version_resource_paths_send_no_requests(version_server,
operation):
+ catalog = make_catalog(version_server)
+ version_server.requests.clear()
+ with branch_fixtures.python_rest_only():
+ for identifier in (Identifier('..', 'table'), Identifier('db', '.')):
+ with pytest.raises(ValueError):
+ invoke(catalog, operation, identifier)
+ if operation in ('get_tag', 'delete_tag'):
+ for name in ('.', '..'):
+ with pytest.raises(ValueError):
+ getattr(catalog, operation)('db.table', name)
+ assert version_server.requests == []
diff --git a/paimon-python/pypaimon/tests/rest/rollback_protocol_test.py
b/paimon-python/pypaimon/tests/rest/rollback_protocol_test.py
index 56ac97c37b..2790806373 100644
--- a/paimon-python/pypaimon/tests/rest/rollback_protocol_test.py
+++ b/paimon-python/pypaimon/tests/rest/rollback_protocol_test.py
@@ -75,6 +75,9 @@ def catalog(transport):
'metastore': 'rest', 'uri': 'http://127.0.0.1:1', 'warehouse':
'warehouse',
'prefix': 'rollback-test', 'token.provider': 'bear', 'token':
'test-token',
'data-token.enabled': 'false',
+ # This suite spies on requests.Session.send. Native wire behavior is
+ # covered by the real HTTP Native version-management tests.
+ 'read.native.enabled': 'false', 'write.native.enabled': 'false',
})), config_required=False)
@@ -108,9 +111,9 @@ def test_request_serialization(table_uuid):
assert json.loads(JSON.to_json(request)) == expected
-def test_legacy_request_constructor():
+def test_unguarded_request_omits_absent_guard():
assert json.loads(JSON.to_json(RollbackTableRequest(Instant.snapshot(1),
None))) == {
- 'instant': {'type': 'snapshot', 'snapshotId': 1}, 'fromSnapshot': None,
+ 'instant': {'type': 'snapshot', 'snapshotId': 1},
}
@@ -124,7 +127,7 @@ def test_table_rollback_transmits_loaded_uuid(table,
transport, target, instant)
assert len(transport.requests) == 1
assert transport.requests[0].method == 'POST'
assert request_body(transport.requests[0]) == {
- 'instant': instant, 'fromSnapshot': None, 'tableId':
'loaded-table-uuid',
+ 'instant': instant, 'tableId': 'loaded-table-uuid',
}
@@ -185,8 +188,9 @@ def test_existing_calls_and_encoded_branch_url(catalog,
transport):
catalog.rollback_to(identifier, Instant.snapshot(1), 2)
catalog.rollback_to('db.table', Instant.tag('v1'))
- assert [request_body(request)['fromSnapshot'] for request in
transport.requests] == [None, 2, None]
- assert all(set(request_body(request)) == {'instant', 'fromSnapshot'} for
request in transport.requests)
+ assert [request_body(request).get('fromSnapshot') for request in
transport.requests] == [None, 2, None]
+ assert [set(request_body(request)) for request in transport.requests] == [
+ {'instant'}, {'instant', 'fromSnapshot'}, {'instant'}]
path = urlsplit(transport.requests[0].url).path.split('/')
assert len(path) == 8
assert unquote_plus(path[4]) == identifier.get_database_name()
@@ -203,7 +207,7 @@ def test_table_rollback_without_uuid(catalog, transport):
assert len(transport.requests) == 1
assert request_body(transport.requests[0]) == {
- 'instant': {'type': 'snapshot', 'snapshotId': 1}, 'fromSnapshot': None,
+ 'instant': {'type': 'snapshot', 'snapshotId': 1},
}
@@ -293,7 +297,7 @@ def
test_copied_branch_rollback_omits_source_identity(catalog, transport, compac
assert len(transport.requests) == 1
assert request_body(transport.requests[0]) == {
'instant': {'type': 'snapshot', 'snapshotId': 2 if automatic else 1},
- 'fromSnapshot': 3 if automatic else None,
+ **({'fromSnapshot': 3} if automatic else {}),
}
object_name =
unquote_plus(urlsplit(transport.requests[0].url).path.split('/')[-2])
assert object_name == Identifier('db', 'table',
branch=target).get_object_name()
@@ -325,7 +329,7 @@ def
test_same_branch_copy_and_independent_load_preserve_identity(table, catalog,
assert len(transport.requests) == 1
assert request_body(transport.requests[0]) == {
'instant': {'type': 'snapshot', 'snapshotId': 2 if automatic else 1},
- 'fromSnapshot': 3 if automatic else None, 'tableId': 'loaded-dev-uuid',
+ **({'fromSnapshot': 3} if automatic else {}), 'tableId':
'loaded-dev-uuid',
}
object_name =
unquote_plus(urlsplit(transport.requests[0].url).path.split('/')[-2])
assert object_name == Identifier('db', 'table',
branch='dev').get_object_name()