Merge lp:~frankban/juju-quickstart/env-manage-selection-view into lp:juju-quickstart

Proposed by Francesco Banconi
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
Reviewer Review Type Date Requested Status
Juju GUI Hackers Pending
Review via email: mp+199484@code.launchpad.net

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-app-demo.py`. This is still not
integrated in quickstart, but the demo
file demonstrates how to show the view
passing the required data structures
already implemented in the
quickstart.models.envs module.

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://codereview.appspot.com/43860044/

To post a comment you must log in.
Revision history for this message
Francesco Banconi (frankban) wrote :

Reviewers: mp+199484_code.launchpad.net,

Message:
Please take a look.

Description:
Environment list/selection view.

This branch implements the environment
selection Urwid view. It is possible to
start it running `make` and then
`./cli-app-demo.py`. This is still not
integrated in quickstart, but the demo
file demonstrates how to show the view
passing the required data structures
already implemented in the
quickstart.models.envs module.

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):
   A [revision details]
   M cli-app-demo.py
   M quickstart/cli/base.py
   M quickstart/cli/ui.py
   M quickstart/cli/views.py
   M quickstart/tests/cli/test_base.py
   M quickstart/tests/cli/test_ui.py
   M quickstart/tests/cli/test_views.py
   M quickstart/tests/helpers.py
   M quickstart/tests/test_utils.py
   M quickstart/utils.py

Revision history for this message
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://codereview.appspot.com/43860044/diff/1/quickstart/cli/base.py
File quickstart/cli/base.py (right):

https://codereview.appspot.com/43860044/diff/1/quickstart/cli/base.py#newcode37
quickstart/cli/base.py:37: _Application = namedtuple(
why the _? Why call it 'App' but also call it _Application? In my
experience I'd just have App = namedtuple('App'...

https://codereview.appspot.com/43860044/diff/1/quickstart/cli/base.py#newcode152
quickstart/cli/base.py:152: # place where to add new API functions,
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://codereview.appspot.com/43860044/diff/1/quickstart/cli/ui.py
File quickstart/cli/ui.py (right):

https://codereview.appspot.com/43860044/diff/1/quickstart/cli/ui.py#newcode49
quickstart/cli/ui.py:49: None: 'selected',
I don't follow this. When None is in focus it's selected?

https://codereview.appspot.com/43860044/diff/1/quickstart/cli/views.py
File quickstart/cli/views.py (right):

https://codereview.appspot.com/43860044/diff/1/quickstart/cli/views.py#newcode32
quickstart/cli/views.py:32: A view is a callable receiving an app object
(a named tuple of functions) and
Can we capitalize the App here and in cases referencing the specific
object?

https://codereview.appspot.com/43860044/

Revision history for this message
Gary Poster (gary) wrote :

LGTM with mostly trivial comments (discussion on possible decoys is the
only thing that ended up being interesting).

https://codereview.appspot.com/43860044/diff/1/quickstart/cli/base.py
File quickstart/cli/base.py (right):

https://codereview.appspot.com/43860044/diff/1/quickstart/cli/base.py#newcode37
quickstart/cli/base.py:37: _Application = namedtuple(
Nice improvement.

https://codereview.appspot.com/43860044/diff/1/quickstart/cli/views.py
File quickstart/cli/views.py (right):

https://codereview.appspot.com/43860044/diff/1/quickstart/cli/views.py#newcode140
quickstart/cli/views.py:140: detail_view = functools.partial(
nice :-)

I toyed with suggesting that you pass env_index to env_detail, and vice
versa, but naah for now.

https://codereview.appspot.com/43860044/diff/1/quickstart/cli/views.py#newcode144
quickstart/cli/views.py:144: envs.get_env_data(env_db, env_name)
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://codereview.appspot.com/43860044/diff/1/quickstart/cli/views.py#newcode180
quickstart/cli/views.py:180: return env_db, None
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://codereview.appspot.com/43860044/diff/1/quickstart/cli/views.py#newcode244
quickstart/cli/views.py:244: return env_db, None
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?

https://codereview.appspot.com/43860044/

Revision history for this message
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://www.fileformat.info/info/unicode/char/2713/index.htm

or something?

<3 the unicode toxic type.

https://codereview.appspot.com/43860044/

Revision history for this message
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!

https://codereview.appspot.com/43860044/

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.

Revision history for this message
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://www.fileformat.info/info/unicode/char/2713/index.htm

> or something?

Done as part of this branch, thank you for this suggestion.

> <3 the unicode toxic type.

:-)

https://codereview.appspot.com/43860044/

Revision history for this message
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!

https://codereview.appspot.com/43860044/

Revision history for this message
Francesco Banconi (frankban) wrote :
Download full text (3.4 KiB)

*** Submitted:

Environment list/selection view.

This branch implements the environment
selection Urwid view. It is possible to
start it running `make` and then
`./cli-app-demo.py`. This is still not
integrated in quickstart, but the demo
file demonstrates how to show the view
passing the required data structures
already implemented in the
quickstart.models.envs module.

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://codereview.appspot.com/43860044

https://codereview.appspot.com/43860044/diff/1/quickstart/cli/base.py
File quickstart/cli/base.py (right):

https://codereview.appspot.com/43860044/diff/1/quickstart/cli/base.py#newcode37
quickstart/cli/base.py:37: _Application = namedtuple(
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://codereview.appspot.com/43860044/diff/1/quickstart/cli/base.py#newcode152
quickstart/cli/base.py:152: # place where to add new API functions,
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://codereview.appspot.com/43860044/diff/1/quickstart/cli/ui.py
File quickstart/cli/ui.py (right):

https://codereview.appspot.com/43860044/diff/1/quickstart/cli/ui.py#newcode49
quickstart/cli/ui.py:49: None: 'selected',
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://codereview.appspot.com/43860044/diff/1/quickstart/cli/views.py
File quickstart/cli/views.py (right):

https://codereview.appspot.com/43860044/diff/1/quickstart/cli/views.py#newcode32
quickstart/cli/views.py:32: A view is a callable receiving an app object
(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://codereview.appspot.com/43860044/diff/1/quickstart/cli/views.py#newcode180
quickstart/cli/views.py:180: return env_db, None
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
> ...

Read more...

Revision history for this message
Francesco Banconi (frankban) wrote :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
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

Subscribers

People subscribed via source and target branches