[lxc-devel] [pylxd/master] Stateful object experiment

rockstar on Github lxc-bot at linuxcontainers.org
Sat Jun 25 07:31:29 UTC 2016


A non-text attachment was scrubbed...
Name: not available
Type: text/x-mailbox
Size: 1864 bytes
Desc: not available
URL: <http://lists.linuxcontainers.org/pipermail/lxc-devel/attachments/20160625/a5c63e20/attachment.bin>
-------------- next part --------------
From 2478240bdb5aaeaeae54452c84dcc1588ab2ebac Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul.hummer at canonical.com>
Date: Fri, 24 Jun 2016 21:12:23 -0600
Subject: [PATCH 1/5] Add coverage (why wasn't it already there?)

---
 test-requirements.txt | 1 +
 1 file changed, 1 insertion(+)

diff --git a/test-requirements.txt b/test-requirements.txt
index f76850a..0c5e340 100644
--- a/test-requirements.txt
+++ b/test-requirements.txt
@@ -2,5 +2,6 @@ ddt>=0.7.0
 nose>=1.3.7
 mock>=1.3.0
 flake8>=2.5.0
+coverage>=4.1
 # See https://github.com/novafloss/mock-services/pull/15
 -e git://github.com/rockstar/mock-services.git@aba3977d1a3f43afd77d99f241ee1111c20deeed#egg=mock-services

From 4f824ac24f939476b51e8ef22d70867e17ba7e9b Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul.hummer at canonical.com>
Date: Sat, 25 Jun 2016 00:01:13 -0600
Subject: [PATCH 2/5] Add the model.

---
 pylxd/model.py            | 126 ++++++++++++++++++++++++++++++++++++++++++++++
 pylxd/tests/test_model.py |  93 ++++++++++++++++++++++++++++++++++
 2 files changed, 219 insertions(+)
 create mode 100644 pylxd/model.py
 create mode 100644 pylxd/tests/test_model.py

diff --git a/pylxd/model.py b/pylxd/model.py
new file mode 100644
index 0000000..ad30a6d
--- /dev/null
+++ b/pylxd/model.py
@@ -0,0 +1,126 @@
+# 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 six
+
+
+class Attribute(object):
+    """A metadata class for model attributes."""
+
+    def __init__(self, validator=None):
+        self.validator = validator
+
+
+class ModelType(type):
+    """A Model metaclass.
+
+    This metaclass converts the declarative Attribute style
+    to attributes on the model instance itself.
+    """
+
+    def __new__(cls, name, bases, attrs):
+        if '__slots__' in attrs and name != 'Model':  # pragma: no cover
+            raise TypeError('__slots__ should not be specified.')
+        attributes = {}
+        for_removal = []
+
+        for key, val in attrs.items():
+            if type(val) == Attribute:
+                attributes[key] = val
+                for_removal.append(key)
+        for key in for_removal:
+            del attrs[key]
+
+        slots = list(attributes.keys())
+        if '__slots__' in attrs:
+            slots = slots + attrs['__slots__']
+        for base in bases:
+            if '__slots__' in dir(base):
+                slots = slots + base.__slots__
+        attrs['__slots__'] = slots
+        attrs['__attributes__'] = attributes
+
+        return super(ModelType, cls).__new__(cls, name, bases, attrs)
+
+
+ at six.add_metaclass(ModelType)
+class Model(object):
+    """A Base LXD object model.
+
+    Objects fetched from the LXD API have state, which allows
+    the objects to be used transactionally, with E-tag support,
+    and be smart about I/O.
+
+    The model lifecycle is this: A model's get/create methods will
+    return an instance. That instance may or may not be a partial
+    instance. If it is a partial instance, `sync` will be called
+    and the rest of the object retrieved from the server when
+    un-initialized attributes are read. When attributes are modified,
+    the instance is marked as dirty. `save` will save the changes
+    to the server.
+    """
+    __slots__ = ['client', '__dirty__']
+
+    def __init__(self, client, **kwargs):
+        self.client = client
+
+        for key, val in kwargs.items():
+            setattr(self, key, val)
+        self.__dirty__ = False
+
+    def __getattribute__(self, name):
+        try:
+            return super(Model, self).__getattribute__(name)
+        except AttributeError:
+            if name in self.__slots__:
+                self.sync()
+                return super(Model, self).__getattribute__(name)
+            else:
+                raise
+
+    def __setattr__(self, name, value):
+        if name in self.__attributes__:
+            attribute = self.__attributes__[name]
+
+            if attribute.validator is not None:
+                if attribute.validator is not type(value):
+                    value = attribute.validator(value)
+            self.__dirty__ = True
+        return super(Model, self).__setattr__(name, value)
+
+    @property
+    def dirty(self):
+        return self.__dirty__
+
+    def sync(self):  # pragma: no cover
+        """Sync from the server.
+
+        When collections of objects are retrieved from the server, they
+        are often partial objects. The full object must be retrieved before
+        it can modified. This method is called when getattr is called on
+        a non-initaliazed object.
+        """
+        raise NotImplemented('sync is not implemented')
+
+    def save(self):  # pragma: no cover
+        """Save data to the server.
+
+        This method should write the new data to the server via marshalling.
+        It should be a no-op when the object is not dirty, to prevent needless
+        I/O.
+        """
+        raise NotImplemented('save is not implemented')
+
+    def delete(self):  # pragma: no cover
+        """Delete an object from the server."""
+        raise NotImplemented('delete is not implemented')
diff --git a/pylxd/tests/test_model.py b/pylxd/tests/test_model.py
new file mode 100644
index 0000000..bfcb4b9
--- /dev/null
+++ b/pylxd/tests/test_model.py
@@ -0,0 +1,93 @@
+# 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 pylxd import model
+from pylxd.tests import testing
+
+
+class Item(model.Model):
+    """A fake model."""
+    name = model.Attribute()
+    age = model.Attribute(int)
+    data = model.Attribute()
+
+    def sync(self):
+        self.age = 1000
+        self.data = {'key': 'val'}
+
+
+class TestModel(testing.PyLXDTestCase):
+    """Tests for pylxd.model.Model."""
+
+    def test_init(self):
+        """Initial attributes are set."""
+        item = Item(self.client, name='an-item', age=15, data={'key': 'val'})
+
+        self.assertEqual(self.client, item.client)
+        self.assertEqual('an-item', item.name)
+
+    def test_init_unknown_attribute(self):
+        """Unknown attributes raise an exception."""
+        self.assertRaises(
+            AttributeError,
+            Item, self.client, name='an-item', nonexistent='SRSLY')
+
+    def test_unknown_attribute(self):
+        """Setting unknown attributes raise an exception."""
+        def set_unknown_attribute():
+            item = Item(self.client, name='an-item')
+            item.nonexistent = 'SRSLY'
+        self.assertRaises(AttributeError, set_unknown_attribute)
+
+    def test_get_unknown_attribute(self):
+        """Setting unknown attributes raise an exception."""
+        def get_unknown_attribute():
+            item = Item(self.client, name='an-item')
+            return item.nonexistent
+        self.assertRaises(AttributeError, get_unknown_attribute)
+
+    def test_unset_attribute_sync(self):
+        """Reading unavailable attributes calls sync."""
+        item = Item(self.client, name='an-item')
+
+        self.assertEqual(1000, item.age)
+
+    def test_int_attribute_validator(self):
+        """Age is set properly to be an int."""
+        item = Item(self.client)
+
+        item.age = '100'
+
+        self.assertEqual(100, item.age)
+
+    def test_int_attribute_invalid(self):
+        """TypeError is raised when data can't be converted to type."""
+        def set_string():
+            item = Item(self.client)
+            item.age = 'abc'
+
+        self.assertRaises(ValueError, set_string)
+
+    def test_dirty(self):
+        """Changes mark the object as dirty."""
+        item = Item(self.client, name='an-item', age=15, data={'key': 'val'})
+
+        item.age = 100
+
+        self.assertTrue(item.dirty)
+
+    def test_not_dirty(self):
+        """Changes mark the object as dirty."""
+        item = Item(self.client, name='an-item', age=15, data={'key': 'val'})
+
+        self.assertFalse(item.dirty)

From 85cccb964180f8d0f3d51e46b9ccc1f6303d64b9 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul.hummer at canonical.com>
Date: Sat, 25 Jun 2016 01:02:20 -0600
Subject: [PATCH 3/5] Add support for /1.0/networks

---
 pylxd/client.py             |  1 +
 pylxd/managers.py           |  8 ++++++--
 pylxd/model.py              |  6 +++++-
 pylxd/network.py            | 44 ++++++++++++++++++++++++++++++++++++++++++++
 pylxd/tests/mock_lxd.py     | 23 +++++++++++++++++++++++
 pylxd/tests/test_network.py | 33 +++++++++++++++++++++++++++++++++
 6 files changed, 112 insertions(+), 3 deletions(-)
 create mode 100644 pylxd/network.py
 create mode 100644 pylxd/tests/test_network.py

diff --git a/pylxd/client.py b/pylxd/client.py
index 5d66bfc..2890b43 100644
--- a/pylxd/client.py
+++ b/pylxd/client.py
@@ -194,6 +194,7 @@ def __init__(self, endpoint=None, version='1.0', cert=None, verify=True):
         self.certificates = managers.CertificateManager(self)
         self.containers = managers.ContainerManager(self)
         self.images = managers.ImageManager(self)
+        self.networks = managers.NetworkManager(self)
         self.operations = managers.OperationManager(self)
         self.profiles = managers.ProfileManager(self)
 
diff --git a/pylxd/managers.py b/pylxd/managers.py
index ddb6214..b8c047e 100644
--- a/pylxd/managers.py
+++ b/pylxd/managers.py
@@ -37,13 +37,17 @@ class ImageManager(BaseManager):
     manager_for = 'pylxd.image.Image'
 
 
-class ProfileManager(BaseManager):
-    manager_for = 'pylxd.profile.Profile'
+class NetworkManager(BaseManager):
+    manager_for = 'pylxd.network.Network'
 
 
 class OperationManager(BaseManager):
     manager_for = 'pylxd.operation.Operation'
 
 
+class ProfileManager(BaseManager):
+    manager_for = 'pylxd.profile.Profile'
+
+
 class SnapshotManager(BaseManager):
     manager_for = 'pylxd.container.Snapshot'
diff --git a/pylxd/model.py b/pylxd/model.py
index ad30a6d..d31e726 100644
--- a/pylxd/model.py
+++ b/pylxd/model.py
@@ -110,7 +110,11 @@ def sync(self):  # pragma: no cover
         it can modified. This method is called when getattr is called on
         a non-initaliazed object.
         """
-        raise NotImplemented('sync is not implemented')
+        # XXX: rockstar (25 Jun 2016) - This has the potential to step
+        # on existing attributes.
+        response = self.api.get()
+        for key, val in response.json()['metadata'].items():
+            setattr(self, key, val)
 
     def save(self):  # pragma: no cover
         """Save data to the server.
diff --git a/pylxd/network.py b/pylxd/network.py
new file mode 100644
index 0000000..9609172
--- /dev/null
+++ b/pylxd/network.py
@@ -0,0 +1,44 @@
+# 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 pylxd import model
+
+
+class Network(model.Model):
+    """A LXD network."""
+    name = model.Attribute()
+    type = model.Attribute()
+    used_by = model.Attribute()
+
+    @classmethod
+    def get(cls, client, name):
+        """Get a network by name."""
+        response = client.api.networks[name].get()
+
+        network = cls(client, **response.json()['metadata'])
+        return network
+
+    @classmethod
+    def all(cls, client):
+        """Get all networks."""
+        response = client.api.networks.get()
+
+        networks = []
+        for url in response.json()['metadata']:
+            name = url.split('/')[-1]
+            networks.append(cls(client, name=name))
+        return networks
+
+    @property
+    def api(self):
+        return self.client.api.networks[self.name]
diff --git a/pylxd/tests/mock_lxd.py b/pylxd/tests/mock_lxd.py
index 15ade82..8f5d531 100644
--- a/pylxd/tests/mock_lxd.py
+++ b/pylxd/tests/mock_lxd.py
@@ -335,6 +335,29 @@ def profile_GET(request, context):
     },
 
 
