You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@polaris.apache.org by yu...@apache.org on 2026/05/12 18:40:25 UTC

(polaris) branch main updated: CLI: Improve Python CLI error handling (#4399)

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

flyrain 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 666dc1104a CLI: Improve Python CLI error handling (#4399)
666dc1104a is described below

commit 666dc1104a77502b2bedd53e947c0bfcfa8ead34
Author: venkateshwaran shanmugham <ve...@gmail.com>
AuthorDate: Wed May 13 00:10:20 2026 +0530

    CLI: Improve Python CLI error handling (#4399)
---
 CHANGELOG.md                                       |  1 +
 .../apache_polaris/cli/api_client_builder.py       | 14 +++--
 .../python/apache_polaris/cli/command/__init__.py  |  7 ++-
 .../apache_polaris/cli/command/catalog_roles.py    |  9 +--
 .../python/apache_polaris/cli/command/catalogs.py  | 45 +++++++-------
 client/python/apache_polaris/cli/command/find.py   |  3 +-
 .../apache_polaris/cli/command/namespaces.py       |  7 ++-
 .../python/apache_polaris/cli/command/policies.py  | 19 +++---
 .../apache_polaris/cli/command/principal_roles.py  |  9 +--
 .../apache_polaris/cli/command/principals.py       |  5 +-
 .../apache_polaris/cli/command/privileges.py       | 25 ++++----
 .../python/apache_polaris/cli/command/profiles.py  |  5 +-
 client/python/apache_polaris/cli/command/setup.py  |  5 +-
 client/python/apache_polaris/cli/command/tables.py |  7 ++-
 client/python/apache_polaris/cli/exceptions.py     | 43 ++++++++++++++
 .../apache_polaris/cli/options/option_tree.py      |  2 +-
 client/python/apache_polaris/cli/options/parser.py |  7 ++-
 client/python/apache_polaris/cli/polaris_cli.py    | 48 ++++++++-------
 client/python/tests/cli_test_utils.py              |  3 +-
 client/python/tests/test_cli_exceptions.py         | 68 ++++++++++++++++++++++
 client/python/tests/test_parser_basic.py           |  9 +--
 21 files changed, 240 insertions(+), 101 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5edd81ba7b..c99b35d757 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -45,6 +45,7 @@ request adding CHANGELOG notes for breaking (!) changes and possibly other secti
 - Added support for **Apache Ranger** as an external authorizer (Beta).
 
 ### Changes
+- Improved Python CLI error messages and exit codes for invalid arguments and configuration errors.
 - Removed unused `PolarisAuthorizableOperation` values: `REVOKE_PRINCIPAL_GRANT_FROM_PRINCIPAL_ROLE`, `REVOKE_PRINCIPAL_ROLE_GRANT_FROM_PRINCIPAL_ROLE`, `LIST_GRANTS_ON_ROOT`, `ADD_PRINCIPAL_GRANT_TO_PRINCIPAL_ROLE`, `LIST_GRANTS_ON_PRINCIPAL`, `ADD_PRINCIPAL_ROLE_GRANT_TO_PRINCIPAL_ROLE`, `LIST_GRANTS_ON_PRINCIPAL_ROLE`, `ADD_CATALOG_ROLE_GRANT_TO_CATALOG_ROLE`, `REVOKE_CATALOG_ROLE_GRANT_FROM_CATALOG_ROLE`, `LIST_GRANTS_ON_CATALOG_ROLE`, `LIST_GRANTS_ON_CATALOG`, `LIST_GRANTS_ON_NAMESP [...]
 - Changed deprecated APIs in JUnit 5. This change will force downstream projects that pull in the Polaris test packages to adopt JUnit 6.
 
diff --git a/client/python/apache_polaris/cli/api_client_builder.py b/client/python/apache_polaris/cli/api_client_builder.py
index b95db58541..9206d23e6e 100644
--- a/client/python/apache_polaris/cli/api_client_builder.py
+++ b/client/python/apache_polaris/cli/api_client_builder.py
@@ -33,6 +33,7 @@ from apache_polaris.cli.constants import (
     DEFAULT_HOSTNAME,
     DEFAULT_PORT,
 )
+from apache_polaris.cli.exceptions import CliError, CLI_ERROR_EXIT_CODE
 from apache_polaris.cli.options.option_tree import Argument
 from apache_polaris.sdk.management import ApiClient, Configuration
 
@@ -58,7 +59,7 @@ class BuilderConfig:
             profiles = _load_profiles()
             loaded_profile = profiles.get(client_profile)
             if loaded_profile is None:
-                raise Exception(f"Polaris profile {client_profile} not found")
+                raise CliError(f"Polaris profile {client_profile} not found")
             profile = loaded_profile
         return profile
 
@@ -66,7 +67,7 @@ class BuilderConfig:
     def base_url(self) -> str:
         if self.options.base_url:
             if self.options.host is not None or self.options.port is not None:
-                raise Exception(
+                raise CliError(
                     f"Please provide either {Argument.to_flag_name(Arguments.BASE_URL)} or"
                     f" {Argument.to_flag_name(Arguments.HOST)} &"
                     f" {Argument.to_flag_name(Arguments.PORT)}, but not both"
@@ -144,7 +145,8 @@ class ApiClientBuilder:
             },
         ).response.data
         if "access_token" not in json.loads(response):
-            raise Exception("Failed to get access token")
+            # Distinct from validation errors: HTTP succeeded but body was not a usable token.
+            raise CliError("Failed to get access token", exit_code=CLI_ERROR_EXIT_CODE)
         return json.loads(response)["access_token"]
 
     def _build(self) -> ApiClient:
@@ -153,13 +155,15 @@ class ApiClientBuilder:
             self.conf.client_id is not None and self.conf.client_secret is not None
         )
         if has_access_token and (self.conf.client_id or self.conf.client_secret):
-            raise Exception(
+            # User supplied conflicting credential sources (usage / EX_USAGE).
+            raise CliError(
                 f"Please provide credentials via either {Argument.to_flag_name(Arguments.CLIENT_ID)} &"
                 f" {Argument.to_flag_name(Arguments.CLIENT_SECRET)} or"
                 f" {Argument.to_flag_name(Arguments.ACCESS_TOKEN)}, but not both"
             )
         if not has_access_token and not has_client_secret:
-            raise Exception(
+            # Missing credentials entirely (usage / EX_USAGE).
+            raise CliError(
                 f"Please provide credentials via either {Argument.to_flag_name(Arguments.CLIENT_ID)} &"
                 f" {Argument.to_flag_name(Arguments.CLIENT_SECRET)} or"
                 f" {Argument.to_flag_name(Arguments.ACCESS_TOKEN)}."
diff --git a/client/python/apache_polaris/cli/command/__init__.py b/client/python/apache_polaris/cli/command/__init__.py
index 06a7aa79df..ca74b68804 100644
--- a/client/python/apache_polaris/cli/command/__init__.py
+++ b/client/python/apache_polaris/cli/command/__init__.py
@@ -21,6 +21,7 @@ from abc import ABC
 from typing import Callable, Optional, Any
 
 from apache_polaris.cli.constants import Commands, Arguments
+from apache_polaris.cli.exceptions import CliError
 from apache_polaris.cli.options.parser import Parser
 from apache_polaris.sdk.management import PolarisDefaultApi
 
@@ -244,7 +245,7 @@ class Command(ABC):
             command.validate()
             return command
         else:
-            raise Exception(
+            raise CliError(
                 "Please specify a command or run ./polaris --help to view the available commands"
             )
 
@@ -252,11 +253,11 @@ class Command(ABC):
         """
         Execute a given command and, where applicable, print the response as JSON.
         """
-        raise Exception("`execute` called on abstract `Command`")
+        raise NotImplementedError("`execute` called on abstract `Command`")
 
     def validate(self) -> None:
         """
         Used to validate a command. Should always be called before `execute`. The arg parser will catch many issues
         with options, but this is used to apply additional constraints that the arg parser can't currently handle.
         """
-        raise Exception("`validate` called on abstract `Command`")
+        raise NotImplementedError("`validate` called on abstract `Command`")
diff --git a/client/python/apache_polaris/cli/command/catalog_roles.py b/client/python/apache_polaris/cli/command/catalog_roles.py
index 6d6ad617b4..1850fd72aa 100644
--- a/client/python/apache_polaris/cli/command/catalog_roles.py
+++ b/client/python/apache_polaris/cli/command/catalog_roles.py
@@ -22,6 +22,7 @@ from typing import Dict, Optional, List, cast
 from pydantic import StrictStr
 
 from apache_polaris.cli.command import Command
+from apache_polaris.cli.exceptions import CliError
 from apache_polaris.cli.constants import Subcommands, Arguments
 from apache_polaris.cli.options.option_tree import Argument
 from apache_polaris.sdk.management import (
@@ -63,7 +64,7 @@ class CatalogRolesCommand(Command):
 
     def validate(self) -> None:
         if not self.catalog_name:
-            raise Exception(
+            raise CliError(
                 f"Missing required argument: {Argument.to_flag_name(Arguments.CATALOG)}"
             )
 
@@ -77,13 +78,13 @@ class CatalogRolesCommand(Command):
             Subcommands.SUMMARIZE,
         }:
             if not self.catalog_role_name:
-                raise Exception(
+                raise CliError(
                     f"Missing required argument: {Argument.to_flag_name(Arguments.CATALOG_ROLE)}"
                 )
 
         if self.catalog_roles_subcommand in {Subcommands.GRANT, Subcommands.REVOKE}:
             if not self.principal_role_name:
-                raise Exception(
+                raise CliError(
                     f"Missing required argument: {Argument.to_flag_name(Arguments.PRINCIPAL_ROLE)}"
                 )
 
@@ -144,7 +145,7 @@ class CatalogRolesCommand(Command):
         elif self.catalog_roles_subcommand == Subcommands.SUMMARIZE:
             self._generate_summary(api)
         else:
-            raise Exception(
+            raise CliError(
                 f"{self.catalog_roles_subcommand} is not supported in the CLI"
             )
 
diff --git a/client/python/apache_polaris/cli/command/catalogs.py b/client/python/apache_polaris/cli/command/catalogs.py
index 9a26915659..89456e9510 100644
--- a/client/python/apache_polaris/cli/command/catalogs.py
+++ b/client/python/apache_polaris/cli/command/catalogs.py
@@ -18,6 +18,7 @@
 #
 
 from apache_polaris.cli.command import Command
+from apache_polaris.cli.exceptions import CliError
 from apache_polaris.cli.constants import (
     StorageType,
     CatalogType,
@@ -140,19 +141,19 @@ class CatalogsCommand(Command):
             Subcommands.SUMMARIZE,
         }:
             if not self.catalog_name:
-                raise Exception(
+                raise CliError(
                     f"Missing required argument: {Argument.to_flag_name(Arguments.CATALOG)}"
                 )
 
         if self.catalogs_subcommand == Subcommands.CREATE:
             if self.catalog_type != CatalogType.EXTERNAL.value:
                 if not self.storage_type:
-                    raise Exception(
+                    raise CliError(
                         f"Missing required argument:"
                         f" {Argument.to_flag_name(Arguments.STORAGE_TYPE)}"
                     )
                 if not self.default_base_location:
-                    raise Exception(
+                    raise CliError(
                         f"Missing required argument:"
                         f" {Argument.to_flag_name(Arguments.DEFAULT_BASE_LOCATION)}"
                     )
@@ -165,7 +166,7 @@ class CatalogsCommand(Command):
                         or not self.catalog_client_scopes
                         or len(self.catalog_client_scopes) == 0
                     ):
-                        raise Exception(
+                        raise CliError(
                             f"Authentication type 'OAUTH' requires"
                             f" {Argument.to_flag_name(Arguments.CATALOG_TOKEN_URI)},"
                             f" {Argument.to_flag_name(Arguments.CATALOG_CLIENT_ID)},"
@@ -176,41 +177,41 @@ class CatalogsCommand(Command):
                     self.catalog_authentication_type == AuthenticationType.BEARER.value
                 ):
                     if not self.catalog_bearer_token:
-                        raise Exception(
+                        raise CliError(
                             f"Missing required argument for authentication type 'BEARER':"
                             f" {Argument.to_flag_name(Arguments.CATALOG_BEARER_TOKEN)}"
                         )
                 elif self.catalog_authentication_type == AuthenticationType.SIGV4.value:
                     if not self.catalog_role_arn or not self.catalog_signing_region:
-                        raise Exception(
+                        raise CliError(
                             f"Authentication type 'SIGV4' requires"
                             f" {Argument.to_flag_name(Arguments.CATALOG_ROLE_ARN)}"
                             f" and {Argument.to_flag_name(Arguments.CATALOG_SIGNING_REGION)}"
                         )
                 if self.catalog_connection_type == CatalogConnectionType.HADOOP.value:
                     if not self.hadoop_warehouse or not self.catalog_uri:
-                        raise Exception(
+                        raise CliError(
                             f"Missing required argument for connection type 'hadoop':"
                             f" {Argument.to_flag_name(Arguments.HADOOP_WAREHOUSE)}"
                             f" and {Argument.to_flag_name(Arguments.CATALOG_URI)}"
                         )
                 elif self.catalog_connection_type == CatalogConnectionType.HIVE.value:
                     if not self.hive_warehouse or not self.catalog_uri:
-                        raise Exception(
+                        raise CliError(
                             f"Missing required argument for connection type 'hive':"
                             f" {Argument.to_flag_name(Arguments.HIVE_WAREHOUSE)}"
                             f" and {Argument.to_flag_name(Arguments.CATALOG_URI)}"
                         )
         if self.catalog_service_identity_type == ServiceIdentityType.AWS_IAM.value:
             if not self.catalog_service_identity_iam_arn:
-                raise Exception(
+                raise CliError(
                     f"Missing required argument for service identity type 'AWS_IAM':"
                     f" {Argument.to_flag_name(Arguments.CATALOG_SERVICE_IDENTITY_IAM_ARN)}"
                 )
 
         if self.storage_type == StorageType.S3.value:
             if self._has_azure_storage_info() or self._has_gcs_storage_info():
-                raise Exception(
+                raise CliError(
                     f"Storage type 's3' supports the options"
                     f" {Argument.to_flag_name(Arguments.ROLE_ARN)},"
                     f" {Argument.to_flag_name(Arguments.REGION)},"
@@ -227,12 +228,12 @@ class CatalogsCommand(Command):
                 )
         elif self.storage_type == StorageType.AZURE.value:
             if not self.tenant_id:
-                raise Exception(
+                raise CliError(
                     "Missing required argument for storage type 'azure': "
                     f" {Argument.to_flag_name(Arguments.TENANT_ID)}"
                 )
             if self._has_aws_storage_info() or self._has_gcs_storage_info():
-                raise Exception(
+                raise CliError(
                     "Storage type 'azure' supports the options"
                     f" {Argument.to_flag_name(Arguments.TENANT_ID)},"
                     f" {Argument.to_flag_name(Arguments.MULTI_TENANT_APP_NAME)}, and"
@@ -240,7 +241,7 @@ class CatalogsCommand(Command):
                 )
         elif self.storage_type == StorageType.GCS.value:
             if self._has_aws_storage_info() or self._has_azure_storage_info():
-                raise Exception(
+                raise CliError(
                     "Storage type 'gcs' supports the storage credential"
                     f" {Argument.to_flag_name(Arguments.SERVICE_ACCOUNT)}"
                 )
@@ -250,7 +251,7 @@ class CatalogsCommand(Command):
                 or self._has_azure_storage_info()
                 or self._has_gcs_storage_info()
             ):
-                raise Exception(
+                raise CliError(
                     "Storage type 'file' does not support any additional options"
                 )
 
@@ -360,8 +361,8 @@ class CatalogsCommand(Command):
                 authentication_type=self.catalog_authentication_type.upper()
             )
         elif self.catalog_authentication_type is not None:
-            raise Exception(
-                "Unknown authentication type:", self.catalog_authentication_type
+            raise CliError(
+                f"Unknown authentication type: {self.catalog_authentication_type}"
             )
 
         service_identity = None
@@ -371,8 +372,8 @@ class CatalogsCommand(Command):
                 iam_arn=self.catalog_service_identity_iam_arn,
             )
         elif self.catalog_service_identity_type is not None:
-            raise Exception(
-                "Unknown service identity type:", self.catalog_service_identity_type
+            raise CliError(
+                f"Unknown service identity type: {self.catalog_service_identity_type}"
             )
 
         config = None
@@ -401,8 +402,8 @@ class CatalogsCommand(Command):
                 warehouse=self.hive_warehouse,
             )
         elif self.catalog_connection_type is not None:
-            raise Exception(
-                "Unknown catalog connection type:", self.catalog_connection_type
+            raise CliError(
+                f"Unknown catalog connection type: {self.catalog_connection_type}"
             )
         return config
 
@@ -504,7 +505,7 @@ class CatalogsCommand(Command):
                     # is uppercase but we defined the StorageType enums as lowercase.
                     storage_type = updated_storage_info.storage_type
                     if storage_type.lower() != StorageType.S3.value:
-                        raise Exception(
+                        raise CliError(
                             f"--region requires S3 storage_type, got: {storage_type}"
                         )
                     updated_storage_info.region = self.region
@@ -524,7 +525,7 @@ class CatalogsCommand(Command):
         elif self.catalogs_subcommand == Subcommands.SUMMARIZE:
             self._generate_summary(api)
         else:
-            raise Exception(f"{self.catalogs_subcommand} is not supported in the CLI")
+            raise CliError(f"{self.catalogs_subcommand} is not supported in the CLI")
 
     def _generate_summary(self, api: PolarisDefaultApi) -> None:
         catalog_name = cast(str, self.catalog_name)
diff --git a/client/python/apache_polaris/cli/command/find.py b/client/python/apache_polaris/cli/command/find.py
index 080f1a5a50..468fcfa0df 100644
--- a/client/python/apache_polaris/cli/command/find.py
+++ b/client/python/apache_polaris/cli/command/find.py
@@ -21,6 +21,7 @@ from dataclasses import dataclass, field
 from typing import Optional, List, Tuple, cast
 
 from apache_polaris.cli.command import Command
+from apache_polaris.cli.exceptions import CliError
 from apache_polaris.cli.command.utils import (
     get_catalog_api_client,
     crawl_namespace,
@@ -53,7 +54,7 @@ class FindCommand(Command):
 
     def validate(self) -> None:
         if not self.identifier or not self.identifier.strip():
-            raise Exception("The search identifier cannot be empty.")
+            raise CliError("The search identifier cannot be empty.")
 
     def execute(self, api: PolarisDefaultApi) -> None:
         identifier = cast(str, self.identifier)
diff --git a/client/python/apache_polaris/cli/command/namespaces.py b/client/python/apache_polaris/cli/command/namespaces.py
index 354b90da69..a7cca65cc2 100644
--- a/client/python/apache_polaris/cli/command/namespaces.py
+++ b/client/python/apache_polaris/cli/command/namespaces.py
@@ -23,6 +23,7 @@ from typing import Dict, Optional, List, cast
 
 from apache_polaris.cli.command import Command
 from apache_polaris.cli.command.utils import get_catalog_api_client
+from apache_polaris.cli.exceptions import CliError
 from apache_polaris.cli.constants import Subcommands, Arguments, UNIT_SEPARATOR
 from apache_polaris.cli.options.option_tree import Argument
 from apache_polaris.sdk.catalog import IcebergCatalogAPI, CreateNamespaceRequest
@@ -56,7 +57,7 @@ class NamespacesCommand(Command):
 
     def validate(self) -> None:
         if not self.catalog:
-            raise Exception(
+            raise CliError(
                 f"Missing required argument: {Argument.to_flag_name(Arguments.CATALOG)}"
             )
         if self.namespaces_subcommand in {
@@ -66,7 +67,7 @@ class NamespacesCommand(Command):
             Subcommands.SUMMARIZE,
         }:
             if not self.namespace:
-                raise Exception(
+                raise CliError(
                     f"Missing required argument: {Argument.to_flag_name(Arguments.NAMESPACE)}"
                 )
 
@@ -112,7 +113,7 @@ class NamespacesCommand(Command):
         elif self.namespaces_subcommand == Subcommands.SUMMARIZE:
             self._generate_summary(catalog_api)
         else:
-            raise Exception(f"{self.namespaces_subcommand} is not supported in the CLI")
+            raise CliError(f"{self.namespaces_subcommand} is not supported in the CLI")
 
     def _generate_summary(self, catalog_api: IcebergCatalogAPI) -> None:
         catalog_name = cast(str, self.catalog)
diff --git a/client/python/apache_polaris/cli/command/policies.py b/client/python/apache_polaris/cli/command/policies.py
index 601fcdd34e..4eb53904d6 100644
--- a/client/python/apache_polaris/cli/command/policies.py
+++ b/client/python/apache_polaris/cli/command/policies.py
@@ -24,6 +24,7 @@ from typing import Optional, Dict, List, cast
 
 from apache_polaris.cli.command import Command
 from apache_polaris.cli.command.utils import get_catalog_api_client
+from apache_polaris.cli.exceptions import CliError
 from apache_polaris.cli.constants import Subcommands, Arguments, UNIT_SEPARATOR
 from apache_polaris.cli.options.option_tree import Argument
 from apache_polaris.sdk.catalog.api.policy_api import PolicyAPI
@@ -70,7 +71,7 @@ class PoliciesCommand(Command):
 
     def validate(self) -> None:
         if not self.catalog_name:
-            raise Exception(
+            raise CliError(
                 f"Missing required argument: {Argument.to_flag_name(Arguments.CATALOG)}"
             )
 
@@ -83,22 +84,22 @@ class PoliciesCommand(Command):
             Subcommands.DETACH,
         }:
             if not self.policy_name:
-                raise Exception(
+                raise CliError(
                     f"Missing required argument: {Argument.to_flag_name(Arguments.POLICY)}"
                 )
 
         if self.policies_subcommand in [Subcommands.CREATE, Subcommands.UPDATE]:
             if not self.policy_file:
-                raise Exception(
+                raise CliError(
                     f"Missing required argument: {Argument.to_flag_name(Arguments.POLICY_FILE)}"
                 )
         if self.policies_subcommand in [Subcommands.ATTACH, Subcommands.DETACH]:
             if not self.attachment_type:
-                raise Exception(
+                raise CliError(
                     f"Missing required argument: {Argument.to_flag_name(Arguments.ATTACHMENT_TYPE)}"
                 )
             if self.attachment_type != "catalog" and not self.attachment_path:
-                raise Exception(
+                raise CliError(
                     f"'{Argument.to_flag_name(Arguments.ATTACHMENT_PATH)}' is required when attachment type is not 'catalog'"
                 )
         if (
@@ -107,13 +108,13 @@ class PoliciesCommand(Command):
             and self.target_name
         ):
             if not self.namespace:
-                raise Exception(
+                raise CliError(
                     f"Missing required argument: {Argument.to_flag_name(Arguments.NAMESPACE)}"
                     f" when {Argument.to_flag_name(Arguments.TARGET_NAME)} is set."
                 )
         if self.policies_subcommand == Subcommands.LIST and not self.applicable:
             if not self.namespace:
-                raise Exception(
+                raise CliError(
                     f"Missing required argument: {Argument.to_flag_name(Arguments.NAMESPACE)}"
                     f" when listing policies without {Argument.to_flag_name(Arguments.APPLICABLE)} flag."
                 )
@@ -204,7 +205,7 @@ class PoliciesCommand(Command):
             if loaded_policy_response and loaded_policy_response.policy:
                 current_policy_version = loaded_policy_response.policy.version
             else:
-                raise Exception(
+                raise CliError(
                     f"Could not retrieve current policy version for {policy_name}"
                 )
 
@@ -252,4 +253,4 @@ class PoliciesCommand(Command):
                     ),
                 )
         else:
-            raise Exception(f"{self.policies_subcommand} is not supported in the CLI")
+            raise CliError(f"{self.policies_subcommand} is not supported in the CLI")
diff --git a/client/python/apache_polaris/cli/command/principal_roles.py b/client/python/apache_polaris/cli/command/principal_roles.py
index 1a205e0e7c..90ac8f4667 100644
--- a/client/python/apache_polaris/cli/command/principal_roles.py
+++ b/client/python/apache_polaris/cli/command/principal_roles.py
@@ -22,6 +22,7 @@ from typing import Dict, Optional, List, cast
 
 from apache_polaris.cli.command import Command
 from apache_polaris.cli.constants import Subcommands, Arguments
+from apache_polaris.cli.exceptions import CliError
 from apache_polaris.cli.options.option_tree import Argument
 from apache_polaris.sdk.management import (
     PolarisDefaultApi,
@@ -71,19 +72,19 @@ class PrincipalRolesCommand(Command):
             Subcommands.SUMMARIZE,
         }:
             if not self.principal_role_name:
-                raise Exception(
+                raise CliError(
                     f"Missing required argument: {Argument.to_flag_name(Arguments.PRINCIPAL_ROLE)}"
                 )
 
         if self.principal_roles_subcommand == Subcommands.LIST:
             if self.principal_name and self.catalog_role_name:
-                raise Exception(
+                raise CliError(
                     f"You may provide either {Argument.to_flag_name(Arguments.PRINCIPAL)} or"
                     f" {Argument.to_flag_name(Arguments.CATALOG_ROLE)}, but not both"
                 )
         if self.principal_roles_subcommand in {Subcommands.GRANT, Subcommands.REVOKE}:
             if not self.principal_name:
-                raise Exception(
+                raise CliError(
                     f"Missing required argument for {self.principal_roles_subcommand}:"
                     f" {Argument.to_flag_name(Arguments.PRINCIPAL)}"
                 )
@@ -146,7 +147,7 @@ class PrincipalRolesCommand(Command):
         elif self.principal_roles_subcommand == Subcommands.SUMMARIZE:
             self._generate_summary(api)
         else:
-            raise Exception(
+            raise CliError(
                 f"{self.principal_roles_subcommand} is not supported in the CLI"
             )
 
diff --git a/client/python/apache_polaris/cli/command/principals.py b/client/python/apache_polaris/cli/command/principals.py
index f866eb0fc7..8bbc7c9080 100644
--- a/client/python/apache_polaris/cli/command/principals.py
+++ b/client/python/apache_polaris/cli/command/principals.py
@@ -23,6 +23,7 @@ from typing import Dict, Optional, List, Iterator, Any, cast
 from pydantic import StrictStr
 
 from apache_polaris.cli.command import Command
+from apache_polaris.cli.exceptions import CliError
 from apache_polaris.cli.constants import Subcommands, Arguments
 from apache_polaris.cli.options.option_tree import Argument
 from apache_polaris.sdk.management import (
@@ -116,7 +117,7 @@ class PrincipalsCommand(Command):
             Subcommands.SUMMARIZE,
         }:
             if not self.principal_name:
-                raise Exception(
+                raise CliError(
                     f"Missing required argument: {Argument.to_flag_name(Arguments.PRINCIPAL)}"
                 )
 
@@ -217,7 +218,7 @@ class PrincipalsCommand(Command):
         elif self.principals_subcommand == Subcommands.SUMMARIZE:
             self._generate_summary(api)
         else:
-            raise Exception(f"{self.principals_subcommand} is not supported in the CLI")
+            raise CliError(f"{self.principals_subcommand} is not supported in the CLI")
 
     def _generate_summary(self, api: PolarisDefaultApi) -> None:
         principal_name = cast(str, self.principal_name)
diff --git a/client/python/apache_polaris/cli/command/privileges.py b/client/python/apache_polaris/cli/command/privileges.py
index 565ef97901..7fa26e66aa 100644
--- a/client/python/apache_polaris/cli/command/privileges.py
+++ b/client/python/apache_polaris/cli/command/privileges.py
@@ -22,6 +22,7 @@ from typing import List, Optional, cast
 from pydantic import StrictStr
 
 from apache_polaris.cli.command import Command
+from apache_polaris.cli.exceptions import CliError
 from apache_polaris.cli.constants import Subcommands, Actions, Arguments
 from apache_polaris.cli.options.option_tree import Argument
 from apache_polaris.sdk.management import (
@@ -64,46 +65,46 @@ class PrivilegesCommand(Command):
 
     def validate(self) -> None:
         if not self.catalog_name:
-            raise Exception(
+            raise CliError(
                 f"Missing required argument: {Argument.to_flag_name(Arguments.CATALOG)}"
             )
         if not self.catalog_role_name:
-            raise Exception(
+            raise CliError(
                 f"Missing required argument: {Argument.to_flag_name(Arguments.CATALOG_ROLE)}"
             )
 
         if self.privileges_subcommand != Subcommands.LIST:
             if not self.privilege:
-                raise Exception(
+                raise CliError(
                     f"Missing required argument: {Argument.to_flag_name(Arguments.PRIVILEGE)}"
                 )
 
         if not self.privileges_subcommand:
-            raise Exception("A subcommand must be provided")
+            raise CliError("A subcommand must be provided")
         if (
             self.privileges_subcommand
             in {Subcommands.NAMESPACE, Subcommands.TABLE, Subcommands.VIEW}
             and not self.namespace
         ):
-            raise Exception(
+            raise CliError(
                 f"Missing required argument: {Argument.to_flag_name(Arguments.NAMESPACE)}"
             )
 
         if self.action == Actions.GRANT and self.cascade:
-            raise Exception("Unrecognized argument for GRANT: --cascade")
+            raise CliError("Unrecognized argument for GRANT: --cascade")
 
         if self.privileges_subcommand == Subcommands.CATALOG:
             if self.privilege not in {i.value for i in CatalogPrivilege}:
-                raise Exception(f"Invalid catalog privilege: {self.privilege}")
+                raise CliError(f"Invalid catalog privilege: {self.privilege}")
         if self.privileges_subcommand == Subcommands.NAMESPACE:
             if self.privilege not in {i.value for i in NamespacePrivilege}:
-                raise Exception(f"Invalid namespace privilege: {self.privilege}")
+                raise CliError(f"Invalid namespace privilege: {self.privilege}")
         if self.privileges_subcommand == Subcommands.TABLE:
             if self.privilege not in {i.value for i in TablePrivilege}:
-                raise Exception(f"Invalid table privilege: {self.privilege}")
+                raise CliError(f"Invalid table privilege: {self.privilege}")
         if self.privileges_subcommand == Subcommands.VIEW:
             if self.privilege not in {i.value for i in ViewPrivilege}:
-                raise Exception(f"Invalid view privilege: {self.privilege}")
+                raise CliError(f"Invalid view privilege: {self.privilege}")
 
     def execute(self, api: PolarisDefaultApi) -> None:
         catalog_name = cast(str, self.catalog_name)
@@ -144,7 +145,7 @@ class PrivilegesCommand(Command):
                 )
 
             if not grant:
-                raise Exception(
+                raise CliError(
                     f"{self.privileges_subcommand} is not supported in the CLI"
                 )
             elif self.action == Actions.GRANT:
@@ -156,4 +157,4 @@ class PrivilegesCommand(Command):
                     catalog_name, role_name, self.cascade, request
                 )
             else:
-                raise Exception(f"{self.action} is not supported in the CLI")
+                raise CliError(f"{self.action} is not supported in the CLI")
diff --git a/client/python/apache_polaris/cli/command/profiles.py b/client/python/apache_polaris/cli/command/profiles.py
index 668bf29c45..9238fc85b1 100644
--- a/client/python/apache_polaris/cli/command/profiles.py
+++ b/client/python/apache_polaris/cli/command/profiles.py
@@ -25,6 +25,7 @@ from dataclasses import dataclass
 from typing import Dict, Optional, List, Any, cast
 
 from apache_polaris.cli.command import Command
+from apache_polaris.cli.exceptions import CliError
 from apache_polaris.cli.constants import (
     Subcommands,
     Arguments,
@@ -152,7 +153,7 @@ class ProfilesCommand(Command):
             Subcommands.GET,
         }:
             if not self.profile_name:
-                raise Exception("Missing required argument: profile_name")
+                raise CliError("Missing required argument: profile_name")
 
     def execute(self, api: Optional[PolarisDefaultApi] = None) -> None:
         if self.profiles_subcommand == Subcommands.CREATE:
@@ -177,4 +178,4 @@ class ProfilesCommand(Command):
             for profile_name in profiles:
                 print(f" - {profile_name}")
         else:
-            raise Exception(f"{self.profiles_subcommand} is not supported in the CLI")
+            raise CliError(f"{self.profiles_subcommand} is not supported in the CLI")
diff --git a/client/python/apache_polaris/cli/command/setup.py b/client/python/apache_polaris/cli/command/setup.py
index e63feb81ae..8a90dcb76a 100644
--- a/client/python/apache_polaris/cli/command/setup.py
+++ b/client/python/apache_polaris/cli/command/setup.py
@@ -24,6 +24,7 @@ from dataclasses import dataclass
 from typing import Dict, Optional, List, Any, Set
 
 from apache_polaris.cli.command import Command
+from apache_polaris.cli.exceptions import CliError
 from apache_polaris.cli.constants import (
     PrincipalType,
     Subcommands,
@@ -518,7 +519,7 @@ class SetupCommand(Command):
     def validate(self) -> None:
         """Validate the command arguments."""
         if self.setup_subcommand == Subcommands.APPLY and self.setup_config is None:
-            raise Exception("`setup_config` is required for the `apply` subcommand")
+            raise CliError("`setup_config` is required for the `apply` subcommand")
 
     def execute(self, api: PolarisDefaultApi) -> None:
         """Execute the setup command based on the subcommand (apply or export)."""
@@ -578,7 +579,7 @@ class SetupCommand(Command):
         elif self.setup_subcommand == Subcommands.EXPORT:
             self._export_config(api)
         else:
-            raise Exception(f"Unsupported subcommand: {self.setup_subcommand}")
+            raise CliError(f"Unsupported subcommand: {self.setup_subcommand}")
 
     def _log_dry_run(
         self,
diff --git a/client/python/apache_polaris/cli/command/tables.py b/client/python/apache_polaris/cli/command/tables.py
index 9547c73ab6..45d01513b2 100644
--- a/client/python/apache_polaris/cli/command/tables.py
+++ b/client/python/apache_polaris/cli/command/tables.py
@@ -21,6 +21,7 @@ from typing import List, Optional, cast
 
 from apache_polaris.cli.command import Command
 from apache_polaris.cli.command.utils import get_catalog_api_client
+from apache_polaris.cli.exceptions import CliError
 from apache_polaris.cli.constants import Subcommands, Arguments, UNIT_SEPARATOR
 from apache_polaris.cli.options.option_tree import Argument
 from apache_polaris.sdk.catalog import IcebergCatalogAPI
@@ -53,11 +54,11 @@ class TableCommand(Command):
 
     def validate(self) -> None:
         if not self.catalog_name:
-            raise Exception(
+            raise CliError(
                 f"Missing required argument: {Argument.to_flag_name(Arguments.CATALOG)}"
             )
         if not self.namespace:
-            raise Exception(
+            raise CliError(
                 f"Missing required argument: {Argument.to_flag_name(Arguments.NAMESPACE)}"
             )
         if (
@@ -66,7 +67,7 @@ class TableCommand(Command):
             or self.table_subcommand == Subcommands.DELETE
         ):
             if not self.table_name or not self.table_name.strip():
-                raise Exception("The table name cannot be empty.")
+                raise CliError("The table name cannot be empty.")
 
     def execute(self, api: PolarisDefaultApi) -> None:
         catalog_api = IcebergCatalogAPI(get_catalog_api_client(api))
diff --git a/client/python/apache_polaris/cli/exceptions.py b/client/python/apache_polaris/cli/exceptions.py
new file mode 100644
index 0000000000..393210c7b4
--- /dev/null
+++ b/client/python/apache_polaris/cli/exceptions.py
@@ -0,0 +1,43 @@
+#
+# 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.
+#
+
+"""Typed exceptions for the Polaris CLI (user-facing errors vs unexpected bugs)."""
+
+# Exit code for argparse errors and typical usage mistakes (matches POSIX EX_USAGE).
+CLI_USAGE_EXIT_CODE = 2
+
+# Exit code for runtime failures: Polaris API errors, failed token exchange, unexpected bugs.
+# ``polaris_cli`` uses this same value for ``ApiException`` and generic ``Exception`` exits.
+CLI_ERROR_EXIT_CODE = 1
+
+
+class CliError(Exception):
+    """
+    Expected CLI failure with a message suitable for stderr.
+
+    Use exit_code ``CLI_USAGE_EXIT_CODE`` for invalid arguments and validation;
+    use ``CLI_ERROR_EXIT_CODE`` for failures after contacting the server or for
+    internal preconditions that are not argparse validation.
+    """
+
+    __slots__ = ("exit_code",)
+
+    def __init__(self, message: str, *, exit_code: int = CLI_USAGE_EXIT_CODE) -> None:
+        super().__init__(message)
+        self.exit_code = exit_code
diff --git a/client/python/apache_polaris/cli/options/option_tree.py b/client/python/apache_polaris/cli/options/option_tree.py
index 9102c59325..034b6a3cb0 100644
--- a/client/python/apache_polaris/cli/options/option_tree.py
+++ b/client/python/apache_polaris/cli/options/option_tree.py
@@ -53,7 +53,7 @@ class Argument:
 
     def __post_init__(self) -> None:
         if self.name.startswith("--"):
-            raise Exception(
+            raise ValueError(
                 f"Argument name {self.name} starts with `--`: should this be a flag_name?"
             )
 
diff --git a/client/python/apache_polaris/cli/options/parser.py b/client/python/apache_polaris/cli/options/parser.py
index 8b2d4d43e8..6d606c0b06 100644
--- a/client/python/apache_polaris/cli/options/parser.py
+++ b/client/python/apache_polaris/cli/options/parser.py
@@ -20,6 +20,7 @@ import argparse
 from typing import List, Optional, Dict, Any, Union
 
 from apache_polaris.cli.constants import Arguments, DEFAULT_HEADER
+from apache_polaris.cli.exceptions import CliError
 from apache_polaris.cli.options.option_tree import OptionTree, Option, Argument
 
 
@@ -213,11 +214,11 @@ class Parser(object):
         results = dict()
         for property in properties:
             if "=" not in property:
-                raise Exception(f"Could not parse property `{property}`")
+                raise CliError(f"Could not parse property `{property}`")
             key, value = property.split("=", 1)
             if not value:
-                raise Exception(f"Could not parse property `{property}`")
+                raise CliError(f"Could not parse property `{property}`")
             if key in results:
-                raise Exception(f"Duplicate property key `{key}`")
+                raise CliError(f"Duplicate property key `{key}`")
             results[key] = value
         return results
diff --git a/client/python/apache_polaris/cli/polaris_cli.py b/client/python/apache_polaris/cli/polaris_cli.py
index 4295f0e0ce..8ee587e073 100644
--- a/client/python/apache_polaris/cli/polaris_cli.py
+++ b/client/python/apache_polaris/cli/polaris_cli.py
@@ -26,7 +26,9 @@ from typing import Optional, List, Any, cast
 import urllib3
 
 from apache_polaris.cli.api_client_builder import ApiClientBuilder
+from apache_polaris.cli.command import Command
 from apache_polaris.cli.constants import Commands
+from apache_polaris.cli.exceptions import CliError, CLI_ERROR_EXIT_CODE
 from apache_polaris.cli.options.parser import Parser
 from apache_polaris.sdk.management import PolarisDefaultApi
 from apache_polaris.sdk.management.exceptions import ApiException
@@ -57,31 +59,37 @@ class PolarisCli:
         if not args:
             Parser.build_parser().print_help()
             return
-        options = Parser.parse(args)
-        if options.command == Commands.PROFILES:
-            from apache_polaris.cli.command import Command
-
-            command = Command.from_options(options)
-            command = cast(ProfilesCommand, command)
-            command.execute()
-        else:
-            api_client = ApiClientBuilder(
-                options, direct_authentication=PolarisCli.DIRECT_AUTHENTICATION_ENABLED
-            ).get_api_client()
-            try:
-                from apache_polaris.cli.command import Command
-
+        try:
+            options = Parser.parse(args)
+            if options.command == Commands.PROFILES:
+                command = Command.from_options(options)
+                command = cast(ProfilesCommand, command)
+                command.execute()
+            else:
+                api_client = ApiClientBuilder(
+                    options,
+                    direct_authentication=PolarisCli.DIRECT_AUTHENTICATION_ENABLED,
+                ).get_api_client()
                 admin_api = PolarisDefaultApi(api_client)
                 command = Command.from_options(options)
                 if options.debug:
                     PolarisCli._enable_api_request_logging()
                 command.execute(admin_api)
-            except ApiException as e:
-                PolarisCli._try_print_exception(e)
-                sys.exit(1)
-            except Exception as e:
-                sys.stderr.write(f"An unexpected error occurred: {e}{os.linesep}")
-                sys.exit(1)
+        # Handlers from most specific to least: ApiException (OpenAPI client), CliError
+        # (expected user/config failures with their own exit codes), NotImplementedError
+        # (abstract Command misuse), then generic bugs.
+        except ApiException as e:
+            PolarisCli._try_print_exception(e)
+            sys.exit(CLI_ERROR_EXIT_CODE)
+        except CliError as e:
+            sys.stderr.write(f"{e}{os.linesep}")
+            sys.exit(e.exit_code)
+        except NotImplementedError as e:
+            sys.stderr.write(f"Internal error: {e}{os.linesep}")
+            sys.exit(CLI_ERROR_EXIT_CODE)
+        except Exception as e:
+            sys.stderr.write(f"An unexpected error occurred: {e}{os.linesep}")
+            sys.exit(CLI_ERROR_EXIT_CODE)
 
     @staticmethod
     def _enable_api_request_logging() -> None:
diff --git a/client/python/tests/cli_test_utils.py b/client/python/tests/cli_test_utils.py
index f0e7c9dbd6..a43d100756 100644
--- a/client/python/tests/cli_test_utils.py
+++ b/client/python/tests/cli_test_utils.py
@@ -23,6 +23,7 @@ from typing import Any, Callable
 from unittest.mock import patch, MagicMock
 
 from apache_polaris.cli.command import Command
+from apache_polaris.cli.exceptions import CliError
 from apache_polaris.cli.options.parser import Parser
 from apache_polaris.sdk.management import PolarisDefaultApi
 
@@ -49,7 +50,7 @@ class CLITestBase(unittest.TestCase):
         return command.execute(mock_client)
 
     def check_exception(self, func: Callable[[], Any], exception_str: str) -> None:
-        with self.assertRaises(Exception) as cm:
+        with self.assertRaises(CliError) as cm:
             func()
         self.assertIn(exception_str, str(cm.exception))
 
diff --git a/client/python/tests/test_cli_exceptions.py b/client/python/tests/test_cli_exceptions.py
new file mode 100644
index 0000000000..6a491f5ce3
--- /dev/null
+++ b/client/python/tests/test_cli_exceptions.py
@@ -0,0 +1,68 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+
+import io
+import unittest
+from unittest.mock import patch
+
+from apache_polaris.cli.exceptions import (
+    CliError,
+    CLI_ERROR_EXIT_CODE,
+    CLI_USAGE_EXIT_CODE,
+)
+from apache_polaris.cli.options.parser import Parser
+from apache_polaris.cli.polaris_cli import PolarisCli
+
+
+class TestCliExceptions(unittest.TestCase):
+    def test_cli_error_default_exit_code(self) -> None:
+        err = CliError("bad input")
+        self.assertEqual(err.exit_code, CLI_USAGE_EXIT_CODE)
+        self.assertEqual(str(err), "bad input")
+
+    def test_cli_error_custom_exit_code(self) -> None:
+        err = CliError("token failed", exit_code=CLI_ERROR_EXIT_CODE)
+        self.assertEqual(err.exit_code, CLI_ERROR_EXIT_CODE)
+
+    def test_parse_properties_duplicate_key(self) -> None:
+        with self.assertRaises(CliError) as cm:
+            Parser.parse_properties(["a=1", "a=2"])
+        self.assertIn("Duplicate property key", str(cm.exception))
+
+    def test_polaris_cli_exits_on_cli_error(self) -> None:
+        stderr = io.StringIO()
+        with (
+            patch("apache_polaris.cli.polaris_cli.sys.stderr", stderr),
+            patch(
+                "apache_polaris.cli.polaris_cli.Parser.parse",
+                side_effect=CliError("unit test failure"),
+            ),
+            patch(
+                "apache_polaris.cli.polaris_cli.sys.exit",
+                side_effect=SystemExit(CLI_USAGE_EXIT_CODE),
+            ),
+        ):
+            with self.assertRaises(SystemExit) as cm:
+                PolarisCli.execute(["catalogs", "list"])
+        self.assertEqual(cm.exception.code, CLI_USAGE_EXIT_CODE)
+        self.assertIn("unit test failure", stderr.getvalue())
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/client/python/tests/test_parser_basic.py b/client/python/tests/test_parser_basic.py
index eaf7a35eeb..a90ea452f7 100644
--- a/client/python/tests/test_parser_basic.py
+++ b/client/python/tests/test_parser_basic.py
@@ -19,8 +19,9 @@
 
 import unittest
 from cli_test_utils import CLITestBase, INVALID_ARGS
-from apache_polaris.cli.options.parser import Parser
+from apache_polaris.cli.exceptions import CliError
 from apache_polaris.cli.command import Command
+from apache_polaris.cli.options.parser import Parser
 
 
 class TestParserBasic(CLITestBase):
@@ -89,19 +90,19 @@ class TestParserBasic(CLITestBase):
             Parser.parse(["table"])  # missing subcommand
         self.assertEqual(cm.exception.code, INVALID_ARGS)
 
-        with self.assertRaises(Exception) as cm_exc:
+        with self.assertRaises(CliError) 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:
+        with self.assertRaises(CliError) 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:
+        with self.assertRaises(CliError) as cm_exc:
             options = Parser.parse(
                 ["tables", "get", " ", "--catalog", "my_catalog", "--namespace", "ns"]
             )  # empty table name