Skip to content

Commit b4dbbfd

Browse files
committed
Added botocore model loader. Improved unit tests.
1 parent 99a94f9 commit b4dbbfd

File tree

3 files changed

+264
-135
lines changed

3 files changed

+264
-135
lines changed

awsshell/app.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from awsshell.toolbar import Toolbar
2727
from awsshell.utils import build_config_file_path, temporary_file
2828
from awsshell import compat
29+
from awsshell.wizard import WizardLoader
2930

3031

3132
LOG = logging.getLogger(__name__)
@@ -152,8 +153,23 @@ def run(self, command, application):
152153

153154

154155
class WizardHandler(object):
156+
def __init__(self, output=sys.stdout, err=sys.stderr):
157+
self._output = output
158+
self._err = err
159+
self._wizard_loader = WizardLoader()
160+
155161
def run(self, command, application):
156-
raise Exception("TODO")
162+
"""Run the specified wizard.
163+
164+
Delegates to the wizard loader which will search various paths to find
165+
a wizard model with a matching name. Returns and executes the loaded
166+
wizard.
167+
"""
168+
if len(command) != 2:
169+
self._err.write("invalid syntax, must be: .wizard wizname\n")
170+
return
171+
wizard = self._wizard_loader.load_wizard(command[1])
172+
wizard.execute()
157173

158174

159175
class DotCommandHandler(object):

awsshell/wizard.py

Lines changed: 149 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,95 +1,144 @@
1-
import re
1+
import sys
22
import json
33
import jmespath
44
import botocore.session
5+
from botocore import xform_name
6+
from awsshell.resource import index
57

68

7-
# TODO copy pasta'd from stackoverflow... maybe we have something else?
8-
def camel_to_snake(name):
9-
s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
10-
return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower()
9+
class WizardException(Exception):
10+
"""Base exception class for the Wizards"""
11+
pass
1112

1213

13-
class Wizard(object):
14+
class WizardLoader(object):
15+
"""This class is responsible for searching various paths to locate wizard
16+
models. Given a wizard name it will return a wizard object representing the
17+
wizard. Delegates to botocore for finding and loading the JSON models.
18+
"""
19+
20+
def __init__(self):
21+
self._session = botocore.session.get_session()
22+
self._loader = self._session.get_component('data_loader')
23+
24+
def load_wizard(self, name):
25+
"""Given a wizard's name, returns an instance of that wizard.
1426
15-
def __init__(self, filename=None):
16-
self._env = Environment()
17-
if filename:
18-
self.load_from_json(filename)
27+
:type name: str
28+
:param name: The name of the desired wizard.
29+
"""
30+
# TODO possible naming collisions here, always pick first for now
31+
# Need to discuss and specify wizard invocation
32+
services = self._loader.list_available_services(type_name=name)
33+
model = self._loader.load_service_model(services[0], name)
34+
return Wizard(model)
35+
36+
37+
class Wizard(object):
38+
"""The main wizard object containing all of the stages, the environment,
39+
botocore sessions, and the logic to drive the wizards.
40+
"""
41+
42+
def __init__(self, spec=None):
43+
"""Constructs a new Wizard
44+
45+
:type spec: dict
46+
:param spec: (Optional) Constructs the wizard by loading the given
47+
model specification.
48+
"""
49+
self.env = Environment()
50+
self._session = botocore.session.get_session()
51+
self._cached_creator = index.CachedClientCreator(self._session)
52+
if spec:
53+
self.load_from_dict(spec)
1954

20-
# Loads the wizards from the spec in dict form
2155
def load_from_dict(self, spec):
22-
self.start_stage = spec.get('StartStage')
23-
# TODO if no start stage raise exception? default to first in array?
56+
"""Loads the model specification from a dict, populating the
57+
wizard object.
58+
59+
:type spec: dict
60+
:param spec: The model specification.
61+
62+
:raises: :class:`WizardException`
63+
"""
64+
self.start_stage = spec.get('StartStage', None)
65+
if not self.start_stage:
66+
raise WizardException("Start stage not specified")
2467
self.stages = {}
2568
for s in spec['Stages']:
26-
stage = Stage(s, self._env)
69+
stage = Stage(s, self)
2770
self.stages[stage.name] = stage
2871

