From 1afe452136b4e67162a2cd3684507a3392946ffd Mon Sep 17 00:00:00 2001 From: Andrew Asseily Date: Mon, 6 Oct 2025 11:19:26 -0400 Subject: [PATCH 01/11] Structured errors draft - failing tests --- awscli/clidriver.py | 21 +++++++++- awscli/data/cli.json | 11 +++++- awscli/errorhandler.py | 61 ++++++++++++++++++++++++++++- awscli/examples/global_options.rst | 12 ++++++ awscli/examples/global_synopsis.rst | 1 + tests/unit/test_clidriver.py | 5 +++ 6 files changed, 107 insertions(+), 4 deletions(-) diff --git a/awscli/clidriver.py b/awscli/clidriver.py index ae8a3af68845..bd2f8f824ae0 100644 --- a/awscli/clidriver.py +++ b/awscli/clidriver.py @@ -119,7 +119,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=session) driver = CLIDriver( session=session, error_handler=error_handlers_chain, debug=debug ) @@ -275,6 +275,10 @@ 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 +372,20 @@ def _construct_cli_auto_prompt_chain(self): ] return ChainProvider(providers=providers) + def _construct_cli_error_format_chain(self): + providers = [ + InstanceVarProvider( + instance_var='cli_error_format', + session=self.session, + ), + ScopedConfigProvider( + config_var_name='cli_error_format', + session=self.session, + ), + ConstantProvider(value='standard'), + ] + return ChainProvider(providers=providers) + @property def subcommand_table(self): return self._get_command_table() @@ -386,6 +404,7 @@ def _get_cli_data(self): # we load it here once. if self._cli_data is None: self._cli_data = self.session.get_data('cli') + print("CLI Data:", self._cli_data) return self._cli_data def _get_command_table(self): diff --git a/awscli/data/cli.json b/awscli/data/cli.json index 27d32410393f..d3ea070c2c53 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,14 @@ "no-cli-auto-prompt": { "action": "store_true", "help": "

Disable automatically prompt for CLI input parameters.

" + }, + "cli-error-format": { + "choices": [ + "standard", + "legacy" + ], + "default": "standard", + "help": "

The formatting style for error output. 'standard' displays modeled error details, 'legacy' shows only the traditional error message.

" } } } diff --git a/awscli/errorhandler.py b/awscli/errorhandler.py index 954b005f8a0e..2d89ffa479de 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 json import logging import signal @@ -51,7 +52,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): handlers = [ ParamValidationErrorsHandler(), UnknownArgumentErrorHandler(), @@ -60,7 +61,7 @@ def construct_cli_error_handlers_chain(): NoCredentialsErrorHandler(), PagerErrorHandler(), InterruptExceptionHandler(), - ClientErrorHandler(), + ClientErrorHandler(session=session), GeneralExceptionHandler(), ] return ChainedExceptionHandler(exception_handlers=handlers) @@ -108,6 +109,62 @@ class ClientErrorHandler(FilteredExceptionHandler): EXCEPTIONS_TO_HANDLE = ClientError RC = CLIENT_ERROR_RC + def __init__(self, session=None): + self._session = session + + def _do_handle_exception(self, exception, stdout, stderr): + if self._should_display_error_details(): + self._display_error_details(exception, stdout) + + stderr.write("\n") + stderr.write(str(exception)) + stderr.write("\n") + + return self.RC + + def _should_display_error_details(self): + if not self._session: + return True + + output_format = self._session.get_config_variable('output') + if output_format == 'off': + return False + + error_format = self._session.get_config_variable('cli_error_format') + return error_format != 'legacy' + + def _display_error_details(self, exception, stdout): + if not hasattr(exception, 'response') or not exception.response: + return + + error_details = exception.response.get('Error', {}) + if not error_details: + return + + output_format = 'json' + if self._session: + output_format = self._session.get_config_variable('output') or 'json' + + try: + if output_format == 'yaml': + self._display_yaml_error(error_details, stdout) + else: + json.dump(error_details, stdout, indent=4, ensure_ascii=False) + stdout.write('\n') + except (OSError, UnicodeError, TypeError) as e: + LOG.debug("Error formatting failed: %s", e) + + def _display_yaml_error(self, error_details, stdout): + try: + from ruamel.yaml import YAML + yaml = YAML(typ='safe') + yaml.encoding = None + yaml.representer.default_flow_style = False + yaml.dump(error_details, stdout) + except ImportError: + json.dump(error_details, stdout, indent=4, ensure_ascii=False) + stdout.write('\n') + class ConfigurationErrorHandler(FilteredExceptionHandler): EXCEPTIONS_TO_HANDLE = ConfigurationError diff --git a/awscli/examples/global_options.rst b/awscli/examples/global_options.rst index cec1f5529736..b673edff3539 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,13 @@ Disable automatically prompt for CLI input parameters. +``--cli-error-format`` (string) + + The formatting style for error output. 'standard' displays modeled error details, 'legacy' shows only the traditional error message. + + + * standard + + * legacy + + 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/tests/unit/test_clidriver.py b/tests/unit/test_clidriver.py index 2140afae02f1..6214d07b1ce6 100644 --- a/tests/unit/test_clidriver.py +++ b/tests/unit/test_clidriver.py @@ -79,6 +79,11 @@ }, "read-timeout": {"type": "int", "help": ""}, "connect-timeout": {"type": "int", "help": ""}, + "cli-error-format": { + "choices": ["standard", "legacy"], + "default": "standard", + "help": "The formatting style for error output." + } }, }, } From 6842e4ef5809033db873b502676938473a78951c Mon Sep 17 00:00:00 2001 From: Andrew Asseily Date: Fri, 24 Oct 2025 14:38:42 -0400 Subject: [PATCH 02/11] Revert "Structured errors draft - failing tests" This reverts commit 1afe452136b4e67162a2cd3684507a3392946ffd. --- awscli/clidriver.py | 21 +--------- awscli/data/cli.json | 11 +----- awscli/errorhandler.py | 61 +---------------------------- awscli/examples/global_options.rst | 12 ------ awscli/examples/global_synopsis.rst | 1 - tests/unit/test_clidriver.py | 5 --- 6 files changed, 4 insertions(+), 107 deletions(-) diff --git a/awscli/clidriver.py b/awscli/clidriver.py index bd2f8f824ae0..ae8a3af68845 100644 --- a/awscli/clidriver.py +++ b/awscli/clidriver.py @@ -119,7 +119,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(session=session) + error_handlers_chain = construct_cli_error_handlers_chain() driver = CLIDriver( session=session, error_handler=error_handlers_chain, debug=debug ) @@ -275,10 +275,6 @@ 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 = [ @@ -372,20 +368,6 @@ def _construct_cli_auto_prompt_chain(self): ] return ChainProvider(providers=providers) - def _construct_cli_error_format_chain(self): - providers = [ - InstanceVarProvider( - instance_var='cli_error_format', - session=self.session, - ), - ScopedConfigProvider( - config_var_name='cli_error_format', - session=self.session, - ), - ConstantProvider(value='standard'), - ] - return ChainProvider(providers=providers) - @property def subcommand_table(self): return self._get_command_table() @@ -404,7 +386,6 @@ def _get_cli_data(self): # we load it here once. if self._cli_data is None: self._cli_data = self.session.get_data('cli') - print("CLI Data:", self._cli_data) return self._cli_data def _get_command_table(self): diff --git a/awscli/data/cli.json b/awscli/data/cli.json index d3ea070c2c53..27d32410393f 100644 --- a/awscli/data/cli.json +++ b/awscli/data/cli.json @@ -26,8 +26,7 @@ "text", "table", "yaml", - "yaml-stream", - "off" + "yaml-stream" ], "help": "

