Merge lp:~frankban/juju-quickstart/quickstart-deploy into lp:juju-quickstart

Proposed by Francesco Banconi
Status: Merged
Merged at revision: 5
Proposed branch: lp:~frankban/juju-quickstart/quickstart-deploy
Merge into: lp:juju-quickstart
Diff against target: 625 lines (+497/-17)
10 files modified
.lbox (+1/-1)
Makefile (+1/-1)
quickstart/app.py (+46/-1)
quickstart/juju.py (+108/-0)
quickstart/manage.py (+3/-0)
quickstart/tests/helpers.py (+14/-14)
quickstart/tests/test_app.py (+84/-0)
quickstart/tests/test_juju.py (+210/-0)
quickstart/tests/test_utils.py (+20/-0)
quickstart/utils.py (+10/-0)
To merge this branch: bzr merge lp:~frankban/juju-quickstart/quickstart-deploy
Reviewer Review Type Date Requested Status
Juju GUI Hackers Pending
Review via email: mp+191679@code.launchpad.net

Description of the change

Deploy and expose the GUI.

Use a customized jujuclient.Environment
object in order to be able deploy to a
specific machine and to log traffic.

Tests: make check
QA: make run -> no errors and a resulting
juju environment with the GUI exposed
and deploying in the bootstrap node.

https://codereview.appspot.com/14764044/

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

Reviewers: mp+191679_code.launchpad.net,

Message:
Please take a look.

Description:
Deploy and expose the GUI.

Use a customized jujuclient.Environment
object in order to be able deploy to a
specific machine and to log traffic.

Tests: make check
QA: make run -> no errors and a resulting
juju environment with the GUI exposed
and deploying in the bootstrap node.

https://code.launchpad.net/~frankban/juju-quickstart/quickstart-deploy/+merge/191679

(do not edit description out of merge proposal)

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

Affected files (+499, -17 lines):
   M .lbox
   M Makefile
   A [revision details]
   M quickstart/app.py
   A quickstart/juju.py
   M quickstart/manage.py
   M quickstart/tests/helpers.py
   M quickstart/tests/test_app.py
   A quickstart/tests/test_juju.py
   M quickstart/tests/test_utils.py
   M quickstart/utils.py

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

LGTM, with trivial. Thank you!

https://codereview.appspot.com/14764044/diff/1/quickstart/app.py
File quickstart/app.py (right):

https://codereview.appspot.com/14764044/diff/1/quickstart/app.py#newcode115
quickstart/app.py:115: # Retrieve the URL of the last charm revision
from
Nice way to be incremental.

https://codereview.appspot.com/14764044/diff/1/quickstart/juju.py
File quickstart/juju.py (right):

https://codereview.appspot.com/14764044/diff/1/quickstart/juju.py#newcode79
quickstart/juju.py:79: with jujuclient.Watcher(self.conn) as watcher:
I'm guessing the watcher stops itself and closes when the context
manager hook is called?

https://codereview.appspot.com/14764044/diff/1/quickstart/juju.py#newcode80
quickstart/juju.py:80: watcher.start()
If the previous comment is correct, it might be nice to add a comment to
that regard here. On first glance it seems odd that you have to call
start but you don't have to call stop.

https://codereview.appspot.com/14764044/

Revision history for this message
Madison Scott-Clary (makyo) wrote :
Revision history for this message
Francesco Banconi (frankban) wrote :

*** Submitted:

Deploy and expose the GUI.

Use a customized jujuclient.Environment
object in order to be able deploy to a
specific machine and to log traffic.

Tests: make check
QA: make run -> no errors and a resulting
juju environment with the GUI exposed
and deploying in the bootstrap node.

R=gary.poster, matthew.scott
CC=
https://codereview.appspot.com/14764044

https://codereview.appspot.com/14764044/diff/1/quickstart/juju.py
File quickstart/juju.py (right):

https://codereview.appspot.com/14764044/diff/1/quickstart/juju.py#newcode79
quickstart/juju.py:79: with jujuclient.Watcher(self.conn) as watcher:
On 2013/10/17 17:20:13, gary.poster wrote:
> I'm guessing the watcher stops itself and closes when the context
manager hook
> is called?

Yes indeed, added a comment.

