← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~allenap/maas/command-line into lp:maas

 

Gavin Panella has proposed merging lp:~allenap/maas/command-line into lp:maas.

Requested reviews:
  MAAS Maintainers (maas-maintainers)

For more details, see:
https://code.launchpad.net/~allenap/maas/command-line/+merge/124704

The first iteration of the MAAS command-line API client. It's not fully tested, but hopefully we can all chip in once it's in the tree.

-- 
https://code.launchpad.net/~allenap/maas/command-line/+merge/124704
Your team MAAS Maintainers is requested to review the proposed merge of lp:~allenap/maas/command-line into lp:maas.
=== modified file 'buildout.cfg'
--- buildout.cfg	2012-09-12 17:01:41 +0000
+++ buildout.cfg	2012-09-17 14:41:20 +0000
@@ -112,6 +112,7 @@
 recipe = zc.recipe.egg
 eggs =
   bzr
+  httplib2
 entry-points =
   maascli=maascli:main
 extra-paths =
@@ -124,6 +125,7 @@
 eggs =
   ${maascli:eggs}
   ${common:test-eggs}
+  twisted
 entry-points =
   test.maascli=nose.core:TestProgram
 initialization =

=== modified file 'required-packages/base'
--- required-packages/base	2012-09-12 17:01:41 +0000
+++ required-packages/base	2012-09-17 14:41:20 +0000
@@ -19,6 +19,7 @@
 python-django
 python-django-south
 python-formencode
+python-httplib2
 python-lockfile
 python-netaddr
 python-oauth

=== modified file 'src/apiclient/maas_client.py'
--- src/apiclient/maas_client.py	2012-08-30 06:31:38 +0000
+++ src/apiclient/maas_client.py	2012-09-17 14:41:20 +0000
@@ -18,22 +18,11 @@
 
 from urllib import urlencode
 import urllib2
-from urlparse import urlparse
 
 from apiclient.multipart import encode_multipart_data
 import oauth.oauth as oauth
 
 
-def _ascii_url(url):
-    """Encode `url` as ASCII if it isn't already."""
-    if isinstance(url, unicode):
-        urlparts = urlparse(url)
-        urlparts = urlparts._replace(
-            netloc=urlparts.netloc.encode("idna"))
-        url = urlparts.geturl()
-    return url.encode("ascii")
-
-
 class MAASOAuth:
     """Helper class to OAuth-sign an HTTP request."""
 

=== modified file 'src/apiclient/tests/test_maas_client.py'
--- src/apiclient/tests/test_maas_client.py	2012-08-30 06:31:38 +0000
+++ src/apiclient/tests/test_maas_client.py	2012-09-17 14:41:20 +0000
@@ -21,7 +21,6 @@
     )
 
 from apiclient.maas_client import (
-    _ascii_url,
     MAASClient,
     MAASDispatcher,
     MAASOAuth,
@@ -30,19 +29,6 @@
 from maastesting.testcase import TestCase
 
 
-class TestHelpers(TestCase):
-
-    def test_ascii_url_leaves_ascii_bytes_unchanged(self):
-        self.assertEqual(
-            b'http://example.com/', _ascii_url(b'http://example.com/'))
-        self.assertIsInstance(_ascii_url(b'http://example.com'), bytes)
-
-    def test_ascii_url_asciifies_unicode(self):
-        self.assertEqual(
-            b'http://example.com/', _ascii_url('http://example.com/'))
-        self.assertIsInstance(_ascii_url('http://example.com'), bytes)
-
-
 class TestMAASOAuth(TestCase):
 
     def test_sign_request_adds_header(self):

=== added file 'src/apiclient/tests/test_utils.py'
--- src/apiclient/tests/test_utils.py	1970-01-01 00:00:00 +0000
+++ src/apiclient/tests/test_utils.py	2012-09-17 14:41:20 +0000
@@ -0,0 +1,29 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test general utilities."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = []
+
+from apiclient.utils import ascii_url
+from maastesting.testcase import TestCase
+
+
+class TestHelpers(TestCase):
+
+    def test_ascii_url_leaves_ascii_bytes_unchanged(self):
+        self.assertEqual(
+            b'http://example.com/', ascii_url(b'http://example.com/'))
+        self.assertIsInstance(ascii_url(b'http://example.com'), bytes)
+
+    def test_ascii_url_asciifies_unicode(self):
+        self.assertEqual(
+            b'http://example.com/', ascii_url('http://example.com/'))
+        self.assertIsInstance(ascii_url('http://example.com'), bytes)

=== added file 'src/apiclient/utils.py'
--- src/apiclient/utils.py	1970-01-01 00:00:00 +0000
+++ src/apiclient/utils.py	2012-09-17 14:41:20 +0000
@@ -0,0 +1,28 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Remote API library."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = [
+    "ascii_url",
+    ]
+
+
+from urlparse import urlparse
+
+
+def ascii_url(url):
+    """Encode `url` as ASCII if it isn't already."""
+    if isinstance(url, unicode):
+        urlparts = urlparse(url)
+        urlparts = urlparts._replace(
+            netloc=urlparts.netloc.encode("idna"))
+        url = urlparts.geturl()
+    return url.encode("ascii")

=== modified file 'src/maascli/__init__.py'
--- src/maascli/__init__.py	2012-09-12 19:27:01 +0000
+++ src/maascli/__init__.py	2012-09-17 14:41:20 +0000
@@ -10,30 +10,125 @@
     )
 
 __metaclass__ = type
-__all__ = []
+__all__ = [
+    "Command",
+    "CommandError",
+    "register",
+    ]
 
