Skip to content

Commit 270fc42

Browse files
authored
Experimental plugin autoloading (boto#3514)
1 parent 55324d1 commit 270fc42

12 files changed

Lines changed: 435 additions & 2 deletions

botocore/credentials.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
InstanceMetadataFetcher,
5151
JSONFileCache,
5252
SSOTokenLoader,
53+
create_nested_client,
5354
parse_key_val_file,
5455
resolve_imds_endpoint_mode,
5556
)
@@ -265,7 +266,9 @@ def _get_client_creator(session, region_name):
265266
def client_creator(service_name, **kwargs):
266267
create_client_kwargs = {'region_name': region_name}
267268
create_client_kwargs.update(**kwargs)
268-
return session.create_client(service_name, **create_client_kwargs)
269+
return create_nested_client(
270+
session, service_name, **create_client_kwargs
271+
)
269272

270273
return client_creator
271274

botocore/plugin.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"). You
4+
# may not use this file except in compliance with the License. A copy of
5+
# the License is located at
6+
#
7+
# http://aws.amazon.com/apache2.0/
8+
#
9+
# or in the "license" file accompanying this file. This file is
10+
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
11+
# ANY KIND, either express or implied. See the License for the specific
12+
# language governing permissions and limitations under the License.
13+
"""
14+
NOTE: This module is considered private and is subject to abrupt breaking
15+
changes without prior announcement. Please do not use it directly.
16+
"""
17+
18+
import importlib
19+
import logging
20+
import os
21+
from contextvars import ContextVar
22+
from dataclasses import dataclass
23+
from typing import Optional
24+
25+
log = logging.getLogger(__name__)
26+
27+
28+
@dataclass
29+
class PluginContext:
30+
"""
31+
Encapsulation of plugins tracked within the `_plugin_context` context variable.
32+
"""
33+
34+
plugins: Optional[str] = None
35+
36+
37+
_plugin_context = ContextVar("_plugin_context")
38+
39+
40+
def get_plugin_context():
41+
"""Get the current `_plugin_context` context variable if set, else None."""
42+
return _plugin_context.get(None)
43+
44+
45+
def set_plugin_context(ctx):
46+
"""Set the current `_plugin_context` context variable."""
47+
token = _plugin_context.set(ctx)
48+
return token
49+
50+
51+
def reset_plugin_context(token):
52+
"""Reset the current `_plugin_context` context variable."""
53+
_plugin_context.reset(token)
54+
55+
56+
def get_botocore_plugins():
57+
context = get_plugin_context()
58+
if context is not None:
59+
plugins = context.plugins
60+
if plugins is None:
61+
context.plugins = os.environ.get('BOTOCORE_EXPERIMENTAL__PLUGINS')
62+
else:
63+
return plugins
64+
return os.environ.get('BOTOCORE_EXPERIMENTAL__PLUGINS')
65+
66+
67+
def load_client_plugins(client, plugins):
68+
for plugin_name, module_name in plugins.items():
69+
log.debug(
70+
"Importing client plugin %s from module %s",
71+
plugin_name,
72+
module_name,
73+
)
74+
try:
75+
module = importlib.import_module(module_name)
76+
module.initialize_client_plugin(client)
77+
except ModuleNotFoundError:
78+
log.debug(
79+
"Failed to locate the following plugin module: %s.",
80+
plugin_name,
81+
)
82+
except Exception as e:
83+
log.debug(
84+
"Error raised during the loading of %s: %s", plugin_name, e
85+
)

