diff --git a/tobiko/__init__.py b/tobiko/__init__.py index 17ab5092d..c3b458123 100644 --- a/tobiko/__init__.py +++ b/tobiko/__init__.py @@ -14,6 +14,7 @@ from __future__ import absolute_import from tobiko.common import _asserts +from tobiko.common import _cached from tobiko.common import _config from tobiko.common import _detail from tobiko.common import _exception @@ -33,6 +34,9 @@ details_content = _detail.details_content FailureException = _asserts.FailureException fail = _asserts.fail +cached = _cached.cached +CachedProperty = _cached.CachedProperty + tobiko_config = _config.tobiko_config tobiko_config_dir = _config.tobiko_config_dir tobiko_config_path = _config.tobiko_config_path diff --git a/tobiko/common/_cached.py b/tobiko/common/_cached.py new file mode 100644 index 000000000..20beafef4 --- /dev/null +++ b/tobiko/common/_cached.py @@ -0,0 +1,140 @@ +# Copyright 2020 Red Hat +# +# 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 __future__ import absolute_import + + +NOT_CACHED = object() + + +class CachedProperty(object): + """ Property that calls getter only the first time it is required + + It invokes property setter with the value returned by getter the first time + it is required so that it is not requested again until del operator is not + called again on top of target object. + + Implements default setter and deleter behaving as a regular attribute would + by setting or removing named attribute of target object __dict__ + + The name used for storing cached property value is got from getter function + name if not passed as constructor attribute. + + Examples of use: + + class MyClass(object): + + @cached + def my_property(self): + return object() + + + obj = MyClass() + # my_property method not yet called + assert 'my_property' not in obj.__dict__ + + # my_property method is called + first_value = obj.my_property + assert obj.__dict__['my_property'] is first_value + + # my_property method is not called again + assert obj.my_property is first_value + + # first value is removed from dictionary + del obj.my_property + assert 'my_property' not in obj.__dict__ + + # my_property method is called + second_value = obj.my_property + assert obj.__dict__['my_property'] is second_value + + # value returned by second call of method can be different + second_value is not first_value + + For more details about how Python properties works please refers to + language documentation [1] + + [1] https://docs.python.org/3/howto/descriptor.html + """ + + fget = None + fset = None + fdel = None + __doc__ = None + cached_id = None + + def __init__(self, fget=None, fset=None, fdel=None, doc=None, + cached_id=None): + if fget: + self.getter(fget) + if fset: + self.setter(fset) + if fdel: + self.deleter(fdel) + if doc: + self.__doc__ = doc + if cached_id: + self.cached_id = cached_id + elif self.cached_id is None: + self.cached_id = '_cached_' + str(id(self)) + + def getter(self, fget): + assert callable(fget) + self.fget = fget + if self.__doc__ is None: + self.__doc__ = fget.__doc__ + return fget + + def setter(self, fset): + self.fset = fset + return fset + + def deleter(self, fdel): + self.fdel = fdel + return fdel + + def __get__(self, obj, _objtype=None): + if obj is None: + return self + + value = self._get_cached(obj) + if value is NOT_CACHED: + if self.fget is None: + raise AttributeError("Cached property has no getter method") + value = self.fget(obj) + self.__set__(obj, value) + + return value + + def __set__(self, obj, value): + if self.fset: + self.fset(obj, value) + self._set_cached(obj, value) + + def __delete__(self, obj): + if self.fdel: + self.fdel(obj) + self._delete_cached(obj) + + def _get_cached(self, obj): + return getattr(obj, self.cached_id, NOT_CACHED) + + def _set_cached(self, obj, value): + setattr(obj, self.cached_id, value) + + def _delete_cached(self, obj): + return obj.__dict__.pop(self.cached_id, NOT_CACHED) + + +def cached(*args, **kwargs): + return CachedProperty(*args, **kwargs) diff --git a/tobiko/tests/unit/test_cached.py b/tobiko/tests/unit/test_cached.py new file mode 100644 index 000000000..fa45a9bc9 --- /dev/null +++ b/tobiko/tests/unit/test_cached.py @@ -0,0 +1,276 @@ +# Copyright 2020 Red Hat +# +# 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 __future__ import absolute_import + +import typing # noqa + +import tobiko +from tobiko.tests import unit + + +NOT_CALLED = object() + + +class TestCached(unit.TobikoUnitTest): + + my_property = tobiko.cached(doc='some doc') + + def test_init(self): + prop = type(self).my_property + self.assertIsNone(prop.fget) + self.assertIsNone(prop.fset) + self.assertIsNone(prop.fdel) + self.assertEqual('some doc', prop.__doc__) + self.assertTrue(prop.cached_id) + + def test_get(self): + ex = self.assertRaises(AttributeError, lambda: self.my_property) + self.assertEqual("Cached property has no getter method", str(ex)) + + def test_set(self): + self.my_property = value = object() + self.assertIs(value, self.my_property) + + def test_delete(self): + self.my_property = object() + del self.my_property + ex = self.assertRaises(AttributeError, lambda: self.my_property) + self.assertEqual("Cached property has no getter method", str(ex)) + + +class TestCachedWithGetter(unit.TobikoUnitTest): + + @tobiko.cached + def my_property(self): + return object() + + def test_init(self): + # pylint: disable=no-member + prop = type(self).my_property + assert isinstance(prop, tobiko.CachedProperty) + self.assertTrue(callable(prop.fget)) + self.assertIsNone(prop.fset) + self.assertIsNone(prop.fdel) + self.assertIs(prop.fget.__doc__, prop.__doc__) + self.assertTrue(prop.cached_id) + + def test_get(self): + value = self.my_property + self.assertIs(value, self.my_property) + + def test_set(self): + self.my_property = value = object() + self.assertIs(value, self.my_property) + + def test_delete(self): + self.my_property = value = object() + del self.my_property + self.assertIsNot(value, self.my_property) + + +class TestCachedWithSetter(unit.TobikoUnitTest): + + _set_value = None + + def set_my_property(self, value): + self._set_value = value + + my_property = tobiko.cached(fset=set_my_property) + + def test_init(self): + prop = type(self).my_property + self.assertIsNone(prop.fget) + self.assertIs(type(self).set_my_property, prop.fset) + self.assertIsNone(prop.fdel) + self.assertIsNone(prop.__doc__) + self.assertTrue(prop.cached_id) + + def test_get(self): + self.assertFalse(hasattr(self, 'my_property')) + self.assertIsNone(self._set_value) + + def test_set(self): + self.my_property = value = object() + self.assertIs(value, self.my_property) + self.assertIs(value, self._set_value) + + def test_delete(self): + self.my_property = object() + del self.my_property + self.assertFalse(hasattr(self, 'my_property')) + + +class TestCachedWithDeleter(unit.TobikoUnitTest): + + _delete_value = None + + def delete_my_property(self): + self._delete_value = object() + + my_property = tobiko.cached(fdel=delete_my_property) + + def test_init(self): + prop = type(self).my_property + self.assertIsNone(prop.fget) + self.assertIsNone(prop.fset) + self.assertIs(type(self).delete_my_property, prop.fdel) + self.assertIsNone(prop.__doc__) + self.assertTrue(prop.cached_id) + + def test_get(self): + self.assertFalse(hasattr(self, 'my_property')) + self.assertIsNone(self._delete_value) + + def test_set(self): + self.my_property = value = object() + self.assertIs(value, self.my_property) + self.assertIsNone(self._delete_value) + + def test_delete(self): + self.my_property = object() + del self.my_property + self.assertFalse(hasattr(self, 'my_property')) + self.assertIsNotNone(self._delete_value) + + +class TestCachedWithDoc(unit.TobikoUnitTest): + + my_doc = 'some doc' + + my_property = tobiko.cached(doc=my_doc) + + @my_property.getter + def get_my_property(self): + return object() + + def test_init(self): + prop = type(self).my_property + self.assertTrue(callable(prop.fget)) + self.assertIsNone(prop.fset) + self.assertIsNone(prop.fdel) + self.assertIs(self.my_doc, prop.__doc__) + self.assertTrue(prop.cached_id) + + +class TestCachedWithCachedId(unit.TobikoUnitTest): + + cached_id = 'my_cached_id' + + my_property = tobiko.cached(cached_id=cached_id) + + @my_property.getter + def get_my_property(self): + return object() + + def test_init(self): + prop = type(self).my_property + self.assertTrue(callable(prop.fget)) + self.assertIsNone(prop.fset) + self.assertIsNone(prop.fdel) + self.assertIsNone(prop.__doc__) + self.assertEqual(self.cached_id, prop.cached_id) + self.assertFalse(hasattr(self, 'my_cached_id')) + + def test_get(self): + # pylint: disable=no-member + value = self.my_property + self.assertIs(value, self.my_property) + self.assertIs(value, self.my_cached_id) + + def test_set(self): + # pylint: disable=no-member + self.my_property = value = object() + self.assertIs(value, self.my_property) + self.assertIs(value, self.my_cached_id) + + def test_delete(self): + # pylint: disable=no-member + self.my_property = value = object() + del self.my_property + self.assertIsNot(value, self.my_property) + self.assertIsNot(value, self.my_cached_id) + + +class TestCachedWithDecorators(unit.TobikoUnitTest): + + my_property = tobiko.cached() + + getter_called = 0 + + @my_property.getter + def getter(self): + """Getter documentation""" + self.getter_called += 1 + return object() + + setter_called = tuple() # type: typing.Tuple + + @my_property.setter + def setter(self, value): + self.setter_called += (value,) + + deleter_called = 0 + + @my_property.deleter + def deleter(self): + self.deleter_called += 1 + + def test_init(self): + # self.my_property method not called yet + prop = type(self).my_property + self.assertIs(type(self).getter, prop.fget) + self.assertIs(type(self).setter, prop.fset) + self.assertIs(type(self).deleter, prop.fdel) + self.assertIs(self.getter.__doc__, prop.__doc__) + self.assertTrue(prop.cached_id) + + def test_get(self): + # my_property method is called + prop = type(self).my_property + value = self.my_property + self.assertEqual(1, self.getter_called) + self.assertIs(value, getattr(self, prop.cached_id)) + self.assertEqual((value,), self.setter_called) + return value + + def test_get_twice(self): + # my_property method is not called again + value1 = self.test_get() + value2 = self.test_get() + self.assertIs(value1, value2) + + def test_set(self): + # property value is stored into the object + prop = type(self).my_property + self.my_property = value = object() + self.assertIs(value, self.my_property) + self.assertEqual(0, self.getter_called) + self.assertEqual(value, self.setter_called[-1]) + self.assertIs(value, getattr(self, prop.cached_id)) + return value + + def test_set_twice(self): + # stored value is got after setting + value1 = self.test_set() + value2 = self.test_set() + self.assertIsNot(value1, value2) + + def test_delete(self): + # deleting remove stored value + prop = type(self).my_property + value = self.test_set() + del self.my_property + self.assertFalse(hasattr(self, prop.cached_id)) + self.assertIsNot(value, self.my_property) + self.assertEqual(1, self.deleter_called)