← Back to team overview

yellow team mailing list archive

[Merge] lp:~yellow/charm-tools/trunk into lp:charm-tools

 

Brad Crittenden has proposed merging lp:~yellow/charm-tools/trunk into lp:charm-tools.

Requested reviews:
  charmers (charmers)

For more details, see:
https://code.launchpad.net/~yellow/charm-tools/trunk/+merge/101554

Update parsing of 'juju status' output to account for change in tokens ('state' -> 'agent-state').
-- 
https://code.launchpad.net/~yellow/charm-tools/trunk/+merge/101554
Your team Launchpad Yellow Squad is subscribed to branch lp:~yellow/charm-tools/trunk.
=== modified file 'Makefile'
--- Makefile	2012-01-03 18:19:56 +0000
+++ Makefile	2012-04-11 13:26:47 +0000
@@ -25,3 +25,4 @@
 	tests/helpers/helpers.sh || sh -x tests/helpers/helpers.sh timeout
 	@echo Test shell helpers with bash
 	bash tests/helpers/helpers.sh || bash -x tests/helpers/helpers.sh timeout
+	python helpers/python/charmhelpers/tests/test_charmhelpers.py

=== added file 'ez_setup.py'
--- ez_setup.py	1970-01-01 00:00:00 +0000
+++ ez_setup.py	2012-04-11 13:26:47 +0000
@@ -0,0 +1,288 @@
+#!python
+
+# NOTE TO LAUNCHPAD DEVELOPERS: This is a bootstrapping file from the
+# setuptools project.  It is imported by our setup.py.
+
+"""Bootstrap setuptools installation
+
+If you want to use setuptools in your package's setup.py, just include this
+file in the same directory with it, and add this to the top of your setup.py::
+
+    from ez_setup import use_setuptools
+    use_setuptools()
+
+If you want to require a specific version of setuptools, set a download
+mirror, or use an alternate download directory, you can do so by supplying
+the appropriate options to ``use_setuptools()``.
+
+This file can also be run as a script to install or upgrade setuptools.
+"""
+import sys
+DEFAULT_VERSION = "0.6c11"
+DEFAULT_URL     = "http://pypi.python.org/packages/%s/s/setuptools/"; % sys.version[:3]
+
+md5_data = {
+    'setuptools-0.6b1-py2.3.egg': '8822caf901250d848b996b7f25c6e6ca',
+    'setuptools-0.6b1-py2.4.egg': 'b79a8a403e4502fbb85ee3f1941735cb',
+    'setuptools-0.6b2-py2.3.egg': '5657759d8a6d8fc44070a9d07272d99b',
+    'setuptools-0.6b2-py2.4.egg': '4996a8d169d2be661fa32a6e52e4f82a',
+    'setuptools-0.6b3-py2.3.egg': 'bb31c0fc7399a63579975cad9f5a0618',
+    'setuptools-0.6b3-py2.4.egg': '38a8c6b3d6ecd22247f179f7da669fac',
+    'setuptools-0.6b4-py2.3.egg': '62045a24ed4e1ebc77fe039aa4e6f7e5',
+    'setuptools-0.6b4-py2.4.egg': '4cb2a185d228dacffb2d17f103b3b1c4',
+    'setuptools-0.6c1-py2.3.egg': 'b3f2b5539d65cb7f74ad79127f1a908c',
+    'setuptools-0.6c1-py2.4.egg': 'b45adeda0667d2d2ffe14009364f2a4b',
+    'setuptools-0.6c10-py2.3.egg': 'ce1e2ab5d3a0256456d9fc13800a7090',
+    'setuptools-0.6c10-py2.4.egg': '57d6d9d6e9b80772c59a53a8433a5dd4',
+    'setuptools-0.6c10-py2.5.egg': 'de46ac8b1c97c895572e5e8596aeb8c7',
+    'setuptools-0.6c10-py2.6.egg': '58ea40aef06da02ce641495523a0b7f5',
+    'setuptools-0.6c11-py2.3.egg': '2baeac6e13d414a9d28e7ba5b5a596de',
+    'setuptools-0.6c11-py2.4.egg': 'bd639f9b0eac4c42497034dec2ec0c2b',
+    'setuptools-0.6c11-py2.5.egg': '64c94f3bf7a72a13ec83e0b24f2749b2',
+    'setuptools-0.6c11-py2.6.egg': 'bfa92100bd772d5a213eedd356d64086',
+    'setuptools-0.6c2-py2.3.egg': 'f0064bf6aa2b7d0f3ba0b43f20817c27',
+    'setuptools-0.6c2-py2.4.egg': '616192eec35f47e8ea16cd6a122b7277',
+    'setuptools-0.6c3-py2.3.egg': 'f181fa125dfe85a259c9cd6f1d7b78fa',
+    'setuptools-0.6c3-py2.4.egg': 'e0ed74682c998bfb73bf803a50e7b71e',
+    'setuptools-0.6c3-py2.5.egg': 'abef16fdd61955514841c7c6bd98965e',
+    'setuptools-0.6c4-py2.3.egg': 'b0b9131acab32022bfac7f44c5d7971f',
+    'setuptools-0.6c4-py2.4.egg': '2a1f9656d4fbf3c97bf946c0a124e6e2',
+    'setuptools-0.6c4-py2.5.egg': '8f5a052e32cdb9c72bcf4b5526f28afc',
+    'setuptools-0.6c5-py2.3.egg': 'ee9fd80965da04f2f3e6b3576e9d8167',
+    'setuptools-0.6c5-py2.4.egg': 'afe2adf1c01701ee841761f5bcd8aa64',
+    'setuptools-0.6c5-py2.5.egg': 'a8d3f61494ccaa8714dfed37bccd3d5d',
+    'setuptools-0.6c6-py2.3.egg': '35686b78116a668847237b69d549ec20',
+    'setuptools-0.6c6-py2.4.egg': '3c56af57be3225019260a644430065ab',
+    'setuptools-0.6c6-py2.5.egg': 'b2f8a7520709a5b34f80946de5f02f53',
+    'setuptools-0.6c7-py2.3.egg': '209fdf9adc3a615e5115b725658e13e2',
+    'setuptools-0.6c7-py2.4.egg': '5a8f954807d46a0fb67cf1f26c55a82e',
+    'setuptools-0.6c7-py2.5.egg': '45d2ad28f9750e7434111fde831e8372',
+    'setuptools-0.6c8-py2.3.egg': '50759d29b349db8cfd807ba8303f1902',
+    'setuptools-0.6c8-py2.4.egg': 'cba38d74f7d483c06e9daa6070cce6de',
+    'setuptools-0.6c8-py2.5.egg': '1721747ee329dc150590a58b3e1ac95b',
+    'setuptools-0.6c9-py2.3.egg': 'a83c4020414807b496e4cfbe08507c03',
+    'setuptools-0.6c9-py2.4.egg': '260a2be2e5388d66bdaee06abec6342a',
+    'setuptools-0.6c9-py2.5.egg': 'fe67c3e5a17b12c0e7c541b7ea43a8e6',
+    'setuptools-0.6c9-py2.6.egg': 'ca37b1ff16fa2ede6e19383e7b59245a',
+}
+
+import sys, os
+try: from hashlib import md5
+except ImportError: from md5 import md5
+
+def _validate_md5(egg_name, data):
+    if egg_name in md5_data:
+        digest = md5(data).hexdigest()
+        if digest != md5_data[egg_name]:
+            print >>sys.stderr, (
+                "md5 validation of %s failed!  (Possible download problem?)"
+                % egg_name
+            )
+            sys.exit(2)
+    return data
+
+def use_setuptools(
+    version=DEFAULT_VERSION, download_base=DEFAULT_URL, to_dir=os.curdir,
+    download_delay=15
+):
+    """Automatically find/download setuptools and make it available on sys.path
+
+    `version` should be a valid setuptools version number that is available
+    as an egg for download under the `download_base` URL (which should end with
+    a '/').  `to_dir` is the directory where setuptools will be downloaded, if
+    it is not already available.  If `download_delay` is specified, it should
+    be the number of seconds that will be paused before initiating a download,
+    should one be required.  If an older version of setuptools is installed,
+    this routine will print a message to ``sys.stderr`` and raise SystemExit in
+    an attempt to abort the calling script.
+    """
+    was_imported = 'pkg_resources' in sys.modules or 'setuptools' in sys.modules
+    def do_download():
+        egg = download_setuptools(version, download_base, to_dir, download_delay)
+        sys.path.insert(0, egg)
+        import setuptools; setuptools.bootstrap_install_from = egg
+    try:
+        import pkg_resources
+    except ImportError:
+        return do_download()
+    try:
+        pkg_resources.require("setuptools>="+version); return
+    except pkg_resources.VersionConflict, e:
+        if was_imported:
+            print >>sys.stderr, (
+            "The required version of setuptools (>=%s) is not available, and\n"
+            "can't be installed while this script is running. Please install\n"
+            " a more recent version first, using 'easy_install -U setuptools'."
+            "\n\n(Currently using %r)"
+            ) % (version, e.args[0])
+            sys.exit(2)
+        else:
+            del pkg_resources, sys.modules['pkg_resources']    # reload ok
+            return do_download()
+    except pkg_resources.DistributionNotFound:
+        return do_download()
+
+def download_setuptools(
+    version=DEFAULT_VERSION, download_base=DEFAULT_URL, to_dir=os.curdir,
+    delay = 15
+):
+    """Download setuptools from a specified location and return its filename
+
+    `version` should be a valid setuptools version number that is available
+    as an egg for download under the `download_base` URL (which should end
+    with a '/'). `to_dir` is the directory where the egg will be downloaded.
+    `delay` is the number of seconds to pause before an actual download attempt.
+    """
+    import urllib2, shutil
+    egg_name = "setuptools-%s-py%s.egg" % (version,sys.version[:3])
+    url = download_base + egg_name
+    saveto = os.path.join(to_dir, egg_name)
+    src = dst = None
+    if not os.path.exists(saveto):  # Avoid repeated downloads
+        try:
+            from distutils import log
+            if delay:
+                log.warn("""
+---------------------------------------------------------------------------
+This script requires setuptools version %s to run (even to display
+help).  I will attempt to download it for you (from
+%s), but
+you may need to enable firewall access for this script first.
+I will start the download in %d seconds.
+
+(Note: if this machine does not have network access, please obtain the file
+
+   %s
+
+and place it in this directory before rerunning this script.)
+---------------------------------------------------------------------------""",
+                    version, download_base, delay, url
+                ); from time import sleep; sleep(delay)
+            log.warn("Downloading %s", url)
+            src = urllib2.urlopen(url)
+            # Read/write all in one block, so we don't create a corrupt file
+            # if the download is interrupted.
+            data = _validate_md5(egg_name, src.read())
+            dst = open(saveto,"wb"); dst.write(data)
+        finally:
+            if src: src.close()
+            if dst: dst.close()
+    return os.path.realpath(saveto)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+def main(argv, version=DEFAULT_VERSION):
+    """Install or upgrade setuptools and EasyInstall"""
+    try:
+        import setuptools
+    except ImportError:
+        egg = None
+        try:
+            egg = download_setuptools(version, delay=0)
+            sys.path.insert(0,egg)
+            from setuptools.command.easy_install import main
+            return main(list(argv)+[egg])   # we're done here
+        finally:
+            if egg and os.path.exists(egg):
+                os.unlink(egg)
+    else:
+        if setuptools.__version__ == '0.0.1':
+            print >>sys.stderr, (
+            "You have an obsolete version of setuptools installed.  Please\n"
+            "remove it from your system entirely before rerunning this script."
+            )
+            sys.exit(2)
+
+    req = "setuptools>="+version
+    import pkg_resources
+    try:
+        pkg_resources.require(req)
+    except pkg_resources.VersionConflict:
+        try:
+            from setuptools.command.easy_install import main
+        except ImportError:
+            from easy_install import main
+        main(list(argv)+[download_setuptools(delay=0)])
+        sys.exit(0) # try to force an exit
+    else:
+        if argv:
+            from setuptools.command.easy_install import main
+            main(argv)
+        else:
+            print "Setuptools version",version,"or greater has been installed."
+            print '(Run "ez_setup.py -U setuptools" to reinstall or upgrade.)'
+
+def update_md5(filenames):
+    """Update our built-in md5 registry"""
+
+    import re
+
+    for name in filenames:
+        base = os.path.basename(name)
+        f = open(name,'rb')
+        md5_data[base] = md5(f.read()).hexdigest()
+        f.close()
+
+    data = ["    %r: %r,\n" % it for it in md5_data.items()]
+    data.sort()
+    repl = "".join(data)
+
+    import inspect
+    srcfile = inspect.getsourcefile(sys.modules[__name__])
+    f = open(srcfile, 'rb'); src = f.read(); f.close()
+
+    match = re.search("\nmd5_data = {\n([^}]+)}", src)
+    if not match:
+        print >>sys.stderr, "Internal error!"
+        sys.exit(2)
+
+    src = src[:match.start(1)] + repl + src[match.end(1):]
+    f = open(srcfile,'w')
+    f.write(src)
+    f.close()
+
+
+if __name__=='__main__':
+    if len(sys.argv)>2 and sys.argv[1]=='--md5update':
+        update_md5(sys.argv[2:])
+    else:
+        main(sys.argv[1:])
+
+
+
+
+
+