-from os.path import (
-    dirname,
-    join,
+from abc import (
+    ABCMeta,
+    abstractmethod,
     )
+import argparse
+import locale
 import sys
 
-# Add `lib` in this package's directory to sys.path.
-sys.path.insert(0, join(dirname(__file__), "lib"))
-
-from commandant import builtins
-from commandant.controller import CommandController
-
-
-def main(argv=sys.argv):
-    controller = CommandController(
-        program_name=argv[0],
-        program_version="1.0",
-        program_summary="Control MAAS using its API from the command-line.",
-        program_url="http://maas.ubuntu.com/";)
-    # At this point controller.load_path(...) can be used to load commands
-    # from a pre-agreed location on the filesystem, so that the command set
-    # will grow and shrink with the installed packages.
-    controller.load_module(builtins)
-    controller.install_bzrlib_hooks()
-    controller.run(argv[1:])
+from bzrlib import osutils
+from maascli.utils import (
+    parse_docstring,
+    safe_name,
+    )
+
+
+modules = {
+    "api": "maascli.api",
+    }
+
+
+class ArgumentParser(argparse.ArgumentParser):
+    """Specialisation of argparse's parser with better support for subparsers.
+
+    Specifically, the one-shot `add_subparsers` call is disabled, replaced by
+    a lazily evaluated `subparsers` property.
+    """
+
+    def add_subparsers(self):
+        raise NotImplementedError(
+            "add_subparsers has been disabled")
+
+    @property
+    def subparsers(self):
+        try:
+            return self.__subparsers
+        except AttributeError:
+            parent = super(ArgumentParser, self)
+            self.__subparsers = parent.add_subparsers(title="commands")
+            return self.__subparsers
+
+
+def main(argv=None):
+    # Set up the process's locale; this helps bzrlib decode command-line
+    # arguments in the next step.
+    locale.setlocale(locale.LC_ALL, "")
+    if argv is None:
+        argv = sys.argv[:1] + osutils.get_unicode_argv()
+
+    # Create the base argument parser.
+    parser = ArgumentParser(
+        description="Control MAAS from the command-line.",
+        prog=argv[0], epilog="http://maas.ubuntu.com/";)
+
+    # Register declared modules.
+    for name, module in sorted(modules.items()):
+        if isinstance(module, (str, unicode)):
+            module = __import__(module, fromlist=True)
+        help_title, help_body = parse_docstring(module)
+        module_parser = parser.subparsers.add_parser(
+            name, help=help_title, description=help_body)
+        register(module, module_parser)
+
+    # Run, doing polite things with exceptions.
+    try:
+        options = parser.parse_args(argv[1:])
+        options.execute(options)
+    except KeyboardInterrupt:
+        raise SystemExit(1)
+    except StandardError as error:
+        parser.error("%s" % error)
+
+
+class Command:
+    """A base class for composing commands.
+
+    This adheres to the expectations of `register`.
+    """
+
+    __metaclass__ = ABCMeta
+
+    def __init__(self, parser):
+        super(Command, self).__init__()
+        self.parser = parser
+
+    @abstractmethod
+    def __call__(self, options):
+        """Execute this command."""
+
+
+CommandError = SystemExit
+
+
+def register(module, parser):
+    """Register commands in `module` with the given argument parser.
+
+    This looks for callable objects named `cmd_*`, calls them with a new
+    subparser, and registers them as the default value for `execute` in the
+    namespace.
+
+    If the module also has a `register` function, this is also called, passing
+    in the module being scanned, and the parser given to this function.
+    """
+    # Register commands.
+    commands = {
+        name[4:]: command for name, command in vars(module).items()
+        if name.startswith("cmd_") and callable(command)
+        }
+    for name, command in commands.items():
+        help_title, help_body = parse_docstring(command)
+        command_parser = parser.subparsers.add_parser(
+            safe_name(name), help=help_title, description=help_body)
+        command_parser.set_defaults(execute=command(command_parser))
+    # Extra subparser registration.
+    register_module = getattr(module, "register", None)
+    if callable(register_module):
+        register_module(module, parser)

=== added file 'src/maascli/api.py'
--- src/maascli/api.py	1970-01-01 00:00:00 +0000
+++ src/maascli/api.py	2012-09-17 14:41:20 +0000
@@ -0,0 +1,333 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Interact with a remote MAAS server."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = [
+    "register",
+    ]
+
+from getpass import getpass
+import httplib
+import json
+import sys
+from urllib import urlencode
+from urlparse import (
+    urljoin,
+    urlparse,
+    )
+
+from apiclient.creds import (
+    convert_string_to_tuple,
+    convert_tuple_to_string,
+    )
+from apiclient.maas_client import MAASOAuth
+from apiclient.multipart import encode_multipart_data
+from apiclient.utils import ascii_url
+import httplib2
+from maascli import (
+    Command,
+    CommandError,
+    )
+from maascli.config import ProfileConfig
+from maascli.utils import (
+    ensure_trailing_slash,
+    handler_command_name,
+    parse_docstring,
+    safe_name,
+    )
+
+
+def try_getpass(prompt):
+    """Call `getpass`, ignoring EOF errors."""
+    try:
+        return getpass(prompt)
+    except EOFError:
+        return None
+
+
+def obtain_credentials(credentials):
+    """Prompt for credentials if possible.
+
+    If the credentials are "-" then read from stdin without interactive
+    prompting.
+    """
+    if credentials == "-":
+        credentials = sys.stdin.readline().strip()
+    elif credentials is None:
+        credentials = try_getpass(
+            "API key (leave empty for anonymous access): ")
+    # Ensure that the credentials have a valid form.
+    if credentials and not credentials.isspace():
+        return convert_string_to_tuple(credentials)
+    else:
+        return None
+
+
+def fetch_api_description(url):
+    """Obtain the description of remote API given its base URL."""
+    url_describe = urljoin(url, "describe/")
+    http = httplib2.Http()
+    response, content = http.request(
+        ascii_url(url_describe), "GET")
+    if response.status != httplib.OK:
+        raise CommandError(
+            "{0.status} {0.reason}:\n{1}".format(response, content))
+    if response["content-type"] != "application/json":
+        raise CommandError(
+            "Expected application/json, got: %(content-type)s" % response)
+    return json.loads(content)
+
+
+class cmd_login(Command):
+    """Log-in to a remote API, storing its description and credentials.
+
+    If credentials are not provided on the command-line, they will be prompted
+    for interactively.
+    """
+
+    def __init__(self, parser):
+        super(cmd_login, self).__init__(parser)
+        parser.add_argument(
+            "profile_name", metavar="profile-name", help=(
+                "The name with which you will later refer to this remote "
+                "server and credentials within this tool."
+                ))
+        parser.add_argument(
+            "url", help=(
+                "The URL of the remote API, e.g. "
+                "http://example.com/api/1.0/";))
+        parser.add_argument(
+            "credentials", nargs="?", default=None, help=(
+                "The credentials, also known as the API key, for the "
+                "remote MAAS server. These can be found in the user "
+                "preferences page in the web UI."
+                ))
+        parser.set_defaults(credentials=None)
+
+    def __call__(self, options):
+        # Try and obtain credentials interactively if they're not given, or
+        # read them from stdin if they're specified as "-".
+        credentials = obtain_credentials(options.credentials)
+        # Normalise the remote service's URL.
+        url = ensure_trailing_slash(options.url)
+        # Get description of remote API.
+        description = fetch_api_description(url)
+        # Save the config.
+        profile_name = options.profile_name
+        with ProfileConfig.open() as config:
+            config[profile_name] = {
+                "credentials": credentials,
+                "description": description,
+                "name": profile_name,
+                "url": url,
+                }
+
+
+class cmd_refresh(Command):
+    """Refresh the API descriptions of all profiles."""
+
+    def __call__(self, options):
+        with ProfileConfig.open() as config:
+            for profile_name in config:
+                profile = config[profile_name]
+                url = profile["url"]
+                profile["description"] = fetch_api_description(url)
+                config[profile_name] = profile
+
+
+class cmd_logout(Command):
+    """Log-out of a remote API, purging any stored credentials."""
+
+    def __init__(self, parser):
+        super(cmd_logout, self).__init__(parser)
+        parser.add_argument(
+            "profile_name", metavar="profile-name", help=(
+                "The name with which a remote server and its credentials "
+                "are referred to within this tool."
+                ))
+
+    def __call__(self, options):
+        with ProfileConfig.open() as config:
+            del config[options.profile_name]
+
+
+class cmd_list(Command):
+    """List remote APIs that have been logged-in to."""
+
+    def __call__(self, options):
+        with ProfileConfig.open() as config:
+            for profile_name in config:
+                profile = config[profile_name]
+                url = profile["url"]
+                creds = profile["credentials"]
+                if creds is None:
+                    print(profile_name, url)
+                else:
+                    creds = convert_tuple_to_string(creds)
+                    print(profile_name, url, creds)
+
+
+class Action(Command):
+    """A generic MAAS API action.
+
+    This is used as a base for creating more specific commands; see
+    `register_actions`.
+
+    **Note** that this class conflates two things: CLI exposure and API
+    client. The client in apiclient.maas_client is not quite suitable yet, but
+    it should be iterated upon to make it suitable.
+    """
+
+    # Override these in subclasses; see `register_actions`.
+    profile = handler = action = None
+
+    uri = property(lambda self: self.handler["uri"])
+    method = property(lambda self: self.action["method"])
+    restful = property(lambda self: self.action["restful"])
+    credentials = property(lambda self: self.profile["credentials"])
+    op = property(lambda self: self.action["op"])
+
+    def __init__(self, parser):
+        super(Action, self).__init__(parser)
+        for param in self.handler["params"]:
+            parser.add_argument(param)
+        parser.add_argument(
+            "data", type=self.name_value_pair, nargs="*")
+
+    def __call__(self, options):
+        # TODO: this is el-cheapo URI Template
+        # <http://tools.ietf.org/html/rfc6570> support; use uritemplate-py
+        # <https://github.com/uri-templates/uritemplate-py> here?
+        uri = self.uri.format(**vars(options))
+
+        # Parse data out of the positional arguments.
+        data = dict(options.data)
+        if self.op is not None:
+            data["op"] = self.op
+
+        # Bundle things up ready to throw over the wire.
+        uri, body, headers = self.prepare_payload(uri, data)
+
+        # Sign request if credentials have been provided.
+        self.maybe_sign(uri, headers)
+
+        # Use httplib2 instead of urllib2 (or MAASDispatcher, which is based
+        # on urllib2) so that we get full control over HTTP method. TODO:
+        # create custom MAASDispatcher to use httplib2 so that MAASClient can
+        # be used.
+        http = httplib2.Http()
+        response, content = http.request(
+            uri, self.method, body=body, headers=headers)
+
+        # TODO: decide on how to display responses to users.
+        self.print_response(response, content)
+
+        # 2xx status codes are all okay.
+        if response.status // 100 != 2:
+            raise CommandError(2)
+
+    @staticmethod
+    def name_value_pair(string):
+        parts = string.split("=", 1)
+        if len(parts) == 2:
+            return parts
+        else:
+            raise CommandError(
+                "%r is not a name=value pair" % string)
+
+    def prepare_payload(self, uri, data):
+        """Return the URI (modified perhaps) and body and headers.
+
+        :param method: The HTTP method.
+        :param uri: The URI of the action.
+        :param data: A dict or iterable of name=value pairs to pack into the
+            body or headers, depending on the type of request.
+        """
+        if self.method == "POST" and not self.restful:
+            # Encode the data as multipart for non-ReSTful POST requests; all
+            # others should use query parameters. TODO: encode_multipart_data
+            # insists on a dict for the data, which prevents specifying
+            # multiple values for a field, like mac_addresses.  This needs to
+            # be fixed.
+            body, headers = encode_multipart_data(data, {})
+            # TODO: make encode_multipart_data work with arbitrarily encoded
+            # strings; atm, it blows up when encountering a non-ASCII string.
+        else:
+            # TODO: deal with state information, i.e. where to stuff CRUD
+            # data, content types, etc.
+            body, headers = None, {}
+            # TODO: smarter merging of data with query.
+            uri = urlparse(uri)._replace(query=urlencode(data)).geturl()
+
+        return uri, body, headers
+
+    def maybe_sign(self, uri, headers):
+        """Sign the URI and headers, if possible."""
+        if self.credentials is not None:
+            auth = MAASOAuth(*self.credentials)
+            auth.sign_request(uri, headers)
+
+    @staticmethod
+    def print_response(response, content):
+        """Show an HTTP response in a human-friendly way."""
+        # Function to change headers like "transfer-encoding" into
+        # "Transfer-Encoding".
+        cap = lambda header: "-".join(
+            part.capitalize() for part in header.split("-"))
+        # Format string to prettify reporting of response headers.
+        form = "%%%ds: %%s" % (
+            max(len(header) for header in response) + 2)
+        # Print the response.
+        print(response.status, response.reason)
+        print()
+        for header in sorted(response):
+            print(form % (cap(header), response[header]))
+        print()
+        print(content)
+
+
+def register_actions(profile, handler, parser):
+    """Register a handler's actions."""
+    for action in handler["actions"]:
+        help_title, help_body = parse_docstring(action["doc"])
+        action_name = safe_name(action["name"]).encode("ascii")
+        action_bases = (Action,)
+        action_ns = {
+            "action": action,
+            "handler": handler,
+            "profile": profile,
+            }
+        action_class = type(action_name, action_bases, action_ns)
+        action_parser = parser.subparsers.add_parser(
+            action_name, help=help_title, description=help_body)
+        action_parser.set_defaults(
+            execute=action_class(action_parser))
+
+
+def register_handlers(profile, parser):
+    """Register a profile's handlers."""
+    description = profile["description"]
+    for handler in description["handlers"]:
+        help_title, help_body = parse_docstring(handler["doc"])
+        handler_name = handler_command_name(handler["name"])
+        handler_parser = parser.subparsers.add_parser(
+            handler_name, help=help_title, description=help_body)
+        register_actions(profile, handler, handler_parser)
+
+
+def register(module, parser):
+    """Register profiles."""
+    with ProfileConfig.open() as config:
+        for profile_name in config:
+            profile = config[profile_name]
+            profile_parser = parser.subparsers.add_parser(
+                profile["name"], help="Interact with %(url)s" % profile)
+            register_handlers(profile, profile_parser)

