diff --git a/ironic/common/exception.py b/ironic/common/exception.py index f74395b69c..3c846f8d02 100644 --- a/ironic/common/exception.py +++ b/ironic/common/exception.py @@ -260,3 +260,15 @@ class IPMIFailure(IronicException): class SSHConnectFailed(IronicException): message = _("Failed to establish SSH connection to host %(host)s.") + + +class UnsupportedObjectError(IronicException): + message = _('Unsupported object type %(objtype)s') + + +class OrphanedObjectError(IronicException): + message = _('Cannot call %(method)s on orphaned %(objtype)s object') + + +class IncompatibleObjectVersion(IronicException): + message = _('Version %(objver)s of %(objname)s is not supported') diff --git a/ironic/objects/__init__.py b/ironic/objects/__init__.py new file mode 100644 index 0000000000..67f4db51af --- /dev/null +++ b/ironic/objects/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2013 IBM Corp. +# +# 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. diff --git a/ironic/objects/base.py b/ironic/objects/base.py new file mode 100644 index 0000000000..a48a76710c --- /dev/null +++ b/ironic/objects/base.py @@ -0,0 +1,368 @@ +# Copyright 2013 IBM Corp. +# +# 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. + +"""Ironic common internal object model""" + +import collections + +from ironic.common import exception +from ironic.objects import utils as obj_utils +from ironic.openstack.common import log as logging +from ironic.openstack.common.rpc import serializer as rpc_serializer + + +LOG = logging.getLogger('object') + + +def get_attrname(name): + """Return the mangled name of the attribute's underlying storage.""" + return '_%s' % name + + +def make_class_properties(cls): + # NOTE(danms): Inherit IronicObject's base fields only + cls.fields.update(IronicObject.fields) + for name, typefn in cls.fields.iteritems(): + + def getter(self, name=name, typefn=typefn): + attrname = get_attrname(name) + if not hasattr(self, attrname): + self.obj_load(name) + return getattr(self, attrname) + + def setter(self, value, name=name, typefn=typefn): + self._changed_fields.add(name) + try: + return setattr(self, get_attrname(name), typefn(value)) + except Exception: + attr = "%s.%s" % (self.obj_name(), name) + LOG.exception(_('Error setting %(attr)s') % + {'attr': attr}) + raise + + setattr(cls, name, property(getter, setter)) + + +class IronicObjectMetaclass(type): + """Metaclass that allows tracking of object classes.""" + + # NOTE(danms): This is what controls whether object operations are + # remoted. If this is not None, use it to remote things over RPC. + indirection_api = None + + def __init__(cls, names, bases, dict_): + if not hasattr(cls, '_obj_classes'): + # This will be set in the 'IronicObject' class. + cls._obj_classes = collections.defaultdict(list) + else: + # Add the subclass to IronicObject._obj_classes + make_class_properties(cls) + cls._obj_classes[cls.obj_name()].append(cls) + + +# These are decorators that mark an object's method as remotable. +# If the metaclass is configured to forward object methods to an +# indirection service, these will result in making an RPC call +# instead of directly calling the implementation in the object. Instead, +# the object implementation on the remote end will perform the +# requested action and the result will be returned here. +def remotable_classmethod(fn): + """Decorator for remotable classmethods.""" + def wrapper(cls, context, **kwargs): + if IronicObject.indirection_api: + result = IronicObject.indirection_api.object_class_action( + context, cls.obj_name(), fn.__name__, cls.version, kwargs) + else: + result = fn(cls, context, **kwargs) + if isinstance(result, IronicObject): + result._context = context + return result + return classmethod(wrapper) + + +# See comment above for remotable_classmethod() +# +# Note that this will use either the provided context, or the one +# stashed in the object. If neither are present, the object is +# "orphaned" and remotable methods cannot be called. +def remotable(fn): + """Decorator for remotable object methods.""" + def wrapper(self, context=None, **kwargs): + if context is None: + context = self._context + if context is None: + raise exception.OrphanedObjectError(method=fn.__name__, + objtype=self.obj_name()) + if IronicObject.indirection_api: + updates, result = IronicObject.indirection_api.object_action( + context, self, fn.__name__, kwargs) + for key, value in updates.iteritems(): + if key in self.fields: + self[key] = self._attr_from_primitive(key, value) + self._changed_fields = set(updates.get('obj_what_changed', [])) + return result + else: + return fn(self, context, **kwargs) + return wrapper + + +# Object versioning rules +# +# Each service has its set of objects, each with a version attached. When +# a client attempts to call an object method, the server checks to see if +# the version of that object matches (in a compatible way) its object +# implementation. If so, cool, and if not, fail. +def check_object_version(server, client): + try: + client_major, _client_minor = client.split('.') + server_major, _server_minor = server.split('.') + client_minor = int(_client_minor) + server_minor = int(_server_minor) + except ValueError: + raise exception.IncompatibleObjectVersion( + _('Invalid version string')) + + if client_major != server_major: + raise exception.IncompatibleObjectVersion( + dict(client=client_major, server=server_major)) + if client_minor > server_minor: + raise exception.IncompatibleObjectVersion( + dict(client=client_minor, server=server_minor)) + + +class IronicObject(object): + """Base class and object factory. + + This forms the base of all objects that can be remoted or instantiated + via RPC. Simply defining a class that inherits from this base class + will make it remotely instantiatable. Objects should implement the + necessary "get" classmethod routines as well as "save" object methods + as appropriate. + """ + __metaclass__ = IronicObjectMetaclass + + # Version of this object (see rules above check_object_version()) + version = '1.0' + + # The fields present in this object as key:typefn pairs. For example: + # + # fields = { 'foo': int, + # 'bar': str, + # 'baz': lambda x: str(x).ljust(8), + # } + # + # NOTE(danms): The base IronicObject class' fields will be inherited + # by subclasses, but that is a special case. Objects inheriting from + # other objects will not receive this merging of fields contents. + fields = { + 'created_at': obj_utils.datetime_or_none, + 'updated_at': obj_utils.datetime_or_none, + 'deleted_at': obj_utils.datetime_or_none, + 'deleted': int, + } + + def __init__(self): + self._changed_fields = set() + self._context = None + + @classmethod + def obj_name(cls): + """Return a canonical name for this object which will be used over + the wire for remote hydration. + """ + return cls.__name__ + + @classmethod + def obj_class_from_name(cls, objname, objver): + """Returns a class from the registry based on a name and version.""" + if objname not in cls._obj_classes: + LOG.error(_('Unable to instantiate unregistered object type ' + '%(objtype)s') % dict(objtype=objname)) + raise exception.UnsupportedObjectError(objtype=objname) + + compatible_match = None + for objclass in cls._obj_classes[objname]: + if objclass.version == objver: + return objclass + try: + check_object_version(objclass.version, objver) + compatible_match = objclass + except exception.IncompatibleObjectVersion: + pass + + if compatible_match: + return compatible_match + + raise exception.IncompatibleObjectVersion(objname=objname, + objver=objver) + + _attr_created_at_from_primitive = obj_utils.dt_deserializer + _attr_updated_at_from_primitive = obj_utils.dt_deserializer + _attr_deleted_at_from_primitive = obj_utils.dt_deserializer + + def _attr_from_primitive(self, attribute, value): + """Attribute deserialization dispatcher. + + This calls self._attr_foo_from_primitive(value) for an attribute + foo with value, if it exists, otherwise it assumes the value + is suitable for the attribute's setter method. + """ + handler = '_attr_%s_from_primitive' % attribute + if hasattr(self, handler): + return getattr(self, handler)(value) + return value + + @classmethod + def obj_from_primitive(cls, primitive): + """Simple base-case hydration. + + This calls self._attr_from_primitive() for each item in fields. + """ + if primitive['ironic_object.namespace'] != 'ironic': + # NOTE(danms): We don't do anything with this now, but it's + # there for "the future" + raise exception.UnsupportedObjectError( + objtype='%s.%s' % (primitive['ironic_object.namespace'], + primitive['ironic_object.name'])) + objname = primitive['ironic_object.name'] + objver = primitive['ironic_object.version'] + objdata = primitive['ironic_object.data'] + objclass = cls.obj_class_from_name(objname, objver) + self = objclass() + for name in self.fields: + if name in objdata: + setattr(self, name, + self._attr_from_primitive(name, objdata[name])) + changes = primitive.get('ironic_object.changes', []) + self._changed_fields = set([x for x in changes if x in self.fields]) + return self + + _attr_created_at_to_primitive = obj_utils.dt_serializer('created_at') + _attr_updated_at_to_primitive = obj_utils.dt_serializer('updated_at') + _attr_deleted_at_to_primitive = obj_utils.dt_serializer('deleted_at') + + def _attr_to_primitive(self, attribute): + """Attribute serialization dispatcher. + + This calls self._attr_foo_to_primitive() for an attribute foo, + if it exists, otherwise it assumes the attribute itself is + primitive-enough to be sent over the RPC wire. + """ + handler = '_attr_%s_to_primitive' % attribute + if hasattr(self, handler): + return getattr(self, handler)() + else: + return getattr(self, attribute) + + def obj_to_primitive(self): + """Simple base-case dehydration. + + This calls self._attr_to_primitive() for each item in fields. + """ + primitive = dict() + for name in self.fields: + if hasattr(self, get_attrname(name)): + primitive[name] = self._attr_to_primitive(name) + obj = {'ironic_object.name': self.obj_name(), + 'ironic_object.namespace': 'ironic', + 'ironic_object.version': self.version, + 'ironic_object.data': primitive} + if self.obj_what_changed(): + obj['ironic_object.changes'] = list(self.obj_what_changed()) + return obj + + def obj_load_attr(self, attrname): + """Load an additional attribute from the real object. + + This should use self._conductor, and cache any data that might + be useful for future load operations. + """ + raise NotImplementedError( + _("Cannot load '%(attrname)s' in the base class") % locals()) + + def save(self, context): + """Save the changed fields back to the store. + + This is optional for subclasses, but is presented here in the base + class for consistency among those that do. + """ + raise NotImplementedError('Cannot save anything in the base class') + + def obj_what_changed(self): + """Returns a list of fields that have been modified.""" + return self._changed_fields + + def obj_reset_changes(self, fields=None): + """Reset the list of fields that have been changed. + + Note that this is NOT "revert to previous values" + """ + if fields: + self._changed_fields -= set(fields) + else: + self._changed_fields.clear() + + # dictish syntactic sugar + def iteritems(self): + """For backwards-compatibility with dict-based objects. + + NOTE(danms): May be removed in the future. + """ + for name in self.fields: + if hasattr(self, get_attrname(name)): + yield name, getattr(self, name) + + items = lambda self: list(self.iteritems()) + + def __getitem__(self, name): + """For backwards-compatibility with dict-based objects. + + NOTE(danms): May be removed in the future. + """ + return getattr(self, name) + + def __setitem__(self, name, value): + """For backwards-compatibility with dict-based objects. + + NOTE(danms): May be removed in the future. + """ + setattr(self, name, value) + + def get(self, key, value=None): + """For backwards-compatibility with dict-based objects. + + NOTE(danms): May be removed in the future. + """ + return self[key] + + +class IronicObjectSerializer(rpc_serializer.Serializer): + """A IronicObject-aware Serializer. + + This implements the Oslo Serializer interface and provides the + ability to serialize and deserialize IronicObject entities. Any service + that needs to accept or return IronicObjects as arguments or result values + should pass this to its RpcProxy and RpcDispatcher objects. + """ + def serialize_entity(self, context, entity): + if (hasattr(entity, 'obj_to_primitive') and + callable(entity.obj_to_primitive)): + entity = entity.obj_to_primitive() + return entity + + def deserialize_entity(self, context, entity): + if isinstance(entity, dict) and 'ironic_object.name' in entity: + entity = IronicObject.obj_from_primitive(entity) + entity._context = context + return entity diff --git a/ironic/objects/utils.py b/ironic/objects/utils.py new file mode 100644 index 0000000000..24af4ffc0d --- /dev/null +++ b/ironic/objects/utils.py @@ -0,0 +1,71 @@ +# Copyright 2013 IBM Corp. +# +# 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. + +"""Utility methods for objects""" + +import datetime +import netaddr + +from ironic.openstack.common import timeutils + + +def datetime_or_none(dt): + """Validate a datetime or None value.""" + if dt is None or isinstance(dt, datetime.datetime): + return dt + raise ValueError('A datetime.datetime is required here') + + +def int_or_none(val): + """Attempt to parse an integer value, or None.""" + if val is None: + return val + else: + return int(val) + + +def str_or_none(val): + """Attempt to stringify a value, or None.""" + if val is None: + return val + else: + return str(val) + + +def ip_or_none(version): + """Return a version-specific IP address validator.""" + def validator(val, version=version): + if val is None: + return val + else: + return netaddr.IPAddress(val, version=version) + return validator + + +def dt_serializer(name): + """Return a datetime serializer for a named attribute.""" + def serializer(self, name=name): + if getattr(self, name) is not None: + return timeutils.isotime(getattr(self, name)) + else: + return None + return serializer + + +def dt_deserializer(instance, val): + """A deserializer method for datetime attributes.""" + if val is None: + return None + else: + return timeutils.parse_isotime(val) diff --git a/ironic/tests/objects/__init__.py b/ironic/tests/objects/__init__.py new file mode 100644 index 0000000000..67f4db51af --- /dev/null +++ b/ironic/tests/objects/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2013 IBM Corp. +# +# 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. diff --git a/ironic/tests/objects/test_objects.py b/ironic/tests/objects/test_objects.py new file mode 100644 index 0000000000..a09b9e3fad --- /dev/null +++ b/ironic/tests/objects/test_objects.py @@ -0,0 +1,376 @@ +# Copyright 2013 IBM Corp. +# +# 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 contextlib +import datetime +import gettext +import netaddr + +gettext.install('ironic') + +from ironic.common import exception +from ironic.objects import base +from ironic.objects import utils +from ironic.openstack.common import context +from ironic.openstack.common import timeutils +from ironic.tests.db import base as test_base + + +class MyObj(base.IronicObject): + version = '1.5' + fields = {'foo': int, + 'bar': str, + 'missing': str, + } + + def obj_load(self, attrname): + setattr(self, attrname, 'loaded!') + + @base.remotable_classmethod + def get(cls, context): + obj = cls() + obj.foo = 1 + obj.bar = 'bar' + obj.obj_reset_changes() + return obj + + @base.remotable + def marco(self, context): + return 'polo' + + @base.remotable + def update_test(self, context): + if context.tenant == 'alternate': + self.bar = 'alternate-context' + else: + self.bar = 'updated' + + @base.remotable + def save(self, context): + self.obj_reset_changes() + + @base.remotable + def refresh(self, context): + self.foo = 321 + self.bar = 'refreshed' + self.obj_reset_changes() + + @base.remotable + def modify_save_modify(self, context): + self.bar = 'meow' + self.save() + self.foo = 42 + + +class MyObj2(object): + @classmethod + def obj_name(cls): + return 'MyObj' + + @base.remotable_classmethod + def get(cls, *args, **kwargs): + pass + + +class TestMetaclass(test_base.DbTestCase): + def test_obj_tracking(self): + + class NewBaseClass(object): + __metaclass__ = base.IronicObjectMetaclass + fields = {} + + @classmethod + def obj_name(cls): + return cls.__name__ + + class Test1(NewBaseClass): + @staticmethod + def obj_name(): + return 'fake1' + + class Test2(NewBaseClass): + pass + + class Test2v2(NewBaseClass): + @staticmethod + def obj_name(): + return 'Test2' + + expected = {'fake1': [Test1], 'Test2': [Test2, Test2v2]} + + self.assertEqual(expected, NewBaseClass._obj_classes) + # The following should work, also. + self.assertEqual(expected, Test1._obj_classes) + self.assertEqual(expected, Test2._obj_classes) + + +class TestUtils(test_base.DbTestCase): + def test_datetime_or_none(self): + dt = datetime.datetime.now() + self.assertEqual(utils.datetime_or_none(dt), dt) + self.assertEqual(utils.datetime_or_none(None), None) + self.assertRaises(ValueError, utils.datetime_or_none, 'foo') + + def test_int_or_none(self): + self.assertEqual(utils.int_or_none(1), 1) + self.assertEqual(utils.int_or_none('1'), 1) + self.assertEqual(utils.int_or_none(None), None) + self.assertRaises(ValueError, utils.int_or_none, 'foo') + + def test_str_or_none(self): + class Obj(object): + pass + self.assertEqual(utils.str_or_none('foo'), 'foo') + self.assertEqual(utils.str_or_none(1), '1') + self.assertEqual(utils.str_or_none(None), None) + + def test_ip_or_none(self): + ip4 = netaddr.IPAddress('1.2.3.4', 4) + ip6 = netaddr.IPAddress('1::2', 6) + self.assertEqual(utils.ip_or_none(4)('1.2.3.4'), ip4) + self.assertEqual(utils.ip_or_none(6)('1::2'), ip6) + self.assertEqual(utils.ip_or_none(4)(None), None) + self.assertEqual(utils.ip_or_none(6)(None), None) + self.assertRaises(netaddr.AddrFormatError, utils.ip_or_none(4), 'foo') + self.assertRaises(netaddr.AddrFormatError, utils.ip_or_none(6), 'foo') + + def test_dt_serializer(self): + class Obj(object): + foo = utils.dt_serializer('bar') + + obj = Obj() + obj.bar = timeutils.parse_isotime('1955-11-05T00:00:00Z') + self.assertEqual(obj.foo(), '1955-11-05T00:00:00Z') + obj.bar = None + self.assertEqual(obj.foo(), None) + obj.bar = 'foo' + self.assertRaises(AttributeError, obj.foo) + + def test_dt_deserializer(self): + dt = timeutils.parse_isotime('1955-11-05T00:00:00Z') + self.assertEqual(utils.dt_deserializer(None, timeutils.isotime(dt)), + dt) + self.assertEqual(utils.dt_deserializer(None, None), None) + self.assertRaises(ValueError, utils.dt_deserializer, None, 'foo') + + +class _BaseTestCase(test_base.DbTestCase): + def setUp(self): + super(_BaseTestCase, self).setUp() + self.remote_object_calls = list() + + +class _LocalTest(_BaseTestCase): + def setUp(self): + super(_LocalTest, self).setUp() + # Just in case + base.IronicObject.indirection_api = None + + def assertRemotes(self): + self.assertEqual(self.remote_object_calls, []) + + +@contextlib.contextmanager +def things_temporarily_local(): + # Temporarily go non-remote so the conductor handles + # this request directly + _api = base.IronicObject.indirection_api + base.IronicObject.indirection_api = None + yield + base.IronicObject.indirection_api = _api + + +class _TestObject(object): + def test_hydration_type_error(self): + primitive = {'ironic_object.name': 'MyObj', + 'ironic_object.namespace': 'ironic', + 'ironic_object.version': '1.5', + 'ironic_object.data': {'foo': 'a'}} + self.assertRaises(ValueError, MyObj.obj_from_primitive, primitive) + + def test_hydration(self): + primitive = {'ironic_object.name': 'MyObj', + 'ironic_object.namespace': 'ironic', + 'ironic_object.version': '1.5', + 'ironic_object.data': {'foo': 1}} + obj = MyObj.obj_from_primitive(primitive) + self.assertEqual(obj.foo, 1) + + def test_hydration_bad_ns(self): + primitive = {'ironic_object.name': 'MyObj', + 'ironic_object.namespace': 'foo', + 'ironic_object.version': '1.5', + 'ironic_object.data': {'foo': 1}} + self.assertRaises(exception.UnsupportedObjectError, + MyObj.obj_from_primitive, primitive) + + def test_dehydration(self): + expected = {'ironic_object.name': 'MyObj', + 'ironic_object.namespace': 'ironic', + 'ironic_object.version': '1.5', + 'ironic_object.data': {'foo': 1}} + obj = MyObj() + obj.foo = 1 + obj.obj_reset_changes() + self.assertEqual(obj.obj_to_primitive(), expected) + + def test_object_property(self): + obj = MyObj() + obj.foo = 1 + self.assertEqual(obj.foo, 1) + + def test_object_property_type_error(self): + obj = MyObj() + + def fail(): + obj.foo = 'a' + self.assertRaises(ValueError, fail) + + def test_object_dict_syntax(self): + obj = MyObj() + obj.foo = 123 + obj.bar = 'bar' + self.assertEqual(obj['foo'], 123) + self.assertEqual(sorted(obj.items(), key=lambda x: x[0]), + [('bar', 'bar'), ('foo', 123)]) + self.assertEqual(sorted(list(obj.iteritems()), key=lambda x: x[0]), + [('bar', 'bar'), ('foo', 123)]) + + def test_load(self): + obj = MyObj() + self.assertEqual(obj.bar, 'loaded!') + + def test_loaded_in_primitive(self): + obj = MyObj() + obj.foo = 1 + obj.obj_reset_changes() + self.assertEqual(obj.bar, 'loaded!') + expected = {'ironic_object.name': 'MyObj', + 'ironic_object.namespace': 'ironic', + 'ironic_object.version': '1.5', + 'ironic_object.changes': ['bar'], + 'ironic_object.data': {'foo': 1, + 'bar': 'loaded!'}} + self.assertEqual(obj.obj_to_primitive(), expected) + + def test_changes_in_primitive(self): + obj = MyObj() + obj.foo = 123 + self.assertEqual(obj.obj_what_changed(), set(['foo'])) + primitive = obj.obj_to_primitive() + self.assertTrue('ironic_object.changes' in primitive) + obj2 = MyObj.obj_from_primitive(primitive) + self.assertEqual(obj2.obj_what_changed(), set(['foo'])) + obj2.obj_reset_changes() + self.assertEqual(obj2.obj_what_changed(), set()) + + def test_unknown_objtype(self): + self.assertRaises(exception.UnsupportedObjectError, + base.IronicObject.obj_class_from_name, 'foo', '1.0') + + def test_with_alternate_context(self): + ctxt1 = context.RequestContext('foo', 'foo') + ctxt2 = context.RequestContext('bar', tenant='alternate') + obj = MyObj.get(ctxt1) + obj.update_test(ctxt2) + self.assertEqual(obj.bar, 'alternate-context') + self.assertRemotes() + + def test_orphaned_object(self): + ctxt = context.get_admin_context() + obj = MyObj.get(ctxt) + obj._context = None + self.assertRaises(exception.OrphanedObjectError, + obj.update_test) + self.assertRemotes() + + def test_changed_1(self): + ctxt = context.get_admin_context() + obj = MyObj.get(ctxt) + obj.foo = 123 + self.assertEqual(obj.obj_what_changed(), set(['foo'])) + obj.update_test(ctxt) + self.assertEqual(obj.obj_what_changed(), set(['foo', 'bar'])) + self.assertEqual(obj.foo, 123) + self.assertRemotes() + + def test_changed_2(self): + ctxt = context.get_admin_context() + obj = MyObj.get(ctxt) + obj.foo = 123 + self.assertEqual(obj.obj_what_changed(), set(['foo'])) + obj.save(ctxt) + self.assertEqual(obj.obj_what_changed(), set([])) + self.assertEqual(obj.foo, 123) + self.assertRemotes() + + def test_changed_3(self): + ctxt = context.get_admin_context() + obj = MyObj.get(ctxt) + obj.foo = 123 + self.assertEqual(obj.obj_what_changed(), set(['foo'])) + obj.refresh(ctxt) + self.assertEqual(obj.obj_what_changed(), set([])) + self.assertEqual(obj.foo, 321) + self.assertEqual(obj.bar, 'refreshed') + self.assertRemotes() + + def test_changed_4(self): + ctxt = context.get_admin_context() + obj = MyObj.get(ctxt) + obj.bar = 'something' + self.assertEqual(obj.obj_what_changed(), set(['bar'])) + obj.modify_save_modify(ctxt) + self.assertEqual(obj.obj_what_changed(), set(['foo'])) + self.assertEqual(obj.foo, 42) + self.assertEqual(obj.bar, 'meow') + self.assertRemotes() + + def test_static_result(self): + ctxt = context.get_admin_context() + obj = MyObj.get(ctxt) + self.assertEqual(obj.bar, 'bar') + result = obj.marco() + self.assertEqual(result, 'polo') + self.assertRemotes() + + def test_updates(self): + ctxt = context.get_admin_context() + obj = MyObj.get(ctxt) + self.assertEqual(obj.foo, 1) + obj.update_test() + self.assertEqual(obj.bar, 'updated') + self.assertRemotes() + + def test_base_attributes(self): + dt = datetime.datetime(1955, 11, 5) + obj = MyObj() + obj.created_at = dt + obj.updated_at = dt + obj.deleted_at = None + expected = {'ironic_object.name': 'MyObj', + 'ironic_object.namespace': 'ironic', + 'ironic_object.version': '1.5', + 'ironic_object.changes': + ['created_at', 'deleted_at', 'updated_at'], + 'ironic_object.data': + {'created_at': timeutils.isotime(dt), + 'updated_at': timeutils.isotime(dt), + 'deleted_at': None} + } + self.assertEqual(obj.obj_to_primitive(), expected) + + +class TestObject(_LocalTest, _TestObject): + pass