From 66be5366f4207f305d7a8a94ff80704ef343d92e Mon Sep 17 00:00:00 2001 From: veradri Date: Mon, 2 Feb 2026 00:32:40 -0800 Subject: [PATCH 1/8] Fix argparse error for BooleanArgument with % in documentation Python 3.14+ argparse treats % characters in help strings as format specifiers. When service model documentation contains % (e.g., IAM's UpdateAccountPasswordPolicy RequireSymbols parameter), argparse raises: ValueError: unsupported format character '^' (0x5e) at index 129 This was already fixed for CLIArgument in PR #9790 but BooleanArgument was missed. This commit adds the same .replace('%', '%%') escaping to BooleanArgument.add_to_parser() and adds a test to verify all service operations with % in documentation work correctly. Fixes compatibility with Python 3.14+ --- awscli/arguments.py | 2 +- tests/unit/test_argprocess.py | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/awscli/arguments.py b/awscli/arguments.py index 686253ad0f6a..f9f75fb57ffe 100644 --- a/awscli/arguments.py +++ b/awscli/arguments.py @@ -593,7 +593,7 @@ def add_to_arg_table(self, argument_table): def add_to_parser(self, parser): parser.add_argument( self.cli_name, - help=self.documentation, + help=self.documentation.replace('%', '%%'), action=self._action, default=self._default, dest=self._destination, diff --git a/tests/unit/test_argprocess.py b/tests/unit/test_argprocess.py index fd9e6e063908..9356128e1308 100644 --- a/tests/unit/test_argprocess.py +++ b/tests/unit/test_argprocess.py @@ -32,6 +32,9 @@ from awscli.arguments import ListArgument, BooleanArgument from awscli.arguments import create_argument_model_from_schema +from awscli.clidriver import ServiceOperation, CLIOperationCaller +from awscli.argparser import ArgTableArgParser + # These tests use real service types so that we can # verify the real shapes of services. @@ -894,6 +897,28 @@ def test_json_value_decode_error(self): with self.assertRaises(ParamError): unpack_cli_arg(self.p, value) +class TestPercentInDocumentation(BaseArgProcessTest): + def test_percent_characters_escaped_in_argument_help(self): + operation_caller = CLIOperationCaller(self.session) + for service_name in self.session.get_available_services(): + service_model = self.session.get_service_model(service_name) + for operation_name in service_model.operation_names: + operation_model = service_model.operation_model(operation_name) + if not operation_model.input_shape: + continue + has_percent = any( + '%' in (member.documentation or '') + for member in operation_model.input_shape.members.values() + ) + if has_percent: + service_op = ServiceOperation( + name=xform_name(operation_name, '-'), + parent_name=service_name, + operation_caller=operation_caller, + operation_model=operation_model, + session=self.session, + ) + ArgTableArgParser(service_op.arg_table) if __name__ == '__main__': unittest.main() From 8113b5df03607a34e731349297ef07f27d94342e Mon Sep 17 00:00:00 2001 From: veradri Date: Mon, 2 Feb 2026 16:17:55 -0800 Subject: [PATCH 2/8] Improve test for percent escaping in argument documentation Replace expensive test that iterated through all services with focused unit tests that directly verify % character escaping behavior. The new tests use controlled test data to verify both CLIArgument and BooleanArgument properly escape % in symbols (% ^) and URL-encoded strings (%28%29) when passed to argparse. --- tests/unit/test_argprocess.py | 46 ++++++++++++++++------------------- 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/tests/unit/test_argprocess.py b/tests/unit/test_argprocess.py index 9356128e1308..dae9c49fd47d 100644 --- a/tests/unit/test_argprocess.py +++ b/tests/unit/test_argprocess.py @@ -32,9 +32,7 @@ from awscli.arguments import ListArgument, BooleanArgument from awscli.arguments import create_argument_model_from_schema -from awscli.clidriver import ServiceOperation, CLIOperationCaller -from awscli.argparser import ArgTableArgParser - +import argparse # These tests use real service types so that we can # verify the real shapes of services. @@ -454,6 +452,26 @@ def test_csv_syntax_errors(self): with self.assertRaisesRegex(ParamError, error_msg): self.parse_shorthand(p, ['ParameterKey=key,ParameterValue="foo,bar\'']) + def _test_argument_escapes_percent(self, arg_class, arg_type, doc_string, expected_substring): + argument = self.create_argument({'Test': {'type': arg_type}}) + argument.argument_model.members['Test'].documentation = doc_string + arg = arg_class('test-arg', argument.argument_model.members['Test'], None, False) + parser = argparse.ArgumentParser() + arg.add_to_parser(parser) + action = parser._actions[-1] + self.assertIn(expected_substring, action.help) + + def test_cli_argument_escapes_percent_in_symbols(self): + self._test_argument_escapes_percent(CLIArgument, 'string', 'Symbols: ! @ # $ % ^ & * ( )', '% ^') + + def test_cli_argument_escapes_percent_in_url_encoding(self): + self._test_argument_escapes_percent(CLIArgument, 'string', 'test_file%283%29.png', '%28') + + def test_boolean_argument_escapes_percent_in_symbols(self): + self._test_argument_escapes_percent(BooleanArgument, 'boolean', 'Symbols: ! @ # $ % ^ & * ( )', '% ^') + + def test_boolean_argument_escapes_percent_in_url_encoding(self): + self._test_argument_escapes_percent(BooleanArgument, 'boolean', 'test_file%283%29.png', '%28') class TestParamShorthandCustomArguments(BaseArgProcessTest): @@ -897,28 +915,6 @@ def test_json_value_decode_error(self): with self.assertRaises(ParamError): unpack_cli_arg(self.p, value) -class TestPercentInDocumentation(BaseArgProcessTest): - def test_percent_characters_escaped_in_argument_help(self): - operation_caller = CLIOperationCaller(self.session) - for service_name in self.session.get_available_services(): - service_model = self.session.get_service_model(service_name) - for operation_name in service_model.operation_names: - operation_model = service_model.operation_model(operation_name) - if not operation_model.input_shape: - continue - has_percent = any( - '%' in (member.documentation or '') - for member in operation_model.input_shape.members.values() - ) - if has_percent: - service_op = ServiceOperation( - name=xform_name(operation_name, '-'), - parent_name=service_name, - operation_caller=operation_caller, - operation_model=operation_model, - session=self.session, - ) - ArgTableArgParser(service_op.arg_table) if __name__ == '__main__': unittest.main() From f1a4c77b213363d2b9c32a0bb8e737ac6914c77b Mon Sep 17 00:00:00 2001 From: veradri Date: Tue, 3 Feb 2026 13:34:53 -0800 Subject: [PATCH 3/8] Use ArgTableArgParser in percent escaping tests Update tests to use AWS CLI's ArgTableArgParser instead of standard argparse.ArgumentParser to properly test the CLI's argument parser abstraction. Extract common test logic into helper method to reduce code duplication. --- tests/unit/test_argprocess.py | 44 ++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/tests/unit/test_argprocess.py b/tests/unit/test_argprocess.py index dae9c49fd47d..f83b9e4cd9b7 100644 --- a/tests/unit/test_argprocess.py +++ b/tests/unit/test_argprocess.py @@ -32,6 +32,8 @@ from awscli.arguments import ListArgument, BooleanArgument from awscli.arguments import create_argument_model_from_schema +from awscli.argparser import ArgTableArgParser + import argparse # These tests use real service types so that we can @@ -452,27 +454,6 @@ def test_csv_syntax_errors(self): with self.assertRaisesRegex(ParamError, error_msg): self.parse_shorthand(p, ['ParameterKey=key,ParameterValue="foo,bar\'']) - def _test_argument_escapes_percent(self, arg_class, arg_type, doc_string, expected_substring): - argument = self.create_argument({'Test': {'type': arg_type}}) - argument.argument_model.members['Test'].documentation = doc_string - arg = arg_class('test-arg', argument.argument_model.members['Test'], None, False) - parser = argparse.ArgumentParser() - arg.add_to_parser(parser) - action = parser._actions[-1] - self.assertIn(expected_substring, action.help) - - def test_cli_argument_escapes_percent_in_symbols(self): - self._test_argument_escapes_percent(CLIArgument, 'string', 'Symbols: ! @ # $ % ^ & * ( )', '% ^') - - def test_cli_argument_escapes_percent_in_url_encoding(self): - self._test_argument_escapes_percent(CLIArgument, 'string', 'test_file%283%29.png', '%28') - - def test_boolean_argument_escapes_percent_in_symbols(self): - self._test_argument_escapes_percent(BooleanArgument, 'boolean', 'Symbols: ! @ # $ % ^ & * ( )', '% ^') - - def test_boolean_argument_escapes_percent_in_url_encoding(self): - self._test_argument_escapes_percent(BooleanArgument, 'boolean', 'test_file%283%29.png', '%28') - class TestParamShorthandCustomArguments(BaseArgProcessTest): def setUp(self): @@ -915,6 +896,27 @@ def test_json_value_decode_error(self): with self.assertRaises(ParamError): unpack_cli_arg(self.p, value) +class TestArgumentPercentEscaping(BaseArgProcessTest): + def _test_percent_escaping(self, arg_type, arg_class, doc_string): + argument = self.create_argument({'Test': {'type': arg_type}}) + argument.argument_model.members['Test'].documentation = doc_string + arg = arg_class('test-arg', argument.argument_model.members['Test'], mock.Mock(), mock.Mock(), is_required=False) + arg_table = {arg.name: arg} + parser = ArgTableArgParser(arg_table) + help_output = parser.format_help() + self.assertIn(arg.cli_name, help_output) + + def test_cli_argument_escapes_percent(self): + self._test_percent_escaping('integer', CLIArgument, 'Symbols: % ^ & *') + + def test_boolean_argument_escapes_percent(self): + self._test_percent_escaping('boolean', BooleanArgument, 'Symbols: % ^ & *') + + def test_cli_argument_escapes_url_encoded_percent(self): + self._test_percent_escaping('integer', CLIArgument, 'File: test%28file%29.png') + + def test_boolean_argument_escapes_url_encoded_percent(self): + self._test_percent_escaping('boolean', BooleanArgument, 'File: test%28file%29.png') if __name__ == '__main__': unittest.main() From c2f2d4047d7fbec28259660a691155fc3976e20b Mon Sep 17 00:00:00 2001 From: veradri Date: Tue, 3 Feb 2026 13:37:15 -0800 Subject: [PATCH 4/8] removed import argparse --- tests/unit/test_argprocess.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/unit/test_argprocess.py b/tests/unit/test_argprocess.py index f83b9e4cd9b7..83dd70164bf2 100644 --- a/tests/unit/test_argprocess.py +++ b/tests/unit/test_argprocess.py @@ -34,8 +34,6 @@ from awscli.argparser import ArgTableArgParser -import argparse - # These tests use real service types so that we can # verify the real shapes of services. class BaseArgProcessTest(BaseCLIDriverTest): From 968a5a93b9c71d2af01c79ef248542cd43fab144 Mon Sep 17 00:00:00 2001 From: "Adrian D." <101290859+adev-code@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:33:26 -0800 Subject: [PATCH 5/8] Apply suggestion from @kdaily Co-authored-by: Kenneth Daily --- tests/unit/test_argprocess.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_argprocess.py b/tests/unit/test_argprocess.py index 83dd70164bf2..9db6b05b612d 100644 --- a/tests/unit/test_argprocess.py +++ b/tests/unit/test_argprocess.py @@ -896,8 +896,14 @@ def test_json_value_decode_error(self): class TestArgumentPercentEscaping(BaseArgProcessTest): def _test_percent_escaping(self, arg_type, arg_class, doc_string): - argument = self.create_argument({'Test': {'type': arg_type}}) - argument.argument_model.members['Test'].documentation = doc_string + argument = self.create_argument( + { + 'Test': { + 'type': arg_type, + 'documentation': doc_string, + } + } + ) arg = arg_class('test-arg', argument.argument_model.members['Test'], mock.Mock(), mock.Mock(), is_required=False) arg_table = {arg.name: arg} parser = ArgTableArgParser(arg_table) From 6d3db1a8fe0d410525f3ca22d0e3fa31db9c34a3 Mon Sep 17 00:00:00 2001 From: "Adrian D." <101290859+adev-code@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:33:55 -0800 Subject: [PATCH 6/8] Apply suggestion from @kdaily Co-authored-by: Kenneth Daily --- tests/unit/test_argprocess.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_argprocess.py b/tests/unit/test_argprocess.py index 9db6b05b612d..2ac35416e38d 100644 --- a/tests/unit/test_argprocess.py +++ b/tests/unit/test_argprocess.py @@ -904,7 +904,13 @@ def _test_percent_escaping(self, arg_type, arg_class, doc_string): } } ) - arg = arg_class('test-arg', argument.argument_model.members['Test'], mock.Mock(), mock.Mock(), is_required=False) + arg = arg_class( + 'test-arg', + argument.argument_model.members['Test'], + mock.Mock(), + mock.Mock(), + is_required=False, + ) arg_table = {arg.name: arg} parser = ArgTableArgParser(arg_table) help_output = parser.format_help() From ee03ebc60ea6d8e25d59dcdd9d0bb937af340f35 Mon Sep 17 00:00:00 2001 From: veradri Date: Wed, 4 Feb 2026 10:56:56 -0800 Subject: [PATCH 7/8] Improve percent escaping test assertion Change assertion to explicitly verify documentation string with % characters appears in help output, confirming percent escaping works properly. Also use 'string' type instead of 'integer' for better semantic clarity. --- tests/unit/test_argprocess.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_argprocess.py b/tests/unit/test_argprocess.py index 2ac35416e38d..b986fa270c21 100644 --- a/tests/unit/test_argprocess.py +++ b/tests/unit/test_argprocess.py @@ -894,6 +894,7 @@ def test_json_value_decode_error(self): with self.assertRaises(ParamError): unpack_cli_arg(self.p, value) + class TestArgumentPercentEscaping(BaseArgProcessTest): def _test_percent_escaping(self, arg_type, arg_class, doc_string): argument = self.create_argument( @@ -914,16 +915,16 @@ def _test_percent_escaping(self, arg_type, arg_class, doc_string): arg_table = {arg.name: arg} parser = ArgTableArgParser(arg_table) help_output = parser.format_help() - self.assertIn(arg.cli_name, help_output) + self.assertIn(doc_string, help_output) def test_cli_argument_escapes_percent(self): - self._test_percent_escaping('integer', CLIArgument, 'Symbols: % ^ & *') + self._test_percent_escaping('string', CLIArgument, 'Symbols: % ^ & *') def test_boolean_argument_escapes_percent(self): self._test_percent_escaping('boolean', BooleanArgument, 'Symbols: % ^ & *') def test_cli_argument_escapes_url_encoded_percent(self): - self._test_percent_escaping('integer', CLIArgument, 'File: test%28file%29.png') + self._test_percent_escaping('string', CLIArgument, 'File: test%28file%29.png') def test_boolean_argument_escapes_url_encoded_percent(self): self._test_percent_escaping('boolean', BooleanArgument, 'File: test%28file%29.png') From 0e4727a5936904131e715a7822f64e365b090650 Mon Sep 17 00:00:00 2001 From: veradri Date: Wed, 4 Feb 2026 10:58:46 -0800 Subject: [PATCH 8/8] added back space --- tests/unit/test_argprocess.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/test_argprocess.py b/tests/unit/test_argprocess.py index b986fa270c21..87fc1aa49972 100644 --- a/tests/unit/test_argprocess.py +++ b/tests/unit/test_argprocess.py @@ -452,6 +452,7 @@ def test_csv_syntax_errors(self): with self.assertRaisesRegex(ParamError, error_msg): self.parse_shorthand(p, ['ParameterKey=key,ParameterValue="foo,bar\'']) + class TestParamShorthandCustomArguments(BaseArgProcessTest): def setUp(self):