=== added file 'src/maascli/config.py'
--- src/maascli/config.py	1970-01-01 00:00:00 +0000
+++ src/maascli/config.py	2012-09-17 14:41:20 +0000
@@ -0,0 +1,89 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Configuration abstractions for the MAAS CLI."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = [
+    "ProfileConfig",
+    ]
+
+from contextlib import (
+    closing,
+    contextmanager,
+    )
+import json
+import os
+from os.path import expanduser
+import sqlite3
+
+
+class ProfileConfig:
+    """Store profile configurations in an sqlite3 database."""
+
+    def __init__(self, database):
+        self.database = database
+        with self.cursor() as cursor:
+            cursor.execute(
+                "CREATE TABLE IF NOT EXISTS profiles "
+                "(id INTEGER PRIMARY KEY,"
+                " name TEXT NOT NULL UNIQUE,"
+                " data BLOB)")
+
+    def cursor(self):
+        return closing(self.database.cursor())
+
+    def __iter__(self):
+        with self.cursor() as cursor:
+            results = cursor.execute(
+                "SELECT name FROM profiles").fetchall()
+        return (name for (name,) in results)
+
+    def __getitem__(self, name):
+        with self.cursor() as cursor:
+            [data] = cursor.execute(
+                "SELECT data FROM profiles"
+                " WHERE name = ?", (name,)).fetchone()
+        return json.loads(data)
+
+    def __setitem__(self, name, data):
+        with self.cursor() as cursor:
+            cursor.execute(
+                "INSERT OR REPLACE INTO profiles (name, data) "
+                "VALUES (?, ?)", (name, json.dumps(data)))
+
+    def __delitem__(self, name):
+        with self.cursor() as cursor:
+            cursor.execute(
+                "DELETE FROM profiles"
+                " WHERE name = ?", (name,))
+
+    @classmethod
+    @contextmanager
+    def open(cls, dbpath=expanduser("~/.maascli.db")):
+        """Load a profiles database.
+
+        Called without arguments this will open (and create) a database in the
+        user's home directory.
+
+        **Note** that this returns a context manager which will close the
+        database on exit, saving if the exit is clean.
+        """
+        # Initialise filename with restrictive permissions...
+        os.close(os.open(dbpath, os.O_CREAT | os.O_APPEND, 0600))
+        # before opening it with sqlite.
+        database = sqlite3.connect(dbpath)
+        try:
+            yield cls(database)
+        except:
+            raise
+        else:
+            database.commit()
+        finally:
+            database.close()

