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