Skip to content

Commit 060192f

Browse files
committed
Merge branch 'server-side-fix'
* server-side-fix: Remove outdated comment Fail gracefully when no region is configured Don't use boto3 session directly in server completer
2 parents cd73483 + a943747 commit 060192f

File tree

3 files changed

+206
-56
lines changed

3 files changed

+206
-56
lines changed

awsshell/resource/index.py

Lines changed: 105 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
55
* ResourceIndexBuilder - Takes a boto3 resource and converts into the
66
index format we need to do server side completions.
7-
* CompleterQuery - Takes the index from ResourceIndexBuilder and looks
7+
* CompleterDescriber - Takes the index from ResourceIndexBuilder and looks
88
up how to perform the autocompletion. Note that this class does
99
*not* actually do the autocompletion. It merely tells you how
1010
you _would_ do the autocompletion if you made the appropriate
@@ -20,6 +20,7 @@
2020

2121
import jmespath
2222
from botocore import xform_name
23+
from botocore.exceptions import BotoCoreError
2324

2425
LOG = logging.getLogger(__name__)
2526

@@ -86,8 +87,18 @@ def build_index(self, resource_data):
8687
return index
8788

8889

89-
class CompleterQuery(object):
90-
"""Describes how to autocomplete a resource."""
90+
class CompleterDescriber(object):
91+
"""Describes how to autocomplete a resource.
92+
93+
You give this class a service/operation/param and it will
94+
describe to you how you can autocomplete values for the
95+
provided parameter.
96+
97+
It's up to the caller to actually take that description
98+
and make the appropriate service calls + filtering to
99+
extract out the server side values.
100+
101+
"""
91102
def __init__(self, resource_index):
92103
self._index = resource_index
93104

@@ -126,71 +137,115 @@ def describe_autocomplete(self, service, operation, param):
126137
params={}, path=path)
127138

128139

129-
class ServerSideCompleter(object):
130-
def __init__(self, session, builder):
131-
# session is a boto3 session.
132-
# It is a public attribute as it is intended to be
133-
# changed if the profile changes.
134-
self.session = session
135-
self._loader = session._loader
136-
self._builder = builder
140+
class CachedClientCreator(object):
141+
def __init__(self, session):
142+
#: A botocore.session.Session object. Only the
143+
#: create_client() method is used.
144+
self._session = session
137145
self._client_cache = {}
138-
self._completer_cache = {}
139-
self._update_loader_paths()
146+
147+
def create_client(self, service_name):
148+
if service_name not in self._client_cache:
149+
client = self._session.create_client(service_name)
150+
self._client_cache[service_name] = client
151+
return self._client_cache[service_name]
152+
153+
154+
class CompleterDescriberCreator(object):
155+
"""Create and cache CompleterDescriber objects."""
156+
def __init__(self, loader):
157+
#: A botocore.loader.Loader
158+
self._loader = loader
159+
self._describer_cache = {}
160+
self._services_with_completions = None
161+
162+
def create_completer_query(self, service_name):
163+
"""Create a CompleterDescriber for a service.
164+
165+
:type service_name: str
166+
:param service_name: The name of the service, e.g. 'ec2'
167+
168+
:return: A CompleterDescriber object.
169+
170+
"""
171+
if service_name not in self._describer_cache:
172+
query = self._create_completer_query(service_name)
173+
self._describer_cache[service_name] = query
174+
return self._describer_cache[service_name]
175+
176+
def _create_completer_query(self, service_name):
177+
completions_model = self._loader.load_service_model(
178+
service_name, 'completions-1')
179+
cq = CompleterDescriber({service_name: completions_model})
180+
return cq
181+
182+
def services_with_completions(self):
183+
if self._services_with_completions is not None:
184+
return self._services_with_completions
140185
self._services_with_completions = set(
141186
self._loader.list_available_services(type_name='completions-1'))
187+
return self._services_with_completions
142188