=== added directory 'src/maascli/tests'
=== added file 'src/maascli/tests/__init__.py'
=== added file 'src/maascli/tests/test_api.py'
--- src/maascli/tests/test_api.py	1970-01-01 00:00:00 +0000
+++ src/maascli/tests/test_api.py	2012-09-17 14:41:20 +0000
@@ -0,0 +1,122 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for `maascli.api`."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = []
+
+import httplib
+import json
+import sys
+
+from apiclient.creds import convert_tuple_to_string
+import httplib2
+from maascli import (
+    api,
+    CommandError,
+    )
+from maastesting.factory import factory
+from maastesting.testcase import TestCase
+from mock import sentinel
+
+
+class TestFunctions(TestCase):
+    """Test for miscellaneous functions in `maascli.api`."""
+
+    def test_try_getpass(self):
+        getpass = self.patch(api, "getpass")
+        getpass.return_value = sentinel.credentials
+        self.assertIs(sentinel.credentials, api.try_getpass(sentinel.prompt))
+        getpass.assert_called_once_with(sentinel.prompt)
+
+    def test_try_getpass_eof(self):
+        getpass = self.patch(api, "getpass")
+        getpass.side_effect = EOFError
+        self.assertIsNone(api.try_getpass(sentinel.prompt))
+        getpass.assert_called_once_with(sentinel.prompt)
+
+    @staticmethod
+    def make_credentials():
+        return (
+            factory.make_name("cred"),
+            factory.make_name("cred"),
+            factory.make_name("cred"),
+            )
+
+    def test_obtain_credentials_from_stdin(self):
+        # When "-" is passed to obtain_credentials, it reads credentials from
+        # stdin, trims whitespace, and converts it into a 3-tuple of creds.
+        credentials = self.make_credentials()
+        stdin = self.patch(sys, "stdin")
+        stdin.readline.return_value = (
+            convert_tuple_to_string(credentials) + "\n")
+        self.assertEqual(credentials, api.obtain_credentials("-"))
+        stdin.readline.assert_called_once()
+
+    def test_obtain_credentials_via_getpass(self):
+        # When None is passed to obtain_credentials, it attempts to obtain
+        # credentials via getpass, then converts it into a 3-tuple of creds.
+        credentials = self.make_credentials()
+        getpass = self.patch(api, "getpass")
+        getpass.return_value = convert_tuple_to_string(credentials)
+        self.assertEqual(credentials, api.obtain_credentials(None))
+        getpass.assert_called_once()
+
+    def test_obtain_credentials_empty(self):
+        # If the entered credentials are empty or only whitespace,
+        # obtain_credentials returns None.
+        getpass = self.patch(api, "getpass")
+        getpass.return_value = None
+        self.assertEqual(None, api.obtain_credentials(None))
+        getpass.assert_called_once()
+
+    def test_fetch_api_description(self):
+        content = factory.make_name("content")
+        request = self.patch(httplib2.Http, "request")
+        response = httplib2.Response({})
+        response.status = httplib.OK
+        response["content-type"] = "application/json"
+        request.return_value = response, json.dumps(content)
+        self.assertEqual(
+            content, api.fetch_api_description("http://example.com/api/1.0/";))
+        request.assert_called_once_with(
+            b"http://example.com/api/1.0/describe/";, "GET")
+
+    def test_fetch_api_description_not_okay(self):
+        # If the response is not 200 OK, fetch_api_description throws toys.
+        content = factory.make_name("content")
+        request = self.patch(httplib2.Http, "request")
+        response = httplib2.Response({})
+        response.status = httplib.BAD_REQUEST
+        response.reason = httplib.responses[httplib.BAD_REQUEST]
+        request.return_value = response, json.dumps(content)
+        error = self.assertRaises(
+            CommandError, api.fetch_api_description,
+            "http://example.com/api/1.0/";)
+        error_expected = "%d %s:\n%s" % (
+            httplib.BAD_REQUEST, httplib.responses[httplib.BAD_REQUEST],
+            json.dumps(content))
+        self.assertEqual(error_expected, "%s" % error)
+
+    def test_fetch_api_description_wrong_content_type(self):
+        # If the response's content type is not application/json,
+        # fetch_api_description throws toys again.
+        content = factory.make_name("content")
+        request = self.patch(httplib2.Http, "request")
+        response = httplib2.Response({})
+        response.status = httplib.OK
+        response["content-type"] = "text/css"
+        request.return_value = response, json.dumps(content)
+        error = self.assertRaises(
+            CommandError, api.fetch_api_description,
+            "http://example.com/api/1.0/";)
+        self.assertEqual(
+            "Expected application/json, got: text/css",
+            "%s" % error)

