Merge lp:~frankban/juju-quickstart/env-manage-base-views into lp:juju-quickstart

Proposed by Francesco Banconi
Status: Merged
Merged at revision: 35
Proposed branch: lp:~frankban/juju-quickstart/env-manage-base-views
Merge into: lp:juju-quickstart
Diff against target: 1025 lines (+930/-1)
13 files modified
cli-app-demo.py (+52/-0)
quickstart/cli/__init__.py (+33/-0)
quickstart/cli/base.py (+155/-0)
quickstart/cli/ui.py (+118/-0)
quickstart/cli/views.py (+119/-0)
quickstart/models/envs.py (+2/-1)
quickstart/tests/cli/__init__.py (+15/-0)
quickstart/tests/cli/test_base.py (+174/-0)
quickstart/tests/cli/test_ui.py (+117/-0)
quickstart/tests/cli/test_views.py (+86/-0)
quickstart/tests/test_utils.py (+25/-0)
quickstart/utils.py (+33/-0)
requirements.pip (+1/-0)
To merge this branch: bzr merge lp:~frankban/juju-quickstart/env-manage-base-views
Reviewer Review Type Date Requested Status
Juju GUI Hackers Pending
Review via email: mp+199254@code.launchpad.net

Description of the change

Quickstart base structure for views.

This branch implements a way to start
Urwid interactive sessions in quickstart.
This infrastructure will be used in
future branches to implement the
quickstart environment management system.

My apologies for the long diff, but you
can safely ignore all the file headers,
and the code includes long module
docstrings which can help reviewing the
branch.

Tests: `make check`.

QA:
run `make` and then `./cli-app-demo.py`:
this will start a demo application using
the views infrastructure.

Thank you!

https://codereview.appspot.com/42600044/

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

Reviewers: mp+199254_code.launchpad.net,

Message:
Please take a look.

Description:
Quickstart base structure for views.

This branch implements a way to start
Urwid interactive sessions in quickstart.
This infrastructure will be used in
future branches to implement the
quickstart environment management system.

My apologies for the long diff, but you
can safely ignore all the file headers,
and the code includes long module
docstrings which can help reviewing the
branch.

Tests: `make check`.

QA:
run `make` and then `./cli-app-demo.py`:
this will start a demo application using
the views infrastructure.

Thank you!

https://code.launchpad.net/~frankban/juju-quickstart/env-manage-base-views/+merge/199254

(do not edit description out of merge proposal)

Please review this at https://codereview.appspot.com/42600044/

Affected files (+913, -1 lines):
   A [revision details]
   A cli-app-demo.py
   A quickstart/cli/__init__.py
   A quickstart/cli/base.py
   A quickstart/cli/ui.py
   A quickstart/cli/views.py
   M quickstart/models/envs.py
   A quickstart/tests/cli/__init__.py
   A quickstart/tests/cli/test_base.py
   A quickstart/tests/cli/test_ui.py
   A quickstart/tests/cli/test_views.py
   M quickstart/tests/test_utils.py
   M quickstart/utils.py
   M requirements.pip

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

Ran out of time, but I think this looks great. Count me as an LGTM
'cause I know bac will do a great review. :-)

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

https://codereview.appspot.com/42600044/diff/1/quickstart/cli/ui.py#newcode28
quickstart/cli/ui.py:28: # Define the color palette used by the Urwid
application.
Might be nice to describe the columns. I assume they are class name (or
None for default), text style, background style.

https://codereview.appspot.com/42600044/diff/1/quickstart/cli/ui.py#newcode70
quickstart/cli/ui.py:70: def bind(function, *args):
Huh. So...this effectively produces a thunk that discards/ignores the
argument. You could call it that, I guess. This functionality is
really generic. If you didn't need to discard the argument, you could
just use functools.partial.

def make_aggressive_thunk(function, *args, **kwargs):
     def thunk(*ignored, **ignored_kwargs):
         return function(*args, **kwargs)
     return thunk

<shrug> :-)

https://codereview.appspot.com/42600044/

Revision history for this message
Brad Crittenden (bac) wrote :

LGTM and QA-OK. Some changes suggested below but nothing substantial.
Thanks for the interesting work.

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

https://codereview.appspot.com/42600044/diff/1/quickstart/cli/base.py#newcode19
quickstart/cli/base.py:19: A collection of objects which help building a
Quickstart CLI application
change 'building' -> 'in building' or 'build'

https://codereview.appspot.com/42600044/diff/1/quickstart/cli/base.py#newcode55
quickstart/cli/base.py:55: Improve the level of event loop introspection
so that code and tests
Improves

https://codereview.appspot.com/42600044/diff/1/quickstart/cli/base.py#newcode111
quickstart/cli/base.py:111: def set_contents(listbox):
In the docstring you call the param a generic 'widget'. They should
match. If not generic then should you validate that the param is a
listbox? I think using a generic widget is better.

https://codereview.appspot.com/42600044/diff/1/quickstart/cli/base.py#newcode112
quickstart/cli/base.py:112: contents.original_widget = listbox
original_widget seems like an odd name since it is really just the
current widget.

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

https://codereview.appspot.com/42600044/diff/1/quickstart/cli/views.py#newcode51
quickstart/cli/views.py:51: pressing a keyboard shortcut;
by pressing

https://codereview.appspot.com/42600044/diff/1/quickstart/utils.py
File quickstart/utils.py (right):

https://codereview.appspot.com/42600044/diff/1/quickstart/utils.py#newcode194
quickstart/utils.py:194:
http://www.tornadoweb.org/en/stable/_modules/tornado/util.html#ObjectDict
Does the use here complicate our copyright statement above? You've
given attribution but it looks like Canonical is claiming copyright to
it.

