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 ca5d7d18fe [python] Add branch CLI commands (#8325)
ca5d7d18fe is described below

commit ca5d7d18fef8ef2e583e5e8218691d0ea5c2167d
Author: chaoyang <[email protected]>
AuthorDate: Tue Jun 23 11:50:01 2026 +0800

    [python] Add branch CLI commands (#8325)
---
 docs/docs/pypaimon/cli.md                          |  54 +++++
 paimon-python/pypaimon/cli/cli.py                  |   4 +
 paimon-python/pypaimon/cli/cli_branch.py           | 228 +++++++++++++++++++++
 paimon-python/pypaimon/tests/cli_branch_test.py    | 182 ++++++++++++++++
 .../tests/filesystem_catalog_branch_test.py        |  34 +--
 5 files changed, 487 insertions(+), 15 deletions(-)

diff --git a/docs/docs/pypaimon/cli.md b/docs/docs/pypaimon/cli.md
index 94c0fdd688..df5b7e0fee 100644
--- a/docs/docs/pypaimon/cli.md
+++ b/docs/docs/pypaimon/cli.md
@@ -785,3 +785,57 @@ SQL statements end with `;` and can span multiple lines. 
The continuation prompt
 | `exit` / `quit` | Exit the REPL |
 
 For more details on SQL syntax and the Python API, see [SQL Query](./sql).
+
+## Branch Commands
+
+Manage branches on a table. Branches are independent lines of a table that can 
be created from the current state or from a tag, and later fast-forwarded back 
into main.
+
+```shell
+paimon branch <create|list|delete|rename|fast-forward> mydb.users ...
+```
+
+### Branch Create
+
+```shell
+# Create a branch from the current state
+paimon branch create mydb.users b1
+
+# Create a branch from an existing tag
+paimon branch create mydb.users b1 --tag v1
+```
+
+Options:
+- `--tag, -t`: Create the branch from this tag (default: current state)
+
+### Branch List
+
+```shell
+# List all branches
+paimon branch list mydb.users
+
+# JSON output
+paimon branch list mydb.users --format json
+```
+
+Options:
+- `--format, -f`: Output format, `table` (default) or `json`
+
+### Branch Delete
+
+```shell
+paimon branch delete mydb.users b1
+```
+
+### Branch Rename
+
+```shell
+paimon branch rename mydb.users b1 b2
+```
+
+### Branch Fast-Forward
+
+Fast-forward the main branch to the given branch (main adopts the branch's 
snapshots).
+
+```shell
+paimon branch fast-forward mydb.users b1
+```
diff --git a/paimon-python/pypaimon/cli/cli.py 
b/paimon-python/pypaimon/cli/cli.py
index 3e1547fe39..66a1d37b9d 100644
--- a/paimon-python/pypaimon/cli/cli.py
+++ b/paimon-python/pypaimon/cli/cli.py
@@ -125,6 +125,10 @@ def main():
     from pypaimon.cli.cli_sql import add_sql_subcommand
     add_sql_subcommand(subparsers)
 
+    # Branch commands
+    from pypaimon.cli.cli_branch import add_branch_subcommands
+    add_branch_subcommands(subparsers)
+
     args = parser.parse_args()
     
     if args.command is None:
diff --git a/paimon-python/pypaimon/cli/cli_branch.py 
b/paimon-python/pypaimon/cli/cli_branch.py
new file mode 100644
index 0000000000..d6b769a77d
--- /dev/null
+++ b/paimon-python/pypaimon/cli/cli_branch.py
@@ -0,0 +1,228 @@
+# 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.
+
+"""Branch commands for the Paimon CLI.
+
+Adds the top-level ``branch {create,list,delete,rename,fast-forward}``
+command. All operations go through the Catalog layer so they get typed
+exceptions and work for both filesystem and REST catalogs. Command and
+argument names mirror the Java branch procedures (``create_branch`` /
+``delete_branch`` / ``rename_branch`` / ``fast_forward``).
+"""
+
+import json
+import sys
+
+from pypaimon.catalog.catalog_exception import (BranchAlreadyExistException,
+                                                BranchNotExistException,
+                                                TableNotExistException,
+                                                TagNotExistException)
+
+
+def _open_catalog(args):
+    """Load config, build the catalog and validate the ``database.table`` id.
+
+    Returns ``(catalog, identifier)``. On any failure, prints to stderr and
+    exits with a non-zero status (matching the other CLI commands).
+    """
+    from pypaimon.cli.cli import create_catalog, load_catalog_config
+
+    identifier = args.table
+    if len(identifier.split('.')) != 2:
+        print("Error: Invalid table identifier '{}'. Expected format: "
+              "'database.table'".format(identifier), file=sys.stderr)
+        sys.exit(1)
+    try:
+        catalog = create_catalog(load_catalog_config(args.config))
+    except Exception as e:
+        print("Error: {}".format(e), file=sys.stderr)
+        sys.exit(1)
+    return catalog, identifier
+
+
+def cmd_branch_create(args):
+    """Execute ``branch create``."""
+    catalog, identifier = _open_catalog(args)
+    try:
+        catalog.create_branch(identifier, args.branch_name, tag_name=args.tag)
+    except TableNotExistException:
+        print("Error: Table '{}' does not exist.".format(identifier),
+              file=sys.stderr)
+        sys.exit(1)
+    except BranchAlreadyExistException:
+        print("Error: Branch '{}' already exists.".format(args.branch_name),
+              file=sys.stderr)
+        sys.exit(1)
+    except TagNotExistException:
+        print("Error: Tag '{}' does not exist.".format(args.tag),
+              file=sys.stderr)
+        sys.exit(1)
+    except Exception as e:
+        print("Error: Failed to create branch: {}".format(e), file=sys.stderr)
+        sys.exit(1)
+    if args.tag is not None:
+        print("Branch '{}' created from tag '{}' on table '{}'.".format(
+            args.branch_name, args.tag, identifier))
+    else:
+        print("Branch '{}' created on table '{}'.".format(
+            args.branch_name, identifier))
+
+
+def cmd_branch_delete(args):
+    """Execute ``branch delete``."""
+    catalog, identifier = _open_catalog(args)
+    try:
+        catalog.drop_branch(identifier, args.branch_name)
+    except TableNotExistException:
+        print("Error: Table '{}' does not exist.".format(identifier),
+              file=sys.stderr)
+        sys.exit(1)
+    except BranchNotExistException:
+        print("Error: Branch '{}' does not exist.".format(args.branch_name),
+              file=sys.stderr)
+        sys.exit(1)
+    except Exception as e:
+        print("Error: Failed to delete branch: {}".format(e), file=sys.stderr)
+        sys.exit(1)
+    print("Branch '{}' deleted from table '{}'.".format(
+        args.branch_name, identifier))
+
+
+def cmd_branch_list(args):
+    """Execute ``branch list``."""
+    catalog, identifier = _open_catalog(args)
+    try:
+        branches = catalog.list_branches(identifier)
+    except TableNotExistException:
+        print("Error: Table '{}' does not exist.".format(identifier),
+              file=sys.stderr)
+        sys.exit(1)
+    except Exception as e:
+        print("Error: Failed to list branches: {}".format(e), file=sys.stderr)
+        sys.exit(1)
+
+    if args.format == 'json':
+        print(json.dumps(branches, ensure_ascii=False))
+    elif not branches:
+        print("No branches found.")
+    else:
+        for branch in branches:
+            print(branch)
+
+
+def cmd_branch_rename(args):
+    """Execute ``branch rename``."""
+    catalog, identifier = _open_catalog(args)
+    try:
+        catalog.rename_branch(identifier, args.from_branch, args.to_branch)
+    except TableNotExistException:
+        print("Error: Table '{}' does not exist.".format(identifier),
+              file=sys.stderr)
+        sys.exit(1)
+    except BranchNotExistException:
+        print("Error: Branch '{}' does not exist.".format(args.from_branch),
+              file=sys.stderr)
+        sys.exit(1)
+    except BranchAlreadyExistException:
+        print("Error: Branch '{}' already exists.".format(args.to_branch),
+              file=sys.stderr)
+        sys.exit(1)
+    except Exception as e:
+        print("Error: Failed to rename branch: {}".format(e), file=sys.stderr)
+        sys.exit(1)
+    print("Branch '{}' renamed to '{}' on table '{}'.".format(
+        args.from_branch, args.to_branch, identifier))
+
+
+def cmd_branch_fast_forward(args):
+    """Execute ``branch fast-forward``."""
+    catalog, identifier = _open_catalog(args)
+    try:
+        catalog.fast_forward(identifier, args.branch_name)
+    except TableNotExistException:
+        print("Error: Table '{}' does not exist.".format(identifier),
+              file=sys.stderr)
+        sys.exit(1)
+    except BranchNotExistException:
+        print("Error: Branch '{}' does not exist.".format(args.branch_name),
+              file=sys.stderr)
+        sys.exit(1)
+    except Exception as e:
+        print("Error: Failed to fast-forward: {}".format(e), file=sys.stderr)
+        sys.exit(1)
+    print("Fast-forwarded table '{}' to branch '{}'.".format(
+        identifier, args.branch_name))
+
+
+def add_branch_subcommands(subparsers):
+    """Register the top-level ``branch <command>`` command.
+
+    Args:
+        subparsers: The subparsers object from the main argument parser.
+    """
+    branch_parser = subparsers.add_parser('branch', help='Branch operations')
+    branch_subparsers = branch_parser.add_subparsers(
+        dest='branch_command', help='Branch commands')
+
+    # branch create
+    create_parser = branch_subparsers.add_parser(
+        'create', help='Create a branch on a table')
+    create_parser.add_argument(
+        'table', help='Table identifier in format: database.table')
+    create_parser.add_argument(
+        'branch_name', help='Name of the branch to create')
+    create_parser.add_argument(
+        '--tag', '-t', default=None,
+        help='Create the branch from this tag (default: current state)')
+    create_parser.set_defaults(func=cmd_branch_create)
+
+    # branch list
+    list_parser = branch_subparsers.add_parser(
+        'list', help='List branches of a table')
+    list_parser.add_argument(
+        'table', help='Table identifier in format: database.table')
+    list_parser.add_argument(
+        '--format', '-f', choices=['table', 'json'], default='table',
+        help='Output format: table (default) or json')
+    list_parser.set_defaults(func=cmd_branch_list)
+
+    # branch delete
+    delete_parser = branch_subparsers.add_parser(
+        'delete', help='Delete a branch from a table')
+    delete_parser.add_argument(
+        'table', help='Table identifier in format: database.table')
+    delete_parser.add_argument(
+        'branch_name', help='Name of the branch to delete')
+    delete_parser.set_defaults(func=cmd_branch_delete)
+
+    # branch rename
+    rename_parser = branch_subparsers.add_parser(
+        'rename', help='Rename a branch')
+    rename_parser.add_argument(
+        'table', help='Table identifier in format: database.table')
+    rename_parser.add_argument('from_branch', help='Current branch name')
+    rename_parser.add_argument('to_branch', help='New branch name')
+    rename_parser.set_defaults(func=cmd_branch_rename)
+
+    # branch fast-forward
+    ff_parser = branch_subparsers.add_parser(
+        'fast-forward', help='Fast-forward main to a branch')
+    ff_parser.add_argument(
+        'table', help='Table identifier in format: database.table')
+    ff_parser.add_argument(
+        'branch_name', help='Name of the branch to fast-forward to')
+    ff_parser.set_defaults(func=cmd_branch_fast_forward)
diff --git a/paimon-python/pypaimon/tests/cli_branch_test.py 
b/paimon-python/pypaimon/tests/cli_branch_test.py
new file mode 100644
index 0000000000..c21e44779d
--- /dev/null
+++ b/paimon-python/pypaimon/tests/cli_branch_test.py
@@ -0,0 +1,182 @@
+# 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.
+
+"""Integration tests for the top-level ``branch`` CLI command."""
+
+import json
+import os
+import shutil
+import tempfile
+import unittest
+from io import StringIO
+from unittest.mock import patch
+
+import pyarrow as pa
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.cli.cli import main
+
+
+class CliBranchTest(unittest.TestCase):
+
+    def setUp(self):
+        self.tempdir = tempfile.mkdtemp(prefix="cli_branch_")
+        self.warehouse = os.path.join(self.tempdir, 'warehouse')
+        self.catalog = CatalogFactory.create({'warehouse': self.warehouse})
+        self.catalog.create_database('db', True)
+
+        pa_schema = pa.schema([('id', pa.int32()), ('name', pa.string())])
+        self.catalog.create_table(
+            'db.t', Schema.from_pyarrow_schema(pa_schema), False)
+        # One commit so there is a snapshot, plus a tag for from-tag tests.
+        table = self.catalog.get_table('db.t')
+        wb = table.new_batch_write_builder()
+        w = wb.new_write()
+        c = wb.new_commit()
+        w.write_arrow(pa.Table.from_pylist([{'id': 1, 'name': 'a'}],
+                                           schema=pa_schema))
+        c.commit(w.prepare_commit())
+        w.close()
+        c.close()
+        table.create_tag("v1")
+
+        self.config_file = os.path.join(self.tempdir, 'paimon.yaml')
+        with open(self.config_file, 'w') as f:
+            f.write("metastore: filesystem\nwarehouse: {}\n".format(
+                self.warehouse))
+
+    def tearDown(self):
+        shutil.rmtree(self.tempdir, ignore_errors=True)
+
+    def _run(self, *argv):
+        """Run the CLI; return (stdout, stderr, exit_code)."""
+        out, err = StringIO(), StringIO()
+        code = 0
+        full = ['paimon', '-c', self.config_file] + list(argv)
+        with patch('sys.argv', full):
+            with patch('sys.stdout', out), patch('sys.stderr', err):
+                try:
+                    main()
+                except SystemExit as e:
+                    code = 0 if e.code is None else (
+                        e.code if isinstance(e.code, int) else 1)
+        return out.getvalue(), err.getvalue(), code
+
+    # -- create + list -------------------------------------------------------
+
+    def test_create_then_list(self):
+        out, _, code = self._run('branch', 'create', 'db.t', 'b1')
+        self.assertEqual(0, code)
+        self.assertIn("created", out)
+
+        out, _, code = self._run('branch', 'list', 'db.t')
+        self.assertEqual(0, code)
+        self.assertIn("b1", out)
+
+    def test_create_from_tag(self):
+        out, _, code = self._run(
+            'branch', 'create', 'db.t', 'b1', '--tag', 'v1')
+        self.assertEqual(0, code)
+        self.assertIn("from tag 'v1'", out)
+        out, _, _ = self._run('branch', 'list', 'db.t')
+        self.assertIn("b1", out)
+
+    def test_create_from_missing_tag(self):
+        _, err, code = self._run(
+            'branch', 'create', 'db.t', 'b1', '--tag', 'absent')
+        self.assertEqual(1, code)
+        self.assertIn("does not exist", err)
+
+    def test_create_duplicate_raises(self):
+        self._run('branch', 'create', 'db.t', 'b1')
+        _, err, code = self._run('branch', 'create', 'db.t', 'b1')
+        self.assertEqual(1, code)
+        self.assertIn("already exists", err)
+
+    def test_list_empty(self):
+        out, _, code = self._run('branch', 'list', 'db.t')
+        self.assertEqual(0, code)
+        self.assertIn("No branches found.", out)
+
+    def test_list_json(self):
+        self._run('branch', 'create', 'db.t', 'b1')
+        self._run('branch', 'create', 'db.t', 'b2')
+        out, _, code = self._run('branch', 'list', 'db.t', '--format', 'json')
+        self.assertEqual(0, code)
+        self.assertEqual({"b1", "b2"}, set(json.loads(out)))
+
+    # -- delete --------------------------------------------------------------
+
+    def test_delete(self):
+        self._run('branch', 'create', 'db.t', 'b1')
+        out, _, code = self._run('branch', 'delete', 'db.t', 'b1')
+        self.assertEqual(0, code)
+        self.assertIn("deleted", out)
+        out, _, _ = self._run('branch', 'list', 'db.t')
+        self.assertNotIn("b1", out)
+
+    def test_delete_not_exists(self):
+        _, err, code = self._run('branch', 'delete', 'db.t', 'absent')
+        self.assertEqual(1, code)
+        self.assertIn("does not exist", err)
+
+    # -- rename --------------------------------------------------------------
+
+    def test_rename(self):
+        self._run('branch', 'create', 'db.t', 'b1')
+        out, _, code = self._run('branch', 'rename', 'db.t', 'b1', 'b2')
+        self.assertEqual(0, code)
+        self.assertIn("renamed", out)
+        out, _, _ = self._run('branch', 'list', 'db.t')
+        self.assertIn("b2", out)
+        self.assertNotIn("b1", out)
+
+    def test_rename_from_missing(self):
+        _, err, code = self._run('branch', 'rename', 'db.t', 'absent', 'b2')
+        self.assertEqual(1, code)
+        self.assertIn("does not exist", err)
+
+    def test_rename_to_existing(self):
+        self._run('branch', 'create', 'db.t', 'b1')
+        self._run('branch', 'create', 'db.t', 'b2')
+        _, err, code = self._run('branch', 'rename', 'db.t', 'b1', 'b2')
+        self.assertEqual(1, code)
+        self.assertIn("already exists", err)
+
+    # -- fast-forward --------------------------------------------------------
+
+    def test_fast_forward(self):
+        self._run('branch', 'create', 'db.t', 'b1', '--tag', 'v1')
+        out, _, code = self._run('branch', 'fast-forward', 'db.t', 'b1')
+        self.assertEqual(0, code)
+        self.assertIn("Fast-forwarded", out)
+
+    def test_fast_forward_missing(self):
+        _, err, code = self._run('branch', 'fast-forward', 'db.t', 'absent')
+        self.assertEqual(1, code)
+        self.assertIn("does not exist", err)
+
+    # -- bad input -----------------------------------------------------------
+
+    def test_invalid_identifier(self):
+        _, err, code = self._run('branch', 'create', 'nodot', 'b1')
+        self.assertEqual(1, code)
+        self.assertIn("Invalid table identifier", err)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/paimon-python/pypaimon/tests/filesystem_catalog_branch_test.py 
b/paimon-python/pypaimon/tests/filesystem_catalog_branch_test.py
index b60fe97a47..2129f811fa 100644
--- a/paimon-python/pypaimon/tests/filesystem_catalog_branch_test.py
+++ b/paimon-python/pypaimon/tests/filesystem_catalog_branch_test.py
@@ -94,15 +94,18 @@ class FileSystemCatalogBranchCRUDTest(unittest.TestCase):
                 self.identifier, "b1", tag_name="absent_tag")
         self.assertEqual(cm.exception.tag, "absent_tag")
 
-    # NOTE: ``test_create_branch_from_existing_tag`` (a true happy-path
-    # ``create_branch(tag_name=...)``) is not included here. The
-    # ``FileSystemBranchManager`` "from-tag" path has a pre-existing bug
-    # (``branch_snapshot_manager`` is constructed without switching to
-    # the new branch's path, so ``copy_file(src, dst)`` ends up with
-    # ``src == dst`` and raises ``SameFileError``). That's a manager-
-    # level fix, not in the scope of this catalog-layer thin wrapper.
-    # Catalog-layer error translation for the from-tag path is still
-    # covered by ``test_create_branch_from_nonexistent_tag_raises``.
+    def test_create_branch_from_existing_tag(self):
+        # The from-tag happy path: create_tag then create_branch(tag_name=...)
+        # must land the branch files under ``branch/branch-<name>/`` and not
+        # raise (regresses the historical src == dst SameFileError).
+        table = self.catalog.get_table(self.identifier)
+        table.create_tag("t1")
+        self.catalog.create_branch(self.identifier, "b1", tag_name="t1")
+        self.assertIn("b1", self.catalog.list_branches(self.identifier))
+        branch_root = "{}/branch/branch-b1".format(
+            table.table_path.rstrip('/'))
+        self.assertTrue(os.path.isdir(branch_root))
+        self.assertTrue(os.path.isfile("{}/tag/tag-t1".format(branch_root)))
 
     # -- list -----------------------------------------------------------------
 
@@ -164,12 +167,13 @@ class FileSystemCatalogBranchCRUDTest(unittest.TestCase):
             self.catalog.fast_forward(self.identifier, "absent")
         self.assertEqual(cm.exception.branch, "absent")
 
-    # NOTE: a true happy-path ``fast_forward`` end-to-end test is not
-    # included here for the same reason as the create-branch-from-tag
-    # case above — it requires the manager-level fix to the from-tag
-    # path (so the branch carries a snapshot for fast-forward to move).
-    # Catalog-layer error translation is covered by the missing-branch
-    # case above.
+    def test_fast_forward_after_create_branch_from_tag(self):
+        # Happy path: create a branch from a tag, then fast-forward main to
+        # it. Must not raise (regresses the historical src == dst error).
+        table = self.catalog.get_table(self.identifier)
+        table.create_tag("t1")
+        self.catalog.create_branch(self.identifier, "b1", tag_name="t1")
+        self.catalog.fast_forward(self.identifier, "b1")
 
 
 if __name__ == "__main__":

Reply via email to