=== added file 'src/maascli/tests/test_config.py'
--- src/maascli/tests/test_config.py	1970-01-01 00:00:00 +0000
+++ src/maascli/tests/test_config.py	2012-09-17 14:41:20 +0000
@@ -0,0 +1,102 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for `maascli.config`."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = []
+
+import contextlib
+import os.path
+import sqlite3
+
+from maascli import api
+from maastesting.testcase import TestCase
+from twisted.python.filepath import FilePath
+
+
+class TestProfileConfig(TestCase):
+    """Tests for `ProfileConfig`."""
+
+    def test_init(self):
+        database = sqlite3.connect(":memory:")
+        config = api.ProfileConfig(database)
+        with config.cursor() as cursor:
+            # The profiles table has been created.
+            self.assertEqual(
+                cursor.execute(
+                    "SELECT COUNT(*) FROM sqlite_master"
+                    " WHERE type = 'table'"
+                    "   AND name = 'profiles'").fetchone(),
+                (1,))
+
+    def test_profiles_pristine(self):
+        # A pristine configuration has no profiles.
+        database = sqlite3.connect(":memory:")
+        config = api.ProfileConfig(database)
+        self.assertSetEqual(set(), set(config))
+
+    def test_adding_profile(self):
+        database = sqlite3.connect(":memory:")
+        config = api.ProfileConfig(database)
+        config["alice"] = {"abc": 123}
+        self.assertEqual({"alice"}, set(config))
+        self.assertEqual({"abc": 123}, config["alice"])
+
+    def test_replacing_profile(self):
+        database = sqlite3.connect(":memory:")
+        config = api.ProfileConfig(database)
+        config["alice"] = {"abc": 123}
+        config["alice"] = {"def": 456}
+        self.assertEqual({"alice"}, set(config))
+        self.assertEqual({"def": 456}, config["alice"])
+
+    def test_getting_profile(self):
+        database = sqlite3.connect(":memory:")
+        config = api.ProfileConfig(database)
+        config["alice"] = {"abc": 123}
+        self.assertEqual({"abc": 123}, config["alice"])
+
+    def test_removing_profile(self):
+        database = sqlite3.connect(":memory:")
+        config = api.ProfileConfig(database)
+        config["alice"] = {"abc": 123}
+        del config["alice"]
+        self.assertEqual(set(), set(config))
+
+    def test_open_and_close(self):
+        # ProfileConfig.open() returns a context manager that closes the
+        # database on exit.
+        config_file = os.path.join(self.make_dir(), "config")
+        config = api.ProfileConfig.open(config_file)
+        self.assertIsInstance(config, contextlib.GeneratorContextManager)
+        with config as config:
+            self.assertIsInstance(config, api.ProfileConfig)
+            with config.cursor() as cursor:
+                self.assertEqual(
+                    (1,), cursor.execute("SELECT 1").fetchone())
+        self.assertRaises(sqlite3.ProgrammingError, config.cursor)
+
+    def test_open_permissions_new_database(self):
+        # ProfileConfig.open() applies restrictive file permissions to newly
+        # created configuration databases.
+        config_file = os.path.join(self.make_dir(), "config")
+        with api.ProfileConfig.open(config_file):
+            perms = FilePath(config_file).getPermissions()
+            self.assertEqual("rw-------", perms.shorthand())
+
+    def test_open_permissions_existing_database(self):
+        # ProfileConfig.open() leaves the file permissions of existing
+        # configuration databases.
+        config_file = os.path.join(self.make_dir(), "config")
+        open(config_file, "wb").close()  # touch.
+        os.chmod(config_file, 0644)  # u=rw,go=r
+        with api.ProfileConfig.open(config_file):
+            perms = FilePath(config_file).getPermissions()
+            self.assertEqual("rw-r--r--", perms.shorthand())