Perhaps we include at the top the copyright statement from tornado with
the reference to ObjectDict.

# The ObjectDict code:
#
# Copyright 2009 Facebook
#
# Licensed under the Apache License, Version 2.0 (the "License"); you
may
# not use this file except in compliance with the License. You may
obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See
the
# License for the specific language governing permissions and
limitations
# under the License.

https://codereview.appspot.com/42600044/

55. By Francesco Banconi

Changes as per review.

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

*** Submitted:

Quickstart base structure for views.

This branch implements a way to start
Urwid interactive sessions in quickstart.
This infrastructure will be used in
future branches to implement the
quickstart environment management system.

My apologies for the long diff, but you
can safely ignore all the file headers,
and the code includes long module
docstrings which can help reviewing the
branch.

Tests: `make check`.

QA:
run `make` and then `./cli-app-demo.py`:
this will start a demo application using
the views infrastructure.

Thank you!

R=gary.poster, bac
CC=
https://codereview.appspot.com/42600044

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

https://codereview.appspot.com/42600044/diff/1/quickstart/cli/base.py#newcode19
quickstart/cli/base.py:19: A collection of objects which help building a
Quickstart CLI application
On 2013/12/17 14:30:47, bac wrote:
> change 'building' -> 'in building' or 'build'

Done.

https://codereview.appspot.com/42600044/diff/1/quickstart/cli/base.py#newcode55
quickstart/cli/base.py:55: Improve the level of event loop introspection
so that code and tests
On 2013/12/17 14:30:47, bac wrote:
> Improves

Done.

https://codereview.appspot.com/42600044/diff/1/quickstart/cli/base.py#newcode111
quickstart/cli/base.py:111: def set_contents(listbox):
On 2013/12/17 14:30:47, bac wrote:
> In the docstring you call the param a generic 'widget'. They should
match. If
> not generic then should you validate that the param is a listbox? I
think using
> a generic widget is better.

Done.

https://codereview.appspot.com/42600044/diff/1/quickstart/cli/base.py#newcode112
quickstart/cli/base.py:112: contents.original_widget = listbox
On 2013/12/17 14:30:47, bac wrote:
> original_widget seems like an odd name since it is really just the
current
> widget.

Unfortunately this is how the wrapped widget is named in Urwid.

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

https://codereview.appspot.com/42600044/diff/1/quickstart/cli/ui.py#newcode28
quickstart/cli/ui.py:28: # Define the color palette used by the Urwid
application.
On 2013/12/17 14:00:43, gary.poster wrote:
> Might be nice to describe the columns. I assume they are class name
(or None
> for default), text style, background style.

Done.

https://codereview.appspot.com/42600044/diff/1/quickstart/cli/ui.py#newcode70
quickstart/cli/ui.py:70: def bind(function, *args):
On 2013/12/17 14:00:43, gary.poster wrote:
> Huh. So...this effectively produces a thunk that discards/ignores the
argument.
> You could call it that, I guess. This functionality is really
generic. If you
> didn't need to discard the argument, you could just use
functools.partial.

> def make_aggressive_thunk(function, *args, **kwargs):
> def thunk(*ignored, **ignored_kwargs):
> return function(*args, **kwargs)
> return thunk

> <shrug> :-)

Renamed to thunk.

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

