diff --git a/.changes/next-release/enhancement-Output-51826.json b/.changes/next-release/enhancement-Output-51826.json
new file mode 100644
index 000000000000..685f6a4c976c
--- /dev/null
+++ b/.changes/next-release/enhancement-Output-51826.json
@@ -0,0 +1,5 @@
+{
+ "type": "enhancement",
+ "category": "Output",
+ "description": "Add support for ``--output off`` to suppress all stdout output while preserving stderr for errors and warnings."
+}
diff --git a/.changes/next-release/feature-Output-59989.json b/.changes/next-release/feature-Output-59989.json
new file mode 100644
index 000000000000..115a7ad09e17
--- /dev/null
+++ b/.changes/next-release/feature-Output-59989.json
@@ -0,0 +1,5 @@
+{
+ "type": "feature",
+ "category": "Output",
+ "description": "Add structured error output with configurable formats. AWS service errors now display additional fields in the configured format (legacy, json, yaml, text, table, or enhanced). Configure via ``--cli-error-format``, ``cli_error_format`` config variable, or ``AWS_CLI_ERROR_FORMAT`` environment variable. The new enhanced format is the default. Set ``cli_error_format=legacy`` to preserve the original error format."
+}
diff --git a/awscli/clidriver.py b/awscli/clidriver.py
index ae8a3af68845..a049f03e0c03 100644
--- a/awscli/clidriver.py
+++ b/awscli/clidriver.py
@@ -30,6 +30,7 @@
ScopedConfigProvider,
)
from botocore.context import start_as_current_context
+from botocore.exceptions import ClientError
from botocore.history import get_global_history_recorder
from awscli import __version__
@@ -119,7 +120,7 @@ def create_clidriver(args=None):
session.full_config.get('plugins', {}),
event_hooks=session.get_component('event_emitter'),
)
- error_handlers_chain = construct_cli_error_handlers_chain()
+ error_handlers_chain = construct_cli_error_handlers_chain(session)
driver = CLIDriver(
session=session, error_handler=error_handlers_chain, debug=debug
)
@@ -246,7 +247,9 @@ def __init__(self, session=None, error_handler=None, debug=False):
self.session = session
self._error_handler = error_handler
if self._error_handler is None:
- self._error_handler = construct_cli_error_handlers_chain()
+ self._error_handler = construct_cli_error_handlers_chain(
+ self.session
+ )
if debug:
self._set_logging(debug)
self._update_config_chain()
@@ -275,6 +278,9 @@ def _update_config_chain(self):
config_store.set_config_provider(
'cli_help_output', self._construct_cli_help_output_chain()
)
+ config_store.set_config_provider(
+ 'cli_error_format', self._construct_cli_error_format_chain()
+ )
def _construct_cli_region_chain(self):
providers = [
@@ -368,6 +374,20 @@ def _construct_cli_auto_prompt_chain(self):
]
return ChainProvider(providers=providers)
+ def _construct_cli_error_format_chain(self):
+ providers = [
+ EnvironmentProvider(
+ name='AWS_CLI_ERROR_FORMAT',
+ env=os.environ,
+ ),
+ ScopedConfigProvider(
+ config_var_name='cli_error_format',
+ session=self.session,
+ ),
+ ConstantProvider(value='enhanced'),
+ ]
+ return ChainProvider(providers=providers)
+
@property
def subcommand_table(self):
return self._get_command_table()
@@ -516,12 +536,16 @@ def main(self, args=None):
command_table = self._get_command_table()
parser = self.create_parser(command_table)
self._add_aliases(command_table, parser)
+
try:
# Because _handle_top_level_args emits events, it's possible
# that exceptions can be raised, which should have the same
# general exception handling logic as calling into the
# command table. This is why it's in the try/except clause.
parsed_args, remaining = parser.parse_known_args(args)
+ self._error_handler = construct_cli_error_handlers_chain(
+ self.session, parsed_args
+ )
self._handle_top_level_args(parsed_args)
validate_preferred_output_encoding()
self._emit_session_event(parsed_args)
@@ -1028,9 +1052,7 @@ def _make_client_call(
paginator = client.get_paginator(py_operation_name)
response = paginator.paginate(**parameters)
else:
- response = getattr(client, xform_name(operation_name))(
- **parameters
- )
+ response = getattr(client, py_operation_name)(**parameters)
return response
def _display_response(self, command_name, response, parsed_globals):
diff --git a/awscli/data/cli.json b/awscli/data/cli.json
index 27d32410393f..312d46b9e192 100644
--- a/awscli/data/cli.json
+++ b/awscli/data/cli.json
@@ -26,7 +26,8 @@
"text",
"table",
"yaml",
- "yaml-stream"
+ "yaml-stream",
+ "off"
],
"help": "
The formatting style for command output.
"
},
@@ -85,6 +86,17 @@
"no-cli-auto-prompt": {
"action": "store_true",
"help": "Disable automatically prompt for CLI input parameters.
"
+ },
+ "cli-error-format": {
+ "choices": [
+ "legacy",
+ "json",
+ "yaml",
+ "text",
+ "table",
+ "enhanced"
+ ],
+ "help": "The formatting style for error output. By default, errors are displayed in enhanced format.
"
}
}
}
diff --git a/awscli/errorhandler.py b/awscli/errorhandler.py
index 51d1c31f9514..2cdf759b938f 100644
--- a/awscli/errorhandler.py
+++ b/awscli/errorhandler.py
@@ -10,6 +10,7 @@
# 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 argparse
import logging
import signal
@@ -36,10 +37,64 @@
ConfigurationError,
ParamValidationError,
)
+from awscli.formatter import get_formatter
from awscli.utils import PagerInitializationException
LOG = logging.getLogger(__name__)
+VALID_ERROR_FORMATS = ['legacy', 'json', 'yaml', 'text', 'table', 'enhanced']
+# Maximum number of items to display inline for collections
+MAX_INLINE_ITEMS = 5
+
+
+class EnhancedErrorFormatter:
+ def format_error(self, error_info, formatted_message, stream):
+ stream.write(f'{formatted_message}\n')
+
+ additional_fields = self._get_additional_fields(error_info)
+
+ if not additional_fields:
+ return
+
+ stream.write('\nAdditional error details:\n')
+ for key, value in additional_fields.items():
+ if self._is_simple_value(value):
+ stream.write(f'{key}: {value}\n')
+ elif self._is_small_collection(value):
+ stream.write(f'{key}: {self._format_inline(value)}\n')
+ else:
+ stream.write(
+ f'{key}: \n'
+ f'(Use --cli-error-format with json or yaml '
+ f'to see full details)\n'
+ )
+
+ def _is_simple_value(self, value):
+ return isinstance(value, (str, int, float, bool, type(None)))
+
+ def _is_small_collection(self, value):
+ if isinstance(value, list):
+ return len(value) < MAX_INLINE_ITEMS and all(
+ self._is_simple_value(item) for item in value
+ )
+ elif isinstance(value, dict):
+ return len(value) < MAX_INLINE_ITEMS and all(
+ self._is_simple_value(v) for v in value.values()
+ )
+ return False
+
+ def _format_inline(self, value):
+ if isinstance(value, list):
+ return f"[{', '.join(str(item) for item in value)}]"
+ elif isinstance(value, dict):
+ items = ', '.join(f'{k}: {v}' for k, v in value.items())
+ return f'{{{items}}}'
+ return str(value)
+
+ def _get_additional_fields(self, error_info):
+ standard_keys = {'Code', 'Message'}
+ return {k: v for k, v in error_info.items() if k not in standard_keys}
+
def construct_entry_point_handlers_chain():
handlers = [
@@ -51,7 +106,7 @@ def construct_entry_point_handlers_chain():
return ChainedExceptionHandler(exception_handlers=handlers)
-def construct_cli_error_handlers_chain():
+def construct_cli_error_handlers_chain(session=None, parsed_globals=None):
handlers = [
ParamValidationErrorsHandler(),
UnknownArgumentErrorHandler(),
@@ -60,7 +115,7 @@ def construct_cli_error_handlers_chain():
NoCredentialsErrorHandler(),
PagerErrorHandler(),
InterruptExceptionHandler(),
- ClientErrorHandler(),
+ ClientErrorHandler(session, parsed_globals),
GeneralExceptionHandler(),
]
return ChainedExceptionHandler(exception_handlers=handlers)
@@ -108,6 +163,111 @@ class ClientErrorHandler(FilteredExceptionHandler):
EXCEPTIONS_TO_HANDLE = ClientError
RC = CLIENT_ERROR_RC
+ def __init__(self, session=None, parsed_globals=None):
+ self._session = session
+ self._parsed_globals = parsed_globals
+ self._enhanced_formatter = None
+
+ def _do_handle_exception(self, exception, stdout, stderr):
+ displayed_structured = False
+ if self._session:
+ displayed_structured = self._try_display_structured_error(
+ exception, stderr
+ )
+
+ if not displayed_structured:
+ return super()._do_handle_exception(exception, stdout, stderr)
+
+ return self.RC
+
+ def _resolve_error_format(self, parsed_globals):
+ if parsed_globals:
+ error_format = getattr(parsed_globals, 'cli_error_format', None)
+ if error_format:
+ return error_format.lower()
+ try:
+ error_format = self._session.get_config_variable(
+ 'cli_error_format'
+ )
+ if error_format:
+ return error_format.lower()
+ except (KeyError, AttributeError) as e:
+ LOG.debug(
+ 'Failed to get cli_error_format from config: %s', e
+ )
+
+ return 'enhanced'
+
+ def _try_display_structured_error(self, exception, stderr):
+ try:
+ error_response = self._extract_error_response(exception)
+ if not error_response or 'Error' not in error_response:
+ return False
+
+ error_info = error_response['Error']
+ error_format = self._resolve_error_format(self._parsed_globals)
+
+ if error_format not in VALID_ERROR_FORMATS:
+ LOG.warning(
+ f"Invalid cli_error_format: '{error_format}'. "
+ f"Using 'enhanced' format."
+ )
+ error_format = 'enhanced'
+
+ if error_format == 'legacy':
+ return False
+
+ formatted_message = str(exception)
+
+ if error_format == 'enhanced':
+ if self._enhanced_formatter is None:
+ self._enhanced_formatter = EnhancedErrorFormatter()
+ self._enhanced_formatter.format_error(
+ error_info, formatted_message, stderr
+ )
+ return True
+
+ temp_parsed_globals = argparse.Namespace()
+ temp_parsed_globals.output = error_format
+ temp_parsed_globals.query = None
+ temp_parsed_globals.color = (
+ getattr(self._parsed_globals, 'color', 'auto')
+ if self._parsed_globals
+ else 'auto'
+ )
+
+ formatter = get_formatter(error_format, temp_parsed_globals)
+ formatter('error', error_info, stderr)
+ return True
+
+ except Exception as e:
+ LOG.debug(
+ 'Failed to display structured error: %s', e, exc_info=True
+ )
+ return False
+
+ @staticmethod
+ def _extract_error_response(exception):
+ if not isinstance(exception, ClientError):
+ return None
+
+ if hasattr(exception, 'response') and 'Error' in exception.response:
+ error_dict = dict(exception.response['Error'])
+
+ # AWS services return modeled error fields
+ # at the top level of the error response,
+ # not nested under an Error key. Botocore preserves this structure.
+ # Include these fields to provide complete error information.
+ # Exclude response metadata and avoid duplicates.
+ excluded_keys = {'Error', 'ResponseMetadata', 'Code', 'Message'}
+ for key, value in exception.response.items():
+ if key not in excluded_keys and key not in error_dict:
+ error_dict[key] = value
+
+ return {'Error': error_dict}
+
+ return None
+
class ConfigurationErrorHandler(FilteredExceptionHandler):
EXCEPTIONS_TO_HANDLE = ConfigurationError
diff --git a/awscli/examples/global_options.rst b/awscli/examples/global_options.rst
index cec1f5529736..20354555fe3c 100644
--- a/awscli/examples/global_options.rst
+++ b/awscli/examples/global_options.rst
@@ -29,6 +29,8 @@
* yaml-stream
+ * off
+
``--query`` (string)
@@ -96,3 +98,21 @@
Disable automatically prompt for CLI input parameters.
+``--cli-error-format`` (string)
+
+ The formatting style for error output. By default, errors are displayed in enhanced format.
+
+
+ * legacy
+
+ * json
+
+ * yaml
+
+ * text
+
+ * table
+
+ * enhanced
+
+
diff --git a/awscli/examples/global_synopsis.rst b/awscli/examples/global_synopsis.rst
index 1ca332c717ff..3e603348debb 100644
--- a/awscli/examples/global_synopsis.rst
+++ b/awscli/examples/global_synopsis.rst
@@ -16,3 +16,4 @@
[--no-cli-pager]
[--cli-auto-prompt]
[--no-cli-auto-prompt]
+[--cli-error-format ]
diff --git a/awscli/formatter.py b/awscli/formatter.py
index 60d80d8db0c6..6cbd46369081 100644
--- a/awscli/formatter.py
+++ b/awscli/formatter.py
@@ -227,9 +227,9 @@ def _format_response(self, command_name, response, stream):
try:
self.table.render(stream)
except OSError:
- # If they're piping stdout to another process which exits before
- # we're done writing all of our output, we'll get an error about a
- # closed pipe which we can safely ignore.
+ # If they're piping stdout to another process which exits
+ # before we're done writing all of our output, we'll get an
+ # error about a closed pipe which we can safely ignore.
pass
def _build_table(self, title, current, indent_level=0):
@@ -368,12 +368,26 @@ def _format_response(self, response, stream):
text.format_text(response, stream)
+class OffFormatter(Formatter):
+ """Formatter that suppresses all output.
+ Only stdout is suppressed; stderr (error messages) remains visible.
+ """
+
+ def __call__(self, command_name, response, stream=None):
+ if is_response_paginated(response):
+ for page in response:
+ self._get_transformed_response_for_output(page)
+ else:
+ self._get_transformed_response_for_output(response)
+
+
CLI_OUTPUT_FORMATS = {
'json': JSONFormatter,
'text': TextFormatter,
'table': TableFormatter,
'yaml': YAMLFormatter,
'yaml-stream': StreamedYAMLFormatter,
+ 'off': OffFormatter,
}
diff --git a/awscli/topics/config-vars.rst b/awscli/topics/config-vars.rst
index 8e291725c166..28828bc88fba 100644
--- a/awscli/topics/config-vars.rst
+++ b/awscli/topics/config-vars.rst
@@ -82,6 +82,9 @@ max_attempts N/A max_attempts AWS_MAX_ATTEMPTS
retry_mode N/A retry_mode AWS_RETRY_MODE Type of retries performed
-------------------- -------------- --------------------- --------------------- --------------------------------
cli_pager --no-cli-pager cli_pager AWS_PAGER Redirect/Disable output to pager
+-------------------- -------------- --------------------- --------------------- --------------------------------
+cli_error_format --cli-error- cli_error_format AWS_CLI_ERROR_FORMAT Format for error output
+ format
==================== ============== ===================== ===================== ================================
The third column, Config Entry, is the value you would specify in the AWS CLI
@@ -94,6 +97,29 @@ The valid values of the ``output`` configuration variable are:
* json
* table
* text
+* yaml
+* yaml-stream
+* off
+
+The ``off`` value suppresses all stdout output while preserving stderr for
+errors and warnings.
+
+``cli_error_format`` controls how AWS service errors are displayed. The valid
+values of the ``cli_error_format`` configuration variable are:
+
+* enhanced - AWS service errors display additional modeled fields in a
+ human-readable format with inline display for simple values and small
+ collections. This is the default behavior.
+* json - AWS service errors are formatted as JSON, showing all modeled fields
+ in the error response.
+* yaml - AWS service errors are formatted as YAML, showing all modeled fields
+ in the error response.
+* text - AWS service errors are formatted as text with key-value pairs, showing
+ all modeled fields in the error response.
+* table - AWS service errors are formatted as a table, showing all modeled
+ fields in the error response.
+* legacy - AWS service errors are written to stderr as unstructured text,
+ displaying only the error code and message without additional modeled fields.
``cli_timestamp_format`` controls the format of timestamps displayed by the AWS CLI.
The valid values of the ``cli_timestamp_format`` configuration variable are:
diff --git a/tests/functional/autocomplete/test_completer.py b/tests/functional/autocomplete/test_completer.py
index 0e9e5dbc2a4c..568d38566509 100644
--- a/tests/functional/autocomplete/test_completer.py
+++ b/tests/functional/autocomplete/test_completer.py
@@ -483,7 +483,7 @@ def test_return_suggestions_for_global_arg_with_choices(self):
suggestions = self.completer.complete(parsed)
names = [s.name for s in suggestions]
self.assertEqual(
- names, ['json', 'text', 'table', 'yaml', 'yaml-stream']
+ names, ['json', 'text', 'table', 'yaml', 'yaml-stream', 'off']
)
def test_not_return_suggestions_for_global_arg_wo_trailing_space(self):
diff --git a/tests/unit/test_formatter.py b/tests/unit/test_formatter.py
index db24f1946754..f4f35a5677c5 100644
--- a/tests/unit/test_formatter.py
+++ b/tests/unit/test_formatter.py
@@ -19,7 +19,12 @@
from botocore.paginate import PageIterator
from awscli.compat import StringIO, contextlib
-from awscli.formatter import JSONFormatter, StreamedYAMLFormatter, YAMLDumper
+from awscli.formatter import (
+ JSONFormatter,
+ OffFormatter,
+ StreamedYAMLFormatter,
+ YAMLDumper,
+)
from awscli.testutils import mock, unittest
@@ -180,3 +185,44 @@ def test_encoding_override(self, env_vars):
'}\n'
).encode()
)
+
+
+class TestOffFormatter:
+ def setup_method(self):
+ self.args = Namespace(query=None)
+ self.formatter = OffFormatter(self.args)
+ self.output = StringIO()
+
+ def test_suppresses_simple_response(self):
+ response = {'Key': 'Value'}
+ self.formatter('test-command', response, self.output)
+ assert self.output.getvalue() == ''
+
+ def test_suppresses_complex_response(self):
+ response = {
+ 'Items': [
+ {'Name': 'Item1', 'Value': 'data'},
+ {'Name': 'Item2', 'Value': 'more-data'}
+ ],
+ 'Count': 2
+ }
+ self.formatter('test-command', response, self.output)
+ assert self.output.getvalue() == ''
+
+ def test_suppresses_empty_response(self):
+ response = {}
+ self.formatter('test-command', response, self.output)
+ assert self.output.getvalue() == ''
+
+ def test_suppresses_paginated_response(self):
+ response = FakePageIterator([
+ {'Items': ['Item1']},
+ {'Items': ['Item2']}
+ ])
+ self.formatter('test-command', response, self.output)
+ assert self.output.getvalue() == ''
+
+ def test_works_without_stream(self):
+ response = {'Key': 'Value'}
+ # Should not raise an exception
+ self.formatter('test-command', response, None)
diff --git a/tests/unit/test_structured_error.py b/tests/unit/test_structured_error.py
new file mode 100644
index 000000000000..cdd811daa550
--- /dev/null
+++ b/tests/unit/test_structured_error.py
@@ -0,0 +1,577 @@
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"). You
+# may not use this file except in compliance with the License. A copy of
+# the License is located at
+#
+# http://aws.amazon.com/apache2.0/
+#
+# or in the "license" file accompanying this file. This file 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 argparse
+import io
+from unittest import mock
+
+from botocore.exceptions import ClientError
+
+from awscli.constants import CLIENT_ERROR_RC
+from awscli.errorhandler import (
+ ClientErrorHandler,
+ EnhancedErrorFormatter,
+ construct_cli_error_handlers_chain,
+)
+from tests.unit.test_clidriver import FakeSession
+
+
+class TestClientErrorHandler:
+ def setup_method(self):
+ self.session = FakeSession()
+ self.handler = ClientErrorHandler(self.session)
+
+ def test_extract_error_response_from_client_error(self):
+ error_response = {
+ 'Error': {
+ 'Code': 'NoSuchBucket',
+ 'Message': 'The specified bucket does not exist',
+ 'BucketName': 'my-bucket',
+ },
+ 'ResponseMetadata': {'RequestId': '123'},
+ }
+ client_error = ClientError(error_response, 'GetObject')
+
+ result = ClientErrorHandler._extract_error_response(client_error)
+
+ assert result is not None
+ assert 'Error' in result
+ assert result['Error']['Code'] == 'NoSuchBucket'
+ assert result['Error']['BucketName'] == 'my-bucket'
+
+ def test_extract_error_response_with_top_level_fields(self):
+ error_response = {
+ 'Error': {
+ 'Code': 'TransactionCanceledException',
+ 'Message': 'Transaction cancelled',
+ },
+ 'CancellationReasons': [
+ {
+ 'Code': 'ConditionalCheckFailed',
+ 'Message': 'The conditional request failed',
+ 'Item': {'id': {'S': '123'}},
+ }
+ ],
+ 'ResponseMetadata': {'RequestId': '456'},
+ }
+ client_error = ClientError(error_response, 'TransactWriteItems')
+
+ result = ClientErrorHandler._extract_error_response(client_error)
+
+ assert result is not None
+ assert 'Error' in result
+ assert result['Error']['Code'] == 'TransactionCanceledException'
+ assert 'CancellationReasons' in result['Error']
+ assert len(result['Error']['CancellationReasons']) == 1
+ assert (
+ result['Error']['CancellationReasons'][0]['Code']
+ == 'ConditionalCheckFailed'
+ )
+
+ def test_extract_error_response_from_non_client_error(self):
+ result = ClientErrorHandler._extract_error_response(
+ ValueError('Some error')
+ )
+ assert result is None
+
+ def test_displays_structured_error_with_additional_members(self):
+ error_response = {
+ 'Error': {
+ 'Code': 'NoSuchBucket',
+ 'Message': 'Error',
+ 'BucketName': 'my-bucket',
+ },
+ 'ResponseMetadata': {'RequestId': '123'},
+ }
+ client_error = ClientError(error_response, 'GetObject')
+
+ stdout = io.StringIO()
+ stderr = io.StringIO()
+
+ rc = self.handler.handle_exception(client_error, stdout, stderr)
+
+ assert rc == CLIENT_ERROR_RC
+ stderr_output = stderr.getvalue()
+ assert 'NoSuchBucket' in stderr_output
+ assert 'my-bucket' in stderr_output
+ assert 'BucketName' in stderr_output
+
+ def test_displays_standard_error_without_additional_members(self):
+ error_response = {
+ 'Error': {
+ 'Code': 'AccessDenied',
+ 'Message': 'Access Denied',
+ },
+ 'ResponseMetadata': {'RequestId': '123'},
+ }
+ client_error = ClientError(error_response, 'GetObject')
+
+ stdout = io.StringIO()
+ stderr = io.StringIO()
+
+ rc = self.handler.handle_exception(client_error, stdout, stderr)
+
+ assert rc == CLIENT_ERROR_RC
+ stderr_output = stderr.getvalue()
+ assert 'AccessDenied' in stderr_output
+ assert 'Additional error details' not in stderr_output
+
+ def test_respects_legacy_format_config(self):
+ error_response = {
+ 'Error': {
+ 'Code': 'NoSuchBucket',
+ 'Message': 'Error',
+ 'BucketName': 'test',
+ },
+ 'ResponseMetadata': {'RequestId': '123'},
+ }
+ client_error = ClientError(error_response, 'GetObject')
+
+ self.session.session_vars['cli_error_format'] = 'legacy'
+
+ stdout = io.StringIO()
+ stderr = io.StringIO()
+
+ rc = self.handler.handle_exception(client_error, stdout, stderr)
+
+ assert rc == CLIENT_ERROR_RC
+ stderr_output = stderr.getvalue()
+ assert 'NoSuchBucket' in stderr_output
+ assert 'BucketName' not in stderr_output
+
+ def test_error_format_case_insensitive(self):
+ """Test that error format config is case-insensitive."""
+ error_response = {
+ 'Error': {
+ 'Code': 'NoSuchBucket',
+ 'Message': 'Error',
+ 'BucketName': 'test',
+ },
+ 'ResponseMetadata': {'RequestId': '123'},
+ }
+ client_error = ClientError(error_response, 'GetObject')
+
+ self.session.config_store.set_config_provider(
+ 'cli_error_format', mock.Mock(provide=lambda: 'Enhanced')
+ )
+
+ stdout = io.StringIO()
+ stderr = io.StringIO()
+
+ rc = self.handler.handle_exception(client_error, stdout, stderr)
+
+ assert rc == CLIENT_ERROR_RC
+ stderr_output = stderr.getvalue()
+ assert 'NoSuchBucket' in stderr_output
+ assert 'test' in stderr_output
+
+
+class TestEnhancedErrorFormatter:
+ def setup_method(self):
+ self.formatter = EnhancedErrorFormatter()
+
+ def test_is_simple_value(self):
+ assert self.formatter._is_simple_value('string')
+ assert self.formatter._is_simple_value(42)
+ assert self.formatter._is_simple_value(3.14)
+ assert self.formatter._is_simple_value(True)
+ assert self.formatter._is_simple_value(None)
+ assert not self.formatter._is_simple_value([1, 2, 3])
+ assert not self.formatter._is_simple_value({'key': 'value'})
+
+ def test_is_small_collection_list(self):
+ assert self.formatter._is_small_collection(['a', 'b'])
+ assert self.formatter._is_small_collection([1, 2, 3, 4])
+
+ assert not self.formatter._is_small_collection([1, 2, 3, 4, 5])
+
+ assert not self.formatter._is_small_collection([1, [2, 3]])
+ assert not self.formatter._is_small_collection([{'key': 'value'}])
+
+ def test_is_small_collection_dict(self):
+ assert self.formatter._is_small_collection({'key': 'value'})
+ assert self.formatter._is_small_collection(
+ {'a': 1, 'b': 2, 'c': 3, 'd': 4}
+ )
+
+ assert not self.formatter._is_small_collection(
+ {'a': 1, 'b': 2, 'c': 3, 'd': 4, 'e': 5}
+ )
+
+ assert not self.formatter._is_small_collection({'key': [1, 2]})
+ assert not self.formatter._is_small_collection({'key': {'nested': 1}})
+
+ def test_format_inline_list(self):
+ result = self.formatter._format_inline([1, 2, 3])
+ assert result == '[1, 2, 3]'
+
+ result = self.formatter._format_inline(['a', 'b', 'c'])
+ assert result == '[a, b, c]'
+
+ def test_format_inline_dict(self):
+ result = self.formatter._format_inline({'a': 1, 'b': 2})
+ assert 'a: 1' in result
+ assert 'b: 2' in result
+ assert result.startswith('{')
+ assert result.endswith('}')
+
+ def test_get_additional_fields(self):
+ error_info = {
+ 'Code': 'NoSuchBucket',
+ 'Message': 'The bucket does not exist',
+ 'BucketName': 'my-bucket',
+ 'Region': 'us-east-1',
+ }
+
+ additional = self.formatter._get_additional_fields(error_info)
+ assert 'Code' not in additional
+ assert 'Message' not in additional
+ assert additional['BucketName'] == 'my-bucket'
+ assert additional['Region'] == 'us-east-1'
+
+ def test_format_error_with_no_additional_fields(self):
+ error_info = {
+ 'Code': 'AccessDenied',
+ 'Message': 'Access Denied',
+ }
+ formatted_message = 'An error occurred (AccessDenied): Access Denied'
+
+ stream = io.StringIO()
+ self.formatter.format_error(error_info, formatted_message, stream)
+
+ output = stream.getvalue()
+ assert formatted_message in output
+ assert 'Additional error details' not in output
+
+ def test_format_error_with_simple_fields(self):
+ error_info = {
+ 'Code': 'NoSuchBucket',
+ 'Message': 'The bucket does not exist',
+ 'BucketName': 'my-bucket',
+ 'Region': 'us-east-1',
+ }
+ formatted_message = (
+ 'An error occurred (NoSuchBucket): The bucket does not exist'
+ )
+
+ stream = io.StringIO()
+ self.formatter.format_error(error_info, formatted_message, stream)
+
+ output = stream.getvalue()
+ assert formatted_message in output
+ assert 'Additional error details' in output
+ assert 'BucketName: my-bucket' in output
+ assert 'Region: us-east-1' in output
+
+ def test_format_error_with_small_list(self):
+ error_info = {
+ 'Code': 'ValidationError',
+ 'Message': 'Validation failed',
+ 'AllowedValues': ['value1', 'value2', 'value3'],
+ }
+ formatted_message = (
+ 'An error occurred (ValidationError): Validation failed'
+ )
+
+ stream = io.StringIO()
+ self.formatter.format_error(error_info, formatted_message, stream)
+
+ output = stream.getvalue()
+ assert formatted_message in output
+ assert 'Additional error details' in output
+ assert 'AllowedValues: [value1, value2, value3]' in output
+
+ def test_format_error_with_small_dict(self):
+ error_info = {
+ 'Code': 'ValidationError',
+ 'Message': 'Validation failed',
+ 'Metadata': {'key1': 'value1', 'key2': 'value2'},
+ }
+ formatted_message = (
+ 'An error occurred (ValidationError): Validation failed'
+ )
+
+ stream = io.StringIO()
+ self.formatter.format_error(error_info, formatted_message, stream)
+
+ output = stream.getvalue()
+ assert formatted_message in output
+ assert 'Additional error details' in output
+ assert 'Metadata:' in output
+ assert 'key1: value1' in output
+ assert 'key2: value2' in output
+
+ def test_format_error_with_complex_object(self):
+ error_info = {
+ 'Code': 'ValidationError',
+ 'Message': 'Validation failed',
+ 'Details': [1, 2, 3, 4, 5, 6],
+ }
+ formatted_message = (
+ 'An error occurred (ValidationError): Validation failed'
+ )
+
+ stream = io.StringIO()
+ self.formatter.format_error(error_info, formatted_message, stream)
+
+ output = stream.getvalue()
+ assert formatted_message in output
+ assert 'Additional error details' in output
+ assert 'Details: ' in output
+ assert '--cli-error-format with json or yaml' in output
+
+ def test_format_error_with_nested_dict(self):
+ """Test formatting with nested dictionary structures."""
+ error_info = {
+ 'Code': 'ValidationError',
+ 'Message': 'Validation failed',
+ 'FieldErrors': {
+ 'email': {'pattern': 'invalid', 'required': True},
+ 'age': {'min': 0, 'max': 120},
+ },
+ }
+ formatted_message = (
+ 'An error occurred (ValidationError): Validation failed'
+ )
+
+ stream = io.StringIO()
+ self.formatter.format_error(error_info, formatted_message, stream)
+
+ output = stream.getvalue()
+ assert formatted_message in output
+ assert 'FieldErrors: ' in output
+
+ def test_format_error_with_list_of_dicts(self):
+ """Test formatting with list containing dictionaries."""
+ error_info = {
+ 'Code': 'TransactionCanceledException',
+ 'Message': 'Transaction cancelled',
+ 'CancellationReasons': [
+ {'Code': 'ConditionalCheckFailed', 'Message': 'Check failed'},
+ {'Code': 'ItemCollectionSizeLimitExceeded', 'Message': 'Too large'},
+ ],
+ }
+ formatted_message = (
+ 'An error occurred (TransactionCanceledException): '
+ 'Transaction cancelled'
+ )
+
+ stream = io.StringIO()
+ self.formatter.format_error(error_info, formatted_message, stream)
+
+ output = stream.getvalue()
+ assert formatted_message in output
+ assert 'CancellationReasons: ' in output
+
+ def test_format_error_with_mixed_types(self):
+ """Test formatting with various data types."""
+ error_info = {
+ 'Code': 'ComplexError',
+ 'Message': 'Complex error occurred',
+ 'StringField': 'test-value',
+ 'IntField': 42,
+ 'FloatField': 3.14,
+ 'BoolField': True,
+ 'NoneField': None,
+ }
+ formatted_message = (
+ 'An error occurred (ComplexError): Complex error occurred'
+ )
+
+ stream = io.StringIO()
+ self.formatter.format_error(error_info, formatted_message, stream)
+
+ output = stream.getvalue()
+ assert formatted_message in output
+ assert 'Additional error details' in output
+ assert 'StringField: test-value' in output
+ assert 'IntField: 42' in output
+ assert 'FloatField: 3.14' in output
+ assert 'BoolField: True' in output
+ assert 'NoneField: None' in output
+
+ def test_format_error_with_unicode_and_special_chars(self):
+ """Test formatting with unicode and special characters."""
+ error_info = {
+ 'Code': 'InvalidInput',
+ 'Message': 'Invalid input provided',
+ 'UserName': 'José García',
+ 'Description': 'Error with "quotes" and \'apostrophes\'',
+ 'Path': '/path/to/file.txt',
+ }
+ formatted_message = (
+ 'An error occurred (InvalidInput): Invalid input provided'
+ )
+
+ stream = io.StringIO()
+ self.formatter.format_error(error_info, formatted_message, stream)
+
+ output = stream.getvalue()
+ assert formatted_message in output
+ assert 'José García' in output
+ assert 'quotes' in output
+ assert 'apostrophes' in output
+
+ def test_format_error_with_large_list(self):
+ """Test that large lists are marked as complex."""
+ error_info = {
+ 'Code': 'LargeList',
+ 'Message': 'Large list error',
+ 'Items': list(range(10)),
+ }
+ formatted_message = (
+ 'An error occurred (LargeList): Large list error'
+ )
+
+ stream = io.StringIO()
+ self.formatter.format_error(error_info, formatted_message, stream)
+
+ output = stream.getvalue()
+ assert formatted_message in output
+ assert 'Items: ' in output
+
+
+class TestRealWorldErrorScenarios:
+ """Test real-world AWS error response structures."""
+
+ def setup_method(self):
+ self.session = FakeSession()
+ self.handler = ClientErrorHandler(self.session)
+
+ def test_dynamodb_transaction_cancelled_error(self):
+ """Test DynamoDB TransactionCanceledException with CancellationReasons."""
+ error_response = {
+ 'Error': {
+ 'Code': 'TransactionCanceledException',
+ 'Message': (
+ 'Transaction cancelled, please refer to '
+ 'CancellationReasons for specific reasons'
+ ),
+ },
+ 'CancellationReasons': [
+ {
+ 'Code': 'ConditionalCheckFailed',
+ 'Message': 'The conditional request failed',
+ 'Item': {
+ 'id': {'S': 'item-123'},
+ 'status': {'S': 'active'},
+ },
+ },
+ {
+ 'Code': 'None',
+ 'Message': None,
+ },
+ ],
+ 'ResponseMetadata': {
+ 'RequestId': 'abc-123',
+ 'HTTPStatusCode': 400,
+ },
+ }
+ client_error = ClientError(error_response, 'TransactWriteItems')
+
+ stdout = io.StringIO()
+ stderr = io.StringIO()
+
+ rc = self.handler.handle_exception(client_error, stdout, stderr)
+
+ assert rc == CLIENT_ERROR_RC
+ stderr_output = stderr.getvalue()
+ assert 'TransactionCanceledException' in stderr_output
+ assert 'CancellationReasons' in stderr_output
+
+ def test_throttling_error_with_retry_info(self):
+ """Test throttling error with retry information."""
+ error_response = {
+ 'Error': {
+ 'Code': 'ThrottlingException',
+ 'Message': 'Rate exceeded',
+ },
+ 'RetryAfterSeconds': 30,
+ 'RequestsPerSecond': 100,
+ 'CurrentRate': 150,
+ 'ResponseMetadata': {'RequestId': 'throttle-123'},
+ }
+ client_error = ClientError(error_response, 'DescribeInstances')
+
+ stdout = io.StringIO()
+ stderr = io.StringIO()
+
+ rc = self.handler.handle_exception(client_error, stdout, stderr)
+
+ assert rc == CLIENT_ERROR_RC
+ stderr_output = stderr.getvalue()
+ assert 'ThrottlingException' in stderr_output
+ assert '30' in stderr_output
+ assert '100' in stderr_output
+
+
+class TestParsedGlobalsPassthrough:
+ def test_error_handler_receives_parsed_globals_from_clidriver(self):
+ session = FakeSession()
+
+ parsed_globals = argparse.Namespace()
+ parsed_globals.cli_error_format = 'json'
+ parsed_globals.command = 's3'
+ parsed_globals.color = 'auto'
+
+ error_handler = construct_cli_error_handlers_chain(
+ session, parsed_globals
+ )
+
+ error_response = {
+ 'Error': {
+ 'Code': 'NoSuchBucket',
+ 'Message': 'The specified bucket does not exist',
+ 'BucketName': 'test-bucket',
+ },
+ 'ResponseMetadata': {'RequestId': '123'},
+ }
+ client_error = ClientError(error_response, 'GetObject')
+
+ stdout = io.StringIO()
+ stderr = io.StringIO()
+
+ rc = error_handler.handle_exception(client_error, stdout, stderr)
+
+ assert rc == CLIENT_ERROR_RC
+
+ stderr_output = stderr.getvalue()
+ assert '"Code"' in stderr_output or '"code"' in stderr_output.lower()
+ assert 'NoSuchBucket' in stderr_output
+ assert 'test-bucket' in stderr_output
+
+ def test_error_handler_without_parsed_globals_uses_default(self):
+ session = FakeSession()
+
+ error_handler = construct_cli_error_handlers_chain(session, None)
+
+ error_response = {
+ 'Error': {
+ 'Code': 'NoSuchBucket',
+ 'Message': 'The specified bucket does not exist',
+ 'BucketName': 'test-bucket',
+ },
+ 'ResponseMetadata': {'RequestId': '123'},
+ }
+ client_error = ClientError(error_response, 'GetObject')
+
+ stdout = io.StringIO()
+ stderr = io.StringIO()
+
+ rc = error_handler.handle_exception(client_error, stdout, stderr)
+
+ assert rc == CLIENT_ERROR_RC
+
+ stderr_output = stderr.getvalue()
+ assert 'NoSuchBucket' in stderr_output
+ assert 'test-bucket' in stderr_output
+ assert 'BucketName' in stderr_output