launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #12079
[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