=== added directory 'helpers/python'
=== added directory 'helpers/python/charmhelpers'
=== added file 'helpers/python/charmhelpers/__init__.py'
--- helpers/python/charmhelpers/__init__.py	1970-01-01 00:00:00 +0000
+++ helpers/python/charmhelpers/__init__.py	2012-04-11 13:26:47 +0000
@@ -0,0 +1,185 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Helper functions for writing Juju charms in Python."""
+
+__metaclass__ = type
+__all__ = [
+    'get_config',
+    'log',
+    'log_entry',
+    'log_exit',
+    'relation_get',
+    'relation_set',
+    'unit_info',
+    'wait_for_machine',
+    'wait_for_page_contents',
+    'wait_for_relation',
+    'wait_for_unit',
+    ]
+
+from collections import namedtuple
+import json
+import operator
+from shelltoolbox import (
+    command,
+    script_name,
+    )
+import tempfile
+import time
+import urllib2
+import yaml
+
+
+SLEEP_AMOUNT = 0.1
+Env = namedtuple('Env', 'uid gid home')
+log = command('juju-log')
+# We create a juju_status Command here because it makes testing much,
+# much easier.
+juju_status = lambda: command('juju')('status')
+
+
+def log_entry():
+    log("--> Entering {}".format(script_name()))
+
+
+def log_exit():
+    log("<-- Exiting {}".format(script_name()))
+
+
+def get_config():
+    config_get = command('config-get', '--format=json')
+    return json.loads(config_get())
+
+
+def relation_get(*args):
+    cmd = command('relation-get')
+    return cmd(*args).strip()
+
+
+def relation_set(**kwargs):
+    cmd = command('relation-set')
+    args = ['{}={}'.format(k, v) for k, v in kwargs.items()]
+    return cmd(*args)
+
+
+def make_charm_config_file(charm_config):
+    charm_config_file = tempfile.NamedTemporaryFile()
+    charm_config_file.write(yaml.dump(charm_config))
+    charm_config_file.flush()
+    # The NamedTemporaryFile instance is returned instead of just the name
+    # because we want to take advantage of garbage collection-triggered
+    # deletion of the temp file when it goes out of scope in the caller.
+    return charm_config_file
+
+
+def unit_info(service_name, item_name, data=None, unit=None):
+    if data is None:
+        data = yaml.safe_load(juju_status())
+    service = data['services'].get(service_name)
+    if service is None:
+        # XXX 2012-02-08 gmb:
+        #     This allows us to cope with the race condition that we
+        #     have between deploying a service and having it come up in
+        #     `juju status`. We could probably do with cleaning it up so
+        #     that it fails a bit more noisily after a while.
+        return ''
+    units = service['units']
+    if unit is not None:
+        item = units[unit][item_name]
+    else:
+        # It might seem odd to sort the units here, but we do it to
+        # ensure that when no unit is specified, the first unit for the
+        # service (or at least the one with the lowest number) is the
+        # one whose data gets returned.
+        sorted_unit_names = sorted(units.keys())
+        item = units[sorted_unit_names[0]][item_name]
+    return item
+
+
+def get_machine_data():
+    return yaml.safe_load(juju_status())['machines']
+
+
+def wait_for_machine(num_machines=1, timeout=300):
+    """Wait `timeout` seconds for `num_machines` machines to come up.
+
+    This wait_for... function can be called by other wait_for functions
+    whose timeouts might be too short in situations where only a bare
+    Juju setup has been bootstrapped.
+
+    :return: A tuple of (num_machines, time_taken). This is used for
+             testing.
+    """
+    # You may think this is a hack, and you'd be right. The easiest way
+    # to tell what environment we're working in (LXC vs EC2) is to check
+    # the dns-name of the first machine. If it's localhost we're in LXC
+    # and we can just return here.
+    if get_machine_data()[0]['dns-name'] == 'localhost':
+        return 1, 0
+    start_time = time.time()
+    while True:
+        # Drop the first machine, since it's the Zookeeper and that's
+        # not a machine that we need to wait for. This will only work
+        # for EC2 environments, which is why we return early above if
+        # we're in LXC.
+        machine_data = get_machine_data()
+        non_zookeeper_machines = [
+            machine_data[key] for key in machine_data.keys()[1:]]
+        if len(non_zookeeper_machines) >= num_machines:
+            all_machines_running = True
+            for machine in non_zookeeper_machines:
+                if machine.get('instance-state') != 'running':
+                    all_machines_running = False
+                    break
+            if all_machines_running:
+                break
+        if time.time() - start_time >= timeout:
+            raise RuntimeError('timeout waiting for service to start')
+        time.sleep(SLEEP_AMOUNT)
+    return num_machines, time.time() - start_time
+
+
+def wait_for_unit(service_name, timeout=480):
+    """Wait `timeout` seconds for a given service name to come up."""
+    wait_for_machine(num_machines=1)
+    start_time = time.time()
+    while True:
+        state = unit_info(service_name, 'agent-state')
+        if 'error' in state or state == 'started':
+            break
+        if time.time() - start_time >= timeout:
+            raise RuntimeError('timeout waiting for service to start')
+        time.sleep(SLEEP_AMOUNT)
+    if state != 'started':
+        raise RuntimeError('unit did not start, agent-state: ' + state)
+
+
+def wait_for_relation(service_name, relation_name, timeout=120):
+    """Wait `timeout` seconds for a given relation to come up."""
+    start_time = time.time()
+    while True:
+        relation = unit_info(service_name, 'relations').get(relation_name)
+        if relation is not None and relation['state'] == 'up':
+            break
+        if time.time() - start_time >= timeout:
+            raise RuntimeError('timeout waiting for relation to be up')
+        time.sleep(SLEEP_AMOUNT)
+
+
+def wait_for_page_contents(url, contents, timeout=120, validate=None):
+    if validate is None:
+        validate = operator.contains
+    start_time = time.time()
+    while True:
+        try:
+            stream = urllib2.urlopen(url)
+        except (urllib2.HTTPError, urllib2.URLError):
+            pass
+        else:
+            page = stream.read()
+            if validate(page, contents):
+                return page
+        if time.time() - start_time >= timeout:
+            raise RuntimeError('timeout waiting for contents of ' + url)
+        time.sleep(SLEEP_AMOUNT)

=== added directory 'helpers/python/charmhelpers/tests'
=== added file 'helpers/python/charmhelpers/tests/test_charmhelpers.py'
--- helpers/python/charmhelpers/tests/test_charmhelpers.py	1970-01-01 00:00:00 +0000
+++ helpers/python/charmhelpers/tests/test_charmhelpers.py	2012-04-11 13:26:47 +0000
@@ -0,0 +1,337 @@
+# Tests for Python charm helpers.
+
+import unittest
+import yaml
+
+from simplejson import dumps
+from StringIO import StringIO
+from testtools import TestCase
+
+import sys
+# Path hack to ensure we test the local code, not a version installed in
+# /usr/local/lib.  This is necessary since /usr/local/lib is prepended before
+# what is specified in PYTHONPATH.
+sys.path.insert(0, 'helpers/python')
+import charmhelpers
+
+
+class CharmHelpersTestCase(TestCase):
+    """A basic test case for Python charm helpers."""
+
+    def _patch_command(self, replacement_command):
+        """Monkeypatch charmhelpers.command for testing purposes.
+
+        :param replacement_command: The replacement Callable for
+                                    command().
+        """
+        new_command = lambda *args: replacement_command
+        self.patch(charmhelpers, 'command', new_command)
+
+    def _make_juju_status_dict(self, num_units=1,
+                               service_name='test-service',
+                               unit_state='pending',
+                               machine_state='not-started'):
+        """Generate valid juju status dict and return it."""
+        machine_data = {}
+        # The 0th machine is the Zookeeper.
+        machine_data[0] = {
+            'dns-name': 'zookeeper.example.com',
+            'instance-id': 'machine0',
+            'state': 'not-started',
+            }
+        service_data = {
+            'charm': 'local:precise/{}-1'.format(service_name),
+            'relations': {},
+            'units': {},
+            }
+        for i in range(num_units):
+            # The machine is always going to be i+1 because there
+            # will always be num_units+1 machines.
+            machine_number = i+1
+            unit_machine_data = {
+                'dns-name': 'machine{}.example.com'.format(machine_number),
+                'instance-id': 'machine{}'.format(machine_number),
+                'state': machine_state,
+                'instance-state': machine_state,
+                }
+            machine_data[machine_number] = unit_machine_data
+            unit_data = {
+                'machine': machine_number,
+                'public-address':
+                    '{}-{}.example.com'.format(service_name, i),
+                'relations': {
+                    'db': {'state': 'up'},
+                    },
+                'agent-state': unit_state,
+                }
+            service_data['units']['{}/{}'.format(service_name, i)] = (
+                unit_data)
+        juju_status_data = {
+            'machines': machine_data,
+            'services': {service_name: service_data},
+            }
+        return juju_status_data
+
+    def _make_juju_status_yaml(self, num_units=1,
+                               service_name='test-service',
+                               unit_state='pending',
+                               machine_state='not-started'):
+        """Convert the dict returned by `_make_juju_status_dict` to YAML."""
+        return yaml.dump(
+            self._make_juju_status_dict(
+                num_units, service_name, unit_state, machine_state))
+
+    def test_get_config(self):
+        # get_config returns the contents of the current charm
+        # configuration, as returned by config-get --format=json.
+        mock_config = {'key': 'value'}
+
+        # Monkey-patch shelltoolbox.command to avoid having to call out
+        # to config-get.
+        self._patch_command(lambda: dumps(mock_config))
+        self.assertEqual(mock_config, charmhelpers.get_config())
+
+    def test_relation_get(self):
+        # relation_get returns the value of a given relation variable,
+        # as returned by relation-get $VAR.
+        mock_relation_values = {
+            'foo': 'bar',
+            'spam': 'eggs',
+            }
+        self._patch_command(lambda *args: mock_relation_values[args[0]])
+        self.assertEqual('bar', charmhelpers.relation_get('foo'))
+        self.assertEqual('eggs', charmhelpers.relation_get('spam'))
+
+    def test_relation_set(self):
+        # relation_set calls out to relation-set and passes key=value
+        # pairs to it.
+        items_set = {}
+        def mock_relation_set(*args):
+            for arg in args:
+                key, value = arg.split("=")
+                items_set[key] = value
+        self._patch_command(mock_relation_set)
+        charmhelpers.relation_set(foo='bar', spam='eggs')
+        self.assertEqual('bar', items_set.get('foo'))
+        self.assertEqual('eggs', items_set.get('spam'))
+
+    def test_make_charm_config_file(self):
+        # make_charm_config_file() writes the passed configuration to a
+        # temporary file as YAML.
+        charm_config = {
+            'foo': 'bar',
+            'spam': 'eggs',
+            'ham': 'jam',
+            }
+        # make_charm_config_file() returns the file object so that it
+        # can be garbage collected properly.
+        charm_config_file = charmhelpers.make_charm_config_file(charm_config)
+        with open(charm_config_file.name) as config_in:
+            written_config = config_in.read()
+        self.assertEqual(yaml.dump(charm_config), written_config)
+
+    def test_unit_info(self):
+        # unit_info returns requested data about a given service.
+        juju_yaml = self._make_juju_status_yaml()
+        mock_juju_status = lambda: juju_yaml
+        self.patch(charmhelpers, 'juju_status', mock_juju_status)
+        self.assertEqual(
+            'pending',
+            charmhelpers.unit_info('test-service', 'agent-state'))
+
+    def test_unit_info_returns_empty_for_nonexistent_service(self):
+        # If the service passed to unit_info() has not yet started (or
+        # otherwise doesn't exist), unit_info() will return an empty
+        # string.
+        juju_yaml = "services: {}"
+        mock_juju_status = lambda: juju_yaml
+        self.patch(charmhelpers, 'juju_status', mock_juju_status)
+        self.assertEqual(
+            '', charmhelpers.unit_info('test-service', 'state'))
+
+    def test_unit_info_accepts_data(self):
+        # It's possible to pass a `data` dict, containing the parsed
+        # result of juju status, to unit_info().
+        juju_status_data = yaml.safe_load(
+            self._make_juju_status_yaml())
+        self.patch(charmhelpers, 'juju_status', lambda: None)
+        service_data = juju_status_data['services']['test-service']
+        unit_info_dict = service_data['units']['test-service/0']
+        for key, value in unit_info_dict.items():
+            item_info = charmhelpers.unit_info(
+                'test-service', key, data=juju_status_data)
+            self.assertEqual(value, item_info)
+
+    def test_unit_info_returns_first_unit_by_default(self):
+        # By default, unit_info() just returns the value of the
+        # requested item for the first unit in a service.
+        juju_yaml = self._make_juju_status_yaml(num_units=2)
+        mock_juju_status = lambda: juju_yaml
+        self.patch(charmhelpers, 'juju_status', mock_juju_status)
+        unit_address = charmhelpers.unit_info(
+            'test-service', 'public-address')
+        self.assertEqual('test-service-0.example.com', unit_address)
+
+    def test_unit_info_accepts_unit_name(self):
+        # By default, unit_info() just returns the value of the
+        # requested item for the first unit in a service. However, it's
+        # possible to pass a unit name to it, too.
+        juju_yaml = self._make_juju_status_yaml(num_units=2)
+        mock_juju_status = lambda: juju_yaml
+        self.patch(charmhelpers, 'juju_status', mock_juju_status)
+        unit_address = charmhelpers.unit_info(
+            'test-service', 'public-address', unit='test-service/1')
+        self.assertEqual('test-service-1.example.com', unit_address)
+
+    def test_get_machine_data(self):
+        # get_machine_data() returns a dict containing the machine data
+        # parsed from juju status.
+        juju_yaml = self._make_juju_status_yaml()
+        mock_juju_status = lambda: juju_yaml
+        self.patch(charmhelpers, 'juju_status', mock_juju_status)
+        machine_0_data = charmhelpers.get_machine_data()[0]
+        self.assertEqual('zookeeper.example.com', machine_0_data['dns-name'])
+
+    def test_wait_for_machine_returns_if_machine_up(self):
+        # If wait_for_machine() is called and the machine(s) it is
+        # waiting for are already up, it will return.
+        juju_yaml = self._make_juju_status_yaml(machine_state='running')
+        mock_juju_status = lambda: juju_yaml
+        self.patch(charmhelpers, 'juju_status', mock_juju_status)
+        machines, time_taken = charmhelpers.wait_for_machine(timeout=1)
+        self.assertEqual(1, machines)
+
+    def test_wait_for_machine_times_out(self):
+        # If the machine that wait_for_machine is waiting for isn't
+        # 'running' before the passed timeout is reached,
+        # wait_for_machine will raise an error.
+        juju_yaml = self._make_juju_status_yaml()
+        mock_juju_status = lambda: juju_yaml
+        self.patch(charmhelpers, 'juju_status', mock_juju_status)
+        self.assertRaises(
+            RuntimeError, charmhelpers.wait_for_machine, timeout=0)
+
+    def test_wait_for_machine_always_returns_if_running_locally(self):
+        # If juju is actually running against a local LXC container,
+        # wait_for_machine will always return.
+        juju_status_dict = self._make_juju_status_dict()
+        # We'll update the 0th machine to make it look like it's an LXC
+        # container.
+        juju_status_dict['machines'][0]['dns-name'] = 'localhost'
+        juju_yaml = yaml.dump(juju_status_dict)
+        mock_juju_status = lambda: juju_yaml
+        self.patch(charmhelpers, 'juju_status', mock_juju_status)
+        machines, time_taken = charmhelpers.wait_for_machine(timeout=1)
+        # wait_for_machine will always return 1 machine started here,
+        # since there's only one machine to start.
+        self.assertEqual(1, machines)
+        # time_taken will be 0, since no actual waiting happened.
+        self.assertEqual(0, time_taken)
+
+    def test_wait_for_machine_waits_for_multiple_machines(self):
+        # wait_for_machine can be told to wait for multiple machines.
+        juju_yaml = self._make_juju_status_yaml(
+            num_units=2, machine_state='running')
+        mock_juju_status = lambda: juju_yaml
+        self.patch(charmhelpers, 'juju_status', mock_juju_status)
+        machines, time_taken = charmhelpers.wait_for_machine(num_machines=2)
+        self.assertEqual(2, machines)
+
+    def test_wait_for_unit_returns_if_unit_started(self):
+        # wait_for_unit() will return if the service it's waiting for is
+        # already up.
+        juju_yaml = self._make_juju_status_yaml(
+            unit_state='started', machine_state='running')
+        mock_juju_status = lambda: juju_yaml
+        self.patch(charmhelpers, 'juju_status', mock_juju_status)
+        charmhelpers.wait_for_unit('test-service', timeout=0)
+
+    def test_wait_for_unit_raises_error_on_error_state(self):
+        # If the unit is in some kind of error state, wait_for_unit will
+        # raise a RuntimeError.
+        juju_yaml = self._make_juju_status_yaml(
+            unit_state='start-error', machine_state='running')
+        mock_juju_status = lambda: juju_yaml
+        self.patch(charmhelpers, 'juju_status', mock_juju_status)
+        self.assertRaises(
+            RuntimeError, charmhelpers.wait_for_unit, 'test-service', timeout=0)
+
+    def test_wait_for_unit_raises_error_on_timeout(self):
+        # If the unit does not start before the timeout is reached,
+        # wait_for_unit will raise a RuntimeError.
+        juju_yaml = self._make_juju_status_yaml(
+            unit_state='pending', machine_state='running')
+        mock_juju_status = lambda: juju_yaml
+        self.patch(charmhelpers, 'juju_status', mock_juju_status)
+        self.assertRaises(
+            RuntimeError, charmhelpers.wait_for_unit, 'test-service', timeout=0)
+
+    def test_wait_for_relation_returns_if_relation_up(self):
+        # wait_for_relation() waits for relations to come up. If a
+        # relation is already 'up', wait_for_relation() will return
+        # immediately.
+        juju_yaml = self._make_juju_status_yaml(
+            unit_state='started', machine_state='running')
+        mock_juju_status = lambda: juju_yaml
+        self.patch(charmhelpers, 'juju_status', mock_juju_status)
+        charmhelpers.wait_for_relation('test-service', 'db', timeout=0)
+
+    def test_wait_for_relation_times_out_if_relation_not_present(self):
+        # If a relation does not exist at all before a timeout is
+        # reached, wait_for_relation() will raise a RuntimeError.
+        juju_dict = self._make_juju_status_dict(
+            unit_state='started', machine_state='running')
+        units = juju_dict['services']['test-service']['units']
+        # We'll remove all the relations for test-service for this test.
+        units['test-service/0']['relations'] = {}
+        juju_dict['services']['test-service']['units'] = units
+        juju_yaml = yaml.dump(juju_dict)
+        mock_juju_status = lambda: juju_yaml
+        self.patch(charmhelpers, 'juju_status', mock_juju_status)
+        self.assertRaises(
+            RuntimeError, charmhelpers.wait_for_relation, 'test-service',
+            'db', timeout=0)
+
+    def test_wait_for_relation_times_out_if_relation_not_up(self):
+        # If a relation does not transition to an 'up' state, before a
+        # timeout is reached, wait_for_relation() will raise a
+        # RuntimeError.
+        juju_dict = self._make_juju_status_dict(
+            unit_state='started', machine_state='running')
+        units = juju_dict['services']['test-service']['units']
+        units['test-service/0']['relations']['db']['state'] = 'down'
+        juju_dict['services']['test-service']['units'] = units
+        juju_yaml = yaml.dump(juju_dict)
+        mock_juju_status = lambda: juju_yaml
+        self.patch(charmhelpers, 'juju_status', mock_juju_status)
+        self.assertRaises(
+            RuntimeError, charmhelpers.wait_for_relation, 'test-service',
+            'db', timeout=0)
+
+    def test_wait_for_page_contents_returns_if_contents_available(self):
+        # wait_for_page_contents() will wait until a given string is
+        # contained within the results of a given url and will return
+        # once it does.
+        # We need to patch the charmhelpers instance of urllib2 so that
+        # it doesn't try to connect out.
+        test_content = "Hello, world."
+        new_urlopen = lambda *args: StringIO(test_content)
+        self.patch(charmhelpers.urllib2, 'urlopen', new_urlopen)
+        charmhelpers.wait_for_page_contents(
+            'http://example.com', test_content, timeout=0)
+
+    def test_wait_for_page_contents_times_out(self):
+        # If the desired contents do not appear within the page before
+        # the specified timeout, wait_for_page_contents() will raise a
+        # RuntimeError.
+        # We need to patch the charmhelpers instance of urllib2 so that
+        # it doesn't try to connect out.
+        new_urlopen = lambda *args: StringIO("This won't work.")
+        self.patch(charmhelpers.urllib2, 'urlopen', new_urlopen)
+        self.assertRaises(
+            RuntimeError, charmhelpers.wait_for_page_contents,
+            'http://example.com', "This will error", timeout=0)
+
+
+if __name__ == '__main__':
+    unittest.main()

=== added file 'setup.py'
--- setup.py	1970-01-01 00:00:00 +0000
+++ setup.py	2012-04-11 13:26:47 +0000
@@ -0,0 +1,32 @@
+#!/usr/bin/env python
+#
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU General Public License version 3 (see the file LICENSE).
+
+import ez_setup
+
+
+ez_setup.use_setuptools()
+
+from setuptools import setup, find_packages
+
+__version__ = '0.0.3'
+
+
+setup(
+    name='charmhelpers',
+    version=__version__,
+    packages=find_packages('helpers/python'),
+    package_dir={'': 'helpers/python'},
+    include_package_data=True,
+    zip_safe=False,
+    maintainer='Launchpad Yellow',
+    description=('Helper functions for writing Juju charms'),
+    license='GPL v3',
+    url='https://launchpad.net/charm-tools',
+    classifiers=[
+        "Development Status :: 3 - Alpha",
+        "Intended Audience :: Developers",
+        "Programming Language :: Python",
+    ],
+)


Follow ups