[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