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