29-
# TODO temporary loader from file, will likely deprecate this when
30-
# there's a systemic way of loading all wizards in specified locations
31-
def load_from_json(self, filename):
32-
with open(filename, 'r') as f:
33-
spec = json.load(f)
34-
self.load_from_dict(spec)
35-
36-
# Performs the basic loop for progressing stages
3772
def execute(self):
73+
"""Runs the wizard. Executes Stages until a final stage is reached.
74+
75+
:raises: :class:WizardException
76+
"""
3877
current_stage = self.start_stage
3978
while current_stage:
4079
stage = self.stages.get(current_stage, None)
4180
if not stage:
42-
raise Exception("Stage does not exist: %s" % current_stage)
81+
raise WizardException("Stage not found: %s" % current_stage)
4382
stage.execute()
4483
current_stage = stage.get_next_stage()
84+
# TODO decouple wizard from all I/O
85+
sys.stdout.write(str(self.env)+'\n')
4586

4687

4788
class Stage(object):
48-
49-
KEYS = ['Name', 'Prompt', 'Retrieval',
50-
'Interaction', 'Resolution', 'NextStage']
51-
52-
def __init__(self, spec, env):
53-
self._wizard_env = env
54-
for key in Stage.KEYS:
55-
setattr(self, camel_to_snake(key), spec.get(key, None))
89+
"""The Stage object contains the meta information for a stage and logic
90+
required to perform all steps present.
91+
"""
92+
93+
def __init__(self, spec, wizard):
94+
"""Constructs a new Stage object.
95+
96+
:type spec: dict
97+
:param spec: The stage specification model.
98+
99+
:type wizard: :class:`Wizard`
100+
:param wizard: The wizard that this stage is a part of.
101+
"""
102+
self._wizard = wizard
103+
self.name = spec.get('Name', None)
104+
self.prompt = spec.get('Prompt', None)
105+
self.retrieval = spec.get('Retrieval', None)
106+
self.next_stage = spec.get('NextStage', None)
107+
self.resolution = spec.get('Resolution', None)
108+
self.interaction = spec.get('Interaction', None)
56109

57110
def __handle_static_retrieval(self):
58111
return self.retrieval.get('Resource')
59112

60113
def __handle_request_retrieval(self):
61-
# TODO very basic requests... refactor needed
62114
req = self.retrieval['Resource']
63-
# initialize botocore session and client for service in the request
64-
session = botocore.session.get_session()
65-
client = session.create_client(req['Service'])
115+
# get client from wizard's cache
116+
client = self._wizard._cached_creator.create_client(req['Service'])
66117
# get the operation from the client
67-
operation = getattr(client, camel_to_snake(req['Operation']))
118+
operation = getattr(client, xform_name(req['Operation']))
68119
# get any parameters
69120
parameters = req.get('Parameters', {})
70-
env_parameters = self._resolve_parameters(req.get('EnvParameters', {}))
121+
env_parameters = \
122+
self._wizard.env.resolve_parameters(req.get('EnvParameters', {}))
71123
# union of parameters and env_parameters, conflicts favor env_params
72124
parameters = dict(parameters, **env_parameters)
73125
# execute operation passing all parameters
74-
result = operation(**parameters)
75-
if self.retrieval.get('Path'):
76-
result = jmespath.search(self.retrieval['Path'], result)
77-
return result
126+
return operation(**parameters)
78127

