[lxc-devel] [pylxd/master] Image export

rockstar on Github lxc-bot at linuxcontainers.org
Fri Jun 3 23:53:42 UTC 2016


A non-text attachment was scrubbed...
Name: not available
Type: text/x-mailbox
Size: 467 bytes
Desc: not available
URL: <http://lists.linuxcontainers.org/pipermail/lxc-devel/attachments/20160603/01bb5713/attachment.bin>
-------------- next part --------------
From 28daa60c1176519c819016f1d7af91e678984a6e Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul.hummer at canonical.com>
Date: Fri, 3 Jun 2016 13:32:05 -0600
Subject: [PATCH 01/10] Add response assertions for lxd

This exposed a bug where deleting a running container doesn't
report that the deletion didn't occur.
---
 integration/test_containers.py |  2 +-
 integration/test_images.py     | 10 ++++++--
 integration/testing.py         | 29 +++++++++++++++++++----
 pylxd/client.py                | 54 +++++++++++++++++++++++++++++++++++++-----
 pylxd/container.py             | 42 ++++++++++++++++----------------
 pylxd/exceptions.py            | 33 ++++++++++++++++++++++++--
 pylxd/image.py                 | 25 +++++++++++--------
 pylxd/profile.py               | 19 +++++++++------
 8 files changed, 162 insertions(+), 52 deletions(-)

diff --git a/integration/test_containers.py b/integration/test_containers.py
index 609f19b..156f50e 100644
--- a/integration/test_containers.py
+++ b/integration/test_containers.py
@@ -132,7 +132,7 @@ def test_snapshot(self):
     def test_put_get_file(self):
         """A file is written to the container and then read."""
         filepath = '/tmp/an_file'
-        data = 'abcdef'
+        data = b'abcdef'
 
         retval = self.container.put_file(filepath, data)
 
diff --git a/integration/test_images.py b/integration/test_images.py
index a16932e..4e2d94f 100644
--- a/integration/test_images.py
+++ b/integration/test_images.py
@@ -11,6 +11,8 @@
 #    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 time
+
 from pylxd import exceptions
 
 from integration.testing import create_busybox_image, IntegrationTestCase
@@ -33,6 +35,9 @@ def test_all(self):
         fingerprint, _ = self.create_image()
         self.addCleanup(self.delete_image, fingerprint)
 
+        # XXX: rockstar (02 Jun 2016) - This seems to have a failure
+        # of some sort. This is a hack.
+        time.sleep(5)
         images = self.client.images.all()
 
         self.assertIn(fingerprint, [image.fingerprint for image in images])
@@ -42,8 +47,9 @@ def test_create(self):
         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)
+        with open(path, 'rb') as f:
+            data = f.read()
+            image = self.client.images.create(data, wait=True)
 
         self.assertEqual(fingerprint, image.fingerprint)
 
diff --git a/integration/testing.py b/integration/testing.py
index c8ee7be..a6ea93b 100644
--- a/integration/testing.py
+++ b/integration/testing.py
@@ -14,6 +14,7 @@
 import unittest
 import uuid
 
+from pylxd import exceptions
 from pylxd.client import Client
 from integration.busybox import create_busybox_image
 
@@ -59,9 +60,19 @@ def delete_container(self, name, enforce=False):
         # 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['containers'][name].delete()
-        while enforce and result.status_code == 404 and count < 10:
+        try:
             result = self.lxd['containers'][name].delete()
+        except exceptions.LXDAPIException as e:
+            if e.response.status_code in (400, 404):
+                return
+            raise
+        while enforce and result.status_code == 404 and count < 10:
+            try:
+                result = self.lxd['containers'][name].delete()
+            except exceptions.LXDAPIException as e:
+                if e.response.status_code in (400, 404):
+                    return
+                raise
             count += 1
         try:
             operation_uuid = result.json()['operation'].split('/')[-1]
@@ -92,7 +103,12 @@ def create_image(self):
 
     def delete_image(self, fingerprint):
         """Delete an image in lxd."""
-        self.lxd.images[fingerprint].delete()
+        try:
+            self.lxd.images[fingerprint].delete()
+        except exceptions.LXDAPIException as e:
+            if e.response.status_code == 404:
+                return
+            raise
 
     def create_profile(self):
         """Create a profile."""
@@ -106,7 +122,12 @@ def create_profile(self):
 
     def delete_profile(self, name):
         """Delete a profile."""
