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() == ""