botocore/session.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
from botocore.loaders import create_loader
6969
from botocore.model import ServiceModel
7070
from botocore.parsers import ResponseParserFactory
71+
from botocore.plugin import get_botocore_plugins, load_client_plugins
7172
from botocore.regions import EndpointResolver
7273
from botocore.useragent import UserAgentString
7374
from botocore.utils import (
@@ -1039,6 +1040,7 @@ def create_client(
10391040
monitor = self._get_internal_component('monitor')
10401041
if monitor is not None:
10411042
monitor.register(client.meta.events)
1043+
self._register_client_plugins(client)
10421044
return client
10431045

10441046
def _resolve_region_name(self, region_name, config):
@@ -1164,6 +1166,24 @@ def _get_ignored_credentials(self, aws_session_token, aws_account_id):
11641166
credential_inputs.append('aws_account_id')
11651167
return ', '.join(credential_inputs) if credential_inputs else None
11661168

1169+
def _register_client_plugins(self, client):
1170+
plugins_list = get_botocore_plugins()
1171+
if plugins_list == "DISABLED" or not plugins_list:
1172+
return
1173+
1174+
client_plugins = {}
1175+
for plugin in plugins_list.split(','):
1176+
try:
1177+
name, module = [part.strip() for part in plugin.split('=')]
1178+
client_plugins[name] = module
1179+
except ValueError:
1180+
logger.warning(
1181+
f"Invalid plugin format: {plugin}. Expected 'name=module'"
1182+
)
1183+
1184+
if client_plugins:
1185+
load_client_plugins(client, client_plugins)
1186+
11671187

11681188
class ComponentLocator:
11691189
"""Service locator for session components."""

botocore/tokens.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
CachedProperty,
3333
JSONFileCache,
3434
SSOTokenLoader,
35+
create_nested_client,
3536
get_token_from_environment,
3637
)
3738

@@ -256,7 +257,7 @@ def _client(self):
256257
region_name=self._sso_config["sso_region"],
257258
signature_version=UNSIGNED,
258259
)
259-
return self._session.create_client("sso-oidc", config=config)
260+
return create_nested_client(self._session, "sso-oidc", config=config)
260261

261262
def _attempt_create_token(self, token):
262263
response = self._client.create_token(

botocore/utils.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@
8787
UnsupportedS3ControlArnError,
8888
UnsupportedS3ControlConfigurationError,
8989
)
90+
from botocore.plugin import (
91+
PluginContext,
92+
reset_plugin_context,
93+
set_plugin_context,
94+
)
9095

9196
logger = logging.getLogger(__name__)
9297
DEFAULT_METADATA_SERVICE_TIMEOUT = 1
@@ -364,6 +369,18 @@ def is_global_accesspoint(context):
364369
return is_global
365370

366371