-        self.lxd.profiles[name].delete()
+        try:
+            self.lxd.profiles[name].delete()
+        except exceptions.LXDAPIException as e:
+            if e.response.status_code == 404:
+                return
+            raise
 
     def assertCommon(self, response):
         """Assert common LXD responses.
diff --git a/pylxd/client.py b/pylxd/client.py
index cfec79a..b8ea444 100644
--- a/pylxd/client.py
+++ b/pylxd/client.py
@@ -27,8 +27,7 @@
 
 
 class _APINode(object):
-    """An api node object.
-    """
+    """An api node object."""
 
     def __init__(self, api_endpoint, cert=None, verify=True):
         self._api_endpoint = api_endpoint
@@ -50,21 +49,64 @@ def __getitem__(self, item):
             '{}/{}'.format(self._api_endpoint, item),
             cert=self.session.cert, verify=self.session.verify)
 
+    def _assert_response(self, response, allowed_status_codes=(200,)):
+        """Assert properties of the response.
+
+        LXD's API clearly defines specific responses. If the API
+        response is something unexpected (i.e. an error), then
+        we need to raise an exception and have the call points
+        handle the errors or just let the issue be raised to the
+        user.
+        """
+        if response.status_code not in allowed_status_codes:
+            raise exceptions.LXDAPIException(response)
+
+        try:
+            data = response.json()
+        except ValueError:
+            # Not a JSON response
+            return
+
+        if response.status_code == 200:
+            # Synchronous request
+            try:
+                if data['type'] != 'sync':
+                    raise exceptions.LXDAPIException(response)
+            except KeyError:
+                # Missing 'type' in response
+                raise exceptions.LXDAPIException(response)
+
+        if response.status_code == 202:
+            try:
+                if data['type'] != 'async':
+                    raise exceptions.LXDAPIException(response)
+            except KeyError:
+                # Missing 'type' in response
+                raise exceptions.LXDAPIException(response)
+
     def get(self, *args, **kwargs):
         """Perform an HTTP GET."""
-        return self.session.get(self._api_endpoint, *args, **kwargs)
+        response = self.session.get(self._api_endpoint, *args, **kwargs)
+        self._assert_response(response)
+        return response
 
     def post(self, *args, **kwargs):
         """Perform an HTTP POST."""
-        return self.session.post(self._api_endpoint, *args, **kwargs)
+        response = self.session.post(self._api_endpoint, *args, **kwargs)
+        self._assert_response(response, allowed_status_codes=(200, 202))
+        return response
 
     def put(self, *args, **kwargs):
         """Perform an HTTP PUT."""
-        return self.session.put(self._api_endpoint, *args, **kwargs)
+        response = self.session.put(self._api_endpoint, *args, **kwargs)
+        self._assert_response(response, allowed_status_codes=(200, 202))
+        return response
 
     def delete(self, *args, **kwargs):
         """Perform an HTTP delete."""
-        return self.session.delete(self._api_endpoint, *args, **kwargs)
+        response = self.session.delete(self._api_endpoint, *args, **kwargs)
+        self._assert_response(response, allowed_status_codes=(200, 202))
+        return response
 
 
 class Client(object):
diff --git a/pylxd/container.py b/pylxd/container.py
index 4620f58..21a5c69 100644
--- a/pylxd/container.py
+++ b/pylxd/container.py
@@ -44,16 +44,15 @@ def put(self, filepath, data):
             return response.status_code == 200
 
         def get(self, filepath):
-            response = self._client.api.containers[
-                self._container.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 exceptions.NotFound({
-                    'error': '{} not found in container {}'.format(
-                        filepath, self._container.name
-                        )})
+            try:
+                response = self._client.api.containers[
+                    self._container.name].files.get(
+                    params={'path': filepath})
+            except exceptions.LXDAPIException as e:
+                # LXD 2.0.3+ return 404, not 500,
+                if e.response.status_code in (500, 404):
+                    raise exceptions.NotFound()
+                raise
             return response.content
 
     __slots__ = [
@@ -65,10 +64,13 @@ def get(self, filepath):
     @classmethod
     def get(cls, client, name):
         """Get a container by name."""
-        response = client.api.containers[name].get()
+        try:
+            response = client.api.containers[name].get()
+        except exceptions.LXDAPIException as e:
+            if e.response.status_code == 404:
+                raise exceptions.NotFound()
+            raise
 
-        if response.status_code == 404:
-            raise exceptions.NotFound(response.json())
         container = cls(_client=client, **response.json()['metadata'])
         return container
 
@@ -95,7 +97,7 @@ def create(cls, client, config, wait=False):
         response = client.api.containers.post(json=config)
 
         if response.status_code != 202:
-            raise exceptions.CreateFailed(response.json())
+            raise exceptions.CreateFailed(response)
         if wait:
             Operation.wait_for_operation(client, response.json()['operation'])
         return cls(name=config['name'], _client=client)
@@ -151,8 +153,6 @@ def delete(self, wait=False):
         """Delete the container."""
         response = self._client.api.containers[self.name].delete()
 
-        if response.status_code != 202:
-            raise RuntimeError('Error deleting instance {}'.format(self.name))
         if wait:
             self.wait_for_operation(response.json()['operation'])
 
@@ -260,10 +260,12 @@ class Snapshot(mixin.Waitable, mixin.Marshallable):
 
     @classmethod
     def get(cls, client, container, name):
-        response = client.api.containers[container.name].snapshots[name].get()
-
-        if response.status_code == 404:
-            raise exceptions.NotFound(response.json())
+        try:
+            response = client.api.containers[container.name].snapshots[name].get()
+        except exceptions.LXDAPIException as e:
+            if e.response.status_code == 404:
+                raise exceptions.NotFound()
+            raise
 
         snapshot = cls(
             _client=client, _container=container,
diff --git a/pylxd/exceptions.py b/pylxd/exceptions.py
index 9e243dd..8fe70a1 100644
--- a/pylxd/exceptions.py
+++ b/pylxd/exceptions.py
@@ -9,12 +9,38 @@ def __str__(self):
         return "LXD client certificates are not trusted."""
 
 
+class LXDAPIException(Exception):
+    """A generic exception for representing unexpected LXD API responses.
+
+    LXD API responses are clearly documented, and are either a standard
+    return value, and background operation, or an error. This exception
+    is raised on an error case, or when the response status code is
+    not expected for the response.
+    """
+
+    def __init__(self, response):
+        super(LXDAPIException, self).__init__()
+        self.response = response
+
+    def __str__(self):
+        try:
+            data = self.response.json()
+            return data['error']
+        except (ValueError, KeyError):
+            pass
+        return self.response.content.decode('utf-8')
+
+
 class _LXDAPIException(Exception):
     """A LXD API Exception.
 
     An exception representing an issue in the LXD API. It
     contains the error information returned from LXD.
 
+    This exception type should be phased out, with the exception being
+    raised at a lower level (i.e. where we actually talk to the LXD
+    API, not in our pylxd logic).
+
     DO NOT CATCH THIS EXCEPTION DIRECTLY.
     """
 
@@ -25,8 +51,11 @@ def __str__(self):
         return self.data.get('error')
 
 
-class NotFound(_LXDAPIException):
-    """Generic get failure exception."""
+class NotFound(Exception):
+    """Generic NotFound exception."""
+
+    def __str__(self):
+        return 'Object not found'
 
 
 class CreateFailed(_LXDAPIException):
diff --git a/pylxd/image.py b/pylxd/image.py
index 535c083..104d91f 100644
--- a/pylxd/image.py
+++ b/pylxd/image.py
@@ -30,11 +30,14 @@ class Image(mixin.Waitable, mixin.Marshallable):
     @classmethod
     def get(cls, client, fingerprint):
         """Get an image."""
-        response = client.api.images[fingerprint].get()
+        try:
+            response = client.api.images[fingerprint].get()
+        except exceptions.LXDAPIException as e:
+            if e.response.status_code == 404:
+                raise exceptions.NotFound()
+            raise
 
-        if response.status_code == 404:
-            raise exceptions.NotFound(response.json())
-        image = Image(_client=client, **response.json()['metadata'])
+        image = cls(_client=client, **response.json()['metadata'])
         return image
 
     @classmethod
@@ -45,7 +48,7 @@ def all(cls, client):
         images = []
         for url in response.json()['metadata']:
             fingerprint = url.split('/')[-1]
-            images.append(Image(_client=client, fingerprint=fingerprint))
+            images.append(cls(_client=client, fingerprint=fingerprint))
         return images
 
     @classmethod
@@ -64,7 +67,7 @@ def create(cls, client, image_data, public=False, wait=False):
 
         if wait:
             Operation.wait_for_operation(client, response.json()['operation'])
-        return cls.get(client, fingerprint)
+        return cls(_client=client, fingerprint=fingerprint)
 
     def __init__(self, **kwargs):
         super(Image, self).__init__()
@@ -89,10 +92,12 @@ def delete(self, wait=False):
 
     def fetch(self):
         """Fetch the object from LXD, populating attributes."""
-        response = self._client.api.images[self.fingerprint].get()
-
-        if response.status_code == 404:
-            raise exceptions.NotFound(response.json())
+        try:
+            response = self._client.api.images[self.fingerprint].get()
+        except exceptions.LXDAPIException as e:
+            if e.response.status_code == 404:
+                raise exceptions.NotFound()
+            raise
 
         for key, val in six.iteritems(response.json()['metadata']):
             setattr(self, key, val)
