[lxc-devel] [pylxd/master] 2 0 api

rockstar on Github lxc-bot at linuxcontainers.org
Tue Feb 16 01:34:41 UTC 2016


A non-text attachment was scrubbed...
Name: not available
Type: text/x-mailbox
Size: 1132 bytes
Desc: not available
URL: <http://lists.linuxcontainers.org/pipermail/lxc-devel/attachments/20160216/5ebba572/attachment.bin>
-------------- next part --------------
From caea534282c3309542fb517e0af77a13912e62eb Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul.hummer at canonical.com>
Date: Thu, 14 Jan 2016 05:59:10 -0700
Subject: [PATCH 01/20] Add integration tests for containers.

---
 integration/test_1_0.py            |  40 +++++++
 integration/test_1_0_containers.py | 232 +++++++++++++++++++++++++++++++++++++
 integration/test_base.py           | 126 --------------------
 integration/test_root.py           |  25 ++++
 integration/testing.py             |  57 +++++++++
 pylxd/api.py                       |   8 ++
 pylxd/tests/test_api.py            |  18 +++
 setup.cfg                          |   3 +
 8 files changed, 383 insertions(+), 126 deletions(-)
 create mode 100644 integration/test_1_0.py
 create mode 100644 integration/test_1_0_containers.py
 delete mode 100644 integration/test_base.py
 create mode 100644 integration/test_root.py
 create mode 100644 integration/testing.py

diff --git a/integration/test_1_0.py b/integration/test_1_0.py
new file mode 100644
index 0000000..1218893
--- /dev/null
+++ b/integration/test_1_0.py
@@ -0,0 +1,40 @@
+# Copyright (c) 2016 Canonical Ltd
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+from integration.testing import IntegrationTestCase
+
+
+class Test10(IntegrationTestCase):
+    """Tests for /1.0"""
+
+    def test_1_0(self):
+        """Return: Dict representing server state."""
+        result = self.lxd['1.0'].get()
+
+        self.assertCommon(result)
+        self.assertEqual(200, result.status_code)
+        self.assertEqual(
+            ['api_compat', 'auth', 'config', 'environment'],
+            sorted(result.json()['metadata'].keys()))
+        self.assertEqual(
+            ['addresses', 'architectures', 'driver', 'driver_version', 'kernel',
+             'kernel_architecture', 'kernel_version', 'server', 'server_pid',
+             'server_version', 'storage', 'storage_version'],
+            sorted(result.json()['metadata']['environment'].keys()))
+
+    def test_1_0_PUT(self):
+        """Return: standard return value or standard error."""
+        result = self.lxd['1.0'].put(json={'config': {'core.trust_password': 'test'}})
+
+        self.assertCommon(result)
+        self.assertEqual(200, result.status_code)
diff --git a/integration/test_1_0_containers.py b/integration/test_1_0_containers.py
new file mode 100644
index 0000000..c376870
--- /dev/null
+++ b/integration/test_1_0_containers.py
@@ -0,0 +1,232 @@
+# Copyright (c) 2016 Canonical Ltd
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+from integration.testing import IntegrationTestCase
+
+
+class Test10Containers(IntegrationTestCase):
+    """Tests for /1.0/containers"""
+
+    def test_1_0_containers(self):
+        """Return: list of URLs for containers this server publishes."""
+        result = self.lxd['1.0']['containers'].get()
+
+        self.assertCommon(result)
+        self.assertEqual(200, result.status_code)
+
+    def test_1_0_containers_POST(self):
+        """Return: background operation or standard error."""
+        name = self.create_container()
+        self.addCleanup(self.delete_container, name)
+
+        machine = {
+            'name': name,
+            'architecture': 2,
+            'profiles': ['default'],
+            'ephemeral': True,
+            'config': {'limits.cpu': '2'},
+            'source': {'type': 'image',
+                       'alias': 'busybox'},
+        }
+        result = self.lxd['1.0']['containers'].post(json=machine)
+
+        # self.assertCommon(result)
+        self.assertEqual(202, result.status_code)
+
+
+class ContainerTestCase(IntegrationTestCase):
+    """A Container-specific test case."""
+
+    def setUp(self):
+        super(ContainerTestCase, self).setUp()
+        self.name = self.create_container()
+        self.addCleanup(self.delete_container, self.name)
+
+
+class Test10Container(ContainerTestCase):
+    """Tests for /1.0/containers/<name>"""
+
+    def test10container(self):
+        """Return: dict of the container configuration and current state."""
+        result = self.lxd['1.0']['containers'][self.name].get()
+
+        self.assertEqual(200, result.status_code)
+
+    def test10container_put(self):
+        """Return: background operation or standard error."""
+        result = self.lxd['1.0']['containers'][self.name].put(json={
+            'config': {'limits.cpu': '1'}
+            })
+
+        self.assertEqual(202, result.status_code)
+
+    def test10container_post(self):
+        """Return: background operation or standard error."""
+        new_name = 'newname'
+        result = self.lxd['1.0']['containers'][self.name].post(json={
+            'name': new_name,
+            })
+        self.addCleanup(self.delete_container, new_name)
+
+        self.assertEqual(202, result.status_code)
+
+    def test10container_delete(self):
+        """Return: background operation or standard error."""
+        result = self.lxd['1.0']['containers'][self.name].delete()
+
+        self.assertEqual(202, result.status_code)
+
+
+class Test10ContainerState(ContainerTestCase):
+    """Tests for /1.0/containers/<name>/state."""
+
+    def test10containerstate(self):
+        """Return: dict representing current state."""
+        result = self.lxd['1.0'].containers[self.name].state.get()
+
+        self.assertEqual(200, result.status_code)
+
+    def test10containerstate_PUT(self):
+        """Return: background operation or standard error."""
+        result = self.lxd['1.0'].containers[self.name].state.put(json={
+            'action': 'start',
+            'timeout': 30,
+            'force': True
+            })
+
+        self.assertEqual(202, result.status_code)
+
+
+class Test10ContainerFiles(ContainerTestCase):
+    """Tests for /1.0/containers/<name>/files."""
+
+    def test10containerfiles(self):
+        """Return: dict representing current files."""
+        result = self.lxd['1.0'].containers[self.name].files.get(params={
+            'path': '/bin/sh'
+            })
+
+        self.assertEqual(200, result.status_code)
+
+    def test10containerfiles_POST(self):
+        """Return: standard return value or standard error."""
+        result = self.lxd['1.0'].containers[self.name].files.get(params={
+            'path': '/bin/sh'
+            }, data='abcdef')
+
+        self.assertEqual(200, result.status_code)
+
+
+class Test10ContainerSnapshots(ContainerTestCase):
+    """Tests for /1.0/containers/<name>/snapshots."""
+
+    def test10containersnapshots(self):
+        """Return: list of URLs for snapshots for this container."""
+        result = self.lxd['1.0'].containers[self.name].snapshots.get()
+
+        self.assertEqual(200, result.status_code)
+
+    def test10containersnapshots_POST(self):
+        """Return: background operation or standard error."""
+        result = self.lxd['1.0'].containers[self.name].snapshots.post(json={
+            'name': 'test-snapshot',
+            'stateful': False
+            })
+
+        self.assertEqual(202, result.status_code)
+
+
+class Test10ContainerSnapshot(ContainerTestCase):
+    """Tests for /1.0/containers/<name>/snapshot/<name>."""
+
+    def setUp(self):
+        super(Test10ContainerSnapshot, self).setUp()
+        result = self.lxd['1.0'].containers[self.name].snapshots.post(json={
+            'name': 'test-snapshot', 'stateful': False})
+        operation_uuid = result.json()['operation'].split('/')[-1]
+        result = self.lxd['1.0'].operations[operation_uuid].wait.get()
+
+    def test10containersnapshot(self):
+        """Return: dict representing the snapshot."""
+        result = self.lxd['1.0'].containers[self.name].snapshots['test-snapshot'].get()
+
+        self.assertEqual(200, result.status_code)
+
+    def test10containersnapshot_POST(self):
+        """Return: dict representing the snapshot."""
+        result = self.lxd['1.0'].containers[self.name].snapshots['test-snapshot'].post(json={
+            'name': 'test-snapshot.bak-lol'
+            })
+
+        self.assertEqual(202, result.status_code)
+
+    def test10containersnapshot_DELETE(self):
+        """Return: dict representing the snapshot."""
+        result = self.lxd['1.0'].containers[self.name].snapshots['test-snapshot'].delete()
+
+        self.assertEqual(202, result.status_code)
+
+
+class Test10ContainerExec(ContainerTestCase):
+    """Tests for /1.0/containers/<name>/exec."""
+
+    def setUp(self):
+        super(Test10ContainerExec, self).setUp()
+
+        result = self.lxd['1.0'].containers[self.name].state.put(json={
+            'action': 'start', 'timeout': 30, 'force': True})
+        operation_uuid = result.json()['operation'].split('/')[-1]
+        self.lxd['1.0'].operations[operation_uuid].wait.get()
+
+    def test10containerexec(self):
+        """Return: background operation + optional websocket information.
+
+        ...or standard error."""
+        result = self.lxd['1.0'].containers[self.name]['exec'].post(json={
+            'command': ['/bin/bash'],
+            'wait-for-websocket': False,
+            'interactive': True,
+            })
+
+        self.assertEqual(202, result.status_code)
+
+
+class Test10ContainerLogs(ContainerTestCase):
+    """Tests for /1.0/containers/<name>/logs."""
+
+    def test10containerlogs(self):
+        """Return: a list of the available log files."""
+        result = self.lxd['1.0'].containers[self.name].logs.get()
+
+        self.assertEqual(200, result.status_code)
+
+
+class Test10ContainerLog(ContainerTestCase):
+    """Tests for /1.0/containers/<name>/logs/<logfile>."""
+
+    def setUp(self):
+        super(Test10ContainerLog, self).setUp()
+        result = self.lxd['1.0'].containers[self.name].logs.get()
+        self.log_name = result.json()['metadata'][0]['name']
+
+    def test10containerlog(self):
+        """Return: the contents of the log file."""
+        result = self.lxd['1.0'].containers[self.name].logs[self.log_name].get()
+
+        self.assertEqual(200, result.status_code)
+
+    def test10containerlog_DELETE(self):
+        """Return: the contents of the log file."""
+        result = self.lxd['1.0'].containers[self.name].logs[self.log_name].delete()
+
+        self.assertEqual(200, result.status_code)
diff --git a/integration/test_base.py b/integration/test_base.py
deleted file mode 100644
index 486c626..0000000
--- a/integration/test_base.py
+++ /dev/null
@@ -1,126 +0,0 @@
-# Copyright (c) 2016 Canonical Ltd
-#
-#    Licensed under the Apache License, Version 2.0 (the "License"); you may
-#    not use this file except in compliance with the License. You may obtain
-#    a copy of the License at
-#
-#         http://www.apache.org/licenses/LICENSE-2.0
-#
-#    Unless required by applicable law or agreed to in writing, software
-#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
-#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
-#    License for the specific language governing permissions and limitations
-#    under the License.
-
-import unittest
-
-from pylxd.api import LXD
-
-CERT = '''MIICATCCAWoCCQDejRDAWZlG0DANBgkqhkiG9w0BAQsFADBFMQswCQYDVQQGEwJV
-UzETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0
-cyBQdHkgTHRkMB4XDTE2MDExMzE0MTAyNFoXDTI2MDExMDE0MTAyNFowRTELMAkG
-A1UEBhMCVVMxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0
-IFdpZGdpdHMgUHR5IEx0ZDCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAv4bk
-u8i1q/5vl8VbSALC4Q2MHcyVgQ7I8RGnTZArbD5fGAhhvBXki2w2fo8eoaOV7hh0
-bAo+t7rn4RE39OQbdeyAdG62qkRug58eeUb0Qz2Zcg9CQ5vcbApElykHmMt2yXW3
-gxtPu0mqUQnUt2zs7/8okwtfc2SMZKpwrUrxDU8CAwEAATANBgkqhkiG9w0BAQsF
-AAOBgQA2c14J5FbxMFxTJuEjiIY1s4eJCW1XDC0SO2WRY3iz0fwKautLoOnZJOQk
-OZNzJlCVpZjpHeL847mz2ybtoFpUO45bWGg75W5gh4D2gmnG+FlCg2l3Tno1bMoS
-tTQu8yyFguWnWnCokzEovlKMR1YajPvndmzf7zRujcL2vKL5uA==
-'''
-
-
-class TestBaseAssumptions(unittest.TestCase):
-    """lxd makes some common base assumptions about the rest api."""
-
-    def setUp(self):
-        self.lxd = LXD('http+unix://%2Fvar%2Flib%2Flxd%2Funix.socket')
-
-    def test_root(self):
-        """GET to / is allowed for everyone (lists the API endpoints)."""
-        expected = {
-            'type': 'sync',
-            'status': 'Success',
-            'status_code': 200,
-            'metadata': ['/1.0']
-        }
-
-        result = self.lxd.get()
-
-        self.assertEqual(200, result.status_code)
-        self.assertEqual(expected, result.json())
-
-    def test_1_0(self):
-        """GET to /1.0 is allowed for everyone (but result varies)."""
-        expected = {
-            "type": "sync",
-            "status": "Success",
-            "status_code": 200,
-            "metadata": {
-                "api_compat": 1,
-                "auth": "trusted",
-                "config": {
-                    "images.remote_cache_expiry": "10"
-                },
-                "environment": {
-                    "addresses": [],
-                    "architectures": [2, 1],
-                    "driver": "lxc",
-                    "driver_version": "1.1.5",
-                    "kernel": "Linux",
-                    "kernel_architecture": "x86_64",
-                    "kernel_version": "4.2.0-19-generic",
-                    "server": "lxd",
-                    "server_pid": 7453,
-                    "server_version": "0.20",
-                    "storage": "dir",
-                    "storage_version": ""
-                }
-            }
-        }
-
-        result = self.lxd['1.0'].get()
-
-        self.assertEqual(200, result.status_code)
-        self.assertEqual(expected, result.json())
-
-    def test_1_0_images(self):
-        """GET to /1.0/images/* is allowed for everyone.
-
-        ...but only returns public images for unauthenticated users.
-        """
-        expected = {
-            "type": "sync",
-            "status": "Success",
-            "status_code": 200,
-            "metadata": []
-        }
-
-        result = self.lxd['1.0'].images.get()
-        json = result.json()
-        # metadata should exist, but its content varies.
-        json['metadata'] = []
-
-        self.assertEqual(200, result.status_code)
-        self.assertEqual(expected, json)
-
-    def test_1_0_certificates_POST(self):
-        """POST to /1.0/certificates is allowed for everyone.
-
-        ...with a client certificate.
-        """
-        expected = {
-            "type": "sync",
-            "status": "Success",
-            "status_code": 200,
-            "metadata": {}
-        }
-
-        data = {
-            'type': 'client',
-            'certificate': CERT,
-        }
-        result = self.lxd['1.0'].certificates.post(json=data)
-
-        self.assertEqual(200, result.status_code)
-        self.assertEqual(expected, result.json())
diff --git a/integration/test_root.py b/integration/test_root.py
new file mode 100644
index 0000000..aeae2ae
--- /dev/null
+++ b/integration/test_root.py
@@ -0,0 +1,25 @@
+# Copyright (c) 2016 Canonical Ltd
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+from integration.testing import IntegrationTestCase
+
+
+class TestRoot(IntegrationTestCase):
+    """Tests for /"""
+
+    def test_root(self):
+        """Return: list of supported API endpoint URLs."""
+        result = self.lxd.get()
+
+        self.assertCommon(result)
+        self.assertEqual(200, result.status_code)
diff --git a/integration/testing.py b/integration/testing.py
new file mode 100644
index 0000000..29a3bc8
--- /dev/null
+++ b/integration/testing.py
@@ -0,0 +1,57 @@
+# Copyright (c) 2016 Canonical Ltd
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+import unittest
+import uuid
+
+from pylxd.api import LXD
+
+
+class IntegrationTestCase(unittest.TestCase):
+    """A base test case for pylxd integration tests."""
+
+    def setUp(self):
+        super(IntegrationTestCase, self).setUp()
+        self.lxd = LXD('http+unix://%2Fvar%2Flib%2Flxd%2Funix.socket')
+
+    def create_container(self):
+        """Create a container in lxd."""
+        name = 'a' + str(uuid.uuid4())
+        machine = {
+            'name': name,
+            'architecture': 2,
+            'profiles': ['default'],
+            'ephemeral': True,
+            'config': {'limits.cpu': '2'},
+            'source': {'type': 'image',
+                       'alias': 'busybox'},
+        }
+        result = self.lxd['1.0']['containers'].post(json=machine)
+        operation_uuid = result.json()['operation'].split('/')[-1]
+        self.lxd['1.0'].operations[operation_uuid].wait.get()
+        return name
+
+    def delete_container(self, name):
+        """Delete a container in lxd."""
+        self.lxd['1.0']['containers'][name].delete()
+
+    def assertCommon(self, response):
+        """Assert common LXD responses.
+
+        LXD responses are relatively standard. This function makes assertions
+        to all those standards.
+        """
+        self.assertEqual(response.status_code, response.json()['status_code'])
+        self.assertEqual(
+            ['metadata', 'operation', 'status', 'status_code', 'type'],
+            sorted(response.json().keys()))
diff --git a/pylxd/api.py b/pylxd/api.py
index a08a202..f9a1370 100644
--- a/pylxd/api.py
+++ b/pylxd/api.py
@@ -62,6 +62,14 @@ def get(self, *args, **kwargs):
     def post(self, *args, **kwargs):
         """Perform an HTTP POST."""
         return self.session.post(self._api_endpoint, *args, **kwargs)
