From ddf31b514d291d490963634f183d2b625bccba4b Mon Sep 17 00:00:00 2001 From: aemous Date: Mon, 11 May 2026 13:30:44 -0400 Subject: [PATCH 01/21] Make PrefixTrie public and implement LazyCommand class to defer loading plugins until commands are invoked. --- awscli/botocore/hooks.py | 4 +- awscli/lazy.py | 60 +++++++++++++ tests/functional/test_lazy.py | 87 +++++++++++++++++++ tests/unit/botocore/test_hooks.py | 54 +++++++++++- tests/unit/test_lazy.py | 139 ++++++++++++++++++++++++++++++ 5 files changed, 341 insertions(+), 3 deletions(-) create mode 100644 awscli/lazy.py create mode 100644 tests/functional/test_lazy.py create mode 100644 tests/unit/test_lazy.py diff --git a/awscli/botocore/hooks.py b/awscli/botocore/hooks.py index 90db67d6fe69..1530bbf158ee 100644 --- a/awscli/botocore/hooks.py +++ b/awscli/botocore/hooks.py @@ -196,7 +196,7 @@ def __init__(self): # read only access (we never modify self._handlers). # A cache of event name to handler list. self._lookup_cache = {} - self._handlers = _PrefixTrie() + self._handlers = PrefixTrie() # This is used to ensure that unique_id's are only # registered once. self._unique_id_handlers = {} @@ -398,7 +398,7 @@ def __copy__(self): return new_instance -class _PrefixTrie: +class PrefixTrie: """Specialized prefix trie that handles wildcards. The prefixes in this case are based on dot separated diff --git a/awscli/lazy.py b/awscli/lazy.py new file mode 100644 index 000000000000..4cad0964443d --- /dev/null +++ b/awscli/lazy.py @@ -0,0 +1,60 @@ +import importlib + +from awscli.commands import CLICommand + + +class LazyCommand(CLICommand): + """A command-table entry that defers importing its real implementation. + + Sits in the command table like any other CLICommand, but only imports + the actual module (and creates the real command object) when the command + is invoked or its help is accessed. + """ + + def __init__(self, name, session, module_path, class_name): + self._name = name + self._session = session + self._module_path = module_path + self._class_name = class_name + self._real = None + self._lineage = [self] + + def _resolve(self): + if self._real is None: + mod = importlib.import_module(self._module_path) + cls = getattr(mod, self._class_name) + self._real = cls(self._session) + self._real.lineage = self._lineage + return self._real + + def __call__(self, args, parsed_globals): + return self._resolve()(args, parsed_globals) + + def create_help_command(self): + return self._resolve().create_help_command() + + @property + def arg_table(self): + return self._resolve().arg_table + + @property + def subcommand_table(self): + return self._resolve().subcommand_table + + @property + def name(self): + return self._name + + @name.setter + def name(self, value): + self._name = value + + @property + def lineage(self): + return self._lineage + + @lineage.setter + def lineage(self, value): + self._lineage = value + if self._real is not None: + self._real.lineage = value diff --git a/tests/functional/test_lazy.py b/tests/functional/test_lazy.py new file mode 100644 index 000000000000..3ba067e13aad --- /dev/null +++ b/tests/functional/test_lazy.py @@ -0,0 +1,87 @@ +# 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 pytest + +from awscli.handlers_registry import MAIN_COMMAND_TABLE_OPS +from awscli.lazy import LazyCommand +from awscli.testutils import BaseAWSHelpOutputTest, mock + +# Derive test parameters from MAIN_COMMAND_TABLE_OPS. +_ADD_OPS = [op for op in MAIN_COMMAND_TABLE_OPS if op[0] == 'add'] +_RENAME_OPS = [op for op in MAIN_COMMAND_TABLE_OPS if op[0] == 'rename'] +_ADD_CMD_NAMES = [op[1] for op in _ADD_OPS] +_RENAME_NEW_NAMES = [op[2] for op in _RENAME_OPS] + + +class TestLazyCommandHelpRenders(BaseAWSHelpOutputTest): + def test_added_command_help_renders(self): + for cmd_name in _ADD_CMD_NAMES: + with self.subTest(cmd_name=cmd_name): + self.driver.main([cmd_name, 'help']) + self.assert_contains(cmd_name) + + def test_renamed_command_help_renders(self): + for new_name in _RENAME_NEW_NAMES: + with self.subTest(new_name=new_name): + self.driver.main([new_name, 'help']) + self.assert_contains(new_name) + + +class TestLazyCommandIsTransparent(BaseAWSHelpOutputTest): + def test_added_commands_appear_in_top_level_help(self): + self.driver.main(['help']) + for cmd_name in _ADD_CMD_NAMES: + self.assert_contains(cmd_name) + + def test_lazy_command_has_subcommands(self): + command_table = self.driver.subcommand_table + s3_cmd = command_table['s3'] + assert isinstance(s3_cmd, LazyCommand) + subcommands = s3_cmd.subcommand_table + assert 'ls' in subcommands + assert 'cp' in subcommands + + +class TestLazyCommandErrorPaths: + def test_invalid_module_path_raises_on_resolve(self): + session = mock.MagicMock() + cmd = LazyCommand( + 'bad-cmd', + session, + 'awscli.nonexistent.module', + 'FakeCommand', + ) + with pytest.raises(ModuleNotFoundError): + cmd([], mock.MagicMock()) + + def test_invalid_class_name_raises_on_resolve(self): + session = mock.MagicMock() + cmd = LazyCommand( + 'bad-cmd', + session, + 'awscli.customizations.dynamodb.ddb', + 'NonexistentClass', + ) + with pytest.raises(AttributeError): + cmd([], mock.MagicMock()) + + def test_invalid_module_path_raises_on_help(self): + session = mock.MagicMock() + cmd = LazyCommand( + 'bad-cmd', + session, + 'awscli.nonexistent.module', + 'FakeCommand', + ) + with pytest.raises(ModuleNotFoundError): + cmd.create_help_command() diff --git a/tests/unit/botocore/test_hooks.py b/tests/unit/botocore/test_hooks.py index 23d9b4985d6c..4defdd5511dc 100644 --- a/tests/unit/botocore/test_hooks.py +++ b/tests/unit/botocore/test_hooks.py @@ -14,7 +14,11 @@ import functools from functools import partial -from botocore.hooks import HierarchicalEmitter, first_non_none_response +from botocore.hooks import ( + HierarchicalEmitter, + PrefixTrie, + first_non_none_response, +) from tests import unittest @@ -584,5 +588,53 @@ def handler(a, b, **kwargs): ) +class TestPrefixTrie(unittest.TestCase): + def setUp(self): + self.trie = PrefixTrie() + + def test_append_and_prefix_search_exact_match(self): + self.trie.append_item('building-command-table.main', 'handler1') + results = self.trie.prefix_search('building-command-table.main') + self.assertIn('handler1', results) + + def test_prefix_search_matches_parent(self): + self.trie.append_item('building-command-table', 'handler1') + results = self.trie.prefix_search('building-command-table.main') + self.assertIn('handler1', results) + + def test_prefix_search_does_not_match_sibling(self): + self.trie.append_item('building-command-table.ecs', 'handler1') + results = self.trie.prefix_search('building-command-table.main') + self.assertNotIn('handler1', results) + + def test_prefix_search_does_not_match_child(self): + self.trie.append_item('building-command-table.main.sub', 'handler1') + results = self.trie.prefix_search('building-command-table.main') + self.assertNotIn('handler1', results) + + def test_wildcard_match(self): + self.trie.append_item('building-command-table.*', 'handler1') + results = self.trie.prefix_search('building-command-table.main') + self.assertIn('handler1', results) + + def test_multiple_items_at_same_key(self): + self.trie.append_item('building-command-table.main', 'handler1') + self.trie.append_item('building-command-table.main', 'handler2') + results = self.trie.prefix_search('building-command-table.main') + self.assertIn('handler1', results) + self.assertIn('handler2', results) + + def test_multiple_levels_all_returned(self): + self.trie.append_item('building-command-table', 'parent') + self.trie.append_item('building-command-table.main', 'exact') + results = self.trie.prefix_search('building-command-table.main') + self.assertIn('parent', results) + self.assertIn('exact', results) + + def test_empty_trie_returns_empty(self): + results = self.trie.prefix_search('building-command-table.main') + self.assertEqual(len(results), 0) + + if __name__ == '__main__': unittest.main() diff --git a/tests/unit/test_lazy.py b/tests/unit/test_lazy.py new file mode 100644 index 000000000000..63a1ff5ea44b --- /dev/null +++ b/tests/unit/test_lazy.py @@ -0,0 +1,139 @@ +# 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. +from unittest.mock import MagicMock, patch + +import pytest + +from awscli.lazy import LazyCommand + + +@pytest.fixture +def session(): + return MagicMock() + + +@pytest.fixture +def mock_command_class(): + cls = MagicMock() + instance = MagicMock() + cls.return_value = instance + return cls + + +@pytest.fixture +def mock_module(mock_command_class): + module = MagicMock() + module.MyCommand = mock_command_class + return module + + +class TestLazyCommandResolution: + def test_does_not_import_on_construction(self, session): + with patch('importlib.import_module') as imp: + LazyCommand('cmd', session, 'some.module', 'MyCommand') + imp.assert_not_called() + + def test_imports_module_on_call(self, session, mock_module): + cmd = LazyCommand('cmd', session, 'some.module', 'MyCommand') + with patch('importlib.import_module', return_value=mock_module): + cmd(['arg1'], MagicMock()) + mock_module.MyCommand.assert_called_once_with(session) + + def test_imports_module_on_help(self, session, mock_module): + cmd = LazyCommand('cmd', session, 'some.module', 'MyCommand') + with patch('importlib.import_module', return_value=mock_module): + cmd.create_help_command() + mock_module.MyCommand.assert_called_once_with(session) + + def test_imports_module_on_arg_table(self, session, mock_module): + cmd = LazyCommand('cmd', session, 'some.module', 'MyCommand') + with patch('importlib.import_module', return_value=mock_module): + _ = cmd.arg_table + mock_module.MyCommand.assert_called_once_with(session) + + def test_imports_module_on_subcommand_table(self, session, mock_module): + cmd = LazyCommand('cmd', session, 'some.module', 'MyCommand') + with patch('importlib.import_module', return_value=mock_module): + _ = cmd.subcommand_table + mock_module.MyCommand.assert_called_once_with(session) + + def test_resolves_only_once(self, session, mock_module): + cmd = LazyCommand('cmd', session, 'some.module', 'MyCommand') + with patch('importlib.import_module', return_value=mock_module) as imp: + cmd(['arg1'], MagicMock()) + cmd(['arg2'], MagicMock()) + imp.assert_called_once_with('some.module') + + def test_delegates_call_to_real_command(self, session, mock_module): + cmd = LazyCommand('cmd', session, 'some.module', 'MyCommand') + args = ['arg1'] + parsed_globals = MagicMock() + with patch('importlib.import_module', return_value=mock_module): + cmd(args, parsed_globals) + mock_module.MyCommand.return_value.assert_called_once_with( + args, parsed_globals + ) + + def test_delegates_help_to_real_command(self, session, mock_module): + cmd = LazyCommand('cmd', session, 'some.module', 'MyCommand') + with patch('importlib.import_module', return_value=mock_module): + result = cmd.create_help_command() + assert ( + result == mock_module.MyCommand.return_value.create_help_command() + ) + + +class TestLazyCommandProperties: + def test_name_returns_initial_name(self, session): + cmd = LazyCommand('my-cmd', session, 'some.module', 'MyCommand') + assert cmd.name == 'my-cmd' + + def test_name_setter_updates_name(self, session): + cmd = LazyCommand('old-name', session, 'some.module', 'MyCommand') + cmd.name = 'new-name' + assert cmd.name == 'new-name' + + def test_lineage_defaults_to_self(self, session): + cmd = LazyCommand('cmd', session, 'some.module', 'MyCommand') + assert cmd.lineage == [cmd] + + def test_lineage_setter_updates_lineage(self, session): + cmd = LazyCommand('cmd', session, 'some.module', 'MyCommand') + new_lineage = [MagicMock(), cmd] + cmd.lineage = new_lineage + assert cmd.lineage == new_lineage + + def test_lineage_propagated_to_real_on_resolve(self, session, mock_module): + cmd = LazyCommand('cmd', session, 'some.module', 'MyCommand') + new_lineage = [MagicMock(), cmd] + cmd.lineage = new_lineage + with patch('importlib.import_module', return_value=mock_module): + cmd.create_help_command() + assert mock_module.MyCommand.return_value.lineage == new_lineage + + def test_lineage_setter_propagates_to_already_resolved( + self, session, mock_module + ): + cmd = LazyCommand('cmd', session, 'some.module', 'MyCommand') + with patch('importlib.import_module', return_value=mock_module): + cmd.create_help_command() + new_lineage = [MagicMock(), cmd] + cmd.lineage = new_lineage + assert mock_module.MyCommand.return_value.lineage == new_lineage + + def test_lineage_not_propagated_if_not_resolved(self, session): + cmd = LazyCommand('cmd', session, 'some.module', 'MyCommand') + new_lineage = [MagicMock(), cmd] + # Should not raise even though underlying command is not resolved. + cmd.lineage = new_lineage + assert cmd.lineage == new_lineage From dab19fb02ba42ff07547dab9f07bb1f58f9389d7 Mon Sep 17 00:00:00 2001 From: aemous Date: Mon, 11 May 2026 13:56:34 -0400 Subject: [PATCH 02/21] Remove parts of functional/test_lazy.py that depend on LazyInitEmitter. --- tests/functional/test_lazy.py | 38 +---------------------------------- 1 file changed, 1 insertion(+), 37 deletions(-) diff --git a/tests/functional/test_lazy.py b/tests/functional/test_lazy.py index 3ba067e13aad..fc8d33edd359 100644 --- a/tests/functional/test_lazy.py +++ b/tests/functional/test_lazy.py @@ -12,44 +12,8 @@ # language governing permissions and limitations under the License. import pytest -from awscli.handlers_registry import MAIN_COMMAND_TABLE_OPS from awscli.lazy import LazyCommand -from awscli.testutils import BaseAWSHelpOutputTest, mock - -# Derive test parameters from MAIN_COMMAND_TABLE_OPS. -_ADD_OPS = [op for op in MAIN_COMMAND_TABLE_OPS if op[0] == 'add'] -_RENAME_OPS = [op for op in MAIN_COMMAND_TABLE_OPS if op[0] == 'rename'] -_ADD_CMD_NAMES = [op[1] for op in _ADD_OPS] -_RENAME_NEW_NAMES = [op[2] for op in _RENAME_OPS] - - -class TestLazyCommandHelpRenders(BaseAWSHelpOutputTest): - def test_added_command_help_renders(self): - for cmd_name in _ADD_CMD_NAMES: - with self.subTest(cmd_name=cmd_name): - self.driver.main([cmd_name, 'help']) - self.assert_contains(cmd_name) - - def test_renamed_command_help_renders(self): - for new_name in _RENAME_NEW_NAMES: - with self.subTest(new_name=new_name): - self.driver.main([new_name, 'help']) - self.assert_contains(new_name) - - -class TestLazyCommandIsTransparent(BaseAWSHelpOutputTest): - def test_added_commands_appear_in_top_level_help(self): - self.driver.main(['help']) - for cmd_name in _ADD_CMD_NAMES: - self.assert_contains(cmd_name) - - def test_lazy_command_has_subcommands(self): - command_table = self.driver.subcommand_table - s3_cmd = command_table['s3'] - assert isinstance(s3_cmd, LazyCommand) - subcommands = s3_cmd.subcommand_table - assert 'ls' in subcommands - assert 'cp' in subcommands +from awscli.testutils import mock class TestLazyCommandErrorPaths: From 494bd6a047a10694478ef663ba9a1ce720ebf156 Mon Sep 17 00:00:00 2001 From: aemous Date: Mon, 11 May 2026 14:04:28 -0400 Subject: [PATCH 03/21] Add license header to awscli/lazy.py. --- awscli/lazy.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/awscli/lazy.py b/awscli/lazy.py index 4cad0964443d..58eccd7df515 100644 --- a/awscli/lazy.py +++ b/awscli/lazy.py @@ -1,3 +1,15 @@ +# 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 importlib from awscli.commands import CLICommand From 40f4027c699a18d34f28f3d6d0f7bb27090e4dc0 Mon Sep 17 00:00:00 2001 From: aemous Date: Wed, 13 May 2026 11:18:59 -0400 Subject: [PATCH 04/21] Switch new test suite to use PyTest instead of unittest.TestCase. --- tests/unit/botocore/test_hooks.py | 96 ++++++++++++++++--------------- 1 file changed, 50 insertions(+), 46 deletions(-) diff --git a/tests/unit/botocore/test_hooks.py b/tests/unit/botocore/test_hooks.py index 4defdd5511dc..cb7e1d738131 100644 --- a/tests/unit/botocore/test_hooks.py +++ b/tests/unit/botocore/test_hooks.py @@ -14,6 +14,8 @@ import functools from functools import partial +import pytest + from botocore.hooks import ( HierarchicalEmitter, PrefixTrie, @@ -588,52 +590,54 @@ def handler(a, b, **kwargs): ) -class TestPrefixTrie(unittest.TestCase): - def setUp(self): - self.trie = PrefixTrie() - - def test_append_and_prefix_search_exact_match(self): - self.trie.append_item('building-command-table.main', 'handler1') - results = self.trie.prefix_search('building-command-table.main') - self.assertIn('handler1', results) - - def test_prefix_search_matches_parent(self): - self.trie.append_item('building-command-table', 'handler1') - results = self.trie.prefix_search('building-command-table.main') - self.assertIn('handler1', results) - - def test_prefix_search_does_not_match_sibling(self): - self.trie.append_item('building-command-table.ecs', 'handler1') - results = self.trie.prefix_search('building-command-table.main') - self.assertNotIn('handler1', results) - - def test_prefix_search_does_not_match_child(self): - self.trie.append_item('building-command-table.main.sub', 'handler1') - results = self.trie.prefix_search('building-command-table.main') - self.assertNotIn('handler1', results) - - def test_wildcard_match(self): - self.trie.append_item('building-command-table.*', 'handler1') - results = self.trie.prefix_search('building-command-table.main') - self.assertIn('handler1', results) - - def test_multiple_items_at_same_key(self): - self.trie.append_item('building-command-table.main', 'handler1') - self.trie.append_item('building-command-table.main', 'handler2') - results = self.trie.prefix_search('building-command-table.main') - self.assertIn('handler1', results) - self.assertIn('handler2', results) - - def test_multiple_levels_all_returned(self): - self.trie.append_item('building-command-table', 'parent') - self.trie.append_item('building-command-table.main', 'exact') - results = self.trie.prefix_search('building-command-table.main') - self.assertIn('parent', results) - self.assertIn('exact', results) - - def test_empty_trie_returns_empty(self): - results = self.trie.prefix_search('building-command-table.main') - self.assertEqual(len(results), 0) +@pytest.fixture +def trie(): + return PrefixTrie() + + +class TestPrefixTrie: + def test_append_and_prefix_search_exact_match(self, trie): + trie.append_item('building-command-table.main', 'handler1') + results = trie.prefix_search('building-command-table.main') + assert 'handler1' in results + + def test_prefix_search_matches_parent(self, trie): + trie.append_item('building-command-table', 'handler1') + results = trie.prefix_search('building-command-table.main') + assert 'handler1' in results + + def test_prefix_search_does_not_match_sibling(self, trie): + trie.append_item('building-command-table.ecs', 'handler1') + results = trie.prefix_search('building-command-table.main') + assert 'handler1' not in results + + def test_prefix_search_does_not_match_child(self, trie): + trie.append_item('building-command-table.main.sub', 'handler1') + results = trie.prefix_search('building-command-table.main') + assert 'handler1' not in results + + def test_wildcard_match(self, trie): + trie.append_item('building-command-table.*', 'handler1') + results = trie.prefix_search('building-command-table.main') + assert 'handler1' in results + + def test_multiple_items_at_same_key(self, trie): + trie.append_item('building-command-table.main', 'handler1') + trie.append_item('building-command-table.main', 'handler2') + results = trie.prefix_search('building-command-table.main') + assert 'handler1' in results + assert 'handler2' in results + + def test_multiple_levels_all_returned(self, trie): + trie.append_item('building-command-table', 'parent') + trie.append_item('building-command-table.main', 'exact') + results = trie.prefix_search('building-command-table.main') + assert 'parent' in results + assert 'exact' in results + + def test_empty_trie_returns_empty(self, trie): + results = trie.prefix_search('building-command-table.main') + assert len(results) == 0 if __name__ == '__main__': From fdddde11812c7f6e9531a0b81c10b5d328d1b04f Mon Sep 17 00:00:00 2001 From: aemous Date: Wed, 13 May 2026 11:19:36 -0400 Subject: [PATCH 05/21] Formatting. --- tests/unit/botocore/test_hooks.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/botocore/test_hooks.py b/tests/unit/botocore/test_hooks.py index cb7e1d738131..b55e98bd1e77 100644 --- a/tests/unit/botocore/test_hooks.py +++ b/tests/unit/botocore/test_hooks.py @@ -15,7 +15,6 @@ from functools import partial import pytest - from botocore.hooks import ( HierarchicalEmitter, PrefixTrie, From c8b4b7b671c18afd5daf46f03ee3ae0b497ad6ee Mon Sep 17 00:00:00 2001 From: aemous Date: Wed, 13 May 2026 14:06:37 -0400 Subject: [PATCH 06/21] Wrap plugins that directly register against events in handlers.py in a thin initialization function. --- awscli/argprocess.py | 7 + awscli/clidriver.py | 7 + awscli/customizations/addexamples.py | 14 ++ awscli/customizations/binaryformat.py | 7 + .../customizations/codedeploy/codedeploy.py | 8 +- awscli/customizations/ec2/decryptpassword.py | 7 + awscli/customizations/iot.py | 14 ++ awscli/customizations/s3/s3.py | 21 +-- awscli/customizations/streamingoutputarg.py | 7 + awscli/handlers.py | 73 ++++----- awscli/paramfile.py | 7 + tests/functional/test_handlers_registry.py | 155 ++++++++++++++++++ tests/unit/customizations/s3/test_s3.py | 9 +- 13 files changed, 272 insertions(+), 64 deletions(-) create mode 100644 tests/functional/test_handlers_registry.py diff --git a/awscli/argprocess.py b/awscli/argprocess.py index fdc5eee8ba85..529ca1cd53e2 100644 --- a/awscli/argprocess.py +++ b/awscli/argprocess.py @@ -270,6 +270,13 @@ def _is_complex_shape(model): return True +def register_param_shorthand_parser(event_emitter): + event_emitter.register( + 'process-cli-arg', + ParamShorthandParser(), + ) + + class ParamShorthand: def _uses_old_list_case(self, command_name, operation_name, argument_name): """ diff --git a/awscli/clidriver.py b/awscli/clidriver.py index 643331c50d46..f7ba957fd94f 100644 --- a/awscli/clidriver.py +++ b/awscli/clidriver.py @@ -214,6 +214,13 @@ def _set_user_agent_for_session(session): add_session_id_component_to_user_agent_extra(session) +def register_no_pager_handler(event_emitter): + event_emitter.register( + 'session-initialized', + no_pager_handler, + ) + + def no_pager_handler(session, parsed_args, **kwargs): if parsed_args.no_cli_pager: config_store = session.get_component('config_store') diff --git a/awscli/customizations/addexamples.py b/awscli/customizations/addexamples.py index 49a9ed65b55c..9ccf670ec6e8 100644 --- a/awscli/customizations/addexamples.py +++ b/awscli/customizations/addexamples.py @@ -33,6 +33,20 @@ LOG = logging.getLogger(__name__) +def register_docs_add_examples(event_emitter): + + # The following will get fired for every option we are + # documenting. It will attempt to add an example_fn on to + # the parameter object if the parameter supports shorthand + # syntax. The documentation event handlers will then use + # the examplefn to generate the sample shorthand syntax + # in the docs. Registering here should ensure that this + # handler gets called first, but it still feels a bit brittle. + # event_handlers.register('doc-option-example.*.*.*', + # param_shorthand.add_example_fn) + event_emitter.register('doc-examples.*.*', add_examples) + + def add_examples(help_command, **kwargs): doc_path = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'examples' diff --git a/awscli/customizations/binaryformat.py b/awscli/customizations/binaryformat.py index 7ac8030231c7..4812047fdf39 100644 --- a/awscli/customizations/binaryformat.py +++ b/awscli/customizations/binaryformat.py @@ -18,6 +18,13 @@ from awscli.shorthand import ModelVisitor +def register_init_binary_formatter(event_emitter): + event_emitter.register( + 'session-initialized', + add_binary_formatter, + ) + + def add_binary_formatter(session, parsed_args, **kwargs): binary_format = parsed_args.cli_binary_format if binary_format is None: diff --git a/awscli/customizations/codedeploy/codedeploy.py b/awscli/customizations/codedeploy/codedeploy.py index b1c78d648f0e..873511958909 100644 --- a/awscli/customizations/codedeploy/codedeploy.py +++ b/awscli/customizations/codedeploy/codedeploy.py @@ -22,11 +22,11 @@ from awscli.customizations.codedeploy.uninstall import Uninstall -def initialize(cli): - """ - The entry point for CodeDeploy high level commands. - """ +def register_rename_codedeploy(cli): cli.register('building-command-table.main', change_name) + + +def register_codedeploy(cli): cli.register('building-command-table.deploy', inject_commands) cli.register( 'building-argument-table.deploy.get-application-revision', diff --git a/awscli/customizations/ec2/decryptpassword.py b/awscli/customizations/ec2/decryptpassword.py index 4fba23fb59f0..902cd4c800cd 100644 --- a/awscli/customizations/ec2/decryptpassword.py +++ b/awscli/customizations/ec2/decryptpassword.py @@ -27,6 +27,13 @@ password data sent from EC2 will be decrypted before display.

