youknowone / methodtools

Expand functools features(lru_cache) to class - methods, classmethods, staticmethods and even for (unofficial) hybrid methods.

Home Page:https://pypi.org/project/methodtools/

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Automatically clearing/invalidating the lru cache

pbsds opened this issue · comments

lru_cache introduces a correctness problem for methods or properties whose output depend on attributes stored in self.
Of course you can manually call clear_cache, but

  1. this is a leaky abstraction
  2. this method is highly inaccessible for properties

Here i explore how the cache behaves, along with my suggested solution:

problem exploration
from methodtools import lru_cache

# no magic here
class Foo:
    a = 1
    b = 2

    @lru_cache()
    @property
    def prop(self):
        return self.a + self.b

    @lru_cache()
    def method(self):
        return self.a + self.b


# prints __setattr__ events
class Bar(Foo):
    def __setattr__(self, key, value):
        print(f"{self.__class__.__name__}.__setattr__({key!r}, {value!r})")
        return super().__setattr__(key, value)


# clears its cache on relevant __setattr__ events
class Baz(Foo):
    def __setattr__(self, key, value):
        print(f"{self.__class__.__name__}.__setattr__({key!r}, {value!r})")
        if not key.startswith("__wire|"):
            for attr in dir(self):
                if attr.startswith("__wire|"):
                    getattr(self, attr).cache_clear()
        return super().__setattr__(key, value)

# tests

print("-"*10)

for i in (Foo, Bar, Baz):
    obj = i()

    print(f"{obj.prop = }, {obj.method() = }")

    obj.a = 4
    print(f"{obj.prop = }, {obj.method() = }")

    getattr(obj, "__wire|Foo|prop").cache_clear()
    print(f"{obj.prop = }, {obj.method() = }")

    print("-"*10)
output
----------
obj.prop = 3, obj.method() = 3
obj.prop = 3, obj.method() = 3
obj.prop = 6, obj.method() = 3
----------
Bar.__setattr__('__wire|Foo|prop', <methodtools._LruCacheWire object at 0x7f23675248e0>)
Bar.__setattr__('__wire|Foo|method', <methodtools._LruCacheWire object at 0x7f2367524940>)
obj.prop = 3, obj.method() = 3
Bar.__setattr__('a', 4)
obj.prop = 3, obj.method() = 3
obj.prop = 6, obj.method() = 3
----------
Baz.__setattr__('__wire|Foo|prop', <methodtools._LruCacheWire object at 0x7f23675249a0>)
Baz.__setattr__('__wire|Foo|method', <methodtools._LruCacheWire object at 0x7f2367524a00>)
obj.prop = 3, obj.method() = 3
Baz.__setattr__('a', 4)
obj.prop = 6, obj.method() = 6
obj.prop = 6, obj.method() = 6
----------

My suggested solution is an optional mixin class, that can be added as a parent class:

class InvalidateLRUOnWriteMixin:
    def __setattr__(self, key, value):
        if not key.startswith("__wire|"):
            for attr in dir(self):
                if attr.startswith("__wire|"):
                    getattr(self, attr).cache_clear()
        return super().__setattr__(key, value)

It clears all caches each time you write to an attribute stored in the class.
Although a pessimistic approach, it does work.

Do you want a PR? Comments?