79128
def _handle_retrieval(self):
80-
print(self.prompt)
129+
# TODO decouple wizard from all I/O
130+
sys.stdout.write(self.prompt+'\n')
81131
# In case of no retrieval, empty dict
82132
if not self.retrieval:
83133
return {}
84134
elif self.retrieval['Type'] == 'Static':
85-
return self.__handle_static_retrieval()
135+
data = self.__handle_static_retrieval()
86136
elif self.retrieval['Type'] == 'Request':
87-
return self.__handle_request_retreival()
88-
89-
def _resolve_parameters(self, keys):
90-
for key in keys:
91-
keys[key] = self._wizard_env.retrieve(keys[key])
92-
return keys
137+
data = self.__handle_request_retrieval()
138+
# Apply JMESPath query if given
139+
if self.retrieval.get('Path'):
140+
data = jmespath.search(self.retrieval['Path'], data)
141+
return data
93142

94143
def _handle_interaction(self, data):
95144
# TODO actually implement this step
@@ -100,37 +149,82 @@ def _handle_interaction(self, data):
100149
data = data[0]
101150
elif self.interaction['ScreenType'] == 'SimplePrompt':
102151
for field in data:
103-
data[field] = '500'
152+
data[field] = 'random'
104153
return data
105154

106155
def _handle_resolution(self, data):
107156
if self.resolution:
108157
if self.resolution.get('Path'):
109158
data = jmespath.search(self.resolution['Path'], data)
110-
self._wizard_env.store(self.resolution['Key'], data)
159+
self._wizard.env.store(self.resolution['Key'], data)
111160

112161
def get_next_stage(self):
162+
"""Resolves the next stage name for the stage after this one.
163+
164+
:rtype: str
165+
:return: The name of the next stage.
166+
"""
113167
if not self.next_stage:
114168
return None
115169
elif self.next_stage['Type'] == 'Name':
116170
return self.next_stage['Name']
117171
elif self.next_stage['Type'] == 'Variable':
118-
return self._wizard_env.retrieve(self.next_stage['Name'])
172+
return self._wizard.env.retrieve(self.next_stage['Name'])
119173

120174
# Executes all three steps of the stage
121175
def execute(self):
122-
retrieved = self._handle_retrieval()
123-
transformed = self._handle_interaction(retrieved)
124-
self._handle_resolution(transformed)
176+
"""Executes all steps in the stage if they are present.
177+
1) Perform Retrieval.
178+
2) Perform Interaction on retrieved data.
179+
3) Perform Resolution to store data in the environment.
180+
"""
181+
retrieved_options = self._handle_retrieval()
182+
selected_data = self._handle_interaction(retrieved_options)
183+
self._handle_resolution(selected_data)
125184

126185

127186
class Environment(object):
187+
"""This class is used to store variables into a dict and retrieve them
188+
via JMESPath queries instead of normal keys.
189+
"""
128190

129191
def __init__(self):
130192
self._variables = {}
131193

194+
def __str__(self):
195+
return json.dumps(self._variables, indent=4, sort_keys=True)
196+
132197
def store(self, key, val):
198+
"""Stores a variable under the given key.
199+
200+
:type key: str
201+
:param key: The key to store the value as.
202+
203+
:type val: object
204+
:param val: The value to store into the environment.
205+
"""
133206
self._variables[key] = val
134207

135208
def retrieve(self, path):
209+
"""Retrieves the variable corresponding to the given JMESPath query.
210+
211+
:type path: str
212+
:param path: The JMESPath query to be used when locating the variable.
213+
"""
136214
return jmespath.search(path, self._variables)
215+
216+
def resolve_parameters(self, keys):
217+
"""Resolves all keys in the given keys dict. Expects all values in the
218+
keys dict to be JMESPath queries to be used when retrieving from the
219+
environment. Interpolates all values from their path to the actual
220+
value stored in the environment.
221+
222+
:type keys: dict
223+
:param keys: A dict of keys to paths that need to be resolved.
224+
225+
:rtype: dict
226+
:return: The dict of with all of the paths resolved to their values.
227+
"""
228+
for key in keys:
229+
keys[key] = self.retrieve(keys[key])
230+
return keys

0 commit comments

Comments
 (0)