Merge lp:~gmb/charm-tools/add-charm-helpers into lp:~charmers/charm-tools/trunk

Proposed by Graham Binns
Status: Merged
Approved by: Clint Byrum
Approved revision: 146
Merged at revision: 156
Proposed branch: lp:~gmb/charm-tools/add-charm-helpers
Merge into: lp:~charmers/charm-tools/trunk
Diff against target: 863 lines (+833/-0)
5 files modified
Makefile (+1/-0)
ez_setup.py (+288/-0)
helpers/python/charmhelpers/__init__.py (+181/-0)
helpers/python/charmhelpers/tests/test_charmhelpers.py (+331/-0)
setup.py (+32/-0)
To merge this branch: bzr merge lp:~gmb/charm-tools/add-charm-helpers
Reviewer Review Type Date Requested Status
Clint Byrum (community) Approve
Review via email: mp+96204@code.launchpad.net

Description of the change

This branch adds Python helpers to the charm-tools helpers/ directory. The Python helpers code comes from our (Launchpad Yellow Squad) efforts to make charms for buildbot and buildslave, and should be generic enough for any charms whose hooks are written in Python.

To post a comment you must log in.
131. By Graham Binns

Debugging...

132. By Graham Binns

Added setuptools egg. This is a terrible idea.

133. By Graham Binns

Removed cruft.

134. By Graham Binns

Tweaked Makefile.

135. By Graham Binns

Tweaked setup.py.

136. By Graham Binns

Undid everything that I changed since Brad's branch.

Revision history for this message
Clint Byrum (clint-fewbar) wrote :

These all look good, but I'd like to see some tests!

I had to manually import the module to find out I was missing 'shelltoolbox'.

It took me another 20 minutes to figure out that this was where the meat of what you guys have done lives, and its not yet packaged, so depending on it becomes problematic.

Anyway, I think this is good to go, but needs to be deferred until python-shell-toolbox is packaged so that this module can depend on it.

incidentally, I was able to get the packaging for charm-tools setup in a way to build/install this module here:

lp:~clint-fewbar/ubuntu/precise/charm-tools/add-python-packaging

137. By Graham Binns

Added initial tests.

138. By Graham Binns

Added more tests.

139. By Graham Binns

And yet more tests...

140. By Graham Binns

More tests, plus unit_info tweakage.

141. By Graham Binns

More tests.

142. By Graham Binns

Tests for wait_for_machine().

143. By Graham Binns

Added tests for wait_for_unit.

144. By Graham Binns

Added tests for wait_for_relation.

145. By Graham Binns

Added tests for wait_for_page_contents.

146. By Graham Binns

Added Python tests to Makefile.

Revision history for this message
Graham Binns (gmb) wrote :

Hi Clint,

As you can see, I've added plenty of test :). The whole module should now be covered. I've used testools in order to be able to do some safe monkey-patching, so python-testtools needs to be made a dependency of charm-tools.

I'll look into the python-shell-toolbox packaging today (I think it's already in the ~yellow PPA).

Revision history for this message
Graham Binns (gmb) wrote :

Confirmed; python-shell-toolbox is packaged in ppa:yellow/ppa. Not sure how you want to proceed with it from there.

Revision history for this message
Clint Byrum (clint-fewbar) wrote :

Graham, this is fantastic.

One thing, python policy requires that the binary package name be

python-shelltoolbox

So as to match the python module to the package name easily.

I like the way these look, and think they'll be fantastic additions for charm authors writing charms in python.

+1

Next steps:

We should pull python-shelltoolbox (once re-named) into the charm-helpers PPA, and host the packaging branch for that under ~charmers. I'd like to delay that until we have at least submitted python-shelltoolbox to Ubuntu so we can make use of it in the archive version of charm-tools.

Please ACK the binary package name change and I'll handle the upload to Ubuntu and subsequent adding to the PPA/merging into charm-tools trunk.

THANKS YELLOW SQUAD!

review: Approve
Revision history for this message
Graham Binns (gmb) wrote :

Hi Clint,

Consider this the ACK you requested; let's rename it and get it out
into the wide world.

Let me know if there's anything else you need from me; I'll take care
of it first thing tomorrow.

Cheers,