=== added file 'src/maascli/tests/test_init.py'
--- src/maascli/tests/test_init.py	1970-01-01 00:00:00 +0000
+++ src/maascli/tests/test_init.py	2012-09-17 14:41:20 +0000
@@ -0,0 +1,113 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for `maascli`."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = []
+
+import new
+
+from maascli import (
+    ArgumentParser,
+    register,
+    )
+from maastesting.testcase import TestCase
+from mock import sentinel
+
+
+class TestArgumentParser(TestCase):
+
+    def test_add_subparsers_disabled(self):
+        parser = ArgumentParser()
+        self.assertRaises(NotImplementedError, parser.add_subparsers)
+
+    def test_subparsers_property(self):
+        parser = ArgumentParser()
+        # argparse.ArgumentParser.add_subparsers populates a _subparsers
+        # attribute when called. Its contents are not the same as the return
+        # value from add_subparsers, so we just use it an indicator here.
+        self.assertIsNone(parser._subparsers)
+        # Reference the subparsers property.
+        subparsers = parser.subparsers
+        # _subparsers is populated, meaning add_subparsers has been called on
+        # the superclass.
+        self.assertIsNotNone(parser._subparsers)
+        # The subparsers property, once populated, always returns the same
+        # object.
+        self.assertIs(subparsers, parser.subparsers)
+
+
+class TestRegister(TestCase):
+    """Tests for `maascli.register`."""
+
+    def test_empty(self):
+        module = new.module(b"%s.test" % __name__)
+        parser = ArgumentParser()
+        register(module, parser)
+        # No subparsers were registered.
+        self.assertIsNone(parser._subparsers)
+
+    def test_command(self):
+        module = new.module(b"%s.test" % __name__)
+        cmd = self.patch(module, "cmd_one")
+        cmd.return_value = sentinel.execute
+        parser = ArgumentParser()
+        register(module, parser)
+        # Subparsers were registered.
+        self.assertIsNotNone(parser._subparsers)
+        # The command was called once with a subparser called "one".
+        subparser_one = parser.subparsers.choices["one"]
+        cmd.assert_called_once_with(subparser_one)
+        # The subparser has an appropriate execute default.
+        self.assertIs(
+            sentinel.execute,
+            subparser_one.get_default("execute"))
+
+    def test_commands(self):
+        module = new.module(b"%s.test" % __name__)
+        cmd_one = self.patch(module, "cmd_one")
+        cmd_one.return_value = sentinel.x_one
+        cmd_two = self.patch(module, "cmd_two")
+        cmd_two.return_value = sentinel.x_two
+        parser = ArgumentParser()
+        register(module, parser)
+        # The commands were called with appropriate subparsers.
+        subparser_one = parser.subparsers.choices["one"]
+        cmd_one.assert_called_once_with(subparser_one)
+        subparser_two = parser.subparsers.choices["two"]
+        cmd_two.assert_called_once_with(subparser_two)
+        # The subparsers have appropriate execute defaults.
+        self.assertIs(sentinel.x_one, subparser_one.get_default("execute"))
+        self.assertIs(sentinel.x_two, subparser_two.get_default("execute"))
+
+    def test_register(self):
+        module = new.module(b"%s.test" % __name__)
+        module_register = self.patch(module, "register")
+        parser = ArgumentParser()
+        register(module, parser)
+        # No subparsers were registered; calling module.register does not
+        # imply that this happens.
+        self.assertIsNone(parser._subparsers)
+        # The command was called once with a subparser called "one".
+        module_register.assert_called_once_with(module, parser)
+
+    def test_command_and_register(self):
+        module = new.module(b"%s.test" % __name__)
+        module_register = self.patch(module, "register")
+        cmd = self.patch(module, "cmd_one")
+        parser = ArgumentParser()
+        register(module, parser)
+        # Subparsers were registered because a command was found.
+        self.assertIsNotNone(parser._subparsers)
+        # The command was called once with a subparser called "one".
+        module_register.assert_called_once_with(module, parser)
+        # The command was called once with a subparser called "one".
+        cmd.assert_called_once_with(
+            parser.subparsers.choices["one"])