The formatting style for command output.

" }, @@ -86,14 +85,6 @@ "no-cli-auto-prompt": { "action": "store_true", "help": "

Disable automatically prompt for CLI input parameters.

" - }, - "cli-error-format": { - "choices": [ - "standard", - "legacy" - ], - "default": "standard", - "help": "

The formatting style for error output. 'standard' displays modeled error details, 'legacy' shows only the traditional error message.

" } } } diff --git a/awscli/errorhandler.py b/awscli/errorhandler.py index 2d89ffa479de..954b005f8a0e 100644 --- a/awscli/errorhandler.py +++ b/awscli/errorhandler.py @@ -10,7 +10,6 @@ # 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 json import logging import signal @@ -52,7 +51,7 @@ def construct_entry_point_handlers_chain(): return ChainedExceptionHandler(exception_handlers=handlers) -def construct_cli_error_handlers_chain(session=None): +def construct_cli_error_handlers_chain(): handlers = [ ParamValidationErrorsHandler(), UnknownArgumentErrorHandler(), @@ -61,7 +60,7 @@ def construct_cli_error_handlers_chain(session=None): NoCredentialsErrorHandler(), PagerErrorHandler(), InterruptExceptionHandler(), - ClientErrorHandler(session=session), + ClientErrorHandler(), GeneralExceptionHandler(), ] return ChainedExceptionHandler(exception_handlers=handlers) @@ -109,62 +108,6 @@ class ClientErrorHandler(FilteredExceptionHandler): EXCEPTIONS_TO_HANDLE = ClientError RC = CLIENT_ERROR_RC - def __init__(self, session=None): - self._session = session - - def _do_handle_exception(self, exception, stdout, stderr): - if self._should_display_error_details(): - self._display_error_details(exception, stdout) - - stderr.write("\n") - stderr.write(str(exception)) - stderr.write("\n") - - return self.RC - - def _should_display_error_details(self): - if not self._session: - return True - - output_format = self._session.get_config_variable('output') - if output_format == 'off': - return False - - error_format = self._session.get_config_variable('cli_error_format') - return error_format != 'legacy' - - def _display_error_details(self, exception, stdout): - if not hasattr(exception, 'response') or not exception.response: - return - - error_details = exception.response.get('Error', {}) - if not error_details: - return - - output_format = 'json' - if self._session: - output_format = self._session.get_config_variable('output') or 'json' - - try: - if output_format == 'yaml': - self._display_yaml_error(error_details, stdout) - else: - json.dump(error_details, stdout, indent=4, ensure_ascii=False) - stdout.write('\n') - except (OSError, UnicodeError, TypeError) as e: - LOG.debug("Error formatting failed: %s", e) - - def _display_yaml_error(self, error_details, stdout): - try: - from ruamel.yaml import YAML - yaml = YAML(typ='safe') - yaml.encoding = None - yaml.representer.default_flow_style = False - yaml.dump(error_details, stdout) - except ImportError: - json.dump(error_details, stdout, indent=4, ensure_ascii=False) - stdout.write('\n') - class ConfigurationErrorHandler(FilteredExceptionHandler): EXCEPTIONS_TO_HANDLE = ConfigurationError diff --git a/awscli/examples/global_options.rst b/awscli/examples/global_options.rst index b673edff3539..cec1f5529736 100644 --- a/awscli/examples/global_options.rst +++ b/awscli/examples/global_options.rst @@ -29,8 +29,6 @@ * yaml-stream - * off - ``--query`` (string) @@ -98,13 +96,3 @@ Disable automatically prompt for CLI input parameters. -``--cli-error-format`` (string) - - The formatting style for error output. 'standard' displays modeled error details, 'legacy' shows only the traditional error message. - - - * standard - - * legacy - - diff --git a/awscli/examples/global_synopsis.rst b/awscli/examples/global_synopsis.rst index 3e603348debb..1ca332c717ff 100644 --- a/awscli/examples/global_synopsis.rst +++ b/awscli/examples/global_synopsis.rst @@ -16,4 +16,3 @@ [--no-cli-pager] [--cli-auto-prompt] [--no-cli-auto-prompt] -[--cli-error-format ] diff --git a/tests/unit/test_clidriver.py b/tests/unit/test_clidriver.py index 6214d07b1ce6..2140afae02f1 100644 --- a/tests/unit/test_clidriver.py +++ b/tests/unit/test_clidriver.py @@ -79,11 +79,6 @@ }, "read-timeout": {"type": "int", "help": ""}, "connect-timeout": {"type": "int", "help": ""}, - "cli-error-format": { - "choices": ["standard", "legacy"], - "default": "standard", - "help": "The formatting style for error output." - } }, }, } From bcc33fcb3f940a6431b10545826baae22f6c480a Mon Sep 17 00:00:00 2001 From: Andrew Asseily Date: Fri, 14 Nov 2025 13:51:26 -0500 Subject: [PATCH 03/11] Structured Errors implementation Draft 1 --- awscli/clidriver.py | 91 +++++++++++++-- awscli/structured_error.py | 117 +++++++++++++++++++ tests/unit/test_structured_error.py | 168 ++++++++++++++++++++++++++++ 3 files changed, 367 insertions(+), 9 deletions(-) create mode 100644 awscli/structured_error.py create mode 100644 tests/unit/test_structured_error.py diff --git a/awscli/clidriver.py b/awscli/clidriver.py index ae8a3af68845..b319864d80d8 100644 --- a/awscli/clidriver.py +++ b/awscli/clidriver.py @@ -76,6 +76,7 @@ ) from awscli.plugin import load_plugins from awscli.telemetry import add_session_id_component_to_user_agent_extra +from awscli.structured_error import StructuredErrorHandler from awscli.utils import ( IMDSRegionProvider, OutputStreamFactory, @@ -275,6 +276,12 @@ 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() + ) + config_store.set_config_provider( + 'cli_hide_error_details', self._construct_cli_hide_error_details_chain() + ) def _construct_cli_region_chain(self): providers = [ @@ -368,6 +375,34 @@ 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='STANDARD'), + ] + return ChainProvider(providers=providers) + + def _construct_cli_hide_error_details_chain(self): + providers = [ + EnvironmentProvider( + name='AWS_CLI_HIDE_ERROR_DETAILS', + env=os.environ, + ), + ScopedConfigProvider( + config_var_name='cli_hide_error_details', + session=self.session, + ), + ConstantProvider(value='false'), + ] + return ChainProvider(providers=providers) + @property def subcommand_table(self): return self._get_command_table() @@ -983,6 +1018,9 @@ class CLIOperationCaller: def __init__(self, session): self._session = session self._output_stream_factory = OutputStreamFactory(session) + self._structured_error_handler = StructuredErrorHandler( + session, self._output_stream_factory + ) def invoke(self, service_name, operation_name, parameters, parsed_globals): """Invoke an operation and format the response. @@ -1023,15 +1061,50 @@ def invoke(self, service_name, operation_name, parameters, parsed_globals): def _make_client_call( self, client, operation_name, parameters, parsed_globals ): - py_operation_name = xform_name(operation_name) - if client.can_paginate(py_operation_name) and parsed_globals.paginate: - paginator = client.get_paginator(py_operation_name) - response = paginator.paginate(**parameters) - else: - response = getattr(client, xform_name(operation_name))( - **parameters - ) - return response + service_id = client._service_model.service_id.hyphenize() + operation_model = client._service_model.operation_model( + operation_name + ) + + event_name = f'after-call-error.{service_id}.{operation_model.name}' + + def error_handler(**kwargs): + try: + exception = kwargs.get('exception') + if exception: + handler = self._structured_error_handler + error_response = handler.extract_error_response( + exception + ) + if error_response: + handler.handle_error( + error_response, parsed_globals + ) + except Exception as e: + # Don't let structured error display break error handling + LOG.debug( + 'Failed to display structured error: %s', + e, + exc_info=True, + ) + + client.meta.events.register(event_name, error_handler) + + try: + py_operation_name = xform_name(operation_name) + if ( + client.can_paginate(py_operation_name) + and parsed_globals.paginate + ): + paginator = client.get_paginator(py_operation_name) + response = paginator.paginate(**parameters) + else: + response = getattr(client, xform_name(operation_name))( + **parameters + ) + return response + finally: + client.meta.events.unregister(event_name, error_handler) def _display_response(self, command_name, response, parsed_globals): output = parsed_globals.output diff --git a/awscli/structured_error.py b/awscli/structured_error.py new file mode 100644 index 000000000000..4be6136825fe --- /dev/null +++ b/awscli/structured_error.py @@ -0,0 +1,117 @@ +# Copyright 2012-2013 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 logging + +from botocore.exceptions import ClientError + +from awscli.formatter import get_formatter + + +LOG = logging.getLogger('awscli.structured_error') + + +class StructuredErrorHandler: + """Handles display of structured error information from AWS services. + + This class is responsible for determining when to display structured + error output and formatting it appropriately based on user configuration + and output format settings. + """ + + def __init__(self, session, output_stream_factory): + self._session = session + self._output_stream_factory = output_stream_factory + + def handle_error(self, error_response, parsed_globals): + error_info = error_response.get('Error', {}) + + if self.should_display(error_info, parsed_globals): + filtered_error_info = self._filter_sensitive_fields(error_info) + self.display(filtered_error_info, parsed_globals) + + def should_display(self, error_response, parsed_globals): + if not self._has_additional_error_members(error_response): + return False + + config_store = self._session.get_component('config_store') + hide_details = config_store.get_config_variable( + 'cli_hide_error_details' + ) + try: + if isinstance(hide_details, str): + hide_details = hide_details.lower() != 'false' + else: + hide_details = bool(hide_details) if hide_details else False + except (AttributeError, ValueError): + hide_details = False + + if hide_details: + return False + + error_format = config_store.get_config_variable('cli_error_format') + if error_format == 'LEGACY': + return False + + output = parsed_globals.output + if output is None: + output = self._session.get_config_variable('output') + if output == 'off': + return False + + return True + + def display(self, error_response, parsed_globals): + output = parsed_globals.output + if output is None: + output = self._session.get_config_variable('output') + + try: + formatter = get_formatter(output, parsed_globals) + + with self._output_stream_factory.get_output_stream() as stream: + formatter('error', error_response, stream) + except Exception as e: + # Log the error but don't let it prevent the normal error handling + LOG.debug( + "Failed to display structured error output: %s", + e, + exc_info=True, + ) + + def _filter_sensitive_fields(self, error_info): + """Filter sensitive fields from error response before display. + + TODO: Implement sensitive output mitigation to filter fields + marked with the sensitive trait according to AWS CLI sensitive + output mitigation design. + """ + # Currently returns unfiltered + return error_info + + def _has_additional_error_members(self, error_response): + if not error_response: + return False + + standard_keys = {'Code', 'Message'} + error_keys = set(error_response.keys()) + return len(error_keys - standard_keys) > 0 + + @staticmethod + def extract_error_response(exception): + if not isinstance(exception, ClientError): + return None + + if hasattr(exception, 'response') and 'Error' in exception.response: + return {'Error': exception.response['Error']} + + return None diff --git a/tests/unit/test_structured_error.py b/tests/unit/test_structured_error.py new file mode 100644 index 000000000000..65312b797475 --- /dev/null +++ b/tests/unit/test_structured_error.py @@ -0,0 +1,168 @@ +# Copyright 2012-2013 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 io +from unittest import mock + +from botocore.exceptions import ClientError + +from awscli.structured_error import StructuredErrorHandler +from tests.unit.test_clidriver import FakeSession + + +class TestStructuredErrorHandler: + def setup_method(self): + self.session = FakeSession() + from awscli.utils import OutputStreamFactory + + self.output_stream_factory = OutputStreamFactory(self.session) + self.handler = StructuredErrorHandler( + self.session, self.output_stream_factory + ) + + 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 = StructuredErrorHandler.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_from_non_client_error(self): + result = StructuredErrorHandler.extract_error_response( + ValueError('Some error') + ) + assert result is None + + def test_has_additional_error_members(self): + assert self.handler._has_additional_error_members( + {'Code': 'NoSuchBucket', 'Message': 'Error', 'BucketName': 'test'} + ) + + assert not self.handler._has_additional_error_members( + {'Code': 'AccessDenied', 'Message': 'Access Denied'} + ) + + assert not self.handler._has_additional_error_members({}) + assert not self.handler._has_additional_error_members(None) + + def test_should_display_with_additional_members(self): + error_response = { + 'Code': 'NoSuchBucket', + 'Message': 'Error', + 'BucketName': 'my-bucket', + } + parsed_globals = mock.Mock() + parsed_globals.output = 'json' + + assert self.handler.should_display(error_response, parsed_globals) + + def test_should_display_without_additional_members(self): + error_response = {'Code': 'AccessDenied', 'Message': 'Access Denied'} + parsed_globals = mock.Mock() + parsed_globals.output = 'json' + + assert not self.handler.should_display(error_response, parsed_globals) + + def test_should_display_respects_hide_details(self): + error_response = { + 'Code': 'NoSuchBucket', + 'Message': 'Error', + 'BucketName': 'test', + } + parsed_globals = mock.Mock() + parsed_globals.output = 'json' + + self.session.config_store.set_config_provider( + 'cli_hide_error_details', mock.Mock(provide=lambda: True) + ) + + assert not self.handler.should_display(error_response, parsed_globals) + + def test_should_display_respects_legacy_format(self): + error_response = { + 'Code': 'NoSuchBucket', + 'Message': 'Error', + 'BucketName': 'test', + } + parsed_globals = mock.Mock() + parsed_globals.output = 'json' + + self.session.config_store.set_config_provider( + 'cli_error_format', mock.Mock(provide=lambda: 'LEGACY') + ) + + assert not self.handler.should_display(error_response, parsed_globals) + + def test_should_display_respects_output_off(self): + error_response = { + 'Code': 'NoSuchBucket', + 'Message': 'Error', + 'BucketName': 'test', + } + parsed_globals = mock.Mock() + parsed_globals.output = 'off' + + assert not self.handler.should_display(error_response, parsed_globals) + + def test_display_json_format(self): + error_response = { + 'Code': 'NoSuchBucket', + 'Message': 'The specified bucket does not exist', + 'BucketName': 'my-bucket', + } + parsed_globals = mock.Mock() + parsed_globals.output = 'json' + parsed_globals.query = None + + mock_stream = io.StringIO() + mock_context_manager = mock.MagicMock() + mock_context_manager.__enter__.return_value = mock_stream + mock_context_manager.__exit__.return_value = False + + mock_stream_factory = mock.Mock() + mock_stream_factory.get_output_stream.return_value = ( + mock_context_manager + ) + self.handler._output_stream_factory = mock_stream_factory + + self.handler.display(error_response, parsed_globals) + + output = mock_stream.getvalue() + assert 'NoSuchBucket' in output + assert 'my-bucket' in output + + def test_display_handles_exceptions_gracefully(self): + error_response = {'Code': 'SomeError', 'Message': 'An error occurred'} + parsed_globals = mock.Mock() + parsed_globals.output = 'json' + + mock_context_manager = mock.MagicMock() + mock_context_manager.__enter__.side_effect = Exception('Stream error') + + mock_stream_factory = mock.Mock() + mock_stream_factory.get_output_stream.return_value = ( + mock_context_manager + ) + self.handler._output_stream_factory = mock_stream_factory + + self.handler.display(error_response, parsed_globals) From 829968bf852ae2310f93240448bd4a046bf8c4ac Mon Sep 17 00:00:00 2001 From: Andrew Asseily Date: Mon, 17 Nov 2025 10:47:35 -0500 Subject: [PATCH 04/11] Refactor structured error --- awscli/clidriver.py | 63 +++++++++++++---------------- awscli/structured_error.py | 29 +++++++------ tests/unit/test_structured_error.py | 30 ++++++++++++++ 3 files changed, 73 insertions(+), 49 deletions(-) diff --git a/awscli/clidriver.py b/awscli/clidriver.py index b319864d80d8..5a9f5cbb7d94 100644 --- a/awscli/clidriver.py +++ b/awscli/clidriver.py @@ -22,6 +22,7 @@ import distro from botocore import xform_name from botocore.compat import OrderedDict, copy_kwargs +from botocore.exceptions import ClientError from botocore.configprovider import ( ChainProvider, ConstantProvider, @@ -1061,35 +1062,6 @@ def invoke(self, service_name, operation_name, parameters, parsed_globals): def _make_client_call( self, client, operation_name, parameters, parsed_globals ): - service_id = client._service_model.service_id.hyphenize() - operation_model = client._service_model.operation_model( - operation_name - ) - - event_name = f'after-call-error.{service_id}.{operation_model.name}' - - def error_handler(**kwargs): - try: - exception = kwargs.get('exception') - if exception: - handler = self._structured_error_handler - error_response = handler.extract_error_response( - exception - ) - if error_response: - handler.handle_error( - error_response, parsed_globals - ) - except Exception as e: - # Don't let structured error display break error handling - LOG.debug( - 'Failed to display structured error: %s', - e, - exc_info=True, - ) - - client.meta.events.register(event_name, error_handler) - try: py_operation_name = xform_name(operation_name) if ( @@ -1099,12 +1071,35 @@ def error_handler(**kwargs): 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 - finally: - client.meta.events.unregister(event_name, error_handler) + except ClientError as e: + # Display structured error output before re-raising + self._display_structured_error_for_exception( + e, parsed_globals + ) + raise + + def _display_structured_error_for_exception( + self, exception, parsed_globals + ): + try: + error_response = ( + self._structured_error_handler.extract_error_response( + exception + ) + ) + if error_response: + self._structured_error_handler.handle_error( + error_response, parsed_globals + ) + except Exception as e: + # Don't let structured error display break error handling + LOG.debug( + 'Failed to display structured error: %s', + e, + exc_info=True, + ) def _display_response(self, command_name, response, parsed_globals): output = parsed_globals.output diff --git a/awscli/structured_error.py b/awscli/structured_error.py index 4be6136825fe..4e3b5a4ae3f1 100644 --- a/awscli/structured_error.py +++ b/awscli/structured_error.py @@ -39,21 +39,18 @@ def handle_error(self, error_response, parsed_globals): filtered_error_info = self._filter_sensitive_fields(error_info) self.display(filtered_error_info, parsed_globals) - def should_display(self, error_response, parsed_globals): - if not self._has_additional_error_members(error_response): + def should_display(self, error_info, parsed_globals): + if not self._has_additional_error_members(error_info): return False config_store = self._session.get_component('config_store') hide_details = config_store.get_config_variable( 'cli_hide_error_details' ) - try: - if isinstance(hide_details, str): - hide_details = hide_details.lower() != 'false' - else: - hide_details = bool(hide_details) if hide_details else False - except (AttributeError, ValueError): - hide_details = False + if isinstance(hide_details, str): + hide_details = hide_details.lower() == 'true' + else: + hide_details = bool(hide_details) if hide_details else False if hide_details: return False @@ -62,18 +59,14 @@ def should_display(self, error_response, parsed_globals): if error_format == 'LEGACY': return False - output = parsed_globals.output - if output is None: - output = self._session.get_config_variable('output') + output = self._get_output_format(parsed_globals) if output == 'off': return False return True def display(self, error_response, parsed_globals): - output = parsed_globals.output - if output is None: - output = self._session.get_config_variable('output') + output = self._get_output_format(parsed_globals) try: formatter = get_formatter(output, parsed_globals) @@ -88,6 +81,12 @@ def display(self, error_response, parsed_globals): exc_info=True, ) + def _get_output_format(self, parsed_globals): + output = parsed_globals.output + if output is None: + output = self._session.get_config_variable('output') + return output + def _filter_sensitive_fields(self, error_info): """Filter sensitive fields from error response before display. diff --git a/tests/unit/test_structured_error.py b/tests/unit/test_structured_error.py index 65312b797475..8bc53c76f000 100644 --- a/tests/unit/test_structured_error.py +++ b/tests/unit/test_structured_error.py @@ -166,3 +166,33 @@ def test_display_handles_exceptions_gracefully(self): self.handler._output_stream_factory = mock_stream_factory self.handler.display(error_response, parsed_globals) + + def test_should_display_with_parsed_globals_output_none(self): + error_response = { + 'Code': 'NoSuchBucket', + 'Message': 'Error', + 'BucketName': 'test', + } + parsed_globals = mock.Mock() + parsed_globals.output = None + + with mock.patch.object( + self.session, 'get_config_variable', return_value='off' + ): + assert not self.handler.should_display( + error_response, parsed_globals + ) + + def test_should_display_with_parsed_globals_output_none_json(self): + error_response = { + 'Code': 'NoSuchBucket', + 'Message': 'Error', + 'BucketName': 'test', + } + parsed_globals = mock.Mock() + parsed_globals.output = None + + with mock.patch.object( + self.session, 'get_config_variable', return_value='json' + ): + assert self.handler.should_display(error_response, parsed_globals) From 6958d85dfec12a24f97f6019c6a77fbb92c90c8f Mon Sep 17 00:00:00 2001 From: Andrew Asseily Date: Mon, 17 Nov 2025 16:48:44 -0500 Subject: [PATCH 05/11] Remove cli_hide_error_details --- awscli/clidriver.py | 17 ----------------- awscli/structured_error.py | 13 +------------ tests/unit/test_structured_error.py | 15 --------------- 3 files changed, 1 insertion(+), 44 deletions(-) diff --git a/awscli/clidriver.py b/awscli/clidriver.py index 5a9f5cbb7d94..77313a93fc5b 100644 --- a/awscli/clidriver.py +++ b/awscli/clidriver.py @@ -280,9 +280,6 @@ def _update_config_chain(self): config_store.set_config_provider( 'cli_error_format', self._construct_cli_error_format_chain() ) - config_store.set_config_provider( - 'cli_hide_error_details', self._construct_cli_hide_error_details_chain() - ) def _construct_cli_region_chain(self): providers = [ @@ -390,20 +387,6 @@ def _construct_cli_error_format_chain(self): ] return ChainProvider(providers=providers) - def _construct_cli_hide_error_details_chain(self): - providers = [ - EnvironmentProvider( - name='AWS_CLI_HIDE_ERROR_DETAILS', - env=os.environ, - ), - ScopedConfigProvider( - config_var_name='cli_hide_error_details', - session=self.session, - ), - ConstantProvider(value='false'), - ] - return ChainProvider(providers=providers) - @property def subcommand_table(self): return self._get_command_table() diff --git a/awscli/structured_error.py b/awscli/structured_error.py index 4e3b5a4ae3f1..f5263bd07cab 100644 --- a/awscli/structured_error.py +++ b/awscli/structured_error.py @@ -44,17 +44,6 @@ def should_display(self, error_info, parsed_globals): return False config_store = self._session.get_component('config_store') - hide_details = config_store.get_config_variable( - 'cli_hide_error_details' - ) - if isinstance(hide_details, str): - hide_details = hide_details.lower() == 'true' - else: - hide_details = bool(hide_details) if hide_details else False - - if hide_details: - return False - error_format = config_store.get_config_variable('cli_error_format') if error_format == 'LEGACY': return False @@ -92,7 +81,7 @@ def _filter_sensitive_fields(self, error_info): TODO: Implement sensitive output mitigation to filter fields marked with the sensitive trait according to AWS CLI sensitive - output mitigation design. + output mitigation design when finalized. """ # Currently returns unfiltered return error_info diff --git a/tests/unit/test_structured_error.py b/tests/unit/test_structured_error.py index 8bc53c76f000..b198f2f4fb68 100644 --- a/tests/unit/test_structured_error.py +++ b/tests/unit/test_structured_error.py @@ -83,21 +83,6 @@ def test_should_display_without_additional_members(self): assert not self.handler.should_display(error_response, parsed_globals) - def test_should_display_respects_hide_details(self): - error_response = { - 'Code': 'NoSuchBucket', - 'Message': 'Error', - 'BucketName': 'test', - } - parsed_globals = mock.Mock() - parsed_globals.output = 'json' - - self.session.config_store.set_config_provider( - 'cli_hide_error_details', mock.Mock(provide=lambda: True) - ) - - assert not self.handler.should_display(error_response, parsed_globals) - def test_should_display_respects_legacy_format(self): error_response = { 'Code': 'NoSuchBucket', From 329d1671c1afce5983ed001764f371323327d76f Mon Sep 17 00:00:00 2001 From: Andrew Asseily Date: Tue, 25 Nov 2025 14:32:44 -0500 Subject: [PATCH 06/11] Implement --output off format to suppress stdout --- awscli/formatter.py | 17 ++++++++++--- tests/unit/test_formatter.py | 48 +++++++++++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/awscli/formatter.py b/awscli/formatter.py index 60d80d8db0c6..0f0f7f002611 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,23 @@ 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): + # Suppress all output + pass + + CLI_OUTPUT_FORMATS = { 'json': JSONFormatter, 'text': TextFormatter, 'table': TableFormatter, 'yaml': YAMLFormatter, 'yaml-stream': StreamedYAMLFormatter, + 'off': OffFormatter, } 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) From 073c28dd0e184076ff3f962674f532163f127177 Mon Sep 17 00:00:00 2001 From: Andrew Asseily Date: Wed, 26 Nov 2025 15:22:48 -0500 Subject: [PATCH 07/11] Add missing off status to output choices --- awscli/data/cli.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/awscli/data/cli.json b/awscli/data/cli.json index 27d32410393f..7183b5219e4f 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.

