Merge lp:~frankban/juju-quickstart/env-manage-selection-view into lp:juju-quickstart
- env-manage-selection-view
- Merge into trunk
Status: | Merged |
---|---|
Merged at revision: | 38 |
Proposed branch: | lp:~frankban/juju-quickstart/env-manage-selection-view |
Merge into: | lp:juju-quickstart |
Diff against target: |
933 lines (+613/-114) 10 files modified
cli-app-demo.py (+21/-19) quickstart/cli/base.py (+25/-17) quickstart/cli/ui.py (+53/-2) quickstart/cli/views.py (+141/-7) quickstart/tests/cli/test_base.py (+2/-10) quickstart/tests/cli/test_ui.py (+49/-1) quickstart/tests/cli/test_views.py (+267/-0) quickstart/tests/helpers.py (+55/-0) quickstart/tests/test_utils.py (+0/-25) quickstart/utils.py (+0/-33) |
To merge this branch: | bzr merge lp:~frankban/juju-quickstart/env-manage-selection-view |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Juju GUI Hackers | Pending | ||
Review via email:
|
Commit message
Description of the change
Environment list/selection view.
This branch implements the environment
selection Urwid view. It is possible to
start it running `make` and then
`./cli-
integrated in quickstart, but the demo
file demonstrates how to show the view
passing the required data structures
already implemented in the
quickstart.
To test the branch, run `make check`.
To QA the new functionality, run the
demo application as described above,
and play with the interface: any UX
suggestion is welcome, and will be part
of a card we already have
("envs management: ux improvements").
Other changes:
The Urwid application is no longer an
ObjectDict (removed from the code).
I decided to use a Python namedtuple
which seemed to me a better choice,
considering that views are not supposed
to modify the app data structure.
This way we have both immutability and
attribute access to the object.
Implemented a TimeoutText wrapper used to
wrap the message notification widget.
This wayt he timer is restarted when a
subsequent message is set and the previous
has not yet been removed.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Francesco Banconi (frankban) wrote : | # |
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Richard Harding (rharding) wrote : | # |
Code looks good with a couple of readable enhancing notes. The UI code
is a bit dense as someone that's not looked at it before so will trust
in QA.
https:/
File quickstart/
https:/
quickstart/
why the _? Why call it 'App' but also call it _Application? In my
experience I'd just have App = namedtuple('App'...
https:/
quickstart/
after changing the application
Create the App named tuple. If, in the future, we have a view that
requires additional capabilities or API access, this is the place to add
those.
https:/
File quickstart/
https:/
quickstart/
I don't follow this. When None is in focus it's selected?
https:/
File quickstart/
https:/
quickstart/
(a named tuple of functions) and
Can we capitalize the App here and in cases referencing the specific
object?
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Gary Poster (gary) wrote : | # |
LGTM with mostly trivial comments (discussion on possible decoys is the
only thing that ended up being interesting).
https:/
File quickstart/
https:/
quickstart/
Nice improvement.
https:/
File quickstart/
https:/
quickstart/
nice :-)
I toyed with suggesting that you pass env_index to env_detail, and vice
versa, but naah for now.
https:/
quickstart/
Actually getting the data here seems dangerous to me, since each of
these include a "default" flag which is actually determined by the
env_db collectively. As a pattern, I think it would be better to have
env_name only sorted, or possibly env_name mapping to thunks. I haven't
seen whether this actually affects your code yet, but it feels like a
bug magnet.
[Later] Eh, never mind. This is not a long-lived data structure. It's
fine. :-)
https:/
quickstart/
this is a decoy, right? No one can use these?
I worry a bit that the functional style here is a mismatch with Urwid.
These functions are all about side effects. However, on reflection, I
feel like it's easy to read what you have, and that increasing the
functional feel is increasing the simplicity. We'll see how time and
other people instruct us, I guess. :-)
https:/
quickstart/
Isn't this a decoy? That is, no code will ever actually pay attention
to these results? either index_view will get the mutated result in
set_default, or the urwid caller will get it via use?
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Richard Harding (rharding) wrote : | # |
LGTM
QA Notes:
The * in front of each alternates colors. Two are red, which I at first
thought was a default indicator.
The default is easy to miss down the row. I'ts in the () with type so I
assumed it was part of that at first. A typical thing in cli is to
bracket defaults with a []. Maybe the default row could be
[*checkmark*] lxc (type: local)
http://
or something?
<3 the unicode toxic type.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Gary Poster (gary) wrote : | # |
QA: looks nice and works well! Mouse support is convenient. Scrolling
in main content works well with cursor arrows.
I suggest a bit more space between the different parts of the help
text--particularly a space or two more before the "{BULLET} has errors"
section.
The temporary messages ("ec2-west successfully set as default") might
look nicer as a horizontal line rather than as a vertical section on the
right.
Thanks!
- 44. By Francesco Banconi
-
Merged trunk.
- 45. By Francesco Banconi
-
Fix typo.
- 46. By Francesco Banconi
-
Changes as per review.
- 47. By Francesco Banconi
-
UX improvements.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Francesco Banconi (frankban) wrote : | # |
On 2013/12/18 16:58:53, rharding wrote:
> The default is easy to miss down the row. I'ts in the () with type so
I assumed
> it was part of that at first. A typical thing in cli is to bracket
defaults with
> a []. Maybe the default row could be
> [*checkmark*] lxc (type: local)
> http://
> or something?
Done as part of this branch, thank you for this suggestion.
> <3 the unicode toxic type.
:-)
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Francesco Banconi (frankban) wrote : | # |
On 2013/12/18 17:07:44, gary.poster wrote:
> I suggest a bit more space between the different parts of the help
> text--particularly a space or two more before the "{BULLET} has
errors" section.
Done.
> The temporary messages ("ec2-west successfully set as default") might
look nicer
> as a horizontal line rather than as a vertical section on the right.
Added this suggestion to the UX card, thank you!
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Francesco Banconi (frankban) wrote : | # |
*** Submitted:
Environment list/selection view.
This branch implements the environment
selection Urwid view. It is possible to
start it running `make` and then
`./cli-
integrated in quickstart, but the demo
file demonstrates how to show the view
passing the required data structures
already implemented in the
quickstart.
To test the branch, run `make check`.
To QA the new functionality, run the
demo application as described above,
and play with the interface: any UX
suggestion is welcome, and will be part
of a card we already have
("envs management: ux improvements").
Other changes:
The Urwid application is no longer an
ObjectDict (removed from the code).
I decided to use a Python namedtuple
which seemed to me a better choice,
considering that views are not supposed
to modify the app data structure.
This way we have both immutability and
attribute access to the object.
Implemented a TimeoutText wrapper used to
wrap the message notification widget.
This wayt he timer is restarted when a
subsequent message is set and the previous
has not yet been removed.
R=rharding, gary.poster
CC=
https:/
https:/
File quickstart/
https:/
quickstart/
On 2013/12/18 16:44:58, rharding wrote:
> why the _? Why call it 'App' but also call it _Application? In my
experience I'd
> just have App = namedtuple('App'...
Done.
https:/
quickstart/
after changing the application
On 2013/12/18 16:44:58, rharding wrote:
> Create the App named tuple. If, in the future, we have a view that
requires
> additional capabilities or API access, this is the place to add those.
Done.
https:/
File quickstart/
https:/
quickstart/
On 2013/12/18 16:44:58, rharding wrote:
> I don't follow this. When None is in focus it's selected?
When a widget without a class is in focus, we apply the selected class.
https:/
File quickstart/
https:/
quickstart/
(a named tuple of functions) and
On 2013/12/18 16:44:58, rharding wrote:
> Can we capitalize the App here and in cases referencing the specific
object?
Done.
https:/
quickstart/
On 2013/12/18 16:57:15, gary.poster wrote:
> this is a decoy, right? No one can use these?
> I worry a bit that the functional style here is a mismatch with Urwid.
These
> functions are all about side effects. However, on reflection, I feel
like it's
> ...
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Francesco Banconi (frankban) wrote : | # |
Thank you both!
Preview Diff
1 | === modified file 'cli-app-demo.py' |
2 | --- cli-app-demo.py 2013-12-16 16:38:20 +0000 |
3 | +++ cli-app-demo.py 2013-12-19 09:30:30 +0000 |
4 | @@ -27,26 +27,28 @@ |
5 | unicode_literals, |
6 | ) |
7 | |
8 | -import urwid |
9 | - |
10 | from quickstart.cli import views |
11 | - |
12 | - |
13 | -def example_view(app, message): |
14 | - """An example Quickstart view.""" |
15 | - app.set_title('This is the title') |
16 | - widgets = [ |
17 | - urwid.Text('These are the app contents. Press ^X to exit.'), |
18 | - urwid.Divider(), |
19 | - urwid.Text('Message: {}'.format(message)), |
20 | - ] |
21 | - contents = urwid.ListBox(urwid.SimpleFocusListWalker(widgets)) |
22 | - app.set_contents(contents) |
23 | - app.set_status(('status error', '\u2622 Look! A toxic status! \u2622')) |
24 | - app.set_message('this notification will disappear in three seconds') |
25 | - return 'Goodbye, world' |
26 | +from quickstart.models import envs |
27 | +from quickstart.tests import helpers |
28 | + |
29 | + |
30 | +def main(): |
31 | + """Start the Quickstart environments list view.""" |
32 | + env_type_db = envs.get_env_type_db() |
33 | + # This simulates an env database as returned by envs.load(). |
34 | + env_db = helpers.make_env_db(default='lxc') |
35 | + save_callable = lambda db: None |
36 | + # Show the view. |
37 | + new_env_db, env_data = views.show( |
38 | + views.env_index, env_type_db, env_db, save_callable) |
39 | + if new_env_db != env_db: |
40 | + print('saved a new env db') |
41 | + print('default: {}'.format(new_env_db.get('default'))) |
42 | + if env_data is None: |
43 | + print('no environment selected') |
44 | + else: |
45 | + print('selected: {}'.format(env_data['name'])) |
46 | |
47 | |
48 | if __name__ == '__main__': |
49 | - return_value = views.show(example_view, 'Hello, world') |
50 | - print(return_value) |
51 | + main() |
52 | |
53 | === modified file 'quickstart/cli/base.py' |
54 | --- quickstart/cli/base.py 2013-12-17 15:23:28 +0000 |
55 | +++ quickstart/cli/base.py 2013-12-19 09:30:30 +0000 |
56 | @@ -25,13 +25,23 @@ |
57 | |
58 | from __future__ import unicode_literals |
59 | |
60 | +from collections import namedtuple |
61 | + |
62 | import urwid |
63 | |
64 | -from quickstart import ( |
65 | - get_version, |
66 | - utils, |
67 | +from quickstart import get_version |
68 | +from quickstart.cli import ui |
69 | + |
70 | + |
71 | +# Define the application as a named tuple of callables. |
72 | +App = namedtuple( |
73 | + 'App', [ |
74 | + 'set_title', 'get_title', |
75 | + 'set_contents', 'get_contents', |
76 | + 'set_status', 'get_status', |
77 | + 'set_message', 'get_message', |
78 | + ], |
79 | ) |
80 | -from quickstart.cli import ui |
81 | |
82 | |
83 | class _MainLoop(urwid.MainLoop): |
84 | @@ -69,8 +79,8 @@ |
85 | that can be used by views to change the contents of the frame. |
86 | |
87 | Return a tuple (loop, app) where loop is the interactive session main loop |
88 | - (ready to be started invoking loop.run()) and app is an ObjectDict exposing |
89 | - an API to be used by views to customize the application. |
90 | + (ready to be started invoking loop.run()) and app is a named tuple of |
91 | + callables exposing an API to be used by views to customize the application. |
92 | |
93 | The API exposed by app is limited by design, and includes: |
94 | |
95 | @@ -134,22 +144,20 @@ |
96 | min_width=78, min_height=20) |
97 | # Instantiate the Urwid main loop. |
98 | loop = _MainLoop(top_widget, palette=ui.PALETTE) |
99 | - |
100 | - def set_message(msg): |
101 | - message.set_text(('message', msg)) |
102 | - loop.set_alarm_in(3, lambda *args: message.set_text('')) |
103 | - |
104 | - # Create the app ObjectDict. If in the future we will find a view to |
105 | - # require more capabilities/API access than the current one, this is the |
106 | - # place where to add new API functions. |
107 | - app = utils.ObjectDict( |
108 | + # Add a timeout to the notification message. |
109 | + timeout_message = ui.TimeoutText( |
110 | + message, 3, loop.set_alarm_in, loop.remove_alarm) |
111 | + # Create the App named tuple. If, in the future, we have a view that |
112 | + # requires additional capabilities or API access, this is the place to add |
113 | + # those. |
114 | + app = App( |
115 | set_title=lambda msg: title.set_text('\n{}'.format(msg)), |
116 | get_title=lambda: title.text.lstrip(), |
117 | set_contents=set_contents, |
118 | get_contents=lambda: contents.original_widget, |
119 | set_status=lambda msg: status.set_text(msg), |
120 | get_status=lambda: status.text, |
121 | - set_message=set_message, |
122 | - get_message=lambda: message.text, |
123 | + set_message=lambda msg: timeout_message.set_text(('message', msg)), |
124 | + get_message=lambda: timeout_message.text, |
125 | ) |
126 | return loop, app |
127 | |
128 | === modified file 'quickstart/cli/ui.py' |
129 | --- quickstart/cli/ui.py 2013-12-17 15:23:28 +0000 |
130 | +++ quickstart/cli/ui.py 2013-12-19 09:30:30 +0000 |
131 | @@ -34,7 +34,8 @@ |
132 | ('controls', 'dark gray', 'light gray'), |
133 | ('edit', 'white,underline', 'black'), |
134 | ('error', 'light red', 'black'), |
135 | - ('status error', 'light red', 'light gray'), |
136 | + ('error status', 'light red', 'light gray'), |
137 | + ('error selected', 'light red', 'dark blue'), |
138 | ('footer', 'black', 'light gray'), |
139 | ('message', 'white', 'dark green'), |
140 | ('header', 'white', 'dark magenta'), |
141 | @@ -43,6 +44,11 @@ |
142 | ('line footer', 'light gray', 'light gray'), |
143 | ('selected', 'white', 'dark blue'), |
144 | ] |
145 | +# Map attributes to new attributes to apply when the widget is selected. |
146 | +FOCUS_MAP = { |
147 | + None: 'selected', |
148 | + 'error': 'error selected', |
149 | +} |
150 | # Define a default padding for the Urwid application. |
151 | padding = functools.partial(urwid.Padding, left=2, right=2) |
152 | |
153 | @@ -91,7 +97,7 @@ |
154 | urwid.connect_signal(self, 'click', callback) |
155 | icon = urwid.SelectableIcon(caption, 0) |
156 | # Replace the original widget: it seems ugly but it is Urwid idiomatic. |
157 | - self._w = urwid.AttrMap(icon, None, 'selected') |
158 | + self._w = urwid.AttrMap(icon, None, FOCUS_MAP) |
159 | |
160 | |
161 | def thunk(function, *args, **kwargs): |
162 | @@ -116,3 +122,48 @@ |
163 | def callback(*ignored_args, **ignored_kwargs): |
164 | return function(*args, **kwargs) |
165 | return callback |
166 | + |
167 | + |
168 | +class TimeoutText(object): |
169 | + """Wrap urwid.Text widget instances. |
170 | + |
171 | + The resulting widget, when set_text is called, displays text messages only |
172 | + for the given number of seconds. |
173 | + """ |
174 | + |
175 | + def __init__(self, widget, seconds, set_alarm, remove_alarm): |
176 | + """Create the wrapper widget. |
177 | + |
178 | + Receives the text widget to be wrapped, the number of seconds before |
179 | + the message disappears, the functions used to set and to remove an |
180 | + alarm on the loop (usually loop.set_alarm_in and loop.remove_alarm). |
181 | + """ |
182 | + self.original_widget = widget |
183 | + self.seconds = seconds |
184 | + self._set_alarm = set_alarm |
185 | + self._remove_alarm = remove_alarm |
186 | + self._handle = None |
187 | + |
188 | + def __getattr__(self, attr): |
189 | + """Allow access to the original widget's attributes.""" |
190 | + return getattr(self.original_widget, attr) |
191 | + |
192 | + def set_text(self, text): |
193 | + """Set the text message on the original widget. |
194 | + |
195 | + Set up an alert that will clear the message after the given number of |
196 | + seconds. Remove any previously set alarms if required. |
197 | + """ |
198 | + handle = self._handle |
199 | + if handle is not None: |
200 | + self._remove_alarm(handle) |
201 | + self.original_widget.set_text(text) |
202 | + self._handle = self._set_alarm(self.seconds, self._alarm_callback) |
203 | + |
204 | + def _alarm_callback(self, *args): |
205 | + """Remove the message from the original widget. |
206 | + |
207 | + This method is called by the alarm set up in self.set_text(). |
208 | + """ |
209 | + self.original_widget.set_text('') |
210 | + self._handle = None |
211 | |
212 | === modified file 'quickstart/cli/views.py' |
213 | --- quickstart/cli/views.py 2013-12-17 15:23:28 +0000 |
214 | +++ quickstart/cli/views.py 2013-12-19 09:30:30 +0000 |
215 | @@ -29,9 +29,9 @@ |
216 | started, and the show function blocks until the user or the view itself |
217 | request to exit the application. |
218 | |
219 | -A view is a callable receiving an app ObjectDict and other optional arguments |
220 | -(based on specific view needs). A view function can configure the Urwid |
221 | -application using the API exposed by the application object |
222 | +A view is a callable receiving an App object (a named tuple of functions) and |
223 | +other optional arguments (based on specific view needs). A view function can |
224 | +configure the Urwid application using the API exposed by the application object |
225 | (see quickstart.cli.base.setup_urwid_app). |
226 | |
227 | Assume a view is defined like the following: |
228 | @@ -79,29 +79,36 @@ |
229 | |
230 | pressed = views.show(button_view) |
231 | |
232 | -In this example the button_view function configures the app to show a button. |
233 | +In this example the button_view function configures the App to show a button. |
234 | Clicking that button an AppExit(True) is raised. The view itself instead just |
235 | returns False. This means that "pressed" will be True if the user exited using |
236 | the button, or False if the user exited using the global shortcut. |
237 | |
238 | As a final note, it is absolutely safe for a view to call, directly or |
239 | indirectly, other views, as long as all the arguments required by the other |
240 | -views, including app, are properly provided. This is effectively the proposed |
241 | -solution to build multi-views CLI applications in Quickstart. |
242 | +views, including the App object, are properly provided. This is effectively the |
243 | +proposed solution to build multi-views CLI applications in Quickstart. |
244 | """ |
245 | |
246 | from __future__ import unicode_literals |
247 | |
248 | +import copy |
249 | +import functools |
250 | +import operator |
251 | + |
252 | +import urwid |
253 | + |
254 | from quickstart.cli import ( |
255 | base, |
256 | ui, |
257 | ) |
258 | +from quickstart.models import envs |
259 | |
260 | |
261 | def show(view, *args): |
262 | """Start an Urwid interactive session showing the given view. |
263 | |
264 | - The view is called passing an app ObjectDict and the provided *args. |
265 | + The view is called passing an App named tuple and the provided *args. |
266 | |
267 | Block until the main loop is stopped, either by the user with the exit |
268 | shortcut or by the view itself with the AppExit exception. In the former |
269 | @@ -117,3 +124,130 @@ |
270 | loop.run() |
271 | except ui.AppExit as err: |
272 | return err.return_value |
273 | + |
274 | + |
275 | +def env_index(app, env_type_db, env_db, save_callable): |
276 | + """Show the Juju environments list. |
277 | + |
278 | + The env_detail view is displayed when the user clicks on an environment. |
279 | + |
280 | + Receives: |
281 | + - env_type_db: the environments meta information; |
282 | + - env_db: the environments database; |
283 | + - save_callable: a function called to save a new environment database. |
284 | + """ |
285 | + env_db = copy.deepcopy(env_db) |
286 | + detail_view = functools.partial( |
287 | + env_detail, app, env_type_db, env_db, save_callable) |
288 | + # Alphabetically sort the existing environments. |
289 | + environments = sorted([ |
290 | + envs.get_env_data(env_db, env_name) |
291 | + for env_name in env_db['environments'] |
292 | + ], key=operator.itemgetter('name')) |
293 | + if environments: |
294 | + title = 'Select the Juju environment you want to use' |
295 | + else: |
296 | + # XXX frankban 2013-12-18: implement the env creation view. |
297 | + title = 'No Juju environments already set up: please create one' |
298 | + app.set_title(title) |
299 | + app.set_status('') |
300 | + # Start creating the page contents: a list of selectable environments. |
301 | + widgets = [] |
302 | + focus_position = None |
303 | + errors_found = default_found = False |
304 | + for position, env_data in enumerate(environments): |
305 | + bullet = '\N{BULLET}' |
306 | + # Is this environment the default one? |
307 | + if env_data['is-default']: |
308 | + default_found = True |
309 | + focus_position = position |
310 | + bullet = '\N{CHECK MARK}' |
311 | + # Is this environment valid? |
312 | + env_metadata = envs.get_env_metadata(env_type_db, env_data) |
313 | + errors = envs.validate(env_metadata, env_data) |
314 | + if errors: |
315 | + errors_found = True |
316 | + bullet = ('error', bullet) |
317 | + # Create a label for the environment. |
318 | + env_short_description = envs.get_env_short_description(env_data) |
319 | + text = [bullet, ' {}'.format(env_short_description)] |
320 | + widgets.append(ui.MenuButton(text, ui.thunk(detail_view, env_data))) |
321 | + widgets.append(urwid.Divider()) |
322 | + contents = urwid.ListBox(urwid.SimpleFocusListWalker(widgets)) |
323 | + if focus_position is not None: |
324 | + contents.set_focus(focus_position) |
325 | + # Set up the application status messages. |
326 | + status = [] |
327 | + if default_found: |
328 | + status.append(' \N{CHECK MARK} default ') |
329 | + if errors_found: |
330 | + status.extend([('error status', ' \N{BULLET}'), ' has errors ']) |
331 | + if status: |
332 | + app.set_status(status) |
333 | + app.set_contents(contents) |
334 | + return env_db, None |
335 | + |
336 | + |
337 | +def env_detail(app, env_type_db, env_db, save_callable, env_data): |
338 | + """Show details on a Juju environment. |
339 | + |
340 | + From this view it is possible to start the environment, set it as default, |
341 | + edit/remove the environment. |
342 | + |
343 | + Receives: |
344 | + - env_type_db: the environments meta information; |
345 | + - env_db: the environments database; |
346 | + - save_callable: a function called to save a new environment database. |
347 | + - env_data: the environment data. |
348 | + """ |
349 | + env_db = copy.deepcopy(env_db) |
350 | + index_view = functools.partial( |
351 | + env_index, app, env_type_db, env_db, save_callable) |
352 | + |
353 | + def use(env_data): |
354 | + # Quit the interactive session returning the (possibly modified) |
355 | + # environment database and the environment data corresponding to the |
356 | + # selected environment. |
357 | + raise ui.AppExit((env_db, env_data)) |
358 | + |
359 | + def set_default(env_data): |
360 | + # Set this environment as the default one, save the env_db and return |
361 | + # to the index view. |
362 | + env_name = env_data['name'] |
363 | + env_db['default'] = env_name |
364 | + save_callable(env_db) |
365 | + app.set_message('{} successfully set as default'.format(env_name)) |
366 | + index_view() |
367 | + |
368 | + env_metadata = envs.get_env_metadata(env_type_db, env_data) |
369 | + app.set_title(envs.get_env_short_description(env_data)) |
370 | + app.set_status('') |
371 | + # Validate the environment. |
372 | + errors = envs.validate(env_metadata, env_data) |
373 | + widgets = [] |
374 | + field_value_pairs = envs.map_fields_to_env_data(env_metadata, env_data) |
375 | + for field, value in field_value_pairs: |
376 | + if field.required or (value is not None): |
377 | + label = '{}: '.format(field.name) |
378 | + if field.name in errors: |
379 | + label = ('error', label) |
380 | + text = [label, ('highlight', field.display(value))] |
381 | + widgets.append(urwid.Text(text)) |
382 | + controls = [ui.MenuButton('back', ui.thunk(index_view))] |
383 | + if errors: |
384 | + app.set_status([ |
385 | + ('error status', ' \N{LOWER SEVEN EIGHTHS BLOCK}'), |
386 | + ' field error ', |
387 | + ]) |
388 | + else: |
389 | + # Without errors, it is possible to use/start this environment. |
390 | + controls.append(ui.MenuButton('use', ui.thunk(use, env_data))) |
391 | + if not env_data['is-default']: |
392 | + controls.append( |
393 | + ui.MenuButton('set default', ui.thunk(set_default, env_data))) |
394 | + # XXX frankban 2013-12-18: implement the "remove env" functionality. |
395 | + # XXX frankban 2013-12-18: implement the env modification view. |
396 | + widgets.append(ui.create_controls(*controls)) |
397 | + listbox = urwid.ListBox(urwid.SimpleFocusListWalker(widgets)) |
398 | + app.set_contents(listbox) |
399 | + return env_db, None |
400 | |
401 | === modified file 'quickstart/tests/cli/test_base.py' |
402 | --- quickstart/tests/cli/test_base.py 2013-12-17 10:37:13 +0000 |
403 | +++ quickstart/tests/cli/test_base.py 2013-12-19 09:30:30 +0000 |
404 | @@ -22,7 +22,6 @@ |
405 | |
406 | import urwid |
407 | |
408 | -from quickstart import utils |
409 | from quickstart.cli import base |
410 | |
411 | |
412 | @@ -107,15 +106,8 @@ |
413 | self.assertIsInstance(self.loop, base._MainLoop) |
414 | |
415 | def test_app(self): |
416 | - # The returned app is an ObjectDict including the expected keys. |
417 | - expected_keys = set([ |
418 | - 'set_title', 'get_title', |
419 | - 'set_contents', 'get_contents', |
420 | - 'set_status', 'get_status', |
421 | - 'set_message', 'get_message', |
422 | - ]) |
423 | - self.assertIsInstance(self.app, utils.ObjectDict) |
424 | - self.assertEqual(expected_keys, set(self.app.keys())) |
425 | + # The returned app is the application named tuple |
426 | + self.assertIsInstance(self.app, base.App) |
427 | |
428 | def test_set_title(self): |
429 | # The set_title API sets the application title. |
430 | |
431 | === modified file 'quickstart/tests/cli/test_ui.py' |
432 | --- quickstart/tests/cli/test_ui.py 2013-12-17 15:23:28 +0000 |
433 | +++ quickstart/tests/cli/test_ui.py 2013-12-19 09:30:30 +0000 |
434 | @@ -23,7 +23,10 @@ |
435 | import mock |
436 | import urwid |
437 | |
438 | -from quickstart.cli import ui |
439 | +from quickstart.cli import ( |
440 | + base, |
441 | + ui, |
442 | +) |
443 | |
444 | |
445 | class TestAppExit(unittest.TestCase): |
446 | @@ -115,3 +118,48 @@ |
447 | sqr = lambda value: value * value |
448 | thunk_function = ui.thunk(sqr, 3) |
449 | self.assertEqual(9, thunk_function(self.widget)) |
450 | + |
451 | + |
452 | +class TestTimeoutText(unittest.TestCase): |
453 | + |
454 | + def setUp(self): |
455 | + # Set up a timeout text widget. |
456 | + self.original_widget = urwid.Text('original contents') |
457 | + self.loop = base._MainLoop(None) |
458 | + self.wrapper = ui.TimeoutText( |
459 | + self.original_widget, 42, |
460 | + self.loop.set_alarm_in, self.loop.remove_alarm) |
461 | + |
462 | + def test_attributes(self): |
463 | + # The original widget and the timeout seconds are accessible from the |
464 | + # wrapper. |
465 | + self.assertEqual(self.original_widget, self.wrapper.original_widget) |
466 | + self.assertEqual(42, self.wrapper.seconds) |
467 | + |
468 | + def test_original_attributes(self): |
469 | + # The original widget attributes can be accessed from the wrapper. |
470 | + self.assertEqual('original contents', self.wrapper.text) |
471 | + self.assertEqual(('original contents', []), self.wrapper.get_text()) |
472 | + |
473 | + def test_set_timeout(self): |
474 | + # When setting text on a timeout text widget, am alarm is set up. The |
475 | + # alarm clears the text after the given number of seconds. |
476 | + self.wrapper.set_text('this will disappear') |
477 | + self.assertEqual('this will disappear', self.wrapper.text) |
478 | + alarms = self.loop.get_alarms() |
479 | + self.assertEqual(1, len(alarms)) |
480 | + # Calling the callback makes the message go away. |
481 | + _, callback = alarms[0] |
482 | + callback() |
483 | + self.assertEqual('', self.wrapper.text) |
484 | + |
485 | + def test_update_timeout(self): |
486 | + # The alarm is updated when setting text multiple time. |
487 | + self.wrapper.set_text('this will disappear') |
488 | + timeout, _ = self.loop.get_alarms()[0] |
489 | + self.wrapper.set_text('and this too') |
490 | + alarms = self.loop.get_alarms() |
491 | + self.assertEqual(1, len(alarms)) |
492 | + new_timeout, _ = alarms[0] |
493 | + # The new timeout is more far away in the future. |
494 | + self.assertGreater(new_timeout, timeout) |
495 | |
496 | === modified file 'quickstart/tests/cli/test_views.py' |
497 | --- quickstart/tests/cli/test_views.py 2013-12-17 09:33:13 +0000 |
498 | +++ quickstart/tests/cli/test_views.py 2013-12-19 09:30:30 +0000 |
499 | @@ -22,11 +22,15 @@ |
500 | import unittest |
501 | |
502 | import mock |
503 | +import urwid |
504 | |
505 | from quickstart.cli import ( |
506 | + base, |
507 | ui, |
508 | views, |
509 | ) |
510 | +from quickstart.models import envs |
511 | +from quickstart.tests import helpers |
512 | |
513 | |
514 | class TestShow(unittest.TestCase): |
515 | @@ -84,3 +88,266 @@ |
516 | with self.patch_setup_urwid_app() as (mock_loop, mock_app): |
517 | views.show(view, 'arg1', 'arg2') |
518 | view.assert_called_once_with(mock_app, 'arg1', 'arg2') |
519 | + |
520 | + |
521 | +class EnvViewTestsMixin(object): |
522 | + """Shared helpers for testing environment views.""" |
523 | + |
524 | + env_type_db = envs.get_env_type_db() |
525 | + |
526 | + def setUp(self): |
527 | + # Set up the base Urwid application. |
528 | + self.loop, self.app = base.setup_urwid_app() |
529 | + self.save_callable = mock.Mock() |
530 | + |
531 | + def get_widgets_in_contents(self, filter_function=None): |
532 | + """Return a list of widgets included in the app contents. |
533 | + |
534 | + Use the filter_function argument to filter the returned list. |
535 | + """ |
536 | + contents = self.app.get_contents() |
537 | + return filter(filter_function, list(contents.body)) |
538 | + |
539 | + def get_control_buttons(self): |
540 | + """Return the list of buttons included in a control box. |
541 | + |
542 | + Control boxes are created using ui.create_controls(). |
543 | + """ |
544 | + piles = self.get_widgets_in_contents( |
545 | + filter_function=self.is_a(urwid.Pile)) |
546 | + # Assume the control box is the last Pile. |
547 | + controls = piles[-1] |
548 | + # The button columns is the second widget in the Pile. |
549 | + columns = controls.contents[1][0].base_widget |
550 | + return [content[0].base_widget for content in columns.contents] |
551 | + |
552 | + def is_a(self, cls): |
553 | + """Return a function returning True if the given argument is a cls. |
554 | + |
555 | + The resulting function can be used as the filter_function argument in |
556 | + self.get_widgets_in_contents() calls. |
557 | + """ |
558 | + return lambda arg: isinstance(arg, cls) |
559 | + |
560 | + def get_button_caption(self, button): |
561 | + """Return the button caption as a string.""" |
562 | + return button._w.original_widget.text |
563 | + |
564 | + def emit(self, widget): |
565 | + """Emit the first signal associated withe the given widget. |
566 | + |
567 | + This is usually invoked to click buttons. |
568 | + """ |
569 | + # Retrieve the first signal name (usually is 'click'). |
570 | + signal_name = widget.signals[0] |
571 | + urwid.emit_signal(widget, signal_name, widget) |
572 | + |
573 | + |
574 | +class TestEnvIndex(EnvViewTestsMixin, unittest.TestCase): |
575 | + |
576 | + def test_view_return_value(self): |
577 | + # The view returns a tuple including a copy of the given env_db and |
578 | + # None, the latter meaning no environment has been selected. |
579 | + env_db = helpers.make_env_db() |
580 | + new_env_db, env_data = views.env_index( |
581 | + self.app, self.env_type_db, env_db, self.save_callable) |
582 | + self.assertEqual(env_db, new_env_db) |
583 | + self.assertIsNot(env_db, new_env_db) |
584 | + self.assertIsNone(env_data) |
585 | + |
586 | + def test_view_title(self): |
587 | + # The application title is correctly set up. |
588 | + env_db = helpers.make_env_db() |
589 | + views.env_index(self.app, self.env_type_db, env_db, self.save_callable) |
590 | + self.assertEqual( |
591 | + 'Select the Juju environment you want to use', |
592 | + self.app.get_title()) |
593 | + |
594 | + def test_view_title_no_environments(self): |
595 | + # The application title changes if the env_db has no environments. |
596 | + env_db = {'environments': {}} |
597 | + views.env_index(self.app, self.env_type_db, env_db, self.save_callable) |
598 | + self.assertEqual( |
599 | + 'No Juju environments already set up: please create one', |
600 | + self.app.get_title()) |
601 | + |
602 | + @mock.patch('quickstart.cli.views.env_detail') |
603 | + def test_view_contents(self, mock_env_detail): |
604 | + # The view displays a list of the environments in env_db. |
605 | + env_db = helpers.make_env_db() |
606 | + views.env_index(self.app, self.env_type_db, env_db, self.save_callable) |
607 | + buttons = self.get_widgets_in_contents( |
608 | + filter_function=self.is_a(ui.MenuButton)) |
609 | + # The environments are listed in alphabetical order. |
610 | + environments = sorted(env_db['environments']) |
611 | + # A button is created for each environment. |
612 | + self.assertEqual(len(environments), len(buttons)) |
613 | + for env_name, button in zip(environments, buttons): |
614 | + env_data = envs.get_env_data(env_db, env_name) |
615 | + # The caption includes the environment description. |
616 | + env_description = envs.get_env_short_description(env_data) |
617 | + self.assertIn(env_description, self.get_button_caption(button)) |
618 | + # When the button is clicked, the detail view is called passing the |
619 | + # corresponding environment data. |
620 | + self.emit(button) |
621 | + mock_env_detail.assert_called_once_with( |
622 | + self.app, self.env_type_db, env_db, self.save_callable, |
623 | + env_data) |
624 | + # Reset the mock so that it does not include any calls on the next |
625 | + # loop cycle. |
626 | + mock_env_detail.reset_mock() |
627 | + |
628 | + def test_selected_environment(self): |
629 | + # The default environment is already selected in the list. |
630 | + env_db = helpers.make_env_db(default='lxc') |
631 | + views.env_index(self.app, self.env_type_db, env_db, self.save_callable) |
632 | + env_data = envs.get_env_data(env_db, 'lxc') |
633 | + env_description = envs.get_env_short_description(env_data) |
634 | + contents = self.app.get_contents() |
635 | + focused_widget = contents.get_focus()[0] |
636 | + self.assertIsInstance(focused_widget, ui.MenuButton) |
637 | + self.assertIn(env_description, self.get_button_caption(focused_widget)) |
638 | + |
639 | + def test_status_with_errors(self): |
640 | + # The status message explains how errors are displayed. |
641 | + env_db = helpers.make_env_db() |
642 | + views.env_index(self.app, self.env_type_db, env_db, self.save_callable) |
643 | + status = self.app.get_status() |
644 | + self.assertEqual(' \N{BULLET} has errors ', status) |
645 | + |
646 | + def test_status_with_default(self): |
647 | + # The status message explains how default environment is represented. |
648 | + env_db = helpers.make_env_db(default='lxc', exclude_invalid=True) |
649 | + views.env_index(self.app, self.env_type_db, env_db, self.save_callable) |
650 | + status = self.app.get_status() |
651 | + self.assertEqual(' \N{CHECK MARK} default ', status) |
652 | + |
653 | + def test_status_with_default_and_errors(self): |
654 | + # The status message includes both default and errors explanations. |
655 | + env_db = helpers.make_env_db(default='lxc') |
656 | + views.env_index(self.app, self.env_type_db, env_db, self.save_callable) |
657 | + status = self.app.get_status() |
658 | + self.assertEqual( |
659 | + ' \N{CHECK MARK} default \N{BULLET} has errors ', status) |
660 | + |
661 | + def test_empty_status(self): |
662 | + # The status message is empty if there are no errors. |
663 | + env_db = helpers.make_env_db(exclude_invalid=True) |
664 | + views.env_index(self.app, self.env_type_db, env_db, self.save_callable) |
665 | + status = self.app.get_status() |
666 | + self.assertEqual('', status) |
667 | + |
668 | + |
669 | +class TestEnvDetail(EnvViewTestsMixin, unittest.TestCase): |
670 | + |
671 | + env_db = helpers.make_env_db(default='lxc') |
672 | + |
673 | + def call_view(self, env_name='lxc'): |
674 | + """Call the view passing the env_data corresponding to env_name.""" |
675 | + self.env_data = envs.get_env_data(self.env_db, env_name) |
676 | + return views.env_detail( |
677 | + self.app, self.env_type_db, self.env_db, self.save_callable, |
678 | + self.env_data) |
679 | + |
680 | + def test_view_return_value(self): |
681 | + # The view returns a tuple including a copy of the given env_db and |
682 | + # None, the latter meaning no environment has been selected (for now). |
683 | + new_env_db, env_data = self.call_view() |
684 | + self.assertEqual(self.env_db, new_env_db) |
685 | + self.assertIsNot(self.env_db, new_env_db) |
686 | + self.assertIsNone(env_data) |
687 | + |
688 | + def test_view_title(self): |
689 | + # The application title is correctly set up: it shows the description |
690 | + # of the current environment. |
691 | + self.call_view() |
692 | + env_description = envs.get_env_short_description(self.env_data) |
693 | + self.assertEqual(env_description, self.app.get_title()) |
694 | + |
695 | + def test_view_contents(self): |
696 | + # The view displays a list of the environment fields. |
697 | + self.call_view() |
698 | + widgets = self.get_widgets_in_contents( |
699 | + filter_function=self.is_a(urwid.Text)) |
700 | + env_metadata = envs.get_env_metadata(self.env_type_db, self.env_data) |
701 | + expected_texts = [ |
702 | + '{}: {}'.format(field.name, field.display(value)) for field, value |
703 | + in envs.map_fields_to_env_data(env_metadata, self.env_data) |
704 | + if field.required or (value is not None) |
705 | + ] |
706 | + for expected_text, widget in zip(expected_texts, widgets): |
707 | + self.assertEqual(expected_text, widget.text) |
708 | + |
709 | + def test_view_buttons(self): |
710 | + # The following buttons are displayed: "back", "use" and "set default". |
711 | + self.call_view(env_name='ec2-west') |
712 | + buttons = self.get_control_buttons() |
713 | + captions = [self.get_button_caption(button) for button in buttons] |
714 | + self.assertEqual(['back', 'use', 'set default'], captions) |
715 | + |
716 | + def test_view_buttons_default(self): |
717 | + # If the environment is the default one, the "set default" button is |
718 | + # not displayed. The buttons we expect are "back" and "use". |
719 | + self.call_view(env_name='lxc') |
720 | + buttons = self.get_control_buttons() |
721 | + captions = [self.get_button_caption(button) for button in buttons] |
722 | + self.assertEqual(['back', 'use'], captions) |
723 | + |
724 | + def test_view_buttons_error(self): |
725 | + # If the environment is not valid, the "use" button is not displayed. |
726 | + # The buttons we expect are "back" and "set default". |
727 | + self.call_view(env_name='local-with-errors') |
728 | + buttons = self.get_control_buttons() |
729 | + captions = [self.get_button_caption(button) for button in buttons] |
730 | + self.assertEqual(['back', 'set default'], captions) |
731 | + |
732 | + @mock.patch('quickstart.cli.views.env_index') |
733 | + def test_back_button(self, mock_env_index): |
734 | + # The index view is called if the "back" button is clicked. |
735 | + self.call_view(env_name='ec2-west') |
736 | + # The control buttons are: "back", "use" and "set default". |
737 | + back_button = self.get_control_buttons()[0] |
738 | + self.emit(back_button) |
739 | + mock_env_index.assert_called_once_with( |
740 | + self.app, self.env_type_db, self.env_db, self.save_callable) |
741 | + |
742 | + def test_use_button(self): |
743 | + # The application exits if the "use" button is clicked. |
744 | + # The env_db and the current environment data are returned. |
745 | + self.call_view(env_name='ec2-west') |
746 | + # The control buttons are: "back", "use" and "set default". |
747 | + use_button = self.get_control_buttons()[1] |
748 | + with self.assertRaises(ui.AppExit) as context_manager: |
749 | + self.emit(use_button) |
750 | + expected_return_value = (self.env_db, self.env_data) |
751 | + self.assertEqual( |
752 | + expected_return_value, context_manager.exception.return_value) |
753 | + |
754 | + @mock.patch('quickstart.cli.views.env_index') |
755 | + def test_set_default_button(self, mock_env_index): |
756 | + # The current environment is set as default if the "set default" button |
757 | + # is clicked. Subsequently the application switches to the index view. |
758 | + self.call_view(env_name='ec2-west') |
759 | + # The control buttons are: "back", "use" and "set default". |
760 | + set_default_button = self.get_control_buttons()[2] |
761 | + self.emit(set_default_button) |
762 | + # The index view has been called passing the modified env_db as third |
763 | + # argument. |
764 | + self.assertTrue(mock_env_index.called) |
765 | + new_env_db = mock_env_index.call_args[0][2] |
766 | + # The new env_db has a new default. |
767 | + self.assertEqual(new_env_db['default'], 'ec2-west') |
768 | + # The new env_db has been saved. |
769 | + self.save_callable.assert_called_once_with(new_env_db) |
770 | + |
771 | + def test_status_with_errors(self): |
772 | + # The status message explains how field errors are displayed. |
773 | + self.call_view(env_name='local-with-errors') |
774 | + status = self.app.get_status() |
775 | + self.assertEqual(' \N{LOWER SEVEN EIGHTHS BLOCK} field error ', status) |
776 | + |
777 | + def test_status_without_errors(self): |
778 | + # The status message is empty if there are no errors. |
779 | + self.call_view(env_name='lxc') |
780 | + status = self.app.get_status() |
781 | + self.assertEqual('', status) |
782 | |
783 | === modified file 'quickstart/tests/helpers.py' |
784 | --- quickstart/tests/helpers.py 2013-12-09 13:57:17 +0000 |
785 | +++ quickstart/tests/helpers.py 2013-12-19 09:30:30 +0000 |
786 | @@ -130,6 +130,61 @@ |
787 | return env_file.name |
788 | |
789 | |
790 | +def make_env_db(default=None, exclude_invalid=False): |
791 | + """Create and return an env_db. |
792 | + |
793 | + The default argument can be used to specify a default environment. |
794 | + If exclude_invalid is set to True, the resulting env_db only includes |
795 | + valid environments. |
796 | + """ |
797 | + environments = { |
798 | + 'ec2-west': { |
799 | + 'type': 'ec2', |
800 | + 'admin-secret': 'adm-307c4a53bd174c1a89e933e1e8dc8131', |
801 | + 'control-bucket': 'con-aa2c6618b02d448ca7fd0f280ef66cba', |
802 | + 'region': u'us-west-1', |
803 | + 'access-key': 'hash', |
804 | + 'secret-key': 'Secret!', |
805 | + }, |
806 | + 'lxc': { |
807 | + 'admin-secret': 'bones', |
808 | + 'default-series': 'raring', |
809 | + 'storage-port': 8888, |
810 | + 'type': 'local', |
811 | + }, |
812 | + 'test-encoding': { |
813 | + 'access-key': '\xe0\xe8\xec\xf2\xf9', |
814 | + 'secret-key': '\xe0\xe8\xec\xf2\xf9', |
815 | + 'admin-secret': '\u2622\u2622\u2622\u2622', |
816 | + 'control-bucket': '\u2746 winter-bucket \u2746', |
817 | + 'juju-origin': '\u2606 the outer space \u2606', |
818 | + 'type': 'toxic \u2622 type', |
819 | + }, |
820 | + } |
821 | + if not exclude_invalid: |
822 | + environments.update({ |
823 | + 'local-with-errors': { |
824 | + 'admin-secret': '', |
825 | + 'storage-port': 'this-should-be-an-int', |
826 | + 'type': 'local', |
827 | + }, |
828 | + 'private-cloud-errors': { |
829 | + 'admin-secret': 'Secret!', |
830 | + 'auth-url': 'https://keystone.example.com:443/v2.0/', |
831 | + 'authorized-keys-path': '/home/frankban/.ssh/juju-rsa.pub', |
832 | + 'control-bucket': 'e3d48007292c9abba499d96a577ceab891d320fe', |
833 | + 'default-image-id': 'bb636e4f-79d7-4d6b-b13b-c7d53419fd5a', |
834 | + 'default-instance-type': 'm1.medium', |
835 | + 'default-series': 'no-such', |
836 | + 'type': 'openstack', |
837 | + }, |
838 | + }) |
839 | + env_db = {'environments': environments} |
840 | + if default is not None: |
841 | + env_db['default'] = default |
842 | + return env_db |
843 | + |
844 | + |
845 | # Mock the builtin print function. |
846 | mock_print = mock.patch('__builtin__.print') |
847 | |
848 | |
849 | === modified file 'quickstart/tests/test_utils.py' |
850 | --- quickstart/tests/test_utils.py 2013-12-19 03:32:49 +0000 |
851 | +++ quickstart/tests/test_utils.py 2013-12-19 09:30:30 +0000 |
852 | @@ -378,31 +378,6 @@ |
853 | mock_call.assert_called_once_with('lsb_release', '-cs') |
854 | |
855 | |
856 | -class TestObjectDict(unittest.TestCase): |
857 | - |
858 | - def setUp(self): |
859 | - self.object_dict = utils.ObjectDict(mykey='myvalue') |
860 | - |
861 | - def test_get_attribute(self): |
862 | - # A value can be retrieved accessing the key as an attribute. |
863 | - self.assertEqual('myvalue', self.object_dict.mykey) |
864 | - |
865 | - def test_get_attribute_error(self): |
866 | - # An AttributeError is raised if the key is not in the dictionary. |
867 | - with self.assertRaises(AttributeError): |
868 | - self.object_dict.no_such_key |
869 | - |
870 | - def test_set_attribute(self): |
871 | - # A key can be added by setting the corresponding attribute. |
872 | - self.object_dict.another_key = 42 |
873 | - self.assertEqual(42, self.object_dict['another_key']) |
874 | - |
875 | - def test_update_attribute(self): |
876 | - # It is possible to update an attribute. |
877 | - self.object_dict.mykey = 47 |
878 | - self.assertEqual(47, self.object_dict.mykey) |
879 | - |
880 | - |
881 | class TestParseBundle( |
882 | helpers.BundleFileTestsMixin, helpers.ValueErrorTestsMixin, |
883 | unittest.TestCase): |
884 | |
885 | === modified file 'quickstart/utils.py' |
886 | --- quickstart/utils.py 2013-12-19 03:32:49 +0000 |
887 | +++ quickstart/utils.py 2013-12-19 09:30:30 +0000 |
888 | @@ -14,22 +14,6 @@ |
889 | # You should have received a copy of the GNU Affero General Public License |
890 | # along with this program. If not, see <http://www.gnu.org/licenses/>. |
891 | |
892 | -# The ObjectDict code: |
893 | -# |
894 | -# Copyright 2009 Facebook |
895 | -# |
896 | -# Licensed under the Apache License, Version 2.0 (the "License"); you may |
897 | -# not use this file except in compliance with the License. You may obtain |
898 | -# a copy of the License at |
899 | -# |
900 | -# http://www.apache.org/licenses/LICENSE-2.0 |
901 | -# |
902 | -# Unless required by applicable law or agreed to in writing, software |
903 | -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
904 | -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
905 | -# License for the specific language governing permissions and limitations |
906 | -# under the License. |
907 | - |
908 | """Juju Quickstart utility functions and classes.""" |
909 | |
910 | from __future__ import ( |
911 | @@ -220,23 +204,6 @@ |
912 | return output.strip() |
913 | |
914 | |
915 | -class ObjectDict(dict): |
916 | - """Makes a dictionary behave like an object, with attribute-style access. |
917 | - |
918 | - Original: |
919 | - http://www.tornadoweb.org/en/stable/_modules/tornado/util.html#ObjectDict |
920 | - """ |
921 | - |
922 | - def __getattr__(self, name): |
923 | - try: |
924 | - return self[name] |
925 | - except KeyError: |
926 | - raise AttributeError(name) |
927 | - |
928 | - def __setattr__(self, name, value): |
929 | - self[name] = value |
930 | - |
931 | - |
932 | def parse_bundle(bundle_yaml, bundle_name=None): |
933 | """Parse the provided bundle YAML encoded contents. |
934 |
Reviewers: mp+199484_ code.launchpad. net,
Message:
Please take a look.
Description:
Environment list/selection view.
This branch implements the environment app-demo. py`. This is still not models. envs module.
selection Urwid view. It is possible to
start it running `make` and then
`./cli-
integrated in quickstart, but the demo
file demonstrates how to show the view
passing the required data structures
already implemented in the
quickstart.
To test the branch, run `make check`.
To QA the new functionality, run the
demo application as described above,
and play with the interface: any UX
suggestion is welcome, and will be part
of a card we already have
("envs management: ux improvements").
Other changes:
The Urwid application is no longer an
ObjectDict (removed from the code).
I decided to use a Python namedtuple
which seemed to me a better choice,
considering that views are not supposed
to modify the app data structure.
This way we have both immutability and
attribute access to the object.
Implemented a TimeoutText wrapper used to
wrap the message notification widget.
This wayt he timer is restarted when a
subsequent message is set and the previous
has not yet been removed.
https:/ /code.launchpad .net/~frankban/ juju-quickstart /env-manage- selection- view/+merge/ 199484
(do not edit description out of merge proposal)
Please review this at https:/ /codereview. appspot. com/43860044/
Affected files (+588, -110 lines): cli/base. py cli/ui. py cli/views. py tests/cli/ test_base. py tests/cli/ test_ui. py tests/cli/ test_views. py tests/helpers. py tests/test_ utils.py
A [revision details]
M cli-app-demo.py
M quickstart/
M quickstart/
M quickstart/
M quickstart/
M quickstart/
M quickstart/
M quickstart/
M quickstart/
M quickstart/utils.py