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..00968c670bf1
--- /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. CLI 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..7a5e95ebaeab 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)
driver = CLIDriver(
session=session, error_handler=error_handlers_chain, debug=debug
)
@@ -246,7 +246,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 +277,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 +373,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,6 +535,7 @@ def main(self, args=None):
command_table = self._get_command_table()
parser = self.create_parser(command_table)
self._add_aliases(command_table, parser)
+ parsed_args = None
try:
# Because _handle_top_level_args emits events, it's possible
# that exceptions can be raised, which should have the same
@@ -538,6 +558,7 @@ def main(self, args=None):
e,
stdout=get_stdout_text_writer(),
stderr=get_stderr_text_writer(),
+ parsed_globals=parsed_args,
)
def _emit_session_event(self, parsed_args):
@@ -818,7 +839,10 @@ def _parse_potential_subcommand(self, args, subcommand_table):
def __call__(self, args, parsed_globals):
# Once we know we're trying to call a particular operation
# of a service we can go ahead and load the parameters.
- event = f'before-building-argument-table-parser.{self._parent_name}.{self._name}'
+ event = (
+ f'before-building-argument-table-parser.'
+ f'{self._parent_name}.{self._name}'
+ )
self._emit(
event,
argument_table=self.arg_table,
@@ -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 9f1c2b8862c4..bf4a5367b46a 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,11 +37,63 @@
ConfigurationError,
ParamValidationError,
)
-from awscli.utils import PagerInitializationException
from awscli.errorformat import write_error
+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, stream):
+ 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}: '
+ 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 = [
@@ -52,41 +105,126 @@ 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):
+ # UnknownArgumentErrorHandler and InterruptExceptionHandler are
+ # intentionally excluded from structured formatting
handlers = [
- ParamValidationErrorsHandler(),
+ ParamValidationErrorsHandler(session),
UnknownArgumentErrorHandler(),
- ConfigurationErrorHandler(),
- NoRegionErrorHandler(),
- NoCredentialsErrorHandler(),
- PagerErrorHandler(),
+ ConfigurationErrorHandler(session),
+ NoRegionErrorHandler(session),
+ NoCredentialsErrorHandler(session),
+ PagerErrorHandler(session),
InterruptExceptionHandler(),
- ClientErrorHandler(),
- GeneralExceptionHandler(),
+ ClientErrorHandler(session),
+ GeneralExceptionHandler(session),
]
return ChainedExceptionHandler(exception_handlers=handlers)
class BaseExceptionHandler:
- def handle_exception(self, exception, stdout, stderr):
+ def handle_exception(self, exception, stdout, stderr, **kwargs):
raise NotImplementedError('handle_exception')
class FilteredExceptionHandler(BaseExceptionHandler):
EXCEPTIONS_TO_HANDLE = ()
- MESSAGE = '%s'
+ RC = None
+
+ def __init__(self, session=None):
+ self._session = session
- def handle_exception(self, exception, stdout, stderr):
+ def handle_exception(self, exception, stdout, stderr, **kwargs):
if isinstance(exception, self.EXCEPTIONS_TO_HANDLE):
- return_val = self._do_handle_exception(exception, stdout, stderr)
+ return_val = self._do_handle_exception(
+ exception, stdout, stderr, **kwargs
+ )
if return_val is not None:
return return_val
- def _do_handle_exception(self, exception, stdout, stderr):
- message = self.MESSAGE % exception
+ def _do_handle_exception(self, exception, stdout, stderr, **kwargs):
+ parsed_globals = kwargs.get('parsed_globals')
+ error_info = self._extract_error_info(exception)
+
+ if error_info:
+ formatted_message = self._get_formatted_message(
+ error_info, exception
+ )
+ displayed_structured = self._display_structured_error(
+ error_info, formatted_message, stderr, parsed_globals
+ )
+ if displayed_structured:
+ return self.RC
+
+ message = (error_info or {}).get('Message', str(exception))
write_error(stderr, message)
return self.RC
+ def _extract_error_info(self, exception):
+ """Extract error information for structured formatting.
+
+ Returns None by default. Subclasses should override to provide
+ error information as a dict with 'Code' and 'Message' keys.
+ """
+ return None
+
+ def _get_formatted_message(self, error_info, exception):
+ code = error_info.get('Code', 'Unknown')
+ message = error_info.get('Message', str(exception))
+ return f"An error occurred ({code}): {message}"
+
+ 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()
+
+ if self._session:
+ 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 _display_structured_error(
+ self, error_info, formatted_message, stderr, parsed_globals=None
+ ):
+ try:
+ error_format = self._resolve_error_format(parsed_globals)
+
+ if error_format == 'legacy':
+ return False
+
+ 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 == 'enhanced':
+ write_error(stderr, formatted_message)
+ EnhancedErrorFormatter().format_error(error_info, stderr)
+ return True
+
+ formatter_args = parsed_globals or argparse.Namespace(
+ query=None, color='auto'
+ )
+ formatter = get_formatter(error_format, formatter_args)
+ 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
+
class ParamValidationErrorsHandler(FilteredExceptionHandler):
EXCEPTIONS_TO_HANDLE = (
@@ -98,9 +236,12 @@ class ParamValidationErrorsHandler(FilteredExceptionHandler):
)
RC = PARAM_VALIDATION_ERROR_RC
+ def _extract_error_info(self, exception):
+ return {'Code': 'ParamValidation', 'Message': str(exception)}
+
class SilenceParamValidationMsgErrorHandler(ParamValidationErrorsHandler):
- def _do_handle_exception(self, exception, stdout, stderr):
+ def _do_handle_exception(self, exception, stdout, stderr, **kwargs):
return self.RC
@@ -108,42 +249,106 @@ class ClientErrorHandler(FilteredExceptionHandler):
EXCEPTIONS_TO_HANDLE = ClientError
RC = CLIENT_ERROR_RC
+ def _do_handle_exception(self, exception, stdout, stderr, **kwargs):
+ parsed_globals = kwargs.get('parsed_globals')
+ error_info = self._extract_error_info(exception)
+
+ if error_info:
+ formatted_message = self._get_formatted_message(
+ error_info, exception
+ )
+ displayed_structured = self._display_structured_error(
+ error_info, formatted_message, stderr, parsed_globals
+ )
+ if displayed_structured:
+ return self.RC
+
+ write_error(stderr, str(exception))
+ return self.RC
+
+ def _get_formatted_message(self, error_info, exception):
+ return str(exception)
+
+ def _extract_error_info(self, exception):
+ error_response = self._extract_error_response(exception)
+ if error_response and 'Error' in error_response:
+ return error_response['Error']
+ return None
+
+ @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
RC = CONFIGURATION_ERROR_RC
+ def _extract_error_info(self, exception):
+ return {'Code': 'Configuration', 'Message': str(exception)}
+
class NoRegionErrorHandler(FilteredExceptionHandler):
EXCEPTIONS_TO_HANDLE = NoRegionError
RC = CONFIGURATION_ERROR_RC
- MESSAGE = (
- '%s You can also configure your region by running "aws configure".'
- )
+
+ def _extract_error_info(self, exception):
+ message = (
+ f'{exception} You can also configure your region by running '
+ f'"aws configure".'
+ )
+ return {'Code': 'NoRegion', 'Message': message}
class NoCredentialsErrorHandler(FilteredExceptionHandler):
EXCEPTIONS_TO_HANDLE = NoCredentialsError
RC = CONFIGURATION_ERROR_RC
- MESSAGE = '%s. You can configure credentials by running "aws login".'
+
+ def _extract_error_info(self, exception):
+ message = (
+ f'{exception}. You can configure credentials '
+ f'by running "aws login".'
+ )
+ return {'Code': 'NoCredentials', 'Message': message}
class PagerErrorHandler(FilteredExceptionHandler):
EXCEPTIONS_TO_HANDLE = PagerInitializationException
RC = CONFIGURATION_ERROR_RC
- MESSAGE = (
- 'Unable to redirect output to pager. Received the '
- 'following error when opening pager:\n%s\n\n'
- 'Learn more about configuring the output pager by running '
- '"aws help config-vars".'
- )
+
+ def _extract_error_info(self, exception):
+ message = (
+ f'Unable to redirect output to pager. Received the '
+ f'following error when opening pager:\n{exception}\n\n'
+ f'Learn more about configuring the output pager by running '
+ f'"aws help config-vars".'
+ )
+ return {'Code': 'Pager', 'Message': message}
class UnknownArgumentErrorHandler(FilteredExceptionHandler):
EXCEPTIONS_TO_HANDLE = UnknownArgumentError
RC = PARAM_VALIDATION_ERROR_RC
- def _do_handle_exception(self, exception, stdout, stderr):
+ def _do_handle_exception(self, exception, stdout, stderr, **kwargs):
stderr.write("\n")
stderr.write(f'usage: {USAGE}\n')
write_error(stderr, str(exception))
@@ -154,7 +359,7 @@ class InterruptExceptionHandler(FilteredExceptionHandler):
EXCEPTIONS_TO_HANDLE = KeyboardInterrupt
RC = 128 + signal.SIGINT
- def _do_handle_exception(self, exception, stdout, stderr):
+ def _do_handle_exception(self, exception, stdout, stderr, **kwargs):
stdout.write("\n")
return self.RC
@@ -162,7 +367,7 @@ def _do_handle_exception(self, exception, stdout, stderr):
class PrompterInterruptExceptionHandler(InterruptExceptionHandler):
EXCEPTIONS_TO_HANDLE = PrompterKeyboardInterrupt
- def _do_handle_exception(self, exception, stdout, stderr):
+ def _do_handle_exception(self, exception, stdout, stderr, **kwargs):
stderr.write(f'{exception}')
stderr.write("\n")
return self.RC
@@ -172,6 +377,12 @@ class GeneralExceptionHandler(FilteredExceptionHandler):
EXCEPTIONS_TO_HANDLE = Exception
RC = GENERAL_ERROR_RC
+ def _do_handle_exception(self, exception, stdout, stderr, **kwargs):
+ # Generic exceptions don't have meaningful structure,
+ # so always use plain text formatting
+ write_error(stderr, str(exception))
+ return self.RC
+
class ChainedExceptionHandler(BaseExceptionHandler):
def __init__(self, exception_handlers):
@@ -180,8 +391,10 @@ def __init__(self, exception_handlers):
def inject_handler(self, position, handler):
self._exception_handlers.insert(position, handler)
- def handle_exception(self, exception, stdout, stderr):
+ def handle_exception(self, exception, stdout, stderr, **kwargs):
for handler in self._exception_handlers:
- return_value = handler.handle_exception(exception, stdout, stderr)
+ return_value = handler.handle_exception(
+ exception, stdout, stderr, **kwargs
+ )
if return_value is not None:
return return_value
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..d1bcaaea2299 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,24 @@ 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 _ in response:
+ pass
+
+
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..4cf0ea3d0979 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,26 @@ 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 CLI errors are displayed. The valid
+values of the ``cli_error_format`` configuration variable are:
+
+* enhanced - Errors display additional fields in a
+ human-readable format with inline display for simple values and small
+ collections. This is the default behavior.
+* json - Errors are formatted as JSON, showing all available fields.
+* yaml - Errors are formatted as YAML, showing all available fields.
+* text - Errors are formatted as text with key-value pairs, showing
+ all available fields.
+* table - Errors are formatted as a table, showing all available fields.
+* legacy - Errors are written to stderr as unstructured text,
+ displaying only the error code and message without additional 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/functional/s3/test_mv_command.py b/tests/functional/s3/test_mv_command.py
index 91eb11c85a2c..4ae92dbede41 100644
--- a/tests/functional/s3/test_mv_command.py
+++ b/tests/functional/s3/test_mv_command.py
@@ -525,7 +525,8 @@ def test_get_mrap_buckets_raises_if_alias_not_found(self):
]
stderr = self.run_cmd(cmdline, expected_rc=252)[1]
self.assertEqual(
- "\naws: [ERROR]: Couldn't find multi-region access point with alias foobar.mrap "
+ "\naws: [ERROR]: An error occurred (ParamValidation): "
+ "Couldn't find multi-region access point with alias foobar.mrap "
"in account 123456789012\n",
stderr,
)
diff --git a/tests/unit/customizations/emr/__init__.py b/tests/unit/customizations/emr/__init__.py
index 635d69227c0e..1fbfba67435a 100644
--- a/tests/unit/customizations/emr/__init__.py
+++ b/tests/unit/customizations/emr/__init__.py
@@ -42,6 +42,6 @@ def assert_error_msg(
self, cmd, exception_class_name, error_msg_kwargs={}, rc=255
):
exception_class = getattr(exceptions, exception_class_name)
- error_msg = "\naws: [ERROR]: %s\n" % exception_class.fmt.format(**error_msg_kwargs)
+ error_msg = "\naws: [ERROR]: An error occurred (ParamValidation): %s\n" % exception_class.fmt.format(**error_msg_kwargs)
result = self.run_cmd(cmd, rc)
self.assertEqual(error_msg, result[1])
diff --git a/tests/unit/customizations/emr/test_add_steps.py b/tests/unit/customizations/emr/test_add_steps.py
index d34f494530d8..0f7e8967542c 100644
--- a/tests/unit/customizations/emr/test_add_steps.py
+++ b/tests/unit/customizations/emr/test_add_steps.py
@@ -188,7 +188,8 @@ class TestAddSteps(BaseAWSCommandParamsTest):
def test_unknown_step_type(self):
cmd = self.prefix + 'Type=unknown'
expected_error_msg = (
- '\naws: [ERROR]: ' + 'The step type unknown is not supported.\n'
+ '\naws: [ERROR]: An error occurred (ParamValidation): '
+ + 'The step type unknown is not supported.\n'
)
self.assert_error_for_ami_and_release_based_clusters(
cmd=cmd,
@@ -218,7 +219,7 @@ def test_default_step_type_name_action_on_failure(self):
def test_custom_jar_step_missing_jar(self):
cmd = self.prefix + 'Name=CustomJarMissingJar'
expected_error_msg = (
- '\naws: [ERROR]: The following '
+ '\naws: [ERROR]: An error occurred (ParamValidation): The following '
+ 'required parameters are missing for CustomJARStepConfig: Jar.\n'
)
self.assert_error_for_ami_and_release_based_clusters(
@@ -420,7 +421,7 @@ def test_step_with_execution_role_arn(self):
def test_streaming_step_missing_args(self):
cmd = self.prefix + 'Type=Streaming'
expected_error_msg = (
- '\naws: [ERROR]: The following '
+ '\naws: [ERROR]: An error occurred (ParamValidation): The following '
+ 'required parameters are missing for StreamingStepConfig: Args.\n'
)
self.assert_error_for_ami_and_release_based_clusters(
@@ -602,7 +603,7 @@ def test_hive_step_with_default_fields(self):
def test_hive_step_missing_args(self):
cmd = self.prefix + 'Type=Hive'
expected_error_msg = (
- '\naws: [ERROR]: The following '
+ '\naws: [ERROR]: An error occurred (ParamValidation): The following '
+ 'required parameters are missing for HiveStepConfig: Args.\n'
)
@@ -781,7 +782,7 @@ def test_pig_step_with_default_fields(self):
def test_pig_missing_args(self):
cmd = self.prefix + 'Type=Pig'
expected_error_msg = (
- '\naws: [ERROR]: The following '
+ '\naws: [ERROR]: An error occurred (ParamValidation): The following '
+ 'required parameters are missing for PigStepConfig: Args.\n'
)
self.assert_error_for_ami_and_release_based_clusters(
@@ -973,7 +974,7 @@ def test_SPARK_SUBMIT_SCRIPT_RUNNER_STEP(self):
def test_spark_missing_arg(self):
cmd = self.prefix + 'Type=SPARK'
expected_error_msg = (
- '\naws: [ERROR]: The following '
+ '\naws: [ERROR]: An error occurred (ParamValidation): The following '
+ 'required parameters are missing for SparkStepConfig: Args.\n'
)
self.assert_error_for_ami_and_release_based_clusters(
@@ -1114,7 +1115,7 @@ def test_spark_step_with_step_monitoring_configuration_no_log_uri_or_encryption_
def test_impala_missing_args(self):
cmd = self.prefix + 'Type=Impala'
expected_error_msg = (
- '\naws: [ERROR]: The following '
+ '\naws: [ERROR]: An error occurred (ParamValidation): The following '
+ 'required parameters are missing for ImpalaStepConfig: Args.\n'
)
self.assert_error_for_ami_and_release_based_clusters(
@@ -1249,7 +1250,8 @@ def test_impala_step_with_release(self):
test_step_config = 'Type=Impala,' + self.IMPALA_BASIC_ARGS
cmd = self.prefix + test_step_config
expected_result_release = (
- '\naws: [ERROR]: The step type impala ' + 'is not supported.\n'
+ '\naws: [ERROR]: An error occurred (ParamValidation): '
+ + 'The step type impala is not supported.\n'
)
self.assert_error_for_ami_and_release_based_clusters(
@@ -1261,7 +1263,8 @@ def test_impala_step_with_release(self):
def test_empty_step_args(self):
cmd = self.prefix + 'Type=Streaming,Args='
expected_error_msg = (
- '\naws: [ERROR]: The prameter Args cannot ' 'be an empty list.\n'
+ '\naws: [ERROR]: An error occurred (ParamValidation): '
+ + 'The prameter Args cannot be an empty list.\n'
)
self.assert_error_for_ami_and_release_based_clusters(
cmd=cmd,
@@ -1285,7 +1288,8 @@ def test_empty_step_args(self):
cmd = self.prefix + 'Args='
expected_error_msg = (
- '\naws: [ERROR]: The following required parameters'
+ '\naws: [ERROR]: An error occurred (ParamValidation): '
+ 'The following required parameters'
' are missing for CustomJARStepConfig: Jar.\n'
)
self.assert_error_for_ami_and_release_based_clusters(
diff --git a/tests/unit/customizations/emr/test_config.py b/tests/unit/customizations/emr/test_config.py
index 921e5949e85a..30ef5cd5992f 100644
--- a/tests/unit/customizations/emr/test_config.py
+++ b/tests/unit/customizations/emr/test_config.py
@@ -91,10 +91,13 @@ def test_with_bad_configs(self, mock_run_main_command):
def test_with_bad_boolean_value(self):
self.set_configs(BAD_BOOLEAN_VALUE_CONFIGS)
cmd = CREATE_CLUSTER_CMD
- expect_error_msg = "\naws: [ERROR]: %s\n" % InvalidBooleanConfigError.fmt.format(
- config_value='False1',
- config_key='enable_debugging',
- profile_var_name='default',
+ expect_error_msg = (
+ "\naws: [ERROR]: An error occurred (ParamValidation): %s\n"
+ % InvalidBooleanConfigError.fmt.format(
+ config_value='False1',
+ config_key='enable_debugging',
+ profile_var_name='default',
+ )
)
result = self.run_cmd(cmd, 252)
self.assertEqual(expect_error_msg, result[1])
diff --git a/tests/unit/customizations/emr/test_create_cluster_ami_version.py b/tests/unit/customizations/emr/test_create_cluster_ami_version.py
index 555ac052dcca..62cc2c4c2e4f 100644
--- a/tests/unit/customizations/emr/test_create_cluster_ami_version.py
+++ b/tests/unit/customizations/emr/test_create_cluster_ami_version.py
@@ -440,7 +440,7 @@ def test_mutual_exclusive_use_default_roles_and_service_role(self):
+ '--ec2-attributes InstanceProfile=Ec2_InstanceProfile'
)
expected_error_msg = (
- '\naws: [ERROR]: You cannot specify both --use-default-roles '
+ '\naws: [ERROR]: An error occurred (ParamValidation): You cannot specify both --use-default-roles '
'and --ec2-attributes InstanceProfile options together. Either '
'choose --use-default-roles or use both --service-role '
' and --ec2-attributes InstanceProfile=.\n'
@@ -454,7 +454,7 @@ def test_mutual_exclusive_use_default_roles_and_instance_profile(self):
'--ec2-attributes InstanceProfile=Ec2_InstanceProfile'
)
expected_error_msg = (
- '\naws: [ERROR]: You cannot specify both --use-default-roles '
+ '\naws: [ERROR]: An error occurred (ParamValidation): You cannot specify both --use-default-roles '
'and --service-role options together. Either choose '
'--use-default-roles or use both --service-role '
'and --ec2-attributes InstanceProfile=.\n'
@@ -512,7 +512,7 @@ def test_auto_terminate_and_no_auto_terminate(self):
+ '--auto-terminate --no-auto-terminate'
)
expected_error_msg = (
- '\naws: [ERROR]: cannot use both --no-auto-terminate and'
+ '\naws: [ERROR]: An error occurred (ParamValidation): cannot use both --no-auto-terminate and'
' --auto-terminate options together.\n'
)
result = self.run_cmd(cmd, 252)
@@ -535,7 +535,7 @@ def test_termination_protected_and_no_termination_protected(self):
DEFAULT_CMD + '--termination-protected --no-termination-protected'
)
expected_error_msg = (
- '\naws: [ERROR]: cannot use both --termination-protected'
+ '\naws: [ERROR]: An error occurred (ParamValidation): cannot use both --termination-protected'
' and --no-termination-protected options together.\n'
)
result = self.run_cmd(cmd, 252)
@@ -565,7 +565,7 @@ def test_unhealthy_node_replacement_and_no_unhealthy_node_replacement(
+ '--unhealthy-node-replacement --no-unhealthy-node-replacement'
)
expected_error_msg = (
- '\naws: [ERROR]: cannot use both --unhealthy-node-replacement'
+ '\naws: [ERROR]: An error occurred (ParamValidation): cannot use both --unhealthy-node-replacement'
' and --no-unhealthy-node-replacement options together.\n'
)
result = self.run_cmd(cmd, 252)
@@ -584,7 +584,7 @@ def test_no_visible_to_all_users(self):
def test_visible_to_all_users_and_no_visible_to_all_users(self):
cmd = DEFAULT_CMD + '--visible-to-all-users --no-visible-to-all-users'
expected_error_msg = (
- '\naws: [ERROR]: cannot use both --visible-to-all-users and '
+ '\naws: [ERROR]: An error occurred (ParamValidation): cannot use both --visible-to-all-users and '
'--no-visible-to-all-users options together.\n'
)
result = self.run_cmd(cmd, 252)
@@ -605,7 +605,7 @@ def test_no_extended_support(self):
def test_extended_support_and_no_extended_support(self):
cmd = DEFAULT_CMD + '--extended-support --no-extended-support'
expected_error_msg = (
- '\naws: [ERROR]: cannot use both --extended-support'
+ '\naws: [ERROR]: An error occurred (ParamValidation): cannot use both --extended-support'
' and --no-extended-support options together.\n'
)
result = self.run_cmd(cmd, 252)
@@ -670,7 +670,7 @@ def test_enable_debugging(self):
def test_enable_debugging_no_log_uri(self):
cmd = DEFAULT_CMD + '--enable-debugging'
expected_error_msg = (
- '\naws: [ERROR]: LogUri not specified. You must specify a logUri'
+ '\naws: [ERROR]: An error occurred (ParamValidation): LogUri not specified. You must specify a logUri'
' if you enable debugging when creating a cluster.\n'
)
result = self.run_cmd(cmd, 252)
@@ -683,7 +683,7 @@ def test_enable_debugging_and_no_enable_debugging(self):
+ ' --log-uri s3://test/logs'
)
expected_error_msg = (
- '\naws: [ERROR]: cannot use both --enable-debugging and '
+ '\naws: [ERROR]: An error occurred (ParamValidation): cannot use both --enable-debugging and '
'--no-enable-debugging options together.\n'
)
result = self.run_cmd(cmd, 252)
@@ -760,7 +760,7 @@ def test_instance_groups_instance_type_and_count(self):
def test_instance_groups_missing_required_parameter_error(self):
cmd = 'emr create-cluster --use-default-roles --ami-version 3.0.4 '
expect_error_msg = (
- '\naws: [ERROR]: Must specify either --instance-groups or '
+ '\naws: [ERROR]: An error occurred (ParamValidation): Must specify either --instance-groups or '
'--instance-type with --instance-count(optional) to '
'configure instance groups.\n'
)
@@ -772,7 +772,7 @@ def test_instance_groups_missing_required_parameter_error(self):
'--instance-count 2'
)
expect_error_msg = (
- '\naws: [ERROR]: Must specify either --instance-groups or '
+ '\naws: [ERROR]: An error occurred (ParamValidation): Must specify either --instance-groups or '
'--instance-type with --instance-count(optional) to '
'configure instance groups.\n'
)
@@ -786,7 +786,7 @@ def test_instance_groups_exclusive_parameter_validation_error(self):
+ DEFAULT_INSTANCE_GROUPS_ARG
)
expect_error_msg = (
- '\naws: [ERROR]: You may not specify --instance-type '
+ '\naws: [ERROR]: An error occurred (ParamValidation): You may not specify --instance-type '
'or --instance-count with --instance-groups, '
'because --instance-type and --instance-count are '
'shortcut options for --instance-groups.\n'
@@ -800,7 +800,7 @@ def test_instance_groups_exclusive_parameter_validation_error(self):
'--instance-groups ' + DEFAULT_INSTANCE_GROUPS_ARG
)
expect_error_msg = (
- '\naws: [ERROR]: You may not specify --instance-type '
+ '\naws: [ERROR]: An error occurred (ParamValidation): You may not specify --instance-type '
'or --instance-count with --instance-groups, '
'because --instance-type and --instance-count are '
'shortcut options for --instance-groups.\n'
@@ -938,7 +938,7 @@ def test_ec2_attributes_subnet_az_error(self):
+ 'SubnetId=subnet-123456,AvailabilityZone=us-east-1a'
)
expect_error_msg = (
- '\naws: [ERROR]: You may not specify both a SubnetId and an Availab'
+ '\naws: [ERROR]: An error occurred (ParamValidation): You may not specify both a SubnetId and an Availab'
'ilityZone (placement) because ec2SubnetId implies a placement.\n'
)
result = self.run_cmd(cmd, 252)
@@ -1004,7 +1004,7 @@ def test_bootstrap_actions_exceed_maximum_error(self):
cmd += ba_cmd
expected_error_msg = (
- '\naws: [ERROR]: maximum number of '
+ '\naws: [ERROR]: An error occurred (ParamValidation): maximum number of '
+ 'bootstrap actions for a cluster exceeded.\n'
)
result = self.run_cmd(cmd, 252)
@@ -1021,7 +1021,7 @@ def test_bootstrap_actions_exceed_maximum_with_applications_error(self):
for i in range(1, 15):
cmd += ba_cmd
expected_error_msg = (
- '\naws: [ERROR]: maximum number of '
+ '\naws: [ERROR]: An error occurred (ParamValidation): maximum number of '
+ 'bootstrap actions for a cluster exceeded.\n'
)
result = self.run_cmd(cmd, 252)
@@ -1194,7 +1194,7 @@ def test_applications_all_types_from_json_file(self):
def test_wrong_step_type_error(self):
cmd = DEFAULT_CMD + '--steps Type=unknown'
expected_error_msg = (
- '\naws: [ERROR]: The step type unknown is not supported.\n'
+ '\naws: [ERROR]: An error occurred (ParamValidation): The step type unknown is not supported.\n'
)
result = self.run_cmd(cmd, 252)
self.assertEqual(expected_error_msg, result[1])
@@ -1208,7 +1208,7 @@ def test_default_step_type_name_action_on_failure(self):
def test_custom_jar_step_missing_jar(self):
cmd = DEFAULT_CMD + '--steps Name=CustomJarMissingJar'
expect_error_msg = (
- '\naws: [ERROR]: The following '
+ '\naws: [ERROR]: An error occurred (ParamValidation): The following '
+ 'required parameters are missing for CustomJARStepConfig: Jar.\n'
)
result = self.run_cmd(cmd, 252)
@@ -1255,7 +1255,7 @@ def test_streaming_step_with_default_fields(self):
def test_streaming_step_missing_args(self):
cmd = DEFAULT_CMD + '--steps Type=Streaming'
expect_error_msg = (
- '\naws: [ERROR]: The following '
+ '\naws: [ERROR]: An error occurred (ParamValidation): The following '
+ 'required parameters are missing for StreamingStepConfig: Args.\n'
)
result = self.run_cmd(cmd, 252)
@@ -1288,7 +1288,7 @@ def test_hive_step_with_default_fields(self):
def test_hive_step_missing_args(self):
cmd = DEFAULT_CMD + '--applications Name=Hive --steps Type=Hive'
expect_error_msg = (
- '\naws: [ERROR]: The following '
+ '\naws: [ERROR]: An error occurred (ParamValidation): The following '
+ 'required parameters are missing for HiveStepConfig: Args.\n'
)
result = self.run_cmd(cmd, 252)
@@ -1318,7 +1318,7 @@ def test_pig_step_with_default_fields(self):
def test_pig_missing_args(self):
cmd = DEFAULT_CMD + '--applications Name=Pig --steps Type=Pig'
expect_error_msg = (
- '\naws: [ERROR]: The following '
+ '\naws: [ERROR]: An error occurred (ParamValidation): The following '
+ 'required parameters are missing for PigStepConfig: Args.\n'
)
result = self.run_cmd(cmd, 252)
@@ -1351,7 +1351,7 @@ def test_impala_step_with_default_fields(self):
def test_impala_missing_args(self):
cmd = DEFAULT_CMD + '--applications Name=Impala --steps Type=Impala'
expect_error_msg = (
- '\naws: [ERROR]: The following '
+ '\naws: [ERROR]: An error occurred (ParamValidation): The following '
+ 'required parameters are missing for ImpalaStepConfig: Args.\n'
)
result = self.run_cmd(cmd, 252)
@@ -1412,7 +1412,7 @@ def test_restore_from_hbase(self):
def test_empty_step_args(self):
cmd = DEFAULT_CMD + '--steps Type=Streaming,Args= '
expect_error_msg = (
- '\naws: [ERROR]: The prameter Args cannot ' 'be an empty list.\n'
+ '\naws: [ERROR]: An error occurred (ParamValidation): The prameter Args cannot ' 'be an empty list.\n'
)
result = self.run_cmd(cmd, 252)
self.assertEqual(expect_error_msg, result[1])
@@ -1427,7 +1427,7 @@ def test_empty_step_args(self):
cmd = DEFAULT_CMD + '--steps Args= '
expect_error_msg = (
- '\naws: [ERROR]: The following required parameters '
+ '\naws: [ERROR]: An error occurred (ParamValidation): The following required parameters '
'are missing for CustomJARStepConfig: Jar.\n'
)
result = self.run_cmd(cmd, 252)
@@ -1451,12 +1451,12 @@ def test_missing_applications_for_steps(self):
)
expected_error_msg1 = (
- '\naws: [ERROR]: Some of the steps require the following'
+ '\naws: [ERROR]: An error occurred (ParamValidation): Some of the steps require the following'
' applications to be installed: Impala, Pig. '
'Please install the applications using --applications.\n'
)
expected_error_msg2 = (
- '\naws: [ERROR]: Some of the steps require the following'
+ '\naws: [ERROR]: An error occurred (ParamValidation): Some of the steps require the following'
' applications to be installed: Pig, Impala. '
'Please install the applications using --applications.\n'
)
@@ -1485,12 +1485,12 @@ def test_missing_applications_with_hbase(self):
)
expected_error_msg1 = (
- '\naws: [ERROR]: Some of the steps require the following'
+ '\naws: [ERROR]: An error occurred (ParamValidation): Some of the steps require the following'
' applications to be installed: Hbase, Impala. '
'Please install the applications using --applications.\n'
)
expected_error_msg2 = (
- '\naws: [ERROR]: Some of the steps require the following'
+ '\naws: [ERROR]: An error occurred (ParamValidation): Some of the steps require the following'
' applications to be installed: Impala, Hbase. '
'Please install the applications using --applications.\n'
)
@@ -1632,7 +1632,7 @@ def test_instance_group_with_autoscaling_policy_missing_autoscaling_role(
+ CONSTANTS.INSTANCE_GROUPS_WITH_AUTOSCALING_POLICY_ARG
)
expected_error_msg = (
- '\naws: [ERROR]: Must specify --auto-scaling-role when'
+ '\naws: [ERROR]: An error occurred (ParamValidation): Must specify --auto-scaling-role when'
' configuring an AutoScaling policy for an instance group.\n'
)
result = self.run_cmd(cmd, 252)
@@ -1990,7 +1990,7 @@ def test_instance_fleets_with_both_az_azs_specified(self):
+ ' --ec2-attributes AvailabilityZone=us-east-1a,AvailabilityZones=[us-east-1a,us-east-1b]'
)
expected_error_msg = (
- '\naws: [ERROR]: You cannot specify both AvailabilityZone'
+ '\naws: [ERROR]: An error occurred (ParamValidation): You cannot specify both AvailabilityZone'
' and AvailabilityZones options together.\n'
)
result = self.run_cmd(cmd, 252)
diff --git a/tests/unit/customizations/emr/test_create_cluster_release_label.py b/tests/unit/customizations/emr/test_create_cluster_release_label.py
index 043930090083..abcd51cd08c0 100644
--- a/tests/unit/customizations/emr/test_create_cluster_release_label.py
+++ b/tests/unit/customizations/emr/test_create_cluster_release_label.py
@@ -289,7 +289,7 @@ def test_ami_version_release_label_exclusive_validation(self):
'emr-4.0.0 --instance-groups ' + DEFAULT_INSTANCE_GROUPS_ARG
)
expected_error_msg = (
- '\naws: [ERROR]: You cannot specify both --ami-version'
+ '\naws: [ERROR]: An error occurred (ParamValidation): You cannot specify both --ami-version'
' and --release-label options together.\n'
)
result = self.run_cmd(cmd, 252)
@@ -298,7 +298,7 @@ def test_ami_version_release_label_exclusive_validation(self):
def test_if_ami_version_or_release_label_is_provided(self):
cmd = self.prefix + ' --instance-groups ' + DEFAULT_INSTANCE_GROUPS_ARG
expected_error_msg = (
- '\naws: [ERROR]: Either --ami-version or'
+ '\naws: [ERROR]: An error occurred (ParamValidation): Either --ami-version or'
' --release-label is required.\n'
)
result = self.run_cmd(cmd, 252)
@@ -406,7 +406,7 @@ def test_mutual_exclusive_use_default_roles_and_service_role(self):
+ '--ec2-attributes InstanceProfile=Ec2_InstanceProfile'
)
expected_error_msg = (
- '\naws: [ERROR]: You cannot specify both --use-default-roles '
+ '\naws: [ERROR]: An error occurred (ParamValidation): You cannot specify both --use-default-roles '
'and --ec2-attributes InstanceProfile options together. Either '
'choose --use-default-roles or use both --service-role '
' and --ec2-attributes InstanceProfile=.\n'
@@ -420,7 +420,7 @@ def test_mutual_exclusive_use_default_roles_and_instance_profile(self):
'--ec2-attributes InstanceProfile=Ec2_InstanceProfile'
)
expected_error_msg = (
- '\naws: [ERROR]: You cannot specify both --use-default-roles '
+ '\naws: [ERROR]: An error occurred (ParamValidation): You cannot specify both --use-default-roles '
'and --service-role options together. Either choose '
'--use-default-roles or use both --service-role '
'and --ec2-attributes InstanceProfile=.\n'
@@ -479,7 +479,7 @@ def test_auto_terminate_and_no_auto_terminate(self):
+ '--auto-terminate --no-auto-terminate'
)
expected_error_msg = (
- '\naws: [ERROR]: cannot use both --no-auto-terminate and'
+ '\naws: [ERROR]: An error occurred (ParamValidation): cannot use both --no-auto-terminate and'
' --auto-terminate options together.\n'
)
result = self.run_cmd(cmd, 252)
@@ -502,7 +502,7 @@ def test_termination_protected_and_no_termination_protected(self):
DEFAULT_CMD + '--termination-protected --no-termination-protected'
)
expected_error_msg = (
- '\naws: [ERROR]: cannot use both --termination-protected'
+ '\naws: [ERROR]: An error occurred (ParamValidation): cannot use both --termination-protected'
' and --no-termination-protected options together.\n'
)
result = self.run_cmd(cmd, 252)
@@ -532,7 +532,7 @@ def test_unhealthy_node_replacement_and_no_unhealthy_node_replacement(
+ '--unhealthy-node-replacement --no-unhealthy-node-replacement'
)
expected_error_msg = (
- '\naws: [ERROR]: cannot use both --unhealthy-node-replacement'
+ '\naws: [ERROR]: An error occurred (ParamValidation): cannot use both --unhealthy-node-replacement'
' and --no-unhealthy-node-replacement options together.\n'
)
result = self.run_cmd(cmd, 252)
@@ -551,7 +551,7 @@ def test_no_visible_to_all_users(self):
def test_visible_to_all_users_and_no_visible_to_all_users(self):
cmd = DEFAULT_CMD + '--visible-to-all-users --no-visible-to-all-users'
expected_error_msg = (
- '\naws: [ERROR]: cannot use both --visible-to-all-users and '
+ '\naws: [ERROR]: An error occurred (ParamValidation): cannot use both --visible-to-all-users and '
'--no-visible-to-all-users options together.\n'
)
result = self.run_cmd(cmd, 252)
@@ -572,7 +572,7 @@ def test_no_extended_support(self):
def test_extended_support_and_no_extended_support(self):
cmd = DEFAULT_CMD + '--extended-support --no-extended-support'
expected_error_msg = (
- '\naws: [ERROR]: cannot use both --extended-support'
+ '\naws: [ERROR]: An error occurred (ParamValidation): cannot use both --extended-support'
' and --no-extended-support options together.\n'
)
result = self.run_cmd(cmd, 252)
@@ -625,7 +625,7 @@ def test_enable_debugging(self):
def test_enable_debugging_no_log_uri(self):
cmd = DEFAULT_CMD + '--enable-debugging'
expected_error_msg = (
- '\naws: [ERROR]: LogUri not specified. You must specify a logUri'
+ '\naws: [ERROR]: An error occurred (ParamValidation): LogUri not specified. You must specify a logUri'
' if you enable debugging when creating a cluster.\n'
)
result = self.run_cmd(cmd, 252)
@@ -638,7 +638,7 @@ def test_enable_debugging_and_no_enable_debugging(self):
+ ' --log-uri s3://test/logs'
)
expected_error_msg = (
- '\naws: [ERROR]: cannot use both --enable-debugging and '
+ '\naws: [ERROR]: An error occurred (ParamValidation): cannot use both --enable-debugging and '
'--no-enable-debugging options together.\n'
)
result = self.run_cmd(cmd, 252)
@@ -718,7 +718,7 @@ def test_instance_groups_missing_required_parameter_error(self):
' emr-4.0.0 '
)
expect_error_msg = (
- '\naws: [ERROR]: Must specify either --instance-groups or '
+ '\naws: [ERROR]: An error occurred (ParamValidation): Must specify either --instance-groups or '
'--instance-type with --instance-count(optional) to '
'configure instance groups.\n'
)
@@ -730,7 +730,7 @@ def test_instance_groups_missing_required_parameter_error(self):
'--instance-count 2'
)
expect_error_msg = (
- '\naws: [ERROR]: Must specify either --instance-groups or '
+ '\naws: [ERROR]: An error occurred (ParamValidation): Must specify either --instance-groups or '
'--instance-type with --instance-count(optional) to '
'configure instance groups.\n'
)
@@ -744,7 +744,7 @@ def test_instance_groups_exclusive_parameter_validation_error(self):
+ DEFAULT_INSTANCE_GROUPS_ARG
)
expect_error_msg = (
- '\naws: [ERROR]: You may not specify --instance-type '
+ '\naws: [ERROR]: An error occurred (ParamValidation): You may not specify --instance-type '
'or --instance-count with --instance-groups, '
'because --instance-type and --instance-count are '
'shortcut options for --instance-groups.\n'
@@ -758,7 +758,7 @@ def test_instance_groups_exclusive_parameter_validation_error(self):
'--instance-groups ' + DEFAULT_INSTANCE_GROUPS_ARG
)
expect_error_msg = (
- '\naws: [ERROR]: You may not specify --instance-type '
+ '\naws: [ERROR]: An error occurred (ParamValidation): You may not specify --instance-type '
'or --instance-count with --instance-groups, '
'because --instance-type and --instance-count are '
'shortcut options for --instance-groups.\n'
@@ -927,7 +927,7 @@ def test_ec2_attributes_subnet_az_error(self):
+ 'SubnetId=subnet-123456,AvailabilityZone=us-east-1a'
)
expect_error_msg = (
- '\naws: [ERROR]: You may not specify both a SubnetId and an Availab'
+ '\naws: [ERROR]: An error occurred (ParamValidation): You may not specify both a SubnetId and an Availab'
'ilityZone (placement) because ec2SubnetId implies a placement.\n'
)
result = self.run_cmd(cmd, 252)
@@ -993,7 +993,7 @@ def test_bootstrap_actions_exceed_maximum_error(self):
cmd += ba_cmd
expected_error_msg = (
- '\naws: [ERROR]: maximum number of '
+ '\naws: [ERROR]: An error occurred (ParamValidation): maximum number of '
+ 'bootstrap actions for a cluster exceeded.\n'
)
result = self.run_cmd(cmd, 252)
@@ -1006,7 +1006,7 @@ def test_bootstrap_actions_exceed_maximum_with_applications_error(self):
for i in range(1, 20):
cmd += ba_cmd
expected_error_msg = (
- '\naws: [ERROR]: maximum number of '
+ '\naws: [ERROR]: An error occurred (ParamValidation): maximum number of '
+ 'bootstrap actions for a cluster exceeded.\n'
)
result = self.run_cmd(cmd, 252)
@@ -1057,7 +1057,7 @@ def test_bootstrap_actions_from_json_file(self):
def test_wrong_step_type_error(self):
cmd = DEFAULT_CMD + '--steps Type=unknown'
expected_error_msg = (
- '\naws: [ERROR]: The step type unknown is not supported.\n'
+ '\naws: [ERROR]: An error occurred (ParamValidation): The step type unknown is not supported.\n'
)
result = self.run_cmd(cmd, 252)
self.assertEqual(expected_error_msg, result[1])
@@ -1071,7 +1071,7 @@ def test_default_step_type_name_action_on_failure(self):
def test_custom_jar_step_missing_jar(self):
cmd = DEFAULT_CMD + '--steps Name=CustomJarMissingJar'
expect_error_msg = (
- '\naws: [ERROR]: The following '
+ '\naws: [ERROR]: An error occurred (ParamValidation): The following '
+ 'required parameters are missing for CustomJARStepConfig: Jar.\n'
)
result = self.run_cmd(cmd, 252)
@@ -1118,7 +1118,7 @@ def test_streaming_step_with_default_fields(self):
def test_streaming_step_missing_args(self):
cmd = DEFAULT_CMD + '--steps Type=Streaming'
expect_error_msg = (
- '\naws: [ERROR]: The following '
+ '\naws: [ERROR]: An error occurred (ParamValidation): The following '
+ 'required parameters are missing for StreamingStepConfig: Args.\n'
)
result = self.run_cmd(cmd, 252)
@@ -1152,7 +1152,7 @@ def test_hive_step_with_default_fields(self):
def test_hive_step_missing_args(self):
cmd = DEFAULT_CMD + '--applications Name=Hive --steps Type=Hive'
expect_error_msg = (
- '\naws: [ERROR]: The following '
+ '\naws: [ERROR]: An error occurred (ParamValidation): The following '
+ 'required parameters are missing for HiveStepConfig: Args.\n'
)
result = self.run_cmd(cmd, 252)
@@ -1183,7 +1183,7 @@ def test_pig_step_with_default_fields(self):
def test_pig_missing_args(self):
cmd = DEFAULT_CMD + '--applications Name=Pig --steps Type=Pig'
expect_error_msg = (
- '\naws: [ERROR]: The following '
+ '\naws: [ERROR]: An error occurred (ParamValidation): The following '
+ 'required parameters are missing for PigStepConfig: Args.\n'
)
result = self.run_cmd(cmd, 252)
@@ -1331,7 +1331,7 @@ def test_instance_group_with_autoscaling_policy_missing_autoscaling_role(
+ CONSTANTS.INSTANCE_GROUPS_WITH_AUTOSCALING_POLICY_ARG
)
expected_error_msg = (
- '\naws: [ERROR]: Must specify --auto-scaling-role when'
+ '\naws: [ERROR]: An error occurred (ParamValidation): Must specify --auto-scaling-role when'
' configuring an AutoScaling policy for an instance group.\n'
)
result = self.run_cmd(cmd, 252)
@@ -1582,7 +1582,7 @@ def test_instance_fleets_with_both_fleet_group_specified(self):
+ CONSTANTS.INSTANCE_GROUPS_WITH_EBS_VOLUME_ARG
)
expected_error_msg = (
- '\naws: [ERROR]: You cannot specify both --instance-groups'
+ '\naws: [ERROR]: An error occurred (ParamValidation): You cannot specify both --instance-groups'
' and --instance-fleets options together.\n'
)
result = self.run_cmd(cmd, 252)
@@ -1596,7 +1596,7 @@ def test_instance_fleets_with_both_subnetid_subnetids_specified(self):
+ ' --ec2-attributes SubnetId=subnetid-1,SubnetIds=[subnetid-1,subnetid-2]'
)
expected_error_msg = (
- '\naws: [ERROR]: You cannot specify both SubnetId'
+ '\naws: [ERROR]: An error occurred (ParamValidation): You cannot specify both SubnetId'
' and SubnetIds options together.\n'
)
result = self.run_cmd(cmd, 252)
diff --git a/tests/unit/customizations/emr/test_create_hbase_backup.py b/tests/unit/customizations/emr/test_create_hbase_backup.py
index f5bcaa883b1e..b8193f0bddab 100644
--- a/tests/unit/customizations/emr/test_create_hbase_backup.py
+++ b/tests/unit/customizations/emr/test_create_hbase_backup.py
@@ -62,7 +62,7 @@ def test_unsupported_command_on_release_based_cluster_error(
args = ' --cluster-id j-ABCD --dir s3://abc/'
cmdline = self.prefix + args
expected_error_msg = (
- "\naws: [ERROR]: create-hbase-backup"
+ "\naws: [ERROR]: An error occurred (ParamValidation): create-hbase-backup"
" is not supported with 'emr-4.0' release.\n"
)
result = self.run_cmd(cmdline, 252)
diff --git a/tests/unit/customizations/emr/test_disable_hbase_backup.py b/tests/unit/customizations/emr/test_disable_hbase_backup.py
index 5ecd5a9b3d6f..1b83a4bd700c 100644
--- a/tests/unit/customizations/emr/test_disable_hbase_backup.py
+++ b/tests/unit/customizations/emr/test_disable_hbase_backup.py
@@ -73,7 +73,7 @@ def test_disable_hbase_backups_none(self):
args = ' --cluster-id j-ABCD'
cmdline = self.prefix + args
expected_error_msg = (
- '\naws: [ERROR]: Should specify at least one of --full' + ' and --incremental.\n'
+ '\naws: [ERROR]: An error occurred (ParamValidation): Should specify at least one of --full' + ' and --incremental.\n'
)
result = self.run_cmd(cmdline, 252)
@@ -87,7 +87,7 @@ def test_unsupported_command_on_release_based_cluster_error(
args = ' --cluster-id j-ABCD --full'
cmdline = self.prefix + args
expected_error_msg = (
- "\naws: [ERROR]: disable-hbase-backups"
+ "\naws: [ERROR]: An error occurred (ParamValidation): disable-hbase-backups"
" is not supported with 'emr-4.0' release.\n"
)
result = self.run_cmd(cmdline, 252)
diff --git a/tests/unit/customizations/emr/test_install_applications.py b/tests/unit/customizations/emr/test_install_applications.py
index eb623a4f7fb6..fb2acd07a750 100644
--- a/tests/unit/customizations/emr/test_install_applications.py
+++ b/tests/unit/customizations/emr/test_install_applications.py
@@ -120,7 +120,7 @@ def test_install_impala_error(self):
cmdline = self.prefix + ' Name=Impala'
expected_error_msg = (
- "\naws: [ERROR]: Impala cannot be installed on"
+ "\naws: [ERROR]: An error occurred (ParamValidation): Impala cannot be installed on"
+ " a running cluster. 'Name' should be one of the following:"
+ " HIVE, PIG\n"
)
@@ -131,7 +131,7 @@ def test_install_unknown_app_error(self):
cmdline = self.prefix + 'Name=unknown'
expected_error_msg = (
- "\naws: [ERROR]: Unknown application: unknown."
+ "\naws: [ERROR]: An error occurred (ParamValidation): Unknown application: unknown."
+ " 'Name' should be one of the following: HIVE, PIG, HBASE,"
+ " GANGLIA, IMPALA, SPARK, MAPR, MAPR_M3, MAPR_M5, MAPR_M7\n"
)
@@ -149,7 +149,7 @@ def test_unsupported_command_on_release_based_cluster_error(
)
expected_error_msg = (
- "\naws: [ERROR]: install-applications"
+ "\naws: [ERROR]: An error occurred (ParamValidation): install-applications"
" is not supported with 'emr-4.0' release.\n"
)
result = self.run_cmd(cmdline, 252)
diff --git a/tests/unit/customizations/emr/test_list_clusters.py b/tests/unit/customizations/emr/test_list_clusters.py
index fc7ab1247a64..1b9c832e1459 100644
--- a/tests/unit/customizations/emr/test_list_clusters.py
+++ b/tests/unit/customizations/emr/test_list_clusters.py
@@ -58,7 +58,7 @@ def test_exclusive_states_filters(self):
args = '--active --failed'
cmdline = self.prefix + args
expected_error_msg = (
- '\naws: [ERROR]: You can specify only one of the cluster state '
+ '\naws: [ERROR]: An error occurred (ParamValidation): You can specify only one of the cluster state '
'filters: --cluster-states, --active, --terminated, --failed.\n'
)
result = self.run_cmd(cmdline, 252)
@@ -67,7 +67,7 @@ def test_exclusive_states_filters(self):
args = '--cluster-states STARTING RUNNING --terminated'
cmdline = self.prefix + args
expected_error_msg = (
- '\naws: [ERROR]: You can specify only one of the cluster state '
+ '\naws: [ERROR]: An error occurred (ParamValidation): You can specify only one of the cluster state '
'filters: --cluster-states, --active, --terminated, --failed.\n'
)
result = self.run_cmd(cmdline, 252)
diff --git a/tests/unit/customizations/emr/test_modify_cluster_attributes.py b/tests/unit/customizations/emr/test_modify_cluster_attributes.py
index 98f6b5fe6771..b7431a70a6ef 100644
--- a/tests/unit/customizations/emr/test_modify_cluster_attributes.py
+++ b/tests/unit/customizations/emr/test_modify_cluster_attributes.py
@@ -85,7 +85,7 @@ def test_visible_to_all_and_no_visible_to_all(self):
)
cmdline = self.prefix + args
expected_error_msg = (
- '\naws: [ERROR]: You cannot specify both --visible-to-all-users '
+ '\naws: [ERROR]: An error occurred (ParamValidation): You cannot specify both --visible-to-all-users '
'and --no-visible-to-all-users options together.\n'
)
result = self.run_cmd(cmdline, 252)
@@ -98,7 +98,7 @@ def test_temination_protected_and_no_termination_protected(self):
)
cmdline = self.prefix + args
expected_error_msg = (
- '\naws: [ERROR]: You cannot specify both --termination-protected '
+ '\naws: [ERROR]: An error occurred (ParamValidation): You cannot specify both --termination-protected '
'and --no-termination-protected options together.\n'
)
result = self.run_cmd(cmdline, 252)
@@ -110,7 +110,7 @@ def test_auto_terminate_and_no_auto_terminate(self):
)
cmdline = self.prefix + args
expected_error_msg = (
- '\naws: [ERROR]: You cannot specify both --auto-terminate '
+ '\naws: [ERROR]: An error occurred (ParamValidation): You cannot specify both --auto-terminate '
'and --no-auto-terminate options together.\n'
)
result = self.run_cmd(cmdline, 252)
@@ -178,7 +178,7 @@ def test_at_least_one_option(self):
args = ' --cluster-id j-ABC123456'
cmdline = self.prefix + args
expected_error_msg = (
- '\naws: [ERROR]: Must specify one of the following boolean options: '
+ '\naws: [ERROR]: An error occurred (ParamValidation): Must specify one of the following boolean options: '
'--visible-to-all-users|--no-visible-to-all-users, '
'--termination-protected|--no-termination-protected, '
'--auto-terminate|--no-auto-terminate, '
diff --git a/tests/unit/customizations/emr/test_restore_from_hbase_backup.py b/tests/unit/customizations/emr/test_restore_from_hbase_backup.py
index feb00ee3b39e..a34a062c6fd2 100644
--- a/tests/unit/customizations/emr/test_restore_from_hbase_backup.py
+++ b/tests/unit/customizations/emr/test_restore_from_hbase_backup.py
@@ -63,7 +63,7 @@ def test_unsupported_command_on_release_based_cluster_error(
args = ' --cluster-id j-ABCD --dir s3://abc/'
cmdline = self.prefix + args
expected_error_msg = (
- "\naws: [ERROR]: restore-from-hbase-backup"
+ "\naws: [ERROR]: An error occurred (ParamValidation): restore-from-hbase-backup"
" is not supported with 'emr-4.0' release.\n"
)
result = self.run_cmd(cmdline, 252)
diff --git a/tests/unit/customizations/emr/test_schedule_hbase_backup.py b/tests/unit/customizations/emr/test_schedule_hbase_backup.py
index 636e726ff816..a7c04efaf6b5 100644
--- a/tests/unit/customizations/emr/test_schedule_hbase_backup.py
+++ b/tests/unit/customizations/emr/test_schedule_hbase_backup.py
@@ -104,7 +104,7 @@ def test_schedule_hbase_backup_wrong_type(self):
)
cmdline = self.prefix + args
expected_error_msg = (
- '\naws: [ERROR]: invalid type. type should be'
+ '\naws: [ERROR]: An error occurred (ParamValidation): invalid type. type should be'
+ ' either full or incremental.\n'
)
result = self.run_cmd(cmdline, 252)
@@ -118,7 +118,7 @@ def test_schedule_hbase_backup_wrong_unit(self):
)
cmdline = self.prefix + args
expected_error_msg = (
- '\naws: [ERROR]: invalid unit. unit should be'
+ '\naws: [ERROR]: An error occurred (ParamValidation): invalid unit. unit should be'
+ ' one of the following values: minutes,'
+ ' hours or days.\n'
)
@@ -163,7 +163,7 @@ def test_unsupported_command_on_release_based_cluster_error(
)
cmdline = self.prefix + args
expected_error_msg = (
- "\naws: [ERROR]: schedule-hbase-backup"
+ "\naws: [ERROR]: An error occurred (ParamValidation): schedule-hbase-backup"
" is not supported with 'emr-4.0' release.\n"
)
result = self.run_cmd(cmdline, 252)
diff --git a/tests/unit/test_clidriver.py b/tests/unit/test_clidriver.py
index 3fa7882607c8..645a27ef5c1b 100644
--- a/tests/unit/test_clidriver.py
+++ b/tests/unit/test_clidriver.py
@@ -725,7 +725,8 @@ def raise_exception(*args, **kwargs):
driver.main('ec2 describe-instances'.split())
self.assertEqual(
f.write.call_args_list[1][0][0],
- 'aws: [ERROR]: Unable to locate credentials. '
+ 'aws: [ERROR]: An error occurred (NoCredentials): '
+ 'Unable to locate credentials. '
'You can configure credentials by running "aws login".',
)
diff --git a/tests/unit/test_formatter.py b/tests/unit/test_formatter.py
index db24f1946754..cefad104af4d 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,28 @@ 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_response(self):
+ response = {'Key': 'Value'}
+ 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..00fcd33eb2ce
--- /dev/null
+++ b/tests/unit/test_structured_error.py
@@ -0,0 +1,743 @@
+# 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
+import json
+import signal
+from unittest import mock
+
+from ruamel.yaml import YAML
+from botocore.exceptions import ClientError, NoCredentialsError, NoRegionError
+
+from awscli.arguments import UnknownArgumentError
+from awscli.constants import (
+ CLIENT_ERROR_RC,
+ CONFIGURATION_ERROR_RC,
+ PARAM_VALIDATION_ERROR_RC,
+)
+from awscli.customizations.exceptions import (
+ ConfigurationError,
+ ParamValidationError,
+)
+from awscli.errorhandler import (
+ ClientErrorHandler,
+ EnhancedErrorFormatter,
+ construct_cli_error_handlers_chain,
+)
+from awscli.utils import PagerInitializationException
+from tests.unit.test_clidriver import FakeSession
+
+
+class TestClientErrorHandler:
+ def setup_method(self):
+ self.session = FakeSession()
+ self.handler = ClientErrorHandler(self.session)
+
+ 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
+ expected = (
+ '\n'
+ 'aws: [ERROR]: An error occurred (NoSuchBucket) '
+ 'when calling the GetObject operation: Error\n'
+ '\n'
+ 'Additional error details:\n'
+ 'BucketName: my-bucket\n'
+ )
+ assert stderr.getvalue() == expected
+
+ 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
+ expected = (
+ '\n'
+ 'aws: [ERROR]: An error occurred (AccessDenied) '
+ 'when calling the GetObject operation: Access Denied\n'
+ )
+ assert stderr.getvalue() == expected
+
+ 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
+ expected = (
+ '\n'
+ 'aws: [ERROR]: An error occurred (NoSuchBucket) '
+ 'when calling the GetObject operation: Error\n'
+ )
+ assert stderr.getvalue() == expected
+
+ def test_error_format_case_insensitive(self):
+ 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
+ expected = (
+ '\n'
+ 'aws: [ERROR]: An error occurred (NoSuchBucket) '
+ 'when calling the GetObject operation: Error\n'
+ '\n'
+ 'Additional error details:\n'
+ 'BucketName: test\n'
+ )
+ assert stderr.getvalue() == expected
+
+
+class TestEnhancedErrorFormatter:
+ def setup_method(self):
+ self.formatter = EnhancedErrorFormatter()
+
+ def test_format_error_with_no_additional_fields(self):
+ error_info = {
+ 'Code': 'AccessDenied',
+ 'Message': 'Access Denied',
+ }
+
+ stream = io.StringIO()
+ self.formatter.format_error(error_info, stream)
+
+ output = stream.getvalue()
+ assert 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',
+ }
+
+ stream = io.StringIO()
+ self.formatter.format_error(error_info, stream)
+
+ output = stream.getvalue()
+ expected = (
+ '\n'
+ 'Additional error details:\n'
+ 'BucketName: my-bucket\n'
+ 'Region: us-east-1\n'
+ )
+ assert output == expected
+
+ def test_format_error_with_small_list(self):
+ error_info = {
+ 'Code': 'ValidationError',
+ 'Message': 'Validation failed',
+ 'AllowedValues': ['value1', 'value2', 'value3'],
+ }
+
+ stream = io.StringIO()
+ self.formatter.format_error(error_info, stream)
+
+ output = stream.getvalue()
+ expected = (
+ '\n'
+ 'Additional error details:\n'
+ 'AllowedValues: [value1, value2, value3]\n'
+ )
+ assert output == expected
+
+ def test_format_error_with_small_dict(self):
+ error_info = {
+ 'Code': 'ValidationError',
+ 'Message': 'Validation failed',
+ 'Metadata': {'key1': 'value1', 'key2': 'value2'},
+ }
+
+ stream = io.StringIO()
+ self.formatter.format_error(error_info, stream)
+
+ output = stream.getvalue()
+ expected = (
+ '\n'
+ 'Additional error details:\n'
+ 'Metadata: {key1: value1, key2: value2}\n'
+ )
+ assert output == expected
+
+ def test_format_error_with_complex_object(self):
+ error_info = {
+ 'Code': 'ValidationError',
+ 'Message': 'Validation failed',
+ 'Details': [1, 2, 3, 4, 5, 6],
+ }
+
+ stream = io.StringIO()
+ self.formatter.format_error(error_info, stream)
+
+ output = stream.getvalue()
+ expected = (
+ '\n'
+ 'Additional error details:\n'
+ 'Details: '
+ '(Use --cli-error-format with json or yaml to see full details)\n'
+ )
+ assert output == expected
+
+ def test_format_error_with_nested_dict(self):
+ error_info = {
+ 'Code': 'ValidationError',
+ 'Message': 'Validation failed',
+ 'FieldErrors': {
+ 'email': {'pattern': 'invalid', 'required': True},
+ 'age': {'min': 0, 'max': 120},
+ },
+ }
+
+ stream = io.StringIO()
+ self.formatter.format_error(error_info, stream)
+
+ output = stream.getvalue()
+ expected = (
+ '\n'
+ 'Additional error details:\n'
+ 'FieldErrors: '
+ '(Use --cli-error-format with json or yaml to see full details)\n'
+ )
+ assert output == expected
+
+ def test_format_error_with_list_of_dicts(self):
+ error_info = {
+ 'Code': 'TransactionCanceledException',
+ 'Message': 'Transaction cancelled',
+ 'CancellationReasons': [
+ {
+ 'Code': 'ConditionalCheckFailed',
+ 'Message': 'Check failed',
+ },
+ {
+ 'Code': 'ItemCollectionSizeLimitExceeded',
+ 'Message': 'Too large',
+ },
+ ],
+ }
+
+ stream = io.StringIO()
+ self.formatter.format_error(error_info, stream)
+
+ output = stream.getvalue()
+ expected = (
+ '\n'
+ 'Additional error details:\n'
+ 'CancellationReasons: '
+ '(Use --cli-error-format with json or yaml to see full details)\n'
+ )
+ assert output == expected
+
+ def test_format_error_with_mixed_types(self):
+ error_info = {
+ 'Code': 'ComplexError',
+ 'Message': 'Complex error occurred',
+ 'StringField': 'test-value',
+ 'IntField': 42,
+ 'FloatField': 3.14,
+ 'BoolField': True,
+ 'NoneField': None,
+ }
+
+ stream = io.StringIO()
+ self.formatter.format_error(error_info, stream)
+
+ output = stream.getvalue()
+ expected = (
+ '\n'
+ 'Additional error details:\n'
+ 'StringField: test-value\n'
+ 'IntField: 42\n'
+ 'FloatField: 3.14\n'
+ 'BoolField: True\n'
+ 'NoneField: None\n'
+ )
+ assert output == expected
+
+ def test_format_error_with_unicode_and_special_chars(self):
+ error_info = {
+ 'Code': 'InvalidInput',
+ 'Message': 'Invalid input provided',
+ 'UserName': 'éîa',
+ 'Description': 'Error with "quotes" and \'apostrophes\'',
+ 'Path': '/path/to/file.txt',
+ }
+
+ stream = io.StringIO()
+ self.formatter.format_error(error_info, stream)
+
+ output = stream.getvalue()
+ expected = (
+ '\n'
+ 'Additional error details:\n'
+ 'UserName: éîa\n'
+ 'Description: Error with "quotes" and \'apostrophes\'\n'
+ 'Path: /path/to/file.txt\n'
+ )
+ assert output == expected
+
+ def test_format_error_with_large_list(self):
+ error_info = {
+ 'Code': 'LargeList',
+ 'Message': 'Large list error',
+ 'Items': list(range(10)),
+ }
+
+ stream = io.StringIO()
+ self.formatter.format_error(error_info, stream)
+
+ output = stream.getvalue()
+ expected = (
+ '\n'
+ 'Additional error details:\n'
+ 'Items: '
+ '(Use --cli-error-format with json or yaml to see full details)\n'
+ )
+ assert output == expected
+
+
+class TestRealWorldErrorScenarios:
+ def setup_method(self):
+ self.session = FakeSession()
+ self.handler = ClientErrorHandler(self.session)
+
+ def test_dynamodb_transaction_cancelled_error(self):
+ 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
+ expected = (
+ '\n'
+ 'aws: [ERROR]: An error occurred (TransactionCanceledException) '
+ 'when calling the TransactWriteItems operation: '
+ 'Transaction cancelled, please refer to '
+ 'CancellationReasons for specific reasons\n'
+ '\n'
+ 'Additional error details:\n'
+ 'CancellationReasons: '
+ '(Use --cli-error-format with json or yaml to see full details)\n'
+ )
+ assert stderr.getvalue() == expected
+
+
+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'
+ parsed_globals.query = None
+
+ error_handler = construct_cli_error_handlers_chain(session)
+
+ 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, parsed_globals=parsed_globals
+ )
+
+ assert rc == CLIENT_ERROR_RC
+
+ stderr_output = stderr.getvalue()
+ parsed_json = json.loads(stderr_output)
+ assert parsed_json['Code'] == 'NoSuchBucket'
+ assert parsed_json['Message'] == 'The specified bucket does not exist'
+ assert parsed_json['BucketName'] == 'test-bucket'
+
+ def test_error_handler_without_parsed_globals_uses_default(self):
+ session = FakeSession()
+
+ error_handler = construct_cli_error_handlers_chain(session)
+
+ 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, parsed_globals=None
+ )
+
+ assert rc == CLIENT_ERROR_RC
+ expected = (
+ '\n'
+ 'aws: [ERROR]: An error occurred (NoSuchBucket) '
+ 'when calling the GetObject operation: '
+ 'The specified bucket does not exist\n'
+ '\n'
+ 'Additional error details:\n'
+ 'BucketName: test-bucket\n'
+ )
+ assert stderr.getvalue() == expected
+
+
+class TestNonModeledErrorStructuredFormatting:
+ def setup_method(self):
+ self.yaml = YAML(typ="safe", pure=True)
+
+ def _load_yaml(self, content):
+ return self.yaml.load(io.StringIO(content))
+
+ def test_no_region_error_with_json_format(self):
+ session = FakeSession()
+ error_handler = construct_cli_error_handlers_chain(session)
+ exception = NoRegionError()
+
+ stdout = io.StringIO()
+ stderr = io.StringIO()
+ parsed_globals = argparse.Namespace()
+ parsed_globals.cli_error_format = 'json'
+ parsed_globals.query = None
+ parsed_globals.color = 'auto'
+
+ rc = error_handler.handle_exception(
+ exception, stdout, stderr, parsed_globals=parsed_globals
+ )
+
+ assert rc == CONFIGURATION_ERROR_RC
+ stderr_output = stderr.getvalue()
+ parsed_json = json.loads(stderr_output)
+ assert parsed_json['Code'] == 'NoRegion'
+ assert 'aws configure' in parsed_json['Message']
+
+ def test_no_credentials_error_with_yaml_format(self):
+ session = FakeSession()
+ error_handler = construct_cli_error_handlers_chain(session)
+ exception = NoCredentialsError()
+
+ stdout = io.StringIO()
+ stderr = io.StringIO()
+ parsed_globals = argparse.Namespace()
+ parsed_globals.cli_error_format = 'yaml'
+ parsed_globals.query = None
+ parsed_globals.color = 'auto'
+
+ rc = error_handler.handle_exception(
+ exception, stdout, stderr, parsed_globals=parsed_globals
+ )
+
+ assert rc == CONFIGURATION_ERROR_RC
+ stderr_output = stderr.getvalue()
+ parsed_yaml = self._load_yaml(stderr_output)
+ assert parsed_yaml['Code'] == 'NoCredentials'
+ assert (
+ 'aws' in parsed_yaml['Message']
+ and 'login' in parsed_yaml['Message']
+ )
+
+ def test_configuration_error_with_enhanced_format(self):
+ session = FakeSession()
+
+ error_handler = construct_cli_error_handlers_chain(session)
+ exception = ConfigurationError('Invalid configuration value')
+
+ stdout = io.StringIO()
+ stderr = io.StringIO()
+ parsed_globals = argparse.Namespace()
+ parsed_globals.cli_error_format = 'enhanced'
+ parsed_globals.query = None
+ parsed_globals.color = 'auto'
+
+ rc = error_handler.handle_exception(
+ exception, stdout, stderr, parsed_globals=parsed_globals
+ )
+
+ assert rc == CONFIGURATION_ERROR_RC
+ expected = (
+ '\n'
+ 'aws: [ERROR]: An error occurred (Configuration): '
+ 'Invalid configuration value\n'
+ )
+ assert stderr.getvalue() == expected
+
+ def test_pager_error_with_json_format(self):
+ session = FakeSession()
+
+ error_handler = construct_cli_error_handlers_chain(session)
+ exception = PagerInitializationException('Pager not found')
+
+ stdout = io.StringIO()
+ stderr = io.StringIO()
+ parsed_globals = argparse.Namespace()
+ parsed_globals.cli_error_format = 'json'
+ parsed_globals.query = None
+ parsed_globals.color = 'auto'
+
+ rc = error_handler.handle_exception(
+ exception, stdout, stderr, parsed_globals=parsed_globals
+ )
+
+ assert rc == CONFIGURATION_ERROR_RC
+ stderr_output = stderr.getvalue()
+ parsed_json = json.loads(stderr_output)
+ assert parsed_json['Code'] == 'Pager'
+ assert 'Unable to redirect output to pager' in parsed_json['Message']
+
+ def test_param_validation_error_with_yaml_format(self):
+ session = FakeSession()
+
+ error_handler = construct_cli_error_handlers_chain(session)
+ exception = ParamValidationError('Invalid parameter value')
+
+ stdout = io.StringIO()
+ stderr = io.StringIO()
+ parsed_globals = argparse.Namespace()
+ parsed_globals.cli_error_format = 'yaml'
+ parsed_globals.query = None
+ parsed_globals.color = 'auto'
+
+ rc = error_handler.handle_exception(
+ exception, stdout, stderr, parsed_globals=parsed_globals
+ )
+
+ assert rc == PARAM_VALIDATION_ERROR_RC
+ stderr_output = stderr.getvalue()
+ parsed_yaml = self._load_yaml(stderr_output)
+ assert parsed_yaml['Code'] == 'ParamValidation'
+ assert 'Invalid parameter value' in parsed_yaml['Message']
+
+ def test_error_codes_without_error_suffix(self):
+ session = FakeSession()
+ error_handler = construct_cli_error_handlers_chain(session)
+
+ parsed_globals = argparse.Namespace()
+ parsed_globals.cli_error_format = 'json'
+ parsed_globals.query = None
+ parsed_globals.color = 'auto'
+
+ test_cases = [
+ (NoRegionError(), 'NoRegion'),
+ (NoCredentialsError(), 'NoCredentials'),
+ (ConfigurationError('test'), 'Configuration'),
+ (PagerInitializationException('test'), 'Pager'),
+ (ParamValidationError('test'), 'ParamValidation'),
+ ]
+
+ for exception, expected_code in test_cases:
+ stdout = io.StringIO()
+ stderr = io.StringIO()
+
+ error_handler.handle_exception(
+ exception, stdout, stderr, parsed_globals=parsed_globals
+ )
+
+ stderr_output = stderr.getvalue()
+ parsed_json = json.loads(stderr_output)
+ assert parsed_json['Code'] == expected_code
+
+ def test_unknown_argument_error_remains_plain_text(self):
+ session = FakeSession()
+ error_handler = construct_cli_error_handlers_chain(session)
+ exception = UnknownArgumentError('--invalid-arg')
+
+ stdout = io.StringIO()
+ stderr = io.StringIO()
+ parsed_globals = argparse.Namespace()
+ parsed_globals.cli_error_format = 'json'
+ parsed_globals.query = None
+ parsed_globals.color = 'auto'
+
+ rc = error_handler.handle_exception(
+ exception, stdout, stderr, parsed_globals=parsed_globals
+ )
+
+ assert rc == PARAM_VALIDATION_ERROR_RC
+ expected = (
+ '\n'
+ 'usage: aws [options] '
+ '[ ...] [parameters]\n'
+ 'To see help text, you can run:\n'
+ '\n'
+ ' aws help\n'
+ ' aws help\n'
+ ' aws help\n'
+ '\n'
+ '\n'
+ 'aws: [ERROR]: --invalid-arg\n'
+ )
+ assert stderr.getvalue() == expected
+
+ def test_legacy_format_uses_plain_text(self):
+ session = FakeSession()
+ error_handler = construct_cli_error_handlers_chain(session)
+ exception = NoRegionError()
+
+ stdout = io.StringIO()
+ stderr = io.StringIO()
+ parsed_globals = argparse.Namespace()
+ parsed_globals.cli_error_format = 'legacy'
+ parsed_globals.query = None
+ parsed_globals.color = 'auto'
+
+ rc = error_handler.handle_exception(
+ exception, stdout, stderr, parsed_globals=parsed_globals
+ )
+
+ assert rc == CONFIGURATION_ERROR_RC
+ expected = (
+ '\n'
+ 'aws: [ERROR]: You must specify a region. You can also '
+ 'configure your region by running "aws configure".\n'
+ )
+ assert stderr.getvalue() == expected
+
+ def test_enhanced_format_includes_error_prefix(self):
+ session = FakeSession()
+ error_handler = construct_cli_error_handlers_chain(session)
+ exception = NoRegionError()
+
+ stdout = io.StringIO()
+ stderr = io.StringIO()
+ parsed_globals = argparse.Namespace()
+ parsed_globals.cli_error_format = 'enhanced'
+ parsed_globals.query = None
+ parsed_globals.color = 'auto'
+
+ rc = error_handler.handle_exception(
+ exception, stdout, stderr, parsed_globals=parsed_globals
+ )
+
+ assert rc == CONFIGURATION_ERROR_RC
+ expected = (
+ '\n'
+ 'aws: [ERROR]: An error occurred (NoRegion): '
+ 'You must specify a region. You can also configure your region '
+ 'by running "aws configure".\n'
+ )
+ assert stderr.getvalue() == expected
+
+ def test_interrupt_exception_remains_plain_text(self):
+ session = FakeSession()
+ error_handler = construct_cli_error_handlers_chain(session)
+ exception = KeyboardInterrupt()
+
+ stdout = io.StringIO()
+ stderr = io.StringIO()
+ parsed_globals = argparse.Namespace()
+ parsed_globals.cli_error_format = 'json'
+ parsed_globals.query = None
+ parsed_globals.color = 'auto'
+
+ rc = error_handler.handle_exception(
+ exception, stdout, stderr, parsed_globals=parsed_globals
+ )
+
+ assert rc == 128 + signal.SIGINT
+ assert stdout.getvalue() == "\n"
+ assert stderr.getvalue() == ""