This is an automated email from the ASF dual-hosted git repository.

lzljs3620320 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 949cb4fd14 [python] Introduce Paimon CLI in PyPaimon (#7358)
949cb4fd14 is described below

commit 949cb4fd140b3b461c676cf5a54b94d8f772d793
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Mar 8 11:11:29 2026 +0800

    [python] Introduce Paimon CLI in PyPaimon (#7358)
    
    PyPaimon provides a command-line interface (CLI) for interacting with
    Paimon catalogs and tables.
    The CLI allows you to read data from Paimon tables directly from the
    command line.
    
    The CLI is installed automatically when you install PyPaimon.
    
    This PR implements:
    1. table read.
    2. table get.
    3. table create.
---
 docs/content/pypaimon/cli.md             | 206 +++++++++++++++++
 paimon-python/dev/requirements.txt       |   1 +
 paimon-python/pypaimon/cli/__init__.py   |  20 ++
 paimon-python/pypaimon/cli/cli.py        | 123 ++++++++++
 paimon-python/pypaimon/cli/cli_table.py  | 235 +++++++++++++++++++
 paimon-python/pypaimon/tests/cli_test.py | 378 +++++++++++++++++++++++++++++++
 paimon-python/setup.py                   |   5 +
 7 files changed, 968 insertions(+)

diff --git a/docs/content/pypaimon/cli.md b/docs/content/pypaimon/cli.md
new file mode 100644
index 0000000000..27ee3bc2d8
--- /dev/null
+++ b/docs/content/pypaimon/cli.md
@@ -0,0 +1,206 @@
+---
+title: "Command Line Interface"
+weight: 99
+type: docs
+aliases:
+- /pypaimon/cli.html
+---
+<!--
+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.
+-->
+
+# Command Line Interface
+
+PyPaimon provides a command-line interface (CLI) for interacting with Paimon 
catalogs and tables. 
+The CLI allows you to read data from Paimon tables directly from the command 
line.
+
+## Installation
+
+The CLI is installed automatically when you install PyPaimon:
+
+```shell
+pip install pypaimon
+```
+
+After installation, the `paimon` command will be available in your terminal.
+
+## Configuration
+
+Before using the CLI, you need to create a catalog configuration file. 
+By default, the CLI looks for a `paimon.yaml` file in the current directory.
+
+### Create Configuration File
+
+Create a `paimon.yaml` file with your catalog settings:
+
+**Filesystem Catalog:**
+
+```yaml
+metastore: filesystem
+warehouse: /path/to/warehouse
+```
+
+**REST Catalog:**
+
+```yaml
+metastore: rest
+uri: http://localhost:8080
+warehouse: catalog_name
+```
+
+## Usage
+
+### Basic Syntax
+
+```shell
+paimon [OPTIONS] COMMAND [ARGS]...
+```
+
+### Global Options
+
+- `-c, --config PATH`: Path to catalog configuration file (default: 
`paimon.yaml`)
+- `--help`: Show help message and exit
+
+## Commands
+
+### Table Read
+
+Read data from a Paimon table and display it in a formatted table.
+
+#### Basic Usage
+
+```shell
+paimon table read DATABASE.TABLE
+```
+
+**Example:**
+
+```shell
+paimon table read mydb.users
+```
+
+Output:
+```
+ id    name  age      city
+  1   Alice   25   Beijing
+  2     Bob   30  Shanghai
+  3 Charlie   35 Guangzhou
+  4   David   28  Shenzhen
+  5     Eve   32  Hangzhou
+```
+
+#### Limit Results
+
+Use the `-l` or `--limit` option (default 100) to limit the number of rows 
displayed:
+
+```shell
+paimon table read DATABASE.TABLE -l 10
+```
+
+**Example:**
+
+```shell
+paimon table read mydb.users -l 2
+```
+
+Output:
+```
+ id  name  age     city
+  1 Alice   25  Beijing
+  2   Bob   30 Shanghai
+```
+
+### Table Get
+
+Get and display table schema information in JSON format. The output format is 
the same as the schema JSON format used in table create, making it easy to 
export and reuse table schemas.
+
+```shell
+paimon table get DATABASE.TABLE
+```
+
+**Example:**
+
+```shell
+paimon table get mydb.users
+```
+
+Output:
+```json
+{
+  "fields": [
+    {"id": 0, "name": "user_id", "type": "BIGINT"},
+    {"id": 1, "name": "username", "type": "STRING"},
+    {"id": 2, "name": "email", "type": "STRING"},
+    {"id": 3, "name": "age", "type": "INT"},
+    {"id": 4, "name": "city", "type": "STRING"},
+    {"id": 5, "name": "created_at", "type": "TIMESTAMP"},
+    {"id": 6, "name": "is_active", "type": "BOOLEAN"}
+  ],
+  "partitionKeys": ["city"],
+  "primaryKeys": ["user_id"],
+  "options": {
+    "bucket": "4",
+    "changelog-producer": "input"
+  },
+  "comment": "User information table"
+}
+```
+
+**Note:** The output JSON can be saved to a file and used directly with the 
`table create` command to recreate the table structure.
+
+### Table Create
+
+Create a new Paimon table with a schema defined in a JSON file. The schema 
JSON format is the same as the output from `table get`, ensuring consistency 
and easy schema reuse.
+
+```shell
+paimon table create DATABASE.TABLE --schema SCHEMA_FILE
+```
+
+**Options:**
+
+- `--schema, -s`: Path to schema JSON file - **Required**
+- `--ignore-if-exists, -i`: Do not raise error if table already exists
+
+The schema JSON file follows the same format as output by `table get`:
+
+**Field Properties:**
+
+- `id`: Field ID (integer, typically starts from 0) - **Required**
+- `name`: Field name - **Required**
+- `type`: Field data type (e.g., `INT`, `BIGINT`, `STRING`, `TIMESTAMP`, 
`DECIMAL(10,2)`) - **Required**
+- `description`: Optional field description
+
+**Schema Properties:**
+
+- `fields`: List of field definitions - **Required**
+- `partitionKeys`: List of partition key column names
+- `primaryKeys`: List of primary key column names
+- `options`: Table options as key-value pairs
+- `comment`: Table comment
+
+**Example Workflow:**
+
+1. Export schema from an existing table:
+   ```shell
+   paimon table get mydb.users > users_schema.json
+   ```
+
+2. Create a new table with the same schema:
+   ```shell
+   paimon table create mydb.users_copy --schema users_schema.json
+   ```
\ No newline at end of file
diff --git a/paimon-python/dev/requirements.txt 
b/paimon-python/dev/requirements.txt
index 936fd23198..a9f7456d63 100644
--- a/paimon-python/dev/requirements.txt
+++ b/paimon-python/dev/requirements.txt
@@ -33,3 +33,4 @@ pyroaring
 readerwriterlock>=1,<2
 zstandard>=0.19,<1
 cramjam>=1.3.0,<3; python_version>="3.7"
+pyyaml>=5.4,<7
diff --git a/paimon-python/pypaimon/cli/__init__.py 
b/paimon-python/pypaimon/cli/__init__.py
new file mode 100644
index 0000000000..88ff979214
--- /dev/null
+++ b/paimon-python/pypaimon/cli/__init__.py
@@ -0,0 +1,20 @@
+#  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.
+
+from pypaimon.cli.cli import main
+
+__all__ = ['main']
diff --git a/paimon-python/pypaimon/cli/cli.py 
b/paimon-python/pypaimon/cli/cli.py
new file mode 100644
index 0000000000..a262301cc7
--- /dev/null
+++ b/paimon-python/pypaimon/cli/cli.py
@@ -0,0 +1,123 @@
+#  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.
+
+"""
+Paimon CLI - Command Line Interface for Apache Paimon.
+
+This module provides a CLI for interacting with Paimon catalogs and tables.
+"""
+
+import argparse
+import os
+import sys
+from typing import Dict
+
+import yaml
+
+
+def load_catalog_config(config_path: str = 'paimon.yaml') -> Dict:
+    """
+    Load catalog configuration from a YAML file.
+    
+    Args:
+        config_path: Path to the catalog configuration file.
+                     Defaults to 'paimon.yaml' in the current directory.
+    
+    Returns:
+        Dictionary containing catalog configuration options.
+    
+    Raises:
+        FileNotFoundError: If the configuration file does not exist.
+        ValueError: If the configuration file is invalid.
+    """
+    if not os.path.exists(config_path):
+        raise FileNotFoundError(
+            f"Catalog configuration file not found: {config_path}\n"
+            f"Please create a paimon.yaml file in the current directory.\n"
+            f"Example paimon.yaml:\n"
+            f"  metastore: filesystem\n"
+            f"  warehouse: /path/to/warehouse\n"
+        )
+    
+    with open(config_path, 'r', encoding='utf-8') as f:
+        config = yaml.safe_load(f)
+    
+    if not config:
+        raise ValueError(f"Empty configuration file: {config_path}")
+    
+    # Validate required fields
+    if 'metastore' not in config:
+        config['metastore'] = 'filesystem'
+    
+    if config['metastore'] == 'filesystem' and 'warehouse' not in config:
+        raise ValueError(
+            "Missing required 'warehouse' configuration for filesystem 
catalog.\n"
+            "Please add 'warehouse: /path/to/warehouse' to your paimon.yaml"
+        )
+    
+    return config
+
+
+def create_catalog(config: Dict):
+    """
+    Create a catalog instance from configuration.
+    
+    Args:
+        config: Dictionary containing catalog configuration options.
+    
+    Returns:
+        Catalog instance.
+    """
+    from pypaimon import CatalogFactory
+    return CatalogFactory.create(config)
+
+
+def main():
+    """Main entry point for the Paimon CLI."""
+    parser = argparse.ArgumentParser(
+        prog='paimon',
+        description='Apache Paimon Command Line Interface'
+    )
+    parser.add_argument(
+        '--config', '-c',
+        default='paimon.yaml',
+        help='Path to catalog configuration file (default: paimon.yaml)'
+    )
+    
+    subparsers = parser.add_subparsers(dest='command', help='Available 
commands')
+    
+    # Table commands
+    table_parser = subparsers.add_parser('table', help='Table operations')
+    
+    # Import and add table subcommands
+    from pypaimon.cli.cli_table import add_table_subcommands
+    add_table_subcommands(table_parser)
+
+    args = parser.parse_args()
+    
+    if args.command is None:
+        parser.print_help()
+        sys.exit(0)
+    
+    if hasattr(args, 'func'):
+        args.func(args)
+    else:
+        parser.print_help()
+
+
+if __name__ == '__main__':
+    main()
diff --git a/paimon-python/pypaimon/cli/cli_table.py 
b/paimon-python/pypaimon/cli/cli_table.py
new file mode 100644
index 0000000000..1f4dc59734
--- /dev/null
+++ b/paimon-python/pypaimon/cli/cli_table.py
@@ -0,0 +1,235 @@
+#  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.
+
+"""
+Table commands for Paimon CLI.
+
+This module provides table-related commands for the CLI.
+"""
+
+import sys
+from pypaimon.common.json_util import JSON
+
+
+def cmd_table_read(args):
+    """
+    Execute the 'table read' command.
+    
+    Reads data from a Paimon table and displays it.
+    
+    Args:
+        args: Parsed command line arguments.
+    """
+    from pypaimon.cli.cli import load_catalog_config, create_catalog
+    
+    # Load catalog configuration
+    config_path = args.config
+    config = load_catalog_config(config_path)
+    
+    # Create catalog
+    catalog = create_catalog(config)
+    
+    # Parse table identifier
+    table_identifier = args.table
+    parts = table_identifier.split('.')
+    if len(parts) != 2:
+        print(f"Error: Invalid table identifier '{table_identifier}'. "
+              f"Expected format: 'database.table'", file=sys.stderr)
+        sys.exit(1)
+    
+    database_name, table_name = parts
+    
+    # Get table
+    try:
+        table = catalog.get_table(f"{database_name}.{table_name}")
+    except Exception as e:
+        print(f"Error: Failed to get table '{table_identifier}': {e}", 
file=sys.stderr)
+        sys.exit(1)
+    
+    # Build read pipeline
+    read_builder = table.new_read_builder()
+    
+    # Apply limit if specified
+    limit = args.limit
+    if limit:
+        read_builder = read_builder.with_limit(limit)
+    
+    # Scan and read
+    scan = read_builder.new_scan()
+    plan = scan.plan()
+    splits = plan.splits()
+    
+    read = read_builder.new_read()
+
+    # Use pandas to display as a nice table
+    df = read.to_pandas(splits)
+    if limit and len(df) > limit:
+        df = df.head(limit)
+    print(df.to_string(index=False))
+
+
+def cmd_table_get(args):
+    """
+    Execute the 'table get' command.
+    
+    Gets and displays table schema information in JSON format.
+    
+    Args:
+        args: Parsed command line arguments.
+    """
+    from pypaimon.cli.cli import load_catalog_config, create_catalog
+    
+    # Load catalog configuration
+    config_path = args.config
+    config = load_catalog_config(config_path)
+    
+    # Create catalog
+    catalog = create_catalog(config)
+    
+    # Parse table identifier
+    table_identifier = args.table
+    parts = table_identifier.split('.')
+    if len(parts) != 2:
+        print(f"Error: Invalid table identifier '{table_identifier}'. "
+              f"Expected format: 'database.table'", file=sys.stderr)
+        sys.exit(1)
+    
+    database_name, table_name = parts
+    
+    # Get table
+    try:
+        table = catalog.get_table(f"{database_name}.{table_name}")
+    except Exception as e:
+        print(f"Error: Failed to get table '{table_identifier}': {e}", 
file=sys.stderr)
+        sys.exit(1)
+    
+    # Get table schema and convert to Schema, then output as JSON
+    schema = table.table_schema.to_schema()
+    print(JSON.to_json(schema, indent=2))
+
+
+def cmd_table_create(args):
+    """
+    Execute the 'table create' command.
+    
+    Creates a new Paimon table with the specified schema.
+    
+    Args:
+        args: Parsed command line arguments.
+    """
+    import json
+    from pypaimon.cli.cli import load_catalog_config, create_catalog
+    from pypaimon import Schema
+    
+    # Load catalog configuration
+    config_path = args.config
+    config = load_catalog_config(config_path)
+    
+    # Create catalog
+    catalog = create_catalog(config)
+    
+    # Parse table identifier
+    table_identifier = args.table
+    parts = table_identifier.split('.')
+    if len(parts) != 2:
+        print(f"Error: Invalid table identifier '{table_identifier}'. "
+              f"Expected format: 'database.table'", file=sys.stderr)
+        sys.exit(1)
+    
+    database_name, table_name = parts
+    
+    # Load schema from JSON file
+    schema_file = args.schema
+    if not schema_file:
+        print("Error: Schema is required. Use --schema option.", 
file=sys.stderr)
+        sys.exit(1)
+    
+    try:
+        with open(schema_file, 'r', encoding='utf-8') as f:
+            schema_json = f.read()
+        paimon_schema = JSON.from_json(schema_json, Schema)
+        
+    except FileNotFoundError:
+        print(f"Error: Schema file not found: {schema_file}", file=sys.stderr)
+        sys.exit(1)
+    except json.JSONDecodeError as e:
+        print(f"Error: Invalid JSON format in schema file: {e}", 
file=sys.stderr)
+        sys.exit(1)
+    except Exception as e:
+        print(f"Error: Failed to parse schema: {e}", file=sys.stderr)
+        sys.exit(1)
+    
+    # Create table
+    try:
+        ignore_if_exists = args.ignore_if_exists
+        catalog.create_table(f"{database_name}.{table_name}", paimon_schema, 
ignore_if_exists)
+        
+        print(f"Table '{database_name}.{table_name}' created successfully.")
+        
+    except Exception as e:
+        print(f"Error: Failed to create table: {e}", file=sys.stderr)
+        sys.exit(1)
+
+
+def add_table_subcommands(table_parser):
+    """
+    Add table subcommands to the parser.
+    
+    Args:
+        table_parser: The table subparser to add commands to.
+    """
+    table_subparsers = table_parser.add_subparsers(dest='table_command', 
help='Table commands')
+    
+    # table read command
+    read_parser = table_subparsers.add_parser('read', help='Read data from a 
table')
+    read_parser.add_argument(
+        'table',
+        help='Table identifier in format: database.table'
+    )
+    read_parser.add_argument(
+        '--limit', '-l',
+        type=int,
+        default=100,
+        help='Maximum number of results to display (default: 100)'
+    )
+    read_parser.set_defaults(func=cmd_table_read)
+    
+    # table get command
+    get_parser = table_subparsers.add_parser('get', help='Get table schema 
information')
+    get_parser.add_argument(
+        'table',
+        help='Table identifier in format: database.table'
+    )
+    get_parser.set_defaults(func=cmd_table_get)
+    
+    # table create command
+    create_parser = table_subparsers.add_parser('create', help='Create a new 
table')
+    create_parser.add_argument(
+        'table',
+        help='Table identifier in format: database.table'
+    )
+    create_parser.add_argument(
+        '--schema', '-s',
+        required=True,
+        help='Path to schema JSON file'
+    )
+    create_parser.add_argument(
+        '--ignore-if-exists', '-i',
+        action='store_true',
+        help='Do not raise error if table already exists'
+    )
+    create_parser.set_defaults(func=cmd_table_create)
diff --git a/paimon-python/pypaimon/tests/cli_test.py 
b/paimon-python/pypaimon/tests/cli_test.py
new file mode 100644
index 0000000000..f459a3cd97
--- /dev/null
+++ b/paimon-python/pypaimon/tests/cli_test.py
@@ -0,0 +1,378 @@
+#  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.
+
+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 CliTest(unittest.TestCase):
+    """Integration tests for CLI with real catalog and table operations."""
+
+    @classmethod
+    def setUpClass(cls):
+        """Set up test catalog, database, and table with sample data."""
+        cls.tempdir = tempfile.mkdtemp()
+        cls.warehouse = os.path.join(cls.tempdir, 'warehouse')
+        
+        # Create catalog
+        cls.catalog = CatalogFactory.create({
+            'warehouse': cls.warehouse
+        })
+        cls.catalog.create_database('test_db', True)
+        
+        # Create test table with sample data
+        cls._create_test_table()
+        
+        # Create catalog config file
+        cls.config_file = os.path.join(cls.tempdir, 'paimon.yaml')
+        with open(cls.config_file, 'w') as f:
+            f.write(f"metastore: filesystem\nwarehouse: {cls.warehouse}\n")
+
+    @classmethod
+    def tearDownClass(cls):
+        """Clean up temporary directory."""
+        shutil.rmtree(cls.tempdir, ignore_errors=True)
+
+    @classmethod
+    def _create_test_table(cls):
+        """Create a test table and insert sample data."""
+        # Define schema
+        pa_schema = pa.schema([
+            ('id', pa.int32()),
+            ('name', pa.string()),
+            ('age', pa.int32()),
+            ('city', pa.string())
+        ])
+        
+        schema = Schema.from_pyarrow_schema(pa_schema)
+        cls.catalog.create_table('test_db.users', schema, False)
+        
+        # Get table and write data
+        table = cls.catalog.get_table('test_db.users')
+        write_builder = table.new_batch_write_builder()
+        table_write = write_builder.new_write()
+        table_commit = write_builder.new_commit()
+        
+        # Create sample data
+        data = {
+            'id': [1, 2, 3, 4, 5],
+            'name': ['Alice', 'Bob', 'Charlie', 'David', 'Eve'],
+            'age': [25, 30, 35, 28, 32],
+            'city': ['Beijing', 'Shanghai', 'Guangzhou', 'Shenzhen', 
'Hangzhou']
+        }
+        
+        table_data = pa.Table.from_pydict(data, schema=pa_schema)
+        table_write.write_arrow(table_data)
+        table_commit.commit(table_write.prepare_commit())
+        table_write.close()
+        table_commit.close()
+
+    def test_cli_table_read_basic(self):
+        """Test basic table read via CLI."""
+        # Simulate CLI command: paimon -c <config> table read test_db.users
+        with patch('sys.argv', ['paimon', '-c', self.config_file, 'table', 
'read', 'test_db.users']):
+            with patch('sys.stdout', new_callable=StringIO) as mock_stdout:
+                try:
+                    main()
+                except SystemExit:
+                    pass
+                
+                output = mock_stdout.getvalue()
+                
+                # Verify output contains data
+                self.assertIn('Alice', output)
+                self.assertIn('Bob', output)
+                self.assertIn('Beijing', output)
+                self.assertIn('Shanghai', output)
+                # Verify header
+                self.assertIn('id', output.lower())
+                self.assertIn('name', output.lower())
+
+    def test_cli_table_read_with_limit(self):
+        """Test table read with max results limit via CLI."""
+        # Simulate CLI command: paimon table read test_db.users -n 2
+        with patch('sys.argv', ['paimon', '-c', self.config_file, 'table', 
'read', 'test_db.users', '-l', '2']):
+            with patch('sys.stdout', new_callable=StringIO) as mock_stdout:
+                try:
+                    main()
+                except SystemExit:
+                    pass
+                
+                output = mock_stdout.getvalue()
+                
+                # Verify output contains limited data (only first 2 rows)
+                lines = [line for line in output.split('\n') if line.strip()]
+                # Should have header + 2 data rows
+                self.assertLessEqual(len(lines), 4)  # header + 2 data rows + 
possible empty lines
+
+    def test_cli_table_read_nonexistent_database(self):
+        """Test CLI error handling for nonexistent database."""
+        with patch('sys.argv', ['paimon', '-c', self.config_file, 'table', 
'read', 'nonexistent.table']):
+            with patch('sys.stderr', new_callable=StringIO) as mock_stderr:
+                with self.assertRaises(SystemExit) as context:
+                    main()
+                
+                self.assertEqual(context.exception.code, 1)
+                error_output = mock_stderr.getvalue()
+                self.assertIn('Error', error_output)
+
+    def test_cli_table_read_invalid_table_identifier(self):
+        """Test CLI error handling for invalid table identifier format."""
+        with patch('sys.argv', ['paimon', '-c', self.config_file, 'table', 
'read', 'invalid_format']):
+            with patch('sys.stderr', new_callable=StringIO) as mock_stderr:
+                with self.assertRaises(SystemExit) as context:
+                    main()
+                
+                self.assertEqual(context.exception.code, 1)
+                error_output = mock_stderr.getvalue()
+                self.assertIn('Invalid table identifier', error_output)
+
+    def test_cli_with_custom_config_path(self):
+        """Test CLI with custom configuration file path."""
+        # Create a different config file
+        custom_config = os.path.join(self.tempdir, 'custom_catalog.yaml')
+        with open(custom_config, 'w') as f:
+            f.write(f"metastore: filesystem\nwarehouse: {self.warehouse}\n")
+        
+        with patch('sys.argv', ['paimon', '-c', custom_config, 'table', 
'read', 'test_db.users']):
+            with patch('sys.stdout', new_callable=StringIO) as mock_stdout:
+                try:
+                    main()
+                except SystemExit:
+                    pass
+                
+                output = mock_stdout.getvalue()
+                self.assertIn('Alice', output)
+
+    def test_cli_table_get_basic(self):
+        """Test basic table get via CLI."""
+        # Simulate CLI command: paimon -c <config> table get test_db.users
+        with patch('sys.argv', ['paimon', '-c', self.config_file, 'table', 
'get', 'test_db.users']):
+            with patch('sys.stdout', new_callable=StringIO) as mock_stdout:
+                try:
+                    main()
+                except SystemExit:
+                    pass
+                
+                output = mock_stdout.getvalue()
+                
+                # Verify output is valid JSON
+                import json
+                schema_json = json.loads(output)
+                
+                # Verify schema structure
+                self.assertIn('fields', schema_json)
+                self.assertIsInstance(schema_json['fields'], list)
+                
+                # Verify field names are present
+                field_names = [field['name'] for field in 
schema_json['fields']]
+                self.assertIn('id', field_names)
+                self.assertIn('name', field_names)
+                self.assertIn('age', field_names)
+                self.assertIn('city', field_names)
+
+    def test_cli_table_get_nonexistent_table(self):
+        """Test CLI error handling for nonexistent table in table get."""
+        with patch('sys.argv', ['paimon', '-c', self.config_file, 'table', 
'get', 'nonexistent.table']):
+            with patch('sys.stderr', new_callable=StringIO) as mock_stderr:
+                with self.assertRaises(SystemExit) as context:
+                    main()
+                
+                self.assertEqual(context.exception.code, 1)
+                error_output = mock_stderr.getvalue()
+                self.assertIn('Error', error_output)
+
+    def test_cli_table_get_invalid_table_identifier(self):
+        """Test CLI error handling for invalid table identifier format in 
table get."""
+        with patch('sys.argv', ['paimon', '-c', self.config_file, 'table', 
'get', 'invalid_format']):
+            with patch('sys.stderr', new_callable=StringIO) as mock_stderr:
+                with self.assertRaises(SystemExit) as context:
+                    main()
+                
+                self.assertEqual(context.exception.code, 1)
+                error_output = mock_stderr.getvalue()
+                self.assertIn('Invalid table identifier', error_output)
+
+    def test_cli_table_create_basic(self):
+        """Test basic table create via CLI."""
+        # Create schema file in JSON format (CLI only supports JSON)
+        import json
+        schema_file = os.path.join(self.tempdir, 'test_schema.json')
+        schema_data = {
+            'fields': [
+                {'id': 0, 'name': 'product_id', 'type': 'BIGINT'},
+                {'id': 1, 'name': 'product_name', 'type': 'STRING'},
+                {'id': 2, 'name': 'price', 'type': 'DOUBLE'},
+                {'id': 3, 'name': 'category', 'type': 'STRING'}
+            ],
+            'primaryKeys': ['product_id'],
+            'options': {'bucket': '2'},
+            'comment': 'Test products table'
+        }
+        
+        with open(schema_file, 'w') as f:
+            json.dump(schema_data, f)
+        
+        # Simulate CLI command: paimon -c <config> table create 
test_db.products -s schema.json
+        with patch('sys.argv',
+                   ['paimon', '-c', self.config_file, 'table', 'create', 
'test_db.products', '-s', schema_file]):
+            with patch('sys.stdout', new_callable=StringIO) as mock_stdout:
+                try:
+                    main()
+                except SystemExit:
+                    pass
+                
+                output = mock_stdout.getvalue()
+                
+                # Verify success message
+                self.assertIn('created successfully', output)
+                
+        # Verify table was created
+        table = self.catalog.get_table('test_db.products')
+        self.assertIsNotNone(table)
+
+    def test_cli_table_create_with_json_schema(self):
+        """Test table create with JSON schema file."""
+        import json
+        
+        schema_file = os.path.join(self.tempdir, 'test_schema.json')
+        schema_data = {
+            'fields': [
+                {'id': 0, 'name': 'order_id', 'type': 'BIGINT'},
+                {'id': 1, 'name': 'customer_id', 'type': 'INT'},
+                {'id': 2, 'name': 'amount', 'type': 'DOUBLE'}
+            ],
+            'partitionKeys': ['customer_id'],
+            'options': {'bucket': '3'}
+        }
+        
+        with open(schema_file, 'w') as f:
+            json.dump(schema_data, f)
+        
+        with patch('sys.argv',
+                   ['paimon', '-c', self.config_file, 'table', 'create', 
'test_db.orders', '-s', schema_file]):
+            with patch('sys.stdout', new_callable=StringIO) as mock_stdout:
+                try:
+                    main()
+                except SystemExit:
+                    pass
+                
+                output = mock_stdout.getvalue()
+                self.assertIn('created successfully', output)
+        
+        # Verify table was created with correct schema
+        table = self.catalog.get_table('test_db.orders')
+        schema = table.table_schema
+        self.assertEqual(len(schema.fields), 3)
+        self.assertIn('customer_id', schema.partition_keys)
+
+    def test_cli_table_create_ignore_if_exists(self):
+        """Test table create with ignore-if-exists flag."""
+        import json
+        schema_file = os.path.join(self.tempdir, 'test_schema2.json')
+        schema_data = {
+            'fields': [
+                {'id': 0, 'name': 'id', 'type': 'INT'},
+                {'id': 1, 'name': 'value', 'type': 'STRING'}
+            ]
+        }
+        
+        with open(schema_file, 'w') as f:
+            json.dump(schema_data, f)
+        
+        # Create table first time
+        with patch('sys.argv',
+                   ['paimon', '-c', self.config_file, 'table', 'create', 
'test_db.temp_table', '-s', schema_file]):
+            with patch('sys.stdout', new_callable=StringIO):
+                try:
+                    main()
+                except SystemExit:
+                    pass
+        
+        # Try to create again with ignore-if-exists flag
+        with patch('sys.argv',
+                   ['paimon',
+                    '-c',
+                    self.config_file,
+                    'table',
+                    'create',
+                    'test_db.temp_table',
+                    '-s',
+                    schema_file,
+                    '-i']):
+            with patch('sys.stdout', new_callable=StringIO) as mock_stdout:
+                try:
+                    main()
+                except SystemExit:
+                    pass
+                
+                # Should succeed without error
+                output = mock_stdout.getvalue()
+                self.assertIn('created successfully', output)
+
+    def test_cli_table_create_invalid_table_identifier(self):
+        """Test CLI error handling for invalid table identifier format in 
table create."""
+        import json
+        schema_file = os.path.join(self.tempdir, 'dummy_schema.json')
+        schema_data = {
+            'fields': [
+                {'id': 0, 'name': 'id', 'type': 'INT'}
+            ]
+        }
+        with open(schema_file, 'w') as f:
+            json.dump(schema_data, f)
+        
+        with patch('sys.argv',
+                   ['paimon', '-c', self.config_file, 'table', 'create', 
'invalid_format', '-s', schema_file]):
+            with patch('sys.stderr', new_callable=StringIO) as mock_stderr:
+                with self.assertRaises(SystemExit) as context:
+                    main()
+                
+                self.assertEqual(context.exception.code, 1)
+                error_output = mock_stderr.getvalue()
+                self.assertIn('Invalid table identifier', error_output)
+
+    def test_cli_table_create_missing_schema_file(self):
+        """Test CLI error handling for missing schema file."""
+        with patch('sys.argv',
+                   ['paimon',
+                    '-c',
+                    self.config_file,
+                    'table',
+                    'create',
+                    'test_db.missing_table',
+                    '-s',
+                    '/nonexistent/path/schema.json']):
+            with patch('sys.stderr', new_callable=StringIO) as mock_stderr:
+                with self.assertRaises(SystemExit) as context:
+                    main()
+                
+                self.assertEqual(context.exception.code, 1)
+                error_output = mock_stderr.getvalue()
+                self.assertIn('Schema file not found', error_output)
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/paimon-python/setup.py b/paimon-python/setup.py
index 18af2e3f76..741ee80366 100644
--- a/paimon-python/setup.py
+++ b/paimon-python/setup.py
@@ -50,6 +50,11 @@ setup(
     packages=PACKAGES,
     include_package_data=True,
     install_requires=install_requires,
+    entry_points={
+        'console_scripts': [
+            'paimon=pypaimon.cli:main',
+        ],
+    },
     extras_require={
         'ray': [
             'ray>=2.10,<3; python_version>="3.7"',


Reply via email to