+
+    def put(self, *args, **kwargs):
+        """Perform an HTTP PUT."""
+        return self.session.put(self._api_endpoint, *args, **kwargs)
+
+    def delete(self, *args, **kwargs):
+        """Perform an HTTP delete."""
+        return self.session.delete(self._api_endpoint, *args, **kwargs)
 LXD = _APINode
 
 
diff --git a/pylxd/tests/test_api.py b/pylxd/tests/test_api.py
index 8f84f68..60fb7f8 100644
--- a/pylxd/tests/test_api.py
+++ b/pylxd/tests/test_api.py
@@ -70,3 +70,21 @@ def test_post(self, _post):
         lxd.fake.post()
 
         _post.assert_called_once_with('{}/{}'.format(self.ROOT, 'fake'))
+
+    @mock.patch('pylxd.api.requests.put')
+    def test_put(self, _put):
+        """`put` will PUT to the smart url."""
+        lxd = api._APINode(self.ROOT)
+
+        lxd.fake.put()
+
+        _put.assert_called_once_with('{}/{}'.format(self.ROOT, 'fake'))
+
+    @mock.patch('pylxd.api.requests.delete')
+    def test_delete(self, _delete):
+        """`delete` will DELETE to the smart url."""
+        lxd = api._APINode(self.ROOT)
+
+        lxd.fake.delete()
+
+        _delete.assert_called_once_with('{}/{}'.format(self.ROOT, 'fake'))
diff --git a/setup.cfg b/setup.cfg
index d3ab14d..b2666d7 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -46,3 +46,6 @@ input_file = pylxd/locale/pylxd.pot
 keywords = _ gettext ngettext l_ lazy_gettext
 mapping_file = babel.cfg
 output_file = pylxd/locale/pylxd.pot
+
+[nosetests]
+nologcapture=1

From 3cafbeb17edbc48250c460f38b13e80be3d4139f Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul.hummer at canonical.com>
Date: Fri, 29 Jan 2016 11:55:22 -0700
Subject: [PATCH 02/20] Add more integration tests

---
 integration/busybox.py             | 155 +++++++++++++++++++++++++++++++++++++
 integration/test_1_0_containers.py |  68 ++++++++--------
 integration/test_1_0_events.py     |  27 +++++++
 integration/test_1_0_images.py     | 127 ++++++++++++++++++++++++++++++
 integration/testing.py             |  43 ++++++++--
 5 files changed, 384 insertions(+), 36 deletions(-)
 create mode 100644 integration/busybox.py
 create mode 100644 integration/test_1_0_events.py
 create mode 100644 integration/test_1_0_images.py

diff --git a/integration/busybox.py b/integration/busybox.py
new file mode 100644
index 0000000..8e87b00
--- /dev/null
+++ b/integration/busybox.py
@@ -0,0 +1,155 @@
+# This code is stolen directly from lxd-images, for expediency's sake.
+import atexit
+import hashlib
+import io
+import json
+import os
+import shutil
+import subprocess
+import tarfile
+import tempfile
+import uuid
+
+
+def find_on_path(command):
+    """Is command on the executable search path?"""
+
+    if 'PATH' not in os.environ:
+        return False
+    path = os.environ['PATH']
+    for element in path.split(os.pathsep):
+        if not element:
+            continue
+        filename = os.path.join(element, command)
+        if os.path.isfile(filename) and os.access(filename, os.X_OK):
+            return True
+    return False
+
+
+class Busybox(object):
+    workdir = None
+
+    def __init__(self):
+        # Create our workdir
+        self.workdir = tempfile.mkdtemp()
+
+    def cleanup(self):
+        if self.workdir:
+            shutil.rmtree(self.workdir)
+
+    def create_tarball(self, split=False):
+        xz = "pxz" if find_on_path("pxz") else "xz"
+
+        destination_tar = os.path.join(self.workdir, "busybox.tar")
+        target_tarball = tarfile.open(destination_tar, "w:")
+
+        if split:
+            destination_tar_rootfs = os.path.join(self.workdir,
+                                                  "busybox.rootfs.tar")
+            target_tarball_rootfs = tarfile.open(destination_tar_rootfs, "w:")
+
+        metadata = {'architecture': os.uname()[4],
+                    'creation_date': int(os.stat("/bin/busybox").st_ctime),
+                    'properties': {
+                        'os': "Busybox",
+                        'architecture': os.uname()[4],
+                        'description': "Busybox %s" % os.uname()[4],
+                        'name': "busybox-%s" % os.uname()[4],
+                        # Don't overwrite actual busybox images.
+                        'obfuscate': str(uuid.uuid4),
+                        },
+                    }
+
+        # Add busybox
+        with open("/bin/busybox", "rb") as fd:
+            busybox_file = tarfile.TarInfo()
+            busybox_file.size = os.stat("/bin/busybox").st_size
+            busybox_file.mode = 0o755
+            if split:
+                busybox_file.name = "bin/busybox"
+                target_tarball_rootfs.addfile(busybox_file, fd)
+            else:
+                busybox_file.name = "rootfs/bin/busybox"
+                target_tarball.addfile(busybox_file, fd)
+
+        # Add symlinks
+        busybox = subprocess.Popen(["/bin/busybox", "--list-full"],
+                                   stdout=subprocess.PIPE,
+                                   universal_newlines=True)
+        busybox.wait()
+
+        for path in busybox.stdout.read().split("\n"):
+            if not path.strip():
+                continue
+
+            symlink_file = tarfile.TarInfo()
+            symlink_file.type = tarfile.SYMTYPE
+            symlink_file.linkname = "/bin/busybox"
+            if split:
+                symlink_file.name = "%s" % path.strip()
+                target_tarball_rootfs.addfile(symlink_file)
+            else:
+                symlink_file.name = "rootfs/%s" % path.strip()
+                target_tarball.addfile(symlink_file)
+
+        # Add directories
+        for path in ("dev", "mnt", "proc", "root", "sys", "tmp"):
+            directory_file = tarfile.TarInfo()
+            directory_file.type = tarfile.DIRTYPE
+            if split:
+                directory_file.name = "%s" % path
+                target_tarball_rootfs.addfile(directory_file)
+            else:
+                directory_file.name = "rootfs/%s" % path
+                target_tarball.addfile(directory_file)
+
+        # Add the metadata file
+        metadata_yaml = json.dumps(metadata, sort_keys=True,
+                                   indent=4, separators=(',', ': '),
+                                   ensure_ascii=False).encode('utf-8') + b"\n"
+
+        metadata_file = tarfile.TarInfo()
+        metadata_file.size = len(metadata_yaml)
+        metadata_file.name = "metadata.yaml"
+        target_tarball.addfile(metadata_file,
+                               io.BytesIO(metadata_yaml))
+
+        # Add an /etc/inittab; this is to work around:
+        # http://lists.busybox.net/pipermail/busybox/2015-November/083618.html
+        # Basically, since there are some hardcoded defaults that misbehave, we
+        # just pass an empty inittab so those aren't applied, and then busybox
+        # doesn't spin forever.
+        inittab = tarfile.TarInfo()
+        inittab.size = 1
+        inittab.name = "/rootfs/etc/inittab"
+        target_tarball.addfile(inittab, io.BytesIO(b"\n"))
+
+        target_tarball.close()
+        if split:
+            target_tarball_rootfs.close()
+
+        # Compress the tarball
+        r = subprocess.call([xz, "-9", destination_tar])
+        if r:
+            raise Exception("Failed to compress: %s" % destination_tar)
+
+        if split:
+            r = subprocess.call([xz, "-9", destination_tar_rootfs])
+            if r:
+                raise Exception("Failed to compress: %s" %
+                                destination_tar_rootfs)
+            return destination_tar + ".xz", destination_tar_rootfs + ".xz"
+        else:
+            return destination_tar + ".xz"
+
+
+def create_busybox_image():
+    busybox = Busybox()
+    atexit.register(busybox.cleanup)
+
+    path = busybox.create_tarball()
+
+    with open(path, "rb") as fd:
+        fingerprint = hashlib.sha256(fd.read()).hexdigest()
+
+    return path, fingerprint
diff --git a/integration/test_1_0_containers.py b/integration/test_1_0_containers.py
index c376870..e66541e 100644
--- a/integration/test_1_0_containers.py
+++ b/integration/test_1_0_containers.py
@@ -26,9 +26,7 @@ def test_1_0_containers(self):
 
     def test_1_0_containers_POST(self):
         """Return: background operation or standard error."""
-        name = self.create_container()
-        self.addCleanup(self.delete_container, name)
-
+        name = self.id().split('.')[-1].replace('_', '')
         machine = {
             'name': name,
             'architecture': 2,
@@ -39,6 +37,7 @@ def test_1_0_containers_POST(self):
                        'alias': 'busybox'},
         }
         result = self.lxd['1.0']['containers'].post(json=machine)
+        self.addCleanup(self.delete_container, name, enforce=True)
 
         # self.assertCommon(result)
         self.assertEqual(202, result.status_code)
@@ -50,7 +49,6 @@ class ContainerTestCase(IntegrationTestCase):
     def setUp(self):
         super(ContainerTestCase, self).setUp()
         self.name = self.create_container()
-        self.addCleanup(self.delete_container, self.name)
 
 
 class Test10Container(ContainerTestCase):
@@ -76,7 +74,7 @@ def test10container_post(self):
         result = self.lxd['1.0']['containers'][self.name].post(json={
             'name': new_name,
             })
-        self.addCleanup(self.delete_container, new_name)
+        self.addCleanup(self.delete_container, new_name, enforce=True)
 
         self.assertEqual(202, result.status_code)
 
@@ -187,28 +185,36 @@ def setUp(self):
             'action': 'start', 'timeout': 30, 'force': True})
         operation_uuid = result.json()['operation'].split('/')[-1]
         self.lxd['1.0'].operations[operation_uuid].wait.get()
+        self.addCleanup(self.stop_container)
 
-    def test10containerexec(self):
-        """Return: background operation + optional websocket information.
-
-        ...or standard error."""
-        result = self.lxd['1.0'].containers[self.name]['exec'].post(json={
-            'command': ['/bin/bash'],
-            'wait-for-websocket': False,
-            'interactive': True,
-            })
+    def stop_container(self):
+        """Stop the container (before deleting it)."""
+        result = self.lxd['1.0'].containers[self.name].state.put(json={
+            'action': 'stop', 'timeout': 30, 'force': True})
+        operation_uuid = result.json()['operation'].split('/')[-1]
+        self.lxd['1.0'].operations[operation_uuid].wait.get()
 
-        self.assertEqual(202, result.status_code)
+#    def test10containerexec(self):
+#        """Return: background operation + optional websocket information.
+#
+#        ...or standard error."""
+#        result = self.lxd['1.0'].containers[self.name]['exec'].post(json={
+#            'command': ['/bin/bash'],
+#            'wait-for-websocket': False,
+#            'interactive': True,
+#            })
+#
+#        self.assertEqual(202, result.status_code)
 
 
 class Test10ContainerLogs(ContainerTestCase):
     """Tests for /1.0/containers/<name>/logs."""
 
-    def test10containerlogs(self):
-        """Return: a list of the available log files."""
-        result = self.lxd['1.0'].containers[self.name].logs.get()
-
-        self.assertEqual(200, result.status_code)
+#    def test10containerlogs(self):
+#        """Return: a list of the available log files."""
+#        result = self.lxd['1.0'].containers[self.name].logs.get()
+#
+#        self.assertEqual(200, result.status_code)
 
 
 class Test10ContainerLog(ContainerTestCase):
@@ -219,14 +225,14 @@ def setUp(self):
         result = self.lxd['1.0'].containers[self.name].logs.get()
         self.log_name = result.json()['metadata'][0]['name']
 
-    def test10containerlog(self):
-        """Return: the contents of the log file."""
-        result = self.lxd['1.0'].containers[self.name].logs[self.log_name].get()
-
-        self.assertEqual(200, result.status_code)
-
-    def test10containerlog_DELETE(self):
-        """Return: the contents of the log file."""
-        result = self.lxd['1.0'].containers[self.name].logs[self.log_name].delete()
-
-        self.assertEqual(200, result.status_code)
+#    def test10containerlog(self):
+#        """Return: the contents of the log file."""
+#        result = self.lxd['1.0'].containers[self.name].logs[self.log_name].get()
+#
+#        self.assertEqual(200, result.status_code)
+#
+#    def test10containerlog_DELETE(self):
+#        """Return: the contents of the log file."""
+#        result = self.lxd['1.0'].containers[self.name].logs[self.log_name].delete()
+#
+#        self.assertEqual(200, result.status_code)
diff --git a/integration/test_1_0_events.py b/integration/test_1_0_events.py
new file mode 100644
index 0000000..f95831e
--- /dev/null
+++ b/integration/test_1_0_events.py
@@ -0,0 +1,27 @@
+# Copyright (c) 2016 Canonical Ltd
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+from integration.testing import IntegrationTestCase
+
+
+class Test10Events(IntegrationTestCase):
+    """Tests for /1.0/events."""
+
+    def test_1_0_events(self):
+        """Return: none (never ending flow of events)."""
+        # XXX: rockstar (14 Jan 2016) - This returns a 400 in pylxd, because
+        # websockets. I plan to sort this integration test out later, but nova-lxd
+        # does not use websockets, so I'll wait a bit on that.
+        result = self.lxd['1.0']['events'].get(params={'type': 'operation,logging'})
+
+        self.assertEqual(400, result.status_code)
diff --git a/integration/test_1_0_images.py b/integration/test_1_0_images.py
new file mode 100644
index 0000000..2ab3f51
--- /dev/null
+++ b/integration/test_1_0_images.py
@@ -0,0 +1,127 @@
+# Copyright (c) 2016 Canonical Ltd
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+import unittest
+
+from integration.testing import IntegrationTestCase
+
+
+class Test10Images(IntegrationTestCase):
+    """Tests for /1.0/images"""
+
+    def test_1_0_images(self):
+        """Return: list of URLs for images this server publishes."""
+        response = self.lxd['1.0'].images.get()
+
+        self.assertEqual(200, response.status_code)
+
+    def test_1_0_images_POST(self):
+        """Return: list of URLs for images this server publishes."""
+        response = self.lxd['1.0'].images.post(json={
+            'public': True,
+            'source': {
+                'type': 'url',
+                'server': 'http://example.com',
+                'alias': 'test-image'
+                }})
+
+        self.assertEqual(202, response.status_code)
+
+
+class ImageTestCase(IntegrationTestCase):
+    """An Image test case."""
+
+    def setUp(self):
+        super(ImageTestCase, self).setUp()
+        self.fingerprint = self.create_image()
+
+
+class Test10Image(ImageTestCase):
+    """Tests for /1.0/images/<fingerprint>."""
+
+    def test_1_0_images_name(self):
+        """Return: dict representing an image properties."""
+        response = self.lxd['1.0'].images[self.fingerprint].get()
+
+        self.assertEqual(200, response.status_code)
+
+    def test_1_0_images_name_DELETE(self):
+        """Return: dict representing an image properties."""
+        response = self.lxd['1.0'].images[self.fingerprint].delete()
+
+        self.assertEqual(200, response.status_code)
+
+    @unittest.skip("Not yet implemented in LXD")
+    def test_1_0_images_name_POST(self):
+        """Return: dict representing an image properties."""
+        response = self.lxd['1.0'].images[self.fingerprint].post(json={
+            'name': 'test-image'
+            })
+
+        self.assertEqual(200, response.status_code)
+
+    def test_1_0_images_name_PUT(self):
+        """Return: dict representing an image properties."""
+        response = self.lxd['1.0'].images[self.fingerprint].put(json={
+            'public': False
+            })
+
+        self.assertEqual(200, response.status_code)
+
+
+class Test10ImageExport(ImageTestCase):
+    """Tests for /1.0/images/<fingerprint>/export."""
+
+    def test_1_0_images_export(self):
+        """Return: dict representing an image properties."""
+        response = self.lxd['1.0'].images[self.fingerprint].export.get()
+
+        self.assertEqual(200, response.status_code)
+
+
+class Test10ImageSecret(ImageTestCase):
+    """Tests for /1.0/images/<fingerprint>/secret."""
+
+    def test_1_0_images_secret(self):
+        """Return: dict representing an image properties."""
+        response = self.lxd['1.0'].images[self.fingerprint].secret.post({
+            'name': 'abcdef'
+            })
+
+        self.assertEqual(202, response.status_code)
+
+
+class Test10ImageAliases(IntegrationTestCase):
+    """Tests for /1.0/images/aliases."""
+
+    def test_1_0_images_aliases(self):
+        """Return: list of URLs for images this server publishes."""
+        response = self.lxd['1.0'].images.aliases.get()
+
+        self.assertEqual(200, response.status_code)
+
+    def test_1_0_images_aliases_POST(self):
+        """Return: list of URLs for images this server publishes."""
+        fingerprint = self.create_image()
+        alias = 'test-alias'
+        self.addCleanup(self.delete_image, alias)
+        response = self.lxd['1.0'].images.aliases.post(json={
+            'target': fingerprint,
+            'name': alias
+            })
+
+        self.assertEqual(200, response.status_code)
+
+
+class Test10ImageAlias(IntegrationTestCase):
+    """Tests for /1.0/images/aliases/<alias>."""
diff --git a/integration/testing.py b/integration/testing.py
index 29a3bc8..f06b336 100644
--- a/integration/testing.py
+++ b/integration/testing.py
@@ -12,9 +12,9 @@
 #    License for the specific language governing permissions and limitations
 #    under the License.
 import unittest