https://codereview.appspot.com/42600044/diff/1/quickstart/cli/views.py#newcode51
quickstart/...

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
=== added file 'cli-app-demo.py'
--- cli-app-demo.py 1970-01-01 00:00:00 +0000
+++ cli-app-demo.py 2013-12-17 15:23:53 +0000
@@ -0,0 +1,52 @@
1#!.venv/bin/python
2
3# This file is part of the Juju Quickstart Plugin, which lets users set up a
4# Juju environment in very few steps (https://launchpad.net/juju-quickstart).
5# Copyright (C) 2013 Canonical Ltd.
6#
7# This program is free software: you can redistribute it and/or modify it under
8# the terms of the GNU Affero General Public License version 3, as published by
9# the Free Software Foundation.
10#
11# This program is distributed in the hope that it will be useful, but WITHOUT
12# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
13# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14# Affero General Public License for more details.
15#
16# You should have received a copy of the GNU Affero General Public License
17# along with this program. If not, see <http://www.gnu.org/licenses/>.
18
19"""Juju Quickstart CLI application demo."""
20
21# XXX frankban (2013-12-16): this file is only for demonstration/QA purposes.
22# Remove this file when the environment management integration is completed.
23# Run "make" before running this file.
24
25from __future__ import (
26 print_function,
27 unicode_literals,
28)
29
30import urwid
31
32from quickstart.cli import views
33
34
35def example_view(app, message):
36 """An example Quickstart view."""
37 app.set_title('This is the title')
38 widgets = [
39 urwid.Text('These are the app contents. Press ^X to exit.'),
40 urwid.Divider(),
41 urwid.Text('Message: {}'.format(message)),
42 ]
43 contents = urwid.ListBox(urwid.SimpleFocusListWalker(widgets))
44 app.set_contents(contents)
45 app.set_status(('status error', '\u2622 Look! A toxic status! \u2622'))
46 app.set_message('this notification will disappear in three seconds')
47 return 'Goodbye, world'
48
49
50if __name__ == '__main__':
51 return_value = views.show(example_view, 'Hello, world')
52 print(return_value)
053
=== added directory 'quickstart/cli'
=== added file 'quickstart/cli/__init__.py'
--- quickstart/cli/__init__.py 1970-01-01 00:00:00 +0000
+++ quickstart/cli/__init__.py 2013-12-17 15:23:53 +0000
@@ -0,0 +1,33 @@
1# This file is part of the Juju Quickstart Plugin, which lets users set up a
2# Juju environment in very few steps (https://launchpad.net/juju-quickstart).
3# Copyright (C) 2013 Canonical Ltd.
4#
5# This program is free software: you can redistribute it and/or modify it under
6# the terms of the GNU Affero General Public License version 3, as published by
7# the Free Software Foundation.
8#
9# This program is distributed in the hope that it will be useful, but WITHOUT
10# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
11# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
12# Affero General Public License for more details.
13#
14# 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/>.
16
17"""Juju Quickstart command line interface management.
18
19The functions and objects included in this package can be used to build
20rich command line interfaces using the Urwid console interface library
21(see <http://excess.org/urwid/>).
22
23This package is organized in several modules:
24 - base: the base pieces used to set up Urwid applications;
25 - views: view functions responsible for showing specific contents in the
26 context of a Urwid app;
27 - ui: Urwid related utility objects, including callback wrappers,
28 customized widgets and style specific helpers.
29
30Client code usually starts a Quickstart terminal application calling
31views.show() with the view to display along with the view required arguments.
32See the quickstart.cli.views module docstring for further details.
33"""
034
=== added file 'quickstart/cli/base.py'
--- quickstart/cli/base.py 1970-01-01 00:00:00 +0000
+++ quickstart/cli/base.py 2013-12-17 15:23:53 +0000
@@ -0,0 +1,155 @@
1# This file is part of the Juju Quickstart Plugin, which lets users set up a
2# Juju environment in very few steps (https://launchpad.net/juju-quickstart).
3# Copyright (C) 2013 Canonical Ltd.
4#
5# This program is free software: you can redistribute it and/or modify it under
6# the terms of the GNU Affero General Public License version 3, as published by
7# the Free Software Foundation.
8#
9# This program is distributed in the hope that it will be useful, but WITHOUT
10# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
11# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
12# Affero General Public License for more details.
13#
14# 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/>.
16
17"""Juju Quickstart Urwid application base handling.
18
19A collection of objects which help in building a Quickstart CLI application
20skeleton. Views use these functions to set up the Urwid top widget and to start
21the application main loop.
22
23See the quickstart.cli.views module docstring for further details.
24"""
25
26from __future__ import unicode_literals
27
28import urwid
29
30from quickstart import (
31 get_version,
32 utils,
33)
34from quickstart.cli import ui
35
36
37class _MainLoop(urwid.MainLoop):
38 """A customized Urwid loop.
39
40 Allow for setting the unhandled_input callable after the loop
41 initialization.
42 """
43
44 def set_unhandled_input(self, unhandled_input):
45 """Set the unhandled_input callable.
46
47 The passed unhandled_input is a callable called when input is not
48 handled by the application top widget.
49 """
50 self._unhandled_input = unhandled_input
51
52 def get_alarms(self):
53 """Return all the alarms set for this loop.
54
55 Improves the level of event loop introspection so that code and tests
56 can easily access the alarms list.
57
58 The alarms list is a sequence of (time, callback) tuples.
59 """
60 return self.event_loop._alarms
61
62
63def setup_urwid_app():
64 """Configure a Urwid application suitable for being used by views.
65
66 Build the Urwid top widget and instantiate a main loop. The top widget
67 is basically a frame, composed by a header, some contents, and a footer.
68 This application skeleton is Quickstart branded, and exposes functions
69 that can be used by views to change the contents of the frame.
70
71 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 exposing
73 an API to be used by views to customize the application.
74
75 The API exposed by app is limited by design, and includes:
76
77 - set_title(text): set/change the title displayed in the application
78 header (e.g.: app.set_title('my title'));
79 - get_title(): return the current application title;
80
81 - set_contents(widget): set/change the application body contents. A
82 Urwid ListBox widget instance is usually provided, which replaces the
83 current application contents;
84 - get_contents(): return the current application contents widget;
85
86 - set_status(text): set/change the status text displayed in the
87 application footer (e.g.: set_status('press play on tape')). The
88 status message can also be passed as a (style, text) tuple, as usual
89 in Urwid code, e.g.: app.set_status(('error', 'error message'));
90 - get_status(): return the current status message;
91
92 - set_message(text): set/change a notification message, which is
93 displayed in the footer for a couple of seconds before disappearing;
94 - get_message(): return the message currently displayed in the
95 notifications area.
96 """
97 # Set up the application header.
98 title = urwid.Text('\npreparing...')
99 header_line = urwid.Divider('\N{LOWER ONE QUARTER BLOCK}')
100 header = urwid.Pile([
101 urwid.AttrMap(ui.padding(title), 'header'),
102 urwid.AttrMap(header_line, 'line header'),
103 urwid.Divider(),
104 ])
105 # Set up the application default contents.
106 # View code is assumed to replace the placeholder widget using
107 # app.set_contents(widget).
108 placeholder = urwid.ListBox(urwid.SimpleFocusListWalker([]))
109 contents = ui.padding(urwid.AttrMap(placeholder, None))
110
111 def set_contents(widget):
112 contents.original_widget = widget
113
114 # Set up the application footer.
115 # The CTRL-x shortcut is automatically set up by views.show().
116 brand_message = 'juju-quickstart v{} - ^X exit '.format(get_version())
117 brand = urwid.Text(brand_message)
118 status = urwid.Text('')
119 message = urwid.Text('', align='right')
120 footer_line = urwid.Divider('\N{UPPER ONE EIGHTH BLOCK}')
121 status_columns = urwid.Columns([('pack', brand), status, message])
122 footer = urwid.Pile([
123 urwid.Divider(),
124 urwid.AttrMap(ui.padding(status_columns), 'footer'),
125 urwid.AttrMap(footer_line, 'line footer'),
126 ])
127 # Compose the components in a frame, and set up the top widget. The top
128 # widget is the topmost widget used for painting the screen.
129 page = urwid.Frame(contents, header=header, footer=footer)
130 top_widget = urwid.Overlay(
131 page, urwid.SolidFill('\N{MEDIUM SHADE}'),
132 align='center', width=('relative', 90),
133 valign='middle', height=('relative', 90),
134 min_width=78, min_height=20)
135 # Instantiate the Urwid main loop.
136 loop = _MainLoop(top_widget, palette=ui.PALETTE)
137
138 def set_message(msg):
139 message.set_text(('message', msg))
140 loop.set_alarm_in(3, lambda *args: message.set_text(''))
141
142 # Create the app ObjectDict. If in the future we will find a view to
143 # require more capabilities/API access than the current one, this is the
144 # place where to add new API functions.
145 app = utils.ObjectDict(
146 set_title=lambda msg: title.set_text('\n{}'.format(msg)),
147 get_title=lambda: title.text.lstrip(),
148 set_contents=set_contents,
149 get_contents=lambda: contents.original_widget,
150 set_status=lambda msg: status.set_text(msg),
151 get_status=lambda: status.text,
152 set_message=set_message,
153 get_message=lambda: message.text,
154 )
155 return loop, app
0156
=== added file 'quickstart/cli/ui.py'
--- quickstart/cli/ui.py 1970-01-01 00:00:00 +0000
+++ quickstart/cli/ui.py 2013-12-17 15:23:53 +0000
@@ -0,0 +1,118 @@
1# This file is part of the Juju Quickstart Plugin, which lets users set up a
2# Juju environment in very few steps (https://launchpad.net/juju-quickstart).
3# Copyright (C) 2013 Canonical Ltd.
4#
5# This program is free software: you can redistribute it and/or modify it under
6# the terms of the GNU Affero General Public License version 3, as published by
7# the Free Software Foundation.
8#
9# This program is distributed in the hope that it will be useful, but WITHOUT
10# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
11# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
12# Affero General Public License for more details.
13#
14# 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/>.
16
17"""Juju Quickstart Urwid related utility objects."""
18
19from __future__ import unicode_literals
20
21import functools
22
23import urwid
24
25
26# Define the shortcut used to quit the interactive session.
27EXIT_KEY = 'ctrl x'
28# Define the color palette used by the Urwid application.
29PALETTE = [
30 # Class name, foreground color, background color.
31 # See <http://excess.org/urwid/docs/reference/constants.html
32 # foreground-and-background-colors>.
33 (None, 'light gray', 'black'),
34 ('controls', 'dark gray', 'light gray'),
35 ('edit', 'white,underline', 'black'),
36 ('error', 'light red', 'black'),
37 ('status error', 'light red', 'light gray'),
38 ('footer', 'black', 'light gray'),
39 ('message', 'white', 'dark green'),
40 ('header', 'white', 'dark magenta'),
41 ('highlight', 'white', 'black'),
42 ('line header', 'dark gray', 'dark magenta'),
43 ('line footer', 'light gray', 'light gray'),
44 ('selected', 'white', 'dark blue'),
45]
46# Define a default padding for the Urwid application.
47padding = functools.partial(urwid.Padding, left=2, right=2)
48
49
50class AppExit(Exception):
51 """Used by views to stop the interactive execution returning a value."""
52
53 def __init__(self, return_value=None):
54 """Set the value to return to the view caller (default is None)."""
55 self.return_value = return_value
56
57 def __str__(self):
58 return b'{}: {!r}'.format(self.__class__.__name__, self.return_value)
59
60
61def exit_and_return(return_value):
62 """Return a function that can be used as unhandled_input for an Urwid app.
63
64 The resulting function terminates the interactive session with the given
65 return_value when the user hits CTRL-x.
66 """
67 def unhandled_input(key):
68 if key == EXIT_KEY:
69 raise AppExit(return_value)
70 return unhandled_input
71
72
73def create_controls(*args):
74 """Create a row of control widgets surrounded by line boxes."""
75 controls = urwid.Columns([padding(urwid.LineBox(arg)) for arg in args])
76 return urwid.Pile([
77 urwid.Divider(top=1, bottom=1),
78 urwid.AttrMap(controls, 'controls')
79 ])
80
81
82class MenuButton(urwid.Button):
83 """A customized Urwid button widget.
84
85 This behaves like a regular button, but also takes a callback that is
86 called when the button is clicked.
87 """
88
89 def __init__(self, caption, callback):
90 super(MenuButton, self).__init__('')
91 urwid.connect_signal(self, 'click', callback)
92 icon = urwid.SelectableIcon(caption, 0)
93 # Replace the original widget: it seems ugly but it is Urwid idiomatic.
94 self._w = urwid.AttrMap(icon, None, 'selected')
95
96
97def thunk(function, *args, **kwargs):
98 """Create and return a callable binding the given method and args/kwargs.
99
100 This is useful when the given function is used as a signal subscriber, e.g.
101 as a callback to be called when an Urwid signal is sent. In most cases, the
102 widget which generated the event is sent as first argument to the callback.
103 Moreover, urwid.connect_signal handles only one user argument.
104 See <http://excess.org/urwid/docs/reference/signals.html>.
105
106 This function helps when the callback does not require the original widget
107 and/or when it instead requires more than one argument, e.g.:
108
109 def save(contents, commit=False):
110 ...
111
112 button = MenuButton('save and commit', ui.thunk(save, contents, True))
113
114 This example uses the MenuButton widget defined above in this module.
115 """
116 def callback(*ignored_args, **ignored_kwargs):
117 return function(*args, **kwargs)
118 return callback
0119
=== added file 'quickstart/cli/views.py'
--- quickstart/cli/views.py 1970-01-01 00:00:00 +0000
+++ quickstart/cli/views.py 2013-12-17 15:23:53 +0000
@@ -0,0 +1,119 @@
1# This file is part of the Juju Quickstart Plugin, which lets users set up a
2# Juju environment in very few steps (https://launchpad.net/juju-quickstart).
3# Copyright (C) 2013 Canonical Ltd.
4#
5# This program is free software: you can redistribute it and/or modify it under
6# the terms of the GNU Affero General Public License version 3, as published by
7# the Free Software Foundation.
8#
9# This program is distributed in the hope that it will be useful, but WITHOUT
10# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
11# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
12# Affero General Public License for more details.
13#
14# 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/>.
16
17"""Juju Quickstart CLI application views.
18
19This module contains the Quickstart view implementations along with a function
20(show) to easily start a view automatically creating an Urwid application.
21
22To start a Quickstart interactive session, just run the following:
23
24 show(view, *args)
25
26The code above sets up a Quickstart branded CLI application, then calls the
27given view callable passing the application object ready to be configured
28and all the given optional arguments. Finally the interactive session is
29started, and the show function blocks until the user or the view itself
30request to exit the application.
31
32A view is a callable receiving an app ObjectDict and other optional arguments
33(based on specific view needs). A view function can configure the Urwid
34application using the API exposed by the application object
35(see quickstart.cli.base.setup_urwid_app).
36
37Assume a view is defined like the following:
38
39 def myview(app, title):
40 app.set_title(title)
41 return 42
42
43The view above, requiring a title argument, can be started this way:
44
45 show(myview, 'this title will be shown in the header')
46
47At this point the application main loop is started, and the user can interact
48with the CLI interface. There are two ways to stop the interactive session:
49 1) the user explicitly requests to exit. The Urwid application is
50 automatically configured to allow the user to quit whenever she wants by
51 pressing a keyboard shortcut;
52 2) a view decides it is time to quit (e.g. reacting to an event/input).
53
54In both cases, the show function returns something to the caller:
55 1) when the user explicitly requests to quit, the value returned by the
56 view itself (the callable passed to show()) is returned. For instance,
57 the show(myview...) call above would return 42;
58 2) to force the end of the interactive session, a view can raise a
59 quickstart.cli.ui.AppExit exception, passing a return value: if the
60 application is exited this way, then show() returns the value
61 encapsulated in the exception. Note that this exception can be raised
62 as a reaction to an event, and not in the first execution of the view
63 body, i.e. during the app configuration.
64
65The above is better described by code:
66
67 from quickstart.cli import views, ui
68
69 def button_view(app):
70
71 def exit():
72 raise ui.AppExit(True)
73
74 app.set_title('behold the button below')
75 button = ui.MenuButton('press to exit', ui.thunk(exit))
76 widgets = urwid.ListBox(urwid.SimpleFocusListWalker([button]))
77 app.set_contents(widgets)
78 return False
79
80 pressed = views.show(button_view)
81
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 just
84returns 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.
86
87As 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 other
89views, including app, are properly provided. This is effectively the proposed
90solution to build multi-views CLI applications in Quickstart.
91"""
92
93from __future__ import unicode_literals
94
95from quickstart.cli import (
96 base,
97 ui,
98)
99
100
101def show(view, *args):
102 """Start an Urwid interactive session showing the given view.
103
104 The view is called passing an app ObjectDict and the provided *args.
105
106 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 former
108 case, return what is returned by the view. In the AppExit case, return
109 the value encapsulated in the exception.
110 """
111 loop, app = base.setup_urwid_app()
112 default_return_value = view(app, *args)
113 unhandled_input = ui.exit_and_return(default_return_value)
114 loop.set_unhandled_input(unhandled_input)
115 # Start the Urwid interactive session (main loop).
116 try:
117 loop.run()
118 except ui.AppExit as err:
119 return err.return_value
0120
=== modified file 'quickstart/models/envs.py'
--- quickstart/models/envs.py 2013-12-16 08:21:57 +0000
+++ quickstart/models/envs.py 2013-12-17 15:23:53 +0000
@@ -321,7 +321,8 @@
321 label='default series', required=False,321 label='default series', required=False,
322 help='the default Ubuntu series to use for the bootstrap node')322 help='the default Ubuntu series to use for the bootstrap node')
323 is_default_field = fields.BoolField(323 is_default_field = fields.BoolField(
324 'is-default', help='make this the default environment', required=True)324 'is-default', label='default', allow_mixed=False, required=True,
325 help='make this the default environment')
325 # Define data structures used as part of the metadata below.326 # Define data structures used as part of the metadata below.
326 ec2_regions = (327 ec2_regions = (
327 'ap-northeast-1', 'ap-southeast-1', 'ap-southeast-2',328 'ap-northeast-1', 'ap-southeast-1', 'ap-southeast-2',
328329
=== added directory 'quickstart/tests/cli'
=== added file 'quickstart/tests/cli/__init__.py'
--- quickstart/tests/cli/__init__.py 1970-01-01 00:00:00 +0000
+++ quickstart/tests/cli/__init__.py 2013-12-17 15:23:53 +0000
@@ -0,0 +1,15 @@
1# This file is part of the Juju Quickstart Plugin, which lets users set up a
2# Juju environment in very few steps (https://launchpad.net/juju-quickstart).
3# Copyright (C) 2013 Canonical Ltd.
4#
5# This program is free software: you can redistribute it and/or modify it under
6# the terms of the GNU Affero General Public License version 3, as published by
7# the Free Software Foundation.
8#
9# This program is distributed in the hope that it will be useful, but WITHOUT
10# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
11# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
12# Affero General Public License for more details.
13#
14# 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/>.
016
=== added file 'quickstart/tests/cli/test_base.py'
--- quickstart/tests/cli/test_base.py 1970-01-01 00:00:00 +0000
+++ quickstart/tests/cli/test_base.py 2013-12-17 15:23:53 +0000
@@ -0,0 +1,174 @@
1# This file is part of the Juju Quickstart Plugin, which lets users set up a
2# Juju environment in very few steps (https://launchpad.net/juju-quickstart).
3# Copyright (C) 2013 Canonical Ltd.
4#
5# This program is free software: you can redistribute it and/or modify it under
6# the terms of the GNU Affero General Public License version 3, as published by
7# the Free Software Foundation.
8#
9# This program is distributed in the hope that it will be useful, but WITHOUT
10# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
11# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
12# Affero General Public License for more details.
13#
14# 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/>.
16
17"""Tests for the Juju Quickstart Urwid application base handling."""
18
19from __future__ import unicode_literals
20
21import unittest
22
23import urwid
24
25from quickstart import utils
26from quickstart.cli import base
27
28
29class TestMainLoop(unittest.TestCase):
30
31 def setUp(self):
32 # Create a loop instance.
33 self.widget = urwid.ListBox(urwid.SimpleFocusListWalker([]))
34 self.loop = base._MainLoop(self.widget)
35
36 def test_initialization(self):
37 # The customized loop is properly initialized, and it is an instance
38 # of the Urwid loop.
39 self.assertEqual(self.widget, self.loop.widget)
40 self.assertIsInstance(self.loop, urwid.MainLoop)
41
42 def test_unhandled_input(self):
43 # The unhandled_input function can be set after the initialization.
44 inputs = []
45 self.loop.set_unhandled_input(inputs.append)
46 self.loop.unhandled_input('ctrl z')
47 self.assertEqual(['ctrl z'], inputs)
48
49 def test_alarms(self):
50 # It is possible to retrieve the list of event loop alarms.
51 times_called = []
52 self.assertEqual(0, len(self.loop.get_alarms()))
53 callback = lambda *args: times_called.append(1)
54 self.loop.set_alarm_in(3, callback)
55 alarms = self.loop.get_alarms()
56 self.assertEqual(1, len(alarms))
57 alarms[0][1]()
58 self.assertEqual(1, sum(times_called))
59
60
61class TestSetupUrwidApp(unittest.TestCase):
62
63 def setUp(self):
64 # Set up the base Urwid application.
65 self.loop, self.app = base.setup_urwid_app()
66
67 def get_title_widget(self, loop):
68 """Return the title widget given the application main loop."""
69 # The frame is the main overlay's top widget.
70 frame = loop.widget.top_w
71 # Retrieve the header.
72 header = frame.contents['header'][0]
73 # The title widget is the first in the header pile.
74 return header.contents[0][0].base_widget
75
76 def get_contents_widget(self, loop):
77 """Return the contents widget given the application main loop."""
78 # The frame is the main overlay's top widget.
79 frame = loop.widget.top_w
80 # Retrieve the body.
81 body = frame.contents['body'][0]
82 # The contents widget is the body's original widget.
83 return body.original_widget
84
85 def _get_footer_columns(self, loop):
86 # The frame is the main overlay's top widget.
87 frame = loop.widget.top_w
88 # Retrieve the footer.
89 footer = frame.contents['footer'][0]
90 # Return the columns widget.
91 return footer.contents[1][0].base_widget
92
93 def get_status_widget(self, loop):
94 """Return the status widget given the application main loop."""
95 columns = self._get_footer_columns(loop)
96 # The status widget is the second one (brand, status, message).
97 return columns.contents[1][0]
98
99 def get_message_widget(self, loop):
100 """Return the message widget given the application main loop."""
101 columns = self._get_footer_columns(loop)
102 # The message widget is the third one (brand, status, message).
103 return columns.contents[2][0]
104
105 def test_loop(self):
106 # The returned loop is an instance of the base customized loop.
107 self.assertIsInstance(self.loop, base._MainLoop)
108
109 def test_app(self):
110 # The returned app is an ObjectDict including the expected keys.
111 expected_keys = set([
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()))
119
120 def test_set_title(self):
121 # The set_title API sets the application title.
122 self.app.set_title('The Inner Light')
123 title_widget = self.get_title_widget(self.loop)
124 self.assertEqual('\nThe Inner Light', title_widget.text)
125
126 def test_get_title(self):
127 # The get_title API retrieves the application title.
128 title_widget = self.get_title_widget(self.loop)
129 title_widget.set_text('The Outer Space')
130 self.assertEqual('The Outer Space', self.app.get_title())
131
132 def test_set_contents(self):
133 # The set_contents API changes the application main contents widget.
134 text_widget = urwid.Text('my contents')
135 self.app.set_contents(text_widget)
136 contents_widget = self.get_contents_widget(self.loop)
137 self.assertEqual('my contents', contents_widget.text)
138
139 def test_get_contents(self):
140 # The get_contents API returns the contents widget.
141 contents_widget = self.get_contents_widget(self.loop)
142 self.assertEqual(contents_widget, self.app.get_contents())
143
144 def test_set_status(self):
145 # The set_status API sets the status message displayed in the footer.
146 self.app.set_status('press play on tape')
147 status_widget = self.get_status_widget(self.loop)
148 self.assertEqual('press play on tape', status_widget.text)
149
150 def test_get_status(self):
151 # The get_status API returns the current status message.
152 status_widget = self.get_status_widget(self.loop)
153 status_widget.set_text('hit space to continue')
154 self.assertEqual('hit space to continue', self.app.get_status())
155
156 def test_set_message(self):
157 # The set_message API sets the message to be displayed in the
158 # notification area.
159 self.app.set_message('this will disappear')
160 message_widget = self.get_message_widget(self.loop)
161 self.assertEqual('this will disappear', message_widget.text)
162 # An alarm is set to make this message disappear.
163 alarms = self.loop.get_alarms()
164 self.assertEqual(1, len(alarms))
165 # Calling the callback makes the message go away.
166 _, callback = alarms[0]
167 callback()
168 self.assertEqual('', message_widget.text)
169
170 def test_get_message(self):
171 # The get_message API returns the current notification message.
172 message_widget = self.get_message_widget(self.loop)
173 message_widget.set_text('42')
174 self.assertEqual('42', self.app.get_message())
0175
=== added file 'quickstart/tests/cli/test_ui.py'
--- quickstart/tests/cli/test_ui.py 1970-01-01 00:00:00 +0000
+++ quickstart/tests/cli/test_ui.py 2013-12-17 15:23:53 +0000
@@ -0,0 +1,117 @@
1# This file is part of the Juju Quickstart Plugin, which lets users set up a
2# Juju environment in very few steps (https://launchpad.net/juju-quickstart).
3# Copyright (C) 2013 Canonical Ltd.
4#
5# This program is free software: you can redistribute it and/or modify it under
6# the terms of the GNU Affero General Public License version 3, as published by
7# the Free Software Foundation.
8#
9# This program is distributed in the hope that it will be useful, but WITHOUT
10# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
11# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
12# Affero General Public License for more details.
13#
14# 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/>.
16
17"""Tests for the Juju Quickstart Urwid related utility objects."""
18
19from __future__ import unicode_literals
20
21import unittest
22
23import mock
24import urwid
25
26from quickstart.cli import ui
27
28
29class TestAppExit(unittest.TestCase):
30
31 def test_no_return_value(self):
32 # The exception accepts a return value argument.
33 exception = ui.AppExit(42)
34 self.assertEqual(42, exception.return_value)
35
36 def test_return_value(self):
37 # The exception's return value defaults to None.
38 exception = ui.AppExit()
39 self.assertIsNone(exception.return_value)
40
41 def test_string_representation(self):
42 # The exception is correctly represented as a byte string.
43 exception = ui.AppExit(42)
44 str_exception = str(exception)
45 self.assertIsInstance(str_exception, bytes)
46 self.assertEqual(b'AppExit: 42', str_exception)
47
48
49class TestExitAndReturn(unittest.TestCase):
50
51 def test_app_exit(self):
52 # The function returned raises an AppExit error if CTRL-x is passed.
53 function = ui.exit_and_return(42)
54 with self.assertRaises(ui.AppExit) as context_manager:
55 function(ui.EXIT_KEY)
56 self.assertEqual(42, context_manager.exception.return_value)
57
58 def test_unhandled(self):
59 # Passing other keys, the resulting function is a no-op.
60 function = ui.exit_and_return(42)
61 self.assertIsNone(function('alt z'))
62
63
64class TestCreateControls(unittest.TestCase):
65
66 def test_resulting_pile(self):
67 # The resulting pile is properly structured: it includes a columns
68 # widget containing the provided widgets.
69 widget0 = urwid.Text('w0')
70 widget1 = urwid.Text('w1')
71 pile = ui.create_controls(widget0, widget1)
72 divider_contents, columns_contents = pile.contents
73 self.assertIsInstance(divider_contents[0], urwid.Divider)
74 columns = columns_contents[0].original_widget
75 widgets = [content[0].base_widget for content in columns.contents]
76 self.assertIs(widget0, widgets[0])
77 self.assertIs(widget1, widgets[1])
78
79
80class TestMenuButton(unittest.TestCase):
81
82 def test_caption(self):
83 # The button's caption is properly set up.
84 button = ui.MenuButton('my caption', mock.Mock())
85 self.assertEqual('my caption', button._w.base_widget.text)
86
87 def test_signals(self):
88 # The given callback is called when the click signal is emitted.
89 callback = mock.Mock()
90 button = ui.MenuButton('my caption', callback)
91 urwid.emit_signal(button, 'click', button)
92 callback.assert_called_once_with(button)
93
94
95class TestThunk(unittest.TestCase):
96
97 widget = 'test-widget'
98
99 def test_no_args(self):
100 # A callback can be set up without arguments.
101 function = mock.Mock()
102 thunk_function = ui.thunk(function)
103 thunk_function(self.widget)
104 function.assert_called_once_with()
105
106 def test_args(self):
107 # It is possible to bind arguments to the callback function.
108 function = mock.Mock()
109 thunk_function = ui.thunk(function, 'arg1', 'arg2')
110 thunk_function(self.widget)
111 function.assert_called_once_with('arg1', 'arg2')
112
113 def test_return_value(self):
114 # The closure returns the value returned by the original callback.
115 sqr = lambda value: value * value
116 thunk_function = ui.thunk(sqr, 3)
117 self.assertEqual(9, thunk_function(self.widget))
0118
=== added file 'quickstart/tests/cli/test_views.py'
--- quickstart/tests/cli/test_views.py 1970-01-01 00:00:00 +0000
+++ quickstart/tests/cli/test_views.py 2013-12-17 15:23:53 +0000
@@ -0,0 +1,86 @@
1# This file is part of the Juju Quickstart Plugin, which lets users set up a
2# Juju environment in very few steps (https://launchpad.net/juju-quickstart).
3# Copyright (C) 2013 Canonical Ltd.
4#
5# This program is free software: you can redistribute it and/or modify it under
6# the terms of the GNU Affero General Public License version 3, as published by
7# the Free Software Foundation.
8#
9# This program is distributed in the hope that it will be useful, but WITHOUT
10# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
11# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
12# Affero General Public License for more details.
13#
14# 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/>.
16
17"""Tests for the Juju Quickstart CLI application views."""
18
19from __future__ import unicode_literals
20
21from contextlib import contextmanager
22import unittest
23
24import mock
25
26from quickstart.cli import (
27 ui,
28 views,
29)
30
31
32class TestShow(unittest.TestCase):
33
34 @contextmanager
35 def patch_setup_urwid_app(self, run_side_effect=None):
36 """Patch the base.setup_urwid_app function.
37
38 The context manager returns a tuple (mock_loop, mock_app) containing
39 the two mock objects returned by the mock call.
40
41 The run_side_effect argument can be provided to specify the side
42 effects of the mock_loop.run call.
43 """
44 mock_loop = mock.Mock()
45 mock_loop.run = mock.Mock(side_effect=run_side_effect)
46 mock_setup_urwid_app = mock.Mock(return_value=(mock_loop, mock.Mock()))
47 setup_urwid_app_path = 'quickstart.cli.views.base.setup_urwid_app'
48 with mock.patch(setup_urwid_app_path, mock_setup_urwid_app):
49 yield mock_setup_urwid_app()
50
51 def test_show_view(self):
52 # The loop and app objects are properly used by the show function:
53 # the loop is run and the app is passed to the view.
54 view = mock.Mock()
55 with self.patch_setup_urwid_app() as (mock_loop, mock_app):
56 views.show(view)
57 view.assert_called_once_with(mock_app)
58 mock_loop.run.assert_called_once_with()
59
60 def test_view_exit(self):
61 # An ui.AppExit correctly quits the application. The return value
62 # encapsulated on the exception is also returned by the show function.
63 view = mock.Mock()
64 run_side_effect = ui.AppExit('bad wolf')
65 with self.patch_setup_urwid_app(run_side_effect=run_side_effect):
66 return_value = views.show(view)
67 self.assertEqual('bad wolf', return_value)
68
69 def test_unhandled_input(self):
70 # The unhandled_input callable is properly set up and registered to
71 # the main loop.
72 view = mock.Mock(return_value=42)
73 with self.patch_setup_urwid_app() as (mock_loop, mock_app):
74 views.show(view)
75 unhandled_input = mock_loop.set_unhandled_input.call_args[0][0]
76 with self.assertRaises(ui.AppExit) as context_manager:
77 unhandled_input(ui.EXIT_KEY)
78 self.assertEqual(42, context_manager.exception.return_value)
79
80 def test_view_arguments(self):
81 # The view is invoked passing the app and all the optional show
82 # function arguments.
83 view = mock.Mock()
84 with self.patch_setup_urwid_app() as (mock_loop, mock_app):
85 views.show(view, 'arg1', 'arg2')
86 view.assert_called_once_with(mock_app, 'arg1', 'arg2')
087
=== modified file 'quickstart/tests/test_utils.py'
--- quickstart/tests/test_utils.py 2013-12-10 14:37:58 +0000
+++ quickstart/tests/test_utils.py 2013-12-17 15:23:53 +0000
@@ -349,6 +349,31 @@
349 mock_call.assert_called_once_with('lsb_release', '-cs')349 mock_call.assert_called_once_with('lsb_release', '-cs')
350350
351351
352class TestObjectDict(unittest.TestCase):
353
354 def setUp(self):
355 self.object_dict = utils.ObjectDict(mykey='myvalue')
356
357 def test_get_attribute(self):
358 # A value can be retrieved accessing the key as an attribute.
359 self.assertEqual('myvalue', self.object_dict.mykey)
360
361 def test_get_attribute_error(self):
362 # An AttributeError is raised if the key is not in the dictionary.
363 with self.assertRaises(AttributeError):
364 self.object_dict.no_such_key
365
366 def test_set_attribute(self):
367 # A key can be added by setting the corresponding attribute.
368 self.object_dict.another_key = 42
369 self.assertEqual(42, self.object_dict['another_key'])
370
371 def test_update_attribute(self):
372 # It is possible to update an attribute.
373 self.object_dict.mykey = 47
374 self.assertEqual(47, self.object_dict.mykey)
375
376
352class TestParseBundle(377class TestParseBundle(
353 helpers.BundleFileTestsMixin, helpers.ValueErrorTestsMixin,378 helpers.BundleFileTestsMixin, helpers.ValueErrorTestsMixin,
354 unittest.TestCase):379 unittest.TestCase):
355380
=== modified file 'quickstart/utils.py'
--- quickstart/utils.py 2013-12-10 14:37:58 +0000
+++ quickstart/utils.py 2013-12-17 15:23:53 +0000
@@ -14,6 +14,22 @@
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
17"""Juju Quickstart utility functions and classes."""33"""Juju Quickstart utility functions and classes."""
1834
19from __future__ import (35from __future__ import (
@@ -187,6 +203,23 @@
187 return output.strip()203 return output.strip()
188204
189205
206class ObjectDict(dict):
207 """Makes a dictionary behave like an object, with attribute-style access.
208
209 Original:
210 http://www.tornadoweb.org/en/stable/_modules/tornado/util.html#ObjectDict
211 """
212
213 def __getattr__(self, name):
214 try:
215 return self[name]
216 except KeyError:
217 raise AttributeError(name)
218
219 def __setattr__(self, name, value):
220 self[name] = value
221
222
190def parse_bundle(bundle_yaml, bundle_name=None):223def parse_bundle(bundle_yaml, bundle_name=None):
191 """Parse the provided bundle YAML encoded contents.224 """Parse the provided bundle YAML encoded contents.
192225
193226
=== modified file 'requirements.pip'
--- requirements.pip 2013-11-14 10:37:29 +0000
+++ requirements.pip 2013-12-17 15:23:53 +0000
@@ -23,3 +23,4 @@
2323
24jujuclient==0.1124jujuclient==0.11
25PyYAML==3.1025PyYAML==3.10
26urwid==1.1.1

Subscribers

People subscribed via source and target branches