Implement cached property tool
Change-Id: I169265771f018d56a518357615afafbe3bf4885a
This commit is contained in:
parent
48795f0a47
commit
5f143a6e69
@ -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
|
||||
|
140
tobiko/common/_cached.py
Normal file
140
tobiko/common/_cached.py
Normal file
@ -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)
|
276
tobiko/tests/unit/test_cached.py
Normal file
276
tobiko/tests/unit/test_cached.py
Normal file
@ -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)
|
Loading…
Reference in New Issue
Block a user