-import uuid
 
 from pylxd.api import LXD
+from integration.busybox import create_busybox_image
 
 
 class IntegrationTestCase(unittest.TestCase):
@@ -26,7 +26,7 @@ def setUp(self):
 
     def create_container(self):
         """Create a container in lxd."""
-        name = 'a' + str(uuid.uuid4())
+        name = self.id().split('.')[-1].replace('_', '')
         machine = {
             'name': name,
             'architecture': 2,
@@ -38,12 +38,45 @@ def create_container(self):
         }
         result = self.lxd['1.0']['containers'].post(json=machine)
         operation_uuid = result.json()['operation'].split('/')[-1]
-        self.lxd['1.0'].operations[operation_uuid].wait.get()
+        result = self.lxd['1.0'].operations[operation_uuid].wait.get()
+
+        self.addCleanup(self.delete_container, name)
         return name
 
-    def delete_container(self, name):
+    def delete_container(self, name, enforce=False):
         """Delete a container in lxd."""
-        self.lxd['1.0']['containers'][name].delete()
+        #response = self.lxd['1.0'].containers['name'].get()
+        #if response == 200:
+        # enforce is a hack. There's a race somewhere in the delete.
+        # To ensure we don't get an infinite loop, let's count.
+        count = 0
+        result = self.lxd['1.0']['containers'][name].delete()
+        while enforce and result.status_code == 404 and count < 10:
+            result = self.lxd['1.0']['containers'][name].delete()
+            count += 1
+        try:
+            operation_uuid = result.json()['operation'].split('/')[-1]
+            result = self.lxd['1.0'].operations[operation_uuid].wait.get()
+        except KeyError:
+            pass  # 404 cases are okay.
+
+    def create_image(self):
+        """Create an image in lxd."""
+        path, fingerprint = create_busybox_image()
+        with open(path, 'rb') as f:
+            headers = {
+                'X-LXD-Public': '1',
+                }
+            response = self.lxd['1.0'].images.post(data=f.read(), headers=headers)
+        operation_uuid = response.json()['operation'].split('/')[-1]
+        self.lxd['1.0'].operations[operation_uuid].wait.get()
+
+        self.addCleanup(self.delete_image, fingerprint)
+        return fingerprint
+
+    def delete_image(self, fingerprint):
+        """Delete an image in lxd."""
+        self.lxd['1.0'].images[fingerprint].delete()
 
     def assertCommon(self, response):
         """Assert common LXD responses.

From 73d5f7d9c5e1f193c9dd24acf363612f6771fa44 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul.hummer at canonical.com>
Date: Sun, 14 Feb 2016 21:21:46 -0700
Subject: [PATCH 03/20] Initial skeleton for 2.0 api.

---
 integration/test_containers.py |  53 ++++++++++++++++++
 pylxd/client.py                | 122 +++++++++++++++++++++++++++++++++++++++++
 2 files changed, 175 insertions(+)
 create mode 100644 integration/test_containers.py
 create mode 100644 pylxd/client.py

diff --git a/integration/test_containers.py b/integration/test_containers.py
new file mode 100644
index 0000000..29c89bd
--- /dev/null
+++ b/integration/test_containers.py
@@ -0,0 +1,53 @@
+# Copyright (c) 2016 Canonical Ltd
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+from integration.testing import IntegrationTestCase
+
+
+class TestContainers(IntegrationTestCase):
+    """Tests for `Client.containers`"""
+
+    def test_get(self):
+        """A container is fetched by name."""
+        name = self.create_container()
+        self.addCleanup(self.delete_container, name)
+
+        container = self.client.containers.get(name)
+
+        self.assertEqual(name, container.name)
+
+    def test_all(self):
+        """A list of all containers is returned."""
+        name = self.create_container()
+        self.addCleanup(self.delete_container, name)
+
+        containers = self.client.containers.all()
+
+        self.assertEqual(1, len(containers))
+        self.assertEqual(name, containers[0].name)
+
+    def test_create(self):
+        """Creates and returns a new container."""
+        config = {
+            'name': 'an-container',
+            'architecture': 2,
+            'profiles': ['default'],
+            'ephemeral': True,
+            'config': {'limits.cpu': '2'},
+            'source': {'type': 'image',
+                       'alias': 'busybox'},
+        }
+
+        container = self.client.containers.create(config)
+
+        self.assertEqual(config.name, container.name)
diff --git a/pylxd/client.py b/pylxd/client.py
new file mode 100644
index 0000000..5a329ae
--- /dev/null
+++ b/pylxd/client.py
@@ -0,0 +1,122 @@
+# Copyright (c) 2016 Canonical Ltd
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+import os
+
+import requests
+import requests_unixsocket
+
+
+class _APINode(object):
+    """An api node object.
+
+    This class allows us to dynamically create request urls by expressing them
+    in python. For example:
+
+        >>> node = _APINode('http://example.com/api')
+        >>> node.users[1].comments.get()
+
+    ...would make an HTTP GET request on
+    http://example.com/api/users/1/comments
+    """
+
+    def __init__(self, api_endpoint):
+        self._api_endpoint = api_endpoint
+
+    def __getattr__(self, name):
+        return self.__class__('{}/{}'.format(self._api_endpoint, name))
+
+    def __getitem__(self, item):
+        return self.__class__('{}/{}'.format(self._api_endpoint, item))
+
+    @property
+    def session(self):
+        if self._api_endpoint.startswith('http+unix://'):
+            return requests_unixsocket.Session()
+        else:
+            return requests
+
+    def get(self, *args, **kwargs):
+        """Perform an HTTP GET."""
+        return self.session.get(self._api_endpoint, *args, **kwargs)
+
+    def post(self, *args, **kwargs):
+        """Perform an HTTP POST."""
+        return self.session.post(self._api_endpoint, *args, **kwargs)
+
+    def put(self, *args, **kwargs):
+        """Perform an HTTP PUT."""
+        return self.session.put(self._api_endpoint, *args, **kwargs)
+
+    def delete(self, *args, **kwargs):
+        """Perform an HTTP delete."""
+        return self.session.delete(self._api_endpoint, *args, **kwargs)
+
+
+class Client(object):
+    """A LXD client."""
+
+    def __init__(self, endpoint=None):
+        if endpoint is not None:
+            self.api = _APINode(endpoint)['1.0']
+        else:
+            if 'LXD_DIR' in os.environ:
+                path = os.path.join(
+                    os.environ.get['LXD_DIR'], 'unix.socket')
+            else:
+                path = '/var/lib/lxd/unix.socket'
+            self._api = _APINode('http+unix://{}'.format(path))
+
+        self.containers = _Containers(self)
+        self.images = _Images(self)
+        self.profiles = _Profiles(self)
+        self.operations = _Operations(self)
+
+
+class _Containers(object):
+    """A wrapper for working with containers."""
+
+    def __init__(self, client):
+        self._client = client
+
+    def get(self, name):
+        pass
+
+    def all(self):
+        response = self.client.api.containers.get()
+
+        return []
+
+    def create(self, config):
+        pass
+
+
+class _Images(object):
+    """A wrapper for working with images."""
+
+    def __init__(self, client):
+        self._client = client
+
+
+class _Profiles(object):
+    """A wrapper for working with profiles."""
+
+    def __init__(self, client):
+        self._client = client
+
+
+class _Operations(object):
+    """A wrapper for working with operations."""
+
+    def __init__(self, client):
+        self._client = client

From 93f814757d667ecb5958c5ab9a21c2eb295082d6 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul.hummer at canonical.com>
Date: Sun, 14 Feb 2016 21:23:35 -0700
Subject: [PATCH 04/20] Move _APINode

---
 pylxd/api.py | 51 ---------------------------------------------------
 1 file changed, 51 deletions(-)

diff --git a/pylxd/api.py b/pylxd/api.py
index f9a1370..d1be4c4 100644
--- a/pylxd/api.py
+++ b/pylxd/api.py
@@ -12,10 +12,6 @@
 #    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 #    License for the specific language governing permissions and limitations
 #    under the License.
-
-import requests
-import requests_unixsocket
-
 from pylxd import certificate
 from pylxd import connection
 from pylxd import container
@@ -26,53 +22,6 @@
 from pylxd import profiles
 
 
-class _APINode(object):
-    """An api node object.
-
-    This class allows us to dynamically create request urls by expressing them
-    in python. For example:
-
-        >>> node = APINode('http://example.com/api')
-        >>> node.users[1].comments.get()
-
-    ...would make an HTTP GET request on
-    http://example.com/api/users/1/comments
-    """
-
-    def __init__(self, api_endpoint):
-        self._api_endpoint = api_endpoint
-
-    def __getattr__(self, name):
-        return self.__class__('{}/{}'.format(self._api_endpoint, name))
-
-    def __getitem__(self, item):
-        return self.__class__('{}/{}'.format(self._api_endpoint, item))
-
-    @property
-    def session(self):
-        if self._api_endpoint.startswith('http+unix://'):
-            return requests_unixsocket.Session()
-        else:
-            return requests
-
-    def get(self, *args, **kwargs):
-        """Perform an HTTP GET."""
-        return self.session.get(self._api_endpoint, *args, **kwargs)
-
-    def post(self, *args, **kwargs):
-        """Perform an HTTP POST."""
-        return self.session.post(self._api_endpoint, *args, **kwargs)
-
-    def put(self, *args, **kwargs):
-        """Perform an HTTP PUT."""
-        return self.session.put(self._api_endpoint, *args, **kwargs)
-
-    def delete(self, *args, **kwargs):
-        """Perform an HTTP delete."""
-        return self.session.delete(self._api_endpoint, *args, **kwargs)
-LXD = _APINode
-
-
 class API(object):
 
     def __init__(self, host=None, port=8443):

From 50ba5474b1bef2d655cbaf08db238d4cb6e86570 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul.hummer at canonical.com>
Date: Sun, 14 Feb 2016 21:25:35 -0700
Subject: [PATCH 05/20] Fix integration tests.

---
 integration/testing.py | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/integration/testing.py b/integration/testing.py
index f06b336..f9be74d 100644
--- a/integration/testing.py
+++ b/integration/testing.py
@@ -13,7 +13,7 @@
 #    under the License.
 import unittest
 
-from pylxd.api import LXD
+from pylxd.client import Client
 from integration.busybox import create_busybox_image
 
 
@@ -22,7 +22,8 @@ class IntegrationTestCase(unittest.TestCase):
 
     def setUp(self):
         super(IntegrationTestCase, self).setUp()
-        self.lxd = LXD('http+unix://%2Fvar%2Flib%2Flxd%2Funix.socket')
+        self.client = Client()
+        self.lxd = self.client.api
 
     def create_container(self):
         """Create a container in lxd."""

From ad3d0058b0bcb10327442d7c1947cae9503ca600 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul at eventuallyanyway.com>
Date: Mon, 15 Feb 2016 11:08:27 -0700
Subject: [PATCH 06/20] Get the first of the integration tests working.

---
 integration/test_containers.py |  5 +--
 integration/testing.py         | 18 +++++-----
 pylxd/client.py                | 82 +++++++++++++++++++++++++++++++++++-------
 3 files changed, 82 insertions(+), 23 deletions(-)

diff --git a/integration/test_containers.py b/integration/test_containers.py
index 29c89bd..a76c61d 100644
--- a/integration/test_containers.py
+++ b/integration/test_containers.py
@@ -47,7 +47,8 @@ def test_create(self):
             'source': {'type': 'image',
                        'alias': 'busybox'},
         }
+        self.addCleanup(self.delete_container, config['name'])
 
-        container = self.client.containers.create(config)
+        container = self.client.containers.create(config, wait=True)
 
-        self.assertEqual(config.name, container.name)
+        self.assertEqual(config['name'], container.name)
diff --git a/integration/testing.py b/integration/testing.py
index f9be74d..f102b94 100644
--- a/integration/testing.py
+++ b/integration/testing.py
@@ -37,27 +37,27 @@ def create_container(self):
             'source': {'type': 'image',
                        'alias': 'busybox'},
         }
-        result = self.lxd['1.0']['containers'].post(json=machine)
+        result = self.lxd['containers'].post(json=machine)
         operation_uuid = result.json()['operation'].split('/')[-1]
-        result = self.lxd['1.0'].operations[operation_uuid].wait.get()
+        result = self.lxd.operations[operation_uuid].wait.get()
 
         self.addCleanup(self.delete_container, name)
         return name
 
     def delete_container(self, name, enforce=False):
         """Delete a container in lxd."""
-        #response = self.lxd['1.0'].containers['name'].get()
+        #response = self.lxd.containers['name'].get()
         #if response == 200:
         # enforce is a hack. There's a race somewhere in the delete.
         # To ensure we don't get an infinite loop, let's count.
         count = 0
-        result = self.lxd['1.0']['containers'][name].delete()
+        result = self.lxd['containers'][name].delete()
         while enforce and result.status_code == 404 and count < 10:
-            result = self.lxd['1.0']['containers'][name].delete()
+            result = self.lxd['containers'][name].delete()
             count += 1
         try:
             operation_uuid = result.json()['operation'].split('/')[-1]
-            result = self.lxd['1.0'].operations[operation_uuid].wait.get()
+            result = self.lxd.operations[operation_uuid].wait.get()
         except KeyError:
             pass  # 404 cases are okay.
 
@@ -68,16 +68,16 @@ def create_image(self):
             headers = {
                 'X-LXD-Public': '1',
                 }
-            response = self.lxd['1.0'].images.post(data=f.read(), headers=headers)
+            response = self.lxd.images.post(data=f.read(), headers=headers)
         operation_uuid = response.json()['operation'].split('/')[-1]
-        self.lxd['1.0'].operations[operation_uuid].wait.get()
+        self.lxd.operations[operation_uuid].wait.get()
 
         self.addCleanup(self.delete_image, fingerprint)
         return fingerprint
 
     def delete_image(self, fingerprint):
         """Delete an image in lxd."""
-        self.lxd['1.0'].images[fingerprint].delete()
+        self.lxd.images[fingerprint].delete()
 
     def assertCommon(self, response):
         """Assert common LXD responses.
diff --git a/pylxd/client.py b/pylxd/client.py
index 5a329ae..e5f5399 100644
--- a/pylxd/client.py
+++ b/pylxd/client.py
@@ -12,10 +12,13 @@
 #    License for the specific language governing permissions and limitations
 #    under the License.
 import os
+import urllib
 
 import requests
 import requests_unixsocket
 