diff --git a/pylxd/profile.py b/pylxd/profile.py
index 749aa0d..7cf1024 100644
--- a/pylxd/profile.py
+++ b/pylxd/profile.py
@@ -26,10 +26,13 @@ class Profile(mixin.Marshallable):
     @classmethod
     def get(cls, client, name):
         """Get a profile."""
-        response = client.api.profiles[name].get()
+        try:
+            response = client.api.profiles[name].get()
+        except exceptions.LXDAPIException as e:
+            if e.response.status_code == 404:
+                raise exceptions.NotFound()
+            raise
 
-        if response.status_code == 404:
-            raise exceptions.NotFound(response.json())
         return cls(_client=client, **response.json()['metadata'])
 
     @classmethod
@@ -85,10 +88,12 @@ def delete(self):
 
     def fetch(self):
         """Fetch the object from LXD, populating attributes."""
-        response = self._client.api.profiles[self.name].get()
-
-        if response.status_code == 404:
-            raise exceptions.NotFound(response.json())
+        try:
+            response = self._client.api.profiles[self.name].get()
+        except exceptions.LXDAPIException as e:
+            if e.response.status_code == 404:
+                raise exceptions.NotFound()
+            raise
 
         for key, val in six.iteritems(response.json()['metadata']):
             setattr(self, key, val)

From 0b4a0a323701cafd690e20a50d26e64e0e91515e Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul.hummer at canonical.com>
Date: Fri, 3 Jun 2016 14:27:45 -0600
Subject: [PATCH 02/10] Fix the unit tests

---
 pylxd/container.py            |  17 ++++---
 pylxd/image.py                |  10 ++--
 pylxd/profile.py              |   8 +--
 pylxd/tests/mock_lxd.py       | 115 ++++++++++++++++++++++++++++--------------
 pylxd/tests/test_client.py    |  24 +++++++--
 pylxd/tests/test_container.py |   8 +--
 6 files changed, 121 insertions(+), 61 deletions(-)

diff --git a/pylxd/container.py b/pylxd/container.py
index 21a5c69..39524f7 100644
--- a/pylxd/container.py
+++ b/pylxd/container.py
@@ -94,10 +94,11 @@ def all(cls, client):
     @classmethod
     def create(cls, client, config, wait=False):
         """Create a new container config."""
-        response = client.api.containers.post(json=config)
+        try:
+            response = client.api.containers.post(json=config)
+        except exceptions.LXDAPIException as e:
+            raise exceptions.CreateFailed(e.response)
 
-        if response.status_code != 202:
-            raise exceptions.CreateFailed(response)
         if wait:
             Operation.wait_for_operation(client, response.json()['operation'])
         return cls(name=config['name'], _client=client)
@@ -112,10 +113,12 @@ def __init__(self, **kwargs):
 
     def fetch(self):
         """Reload the container information."""
-        response = self._client.api.containers[self.name].get()
-        if response.status_code == 404:
-            raise NameError(
-                'Container named "{}" has gone away'.format(self.name))
+        try:
+            response = self._client.api.containers[self.name].get()
+        except exceptions.LXDAPIException as e:
+            if e.response.status_code == 404:
+                raise exceptions.NotFound()
+            raise
         for key, value in six.iteritems(response.json()['metadata']):
             setattr(self, key, value)
     # XXX: rockstar (28 Mar 2016) - This method was named improperly
diff --git a/pylxd/image.py b/pylxd/image.py
index 104d91f..82b965d 100644
--- a/pylxd/image.py
+++ b/pylxd/image.py
@@ -59,11 +59,11 @@ def create(cls, client, image_data, public=False, wait=False):
         headers = {}
         if public:
             headers['X-LXD-Public'] = '1'
-        response = client.api.images.post(
-            data=image_data, headers=headers)
-
-        if response.status_code != 202:
-            raise exceptions.CreateFailed(response.json())
+        try:
+            response = client.api.images.post(
+                data=image_data, headers=headers)
+        except exceptions.LXDAPIException as e:
+            raise exceptions.CreateFailed(e.response.json())
 
         if wait:
             Operation.wait_for_operation(client, response.json()['operation'])
diff --git a/pylxd/profile.py b/pylxd/profile.py
index 7cf1024..1bcc0e0 100644
--- a/pylxd/profile.py
+++ b/pylxd/profile.py
@@ -54,10 +54,10 @@ def create(cls, client, name, config=None, devices=None):
             profile['config'] = config
         if devices is not None:
             profile['devices'] = devices
-        response = client.api.profiles.post(json=profile)
-
-        if response.status_code is not 200:
-            raise exceptions.CreateFailed(response.json())
+        try:
+            client.api.profiles.post(json=profile)
+        except exceptions.LXDAPIException as e:
+            raise exceptions.CreateFailed(e.response.json())
 
         return cls.get(client, name)
 
diff --git a/pylxd/tests/mock_lxd.py b/pylxd/tests/mock_lxd.py
index 07c0f39..6df21c9 100644
--- a/pylxd/tests/mock_lxd.py
+++ b/pylxd/tests/mock_lxd.py
@@ -3,32 +3,43 @@
 
 def containers_POST(request, context):
     context.status_code = 202
-    return json.dumps({'operation': 'operation-abc'})
+    return json.dumps({
+        'type': 'async',
+        'operation': 'operation-abc'})
 
 
 def container_DELETE(request, context):
     context.status_code = 202
-    return json.dumps({'operation': 'operation-abc'})
+    return json.dumps({
+        'type': 'async',
+        'operation': 'operation-abc'})
 
 
 def images_POST(request, context):
     context.status_code = 202
-    return json.dumps({'metadata': {}})
+    return json.dumps({
+        'type': 'async',
+        'metadata': {}})
 
 
 def profiles_POST(request, context):
     context.status_code = 200
-    return json.dumps({'metadata': {}})
+    return json.dumps({
+        'type': 'sync',
+        'metadata': {}})
 
 
 def snapshot_DELETE(request, context):
     context.status_code = 202
