You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@polaris.apache.org by yz...@apache.org on 2026/08/12 04:55:49 UTC
(polaris) branch main updated: Add views and generic-tables support in CLI (#5260)
This is an automated email from the ASF dual-hosted git repository.
MonkeyCanCode pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/polaris.git
The following commit(s) were added to refs/heads/main by this push:
new 72346a332e Add views and generic-tables support in CLI (#5260)
72346a332e is described below
commit 72346a332eb4f78e46795fbd8bf8074f4d071b91
Author: Yong Zheng <yo...@gmail.com>
AuthorDate: Tue Aug 11 23:55:44 2026 -0500
Add views and generic-tables support in CLI (#5260)
---
CHANGELOG.md | 1 +
.../python/apache_polaris/cli/command/__init__.py | 24 +++
.../apache_polaris/cli/command/generic_tables.py | 94 ++++++++++
client/python/apache_polaris/cli/command/views.py | 175 ++++++++++++++++++
client/python/apache_polaris/cli/constants.py | 3 +
.../apache_polaris/cli/options/option_tree.py | 86 +++++++++
client/python/tests/test_generic_tables_command.py | 148 ++++++++++++++++
client/python/tests/test_views_command.py | 154 ++++++++++++++++
.../in-dev/unreleased/command-line-interface.md | 197 ++++++++++++++++++++-
9 files changed, 879 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 38abee8da9..d6e7e32846 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -115,6 +115,7 @@ request adding CHANGELOG notes for breaking (!) changes and possibly other secti
- Added optional `sessionPolicy` field to `SigV4AuthenticationParameters` for catalog federation. When set, the IAM session policy JSON is attached to the STS AssumeRole request, allowing administrators to restrict vended credentials to only the required AWS services and actions (Principle of Least Privilege).
- Python CLI: added `--scheme` to specify URL scheme
- Added opt-in idempotency for `createTable` and `updateTable` in the Iceberg REST catalog. When enabled via `polaris.idempotency.enabled=true` (default `false`), a client-supplied `Idempotency-Key` header is embedded into the table entity and committed in the same transaction as the operation; a retry carrying the same key within the TTL window (`polaris.idempotency.ttl`, default `PT5M`) replays the original success instead of failing — with `AlreadyExists` for `createTable`, or with `C [...]
+- Python CLI: Added views and generic-tables support.
### Changes
diff --git a/client/python/apache_polaris/cli/command/__init__.py b/client/python/apache_polaris/cli/command/__init__.py
index 1b16d285ab..14bb810c0a 100644
--- a/client/python/apache_polaris/cli/command/__init__.py
+++ b/client/python/apache_polaris/cli/command/__init__.py
@@ -232,6 +232,30 @@ class Command(ABC):
),
table_name=options_get(Arguments.TABLE),
)
+ elif options.command == Commands.VIEWS:
+ from apache_polaris.cli.command.views import ViewCommand
+
+ subcommand = options_get(f"{Commands.VIEWS}_subcommand")
+ command = ViewCommand(
+ subcommand,
+ catalog_name=options_get(Arguments.CATALOG),
+ namespace=options_get(
+ Arguments.NAMESPACE, lambda x: x.split(".") if x else None
+ ),
+ view_name=options_get(Arguments.VIEW),
+ )
+ elif options.command == Commands.GENERIC_TABLES:
+ from apache_polaris.cli.command.generic_tables import GenericTableCommand
+
+ subcommand = options_get(f"{Commands.GENERIC_TABLES}_subcommand")
+ command = GenericTableCommand(
+ subcommand,
+ catalog_name=options_get(Arguments.CATALOG),
+ namespace=options_get(
+ Arguments.NAMESPACE, lambda x: x.split(".") if x else None
+ ),
+ generic_table_name=options_get(Arguments.GENERIC_TABLE),
+ )
elif options.command == Commands.FIND:
from apache_polaris.cli.command.find import FindCommand
diff --git a/client/python/apache_polaris/cli/command/generic_tables.py b/client/python/apache_polaris/cli/command/generic_tables.py
new file mode 100644
index 0000000000..f4eca1b3dc
--- /dev/null
+++ b/client/python/apache_polaris/cli/command/generic_tables.py
@@ -0,0 +1,94 @@
+#
+# 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 dataclasses import dataclass, field
+from typing import List, Optional, cast
+
+from apache_polaris.cli.command import Command
+from apache_polaris.cli.command.utils import get_catalog_api_client
+from apache_polaris.cli.exceptions import CliError
+from apache_polaris.cli.constants import Subcommands, Arguments, UNIT_SEPARATOR
+from apache_polaris.cli.options.option_tree import Argument
+from apache_polaris.sdk.catalog.api.generic_table_api import GenericTableAPI
+from apache_polaris.sdk.management import PolarisDefaultApi
+
+
+@dataclass
+class GenericTableCommand(Command):
+ """
+ A Command implementation to represent `polaris generic-tables`. It manages generic tables within a Polaris Catalog.
+
+ Example commands:
+ * polaris generic-tables list --catalog my_catalog --namespace ns1
+ * polaris generic-tables get my_table --catalog my_catalog --namespace ns1
+ * polaris generic-tables delete my_table --catalog my_catalog --namespace ns1
+ """
+
+ generic_tables_subcommand: str
+ catalog_name: Optional[str] = None
+ namespace: Optional[List[str]] = field(default_factory=list)
+ generic_table_name: Optional[str] = None
+
+ def validate(self) -> None:
+ if not self.catalog_name:
+ raise CliError(
+ f"Missing required argument: {Argument.to_flag_name(Arguments.CATALOG)}"
+ )
+ if not self.namespace:
+ raise CliError(
+ f"Missing required argument: {Argument.to_flag_name(Arguments.NAMESPACE)}"
+ )
+ if (
+ self.generic_tables_subcommand == Subcommands.GET
+ or self.generic_tables_subcommand == Subcommands.DELETE
+ ):
+ if not self.generic_table_name or not self.generic_table_name.strip():
+ raise CliError("The generic table name cannot be empty.")
+
+ def execute(self, api: PolarisDefaultApi) -> None:
+ generic_api = GenericTableAPI(get_catalog_api_client(api))
+ catalog_name = cast(str, self.catalog_name)
+ namespace_list = cast(List[str], self.namespace)
+ generic_table_name = cast(str, self.generic_table_name)
+ ns_str = UNIT_SEPARATOR.join(namespace_list)
+
+ if self.generic_tables_subcommand == Subcommands.LIST:
+ result = generic_api.list_generic_tables(
+ prefix=catalog_name, namespace=ns_str
+ )
+ for table_identifier in result.identifiers:
+ print(table_identifier.to_json())
+ elif self.generic_tables_subcommand == Subcommands.GET:
+ print(
+ generic_api.load_generic_table(
+ prefix=catalog_name,
+ namespace=ns_str,
+ generic_table=generic_table_name,
+ ).to_json()
+ )
+ elif self.generic_tables_subcommand == Subcommands.DELETE:
+ namespace_dot = ".".join(namespace_list)
+ print(f"Dropping generic table {namespace_dot}.{generic_table_name}...")
+ generic_api.drop_generic_table(
+ prefix=catalog_name,
+ namespace=ns_str,
+ generic_table=generic_table_name,
+ )
+ print(
+ f"Dropping generic table {namespace_dot}.{generic_table_name} completed"
+ )
diff --git a/client/python/apache_polaris/cli/command/views.py b/client/python/apache_polaris/cli/command/views.py
new file mode 100644
index 0000000000..142cb3008b
--- /dev/null
+++ b/client/python/apache_polaris/cli/command/views.py
@@ -0,0 +1,175 @@
+#
+# 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 dataclasses import dataclass, field
+from typing import List, Optional, cast
+
+from apache_polaris.cli.command import Command
+from apache_polaris.cli.command.utils import get_catalog_api_client
+from apache_polaris.cli.exceptions import CliError
+from apache_polaris.cli.constants import Subcommands, Arguments, UNIT_SEPARATOR
+from apache_polaris.cli.options.option_tree import Argument
+from apache_polaris.sdk.catalog import IcebergCatalogAPI
+from apache_polaris.sdk.management import PolarisDefaultApi
+from apache_polaris.cli.command.utils import (
+ handle_api_exception,
+ format_timestamp,
+ format_iceberg_type,
+)
+from prettytable import PrettyTable
+
+
+@dataclass
+class ViewCommand(Command):
+ """
+ A Command implementation to represent `polaris views`. It manages Iceberg views within a Polaris Catalog.
+
+ Example commands:
+ * polaris views list --catalog my_catalog --namespace ns1
+ * polaris views get my_view --catalog my_catalog --namespace ns1
+ * polaris views summarize my_view --catalog my_catalog --namespace ns1
+ * polaris views delete my_view --catalog my_catalog --namespace ns1
+ """
+
+ views_subcommand: str
+ catalog_name: Optional[str] = None
+ namespace: Optional[List[str]] = field(default_factory=list)
+ view_name: Optional[str] = None
+
+ def validate(self) -> None:
+ if not self.catalog_name:
+ raise CliError(
+ f"Missing required argument: {Argument.to_flag_name(Arguments.CATALOG)}"
+ )
+ if not self.namespace:
+ raise CliError(
+ f"Missing required argument: {Argument.to_flag_name(Arguments.NAMESPACE)}"
+ )
+ if (
+ self.views_subcommand == Subcommands.GET
+ or self.views_subcommand == Subcommands.SUMMARIZE
+ or self.views_subcommand == Subcommands.DELETE
+ ):
+ if not self.view_name or not self.view_name.strip():
+ raise CliError("The view name cannot be empty.")
+
+ def execute(self, api: PolarisDefaultApi) -> None:
+ catalog_api = IcebergCatalogAPI(get_catalog_api_client(api))
+ catalog_name = cast(str, self.catalog_name)
+ namespace_list = cast(List[str], self.namespace)
+ view_name = cast(str, self.view_name)
+ ns_str = UNIT_SEPARATOR.join(namespace_list)
+
+ if self.views_subcommand == Subcommands.LIST:
+ result = catalog_api.list_views(prefix=catalog_name, namespace=ns_str)
+ for view_identifier in result.identifiers:
+ print(view_identifier.to_json())
+ elif self.views_subcommand == Subcommands.GET:
+ print(
+ catalog_api.load_view(
+ prefix=catalog_name,
+ namespace=ns_str,
+ view=view_name,
+ ).to_json()
+ )
+ elif self.views_subcommand == Subcommands.DELETE:
+ namespace_dot = ".".join(namespace_list)
+ print(f"Dropping view {namespace_dot}.{view_name}...")
+ catalog_api.drop_view(
+ prefix=catalog_name,
+ namespace=ns_str,
+ view=view_name,
+ )
+ print(f"Dropping view {namespace_dot}.{view_name} completed")
+ elif self.views_subcommand == Subcommands.SUMMARIZE:
+ self._generate_summary(api, catalog_api, ns_str)
+
+ def _generate_summary(
+ self, api: PolarisDefaultApi, catalog_api: IcebergCatalogAPI, ns_str: str
+ ) -> None:
+ catalog_name = cast(str, self.catalog_name)
+ namespace_list = cast(List[str], self.namespace)
+ view_name = cast(str, self.view_name)
+
+ namespace_dot = ".".join(namespace_list)
+ print(f"View: {namespace_dot}.{view_name}")
+ print("-" * 80)
+ try:
+ resp = catalog_api.load_view(
+ prefix=catalog_name, namespace=ns_str, view=view_name
+ )
+ # Metadata
+ metadata = resp.metadata
+ current_version = next(
+ v
+ for v in metadata.versions
+ if v.version_id == metadata.current_version_id
+ )
+ print("Metadata")
+ print(f" {'Location:':<30} {metadata.location}")
+ print(f" {'Format Version:':<30} {metadata.format_version}")
+ print(f" {'Current Version ID:':<30} {metadata.current_version_id}")
+ print(
+ f" {'Last Updated:':<30} {format_timestamp(current_version.timestamp_ms)}"
+ )
+
+ # SQL representations
+ print("\nRepresentations")
+ for representation in current_version.representations:
+ unwrapped = representation.actual_instance
+ if unwrapped is None:
+ continue
+ print(f" {'Dialect:':<30} {unwrapped.dialect}")
+ indented_sql = "\n".join(
+ " " * 4 + line for line in unwrapped.sql.splitlines()
+ )
+ print(" SQL:")
+ print(indented_sql)
+
+ # Schema
+ print("\nSchema")
+ current_schema = next(
+ schema
+ for schema in metadata.schemas
+ if schema.schema_id == current_version.schema_id
+ )
+ table = PrettyTable(
+ field_names=["ID", "Field Name", "Type", "Comment"],
+ align="l",
+ )
+ for field in current_schema.fields:
+ type_str = format_iceberg_type(field.type)
+ column_comment = field.doc or ""
+ table.add_row([field.id, field.name, type_str, column_comment])
+ indented_table = "\n".join(
+ " " * 2 + line for line in table.get_string().splitlines()
+ )
+ print(indented_table)
+
+ # Version history
+ print("\nVersion History")
+ table = PrettyTable(field_names=["Version ID", "Timestamp"], align="l")
+ for entry in metadata.version_log:
+ table.add_row([entry.version_id, format_timestamp(entry.timestamp_ms)])
+ indented_table = "\n".join(
+ " " * 2 + line for line in table.get_string().splitlines()
+ )
+ print(indented_table)
+ except Exception as e:
+ handle_api_exception("View Metadata", e)
+ print("-" * 80)
diff --git a/client/python/apache_polaris/cli/constants.py b/client/python/apache_polaris/cli/constants.py
index 16fc42b153..d25888c9ba 100644
--- a/client/python/apache_polaris/cli/constants.py
+++ b/client/python/apache_polaris/cli/constants.py
@@ -111,6 +111,8 @@ class Commands:
POLICIES = "policies"
SETUP = "setup"
TABLES = "tables"
+ VIEWS = "views"
+ GENERIC_TABLES = "generic-tables"
FIND = "find"
REPL = "repl"
@@ -185,6 +187,7 @@ class Arguments:
NAMESPACE = "namespace"
TABLE = "table"
VIEW = "view"
+ GENERIC_TABLE = "generic-table"
CASCADE = "cascade"
CLIENT_SECRET = "client_secret"
NEW_CLIENT_ID = "new_client_id"
diff --git a/client/python/apache_polaris/cli/options/option_tree.py b/client/python/apache_polaris/cli/options/option_tree.py
index 5ef4d396fb..0bec7654f4 100644
--- a/client/python/apache_polaris/cli/options/option_tree.py
+++ b/client/python/apache_polaris/cli/options/option_tree.py
@@ -1241,6 +1241,90 @@ class OptionTree:
],
)
+ @staticmethod
+ def _views_option() -> Option:
+ return Option(
+ Commands.VIEWS,
+ hint="manage views",
+ children=[
+ Option(
+ Subcommands.LIST,
+ hint="List views",
+ args=[
+ Argument(Arguments.CATALOG, str, Hints.CATALOG),
+ Argument(Arguments.NAMESPACE, str, Hints.NAMESPACE),
+ ],
+ ),
+ Option(
+ Subcommands.GET,
+ hint="Retrieve metadata for a view",
+ args=[
+ Argument(Arguments.CATALOG, str, Hints.CATALOG),
+ Argument(Arguments.NAMESPACE, str, Hints.NAMESPACE),
+ ],
+ input_name=Arguments.VIEW,
+ input_metavar="VIEW_NAME",
+ ),
+ Option(
+ Subcommands.SUMMARIZE,
+ hint="Display a summary for a view",
+ args=[
+ Argument(Arguments.CATALOG, str, Hints.CATALOG),
+ Argument(Arguments.NAMESPACE, str, Hints.NAMESPACE),
+ ],
+ input_name=Arguments.VIEW,
+ input_metavar="VIEW_NAME",
+ ),
+ Option(
+ Subcommands.DELETE,
+ hint="Drop a view from catalog",
+ args=[
+ Argument(Arguments.CATALOG, str, Hints.CATALOG),
+ Argument(Arguments.NAMESPACE, str, Hints.NAMESPACE),
+ ],
+ input_name=Arguments.VIEW,
+ input_metavar="VIEW_NAME",
+ ),
+ ],
+ )
+
+ @staticmethod
+ def _generic_tables_option() -> Option:
+ return Option(
+ Commands.GENERIC_TABLES,
+ hint="manage generic tables",
+ children=[
+ Option(
+ Subcommands.LIST,
+ hint="List generic tables",
+ args=[
+ Argument(Arguments.CATALOG, str, Hints.CATALOG),
+ Argument(Arguments.NAMESPACE, str, Hints.NAMESPACE),
+ ],
+ ),
+ Option(
+ Subcommands.GET,
+ hint="Retrieve metadata for a generic table",
+ args=[
+ Argument(Arguments.CATALOG, str, Hints.CATALOG),
+ Argument(Arguments.NAMESPACE, str, Hints.NAMESPACE),
+ ],
+ input_name=Arguments.GENERIC_TABLE,
+ input_metavar="GENERIC_TABLE_NAME",
+ ),
+ Option(
+ Subcommands.DELETE,
+ hint="Drop a generic table from catalog",
+ args=[
+ Argument(Arguments.CATALOG, str, Hints.CATALOG),
+ Argument(Arguments.NAMESPACE, str, Hints.NAMESPACE),
+ ],
+ input_name=Arguments.GENERIC_TABLE,
+ input_metavar="GENERIC_TABLE_NAME",
+ ),
+ ],
+ )
+
@staticmethod
def _find_option() -> Option:
return Option(
@@ -1282,6 +1366,8 @@ class OptionTree:
OptionTree._policies_option(),
OptionTree._setup_option(),
OptionTree._tables_option(),
+ OptionTree._views_option(),
+ OptionTree._generic_tables_option(),
OptionTree._find_option(),
OptionTree._repl_option(),
]
diff --git a/client/python/tests/test_generic_tables_command.py b/client/python/tests/test_generic_tables_command.py
new file mode 100644
index 0000000000..bc384f637e
--- /dev/null
+++ b/client/python/tests/test_generic_tables_command.py
@@ -0,0 +1,148 @@
+#
+# 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 unittest.mock import patch, MagicMock
+from cli_test_utils import CLITestBase
+from apache_polaris.cli.constants import UNIT_SEPARATOR
+from apache_polaris.cli.exceptions import CLI_ERROR_EXIT_CODE
+from apache_polaris.cli.polaris_cli import PolarisCli
+from apache_polaris.sdk.catalog.exceptions import ApiException
+
+
+class TestGenericTablesCommand(CLITestBase):
+ def test_generic_table_validation(self) -> None:
+ mock_client = self.build_mock_client()
+ # Missing catalog/namespace
+ self.check_exception(
+ lambda: self.mock_execute(mock_client, ["generic-tables", "list"]),
+ "Missing required argument",
+ )
+ # Empty generic table name
+ self.check_exception(
+ lambda: self.mock_execute(
+ mock_client,
+ [
+ "generic-tables",
+ "get",
+ " ",
+ "--catalog",
+ "my-catalog",
+ "--namespace",
+ "ns1",
+ ],
+ ),
+ "The generic table name cannot be empty",
+ )
+
+ @patch("apache_polaris.cli.command.generic_tables.GenericTableAPI")
+ def test_generic_table_list(self, mock_generic_api_class: MagicMock) -> None:
+ mock_client = self.build_mock_client()
+ mock_generic_api = mock_generic_api_class.return_value
+ mock_generic_api.list_generic_tables.return_value.identifiers = []
+ self.mock_execute(
+ mock_client,
+ [
+ "generic-tables",
+ "list",
+ "--catalog",
+ "my-catalog",
+ "--namespace",
+ "ns1.ns2",
+ ],
+ )
+ mock_generic_api.list_generic_tables.assert_called_once_with(
+ prefix="my-catalog", namespace=UNIT_SEPARATOR.join(["ns1", "ns2"])
+ )
+
+ @patch("apache_polaris.cli.command.generic_tables.GenericTableAPI")
+ def test_generic_table_get(self, mock_generic_api_class: MagicMock) -> None:
+ mock_client = self.build_mock_client()
+ mock_generic_api = mock_generic_api_class.return_value
+ mock_generic_api.load_generic_table.return_value.to_json.return_value = "{}"
+ self.mock_execute(
+ mock_client,
+ [
+ "generic-tables",
+ "get",
+ "my_table",
+ "--catalog",
+ "my-catalog",
+ "--namespace",
+ "ns1",
+ ],
+ )
+ mock_generic_api.load_generic_table.assert_called_once_with(
+ prefix="my-catalog", namespace="ns1", generic_table="my_table"
+ )
+
+ @patch("apache_polaris.cli.command.generic_tables.GenericTableAPI")
+ def test_generic_table_delete(self, mock_generic_api_class: MagicMock) -> None:
+ mock_client = self.build_mock_client()
+ mock_generic_api = mock_generic_api_class.return_value
+ self.mock_execute(
+ mock_client,
+ [
+ "generic-tables",
+ "delete",
+ "my_table",
+ "--catalog",
+ "my-catalog",
+ "--namespace",
+ "ns1",
+ ],
+ )
+ mock_generic_api.drop_generic_table.assert_called_once_with(
+ prefix="my-catalog",
+ namespace="ns1",
+ generic_table="my_table",
+ )
+
+ @patch("apache_polaris.cli.polaris_cli.PolarisCli.print_api_exception")
+ @patch("apache_polaris.cli.command.generic_tables.GenericTableAPI")
+ @patch("apache_polaris.cli.polaris_cli.PolarisDefaultApi")
+ @patch("apache_polaris.cli.polaris_cli.ApiClientBuilder.get_api_client")
+ def test_generic_table_api_errors_exit_with_runtime_failure(
+ self,
+ _mock_get_api_client: MagicMock,
+ mock_default_api: MagicMock,
+ mock_generic_api_class: MagicMock,
+ mock_print_api_exception: MagicMock,
+ ) -> None:
+ mock_default_api.return_value = self.build_mock_client()
+ mock_generic_api = mock_generic_api_class.return_value
+ cases = [
+ ("list_generic_tables", ["generic-tables", "list"]),
+ ("load_generic_table", ["generic-tables", "get", "my_table"]),
+ ("drop_generic_table", ["generic-tables", "delete", "my_table"]),
+ ]
+
+ for api_method, args in cases:
+ with self.subTest(api_method=api_method):
+ method = getattr(mock_generic_api, api_method)
+ method.side_effect = ApiException(status=404, reason="Not Found")
+
+ with self.assertRaises(SystemExit) as cm:
+ PolarisCli.execute(
+ [*args, "--catalog", "my-catalog", "--namespace", "ns1"]
+ )
+
+ self.assertEqual(cm.exception.code, CLI_ERROR_EXIT_CODE)
+ mock_print_api_exception.assert_called_once()
+ method.side_effect = None
+ mock_print_api_exception.reset_mock()
diff --git a/client/python/tests/test_views_command.py b/client/python/tests/test_views_command.py
new file mode 100644
index 0000000000..f3e5e9702a
--- /dev/null
+++ b/client/python/tests/test_views_command.py
@@ -0,0 +1,154 @@
+#
+# 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 unittest.mock import patch, MagicMock
+from cli_test_utils import CLITestBase
+from apache_polaris.cli.constants import UNIT_SEPARATOR
+from apache_polaris.cli.exceptions import CLI_ERROR_EXIT_CODE
+from apache_polaris.cli.polaris_cli import PolarisCli
+from apache_polaris.sdk.catalog.exceptions import ApiException
+
+
+class TestViewsCommand(CLITestBase):
+ def test_view_validation(self) -> None:
+ mock_client = self.build_mock_client()
+ # Missing catalog/namespace
+ self.check_exception(
+ lambda: self.mock_execute(mock_client, ["views", "list"]),
+ "Missing required argument",
+ )
+ # Empty view name
+ self.check_exception(
+ lambda: self.mock_execute(
+ mock_client,
+ ["views", "get", " ", "--catalog", "my-catalog", "--namespace", "ns1"],
+ ),
+ "The view name cannot be empty",
+ )
+
+ @patch("apache_polaris.cli.command.views.IcebergCatalogAPI")
+ def test_view_list(self, mock_iceberg_api_class: MagicMock) -> None:
+ mock_client = self.build_mock_client()
+ mock_iceberg_api = mock_iceberg_api_class.return_value
+ mock_iceberg_api.list_views.return_value.identifiers = []
+ self.mock_execute(
+ mock_client,
+ ["views", "list", "--catalog", "my-catalog", "--namespace", "ns1.ns2"],
+ )
+ mock_iceberg_api.list_views.assert_called_once_with(
+ prefix="my-catalog", namespace=UNIT_SEPARATOR.join(["ns1", "ns2"])
+ )
+
+ @patch("apache_polaris.cli.command.views.IcebergCatalogAPI")
+ def test_view_get(self, mock_iceberg_api_class: MagicMock) -> None:
+ mock_client = self.build_mock_client()
+ mock_iceberg_api = mock_iceberg_api_class.return_value
+ mock_iceberg_api.load_view.return_value.to_json.return_value = "{}"
+ self.mock_execute(
+ mock_client,
+ [
+ "views",
+ "get",
+ "my_view",
+ "--catalog",
+ "my-catalog",
+ "--namespace",
+ "ns1",
+ ],
+ )
+ mock_iceberg_api.load_view.assert_called_once_with(
+ prefix="my-catalog", namespace="ns1", view="my_view"
+ )
+
+ @patch("apache_polaris.cli.command.views.IcebergCatalogAPI")
+ def test_view_delete(self, mock_iceberg_api_class: MagicMock) -> None:
+ mock_client = self.build_mock_client()
+ mock_iceberg_api = mock_iceberg_api_class.return_value
+ self.mock_execute(
+ mock_client,
+ [
+ "views",
+ "delete",
+ "my_view",
+ "--catalog",
+ "my-catalog",
+ "--namespace",
+ "ns1",
+ ],
+ )
+ mock_iceberg_api.drop_view.assert_called_once_with(
+ prefix="my-catalog",
+ namespace="ns1",
+ view="my_view",
+ )
+
+ @patch("apache_polaris.cli.polaris_cli.PolarisCli.print_api_exception")
+ @patch("apache_polaris.cli.command.views.IcebergCatalogAPI")
+ @patch("apache_polaris.cli.polaris_cli.PolarisDefaultApi")
+ @patch("apache_polaris.cli.polaris_cli.ApiClientBuilder.get_api_client")
+ def test_view_api_errors_exit_with_runtime_failure(
+ self,
+ _mock_get_api_client: MagicMock,
+ mock_default_api: MagicMock,
+ mock_iceberg_api_class: MagicMock,
+ mock_print_api_exception: MagicMock,
+ ) -> None:
+ mock_default_api.return_value = self.build_mock_client()
+ mock_iceberg_api = mock_iceberg_api_class.return_value
+ cases = [
+ ("list_views", ["views", "list"]),
+ ("load_view", ["views", "get", "my_view"]),
+ ("drop_view", ["views", "delete", "my_view"]),
+ ]
+
+ for api_method, args in cases:
+ with self.subTest(api_method=api_method):
+ method = getattr(mock_iceberg_api, api_method)
+ method.side_effect = ApiException(status=404, reason="Not Found")
+
+ with self.assertRaises(SystemExit) as cm:
+ PolarisCli.execute(
+ [*args, "--catalog", "my-catalog", "--namespace", "ns1"]
+ )
+
+ self.assertEqual(cm.exception.code, CLI_ERROR_EXIT_CODE)
+ mock_print_api_exception.assert_called_once()
+ method.side_effect = None
+ mock_print_api_exception.reset_mock()
+
+ @patch("apache_polaris.cli.command.views.IcebergCatalogAPI")
+ def test_view_summarize(self, mock_iceberg_api_class: MagicMock) -> None:
+ mock_client = self.build_mock_client()
+ mock_iceberg_api = mock_iceberg_api_class.return_value
+
+ self.mock_execute(
+ mock_client,
+ [
+ "views",
+ "summarize",
+ "my_view",
+ "--catalog",
+ "my-catalog",
+ "--namespace",
+ "ns1",
+ ],
+ )
+ mock_iceberg_api.load_view.assert_called_with(
+ prefix="my-catalog", namespace="ns1", view="my_view"
+ )
diff --git a/site/content/in-dev/unreleased/command-line-interface.md b/site/content/in-dev/unreleased/command-line-interface.md
index 1d1f882947..c3f20476d2 100644
--- a/site/content/in-dev/unreleased/command-line-interface.md
+++ b/site/content/in-dev/unreleased/command-line-interface.md
@@ -46,8 +46,8 @@ Global Options:
--host HOST Polaris server hostname
--port PORT Polaris server port
--scheme {http,https} URL scheme for host/port (default: http)
- --base-url BASE_URL Complete base URL (overrides host/port)
- --catalog-url CATALOG_URL Base URL for the Iceberg REST Catalog (IRC) API. Use when a proxy or deployment maps a custom path directly to the catalog root (no /api/catalog appended).
+ --base-url BASE_URL Complete base URL (overrides host/port/scheme)
+ --catalog-url CATALOG_URL Base URL for the Iceberg REST Catalog (IRC) API. Use when your --base-url or deployment maps directly to the catalog root (no /api/catalog appended).
--client-id CLIENT_ID OAuth client ID
--client-secret CLIENT_SECRET OAuth client secret
--access-token ACCESS_TOKEN OAuth access token
@@ -71,7 +71,9 @@ Global Options:
10. setup
11. find
12. tables
-13. repl
+13. views
+14. generic-tables
+15. repl
Each _command_ supports several _subcommands_, and some _subcommands_ have _actions_ that come after the subcommand in turn. Finally, _arguments_ follow to form a full invocation. Within a set of named arguments at the end of an invocation ordering is generally not important. Many invocations also have a required positional argument of the type that the _command_ refers to. Again, the ordering of this positional argument relative to named arguments is not important.
@@ -89,6 +91,8 @@ polaris repair
polaris setup apply setup-config.yaml
polaris find some_table
polaris tables list --catalog my_catalog --namespace ns1
+polaris views list --catalog my_catalog --namespace ns1
+polaris generic-tables list --catalog my_catalog --namespace ns1
polaris repl
```
@@ -1815,6 +1819,193 @@ Command Options:
polaris tables delete my_table --catalog my_catalog --namespace ns1
```
+### Views
+
+The `views` command is used to manage Iceberg views within a Polaris Catalog.
+
+`views` supports the following subcommands:
+
+1. list
+2. get
+3. summarize
+4. delete
+
+#### list
+
+The `list` subcommand is used to list views within a namesace from a given catalog.
+
+```
+usage: polaris views list [-h] [options]
+
+options:
+ -h, --help show this help message and exit
+
+Command Options:
+ --catalog CATALOG The name of a catalog
+ --namespace NAMESPACE A period-delimited namespace
+```
+
+##### Examples
+
+```
+polaris views list
+```
+
+#### get
+
+The `get` subcommand retrieves the view metadata for a specific view.
+
+```
+usage: polaris views get [-h] [options] VIEW_NAME
+
+positional arguments:
+ VIEW_NAME view
+
+options:
+ -h, --help show this help message and exit
+
+Command Options:
+ --catalog CATALOG The name of a catalog
+ --namespace NAMESPACE A period-delimited namespace
+```
+
+##### Examples
+
+```
+polaris views get my_view --catalog my_catalog --namespace ns1
+```
+
+#### summarize
+
+The `summarize` subcommand provides a detail overview of a view.
+
+```
+usage: polaris views summarize [-h] [options] VIEW_NAME
+
+positional arguments:
+ VIEW_NAME view
+
+options:
+ -h, --help show this help message and exit
+
+Command Options:
+ --catalog CATALOG The name of a catalog
+ --namespace NAMESPACE A period-delimited namespace
+```
+
+##### Examples
+
+```
+polaris view summarize my_view --catalog my_catalog --namespace ns1
+```
+
+#### delete
+
+The `delete` subcommand drop a view from catalog.
+
+```
+usage: polaris views delete [-h] [options] VIEW_NAME
+
+positional arguments:
+ VIEW_NAME view
+
+options:
+ -h, --help show this help message and exit
+
+Command Options:
+ --catalog CATALOG The name of a catalog
+ --namespace NAMESPACE A period-delimited namespace
+```
+
+##### Examples
+
+```
+polaris views delete my_view --catalog my_catalog --namespace ns1
+```
+
+
+
+
+
+### Generic Tables
+
+The `generic-tables` command is used to manage generic tables within a Polaris Catalog.
+
+`generic-tables` supports the following subcommands:
+
+1. list
+2. get
+3. delete
+
+#### list
+
+The `list` subcommand is used to list generic tables within a namesace from a given catalog.
+
+```
+usage: polaris generic-tables list [-h] [options]
+
+options:
+ -h, --help show this help message and exit
+
+Command Options:
+ --catalog CATALOG The name of a catalog
+ --namespace NAMESPACE A period-delimited namespace
+```
+
+##### Examples
+
+```
+polaris generic-tables list
+```
+
+#### get
+
+The `get` subcommand retrieves the generic tables metadata for a specific generic table.
+
+```
+usage: polaris generic-tables get [-h] [options] GENERIC_TABLE_NAME
+
+positional arguments:
+ GENERIC_TABLE_NAME generic-table
+
+options:
+ -h, --help show this help message and exit
+
+Command Options:
+ --catalog CATALOG The name of a catalog
+ --namespace NAMESPACE A period-delimited namespace
+```
+
+##### Examples
+
+```
+polaris generic-tables get my_generic_table --catalog my_catalog --namespace ns1
+```
+
+#### delete
+
+The `delete` subcommand drop a generic table from catalog.
+
+```
+usage: polaris generic-tables delete [-h] [options] GENERIC_TABLE_NAME
+
+positional arguments:
+ GENERIC_TABLE_NAME generic-table
+
+options:
+ -h, --help show this help message and exit
+
+Command Options:
+ --catalog CATALOG The name of a catalog
+ --namespace NAMESPACE A period-delimited namespace
+```
+
+##### Examples
+
+```
+polaris generic-tables delete my_generic_table --catalog my_catalog --namespace ns1
+```
+
### REPL
The `REPL` command starts an interactive REPL session for Polaris CLI allowing multiple commands to be executed without re-authenticating.