+requests_unixsocket.monkeypatch()
+
 
 class _APINode(object):
     """An api node object.
@@ -66,16 +69,18 @@ def delete(self, *args, **kwargs):
 class Client(object):
     """A LXD client."""
 
-    def __init__(self, endpoint=None):
+    def __init__(self, endpoint=None, version='1.0'):
         if endpoint is not None:
-            self.api = _APINode(endpoint)['1.0']
+            self.api = _APINode(endpoint)
         else:
             if 'LXD_DIR' in os.environ:
                 path = os.path.join(
                     os.environ.get['LXD_DIR'], 'unix.socket')
             else:
                 path = '/var/lib/lxd/unix.socket'
-            self._api = _APINode('http+unix://{}'.format(path))
+            self.api = _APINode('http+unix://{}'.format(
+                urllib.quote(path, safe='')))
+        self.api = self.api[version]
 
         self.containers = _Containers(self)
         self.images = _Images(self)
@@ -83,40 +88,93 @@ def __init__(self, endpoint=None):
         self.operations = _Operations(self)
 
 
-class _Containers(object):
+class Waitable(object):
+
+    def wait_for_operation(self, operation_id):
+        operation = self._client.operations.get(operation_id)
+        operation.wait()
+
+
+class _Containers(Waitable):
     """A wrapper for working with containers."""
 
     def __init__(self, client):
         self._client = client
 
     def get(self, name):
-        pass
+        response = self._client.api.containers[name].get()
+
+        container = Container(**response.json()['metadata'])
+        return container
 
     def all(self):
-        response = self.client.api.containers.get()
+        response = self._client.api.containers.get()
 
-        return []
+        containers = []
+        for url in response.json()['metadata']:
+            name = url.split('/')[-1]
+            containers.append(Container(name=name))
+        return containers
 
-    def create(self, config):
-        pass
+    def create(self, config, wait=False):
+        response = self._client.api.containers.post(json=config)
 
+        if wait:
+            operation_id = response.json()['operation'].split('/')[-1]
+            self.wait_for_operation(operation_id)
+        return Container(name=config['name'])
 
-class _Images(object):
+
+class _Images(Waitable):
     """A wrapper for working with images."""
 
     def __init__(self, client):
         self._client = client
 
 
-class _Profiles(object):
+class _Profiles(Waitable):
     """A wrapper for working with profiles."""
 
     def __init__(self, client):
         self._client = client
 
 
-class _Operations(object):
+class _Operations(Waitable):
     """A wrapper for working with operations."""
 
     def __init__(self, client):
         self._client = client
+
+    def get(self, operation_id):
+        response = self._client.api.operations[operation_id].get()
+
+        return Operation(_client=self._client, **response.json()['metadata'])
+
+
+class Container(object):
+
+    __slots__ = [
+        'architecture', 'config', 'creation_date', 'devices', 'ephemeral',
+        'expanded_config', 'expanded_devices', 'name', 'profiles', 'status'
+        ]
+
+    def __init__(self, **kwargs):
+        super(Container, self).__init__()
+        for key, value in kwargs.iteritems():
+            setattr(self, key, value)
+
+
+class Operation(object):
+
+    __slots__ = [
+        '_client',
+        'class', 'created_at', 'err', 'id', 'may_cancel', 'metadata', 'resources',
+        'status', 'status_code', 'updated_at']
+
+    def __init__(self, **kwargs):
+        super(Operation, self).__init__()
+        for key, value in kwargs.iteritems():
+            setattr(self, key, value)
+
+    def wait(self):
+        self._client.api.operations[self.id].wait.get()

From ccd62e905004fcb0efe722df1943b117d5203014 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul at eventuallyanyway.com>
Date: Mon, 15 Feb 2016 11:09:20 -0700
Subject: [PATCH 07/20] Remove redundant tests.

---
 integration/test_1_0_containers.py | 29 -----------------------------
 1 file changed, 29 deletions(-)

diff --git a/integration/test_1_0_containers.py b/integration/test_1_0_containers.py
index e66541e..4909284 100644
--- a/integration/test_1_0_containers.py
+++ b/integration/test_1_0_containers.py
@@ -14,35 +14,6 @@
 from integration.testing import IntegrationTestCase
 
 
-class Test10Containers(IntegrationTestCase):
-    """Tests for /1.0/containers"""
-
-    def test_1_0_containers(self):
-        """Return: list of URLs for containers this server publishes."""
-        result = self.lxd['1.0']['containers'].get()
-
-        self.assertCommon(result)
-        self.assertEqual(200, result.status_code)
-
-    def test_1_0_containers_POST(self):
-        """Return: background operation or standard error."""
-        name = self.id().split('.')[-1].replace('_', '')
-        machine = {
-            'name': name,
-            'architecture': 2,
-            'profiles': ['default'],
-            'ephemeral': True,
-            'config': {'limits.cpu': '2'},
-            'source': {'type': 'image',
-                       'alias': 'busybox'},
-        }
-        result = self.lxd['1.0']['containers'].post(json=machine)
-        self.addCleanup(self.delete_container, name, enforce=True)
-
-        # self.assertCommon(result)
-        self.assertEqual(202, result.status_code)
-
-
 class ContainerTestCase(IntegrationTestCase):
     """A Container-specific test case."""
 

From a851d3372f67d818db8484a6566b48c33da8ae19 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul at eventuallyanyway.com>
Date: Mon, 15 Feb 2016 14:01:22 -0700
Subject: [PATCH 08/20] Fully functional Container class

---
 integration/test_1_0_containers.py | 209 -------------------------------------
 integration/test_containers.py     |  93 +++++++++++++++++
 integration/testing.py             |   2 +-
 pylxd/client.py                    | 137 +++++++++++++++++++++++-
 4 files changed, 226 insertions(+), 215 deletions(-)
 delete mode 100644 integration/test_1_0_containers.py

diff --git a/integration/test_1_0_containers.py b/integration/test_1_0_containers.py
deleted file mode 100644
index 4909284..0000000
--- a/integration/test_1_0_containers.py
+++ /dev/null
@@ -1,209 +0,0 @@
-# Copyright (c) 2016 Canonical Ltd
-#
-#    Licensed under the Apache License, Version 2.0 (the "License"); you may
-#    not use this file except in compliance with the License. You may obtain
-#    a copy of the License at
-#
-#         http://www.apache.org/licenses/LICENSE-2.0
-#
-#    Unless required by applicable law or agreed to in writing, software
-#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
-#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
-#    License for the specific language governing permissions and limitations
-#    under the License.
-from integration.testing import IntegrationTestCase
-
-
-class ContainerTestCase(IntegrationTestCase):
-    """A Container-specific test case."""
-
-    def setUp(self):
-        super(ContainerTestCase, self).setUp()
-        self.name = self.create_container()
-
-
-class Test10Container(ContainerTestCase):
-    """Tests for /1.0/containers/<name>"""
-
-    def test10container(self):
-        """Return: dict of the container configuration and current state."""
-        result = self.lxd['1.0']['containers'][self.name].get()
-
-        self.assertEqual(200, result.status_code)
-
-    def test10container_put(self):
-        """Return: background operation or standard error."""
-        result = self.lxd['1.0']['containers'][self.name].put(json={
-            'config': {'limits.cpu': '1'}
-            })
-
-        self.assertEqual(202, result.status_code)
-
-    def test10container_post(self):
-        """Return: background operation or standard error."""
-        new_name = 'newname'
-        result = self.lxd['1.0']['containers'][self.name].post(json={
-            'name': new_name,
-            })
-        self.addCleanup(self.delete_container, new_name, enforce=True)
-
-        self.assertEqual(202, result.status_code)
-
-    def test10container_delete(self):
-        """Return: background operation or standard error."""
-        result = self.lxd['1.0']['containers'][self.name].delete()
-
-        self.assertEqual(202, result.status_code)
-
-
-class Test10ContainerState(ContainerTestCase):
-    """Tests for /1.0/containers/<name>/state."""
-
-    def test10containerstate(self):
-        """Return: dict representing current state."""
-        result = self.lxd['1.0'].containers[self.name].state.get()
-
-        self.assertEqual(200, result.status_code)
-
-    def test10containerstate_PUT(self):
-        """Return: background operation or standard error."""
-        result = self.lxd['1.0'].containers[self.name].state.put(json={
-            'action': 'start',
-            'timeout': 30,
-            'force': True
-            })
-
-        self.assertEqual(202, result.status_code)
-
-
-class Test10ContainerFiles(ContainerTestCase):
-    """Tests for /1.0/containers/<name>/files."""
-
-    def test10containerfiles(self):
-        """Return: dict representing current files."""
-        result = self.lxd['1.0'].containers[self.name].files.get(params={
-            'path': '/bin/sh'
-            })
-
-        self.assertEqual(200, result.status_code)
-
-    def test10containerfiles_POST(self):
-        """Return: standard return value or standard error."""
-        result = self.lxd['1.0'].containers[self.name].files.get(params={
-            'path': '/bin/sh'
-            }, data='abcdef')
-
-        self.assertEqual(200, result.status_code)
-
-
-class Test10ContainerSnapshots(ContainerTestCase):
-    """Tests for /1.0/containers/<name>/snapshots."""
-
-    def test10containersnapshots(self):
-        """Return: list of URLs for snapshots for this container."""
-        result = self.lxd['1.0'].containers[self.name].snapshots.get()
-
-        self.assertEqual(200, result.status_code)
-
-    def test10containersnapshots_POST(self):
-        """Return: background operation or standard error."""
-        result = self.lxd['1.0'].containers[self.name].snapshots.post(json={
-            'name': 'test-snapshot',
-            'stateful': False
-            })
-
-        self.assertEqual(202, result.status_code)
-
-
-class Test10ContainerSnapshot(ContainerTestCase):
-    """Tests for /1.0/containers/<name>/snapshot/<name>."""
-
-    def setUp(self):
-        super(Test10ContainerSnapshot, self).setUp()
-        result = self.lxd['1.0'].containers[self.name].snapshots.post(json={
-            'name': 'test-snapshot', 'stateful': False})
-        operation_uuid = result.json()['operation'].split('/')[-1]
-        result = self.lxd['1.0'].operations[operation_uuid].wait.get()
-
-    def test10containersnapshot(self):
-        """Return: dict representing the snapshot."""
-        result = self.lxd['1.0'].containers[self.name].snapshots['test-snapshot'].get()
-
-        self.assertEqual(200, result.status_code)
-
-    def test10containersnapshot_POST(self):
-        """Return: dict representing the snapshot."""
-        result = self.lxd['1.0'].containers[self.name].snapshots['test-snapshot'].post(json={
-            'name': 'test-snapshot.bak-lol'
-            })
-
-        self.assertEqual(202, result.status_code)
-
-    def test10containersnapshot_DELETE(self):
-        """Return: dict representing the snapshot."""
-        result = self.lxd['1.0'].containers[self.name].snapshots['test-snapshot'].delete()
-
-        self.assertEqual(202, result.status_code)
-
-
-class Test10ContainerExec(ContainerTestCase):
-    """Tests for /1.0/containers/<name>/exec."""
-
-    def setUp(self):
-        super(Test10ContainerExec, self).setUp()
-
-        result = self.lxd['1.0'].containers[self.name].state.put(json={
-            'action': 'start', 'timeout': 30, 'force': True})
-        operation_uuid = result.json()['operation'].split('/')[-1]
-        self.lxd['1.0'].operations[operation_uuid].wait.get()
-        self.addCleanup(self.stop_container)
-
-    def stop_container(self):
-        """Stop the container (before deleting it)."""
-        result = self.lxd['1.0'].containers[self.name].state.put(json={
-            'action': 'stop', 'timeout': 30, 'force': True})
-        operation_uuid = result.json()['operation'].split('/')[-1]
-        self.lxd['1.0'].operations[operation_uuid].wait.get()
-
-#    def test10containerexec(self):
-#        """Return: background operation + optional websocket information.
-#
-#        ...or standard error."""
-#        result = self.lxd['1.0'].containers[self.name]['exec'].post(json={
-#            'command': ['/bin/bash'],
-#            'wait-for-websocket': False,
-#            'interactive': True,
-#            })
-#
-#        self.assertEqual(202, result.status_code)
-
-
-class Test10ContainerLogs(ContainerTestCase):
-    """Tests for /1.0/containers/<name>/logs."""
-
-#    def test10containerlogs(self):
-#        """Return: a list of the available log files."""
-#        result = self.lxd['1.0'].containers[self.name].logs.get()
-#
-#        self.assertEqual(200, result.status_code)
-
-
-class Test10ContainerLog(ContainerTestCase):
-    """Tests for /1.0/containers/<name>/logs/<logfile>."""
-
-    def setUp(self):
-        super(Test10ContainerLog, self).setUp()
-        result = self.lxd['1.0'].containers[self.name].logs.get()
-        self.log_name = result.json()['metadata'][0]['name']
-
-#    def test10containerlog(self):
-#        """Return: the contents of the log file."""
-#        result = self.lxd['1.0'].containers[self.name].logs[self.log_name].get()
-#
-#        self.assertEqual(200, result.status_code)
-#
-#    def test10containerlog_DELETE(self):
-#        """Return: the contents of the log file."""
-#        result = self.lxd['1.0'].containers[self.name].logs[self.log_name].delete()
-#
-#        self.assertEqual(200, result.status_code)
diff --git a/integration/test_containers.py b/integration/test_containers.py
index a76c61d..d72d4fb 100644
--- a/integration/test_containers.py
+++ b/integration/test_containers.py
@@ -52,3 +52,96 @@ def test_create(self):
         container = self.client.containers.create(config, wait=True)
 
         self.assertEqual(config['name'], container.name)
+
+
+class TestContainer(IntegrationTestCase):
+    """Tests for Client.Container."""
+
+    def setUp(self):
+        super(TestContainer, self).setUp()
+        name = self.create_container()
+        self.container = self.client.containers.get(name)
+
+    def tearDown(self):
+        super(TestContainer, self).tearDown()
+        self.delete_container(self.container.name)
+
+    def test_update(self):
+        """The container is updated to a new config."""
+        self.container.config['limits.cpu'] = '1'
+        self.container.update(wait=True)
+
+        self.assertEqual('1', self.container.config['limits.cpu'])
+        container = self.client.containers.get(self.container.name)
+        self.assertEqual('1', container.config['limits.cpu'])
+
+    def test_rename(self):
+        """The container is renamed."""
+        name = 'an-renamed-container'
+        self.container.rename(name, wait=True)
+
+        self.assertEqual(name, self.container.name)
+        container = self.client.containers.get(name)
+        self.assertEqual(name, container.name)
+
+    def test_delete(self):
+        """The container is deleted."""
+        self.container.delete(wait=True)
+
+        self.assertRaises(
+            NameError, self.client.containers.get, self.container.name)
+
+    def test_start_stop(self):
+        """The container is started and then stopped."""
+        # NOTE: rockstar (15 Feb 2016) - I don't care for the
+        # multiple assertions here, but it's a okay-ish way
+        # to test what we need.
+        self.container.start(wait=True)
+
+        self.assertEqual('Running', self.container.status['status'])
+        container = self.client.containers.get(self.container.name)
+        self.assertEqual('Running', container.status['status'])
+
+        self.container.stop(wait=True)
+
+        self.assertEqual('Stopped', self.container.status['status'])
+        container = self.client.containers.get(self.container.name)
+        self.assertEqual('Stopped', container.status['status'])
+
+    def test_snapshot(self):
+        """A container snapshot is made, renamed, and deleted."""
+        # NOTE: rockstar (15 Feb 2016) - Once again, multiple things
+        # asserted in the same test.
+        name = 'an-snapshot'
+        self.container.snapshot(name, wait=True)
+
+        self.assertEqual([name], self.container.list_snapshots())
+
+        new_name = 'an-other-snapshot'
+        self.container.rename_snapshot(name, new_name, wait=True)
+
+        self.assertEqual([new_name], self.container.list_snapshots())
+
+        self.container.delete_snapshot(new_name, wait=True)
+
+        self.assertEqual([], self.container.list_snapshots())
+
+    def test_put_get_file(self):
+        """A file is written to the container and then read."""
+        filepath = '/tmp/an_file'
+        data = 'abcdef'
+
+        retval = self.container.put_file(filepath, data)
+
+        self.assertTrue(retval)
+
+        contents = self.container.get_file(filepath)
+
+        self.assertEqual(data, contents)
+
+    def test_execute(self):
+        """A command is executed on the container."""
+        self.container.start(wait=True)
+        self.addCleanup(self.container.stop, wait=True)
+
+        self.container.execute('ls /')
diff --git a/integration/testing.py b/integration/testing.py
index f102b94..affc630 100644
--- a/integration/testing.py
+++ b/integration/testing.py
@@ -32,7 +32,7 @@ def create_container(self):
             'name': name,
             'architecture': 2,
             'profiles': ['default'],
-            'ephemeral': True,
+            'ephemeral': False,
             'config': {'limits.cpu': '2'},
             'source': {'type': 'image',
                        'alias': 'busybox'},
diff --git a/pylxd/client.py b/pylxd/client.py
index e5f5399..393059a 100644
--- a/pylxd/client.py
+++ b/pylxd/client.py
@@ -90,8 +90,13 @@ def __init__(self, endpoint=None, version='1.0'):
 
 class Waitable(object):
 
+    def get_operation(self, operation_id):
+        if operation_id.startswith('/'):
+            operation_id = operation_id.split('/')[-1]
+        return self._client.operations.get(operation_id)
+
     def wait_for_operation(self, operation_id):
-        operation = self._client.operations.get(operation_id)
+        operation = self.get_operation(operation_id)
         operation.wait()
 
 
@@ -104,7 +109,9 @@ def __init__(self, client):
     def get(self, name):
         response = self._client.api.containers[name].get()
 
-        container = Container(**response.json()['metadata'])
+        if response.status_code == 404:
+            raise NameError('No container named "{}"'.format(name))
+        container = Container(_client=self._client, **response.json()['metadata'])
         return container
 
     def all(self):
@@ -151,9 +158,21 @@ def get(self, operation_id):
         return Operation(_client=self._client, **response.json()['metadata'])
 
 
-class Container(object):
+class Marshallable(object):
+
+    def marshall(self):
+        marshalled = {}
+        for name in self.__slots__:
+            if name.startswith('_'):
+                continue
+            marshalled[name] = getattr(self, name)
+        return marshalled
+
+
+class Container(Waitable, Marshallable):
 
     __slots__ = [
+        '_client',
         'architecture', 'config', 'creation_date', 'devices', 'ephemeral',
         'expanded_config', 'expanded_devices', 'name', 'profiles', 'status'
         ]
@@ -163,13 +182,121 @@ def __init__(self, **kwargs):
         for key, value in kwargs.iteritems():
             setattr(self, key, value)
 
+    def reload(self):
+        response = self._client.api.containers[self.name].get()
+        if response.status_code == 404:
+            raise NameError('Container named "{}" has gone away'.format(self.name))
+        for key, value in response.json()['metadata'].iteritems():
+            setattr(self, key, value)
+
+    def update(self, wait=False):
+        marshalled = self.marshall()
+        # These two properties are explicitly not allowed.
+        del marshalled['name']
+        del marshalled['status']
+
+        response = self._client.api.containers[self.name].put(
+            json=marshalled)
+
+        if wait:
+            self.wait_for_operation(response.json()['operation'])
+
+    def rename(self, name, wait=False):
+        response = self._client.api.containers[self.name].post(json={'name': name})
+
+        if wait:
+            self.wait_for_operation(response.json()['operation'])
+        self.name = name
+
+    def delete(self, wait=False):
+        response = self._client.api.containers[self.name].delete()
+
+        if wait:
+            self.wait_for_operation(response.json()['operation'])
+
+    def _set_state(self, state, timeout=30, force=True, wait=False):
+        response = self._client.api.containers[self.name].state.put(json={
+            'action': state,
+            'timeout': timeout,
+            'force': force
+            })
+        if wait:
+            self.wait_for_operation(response.json()['operation'])
+            self.reload()
+
+    def start(self, timeout=30, force=True, wait=False):
+        return self._set_state('start', timeout=timeout, force=force, wait=wait)
+
+    def stop(self, timeout=30, force=True, wait=False):
+        return self._set_state('stop', timeout=timeout, force=force, wait=wait)
+
+    def restart(self, timeout=30, force=True, wait=False):
+        return self._set_state('stop', timeout=timeout, force=force, wait=wait)
+
+    def freeze(self, timeout=30, force=True, wait=False):
+        return self._set_state('stop', timeout=timeout, force=force, wait=wait)
+
+    def unfreeze(self, timeout=30, force=True, wait=False):
+        return self._set_state('stop', timeout=timeout, force=force, wait=wait)
+
+    def snapshot(self, name, stateful=False, wait=False):
+        response = self._client.api.containers[self.name].snapshots.post(json={
+            'name': name, 'stateful': stateful})
+        if wait:
+            self.wait_for_operation(response.json()['operation'])
+
+    def list_snapshots(self):
+        response = self._client.api.containers[self.name].snapshots.get()
+        return [snapshot.split('/')[-1] for snapshot in response.json()['metadata']]
+
+    def rename_snapshot(self, old, new, wait=False):
+        response = self._client.api.containers[self.name].snapshots[old].post(json={
+            'name': new
+            })
+        if wait:
+            self.wait_for_operation(response.json()['operation'])
+
+    def delete_snapshot(self, name, wait=False):
+        response = self._client.api.containers[self.name].snapshots[name].delete()
+        if wait:
+            self.wait_for_operation(response.json()['operation'])
+
+    def get_file(self, filepath):
+        response = self._client.api.containers[self.name].files.get(
+            params={'path': filepath})
+        if response.status_code == 500:
+            # XXX: rockstar (15 Feb 2016) - This should really return a 404.
+            # I blame LXD. :)
+            raise IOError('Error reading "{}"'.format(filepath))
+        return response.content
+
+    def put_file(self, filepath, data):
+        response = self._client.api.containers[self.name].files.post(
+            params={'path': filepath}, data=data)
+        return response.status_code == 200
+
+    def execute(self, commands, environment={}):
+        # XXX: rockstar (15 Feb 2016) - This functionality is limited by
+        # design, for now. It needs to grow the ability to return web sockets
+        # and perform interactive functions.
+        if type(commands) in [str, unicode]:
+            commands = [commands]
+        response = self._client.api.containers[self.name]['exec'].post(json={
+            'command': commands,
+            'environment': environment,
+            'wait-for-websocket': False,
+            'interactive': False,
+            })
+        operation_id = response.json()['operation']
+        self.wait_for_operation(operation_id)
+
 
 class Operation(object):
 
     __slots__ = [
         '_client',
-        'class', 'created_at', 'err', 'id', 'may_cancel', 'metadata', 'resources',
-        'status', 'status_code', 'updated_at']
+        'class', 'created_at', 'err', 'id', 'may_cancel', 'metadata',
+        'resources', 'status', 'status_code', 'updated_at']
 
     def __init__(self, **kwargs):
         super(Operation, self).__init__()

From 57e610836297ef136b892ea1cdea5fe9109c45fa Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul at eventuallyanyway.com>
Date: Mon, 15 Feb 2016 15:14:27 -0700
Subject: [PATCH 09/20] Change the way that test objects are named.

---
 integration/testing.py | 19 +++++++++++++++----
 1 file changed, 15 insertions(+), 4 deletions(-)

diff --git a/integration/testing.py b/integration/testing.py
index affc630..735e63e 100644
--- a/integration/testing.py
+++ b/integration/testing.py
@@ -11,6 +11,7 @@
 #    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 #    License for the specific language governing permissions and limitations
 #    under the License.
+import uuid
 import unittest
 
 from pylxd.client import Client
@@ -25,9 +26,14 @@ def setUp(self):
         self.client = Client()
         self.lxd = self.client.api
 
+    def generate_object_name(self):
+        test = self.id().split('.')[-1]
+        rando = str(uuid.uuid1()).split('-')[-1]
+        return '{}-{}'.format(test, rando)
+
     def create_container(self):
         """Create a container in lxd."""
-        name = self.id().split('.')[-1].replace('_', '')
+        name = self._generate_object_name()
         machine = {
             'name': name,
             'architecture': 2,
@@ -46,8 +52,6 @@ def create_container(self):
 
     def delete_container(self, name, enforce=False):
         """Delete a container in lxd."""
-        #response = self.lxd.containers['name'].get()
-        #if response == 200:
         # enforce is a hack. There's a race somewhere in the delete.
         # To ensure we don't get an infinite loop, let's count.
         count = 0
@@ -72,8 +76,15 @@ def create_image(self):
         operation_uuid = response.json()['operation'].split('/')[-1]
         self.lxd.operations[operation_uuid].wait.get()
 
+        alias = self.generate_object_name()
+        response = self.lxd.images.aliases.post(json={
+            'description': '',
+            'target': fingerprint,
+            'name': alias
+            })
+
         self.addCleanup(self.delete_image, fingerprint)
-        return fingerprint
+        return fingerprint, alias
 
     def delete_image(self, fingerprint):
         """Delete an image in lxd."""

From 9e7c9fd02649e5755de8050a3b3a930656cace9b Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul at eventuallyanyway.com>
Date: Mon, 15 Feb 2016 15:22:30 -0700
Subject: [PATCH 10/20] Get the basic Images skeleton working

---
 integration/test_1_0_images.py | 22 -------------------
 integration/test_images.py     | 46 ++++++++++++++++++++++++++++++++++++++++
 pylxd/client.py                | 48 +++++++++++++++++++++++++++++++++++++++++-
 3 files changed, 93 insertions(+), 23 deletions(-)
 create mode 100644 integration/test_images.py

diff --git a/integration/test_1_0_images.py b/integration/test_1_0_images.py
index 2ab3f51..8a7fec8 100644
--- a/integration/test_1_0_images.py
+++ b/integration/test_1_0_images.py
@@ -16,28 +16,6 @@
 from integration.testing import IntegrationTestCase
 
 
-class Test10Images(IntegrationTestCase):
-    """Tests for /1.0/images"""
-
-    def test_1_0_images(self):
-        """Return: list of URLs for images this server publishes."""
-        response = self.lxd['1.0'].images.get()
-
-        self.assertEqual(200, response.status_code)
-
-    def test_1_0_images_POST(self):
-        """Return: list of URLs for images this server publishes."""
-        response = self.lxd['1.0'].images.post(json={
-            'public': True,
-            'source': {
-                'type': 'url',
-                'server': 'http://example.com',
-                'alias': 'test-image'
-                }})
-
-        self.assertEqual(202, response.status_code)
-
-
 class ImageTestCase(IntegrationTestCase):
     """An Image test case."""
 
diff --git a/integration/test_images.py b/integration/test_images.py
new file mode 100644
index 0000000..f5106f0
--- /dev/null
+++ b/integration/test_images.py
@@ -0,0 +1,46 @@
+# Copyright (c) 2016 Canonical Ltd
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+from integration.testing import create_busybox_image, IntegrationTestCase
+
+
+class TestImages(IntegrationTestCase):
+    """Tests for `Client.images.`"""
+
+    def test_get(self):
+        """An image is fetched by fingerprint."""
+        fingerprint, _ = self.create_image()
+        self.addCleanup(self.delete_image, fingerprint)
+
+        image = self.client.images.get(fingerprint)
+
+        self.assertEqual(fingerprint, image.fingerprint)
+
+    def test_all(self):
+        """A list of all images is returned."""
+        fingerprint, _ = self.create_image()
+        self.addCleanup(self.delete_image, fingerprint)
+
+        images = self.client.images.all()
+
+        self.assertIn(fingerprint, [image.fingerprint for image in images])
+
+    def test_create(self):
+        """An image is created."""
+        path, fingerprint = create_busybox_image()
+        self.addCleanup(self.delete_image, fingerprint)
+
+        with open(path) as f:
+            image = self.client.images.create(f.read(), wait=True)
+
+        self.assertEqual(fingerprint, image.fingerprint)
diff --git a/pylxd/client.py b/pylxd/client.py
index 393059a..5d795d8 100644
--- a/pylxd/client.py
+++ b/pylxd/client.py
@@ -11,6 +11,7 @@
 #    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 #    License for the specific language governing permissions and limitations
 #    under the License.
+import hashlib
 import os
 import urllib
 
@@ -98,6 +99,7 @@ def get_operation(self, operation_id):
     def wait_for_operation(self, operation_id):
         operation = self.get_operation(operation_id)
         operation.wait()
+        return operation
 
 
 class _Containers(Waitable):
@@ -120,7 +122,7 @@ def all(self):
         containers = []
         for url in response.json()['metadata']:
             name = url.split('/')[-1]
-            containers.append(Container(name=name))
+            containers.append(Container(_client=self._client, name=name))
         return containers
 
     def create(self, config, wait=False):
@@ -138,6 +140,36 @@ class _Images(Waitable):
     def __init__(self, client):
         self._client = client
 
+    def get(self, fingerprint):
+        response = self._client.api.images[fingerprint].get()
+
+        if response.status_code == 404:
+            raise NameError('No image with fingerprint "{}"'.format(fingerprint))
+        image = Image(_client=self._client, **response.json()['metadata'])
+        return image
+
+    def all(self):
+        response = self._client.api.images.get()
+
+        images = []
+        for url in response.json()['metadata']:
+            fingerprint = url.split('/')[-1]
+            images.append(Image(_client=self._client, fingerprint=fingerprint))
+        return images
+
+    def create(self, image_data, public=False, wait=False):
+        fingerprint = hashlib.sha256(image_data).hexdigest()
+
+        headers = {}
+        if public:
+            headers['X-LXD-Public'] = '1'
+        response = self._client.api.images.post(
+            data=image_data, headers=headers)
+
+        if wait:
+            self.wait_for_operation(response.json()['operation'])
+        return self.get(fingerprint)
+
 
 class _Profiles(Waitable):
     """A wrapper for working with profiles."""
@@ -169,6 +201,20 @@ def marshall(self):
         return marshalled
 
 
+class Image(object):
+
+    __slots__ = [
+        '_client',
+        'aliases', 'architecture', 'created_at', 'expires_at', 'filename',
+        'fingerprint', 'properties', 'public', 'size', 'uploaded_at'
+        ]
+
+    def __init__(self, **kwargs):
+        super(Image, self).__init__()
+        for key, value in kwargs.iteritems():
+            setattr(self, key, value)
+
+
 class Container(Waitable, Marshallable):
 
     __slots__ = [

From 1aedc9411dde3489381d6d25f9784e5cb7cec263 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul at eventuallyanyway.com>
Date: Mon, 15 Feb 2016 16:00:06 -0700
Subject: [PATCH 11/20] Add support for updating/deleting images

---
 integration/test_1_0_images.py | 39 ---------------------------------------
 integration/test_images.py     | 29 +++++++++++++++++++++++++++++
 pylxd/client.py                | 12 +++++++++++-
 3 files changed, 40 insertions(+), 40 deletions(-)

diff --git a/integration/test_1_0_images.py b/integration/test_1_0_images.py
index 8a7fec8..91abc67 100644
--- a/integration/test_1_0_images.py
+++ b/integration/test_1_0_images.py
@@ -11,8 +11,6 @@
 #    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 #    License for the specific language governing permissions and limitations
 #    under the License.
-import unittest
-
 from integration.testing import IntegrationTestCase
 
 
@@ -24,39 +22,6 @@ def setUp(self):
         self.fingerprint = self.create_image()
 
 
-class Test10Image(ImageTestCase):
-    """Tests for /1.0/images/<fingerprint>."""
-
-    def test_1_0_images_name(self):
-        """Return: dict representing an image properties."""
-        response = self.lxd['1.0'].images[self.fingerprint].get()
-
-        self.assertEqual(200, response.status_code)
-
-    def test_1_0_images_name_DELETE(self):
-        """Return: dict representing an image properties."""
-        response = self.lxd['1.0'].images[self.fingerprint].delete()
-
-        self.assertEqual(200, response.status_code)
-
-    @unittest.skip("Not yet implemented in LXD")
-    def test_1_0_images_name_POST(self):
-        """Return: dict representing an image properties."""
-        response = self.lxd['1.0'].images[self.fingerprint].post(json={
-            'name': 'test-image'
-            })
-
-        self.assertEqual(200, response.status_code)
-
-    def test_1_0_images_name_PUT(self):
-        """Return: dict representing an image properties."""
-        response = self.lxd['1.0'].images[self.fingerprint].put(json={
-            'public': False
-            })
-
-        self.assertEqual(200, response.status_code)
-
-
 class Test10ImageExport(ImageTestCase):
     """Tests for /1.0/images/<fingerprint>/export."""
 
@@ -99,7 +64,3 @@ def test_1_0_images_aliases_POST(self):
             })
 
         self.assertEqual(200, response.status_code)
-
-
-class Test10ImageAlias(IntegrationTestCase):
-    """Tests for /1.0/images/aliases/<alias>."""
diff --git a/integration/test_images.py b/integration/test_images.py
index f5106f0..7bf1450 100644
--- a/integration/test_images.py
+++ b/integration/test_images.py
@@ -44,3 +44,32 @@ def test_create(self):
             image = self.client.images.create(f.read(), wait=True)
 
         self.assertEqual(fingerprint, image.fingerprint)
+
+
+class TestImage(IntegrationTestCase):
+    """Tests for Image."""
+
+    def setUp(self):
+        super(TestImage, self).setUp()
+        fingerprint, _ = self.create_image()
+        self.image = self.client.images.get(fingerprint)
+
+    def tearDown(self):
+        super(TestImage, self).tearDown()
+        self.delete_image(self.image.fingerprint)
+
+    def test_update(self):
+        """The image properties are updated."""
+        description = 'an description'
+        self.image.properties['description'] = description
+        self.image.update()
+
+        image = self.client.images.get(self.image.fingerprint)
+        self.assertEqual(description, image.properties['description'])
+
+    def test_delete(self):
+        """The image is deleted."""
+        self.image.delete()
+
+        self.assertRaises(
+            NameError, self.client.images.get, self.image.fingerprint)
diff --git a/pylxd/client.py b/pylxd/client.py
index 5d795d8..3f7c1f1 100644
--- a/pylxd/client.py
+++ b/pylxd/client.py
@@ -201,7 +201,7 @@ def marshall(self):
         return marshalled
 
 
-class Image(object):
+class Image(Waitable, Marshallable):
 
     __slots__ = [
         '_client',
@@ -214,6 +214,16 @@ def __init__(self, **kwargs):
         for key, value in kwargs.iteritems():
             setattr(self, key, value)
 
+    def update(self):
+        self._client.api.images[self.fingerprint].put(
+            json=self.marshall())
+
+    def delete(self, wait=False):
+        response = self._client.api.images[self.fingerprint].delete()
+
+        if wait:
+            self.wait_for_operation(response.json()['operation'])
+
 
 class Container(Waitable, Marshallable):
 

From 8082123a9c18ea6d3ef4be6dafb3d91ee00ec9a2 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul at eventuallyanyway.com>
Date: Mon, 15 Feb 2016 16:01:56 -0700
Subject: [PATCH 12/20] Update the create_image use for multiple return values

---
 integration/test_1_0_images.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/integration/test_1_0_images.py b/integration/test_1_0_images.py
index 91abc67..bdc8956 100644
--- a/integration/test_1_0_images.py
+++ b/integration/test_1_0_images.py
@@ -19,7 +19,7 @@ class ImageTestCase(IntegrationTestCase):
 
     def setUp(self):
         super(ImageTestCase, self).setUp()
-        self.fingerprint = self.create_image()
+        self.fingerprint, _ = self.create_image()
 
 
 class Test10ImageExport(ImageTestCase):
@@ -55,7 +55,7 @@ def test_1_0_images_aliases(self):
 
     def test_1_0_images_aliases_POST(self):
         """Return: list of URLs for images this server publishes."""
-        fingerprint = self.create_image()
+        fingerprint, _ = self.create_image()
         alias = 'test-alias'
         self.addCleanup(self.delete_image, alias)
         response = self.lxd['1.0'].images.aliases.post(json={

From 3c3a562995e12c485bf8cca93438d762dd4420f1 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul at eventuallyanyway.com>
Date: Mon, 15 Feb 2016 17:20:17 -0700
Subject: [PATCH 13/20] Add tests and functionality for profiles

---
 integration/test_profiles.py | 87 ++++++++++++++++++++++++++++++++++++++++++++
 integration/testing.py       | 12 ++++++
 pylxd/client.py              | 52 ++++++++++++++++++++++++++
 3 files changed, 151 insertions(+)
 create mode 100644 integration/test_profiles.py

diff --git a/integration/test_profiles.py b/integration/test_profiles.py
new file mode 100644
index 0000000..f5f1e00
--- /dev/null
+++ b/integration/test_profiles.py
@@ -0,0 +1,87 @@
+# Copyright (c) 2016 Canonical Ltd
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+import unittest
+
+from integration.testing import IntegrationTestCase
+
+
+class TestProfiles(IntegrationTestCase):
+    """Tests for `Client.profiles.`"""
+
+    def test_get(self):
+        """A profile is fetched by name."""
+        name = self.create_profile()
+        self.addCleanup(self.delete_profile, name)
+
+        profile = self.client.profiles.get(name)
+
+        self.assertEqual(name, profile.name)
+
+    def test_all(self):
+        """All profiles are fetched."""
+        name = self.create_profile()
+        self.addCleanup(self.delete_profile, name)
+
+        profiles = self.client.profiles.all()
+
+        self.assertIn(name, [profile.name for profile in profiles])
+
+    def test_create(self):
+        """A profile is created."""
+        name = 'an-profile'
+        config = {'limits.memory': '4GB'}
+        profile = self.client.profiles.create(name, config)
+        self.addCleanup(self.delete_profile, name)
+
+        self.assertEqual(name, profile.name)
+        self.assertEqual(config, profile.config)
+
+
+class TestProfile(IntegrationTestCase):
+    """Tests for `Profile`."""
+
+    def setUp(self):
+        super(TestProfile, self).setUp()
+        name = self.create_profile()
+        self.profile = self.client.profiles.get(name)
+
+    def tearDown(self):
+        super(TestProfile, self).tearDown()
+        self.delete_profile(self.profile.name)
+
+    def test_update(self):
+        """A profile is updated."""
+        self.profile.config['limits.memory'] = '16GB'
+        self.profile.update()
+
+        profile = self.client.profiles.get(self.profile.name)
+        self.assertEqual('16GB', profile.config['limits.memory'])
+
+    @unittest.skip('Not implemented in LXD')
+    def test_rename(self):
+        """A profile is renamed."""
+        name = 'a-other-profile'
+        self.addCleanup(self.delete_profile, name)
+
+        self.profile.rename(name)
+        profile = self.client.profiles.get(name)
+
+        self.assertEqual(name, profile.name)
+
+    def test_delete(self):
+        """A profile is deleted."""
+        self.profile.delete()
+
+        self.assertRaises(
+            NameError, self.client.profiles.get, self.profile.name)
diff --git a/integration/testing.py b/integration/testing.py
index 735e63e..01f0ee2 100644
--- a/integration/testing.py
+++ b/integration/testing.py
@@ -90,6 +90,18 @@ def delete_image(self, fingerprint):
         """Delete an image in lxd."""
         self.lxd.images[fingerprint].delete()
 
+    def create_profile(self):
+        name = self.generate_object_name()
+        config = {'limits.memory': '1GB'}
+        self.lxd.profiles.post(json={
+            'name': name,
+            'config': config
+            })
+        return name
+
+    def delete_profile(self, name):
+        self.lxd.profiles[name].delete()
+
     def assertCommon(self, response):
         """Assert common LXD responses.
 
diff --git a/pylxd/client.py b/pylxd/client.py
index 3f7c1f1..002b8f8 100644
--- a/pylxd/client.py
+++ b/pylxd/client.py
@@ -177,6 +177,30 @@ class _Profiles(Waitable):
     def __init__(self, client):
         self._client = client
 
+    def get(self, name):
+        response = self._client.api.profiles[name].get()
+
+        if response.status_code == 404:
+            raise NameError('No profile with name "{}"'.format(name))
+        return Profile(_client=self._client, **response.json()['metadata'])
+
+    def all(self):
+        response = self._client.api.profiles.get()
+
+        profiles = []
+        for url in response.json()['metadata']:
+            name = url.split('/')[-1]
+            profiles.append(Profile(_client=self._client, name=name))
+        return profiles
+
+    def create(self, name, config):
+        self._client.api.profiles.post(json={
+            'name': name,
+            'config': config
+            })
+
+        return self.get(name)
+
 
 class _Operations(Waitable):
     """A wrapper for working with operations."""
@@ -201,6 +225,34 @@ def marshall(self):
         return marshalled
 
 
+class Profile(Marshallable):
+
+    __slots__ = [
+        '_client',
+        'config', 'devices', 'name'
+        ]
+
+    def __init__(self, **kwargs):
+        super(Profile, self).__init__()
+        for key, value in kwargs.iteritems():
+            setattr(self, key, value)
+
+    def update(self):
+        marshalled = self.marshall()
+        # The name property cannot be updated.
+        del marshalled['name']
+
+        self._client.api.profiles[self.name].put(json=marshalled)
+
+    def rename(self, new):
+        raise NotImplementedError('LXD does not currently support renaming profiles')
+        self._client.api.profiles[self.name].post(json={'name': new})
+        self.name = new
+
+    def delete(self):
+        self._client.api.profiles[self.name].delete()
+
+
 class Image(Waitable, Marshallable):
 
     __slots__ = [

From a9fa54a6e6d0094aaefc199173f87278710f89e7 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul at eventuallyanyway.com>
Date: Mon, 15 Feb 2016 17:38:32 -0700
Subject: [PATCH 14/20] Fix the integration tests.

---
 integration/test_1_0.py        | 40 -------------------------
 integration/test_1_0_events.py | 27 -----------------
 integration/test_1_0_images.py | 66 ------------------------------------------
 integration/test_root.py       | 25 ----------------
 integration/testing.py         |  5 ++--
 5 files changed, 3 insertions(+), 160 deletions(-)
 delete mode 100644 integration/test_1_0.py
 delete mode 100644 integration/test_1_0_events.py
 delete mode 100644 integration/test_1_0_images.py
 delete mode 100644 integration/test_root.py

diff --git a/integration/test_1_0.py b/integration/test_1_0.py
deleted file mode 100644
index 1218893..0000000
--- a/integration/test_1_0.py
+++ /dev/null
@@ -1,40 +0,0 @@
-# Copyright (c) 2016 Canonical Ltd
-#
-#    Licensed under the Apache License, Version 2.0 (the "License"); you may
-#    not use this file except in compliance with the License. You may obtain
-#    a copy of the License at
-#
-#         http://www.apache.org/licenses/LICENSE-2.0
-#
-#    Unless required by applicable law or agreed to in writing, software
-#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
-#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
-#    License for the specific language governing permissions and limitations
-#    under the License.
-from integration.testing import IntegrationTestCase
-
-
-class Test10(IntegrationTestCase):
-    """Tests for /1.0"""
-
-    def test_1_0(self):
-        """Return: Dict representing server state."""
-        result = self.lxd['1.0'].get()
-
-        self.assertCommon(result)
-        self.assertEqual(200, result.status_code)
-        self.assertEqual(
-            ['api_compat', 'auth', 'config', 'environment'],
-            sorted(result.json()['metadata'].keys()))
-        self.assertEqual(
-            ['addresses', 'architectures', 'driver', 'driver_version', 'kernel',
-             'kernel_architecture', 'kernel_version', 'server', 'server_pid',
-             'server_version', 'storage', 'storage_version'],
-            sorted(result.json()['metadata']['environment'].keys()))
-
-    def test_1_0_PUT(self):
-        """Return: standard return value or standard error."""
-        result = self.lxd['1.0'].put(json={'config': {'core.trust_password': 'test'}})
-
-        self.assertCommon(result)
-        self.assertEqual(200, result.status_code)
diff --git a/integration/test_1_0_events.py b/integration/test_1_0_events.py
deleted file mode 100644
index f95831e..0000000
--- a/integration/test_1_0_events.py
+++ /dev/null
@@ -1,27 +0,0 @@
-# Copyright (c) 2016 Canonical Ltd
-#
-#    Licensed under the Apache License, Version 2.0 (the "License"); you may
-#    not use this file except in compliance with the License. You may obtain
-#    a copy of the License at
-#
-#         http://www.apache.org/licenses/LICENSE-2.0
-#
-#    Unless required by applicable law or agreed to in writing, software
-#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
-#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
-#    License for the specific language governing permissions and limitations
-#    under the License.
-from integration.testing import IntegrationTestCase
-
-
-class Test10Events(IntegrationTestCase):
-    """Tests for /1.0/events."""
-
-    def test_1_0_events(self):
-        """Return: none (never ending flow of events)."""
-        # XXX: rockstar (14 Jan 2016) - This returns a 400 in pylxd, because
-        # websockets. I plan to sort this integration test out later, but nova-lxd
-        # does not use websockets, so I'll wait a bit on that.
-        result = self.lxd['1.0']['events'].get(params={'type': 'operation,logging'})
-
-        self.assertEqual(400, result.status_code)
diff --git a/integration/test_1_0_images.py b/integration/test_1_0_images.py
deleted file mode 100644
index bdc8956..0000000
--- a/integration/test_1_0_images.py
+++ /dev/null
@@ -1,66 +0,0 @@
-# Copyright (c) 2016 Canonical Ltd
-#
-#    Licensed under the Apache License, Version 2.0 (the "License"); you may
-#    not use this file except in compliance with the License. You may obtain
-#    a copy of the License at
-#
-#         http://www.apache.org/licenses/LICENSE-2.0
-#
-#    Unless required by applicable law or agreed to in writing, software
-#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
-#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
-#    License for the specific language governing permissions and limitations
-#    under the License.
-from integration.testing import IntegrationTestCase
-
-
-class ImageTestCase(IntegrationTestCase):
-    """An Image test case."""
-
-    def setUp(self):
-        super(ImageTestCase, self).setUp()
-        self.fingerprint, _ = self.create_image()
-
-
-class Test10ImageExport(ImageTestCase):
-    """Tests for /1.0/images/<fingerprint>/export."""
-
-    def test_1_0_images_export(self):
-        """Return: dict representing an image properties."""
-        response = self.lxd['1.0'].images[self.fingerprint].export.get()
-
-        self.assertEqual(200, response.status_code)
-
-
-class Test10ImageSecret(ImageTestCase):
-    """Tests for /1.0/images/<fingerprint>/secret."""
-
-    def test_1_0_images_secret(self):
-        """Return: dict representing an image properties."""
-        response = self.lxd['1.0'].images[self.fingerprint].secret.post({
-            'name': 'abcdef'
-            })
-
-        self.assertEqual(202, response.status_code)
-
-
-class Test10ImageAliases(IntegrationTestCase):
-    """Tests for /1.0/images/aliases."""
-
-    def test_1_0_images_aliases(self):
-        """Return: list of URLs for images this server publishes."""
-        response = self.lxd['1.0'].images.aliases.get()
-
-        self.assertEqual(200, response.status_code)
-
-    def test_1_0_images_aliases_POST(self):
-        """Return: list of URLs for images this server publishes."""
-        fingerprint, _ = self.create_image()
-        alias = 'test-alias'
-        self.addCleanup(self.delete_image, alias)
-        response = self.lxd['1.0'].images.aliases.post(json={
-            'target': fingerprint,
-            'name': alias
-            })
-
-        self.assertEqual(200, response.status_code)
diff --git a/integration/test_root.py b/integration/test_root.py
deleted file mode 100644
index aeae2ae..0000000
--- a/integration/test_root.py
+++ /dev/null
@@ -1,25 +0,0 @@
-# Copyright (c) 2016 Canonical Ltd
-#
-#    Licensed under the Apache License, Version 2.0 (the "License"); you may
-#    not use this file except in compliance with the License. You may obtain
-#    a copy of the License at
-#
-#         http://www.apache.org/licenses/LICENSE-2.0
-#
-#    Unless required by applicable law or agreed to in writing, software
-#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
-#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
-#    License for the specific language governing permissions and limitations
-#    under the License.
-from integration.testing import IntegrationTestCase
-
-
-class TestRoot(IntegrationTestCase):
-    """Tests for /"""
-
-    def test_root(self):
-        """Return: list of supported API endpoint URLs."""
-        result = self.lxd.get()
-
-        self.assertCommon(result)
-        self.assertEqual(200, result.status_code)
diff --git a/integration/testing.py b/integration/testing.py
index 01f0ee2..e6e3cfe 100644
--- a/integration/testing.py
+++ b/integration/testing.py
@@ -27,13 +27,14 @@ def setUp(self):
         self.lxd = self.client.api
 
     def generate_object_name(self):
-        test = self.id().split('.')[-1]
+        # Underscores are not allowed in container names.
+        test = self.id().split('.')[-1].replace('_', '')
         rando = str(uuid.uuid1()).split('-')[-1]
         return '{}-{}'.format(test, rando)
 
     def create_container(self):
         """Create a container in lxd."""
-        name = self._generate_object_name()
+        name = self.generate_object_name()
         machine = {
             'name': name,
             'architecture': 2,

From 16d9662383cd31342db55b5a42d491860d666cb0 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul at eventuallyanyway.com>
Date: Mon, 15 Feb 2016 17:46:03 -0700
Subject: [PATCH 15/20] Move Container to its final spot.

---
 pylxd/client.py    | 161 +++--------------------------------------------------
 pylxd/container.py | 123 ++++++++++++++++++++++++++++++++++++++++
 pylxd/mixin.py     |  37 ++++++++++++
 3 files changed, 169 insertions(+), 152 deletions(-)
 create mode 100644 pylxd/mixin.py

diff --git a/pylxd/client.py b/pylxd/client.py
index 002b8f8..f9e3553 100644
--- a/pylxd/client.py
+++ b/pylxd/client.py
@@ -18,6 +18,9 @@
 import requests
 import requests_unixsocket
 
+from pylxd import mixin
+from pylxd.container import Container
+
 requests_unixsocket.monkeypatch()
 
 
@@ -89,20 +92,7 @@ def __init__(self, endpoint=None, version='1.0'):
         self.operations = _Operations(self)
 
 
-class Waitable(object):
-
-    def get_operation(self, operation_id):
-        if operation_id.startswith('/'):
-            operation_id = operation_id.split('/')[-1]
-        return self._client.operations.get(operation_id)
-
-    def wait_for_operation(self, operation_id):
-        operation = self.get_operation(operation_id)
-        operation.wait()
-        return operation
-
-
-class _Containers(Waitable):
+class _Containers(mixin.Waitable):
     """A wrapper for working with containers."""
 
     def __init__(self, client):
@@ -134,7 +124,7 @@ def create(self, config, wait=False):
         return Container(name=config['name'])
 
 
-class _Images(Waitable):
+class _Images(mixin.Waitable):
     """A wrapper for working with images."""
 
     def __init__(self, client):
@@ -171,7 +161,7 @@ def create(self, image_data, public=False, wait=False):
         return self.get(fingerprint)
 
 
-class _Profiles(Waitable):
+class _Profiles(mixin.Waitable):
     """A wrapper for working with profiles."""
 
     def __init__(self, client):
@@ -202,7 +192,7 @@ def create(self, name, config):
         return self.get(name)
 
 
-class _Operations(Waitable):
+class _Operations(mixin.Waitable):
     """A wrapper for working with operations."""
 
     def __init__(self, client):
@@ -214,18 +204,7 @@ def get(self, operation_id):
         return Operation(_client=self._client, **response.json()['metadata'])
 
 
-class Marshallable(object):
-
-    def marshall(self):
-        marshalled = {}
-        for name in self.__slots__:
-            if name.startswith('_'):
-                continue
-            marshalled[name] = getattr(self, name)
-        return marshalled
-
-
-class Profile(Marshallable):
+class Profile(mixin.Marshallable):
 
     __slots__ = [
         '_client',
@@ -253,7 +232,7 @@ def delete(self):
         self._client.api.profiles[self.name].delete()
 
 
-class Image(Waitable, Marshallable):
+class Image(mixin.Waitable, mixin.Marshallable):
 
     __slots__ = [
         '_client',
@@ -277,128 +256,6 @@ def delete(self, wait=False):
             self.wait_for_operation(response.json()['operation'])
 
 
-class Container(Waitable, Marshallable):
-
-    __slots__ = [
-        '_client',
-        'architecture', 'config', 'creation_date', 'devices', 'ephemeral',
-        'expanded_config', 'expanded_devices', 'name', 'profiles', 'status'
-        ]
-
-    def __init__(self, **kwargs):
-        super(Container, self).__init__()
-        for key, value in kwargs.iteritems():
-            setattr(self, key, value)
-
-    def reload(self):
-        response = self._client.api.containers[self.name].get()
-        if response.status_code == 404:
-            raise NameError('Container named "{}" has gone away'.format(self.name))
-        for key, value in response.json()['metadata'].iteritems():
-            setattr(self, key, value)
-
-    def update(self, wait=False):
-        marshalled = self.marshall()
-        # These two properties are explicitly not allowed.
-        del marshalled['name']
-        del marshalled['status']
-
-        response = self._client.api.containers[self.name].put(
-            json=marshalled)
-
-        if wait:
-            self.wait_for_operation(response.json()['operation'])
-
-    def rename(self, name, wait=False):
-        response = self._client.api.containers[self.name].post(json={'name': name})
-
-        if wait:
-            self.wait_for_operation(response.json()['operation'])
-        self.name = name
-
-    def delete(self, wait=False):
-        response = self._client.api.containers[self.name].delete()
-
-        if wait:
-            self.wait_for_operation(response.json()['operation'])
-
-    def _set_state(self, state, timeout=30, force=True, wait=False):
-        response = self._client.api.containers[self.name].state.put(json={
-            'action': state,
-            'timeout': timeout,
-            'force': force
-            })
-        if wait:
-            self.wait_for_operation(response.json()['operation'])
-            self.reload()
-
-    def start(self, timeout=30, force=True, wait=False):
-        return self._set_state('start', timeout=timeout, force=force, wait=wait)
-
-    def stop(self, timeout=30, force=True, wait=False):
-        return self._set_state('stop', timeout=timeout, force=force, wait=wait)
-
-    def restart(self, timeout=30, force=True, wait=False):
-        return self._set_state('stop', timeout=timeout, force=force, wait=wait)
-
-    def freeze(self, timeout=30, force=True, wait=False):
-        return self._set_state('stop', timeout=timeout, force=force, wait=wait)
-
-    def unfreeze(self, timeout=30, force=True, wait=False):
-        return self._set_state('stop', timeout=timeout, force=force, wait=wait)
-
-    def snapshot(self, name, stateful=False, wait=False):
-        response = self._client.api.containers[self.name].snapshots.post(json={
-            'name': name, 'stateful': stateful})
-        if wait:
-            self.wait_for_operation(response.json()['operation'])
-
-    def list_snapshots(self):
-        response = self._client.api.containers[self.name].snapshots.get()
-        return [snapshot.split('/')[-1] for snapshot in response.json()['metadata']]
-
-    def rename_snapshot(self, old, new, wait=False):
-        response = self._client.api.containers[self.name].snapshots[old].post(json={
-            'name': new
-            })
-        if wait:
-            self.wait_for_operation(response.json()['operation'])
-
-    def delete_snapshot(self, name, wait=False):
-        response = self._client.api.containers[self.name].snapshots[name].delete()
-        if wait:
-            self.wait_for_operation(response.json()['operation'])
-
-    def get_file(self, filepath):
-        response = self._client.api.containers[self.name].files.get(
-            params={'path': filepath})
-        if response.status_code == 500:
-            # XXX: rockstar (15 Feb 2016) - This should really return a 404.
-            # I blame LXD. :)
-            raise IOError('Error reading "{}"'.format(filepath))
-        return response.content
-
-    def put_file(self, filepath, data):
-        response = self._client.api.containers[self.name].files.post(
-            params={'path': filepath}, data=data)
-        return response.status_code == 200
-
-    def execute(self, commands, environment={}):
-        # XXX: rockstar (15 Feb 2016) - This functionality is limited by
-        # design, for now. It needs to grow the ability to return web sockets
-        # and perform interactive functions.
-        if type(commands) in [str, unicode]:
-            commands = [commands]
-        response = self._client.api.containers[self.name]['exec'].post(json={
-            'command': commands,
-            'environment': environment,
-            'wait-for-websocket': False,
-            'interactive': False,
-            })
-        operation_id = response.json()['operation']
-        self.wait_for_operation(operation_id)
-
-
 class Operation(object):
 
     __slots__ = [
diff --git a/pylxd/container.py b/pylxd/container.py
index 94ddb34..6e44072 100644
--- a/pylxd/container.py
+++ b/pylxd/container.py
@@ -16,6 +16,7 @@
 
 from pylxd import base
 from pylxd import exceptions
+from pylxd import mixin
 
 
 class LXDContainer(base.LXDBase):
@@ -204,3 +205,125 @@ def snapshot_delete(self, container, snapshot):
         return self.connection.get_object('DELETE',
                                           '/1.0/containers/%s/snapshots/%s'
                                           % (container, snapshot))
+
+
+class Container(mixin.Waitable, mixin.Marshallable):
+
+    __slots__ = [
+        '_client',
+        'architecture', 'config', 'creation_date', 'devices', 'ephemeral',
+        'expanded_config', 'expanded_devices', 'name', 'profiles', 'status'
+        ]
+
+    def __init__(self, **kwargs):
+        super(Container, self).__init__()
+        for key, value in kwargs.iteritems():
+            setattr(self, key, value)
+
+    def reload(self):
+        response = self._client.api.containers[self.name].get()
+        if response.status_code == 404:
+            raise NameError('Container named "{}" has gone away'.format(self.name))
+        for key, value in response.json()['metadata'].iteritems():
+            setattr(self, key, value)
+
+    def update(self, wait=False):
+        marshalled = self.marshall()
+        # These two properties are explicitly not allowed.
+        del marshalled['name']
+        del marshalled['status']
+
+        response = self._client.api.containers[self.name].put(
+            json=marshalled)
+
+        if wait:
+            self.wait_for_operation(response.json()['operation'])
+
+    def rename(self, name, wait=False):
+        response = self._client.api.containers[self.name].post(json={'name': name})
+
+        if wait:
+            self.wait_for_operation(response.json()['operation'])
+        self.name = name
+
+    def delete(self, wait=False):
+        response = self._client.api.containers[self.name].delete()
+
+        if wait:
+            self.wait_for_operation(response.json()['operation'])
+
+    def _set_state(self, state, timeout=30, force=True, wait=False):
+        response = self._client.api.containers[self.name].state.put(json={
+            'action': state,
+            'timeout': timeout,
+            'force': force
+            })
+        if wait:
+            self.wait_for_operation(response.json()['operation'])
+            self.reload()
+
+    def start(self, timeout=30, force=True, wait=False):
+        return self._set_state('start', timeout=timeout, force=force, wait=wait)
+
+    def stop(self, timeout=30, force=True, wait=False):
+        return self._set_state('stop', timeout=timeout, force=force, wait=wait)
+
+    def restart(self, timeout=30, force=True, wait=False):
+        return self._set_state('stop', timeout=timeout, force=force, wait=wait)
+
+    def freeze(self, timeout=30, force=True, wait=False):
+        return self._set_state('stop', timeout=timeout, force=force, wait=wait)
+
+    def unfreeze(self, timeout=30, force=True, wait=False):
+        return self._set_state('stop', timeout=timeout, force=force, wait=wait)
+
+    def snapshot(self, name, stateful=False, wait=False):
+        response = self._client.api.containers[self.name].snapshots.post(json={
+            'name': name, 'stateful': stateful})
+        if wait:
+            self.wait_for_operation(response.json()['operation'])
+
+    def list_snapshots(self):
+        response = self._client.api.containers[self.name].snapshots.get()
+        return [snapshot.split('/')[-1] for snapshot in response.json()['metadata']]
+
+    def rename_snapshot(self, old, new, wait=False):
+        response = self._client.api.containers[self.name].snapshots[old].post(json={
+            'name': new
+            })
+        if wait:
+            self.wait_for_operation(response.json()['operation'])
+
+    def delete_snapshot(self, name, wait=False):
+        response = self._client.api.containers[self.name].snapshots[name].delete()
+        if wait:
+            self.wait_for_operation(response.json()['operation'])
+
+    def get_file(self, filepath):
+        response = self._client.api.containers[self.name].files.get(
+            params={'path': filepath})
+        if response.status_code == 500:
+            # XXX: rockstar (15 Feb 2016) - This should really return a 404.
+            # I blame LXD. :)
+            raise IOError('Error reading "{}"'.format(filepath))
+        return response.content
+
+    def put_file(self, filepath, data):
+        response = self._client.api.containers[self.name].files.post(
+            params={'path': filepath}, data=data)
+        return response.status_code == 200
+
+    def execute(self, commands, environment={}):
+        # XXX: rockstar (15 Feb 2016) - This functionality is limited by
+        # design, for now. It needs to grow the ability to return web sockets
+        # and perform interactive functions.
+        if type(commands) in [str, unicode]:
+            commands = [commands]
+        response = self._client.api.containers[self.name]['exec'].post(json={
+            'command': commands,
+            'environment': environment,
+            'wait-for-websocket': False,
+            'interactive': False,
+            })
+        operation_id = response.json()['operation']
+        self.wait_for_operation(operation_id)
diff --git a/pylxd/mixin.py b/pylxd/mixin.py
new file mode 100644
index 0000000..5bd8960
--- /dev/null
+++ b/pylxd/mixin.py
@@ -0,0 +1,37 @@
+# Copyright (c) 2016 Canonical Ltd
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+
+
+class Waitable(object):
+
+    def get_operation(self, operation_id):
+        if operation_id.startswith('/'):
+            operation_id = operation_id.split('/')[-1]
+        return self._client.operations.get(operation_id)
+
+    def wait_for_operation(self, operation_id):
+        operation = self.get_operation(operation_id)
+        operation.wait()
+        return operation
+
+
+class Marshallable(object):
+
+    def marshall(self):
+        marshalled = {}
+        for name in self.__slots__:
+            if name.startswith('_'):
+                continue
+            marshalled[name] = getattr(self, name)
+        return marshalled

From fafb211fd0ba9136189ec5626a159f74aebafc2d Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul at eventuallyanyway.com>
Date: Mon, 15 Feb 2016 17:48:16 -0700
Subject: [PATCH 16/20] Move Profile to its final spot

---
 pylxd/client.py   | 29 +----------------------------
 pylxd/profiles.py | 31 ++++++++++++++++++++++++++++++-
 2 files changed, 31 insertions(+), 29 deletions(-)

diff --git a/pylxd/client.py b/pylxd/client.py
index f9e3553..d54ae29 100644
--- a/pylxd/client.py
+++ b/pylxd/client.py
@@ -20,6 +20,7 @@
 
 from pylxd import mixin
 from pylxd.container import Container
+from pylxd.profiles import Profile
 
 requests_unixsocket.monkeypatch()
 
@@ -204,34 +205,6 @@ def get(self, operation_id):
         return Operation(_client=self._client, **response.json()['metadata'])
 
 
-class Profile(mixin.Marshallable):
-
-    __slots__ = [
-        '_client',
-        'config', 'devices', 'name'
-        ]
-
-    def __init__(self, **kwargs):
-        super(Profile, self).__init__()
-        for key, value in kwargs.iteritems():
-            setattr(self, key, value)
-
-    def update(self):
-        marshalled = self.marshall()
-        # The name property cannot be updated.
-        del marshalled['name']
-
-        self._client.api.profiles[self.name].put(json=marshalled)
-
-    def rename(self, new):
-        raise NotImplementedError('LXD does not currently support renaming profiles')
-        self._client.api.profiles[self.name].post(json={'name': new})
-        self.name = new
-
-    def delete(self):
-        self._client.api.profiles[self.name].delete()
-
-
 class Image(mixin.Waitable, mixin.Marshallable):
 
     __slots__ = [
diff --git a/pylxd/profiles.py b/pylxd/profiles.py
index f4fbc02..6dcff2e 100644
--- a/pylxd/profiles.py
+++ b/pylxd/profiles.py
@@ -11,10 +11,11 @@
 #    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 #    License for the specific language governing permissions and limitations
 #    under the License.
-
+# XXX: rockstar (15 Feb 2016) - This module should be renamed to 'profile'.
 import json
 
 from pylxd import base
+from pylxd import mixin
 
 
 class LXDProfile(base.LXDBase):
@@ -56,3 +57,31 @@ def profile_delete(self, profile):
         '''Delete the LXD profile'''
         return self.connection.get_status('DELETE', '/1.0/profiles/%s'
                                           % profile)
+
+
+class Profile(mixin.Marshallable):
+
+    __slots__ = [
+        '_client',
+        'config', 'devices', 'name'
+        ]
+
+    def __init__(self, **kwargs):
+        super(Profile, self).__init__()
+        for key, value in kwargs.iteritems():
+            setattr(self, key, value)
+
+    def update(self):
+        marshalled = self.marshall()
+        # The name property cannot be updated.
+        del marshalled['name']
+
+        self._client.api.profiles[self.name].put(json=marshalled)
+
+    def rename(self, new):
+        raise NotImplementedError('LXD does not currently support renaming profiles')
+        self._client.api.profiles[self.name].post(json={'name': new})
+        self.name = new
+
+    def delete(self):
+        self._client.api.profiles[self.name].delete()

From 9ed5d5d856e45d0a42e3ec7e5c930f0b7c24934f Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul at eventuallyanyway.com>
Date: Mon, 15 Feb 2016 17:49:50 -0700
Subject: [PATCH 17/20] Move Image to its final spot.

---
 pylxd/client.py | 25 +------------------------
 pylxd/image.py  | 25 +++++++++++++++++++++++++
 2 files changed, 26 insertions(+), 24 deletions(-)

diff --git a/pylxd/client.py b/pylxd/client.py
index d54ae29..a51e7b3 100644
--- a/pylxd/client.py
+++ b/pylxd/client.py
@@ -20,6 +20,7 @@
 
 from pylxd import mixin
 from pylxd.container import Container
+from pylxd.image import Image
 from pylxd.profiles import Profile
 
 requests_unixsocket.monkeypatch()
@@ -205,30 +206,6 @@ def get(self, operation_id):
         return Operation(_client=self._client, **response.json()['metadata'])
 
 
-class Image(mixin.Waitable, mixin.Marshallable):
-
-    __slots__ = [
-        '_client',
-        'aliases', 'architecture', 'created_at', 'expires_at', 'filename',
-        'fingerprint', 'properties', 'public', 'size', 'uploaded_at'
-        ]
-
-    def __init__(self, **kwargs):
-        super(Image, self).__init__()
-        for key, value in kwargs.iteritems():
-            setattr(self, key, value)
-
-    def update(self):
-        self._client.api.images[self.fingerprint].put(
-            json=self.marshall())
-
-    def delete(self, wait=False):
-        response = self._client.api.images[self.fingerprint].delete()
-
-        if wait:
-            self.wait_for_operation(response.json()['operation'])
-
-
 class Operation(object):
 
     __slots__ = [
diff --git a/pylxd/image.py b/pylxd/image.py
index 007098f..f49c58d 100644
--- a/pylxd/image.py
+++ b/pylxd/image.py
@@ -19,6 +19,7 @@
 from pylxd import base
 from pylxd import connection
 from pylxd import exceptions
+from pylxd import mixin
 
 image_architecture = {
     0: 'Unknown',
@@ -241,3 +242,27 @@ def alias_create(self, data):
     def alias_delete(self, alias):
         return self.connection.get_status('DELETE', '/1.0/images/aliases/%s'
                                           % alias)
+
+
+class Image(mixin.Waitable, mixin.Marshallable):
+
+    __slots__ = [
+        '_client',
+        'aliases', 'architecture', 'created_at', 'expires_at', 'filename',
+        'fingerprint', 'properties', 'public', 'size', 'uploaded_at'
+        ]
+
+    def __init__(self, **kwargs):
+        super(Image, self).__init__()
+        for key, value in kwargs.iteritems():
+            setattr(self, key, value)
+
+    def update(self):
+        self._client.api.images[self.fingerprint].put(
+            json=self.marshall())
+
+    def delete(self, wait=False):
+        response = self._client.api.images[self.fingerprint].delete()
+
+        if wait:
+            self.wait_for_operation(response.json()['operation'])

From cf0904ff6cad99fde1995ee95e50df15fc942d4d Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul at eventuallyanyway.com>
Date: Mon, 15 Feb 2016 17:54:50 -0700
Subject: [PATCH 18/20] Move Operation to its final place

---
 pylxd/client.py    | 17 +----------------
 pylxd/operation.py | 16 ++++++++++++++++
 2 files changed, 17 insertions(+), 16 deletions(-)

diff --git a/pylxd/client.py b/pylxd/client.py
index a51e7b3..f93a36e 100644
--- a/pylxd/client.py
+++ b/pylxd/client.py
@@ -21,6 +21,7 @@
 from pylxd import mixin
 from pylxd.container import Container
 from pylxd.image import Image
+from pylxd.operation import Operation
 from pylxd.profiles import Profile
 
 requests_unixsocket.monkeypatch()
@@ -204,19 +205,3 @@ def get(self, operation_id):
         response = self._client.api.operations[operation_id].get()
 
         return Operation(_client=self._client, **response.json()['metadata'])
-
-
-class Operation(object):
-
-    __slots__ = [
-        '_client',
-        'class', 'created_at', 'err', 'id', 'may_cancel', 'metadata',
-        'resources', 'status', 'status_code', 'updated_at']
-
-    def __init__(self, **kwargs):
-        super(Operation, self).__init__()
-        for key, value in kwargs.iteritems():
-            setattr(self, key, value)
-
-    def wait(self):
-        self._client.api.operations[self.id].wait.get()
diff --git a/pylxd/operation.py b/pylxd/operation.py
index 879be65..d0282f2 100644
--- a/pylxd/operation.py
+++ b/pylxd/operation.py
@@ -72,3 +72,19 @@ def operation_stream(self, operation, operation_secret):
 
     def operation_delete(self, operation):
         return self.connection.get_status('DELETE', operation)
+
+
+class Operation(object):
+
+    __slots__ = [
+        '_client',
+        'class', 'created_at', 'err', 'id', 'may_cancel', 'metadata',
+        'resources', 'status', 'status_code', 'updated_at']
+
+    def __init__(self, **kwargs):
+        super(Operation, self).__init__()
+        for key, value in kwargs.iteritems():
+            setattr(self, key, value)
+
+    def wait(self):
+        self._client.api.operations[self.id].wait.get()

From 1e06c43b4e07ca3e242ec6c6126bfd4d18f33994 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul at eventuallyanyway.com>
Date: Mon, 15 Feb 2016 18:18:33 -0700
Subject: [PATCH 19/20] Cleanup the convenience/wrapper classes in Client

---
 pylxd/client.py    | 155 +++++++++++++----------------------------------------
 pylxd/container.py |  28 ++++++++++
 pylxd/image.py     |  35 ++++++++++++
 pylxd/operation.py |  12 +++++
 pylxd/profiles.py  |  27 ++++++++++
 5 files changed, 138 insertions(+), 119 deletions(-)

diff --git a/pylxd/client.py b/pylxd/client.py
index f93a36e..b2d5fc3 100644
--- a/pylxd/client.py
+++ b/pylxd/client.py
@@ -11,14 +11,13 @@
 #    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 #    License for the specific language governing permissions and limitations
 #    under the License.
-import hashlib
+import functools
 import os
 import urllib
 
 import requests
 import requests_unixsocket
 
-from pylxd import mixin
 from pylxd.container import Container
 from pylxd.image import Image
 from pylxd.operation import Operation
@@ -74,7 +73,37 @@ def delete(self, *args, **kwargs):
 
 
 class Client(object):
-    """A LXD client."""
+    """A LXD client.
+
+    This client wraps all the functionality required to interact with
+    LXD, and is meant to be the sole entry point.
+    """
+
+    class Containers(object):
+        """A convenience wrapper for Container."""
+        def __init__(self, client):
+            self.get = functools.partial(Container.get, client)
+            self.all = functools.partial(Container.all, client)
+            self.create = functools.partial(Container.create, client)
+
+    class Images(object):
+        """A convenience wrapper for Image."""
+        def __init__(self, client):
+            self.get = functools.partial(Image.get, client)
+            self.all = functools.partial(Image.all, client)
+            self.create = functools.partial(Image.create, client)
+
+    class Operations(object):
+        """A convenience wrapper for Operation."""
+        def __init__(self, client):
+            self.get = functools.partial(Operation.get, client)
+
+    class Profiles(object):
+        """A convenience wrapper for Profile."""
+        def __init__(self, client):
+            self.get = functools.partial(Profile.get, client)
+            self.all = functools.partial(Profile.all, client)
+            self.create = functools.partial(Profile.create, client)
 
     def __init__(self, endpoint=None, version='1.0'):
         if endpoint is not None:
@@ -89,119 +118,7 @@ def __init__(self, endpoint=None, version='1.0'):
                 urllib.quote(path, safe='')))
         self.api = self.api[version]
 
-        self.containers = _Containers(self)
-        self.images = _Images(self)
-        self.profiles = _Profiles(self)
-        self.operations = _Operations(self)
-
-
-class _Containers(mixin.Waitable):
-    """A wrapper for working with containers."""
-
-    def __init__(self, client):
-        self._client = client
-
-    def get(self, name):
-        response = self._client.api.containers[name].get()
-
-        if response.status_code == 404:
-            raise NameError('No container named "{}"'.format(name))
-        container = Container(_client=self._client, **response.json()['metadata'])
-        return container
-
-    def all(self):
-        response = self._client.api.containers.get()
-
-        containers = []
-        for url in response.json()['metadata']:
-            name = url.split('/')[-1]
-            containers.append(Container(_client=self._client, name=name))
-        return containers
-
-    def create(self, config, wait=False):
-        response = self._client.api.containers.post(json=config)
-
-        if wait:
-            operation_id = response.json()['operation'].split('/')[-1]
-            self.wait_for_operation(operation_id)
-        return Container(name=config['name'])
-
-
-class _Images(mixin.Waitable):
-    """A wrapper for working with images."""
-
-    def __init__(self, client):
-        self._client = client
-
-    def get(self, fingerprint):
-        response = self._client.api.images[fingerprint].get()
-
-        if response.status_code == 404:
-            raise NameError('No image with fingerprint "{}"'.format(fingerprint))
-        image = Image(_client=self._client, **response.json()['metadata'])
-        return image
-
-    def all(self):
-        response = self._client.api.images.get()
-
-        images = []
-        for url in response.json()['metadata']:
-            fingerprint = url.split('/')[-1]
-            images.append(Image(_client=self._client, fingerprint=fingerprint))
-        return images
-
-    def create(self, image_data, public=False, wait=False):
-        fingerprint = hashlib.sha256(image_data).hexdigest()
-
-        headers = {}
-        if public:
-            headers['X-LXD-Public'] = '1'
-        response = self._client.api.images.post(
-            data=image_data, headers=headers)
-
-        if wait:
-            self.wait_for_operation(response.json()['operation'])
-        return self.get(fingerprint)
-
-
-class _Profiles(mixin.Waitable):
-    """A wrapper for working with profiles."""
-
-    def __init__(self, client):
-        self._client = client
-
-    def get(self, name):
-        response = self._client.api.profiles[name].get()
-
-        if response.status_code == 404:
-            raise NameError('No profile with name "{}"'.format(name))
-        return Profile(_client=self._client, **response.json()['metadata'])
-
-    def all(self):
-        response = self._client.api.profiles.get()
-
-        profiles = []
-        for url in response.json()['metadata']:
-            name = url.split('/')[-1]
-            profiles.append(Profile(_client=self._client, name=name))
-        return profiles
-
-    def create(self, name, config):
-        self._client.api.profiles.post(json={
-            'name': name,
-            'config': config
-            })
-
-        return self.get(name)
-
-
-class _Operations(mixin.Waitable):
-    """A wrapper for working with operations."""
-
-    def __init__(self, client):
-        self._client = client
-
-    def get(self, operation_id):
-        response = self._client.api.operations[operation_id].get()
-
-        return Operation(_client=self._client, **response.json()['metadata'])
+        self.containers = self.Containers(self)
+        self.images = self.Images(self)
+        self.operations = self.Operations(self)
+        self.profiles = self.Profiles(self)
diff --git a/pylxd/container.py b/pylxd/container.py
index 6e44072..407e357 100644
--- a/pylxd/container.py
+++ b/pylxd/container.py
@@ -17,6 +17,7 @@
 from pylxd import base
 from pylxd import exceptions
 from pylxd import mixin
+from pylxd.operation import Operation
 
 
 class LXDContainer(base.LXDBase):
@@ -215,6 +216,33 @@ class Container(mixin.Waitable, mixin.Marshallable):
         'expanded_config', 'expanded_devices', 'name', 'profiles', 'status'
         ]
 
+    @classmethod
+    def get(cls, client, name):
+        response = client.api.containers[name].get()
+
+        if response.status_code == 404:
+            raise NameError('No container named "{}"'.format(name))
+        container = cls(_client=client, **response.json()['metadata'])
+        return container
+
+    @classmethod
+    def all(cls, client):
+        response = client.api.containers.get()
+
+        containers = []
+        for url in response.json()['metadata']:
+            name = url.split('/')[-1]
+            containers.append(cls(_client=client, name=name))
+        return containers
+
+    @classmethod
+    def create(cls, client, config, wait=False):
+        response = client.api.containers.post(json=config)
+
+        if wait:
+            Operation.wait_for_operation(client, response.json()['operation'])
+        return cls(name=config['name'])
+
     def __init__(self, **kwargs):
         super(Container, self).__init__()
         for key, value in kwargs.iteritems():
diff --git a/pylxd/image.py b/pylxd/image.py
index f49c58d..7cdbc72 100644
--- a/pylxd/image.py
+++ b/pylxd/image.py
@@ -13,6 +13,7 @@
 #    under the License.
 from __future__ import print_function
 import datetime
+import hashlib
 import json
 from six.moves import urllib
 
@@ -20,6 +21,7 @@
 from pylxd import connection
 from pylxd import exceptions
 from pylxd import mixin
+from pylxd.operation import Operation
 
 image_architecture = {
     0: 'Unknown',
@@ -252,6 +254,39 @@ class Image(mixin.Waitable, mixin.Marshallable):
         'fingerprint', 'properties', 'public', 'size', 'uploaded_at'
         ]
 
+    @classmethod
+    def get(cls, client, fingerprint):
+        response = client.api.images[fingerprint].get()
+
+        if response.status_code == 404:
+            raise NameError('No image with fingerprint "{}"'.format(fingerprint))
+        image = Image(_client=client, **response.json()['metadata'])
+        return image
+
+    @classmethod
+    def all(cls, client):
+        response = client.api.images.get()
+
+        images = []
+        for url in response.json()['metadata']:
+            fingerprint = url.split('/')[-1]
+            images.append(Image(_client=client, fingerprint=fingerprint))
+        return images
+
+    @classmethod
+    def create(cls, client, image_data, public=False, wait=False):
+        fingerprint = hashlib.sha256(image_data).hexdigest()
+
+        headers = {}
+        if public:
+            headers['X-LXD-Public'] = '1'
+        response = client.api.images.post(
+            data=image_data, headers=headers)
+
+        if wait:
+            Operation.wait_for_operation(client, response.json()['operation'])
+        return cls.get(client, fingerprint)
+
     def __init__(self, **kwargs):
         super(Image, self).__init__()
         for key, value in kwargs.iteritems():
diff --git a/pylxd/operation.py b/pylxd/operation.py
index d0282f2..93ab00e 100644
--- a/pylxd/operation.py
+++ b/pylxd/operation.py
@@ -81,6 +81,18 @@ class Operation(object):
         'class', 'created_at', 'err', 'id', 'may_cancel', 'metadata',
         'resources', 'status', 'status_code', 'updated_at']
 
+    @classmethod
+    def wait_for_operation(cls, client, operation_id):
+        if operation_id.startswith('/'):
+            operation_id = operation_id.split('/')[-1]
+        operation = cls.get(client, operation_id)
+        operation.wait()
+
+    @classmethod
+    def get(cls, client, operation_id):
+        response = client.api.operations[operation_id].get()
+        return cls(_client=client, **response.json()['metadata'])
+
     def __init__(self, **kwargs):
         super(Operation, self).__init__()
         for key, value in kwargs.iteritems():
diff --git a/pylxd/profiles.py b/pylxd/profiles.py
index 6dcff2e..2d4ec68 100644
--- a/pylxd/profiles.py
+++ b/pylxd/profiles.py
@@ -66,6 +66,33 @@ class Profile(mixin.Marshallable):
         'config', 'devices', 'name'
         ]
 
+    @classmethod
+    def get(cls, client, name):
+        response = client.api.profiles[name].get()
+
+        if response.status_code == 404:
+            raise NameError('No profile with name "{}"'.format(name))
+        return cls(_client=client, **response.json()['metadata'])
+
+    @classmethod
+    def all(cls, client):
+        response = client.api.profiles.get()
+
+        profiles = []
+        for url in response.json()['metadata']:
+            name = url.split('/')[-1]
+            profiles.append(cls(_client=client, name=name))
+        return profiles
+
+    @classmethod
+    def create(cls, client, name, config):
+        client.api.profiles.post(json={
+            'name': name,
+            'config': config
+            })
+
+        return cls.get(client, name)
+
     def __init__(self, **kwargs):
         super(Profile, self).__init__()
         for key, value in kwargs.iteritems():

From 32006f256354747915ae9af0101cde130b250598 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul at eventuallyanyway.com>
Date: Mon, 15 Feb 2016 18:28:21 -0700
Subject: [PATCH 20/20] Fixed the APINode tests

---
 pylxd/tests/test_api.py    | 90 ----------------------------------------------
 pylxd/tests/test_client.py | 90 ++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 90 insertions(+), 90 deletions(-)
 delete mode 100644 pylxd/tests/test_api.py
 create mode 100644 pylxd/tests/test_client.py

diff --git a/pylxd/tests/test_api.py b/pylxd/tests/test_api.py
deleted file mode 100644
index 60fb7f8..0000000
--- a/pylxd/tests/test_api.py
+++ /dev/null
@@ -1,90 +0,0 @@
-# Copyright (c) 2016 Canonical Ltd
-#
-#    Licensed under the Apache License, Version 2.0 (the "License"); you may
-#    not use this file except in compliance with the License. You may obtain
-#    a copy of the License at
-#
-#         http://www.apache.org/licenses/LICENSE-2.0
-#
-#    Unless required by applicable law or agreed to in writing, software
-#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
-#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
-#    License for the specific language governing permissions and limitations
-#    under the License.
-"""Tests for pylxd.api."""
-import unittest
-
-import mock
-
-from pylxd import api
-
-
-class Test_APINode(unittest.TestCase):
-    """Tests for pylxd.api._APINode."""
-    ROOT = 'http://lxd/api'
-
-    def test_getattr(self):
-        """`__getattr__` returns a nested node."""
-        lxd = api._APINode(self.ROOT)
-
-        self.assertEqual('{}/fake'.format(self.ROOT), lxd.fake._api_endpoint)
-
-    def test_getattr_nested(self):
-        """Nested objects return a more detailed path."""
-        lxd = api._APINode(self.ROOT)  # NOQA
-
-        self.assertEqual(
-            '{}/fake/path'.format(self.ROOT),
-            lxd.fake.path._api_endpoint)
-
-    def test_getitem(self):
-        """`__getitem__` enables dynamic url parts."""
-        lxd = api._APINode(self.ROOT)  # NOQA
-
-        self.assertEqual(
-            '{}/fake/path'.format(self.ROOT),
-            lxd.fake['path']._api_endpoint)
-
-    def test_getitem_integer(self):
-        """`__getitem__` with an integer allows dynamic integer url parts."""
-        lxd = api._APINode(self.ROOT)  # NOQA
-
-        self.assertEqual(
-            '{}/fake/0'.format(self.ROOT),
-            lxd.fake[0]._api_endpoint)
-
-    @mock.patch('pylxd.api.requests.get')
-    def test_get(self, _get):
-        """`get` will make a request to the smart url."""
-        lxd = api._APINode(self.ROOT)
-
-        lxd.fake.get()
-
-        _get.assert_called_once_with('{}/{}'.format(self.ROOT, 'fake'))
-
-    @mock.patch('pylxd.api.requests.post')
-    def test_post(self, _post):
-        """`post` will POST to the smart url."""
-        lxd = api._APINode(self.ROOT)
-
-        lxd.fake.post()
-
-        _post.assert_called_once_with('{}/{}'.format(self.ROOT, 'fake'))
-
-    @mock.patch('pylxd.api.requests.put')
-    def test_put(self, _put):
-        """`put` will PUT to the smart url."""
-        lxd = api._APINode(self.ROOT)
-
-        lxd.fake.put()
-
-        _put.assert_called_once_with('{}/{}'.format(self.ROOT, 'fake'))
-
-    @mock.patch('pylxd.api.requests.delete')
-    def test_delete(self, _delete):
-        """`delete` will DELETE to the smart url."""
-        lxd = api._APINode(self.ROOT)
-
-        lxd.fake.delete()
-
-        _delete.assert_called_once_with('{}/{}'.format(self.ROOT, 'fake'))
diff --git a/pylxd/tests/test_client.py b/pylxd/tests/test_client.py
new file mode 100644
index 0000000..821c59d
--- /dev/null
+++ b/pylxd/tests/test_client.py
@@ -0,0 +1,90 @@
+# Copyright (c) 2016 Canonical Ltd
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+"""Tests for pylxd.client."""
+import unittest
+
+import mock
+
+from pylxd import client
+
+
+class Test_APINode(unittest.TestCase):
+    """Tests for pylxd.client._APINode."""
+    ROOT = 'http://lxd/api'
+
+    def test_getattr(self):
+        """`__getattr__` returns a nested node."""
+        lxd = client._APINode(self.ROOT)
+
+        self.assertEqual('{}/fake'.format(self.ROOT), lxd.fake._api_endpoint)
+
+    def test_getattr_nested(self):
+        """Nested objects return a more detailed path."""
+        lxd = client._APINode(self.ROOT)  # NOQA
+
+        self.assertEqual(
+            '{}/fake/path'.format(self.ROOT),
+            lxd.fake.path._api_endpoint)
+
+    def test_getitem(self):
+        """`__getitem__` enables dynamic url parts."""
+        lxd = client._APINode(self.ROOT)  # NOQA
+
+        self.assertEqual(
+            '{}/fake/path'.format(self.ROOT),
+            lxd.fake['path']._api_endpoint)
+
+    def test_getitem_integer(self):
+        """`__getitem__` with an integer allows dynamic integer url parts."""
+        lxd = client._APINode(self.ROOT)  # NOQA
+
+        self.assertEqual(
+            '{}/fake/0'.format(self.ROOT),
+            lxd.fake[0]._api_endpoint)
+
+    @mock.patch('pylxd.client.requests.get')
+    def test_get(self, _get):
+        """`get` will make a request to the smart url."""
+        lxd = client._APINode(self.ROOT)
+
+        lxd.fake.get()
+
+        _get.assert_called_once_with('{}/{}'.format(self.ROOT, 'fake'))
+
+    @mock.patch('pylxd.client.requests.post')
+    def test_post(self, _post):
+        """`post` will POST to the smart url."""
+        lxd = client._APINode(self.ROOT)
+
+        lxd.fake.post()
+
+        _post.assert_called_once_with('{}/{}'.format(self.ROOT, 'fake'))
+
+    @mock.patch('pylxd.client.requests.put')
+    def test_put(self, _put):
+        """`put` will PUT to the smart url."""
+        lxd = client._APINode(self.ROOT)
+
+        lxd.fake.put()
+
+        _put.assert_called_once_with('{}/{}'.format(self.ROOT, 'fake'))
+
+    @mock.patch('pylxd.client.requests.delete')
+    def test_delete(self, _delete):
+        """`delete` will DELETE to the smart url."""
+        lxd = client._APINode(self.ROOT)
+
+        lxd.fake.delete()
+
+        _delete.assert_called_once_with('{}/{}'.format(self.ROOT, 'fake'))


More information about the lxc-devel mailing list