Skip to content

[v2] Implement event handlers registry for built-in plugins#10305

Open
aemous wants to merge 6 commits into
lazy-plugin-initializationfrom
pr/handlers-registry
Open

[v2] Implement event handlers registry for built-in plugins#10305
aemous wants to merge 6 commits into
lazy-plugin-initializationfrom
pr/handlers-registry

Conversation

@aemous
Copy link
Copy Markdown
Contributor

@aemous aemous commented May 13, 2026

Issue #, if available:

Notes:

  • This PR (2/4) is part of an ongoing effort to reduce AWS CLI initialization time by lazily deferring plugins until they are needed. This PR transforms plugin-initialization in awscli/handlers.py to an alternative registry format in awscli/handlers_registry.py, which will be used in a future PR to efficiently initialize plugins only when they are needed. The registry will be used to determine which plugins are needed during event-emission and command-execution. In a future PR, the monolithic awscli/handlers.py will be removed and replaced entirely by awscli/handlers_registry.py.

Description of changes:

  • Implemented event handlers registry for built-in plugins in awscli/handlers_registry.py that maps events to plugins that register against them. It also declaratively models plugins that register against building-command-table.main to add or rename commands in the command table. The registry was automatically generated from a script that probes the existing initialization functions. The script can be reviewed here: generate-plugin-registry.tar.gz.
  • Wrapped plugins that get registered against events in awscli/handlers.py in a thin initialization function. The initialization function gets called in awscli/handlers.py instead of registering against the events in awscli/handlers.py directly.
    • While this change does not lead to a behavior change, it simplifies the migration of awscli/handlers.py to the new awscli/handlers_registry.py. Also, it is more consistent with the majority of existing plugin-initialization in awscli/handlers.py.
  • For plugin-initialization functions that register against building-command-table.main to add or rename commands, that also register against at least 1 other event, these functions were split into two functions: one that registers the add/rename callbacks, and one that registers against everything else.
    • This change does not lead to a behavior change, but it simplifies the migration to the new handlers registry, and also paves a clear path forward for deleting the init-functions that solely add/rename commands in a future PR.
  • Implemented test cases that assert all the modules in awscli/handlers_registry.py are importable, and misc. tests verifying expected behavior of new components.
  • Updated existing s3 tests based on changes described above.

Description of tests:

  • Successfully ran all test suites and the pre-production build workflow (see CI).

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@aemous aemous requested a review from a team May 13, 2026 18:54
@aemous aemous added v2 performance This PR or issue is related to performance. labels May 13, 2026
@aemous aemous marked this pull request as ready for review May 13, 2026 18:58
Comment on lines +102 to +208
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)
)
Copy link
Copy Markdown
Contributor Author

@aemous aemous May 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note for reviewer: these two tests add some invariants about plugins/functions that register against building-command-table.main that are needed for the initial migration from the monolith to the registry. In a future PR, we will remove the init functions that add/rename commands entirely, and we will delete these two tests.

These two tests are just asserting invariants of the current state that is needed for the script to work correctly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance This PR or issue is related to performance. v2

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant