Adapting maas to newer api
diff --git a/_modules/apiclient/__init__.py b/_modules/apiclient/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/_modules/apiclient/__init__.py
diff --git a/_modules/apiclient/creds.py b/_modules/apiclient/creds.py
new file mode 100644
index 0000000..5cace7e
--- /dev/null
+++ b/_modules/apiclient/creds.py
@@ -0,0 +1,48 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Handling of MAAS API credentials.
+
+The API client deals with credentials consisting of 3 elements: consumer
+key, resource token, and resource secret.  These are in OAuth, but the
+consumer secret is hardwired to the empty string.
+
+Credentials are represented internally as tuples of these three elements,
+but can also be converted to a colon-separated string format for easy
+transport between processes.
+"""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+str = None
+
+__metaclass__ = type
+__all__ = [
+    'convert_string_to_tuple',
+    'convert_tuple_to_string',
+    ]
+
+
+def convert_tuple_to_string(creds_tuple):
+    """Represent a MAAS API credentials tuple as a colon-separated string."""
+    if len(creds_tuple) != 3:
+        raise ValueError(
+            "Credentials tuple does not consist of 3 elements as expected; "
+            "it contains %d."
+            % len(creds_tuple))
+    return ':'.join(creds_tuple)
+
+
+def convert_string_to_tuple(creds_string):
+    """Recreate a MAAS API credentials tuple from a colon-separated string."""
+    creds_tuple = tuple(creds_string.split(':'))
+    if len(creds_tuple) != 3:
+        raise ValueError(
+            "Malformed credentials string.  Expected 3 colon-separated items, "
+            "got %r."
+            % creds_string)
+    return creds_tuple
diff --git a/_modules/apiclient/encode_json.py b/_modules/apiclient/encode_json.py
new file mode 100644
index 0000000..534ac9e
--- /dev/null
+++ b/_modules/apiclient/encode_json.py
@@ -0,0 +1,34 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Encoding requests as JSON data."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+str = None
+
+__metaclass__ = type
+__all__ = [
+    'encode_json_data',
+    ]
+
+import json
+
+
+def encode_json_data(params):
+    """Encode params as JSON and set up headers for an HTTP POST.
+
+    :param params: Can be a dict or a list, but should generally be a dict, to
+    match the key-value data expected by most receiving APIs.
+    :return: (body, headers)
+    """
+    body = json.dumps(params)
+    headers = {
+        'Content-Length': unicode(len(body)),
+        'Content-Type': 'application/json',
+        }
+    return body, headers
diff --git a/_modules/apiclient/maas_client.py b/_modules/apiclient/maas_client.py
new file mode 100644
index 0000000..9a184c5
--- /dev/null
+++ b/_modules/apiclient/maas_client.py
@@ -0,0 +1,244 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""MAAS OAuth API connection library."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+str = None
+
+__metaclass__ = type
+__all__ = [
+    'MAASClient',
+    'MAASDispatcher',
+    'MAASOAuth',
+    ]
+
+import gzip
+from io import BytesIO
+import urllib2
+
+from apiclient.encode_json import encode_json_data
+from apiclient.multipart import encode_multipart_data
+from apiclient.utils import urlencode
+import oauth.oauth as oauth
+
+
+class MAASOAuth:
+    """Helper class to OAuth-sign an HTTP request."""
+
+    def __init__(self, consumer_key, resource_token, resource_secret):
+        resource_tok_string = "oauth_token_secret=%s&oauth_token=%s" % (
+            resource_secret, resource_token)
+        self.resource_token = oauth.OAuthToken.from_string(resource_tok_string)
+        self.consumer_token = oauth.OAuthConsumer(consumer_key, "")
+
+    def sign_request(self, url, headers):
+        """Sign a request.
+
+        @param url: The URL to which the request is to be sent.
+        @param headers: The headers in the request.  These will be updated
+            with the signature.
+        """
+        oauth_request = oauth.OAuthRequest.from_consumer_and_token(
+            self.consumer_token, token=self.resource_token, http_url=url)
+        oauth_request.sign_request(
+            oauth.OAuthSignatureMethod_PLAINTEXT(), self.consumer_token,
+            self.resource_token)
+        headers.update(oauth_request.to_header())
+
+
+class NoAuth:
+    """Anonymous authentication class for making unauthenticated requests."""
+
+    def __init__(self, *args, **kwargs):
+        pass
+
+    def sign_request(self, *args, **kwargs):
+        """Go through the motions of signing a request.
+
+        Since this class does not really authenticate, this does nothing.
+        """
+
+
+class RequestWithMethod(urllib2.Request):
+    """Enhances urllib2.Request so an http method can be supplied."""
+    def __init__(self, *args, **kwargs):
+        self._method = kwargs.pop('method', None)
+        urllib2.Request.__init__(self, *args, **kwargs)
+
+    def get_method(self):
+        return (
+            self._method if self._method
+            else super(RequestWithMethod, self).get_method())
+
+
+class MAASDispatcher:
+    """Helper class to connect to a MAAS server using blocking requests.
+
+    Be careful when changing its API: this class is designed so that it
+    can be replaced with a Twisted-enabled alternative.  See the MAAS
+    provider in Juju for the code this would require.
+    """
+
+    def dispatch_query(self, request_url, headers, method="GET", data=None):
+        """Synchronously dispatch an OAuth-signed request to L{request_url}.
+
+        :param request_url: The URL to which the request is to be sent.
+        :param headers: Headers to include in the request.
+        :type headers: A dict.
+        :param method: The HTTP method, e.g. C{GET}, C{POST}, etc.
+            An AssertionError is raised if trying to pass data for a GET.
+        :param data: The data to send, if any.
+        :type data: A byte string.
+
+        :return: A open file-like object that contains the response.
+        """
+        headers = dict(headers)
+        # header keys are case insensitive, so we have to pass over them
+        set_accept_encoding = False
+        for key in headers:
+            if key.lower() == 'accept-encoding':
+                # The user already supplied a requested encoding, so just pass
+                # it along.
+                break
+        else:
+            set_accept_encoding = True
+            headers['Accept-encoding'] = 'gzip'
+        req = RequestWithMethod(request_url, data, headers, method=method)
+        res = urllib2.urlopen(req)
+        # If we set the Accept-encoding header, then we decode the header for
+        # the caller.
+        is_gzip = (
+            set_accept_encoding
+            and res.info().get('Content-Encoding') == 'gzip')
+        if is_gzip:
+            # Workaround python's gzip failure, gzip.GzipFile wants to be able
+            # to seek the file object.
+            res_content_io = BytesIO(res.read())
+            ungz = gzip.GzipFile(mode='rb', fileobj=res_content_io)
+            res = urllib2.addinfourl(ungz, res.headers, res.url, res.code)
+        return res
+
+
+class MAASClient:
+    """Base class for connecting to MAAS servers.
+
+    All "path" parameters can be either a string describing an absolute
+    resource path, or a sequence of items that, when represented as unicode,
+    make up the elements of the resource's path.  So `['nodes', node_id]`
+    is equivalent to `"nodes/%s" % node_id`.
+    """
+
+    def __init__(self, auth, dispatcher, base_url):
+        """Intialise the client.
+
+        :param auth: A `MAASOAuth` to sign requests.
+        :param dispatcher: An object implementing the MAASOAuthConnection
+            base class.
+        :param base_url: The base URL for the MAAS server, e.g.
+            http://my.maas.com:5240/
+        """
+        self.dispatcher = dispatcher
+        self.auth = auth
+        self.url = base_url
+
+    def _make_url(self, path):
+        """Compose an absolute URL to `path`.
+
+        :param path: Either a string giving a path to the desired resource,
+            or a sequence of items that make up the path.
+        :return: An absolute URL leading to `path`.
+        """
+        assert not isinstance(path, bytes)
+        if not isinstance(path, unicode):
+            assert not any(isinstance(element, bytes) for element in path)
+            path = '/'.join(unicode(element) for element in path)
+        # urljoin is very sensitive to leading slashes and when spurious
+        # slashes appear it removes path parts. This is why joining is
+        # done manually here.
+        return self.url.rstrip("/") + "/" + path.lstrip("/")
+
+    def _formulate_get(self, path, params=None):
+        """Return URL and headers for a GET request.
+
+        This is similar to _formulate_change, except parameters are encoded
+        into the URL.
+
+        :param path: Path to the object to issue a GET on.
+        :param params: Optional dict of parameter values.
+        :return: A tuple: URL and headers for the request.
+        """
+        url = self._make_url(path)
+        if params is not None and len(params) > 0:
+            url += "?" + urlencode(params.items())
+        headers = {}
+        self.auth.sign_request(url, headers)
+        return url, headers
+
+    def _formulate_change(self, path, params, as_json=False):
+        """Return URL, headers, and body for a non-GET request.
+
+        This is similar to _formulate_get, except parameters are encoded as
+        a multipart form body.
+
+        :param path: Path to the object to issue a GET on.
+        :param params: A dict of parameter values.
+        :param as_json: Encode params as application/json instead of
+            multipart/form-data. Only use this if you know the API already
+            supports JSON requests.
+        :return: A tuple: URL, headers, and body for the request.
+        """
+        url = self._make_url(path)
+        if 'op' in params:
+            params = dict(params)
+            op = params.pop('op')
+            url += '?' + urlencode([('op', op)])
+        if as_json:
+            body, headers = encode_json_data(params)
+        else:
+            body, headers = encode_multipart_data(params, {})
+        self.auth.sign_request(url, headers)
+        return url, headers, body
+
+    def get(self, path, op=None, **kwargs):
+        """Dispatch a GET.
+
+        :param op: Optional: named GET operation to invoke.  If given, any
+            keyword arguments are passed to the named operation.
+        :return: The result of the dispatch_query call on the dispatcher.
+        """
+        if op is not None:
+            kwargs['op'] = op
+        url, headers = self._formulate_get(path, kwargs)
+        return self.dispatcher.dispatch_query(
+            url, method="GET", headers=headers)
+
+    def post(self, path, op, as_json=False, **kwargs):
+        """Dispatch POST method `op` on `path`, with the given parameters.
+
+        :param as_json: Instead of POSTing the content as multipart/form-data
+            POST it as application/json
+        :return: The result of the dispatch_query call on the dispatcher.
+        """
+        kwargs['op'] = op
+        url, headers, body = self._formulate_change(
+            path, kwargs, as_json=as_json)
+        return self.dispatcher.dispatch_query(
+            url, method="POST", headers=headers, data=body)
+
+    def put(self, path, **kwargs):
+        """Dispatch a PUT on the resource at `path`."""
+        url, headers, body = self._formulate_change(path, kwargs)
+        return self.dispatcher.dispatch_query(
+            url, method="PUT", headers=headers, data=body)
+
+    def delete(self, path):
+        """Dispatch a DELETE on the resource at `path`."""
+        url, headers, body = self._formulate_change(path, {})
+        return self.dispatcher.dispatch_query(
+            url, method="DELETE", headers=headers)
diff --git a/_modules/apiclient/multipart.py b/_modules/apiclient/multipart.py
new file mode 100644
index 0000000..f6c5ce8
--- /dev/null
+++ b/_modules/apiclient/multipart.py
@@ -0,0 +1,138 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Encoding of MIME multipart data."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+str = None
+
+__metaclass__ = type
+__all__ = [
+    'encode_multipart_data',
+    ]
+
+from collections import (
+    Iterable,
+    Mapping,
+    )
+from email.generator import Generator
+from email.mime.application import MIMEApplication
+from email.mime.multipart import MIMEMultipart
+from io import (
+    BytesIO,
+    IOBase,
+    )
+from itertools import chain
+import mimetypes
+
+
+def get_content_type(*names):
+    """Return the MIME content type for the file with the given name."""
+    for name in names:
+        if name is not None:
+            mimetype, encoding = mimetypes.guess_type(name)
+            if mimetype is not None:
+                return mimetype
+    else:
+        return "application/octet-stream"
+
+
+def make_bytes_payload(name, content):
+    payload = MIMEApplication(content)
+    payload.add_header("Content-Disposition", "form-data", name=name)
+    return payload
+
+
+def make_string_payload(name, content):
+    payload = MIMEApplication(content.encode("utf-8"), charset="utf-8")
+    payload.add_header("Content-Disposition", "form-data", name=name)
+    payload.set_type("text/plain")
+    return payload
+
+
+def make_file_payload(name, content):
+    payload = MIMEApplication(content.read())
+    payload.add_header(
+        "Content-Disposition", "form-data", name=name, filename=name)
+    names = name, getattr(content, "name", None)
+    payload.set_type(get_content_type(*names))
+    return payload
+
+
+def make_payloads(name, content):
+    if isinstance(content, bytes):
+        yield make_bytes_payload(name, content)
+    elif isinstance(content, unicode):
+        yield make_string_payload(name, content)
+    elif isinstance(content, IOBase):
+        yield make_file_payload(name, content)
+    elif callable(content):
+        with content() as content:
+            for payload in make_payloads(name, content):
+                yield payload
+    elif isinstance(content, Iterable):
+        for part in content:
+            for payload in make_payloads(name, part):
+                yield payload
+    else:
+        raise AssertionError(
+            "%r is unrecognised: %r" % (name, content))
+
+
+def build_multipart_message(data):
+    message = MIMEMultipart("form-data")
+    for name, content in data:
+        for payload in make_payloads(name, content):
+            message.attach(payload)
+    return message
+
+
+def encode_multipart_message(message):
+    # The message must be multipart.
+    assert message.is_multipart()
+    # The body length cannot yet be known.
+    assert "Content-Length" not in message
+    # So line-endings can be fixed-up later on, component payloads must have
+    # no Content-Length and their Content-Transfer-Encoding must be base64
+    # (and not quoted-printable, which Django doesn't appear to understand).
+    for part in message.get_payload():
+        assert "Content-Length" not in part
+        assert part["Content-Transfer-Encoding"] == "base64"
+    # Flatten the message without headers.
+    buf = BytesIO()
+    generator = Generator(buf, False)  # Don't mangle "^From".
+    generator._write_headers = lambda self: None  # Ignore.
+    generator.flatten(message)
+    # Ensure the body has CRLF-delimited lines. See
+    # http://bugs.python.org/issue1349106.
+    body = b"\r\n".join(buf.getvalue().splitlines())
+    # Only now is it safe to set the content length.
+    message.add_header("Content-Length", "%d" % len(body))
+    return message.items(), body
+
+
+def encode_multipart_data(data=(), files=()):
+    """Create a MIME multipart payload from L{data} and L{files}.
+
+    **Note** that this function is deprecated. Use `build_multipart_message`
+    and `encode_multipart_message` instead.
+
+    @param data: A mapping of names (ASCII strings) to data (byte string).
+    @param files: A mapping of names (ASCII strings) to file objects ready to
+        be read.
+    @return: A 2-tuple of C{(body, headers)}, where C{body} is a a byte string
+        and C{headers} is a dict of headers to add to the enclosing request in
+        which this payload will travel.
+    """
+    if isinstance(data, Mapping):
+        data = data.items()
+    if isinstance(files, Mapping):
+        files = files.items()
+    message = build_multipart_message(chain(data, files))
+    headers, body = encode_multipart_message(message)
+    return body, dict(headers)
diff --git a/_modules/apiclient/testing/__init__.py b/_modules/apiclient/testing/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/_modules/apiclient/testing/__init__.py
diff --git a/_modules/apiclient/testing/credentials.py b/_modules/apiclient/testing/credentials.py
new file mode 100644
index 0000000..b51beb2
--- /dev/null
+++ b/_modules/apiclient/testing/credentials.py
@@ -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).
+
+"""Testing facilities for API credentials."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+str = None
+
+__metaclass__ = type
+__all__ = [
+    'make_api_credentials',
+    ]
+
+from maastesting.factory import factory
+
+
+def make_api_credentials():
+    """Create a tuple of fake API credentials."""
+    return (
+        factory.make_name('consumer-key'),
+        factory.make_name('resource-token'),
+        factory.make_name('resource-secret'),
+        )
diff --git a/_modules/apiclient/testing/django.py b/_modules/apiclient/testing/django.py
new file mode 100644
index 0000000..280a1e9
--- /dev/null
+++ b/_modules/apiclient/testing/django.py
@@ -0,0 +1,82 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Convenience functions for testing against Django."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+str = None
+
+__metaclass__ = type
+__all__ = []
+
+from io import BytesIO
+import os
+
+from django.core.files.uploadhandler import MemoryFileUploadHandler
+from django.http.multipartparser import MultiPartParser
+from maasserver.utils import ignore_unused
+
+
+def parse_headers_and_body_with_django(headers, body):
+    """Parse `headers` and `body` with Django's :class:`MultiPartParser`.
+
+    `MultiPartParser` is a curiously ugly and RFC non-compliant concoction.
+
+    Amongst other things, it coerces all field names, field data, and
+    filenames into Unicode strings using the "replace" error strategy, so be
+    warned that your data may be silently mangled.
+
+    It also, in 1.3.1 at least, does not recognise any transfer encodings at
+    *all* because its header parsing code was broken.
+
+    I'm also fairly sure that it'll fall over on headers than span more than
+    one line.
+
+    In short, it's a piece of code that inspires little confidence, yet we
+    must work with it, hence we need to round-trip test multipart handling
+    with it.
+    """
+    handler = MemoryFileUploadHandler()
+    meta = {
+        "HTTP_CONTENT_TYPE": headers["Content-Type"],
+        "HTTP_CONTENT_LENGTH": headers["Content-Length"],
+        }
+    parser = MultiPartParser(
+        META=meta, input_data=BytesIO(body),
+        upload_handlers=[handler])
+    return parser.parse()
+
+
+def parse_headers_and_body_with_mimer(headers, body):
+    """Use piston's Mimer functionality to handle the content.
+
+    :return: The value of 'request.data' after using Piston's translate_mime on
+        the input.
+    """
+    # JAM 2012-10-09 Importing emitters has a side effect of registering mime
+    #   type handlers with utils.translate_mime. So we must import it, even
+    #   though we don't use it.  However, piston loads Django's QuerySet code
+    #   which fails if you don't have a settings.py available. Which we don't
+    #   during 'test.pserv'. So we import this late.
+    from piston import emitters
+    ignore_unused(emitters)
+    from piston.utils import translate_mime
+
+    environ = {'wsgi.input': BytesIO(body)}
+    for name, value in headers.items():
+        environ[name.upper().replace('-', '_')] = value
+    environ['REQUEST_METHOD'] = 'POST'
+    environ['SCRIPT_NAME'] = ''
+    environ['PATH_INFO'] = ''
+    # Django 1.6 needs DJANGO_SETTINGS_MODULE to be defined
+    # when importing WSGIRequest.
+    os.environ['DJANGO_SETTINGS_MODULE'] = 'maas.development'
+    from django.core.handlers.wsgi import WSGIRequest
+    request = WSGIRequest(environ)
+    translate_mime(request)
+    return request.data
diff --git a/_modules/apiclient/testing/django_client_proxy.py b/_modules/apiclient/testing/django_client_proxy.py
new file mode 100644
index 0000000..3d67284
--- /dev/null
+++ b/_modules/apiclient/testing/django_client_proxy.py
@@ -0,0 +1,55 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""A proxy that looks like MAASClient.
+
+This actually passes the requests on to a django.test.client.Client, to avoid
+having to go via a real HTTP server.
+"""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+str = None
+
+__metaclass__ = type
+__all__ = [
+    'MAASDjangoTestClient',
+    ]
+
+import httplib
+import io
+import urllib2
+
+
+def to_addinfourl(response):
+    """Convert a `django.http.HttpResponse` to a `urllib2.addinfourl`."""
+    headers_raw = response.serialize_headers()
+    headers = httplib.HTTPMessage(io.BytesIO(headers_raw))
+    return urllib2.addinfourl(
+        fp=io.BytesIO(response.content), headers=headers,
+        url=None, code=response.status_code)
+
+
+class MAASDjangoTestClient:
+    """Wrap the Django testing Client to look like a MAASClient."""
+
+    def __init__(self, django_client):
+        self.django_client = django_client
+
+    def get(self, path, op=None, **kwargs):
+        kwargs['op'] = op
+        return to_addinfourl(self.django_client.get(path, kwargs))
+
+    def post(self, path, op=None, **kwargs):
+        kwargs['op'] = op
+        return to_addinfourl(self.django_client.post(path, kwargs))
+
+    def put(self, path, **kwargs):
+        return to_addinfourl(self.django_client.put(path, kwargs))
+
+    def delete(self, path):
+        return to_addinfourl(self.django_client.delete(path))
diff --git a/_modules/apiclient/utils.py b/_modules/apiclient/utils.py
new file mode 100644
index 0000000..5b61d8b
--- /dev/null
+++ b/_modules/apiclient/utils.py
@@ -0,0 +1,48 @@
+# 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,
+    )
+
+str = None
+
+__metaclass__ = type
+__all__ = [
+    "ascii_url",
+    "urlencode",
+    ]
+
+
+from urllib import quote_plus
+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")
+
+
+def urlencode(data):
+    """A version of `urllib.urlencode` that isn't insane.
+
+    This only cares that `data` is an iterable of iterables. Each sub-iterable
+    must be of overall length 2, i.e. a name/value pair.
+
+    Unicode strings will be encoded to UTF-8. This is what Django expects; see
+    `smart_text` in the Django documentation.
+    """
+    enc = lambda string: quote_plus(
+        string.encode("utf-8") if isinstance(string, unicode) else string)
+    return b"&".join(
+        b"%s=%s" % (enc(name), enc(value))
+        for name, value in data)