372+
def create_nested_client(session, service_name, **kwargs):
373+
# If a client is created from within a plugin based on the environment variable,
374+
# an infinite loop could arise. Any clients created from within another client
375+
# must use this method to prevent infinite loops.
376+
ctx = PluginContext(plugins="DISABLED")
377+
token = set_plugin_context(ctx)
378+
try:
379+
return session.create_client(service_name, **kwargs)
380+
finally:
381+
reset_plugin_context(token)
382+
383+
367384
class _RetriesExceededError(Exception):
368385
"""Internal exception used when the number of retries are exceeded."""
369386

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"). You
4+
# may not use this file except in compliance with the License. A copy of
5+
# the License is located at
6+
#
7+
# http://aws.amazon.com/apache2.0/
8+
#
9+
# or in the "license" file accompanying this file. This file is
10+
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
11+
# ANY KIND, either express or implied. See the License for the specific
12+
# language governing permissions and limitations under the License.
13+
class DisabledPluginModule:
14+
def __init__(self):
15+
self.invocations = 0
16+
17+
def register_event(self, client):
18+
client.meta.events.register('before-call', self.increment_invocations)
19+
20+
def increment_invocations(self, **kwargs):
21+
self.invocations += 1
22+
23+
24+
plugin_instance = DisabledPluginModule()
25+
26+
27+
def initialize_client_plugin(client):
28+
plugin_instance.register_event(client)
29+
return plugin_instance
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"). You
4+
# may not use this file except in compliance with the License. A copy of
5+
# the License is located at
6+
#
7+
# http://aws.amazon.com/apache2.0/
8+
#
9+
# or in the "license" file accompanying this file. This file is
10+
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
11+
# ANY KIND, either express or implied. See the License for the specific
12+
# language governing permissions and limitations under the License.
13+
class FirstPluginModule:
14+
def __init__(self):
15+
self.invocations = 0
16+
17+
def register_event(self, client):
18+
client.meta.events.register('before-call', self.increment_invocations)
19+
20+
def increment_invocations(self, **kwargs):
21+
self.invocations += 1
22+
23+
24+
plugin_instance = FirstPluginModule()
25+
26+
27+
def initialize_client_plugin(client):
28+
plugin_instance.register_event(client)
29+
return plugin_instance
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"). You
4+
# may not use this file except in compliance with the License. A copy of
5+
# the License is located at
6+
#
7+
# http://aws.amazon.com/apache2.0/
8+
#
9+
# or in the "license" file accompanying this file. This file is
10+
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
11+
# ANY KIND, either express or implied. See the License for the specific
12+
# language governing permissions and limitations under the License.
13+
from botocore import utils
14+
from botocore.session import get_session
15+
from tests import ClientHTTPStubber
16+
17+
18+
class RecursivePluginModule:
19+
"""A mock recursive plugin for testing nested client creation."""
20+
21+
def __init__(self):
22+
self.called = False
23+
24+
def register_event(self, client):
25+
client.meta.events.register(
26+
'before-call.dynamodb.*', self.create_client
27+
)
28+
29+
def create_client(self, **kwargs):
30+
self.called = True
31+
session = get_session()
32+
session.set_credentials('key', 'secret')
33+
client = utils.create_nested_client(
34+
session, "dynamodb", region_name="us-west-2"
35+
)
36+
with ClientHTTPStubber(client) as http_stubber:
37+
http_stubber.add_response(status=200, body=b'')
38+
client.list_tables()
39+
40+
41+
plugin_instance = RecursivePluginModule()
42+
43+
44+
def initialize_client_plugin(client):
45+
plugin_instance.register_event(client)
46+
return plugin_instance
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"). You
4+
# may not use this file except in compliance with the License. A copy of
5+
# the License is located at
6+
#
7+
# http://aws.amazon.com/apache2.0/
8+
#
9+
# or in the "license" file accompanying this file. This file is
10+
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
11+
# ANY KIND, either express or implied. See the License for the specific
12+
# language governing permissions and limitations under the License.
13+
class SecondPluginModule:
14+
def __init__(self):
15+
self.invocations = 0
16+
17+
def register_event(self, client):
18+
client.meta.events.register('before-call', self.increment_invocations)
19+
20+
def increment_invocations(self, **kwargs):
21+
self.invocations += 1
22+
23+
24+
plugin_instance = SecondPluginModule()
25+
26+
27+
def initialize_client_plugin(client):
28+
plugin_instance.register_event(client)
29+
return plugin_instance
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"). You
4+
# may not use this file except in compliance with the License. A copy of
5+
# the License is located at
6+
#
7+
# http://aws.amazon.com/apache2.0/
8+
#
9+
# or in the "license" file accompanying this file. This file is
10+
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
11+
# ANY KIND, either express or implied. See the License for the specific
12+
# language governing permissions and limitations under the License.
13+
class TestPluginModule:
14+
"""A mock plugin module for testing client plugin loading."""
15+
16+
def __init__(self):
17+
self.events_seen = []
18+
19+
def register_event(self, client):
20+
client.meta.events.register('before-call', self.increment_calls)
21+
22+
def increment_calls(self, **kwargs):
23+
self.events_seen.append(kwargs)
24+
25+
26+
plugin_instance = TestPluginModule()
27+
28+
29+
def initialize_client_plugin(client):
30+
plugin_instance.register_event(client)
31+
return plugin_instance

0 commit comments

Comments
 (0)