" }, From b82ef33a8bf8065723cd5990158274ac1d88e2ed Mon Sep 17 00:00:00 2001 From: Andrew Asseily Date: Mon, 1 Dec 2025 04:05:20 -0500 Subject: [PATCH 08/11] Catch and format ClientError exceptions raised during pagination --- awscli/clidriver.py | 8 +++-- tests/unit/test_structured_error.py | 53 ++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/awscli/clidriver.py b/awscli/clidriver.py index 77313a93fc5b..109118f631e1 100644 --- a/awscli/clidriver.py +++ b/awscli/clidriver.py @@ -1090,5 +1090,9 @@ def _display_response(self, command_name, response, parsed_globals): output = self._session.get_config_variable('output') formatter = get_formatter(output, parsed_globals) - with self._output_stream_factory.get_output_stream() as stream: - formatter(command_name, response, stream) + try: + with self._output_stream_factory.get_output_stream() as stream: + formatter(command_name, response, stream) + except ClientError as e: + self._display_structured_error_for_exception(e, parsed_globals) + raise diff --git a/tests/unit/test_structured_error.py b/tests/unit/test_structured_error.py index b198f2f4fb68..4ed610ba9640 100644 --- a/tests/unit/test_structured_error.py +++ b/tests/unit/test_structured_error.py @@ -17,12 +17,13 @@ from awscli.structured_error import StructuredErrorHandler from tests.unit.test_clidriver import FakeSession +from awscli.utils import OutputStreamFactory +from awscli.clidriver import CLIOperationCaller class TestStructuredErrorHandler: def setup_method(self): self.session = FakeSession() - from awscli.utils import OutputStreamFactory self.output_stream_factory = OutputStreamFactory(self.session) self.handler = StructuredErrorHandler( @@ -181,3 +182,53 @@ def test_should_display_with_parsed_globals_output_none_json(self): self.session, 'get_config_variable', return_value='json' ): assert self.handler.should_display(error_response, parsed_globals) + + +class TestStructuredErrorWithPagination: + def setup_method(self): + self.session = FakeSession() + self.caller = CLIOperationCaller(self.session) + + def test_formatter_error_displays_structured_error(self): + error_response = { + 'Error': { + 'Code': 'AccessDenied', + 'Message': 'Access Denied', + 'BucketName': 'my-bucket', + }, + 'ResponseMetadata': {'RequestId': '123'}, + } + + client_error = ClientError(error_response, 'ListObjects') + + parsed_globals = mock.Mock() + parsed_globals.output = 'json' + parsed_globals.query = None + + mock_formatter = mock.Mock() + mock_formatter.side_effect = client_error + + mock_stream = io.StringIO() + mock_context_manager = mock.MagicMock() + mock_context_manager.__enter__.return_value = mock_stream + mock_context_manager.__exit__.return_value = False + + with mock.patch( + 'awscli.clidriver.get_formatter', return_value=mock_formatter + ): + with mock.patch.object( + self.caller._output_stream_factory, + 'get_output_stream', + return_value=mock_context_manager, + ): + try: + self.caller._display_response( + 'list-objects', {}, parsed_globals + ) + assert False, "Expected ClientError to be raised" + except ClientError: + pass + + output = mock_stream.getvalue() + assert 'AccessDenied' in output + assert 'my-bucket' in output From 7f511a2d5213bd8f8584b18db6a09ca7329847a1 Mon Sep 17 00:00:00 2001 From: Andrew Asseily Date: Tue, 2 Dec 2025 10:04:08 -0500 Subject: [PATCH 09/11] Update test fixtures and documentation for 'off' output format option --- awscli/examples/global_options.rst | 2 ++ tests/functional/autocomplete/test_completer.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/awscli/examples/global_options.rst b/awscli/examples/global_options.rst index cec1f5529736..e8ba11090991 100644 --- a/awscli/examples/global_options.rst +++ b/awscli/examples/global_options.rst @@ -29,6 +29,8 @@ * yaml-stream + * off + ``--query`` (string) 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): From ae6987d66d41af223b25b21fd846ba0b1def9401 Mon Sep 17 00:00:00 2001 From: Andrew Asseily Date: Thu, 4 Dec 2025 14:17:45 -0500 Subject: [PATCH 10/11] Remove TODO for filtering; use --output off for sensitive data instead --- awscli/structured_error.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/awscli/structured_error.py b/awscli/structured_error.py index f5263bd07cab..d7897cfa11a3 100644 --- a/awscli/structured_error.py +++ b/awscli/structured_error.py @@ -36,8 +36,7 @@ def handle_error(self, error_response, parsed_globals): error_info = error_response.get('Error', {}) if self.should_display(error_info, parsed_globals): - filtered_error_info = self._filter_sensitive_fields(error_info) - self.display(filtered_error_info, parsed_globals) + self.display(error_info, parsed_globals) def should_display(self, error_info, parsed_globals): if not self._has_additional_error_members(error_info): @@ -76,16 +75,6 @@ def _get_output_format(self, parsed_globals): output = self._session.get_config_variable('output') return output - def _filter_sensitive_fields(self, error_info): - """Filter sensitive fields from error response before display. - - TODO: Implement sensitive output mitigation to filter fields - marked with the sensitive trait according to AWS CLI sensitive - output mitigation design when finalized. - """ - # Currently returns unfiltered - return error_info - def _has_additional_error_members(self, error_response): if not error_response: return False From 9db425e7bd4a47553b199218f070169c1895fdfb Mon Sep 17 00:00:00 2001 From: Andrew Asseily Date: Mon, 8 Dec 2025 10:20:16 -0500 Subject: [PATCH 11/11] Add changelog entry for structured error output --- .changes/next-release/feature-Output-59989.json | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/next-release/feature-Output-59989.json diff --git a/.changes/next-release/feature-Output-59989.json b/.changes/next-release/feature-Output-59989.json new file mode 100644 index 000000000000..520f437b86ce --- /dev/null +++ b/.changes/next-release/feature-Output-59989.json @@ -0,0 +1,5 @@ +{ + "type": "feature", + "category": "Output", + "description": "AWS service errors containing modeled fields beyond Code and Message now write structured output to stdout in the configured output format. Set cli_error_format=LEGACY to disable or use --output off to suppress stdout." +}