-    return json.dumps({'operation': 'operation-abc'})
+    return json.dumps({
+        'type': 'async',
+        'operation': 'operation-abc'})
 
 
 def profile_GET(request, context):
     name = request.path.split('/')[-1]
     return json.dumps({
+        'type': 'sync',
         'metadata': {
             'name': name,
             'description': 'An description',
@@ -41,8 +52,10 @@ def profile_GET(request, context):
 RULES = [
     # General service endpoints
     {
-        'text': json.dumps({'metadata': {'auth': 'trusted',
-                                         'environment': {}}}),
+        'text': json.dumps({
+            'type': 'sync',
+            'metadata': {'auth': 'trusted',
+                         'environment': {}}}),
         'method': 'GET',
         'url': r'^http://pylxd.test/1.0$',
     },
@@ -50,9 +63,11 @@ def profile_GET(request, context):
 
     # Containers
     {
-        'text': json.dumps({'metadata': [
-            'http://pylxd.test/1.0/containers/an-container',
-        ]}),
+        'text': json.dumps({
+            'type': 'sync',
+            'metadata': [
+                'http://pylxd.test/1.0/containers/an-container',
+            ]}),
         'method': 'GET',
         'url': r'^http://pylxd.test/1.0/containers$',
     },
@@ -62,28 +77,36 @@ def profile_GET(request, context):
         'url': r'^http://pylxd.test/1.0/containers$',
     },
     {
-        'text': json.dumps({'metadata': {
-            'name': 'an-container',
-            'ephemeral': True,
-        }}),
+        'text': json.dumps({
+            'type': 'sync',
+            'metadata': {
+                'name': 'an-container',
+                'ephemeral': True,
+            }}),
         'method': 'GET',
         'url': r'^http://pylxd.test/1.0/containers/an-container$',
     },
     {
-        'text': json.dumps({'metadata': {
-            'status': 'Running',
-            'status_code': 103,
-        }}),
+        'text': json.dumps({
+            'type': 'sync',
+            'metadata': {
+                'status': 'Running',
+                'status_code': 103,
+            }}),
         'method': 'GET',
         'url': r'^http://pylxd.test/1.0/containers/an-container/state$',  # NOQA
     },
     {
-        'text': json.dumps({'operation': 'operation-abc'}),
+        'text': json.dumps({
+            'type': 'sync',  # This should be async
+            'operation': 'operation-abc'}),
         'method': 'POST',
         'url': r'^http://pylxd.test/1.0/containers/an-container$',
     },
     {
-        'text': json.dumps({'operation': 'operation-abc'}),
+        'text': json.dumps({
+            'type': 'sync',  # This should be async
+            'operation': 'operation-abc'}),
         'method': 'PUT',
         'url': r'^http://pylxd.test/1.0/containers/an-container$',
     },
@@ -96,27 +119,35 @@ def profile_GET(request, context):
 
     # Container Snapshots
     {
-        'text': json.dumps({'metadata': [
-            '/1.0/containers/an_container/snapshots/an-snapshot',
-        ]}),
+        'text': json.dumps({
+            'type': 'sync',
+            'metadata': [
+                '/1.0/containers/an_container/snapshots/an-snapshot',
+            ]}),
         'method': 'GET',
         'url': r'^http://pylxd.test/1.0/containers/an-container/snapshots$',  # NOQA
     },
     {
-        'text': json.dumps({'operation': 'operation-abc'}),
+        'text': json.dumps({
+            'type': 'sync',  # This should be async
+            'operation': 'operation-abc'}),
         'method': 'POST',
         'url': r'^http://pylxd.test/1.0/containers/an-container/snapshots$',  # NOQA
     },
     {
-        'text': json.dumps({'metadata': {
-            'name': 'an_container/an-snapshot',
-            'stateful': False,
-        }}),
+        'text': json.dumps({
+            'type': 'sync',
+            'metadata': {
+                'name': 'an_container/an-snapshot',
+                'stateful': False,
+            }}),
         'method': 'GET',
         'url': r'^http://pylxd.test/1.0/containers/an-container/snapshots/an-snapshot$',  # NOQA
     },
     {
-        'text': json.dumps({'operation': 'operation-abc'}),
+        'text': json.dumps({
+            'type': 'sync',  # This should be async
+            'operation': 'operation-abc'}),
         'method': 'POST',
         'url': r'^http://pylxd.test/1.0/containers/an-container/snapshots/an-snapshot$',  # NOQA
     },
@@ -142,9 +173,11 @@ def profile_GET(request, context):
 
     # Images
     {
-        'text': json.dumps({'metadata': [
-            'http://pylxd.test/1.0/images/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',  # NOQA
-        ]}),
+        'text': json.dumps({
+            'type': 'sync',
+            'metadata': [
+                'http://pylxd.test/1.0/images/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',  # NOQA
+            ]}),
         'method': 'GET',
         'url': r'^http://pylxd.test/1.0/images$',
     },
@@ -155,6 +188,7 @@ def profile_GET(request, context):
     },
     {
         'text': json.dumps({
+            'type': 'sync',
             'metadata': {
                 'aliases': [
                     {
@@ -182,9 +216,11 @@ def profile_GET(request, context):
 
     # Profiles
     {
-        'text': json.dumps({'metadata': [
-            'http://pylxd.test/1.0/profiles/an-profile',
-        ]}),
+        'text': json.dumps({
+            'type': 'sync',
+            'metadata': [
+                'http://pylxd.test/1.0/profiles/an-profile',
+            ]}),
         'method': 'GET',
         'url': r'^http://pylxd.test/1.0/profiles$',
     },
@@ -201,12 +237,17 @@ def profile_GET(request, context):
 
     # Operations
     {
-        'text': '{"metadata": {"id": "operation-abc"}}',
+        'text': json.dumps({
+            'type': 'sync',
+            'metadata': {'id': 'operation-abc'},
+            }),
         'method': 'GET',
         'url': r'^http://pylxd.test/1.0/operations/operation-abc$',
     },
     {
-        'text': '{"metadata": {}',
+        'text': json.dumps({
+            'type': 'sync',
+            }),
         'method': 'GET',
         'url': r'^http://pylxd.test/1.0/operations/operation-abc/wait$',
     },
diff --git a/pylxd/tests/test_client.py b/pylxd/tests/test_client.py
index 3f69d31..7871531 100644
--- a/pylxd/tests/test_client.py
+++ b/pylxd/tests/test_client.py
@@ -117,7 +117,11 @@ def test_session_unix_socket(self):
     @mock.patch('pylxd.client.requests.Session')
     def test_get(self, Session):
         """Perform a session get."""
-        session = mock.Mock()
+        response = mock.Mock(**{
+            'status_code': 200,
+            'json.return_value': {'type': 'sync'},
+        })
+        session = mock.Mock(**{'get.return_value': response})
         Session.return_value = session
 
         node = client._APINode('http://test.com')
@@ -129,7 +133,11 @@ def test_get(self, Session):
     @mock.patch('pylxd.client.requests.Session')
     def test_post(self, Session):
         """Perform a session post."""
-        session = mock.Mock()
+        response = mock.Mock(**{
+            'status_code': 200,
+            'json.return_value': {'type': 'sync'},
+        })
+        session = mock.Mock(**{'post.return_value': response})
         Session.return_value = session
 
         node = client._APINode('http://test.com')
@@ -141,7 +149,11 @@ def test_post(self, Session):
     @mock.patch('pylxd.client.requests.Session')
     def test_put(self, Session):
         """Perform a session put."""
-        session = mock.Mock()
+        response = mock.Mock(**{
+            'status_code': 200,
+            'json.return_value': {'type': 'sync'},
+        })
+        session = mock.Mock(**{'put.return_value': response})
         Session.return_value = session
 
         node = client._APINode('http://test.com')
@@ -153,7 +165,11 @@ def test_put(self, Session):
     @mock.patch('pylxd.client.requests.Session')
     def test_delete(self, Session):
         """Perform a session delete."""
-        session = mock.Mock()
+        response = mock.Mock(**{
+            'status_code': 200,
+            'json.return_value': {'type': 'sync'},
+        })
+        session = mock.Mock(**{'delete.return_value': response})
         Session.return_value = session
 
         node = client._APINode('http://test.com')
diff --git a/pylxd/tests/test_container.py b/pylxd/tests/test_container.py
index 932a38e..8a4d6b3 100644
--- a/pylxd/tests/test_container.py
+++ b/pylxd/tests/test_container.py
@@ -79,7 +79,7 @@ def test_fetch(self):
         self.assertTrue(an_container.ephemeral)
 
     def test_fetch_not_found(self):
-        """NameError is raised on a 404 for updating container."""
+        """NotFound is raised on a 404 for updating container."""
         def not_found(request, context):
             context.status_code = 404
             return json.dumps({
@@ -95,7 +95,7 @@ def not_found(request, context):
         an_container = container.Container(
             name='an-missing-container', _client=self.client)
 
-        self.assertRaises(NameError, an_container.fetch)
+        self.assertRaises(exceptions.NotFound, an_container.fetch)
 
     def test_update(self):
         """A container is updated."""
@@ -214,7 +214,7 @@ def test_delete(self):
         # TODO: add an assertion here
 
     def test_delete_failure(self):
-        """If the response indicates delete failure, raise RuntimeError."""
+        """If the response indicates delete failure, raise an exception."""
         def not_found(request, context):
             context.status_code = 404
             return json.dumps({
@@ -231,7 +231,7 @@ def not_found(request, context):
             _client=self.client, _container=self.container,
             name='an-snapshot')
 
-        self.assertRaises(RuntimeError, snapshot.delete)
+        self.assertRaises(exceptions.LXDAPIException, snapshot.delete)
 
 
 class TestFiles(testing.PyLXDTestCase):

From 6ead81fe0246b2e6b12e2649c2df2781bf921f37 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul.hummer at canonical.com>
Date: Fri, 3 Jun 2016 14:30:38 -0600
Subject: [PATCH 03/10] Fix lint

---
 pylxd/container.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/pylxd/container.py b/pylxd/container.py
index 39524f7..6df712c 100644
--- a/pylxd/container.py
+++ b/pylxd/container.py
@@ -264,7 +264,8 @@ class Snapshot(mixin.Waitable, mixin.Marshallable):
     @classmethod
     def get(cls, client, container, name):
         try:
-            response = client.api.containers[container.name].snapshots[name].get()
+            response = client.api.containers[
+                container.name].snapshots[name].get()
         except exceptions.LXDAPIException as e:
             if e.response.status_code == 404:
                 raise exceptions.NotFound()

From 4cf4ff87f05d0c45332b1fd465bf02a9f13f4db9 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul.hummer at canonical.com>
Date: Fri, 3 Jun 2016 14:46:27 -0600
Subject: [PATCH 04/10] Update client tests for more coverage

---
 pylxd/tests/test_client.py | 48 ++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 48 insertions(+)

diff --git a/pylxd/tests/test_client.py b/pylxd/tests/test_client.py
index 7871531..aaa70d1 100644
--- a/pylxd/tests/test_client.py
+++ b/pylxd/tests/test_client.py
@@ -147,6 +147,54 @@ def test_post(self, Session):
         session.post.assert_called_once_with('http://test.com')
 
     @mock.patch('pylxd.client.requests.Session')
+    def test_post_200_not_sync(self, Session):
+        """A status code of 200 with async request raises an exception."""
+        response = mock.Mock(**{
+            'status_code': 200,
+            'json.return_value': {'type': 'async'},
+        })
+        session = mock.Mock(**{'post.return_value': response})
+        Session.return_value = session
+
+        node = client._APINode('http://test.com')
+
+        self.assertRaises(
+            exceptions.LXDAPIException,
+            node.post)
+
+    @mock.patch('pylxd.client.requests.Session')
+    def test_post_202_sync(self, Session):
+        """A status code of 202 with sync request raises an exception."""
+        response = mock.Mock(**{
+            'status_code': 202,
+            'json.return_value': {'type': 'sync'},
+        })
+        session = mock.Mock(**{'post.return_value': response})
+        Session.return_value = session
+
+        node = client._APINode('http://test.com')
+
+        self.assertRaises(
+            exceptions.LXDAPIException,
+            node.post)
+
+    @mock.patch('pylxd.client.requests.Session')
+    def test_post_missing_type(self, Session):
+        """A missing response type raises an exception."""
+        response = mock.Mock(**{
+            'status_code': 200,
+            'json.return_value': {},
+        })
+        session = mock.Mock(**{'post.return_value': response})
+        Session.return_value = session
+
+        node = client._APINode('http://test.com')
+
+        self.assertRaises(
+            exceptions.LXDAPIException,
+            node.post)
+
+    @mock.patch('pylxd.client.requests.Session')
     def test_put(self, Session):
         """Perform a session put."""
         response = mock.Mock(**{

From 3dacb2bee30053722c4cfd5958678bb896a51176 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul.hummer at canonical.com>
Date: Fri, 3 Jun 2016 15:12:57 -0600
Subject: [PATCH 05/10] Add test coverage (to satisfy codecov)

---
 pylxd/tests/mock_lxd.py     | 31 ++++++++++++++++++++++++
 pylxd/tests/test_image.py   | 30 ++++++++++++++++++++++-
 pylxd/tests/test_profile.py | 58 ++++++++++++++++++++++++++++++++++++++++++++-
 3 files changed, 117 insertions(+), 2 deletions(-)

diff --git a/pylxd/tests/mock_lxd.py b/pylxd/tests/mock_lxd.py
index 6df21c9..0bd12aa 100644
--- a/pylxd/tests/mock_lxd.py
+++ b/pylxd/tests/mock_lxd.py
@@ -22,6 +22,13 @@ def images_POST(request, context):
         'metadata': {}})
 
 
+def image_DELETE(request, context):
+    context.status_code = 202
+    return json.dumps({
+        'type': 'async',
+        'operation': 'operation-abc'})
+
+
 def profiles_POST(request, context):
     context.status_code = 200
     return json.dumps({
@@ -29,6 +36,13 @@ def profiles_POST(request, context):
         'metadata': {}})
 
 
+def profile_DELETE(request, context):
+    context.status_code = 200
+    return json.dumps({
+        'type': 'sync',
+        'operation': 'operation-abc'})
+
+
 def snapshot_DELETE(request, context):
     context.status_code = 202
     return json.dumps({
@@ -213,6 +227,12 @@ def profile_GET(request, context):
         'method': 'GET',
         'url': r'^http://pylxd.test/1.0/images/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855$',  # NOQA
     },
+    {
+        'text': image_DELETE,
+        'method': 'DELETE',
+        'url': r'^http://pylxd.test/1.0/images/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855$',  # NOQA
+    },
+
 
     # Profiles
     {
@@ -234,6 +254,17 @@ def profile_GET(request, context):
         'method': 'GET',
         'url': r'^http://pylxd.test/1.0/profiles/(an-profile|an-new-profile)$',
     },
+    {
+        'text': json.dumps({'type': 'sync'}),
+        'method': 'PUT',
+        'url': r'^http://pylxd.test/1.0/profiles/(an-profile|an-new-profile)$',
+    },
+    {
+        'text': profile_DELETE,
+        'method': 'DELETE',
+        'url': r'^http://pylxd.test/1.0/profiles/(an-profile|an-new-profile)$',
+    },
+
 
     # Operations
     {
diff --git a/pylxd/tests/test_image.py b/pylxd/tests/test_image.py
index 37f9f30..b97485f 100644
--- a/pylxd/tests/test_image.py
+++ b/pylxd/tests/test_image.py
@@ -44,7 +44,7 @@ def test_all(self):
     def test_create(self):
         """An image is created."""
         fingerprint = hashlib.sha256(b'').hexdigest()
-        a_image = image.Image.create(self.client, b'')
+        a_image = image.Image.create(self.client, b'', public=True)
 
         self.assertIsInstance(a_image, image.Image)
         self.assertEqual(fingerprint, a_image.fingerprint)
@@ -101,3 +101,31 @@ def not_found(request, context):
         a_image = image.Image(fingerprint=fingerprint, _client=self.client)
 
         self.assertRaises(exceptions.NotFound, a_image.fetch)
+
+    def test_fetch_error(self):
+        """A 500 error raises LXDAPIException."""
+        def not_found(request, context):
+            context.status_code = 500
+            return json.dumps({
+                'type': 'error',
+                'error': 'Not found',
+                'error_code': 500})
+        self.add_rule({
+            'text': not_found,
+            'method': 'GET',
+            'url': r'^http://pylxd.test/1.0/images/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855$',  # NOQA
+        })
+        fingerprint = hashlib.sha256(b'').hexdigest()
+
+        a_image = image.Image(fingerprint=fingerprint, _client=self.client)
+
+        self.assertRaises(exceptions.LXDAPIException, a_image.fetch)
+
+    def test_delete(self):
+        """An image is deleted."""
+        # XXX: rockstar (03 Jun 2016) - This just executes
+        # a code path. There should be an assertion here, but
+        # it's not clear how to assert that, just yet.
+        a_image = self.client.images.all()[0]
+
+        a_image.delete(wait=True)
diff --git a/pylxd/tests/test_profile.py b/pylxd/tests/test_profile.py
index 9abc487..d964ec7 100644
--- a/pylxd/tests/test_profile.py
+++ b/pylxd/tests/test_profile.py
@@ -32,6 +32,24 @@ def not_found(request, context):
             exceptions.NotFound,
             profile.Profile.get, self.client, 'an-profile')
 
+    def test_get_error(self):
+        """LXDAPIException is raised on get error."""
+        def error(request, context):
+            context.status_code = 500
+            return json.dumps({
+                'type': 'error',
+                'error': 'Not found',
+                'error_code': 500})
+        self.add_rule({
+            'text': error,
+            'method': 'GET',
+            'url': r'^http://pylxd.test/1.0/profiles/an-profile$',
+        })
+
+        self.assertRaises(
+            exceptions.LXDAPIException,
+            profile.Profile.get, self.client, 'an-profile')
+
     def test_all(self):
         """A list of all profiles is returned."""
         profiles = profile.Profile.all(self.client)
@@ -72,7 +90,18 @@ def error(request, context):
             profile.Profile.create, self.client,
             name='an-new-profile', config={})
 
-    def test_partial_objects(self):
+    def test_update(self):
+        """A profile is updated."""
+        # XXX: rockstar (03 Jun 2016) - This just executes
+        # a code path. There should be an assertion here, but
+        # it's not clear how to assert that, just yet.
+        an_profile = profile.Profile.get(self.client, 'an-profile')
+
+        an_profile.update()
+
+        self.assertEqual({}, an_profile.config)
+
+    def test_update_partial_objects(self):
         """A partially fetched profile can't be pushed."""
         an_profile = self.client.profiles.all()[0]
 
@@ -105,3 +134,30 @@ def not_found(request, context):
         an_profile = profile.Profile(name='an-profile', _client=self.client)
 
         self.assertRaises(exceptions.NotFound, an_profile.fetch)
+
+    def test_fetch_error(self):
+        """LXDAPIException is raised on fetch error."""
+        def error(request, context):
+            context.status_code = 500
+            return json.dumps({
+                'type': 'error',
+                'error': 'Not found',
+                'error_code': 500})
+        self.add_rule({
+            'text': error,
+            'method': 'GET',
+            'url': r'^http://pylxd.test/1.0/profiles/an-profile$',
+        })
+
+        an_profile = profile.Profile(name='an-profile', _client=self.client)
+
+        self.assertRaises(exceptions.LXDAPIException, an_profile.fetch)
+
+    def test_delete(self):
+        """A profile is deleted."""
+        # XXX: rockstar (03 Jun 2016) - This just executes
+        # a code path. There should be an assertion here, but
+        # it's not clear how to assert that, just yet.
+        an_profile = self.client.profiles.all()[0]
+
+        an_profile.delete()

From 116dd0a2c4f94b63678bcde3903bf9b598c9f2c1 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul.hummer at canonical.com>
Date: Fri, 3 Jun 2016 15:30:23 -0600
Subject: [PATCH 06/10] More test coverage, specific to the patch

---
 pylxd/container.py            |  2 --
 pylxd/tests/test_container.py | 55 ++++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 54 insertions(+), 3 deletions(-)

diff --git a/pylxd/container.py b/pylxd/container.py
index 6df712c..3408f52 100644
--- a/pylxd/container.py
+++ b/pylxd/container.py
@@ -318,7 +318,5 @@ def delete(self, wait=False):
         response = self._client.api.containers[
             self._container.name].snapshots[self.name].delete()
 
-        if response.status_code != 202:
-            raise RuntimeError('Error deleting snapshot {}'.format(self.name))
         if wait:
             self.wait_for_operation(response.json()['operation'])
diff --git a/pylxd/tests/test_container.py b/pylxd/tests/test_container.py
index 8a4d6b3..38cb0b6 100644
--- a/pylxd/tests/test_container.py
+++ b/pylxd/tests/test_container.py
@@ -22,7 +22,7 @@ def test_get(self):
         self.assertEqual(name, an_container.name)
 
     def test_get_not_found(self):
-        """NameError is raised when the container doesn't exist."""
+        """NotFound is raised when the container doesn't exist."""
         def not_found(request, context):
             context.status_code = 404
             return json.dumps({
@@ -41,6 +41,26 @@ def not_found(request, context):
             exceptions.NotFound,
             container.Container.get, self.client, name)
 
+    def test_get_error(self):
+        """LXDAPIException is raised when the LXD API errors."""
+        def not_found(request, context):
+            context.status_code = 500
+            return json.dumps({
+                'type': 'error',
+                'error': 'Not found',
+                'error_code': 500})
+        self.add_rule({
+            'text': not_found,
+            'method': 'GET',
+            'url': r'^http://pylxd.test/1.0/containers/an-missing-container$',  # NOQA
+        })
+
+        name = 'an-missing-container'
+
+        self.assertRaises(
+            exceptions.LXDAPIException,
+            container.Container.get, self.client, name)
+
     def test_create(self):
         """A new container is created."""
         config = {'name': 'an-new-container'}
@@ -97,6 +117,25 @@ def not_found(request, context):
 
         self.assertRaises(exceptions.NotFound, an_container.fetch)
 
+    def test_fetch_error(self):
+        """LXDAPIException is raised on error."""
+        def not_found(request, context):
+            context.status_code = 500
+            return json.dumps({
+                'type': 'error',
+                'error': 'An bad error',
+                'error_code': 500})
+        self.add_rule({
+            'text': not_found,
+            'method': 'GET',
+            'url': r'^http://pylxd.test/1.0/containers/an-missing-container$',  # NOQA
+        })
+
+        an_container = container.Container(
+            name='an-missing-container', _client=self.client)
+
+        self.assertRaises(exceptions.LXDAPIException, an_container.fetch)
+
     def test_update(self):
         """A container is updated."""
         an_container = container.Container(
@@ -268,3 +307,17 @@ def not_found(request, context):
 
         self.assertRaises(
             exceptions.NotFound, self.container.files.get, '/tmp/getted')
+
+    def test_get_error(self):
+        """LXDAPIException is raised on error."""
+        def not_found(request, context):
+            context.status_code = 503
+        rule = {
+            'text': not_found,
+            'method': 'GET',
+            'url': r'^http://pylxd.test/1.0/containers/an-container/files\?path=%2Ftmp%2Fgetted$',  # NOQA
+        }
+        self.add_rule(rule)
+
+        self.assertRaises(
+            exceptions.LXDAPIException, self.container.files.get, '/tmp/getted')

From 8e00ee37b51844dcdef0cbf107359391a2ca7368 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul.hummer at canonical.com>
Date: Fri, 3 Jun 2016 15:33:52 -0600
Subject: [PATCH 07/10] Fix lint

---
 pylxd/tests/test_container.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/pylxd/tests/test_container.py b/pylxd/tests/test_container.py
index 38cb0b6..49b7c40 100644
--- a/pylxd/tests/test_container.py
+++ b/pylxd/tests/test_container.py
@@ -320,4 +320,5 @@ def not_found(request, context):
         self.add_rule(rule)
 
         self.assertRaises(
-            exceptions.LXDAPIException, self.container.files.get, '/tmp/getted')
+            exceptions.LXDAPIException,
+            self.container.files.get, '/tmp/getted')

From 2acb18b43120280eb819141fbaf4b0d86bbf345d Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul.hummer at canonical.com>
Date: Fri, 3 Jun 2016 15:39:24 -0600
Subject: [PATCH 08/10] Update client back to 100% coverage

---
 pylxd/client.py            |  4 ++--
 pylxd/tests/test_client.py | 18 +++++++++++++++++-
 2 files changed, 19 insertions(+), 3 deletions(-)

diff --git a/pylxd/client.py b/pylxd/client.py
index b8ea444..f25755c 100644
--- a/pylxd/client.py
+++ b/pylxd/client.py
@@ -13,9 +13,9 @@
 #    under the License.
 import os
 
-try:
+try:  # pragma: no cover
     from urllib.parse import quote
-except ImportError:
+except ImportError:  # pragma: no cover
     from urllib import quote
 
 import requests
diff --git a/pylxd/tests/test_client.py b/pylxd/tests/test_client.py
index aaa70d1..bf74bff 100644
--- a/pylxd/tests/test_client.py
+++ b/pylxd/tests/test_client.py
@@ -179,7 +179,7 @@ def test_post_202_sync(self, Session):
             node.post)
 
     @mock.patch('pylxd.client.requests.Session')
-    def test_post_missing_type(self, Session):
+    def test_post_missing_type_200(self, Session):
         """A missing response type raises an exception."""
         response = mock.Mock(**{
             'status_code': 200,
@@ -195,6 +195,22 @@ def test_post_missing_type(self, Session):
             node.post)
 
     @mock.patch('pylxd.client.requests.Session')
+    def test_post_missing_type_202(self, Session):
+        """A missing response type raises an exception."""
+        response = mock.Mock(**{
+            'status_code': 202,
+            'json.return_value': {},
+        })
+        session = mock.Mock(**{'post.return_value': response})
+        Session.return_value = session
+
+        node = client._APINode('http://test.com')
+
+        self.assertRaises(
+            exceptions.LXDAPIException,
+            node.post)
+
+    @mock.patch('pylxd.client.requests.Session')
     def test_put(self, Session):
         """Perform a session put."""
         response = mock.Mock(**{

From be69e4c090004d54ce500c3741e6378e2230c1f5 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul.hummer at canonical.com>
Date: Fri, 3 Jun 2016 17:40:53 -0600
Subject: [PATCH 09/10] Add image export support with accompanying tests

---
 integration/test_images.py |  8 ++++++++
 pylxd/image.py             | 13 ++++++++++++-
 pylxd/tests/mock_lxd.py    |  5 +++++
 pylxd/tests/test_image.py  | 43 +++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 68 insertions(+), 1 deletion(-)

diff --git a/integration/test_images.py b/integration/test_images.py
index 4e2d94f..a6ee5a9 100644
--- a/integration/test_images.py
+++ b/integration/test_images.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 time
 
 from pylxd import exceptions
@@ -82,3 +83,10 @@ def test_delete(self):
         self.assertRaises(
             exceptions.NotFound,
             self.client.images.get, self.image.fingerprint)
+
+    def test_export(self):
+        """The imerage is successfully exported."""
+        data = self.image.export()
+        data_sha = hashlib.sha256(data).hexdigest()
+
+        self.assertEqual(self.image.fingerprint, data_sha)
diff --git a/pylxd/image.py b/pylxd/image.py
index 82b965d..a42ada9 100644
--- a/pylxd/image.py
+++ b/pylxd/image.py
@@ -25,7 +25,7 @@ class Image(mixin.Waitable, mixin.Marshallable):
         '_client',
         'aliases', 'architecture', 'created_at', 'expires_at', 'filename',
         'fingerprint', 'properties', 'public', 'size', 'uploaded_at'
-        ]
+    ]
 
     @classmethod
     def get(cls, client, fingerprint):
@@ -101,3 +101,14 @@ def fetch(self):
 
         for key, val in six.iteritems(response.json()['metadata']):
             setattr(self, key, val)
+
+    def export(self):
+        """Export the image."""
+        try:
+            response = self._client.api.images[self.fingerprint].export.get()
+        except exceptions.LXDAPIException as e:
+            if e.response.status_code == 404:
+                raise exceptions.NotFound()
+            raise
+
+        return response.content
diff --git a/pylxd/tests/mock_lxd.py b/pylxd/tests/mock_lxd.py
index 0bd12aa..cafb71e 100644
--- a/pylxd/tests/mock_lxd.py
+++ b/pylxd/tests/mock_lxd.py
@@ -228,6 +228,11 @@ def profile_GET(request, context):
         'url': r'^http://pylxd.test/1.0/images/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855$',  # NOQA
     },
     {
+        'text': '',
+        'method': 'GET',
+        'url': r'^http://pylxd.test/1.0/images/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855/export$',  # NOQA
+    },
+    {
         'text': image_DELETE,
         'method': 'DELETE',
         'url': r'^http://pylxd.test/1.0/images/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855$',  # NOQA
diff --git a/pylxd/tests/test_image.py b/pylxd/tests/test_image.py
index b97485f..cc9e6d2 100644
--- a/pylxd/tests/test_image.py
+++ b/pylxd/tests/test_image.py
@@ -129,3 +129,46 @@ def test_delete(self):
         a_image = self.client.images.all()[0]
 
         a_image.delete(wait=True)
+
+    def test_export(self):
+        """An image is exported."""
+        a_image = self.client.images.all()[0]
+
+        data = a_image.export()
+        data_sha = hashlib.sha256(data).hexdigest()
+
+        self.assertEqual(a_image.fingerprint, data_sha)
+
+    def test_export_not_found(self):
+        """NotFound is raised on export of bogus image."""
+        def not_found(request, context):
+            context.status_code = 404
+            return json.dumps({
+                'type': 'error',
+                'error': 'Not found',
+                'error_code': 404})
+        self.add_rule({
+            'text': not_found,
+            'method': 'GET',
+            'url': r'^http://pylxd.test/1.0/images/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855/export$',  # NOQA
+        })
+        a_image = self.client.images.all()[0]
+
+        self.assertRaises(exceptions.NotFound, a_image.export)
+
+    def test_export_error(self):
+        """LXDAPIException is raised on API error."""
+        def error(request, context):
+            context.status_code = 500
+            return json.dumps({
+                'type': 'error',
+                'error': 'LOLOLOLOL',
+                'error_code': 500})
+        self.add_rule({
+            'text': error,
+            'method': 'GET',
+            'url': r'^http://pylxd.test/1.0/images/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855/export$',  # NOQA
+        })
+        a_image = self.client.images.all()[0]
+
+        self.assertRaises(exceptions.LXDAPIException, a_image.export)

From 2cfb966dde1842b6740b2d0fff9c2ec70856a037 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul.hummer at canonical.com>
Date: Fri, 3 Jun 2016 17:50:04 -0600
Subject: [PATCH 10/10] Increase test coverage for pylxd.image

---
 pylxd/tests/mock_lxd.py   |  8 +++++++-
 pylxd/tests/test_image.py | 29 ++++++++++++++++++++++++++++-
 2 files changed, 35 insertions(+), 2 deletions(-)

diff --git a/pylxd/tests/mock_lxd.py b/pylxd/tests/mock_lxd.py
index cafb71e..38f4930 100644
--- a/pylxd/tests/mock_lxd.py
+++ b/pylxd/tests/mock_lxd.py
@@ -19,7 +19,7 @@ def images_POST(request, context):
     context.status_code = 202
     return json.dumps({
         'type': 'async',
-        'metadata': {}})
+        'operation': 'operation-abc'})
 
 
 def image_DELETE(request, context):
@@ -214,6 +214,7 @@ def profile_GET(request, context):
                 'cached': False,
                 'filename': 'a_image.tar.bz2',
                 'fingerprint': 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',  # NOQA
+                'public': False,
                 'properties': {},
                 'size': 1,
                 'auto_update': False,
@@ -228,6 +229,11 @@ def profile_GET(request, context):
         'url': r'^http://pylxd.test/1.0/images/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855$',  # NOQA
     },
     {
+        'text': json.dumps({'type': 'sync'}),  # should be async
+        'method': 'PUT',
+        'url': r'^http://pylxd.test/1.0/images/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855$',  # NOQA
+    },
+    {
         'text': '',
         'method': 'GET',
         'url': r'^http://pylxd.test/1.0/images/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855/export$',  # NOQA
diff --git a/pylxd/tests/test_image.py b/pylxd/tests/test_image.py
index cc9e6d2..fa3995a 100644
--- a/pylxd/tests/test_image.py
+++ b/pylxd/tests/test_image.py
@@ -35,6 +35,26 @@ def not_found(request, context):
             exceptions.NotFound,
             image.Image.get, self.client, fingerprint)
 
+    def test_get_error(self):
+        """LXDAPIException is raised on error."""
+        def error(request, context):
+            context.status_code = 500
+            return json.dumps({
+                'type': 'error',
+                'error': 'Not found',
+                'error_code': 500})
+        self.add_rule({
+            'text': error,
+            'method': 'GET',
+            'url': r'^http://pylxd.test/1.0/images/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855$',  # NOQA
+        })
+
+        fingerprint = hashlib.sha256(b'').hexdigest()
+
+        self.assertRaises(
+            exceptions.LXDAPIException,
+            image.Image.get, self.client, fingerprint)
+
     def test_all(self):
         """A list of all images is returned."""
         images = image.Image.all(self.client)
@@ -44,7 +64,7 @@ def test_all(self):
     def test_create(self):
         """An image is created."""
         fingerprint = hashlib.sha256(b'').hexdigest()
-        a_image = image.Image.create(self.client, b'', public=True)
+        a_image = image.Image.create(self.client, b'', public=True, wait=True)
 
         self.assertIsInstance(a_image, image.Image)
         self.assertEqual(fingerprint, a_image.fingerprint)
@@ -67,6 +87,13 @@ def create_fail(request, context):
             exceptions.CreateFailed,
             image.Image.create, self.client, b'')
 
+    def test_update(self):
+        """An image is updated."""
+        a_image = self.client.images.all()[0]
+        a_image.fetch()
+
+        a_image.update()
+
     def test_update_partial_objects(self):
         """A partially fetched image can't be pushed."""
         a_image = self.client.images.all()[0]


More information about the lxc-devel mailing list