143-
def _update_loader_paths(self):
144-
completions_path = os.path.join(
145-
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
146-
'data')
147-
self._loader.search_paths.insert(0, completions_path)
148-
149-
def _get_completer_for_service(self, service_name):
150-
if service_name not in self._completer_cache:
151-
api_version = self._loader.determine_latest_version(
152-
service_name, 'completions-1')
153-
completions_model = self._loader.load_service_model(
154-
service_name, 'completions-1', api_version)
155-
cq = CompleterQuery({service_name: completions_model})
156-
self._completer_cache[service_name] = cq
157-
return self._completer_cache[service_name]
158-
159-
def _get_client(self, service_name):
160-
if service_name in self._client_cache:
161-
return self._client_cache[service_name]
162-
client = self.session.client(service_name)
163-
self._client_cache[service_name] = client
164-
return client
189+
190+
class ServerSideCompleter(object):
191+
def __init__(self, client_creator, describer_creator):
192+
self._client_creator = client_creator
193+
self._describer_creator = describer_creator
165194

166195
def retrieve_candidate_values(self, service, operation, param):
167-
if service not in self._services_with_completions:
168-
return
196+
"""Retrieve server side completions.
197+
198+
:type service: str
199+
:param service: The service name, e.g. 'ec2', 'iam'.
200+
201+
:type operation: str
202+
:param operation: The operation name, in the casing
203+
used by the CLI (words separated by hyphens), e.g.
204+
'describe-instances', 'delete-user'.
205+
206+
:type param: str
207+
:param param: The param name, as specified in the service
208+
model, e.g. 'InstanceIds', 'UserName'.
209+
210+
:rtype: list
211+
:return: A list of possible completions for the
212+
service/operation/param combination. If no
213+
completions were found an empty list is returned.
214+
215+
"""
169216
# Example call:
170-
# service='ec2', operation='terminate-instances',
171-
# param='--instance-ids'.
172-
# We need to convert this to botocore syntax.
173-
# First try to load the resource model.
174-
LOG.debug("Called with: %s, %s, %s", service, operation, param)
175-
# Now convert operation to the name used by botocore.
176-
client = self._get_client(service)
217+
# service='ec2',
218+
# operation='terminate-instances',
219+
# param='InstanceIds'.
220+
if service not in self._describer_creator.services_with_completions():
221+
return []
222+
try:
223+
client = self._client_creator.create_client(service)
224+
except BotoCoreError as e:
225+
# create_client() could raise an exception if the session
226+
# isn't fully configured (say it's missing a region).
227+
# However, we don't want to turn off all server side
228+
# completions because it's still possible to create
229+
# clients for some services without a region, e.g. IAM.
230+
LOG.debug("Error when trying to create a client for %s",
231+
service, exc_info=True)
232+
return []
177233
api_operation_name = client.meta.method_to_api_mapping.get(
178234
operation.replace('-', '_'))
179235
if api_operation_name is None:
180-
return
236+
return []
181237
# Now we need to convert the param name to the
182238
# casing used by the API.
183-
completer = self._get_completer_for_service(service)
239+
completer = self._describer_creator.create_completer_query(service)
184240
result = completer.describe_autocomplete(
185241
service, api_operation_name, param)
186242
if result is None:
187243
return
188-
# DEBUG:awsshell.resource.index:RESULTS:
189-
# ServerCompletion(service=u'ec2', operation=u'DescribeInstances',
190-
# params={}, path=u'Reservations[].Instances[].InstanceId')
191244
try:
192245
response = getattr(client, xform_name(result.operation, '_'))()
193-
except Exception:
246+
except Exception as e:
247+
LOG.debug("Error when calling %s.%s: %s", service,
248+
result.operation, e, exc_info=True)
194249
return
195250
results = jmespath.search(result.path, response)
196251
return results