Graham

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'Makefile'
2--- Makefile 2012-01-03 18:19:56 +0000
3+++ Makefile 2012-03-19 11:55:22 +0000
4@@ -25,3 +25,4 @@
5 tests/helpers/helpers.sh || sh -x tests/helpers/helpers.sh timeout
6 @echo Test shell helpers with bash
7 bash tests/helpers/helpers.sh || bash -x tests/helpers/helpers.sh timeout
8+ PYTHONPATH=helpers/python python helpers/python/charmhelpers/tests/test_charmhelpers.py
9
10=== added file 'ez_setup.py'
11--- ez_setup.py 1970-01-01 00:00:00 +0000
12+++ ez_setup.py 2012-03-19 11:55:22 +0000
13@@ -0,0 +1,288 @@
14+#!python
15+
16+# NOTE TO LAUNCHPAD DEVELOPERS: This is a bootstrapping file from the
17+# setuptools project. It is imported by our setup.py.
18+
19+"""Bootstrap setuptools installation
20+
21+If you want to use setuptools in your package's setup.py, just include this
22+file in the same directory with it, and add this to the top of your setup.py::
23+
24+ from ez_setup import use_setuptools
25+ use_setuptools()
26+
27+If you want to require a specific version of setuptools, set a download
28+mirror, or use an alternate download directory, you can do so by supplying
29+the appropriate options to ``use_setuptools()``.
30+
31+This file can also be run as a script to install or upgrade setuptools.
32+"""
33+import sys
34+DEFAULT_VERSION = "0.6c11"
35+DEFAULT_URL = "http://pypi.python.org/packages/%s/s/setuptools/" % sys.version[:3]
36+
37+md5_data = {
38+ 'setuptools-0.6b1-py2.3.egg': '8822caf901250d848b996b7f25c6e6ca',
39+ 'setuptools-0.6b1-py2.4.egg': 'b79a8a403e4502fbb85ee3f1941735cb',
40+ 'setuptools-0.6b2-py2.3.egg': '5657759d8a6d8fc44070a9d07272d99b',
41+ 'setuptools-0.6b2-py2.4.egg': '4996a8d169d2be661fa32a6e52e4f82a',
42+ 'setuptools-0.6b3-py2.3.egg': 'bb31c0fc7399a63579975cad9f5a0618',
43+ 'setuptools-0.6b3-py2.4.egg': '38a8c6b3d6ecd22247f179f7da669fac',
44+ 'setuptools-0.6b4-py2.3.egg': '62045a24ed4e1ebc77fe039aa4e6f7e5',
45+ 'setuptools-0.6b4-py2.4.egg': '4cb2a185d228dacffb2d17f103b3b1c4',
46+ 'setuptools-0.6c1-py2.3.egg': 'b3f2b5539d65cb7f74ad79127f1a908c',
47+ 'setuptools-0.6c1-py2.4.egg': 'b45adeda0667d2d2ffe14009364f2a4b',
48+ 'setuptools-0.6c10-py2.3.egg': 'ce1e2ab5d3a0256456d9fc13800a7090',
49+ 'setuptools-0.6c10-py2.4.egg': '57d6d9d6e9b80772c59a53a8433a5dd4',
50+ 'setuptools-0.6c10-py2.5.egg': 'de46ac8b1c97c895572e5e8596aeb8c7',
51+ 'setuptools-0.6c10-py2.6.egg': '58ea40aef06da02ce641495523a0b7f5',
52+ 'setuptools-0.6c11-py2.3.egg': '2baeac6e13d414a9d28e7ba5b5a596de',
53+ 'setuptools-0.6c11-py2.4.egg': 'bd639f9b0eac4c42497034dec2ec0c2b',
54+ 'setuptools-0.6c11-py2.5.egg': '64c94f3bf7a72a13ec83e0b24f2749b2',
55+ 'setuptools-0.6c11-py2.6.egg': 'bfa92100bd772d5a213eedd356d64086',
56+ 'setuptools-0.6c2-py2.3.egg': 'f0064bf6aa2b7d0f3ba0b43f20817c27',
57+ 'setuptools-0.6c2-py2.4.egg': '616192eec35f47e8ea16cd6a122b7277',
58+ 'setuptools-0.6c3-py2.3.egg': 'f181fa125dfe85a259c9cd6f1d7b78fa',
59+ 'setuptools-0.6c3-py2.4.egg': 'e0ed74682c998bfb73bf803a50e7b71e',
60+ 'setuptools-0.6c3-py2.5.egg': 'abef16fdd61955514841c7c6bd98965e',
61+ 'setuptools-0.6c4-py2.3.egg': 'b0b9131acab32022bfac7f44c5d7971f',
62+ 'setuptools-0.6c4-py2.4.egg': '2a1f9656d4fbf3c97bf946c0a124e6e2',
63+ 'setuptools-0.6c4-py2.5.egg': '8f5a052e32cdb9c72bcf4b5526f28afc',
64+ 'setuptools-0.6c5-py2.3.egg': 'ee9fd80965da04f2f3e6b3576e9d8167',
65+ 'setuptools-0.6c5-py2.4.egg': 'afe2adf1c01701ee841761f5bcd8aa64',
66+ 'setuptools-0.6c5-py2.5.egg': 'a8d3f61494ccaa8714dfed37bccd3d5d',
67+ 'setuptools-0.6c6-py2.3.egg': '35686b78116a668847237b69d549ec20',
68+ 'setuptools-0.6c6-py2.4.egg': '3c56af57be3225019260a644430065ab',
69+ 'setuptools-0.6c6-py2.5.egg': 'b2f8a7520709a5b34f80946de5f02f53',
70+ 'setuptools-0.6c7-py2.3.egg': '209fdf9adc3a615e5115b725658e13e2',
71+ 'setuptools-0.6c7-py2.4.egg': '5a8f954807d46a0fb67cf1f26c55a82e',
72+ 'setuptools-0.6c7-py2.5.egg': '45d2ad28f9750e7434111fde831e8372',
73+ 'setuptools-0.6c8-py2.3.egg': '50759d29b349db8cfd807ba8303f1902',
74+ 'setuptools-0.6c8-py2.4.egg': 'cba38d74f7d483c06e9daa6070cce6de',
75+ 'setuptools-0.6c8-py2.5.egg': '1721747ee329dc150590a58b3e1ac95b',
76+ 'setuptools-0.6c9-py2.3.egg': 'a83c4020414807b496e4cfbe08507c03',
77+ 'setuptools-0.6c9-py2.4.egg': '260a2be2e5388d66bdaee06abec6342a',
78+ 'setuptools-0.6c9-py2.5.egg': 'fe67c3e5a17b12c0e7c541b7ea43a8e6',
79+ 'setuptools-0.6c9-py2.6.egg': 'ca37b1ff16fa2ede6e19383e7b59245a',
80+}
81+
82+import sys, os
83+try: from hashlib import md5
84+except ImportError: from md5 import md5
85+
86+def _validate_md5(egg_name, data):
87+ if egg_name in md5_data:
88+ digest = md5(data).hexdigest()
89+ if digest != md5_data[egg_name]:
90+ print >>sys.stderr, (
91+ "md5 validation of %s failed! (Possible download problem?)"
92+ % egg_name
93+ )
94+ sys.exit(2)
95+ return data
96+
97+def use_setuptools(
98+ version=DEFAULT_VERSION, download_base=DEFAULT_URL, to_dir=os.curdir,
99+ download_delay=15
100+):
101+ """Automatically find/download setuptools and make it available on sys.path
102+
103+ `version` should be a valid setuptools version number that is available
104+ as an egg for download under the `download_base` URL (which should end with
105+ a '/'). `to_dir` is the directory where setuptools will be downloaded, if
106+ it is not already available. If `download_delay` is specified, it should
107+ be the number of seconds that will be paused before initiating a download,
108+ should one be required. If an older version of setuptools is installed,
109+ this routine will print a message to ``sys.stderr`` and raise SystemExit in
110+ an attempt to abort the calling script.
111+ """
112+ was_imported = 'pkg_resources' in sys.modules or 'setuptools' in sys.modules
113+ def do_download():
114+ egg = download_setuptools(version, download_base, to_dir, download_delay)
115+ sys.path.insert(0, egg)
116+ import setuptools; setuptools.bootstrap_install_from = egg
117+ try:
118+ import pkg_resources
119+ except ImportError:
120+ return do_download()
121+ try:
122+ pkg_resources.require("setuptools>="+version); return
123+ except pkg_resources.VersionConflict, e:
124+ if was_imported:
125+ print >>sys.stderr, (
126+ "The required version of setuptools (>=%s) is not available, and\n"
127+ "can't be installed while this script is running. Please install\n"
128+ " a more recent version first, using 'easy_install -U setuptools'."
129+ "\n\n(Currently using %r)"
130+ ) % (version, e.args[0])
131+ sys.exit(2)
132+ else:
133+ del pkg_resources, sys.modules['pkg_resources'] # reload ok
134+ return do_download()
135+ except pkg_resources.DistributionNotFound:
136+ return do_download()
137+
138+def download_setuptools(
139+ version=DEFAULT_VERSION, download_base=DEFAULT_URL, to_dir=os.curdir,
140+ delay = 15
141+):
142+ """Download setuptools from a specified location and return its filename
143+
144+ `version` should be a valid setuptools version number that is available
145+ as an egg for download under the `download_base` URL (which should end
146+ with a '/'). `to_dir` is the directory where the egg will be downloaded.
147+ `delay` is the number of seconds to pause before an actual download attempt.
148+ """
149+ import urllib2, shutil
150+ egg_name = "setuptools-%s-py%s.egg" % (version,sys.version[:3])
151+ url = download_base + egg_name
152+ saveto = os.path.join(to_dir, egg_name)
153+ src = dst = None
154+ if not os.path.exists(saveto): # Avoid repeated downloads
155+ try:
156+ from distutils import log
157+ if delay:
158+ log.warn("""
159+---------------------------------------------------------------------------
160+This script requires setuptools version %s to run (even to display
161+help). I will attempt to download it for you (from
162+%s), but
163+you may need to enable firewall access for this script first.
164+I will start the download in %d seconds.
165+
166+(Note: if this machine does not have network access, please obtain the file
167+
168+ %s
169+
170+and place it in this directory before rerunning this script.)
171+---------------------------------------------------------------------------""",
172+ version, download_base, delay, url
173+ ); from time import sleep; sleep(delay)
174+ log.warn("Downloading %s", url)
175+ src = urllib2.urlopen(url)
176+ # Read/write all in one block, so we don't create a corrupt file
177+ # if the download is interrupted.
178+ data = _validate_md5(egg_name, src.read())
179+ dst = open(saveto,"wb"); dst.write(data)
180+ finally:
181+ if src: src.close()
182+ if dst: dst.close()
183+ return os.path.realpath(saveto)
184+
185+
186+
187+
188+
189+
190+
191+
192+
193+
194+
195+
196+
197+
198+
199+
200+
201+
202+
203+
204+
205+
206+
207+
208+
209+
210+
211+
212+
213+
214+
215+
216+
217+
218+
219+
220+def main(argv, version=DEFAULT_VERSION):
221+ """Install or upgrade setuptools and EasyInstall"""
222+ try:
223+ import setuptools
224+ except ImportError:
225+ egg = None
226+ try:
227+ egg = download_setuptools(version, delay=0)
228+ sys.path.insert(0,egg)
229+ from setuptools.command.easy_install import main
230+ return main(list(argv)+[egg]) # we're done here
231+ finally:
232+ if egg and os.path.exists(egg):
233+ os.unlink(egg)
234+ else:
235+ if setuptools.__version__ == '0.0.1':
236+ print >>sys.stderr, (
237+ "You have an obsolete version of setuptools installed. Please\n"
238+ "remove it from your system entirely before rerunning this script."
239+ )
240+ sys.exit(2)
241+
242+ req = "setuptools>="+version
243+ import pkg_resources
244+ try:
245+ pkg_resources.require(req)
246+ except pkg_resources.VersionConflict:
247+ try:
248+ from setuptools.command.easy_install import main
249+ except ImportError:
250+ from easy_install import main
251+ main(list(argv)+[download_setuptools(delay=0)])
252+ sys.exit(0) # try to force an exit
253+ else:
254+ if argv:
255+ from setuptools.command.easy_install import main
256+ main(argv)
257+ else:
258+ print "Setuptools version",version,"or greater has been installed."
259+ print '(Run "ez_setup.py -U setuptools" to reinstall or upgrade.)'
260+
261+def update_md5(filenames):
262+ """Update our built-in md5 registry"""
263+
264+ import re
265+
266+ for name in filenames:
267+ base = os.path.basename(name)
268+ f = open(name,'rb')
269+ md5_data[base] = md5(f.read()).hexdigest()
270+ f.close()
271+
272+ data = [" %r: %r,\n" % it for it in md5_data.items()]
273+ data.sort()
274+ repl = "".join(data)
275+
276+ import inspect
277+ srcfile = inspect.getsourcefile(sys.modules[__name__])
278+ f = open(srcfile, 'rb'); src = f.read(); f.close()
279+
280+ match = re.search("\nmd5_data = {\n([^}]+)}", src)
281+ if not match:
282+ print >>sys.stderr, "Internal error!"
283+ sys.exit(2)
284+
285+ src = src[:match.start(1)] + repl + src[match.end(1):]
286+ f = open(srcfile,'w')
287+ f.write(src)
288+ f.close()
289+
290+
291+if __name__=='__main__':
292+ if len(sys.argv)>2 and sys.argv[1]=='--md5update':
293+ update_md5(sys.argv[2:])
294+ else:
295+ main(sys.argv[1:])
296+
297+
298+
299+
300+
301+
302
303=== added directory 'helpers/python'
304=== added directory 'helpers/python/charmhelpers'
305=== added file 'helpers/python/charmhelpers/__init__.py'
306--- helpers/python/charmhelpers/__init__.py 1970-01-01 00:00:00 +0000
307+++ helpers/python/charmhelpers/__init__.py 2012-03-19 11:55:22 +0000
308@@ -0,0 +1,181 @@
309+# Copyright 2012 Canonical Ltd. This software is licensed under the
310+# GNU Affero General Public License version 3 (see the file LICENSE).
311+
312+"""Helper functions for writing Juju charms in Python."""
313+
314+__metaclass__ = type
315+__all__ = [
316+ 'get_config',
317+ 'log',
318+ 'log_entry',
319+ 'log_exit',
320+ 'relation_get',
321+ 'relation_set',
322+ 'unit_info',
323+ ]
324+
325+from collections import namedtuple
326+import json
327+import operator
328+from shelltoolbox import (
329+ command,
330+ run,
331+ script_name,
332+ )
333+import tempfile
334+import time
335+import urllib2
336+import yaml
337+
338+
339+Env = namedtuple('Env', 'uid gid home')
340+log = command('juju-log')
341+# We create a juju_status Command here because it makes testing much,
342+# much easier.
343+juju_status = lambda: command('juju')('status')
344+
345+
346+def log_entry():
347+ log("--> Entering {}".format(script_name()))
348+
349+
350+def log_exit():
351+ log("<-- Exiting {}".format(script_name()))
352+
353+
354+def get_config():
355+ config_get = command('config-get', '--format=json')
356+ return json.loads(config_get())
357+
358+
359+def relation_get(*args):
360+ cmd = command('relation-get')
361+ return cmd(*args).strip()
362+
363+
364+def relation_set(**kwargs):
365+ cmd = command('relation-set')
366+ args = ['{}={}'.format(k, v) for k, v in kwargs.items()]
367+ return cmd(*args)
368+
369+
370+def make_charm_config_file(charm_config):
371+ charm_config_file = tempfile.NamedTemporaryFile()
372+ charm_config_file.write(yaml.dump(charm_config))
373+ charm_config_file.flush()
374+ # The NamedTemporaryFile instance is returned instead of just the name
375+ # because we want to take advantage of garbage collection-triggered
376+ # deletion of the temp file when it goes out of scope in the caller.
377+ return charm_config_file
378+
379+
380+def unit_info(service_name, item_name, data=None, unit=None):
381+ if data is None:
382+ data = yaml.safe_load(juju_status())
383+ service = data['services'].get(service_name)
384+ if service is None:
385+ # XXX 2012-02-08 gmb:
386+ # This allows us to cope with the race condition that we
387+ # have between deploying a service and having it come up in
388+ # `juju status`. We could probably do with cleaning it up so
389+ # that it fails a bit more noisily after a while.
390+ return ''
391+ units = service['units']
392+ if unit is not None:
393+ item = units[unit][item_name]
394+ else:
395+ # It might seem odd to sort the units here, but we do it to
396+ # ensure that when no unit is specified, the first unit for the
397+ # service (or at least the one with the lowest number) is the
398+ # one whose data gets returned.
399+ sorted_unit_names = sorted(units.keys())
400+ item = units[sorted_unit_names[0]][item_name]
401+ return item
402+
403+
404+def get_machine_data():
405+ return yaml.safe_load(juju_status())['machines']
406+
407+
408+def wait_for_machine(num_machines=1, timeout=300):
409+ """Wait `timeout` seconds for `num_machines` machines to come up.
410+
411+ This wait_for... function can be called by other wait_for functions
412+ whose timeouts might be too short in situations where only a bare
413+ Juju setup has been bootstrapped.
414+
415+ :return: A tuple of (num_machines, time_taken). This is used for
416+ testing.
417+ """
418+ # You may think this is a hack, and you'd be right. The easiest way
419+ # to tell what environment we're working in (LXC vs EC2) is to check
420+ # the dns-name of the first machine. If it's localhost we're in LXC
421+ # and we can just return here.
422+ if get_machine_data()[0]['dns-name'] == 'localhost':
423+ return 1, 0
424+ start_time = time.time()
425+ while True:
426+ # Drop the first machine, since it's the Zookeeper and that's
427+ # not a machine that we need to wait for. This will only work
428+ # for EC2 environments, which is why we return early above if
429+ # we're in LXC.
430+ machine_data = get_machine_data()
431+ non_zookeeper_machines = [
432+ machine_data[key] for key in machine_data.keys()[1:]]
433+ if len(non_zookeeper_machines) >= num_machines:
434+ all_machines_running = True
435+ for machine in non_zookeeper_machines:
436+ if machine['instance-state'] != 'running':
437+ all_machines_running = False
438+ break
439+ if all_machines_running:
440+ break
441+ if time.time() - start_time >= timeout:
442+ raise RuntimeError('timeout waiting for service to start')
443+ time.sleep(0.1)
444+ return num_machines, time.time() - start_time
445+
446+
447+def wait_for_unit(service_name, timeout=480):
448+ """Wait `timeout` seconds for a given service name to come up."""
449+ wait_for_machine(num_machines=1)
450+ start_time = time.time()
451+ while True:
452+ state = unit_info(service_name, 'state')
453+ if 'error' in state or state == 'started':
454+ break
455+ if time.time() - start_time >= timeout:
456+ raise RuntimeError('timeout waiting for service to start')
457+ time.sleep(0.1)
458+ if state != 'started':
459+ raise RuntimeError('unit did not start, state: ' + state)
460+
461+
462+def wait_for_relation(service_name, relation_name, timeout=120):
463+ """Wait `timeout` seconds for a given relation to come up."""
464+ start_time = time.time()
465+ while True:
466+ relation = unit_info(service_name, 'relations').get(relation_name)
467+ if relation is not None and relation['state'] == 'up':
468+ break
469+ if time.time() - start_time >= timeout:
470+ raise RuntimeError('timeout waiting for relation to be up')
471+ time.sleep(0.1)
472+
473+
474+def wait_for_page_contents(url, contents, timeout=120, validate=None):
475+ if validate is None:
476+ validate = operator.contains
477+ start_time = time.time()
478+ while True:
479+ try:
480+ stream = urllib2.urlopen(url)
481+ except (urllib2.HTTPError, urllib2.URLError):
482+ pass
483+ else:
484+ page = stream.read()
485+ if validate(page, contents):
486+ return page
487+ if time.time() - start_time >= timeout:
488+ raise RuntimeError('timeout waiting for contents of ' + url)
489+ time.sleep(0.1)
490
491=== added directory 'helpers/python/charmhelpers/tests'
492=== added file 'helpers/python/charmhelpers/tests/test_charmhelpers.py'
493--- helpers/python/charmhelpers/tests/test_charmhelpers.py 1970-01-01 00:00:00 +0000
494+++ helpers/python/charmhelpers/tests/test_charmhelpers.py 2012-03-19 11:55:22 +0000
495@@ -0,0 +1,331 @@
496+# Tests for Python charm helpers.
497+
498+import charmhelpers
499+import unittest
500+import yaml
501+
502+from simplejson import dumps
503+from StringIO import StringIO
504+from testtools import TestCase
505+
506+
507+class CharmHelpersTestCase(TestCase):
508+ """A basic test case for Python charm helpers."""
509+
510+ def _patch_command(self, replacement_command):
511+ """Monkeypatch charmhelpers.command for testing purposes.
512+
513+ :param replacement_command: The replacement Callable for
514+ command().
515+ """
516+ new_command = lambda *args: replacement_command
517+ self.patch(charmhelpers, 'command', new_command)
518+
519+ def _make_juju_status_dict(self, num_units=1,
520+ service_name='test-service',
521+ unit_state='pending',
522+ machine_state='not-started'):
523+ """Generate valid juju status dict and return it."""
524+ machine_data = {}
525+ # The 0th machine is the Zookeeper.
526+ machine_data[0] = {
527+ 'dns-name': 'zookeeper.example.com',
528+ 'instance-id': 'machine0',
529+ 'state': 'not-started',
530+ }
531+ service_data = {
532+ 'charm': 'local:precise/{}-1'.format(service_name),
533+ 'relations': {},
534+ 'units': {},
535+ }
536+ for i in range(num_units):
537+ # The machine is always going to be i+1 because there
538+ # will always be num_units+1 machines.
539+ machine_number = i+1
540+ unit_machine_data = {
541+ 'dns-name': 'machine{}.example.com'.format(machine_number),
542+ 'instance-id': 'machine{}'.format(machine_number),
543+ 'state': machine_state,
544+ 'instance-state': machine_state,
545+ }
546+ machine_data[machine_number] = unit_machine_data
547+ unit_data = {
548+ 'machine': machine_number,
549+ 'public-address':
550+ '{}-{}.example.com'.format(service_name, i),
551+ 'relations': {
552+ 'db': {'state': 'up'},
553+ },
554+ 'state': unit_state,
555+ }
556+ service_data['units']['{}/{}'.format(service_name, i)] = (
557+ unit_data)
558+ juju_status_data = {
559+ 'machines': machine_data,
560+ 'services': {service_name: service_data},
561+ }
562+ return juju_status_data
563+
564+ def _make_juju_status_yaml(self, num_units=1,
565+ service_name='test-service',
566+ unit_state='pending',
567+ machine_state='not-started'):
568+ """Convert the dict returned by `_make_juju_status_dict` to YAML."""
569+ return yaml.dump(
570+ self._make_juju_status_dict(
571+ num_units, service_name, unit_state, machine_state))
572+
573+ def test_get_config(self):
574+ # get_config returns the contents of the current charm
575+ # configuration, as returned by config-get --format=json.
576+ mock_config = {'key': 'value'}
577+
578+ # Monkey-patch shelltoolbox.command to avoid having to call out
579+ # to config-get.
580+ self._patch_command(lambda: dumps(mock_config))
581+ self.assertEqual(mock_config, charmhelpers.get_config())
582+
583+ def test_relation_get(self):
584+ # relation_get returns the value of a given relation variable,
585+ # as returned by relation-get $VAR.
586+ mock_relation_values = {
587+ 'foo': 'bar',
588+ 'spam': 'eggs',
589+ }
590+ self._patch_command(lambda *args: mock_relation_values[args[0]])
591+ self.assertEqual('bar', charmhelpers.relation_get('foo'))
592+ self.assertEqual('eggs', charmhelpers.relation_get('spam'))
593+
594+ def test_relation_set(self):
595+ # relation_set calls out to relation-set and passes key=value
596+ # pairs to it.
597+ items_set = {}
598+ def mock_relation_set(*args):
599+ for arg in args:
600+ key, value = arg.split("=")
601+ items_set[key] = value
602+ self._patch_command(mock_relation_set)
603+ charmhelpers.relation_set(foo='bar', spam='eggs')
604+ self.assertEqual('bar', items_set.get('foo'))
605+ self.assertEqual('eggs', items_set.get('spam'))
606+
607+ def test_make_charm_config_file(self):
608+ # make_charm_config_file() writes the passed configuration to a
609+ # temporary file as YAML.
610+ charm_config = {
611+ 'foo': 'bar',
612+ 'spam': 'eggs',
613+ 'ham': 'jam',
614+ }
615+ # make_charm_config_file() returns the file object so that it
616+ # can be garbage collected properly.
617+ charm_config_file = charmhelpers.make_charm_config_file(charm_config)
618+ with open(charm_config_file.name) as config_in:
619+ written_config = config_in.read()
620+ self.assertEqual(yaml.dump(charm_config), written_config)
621+
622+ def test_unit_info(self):
623+ # unit_info returns requested data about a given service.
624+ juju_yaml = self._make_juju_status_yaml()
625+ mock_juju_status = lambda: juju_yaml
626+ self.patch(charmhelpers, 'juju_status', mock_juju_status)
627+ self.assertEqual(
628+ 'pending',
629+ charmhelpers.unit_info('test-service', 'state'))
630+
631+ def test_unit_info_returns_empty_for_nonexistant_service(self):
632+ # If the service passed to unit_info() has not yet started (or
633+ # otherwise doesn't exist), unit_info() will return an empty
634+ # string.
635+ juju_yaml = "services: {}"
636+ mock_juju_status = lambda: juju_yaml
637+ self.patch(charmhelpers, 'juju_status', mock_juju_status)
638+ self.assertEqual(
639+ '', charmhelpers.unit_info('test-service', 'state'))
640+
641+ def test_unit_info_accepts_data(self):
642+ # It's possible to pass a `data` dict, containing the parsed
643+ # result of juju status, to unit_info().
644+ juju_status_data = yaml.safe_load(
645+ self._make_juju_status_yaml())
646+ self.patch(charmhelpers, 'juju_status', lambda: None)
647+ service_data = juju_status_data['services']['test-service']
648+ unit_info_dict = service_data['units']['test-service/0']
649+ for key, value in unit_info_dict.items():
650+ item_info = charmhelpers.unit_info(
651+ 'test-service', key, data=juju_status_data)
652+ self.assertEqual(value, item_info)
653+
654+ def test_unit_info_returns_first_unit_by_default(self):
655+ # By default, unit_info() just returns the value of the
656+ # requested item for the first unit in a service.
657+ juju_yaml = self._make_juju_status_yaml(num_units=2)
658+ mock_juju_status = lambda: juju_yaml
659+ self.patch(charmhelpers, 'juju_status', mock_juju_status)
660+ unit_address = charmhelpers.unit_info(
661+ 'test-service', 'public-address')
662+ self.assertEqual('test-service-0.example.com', unit_address)
663+
664+ def test_unit_info_accepts_unit_name(self):
665+ # By default, unit_info() just returns the value of the
666+ # requested item for the first unit in a service. However, it's
667+ # possible to pass a unit name to it, too.
668+ juju_yaml = self._make_juju_status_yaml(num_units=2)
669+ mock_juju_status = lambda: juju_yaml
670+ self.patch(charmhelpers, 'juju_status', mock_juju_status)
671+ unit_address = charmhelpers.unit_info(
672+ 'test-service', 'public-address', unit='test-service/1')
673+ self.assertEqual('test-service-1.example.com', unit_address)
674+
675+ def test_get_machine_data(self):
676+ # get_machine_data() returns a dict containing the machine data
677+ # parsed from juju status.
678+ juju_yaml = self._make_juju_status_yaml()
679+ mock_juju_status = lambda: juju_yaml
680+ self.patch(charmhelpers, 'juju_status', mock_juju_status)
681+ machine_0_data = charmhelpers.get_machine_data()[0]
682+ self.assertEqual('zookeeper.example.com', machine_0_data['dns-name'])
683+
684+ def test_wait_for_machine_returns_if_machine_up(self):
685+ # If wait_for_machine() is called and the machine(s) it is
686+ # waiting for are already up, it will return.
687+ juju_yaml = self._make_juju_status_yaml(machine_state='running')
688+ mock_juju_status = lambda: juju_yaml
689+ self.patch(charmhelpers, 'juju_status', mock_juju_status)
690+ machines, time_taken = charmhelpers.wait_for_machine(timeout=1)
691+ self.assertEqual(1, machines)
692+
693+ def test_wait_for_machine_times_out(self):
694+ # If the machine that wait_for_machine is waiting for isn't
695+ # 'running' before the passed timeout is reached,
696+ # wait_for_machine will raise an error.
697+ juju_yaml = self._make_juju_status_yaml()
698+ mock_juju_status = lambda: juju_yaml
699+ self.patch(charmhelpers, 'juju_status', mock_juju_status)
700+ self.assertRaises(
701+ RuntimeError, charmhelpers.wait_for_machine, timeout=0)
702+
703+ def test_wait_for_machine_always_returns_if_running_locally(self):
704+ # If juju is actually running against a local LXC container,
705+ # wait_for_machine will always return.
706+ juju_status_dict = self._make_juju_status_dict()
707+ # We'll update the 0th machine to make it look like it's an LXC
708+ # container.
709+ juju_status_dict['machines'][0]['dns-name'] = 'localhost'
710+ juju_yaml = yaml.dump(juju_status_dict)
711+ mock_juju_status = lambda: juju_yaml
712+ self.patch(charmhelpers, 'juju_status', mock_juju_status)
713+ machines, time_taken = charmhelpers.wait_for_machine(timeout=1)
714+ # wait_for_machine will always return 1 machine started here,
715+ # since there's only one machine to start.
716+ self.assertEqual(1, machines)
717+ # time_taken will be 0, since no actual waiting happened.
718+ self.assertEqual(0, time_taken)
719+
720+ def test_wait_for_machine_waits_for_multiple_machines(self):
721+ # wait_for_machine can be told to wait for multiple machines.
722+ juju_yaml = self._make_juju_status_yaml(
723+ num_units=2, machine_state='running')
724+ mock_juju_status = lambda: juju_yaml
725+ self.patch(charmhelpers, 'juju_status', mock_juju_status)
726+ machines, time_taken = charmhelpers.wait_for_machine(num_machines=2)
727+ self.assertEqual(2, machines)
728+
729+ def test_wait_for_unit_returns_if_unit_started(self):
730+ # wait_for_unit() will return if the service it's waiting for is
731+ # already up.
732+ juju_yaml = self._make_juju_status_yaml(
733+ unit_state='started', machine_state='running')
734+ mock_juju_status = lambda: juju_yaml
735+ self.patch(charmhelpers, 'juju_status', mock_juju_status)
736+ charmhelpers.wait_for_unit('test-service', timeout=0)
737+
738+ def test_wait_for_unit_raises_error_on_error_state(self):
739+ # If the unit is in some kind of error state, wait_for_unit will
740+ # raise a RuntimeError.
741+ juju_yaml = self._make_juju_status_yaml(
742+ unit_state='start-error', machine_state='running')
743+ mock_juju_status = lambda: juju_yaml
744+ self.patch(charmhelpers, 'juju_status', mock_juju_status)
745+ self.assertRaises(
746+ RuntimeError, charmhelpers.wait_for_unit, 'test-service', timeout=0)
747+
748+ def test_wait_for_unit_raises_error_on_timeout(self):
749+ # If the unit does not start before the timeout is reached,
750+ # wait_for_unit will raise a RuntimeError.
751+ juju_yaml = self._make_juju_status_yaml(
752+ unit_state='pending', machine_state='running')
753+ mock_juju_status = lambda: juju_yaml
754+ self.patch(charmhelpers, 'juju_status', mock_juju_status)
755+ self.assertRaises(
756+ RuntimeError, charmhelpers.wait_for_unit, 'test-service', timeout=0)
757+
758+ def test_wait_for_relation_returns_if_relation_up(self):
759+ # wait_for_relation() waits for relations to come up. If a
760+ # relation is already 'up', wait_for_relation() will return
761+ # immediately.
762+ juju_yaml = self._make_juju_status_yaml(
763+ unit_state='started', machine_state='running')
764+ mock_juju_status = lambda: juju_yaml
765+ self.patch(charmhelpers, 'juju_status', mock_juju_status)
766+ charmhelpers.wait_for_relation('test-service', 'db', timeout=0)
767+
768+ def test_wait_for_relation_times_out_if_relation_not_present(self):
769+ # If a relation does not exist at all before a timeout is
770+ # reached, wait_for_relation() will raise a RuntimeError.
771+ juju_dict = self._make_juju_status_dict(
772+ unit_state='started', machine_state='running')
773+ units = juju_dict['services']['test-service']['units']
774+ # We'll remove all the relations for test-service for this test.
775+ units['test-service/0']['relations'] = {}
776+ juju_dict['services']['test-service']['units'] = units
777+ juju_yaml = yaml.dump(juju_dict)
778+ mock_juju_status = lambda: juju_yaml
779+ self.patch(charmhelpers, 'juju_status', mock_juju_status)
780+ self.assertRaises(
781+ RuntimeError, charmhelpers.wait_for_relation, 'test-service',
782+ 'db', timeout=0)
783+
784+ def test_wait_for_relation_times_out_if_relation_not_up(self):
785+ # If a relation does not transition to an 'up' state, before a
786+ # timeout is reached, wait_for_relation() will raise a
787+ # RuntimeError.
788+ juju_dict = self._make_juju_status_dict(
789+ unit_state='started', machine_state='running')
790+ units = juju_dict['services']['test-service']['units']
791+ units['test-service/0']['relations']['db']['state'] = 'down'
792+ juju_dict['services']['test-service']['units'] = units
793+ juju_yaml = yaml.dump(juju_dict)
794+ mock_juju_status = lambda: juju_yaml
795+ self.patch(charmhelpers, 'juju_status', mock_juju_status)
796+ self.assertRaises(
797+ RuntimeError, charmhelpers.wait_for_relation, 'test-service',
798+ 'db', timeout=0)
799+
800+ def test_wait_for_page_contents_returns_if_contents_available(self):
801+ # wait_for_page_contents() will wait until a given string is
802+ # contained within the results of a given url and will return
803+ # once it does.
804+ # We need to patch the charmhelpers instance of urllib2 so that
805+ # it doesn't try to connect out.
806+ test_content = "Hello, world."
807+ new_urlopen = lambda *args: StringIO(test_content)
808+ self.patch(charmhelpers.urllib2, 'urlopen', new_urlopen)
809+ charmhelpers.wait_for_page_contents(
810+ 'http://example.com', test_content, timeout=0)
811+
812+ def test_wait_for_page_contents_times_out(self):
813+ # If the desired contents do not appear within the page before
814+ # the specified timeout, wait_for_page_contents() will raise a
815+ # RuntimeError.
816+ # We need to patch the charmhelpers instance of urllib2 so that
817+ # it doesn't try to connect out.
818+ new_urlopen = lambda *args: StringIO("This won't work.")
819+ self.patch(charmhelpers.urllib2, 'urlopen', new_urlopen)
820+ self.assertRaises(
821+ RuntimeError, charmhelpers.wait_for_page_contents,
822+ 'http://example.com', "This will error", timeout=0)
823+
824+
825+if __name__ == '__main__':
826+ unittest.main()
827
828=== added file 'setup.py'
829--- setup.py 1970-01-01 00:00:00 +0000
830+++ setup.py 2012-03-19 11:55:22 +0000
831@@ -0,0 +1,32 @@
832+#!/usr/bin/env python
833+#
834+# Copyright 2012 Canonical Ltd. This software is licensed under the
835+# GNU General Public License version 3 (see the file LICENSE).
836+
837+import ez_setup
838+
839+
840+ez_setup.use_setuptools()
841+
842+from setuptools import setup, find_packages
843+
844+__version__ = '0.0.1'
845+
846+
847+setup(
848+ name='charmhelpers',
849+ version=__version__,
850+ packages=find_packages('helpers/python'),
851+ package_dir={'': 'helpers/python'},
852+ include_package_data=True,
853+ zip_safe=False,
854+ maintainer='Launchpad Yellow',
855+ description=('Helper functions for writing Juju charms'),
856+ license='GPL v3',
857+ url='https://launchpad.net/charm-tools',
858+ classifiers=[
859+ "Development Status :: 3 - Alpha",
860+ "Intended Audience :: Developers",
861+ "Programming Language :: Python",
862+ ],
863+)

Subscribers

People subscribed via source and target branches