+    # Networks
+    {
+        'json': {
+            'type': 'sync',
+            'metadata': [
+                'http://pylxd.test/1.0/networks/lo',
+            ]},
+        'method': 'GET',
+        'url': r'^http://pylxd.test/1.0/networks$',
+    },
+    {
+        'json': {
+            'type': 'sync',
+            'metadata': {
+                'name': 'lo',
+                'type': 'loopback',
+                'used_by': [],
+            }},
+        'method': 'GET',
+        'url': r'^http://pylxd.test/1.0/networks/lo$',
+    },
+
+
     # Profiles
     {
         'text': json.dumps({
diff --git a/pylxd/tests/test_network.py b/pylxd/tests/test_network.py
new file mode 100644
index 0000000..0aff49b
--- /dev/null
+++ b/pylxd/tests/test_network.py
@@ -0,0 +1,33 @@
+# 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 pylxd import network
+from pylxd.tests import testing
+
+
+class TestNetwork(testing.PyLXDTestCase):
+    """Tests for pylxd.network.Network."""
+
+    def test_all(self):
+        """A list of all networks are returned."""
+        networks = network.Network.all(self.client)
+
+        self.assertEqual(1, len(networks))
+
+    def test_get(self):
+        """Return a container."""
+        name = 'lo'
+
+        an_network = network.Network.get(self.client, name)
+
+        self.assertEqual(name, an_network.name)

From d98910e41c720fe22cb19c2421f5f13e1009ab95 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul.hummer at canonical.com>
Date: Sat, 25 Jun 2016 01:05:12 -0600
Subject: [PATCH 4/5] sync should be covered by test coverage

---
 pylxd/model.py              | 2 +-
 pylxd/tests/test_network.py | 6 ++++++
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/pylxd/model.py b/pylxd/model.py
index d31e726..c3ce6c0 100644
--- a/pylxd/model.py
+++ b/pylxd/model.py
@@ -102,7 +102,7 @@ def __setattr__(self, name, value):
     def dirty(self):
         return self.__dirty__
 
-    def sync(self):  # pragma: no cover
+    def sync(self):
         """Sync from the server.
 
         When collections of objects are retrieved from the server, they
diff --git a/pylxd/tests/test_network.py b/pylxd/tests/test_network.py
index 0aff49b..3534c83 100644
--- a/pylxd/tests/test_network.py
+++ b/pylxd/tests/test_network.py
@@ -31,3 +31,9 @@ def test_get(self):
         an_network = network.Network.get(self.client, name)
 
         self.assertEqual(name, an_network.name)
+
+    def test_partial(self):
+        """A partial network is synced."""
+        an_network = network.Network(self.client, name='lo')
+
+        self.assertEqual('loopback', an_network.type)

From 7e906f9d9ca33727fc6ef1809ca97acf04def509 Mon Sep 17 00:00:00 2001
From: Paul Hummer <paul.hummer at canonical.com>
Date: Sat, 25 Jun 2016 01:08:43 -0600
Subject: [PATCH 5/5] `delete` and `save` are not allowed in networks

---
 pylxd/tests/test_network.py | 12 ++++++++++++
 1 file changed, 12 insertions(+)

diff --git a/pylxd/tests/test_network.py b/pylxd/tests/test_network.py
index 3534c83..20215a6 100644
--- a/pylxd/tests/test_network.py
+++ b/pylxd/tests/test_network.py
@@ -37,3 +37,15 @@ def test_partial(self):
         an_network = network.Network(self.client, name='lo')
 
         self.assertEqual('loopback', an_network.type)
+
+    def test_delete(self):
+        """delete is not implemented in networks."""
+        an_network = network.Network(self.client, name='lo')
+
+        self.assertRaises(NotImplemented, an_network.delete)
+
+    def test_save(self):
+        """save is not implemented in networks."""
+        an_network = network.Network(self.client, name='lo')
+
+        self.assertRaises(NotImplemented, an_network.save)


More information about the lxc-devel mailing list