awsshell/shellcomplete.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
logic, see awsshell.autocomplete.
1010
1111
"""
12+
import os
1213
import logging
1314
from prompt_toolkit.completion import Completer, Completion
1415
from awsshell import fuzzy
@@ -31,12 +32,20 @@ def __init__(self, completer, server_side_completer=None):
3132
server_side_completer = self._create_server_side_completer()
3233
self._server_side_completer = server_side_completer
3334

34-
def _create_server_side_completer(self):
35-
import boto3.session
35+
def _create_server_side_completer(self, session=None):
36+
import botocore.session
3637
from awsshell.resource import index
37-
session = boto3.session.Session()
38-
builder = index.ResourceIndexBuilder()
39-
completer = index.ServerSideCompleter(session, builder)
38+
if session is None:
39+
session = botocore.session.Session()
40+
loader = session.get_component('data_loader')
41+
completions_path = os.path.join(
42+
os.path.dirname(os.path.abspath(__file__)),
43+
'data')
44+
loader.search_paths.insert(0, completions_path)
45+
46+
client_creator = index.CachedClientCreator(session)
47+
describer = index.CompleterDescriberCreator(loader)
48+
completer = index.ServerSideCompleter(client_creator, describer)
4049
return completer
4150

4251
@property

tests/unit/test_resources.py

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,23 @@
11
"""Index and retrive information from the resource JSON."""
22
import pytest
3+
import mock
4+
5+
from botocore.exceptions import NoRegionError
6+
37
from awsshell.resource import index
48

59

10+
@pytest.fixture
11+
def describer_creator():
12+
class FakeDescriberCreator(object):
13+
SERVICES = ['ec2']
14+
15+
def services_with_completions(self):
16+
return self.SERVICES
17+
18+
return FakeDescriberCreator()
19+
20+
621
def test_build_from_has_many():
722
resource = {
823
'service': {
@@ -172,10 +187,81 @@ def test_can_complete_query():
172187
}
173188
}
174189
}
175-
q = index.CompleterQuery(built_index)
190+
q = index.CompleterDescriber(built_index)
176191
result = q.describe_autocomplete(
177192
'dynamodb', 'DeleteTable', 'TableName')
178193
assert result.service == 'dynamodb'
179194
assert result.operation == 'ListTables'
180195
assert result.params == {}
181196
assert result.path == 'TableNames[]'
197+
198+
199+
def test_cached_client_creator_returns_same_instance():
200+
class FakeSession(object):
201+
def create_client(self, service_name):
202+
return object()
203+
204+
cached_creator = index.CachedClientCreator(FakeSession())
205+
ec2 = cached_creator.create_client('ec2')
206+
s3 = cached_creator.create_client('s3')
207+
assert ec2 != s3
208+
# However, asking for a client we've already created
209+
# should return the exact same instance.
210+
assert cached_creator.create_client('ec2') == ec2
211+
212+
213+
def test_can_create_service_completers_from_cache():
214+
class FakeDescriberCreator(object):
215+
def load_service_model(self, service_name, type_name):
216+
assert type_name == 'completions-1'
217+
return "fake_completions_for_%s" % service_name
218+
219+
def services_with_completions(self):
220+
return []
221+
222+
loader = FakeDescriberCreator()
223+
factory = index.CompleterDescriberCreator(loader)
224+
result = factory.create_completer_query('ec2')
225+
assert isinstance(result, index.CompleterDescriber)
226+
assert factory.create_completer_query('ec2') == result
227+
228+
229+
def test_empty_results_returned_when_no_completion_data_exists(describer_creator):
230+
describer_creator.SERVICES = []
231+
232+
completer = index.ServerSideCompleter(
233+
client_creator=None,
234+
describer_creator=describer_creator,
235+
)
236+
assert completer.retrieve_candidate_values(
237+
'ec2', 'run-instances', 'ImageId') == []
238+
239+
240+
def test_no_completions_when_cant_create_client(describer_creator):
241+
client_creator = mock.Mock(spec=index.CachedClientCreator)
242+
# This is raised when you don't have a region configured via config file
243+
# env var or manually via a session.
244+
client_creator.create_client.side_effect = NoRegionError()
245+
completer = index.ServerSideCompleter(
246+
client_creator=client_creator,
247+
describer_creator=describer_creator)
248+
249+
assert completer.retrieve_candidate_values(
250+
'ec2', 'foo', 'Bar') == []
251+
252+
253+
def test_no_completions_returned_on_unknown_operation(describer_creator):
254+
client = mock.Mock()
255+
client_creator = mock.Mock(spec=index.CachedClientCreator)
256+
client_creator.create_client.return_value = client
257+
258+
client.meta.method_to_api_mapping = {
259+
'describe_foo': 'DescribeFoo'
260+
}
261+
262+
completer = index.ServerSideCompleter(
263+
client_creator=client_creator,
264+
describer_creator=describer_creator)
265+
266+
assert completer.retrieve_candidate_values(
267+
'ec2', 'not_describe_foo', 'Bar') == []

0 commit comments

Comments
 (0)