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 f5bb59de3b [python] Add tests for Data Evolution BLOB writes on 
branches (#9221)
f5bb59de3b is described below

commit f5bb59de3bb1aa1b5c9a6e7c4def4ab75657b642
Author: XiaoHongbo <[email protected]>
AuthorDate: Sat Aug 15 20:17:26 2026 +0800

    [python] Add tests for Data Evolution BLOB writes on branches (#9221)
---
 .../tests/filesystem_catalog_branch_test.py        | 58 +++++++++++++++++
 .../pypaimon/tests/rest/rest_branch_test.py        | 76 +++++++++++++++++++++-
 paimon-python/pypaimon/tests/rest/rest_server.py   | 49 +++++++++-----
 3 files changed, 166 insertions(+), 17 deletions(-)

diff --git a/paimon-python/pypaimon/tests/filesystem_catalog_branch_test.py 
b/paimon-python/pypaimon/tests/filesystem_catalog_branch_test.py
index 4bf22b85aa..b1fe5cb681 100644
--- a/paimon-python/pypaimon/tests/filesystem_catalog_branch_test.py
+++ b/paimon-python/pypaimon/tests/filesystem_catalog_branch_test.py
@@ -73,6 +73,21 @@ class FileSystemCatalogBranchCRUDTest(unittest.TestCase):
     def tearDown(self):
         shutil.rmtree(self.temp_dir, ignore_errors=True)
 
+    @staticmethod
+    def _write(table, data):
+        builder = table.new_batch_write_builder()
+        writer = builder.new_write()
+        try:
+            writer.write_arrow(data)
+            builder.new_commit().commit(writer.prepare_commit())
+        finally:
+            writer.close()
+
+    @staticmethod
+    def _read(table):
+        builder = table.new_read_builder()
+        return builder.new_read().to_arrow(builder.new_scan().plan().splits())
+
     # -- create + list --------------------------------------------------------
 
     def test_create_branch_without_from_tag(self):
@@ -97,6 +112,49 @@ class FileSystemCatalogBranchCRUDTest(unittest.TestCase):
         self.assertIn(
             "branch_col", 
self.catalog.get_table(branch_identifier).field_names)
 
+    def test_write_blob_to_data_evolution_branch(self):
+        schema = pa.schema([
+            ("id", pa.int64()),
+            ("payload", pa.large_binary()),
+        ])
+        identifier = Identifier.from_string("default.test_de_blob_branch")
+        self.catalog.create_table(
+            identifier,
+            Schema.from_pyarrow_schema(
+                schema,
+                options={
+                    "data-evolution.enabled": "true",
+                    "row-tracking.enabled": "true",
+                    "blob-field": "payload",
+                },
+            ),
+            False,
+        )
+        main = self.catalog.get_table(identifier)
+        self._write(main, pa.table({
+            "id": [1],
+            "payload": pa.array([b"main"], pa.large_binary()),
+        }, schema=schema))
+        main.create_tag("base")
+        self.catalog.create_branch(identifier, "b1", tag_name="base")
+
+        branch_identifier = Identifier(
+            identifier.get_database_name(), identifier.get_table_name(), 
branch="b1")
+        branch = self.catalog.get_table(branch_identifier)
+        self._write(branch, pa.table({
+            "id": [2],
+            "payload": pa.array([b"branch"], pa.large_binary()),
+        }, schema=schema))
+
+        self.assertEqual(
+            self._read(main).to_pydict(),
+            {"id": [1], "payload": [b"main"]},
+        )
+        self.assertEqual(
+            self._read(branch).to_pydict(),
+            {"id": [1, 2], "payload": [b"main", b"branch"]},
+        )
+
     def test_create_branch_duplicate_raises(self):
         self.catalog.create_branch(self.identifier, "b1")
         with self.assertRaises(BranchAlreadyExistException) as cm:
diff --git a/paimon-python/pypaimon/tests/rest/rest_branch_test.py 
b/paimon-python/pypaimon/tests/rest/rest_branch_test.py
index 3b9eb80ff2..e3c8951fa9 100644
--- a/paimon-python/pypaimon/tests/rest/rest_branch_test.py
+++ b/paimon-python/pypaimon/tests/rest/rest_branch_test.py
@@ -17,9 +17,13 @@
 
 import unittest
 
+import pyarrow as pa
+
+from pypaimon import Schema
 from pypaimon.catalog.catalog_exception import (BranchAlreadyExistException,
                                                 BranchNotExistException,
-                                                TableNotExistException)
+                                                TableNotExistException,
+                                                TagNotExistException)
 from pypaimon.common.identifier import Identifier
 from pypaimon.tests.rest.rest_base_test import RESTBaseTest
 
@@ -36,6 +40,21 @@ class RESTCatalogBranchCRUDTest(RESTBaseTest):
         # snapshot in setUp.
         return Identifier.from_string("default.test_reader_iterator")
 
+    @staticmethod
+    def _write(table, data):
+        builder = table.new_batch_write_builder()
+        writer = builder.new_write()
+        try:
+            writer.write_arrow(data)
+            builder.new_commit().commit(writer.prepare_commit())
+        finally:
+            writer.close()
+
+    @staticmethod
+    def _read(table):
+        builder = table.new_read_builder()
+        return builder.new_read().to_arrow(builder.new_scan().plan().splits())
+
     def test_create_branch_table_not_exist(self):
         with self.assertRaises(TableNotExistException):
             self.rest_catalog.create_branch(
@@ -50,6 +69,14 @@ class RESTCatalogBranchCRUDTest(RESTBaseTest):
         identifier = self._identifier()
         self.rest_catalog.create_branch(identifier, "b1")
         self.assertEqual(self.rest_catalog.list_branches(identifier), ["b1"])
+        branch = self.rest_catalog.get_table(Identifier(
+            identifier.get_database_name(), identifier.get_table_name(), 
branch="b1"))
+        self.assertEqual(self._read(branch).num_rows, 0)
+
+    def test_create_branch_from_missing_tag_raises(self):
+        with self.assertRaises(TagNotExistException):
+            self.rest_catalog.create_branch(
+                self._identifier(), "b1", tag_name="missing")
 
     def test_branch_table_uses_branch_schema_manager(self):
         identifier = self._identifier()
@@ -65,6 +92,53 @@ class RESTCatalogBranchCRUDTest(RESTBaseTest):
         self.assertEqual(table.current_branch(), "b1")
         self.assertEqual(table.schema_manager.branch, "b1")
 
+    def test_write_blob_to_data_evolution_branch(self):
+        schema = pa.schema([
+            ("id", pa.int64()),
+            ("payload", pa.large_binary()),
+        ])
+        identifier = Identifier.from_string("default.test_de_blob_branch")
+        self.rest_catalog.create_table(
+            identifier,
+            Schema.from_pyarrow_schema(
+                schema,
+                options={
+                    "data-evolution.enabled": "true",
+                    "row-tracking.enabled": "true",
+                    "blob-field": "payload",
+                },
+            ),
+            False,
+        )
+        main = self.rest_catalog.get_table(identifier)
+        self._write(main, pa.table({
+            "id": [1],
+            "payload": pa.array([b"main"], pa.large_binary()),
+        }, schema=schema))
+        self.assertEqual(
+            self._read(main).to_pydict(),
+            {"id": [1], "payload": [b"main"]},
+        )
+        self.rest_catalog.create_tag(identifier, "base")
+        self.rest_catalog.create_branch(identifier, "b1", tag_name="base")
+
+        branch_identifier = Identifier(
+            identifier.get_database_name(), identifier.get_table_name(), 
branch="b1")
+        branch = self.rest_catalog.get_table(branch_identifier)
+        self._write(branch, pa.table({
+            "id": [2],
+            "payload": pa.array([b"branch"], pa.large_binary()),
+        }, schema=schema))
+
+        self.assertEqual(
+            self._read(main).to_pydict(),
+            {"id": [1], "payload": [b"main"]},
+        )
+        self.assertEqual(
+            self._read(branch).to_pydict(),
+            {"id": [1, 2], "payload": [b"main", b"branch"]},
+        )
+
     def test_create_branch_duplicate_raises(self):
         identifier = self._identifier()
         self.rest_catalog.create_branch(identifier, "b1")
diff --git a/paimon-python/pypaimon/tests/rest/rest_server.py 
b/paimon-python/pypaimon/tests/rest/rest_server.py
index 92d089b302..612bf7393b 100755
--- a/paimon-python/pypaimon/tests/rest/rest_server.py
+++ b/paimon-python/pypaimon/tests/rest/rest_server.py
@@ -548,7 +548,8 @@ class RESTCatalogServer:
             elif operation == "rollback":
                 return self._table_rollback_handle(method, data, 
lookup_identifier)
             elif operation == "snapshot":
-                return self._table_snapshot_handle(method, lookup_identifier)
+                return self._table_snapshot_handle(
+                    method, lookup_identifier, branch_part)
             elif operation == ResourcePaths.PARTITIONS:
                 return self._table_partitions_handle(method, data, 
lookup_identifier, parameters)
             elif operation == ResourcePaths.TAGS:
@@ -882,14 +883,18 @@ class RESTCatalogServer:
 
         if method == "POST":
             request = JSON.from_json(data, CreateBranchRequest)
-            # Mock simplification: ``from_tag`` existence is NOT validated 
here.
-            # The real Java REST server checks against TagManager and returns
-            # 404+TAG when the tag is missing. pypaimon's mock doesn't track
-            # tag-to-branch dependencies; a TODO for full validation lives
-            # with the Tag CRUD work in #7746.
             store = self.branch_store.setdefault(identifier.get_full_name(), 
set())
             if request.branch in store:
                 raise BranchAlreadyExistException(request.branch)
+
+            if request.from_tag is not None:
+                tags = self.tag_store.get(identifier.get_full_name(), {})
+                if request.from_tag not in tags:
+                    raise TagNotExistException(request.from_tag)
+                snapshot = tags[request.from_tag].snapshot
+                if snapshot is not None:
+                    self._write_snapshot_files(
+                        identifier, snapshot, None, request.branch)
             store.add(request.branch)
             return self._mock_response("", 200)
 
@@ -1115,7 +1120,7 @@ class RESTCatalogServer:
                     ErrorResponse("SNAPSHOT", None, "Snapshot is required for 
commit operation", 400), 400
                 )
 
-            table = self._get_file_table(identifier)
+            table = self._get_file_table(identifier, branch)
             current_snapshot = table.snapshot_manager().get_latest_snapshot()
             current_snapshot_uuid = (
                 current_snapshot.uuid if current_snapshot else None
@@ -1126,7 +1131,9 @@ class RESTCatalogServer:
                 )
 
             # Write snapshot to file system
-            self._write_snapshot_files(identifier, commit_request.snapshot, 
commit_request.statistics)
+            self._write_snapshot_files(
+                identifier, commit_request.snapshot,
+                commit_request.statistics, branch)
 
             self.logger.info(f"Successfully committed snapshot for table 
{identifier.get_full_name()}, "
                              f"branch: {branch or 'main'}")
@@ -1220,7 +1227,8 @@ class RESTCatalogServer:
         table.rollback_to(tag_name)
         return self._mock_response("", 200)
 
-    def _table_snapshot_handle(self, method: str, identifier: Identifier) -> 
Tuple[str, int]:
+    def _table_snapshot_handle(self, method: str, identifier: Identifier,
+                               branch: str = None) -> Tuple[str, int]:
         """Handle table snapshot operations.
 
         Args:
@@ -1246,7 +1254,7 @@ class RESTCatalogServer:
             return self._mock_response(response, 404)
 
         # Get the table and snapshot manager to retrieve snapshot
-        table = self._get_file_table(identifier)
+        table = self._get_file_table(identifier, branch)
         snapshot_manager = table.snapshot_manager()
 
         # Get latest snapshot
@@ -1273,7 +1281,7 @@ class RESTCatalogServer:
         response = GetTableSnapshotResponse(table_snapshot)
         return self._mock_response(response, 200)
 
-    def _get_file_table(self, identifier: Identifier):
+    def _get_file_table(self, identifier: Identifier, branch: str = None):
         """Construct a FileStoreTable from the metadata store.
 
         loads the schema from the metadata store, builds a CatalogEnvironment
@@ -1294,23 +1302,32 @@ class RESTCatalogServer:
             f'file://{self.data_path}/{self.warehouse}/'
             f'{identifier.get_database_name()}/{identifier.get_object_name()}')
 
+        table_identifier = Identifier.create(
+            identifier.get_database_name(), identifier.get_table_name(), 
branch=branch)
         catalog_env = CatalogEnvironment(
-            identifier=identifier,
+            identifier=table_identifier,
             uuid=table_metadata.uuid,
             catalog_loader=None,
             supports_version_management=False
         )
 
         file_io = FileIO.get(table_path, Options({}))
-        return FileStoreTable(file_io, identifier, table_path, table_schema, 
catalog_env)
+        return FileStoreTable(
+            file_io, table_identifier, table_path, table_schema, catalog_env)
 
-    def _write_snapshot_files(self, identifier: Identifier, snapshot, 
statistics):
+    def _write_snapshot_files(self, identifier: Identifier, snapshot, 
statistics,
+                              branch: str = None):
         """Write snapshot and related files to the file system"""
         import os
 
         # Construct table path: {warehouse}/{database}/{table}
-        table_path = os.path.join(self.data_path, self.warehouse, 
identifier.get_database_name(),
-                                  identifier.get_object_name())
+        from pypaimon.branch.branch_manager import BranchManager
+
+        table_path = os.path.join(
+            self.data_path, self.warehouse, identifier.get_database_name(),
+            identifier.get_object_name())
+        table_path = BranchManager.branch_path(
+            table_path, BranchManager.normalize_branch(branch))
 
         # Create directory structure
         snapshot_dir = os.path.join(table_path, "snapshot")

Reply via email to