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 815990c902 [python] Add tag CLI subcommands (#8320)
815990c902 is described below

commit 815990c9023ccbdcc3583623f7bb461371e06d96
Author: chaoyang <[email protected]>
AuthorDate: Tue Jun 23 14:40:33 2026 +0800

    [python] Add tag CLI subcommands (#8320)
    
    pypaimon already implements the full tag Catalog API (filesystem +
    REST), but the CLI had no way to manage tags — users had to drop down to
    the Python API. This adds tag management to the CLI.
---
 docs/docs/pypaimon/cli.md                    |  60 ++++++++
 paimon-python/pypaimon/cli/cli.py            |   4 +
 paimon-python/pypaimon/cli/cli_tag.py        | 214 +++++++++++++++++++++++++++
 paimon-python/pypaimon/tests/cli_tag_test.py | 182 +++++++++++++++++++++++
 4 files changed, 460 insertions(+)

diff --git a/docs/docs/pypaimon/cli.md b/docs/docs/pypaimon/cli.md
index df5b7e0fee..5b192b1daf 100644
--- a/docs/docs/pypaimon/cli.md
+++ b/docs/docs/pypaimon/cli.md
@@ -589,6 +589,66 @@ paimon table alter mydb.users alter-column -n age -t 
BIGINT -c 'User age in year
 paimon table alter mydb.users update-comment -c "Updated user information 
table"
 ```
 
+## Tag Commands
+
+Manage tags (named snapshots) on a table. Tags are useful for time travel and 
pinning a snapshot for later access.
+
+```shell
+paimon tag <create|list|get|delete> mydb.users ...
+```
+
+#### Tag Create
+
+```shell
+# Tag the latest snapshot
+paimon tag create mydb.users v1
+
+# Tag a specific snapshot
+paimon tag create mydb.users v1 --snapshot-id 3
+
+# Do not error if the tag already exists
+paimon tag create mydb.users v1 --ignore-if-exists
+```
+
+Options:
+- `--snapshot-id, -s`: Snapshot id to tag (default: the latest snapshot)
+- `--ignore-if-exists, -i`: Do not raise an error if the tag already exists
+
+#### Tag List
+
+```shell
+# List all tags
+paimon tag list mydb.users
+
+# Only tags with a name prefix
+paimon tag list mydb.users --prefix prod_
+
+# JSON output
+paimon tag list mydb.users --format json
+```
+
+Options:
+- `--prefix, -p`: Only list tags whose name starts with this prefix
+- `--format, -f`: Output format, `table` (default) or `json`
+
+#### Tag Get
+
+```shell
+paimon tag get mydb.users v1
+
+# JSON output
+paimon tag get mydb.users v1 --format json
+```
+
+Options:
+- `--format, -f`: Output format, `table` (default) or `json`
+
+#### Tag Delete
+
+```shell
+paimon tag delete mydb.users v1
+```
+
 ## Database Commands
 
 ### DB Get
diff --git a/paimon-python/pypaimon/cli/cli.py 
b/paimon-python/pypaimon/cli/cli.py
index 66a1d37b9d..b7c21006f2 100644
--- a/paimon-python/pypaimon/cli/cli.py
+++ b/paimon-python/pypaimon/cli/cli.py
@@ -129,6 +129,10 @@ def main():
     from pypaimon.cli.cli_branch import add_branch_subcommands
     add_branch_subcommands(subparsers)
 
+    # Tag commands
+    from pypaimon.cli.cli_tag import add_tag_subcommands
+    add_tag_subcommands(subparsers)
+
     args = parser.parse_args()
     
     if args.command is None:
diff --git a/paimon-python/pypaimon/cli/cli_tag.py 
b/paimon-python/pypaimon/cli/cli_tag.py
new file mode 100644
index 0000000000..eb513ea606
--- /dev/null
+++ b/paimon-python/pypaimon/cli/cli_tag.py
@@ -0,0 +1,214 @@
+# 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.
+
+"""Tag commands for the Paimon CLI.
+
+Adds the top-level ``tag {create,list,delete,get}`` subcommands (alongside
+``table`` / ``db`` / ``catalog``). All operations go through the Catalog
+layer so they get typed exceptions and work for both filesystem and REST
+catalogs.
+"""
+
+import json
+import sys
+
+from pypaimon.catalog.catalog_exception import (TableNotExistException,
+                                                TagAlreadyExistException,
+                                                TagNotExistException)
+from pypaimon.common.json_util import JSON
+
+
+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 table 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_tag_create(args):
+    """Execute ``tag create``."""
+    catalog, identifier = _open_catalog(args)
+    try:
+        catalog.create_tag(
+            identifier,
+            args.tag_name,
+            snapshot_id=args.snapshot_id,
+            ignore_if_exists=args.ignore_if_exists,
+        )
+    except TableNotExistException:
+        print("Error: Table '{}' does not exist.".format(identifier),
+              file=sys.stderr)
+        sys.exit(1)
+    except TagAlreadyExistException:
+        print("Error: Tag '{}' already exists.".format(args.tag_name),
+              file=sys.stderr)
+        sys.exit(1)
+    except Exception as e:
+        print("Error: Failed to create tag: {}".format(e), file=sys.stderr)
+        sys.exit(1)
+    print("Tag '{}' created on table '{}'.".format(args.tag_name, identifier))
+
+
+def cmd_tag_delete(args):
+    """Execute ``tag delete``."""
+    catalog, identifier = _open_catalog(args)
+    try:
+        catalog.delete_tag(identifier, args.tag_name)
+    except TableNotExistException:
+        print("Error: Table '{}' does not exist.".format(identifier),
+              file=sys.stderr)
+        sys.exit(1)
+    except TagNotExistException:
+        print("Error: Tag '{}' does not exist.".format(args.tag_name),
+              file=sys.stderr)
+        sys.exit(1)
+    except Exception as e:
+        print("Error: Failed to delete tag: {}".format(e), file=sys.stderr)
+        sys.exit(1)
+    print("Tag '{}' deleted from table '{}'.".format(args.tag_name, 
identifier))
+
+
+def cmd_tag_list(args):
+    """Execute ``tag list``."""
+    catalog, identifier = _open_catalog(args)
+    try:
+        paged = catalog.list_tags_paged(identifier, 
tag_name_prefix=args.prefix)
+        tags = paged.elements
+    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 tags: {}".format(e), file=sys.stderr)
+        sys.exit(1)
+
+    # The CLI does not expose paging; warn (rather than silently truncate) if
+    # the catalog returned a partial page.
+    if paged.next_page_token is not None:
+        print("Note: tag list may be truncated; more tags are available.",
+              file=sys.stderr)
+
+    if args.format == 'json':
+        print(json.dumps(tags, ensure_ascii=False))
+    elif not tags:
+        print("No tags found.")
+    else:
+        for tag in tags:
+            print(tag)
+
+
+def cmd_tag_get(args):
+    """Execute ``tag get``."""
+    catalog, identifier = _open_catalog(args)
+    try:
+        response = catalog.get_tag(identifier, args.tag_name)
+    except TableNotExistException:
+        print("Error: Table '{}' does not exist.".format(identifier),
+              file=sys.stderr)
+        sys.exit(1)
+    except TagNotExistException:
+        print("Error: Tag '{}' does not exist.".format(args.tag_name),
+              file=sys.stderr)
+        sys.exit(1)
+    except Exception as e:
+        print("Error: Failed to get tag: {}".format(e), file=sys.stderr)
+        sys.exit(1)
+
+    if args.format == 'json':
+        print(JSON.to_json(response, indent=2))
+        return
+
+    print("Tag: {}".format(response.tag_name))
+    snapshot = response.snapshot
+    if snapshot is not None:
+        print("  Snapshot ID: {}".format(snapshot.id))
+        print("  Schema ID: {}".format(snapshot.schema_id))
+        print("  Record Count: {}".format(snapshot.total_record_count))
+    # create_time / time_retained are surfaced only when present (populated for
+    # tags created with a retention).
+    if response.tag_create_time is not None:
+        print("  Create Time: {}".format(response.tag_create_time))
+    if response.tag_time_retained is not None:
+        print("  Time Retained: {}".format(response.tag_time_retained))
+
+
+def add_tag_subcommands(subparsers):
+    """Register the top-level ``tag <command>`` subcommands."""
+    tag_parser = subparsers.add_parser(
+        'tag', help='Tag operations on a table')
+    tag_subparsers = tag_parser.add_subparsers(
+        dest='tag_command', help='Tag commands')
+
+    # tag create
+    create_parser = tag_subparsers.add_parser(
+        'create', help='Create a tag on a table')
+    create_parser.add_argument(
+        'table', help='Table identifier in format: database.table')
+    create_parser.add_argument('tag_name', help='Name of the tag to create')
+    create_parser.add_argument(
+        '--snapshot-id', '-s', type=int, default=None,
+        help='Snapshot id to tag (default: the latest snapshot)')
+    create_parser.add_argument(
+        '--ignore-if-exists', '-i', action='store_true',
+        help='Do not error if the tag already exists')
+    create_parser.set_defaults(func=cmd_tag_create)
+
+    # tag list
+    list_parser = tag_subparsers.add_parser(
+        'list', help='List tags of a table')
+    list_parser.add_argument(
+        'table', help='Table identifier in format: database.table')
+    list_parser.add_argument(
+        '--prefix', '-p', default=None,
+        help='Only list tags whose name starts with this prefix')
+    list_parser.add_argument(
+        '--format', '-f', choices=['table', 'json'], default='table',
+        help='Output format: table (default) or json')
+    list_parser.set_defaults(func=cmd_tag_list)
+
+    # tag get
+    get_parser = tag_subparsers.add_parser(
+        'get', help='Show details of a tag')
+    get_parser.add_argument(
+        'table', help='Table identifier in format: database.table')
+    get_parser.add_argument('tag_name', help='Name of the tag')
+    get_parser.add_argument(
+        '--format', '-f', choices=['table', 'json'], default='table',
+        help='Output format: table (default) or json')
+    get_parser.set_defaults(func=cmd_tag_get)
+
+    # tag delete
+    delete_parser = tag_subparsers.add_parser(
+        'delete', help='Delete a tag from a table')
+    delete_parser.add_argument(
+        'table', help='Table identifier in format: database.table')
+    delete_parser.add_argument('tag_name', help='Name of the tag to delete')
+    delete_parser.set_defaults(func=cmd_tag_delete)
diff --git a/paimon-python/pypaimon/tests/cli_tag_test.py 
b/paimon-python/pypaimon/tests/cli_tag_test.py
new file mode 100644
index 0000000000..9e3b75b9d1
--- /dev/null
+++ b/paimon-python/pypaimon/tests/cli_tag_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 ``tag`` CLI subcommands."""
+
+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 CliTagTest(unittest.TestCase):
+
+    def setUp(self):
+        self.tempdir = tempfile.mkdtemp(prefix="cli_tag_")
+        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)
+        # Two commits so snapshot ids 1 and 2 both exist.
+        table = self.catalog.get_table('db.t')
+        for rows in ([{'id': 1, 'name': 'a'}], [{'id': 2, 'name': 'b'}]):
+            wb = table.new_batch_write_builder()
+            w = wb.new_write()
+            c = wb.new_commit()
+            w.write_arrow(pa.Table.from_pylist(rows, schema=pa_schema))
+            c.commit(w.prepare_commit())
+            w.close()
+            c.close()
+
+        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('tag', 'create', 'db.t', 'v1')
+        self.assertEqual(0, code)
+        self.assertIn("created", out)
+
+        out, _, code = self._run('tag', 'list', 'db.t')
+        self.assertEqual(0, code)
+        self.assertIn("v1", out)
+
+    def test_list_empty(self):
+        out, _, code = self._run('tag', 'list', 'db.t')
+        self.assertEqual(0, code)
+        self.assertIn("No tags found.", out)
+
+    def test_list_json_empty(self):
+        out, _, code = self._run(
+            'tag', 'list', 'db.t', '--format', 'json')
+        self.assertEqual(0, code)
+        self.assertEqual([], json.loads(out))
+
+    def test_list_json(self):
+        self._run('tag', 'create', 'db.t', 'v1')
+        self._run('tag', 'create', 'db.t', 'v2')
+        out, _, code = self._run('tag', 'list', 'db.t', '--format', 'json')
+        self.assertEqual(0, code)
+        self.assertEqual({"v1", "v2"}, set(json.loads(out)))
+
+    def test_list_prefix(self):
+        self._run('tag', 'create', 'db.t', 'prod_v1')
+        self._run('tag', 'create', 'db.t', 'dev_v1')
+        out, _, code = self._run(
+            'tag', 'list', 'db.t', '--prefix', 'prod_', '-f', 'json')
+        self.assertEqual(0, code)
+        self.assertEqual(["prod_v1"], json.loads(out))
+
+    # -- create options ------------------------------------------------------
+
+    def test_create_with_snapshot_id(self):
+        out, _, code = self._run(
+            'tag', 'create', 'db.t', 'v1', '--snapshot-id', '1')
+        self.assertEqual(0, code)
+        out, _, _ = self._run('tag', 'get', 'db.t', 'v1')
+        self.assertIn("Snapshot ID: 1", out)
+
+    def test_create_duplicate_raises(self):
+        self._run('tag', 'create', 'db.t', 'v1')
+        out, err, code = self._run('tag', 'create', 'db.t', 'v1')
+        self.assertEqual(1, code)
+        self.assertIn("already exists", err)
+
+    def test_create_duplicate_ignore_if_exists(self):
+        self._run('tag', 'create', 'db.t', 'v1')
+        _, _, code = self._run(
+            'tag', 'create', 'db.t', 'v1', '--ignore-if-exists')
+        self.assertEqual(0, code)
+
+    # -- get -----------------------------------------------------------------
+
+    def test_get_table_format(self):
+        self._run('tag', 'create', 'db.t', 'v1', '--snapshot-id', '2')
+        out, _, code = self._run('tag', 'get', 'db.t', 'v1')
+        self.assertEqual(0, code)
+        self.assertIn("Tag: v1", out)
+        self.assertIn("Snapshot ID: 2", out)
+
+    def test_get_json_format(self):
+        self._run('tag', 'create', 'db.t', 'v1')
+        out, _, code = self._run(
+            'tag', 'get', 'db.t', 'v1', '--format', 'json')
+        self.assertEqual(0, code)
+        parsed = json.loads(out)
+        self.assertEqual("v1", parsed["tagName"])
+
+    def test_get_not_exists(self):
+        _, err, code = self._run('tag', 'get', 'db.t', 'absent')
+        self.assertEqual(1, code)
+        self.assertIn("does not exist", err)
+
+    # -- delete --------------------------------------------------------------
+
+    def test_delete(self):
+        self._run('tag', 'create', 'db.t', 'v1')
+        out, _, code = self._run('tag', 'delete', 'db.t', 'v1')
+        self.assertEqual(0, code)
+        self.assertIn("deleted", out)
+        out, _, _ = self._run('tag', 'list', 'db.t')
+        self.assertNotIn("v1", out)
+
+    def test_delete_not_exists(self):
+        _, err, code = self._run('tag', 'delete', 'db.t', 'absent')
+        self.assertEqual(1, code)
+        self.assertIn("does not exist", err)
+
+    # -- bad input -----------------------------------------------------------
+
+    def test_invalid_identifier(self):
+        _, err, code = self._run('tag', 'create', 'nodot', 'v1')
+        self.assertEqual(1, code)
+        self.assertIn("Invalid table identifier", err)
+
+
+if __name__ == "__main__":
+    unittest.main()

Reply via email to