=== added file 'src/maascli/tests/test_utils.py'
--- src/maascli/tests/test_utils.py	1970-01-01 00:00:00 +0000
+++ src/maascli/tests/test_utils.py	2012-09-17 14:41:20 +0000
@@ -0,0 +1,189 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for `maascli.utils`."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = []
+
+from maascli import utils
+from maastesting.testcase import TestCase
+
+
+class TestDocstringParsing(TestCase):
+    """Tests for docstring parsing in `maascli.utils`."""
+
+    def test_basic(self):
+        self.assertEqual(
+            ("Title", "Body"),
+            utils.parse_docstring("Title\n\nBody"))
+        self.assertEqual(
+            ("A longer title", "A longer body"),
+            utils.parse_docstring(
+                "A longer title\n\nA longer body"))
+
+    def test_no_body(self):
+        # parse_docstring returns an empty string when there's no body.
+        self.assertEqual(
+            ("Title", ""),
+            utils.parse_docstring("Title\n\n"))
+        self.assertEqual(
+            ("Title", ""),
+            utils.parse_docstring("Title"))
+
+    def test_unwrapping(self):
+        # parse_docstring dedents and unwraps the title and body paragraphs.
+        self.assertEqual(
+            ("Title over two lines",
+             "Paragraph over two lines\n\n"
+             "Another paragraph over two lines"),
+            utils.parse_docstring("""
+                Title over
+                two lines
+
+                Paragraph over
+                two lines
+
+                Another paragraph
+                over two lines
+                """))
+
+    def test_no_unwrapping_for_indented_paragraphs(self):
+        # parse_docstring dedents body paragraphs, but does not unwrap those
+        # with indentation beyond the rest.
+        self.assertEqual(
+            ("Title over two lines",
+             "Paragraph over two lines\n\n"
+             "  An indented paragraph\n  which will remain wrapped\n\n"
+             "Another paragraph over two lines"),
+            utils.parse_docstring("""
+                Title over
+                two lines
+
+                Paragraph over
+                two lines
+
+                  An indented paragraph
+                  which will remain wrapped
+
+                Another paragraph
+                over two lines
+                """))
+
+    def test_gets_docstring_from_function(self):
+        # parse_docstring can extract the docstring when the argument passed
+        # is not a string type.
+        def example():
+            """Title.
+
+            Body.
+            """
+        self.assertEqual(
+            ("Title.", "Body."),
+            utils.parse_docstring(example))
+
+    def test_normalises_whitespace(self):
+        # parse_docstring can parse CRLF/CR/LF text, but always emits LF (\n,
+        # new-line) separated text.
+        self.assertEqual(
+            ("long title", ""),
+            utils.parse_docstring("long\r\ntitle"))
+        self.assertEqual(
+            ("title", "body1\n\nbody2"),
+            utils.parse_docstring("title\n\nbody1\r\rbody2"))
+
+
+class TestFunctions(TestCase):
+    """Tests for miscellaneous functions in `maascli.utils`."""
+
+    maxDiff = TestCase.maxDiff * 2
+
+    def test_safe_name(self):
+        # safe_name attempts to discriminate parts of a vaguely camel-cased
+        # string, and rejoins them using a hyphen.
+        expected = {
+            "NodeHandler": "Node-Handler",
+            "SpadeDiggingHandler": "Spade-Digging-Handler",
+            "SPADE_Digging_Handler": "SPADE-Digging-Handler",
+            "SpadeHandlerForDigging": "Spade-Handler-For-Digging",
+            "JamesBond007": "James-Bond007",
+            "JamesBOND": "James-BOND",
+            "James-BOND-007": "James-BOND-007",
+            }
+        observed = {
+            name_in: utils.safe_name(name_in)
+            for name_in in expected
+            }
+        self.assertItemsEqual(
+            expected.items(), observed.items())
+
+    def test_safe_name_non_ASCII(self):
+        # safe_name will not break if passed a string with non-ASCII
+        # characters. However, those characters will not be present in the
+        # returned name.
+        self.assertEqual(
+            "a-b-c", utils.safe_name(u"a\u1234_b\u5432_c\u9876"))
+
+    def test_safe_name_string_type(self):
+        # Given a unicode string, safe_name will always return a unicode
+        # string, and given a byte string it will always return a byte string.
+        self.assertIsInstance(utils.safe_name(u"fred"), unicode)
+        self.assertIsInstance(utils.safe_name(b"fred"), bytes)
+
+    def test_handler_command_name(self):
+        # handler_command_name attempts to discriminate parts of a vaguely
+        # camel-cased string, removes any "handler" parts, joins again with
+        # hyphens, and returns the whole lot in lower case.
+        expected = {
+            "NodeHandler": "node",
+            "SpadeDiggingHandler": "spade-digging",
+            "SPADE_Digging_Handler": "spade-digging",
+            "SpadeHandlerForDigging": "spade-for-digging",
+            "JamesBond007": "james-bond007",
+            "JamesBOND": "james-bond",
+            "James-BOND-007": "james-bond-007",
+            }
+        observed = {
+            name_in: utils.handler_command_name(name_in)
+            for name_in in expected
+            }
+        self.assertItemsEqual(
+            expected.items(), observed.items())
+        # handler_command_name also ensures that all names are encoded into
+        # byte strings.
+        expected_types = {
+            name_out: bytes
+            for name_out in observed.values()
+            }
+        observed_types = {
+            name_out: type(name_out)
+            for name_out in observed.values()
+            }
+        self.assertItemsEqual(
+            expected_types.items(), observed_types.items())
+
+    def test_handler_command_name_non_ASCII(self):
+        # handler_command_name will not break if passed a string with
+        # non-ASCII characters. However, those characters will not be present
+        # in the returned name.
+        self.assertEqual(
+            "a-b-c", utils.handler_command_name(u"a\u1234_b\u5432_c\u9876"))
+
+    def test_ensure_trailing_slash(self):
+        # ensure_trailing_slash ensures that the given string - typically a
+        # URL or path - has a trailing forward slash.
+        self.assertEqual("fred/", utils.ensure_trailing_slash("fred"))
+        self.assertEqual("fred/", utils.ensure_trailing_slash("fred/"))
+
+    def test_ensure_trailing_slash_string_type(self):
+        # Given a unicode string, ensure_trailing_slash will always return a
+        # unicode string, and given a byte string it will always return a byte
+        # string.
+        self.assertIsInstance(utils.ensure_trailing_slash(u"fred"), unicode)
+        self.assertIsInstance(utils.ensure_trailing_slash(b"fred"), bytes)