""" +def register_ec2_add_priv_launch_key(event_emitter, **kwargs): + event_emitter.register( + 'building-argument-table.ec2.get-password-data', + ec2_add_priv_launch_key, + ) + + def ec2_add_priv_launch_key( argument_table, operation_model, session, **kwargs ): diff --git a/awscli/customizations/iot.py b/awscli/customizations/iot.py index f4e4b9770513..8b0153d6fcc5 100644 --- a/awscli/customizations/iot.py +++ b/awscli/customizations/iot.py @@ -26,6 +26,20 @@ from awscli.customizations.arguments import QueryOutFileArgument +def register_iot_create_keys_from_csr(event_emitter): + event_emitter.register( + 'building-argument-table.iot.create-certificate-from-csr', + register_create_keys_from_csr_arguments, + ) + + +def register_iot_create_keys_and_cert_args(event_emitter): + event_emitter.register( + 'building-argument-table.iot.create-keys-and-certificate', + register_create_keys_and_cert_arguments, + ) + + def register_create_keys_and_cert_arguments(session, argument_table, **kwargs): """Add outfile save arguments to create-keys-and-certificate diff --git a/awscli/customizations/s3/s3.py b/awscli/customizations/s3/s3.py index 725bd0ca9bf7..9dad2aaa03d1 100644 --- a/awscli/customizations/s3/s3.py +++ b/awscli/customizations/s3/s3.py @@ -28,23 +28,14 @@ ) -def awscli_initialize(cli): - """ - This function is require to use the plugin. It calls the functions - required to add all necessary commands and parameters to the CLI. - This function is necessary to install the plugin using a configuration - file - """ - cli.register("building-command-table.main", add_s3) - cli.register('building-command-table.s3_sync', register_sync_strategies) +def register_s3_main(event_handlers): + event_handlers.register('building-command-table.main', add_s3) -def s3_plugin_initialize(event_handlers): - """ - This is a wrapper to make the plugin built-in to the cli as opposed - to specifying it in the configuration file. - """ - awscli_initialize(event_handlers) +def register_s3_sync_strategies(event_handlers): + event_handlers.register( + 'building-command-table.s3_sync', register_sync_strategies + ) def add_s3(command_table, session, **kwargs): diff --git a/awscli/customizations/streamingoutputarg.py b/awscli/customizations/streamingoutputarg.py index c4decbb6844d..6c289c0ec140 100644 --- a/awscli/customizations/streamingoutputarg.py +++ b/awscli/customizations/streamingoutputarg.py @@ -15,6 +15,13 @@ from awscli.arguments import BaseCLIArgument +def register_streaming_output_arg(event_emitter): + event_emitter.register( + 'building-argument-table.*', + add_streaming_output_arg, + ) + + def add_streaming_output_arg( argument_table, operation_model, session, **kwargs ): diff --git a/awscli/handlers.py b/awscli/handlers.py index 3f0aa0180078..bc5234a4a7f9 100644 --- a/awscli/handlers.py +++ b/awscli/handlers.py @@ -18,14 +18,14 @@ """ from awscli.alias import register_alias_commands -from awscli.argprocess import ParamShorthandParser -from awscli.clidriver import no_pager_handler +from awscli.argprocess import register_param_shorthand_parser +from awscli.clidriver import register_no_pager_handler from awscli.customizations import datapipeline -from awscli.customizations.addexamples import add_examples +from awscli.customizations.addexamples import register_docs_add_examples from awscli.customizations.argrename import register_arg_renames from awscli.customizations.assumerole import register_assume_role_provider from awscli.customizations.awslambda import register_lambda_create_function -from awscli.customizations.binaryformat import add_binary_formatter +from awscli.customizations.binaryformat import register_init_binary_formatter from awscli.customizations.cliinput import register_cli_input_args from awscli.customizations.cloudformation import ( initialize as cloudformation_init, @@ -38,7 +38,8 @@ from awscli.customizations.codeartifact import register_codeartifact_commands from awscli.customizations.codecommit import initialize as codecommit_init from awscli.customizations.codedeploy.codedeploy import ( - initialize as codedeploy_init, + register_codedeploy, + register_rename_codedeploy as codedeploy_init, ) from awscli.customizations.configservice.getstatus import register_get_status from awscli.customizations.configservice.putconfigurationrecorder import ( @@ -58,7 +59,9 @@ ) from awscli.customizations.ec2.addcount import register_count_events from awscli.customizations.ec2.bundleinstance import register_bundleinstance -from awscli.customizations.ec2.decryptpassword import ec2_add_priv_launch_key +from awscli.customizations.ec2.decryptpassword import ( + register_ec2_add_priv_launch_key +) from awscli.customizations.ec2.paginate import register_ec2_page_size_injector from awscli.customizations.ec2.protocolarg import register_protocol_args from awscli.customizations.ec2.runinstances import register_runinstances @@ -88,8 +91,8 @@ ) from awscli.customizations.iamvirtmfa import IAMVMFAWrapper from awscli.customizations.iot import ( - register_create_keys_and_cert_arguments, - register_create_keys_from_csr_arguments, + register_iot_create_keys_and_cert_args, + register_iot_create_keys_from_csr, ) from awscli.customizations.iot_data import register_custom_endpoint_note from awscli.customizations.kinesis import ( @@ -113,7 +116,10 @@ ) from awscli.customizations.removals import register_removals from awscli.customizations.route53 import register_create_hosted_zone_doc_fix -from awscli.customizations.s3.s3 import s3_plugin_initialize +from awscli.customizations.s3.s3 import ( + register_s3_main, + register_s3_sync_strategies +) from awscli.customizations.s3errormsg import register_s3_error_msg from awscli.customizations.s3events import ( register_document_expires_string, @@ -125,7 +131,7 @@ from awscli.customizations.sessendemail import register_ses_send_email from awscli.customizations.sessionmanager import register_ssm_session from awscli.customizations.sso import register_sso_commands -from awscli.customizations.streamingoutputarg import add_streaming_output_arg +from awscli.customizations.streamingoutputarg import register_streaming_output_arg from awscli.customizations.timestampformat import register_timestamp_format from awscli.customizations.toplevelbool import register_bool_params from awscli.customizations.translate import ( @@ -133,42 +139,28 @@ ) from awscli.customizations.waiters import register_add_waiters from awscli.customizations.wizard.commands import register_wizard_commands -from awscli.paramfile import register_uri_param_handler +from awscli.paramfile import register_init_uri_param_handler def awscli_initialize(event_handlers): - event_handlers.register('session-initialized', register_uri_param_handler) - event_handlers.register('session-initialized', add_binary_formatter) - event_handlers.register('session-initialized', no_pager_handler) - param_shorthand = ParamShorthandParser() - event_handlers.register('process-cli-arg', param_shorthand) - # The s3 error mesage needs to registered before the + register_init_uri_param_handler(event_handlers) + register_init_binary_formatter(event_handlers) + register_no_pager_handler(event_handlers) + register_param_shorthand_parser(event_handlers) + # The s3 error message needs to registered before the # generic error handler. register_s3_error_msg(event_handlers) - # # The following will get fired for every option we are - # # documenting. It will attempt to add an example_fn on to - # # the parameter object if the parameter supports shorthand - # # syntax. The documentation event handlers will then use - # # the examplefn to generate the sample shorthand syntax - # # in the docs. Registering here should ensure that this - # # handler gets called first but it still feels a bit brittle. - # event_handlers.register('doc-option-example.*.*.*', - # param_shorthand.add_example_fn) - event_handlers.register('doc-examples.*.*', add_examples) + register_docs_add_examples(event_handlers) register_cli_input_args(event_handlers) - event_handlers.register( - 'building-argument-table.*', add_streaming_output_arg - ) + register_streaming_output_arg(event_handlers) register_count_events(event_handlers) - event_handlers.register( - 'building-argument-table.ec2.get-password-data', - ec2_add_priv_launch_key, - ) + register_ec2_add_priv_launch_key(event_handlers) register_parse_global_args(event_handlers) register_pagination(event_handlers) register_secgroup(event_handlers) register_bundleinstance(event_handlers) - s3_plugin_initialize(event_handlers) + register_s3_main(event_handlers) + register_s3_sync_strategies(event_handlers) register_ddb(event_handlers) register_runinstances(event_handlers) register_removals(event_handlers) @@ -199,6 +191,7 @@ def awscli_initialize(event_handlers): register_assume_role_provider(event_handlers) register_add_waiters(event_handlers) codedeploy_init(event_handlers) + register_codedeploy(event_handlers) register_subscribe(event_handlers) register_get_status(event_handlers) register_rename_config(event_handlers) @@ -210,14 +203,8 @@ def awscli_initialize(event_handlers): register_codeartifact_commands(event_handlers) codecommit_init(event_handlers) register_custom_endpoint_note(event_handlers) - event_handlers.register( - 'building-argument-table.iot.create-keys-and-certificate', - register_create_keys_and_cert_arguments, - ) - event_handlers.register( - 'building-argument-table.iot.create-certificate-from-csr', - register_create_keys_from_csr_arguments, - ) + register_iot_create_keys_and_cert_args(event_handlers) + register_iot_create_keys_from_csr(event_handlers) register_cloudfront(event_handlers) register_gamelift_commands(event_handlers) register_ec2_page_size_injector(event_handlers) diff --git a/awscli/paramfile.py b/awscli/paramfile.py index 975470062594..a14b3c56b3fa 100644 --- a/awscli/paramfile.py +++ b/awscli/paramfile.py @@ -24,6 +24,13 @@ class ResourceLoadingError(Exception): pass +def register_init_uri_param_handler(event_emitter): + event_emitter.register( + 'session-initialized', + register_uri_param_handler, + ) + + def register_uri_param_handler(session, **kwargs): prefix_map = copy.deepcopy(LOCAL_PREFIX_MAP) handler = URIArgumentHandler(prefix_map) diff --git a/tests/functional/test_handlers_registry.py b/tests/functional/test_handlers_registry.py new file mode 100644 index 000000000000..7856fa3caf91 --- /dev/null +++ b/tests/functional/test_handlers_registry.py @@ -0,0 +1,155 @@ +# 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 importlib +from collections import OrderedDict + +import botocore.session + +from awscli.handlers_registry import PLUGIN_REGISTRY + + +class _AuditEmitter: + """Minimal emitter that records event names without side effects.""" + + def __init__(self): + self.registrations = [] + + def register(self, event_name, *args, **kwargs): + self.registrations.append(event_name) + + register_first = register + register_last = register + + +class _CallbackCollector: + """Emitter that captures callbacks registered against a specific event.""" + + def __init__(self, target_event): + self._target_event = target_event + self.callbacks = [] + + def register(self, event_name, handler, *args, **kwargs): + if event_name == self._target_event: + self.callbacks.append(handler) + + register_first = register + register_last = register + + +def test_main_command_table_plugins_only_register_against_main(): + """Plugins listed under building-command-table.main must not register + against any other events. + + This invariant allows the lazy-loading system to skip importing these + plugin modules entirely and instead apply pre-computed renames and + LazyCommand additions from MAIN_COMMAND_TABLE_OPS. If a plugin + mixes building-command-table.main registrations with other events, + split it into separate functions: one that only registers against + building-command-table.main, and another for the remaining events. + """ + main_entries = PLUGIN_REGISTRY.get('building-command-table.main', []) + violations = [] + for module_path, fn_name, entry_type in main_entries: + emitter = _AuditEmitter() + mod = importlib.import_module(module_path) + fn = getattr(mod, fn_name) + fn(emitter) + non_main = [ + e + for e in emitter.registrations + if e != 'building-command-table.main' + ] + if non_main: + violations.append( + f'{module_path}.{fn_name} also registers against: ' + f'{non_main}' + ) + assert not violations, ( + 'The following building-command-table.main plugins register ' + 'against additional events. Split each into separate functions ' + 'so that the building-command-table.main function only registers ' + 'against that single event:\n' + + '\n'.join(f' - {v}' for v in violations) + ) + + +def test_main_command_table_callbacks_only_add_or_rename(): + """Callbacks registered against building-command-table.main must only + add new commands or rename existing ones. + + MAIN_COMMAND_TABLE_OPS replaces these callbacks at runtime with + LazyCommand additions and direct renames. If a callback also + modifies existing command table entries (e.g. changes properties on + a command object), that modification would be silently lost. + """ + session = botocore.session.Session() + services = session.get_available_services() + + main_entries = PLUGIN_REGISTRY.get('building-command-table.main', []) + violations = [] + + for module_path, fn_name, entry_type in main_entries: + collector = _CallbackCollector('building-command-table.main') + mod = importlib.import_module(module_path) + fn = getattr(mod, fn_name) + fn(collector) + + for callback in collector.callbacks: + cb_name = f'{callback.__module__}.{callback.__qualname__}' + + # Build a fresh command table for each callback. + class _Placeholder: + def __init__(self, name): + self.name = name + + command_table = OrderedDict() + for svc in services: + command_table[svc] = _Placeholder(svc) + + snap_id_to_key = {id(v): k for k, v in command_table.items()} + snap_id_to_name = {id(v): v.name for k, v in command_table.items()} + + callback(command_table=command_table, session=session) + + # Classify every change. + new_id_to_key = {id(v): k for k, v in command_table.items()} + renamed_ids = set() + + # Detect renames. + for obj_id, new_key in new_id_to_key.items(): + old_key = snap_id_to_key.get(obj_id) + if old_key is not None and old_key != new_key: + renamed_ids.add(obj_id) + + # Detect modifications: an existing (non-renamed) entry whose + # .name property changed, or any entry that was removed. + for obj_id, old_key in snap_id_to_key.items(): + if obj_id in renamed_ids: + continue + if obj_id not in new_id_to_key: + violations.append(f'{cb_name} removed command {old_key!r}') + continue + new_key = new_id_to_key[obj_id] + cmd = command_table[new_key] + if cmd.name != snap_id_to_name[obj_id]: + violations.append( + f'{cb_name} modified .name on {new_key!r} ' + f'without renaming' + ) + + assert not violations, ( + 'Callbacks registered against building-command-table.main must ' + 'only add or rename commands. The following callbacks perform ' + 'other modifications that would be lost when replaced by ' + 'MAIN_COMMAND_TABLE_OPS:\n' + '\n'.join(f' - {v}' for v in violations) + ) diff --git a/tests/unit/customizations/s3/test_s3.py b/tests/unit/customizations/s3/test_s3.py index 29f7fbf151b7..a8a4e4870178 100644 --- a/tests/unit/customizations/s3/test_s3.py +++ b/tests/unit/customizations/s3/test_s3.py @@ -10,7 +10,11 @@ # 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. -from awscli.customizations.s3.s3 import add_s3, awscli_initialize +from awscli.customizations.s3.s3 import ( + add_s3, + register_s3_main, + register_s3_sync_strategies, +) from awscli.testutils import BaseAWSCommandParamsTest, mock, unittest @@ -24,7 +28,8 @@ def setUp(self): self.cli = mock.Mock() def test_initialize(self): - awscli_initialize(self.cli) + register_s3_main(self.cli) + register_s3_sync_strategies(self.cli) reference = [] reference.append("building-command-table.main") reference.append("building-command-table.s3_sync") From 975f57a9aa1b7f7d2ab1bb8e54e8dedb2db486fb Mon Sep 17 00:00:00 2001 From: aemous Date: Wed, 13 May 2026 14:24:34 -0400 Subject: [PATCH 07/21] Add handlers_registry.py which maps events to plugins and declares command-table operations. --- awscli/handlers_registry.py | 738 ++++++++++++++++++++++++++++++++++++ 1 file changed, 738 insertions(+) create mode 100644 awscli/handlers_registry.py diff --git a/awscli/handlers_registry.py b/awscli/handlers_registry.py new file mode 100644 index 000000000000..c20c40183f72 --- /dev/null +++ b/awscli/handlers_registry.py @@ -0,0 +1,738 @@ +# 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. +"""Maps event patterns to initializer entries. + +At runtime, the LazyInitEmitter triggers entries on demand: +before emitting event X, it finds entries whose event patterns +match X, calls each init function at most once (passing the +event_handlers emitter), then proceeds with normal event dispatch. + +Entry format: + (module, fn_name) call fn(event_handlers) +""" +PLUGIN_REGISTRY = { + 'after-call.data-pipeline.GetPipelineDefinition': [ + ('awscli.customizations.datapipeline', 'register_customizations') + ], + 'after-call.ecs.CreateExpressGatewayService': [ + ('awscli.customizations.ecs.monitormutatinggatewayservice', 'register_monitor_mutating_gateway_service') + ], + 'after-call.ecs.DeleteExpressGatewayService': [ + ('awscli.customizations.ecs.monitormutatinggatewayservice', 'register_monitor_mutating_gateway_service') + ], + 'after-call.ecs.UpdateExpressGatewayService': [ + ('awscli.customizations.ecs.monitormutatinggatewayservice', 'register_monitor_mutating_gateway_service') + ], + 'after-call.iam.CreateVirtualMFADevice': [ + ('awscli.customizations.iamvirtmfa', 'IAMVMFAWrapper') + ], + 'after-call.s3': [ + ('awscli.customizations.s3errormsg', 'register_s3_error_msg') + ], + 'before-building-argument-table-parser.ecs.create-express-gateway-service': [ + ('awscli.customizations.ecs.monitormutatinggatewayservice', 'register_monitor_mutating_gateway_service') + ], + 'before-building-argument-table-parser.ecs.delete-express-gateway-service': [ + ('awscli.customizations.ecs.monitormutatinggatewayservice', 'register_monitor_mutating_gateway_service') + ], + 'before-building-argument-table-parser.ecs.update-express-gateway-service': [ + ('awscli.customizations.ecs.monitormutatinggatewayservice', 'register_monitor_mutating_gateway_service') + ], + 'before-building-argument-table-parser.emr.*': [ + ('awscli.customizations.emr.emr', 'emr_initialize') + ], + 'before-parameter-build.ec2.BundleInstance': [ + ('awscli.customizations.ec2.bundleinstance', 'register_bundleinstance') + ], + 'before-parameter-build.ec2.CreateNetworkAclEntry': [ + ('awscli.customizations.ec2.protocolarg', 'register_protocol_args') + ], + 'before-parameter-build.ec2.ReplaceNetworkAclEntry': [ + ('awscli.customizations.ec2.protocolarg', 'register_protocol_args') + ], + 'before-parameter-build.ec2.RunInstances': [ + ('awscli.customizations.ec2.addcount', 'register_count_events'), + ('awscli.customizations.ec2.runinstances', 'register_runinstances') + ], + 'building-argument-table': [ + ('awscli.customizations.cliinput', 'register_cli_input_args'), + ('awscli.customizations.paginate', 'register_pagination'), + ('awscli.customizations.generatecliskeleton', 'register_generate_cli_skeleton') + ], + 'building-argument-table.*': [ + ('awscli.customizations.streamingoutputarg', 'register_streaming_output_arg') + ], + 'building-argument-table.apigateway.create-rest-api': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.apigatewayv2.create-api': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.apigatewayv2.update-api': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.clouddirectory.publish-schema': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.cloudfront.create-distribution': [ + ('awscli.customizations.cloudfront', 'register') + ], + 'building-argument-table.cloudfront.create-invalidation': [ + ('awscli.customizations.cloudfront', 'register') + ], + 'building-argument-table.cloudfront.update-distribution': [ + ('awscli.customizations.cloudfront', 'register') + ], + 'building-argument-table.cloudsearch.define-expression': [ + ('awscli.customizations.cloudsearch', 'initialize') + ], + 'building-argument-table.cloudsearch.define-index-field': [ + ('awscli.customizations.cloudsearch', 'initialize') + ], + 'building-argument-table.cloudsearchdomain.search': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.cloudsearchdomain.suggest': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.cloudwatch.put-metric-data': [ + ('awscli.customizations.putmetricdata', 'register_put_metric_data') + ], + 'building-argument-table.codepipeline.create-custom-action-type': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.codepipeline.delete-custom-action-type': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.codepipeline.get-action-type': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.codepipeline.get-pipeline': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.configservice.put-configuration-recorder': [ + ('awscli.customizations.configservice.putconfigurationrecorder', 'register_modify_put_configuration_recorder') + ], + 'building-argument-table.controltower.create-landing-zone': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.controltower.update-landing-zone': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.datapipeline.*': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.datapipeline.activate-pipeline': [ + ('awscli.customizations.datapipeline', 'register_customizations') + ], + 'building-argument-table.datapipeline.get-pipeline-definition': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.datapipeline.put-pipeline-definition': [ + ('awscli.customizations.datapipeline', 'register_customizations') + ], + 'building-argument-table.deploy.*': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.deploy.create-deployment': [ + ('awscli.customizations.codedeploy.codedeploy', 'register_codedeploy') + ], + 'building-argument-table.deploy.get-application-revision': [ + ('awscli.customizations.codedeploy.codedeploy', 'register_codedeploy') + ], + 'building-argument-table.deploy.register-application-revision': [ + ('awscli.customizations.codedeploy.codedeploy', 'register_codedeploy') + ], + 'building-argument-table.ec2.*': [ + ('awscli.customizations.argrename', 'register_arg_renames'), + ('awscli.customizations.toplevelbool', 'register_bool_params') + ], + 'building-argument-table.ec2.authorize-security-group-egress': [ + ('awscli.customizations.ec2.secgroupsimplify', 'register_secgroup') + ], + 'building-argument-table.ec2.authorize-security-group-ingress': [ + ('awscli.customizations.ec2.secgroupsimplify', 'register_secgroup') + ], + 'building-argument-table.ec2.bundle-instance': [ + ('awscli.customizations.ec2.bundleinstance', 'register_bundleinstance') + ], + 'building-argument-table.ec2.create-image': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.ec2.get-password-data': [ + ('awscli.customizations.ec2.decryptpassword', 'register_ec2_add_priv_launch_key') + ], + 'building-argument-table.ec2.revoke-security-group-egress': [ + ('awscli.customizations.ec2.secgroupsimplify', 'register_secgroup') + ], + 'building-argument-table.ec2.revoke-security-group-ingress': [ + ('awscli.customizations.ec2.secgroupsimplify', 'register_secgroup') + ], + 'building-argument-table.ec2.run-instances': [ + ('awscli.customizations.ec2.addcount', 'register_count_events'), + ('awscli.customizations.ec2.runinstances', 'register_runinstances') + ], + 'building-argument-table.ecs.*': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.ecs.create-express-gateway-service': [ + ('awscli.customizations.ecs.monitormutatinggatewayservice', 'register_monitor_mutating_gateway_service') + ], + 'building-argument-table.ecs.delete-express-gateway-service': [ + ('awscli.customizations.ecs.monitormutatinggatewayservice', 'register_monitor_mutating_gateway_service') + ], + 'building-argument-table.ecs.execute-command': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.ecs.update-express-gateway-service': [ + ('awscli.customizations.ecs.monitormutatinggatewayservice', 'register_monitor_mutating_gateway_service') + ], + 'building-argument-table.eks.create-cluster': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.eks.create-nodegroup': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.eks.update-cluster-components-version': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.eks.update-cluster-version': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.eks.update-nodegroup-version': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.elasticache.create-replication-group': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.emr.*': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.emr.add-tags': [ + ('awscli.customizations.emr.emr', 'emr_initialize') + ], + 'building-argument-table.emr.list-clusters': [ + ('awscli.customizations.emr.emr', 'emr_initialize') + ], + 'building-argument-table.gamelift.create-build': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.gamelift.create-script': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.gamelift.update-build': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.gamelift.update-script': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.glue.get-unfiltered-partition-metadata': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.glue.get-unfiltered-partitions-metadata': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.glue.get-unfiltered-table-metadata': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.iam.create-virtual-mfa-device': [ + ('awscli.customizations.iamvirtmfa', 'IAMVMFAWrapper') + ], + 'building-argument-table.iot.create-certificate-from-csr': [ + ('awscli.customizations.iot', 'register_iot_create_keys_from_csr') + ], + 'building-argument-table.iot.create-keys-and-certificate': [ + ('awscli.customizations.iot', 'register_iot_create_keys_and_cert_args') + ], + 'building-argument-table.iotwireless.*': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.kinesis.list-streams': [ + ('awscli.customizations.kinesis', 'register_kinesis_list_streams_pagination_backcompat') + ], + 'building-argument-table.kinesisanalytics.add-application-output': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.kinesisanalyticsv2.add-application-output': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.lambda.create-function': [ + ('awscli.customizations.awslambda', 'register_lambda_create_function') + ], + 'building-argument-table.lambda.publish-layer-version': [ + ('awscli.customizations.awslambda', 'register_lambda_create_function') + ], + 'building-argument-table.lambda.update-function-code': [ + ('awscli.customizations.awslambda', 'register_lambda_create_function') + ], + 'building-argument-table.lex-models.delete-bot': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.lex-models.delete-bot-version': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.lex-models.delete-intent': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.lex-models.delete-intent-version': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.lex-models.delete-slot-type': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.lex-models.delete-slot-type-version': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.lex-models.get-export': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.lex-models.get-intent': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.lex-models.get-slot-type': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.license-manager.delete-grant': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.license-manager.get-grant': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.license-manager.get-license': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.mgn.*': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.mturk.list-qualification-types': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.pinpoint.delete-email-template': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.pinpoint.delete-in-app-template': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.pinpoint.delete-push-template': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.pinpoint.delete-sms-template': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.pinpoint.delete-voice-template': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.pinpoint.get-campaign-version': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.pinpoint.get-email-template': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.pinpoint.get-in-app-template': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.pinpoint.get-push-template': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.pinpoint.get-segment-version': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.pinpoint.get-sms-template': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.pinpoint.get-voice-template': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.pinpoint.update-email-template': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.pinpoint.update-in-app-template': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.pinpoint.update-push-template': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.pinpoint.update-sms-template': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.pinpoint.update-voice-template': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.quicksight.start-asset-bundle-import-job': [ + ('awscli.customizations.quicksight', 'register_quicksight_asset_bundle_customizations') + ], + 'building-argument-table.rds.add-option-to-option-group': [ + ('awscli.customizations.rds', 'register_rds_modify_split') + ], + 'building-argument-table.rds.remove-option-from-option-group': [ + ('awscli.customizations.rds', 'register_rds_modify_split') + ], + 'building-argument-table.rekognition.*': [ + ('awscli.customizations.rekognition', 'register_rekognition_detect_labels') + ], + 'building-argument-table.rekognition.compare-faces': [ + ('awscli.customizations.rekognition', 'register_rekognition_detect_labels') + ], + 'building-argument-table.rekognition.create-stream-processor': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.route53.delete-traffic-policy': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.route53.get-traffic-policy': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.route53.update-traffic-policy-comment': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.route53domains.view-billing': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.s3api.select-object-content': [ + ('awscli.customizations.s3events', 'register_event_stream_arg') + ], + 'building-argument-table.sagemaker.delete-image-version': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.sagemaker.describe-image-version': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.sagemaker.list-aliases': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.sagemaker.update-image-version': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.schemas.*': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.ses.send-email': [ + ('awscli.customizations.sessendemail', 'register_ses_send_email') + ], + 'building-argument-table.sns.subscribe': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.stepfunctions.send-task-success': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.swf.register-activity-type': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.swf.register-workflow-type': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.translate.import-terminology': [ + ('awscli.customizations.translate', 'register_translate_import_terminology') + ], + 'building-argument-table.translate.translate-document': [ + ('awscli.customizations.translate', 'register_translate_import_terminology') + ], + 'building-argument-table.workdocs.create-notification-subscription': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-argument-table.workdocs.describe-users': [ + ('awscli.customizations.argrename', 'register_arg_renames') + ], + 'building-command-table': [ + ('awscli.customizations.waiters', 'register_add_waiters'), + ('awscli.alias', 'register_alias_commands') + ], + 'building-command-table.bedrock-agent-runtime': [ + ('awscli.customizations.removals', 'register_removals') + ], + 'building-command-table.bedrock-agentcore': [ + ('awscli.customizations.removals', 'register_removals') + ], + 'building-command-table.bedrock-runtime': [ + ('awscli.customizations.removals', 'register_removals') + ], + 'building-command-table.cli-dev': [ + ('awscli.customizations.wizard.commands', 'register_wizard_commands') + ], + 'building-command-table.cloudformation': [ + ('awscli.customizations.cloudformation', 'initialize') + ], + 'building-command-table.cloudfront': [ + ('awscli.customizations.cloudfront', 'register') + ], + 'building-command-table.cloudtrail': [ + ('awscli.customizations.cloudtrail', 'initialize') + ], + 'building-command-table.cloudwatch': [ + ('awscli.customizations.cloudwatch', 'register_rename_otel_commands') + ], + 'building-command-table.codeartifact': [ + ('awscli.customizations.codeartifact', 'register_codeartifact_commands') + ], + 'building-command-table.codecommit': [ + ('awscli.customizations.codecommit', 'initialize') + ], + 'building-command-table.configservice': [ + ('awscli.customizations.configservice.subscribe', 'register_subscribe'), + ('awscli.customizations.configservice.getstatus', 'register_get_status') + ], + 'building-command-table.configure': [ + ('awscli.customizations.wizard.commands', 'register_wizard_commands') + ], + 'building-command-table.connecthealth': [ + ('awscli.customizations.removals', 'register_removals') + ], + 'building-command-table.datapipeline': [ + ('awscli.customizations.datapipeline', 'register_customizations') + ], + 'building-command-table.deploy': [ + ('awscli.customizations.codedeploy.codedeploy', 'register_codedeploy') + ], + 'building-command-table.devops-agent': [ + ('awscli.customizations.removals', 'register_removals') + ], + 'building-command-table.dlm': [ + ('awscli.customizations.dlm.dlm', 'dlm_initialize') + ], + 'building-command-table.dsql': [ + ('awscli.customizations.dsql', 'register_dsql_customizations') + ], + 'building-command-table.dynamodb': [ + ('awscli.customizations.wizard.commands', 'register_wizard_commands') + ], + 'building-command-table.ec2': [ + ('awscli.customizations.removals', 'register_removals') + ], + 'building-command-table.ec2-instance-connect': [ + ('awscli.customizations.ec2instanceconnect', 'register_ec2_instance_connect_commands') + ], + 'building-command-table.ecr': [ + ('awscli.customizations.ecr', 'register_ecr_commands') + ], + 'building-command-table.ecr-public': [ + ('awscli.customizations.ecr_public', 'register_ecr_public_commands') + ], + 'building-command-table.ecs': [ + ('awscli.customizations.ecs', 'initialize') + ], + 'building-command-table.eks': [ + ('awscli.customizations.eks', 'initialize') + ], + 'building-command-table.emr': [ + ('awscli.customizations.removals', 'register_removals'), + ('awscli.customizations.emr.emr', 'emr_initialize') + ], + 'building-command-table.emr-containers': [ + ('awscli.customizations.emrcontainers', 'initialize') + ], + 'building-command-table.events': [ + ('awscli.customizations.wizard.commands', 'register_wizard_commands') + ], + 'building-command-table.gamelift': [ + ('awscli.customizations.gamelift', 'register_gamelift_commands') + ], + 'building-command-table.iam': [ + ('awscli.customizations.wizard.commands', 'register_wizard_commands') + ], + 'building-command-table.iotsitewise': [ + ('awscli.customizations.removals', 'register_removals') + ], + 'building-command-table.kinesis': [ + ('awscli.customizations.removals', 'register_removals') + ], + 'building-command-table.lambda': [ + ('awscli.customizations.removals', 'register_removals'), + ('awscli.customizations.wizard.commands', 'register_wizard_commands') + ], + 'building-command-table.lexv2-runtime': [ + ('awscli.customizations.removals', 'register_removals') + ], + 'building-command-table.lightsail': [ + ('awscli.customizations.lightsail', 'initialize') + ], + 'building-command-table.logs': [ + ('awscli.customizations.removals', 'register_removals'), + ('awscli.customizations.logs', 'register_logs_commands') + ], + 'building-command-table.main': [ + ('awscli.customizations.s3.s3', 'register_s3_main'), + ('awscli.customizations.dynamodb.ddb', 'register_ddb'), + ('awscli.customizations.configure.configure', 'register_configure_cmd'), + ('awscli.customizations.codedeploy.codedeploy', 'register_rename_codedeploy'), + ('awscli.customizations.configservice.rename_cmd', 'register_rename_config'), + ('awscli.customizations.history', 'register_history_commands'), + ('awscli.customizations.devcommands', 'register_dev_commands'), + ('awscli.customizations.login', 'register_login_cmds') + ], + 'building-command-table.polly': [ + ('awscli.customizations.removals', 'register_removals') + ], + 'building-command-table.qbusiness': [ + ('awscli.customizations.removals', 'register_removals') + ], + 'building-command-table.rds': [ + ('awscli.customizations.rds', 'register_rds_modify_split'), + ('awscli.customizations.rds', 'register_add_generate_db_auth_token') + ], + 'building-command-table.s3_sync': [ + ('awscli.customizations.s3.s3', 'register_s3_sync_strategies') + ], + 'building-command-table.sagemaker-runtime': [ + ('awscli.customizations.removals', 'register_removals') + ], + 'building-command-table.servicecatalog': [ + ('awscli.customizations.servicecatalog', 'register_servicecatalog_commands') + ], + 'building-command-table.ses': [ + ('awscli.customizations.removals', 'register_removals') + ], + 'building-command-table.ssm': [ + ('awscli.customizations.sessionmanager', 'register_ssm_session') + ], + 'building-command-table.sso': [ + ('awscli.customizations.sso', 'register_sso_commands') + ], + 'calling-command.cloudsearchdomain': [ + ('awscli.customizations.cloudsearchdomain', 'register_cloudsearchdomain') + ], + 'calling-command.dynamodb.*': [ + ('awscli.customizations.dynamodb.paginatorfix', 'register_dynamodb_paginator_fix') + ], + 'calling-command.ec2.describe-snapshots': [ + ('awscli.customizations.ec2.paginate', 'register_ec2_page_size_injector') + ], + 'calling-command.ec2.describe-volumes': [ + ('awscli.customizations.ec2.paginate', 'register_ec2_page_size_injector') + ], + 'doc-description': [ + ('awscli.customizations.paginate', 'register_pagination') + ], + 'doc-description.ec2.authorize-security-group-egress': [ + ('awscli.customizations.ec2.secgroupsimplify', 'register_secgroup') + ], + 'doc-description.ec2.authorize-security-group-ingress': [ + ('awscli.customizations.ec2.secgroupsimplify', 'register_secgroup') + ], + 'doc-description.ec2.revoke-security-group-ingress': [ + ('awscli.customizations.ec2.secgroupsimplify', 'register_secgroup') + ], + 'doc-description.ec2.revoke-security-groupdoc-ingress': [ + ('awscli.customizations.ec2.secgroupsimplify', 'register_secgroup') + ], + 'doc-description.iot-data': [ + ('awscli.customizations.iot_data', 'register_custom_endpoint_note') + ], + 'doc-examples.*.*': [ + ('awscli.customizations.addexamples', 'register_docs_add_examples') + ], + 'doc-option.route53.create-hosted-zone.hosted-zone-config': [ + ('awscli.customizations.route53', 'register_create_hosted_zone_doc_fix') + ], + 'doc-output.datapipeline.get-pipeline-definition': [ + ('awscli.customizations.datapipeline', 'register_customizations') + ], + 'doc-output.s3api': [ + ('awscli.customizations.s3events', 'register_document_expires_string') + ], + 'doc-output.s3api.select-object-content': [ + ('awscli.customizations.s3events', 'register_event_stream_arg') + ], + 'doc-title.kms.create-grant': [ + ('awscli.customizations.kms', 'register_fix_kms_create_grant_docs') + ], + 'operation-args-parsed.cloudfront.create-distribution': [ + ('awscli.customizations.cloudfront', 'register') + ], + 'operation-args-parsed.cloudfront.create-invalidation': [ + ('awscli.customizations.cloudfront', 'register') + ], + 'operation-args-parsed.cloudfront.update-distribution': [ + ('awscli.customizations.cloudfront', 'register') + ], + 'operation-args-parsed.cloudwatch.put-metric-data': [ + ('awscli.customizations.putmetricdata', 'register_put_metric_data') + ], + 'operation-args-parsed.ec2.authorize-security-group-egress': [ + ('awscli.customizations.ec2.secgroupsimplify', 'register_secgroup') + ], + 'operation-args-parsed.ec2.authorize-security-group-ingress': [ + ('awscli.customizations.ec2.secgroupsimplify', 'register_secgroup') + ], + 'operation-args-parsed.ec2.bundle-instance': [ + ('awscli.customizations.ec2.bundleinstance', 'register_bundleinstance') + ], + 'operation-args-parsed.ec2.revoke-security-group-egress': [ + ('awscli.customizations.ec2.secgroupsimplify', 'register_secgroup') + ], + 'operation-args-parsed.ec2.revoke-security-group-ingress': [ + ('awscli.customizations.ec2.secgroupsimplify', 'register_secgroup') + ], + 'operation-args-parsed.ec2.run-instances': [ + ('awscli.customizations.ec2.runinstances', 'register_runinstances') + ], + 'operation-args-parsed.ecs.create-express-gateway-service': [ + ('awscli.customizations.ecs.monitormutatinggatewayservice', 'register_monitor_mutating_gateway_service') + ], + 'operation-args-parsed.ecs.delete-express-gateway-service': [ + ('awscli.customizations.ecs.monitormutatinggatewayservice', 'register_monitor_mutating_gateway_service') + ], + 'operation-args-parsed.ecs.update-express-gateway-service': [ + ('awscli.customizations.ecs.monitormutatinggatewayservice', 'register_monitor_mutating_gateway_service') + ], + 'operation-args-parsed.kinesis.list-streams': [ + ('awscli.customizations.kinesis', 'register_kinesis_list_streams_pagination_backcompat') + ], + 'operation-args-parsed.ses.send-email': [ + ('awscli.customizations.sessendemail', 'register_ses_send_email') + ], + 'process-cli-arg': [ + ('awscli.argprocess', 'register_param_shorthand_parser') + ], + 'process-cli-arg.lambda.update-function-code': [ + ('awscli.customizations.awslambda', 'register_lambda_create_function') + ], + 'session-initialized': [ + ('awscli.paramfile', 'register_init_uri_param_handler'), + ('awscli.customizations.binaryformat', 'register_init_binary_formatter'), + ('awscli.clidriver', 'register_no_pager_handler'), + ('awscli.customizations.assumerole', 'register_assume_role_provider'), + ('awscli.customizations.timestampformat', 'register_timestamp_format'), + ('awscli.customizations.history', 'register_history_mode'), + ('awscli.customizations.sso', 'register_sso_commands') + ], + 'top-level-args-parsed': [ + ('awscli.customizations.globalargs', 'register_parse_global_args'), + ('awscli.customizations.cloudfront', 'register') + ] +} + +# Declarative model of changes made to the command table by plugins +# that register against building-command-table.main. +# +# At runtime, plugins listed in building-command-table.main above +# are NOT called as init functions. Instead, these pre-computed +# operations are applied directly, allowing added commands to be +# wrapped in LazyCommand and deferring heavy module imports until +# the command is actually invoked. +# +# Entry formats: +# ('rename', old_name, new_name) +# ('add', cmd_name, cmd_module, cmd_class) + +MAIN_COMMAND_TABLE_OPS = [ + ('rename', 's3', 's3api'), + ('add', 's3', 'awscli.customizations.s3.s3', 'S3'), + ('add', 'ddb', 'awscli.customizations.dynamodb.ddb', 'DDB'), + ('add', 'configure', 'awscli.customizations.configure.configure', 'ConfigureCommand'), + ('rename', 'codedeploy', 'deploy'), + ('rename', 'config', 'configservice'), + ('add', 'history', 'awscli.customizations.history', 'HistoryCommand'), + ('add', 'cli-dev', 'awscli.customizations.devcommands', 'CLIDevCommand'), + ('add', 'login', 'awscli.customizations.login.login', 'LoginCommand'), + ('add', 'logout', 'awscli.customizations.login.logout', 'LogoutCommand'), +] From 75d5343c8bc95c40d2fdd3824fadeb4247290c41 Mon Sep 17 00:00:00 2001 From: aemous Date: Wed, 13 May 2026 14:25:16 -0400 Subject: [PATCH 08/21] Formatting. --- awscli/customizations/addexamples.py | 1 - awscli/handlers.py | 10 +- awscli/handlers_registry.py | 217 +++++++++++++++++++++------ 3 files changed, 174 insertions(+), 54 deletions(-) diff --git a/awscli/customizations/addexamples.py b/awscli/customizations/addexamples.py index 9ccf670ec6e8..5e91cc547aed 100644 --- a/awscli/customizations/addexamples.py +++ b/awscli/customizations/addexamples.py @@ -34,7 +34,6 @@ def register_docs_add_examples(event_emitter): - # The following will get fired for every option we are # documenting. It will attempt to add an example_fn on to # the parameter object if the parameter supports shorthand diff --git a/awscli/handlers.py b/awscli/handlers.py index bc5234a4a7f9..2dab06ba171a 100644 --- a/awscli/handlers.py +++ b/awscli/handlers.py @@ -39,6 +39,8 @@ from awscli.customizations.codecommit import initialize as codecommit_init from awscli.customizations.codedeploy.codedeploy import ( register_codedeploy, +) +from awscli.customizations.codedeploy.codedeploy import ( register_rename_codedeploy as codedeploy_init, ) from awscli.customizations.configservice.getstatus import register_get_status @@ -60,7 +62,7 @@ from awscli.customizations.ec2.addcount import register_count_events from awscli.customizations.ec2.bundleinstance import register_bundleinstance from awscli.customizations.ec2.decryptpassword import ( - register_ec2_add_priv_launch_key + register_ec2_add_priv_launch_key, ) from awscli.customizations.ec2.paginate import register_ec2_page_size_injector from awscli.customizations.ec2.protocolarg import register_protocol_args @@ -118,7 +120,7 @@ from awscli.customizations.route53 import register_create_hosted_zone_doc_fix from awscli.customizations.s3.s3 import ( register_s3_main, - register_s3_sync_strategies + register_s3_sync_strategies, ) from awscli.customizations.s3errormsg import register_s3_error_msg from awscli.customizations.s3events import ( @@ -131,7 +133,9 @@ from awscli.customizations.sessendemail import register_ses_send_email from awscli.customizations.sessionmanager import register_ssm_session from awscli.customizations.sso import register_sso_commands -from awscli.customizations.streamingoutputarg import register_streaming_output_arg +from awscli.customizations.streamingoutputarg import ( + register_streaming_output_arg, +) from awscli.customizations.timestampformat import register_timestamp_format from awscli.customizations.toplevelbool import register_bool_params from awscli.customizations.translate import ( diff --git a/awscli/handlers_registry.py b/awscli/handlers_registry.py index c20c40183f72..0aa2bef59223 100644 --- a/awscli/handlers_registry.py +++ b/awscli/handlers_registry.py @@ -20,18 +20,28 @@ Entry format: (module, fn_name) call fn(event_handlers) """ + PLUGIN_REGISTRY = { 'after-call.data-pipeline.GetPipelineDefinition': [ ('awscli.customizations.datapipeline', 'register_customizations') ], 'after-call.ecs.CreateExpressGatewayService': [ - ('awscli.customizations.ecs.monitormutatinggatewayservice', 'register_monitor_mutating_gateway_service') + ( + 'awscli.customizations.ecs.monitormutatinggatewayservice', + 'register_monitor_mutating_gateway_service', + ) ], 'after-call.ecs.DeleteExpressGatewayService': [ - ('awscli.customizations.ecs.monitormutatinggatewayservice', 'register_monitor_mutating_gateway_service') + ( + 'awscli.customizations.ecs.monitormutatinggatewayservice', + 'register_monitor_mutating_gateway_service', + ) ], 'after-call.ecs.UpdateExpressGatewayService': [ - ('awscli.customizations.ecs.monitormutatinggatewayservice', 'register_monitor_mutating_gateway_service') + ( + 'awscli.customizations.ecs.monitormutatinggatewayservice', + 'register_monitor_mutating_gateway_service', + ) ], 'after-call.iam.CreateVirtualMFADevice': [ ('awscli.customizations.iamvirtmfa', 'IAMVMFAWrapper') @@ -40,13 +50,22 @@ ('awscli.customizations.s3errormsg', 'register_s3_error_msg') ], 'before-building-argument-table-parser.ecs.create-express-gateway-service': [ - ('awscli.customizations.ecs.monitormutatinggatewayservice', 'register_monitor_mutating_gateway_service') + ( + 'awscli.customizations.ecs.monitormutatinggatewayservice', + 'register_monitor_mutating_gateway_service', + ) ], 'before-building-argument-table-parser.ecs.delete-express-gateway-service': [ - ('awscli.customizations.ecs.monitormutatinggatewayservice', 'register_monitor_mutating_gateway_service') + ( + 'awscli.customizations.ecs.monitormutatinggatewayservice', + 'register_monitor_mutating_gateway_service', + ) ], 'before-building-argument-table-parser.ecs.update-express-gateway-service': [ - ('awscli.customizations.ecs.monitormutatinggatewayservice', 'register_monitor_mutating_gateway_service') + ( + 'awscli.customizations.ecs.monitormutatinggatewayservice', + 'register_monitor_mutating_gateway_service', + ) ], 'before-building-argument-table-parser.emr.*': [ ('awscli.customizations.emr.emr', 'emr_initialize') @@ -62,15 +81,21 @@ ], 'before-parameter-build.ec2.RunInstances': [ ('awscli.customizations.ec2.addcount', 'register_count_events'), - ('awscli.customizations.ec2.runinstances', 'register_runinstances') + ('awscli.customizations.ec2.runinstances', 'register_runinstances'), ], 'building-argument-table': [ ('awscli.customizations.cliinput', 'register_cli_input_args'), ('awscli.customizations.paginate', 'register_pagination'), - ('awscli.customizations.generatecliskeleton', 'register_generate_cli_skeleton') + ( + 'awscli.customizations.generatecliskeleton', + 'register_generate_cli_skeleton', + ), ], 'building-argument-table.*': [ - ('awscli.customizations.streamingoutputarg', 'register_streaming_output_arg') + ( + 'awscli.customizations.streamingoutputarg', + 'register_streaming_output_arg', + ) ], 'building-argument-table.apigateway.create-rest-api': [ ('awscli.customizations.argrename', 'register_arg_renames') @@ -121,7 +146,10 @@ ('awscli.customizations.argrename', 'register_arg_renames') ], 'building-argument-table.configservice.put-configuration-recorder': [ - ('awscli.customizations.configservice.putconfigurationrecorder', 'register_modify_put_configuration_recorder') + ( + 'awscli.customizations.configservice.putconfigurationrecorder', + 'register_modify_put_configuration_recorder', + ) ], 'building-argument-table.controltower.create-landing-zone': [ ('awscli.customizations.argrename', 'register_arg_renames') @@ -155,7 +183,7 @@ ], 'building-argument-table.ec2.*': [ ('awscli.customizations.argrename', 'register_arg_renames'), - ('awscli.customizations.toplevelbool', 'register_bool_params') + ('awscli.customizations.toplevelbool', 'register_bool_params'), ], 'building-argument-table.ec2.authorize-security-group-egress': [ ('awscli.customizations.ec2.secgroupsimplify', 'register_secgroup') @@ -170,7 +198,10 @@ ('awscli.customizations.argrename', 'register_arg_renames') ], 'building-argument-table.ec2.get-password-data': [ - ('awscli.customizations.ec2.decryptpassword', 'register_ec2_add_priv_launch_key') + ( + 'awscli.customizations.ec2.decryptpassword', + 'register_ec2_add_priv_launch_key', + ) ], 'building-argument-table.ec2.revoke-security-group-egress': [ ('awscli.customizations.ec2.secgroupsimplify', 'register_secgroup') @@ -180,22 +211,31 @@ ], 'building-argument-table.ec2.run-instances': [ ('awscli.customizations.ec2.addcount', 'register_count_events'), - ('awscli.customizations.ec2.runinstances', 'register_runinstances') + ('awscli.customizations.ec2.runinstances', 'register_runinstances'), ], 'building-argument-table.ecs.*': [ ('awscli.customizations.argrename', 'register_arg_renames') ], 'building-argument-table.ecs.create-express-gateway-service': [ - ('awscli.customizations.ecs.monitormutatinggatewayservice', 'register_monitor_mutating_gateway_service') + ( + 'awscli.customizations.ecs.monitormutatinggatewayservice', + 'register_monitor_mutating_gateway_service', + ) ], 'building-argument-table.ecs.delete-express-gateway-service': [ - ('awscli.customizations.ecs.monitormutatinggatewayservice', 'register_monitor_mutating_gateway_service') + ( + 'awscli.customizations.ecs.monitormutatinggatewayservice', + 'register_monitor_mutating_gateway_service', + ) ], 'building-argument-table.ecs.execute-command': [ ('awscli.customizations.argrename', 'register_arg_renames') ], 'building-argument-table.ecs.update-express-gateway-service': [ - ('awscli.customizations.ecs.monitormutatinggatewayservice', 'register_monitor_mutating_gateway_service') + ( + 'awscli.customizations.ecs.monitormutatinggatewayservice', + 'register_monitor_mutating_gateway_service', + ) ], 'building-argument-table.eks.create-cluster': [ ('awscli.customizations.argrename', 'register_arg_renames') @@ -258,7 +298,10 @@ ('awscli.customizations.argrename', 'register_arg_renames') ], 'building-argument-table.kinesis.list-streams': [ - ('awscli.customizations.kinesis', 'register_kinesis_list_streams_pagination_backcompat') + ( + 'awscli.customizations.kinesis', + 'register_kinesis_list_streams_pagination_backcompat', + ) ], 'building-argument-table.kinesisanalytics.add-application-output': [ ('awscli.customizations.argrename', 'register_arg_renames') @@ -369,7 +412,10 @@ ('awscli.customizations.argrename', 'register_arg_renames') ], 'building-argument-table.quicksight.start-asset-bundle-import-job': [ - ('awscli.customizations.quicksight', 'register_quicksight_asset_bundle_customizations') + ( + 'awscli.customizations.quicksight', + 'register_quicksight_asset_bundle_customizations', + ) ], 'building-argument-table.rds.add-option-to-option-group': [ ('awscli.customizations.rds', 'register_rds_modify_split') @@ -378,10 +424,16 @@ ('awscli.customizations.rds', 'register_rds_modify_split') ], 'building-argument-table.rekognition.*': [ - ('awscli.customizations.rekognition', 'register_rekognition_detect_labels') + ( + 'awscli.customizations.rekognition', + 'register_rekognition_detect_labels', + ) ], 'building-argument-table.rekognition.compare-faces': [ - ('awscli.customizations.rekognition', 'register_rekognition_detect_labels') + ( + 'awscli.customizations.rekognition', + 'register_rekognition_detect_labels', + ) ], 'building-argument-table.rekognition.create-stream-processor': [ ('awscli.customizations.argrename', 'register_arg_renames') @@ -432,10 +484,16 @@ ('awscli.customizations.argrename', 'register_arg_renames') ], 'building-argument-table.translate.import-terminology': [ - ('awscli.customizations.translate', 'register_translate_import_terminology') + ( + 'awscli.customizations.translate', + 'register_translate_import_terminology', + ) ], 'building-argument-table.translate.translate-document': [ - ('awscli.customizations.translate', 'register_translate_import_terminology') + ( + 'awscli.customizations.translate', + 'register_translate_import_terminology', + ) ], 'building-argument-table.workdocs.create-notification-subscription': [ ('awscli.customizations.argrename', 'register_arg_renames') @@ -445,7 +503,7 @@ ], 'building-command-table': [ ('awscli.customizations.waiters', 'register_add_waiters'), - ('awscli.alias', 'register_alias_commands') + ('awscli.alias', 'register_alias_commands'), ], 'building-command-table.bedrock-agent-runtime': [ ('awscli.customizations.removals', 'register_removals') @@ -472,14 +530,23 @@ ('awscli.customizations.cloudwatch', 'register_rename_otel_commands') ], 'building-command-table.codeartifact': [ - ('awscli.customizations.codeartifact', 'register_codeartifact_commands') + ( + 'awscli.customizations.codeartifact', + 'register_codeartifact_commands', + ) ], 'building-command-table.codecommit': [ ('awscli.customizations.codecommit', 'initialize') ], 'building-command-table.configservice': [ - ('awscli.customizations.configservice.subscribe', 'register_subscribe'), - ('awscli.customizations.configservice.getstatus', 'register_get_status') + ( + 'awscli.customizations.configservice.subscribe', + 'register_subscribe', + ), + ( + 'awscli.customizations.configservice.getstatus', + 'register_get_status', + ), ], 'building-command-table.configure': [ ('awscli.customizations.wizard.commands', 'register_wizard_commands') @@ -509,7 +576,10 @@ ('awscli.customizations.removals', 'register_removals') ], 'building-command-table.ec2-instance-connect': [ - ('awscli.customizations.ec2instanceconnect', 'register_ec2_instance_connect_commands') + ( + 'awscli.customizations.ec2instanceconnect', + 'register_ec2_instance_connect_commands', + ) ], 'building-command-table.ecr': [ ('awscli.customizations.ecr', 'register_ecr_commands') @@ -525,7 +595,7 @@ ], 'building-command-table.emr': [ ('awscli.customizations.removals', 'register_removals'), - ('awscli.customizations.emr.emr', 'emr_initialize') + ('awscli.customizations.emr.emr', 'emr_initialize'), ], 'building-command-table.emr-containers': [ ('awscli.customizations.emrcontainers', 'initialize') @@ -547,7 +617,7 @@ ], 'building-command-table.lambda': [ ('awscli.customizations.removals', 'register_removals'), - ('awscli.customizations.wizard.commands', 'register_wizard_commands') + ('awscli.customizations.wizard.commands', 'register_wizard_commands'), ], 'building-command-table.lexv2-runtime': [ ('awscli.customizations.removals', 'register_removals') @@ -557,17 +627,26 @@ ], 'building-command-table.logs': [ ('awscli.customizations.removals', 'register_removals'), - ('awscli.customizations.logs', 'register_logs_commands') + ('awscli.customizations.logs', 'register_logs_commands'), ], 'building-command-table.main': [ ('awscli.customizations.s3.s3', 'register_s3_main'), ('awscli.customizations.dynamodb.ddb', 'register_ddb'), - ('awscli.customizations.configure.configure', 'register_configure_cmd'), - ('awscli.customizations.codedeploy.codedeploy', 'register_rename_codedeploy'), - ('awscli.customizations.configservice.rename_cmd', 'register_rename_config'), + ( + 'awscli.customizations.configure.configure', + 'register_configure_cmd', + ), + ( + 'awscli.customizations.codedeploy.codedeploy', + 'register_rename_codedeploy', + ), + ( + 'awscli.customizations.configservice.rename_cmd', + 'register_rename_config', + ), ('awscli.customizations.history', 'register_history_commands'), ('awscli.customizations.devcommands', 'register_dev_commands'), - ('awscli.customizations.login', 'register_login_cmds') + ('awscli.customizations.login', 'register_login_cmds'), ], 'building-command-table.polly': [ ('awscli.customizations.removals', 'register_removals') @@ -577,7 +656,7 @@ ], 'building-command-table.rds': [ ('awscli.customizations.rds', 'register_rds_modify_split'), - ('awscli.customizations.rds', 'register_add_generate_db_auth_token') + ('awscli.customizations.rds', 'register_add_generate_db_auth_token'), ], 'building-command-table.s3_sync': [ ('awscli.customizations.s3.s3', 'register_s3_sync_strategies') @@ -586,7 +665,10 @@ ('awscli.customizations.removals', 'register_removals') ], 'building-command-table.servicecatalog': [ - ('awscli.customizations.servicecatalog', 'register_servicecatalog_commands') + ( + 'awscli.customizations.servicecatalog', + 'register_servicecatalog_commands', + ) ], 'building-command-table.ses': [ ('awscli.customizations.removals', 'register_removals') @@ -598,16 +680,28 @@ ('awscli.customizations.sso', 'register_sso_commands') ], 'calling-command.cloudsearchdomain': [ - ('awscli.customizations.cloudsearchdomain', 'register_cloudsearchdomain') + ( + 'awscli.customizations.cloudsearchdomain', + 'register_cloudsearchdomain', + ) ], 'calling-command.dynamodb.*': [ - ('awscli.customizations.dynamodb.paginatorfix', 'register_dynamodb_paginator_fix') + ( + 'awscli.customizations.dynamodb.paginatorfix', + 'register_dynamodb_paginator_fix', + ) ], 'calling-command.ec2.describe-snapshots': [ - ('awscli.customizations.ec2.paginate', 'register_ec2_page_size_injector') + ( + 'awscli.customizations.ec2.paginate', + 'register_ec2_page_size_injector', + ) ], 'calling-command.ec2.describe-volumes': [ - ('awscli.customizations.ec2.paginate', 'register_ec2_page_size_injector') + ( + 'awscli.customizations.ec2.paginate', + 'register_ec2_page_size_injector', + ) ], 'doc-description': [ ('awscli.customizations.paginate', 'register_pagination') @@ -631,7 +725,10 @@ ('awscli.customizations.addexamples', 'register_docs_add_examples') ], 'doc-option.route53.create-hosted-zone.hosted-zone-config': [ - ('awscli.customizations.route53', 'register_create_hosted_zone_doc_fix') + ( + 'awscli.customizations.route53', + 'register_create_hosted_zone_doc_fix', + ) ], 'doc-output.datapipeline.get-pipeline-definition': [ ('awscli.customizations.datapipeline', 'register_customizations') @@ -676,16 +773,28 @@ ('awscli.customizations.ec2.runinstances', 'register_runinstances') ], 'operation-args-parsed.ecs.create-express-gateway-service': [ - ('awscli.customizations.ecs.monitormutatinggatewayservice', 'register_monitor_mutating_gateway_service') + ( + 'awscli.customizations.ecs.monitormutatinggatewayservice', + 'register_monitor_mutating_gateway_service', + ) ], 'operation-args-parsed.ecs.delete-express-gateway-service': [ - ('awscli.customizations.ecs.monitormutatinggatewayservice', 'register_monitor_mutating_gateway_service') + ( + 'awscli.customizations.ecs.monitormutatinggatewayservice', + 'register_monitor_mutating_gateway_service', + ) ], 'operation-args-parsed.ecs.update-express-gateway-service': [ - ('awscli.customizations.ecs.monitormutatinggatewayservice', 'register_monitor_mutating_gateway_service') + ( + 'awscli.customizations.ecs.monitormutatinggatewayservice', + 'register_monitor_mutating_gateway_service', + ) ], 'operation-args-parsed.kinesis.list-streams': [ - ('awscli.customizations.kinesis', 'register_kinesis_list_streams_pagination_backcompat') + ( + 'awscli.customizations.kinesis', + 'register_kinesis_list_streams_pagination_backcompat', + ) ], 'operation-args-parsed.ses.send-email': [ ('awscli.customizations.sessendemail', 'register_ses_send_email') @@ -698,17 +807,20 @@ ], 'session-initialized': [ ('awscli.paramfile', 'register_init_uri_param_handler'), - ('awscli.customizations.binaryformat', 'register_init_binary_formatter'), + ( + 'awscli.customizations.binaryformat', + 'register_init_binary_formatter', + ), ('awscli.clidriver', 'register_no_pager_handler'), ('awscli.customizations.assumerole', 'register_assume_role_provider'), ('awscli.customizations.timestampformat', 'register_timestamp_format'), ('awscli.customizations.history', 'register_history_mode'), - ('awscli.customizations.sso', 'register_sso_commands') + ('awscli.customizations.sso', 'register_sso_commands'), ], 'top-level-args-parsed': [ ('awscli.customizations.globalargs', 'register_parse_global_args'), - ('awscli.customizations.cloudfront', 'register') - ] + ('awscli.customizations.cloudfront', 'register'), + ], } # Declarative model of changes made to the command table by plugins @@ -728,7 +840,12 @@ ('rename', 's3', 's3api'), ('add', 's3', 'awscli.customizations.s3.s3', 'S3'), ('add', 'ddb', 'awscli.customizations.dynamodb.ddb', 'DDB'), - ('add', 'configure', 'awscli.customizations.configure.configure', 'ConfigureCommand'), + ( + 'add', + 'configure', + 'awscli.customizations.configure.configure', + 'ConfigureCommand', + ), ('rename', 'codedeploy', 'deploy'), ('rename', 'config', 'configservice'), ('add', 'history', 'awscli.customizations.history', 'HistoryCommand'), From 511d6cbdf080725c4dbce6aade0263af9b2bef94 Mon Sep 17 00:00:00 2001 From: aemous Date: Wed, 13 May 2026 14:39:27 -0400 Subject: [PATCH 09/21] Update test_handlers_registry against the simplified format of handlers_registry. --- tests/functional/test_handlers_registry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/functional/test_handlers_registry.py b/tests/functional/test_handlers_registry.py index 7856fa3caf91..97b53f2097dc 100644 --- a/tests/functional/test_handlers_registry.py +++ b/tests/functional/test_handlers_registry.py @@ -59,7 +59,7 @@ def test_main_command_table_plugins_only_register_against_main(): """ main_entries = PLUGIN_REGISTRY.get('building-command-table.main', []) violations = [] - for module_path, fn_name, entry_type in main_entries: + for module_path, fn_name in main_entries: emitter = _AuditEmitter() mod = importlib.import_module(module_path) fn = getattr(mod, fn_name) @@ -98,7 +98,7 @@ def test_main_command_table_callbacks_only_add_or_rename(): main_entries = PLUGIN_REGISTRY.get('building-command-table.main', []) violations = [] - for module_path, fn_name, entry_type in main_entries: + for module_path, fn_name in main_entries: collector = _CallbackCollector('building-command-table.main') mod = importlib.import_module(module_path) fn = getattr(mod, fn_name) From 11b07cde48c721a200cafc3b3b51a7d8418fc546 Mon Sep 17 00:00:00 2001 From: aemous Date: Wed, 13 May 2026 15:04:20 -0400 Subject: [PATCH 10/21] Add new functional tests to assert that all modules referenced in handlers_registry.py are importable. --- tests/functional/test_handlers_registry.py | 59 +++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/tests/functional/test_handlers_registry.py b/tests/functional/test_handlers_registry.py index 97b53f2097dc..48ac38554035 100644 --- a/tests/functional/test_handlers_registry.py +++ b/tests/functional/test_handlers_registry.py @@ -15,7 +15,7 @@ import botocore.session -from awscli.handlers_registry import PLUGIN_REGISTRY +from awscli.handlers_registry import MAIN_COMMAND_TABLE_OPS, PLUGIN_REGISTRY class _AuditEmitter: @@ -46,6 +46,63 @@ def register(self, event_name, handler, *args, **kwargs): register_last = register +def test_all_registry_entries_are_importable(): + """Every (module, fn_name) in PLUGIN_REGISTRY must resolve to a + callable. This catches typos, stale entries, and missing modules. + """ + violations = [] + seen = set() + for entries in PLUGIN_REGISTRY.values(): + for module_path, fn_name in entries: + if (module_path, fn_name) in seen: + continue + seen.add((module_path, fn_name)) + try: + mod = importlib.import_module(module_path) + except ImportError as e: + violations.append(f'{module_path}: {e}') + continue + fn = getattr(mod, fn_name, None) + if fn is None: + violations.append( + f'{module_path}.{fn_name} does not exist' + ) + elif not callable(fn): + violations.append( + f'{module_path}.{fn_name} is not callable' + ) + assert not violations, ( + 'The following PLUGIN_REGISTRY entries are invalid:\n' + + '\n'.join(f' - {v}' for v in violations) + ) + + +def test_all_main_command_table_ops_modules_are_importable(): + """Every module referenced in MAIN_COMMAND_TABLE_OPS 'add' entries + must be importable and contain the specified class. + """ + violations = [] + for op in MAIN_COMMAND_TABLE_OPS: + if op[0] != 'add': + continue + _, cmd_name, cmd_module, cmd_class = op + try: + mod = importlib.import_module(cmd_module) + except ImportError as e: + violations.append(f'{cmd_module}: {e}') + continue + cls_name = cmd_class.split('.')[-1] + if not hasattr(mod, cls_name): + violations.append( + f'{cmd_module}.{cls_name} does not exist ' + f'(referenced by add {cmd_name!r})' + ) + assert not violations, ( + 'The following MAIN_COMMAND_TABLE_OPS entries are invalid:\n' + + '\n'.join(f' - {v}' for v in violations) + ) + + def test_main_command_table_plugins_only_register_against_main(): """Plugins listed under building-command-table.main must not register against any other events. From 844dcceef7d129a4878222376da17ef4550c6126 Mon Sep 17 00:00:00 2001 From: aemous Date: Wed, 13 May 2026 15:04:43 -0400 Subject: [PATCH 11/21] Formatting. --- tests/functional/test_handlers_registry.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/functional/test_handlers_registry.py b/tests/functional/test_handlers_registry.py index 48ac38554035..93d0e36f4854 100644 --- a/tests/functional/test_handlers_registry.py +++ b/tests/functional/test_handlers_registry.py @@ -64,13 +64,9 @@ def test_all_registry_entries_are_importable(): continue fn = getattr(mod, fn_name, None) if fn is None: - violations.append( - f'{module_path}.{fn_name} does not exist' - ) + violations.append(f'{module_path}.{fn_name} does not exist') elif not callable(fn): - violations.append( - f'{module_path}.{fn_name} is not callable' - ) + violations.append(f'{module_path}.{fn_name} is not callable') assert not violations, ( 'The following PLUGIN_REGISTRY entries are invalid:\n' + '\n'.join(f' - {v}' for v in violations) From 15dbbb6bcd1c9cc5ad9a0c569e60b33a6183f52c Mon Sep 17 00:00:00 2001 From: aemous Date: Wed, 20 May 2026 15:27:05 -0400 Subject: [PATCH 12/21] Update MAIN_COMMAND_TABLE_OPS to use an enum. --- awscli/handlers_registry.py | 36 ++++++++++++++-------- tests/functional/test_handlers_registry.py | 8 +++-- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/awscli/handlers_registry.py b/awscli/handlers_registry.py index 0aa2bef59223..54d53eb39cec 100644 --- a/awscli/handlers_registry.py +++ b/awscli/handlers_registry.py @@ -20,6 +20,14 @@ Entry format: (module, fn_name) call fn(event_handlers) """ +import enum + + +class CommandTableOp(enum.Enum): + """Valid operation types for MAIN_COMMAND_TABLE_OPS entries.""" + + ADD = 'add' + RENAME = 'rename' PLUGIN_REGISTRY = { 'after-call.data-pipeline.GetPipelineDefinition': [ @@ -833,23 +841,25 @@ # the command is actually invoked. # # Entry formats: -# ('rename', old_name, new_name) -# ('add', cmd_name, cmd_module, cmd_class) +# (CommandTableOp.RENAME, old_name, new_name) +# (CommandTableOp.ADD, cmd_name, cmd_module, cmd_class) -MAIN_COMMAND_TABLE_OPS = [ - ('rename', 's3', 's3api'), - ('add', 's3', 'awscli.customizations.s3.s3', 'S3'), - ('add', 'ddb', 'awscli.customizations.dynamodb.ddb', 'DDB'), +MAIN_COMMAND_TABLE_OPS: list[ + tuple[CommandTableOp, str, str] | tuple[CommandTableOp, str, str, str] +] = [ + (CommandTableOp.RENAME, 's3', 's3api'), + (CommandTableOp.ADD, 's3', 'awscli.customizations.s3.s3', 'S3'), + (CommandTableOp.ADD, 'ddb', 'awscli.customizations.dynamodb.ddb', 'DDB'), ( - 'add', + CommandTableOp.ADD, 'configure', 'awscli.customizations.configure.configure', 'ConfigureCommand', ), - ('rename', 'codedeploy', 'deploy'), - ('rename', 'config', 'configservice'), - ('add', 'history', 'awscli.customizations.history', 'HistoryCommand'), - ('add', 'cli-dev', 'awscli.customizations.devcommands', 'CLIDevCommand'), - ('add', 'login', 'awscli.customizations.login.login', 'LoginCommand'), - ('add', 'logout', 'awscli.customizations.login.logout', 'LogoutCommand'), + (CommandTableOp.RENAME, 'codedeploy', 'deploy'), + (CommandTableOp.RENAME, 'config', 'configservice'), + (CommandTableOp.ADD, 'history', 'awscli.customizations.history', 'HistoryCommand'), + (CommandTableOp.ADD, 'cli-dev', 'awscli.customizations.devcommands', 'CLIDevCommand'), + (CommandTableOp.ADD, 'login', 'awscli.customizations.login.login', 'LoginCommand'), + (CommandTableOp.ADD, 'logout', 'awscli.customizations.login.logout', 'LogoutCommand'), ] diff --git a/tests/functional/test_handlers_registry.py b/tests/functional/test_handlers_registry.py index 93d0e36f4854..b5587f5664c6 100644 --- a/tests/functional/test_handlers_registry.py +++ b/tests/functional/test_handlers_registry.py @@ -15,7 +15,11 @@ import botocore.session -from awscli.handlers_registry import MAIN_COMMAND_TABLE_OPS, PLUGIN_REGISTRY +from awscli.handlers_registry import ( + CommandTableOp, + MAIN_COMMAND_TABLE_OPS, + PLUGIN_REGISTRY, +) class _AuditEmitter: @@ -79,7 +83,7 @@ def test_all_main_command_table_ops_modules_are_importable(): """ violations = [] for op in MAIN_COMMAND_TABLE_OPS: - if op[0] != 'add': + if op[0] != CommandTableOp.ADD: continue _, cmd_name, cmd_module, cmd_class = op try: From 789f94abfde50cd3cd8db58240d932ff2539c979 Mon Sep 17 00:00:00 2001 From: aemous Date: Wed, 20 May 2026 15:27:48 -0400 Subject: [PATCH 13/21] Formatting. --- awscli/handlers_registry.py | 30 +++++++++++++++++++--- tests/functional/test_handlers_registry.py | 2 +- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/awscli/handlers_registry.py b/awscli/handlers_registry.py index 54d53eb39cec..d36e090bc64a 100644 --- a/awscli/handlers_registry.py +++ b/awscli/handlers_registry.py @@ -20,6 +20,7 @@ Entry format: (module, fn_name) call fn(event_handlers) """ + import enum @@ -29,6 +30,7 @@ class CommandTableOp(enum.Enum): ADD = 'add' RENAME = 'rename' + PLUGIN_REGISTRY = { 'after-call.data-pipeline.GetPipelineDefinition': [ ('awscli.customizations.datapipeline', 'register_customizations') @@ -858,8 +860,28 @@ class CommandTableOp(enum.Enum): ), (CommandTableOp.RENAME, 'codedeploy', 'deploy'), (CommandTableOp.RENAME, 'config', 'configservice'), - (CommandTableOp.ADD, 'history', 'awscli.customizations.history', 'HistoryCommand'), - (CommandTableOp.ADD, 'cli-dev', 'awscli.customizations.devcommands', 'CLIDevCommand'), - (CommandTableOp.ADD, 'login', 'awscli.customizations.login.login', 'LoginCommand'), - (CommandTableOp.ADD, 'logout', 'awscli.customizations.login.logout', 'LogoutCommand'), + ( + CommandTableOp.ADD, + 'history', + 'awscli.customizations.history', + 'HistoryCommand', + ), + ( + CommandTableOp.ADD, + 'cli-dev', + 'awscli.customizations.devcommands', + 'CLIDevCommand', + ), + ( + CommandTableOp.ADD, + 'login', + 'awscli.customizations.login.login', + 'LoginCommand', + ), + ( + CommandTableOp.ADD, + 'logout', + 'awscli.customizations.login.logout', + 'LogoutCommand', + ), ] diff --git a/tests/functional/test_handlers_registry.py b/tests/functional/test_handlers_registry.py index b5587f5664c6..297a001ce838 100644 --- a/tests/functional/test_handlers_registry.py +++ b/tests/functional/test_handlers_registry.py @@ -16,9 +16,9 @@ import botocore.session from awscli.handlers_registry import ( - CommandTableOp, MAIN_COMMAND_TABLE_OPS, PLUGIN_REGISTRY, + CommandTableOp, ) From b48b0d63818068935bc2f65431ec08d2f82c0038 Mon Sep 17 00:00:00 2001 From: aemous Date: Wed, 20 May 2026 18:44:23 -0400 Subject: [PATCH 14/21] Import annotations. --- awscli/handlers_registry.py | 1 + 1 file changed, 1 insertion(+) diff --git a/awscli/handlers_registry.py b/awscli/handlers_registry.py index d36e090bc64a..fcca245f00f7 100644 --- a/awscli/handlers_registry.py +++ b/awscli/handlers_registry.py @@ -20,6 +20,7 @@ Entry format: (module, fn_name) call fn(event_handlers) """ +from __future__ import annotations import enum From a71fa98c217fcdf114e62555426415521bb3d7a9 Mon Sep 17 00:00:00 2001 From: aemous Date: Fri, 22 May 2026 16:15:03 -0400 Subject: [PATCH 15/21] Implement LazyInitEmitter for lazily loading plugins when events they depend on are emitted. --- awscli/lazy_emitter.py | 157 +++++++++++++++++++++ tests/unit/test_lazy_emitter.py | 236 ++++++++++++++++++++++++++++++++ 2 files changed, 393 insertions(+) create mode 100644 awscli/lazy_emitter.py create mode 100644 tests/unit/test_lazy_emitter.py diff --git a/awscli/lazy_emitter.py b/awscli/lazy_emitter.py new file mode 100644 index 000000000000..8c57367f6ed4 --- /dev/null +++ b/awscli/lazy_emitter.py @@ -0,0 +1,157 @@ +# 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. +"""Lazy-initializing event emitter for the AWS CLI plugin system. + +LazyInitEmitter wraps a HierarchicalEmitter and uses the plugin registry +to initialize plugins on demand. Before emitting event X, it finds all +initializer entries whose event patterns match X (using the same +prefix/wildcard semantics as HierarchicalEmitter), calls each initializer +at most once, then delegates to the underlying emitter for normal dispatch. +""" + +import copy +import importlib +import logging + +from botocore.hooks import HierarchicalEmitter, PrefixTrie + +from awscli.handlers_registry import ( + CommandTableOp, + PLUGIN_REGISTRY, +) +from awscli.lazy import LazyCommand + +logger = logging.getLogger(__name__) + + +class LazyInitEmitter(HierarchicalEmitter): + """HierarchicalEmitter that lazily initializes plugins from a registry. + + The registry maps event patterns to lists of (module, fn_name) tuples. + Before emitting any event, this emitter checks whether there are + uninitialised plugins whose event patterns match the event being emitted. + If so, it imports and calls them, then proceeds with normal dispatch. + """ + + def __init__(self, plugin_registry=None, main_command_table_ops=None): + super().__init__() + self._init_trie = PrefixTrie() + # set of (module, fn_name) + self._initialized = set() + # number of entries not yet initialized + self._pending_count = 0 + # event_name -> list of entries from init trie + self._init_cache: dict[str, list] = {} + self._main_ops = main_command_table_ops + self._main_ops_applied = False + if plugin_registry: + self.load_registry(plugin_registry) + + def load_registry(self, registry): + unique = set() + for event_pattern, entries in registry.items(): + for entry in entries: + self._init_trie.append_item(event_pattern, entry) + if entry not in unique: + unique.add(entry) + self._pending_count += 1 + self._init_cache = {} + + @property + def initialized_count(self): + return len(self._initialized) + + def _apply_main_command_table_ops(self, kwargs): + """Apply pre-computed renames and LazyCommand additions. + + This replaces the normal lazy-init path for + building-command-table.main entries, avoiding the import of + heavy plugin modules until the command is actually invoked. + """ + command_table = kwargs.get('command_table') + session = kwargs.get('session') + if command_table is None or session is None: + return + + for op in self._main_ops: + if op[0] == CommandTableOp.RENAME: + _, old_name, new_name = op + if old_name in command_table: + current = command_table[old_name] + command_table[new_name] = current + current.name = new_name + del command_table[old_name] + elif op[0] == CommandTableOp.ADD: + _, cmd_name, cmd_module, cmd_class = op + command_table[cmd_name] = LazyCommand( + cmd_name, + session, + cmd_module, + cmd_class, + ) + else: + raise RuntimeError(f'Unknown command table ops entry: {op}') + + # Mark all building-command-table.main entries as initialized so + # _ensure_initialized never imports them. The ops list fully + # accounts for all plugins registered against this event. + for entry in PLUGIN_REGISTRY.get('building-command-table.main', []): + entry = tuple(entry) + if entry not in self._initialized: + self._initialized.add(entry) + self._pending_count -= 1 + + def _ensure_initialized(self, event_name): + """Initialize any plugins whose event patterns match event_name.""" + if self._pending_count == 0: + return + candidates = self._init_cache.get(event_name) + if candidates is None: + candidates = self._init_trie.prefix_search(event_name) + self._init_cache[event_name] = candidates + for entry in candidates: + if entry not in self._initialized: + self._initialized.add(entry) + self._pending_count -= 1 + module_path, fn_name = entry + logger.debug( + 'Lazy-initializing plugin %s.%s for event %s', + module_path, + fn_name, + event_name, + ) + mod = importlib.import_module(module_path) + fn = getattr(mod, fn_name) + fn(self) + + def _emit(self, event_name, kwargs, stop_on_response=False): + if ( + self._main_ops + and not self._main_ops_applied + and event_name == 'building-command-table.main' + ): + self._main_ops_applied = True + self._apply_main_command_table_ops(kwargs) + self._ensure_initialized(event_name) + return super()._emit(event_name, kwargs, stop_on_response) + + def __copy__(self): + new_instance = self.__class__() + new_state = self.__dict__.copy() + new_state['_handlers'] = copy.copy(self._handlers) + new_state['_unique_id_handlers'] = copy.copy(self._unique_id_handlers) + new_state['_init_trie'] = copy.copy(self._init_trie) + new_state['_initialized'] = copy.copy(self._initialized) + new_state['_main_ops_applied'] = self._main_ops_applied + new_instance.__dict__ = new_state + return new_instance diff --git a/tests/unit/test_lazy_emitter.py b/tests/unit/test_lazy_emitter.py new file mode 100644 index 000000000000..281b6d38a6d7 --- /dev/null +++ b/tests/unit/test_lazy_emitter.py @@ -0,0 +1,236 @@ +# 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. +from unittest.mock import MagicMock, patch + +import pytest + +from awscli.handlers_registry import CommandTableOp +from awscli.lazy import LazyCommand +from awscli.lazy_emitter import LazyInitEmitter + + +@pytest.fixture +def mock_module(): + """Create a mock module with a callable init function.""" + module = MagicMock() + module.my_init = MagicMock() + return module + + +class TestLazyInitEmitterPrefixMatching: + def test_bare_prefix_entry_initialized_on_dotted_emit(self, mock_module): + registry = { + 'building-command-table': [ + ('test.module', 'my_init'), + ], + } + emitter = LazyInitEmitter() + emitter.load_registry(registry) + + with patch('importlib.import_module', return_value=mock_module): + emitter.emit('building-command-table.main', command_table={}) + + assert emitter.initialized_count == 1 + mock_module.my_init.assert_called_once() + + def test_exact_match_entry_initialized(self, mock_module): + registry = { + 'building-command-table.main': [ + ('test.module', 'my_init'), + ], + } + emitter = LazyInitEmitter() + emitter.load_registry(registry) + + with patch('importlib.import_module', return_value=mock_module): + emitter.emit('building-command-table.main', command_table={}) + + assert emitter.initialized_count == 1 + mock_module.my_init.assert_called_once() + + def test_unrelated_entry_not_initialized(self, mock_module): + registry = { + 'building-command-table.ecs': [ + ('test.module', 'my_init'), + ], + } + emitter = LazyInitEmitter() + emitter.load_registry(registry) + + with patch('importlib.import_module', return_value=mock_module): + emitter.emit('building-command-table.main', command_table={}) + + assert emitter.initialized_count == 0 + mock_module.my_init.assert_not_called() + + def test_multiple_prefix_levels_all_initialized(self, mock_module): + mock_module.other_init = MagicMock() + registry = { + 'building-command-table': [ + ('test.module', 'my_init'), + ], + 'building-command-table.main': [ + ('test.module', 'other_init'), + ], + } + emitter = LazyInitEmitter() + emitter.load_registry(registry) + + with patch('importlib.import_module', return_value=mock_module): + emitter.emit('building-command-table.main', command_table={}) + + assert emitter.initialized_count == 2 + mock_module.my_init.assert_called_once() + mock_module.other_init.assert_called_once() + + def test_entry_initialized_only_once(self, mock_module): + registry = { + 'building-command-table': [ + ('test.module', 'my_init'), + ], + } + emitter = LazyInitEmitter() + emitter.load_registry(registry) + + with patch('importlib.import_module', return_value=mock_module): + emitter.emit('building-command-table.main', command_table={}) + emitter.emit('building-command-table.ecs', command_table={}) + + assert emitter.initialized_count == 1 + mock_module.my_init.assert_called_once() + + +class TestMainCommandTableOps: + def test_covered_plugins_not_imported(self, mock_module): + registry = { + 'building-command-table.main': [ + ('heavy.module', 'register_add_cmd'), + ], + } + main_ops = [ + (CommandTableOp.ADD, 'my-cmd', 'heavy.module', 'MyCommand'), + ] + emitter = LazyInitEmitter(main_command_table_ops=main_ops) + emitter.load_registry(registry) + + command_table = {'existing': MagicMock(name='existing')} + session = MagicMock() + + with ( + patch('awscli.lazy_emitter.PLUGIN_REGISTRY', registry), + patch('importlib.import_module', return_value=mock_module) as imp, + ): + emitter.emit( + 'building-command-table.main', + command_table=command_table, + session=session, + ) + + imp.assert_not_called() + assert emitter.initialized_count == 1 + + def test_rename_op_applied_to_command_table(self): + registry = { + 'building-command-table.main': [ + ('rename.module', 'register_rename'), + ], + } + main_ops = [ + (CommandTableOp.RENAME, 'old-name', 'new-name'), + ] + emitter = LazyInitEmitter(main_command_table_ops=main_ops) + emitter.load_registry(registry) + + cmd = MagicMock() + cmd.name = 'old-name' + command_table = {'old-name': cmd} + session = MagicMock() + + with patch('awscli.lazy_emitter.PLUGIN_REGISTRY', registry): + emitter.emit( + 'building-command-table.main', + command_table=command_table, + session=session, + ) + + assert 'old-name' not in command_table + assert 'new-name' in command_table + assert command_table['new-name'] is cmd + assert cmd.name == 'new-name' + + def test_add_op_creates_lazy_command(self): + registry = { + 'building-command-table.main': [ + ('add.module', 'register_add'), + ], + } + main_ops = [ + (CommandTableOp.ADD, 'my-cmd', 'add.module.impl', 'MyCommand'), + ] + emitter = LazyInitEmitter(main_command_table_ops=main_ops) + emitter.load_registry(registry) + + command_table = {} + session = MagicMock() + + with patch('awscli.lazy_emitter.PLUGIN_REGISTRY', registry): + emitter.emit( + 'building-command-table.main', + command_table=command_table, + session=session, + ) + + assert 'my-cmd' in command_table + assert isinstance(command_table['my-cmd'], LazyCommand) + assert command_table['my-cmd'].name == 'my-cmd' + + def test_main_ops_skips_covered_but_initializes_bare_prefix( + self, mock_module + ): + registry = { + 'building-command-table': [ + ('global.module', 'register_global'), + ], + 'building-command-table.main': [ + ('heavy.module', 'register_add_cmd'), + ], + } + main_ops = [ + (CommandTableOp.ADD, 'my-cmd', 'heavy.module', 'MyCommand'), + ] + emitter = LazyInitEmitter(main_command_table_ops=main_ops) + emitter.load_registry(registry) + + command_table = {} + session = MagicMock() + + mock_global = MagicMock() + mock_global.register_global = MagicMock() + + def import_side_effect(mod_path): + if mod_path == 'global.module': + return mock_global + raise AssertionError(f'Unexpected import of {mod_path!r}') + + with ( + patch('awscli.lazy_emitter.PLUGIN_REGISTRY', registry), + patch('importlib.import_module', side_effect=import_side_effect), + ): + emitter.emit( + 'building-command-table.main', + command_table=command_table, + session=session, + ) + + mock_global.register_global.assert_called_once() + assert emitter.initialized_count == 2 From 806814a9d45daa1d388251544c84dac544683c37 Mon Sep 17 00:00:00 2001 From: aemous Date: Fri, 22 May 2026 16:17:18 -0400 Subject: [PATCH 16/21] Formatting. --- awscli/lazy_emitter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awscli/lazy_emitter.py b/awscli/lazy_emitter.py index 8c57367f6ed4..2043902c77cd 100644 --- a/awscli/lazy_emitter.py +++ b/awscli/lazy_emitter.py @@ -26,8 +26,8 @@ from botocore.hooks import HierarchicalEmitter, PrefixTrie from awscli.handlers_registry import ( - CommandTableOp, PLUGIN_REGISTRY, + CommandTableOp, ) from awscli.lazy import LazyCommand From 97db2bbc6aceaffb8ac418f8a911168309777249 Mon Sep 17 00:00:00 2001 From: aemous Date: Wed, 27 May 2026 10:55:00 -0400 Subject: [PATCH 17/21] Implement lazy plugin initialization. --- .../enhancement-Performance-9167.json | 5 + awscli/clidriver.py | 7 +- awscli/handlers.py | 254 +++--------------- awscli/plugin.py | 8 +- exe/pyinstaller/hook-awscli.py | 10 + .../autocomplete/test_server_index.py | 4 +- .../autoprompt/test_prompttoolkit.py | 2 +- tests/functional/test_handlers_registry.py | 115 +------- tests/functional/test_lazy.py | 38 ++- tests/unit/test_clidriver.py | 5 +- tests/unit/test_lazy_emitter.py | 14 + tests/unit/test_plugin.py | 2 +- 12 files changed, 122 insertions(+), 342 deletions(-) create mode 100644 .changes/next-release/enhancement-Performance-9167.json diff --git a/.changes/next-release/enhancement-Performance-9167.json b/.changes/next-release/enhancement-Performance-9167.json new file mode 100644 index 000000000000..b60db1fefb24 --- /dev/null +++ b/.changes/next-release/enhancement-Performance-9167.json @@ -0,0 +1,5 @@ +{ + "type": "enhancement", + "category": "Performance", + "description": "Defer loading of built-in plugins until they are actually needed to reduce initialization overhead." +} diff --git a/awscli/clidriver.py b/awscli/clidriver.py index f7ba957fd94f..6be1e4b06abb 100644 --- a/awscli/clidriver.py +++ b/awscli/clidriver.py @@ -64,11 +64,13 @@ construct_entry_point_handlers_chain, ) from awscli.formatter import get_formatter +from awscli.handlers_registry import MAIN_COMMAND_TABLE_OPS from awscli.help import ( OperationHelpCommand, ProviderHelpCommand, ServiceHelpCommand, ) +from awscli.lazy_emitter import LazyInitEmitter from awscli.logger import ( disable_crt_logging, enable_crt_logging, @@ -117,7 +119,10 @@ def create_clidriver(args=None): parser = FirstPassGlobalArgParser() args, _ = parser.parse_known_args(args) debug = args.debug - session = botocore.session.Session() + lazy_emitter = LazyInitEmitter( + main_command_table_ops=MAIN_COMMAND_TABLE_OPS + ) + session = botocore.session.Session(event_hooks=lazy_emitter) _set_user_agent_for_session(session) load_plugins( session.full_config.get('plugins', {}), diff --git a/awscli/handlers.py b/awscli/handlers.py index 2dab06ba171a..aa335b214d30 100644 --- a/awscli/handlers.py +++ b/awscli/handlers.py @@ -1,4 +1,4 @@ -# Copyright 2013 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# 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 @@ -10,225 +10,45 @@ # 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. -"""Builtin CLI extensions. - -This is a collection of built in CLI extensions that can be automatically -registered with the event system. +"""Built-in CLI extensions. +Load built-in CLI extensions from the plugin registry +(`awscli/handlers_registry.py`). If the supplied event handler is a +LazyInitEmitter, we load the registry so that the LazyInitEmitter can handle +lazy plugin initialization. Otherwise, we fall back to loading all plugins in +the registry eagerly. """ -from awscli.alias import register_alias_commands -from awscli.argprocess import register_param_shorthand_parser -from awscli.clidriver import register_no_pager_handler -from awscli.customizations import datapipeline -from awscli.customizations.addexamples import register_docs_add_examples -from awscli.customizations.argrename import register_arg_renames -from awscli.customizations.assumerole import register_assume_role_provider -from awscli.customizations.awslambda import register_lambda_create_function -from awscli.customizations.binaryformat import register_init_binary_formatter -from awscli.customizations.cliinput import register_cli_input_args -from awscli.customizations.cloudformation import ( - initialize as cloudformation_init, -) -from awscli.customizations.cloudfront import register as register_cloudfront -from awscli.customizations.cloudsearch import initialize as cloudsearch_init -from awscli.customizations.cloudsearchdomain import register_cloudsearchdomain -from awscli.customizations.cloudtrail import initialize as cloudtrail_init -from awscli.customizations.cloudwatch import register_rename_otel_commands -from awscli.customizations.codeartifact import register_codeartifact_commands -from awscli.customizations.codecommit import initialize as codecommit_init -from awscli.customizations.codedeploy.codedeploy import ( - register_codedeploy, -) -from awscli.customizations.codedeploy.codedeploy import ( - register_rename_codedeploy as codedeploy_init, -) -from awscli.customizations.configservice.getstatus import register_get_status -from awscli.customizations.configservice.putconfigurationrecorder import ( - register_modify_put_configuration_recorder, -) -from awscli.customizations.configservice.rename_cmd import ( - register_rename_config, -) -from awscli.customizations.configservice.subscribe import register_subscribe -from awscli.customizations.configure.configure import register_configure_cmd -from awscli.customizations.devcommands import register_dev_commands -from awscli.customizations.dlm.dlm import dlm_initialize -from awscli.customizations.dsql import register_dsql_customizations -from awscli.customizations.dynamodb.ddb import register_ddb -from awscli.customizations.dynamodb.paginatorfix import ( - register_dynamodb_paginator_fix, -) -from awscli.customizations.ec2.addcount import register_count_events -from awscli.customizations.ec2.bundleinstance import register_bundleinstance -from awscli.customizations.ec2.decryptpassword import ( - register_ec2_add_priv_launch_key, -) -from awscli.customizations.ec2.paginate import register_ec2_page_size_injector -from awscli.customizations.ec2.protocolarg import register_protocol_args -from awscli.customizations.ec2.runinstances import register_runinstances -from awscli.customizations.ec2.secgroupsimplify import register_secgroup -from awscli.customizations.ec2instanceconnect import ( - register_ec2_instance_connect_commands, -) -from awscli.customizations.ecr import register_ecr_commands -from awscli.customizations.ecr_public import register_ecr_public_commands -from awscli.customizations.ecs import initialize as ecs_initialize -from awscli.customizations.ecs.monitormutatinggatewayservice import ( - register_monitor_mutating_gateway_service, -) -from awscli.customizations.eks import initialize as eks_initialize -from awscli.customizations.emr.emr import emr_initialize -from awscli.customizations.emrcontainers import ( - initialize as emrcontainers_initialize, -) -from awscli.customizations.gamelift import register_gamelift_commands -from awscli.customizations.generatecliskeleton import ( - register_generate_cli_skeleton, -) -from awscli.customizations.globalargs import register_parse_global_args -from awscli.customizations.history import ( - register_history_commands, - register_history_mode, -) -from awscli.customizations.iamvirtmfa import IAMVMFAWrapper -from awscli.customizations.iot import ( - register_iot_create_keys_and_cert_args, - register_iot_create_keys_from_csr, -) -from awscli.customizations.iot_data import register_custom_endpoint_note -from awscli.customizations.kinesis import ( - register_kinesis_list_streams_pagination_backcompat, -) -from awscli.customizations.kms import register_fix_kms_create_grant_docs -from awscli.customizations.lightsail import initialize as lightsail_initialize -from awscli.customizations.login import register_login_cmds -from awscli.customizations.logs import register_logs_commands -from awscli.customizations.paginate import register_pagination -from awscli.customizations.putmetricdata import register_put_metric_data -from awscli.customizations.quicksight import ( - register_quicksight_asset_bundle_customizations, -) -from awscli.customizations.rds import ( - register_add_generate_db_auth_token, - register_rds_modify_split, -) -from awscli.customizations.rekognition import ( - register_rekognition_detect_labels, -) -from awscli.customizations.removals import register_removals -from awscli.customizations.route53 import register_create_hosted_zone_doc_fix -from awscli.customizations.s3.s3 import ( - register_s3_main, - register_s3_sync_strategies, -) -from awscli.customizations.s3errormsg import register_s3_error_msg -from awscli.customizations.s3events import ( - register_document_expires_string, - register_event_stream_arg, -) -from awscli.customizations.servicecatalog import ( - register_servicecatalog_commands, -) -from awscli.customizations.sessendemail import register_ses_send_email -from awscli.customizations.sessionmanager import register_ssm_session -from awscli.customizations.sso import register_sso_commands -from awscli.customizations.streamingoutputarg import ( - register_streaming_output_arg, -) -from awscli.customizations.timestampformat import register_timestamp_format -from awscli.customizations.toplevelbool import register_bool_params -from awscli.customizations.translate import ( - register_translate_import_terminology, -) -from awscli.customizations.waiters import register_add_waiters -from awscli.customizations.wizard.commands import register_wizard_commands -from awscli.paramfile import register_init_uri_param_handler +import importlib + +from awscli.handlers_registry import PLUGIN_REGISTRY +from awscli.lazy_emitter import LazyInitEmitter def awscli_initialize(event_handlers): - register_init_uri_param_handler(event_handlers) - register_init_binary_formatter(event_handlers) - register_no_pager_handler(event_handlers) - register_param_shorthand_parser(event_handlers) - # The s3 error message needs to registered before the - # generic error handler. - register_s3_error_msg(event_handlers) - register_docs_add_examples(event_handlers) - register_cli_input_args(event_handlers) - register_streaming_output_arg(event_handlers) - register_count_events(event_handlers) - register_ec2_add_priv_launch_key(event_handlers) - register_parse_global_args(event_handlers) - register_pagination(event_handlers) - register_secgroup(event_handlers) - register_bundleinstance(event_handlers) - register_s3_main(event_handlers) - register_s3_sync_strategies(event_handlers) - register_ddb(event_handlers) - register_runinstances(event_handlers) - register_removals(event_handlers) - register_rds_modify_split(event_handlers) - register_rekognition_detect_labels(event_handlers) - register_add_generate_db_auth_token(event_handlers) - register_dsql_customizations(event_handlers) - register_put_metric_data(event_handlers) - register_ses_send_email(event_handlers) - IAMVMFAWrapper(event_handlers) - register_arg_renames(event_handlers) - register_configure_cmd(event_handlers) - cloudtrail_init(event_handlers) - register_ecr_commands(event_handlers) - register_ecr_public_commands(event_handlers) - register_bool_params(event_handlers) - register_protocol_args(event_handlers) - datapipeline.register_customizations(event_handlers) - cloudsearch_init(event_handlers) - emr_initialize(event_handlers) - emrcontainers_initialize(event_handlers) - eks_initialize(event_handlers) - ecs_initialize(event_handlers) - register_monitor_mutating_gateway_service(event_handlers) - lightsail_initialize(event_handlers) - register_cloudsearchdomain(event_handlers) - register_generate_cli_skeleton(event_handlers) - register_assume_role_provider(event_handlers) - register_add_waiters(event_handlers) - codedeploy_init(event_handlers) - register_codedeploy(event_handlers) - register_subscribe(event_handlers) - register_get_status(event_handlers) - register_rename_config(event_handlers) - register_timestamp_format(event_handlers) - register_lambda_create_function(event_handlers) - register_fix_kms_create_grant_docs(event_handlers) - register_create_hosted_zone_doc_fix(event_handlers) - register_modify_put_configuration_recorder(event_handlers) - register_codeartifact_commands(event_handlers) - codecommit_init(event_handlers) - register_custom_endpoint_note(event_handlers) - register_iot_create_keys_and_cert_args(event_handlers) - register_iot_create_keys_from_csr(event_handlers) - register_cloudfront(event_handlers) - register_gamelift_commands(event_handlers) - register_ec2_page_size_injector(event_handlers) - cloudformation_init(event_handlers) - register_servicecatalog_commands(event_handlers) - register_translate_import_terminology(event_handlers) - register_rename_otel_commands(event_handlers) - register_history_mode(event_handlers) - register_history_commands(event_handlers) - register_event_stream_arg(event_handlers) - register_document_expires_string(event_handlers) - dlm_initialize(event_handlers) - register_ssm_session(event_handlers) - register_logs_commands(event_handlers) - register_dev_commands(event_handlers) - register_wizard_commands(event_handlers) - register_sso_commands(event_handlers) - register_dynamodb_paginator_fix(event_handlers) - register_alias_commands(event_handlers) - register_kinesis_list_streams_pagination_backcompat(event_handlers) - register_quicksight_asset_bundle_customizations(event_handlers) - register_ec2_instance_connect_commands(event_handlers) - register_login_cmds(event_handlers) + """Load the plugin registry into the emitter. + + If the emitter is a LazyInitEmitter, the registry is loaded into its + initializer trie for on-demand initialization. Otherwise, all built-in + plugins are eagerly initialized. + """ + if isinstance(event_handlers, LazyInitEmitter): + event_handlers.load_registry(PLUGIN_REGISTRY) + else: + # Fallback to eagerly initializing all built-in plugins. + seen = set() + for event_pattern, entries in PLUGIN_REGISTRY.items(): + for entry in entries: + if entry not in seen: + seen.add(entry) + module_path, fn_name, entry_type = entry + mod = importlib.import_module(module_path) + fn = getattr(mod, fn_name) + if entry_type == 'direct': + handler = fn() if isinstance(fn, type) else fn + event_handlers.register(event_pattern, handler) + else: + if isinstance(fn, type): + fn(event_handlers) + else: + fn(event_handlers) diff --git a/awscli/plugin.py b/awscli/plugin.py index 46a26a4fc1a7..c2bf967a26f1 100644 --- a/awscli/plugin.py +++ b/awscli/plugin.py @@ -14,7 +14,7 @@ import os import sys -from botocore.hooks import HierarchicalEmitter +from awscli.lazy_emitter import LazyInitEmitter log = logging.getLogger('awscli.plugin') @@ -31,19 +31,19 @@ def load_plugins(plugin_mapping, event_hooks=None, include_builtins=True): :type event_hooks: ``EventHooks`` :param event_hooks: Event hook emitter. If one if not provided, - an emitter will be created and returned. Otherwise, the + a LazyInitEmitter will be created and returned. Otherwise, the passed in ``event_hooks`` will be used to initialize plugins. :type include_builtins: bool :param include_builtins: If True, the builtin awscli plugins (specified in ``BUILTIN_PLUGINS``) will be included in the list of plugins to load. - :rtype: HierarchicalEmitter + :rtype: LazyInitEmitter :return: An event emitter object. """ if event_hooks is None: - event_hooks = HierarchicalEmitter() + event_hooks = LazyInitEmitter() if include_builtins: _load_plugins(BUILTIN_PLUGINS, event_hooks) plugin_path = plugin_mapping.pop(CLI_LEGACY_PLUGIN_PATH, None) diff --git a/exe/pyinstaller/hook-awscli.py b/exe/pyinstaller/hook-awscli.py index a0767694019a..31a72dd3332e 100644 --- a/exe/pyinstaller/hook-awscli.py +++ b/exe/pyinstaller/hook-awscli.py @@ -2,6 +2,8 @@ from PyInstaller.utils import hooks +from awscli.handlers_registry import PLUGIN_REGISTRY + hiddenimports = [ 'docutils', 'urllib', @@ -28,6 +30,14 @@ ) + hooks.collect_submodules('awscli.s3transfer') hiddenimports += alias_packages_plugins +# handlers.py uses importlib.import_module at runtime to load customization +# modules, so PyInstaller cannot discover them statically. Collect all module +# paths referenced in handlers_registry.py as hidden imports. +registry_modules = { + entry[0] for entries in PLUGIN_REGISTRY.values() for entry in entries +} +hiddenimports += registry_modules + # Completion model files are only used at build time to generate the # ac.index SQLite database. They are not needed at runtime and can be diff --git a/tests/functional/autocomplete/test_server_index.py b/tests/functional/autocomplete/test_server_index.py index ea3a31508b0b..1d7369f32a32 100644 --- a/tests/functional/autocomplete/test_server_index.py +++ b/tests/functional/autocomplete/test_server_index.py @@ -25,7 +25,7 @@ def setUpClass(cls): ], ) driver = clidriver.create_clidriver() - driver.session.register( + driver.session.get_component('event_emitter').register_last( 'building-command-table.main', _ddb_only_command_table ) index_generator.generate_index(driver) @@ -111,7 +111,7 @@ def test_no_errors_when_missing_completion_data(self): # This will result in the loader not being able to find any # completion data, which allows us to verify the behavior when # there's no completion data. - driver.session.register( + driver.session.get_component('event_emitter').register_last( 'building-command-table.main', _ddb_only_command_table ) driver.session.register( diff --git a/tests/functional/autoprompt/test_prompttoolkit.py b/tests/functional/autoprompt/test_prompttoolkit.py index 37932c1be368..0ed5f9e2c4bf 100644 --- a/tests/functional/autoprompt/test_prompttoolkit.py +++ b/tests/functional/autoprompt/test_prompttoolkit.py @@ -44,7 +44,7 @@ def _generate_index_if_needed(db_connection): [indexer.ModelIndexer(db_connection)], ) driver = create_clidriver() - driver.session.register( + driver.session.get_component('event_emitter').register_last( 'building-command-table.main', _cloudwatch_only_command_table ) index_generator.generate_index(driver) diff --git a/tests/functional/test_handlers_registry.py b/tests/functional/test_handlers_registry.py index 297a001ce838..c982f0d067ef 100644 --- a/tests/functional/test_handlers_registry.py +++ b/tests/functional/test_handlers_registry.py @@ -15,11 +15,7 @@ import botocore.session -from awscli.handlers_registry import ( - MAIN_COMMAND_TABLE_OPS, - PLUGIN_REGISTRY, - CommandTableOp, -) +from awscli.handlers_registry import PLUGIN_REGISTRY class _AuditEmitter: @@ -101,112 +97,3 @@ def test_all_main_command_table_ops_modules_are_importable(): 'The following MAIN_COMMAND_TABLE_OPS entries are invalid:\n' + '\n'.join(f' - {v}' for v in violations) ) - - -def test_main_command_table_plugins_only_register_against_main(): - """Plugins listed under building-command-table.main must not register - against any other events. - - This invariant allows the lazy-loading system to skip importing these - plugin modules entirely and instead apply pre-computed renames and - LazyCommand additions from MAIN_COMMAND_TABLE_OPS. If a plugin - mixes building-command-table.main registrations with other events, - split it into separate functions: one that only registers against - building-command-table.main, and another for the remaining events. - """ - main_entries = PLUGIN_REGISTRY.get('building-command-table.main', []) - violations = [] - for module_path, fn_name in main_entries: - emitter = _AuditEmitter() - mod = importlib.import_module(module_path) - fn = getattr(mod, fn_name) - fn(emitter) - non_main = [ - e - for e in emitter.registrations - if e != 'building-command-table.main' - ] - if non_main: - violations.append( - f'{module_path}.{fn_name} also registers against: ' - f'{non_main}' - ) - assert not violations, ( - 'The following building-command-table.main plugins register ' - 'against additional events. Split each into separate functions ' - 'so that the building-command-table.main function only registers ' - 'against that single event:\n' - + '\n'.join(f' - {v}' for v in violations) - ) - - -def test_main_command_table_callbacks_only_add_or_rename(): - """Callbacks registered against building-command-table.main must only - add new commands or rename existing ones. - - MAIN_COMMAND_TABLE_OPS replaces these callbacks at runtime with - LazyCommand additions and direct renames. If a callback also - modifies existing command table entries (e.g. changes properties on - a command object), that modification would be silently lost. - """ - session = botocore.session.Session() - services = session.get_available_services() - - main_entries = PLUGIN_REGISTRY.get('building-command-table.main', []) - violations = [] - - for module_path, fn_name in main_entries: - collector = _CallbackCollector('building-command-table.main') - mod = importlib.import_module(module_path) - fn = getattr(mod, fn_name) - fn(collector) - - for callback in collector.callbacks: - cb_name = f'{callback.__module__}.{callback.__qualname__}' - - # Build a fresh command table for each callback. - class _Placeholder: - def __init__(self, name): - self.name = name - - command_table = OrderedDict() - for svc in services: - command_table[svc] = _Placeholder(svc) - - snap_id_to_key = {id(v): k for k, v in command_table.items()} - snap_id_to_name = {id(v): v.name for k, v in command_table.items()} - - callback(command_table=command_table, session=session) - - # Classify every change. - new_id_to_key = {id(v): k for k, v in command_table.items()} - renamed_ids = set() - - # Detect renames. - for obj_id, new_key in new_id_to_key.items(): - old_key = snap_id_to_key.get(obj_id) - if old_key is not None and old_key != new_key: - renamed_ids.add(obj_id) - - # Detect modifications: an existing (non-renamed) entry whose - # .name property changed, or any entry that was removed. - for obj_id, old_key in snap_id_to_key.items(): - if obj_id in renamed_ids: - continue - if obj_id not in new_id_to_key: - violations.append(f'{cb_name} removed command {old_key!r}') - continue - new_key = new_id_to_key[obj_id] - cmd = command_table[new_key] - if cmd.name != snap_id_to_name[obj_id]: - violations.append( - f'{cb_name} modified .name on {new_key!r} ' - f'without renaming' - ) - - assert not violations, ( - 'Callbacks registered against building-command-table.main must ' - 'only add or rename commands. The following callbacks perform ' - 'other modifications that would be lost when replaced by ' - 'MAIN_COMMAND_TABLE_OPS:\n' + '\n'.join(f' - {v}' for v in violations) - ) diff --git a/tests/functional/test_lazy.py b/tests/functional/test_lazy.py index fc8d33edd359..3ba067e13aad 100644 --- a/tests/functional/test_lazy.py +++ b/tests/functional/test_lazy.py @@ -12,8 +12,44 @@ # language governing permissions and limitations under the License. import pytest +from awscli.handlers_registry import MAIN_COMMAND_TABLE_OPS from awscli.lazy import LazyCommand -from awscli.testutils import mock +from awscli.testutils import BaseAWSHelpOutputTest, mock + +# Derive test parameters from MAIN_COMMAND_TABLE_OPS. +_ADD_OPS = [op for op in MAIN_COMMAND_TABLE_OPS if op[0] == 'add'] +_RENAME_OPS = [op for op in MAIN_COMMAND_TABLE_OPS if op[0] == 'rename'] +_ADD_CMD_NAMES = [op[1] for op in _ADD_OPS] +_RENAME_NEW_NAMES = [op[2] for op in _RENAME_OPS] + + +class TestLazyCommandHelpRenders(BaseAWSHelpOutputTest): + def test_added_command_help_renders(self): + for cmd_name in _ADD_CMD_NAMES: + with self.subTest(cmd_name=cmd_name): + self.driver.main([cmd_name, 'help']) + self.assert_contains(cmd_name) + + def test_renamed_command_help_renders(self): + for new_name in _RENAME_NEW_NAMES: + with self.subTest(new_name=new_name): + self.driver.main([new_name, 'help']) + self.assert_contains(new_name) + + +class TestLazyCommandIsTransparent(BaseAWSHelpOutputTest): + def test_added_commands_appear_in_top_level_help(self): + self.driver.main(['help']) + for cmd_name in _ADD_CMD_NAMES: + self.assert_contains(cmd_name) + + def test_lazy_command_has_subcommands(self): + command_table = self.driver.subcommand_table + s3_cmd = command_table['s3'] + assert isinstance(s3_cmd, LazyCommand) + subcommands = s3_cmd.subcommand_table + assert 'ls' in subcommands + assert 'cp' in subcommands class TestLazyCommandErrorPaths: diff --git a/tests/unit/test_clidriver.py b/tests/unit/test_clidriver.py index cd5315212728..bcc5a1ca7c8e 100644 --- a/tests/unit/test_clidriver.py +++ b/tests/unit/test_clidriver.py @@ -663,7 +663,10 @@ def test_custom_arg_paramfile(self, mock_handler): mock_handler.return_value = mock_paramfile driver = create_clidriver() - driver.session.register( + # We use register_last to ensure unknown-arg is added to the argument + # table after all plugin-added arguments, so that its load-cli-arg + # event fires last in call_args_list. + driver.session.get_component('event_emitter').register_last( 'building-argument-table', self.inject_new_param ) diff --git a/tests/unit/test_lazy_emitter.py b/tests/unit/test_lazy_emitter.py index 281b6d38a6d7..3ecac7320cac 100644 --- a/tests/unit/test_lazy_emitter.py +++ b/tests/unit/test_lazy_emitter.py @@ -29,6 +29,8 @@ def mock_module(): class TestLazyInitEmitterPrefixMatching: def test_bare_prefix_entry_initialized_on_dotted_emit(self, mock_module): + # Entry registered against 'building-command-table' (bare prefix) + # should be initialized when 'building-command-table.main' is emitted. registry = { 'building-command-table': [ ('test.module', 'my_init'), @@ -59,6 +61,8 @@ def test_exact_match_entry_initialized(self, mock_module): mock_module.my_init.assert_called_once() def test_unrelated_entry_not_initialized(self, mock_module): + # Entry registered against 'building-command-table.ecs' should NOT + # be initialized when 'building-command-table.main' is emitted. registry = { 'building-command-table.ecs': [ ('test.module', 'my_init'), @@ -74,6 +78,9 @@ def test_unrelated_entry_not_initialized(self, mock_module): mock_module.my_init.assert_not_called() def test_multiple_prefix_levels_all_initialized(self, mock_module): + # Both 'building-command-table' and 'building-command-table.main' + # entries should be initialized when 'building-command-table.main' + # is emitted. mock_module.other_init = MagicMock() registry = { 'building-command-table': [ @@ -136,7 +143,9 @@ def test_covered_plugins_not_imported(self, mock_module): session=session, ) + # The heavy module should NOT have been imported via _ensure_initialized imp.assert_not_called() + # But the entry should be marked as initialized assert emitter.initialized_count == 1 def test_rename_op_applied_to_command_table(self): @@ -197,6 +206,8 @@ def test_add_op_creates_lazy_command(self): def test_main_ops_skips_covered_but_initializes_bare_prefix( self, mock_module ): + # Entries registered against bare 'building-command-table' must + # still be initialized even when main_ops are applied. registry = { 'building-command-table': [ ('global.module', 'register_global'), @@ -218,6 +229,9 @@ def test_main_ops_skips_covered_but_initializes_bare_prefix( mock_global.register_global = MagicMock() def import_side_effect(mod_path): + # Ensures that no module besides global.module + # (including heavy.module) are imported. heavy.module should not be + # imported because it is present in main_command_table_ops. if mod_path == 'global.module': return mock_global raise AssertionError(f'Unexpected import of {mod_path!r}') diff --git a/tests/unit/test_plugin.py b/tests/unit/test_plugin.py index 1d1e8957463d..3a31f53e258f 100644 --- a/tests/unit/test_plugin.py +++ b/tests/unit/test_plugin.py @@ -54,7 +54,7 @@ def test_plugin_register(self): ) def test_event_hooks_can_be_passed_in(self): - hooks = plugin.HierarchicalEmitter() + hooks = plugin.LazyInitEmitter() emitter = plugin.load_plugins(self.plugin_mapping, event_hooks=hooks) emitter.emit('before_operation') self.assertEqual(len(self.fake_module.events_seen), 1) From 330ea6155683544d2e337e739c6f4ff200945749 Mon Sep 17 00:00:00 2001 From: aemous Date: Wed, 27 May 2026 11:51:50 -0400 Subject: [PATCH 18/21] Delete handlers.py and migrate the registry-loading code into plugin.py. --- awscli/handlers.py | 54 ---------------------- awscli/plugin.py | 8 ++-- tests/functional/test_handlers_registry.py | 6 ++- 3 files changed, 9 insertions(+), 59 deletions(-) delete mode 100644 awscli/handlers.py diff --git a/awscli/handlers.py b/awscli/handlers.py deleted file mode 100644 index aa335b214d30..000000000000 --- a/awscli/handlers.py +++ /dev/null @@ -1,54 +0,0 @@ -# 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. -"""Built-in CLI extensions. - -Load built-in CLI extensions from the plugin registry -(`awscli/handlers_registry.py`). If the supplied event handler is a -LazyInitEmitter, we load the registry so that the LazyInitEmitter can handle -lazy plugin initialization. Otherwise, we fall back to loading all plugins in -the registry eagerly. -""" - -import importlib - -from awscli.handlers_registry import PLUGIN_REGISTRY -from awscli.lazy_emitter import LazyInitEmitter - - -def awscli_initialize(event_handlers): - """Load the plugin registry into the emitter. - - If the emitter is a LazyInitEmitter, the registry is loaded into its - initializer trie for on-demand initialization. Otherwise, all built-in - plugins are eagerly initialized. - """ - if isinstance(event_handlers, LazyInitEmitter): - event_handlers.load_registry(PLUGIN_REGISTRY) - else: - # Fallback to eagerly initializing all built-in plugins. - seen = set() - for event_pattern, entries in PLUGIN_REGISTRY.items(): - for entry in entries: - if entry not in seen: - seen.add(entry) - module_path, fn_name, entry_type = entry - mod = importlib.import_module(module_path) - fn = getattr(mod, fn_name) - if entry_type == 'direct': - handler = fn() if isinstance(fn, type) else fn - event_handlers.register(event_pattern, handler) - else: - if isinstance(fn, type): - fn(event_handlers) - else: - fn(event_handlers) diff --git a/awscli/plugin.py b/awscli/plugin.py index c2bf967a26f1..2544d5885403 100644 --- a/awscli/plugin.py +++ b/awscli/plugin.py @@ -14,11 +14,11 @@ import os import sys +from awscli.handlers_registry import PLUGIN_REGISTRY from awscli.lazy_emitter import LazyInitEmitter log = logging.getLogger('awscli.plugin') -BUILTIN_PLUGINS = {'__builtin__': 'awscli.handlers'} CLI_LEGACY_PLUGIN_PATH = 'cli_legacy_plugin_path' @@ -35,8 +35,8 @@ def load_plugins(plugin_mapping, event_hooks=None, include_builtins=True): passed in ``event_hooks`` will be used to initialize plugins. :type include_builtins: bool - :param include_builtins: If True, the builtin awscli plugins (specified in - ``BUILTIN_PLUGINS``) will be included in the list of plugins to load. + :param include_builtins: If True, the built-in plugin registry will be + loaded into the emitter. :rtype: LazyInitEmitter :return: An event emitter object. @@ -45,7 +45,7 @@ def load_plugins(plugin_mapping, event_hooks=None, include_builtins=True): if event_hooks is None: event_hooks = LazyInitEmitter() if include_builtins: - _load_plugins(BUILTIN_PLUGINS, event_hooks) + event_hooks.load_registry(PLUGIN_REGISTRY) plugin_path = plugin_mapping.pop(CLI_LEGACY_PLUGIN_PATH, None) if plugin_path is not None: _add_plugin_path_to_sys_path(plugin_path) diff --git a/tests/functional/test_handlers_registry.py b/tests/functional/test_handlers_registry.py index c982f0d067ef..3b2049df53dd 100644 --- a/tests/functional/test_handlers_registry.py +++ b/tests/functional/test_handlers_registry.py @@ -15,7 +15,11 @@ import botocore.session -from awscli.handlers_registry import PLUGIN_REGISTRY +from awscli.handlers_registry import ( + CommandTableOp, + MAIN_COMMAND_TABLE_OPS, + PLUGIN_REGISTRY, +) class _AuditEmitter: From 369d8f410a47093bcbf152741cb31180807a1f31 Mon Sep 17 00:00:00 2001 From: aemous Date: Fri, 29 May 2026 09:29:41 -0400 Subject: [PATCH 19/21] Add eager-loading fallback to plugin.py. --- awscli/plugin.py | 21 ++++++++++++++++++++- exe/pyinstaller/hook-awscli.py | 2 +- tests/functional/test_lazy.py | 6 +++--- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/awscli/plugin.py b/awscli/plugin.py index 2544d5885403..73c44c3c2293 100644 --- a/awscli/plugin.py +++ b/awscli/plugin.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 importlib import logging import os import sys @@ -45,7 +46,12 @@ def load_plugins(plugin_mapping, event_hooks=None, include_builtins=True): if event_hooks is None: event_hooks = LazyInitEmitter() if include_builtins: - event_hooks.load_registry(PLUGIN_REGISTRY) + if isinstance(event_hooks, LazyInitEmitter): + event_hooks.load_registry(PLUGIN_REGISTRY) + else: + # When the event emitter is not a LazyInitEmitter, we fall back + # to eagerly loading all plugins in the registry. + _eager_load_registry(event_hooks) plugin_path = plugin_mapping.pop(CLI_LEGACY_PLUGIN_PATH, None) if plugin_path is not None: _add_plugin_path_to_sys_path(plugin_path) @@ -58,6 +64,19 @@ def load_plugins(plugin_mapping, event_hooks=None, include_builtins=True): return event_hooks +def _eager_load_registry(event_hooks): + """Eagerly initialize all plugins from the plugin registry.""" + seen = set() + for event_pattern, entries in PLUGIN_REGISTRY.items(): + for entry in entries: + if entry not in seen: + seen.add(entry) + module_path, fn_name = entry + mod = importlib.import_module(module_path) + fn = getattr(mod, fn_name) + fn(event_hooks) + + def _load_plugins(plugin_mapping, event_hooks): modules = _import_plugins(plugin_mapping) for name, plugin in zip(plugin_mapping.keys(), modules): diff --git a/exe/pyinstaller/hook-awscli.py b/exe/pyinstaller/hook-awscli.py index 31a72dd3332e..5d284fabeae6 100644 --- a/exe/pyinstaller/hook-awscli.py +++ b/exe/pyinstaller/hook-awscli.py @@ -30,7 +30,7 @@ ) + hooks.collect_submodules('awscli.s3transfer') hiddenimports += alias_packages_plugins -# handlers.py uses importlib.import_module at runtime to load customization +# plugin.py uses importlib.import_module at runtime to load customization # modules, so PyInstaller cannot discover them statically. Collect all module # paths referenced in handlers_registry.py as hidden imports. registry_modules = { diff --git a/tests/functional/test_lazy.py b/tests/functional/test_lazy.py index 3ba067e13aad..be134a98d8c8 100644 --- a/tests/functional/test_lazy.py +++ b/tests/functional/test_lazy.py @@ -12,13 +12,13 @@ # language governing permissions and limitations under the License. import pytest -from awscli.handlers_registry import MAIN_COMMAND_TABLE_OPS +from awscli.handlers_registry import MAIN_COMMAND_TABLE_OPS, CommandTableOp from awscli.lazy import LazyCommand from awscli.testutils import BaseAWSHelpOutputTest, mock # Derive test parameters from MAIN_COMMAND_TABLE_OPS. -_ADD_OPS = [op for op in MAIN_COMMAND_TABLE_OPS if op[0] == 'add'] -_RENAME_OPS = [op for op in MAIN_COMMAND_TABLE_OPS if op[0] == 'rename'] +_ADD_OPS = [op for op in MAIN_COMMAND_TABLE_OPS if op[0] == CommandTableOp.ADD] +_RENAME_OPS = [op for op in MAIN_COMMAND_TABLE_OPS if op[0] == CommandTableOp.RENAME] _ADD_CMD_NAMES = [op[1] for op in _ADD_OPS] _RENAME_NEW_NAMES = [op[2] for op in _RENAME_OPS] From 44c3ce9971ceb6bf88c8fa312535c787c27e83fa Mon Sep 17 00:00:00 2001 From: aemous Date: Fri, 29 May 2026 09:30:12 -0400 Subject: [PATCH 20/21] Formatting. --- tests/functional/test_handlers_registry.py | 2 +- tests/functional/test_lazy.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/functional/test_handlers_registry.py b/tests/functional/test_handlers_registry.py index 3b2049df53dd..ec45094e44ac 100644 --- a/tests/functional/test_handlers_registry.py +++ b/tests/functional/test_handlers_registry.py @@ -16,9 +16,9 @@ import botocore.session from awscli.handlers_registry import ( - CommandTableOp, MAIN_COMMAND_TABLE_OPS, PLUGIN_REGISTRY, + CommandTableOp, ) diff --git a/tests/functional/test_lazy.py b/tests/functional/test_lazy.py index be134a98d8c8..75cba38e5283 100644 --- a/tests/functional/test_lazy.py +++ b/tests/functional/test_lazy.py @@ -18,7 +18,9 @@ # Derive test parameters from MAIN_COMMAND_TABLE_OPS. _ADD_OPS = [op for op in MAIN_COMMAND_TABLE_OPS if op[0] == CommandTableOp.ADD] -_RENAME_OPS = [op for op in MAIN_COMMAND_TABLE_OPS if op[0] == CommandTableOp.RENAME] +_RENAME_OPS = [ + op for op in MAIN_COMMAND_TABLE_OPS if op[0] == CommandTableOp.RENAME +] _ADD_CMD_NAMES = [op[1] for op in _ADD_OPS] _RENAME_NEW_NAMES = [op[2] for op in _RENAME_OPS] From bf9aa0c731ac974fa4853215041d54010bc09b42 Mon Sep 17 00:00:00 2001 From: aemous Date: Fri, 29 May 2026 11:20:43 -0400 Subject: [PATCH 21/21] Switch to using single dispatch in plugin.py. --- awscli/plugin.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/awscli/plugin.py b/awscli/plugin.py index 73c44c3c2293..fe41651c3d4f 100644 --- a/awscli/plugin.py +++ b/awscli/plugin.py @@ -14,6 +14,9 @@ import logging import os import sys +from functools import singledispatch + +from botocore.hooks import HierarchicalEmitter from awscli.handlers_registry import PLUGIN_REGISTRY from awscli.lazy_emitter import LazyInitEmitter @@ -46,12 +49,7 @@ def load_plugins(plugin_mapping, event_hooks=None, include_builtins=True): if event_hooks is None: event_hooks = LazyInitEmitter() if include_builtins: - if isinstance(event_hooks, LazyInitEmitter): - event_hooks.load_registry(PLUGIN_REGISTRY) - else: - # When the event emitter is not a LazyInitEmitter, we fall back - # to eagerly loading all plugins in the registry. - _eager_load_registry(event_hooks) + _load_registry(event_hooks) plugin_path = plugin_mapping.pop(CLI_LEGACY_PLUGIN_PATH, None) if plugin_path is not None: _add_plugin_path_to_sys_path(plugin_path) @@ -64,8 +62,16 @@ def load_plugins(plugin_mapping, event_hooks=None, include_builtins=True): return event_hooks -def _eager_load_registry(event_hooks): - """Eagerly initialize all plugins from the plugin registry.""" +@singledispatch +def _load_registry(event_hooks): + raise NotImplementedError( + f'No _load_registry implementation for ' + f'{type(event_hooks).__name__}' + ) + + +@_load_registry.register +def _(event_hooks: HierarchicalEmitter): seen = set() for event_pattern, entries in PLUGIN_REGISTRY.items(): for entry in entries: @@ -77,6 +83,11 @@ def _eager_load_registry(event_hooks): fn(event_hooks) +@_load_registry.register +def _(event_hooks: LazyInitEmitter): + event_hooks.load_registry(PLUGIN_REGISTRY) + + def _load_plugins(plugin_mapping, event_hooks): modules = _import_plugins(plugin_mapping) for name, plugin in zip(plugin_mapping.keys(), modules):