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.