=== added file 'src/maascli/utils.py'
--- src/maascli/utils.py	1970-01-01 00:00:00 +0000
+++ src/maascli/utils.py	2012-09-17 14:41:20 +0000
@@ -0,0 +1,88 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Utilities for the command-line interface."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = [
+    "ensure_trailing_slash",
+    "handler_command_name",
+    "parse_docstring",
+    "safe_name",
+    ]
+
+from functools import partial
+from inspect import getdoc
+import re
+from textwrap import dedent
+
+
+re_paragraph_splitter = re.compile(
+    r"(?:\r\n){2,}|\r{2,}|\n{2,}", re.MULTILINE)
+
+paragraph_split = re_paragraph_splitter.split
+docstring_split = partial(paragraph_split, maxsplit=1)
+remove_line_breaks = lambda string: (
+    " ".join(line.strip() for line in string.splitlines()))
+
+newline = "\n"
+empty = ""
+
+
+def parse_docstring(thing):
+    doc = thing if isinstance(thing, (str, unicode)) else getdoc(thing)
+    doc = empty if doc is None else doc.expandtabs().strip()
+    # Break the docstring into two parts: title and body.
+    parts = docstring_split(doc)
+    if len(parts) == 2:
+        title, body = parts[0], dedent(parts[1])
+    else:
+        title, body = parts[0], empty
+    # Remove line breaks from the title line.
+    title = remove_line_breaks(title)
+    # Remove line breaks from non-indented paragraphs in the body.
+    paragraphs = []
+    for paragraph in paragraph_split(body):
+        if not paragraph[:1].isspace():
+            paragraph = remove_line_breaks(paragraph)
+        paragraphs.append(paragraph)
+    # Rejoin the paragraphs, normalising on newline.
+    body = (newline + newline).join(
+        paragraph.replace("\r\n", newline).replace("\r", newline)
+        for paragraph in paragraphs)
+    return title, body
+
+
+re_camelcase = re.compile(
+    r"([A-Z]*[a-z0-9]+|[A-Z]+)(?:(?=[^a-z0-9])|\Z)")
+
+
+def safe_name(string):
+    """Return a munged version of string, suitable as an ASCII filename."""
+    hyphen = "-" if isinstance(string, unicode) else b"-"
+    return hyphen.join(re_camelcase.findall(string))
+
+
+def handler_command_name(string):
+    """Create a handler command name from an arbitrary string.
+
+    Camel-case parts of string will be extracted, converted to lowercase,
+    joined with hyphens, and the rest discarded. The term "handler" will also
+    be removed if discovered amongst the aforementioned parts.
+    """
+    parts = re_camelcase.findall(string)
+    parts = (part.lower().encode("ascii") for part in parts)
+    parts = (part for part in parts if part != b"handler")
+    return b"-".join(parts)
+
+
+def ensure_trailing_slash(string):
+    """Ensure that `string` has a trailing forward-slash."""
+    slash = b"/" if isinstance(string, bytes) else u"/"
+    return (string + slash) if not string.endswith(slash) else string

=== modified file 'src/provisioningserver/tftp.py'
--- src/provisioningserver/tftp.py	2012-08-30 10:47:03 +0000
+++ src/provisioningserver/tftp.py	2012-09-17 14:41:20 +0000
@@ -124,7 +124,7 @@
         # Merge updated query into the generator URL.
         url = self.generator_url._replace(query=urlencode(query))
         # TODO: do something more intelligent with unicode URLs here; see
-        # maas_client._ascii_url() for inspiration.
+        # apiclient.utils.ascii_url() for inspiration.
         return url.geturl().encode("ascii")
 
     @deferred

=== modified file 'versions.cfg'
--- versions.cfg	2012-09-12 17:01:41 +0000
+++ versions.cfg	2012-09-17 14:41:20 +0000
@@ -96,7 +96,6 @@
 
 # Required by:
 # entrypoint2==0.0.4
-argparse = 1.2.1
 
 # Required by:
 # entrypoint2==0.0.4