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
=== modified file 'cli-app-demo.py'
--- cli-app-demo.py 2013-12-16 16:38:20 +0000
+++ cli-app-demo.py 2013-12-19 09:30:30 +0000
@@ -27,26 +27,28 @@
27 unicode_literals,27 unicode_literals,
28)28)
2929
30import urwid
31
32from quickstart.cli import views30from quickstart.cli import views
3331from quickstart.models import envs
3432from quickstart.tests import helpers
35def example_view(app, message):33
36 """An example Quickstart view."""34
37 app.set_title('This is the title')35def main():
38 widgets = [36 """Start the Quickstart environments list view."""
39 urwid.Text('These are the app contents. Press ^X to exit.'),37 env_type_db = envs.get_env_type_db()
40 urwid.Divider(),38 # This simulates an env database as returned by envs.load().
41 urwid.Text('Message: {}'.format(message)),39 env_db = helpers.make_env_db(default='lxc')
42 ]40 save_callable = lambda db: None
43 contents = urwid.ListBox(urwid.SimpleFocusListWalker(widgets))41 # Show the view.
44 app.set_contents(contents)42 new_env_db, env_data = views.show(
45 app.set_status(('status error', '\u2622 Look! A toxic status! \u2622'))43 views.env_index, env_type_db, env_db, save_callable)
46 app.set_message('this notification will disappear in three seconds')44 if new_env_db != env_db:
47 return 'Goodbye, world'45 print('saved a new env db')
46 print('default: {}'.format(new_env_db.get('default')))
47 if env_data is None:
48 print('no environment selected')
49 else:
50 print('selected: {}'.format(env_data['name']))
4851
4952
50if __name__ == '__main__':53if __name__ == '__main__':
51 return_value = views.show(example_view, 'Hello, world')54 main()
52 print(return_value)
5355
=== modified file 'quickstart/cli/base.py'
--- quickstart/cli/base.py 2013-12-17 15:23:28 +0000
+++ quickstart/cli/base.py 2013-12-19 09:30:30 +0000
@@ -25,13 +25,23 @@
2525
26from __future__ import unicode_literals26from __future__ import unicode_literals
2727
28from collections import namedtuple
29
28import urwid30import urwid
2931
30from quickstart import (32from quickstart import get_version
31 get_version,33from quickstart.cli import ui
32 utils,34
35
36# Define the application as a named tuple of callables.
37App = namedtuple(
38 'App', [
39 'set_title', 'get_title',
40 'set_contents', 'get_contents',
41 'set_status', 'get_status',
42 'set_message', 'get_message',
43 ],
33)44)
34from quickstart.cli import ui
3545
3646
37class _MainLoop(urwid.MainLoop):47class _MainLoop(urwid.MainLoop):
@@ -69,8 +79,8 @@
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.
7080
71 Return a tuple (loop, app) where loop is the interactive session main loop81 Return a tuple (loop, app) where loop is the interactive session main loop
72 (ready to be started invoking loop.run()) and app is an ObjectDict exposing82 (ready to be started invoking loop.run()) and app is a named tuple of
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.
7484
75 The API exposed by app is limited by design, and includes:85 The API exposed by app is limited by design, and includes:
7686
@@ -134,22 +144,20 @@
134 min_width=78, min_height=20)144 min_width=78, min_height=20)
135 # Instantiate the Urwid main loop.145 # Instantiate the Urwid main loop.
136 loop = _MainLoop(top_widget, palette=ui.PALETTE)146 loop = _MainLoop(top_widget, palette=ui.PALETTE)
137147 # Add a timeout to the notification message.
138 def set_message(msg):148 timeout_message = ui.TimeoutText(
139 message.set_text(('message', msg))149 message, 3, loop.set_alarm_in, loop.remove_alarm)
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
141151 # requires additional capabilities or API access, this is the place to add
142 # Create the app ObjectDict. If in the future we will find a view to152 # those.
143 # require more capabilities/API access than the current one, this is the153 app = App(
144 # place where to add new API functions.
145 app = utils.ObjectDict(
146 set_title=lambda msg: title.set_text('\n{}'.format(msg)),154 set_title=lambda msg: title.set_text('\n{}'.format(msg)),
147 get_title=lambda: title.text.lstrip(),155 get_title=lambda: title.text.lstrip(),
148 set_contents=set_contents,156 set_contents=set_contents,
149 get_contents=lambda: contents.original_widget,157 get_contents=lambda: contents.original_widget,
150 set_status=lambda msg: status.set_text(msg),158 set_status=lambda msg: status.set_text(msg),
151 get_status=lambda: status.text,159 get_status=lambda: status.text,
152 set_message=set_message,160 set_message=lambda msg: timeout_message.set_text(('message', msg)),
153 get_message=lambda: message.text,161 get_message=lambda: timeout_message.text,
154 )162 )
155 return loop, app163 return loop, app
156164
=== modified file 'quickstart/cli/ui.py'
--- quickstart/cli/ui.py 2013-12-17 15:23:28 +0000
+++ quickstart/cli/ui.py 2013-12-19 09:30:30 +0000
@@ -34,7 +34,8 @@
34 ('controls', 'dark gray', 'light gray'),34 ('controls', 'dark gray', 'light gray'),
35 ('edit', 'white,underline', 'black'),35 ('edit', 'white,underline', 'black'),
36 ('error', 'light red', 'black'),36 ('error', 'light red', 'black'),
37 ('status error', 'light red', 'light gray'),37 ('error status', 'light red', 'light gray'),
38 ('error selected', 'light red', 'dark blue'),
38 ('footer', 'black', 'light gray'),39 ('footer', 'black', 'light gray'),
39 ('message', 'white', 'dark green'),40 ('message', 'white', 'dark green'),
40 ('header', 'white', 'dark magenta'),41 ('header', 'white', 'dark magenta'),
@@ -43,6 +44,11 @@
43 ('line footer', 'light gray', 'light gray'),44 ('line footer', 'light gray', 'light gray'),
44 ('selected', 'white', 'dark blue'),45 ('selected', 'white', 'dark blue'),
45]46]
47# Map attributes to new attributes to apply when the widget is selected.
48FOCUS_MAP = {
49 None: 'selected',
50 'error': 'error selected',
51}
46# Define a default padding for the Urwid application.52# Define a default padding for the Urwid application.
47padding = functools.partial(urwid.Padding, left=2, right=2)53padding = functools.partial(urwid.Padding, left=2, right=2)
4854
@@ -91,7 +97,7 @@
91 urwid.connect_signal(self, 'click', callback)97 urwid.connect_signal(self, 'click', callback)
92 icon = urwid.SelectableIcon(caption, 0)98 icon = urwid.SelectableIcon(caption, 0)
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.
94 self._w = urwid.AttrMap(icon, None, 'selected')100 self._w = urwid.AttrMap(icon, None, FOCUS_MAP)
95101
96102
97def thunk(function, *args, **kwargs):103def thunk(function, *args, **kwargs):
@@ -116,3 +122,48 @@
116 def callback(*ignored_args, **ignored_kwargs):122 def callback(*ignored_args, **ignored_kwargs):
117 return function(*args, **kwargs)123 return function(*args, **kwargs)
118 return callback124 return callback
125
126
127class TimeoutText(object):
128 """Wrap urwid.Text widget instances.
129
130 The resulting widget, when set_text is called, displays text messages only
131 for the given number of seconds.
132 """
133
134 def __init__(self, widget, seconds, set_alarm, remove_alarm):
135 """Create the wrapper widget.
136
137 Receives the text widget to be wrapped, the number of seconds before
138 the message disappears, the functions used to set and to remove an
139 alarm on the loop (usually loop.set_alarm_in and loop.remove_alarm).
140 """
141 self.original_widget = widget
142 self.seconds = seconds
143 self._set_alarm = set_alarm
144 self._remove_alarm = remove_alarm
145 self._handle = None
146
147 def __getattr__(self, attr):
148 """Allow access to the original widget's attributes."""
149 return getattr(self.original_widget, attr)
150
151 def set_text(self, text):
152 """Set the text message on the original widget.
153
154 Set up an alert that will clear the message after the given number of
155 seconds. Remove any previously set alarms if required.
156 """
157 handle = self._handle
158 if handle is not None:
159 self._remove_alarm(handle)
160 self.original_widget.set_text(text)
161 self._handle = self._set_alarm(self.seconds, self._alarm_callback)
162
163 def _alarm_callback(self, *args):
164 """Remove the message from the original widget.
165
166 This method is called by the alarm set up in self.set_text().
167 """
168 self.original_widget.set_text('')
169 self._handle = None
119170
=== modified file 'quickstart/cli/views.py'
--- quickstart/cli/views.py 2013-12-17 15:23:28 +0000
+++ quickstart/cli/views.py 2013-12-19 09:30:30 +0000
@@ -29,9 +29,9 @@
29started, and the show function blocks until the user or the view itself29started, and the show function blocks until the user or the view itself
30request to exit the application.30request to exit the application.
3131
32A view is a callable receiving an app ObjectDict and other optional arguments32A view is a callable receiving an App object (a named tuple of functions) and
33(based on specific view needs). A view function can configure the Urwid33other optional arguments (based on specific view needs). A view function can
34application using the API exposed by the application object34configure the Urwid application using the API exposed by the application object
35(see quickstart.cli.base.setup_urwid_app).35(see quickstart.cli.base.setup_urwid_app).
3636
37Assume a view is defined like the following:37Assume a view is defined like the following:
@@ -79,29 +79,36 @@
7979
80 pressed = views.show(button_view)80 pressed = views.show(button_view)
8181
82In this example the button_view function configures the app to show a button.82In this example the button_view function configures the App to show a button.
83Clicking that button an AppExit(True) is raised. The view itself instead just83Clicking that button an AppExit(True) is raised. The view itself instead just
84returns False. This means that "pressed" will be True if the user exited using84returns False. This means that "pressed" will be True if the user exited using
85the button, or False if the user exited using the global shortcut.85the button, or False if the user exited using the global shortcut.
8686
87As a final note, it is absolutely safe for a view to call, directly or87As a final note, it is absolutely safe for a view to call, directly or
88indirectly, other views, as long as all the arguments required by the other88indirectly, other views, as long as all the arguments required by the other
89views, including app, are properly provided. This is effectively the proposed89views, including the App object, are properly provided. This is effectively the
90solution to build multi-views CLI applications in Quickstart.90proposed solution to build multi-views CLI applications in Quickstart.
91"""91"""
9292
93from __future__ import unicode_literals93from __future__ import unicode_literals
9494
95import copy
96import functools
97import operator
98
99import urwid
100
95from quickstart.cli import (101from quickstart.cli import (
96 base,102 base,
97 ui,103 ui,
98)104)
105from quickstart.models import envs
99106
100107
101def show(view, *args):108def show(view, *args):
102 """Start an Urwid interactive session showing the given view.109 """Start an Urwid interactive session showing the given view.
103110
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.
105112
106 Block until the main loop is stopped, either by the user with the exit113 Block until the main loop is stopped, either by the user with the exit
107 shortcut or by the view itself with the AppExit exception. In the former114 shortcut or by the view itself with the AppExit exception. In the former
@@ -117,3 +124,130 @@
117 loop.run()124 loop.run()
118 except ui.AppExit as err:125 except ui.AppExit as err:
119 return err.return_value126 return err.return_value
127
128
129def env_index(app, env_type_db, env_db, save_callable):
130 """Show the Juju environments list.
131
132 The env_detail view is displayed when the user clicks on an environment.
133
134 Receives:
135 - env_type_db: the environments meta information;
136 - env_db: the environments database;
137 - save_callable: a function called to save a new environment database.
138 """
139 env_db = copy.deepcopy(env_db)
140 detail_view = functools.partial(
141 env_detail, app, env_type_db, env_db, save_callable)
142 # Alphabetically sort the existing environments.
143 environments = sorted([
144 envs.get_env_data(env_db, env_name)
145 for env_name in env_db['environments']
146 ], key=operator.itemgetter('name'))
147 if environments:
148 title = 'Select the Juju environment you want to use'
149 else:
150 # XXX frankban 2013-12-18: implement the env creation view.
151 title = 'No Juju environments already set up: please create one'
152 app.set_title(title)
153 app.set_status('')
154 # Start creating the page contents: a list of selectable environments.
155 widgets = []
156 focus_position = None
157 errors_found = default_found = False
158 for position, env_data in enumerate(environments):
159 bullet = '\N{BULLET}'
160 # Is this environment the default one?
161 if env_data['is-default']:
162 default_found = True
163 focus_position = position
164 bullet = '\N{CHECK MARK}'
165 # Is this environment valid?
166 env_metadata = envs.get_env_metadata(env_type_db, env_data)
167 errors = envs.validate(env_metadata, env_data)
168 if errors:
169 errors_found = True
170 bullet = ('error', bullet)
171 # Create a label for the environment.
172 env_short_description = envs.get_env_short_description(env_data)
173 text = [bullet, ' {}'.format(env_short_description)]
174 widgets.append(ui.MenuButton(text, ui.thunk(detail_view, env_data)))
175 widgets.append(urwid.Divider())
176 contents = urwid.ListBox(urwid.SimpleFocusListWalker(widgets))
177 if focus_position is not None:
178 contents.set_focus(focus_position)
179 # Set up the application status messages.
180 status = []
181 if default_found:
182 status.append(' \N{CHECK MARK} default ')
183 if errors_found:
184 status.extend([('error status', ' \N{BULLET}'), ' has errors '])
185 if status:
186 app.set_status(status)
187 app.set_contents(contents)
188 return env_db, None
189
190
191def env_detail(app, env_type_db, env_db, save_callable, env_data):
192 """Show details on a Juju environment.
193
194 From this view it is possible to start the environment, set it as default,
195 edit/remove the environment.
196
197 Receives:
198 - env_type_db: the environments meta information;
199 - env_db: the environments database;
200 - save_callable: a function called to save a new environment database.
201 - env_data: the environment data.
202 """
203 env_db = copy.deepcopy(env_db)
204 index_view = functools.partial(
205 env_index, app, env_type_db, env_db, save_callable)
206
207 def use(env_data):
208 # Quit the interactive session returning the (possibly modified)
209 # environment database and the environment data corresponding to the
210 # selected environment.
211 raise ui.AppExit((env_db, env_data))
212
213 def set_default(env_data):
214 # Set this environment as the default one, save the env_db and return
215 # to the index view.
216 env_name = env_data['name']
217 env_db['default'] = env_name
218 save_callable(env_db)
219 app.set_message('{} successfully set as default'.format(env_name))
220 index_view()
221
222 env_metadata = envs.get_env_metadata(env_type_db, env_data)
223 app.set_title(envs.get_env_short_description(env_data))
224 app.set_status('')
225 # Validate the environment.
226 errors = envs.validate(env_metadata, env_data)
227 widgets = []
228 field_value_pairs = envs.map_fields_to_env_data(env_metadata, env_data)
229 for field, value in field_value_pairs:
230 if field.required or (value is not None):
231 label = '{}: '.format(field.name)
232 if field.name in errors:
233 label = ('error', label)
234 text = [label, ('highlight', field.display(value))]
235 widgets.append(urwid.Text(text))
236 controls = [ui.MenuButton('back', ui.thunk(index_view))]
237 if errors:
238 app.set_status([
239 ('error status', ' \N{LOWER SEVEN EIGHTHS BLOCK}'),
240 ' field error ',
241 ])
242 else:
243 # Without errors, it is possible to use/start this environment.
244 controls.append(ui.MenuButton('use', ui.thunk(use, env_data)))
245 if not env_data['is-default']:
246 controls.append(
247 ui.MenuButton('set default', ui.thunk(set_default, env_data)))
248 # XXX frankban 2013-12-18: implement the "remove env" functionality.
249 # XXX frankban 2013-12-18: implement the env modification view.
250 widgets.append(ui.create_controls(*controls))
251 listbox = urwid.ListBox(urwid.SimpleFocusListWalker(widgets))
252 app.set_contents(listbox)
253 return env_db, None
120254
=== modified file 'quickstart/tests/cli/test_base.py'
--- quickstart/tests/cli/test_base.py 2013-12-17 10:37:13 +0000
+++ quickstart/tests/cli/test_base.py 2013-12-19 09:30:30 +0000
@@ -22,7 +22,6 @@
2222
23import urwid23import urwid
2424
25from quickstart import utils
26from quickstart.cli import base25from quickstart.cli import base
2726
2827
@@ -107,15 +106,8 @@
107 self.assertIsInstance(self.loop, base._MainLoop)106 self.assertIsInstance(self.loop, base._MainLoop)
108107
109 def test_app(self):108 def test_app(self):
110 # The returned app is an ObjectDict including the expected keys.109 # The returned app is the application named tuple
111 expected_keys = set([110 self.assertIsInstance(self.app, base.App)
112 'set_title', 'get_title',
113 'set_contents', 'get_contents',
114 'set_status', 'get_status',
115 'set_message', 'get_message',
116 ])
117 self.assertIsInstance(self.app, utils.ObjectDict)
118 self.assertEqual(expected_keys, set(self.app.keys()))
119111
120 def test_set_title(self):112 def test_set_title(self):
121 # The set_title API sets the application title.113 # The set_title API sets the application title.
122114
=== modified file 'quickstart/tests/cli/test_ui.py'
--- quickstart/tests/cli/test_ui.py 2013-12-17 15:23:28 +0000
+++ quickstart/tests/cli/test_ui.py 2013-12-19 09:30:30 +0000
@@ -23,7 +23,10 @@
23import mock23import mock
24import urwid24import urwid
2525
26from quickstart.cli import ui26from quickstart.cli import (
27 base,
28 ui,
29)
2730
2831
29class TestAppExit(unittest.TestCase):32class TestAppExit(unittest.TestCase):
@@ -115,3 +118,48 @@
115 sqr = lambda value: value * value118 sqr = lambda value: value * value
116 thunk_function = ui.thunk(sqr, 3)119 thunk_function = ui.thunk(sqr, 3)
117 self.assertEqual(9, thunk_function(self.widget))120 self.assertEqual(9, thunk_function(self.widget))
121
122
123class TestTimeoutText(unittest.TestCase):
124
125 def setUp(self):
126 # Set up a timeout text widget.
127 self.original_widget = urwid.Text('original contents')
128 self.loop = base._MainLoop(None)
129 self.wrapper = ui.TimeoutText(
130 self.original_widget, 42,
131 self.loop.set_alarm_in, self.loop.remove_alarm)
132
133 def test_attributes(self):
134 # The original widget and the timeout seconds are accessible from the
135 # wrapper.
136 self.assertEqual(self.original_widget, self.wrapper.original_widget)
137 self.assertEqual(42, self.wrapper.seconds)
138
139 def test_original_attributes(self):
140 # The original widget attributes can be accessed from the wrapper.
141 self.assertEqual('original contents', self.wrapper.text)
142 self.assertEqual(('original contents', []), self.wrapper.get_text())
143
144 def test_set_timeout(self):
145 # When setting text on a timeout text widget, am alarm is set up. The
146 # alarm clears the text after the given number of seconds.
147 self.wrapper.set_text('this will disappear')
148 self.assertEqual('this will disappear', self.wrapper.text)
149 alarms = self.loop.get_alarms()
150 self.assertEqual(1, len(alarms))
151 # Calling the callback makes the message go away.
152 _, callback = alarms[0]
153 callback()
154 self.assertEqual('', self.wrapper.text)
155
156 def test_update_timeout(self):
157 # The alarm is updated when setting text multiple time.
158 self.wrapper.set_text('this will disappear')
159 timeout, _ = self.loop.get_alarms()[0]
160 self.wrapper.set_text('and this too')
161 alarms = self.loop.get_alarms()
162 self.assertEqual(1, len(alarms))
163 new_timeout, _ = alarms[0]
164 # The new timeout is more far away in the future.
165 self.assertGreater(new_timeout, timeout)
118166
=== modified file 'quickstart/tests/cli/test_views.py'
--- quickstart/tests/cli/test_views.py 2013-12-17 09:33:13 +0000
+++ quickstart/tests/cli/test_views.py 2013-12-19 09:30:30 +0000
@@ -22,11 +22,15 @@
22import unittest22import unittest
2323
24import mock24import mock
25import urwid
2526
26from quickstart.cli import (27from quickstart.cli import (
28 base,
27 ui,29 ui,
28 views,30 views,
29)31)
32from quickstart.models import envs
33from quickstart.tests import helpers
3034
3135
32class TestShow(unittest.TestCase):36class TestShow(unittest.TestCase):
@@ -84,3 +88,266 @@
84 with self.patch_setup_urwid_app() as (mock_loop, mock_app):88 with self.patch_setup_urwid_app() as (mock_loop, mock_app):
85 views.show(view, 'arg1', 'arg2')89 views.show(view, 'arg1', 'arg2')
86 view.assert_called_once_with(mock_app, 'arg1', 'arg2')90 view.assert_called_once_with(mock_app, 'arg1', 'arg2')
91
92
93class EnvViewTestsMixin(object):
94 """Shared helpers for testing environment views."""
95
96 env_type_db = envs.get_env_type_db()
97
98 def setUp(self):
99 # Set up the base Urwid application.
100 self.loop, self.app = base.setup_urwid_app()
101 self.save_callable = mock.Mock()
102
103 def get_widgets_in_contents(self, filter_function=None):
104 """Return a list of widgets included in the app contents.
105
106 Use the filter_function argument to filter the returned list.
107 """
108 contents = self.app.get_contents()
109 return filter(filter_function, list(contents.body))
110
111 def get_control_buttons(self):
112 """Return the list of buttons included in a control box.
113
114 Control boxes are created using ui.create_controls().
115 """
116 piles = self.get_widgets_in_contents(
117 filter_function=self.is_a(urwid.Pile))
118 # Assume the control box is the last Pile.
119 controls = piles[-1]
120 # The button columns is the second widget in the Pile.
121 columns = controls.contents[1][0].base_widget
122 return [content[0].base_widget for content in columns.contents]
123
124 def is_a(self, cls):
125 """Return a function returning True if the given argument is a cls.
126
127 The resulting function can be used as the filter_function argument in
128 self.get_widgets_in_contents() calls.
129 """
130 return lambda arg: isinstance(arg, cls)
131
132 def get_button_caption(self, button):
133 """Return the button caption as a string."""
134 return button._w.original_widget.text
135
136 def emit(self, widget):
137 """Emit the first signal associated withe the given widget.
138
139 This is usually invoked to click buttons.
140 """
141 # Retrieve the first signal name (usually is 'click').
142 signal_name = widget.signals[0]
143 urwid.emit_signal(widget, signal_name, widget)
144
145
146class TestEnvIndex(EnvViewTestsMixin, unittest.TestCase):
147
148 def test_view_return_value(self):
149 # The view returns a tuple including a copy of the given env_db and
150 # None, the latter meaning no environment has been selected.
151 env_db = helpers.make_env_db()
152 new_env_db, env_data = views.env_index(
153 self.app, self.env_type_db, env_db, self.save_callable)
154 self.assertEqual(env_db, new_env_db)
155 self.assertIsNot(env_db, new_env_db)
156 self.assertIsNone(env_data)
157
158 def test_view_title(self):
159 # The application title is correctly set up.
160 env_db = helpers.make_env_db()
161 views.env_index(self.app, self.env_type_db, env_db, self.save_callable)
162 self.assertEqual(
163 'Select the Juju environment you want to use',
164 self.app.get_title())
165
166 def test_view_title_no_environments(self):
167 # The application title changes if the env_db has no environments.
168 env_db = {'environments': {}}
169 views.env_index(self.app, self.env_type_db, env_db, self.save_callable)
170 self.assertEqual(
171 'No Juju environments already set up: please create one',
172 self.app.get_title())
173
174 @mock.patch('quickstart.cli.views.env_detail')
175 def test_view_contents(self, mock_env_detail):
176 # The view displays a list of the environments in env_db.
177 env_db = helpers.make_env_db()
178 views.env_index(self.app, self.env_type_db, env_db, self.save_callable)
179 buttons = self.get_widgets_in_contents(
180 filter_function=self.is_a(ui.MenuButton))
181 # The environments are listed in alphabetical order.
182 environments = sorted(env_db['environments'])
183 # A button is created for each environment.
184 self.assertEqual(len(environments), len(buttons))
185 for env_name, button in zip(environments, buttons):
186 env_data = envs.get_env_data(env_db, env_name)
187 # The caption includes the environment description.
188 env_description = envs.get_env_short_description(env_data)
189 self.assertIn(env_description, self.get_button_caption(button))
190 # When the button is clicked, the detail view is called passing the
191 # corresponding environment data.
192 self.emit(button)
193 mock_env_detail.assert_called_once_with(
194 self.app, self.env_type_db, env_db, self.save_callable,
195 env_data)
196 # Reset the mock so that it does not include any calls on the next
197 # loop cycle.
198 mock_env_detail.reset_mock()
199
200 def test_selected_environment(self):
201 # The default environment is already selected in the list.
202 env_db = helpers.make_env_db(default='lxc')
203 views.env_index(self.app, self.env_type_db, env_db, self.save_callable)
204 env_data = envs.get_env_data(env_db, 'lxc')
205 env_description = envs.get_env_short_description(env_data)
206 contents = self.app.get_contents()
207 focused_widget = contents.get_focus()[0]
208 self.assertIsInstance(focused_widget, ui.MenuButton)
209 self.assertIn(env_description, self.get_button_caption(focused_widget))
210
211 def test_status_with_errors(self):
212 # The status message explains how errors are displayed.
213 env_db = helpers.make_env_db()
214 views.env_index(self.app, self.env_type_db, env_db, self.save_callable)
215 status = self.app.get_status()
216 self.assertEqual(' \N{BULLET} has errors ', status)
217
218 def test_status_with_default(self):
219 # The status message explains how default environment is represented.
220 env_db = helpers.make_env_db(default='lxc', exclude_invalid=True)
221 views.env_index(self.app, self.env_type_db, env_db, self.save_callable)
222 status = self.app.get_status()
223 self.assertEqual(' \N{CHECK MARK} default ', status)
224
225 def test_status_with_default_and_errors(self):
226 # The status message includes both default and errors explanations.
227 env_db = helpers.make_env_db(default='lxc')
228 views.env_index(self.app, self.env_type_db, env_db, self.save_callable)
229 status = self.app.get_status()
230 self.assertEqual(
231 ' \N{CHECK MARK} default \N{BULLET} has errors ', status)
232
233 def test_empty_status(self):
234 # The status message is empty if there are no errors.
235 env_db = helpers.make_env_db(exclude_invalid=True)
236 views.env_index(self.app, self.env_type_db, env_db, self.save_callable)
237 status = self.app.get_status()
238 self.assertEqual('', status)
239
240
241class TestEnvDetail(EnvViewTestsMixin, unittest.TestCase):
242
243 env_db = helpers.make_env_db(default='lxc')
244
245 def call_view(self, env_name='lxc'):
246 """Call the view passing the env_data corresponding to env_name."""
247 self.env_data = envs.get_env_data(self.env_db, env_name)
248 return views.env_detail(
249 self.app, self.env_type_db, self.env_db, self.save_callable,
250 self.env_data)
251
252 def test_view_return_value(self):
253 # The view returns a tuple including a copy of the given env_db and
254 # None, the latter meaning no environment has been selected (for now).
255 new_env_db, env_data = self.call_view()
256 self.assertEqual(self.env_db, new_env_db)
257 self.assertIsNot(self.env_db, new_env_db)
258 self.assertIsNone(env_data)
259
260 def test_view_title(self):
261 # The application title is correctly set up: it shows the description
262 # of the current environment.
263 self.call_view()
264 env_description = envs.get_env_short_description(self.env_data)
265 self.assertEqual(env_description, self.app.get_title())
266
267 def test_view_contents(self):
268 # The view displays a list of the environment fields.
269 self.call_view()
270 widgets = self.get_widgets_in_contents(
271 filter_function=self.is_a(urwid.Text))
272 env_metadata = envs.get_env_metadata(self.env_type_db, self.env_data)
273 expected_texts = [
274 '{}: {}'.format(field.name, field.display(value)) for field, value
275 in envs.map_fields_to_env_data(env_metadata, self.env_data)
276 if field.required or (value is not None)
277 ]
278 for expected_text, widget in zip(expected_texts, widgets):
279 self.assertEqual(expected_text, widget.text)
280
281 def test_view_buttons(self):
282 # The following buttons are displayed: "back", "use" and "set default".
283 self.call_view(env_name='ec2-west')
284 buttons = self.get_control_buttons()
285 captions = [self.get_button_caption(button) for button in buttons]
286 self.assertEqual(['back', 'use', 'set default'], captions)
287
288 def test_view_buttons_default(self):
289 # If the environment is the default one, the "set default" button is
290 # not displayed. The buttons we expect are "back" and "use".
291 self.call_view(env_name='lxc')
292 buttons = self.get_control_buttons()
293 captions = [self.get_button_caption(button) for button in buttons]
294 self.assertEqual(['back', 'use'], captions)
295
296 def test_view_buttons_error(self):
297 # If the environment is not valid, the "use" button is not displayed.
298 # The buttons we expect are "back" and "set default".
299 self.call_view(env_name='local-with-errors')
300 buttons = self.get_control_buttons()
301 captions = [self.get_button_caption(button) for button in buttons]
302 self.assertEqual(['back', 'set default'], captions)
303
304 @mock.patch('quickstart.cli.views.env_index')
305 def test_back_button(self, mock_env_index):
306 # The index view is called if the "back" button is clicked.
307 self.call_view(env_name='ec2-west')
308 # The control buttons are: "back", "use" and "set default".
309 back_button = self.get_control_buttons()[0]
310 self.emit(back_button)
311 mock_env_index.assert_called_once_with(
312 self.app, self.env_type_db, self.env_db, self.save_callable)
313
314 def test_use_button(self):
315 # The application exits if the "use" button is clicked.
316 # The env_db and the current environment data are returned.
317 self.call_view(env_name='ec2-west')
318 # The control buttons are: "back", "use" and "set default".
319 use_button = self.get_control_buttons()[1]
320 with self.assertRaises(ui.AppExit) as context_manager:
321 self.emit(use_button)
322 expected_return_value = (self.env_db, self.env_data)
323 self.assertEqual(
324 expected_return_value, context_manager.exception.return_value)
325
326 @mock.patch('quickstart.cli.views.env_index')
327 def test_set_default_button(self, mock_env_index):
328 # The current environment is set as default if the "set default" button
329 # is clicked. Subsequently the application switches to the index view.
330 self.call_view(env_name='ec2-west')
331 # The control buttons are: "back", "use" and "set default".
332 set_default_button = self.get_control_buttons()[2]
333 self.emit(set_default_button)
334 # The index view has been called passing the modified env_db as third
335 # argument.
336 self.assertTrue(mock_env_index.called)
337 new_env_db = mock_env_index.call_args[0][2]
338 # The new env_db has a new default.
339 self.assertEqual(new_env_db['default'], 'ec2-west')
340 # The new env_db has been saved.
341 self.save_callable.assert_called_once_with(new_env_db)
342
343 def test_status_with_errors(self):
344 # The status message explains how field errors are displayed.
345 self.call_view(env_name='local-with-errors')
346 status = self.app.get_status()
347 self.assertEqual(' \N{LOWER SEVEN EIGHTHS BLOCK} field error ', status)
348
349 def test_status_without_errors(self):
350 # The status message is empty if there are no errors.
351 self.call_view(env_name='lxc')
352 status = self.app.get_status()
353 self.assertEqual('', status)
87354
=== modified file 'quickstart/tests/helpers.py'
--- quickstart/tests/helpers.py 2013-12-09 13:57:17 +0000
+++ quickstart/tests/helpers.py 2013-12-19 09:30:30 +0000
@@ -130,6 +130,61 @@
130 return env_file.name130 return env_file.name
131131
132132
133def make_env_db(default=None, exclude_invalid=False):
134 """Create and return an env_db.
135
136 The default argument can be used to specify a default environment.
137 If exclude_invalid is set to True, the resulting env_db only includes
138 valid environments.
139 """
140 environments = {
141 'ec2-west': {
142 'type': 'ec2',
143 'admin-secret': 'adm-307c4a53bd174c1a89e933e1e8dc8131',
144 'control-bucket': 'con-aa2c6618b02d448ca7fd0f280ef66cba',
145 'region': u'us-west-1',
146 'access-key': 'hash',
147 'secret-key': 'Secret!',
148 },
149 'lxc': {
150 'admin-secret': 'bones',
151 'default-series': 'raring',
152 'storage-port': 8888,
153 'type': 'local',
154 },
155 'test-encoding': {
156 'access-key': '\xe0\xe8\xec\xf2\xf9',
157 'secret-key': '\xe0\xe8\xec\xf2\xf9',
158 'admin-secret': '\u2622\u2622\u2622\u2622',
159 'control-bucket': '\u2746 winter-bucket \u2746',
160 'juju-origin': '\u2606 the outer space \u2606',
161 'type': 'toxic \u2622 type',
162 },
163 }
164 if not exclude_invalid:
165 environments.update({
166 'local-with-errors': {
167 'admin-secret': '',
168 'storage-port': 'this-should-be-an-int',
169 'type': 'local',
170 },
171 'private-cloud-errors': {
172 'admin-secret': 'Secret!',
173 'auth-url': 'https://keystone.example.com:443/v2.0/',
174 'authorized-keys-path': '/home/frankban/.ssh/juju-rsa.pub',
175 'control-bucket': 'e3d48007292c9abba499d96a577ceab891d320fe',
176 'default-image-id': 'bb636e4f-79d7-4d6b-b13b-c7d53419fd5a',
177 'default-instance-type': 'm1.medium',
178 'default-series': 'no-such',
179 'type': 'openstack',
180 },
181 })
182 env_db = {'environments': environments}
183 if default is not None:
184 env_db['default'] = default
185 return env_db
186
187
133# Mock the builtin print function.188# Mock the builtin print function.
134mock_print = mock.patch('__builtin__.print')189mock_print = mock.patch('__builtin__.print')
135190
136191
=== modified file 'quickstart/tests/test_utils.py'
--- quickstart/tests/test_utils.py 2013-12-19 03:32:49 +0000
+++ quickstart/tests/test_utils.py 2013-12-19 09:30:30 +0000
@@ -378,31 +378,6 @@
378 mock_call.assert_called_once_with('lsb_release', '-cs')378 mock_call.assert_called_once_with('lsb_release', '-cs')
379379
380380
381class TestObjectDict(unittest.TestCase):
382
383 def setUp(self):
384 self.object_dict = utils.ObjectDict(mykey='myvalue')
385
386 def test_get_attribute(self):
387 # A value can be retrieved accessing the key as an attribute.
388 self.assertEqual('myvalue', self.object_dict.mykey)
389
390 def test_get_attribute_error(self):
391 # An AttributeError is raised if the key is not in the dictionary.
392 with self.assertRaises(AttributeError):
393 self.object_dict.no_such_key
394
395 def test_set_attribute(self):
396 # A key can be added by setting the corresponding attribute.
397 self.object_dict.another_key = 42
398 self.assertEqual(42, self.object_dict['another_key'])
399
400 def test_update_attribute(self):
401 # It is possible to update an attribute.
402 self.object_dict.mykey = 47
403 self.assertEqual(47, self.object_dict.mykey)
404
405
406class TestParseBundle(381class TestParseBundle(
407 helpers.BundleFileTestsMixin, helpers.ValueErrorTestsMixin,382 helpers.BundleFileTestsMixin, helpers.ValueErrorTestsMixin,
408 unittest.TestCase):383 unittest.TestCase):
409384
=== modified file 'quickstart/utils.py'
--- quickstart/utils.py 2013-12-19 03:32:49 +0000
+++ quickstart/utils.py 2013-12-19 09:30:30 +0000
@@ -14,22 +14,6 @@
14# You should have received a copy of the GNU Affero General Public License14# You should have received a copy of the GNU Affero General Public License
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/>.
1616
17# The ObjectDict code:
18#
19# Copyright 2009 Facebook
20#
21# Licensed under the Apache License, Version 2.0 (the "License"); you may
22# not use this file except in compliance with the License. You may obtain
23# a copy of the License at
24#
25# http://www.apache.org/licenses/LICENSE-2.0
26#
27# Unless required by applicable law or agreed to in writing, software
28# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
29# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
30# License for the specific language governing permissions and limitations
31# under the License.
32
33"""Juju Quickstart utility functions and classes."""17"""Juju Quickstart utility functions and classes."""
3418
35from __future__ import (19from __future__ import (
@@ -220,23 +204,6 @@
220 return output.strip()204 return output.strip()
221205
222206
223class ObjectDict(dict):
224 """Makes a dictionary behave like an object, with attribute-style access.
225
226 Original:
227 http://www.tornadoweb.org/en/stable/_modules/tornado/util.html#ObjectDict
228 """
229
230 def __getattr__(self, name):
231 try:
232 return self[name]
233 except KeyError:
234 raise AttributeError(name)
235
236 def __setattr__(self, name, value):
237 self[name] = value
238
239
240def parse_bundle(bundle_yaml, bundle_name=None):207def parse_bundle(bundle_yaml, bundle_name=None):
241 """Parse the provided bundle YAML encoded contents.208 """Parse the provided bundle YAML encoded contents.
242209

Subscribers

People subscribed via source and target branches