Merge lp:~gmb/charm-tools/add-charm-helpers into lp:~charmers/charm-tools/trunk
- add-charm-helpers
- Merge into trunk
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 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Clint Byrum (community) | Approve | ||
Review via email: mp+96204@code.launchpad.net |
Commit message
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.
- 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.
Clint Byrum (clint-fewbar) wrote : | # |
- 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.
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-
Graham Binns (gmb) wrote : | # |
Confirmed; python-
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!
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
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 | +) |
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