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/04/05 16:00:52 UTC
(polaris) branch main updated: CLI: add tables/find commands (#4075)
This is an automated email from the ASF dual-hosted git repository.
yzheng 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 1450649aa4 CLI: add tables/find commands (#4075)
1450649aa4 is described below
commit 1450649aa4ef1f2f419ae294f409a1875f7a499e
Author: Yong Zheng <yo...@gmail.com>
AuthorDate: Sun Apr 5 11:00:47 2026 -0500
CLI: add tables/find commands (#4075)
---
CHANGELOG.md | 1 +
.../python/apache_polaris/cli/command/__init__.py | 20 ++
.../apache_polaris/cli/command/catalog_roles.py | 8 +-
.../python/apache_polaris/cli/command/catalogs.py | 11 +-
client/python/apache_polaris/cli/command/find.py | 219 +++++++++++++++++
.../apache_polaris/cli/command/namespaces.py | 5 +-
.../apache_polaris/cli/command/principal_roles.py | 11 +-
.../apache_polaris/cli/command/principals.py | 16 +-
client/python/apache_polaris/cli/command/tables.py | 265 +++++++++++++++++++++
client/python/apache_polaris/cli/command/utils.py | 139 ++++++++++-
client/python/apache_polaris/cli/constants.py | 29 +++
.../apache_polaris/cli/options/option_tree.py | 62 +++++
client/python/pyproject.toml | 1 +
client/python/test/test_cli_parsing.py | 81 +++++++
client/python/uv.lock | 2 +
.../in-dev/unreleased/command-line-interface.md | 113 +++++++++
16 files changed, 965 insertions(+), 18 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cfdcba3b9d..faebedea5c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -64,6 +64,7 @@ request adding CHANGELOG notes for breaking (!) changes and possibly other secti
- Added setup options to Polaris CLI.
- Added CockroachDB as a supported database for the relational JDBC persistence backend. Set `polaris.persistence.relational.jdbc.database-type` to `cockroachdb` to get started.
- Added summarize subcommand to Polaris CLI.
+- Added find and tables options to Polaris CLI.
### Changes
diff --git a/client/python/apache_polaris/cli/command/__init__.py b/client/python/apache_polaris/cli/command/__init__.py
index 183ceadd2d..06a7aa79df 100644
--- a/client/python/apache_polaris/cli/command/__init__.py
+++ b/client/python/apache_polaris/cli/command/__init__.py
@@ -219,6 +219,26 @@ class Command(ABC):
setup_config=options_get(Arguments.SETUP_CONFIG),
dry_run=options_get(Arguments.DRY_RUN),
)
+ elif options.command == Commands.TABLES:
+ from apache_polaris.cli.command.tables import TableCommand
+
+ subcommand = options_get(f"{Commands.TABLES}_subcommand")
+ command = TableCommand(
+ subcommand,
+ catalog_name=options_get(Arguments.CATALOG),
+ namespace=options_get(
+ Arguments.NAMESPACE, lambda x: x.split(".") if x else None
+ ),
+ table_name=options_get(Arguments.TABLE),
+ )
+ elif options.command == Commands.FIND:
+ from apache_polaris.cli.command.find import FindCommand
+
+ command = FindCommand(
+ options_get(Arguments.IDENTIFIER),
+ catalog_name=options_get(Arguments.CATALOG),
+ type_filter=options_get(Arguments.TYPE),
+ )
if command is not None:
command.validate()
diff --git a/client/python/apache_polaris/cli/command/catalog_roles.py b/client/python/apache_polaris/cli/command/catalog_roles.py
index 209943325e..de1b40bfc3 100644
--- a/client/python/apache_polaris/cli/command/catalog_roles.py
+++ b/client/python/apache_polaris/cli/command/catalog_roles.py
@@ -145,6 +145,7 @@ class CatalogRolesCommand(Command):
print(f" {'Created:':<30} {format_timestamp(role.create_timestamp)}")
print(f" {'Modified:':<30} {format_timestamp(role.last_update_timestamp)}")
print(f" {'Version:':<30} {role.entity_version}")
+
# Assignments
principal_roles = (
api.list_assignee_principal_roles_for_catalog_role(
@@ -152,13 +153,14 @@ class CatalogRolesCommand(Command):
).roles
or []
)
- print("Assignments")
+ print("\nAssignments")
print(f" {'Assigned Principal Roles:':<30} {len(principal_roles)}")
print(f" {'Total Grants:':<30} {len(grants)}")
+
# Privileges
- print("Privileges")
+ print("\nPrivileges")
if not grants:
- print(" No grants found.")
+ print(" No grants found")
else:
for grant in sorted(
grants, key=lambda x: (str(x.privilege), str(x.namespace))
diff --git a/client/python/apache_polaris/cli/command/catalogs.py b/client/python/apache_polaris/cli/command/catalogs.py
index 0c5ad43576..a1963ca9da 100644
--- a/client/python/apache_polaris/cli/command/catalogs.py
+++ b/client/python/apache_polaris/cli/command/catalogs.py
@@ -507,18 +507,20 @@ class CatalogsCommand(Command):
print(f" {'Storage Type:':<30} {storage_info.storage_type}")
print(f" {'Base Location:':<30} {catalog.properties.default_base_location}")
print(
- f" {'Allowed Locations:':<30} {','.join(storage_info.allowed_locations or [])}"
+ f" {'Allowed Locations:':<30} {', '.join(storage_info.allowed_locations or [])}"
)
print(f" {'Created:':<30} {format_timestamp(catalog.create_timestamp)}")
print(f" {'Modified:':<30} {format_timestamp(catalog.last_update_timestamp)}")
print(f" {'Version:':<30} {catalog.entity_version}")
+
# Inventory
catalog_api = IcebergCatalogAPI(get_catalog_api_client(api))
total_ns, total_tables, total_views = self._get_sub_entities_count(catalog_api)
- print("Inventory")
+ print("\nInventory")
print(f" {'Namespaces:':<30} {total_ns}")
print(f" {'Tables:':<30} {total_tables}")
print(f" {'Views:':<30} {total_views}")
+
# Access Control
catalog_roles = api.list_catalog_roles(self.catalog_name).roles or []
principal_roles_names = set()
@@ -529,13 +531,14 @@ class CatalogsCommand(Command):
roles = pr_resp.roles or []
for pr in roles:
principal_roles_names.add(pr.name)
- print("Access Control")
+ print("\nAccess Control")
print(f" {'Catalog Roles:':<30} {len(catalog_roles)}")
print(f" {'Assigned Principal Roles:':<30} {len(principal_roles_names)}")
+
# Policies
policy_api = PolicyAPI(catalog_api.api_client)
policies_resp = policy_api.get_applicable_policies(prefix=self.catalog_name)
- print("Applicable Policies")
+ print("\nApplicable Policies")
applicable_policies = policies_resp.applicable_policies or []
if applicable_policies:
for policy in sorted(applicable_policies, key=lambda x: x.name):
diff --git a/client/python/apache_polaris/cli/command/find.py b/client/python/apache_polaris/cli/command/find.py
new file mode 100644
index 0000000000..62b17a1c73
--- /dev/null
+++ b/client/python/apache_polaris/cli/command/find.py
@@ -0,0 +1,219 @@
+#
+# 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 collections import Counter
+from dataclasses import dataclass, field
+from typing import Optional, List, Tuple
+
+from apache_polaris.cli.command import Command
+from apache_polaris.cli.command.utils import (
+ get_catalog_api_client,
+ crawl_namespace,
+ is_fuzzy_match,
+ handle_api_exception,
+)
+from apache_polaris.cli.constants import EntityType
+from apache_polaris.sdk.catalog import IcebergCatalogAPI
+from apache_polaris.sdk.management import PolarisDefaultApi
+
+
+@dataclass
+class FindCommand(Command):
+ """
+ A Command implemtation to represent `polaris find`. It searches for an identifier across global entities (principals, roles, catalogs)
+ and catalog entities (namespaces, tables, views) using fuzzy matching.
+
+ Example commands:
+ * ./polaris find my_table
+ * ./polaris find ns1.my_table
+ * ./polaris find --catalog my_catalog my_table
+ * ./polaris find my_table --type table
+ """
+
+ identifier: str
+ catalog_name: Optional[str] = None
+ type_filter: Optional[str] = None
+ _current_context: Optional[str] = field(default=None, repr=False)
+ _type_counts: Counter = field(default_factory=Counter, repr=False)
+
+ def validate(self) -> None:
+ if not self.identifier.strip():
+ raise Exception("The search identifier cannot be empty.")
+
+ def execute(self, api: PolarisDefaultApi) -> None:
+ print(f"Searching for '{self.identifier}'...")
+ parts = self.identifier.split(".")
+ target_ns = parts[:-1]
+ target_leaf = parts[-1]
+
+ # Global entities search
+ if not self.catalog_name:
+ global_entity_types = {
+ EntityType.PRINCIPAL.value,
+ EntityType.PRINCIPAL_ROLE.value,
+ EntityType.CATALOG.value,
+ }
+ if not self.type_filter or self.type_filter in global_entity_types:
+ self._find_global_entities(api, target_leaf)
+
+ # Determine which catalogs to search
+ catalog_entity_types = {
+ EntityType.CATALOG_ROLE.value,
+ EntityType.NAMESPACE.value,
+ EntityType.TABLE.value,
+ EntityType.VIEW.value,
+ }
+ if not self.type_filter or self.type_filter in catalog_entity_types:
+ catalogs_to_search, effective_ns = self._resolve_search_scope(
+ api, target_ns
+ )
+ # Quick fail if catalog is not resolvable
+ if catalogs_to_search is None:
+ return
+ # Scoped search
+ if catalogs_to_search:
+ catalog_api = IcebergCatalogAPI(get_catalog_api_client(api))
+ for catalog_name in catalogs_to_search:
+ self._find_in_catalog(
+ api, catalog_api, catalog_name, effective_ns, target_leaf
+ )
+ elif self.catalog_name:
+ print("No catalogs found to search.")
+ else:
+ catalogs_to_search = []
+
+ # Summary
+ total_results = sum(self._type_counts.values())
+ if total_results > 0:
+ types_str = ", ".join(
+ f"{count} {entity_type if count == 1 else entity_type + 's'}"
+ for entity_type, count in sorted(self._type_counts.items())
+ )
+ print(f"\nFound {total_results} matches ({types_str}).")
+ else:
+ print(f"No identifiers found matching '{self.identifier}'.")
+
+ def _resolve_search_scope(
+ self,
+ api: PolarisDefaultApi,
+ target_ns: List[str],
+ ) -> Tuple[Optional[List[str]], List[str]]:
+ if self.catalog_name:
+ try:
+ api.get_catalog(self.catalog_name)
+ return [self.catalog_name], target_ns
+ except Exception as e:
+ if getattr(e, "status", None) == 404:
+ print(f"Catalog '{self.catalog_name}' not found.")
+ return None, []
+ handle_api_exception("Catalog Access", e)
+ return [], target_ns
+ try:
+ return [
+ catalog.name for catalog in api.list_catalogs().catalogs or []
+ ], target_ns
+ except Exception as e:
+ handle_api_exception("Catalog Listing", e)
+ return [], target_ns
+
+ def _print_result(self, context: str, entity_type: EntityType, value: str) -> None:
+ if self._current_context != context:
+ if self._current_context is not None:
+ print()
+ print(f"[{context}]")
+ self._current_context = context
+ label_str = str(entity_type)
+ print(f" {label_str + ':':<20} {value}")
+ self._type_counts[label_str] += 1
+
+ def _find_global_entities(self, api: PolarisDefaultApi, name: str) -> None:
+ context = "Global"
+ # Check principals
+ if not self.type_filter or self.type_filter == EntityType.PRINCIPAL.value:
+ try:
+ for principal in api.list_principals().principals or []:
+ if is_fuzzy_match(name, principal.name):
+ self._print_result(
+ context, EntityType.PRINCIPAL, principal.name
+ )
+ except Exception as e:
+ handle_api_exception("Principal Listing", e)
+
+ # Check principal roles
+ if not self.type_filter or self.type_filter == EntityType.PRINCIPAL_ROLE.value:
+ try:
+ for principal_role in api.list_principal_roles().roles or []:
+ if is_fuzzy_match(name, principal_role.name):
+ self._print_result(
+ context, EntityType.PRINCIPAL_ROLE, principal_role.name
+ )
+ except Exception as e:
+ handle_api_exception("Principal Role Listing", e)
+ # Check catalogs
+ if not self.type_filter or self.type_filter == EntityType.CATALOG.value:
+ try:
+ for catalog in api.list_catalogs().catalogs or []:
+ if is_fuzzy_match(name, catalog.name):
+ self._print_result(context, EntityType.CATALOG, catalog.name)
+ except Exception as e:
+ handle_api_exception("Catalog Listing", e)
+
+ def _find_in_catalog(
+ self,
+ api: PolarisDefaultApi,
+ catalog_api: IcebergCatalogAPI,
+ catalog_name: str,
+ namespace: Optional[List[str]],
+ leaf_name: str,
+ ) -> None:
+ context = f"Catalog: {catalog_name}"
+
+ # Helper function to suppress HTTP code 404 during find
+ def on_crawl_error(label: str, e: Exception) -> None:
+ if getattr(e, "status", None) != 404:
+ handle_api_exception(label, e)
+
+ # Check catalog roles
+ if not namespace and (
+ not self.type_filter or self.type_filter == EntityType.CATALOG_ROLE.value
+ ):
+ try:
+ for catalog_role in api.list_catalog_roles(catalog_name).roles or []:
+ if is_fuzzy_match(leaf_name, catalog_role.name):
+ self._print_result(
+ context, EntityType.CATALOG_ROLE, catalog_role.name
+ )
+ except Exception as e:
+ handle_api_exception(f"Catalog Role ({catalog_name})", e)
+ # Search namespaces recursively
+ catalog_resource_types = {
+ EntityType.NAMESPACE.value,
+ EntityType.TABLE.value,
+ EntityType.VIEW.value,
+ }
+ if not self.type_filter or self.type_filter in catalog_resource_types:
+ for entity_type, path in crawl_namespace(
+ catalog_api=catalog_api,
+ catalog_name=catalog_name,
+ start_ns=namespace if namespace else None,
+ on_error=on_crawl_error,
+ entity_type_filter=self.type_filter,
+ ):
+ # Check if path matches to ns + fuzzy leaf
+ if is_fuzzy_match(leaf_name, path[-1]):
+ self._print_result(context, entity_type, ".".join(path))
diff --git a/client/python/apache_polaris/cli/command/namespaces.py b/client/python/apache_polaris/cli/command/namespaces.py
index 78220866a1..b85e4c6507 100644
--- a/client/python/apache_polaris/cli/command/namespaces.py
+++ b/client/python/apache_polaris/cli/command/namespaces.py
@@ -101,7 +101,7 @@ class NamespacesCommand(Command):
print("Metadata")
print(f" {'Level:':<30} {len(self.namespace)}")
if len(self.namespace) > 1:
- print(f" {'Parent:':<30} {UNIT_SEPARATOR.join(self.namespace[:-1])}")
+ print(f" {'Parent:':<30} {'.'.join(self.namespace[:-1])}")
sub_ns = (
catalog_api.list_namespaces(prefix=self.catalog, parent=ns_str).namespaces
or []
@@ -117,12 +117,13 @@ class NamespacesCommand(Command):
or []
)
print(f" {'Views:':<30} {len(views)}")
+
# Effective policies
policy_api = PolicyAPI(catalog_api.api_client)
policies_resp = policy_api.get_applicable_policies(
prefix=self.catalog, namespace=ns_str
)
- print("Effective Policies")
+ print("\nEffective Policies")
applicable_policies = policies_resp.applicable_policies or []
if applicable_policies:
for policy in sorted(applicable_policies, key=lambda x: x.name):
diff --git a/client/python/apache_polaris/cli/command/principal_roles.py b/client/python/apache_polaris/cli/command/principal_roles.py
index 1cc801a4be..bedcc54b15 100644
--- a/client/python/apache_polaris/cli/command/principal_roles.py
+++ b/client/python/apache_polaris/cli/command/principal_roles.py
@@ -135,6 +135,7 @@ class PrincipalRolesCommand(Command):
print(f" {'Created:':<30} {format_timestamp(role.create_timestamp)}")
print(f" {'Modified:':<30} {format_timestamp(role.last_update_timestamp)}")
print(f" {'Version:':<30} {role.entity_version}")
+
# Assigned Principals
principals = (
api.list_assignee_principals_for_principal_role(
@@ -142,12 +143,13 @@ class PrincipalRolesCommand(Command):
).principals
or []
)
- print("Assigned Principals")
+ print("\nAssigned Principals")
if principals:
for principal in sorted(principals, key=lambda x: x.name):
print(f" - {principal.name}")
else:
- print(" No principals assigned.")
+ print(" No principals assigned")
+
# Assigned Catalog Roles
assigned_catalog_roles = []
catalogs = api.list_catalogs().catalogs or []
@@ -160,10 +162,11 @@ class PrincipalRolesCommand(Command):
)
for catalog_role in catalog_roles:
assigned_catalog_roles.append((catalog_role.name, catalog.name))
- print("Assigned Catalog Roles")
+
+ print("\nAssigned Catalog Roles")
if assigned_catalog_roles:
for catalog_role_name, catalog_name in sorted(assigned_catalog_roles):
print(f" - {catalog_role_name} (Catalog: {catalog_name})")
else:
- print(" No catalog roles assigned.")
+ print(" No catalog roles assigned")
print("-" * 80)
diff --git a/client/python/apache_polaris/cli/command/principals.py b/client/python/apache_polaris/cli/command/principals.py
index c31ddd0407..f21e8b77cf 100644
--- a/client/python/apache_polaris/cli/command/principals.py
+++ b/client/python/apache_polaris/cli/command/principals.py
@@ -211,11 +211,16 @@ class PrincipalsCommand(Command):
f" {'Modified:':<30} {format_timestamp(principal.last_update_timestamp)}"
)
print(f" {'Version:':<30} {principal.entity_version}")
+
# Assigned Roles
principal_roles = list(self._get_principal_roles(api))
- print("Assigned Roles")
- for principal_role in principal_roles:
- print(f" - {principal_role}")
+ print("\nAssigned Roles")
+ if principal_roles:
+ for principal_role in principal_roles:
+ print(f" - {principal_role}")
+ else:
+ print(" No principal roles assigned")
+
catalogs = api.list_catalogs().catalogs or []
accessible_catalogs = []
for catalog in catalogs:
@@ -224,9 +229,12 @@ class PrincipalsCommand(Command):
for role_name in principal_roles
):
accessible_catalogs.append(catalog.name)
+
# Accessible Catalogs
- print("Accessible Catalogs")
+ print("\nAccessible Catalogs")
if accessible_catalogs:
for catalog in sorted(accessible_catalogs):
print(f" - {catalog}")
+ else:
+ print(" No accessible catalogs")
print("-" * 80)
diff --git a/client/python/apache_polaris/cli/command/tables.py b/client/python/apache_polaris/cli/command/tables.py
new file mode 100644
index 0000000000..a9609a0333
--- /dev/null
+++ b/client/python/apache_polaris/cli/command/tables.py
@@ -0,0 +1,265 @@
+#
+# 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
+from typing import List
+
+from apache_polaris.cli.command import Command
+from apache_polaris.cli.command.utils import get_catalog_api_client
+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.sdk.catalog.api.policy_api import PolicyAPI
+from apache_polaris.cli.command.utils import (
+ handle_api_exception,
+ format_timestamp,
+ format_iceberg_type,
+)
+from prettytable import PrettyTable
+
+
+@dataclass
+class TableCommand(Command):
+ """
+ A Command implemtation to represent `polaris tables`. It manages Iceberg tables within a Polaris Catalog.
+
+ Example commands:
+ * ./polaris tables list --catalog my_catalog --namespace ns1
+ * ./polaris tables get my_table --catalog my_catalog --namespace ns1
+ * ./polaris tables summarize my_table --catalog my_catalog --namespace ns1
+ * ./polaris tables delete my_table --catalog my_catalog --namesapce ns1
+ """
+
+ table_subcommand: str
+ catalog_name: str
+ namespace: List[str]
+ table_name: str
+
+ def validate(self) -> None:
+ if not self.catalog_name:
+ raise Exception(
+ f"Missing required argument: {Argument.to_flag_name(Arguments.CATALOG)}"
+ )
+ if not self.namespace:
+ raise Exception(
+ f"Missing required argument: {Argument.to_flag_name(Arguments.NAMESPACE)}"
+ )
+ if (
+ self.table_subcommand == Subcommands.GET
+ or self.table_subcommand == Subcommands.SUMMARIZE
+ or self.table_subcommand == Subcommands.DELETE
+ ):
+ if not self.table_name.strip():
+ raise Exception("The table name cannot be empty.")
+
+ def execute(self, api: PolarisDefaultApi) -> None:
+ catalog_api = IcebergCatalogAPI(get_catalog_api_client(api))
+ ns_str = UNIT_SEPARATOR.join(self.namespace)
+ if self.table_subcommand == Subcommands.LIST:
+ try:
+ result = catalog_api.list_tables(
+ prefix=self.catalog_name, namespace=ns_str
+ )
+ for table_identifier in result.identifiers:
+ print(table_identifier.to_json())
+ except Exception as e:
+ handle_api_exception(f"Table Listing ({'.'.join(self.namespace)})", e)
+ elif self.table_subcommand == Subcommands.GET:
+ try:
+ print(
+ catalog_api.load_table(
+ prefix=self.catalog_name,
+ namespace=ns_str,
+ table=self.table_name,
+ ).to_json()
+ )
+ except Exception as e:
+ handle_api_exception(f"Table Load ({self.table_name})", e)
+ elif self.table_subcommand == Subcommands.DELETE:
+ print(
+ f"De-registering table {'.'.join(self.namespace)}.{self.table_name}..."
+ )
+ try:
+ catalog_api.drop_table(
+ prefix=self.catalog_name,
+ namespace=ns_str,
+ table=self.table_name,
+ purge_requested=False,
+ )
+ print(
+ f"De-registering table {'.'.join(self.namespace)}.{self.table_name} completed"
+ )
+ except Exception as e:
+ handle_api_exception(f"Table De-registration ({self.table_name})", e)
+ elif self.table_subcommand == Subcommands.SUMMARIZE:
+ self._generate_summary(api, catalog_api, ns_str)
+
+ def _generate_summary(
+ self, api: PolarisDefaultApi, catalog_api: IcebergCatalogAPI, ns_str: str
+ ) -> None:
+ print(f"Table: {'.'.join(self.namespace)}.{self.table_name}")
+ print("-" * 80)
+ try:
+ resp = catalog_api.load_table(
+ prefix=self.catalog_name, namespace=ns_str, table=self.table_name
+ )
+ # Metadata
+ metadata = resp.metadata
+ print("Metadata")
+ print(f" {'Location:':<30} {metadata.location}")
+ print(f" {'Format Version:':<30} {metadata.format_version}")
+ print(f" {'Snapshots:':<30} {len(metadata.snapshots)}")
+ print(f" {'Current Snapshot ID:':<30} {metadata.current_snapshot_id}")
+ print(
+ f" {'Last Updated:':<30} {format_timestamp(metadata.last_updated_ms)}"
+ )
+
+ # Statistics
+ print("\nStatistics")
+ current_snapshot = next(
+ (
+ snapshot
+ for snapshot in metadata.snapshots
+ if snapshot.snapshot_id == metadata.current_snapshot_id
+ ),
+ None,
+ )
+ if current_snapshot and current_snapshot.summary:
+ stats = current_snapshot.summary.model_dump().get(
+ "additional_properties", {}
+ )
+ print(f" {'Total Records:':<30} {stats.get('total-records', '0')}")
+ print(
+ f" {'Total Data Files:':<30} {stats.get('total-data-files', '0')}"
+ )
+ print(
+ f" {'Total Files Size:':<30} {stats.get('total-files-size', '0')}"
+ )
+ else:
+ print(" Table is empty (no snapshots found)")
+
+ # Schema
+ print("\nSchema")
+ current_schema = next(
+ (
+ schema
+ for schema in metadata.schemas
+ if schema.schema_id == metadata.current_schema_id
+ ),
+ None,
+ )
+ if current_schema and current_schema.fields:
+ table = PrettyTable(
+ field_names=["ID", "Field Name", "Type", "Required", "Comment"],
+ align="l",
+ )
+ for field in current_schema.fields:
+ required = "*" if field.required else ""
+ type_str = format_iceberg_type(field.type)
+ column_comment = field.doc if getattr(field, "doc", None) else ""
+ table.add_row(
+ [field.id, field.name, type_str, required, column_comment]
+ )
+ indented_table = "\n".join(
+ " " * 2 + line for line in table.get_string().splitlines()
+ )
+ print(indented_table)
+ else:
+ print(" No schema information available")
+
+ # Partitioning
+ print("\nPartitioning")
+ current_spec = next(
+ (
+ spec
+ for spec in metadata.partition_specs
+ if spec.spec_id == metadata.default_spec_id
+ ),
+ None,
+ )
+ if current_spec and current_spec.fields:
+ table = PrettyTable(
+ field_names=["Source ID", "Field Name", "Transform"], align="l"
+ )
+ for field in current_spec.fields:
+ table.add_row([field.source_id, field.name, field.transform])
+ indented_table = "\n".join(
+ " " * 2 + line for line in table.get_string().splitlines()
+ )
+ print(indented_table)
+ else:
+ print(" Table is un-partitioned.")
+
+ # Sort Order
+ print("\nSort order")
+ current_sort_order = next(
+ (
+ sort_order
+ for sort_order in metadata.sort_orders
+ if sort_order.order_id == metadata.default_sort_order_id
+ ),
+ None,
+ )
+ if current_sort_order and current_sort_order.fields:
+ table = PrettyTable(
+ field_names=["Source ID", "Transform", "Null Order", "Direction"],
+ align="l",
+ )
+ for field in current_sort_order.fields:
+ table.add_row(
+ [
+ field.source_id,
+ field.transform,
+ field.null_order.value,
+ field.direction.value,
+ ]
+ )
+ indented_table = "\n".join(
+ " " * 2 + line for line in table.get_string().splitlines()
+ )
+ print(indented_table)
+ else:
+ print(" Table is un-sorted.")
+ except Exception as e:
+ handle_api_exception("Table Metadata", e)
+
+ # Effective policies
+ print("\nEffective policies")
+ try:
+ policy_api = PolicyAPI(catalog_api.api_client)
+ resp = policy_api.get_applicable_policies(
+ prefix=self.catalog_name, namespace=ns_str, target_name=self.table_name
+ )
+ applicable_policies = resp.applicable_policies or []
+ if applicable_policies:
+ for policy in sorted(applicable_policies, key=lambda x: x.name):
+ source = "Direct"
+ if policy.inherited:
+ target = (
+ ".".join(policy.namespace)
+ if policy.namespace
+ else "Catalog"
+ )
+ source = f"Inherited from {target}"
+ print(f" - {policy.name} ({source})")
+ else:
+ print(" No policies apply to this table")
+ except Exception as e:
+ handle_api_exception("Table Policies", e)
+ print("-" * 80)
diff --git a/client/python/apache_polaris/cli/command/utils.py b/client/python/apache_polaris/cli/command/utils.py
index 3ba0400941..23cde7c74c 100644
--- a/client/python/apache_polaris/cli/command/utils.py
+++ b/client/python/apache_polaris/cli/command/utils.py
@@ -17,12 +17,17 @@
# under the License.
#
import re
-
+import sys
import datetime
+from typing import List, Optional, Tuple, Deque, Generator, Callable, Any
+from collections import deque
+from difflib import SequenceMatcher
from apache_polaris.sdk.catalog.api_client import ApiClient
from apache_polaris.sdk.catalog.configuration import Configuration
from apache_polaris.sdk.management import PolarisDefaultApi
+from apache_polaris.sdk.catalog import IcebergCatalogAPI
+from apache_polaris.cli.constants import UNIT_SEPARATOR, EntityType
def get_catalog_api_client(api: PolarisDefaultApi) -> ApiClient:
@@ -64,3 +69,135 @@ def format_timestamp(ms_since_epoch: int) -> str:
ms_since_epoch / 1000, tz=datetime.timezone.utc
)
return dt.strftime("%Y-%m-%d %H:%M:%S UTC")
+
+
+def is_fuzzy_match(query: str, target: str, threshold: float = 0.85) -> bool:
+ """
+ Determine if a query matches a target using multi-stage fuzzy strategies and case-insensitive.
+ """
+ if not query:
+ return False
+ q = query.lower()
+ t = target.lower()
+ query_len = len(q)
+ # Exact match
+ if q == t:
+ return True
+ # Prefix match
+ if t.startswith(q):
+ return True
+ # Substring match: enabled for length > 1
+ if query_len > 1 and q in t:
+ return True
+ # Similarity: enabled for length > 2
+ if query_len > 2:
+ return SequenceMatcher(None, q, t).ratio() >= threshold
+ return False
+
+
+def handle_api_exception(entity_label: str, e: Exception) -> None:
+ """
+ Handle and print API exceptions with formatted output.
+ """
+ status = getattr(e, "status", None)
+ if status == 403:
+ print(f" [x] {entity_label:<30} Permission denied", file=sys.stderr)
+ elif status == 404:
+ print(f" [x] {entity_label:<30} Not found", file=sys.stderr)
+ elif status:
+ print(f" [x] {entity_label:<30} ERROR (HTTP {status}: {e})", file=sys.stderr)
+ else:
+ print(f" [x] {entity_label:<30} Error: {e}", file=sys.stderr)
+
+
+def format_iceberg_type(obj: Any) -> str:
+ """
+ Recursively format Iceberg type into a human-readble string.
+ """
+
+ def get(o: Any, key: str, default: Any = None) -> Any:
+ if isinstance(o, dict):
+ return o.get(key, default)
+ return getattr(o, key, default)
+
+ unwrapped = get(obj, "actual_instance", obj)
+ type_name = get(unwrapped, "type")
+ if type_name == "struct":
+ fields = get(unwrapped, "fields", [])
+ parts = []
+ for field in fields:
+ name = get(field, "name", "unknown")
+ field_type = get(field, "type", "unknown")
+ parts.append(f"{name}:{format_iceberg_type(field_type)}")
+ return f"struct<{', '.join(parts)}>"
+ elif type_name == "list":
+ element = get(unwrapped, "element", "unknown")
+ return f"list<{format_iceberg_type(element)}>"
+ elif type_name == "map":
+ key = get(unwrapped, "key", "unknown")
+ value = get(unwrapped, "value", "unknown")
+ return f"map<{format_iceberg_type(key)}, {format_iceberg_type(value)}>"
+ return str(type_name) if isinstance(type_name, str) else str(unwrapped)
+
+
+def crawl_namespace(
+ catalog_api: IcebergCatalogAPI,
+ catalog_name: str,
+ start_ns: Optional[List[str]] = None,
+ on_error: Optional[Callable[[str, Exception], None]] = None,
+ entity_type_filter: Optional[str] = None,
+) -> Generator[Tuple[EntityType, List[str]], None, None]:
+ """
+ Iterator BFS to crawl namespaces into (type, path_list)
+ """
+ visited = set()
+ queue: Deque[List[str]] = deque()
+ if start_ns:
+ queue.append(start_ns)
+ else:
+ try:
+ resp = catalog_api.list_namespaces(prefix=catalog_name)
+ for ns in resp.namespaces or []:
+ queue.append(ns)
+ except Exception as e:
+ if on_error:
+ on_error(f"Root Namespace ({catalog_name})", e)
+ while queue:
+ current_ns = queue.popleft()
+ ns_str = UNIT_SEPARATOR.join(current_ns)
+ ns_display = ".".join(current_ns)
+ if ns_str in visited:
+ continue
+ visited.add(ns_str)
+ if not entity_type_filter or entity_type_filter == EntityType.NAMESPACE.value:
+ yield EntityType.NAMESPACE, current_ns
+ # List tables
+ if not entity_type_filter or entity_type_filter == EntityType.TABLE.value:
+ try:
+ resp = catalog_api.list_tables(prefix=catalog_name, namespace=ns_str)
+ for table in resp.identifiers or []:
+ # Ensure the listed table is in the same namespace
+ if table.namespace == current_ns:
+ yield EntityType.TABLE, table.namespace + [table.name]
+ except Exception as e:
+ if on_error:
+ on_error(f"Tables in {catalog_name}.{ns_display}", e)
+ # List views:
+ if not entity_type_filter or entity_type_filter == EntityType.VIEW.value:
+ try:
+ resp = catalog_api.list_views(prefix=catalog_name, namespace=ns_str)
+ for view in resp.identifiers or []:
+ # Ensure the listed view is in the same namespace
+ if view.namespace == current_ns:
+ yield EntityType.VIEW, view.namespace + [view.name]
+ except Exception as e:
+ if on_error:
+ on_error(f"Views in {catalog_name}.{ns_display}", e)
+ # List sub-namespaces
+ try:
+ resp = catalog_api.list_namespaces(prefix=catalog_name, parent=ns_str)
+ for ns in resp.namespaces or []:
+ queue.append(ns)
+ except Exception as e:
+ if on_error:
+ on_error(f"Sub-namespaces of {catalog_name}.{ns_display}", e)
diff --git a/client/python/apache_polaris/cli/constants.py b/client/python/apache_polaris/cli/constants.py
index 39aa5243a3..6b6c9e097c 100644
--- a/client/python/apache_polaris/cli/constants.py
+++ b/client/python/apache_polaris/cli/constants.py
@@ -77,6 +77,24 @@ class ServiceIdentityType(Enum):
AWS_IAM = "aws_iam"
+class EntityType(Enum):
+ """
+ Represents the type of entities that can be searched or managed in the CLI
+ """
+
+ CATALOG = "catalog"
+ CATALOG_ROLE = "catalog-role"
+ NAMESPACE = "namespace"
+ PRINCIPAL = "principal"
+ PRINCIPAL_ROLE = "principal-role"
+ TABLE = "table"
+ VIEW = "view"
+
+ def __str__(self) -> str:
+ # Transforms 'catalog-role' tp 'Catalog Role'
+ return self.value.replace("-", " ").title()
+
+
class Commands:
"""
Represents the various commands available in the CLI
@@ -91,6 +109,8 @@ class Commands:
PROFILES = "profiles"
POLICIES = "policies"
SETUP = "setup"
+ TABLES = "tables"
+ FIND = "find"
class Subcommands:
@@ -217,6 +237,7 @@ class Arguments:
DRY_RUN = "dry_run"
REALM = "realm"
HEADER = "header"
+ IDENTIFIER = "identifier"
class Hints:
@@ -422,6 +443,14 @@ class Hints:
ATTACHMENT_TYPE = "The type of entity to attach the policy to, e.g., 'catalog', 'namespace', or table-like."
ATTACHMENT_PATH = "The path of the entity to attach the policy to, e.g., 'ns1.tb1'. Not required for catalog-level attachment."
+ class Find:
+ IDENTIFIER = "The name of the entity to find."
+ TYPE = "Filter results by entity type (e.g. principal, principal-role, catalog, catalog-role, namespace, table, view)"
+
+ class Tables:
+ SUMMARIZE = "Display summary for a table."
+ DELETE = "De-register a table from catalog (metadata-only delete)."
+
UNIT_SEPARATOR = chr(0x1F)
CLIENT_ID_ENV = "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 71dc531112..57de745cbd 100644
--- a/client/python/apache_polaris/cli/options/option_tree.py
+++ b/client/python/apache_polaris/cli/options/option_tree.py
@@ -23,6 +23,7 @@ from apache_polaris.cli.constants import (
StorageType,
CatalogType,
PrincipalType,
+ EntityType,
Hints,
Commands,
Arguments,
@@ -943,4 +944,65 @@ class OptionTree:
Option(Subcommands.EXPORT),
],
),
+ Option(
+ Commands.TABLES,
+ "manage tables",
+ children=[
+ Option(
+ Subcommands.LIST,
+ args=[
+ Argument(
+ Arguments.CATALOG, str, Hints.CatalogRoles.CATALOG_NAME
+ ),
+ Argument(Arguments.NAMESPACE, str, Hints.Grant.NAMESPACE),
+ ],
+ ),
+ Option(
+ Subcommands.GET,
+ args=[
+ Argument(
+ Arguments.CATALOG, str, Hints.CatalogRoles.CATALOG_NAME
+ ),
+ Argument(Arguments.NAMESPACE, str, Hints.Grant.NAMESPACE),
+ ],
+ input_name=Arguments.TABLE,
+ ),
+ Option(
+ Subcommands.SUMMARIZE,
+ hint=Hints.Tables.SUMMARIZE,
+ args=[
+ Argument(
+ Arguments.CATALOG, str, Hints.CatalogRoles.CATALOG_NAME
+ ),
+ Argument(Arguments.NAMESPACE, str, Hints.Grant.NAMESPACE),
+ ],
+ input_name=Arguments.TABLE,
+ ),
+ Option(
+ Subcommands.DELETE,
+ hint=Hints.Tables.DELETE,
+ args=[
+ Argument(
+ Arguments.CATALOG, str, Hints.CatalogRoles.CATALOG_NAME
+ ),
+ Argument(Arguments.NAMESPACE, str, Hints.Grant.NAMESPACE),
+ ],
+ input_name=Arguments.TABLE,
+ ),
+ ],
+ ),
+ Option(
+ Commands.FIND,
+ "find an identifier",
+ args=[
+ Argument(Arguments.CATALOG, str, Hints.CatalogRoles.CATALOG_NAME),
+ Argument(
+ Arguments.TYPE,
+ str,
+ Hints.Find.TYPE,
+ choices=[e.value for e in EntityType],
+ ),
+ ],
+ input_name=Arguments.IDENTIFIER,
+ ),
]
diff --git a/client/python/pyproject.toml b/client/python/pyproject.toml
index 11036d8a56..3c75603b90 100644
--- a/client/python/pyproject.toml
+++ b/client/python/pyproject.toml
@@ -39,6 +39,7 @@ dependencies = [
"typing-extensions>=4.7.1",
"boto3~=1.42.2",
"PyYAML>=6.0.3",
+ "prettytable>=3.17.0",
]
[project.urls]
diff --git a/client/python/test/test_cli_parsing.py b/client/python/test/test_cli_parsing.py
index e362e74bc7..167c01bde0 100644
--- a/client/python/test/test_cli_parsing.py
+++ b/client/python/test/test_cli_parsing.py
@@ -89,6 +89,37 @@ class TestCliParsing(unittest.TestCase):
Parser.parse(["principals", "update", "name", "--client-id", "something"])
self.assertEqual(cm.exception.code, INVALID_ARGS)
+ with self.assertRaises(SystemExit) as cm:
+ Parser.parse(["find"])
+ self.assertEqual(cm.exception.code, INVALID_ARGS)
+
+ with self.assertRaises(SystemExit) as cm:
+ Parser.parse(["find", "my_table", "--type", "invalid-type"])
+ self.assertEqual(cm.exception.code, INVALID_ARGS)
+
+ with self.assertRaises(SystemExit) as cm:
+ Parser.parse(["table"]) # missing subcommand
+ self.assertEqual(cm.exception.code, INVALID_ARGS)
+
+ with self.assertRaises(Exception) as cm_exc:
+ options = Parser.parse(
+ ["find", " ", "--catalog", "my_catalog"]
+ ) # empty identifier
+ Command.from_options(options)
+ self.assertIn("The search identifier cannot be empty", str(cm_exc.exception))
+
+ with self.assertRaises(Exception) as cm_missing:
+ options = Parser.parse(["tables", "list"]) # missing catalog/namespace
+ Command.from_options(options)
+ self.assertIn("Missing required argument", str(cm_missing.exception))
+
+ with self.assertRaises(Exception) as cm_exc:
+ options = Parser.parse(
+ ["tables", "get", " ", "--catalog", "my_catalog", "--namespace", "ns"]
+ ) # empty table name
+ Command.from_options(options)
+ self.assertIn("The table name cannot be empty", str(cm_exc.exception))
+
with self.assertRaises(SystemExit) as cm:
Parser.parse(
[
@@ -251,6 +282,56 @@ class TestCliParsing(unittest.TestCase):
"fake-privilege",
]
)
+ Parser.parse(["find", "my_table"])
+ Parser.parse(["find", "my_table", "--catalog", "my_catalog"])
+ Parser.parse(["find", "my_table", "--catalog", "my_catalog", "--type", "table"])
+ Parser.parse(
+ ["tables", "list", "--catalog", "my_catalog", "--namespace", "ns1.ns2"]
+ )
+ Parser.parse(
+ [
+ "tables",
+ "get",
+ "my_table",
+ "--catalog",
+ "my_catalog",
+ "--namespace",
+ "ns1.ns2",
+ ]
+ )
+ Parser.parse(
+ [
+ "tables",
+ "summarize",
+ "my_table",
+ "--catalog",
+ "my_catalog",
+ "--namespace",
+ "ns1.ns2",
+ ]
+ )
+ Parser.parse(
+ [
+ "tables",
+ "delete",
+ "my_table",
+ "--catalog",
+ "my_catalog",
+ "--namespace",
+ "ns1.ns2",
+ ]
+ )
+ Parser.parse(
+ [
+ "tables",
+ "summarize",
+ "my_table",
+ "--catalog",
+ "my_catalog",
+ "--namespace",
+ "ns1.ns2",
+ ]
+ )
def test_commands(self) -> None:
def build_mock_client() -> MagicMock:
diff --git a/client/python/uv.lock b/client/python/uv.lock
index c1a8328067..d65c50c13b 100644
--- a/client/python/uv.lock
+++ b/client/python/uv.lock
@@ -17,6 +17,7 @@ version = "1.4.0"
source = { editable = "." }
dependencies = [
{ name = "boto3" },
+ { name = "prettytable" },
{ name = "pydantic" },
{ name = "python-dateutil" },
{ name = "pyyaml" },
@@ -40,6 +41,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "boto3", specifier = "~=1.42.2" },
+ { name = "prettytable", specifier = ">=3.17.0" },
{ name = "pydantic", specifier = ">=2.12.5,<2.13.0" },
{ name = "python-dateutil", specifier = ">=2.8.2" },
{ name = "pyyaml", specifier = ">=6.0.3" },
diff --git a/site/content/in-dev/unreleased/command-line-interface.md b/site/content/in-dev/unreleased/command-line-interface.md
index 2570c2fdda..7281f51a18 100644
--- a/site/content/in-dev/unreleased/command-line-interface.md
+++ b/site/content/in-dev/unreleased/command-line-interface.md
@@ -54,6 +54,8 @@ options:
8. policies
9. repair
10. setup
+11. find
+12. tables
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.
@@ -69,6 +71,8 @@ polaris profiles list
polaris policies list --catalog some_catalog --namespace some.schema
polaris repair
polaris setup apply setup-config.yaml
+polaris find some_table
+polaris tables list --catalog my_catalog --namespace ns1
```
### Authentication
@@ -121,6 +125,8 @@ polaris catalogs --help
polaris principals create --help
polaris profiles --help
polaris setup --help
+polaris find --help
+polaris tables --help
```
### Catalogs
@@ -1571,6 +1577,113 @@ options:
polaris setup export
```
+### Find
+
+The `find` command is used to searches for an identifier across global entities (principals, roles, catalogs)
+and catalog entities (namespaces, tables, views) using fuzzy matching.
+
+##### Examples
+
+```
+polaris find my_table
+polaris find ns1.ns2
+polaris find --catalog my_catalog my_table
+polaris find my_table --type table
+```
+
+### Tables
+
+The `tables` command is used to manage Iceberg tables within a Polaris Catalog.
+
+`tables` supports the following subcommands:
+
+1. list
+2. get
+3. summarize
+4. delete
+
+#### list
+
+The `list` subcommand is used to list tables within a namesace from a given catalog.
+
+```
+input: polaris tables list --help
+options:
+ list
+ Named arguments:
+ --catalog The name of an existing catalog
+ --namespace A period-delimited namespace
+```
+
+##### Examples
+
+```
+polaris tables list
+```
+
+#### get
+
+The `get` subcommand retrieves the table metadata for a specific table.
+
+```
+input: polaris tables get --help
+options:
+ get
+ Named arguments:
+ --catalog The name of an existing catalog
+ --namespace A period-delimited namespace
+ Positional arguments:
+ table
+```
+
+##### Examples
+
+```
+polaris tables get my_table --catalog my_catalog --namespace ns1
+```
+
+#### summarize
+
+The `summarize` subcommand provides a detail overview of a table.
+
+```
+input: polaris tables summarize --help
+options:
+ summarize
+ Named arguments:
+ --catalog The name of an existing catalog
+ --namespace A period-delimited namespace
+ Positional arguments:
+ table
+```
+
+##### Examples
+
+```
+polaris tables summarize my_table --catalog my_catalog --namespace ns1
+```
+
+#### delete
+
+The `delete` subcommand de-registers a table from catalog (metadata-only deletion).
+
+```
+input: polaris tables delete --help
+options:
+ delete
+ Named arguments:
+ --catalog The name of an existing catalog
+ --namespace A period-delimited namespace
+ Positional arguments:
+ table
+```
+
+##### Examples
+
+```
+polaris tables delete my_table --catalog my_catalog --namespace ns1
+```
+
## Examples
This section outlines example code for a few common operations as well as for some more complex ones.