HiveBrain v1.2.0
Get Started
← Back to all entries
patternpythonMinor

Python property() implementation that caches getter while still allowing a setter

Submitted by: @import:stackexchange-codereview··
0
Viewed 0 times
allowingwhilestillgettersetterpropertythatcachespythonimplementation

Problem

For a session implementation I needed a property that caches its getter (since it involves a database lookup) but still allows modifications (e.g. assigning a new user and storing that user's id in the session). To make this as comfortable as possibly I subclassed property and added the caching logic to the descriptor methods.

While it works fine and I think I covered all important cases with my unittest I'd prefer if someone else looked over it, too, just in case I missed something.

The code

def cached_writable_property(cache_attr, cache_on_set=True):
    class _cached_writable_property(property):
        def __get__(self, obj, objtype=None):
            if obj is not None and self.fget and hasattr(obj, cache_attr):
                return getattr(obj, cache_attr)
            value = property.__get__(self, obj, objtype)
            setattr(obj, cache_attr, value)
            return value

        def __set__(self, obj, value):
            property.__set__(self, obj, value)
            if cache_on_set:
                setattr(obj, cache_attr, value)
            else:
                try:
                    delattr(obj, cache_attr)
                except AttributeError:
                    pass

        def __delete__(self, obj):
            property.__delete__(self, obj)
            try:
                delattr(obj, cache_attr)
            except AttributeError:
                pass

    return _cached_writable_property


The unittest

```
if __name__ == '__main__':
import unittest

class CannotSet(Exception):
pass

class _Test(dict):
def __init__(self, *args, **kw):
dict.__init__(self, *args, **kw)
self.log = []

@cached_writable_property('_a', True)
def a(self):
self.log.append('get a')
return self['a'][0]
@a.setter
def a(self, val):
self.log.append('set a')
self['a'] = (val, 'xyz')
@a.deleter
def a(self):

Solution

If you can afford to make your class hashable, and you read more often than you need to update the values inside (because an update to any property will invalidate the cache for all the other ones, and for this reason I set the maxsize to 1), it's probably much simpler to just combine property and functools.lru_cache

from functools import lru_cache

if __name__ == '__main__':
    import unittest

    class CannotSet(Exception):
        pass

    class _Test(dict):
        def __init__(self, *args, **kw):
            dict.__init__(self, *args, **kw)
            self.log = []

        def __hash__(self):
            return hash(tuple(sorted(self.items())))

        @property
        @lru_cache(1)
        def a(self):
            self.log.append('get a')
            return self['a'][0]
        @a.setter
        def a(self, val):
            self.log.append('set a')
            self['a'] = (val, 'xyz')
        @a.deleter
        def a(self):
            self.log.append('del a')
            del self['a']

        @property
        @lru_cache(1)
        def b(self):
            self.log.append('get b')
            return self['b'][0]
        @b.setter
        def b(self, val):
            self.log.append('set b')
            self['b'] = (val, 'lol')

        @property
        @lru_cache(1)
        def c(self):
            self.log.append('get c')
            return self['c'][0]
        @c.setter
        def c(self, val):
            self.log.append('set c')
            raise CannotSet

    class TestCachedWritableProperty(unittest.TestCase):
        def test_cwp(self):
            _log = []
            def _expect_log(obj, entry):
                if entry is not None:
                    _log.append(entry)
                self.assertEqual(obj.log, _log)

            t = _Test(b=('initial-b',))
            # Does not exist => error from getter
            self.assertRaises(KeyError, lambda: t.a)
            _expect_log(t, 'get a')
            # Exists but not cached => result from getter
            self.assertEquals(t.b, 'initial-b')
            _expect_log(t, 'get b')
            # Exists and cached => result from cache
            self.assertEquals(t.b, 'initial-b')
            _expect_log(t, None)

            # Modify, cache on set
            t.a = 'new-a'
            _expect_log(t, 'set a')
            # Read
            self.assertEquals(t.a, 'new-a')
            _expect_log(t, 'get a')
            # Read, from cache again
            self.assertEquals(t.a, 'new-a')
            _expect_log(t, None)

            # Delete, should be removed from cache, too
            del t.a
            _expect_log(t, 'del a')
            self.assertRaises(KeyError, lambda: t.a)
            self.assertRaises(KeyError, lambda: t['a'])
            _expect_log(t, 'get a')

            # Modify, do not cache on set
            t.b = 'new-b'
            _expect_log(t, 'set b')
            # Read, not from cache
            self.assertEquals(t.b, 'new-b')
            _expect_log(t, 'get b')
            # Read, from cache
            self.assertEquals(t.b, 'new-b')
            _expect_log(t, None)

            # Test failing setters
            self.assertRaises(KeyError, lambda: t.c)
            _expect_log(t, 'get c')
            self.assertRaises(CannotSet, lambda: setattr(t, 'c', 'nope'))
            _expect_log(t, 'set c')
            t['c'] = ('initial-c',)
            self.assertEquals(t.c, 'initial-c')
            _expect_log(t, 'get c')
            self.assertRaises(CannotSet, lambda: setattr(t, 'c', 'nope'))
            _expect_log(t, 'set c')
            self.assertEquals(t.c, 'initial-c')
            _expect_log(t, None)

    unittest.main()

Code Snippets

from functools import lru_cache

if __name__ == '__main__':
    import unittest

    class CannotSet(Exception):
        pass

    class _Test(dict):
        def __init__(self, *args, **kw):
            dict.__init__(self, *args, **kw)
            self.log = []

        def __hash__(self):
            return hash(tuple(sorted(self.items())))

        @property
        @lru_cache(1)
        def a(self):
            self.log.append('get a')
            return self['a'][0]
        @a.setter
        def a(self, val):
            self.log.append('set a')
            self['a'] = (val, 'xyz')
        @a.deleter
        def a(self):
            self.log.append('del a')
            del self['a']


        @property
        @lru_cache(1)
        def b(self):
            self.log.append('get b')
            return self['b'][0]
        @b.setter
        def b(self, val):
            self.log.append('set b')
            self['b'] = (val, 'lol')

        @property
        @lru_cache(1)
        def c(self):
            self.log.append('get c')
            return self['c'][0]
        @c.setter
        def c(self, val):
            self.log.append('set c')
            raise CannotSet


    class TestCachedWritableProperty(unittest.TestCase):
        def test_cwp(self):
            _log = []
            def _expect_log(obj, entry):
                if entry is not None:
                    _log.append(entry)
                self.assertEqual(obj.log, _log)

            t = _Test(b=('initial-b',))
            # Does not exist => error from getter
            self.assertRaises(KeyError, lambda: t.a)
            _expect_log(t, 'get a')
            # Exists but not cached => result from getter
            self.assertEquals(t.b, 'initial-b')
            _expect_log(t, 'get b')
            # Exists and cached => result from cache
            self.assertEquals(t.b, 'initial-b')
            _expect_log(t, None)

            # Modify, cache on set
            t.a = 'new-a'
            _expect_log(t, 'set a')
            # Read
            self.assertEquals(t.a, 'new-a')
            _expect_log(t, 'get a')
            # Read, from cache again
            self.assertEquals(t.a, 'new-a')
            _expect_log(t, None)

            # Delete, should be removed from cache, too
            del t.a
            _expect_log(t, 'del a')
            self.assertRaises(KeyError, lambda: t.a)
            self.assertRaises(KeyError, lambda: t['a'])
            _expect_log(t, 'get a')

            # Modify, do not cache on set
            t.b = 'new-b'
            _expect_log(t, 'set b')
            # Read, not from cache
            self.assertEquals(t.b, 'new-b')
            _expect_log(t, 'get b')
            # Read, from cache
            self.assertEquals(t.b, 'new-b')
            _expect_log(t, None)

            # Test failing setters
            self.assertRaises(KeyError, lambda: t.c)
            _expect_log(t, 'get c')
            self.assertRaises(CannotSet, lambda: setattr(t, '

Context

StackExchange Code Review Q#26836, answer score: 2

Revisions (0)

No revisions yet.