https://codereview.appspot.com/14764044/diff/1/quickstart/juju.py#newcode80
quickstart/juju.py:80: watcher.start()
On 2013/10/17 17:20:13, gary.poster wrote:
> If the previous comment is correct, it might be nice to add a comment
to that
> regard here. On first glance it seems odd that you have to call start
but you
> don't have to call stop.

Well, removed the start call. The watcher is not started by the context
manager's __start__, but its "next" method does the following:
     if self.watcher_id is None:
         self.start()
So I hope removing the start() call makes the code less surprising.
Thanks for this suggestion.

https://codereview.appspot.com/14764044/

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

Thank you both for the reviews!

https://codereview.appspot.com/14764044/

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file '.lbox'
2--- .lbox 2013-10-14 11:43:06 +0000
3+++ .lbox 2013-10-17 16:57:02 +0000
4@@ -1,1 +1,1 @@
5-propose -cr -for lp:juju-gui/juju-quickstart
6+propose -cr -for lp:juju-quickstart
7
8=== modified file 'Makefile'
9--- Makefile 2013-10-15 18:25:57 +0000
10+++ Makefile 2013-10-17 16:57:02 +0000
11@@ -63,7 +63,7 @@
12 @$(VENV)/bin/flake8 --show-source --exclude=$(VENV) ./quickstart
13
14 run: setup
15- $(VENV)/bin/python ./juju-quickstart
16+ $(VENV)/bin/python ./juju-quickstart --debug
17
18 source:
19 $(PYTHON) setup.py sdist
20
21=== modified file 'quickstart/app.py'
22--- quickstart/app.py 2013-10-16 15:59:23 +0000
23+++ quickstart/app.py 2013-10-17 16:57:02 +0000
24@@ -18,7 +18,12 @@
25
26 import json
27
28-from quickstart import utils
29+import jujuclient
30+
31+from quickstart import (
32+ juju,
33+ utils,
34+)
35
36
37 class ProgramExit(Exception):
38@@ -66,6 +71,7 @@
39
40 Use the Juju CLI in a subprocess in order to retrieve the API addresses.
41 Return the complete URL, e.g. "wss://api.example.com:17070".
42+ Raise a ProgramExit if any error occurs.
43 """
44 retcode, output, error = utils.call(
45 'juju', 'api-endpoints', '-e', env_name, '--format', 'json')
46@@ -75,3 +81,42 @@
47 # from the JSON output.
48 api_address = json.loads(output)[0]
49 return 'wss://{}'.format(api_address)
50+
51+
52+def connect(api_url, admin_secret):
53+ """Connect to the Juju API server and log in using the given credentials.
54+
55+ Return a connected and authenticated Juju Environment instance.
56+ Raise a ProgramExit if any error occurs while establishing the WebSocket
57+ connection or if the API returns an error response.
58+ """
59+ try:
60+ env = juju.connect(api_url)
61+ except Exception as err:
62+ msg = 'unable to connect to the Juju API server on {}: {}'
63+ raise ProgramExit(msg.format(api_url, err))
64+ try:
65+ env.login(admin_secret)
66+ except jujuclient.EnvError as err:
67+ msg = 'unable to log in to the Juju API server on {}: {}'
68+ raise ProgramExit(msg.format(api_url, err.message))
69+ return env
70+
71+
72+def deploy_gui(env, service_name):
73+ """Deploy and expose the given service, reusing the bootstrap node.
74+
75+ Receive an authenticated Juju Environment instance, the name of the service
76+ and the corresponding charm URL.
77+
78+ Raise a ProgramExit if the API server returns an error response.
79+ """
80+ # XXX 2013-10-17 frankban:
81+ # Retrieve the URL of the last charm revision from
82+ # manage.jujucharms.com.
83+ charm_url = 'cs:precise/juju-gui-77'
84+ try:
85+ env.deploy(service_name, charm_url, to=0)
86+ env.expose(service_name)
87+ except jujuclient.EnvError as err:
88+ raise ProgramExit('bad API server response: {}'.format(err.message))
89
90=== added file 'quickstart/juju.py'
91--- quickstart/juju.py 1970-01-01 00:00:00 +0000
92+++ quickstart/juju.py 2013-10-17 16:57:02 +0000
93@@ -0,0 +1,108 @@
94+# This file is part of the Juju GUI, which lets users view and manage Juju
95+# environments within a graphical interface (https://launchpad.net/juju-gui).
96+# Copyright (C) 2013 Canonical Ltd.
97+#
98+# This program is free software: you can redistribute it and/or modify it under
99+# the terms of the GNU Affero General Public License version 3, as published by
100+# the Free Software Foundation.
101+#
102+# This program is distributed in the hope that it will be useful, but WITHOUT
103+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
104+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
105+# Affero General Public License for more details.
106+#
107+# You should have received a copy of the GNU Affero General Public License
108+# along with this program. If not, see <http://www.gnu.org/licenses/>.
109+
110+"""Juju Quickstart API client."""
111+
112+import logging
113+
114+import jujuclient
115+import websocket
116+
117+from quickstart import utils
118+
119+
120+def connect(api_url):
121+ """Return an Environment instance connected to the given API URL."""
122+ connection = WebSocketConnection()
123+ # See the websocket.create_connection function.
124+ connection.settimeout(websocket.default_timeout)
125+ connection.connect(api_url, origin=api_url)
126+ return Environment(api_url, conn=connection)
127+
128+
129+class Environment(jujuclient.Environment):
130+ """A Juju bootstrapped environment.
131+
132+ Instances of this class can be used to run API operations on a Juju
133+ environment. This is a subclass of the jujuclient.Environment object.
134+ """
135+
136+ def deploy(
137+ self, service_name, charm_url, num_units=1, config=None,
138+ constraints=None, to=None):
139+ """Deploy a charm. Local charms are not supported.
140+
141+ This method is overridden to add the ability to deploy to a specific
142+ machine (i.e. support the ToMachineSpec API parameter).
143+ """
144+ service_config = {}
145+ if config is not None:
146+ service_config = self._prepare_strparams(config)
147+ service_constraints = {}
148+ if constraints is not None:
149+ service_constraints = self._prepare_constraints(constraints)
150+ params = {
151+ 'ServiceName': service_name,
152+ 'CharmURL': charm_url,
153+ 'NumUnits': num_units,
154+ 'Config': service_config,
155+ 'Constraints': service_constraints,
156+ }
157+ if to is not None:
158+ params['ToMachineSpec'] = str(to)
159+ request = {
160+ 'Type': 'Client',
161+ 'Request': 'ServiceDeploy',
162+ 'Params': params,
163+ }
164+ return self._rpc(request)
165+
166+ def watch_changes(self, processor):
167+ """Start watching the changes occurring in the Juju environment.
168+
169+ For each changeset, call the given processor callable, and yield
170+ the values returned by the processor.
171+ """
172+ with jujuclient.Watcher(self.conn) as watcher:
173+ watcher.start()
174+ for changeset in watcher:
175+ changes = processor(changeset)
176+ if changes:
177+ yield changes
178+
179+
180+class WebSocketConnection(websocket.WebSocket):
181+ """A WebSocket client connection.
182+
183+ This is a subclass of the websocket.WebSocket object.
184+ """
185+
186+ def send(self, message):
187+ """Send the given WebSocket message.
188+
189+ Overridden to add logging.
190+ """
191+ logging.debug('API message: --> {}'.format(utils.utf8(message)))
192+ return super(WebSocketConnection, self).send(message)
193+
194+ def recv(self):
195+ """Receive a message from the WebSocket server.
196+
197+ Overridden to add logging.
198+ """
199+ message = super(WebSocketConnection, self).recv()
200+ logging.debug('API message: <-- {}'.format(utils.utf8(message)))
201+ return message
202
203=== modified file 'quickstart/manage.py'
204--- quickstart/manage.py 2013-10-16 15:23:08 +0000
205+++ quickstart/manage.py 2013-10-17 16:57:02 +0000
206@@ -140,3 +140,6 @@
207 print('retrieving the Juju API address')
208 api_url = app.get_api_url(options.env_name)
209 print('connecting to {}'.format(api_url))
210+ env = app.connect(api_url, options.admin_secret)
211+ print('deploying Juju GUI')
212+ app.deploy_gui(env, 'juju-gui')
213
214=== modified file 'quickstart/tests/helpers.py'
215--- quickstart/tests/helpers.py 2013-10-16 12:12:31 +0000
216+++ quickstart/tests/helpers.py 2013-10-17 16:57:02 +0000
217@@ -24,20 +24,6 @@
218 import yaml
219
220
221-class CallTestsMixin(object):
222- """Easily use the quickstart.utils.call function."""
223-
224- def patch_call(self, retcode, output='', error=''):
225- """Patch the quickstart.utils.call function."""
226- mock_call = mock.Mock(return_value=(retcode, output, error))
227- return mock.patch('quickstart.utils.call', mock_call)
228-
229- def patch_multiple_calls(self, side_effect):
230- """Patch multiple subsequent quickstart.utils.call calls."""
231- mock_call = mock.Mock(side_effect=side_effect)
232- return mock.patch('quickstart.utils.call', mock_call)
233-
234-
235 @contextmanager
236 def assert_logs(messages, level='debug'):
237 """Ensure the given messages are logged using the given log level.
238@@ -51,6 +37,20 @@
239 mock_log.assert_has_calls(expected_calls)
240
241
242+class CallTestsMixin(object):
243+ """Easily use the quickstart.utils.call function."""
244+
245+ def patch_call(self, retcode, output='', error=''):
246+ """Patch the quickstart.utils.call function."""
247+ mock_call = mock.Mock(return_value=(retcode, output, error))
248+ return mock.patch('quickstart.utils.call', mock_call)
249+
250+ def patch_multiple_calls(self, side_effect):
251+ """Patch multiple subsequent quickstart.utils.call calls."""
252+ mock_call = mock.Mock(side_effect=side_effect)
253+ return mock.patch('quickstart.utils.call', mock_call)
254+
255+
256 class EnvFileTestsMixin(object):
257 """Shared methods for testing a Juju environments file."""
258
259
260=== modified file 'quickstart/tests/test_app.py'
261--- quickstart/tests/test_app.py 2013-10-16 15:59:23 +0000
262+++ quickstart/tests/test_app.py 2013-10-17 16:57:02 +0000
263@@ -20,6 +20,7 @@
264 import json
265 import unittest
266
267+import jujuclient
268 import mock
269 import yaml
270
271@@ -49,6 +50,10 @@
272 expected = 'juju-quickstart: error: {}'.format(error)
273 self.assertEqual(expected, str(context_manager.exception))
274
275+ def make_env_error(self, message):
276+ """Create and return a jujuclient.EnvError with the given message."""
277+ return jujuclient.EnvError({'Error': message})
278+
279
280 class TestBootstrap(
281 helpers.CallTestsMixin, ProgramExitTestsMixin, unittest.TestCase):
282@@ -181,3 +186,82 @@
283 app.get_api_url(self.env_name)
284 mock_call.assert_called_once_with(
285 'juju', 'api-endpoints', '-e', self.env_name, '--format', 'json')
286+
287+
288+class TestConnect(ProgramExitTestsMixin, unittest.TestCase):
289+
290+ admin_secret = 'Secret!'
291+ api_url = 'wss://api.example.com:17070'
292+
293+ def test_connection_established(self):
294+ # The connection is done and the Environment instance is returned.
295+ with mock.patch('quickstart.juju.connect') as mock_connect:
296+ env = app.connect(self.api_url, self.admin_secret)
297+ mock_connect.assert_called_once_with(self.api_url)
298+ mock_env = mock_connect()
299+ mock_env.login.assert_called_once_with(self.admin_secret)
300+ self.assertEqual(mock_env, env)
301+
302+ def test_connection_error(self):
303+ # A ProgramExit is raised if an error occurs in the connection.
304+ mock_connect = mock.Mock(side_effect=ValueError('bad wolf'))
305+ expected = 'unable to connect to the Juju API server on {}: bad wolf'
306+ with mock.patch('quickstart.juju.connect', mock_connect):
307+ with self.assert_program_exit(expected.format(self.api_url)):
308+ app.connect(self.api_url, self.admin_secret)
309+ mock_connect.assert_called_once_with(self.api_url)
310+
311+ def test_authentication_error(self):
312+ # A ProgramExit is raised if an error occurs in the authentication.
313+ expected = 'unable to log in to the Juju API server on {}: bad wolf'
314+ with mock.patch('quickstart.juju.connect') as mock_connect:
315+ mock_login = mock_connect().login
316+ mock_login.side_effect = self.make_env_error('bad wolf')
317+ with self.assert_program_exit(expected.format(self.api_url)):
318+ app.connect(self.api_url, self.admin_secret)
319+ mock_connect.assert_called_with(self.api_url)
320+ mock_login.assert_called_once_with(self.admin_secret)
321+
322+ def test_other_errors(self):
323+ # Any other errors occurred during the log in process are not trapped.
324+ error = ValueError('explode!')
325+ with mock.patch('quickstart.juju.connect') as mock_connect:
326+ mock_login = mock_connect().login
327+ mock_login.side_effect = error
328+ with self.assertRaises(ValueError) as context_manager:
329+ app.connect(self.api_url, self.admin_secret)
330+ self.assertIs(error, context_manager.exception)
331+
332+
333+class TestDeployGui(ProgramExitTestsMixin, unittest.TestCase):
334+
335+ def test_deployment(self):
336+ # The function correctly deploys and exposes the service.
337+ env = mock.Mock()
338+ app.deploy_gui(env, 'my-gui')
339+ env.assert_has_calls([
340+ mock.call.deploy('my-gui', 'cs:precise/juju-gui-77', to=0),
341+ mock.call.expose('my-gui')
342+ ])
343+
344+ def test_api_error(self):
345+ # A ProgramExit is raised if an error occurs in one of the API calls.
346+ env = mock.Mock()
347+ env.deploy.side_effect = self.make_env_error('service already exists')
348+ expected = 'bad API server response: service already exists'
349+ with self.assert_program_exit(expected):
350+ app.deploy_gui(env, 'another-gui')
351+ env.deploy.assert_called_once_with(
352+ 'another-gui', 'cs:precise/juju-gui-77', to=0)
353+
354+ def test_other_errors(self):
355+ # Any other errors occurred during the process are not trapped.
356+ error = ValueError('explode!')
357+ env = mock.Mock()
358+ env.expose.side_effect = error
359+ with self.assertRaises(ValueError) as context_manager:
360+ app.deploy_gui(env, 'juju-gui')
361+ env.deploy.assert_called_once_with(
362+ 'juju-gui', 'cs:precise/juju-gui-77', to=0)
363+ env.expose.assert_called_once_with('juju-gui')
364+ self.assertIs(error, context_manager.exception)
365
366=== added file 'quickstart/tests/test_juju.py'
367--- quickstart/tests/test_juju.py 1970-01-01 00:00:00 +0000
368+++ quickstart/tests/test_juju.py 2013-10-17 16:57:02 +0000
369@@ -0,0 +1,210 @@
370+# This file is part of the Juju GUI, which lets users view and manage Juju
371+# environments within a graphical interface (https://launchpad.net/juju-gui).
372+# Copyright (C) 2013 Canonical Ltd.
373+#
374+# This program is free software: you can redistribute it and/or modify it under
375+# the terms of the GNU Affero General Public License version 3, as published by
376+# the Free Software Foundation.
377+#
378+# This program is distributed in the hope that it will be useful, but WITHOUT
379+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
380+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
381+# Affero General Public License for more details.
382+#
383+# You should have received a copy of the GNU Affero General Public License
384+# along with this program. If not, see <http://www.gnu.org/licenses/>.
385+
386+"""Tests for the Juju Quickstart API client."""
387+
388+import unittest
389+
390+import mock
391+import websocket
392+
393+from quickstart import juju
394+from quickstart.tests import helpers
395+
396+
397+class TestConnect(unittest.TestCase):
398+
399+ api_url = 'wss://api.example.com:17070'
400+
401+ @mock.patch('quickstart.juju.WebSocketConnection')
402+ def test_environment_connection(self, mock_conn):
403+ # A connected Environment instance is correctly returned.
404+ env = juju.connect(self.api_url)
405+ mock_conn.assert_called_once_with()
406+ conn = mock_conn()
407+ conn.assert_has_calls([
408+ mock.call.settimeout(websocket.default_timeout),
409+ mock.call.connect(self.api_url, origin=self.api_url)
410+ ])
411+ self.assertIsInstance(env, juju.Environment)
412+ self.assertEqual(self.api_url, env.endpoint)
413+ self.assertEqual(conn, env.conn)
414+
415+
416+class TestEnvironment(unittest.TestCase):
417+
418+ api_url = 'wss://api.example.com:17070'
419+ charm_url = 'cs:precise/juju-gui-77'
420+ service_name = 'juju-gui'
421+
422+ def setUp(self):
423+ # Set up an Environment instance.
424+ api_url = self.api_url
425+ with mock.patch('websocket.create_connection') as mock_connect:
426+ self.env = juju.Environment(api_url)
427+ mock_connect.assert_called_once_with(api_url, origin=api_url)
428+ # Keep track of watcher changes in the changesets list.
429+ self.changesets = []
430+
431+ def make_deploy_request(self, **kwargs):
432+ """Create and return a deploy request.
433+
434+ Use kwargs to add or override request parameters.
435+ """
436+ params = {
437+ 'ServiceName': self.service_name,
438+ 'CharmURL': self.charm_url,
439+ 'NumUnits': 1,
440+ 'Config': {},
441+ 'Constraints': {},
442+ }
443+ params.update(kwargs)
444+ return {
445+ 'Type': 'Client',
446+ 'Request': 'ServiceDeploy',
447+ 'Params': params,
448+ }
449+
450+ def processor(self, changeset):
451+ self.changesets.append(changeset)
452+ return changeset
453+
454+ @mock.patch('quickstart.juju.Environment._rpc')
455+ def test_deploy(self, mock_rpc):
456+ # The deploy API call is properly generated.
457+ self.env.deploy(self.service_name, self.charm_url)
458+ mock_rpc.assert_called_once_with(self.make_deploy_request())
459+
460+ @mock.patch('quickstart.juju.Environment._rpc')
461+ def test_deploy_config(self, mock_rpc):
462+ # The deploy API call is properly generated when passing settings.
463+ self.env.deploy(
464+ self.service_name, self.charm_url,
465+ config={'key1': 'value1', 'key2': 42})
466+ expected = self.make_deploy_request(
467+ Config={'key1': 'value1', 'key2': '42'})
468+ mock_rpc.assert_called_once_with(expected)
469+
470+ @mock.patch('quickstart.juju.Environment._rpc')
471+ def test_deploy_constraints(self, mock_rpc):
472+ # The deploy API call is properly generated when passing constraints.
473+ constraints = {'cpu-cores': 8, 'mem': 16}
474+ self.env.deploy(
475+ self.service_name, self.charm_url, constraints=constraints)
476+ expected = self.make_deploy_request(Constraints=constraints)
477+ mock_rpc.assert_called_once_with(expected)
478+
479+ @mock.patch('quickstart.juju.Environment._rpc')
480+ def test_deploy_to(self, mock_rpc):
481+ # The deploy API call is properly generated when passing a machine
482+ # specification.
483+ self.env.deploy(self.service_name, self.charm_url, to=0)
484+ expected = self.make_deploy_request(ToMachineSpec='0')
485+ mock_rpc.assert_called_once_with(expected)
486+
487+ @mock.patch('quickstart.juju.jujuclient.Watcher._rpc')
488+ def test_watch_changes(self, mock_rpc):
489+ # It is possible to watch for changes using a processor callable.
490+ changeset1 = ['change1', 'change2']
491+ changeset2 = ['change3']
492+ mock_rpc.side_effect = [
493+ # Define the response to the watcher's start call.
494+ {'Response': {}, 'AllWatcherId': 42},
495+ # Define two responses to the two subsequent next calls.
496+ {'Response': {}, 'Deltas': changeset1},
497+ {'Response': {}, 'Deltas': changeset2},
498+ # Define the response to the watcher's stop call.
499+ {'Response': {}},
500+ ]
501+ watcher = self.env.watch_changes(self.processor)
502+ # The first set of changes is correctly returned.
503+ changeset = watcher.next()
504+ self.assertEqual(changeset1, changeset)
505+ # The second set of changes is correctly returned.
506+ changeset = watcher.next()
507+ self.assertEqual(changeset2, changeset)
508+ # All the changes have been processed.
509+ self.assertEqual([changeset1, changeset2], self.changesets)
510+ # Ensure the API has been used properly.
511+ mock_rpc.assert_has_calls([
512+ mock.call({'Type': 'Client', 'Request': 'WatchAll', 'Params': {}}),
513+ mock.call({'Type': 'AllWatcher', 'Request': 'Next', 'Id': 42}),
514+ mock.call({'Type': 'AllWatcher', 'Request': 'Next', 'Id': 42}),
515+ ])
516+
517+ @mock.patch('quickstart.juju.jujuclient.Watcher._rpc')
518+ def test_watch_closed(self, mock_rpc):
519+ # A stop API call on the AllWatcher is performed when the watcher is
520+ # garbage collected.
521+ mock_rpc.side_effect = [
522+ # Define the response to the watcher's start call.
523+ {'Response': {}, 'AllWatcherId': 42},
524+ # Define a response to a next call.
525+ {'Response': {}, 'Deltas': ['change1', 'change2']},
526+ # Define the response to the watcher's stop call.
527+ {'Response': {}},
528+ ]
529+ watcher = self.env.watch_changes(self.processor)
530+ # The first set of changes is correctly returned.
531+ watcher.next()
532+ del watcher
533+ # Ensure the API has been used properly.
534+ mock_rpc.assert_has_calls([
535+ mock.call({'Type': 'Client', 'Request': 'WatchAll', 'Params': {}}),
536+ mock.call({'Type': 'AllWatcher', 'Request': 'Next', 'Id': 42}),
537+ mock.call({'Type': 'AllWatcher', 'Request': 'Stop', 'Id': 42}),
538+ ])
539+
540+
541+class TestWebSocketConnection(unittest.TestCase):
542+
543+ snowman = u'Here is a snowman\u00a1: \u2603'
544+
545+ def setUp(self):
546+ with mock.patch('socket.socket') as mock_socket:
547+ self.conn = juju.WebSocketConnection()
548+ # Patch the socket.send() function used by the send method.
549+ self.mock_send = mock_socket().send
550+ # The recv method calls the recv_data one.
551+ self.conn.recv_data = self.mock_recv = mock.Mock()
552+
553+ def test_send(self):
554+ # Outgoing messages are properly logged.
555+ with helpers.assert_logs(['API message: --> my message'], 'debug'):
556+ self.conn.send('my message')
557+ self.assertTrue(self.mock_send.called)
558+
559+ def test_send_unicode(self):
560+ # Outgoing unicode messages are properly logged.
561+ expected = 'API message: --> {}'.format(self.snowman.encode('utf-8'))
562+ with helpers.assert_logs([expected], 'debug'):
563+ self.conn.send(self.snowman)
564+ self.assertTrue(self.mock_send.called)
565+
566+ def test_recv(self):
567+ # Incoming messages are properly logged.
568+ self.mock_recv.return_value = (42, 'my message')
569+ with helpers.assert_logs(['API message: <-- my message'], 'debug'):
570+ self.conn.recv()
571+ self.mock_recv.assert_called_once_with()
572+
573+ def test_recv_unicode(self):
574+ # Incoming unicode messages are properly logged.
575+ self.mock_recv.return_value = (42, self.snowman)
576+ expected = 'API message: <-- {}'.format(self.snowman.encode('utf-8'))
577+ with helpers.assert_logs([expected], 'debug'):
578+ self.conn.recv()
579+ self.mock_recv.assert_called_once_with()
580
581=== modified file 'quickstart/tests/test_utils.py'
582--- quickstart/tests/test_utils.py 2013-10-16 12:12:31 +0000
583+++ quickstart/tests/test_utils.py 2013-10-17 16:57:02 +0000
584@@ -216,3 +216,23 @@
585 })
586 agent_state = utils.parse_status_output(output)
587 self.assertEqual('started', agent_state)
588+
589+
590+class TestUtf8(unittest.TestCase):
591+
592+ def test_unicode(self):
593+ # A unicode value is correctly converted.
594+ value = utils.utf8(u'foo')
595+ self.assertIsInstance(value, str)
596+ self.assertEqual('foo', value)
597+
598+ def test_bytes(self):
599+ # A bytes value is left untouched.
600+ original = 'foo'
601+ value = utils.utf8(original)
602+ self.assertIsInstance(value, str)
603+ self.assertIs(original, value)
604+
605+ def test_none(self):
606+ # The None value is returned as is.
607+ self.assertIsNone(utils.utf8(None))
608
609=== modified file 'quickstart/utils.py'
610--- quickstart/utils.py 2013-10-16 12:12:31 +0000
611+++ quickstart/utils.py 2013-10-17 16:57:02 +0000
612@@ -143,3 +143,13 @@
613 if state is None:
614 raise ValueError('agent state not found in {}'.format(status))
615 return state
616+
617+
618+def utf8(value):
619+ """Return the utf8 encoded version of the given value.
620+
621+ The given value is returned as is if already encoded or not a string.
622+ """
623+ if isinstance(value, unicode):
624+ return value.encode('utf-8')
625+ return value

Subscribers

People subscribed via source and target branches