Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Using lru_cache on a class that has a classmethod

I want to cache instances of a class because the constructor is complicated and repeated uses won't alter the contents. This works great. However, I want to create a classmethod to clear the cache. In the real code, clear will also clear file cache in addition to memory cache.

The following is an MWE that works, except for calling the clear

from functools import lru_cache
        
@lru_cache(maxsize=None)
class MyClass:
    def __init__(self, x):
        print(f"Initializing with {x}")
        self.x = x

    @classmethod
    def clear(cls):
        print('clearing cache')
        cls.cache_clear()

a = MyClass(1)
b = MyClass(1)
print(a is b)
try: MyClass.clear()
except Exception as e: print(e)  # <-- 'classmethod' object is not callable
# MyClass.cache_clear() does work however
c = MyClass(1)
like image 376
steveo225 Avatar asked May 30 '26 05:05

steveo225


2 Answers

update

TL;DR: lru_cache is intended to functions, not classes: the wrapped object it returns is not good as a class replacement, and things like, attribute retrieval are messed up with (and therefore, a @classmethod is not retrieved as a callable class method).

But when classes are instantiated, that passes through their metaclass' __call__ method - it turns out that applying lru_cache to that method in the metaclass of MyClass gets you all the benefits and none of the drawbacks of your approach.

NB: there are lots of recipes/answers around suggesting this metaclass __call__ approach to have the "singleton" pattern in Python. I strongly oppose those as over-engineering. Metaclasses can be complicated - (and in Python all you need is to create your singleton at a top level variable and pass that around as your singleton). But for your case, this seems to get very straightforward:


class M(type):
    @lru_cache
    def __call__(cls, *args, **kwargs):
        return super().__call__(*args, **kwargs)

class MyClass(metaclass=M):
    def __init__(self, x):
        print(f"Initializing with {x}")
        self.x = x

    @classmethod
    def clear(cls):
        type(cls).__call__.cache_clear()
        print('clearing cache')




And:

In [93]: a = MyClass(1)
    ...: b = MyClass(1)
    ...: print(a is b)
    ...: MyClass.clear()
    ...: c = MyClass(1)
    ...: print(a is c)
Initializing with 1
True
clearing cache
Initializing with 1
False

You can either create a separate metaclass for each class where you need that approach, or re-use the same metaclass for all of those - just in this case, the .cache_clear call would clear the cache for all classes. If that is not desirable, you might just get some custom caching logic inside M.__call__ itself, instead of a plain lru_cache applied to it, so you have separate caches for each cls.

original answer

Although classes can be instantiated just by been called, they are not the same thing as a function - and it is a bit surprising for me that plain lru_cache works as directly as a class decorator.

(Although, indeed, it doesn't need to know about what is the callable it is caching. So maybe, it is no wonder it works. But - the decorated class is no longer a class, so it can't be inherited from, and retrieving attributes from it change in subtle forms)

So, some of the problems you are finding are that now MyClass is not a class, rather it is an instance of internal _lru_cache_wrapper, and then, trying to retrieve the class-method from MyClass will not activate the descriptor protocol, and return you an "unbound classmethod", which can't be called.

You can retrieve the original, undecorated class, from which you can call the class method, in the attribute MyClass.__wrapped__.clear(). However, by calling the original clear() method, it will receive the undecorated class as cls and that one doesn't have the cache_clear method.

The object which gets the lru_cache cache_clear method, btw, is the decorated class - so, MyClass.cache_clear() did work - as the method is in the _lru_cache_wrapper instance.

In short, a lot of the strange behavior you are getting is due to the fact you are applying a decorator that was not thought of being used for classes - it just happens to work up to a point. Possibly you could make things easier to understand and maintain if you instantiate the class inside a factory function - and decorate that factory function. That way, it is clear and easy to distinguish what is the class, and what is the _lru_cache_wrapper instance - I guess it should be trivial from there:


class MyClass:
    def __init__(self, x):
        print(f"Initializing with {x}")
        self.x = x

    @classmethod
    def clear(cls):
        print('clearing cache')
        # A forward reference to the factory. 
        # No problem, since this should be called just after
        # the factory is declared
        myclass_factory.cache_clear()
        # code to further clear files and etc:
        ...

@lru_cache(maxsize=None)
def myclass_factory(*args, **kwargs):
    return MyClass(*args, **kwargs)

a = myclass_factory(1)
b = myclass_factory(1)
print(a is b)
try: 
    MyClass.clear()
except Exception as e: 
    print(e) 
c = myclass_factory(1)


Oh - it turns out the "factory function" itself can be declared as a classmethod, so things could get even closer to what you intended there:


class MyClass:
    ...

    @classmethod
    @lru_cache
    def cached(cls, *args, **kwargs):
        return MyClass(*args, **kwargs)

    @classmethod
    def clear(cls):
        print('clearing cache')
        cls.cached.cache_clear()


a = MyClass.cached(1)
...

like image 76
jsbueno Avatar answered May 31 '26 18:05

jsbueno


Based on how @functools.lru_cache works, I'm not sure you should be decorating a class with it - as you may know already, all that does is reduce the decorated object's interface to being callable and cache-clearable; any descriptors (like @classmethods) rely on the underlying object being a class, so they stop working.


If you don't expect MyClass to participate in complex inheritance trees, I would opt for an interface which looks like this:

class MyClass:
    @dont_run_if_cached
    def __init__(self, x):
        print(f"Initializing with {x}")
        self.x = x

    @lru_cache(maxsize=None)
    def __new__(cls, *args, **kwargs):
        self = super().__new__(cls)
        # Explicit initialisation; **not** `self.__init__(*args, **kwargs)`,
        #   due to an implementation detail of `@dont_run_if_cached`.
        cls.__init__(self, *args, **kwargs)
        return self

    @classmethod
    def clear(cls):
        print("clearing cache")
        cls.__new__.cache_clear()
>>> a = MyClass(1)
Initializing with 1
>>> b = MyClass(1)
>>> print(a is b)
True
>>> MyClass.clear()
clearing cache
>>> c = MyClass(1)
Initializing with 1
>>> print(c is a)
False

Here, we call __init__ explicitly in __new__ in the line cls.__init__(self, *args, **kwargs). This is because we know __new__ runs if the object wasn't cached, so it's also definitely an appropriate time to run __init__.

The trick is then to implement @dont_run_if_cached, decorated on __init__. Knowing that builtins.type is the default metaclass, we take advantage of how __init__ gets called normally in builtins.type.__call__, which is approximately this:

class type:
    ...
    def __call__(cls, *args, **kwargs):
        self = cls.__new__(cls, *args, **kwargs)
        if isinstance(self, cls):
            # Note: not `cls.__init__(self, *args, **kwargs)`
            self.__init__(*args, **kwargs)
        return self
    ...

type.__call__ accesses __init__ from self, so we need to make sure that this access returns a callable that does absolutely nothing. This access method is explicitly different from how we do it in __new__ (cls.__init__(self, *args, **kwargs)). We can distinguish the two accesses via a descriptor:

from types import MethodType

_noop_init = lambda *args, **kwargs: None

class dont_run_if_cached:
    __slots__ = ("_init",)

    def __init__(self, init, /) -> None:
        self._init = init

    def __get__(self, instance, owner, /):
        if instance is None:
            # Called from `MyClass.__new__` via `cls.__init__(self, *args, **kwargs)`
            return self._init
        elif (
            owner.__init__ is not self._init
        ):  # Called from an overridden `__init__` which internally calls
            # `super().__init__`
            return MethodType(self._init, instance)
        else:
            # Called from `self.__init__(*args, **kwargs)` in `type.__call__`
            return _noop_init

The __get__ implementation above has a second condition which allows you to override __init__ and do super().__init__ calls, with the caveat that the overridden __init__ must also be decorated with @dont_run_if_cached. There are ways to automate this decoration (e.g. in __init_subclass__), but I'll leave that for your specific use-case.

Finally, some points about extending this:

  • The cache is owned by the MyClass.__new__ staticmethod, so MyClass.clear will clear the same cache shared across all subclasses, which are those where __new__ is not overridden. If you want each class to have its own cache that's clearable, you'll need to make some changes depending on what exactly you need.
  • This __new__ implementation assumes the next item in the MRO is builtins.object, and doesn't shuttle any *args, **kwargs across to super().__new__. You'll need to figure out how to shuttle arguments if this assumption doesn't fulfil your needs.

Implementation which plays nicely with static type-checkers given below:

from __future__ import annotations

from typing import TYPE_CHECKING, Generic
from typing_extensions import ParamSpec

from functools import lru_cache as _lru_cache
from types import MethodType


_CtorP = ParamSpec("_CtorP")
if TYPE_CHECKING:
    from collections.abc import Callable
    from typing_extensions import Any, Concatenate, Final, Self, TypeAliasType, TypeVar

    _Init = TypeAliasType(
        "_Init", Callable[Concatenate[Any, _CtorP], None], type_params=(_CtorP,)
    )
    _ClsT = TypeVar("_ClsT", bound=type[Any])
    _F = TypeVar("_F", bound=Callable[..., Any])
    _P = ParamSpec("_P")
    _R = TypeVar("_R")

    dont_run_if_cached: Callable[[_F], _F]

    def _with_signature(
        f: Callable[_P, Any], /
    ) -> Callable[[Callable[..., _R]], Callable[_P, _R]]:
        return lambda _, /: _

    @_with_signature(_lru_cache)
    def lru_cache(*args: Any, **kwargs: Any) -> Callable[[_F], _F]: ...
else:
    lru_cache = _lru_cache


_noop_init: Final = lambda *args, **kwargs: None


def init_to_new(
    init: _Init[_CtorP], /
) -> Callable[
    [Callable[Concatenate[_ClsT, ...], _R]], Callable[Concatenate[_ClsT, _CtorP], _R]
]:
    return lambda _, /: _


class _dont_run_if_cached(Generic[_CtorP]):
    __slots__ = ("_init",)
    _init: _Init[_CtorP]

    def __init__(self, init: _Init[_CtorP], /) -> None:
        self._init = init

    def __get__(self, instance: Any, owner: type[object], /) -> _Init[_CtorP]:
        if instance is None:
            # Called from `MyClass.__new__` via `cls.__init__(self, *args, **kwargs)`
            return self._init
        elif (
            owner.__init__ is not self._init
        ):  # Called from an overridden `__init__` which internally calls
            # `super().__init__`
            return MethodType(self._init, instance)
        else:
            # Called from `self.__init__(*args, **kwargs)` in `type.__call__`
            return _noop_init


if not TYPE_CHECKING:
    dont_run_if_cached = _dont_run_if_cached


class MyClass:
    @dont_run_if_cached
    def __init__(self, x: object) -> None:
        print(f"Initializing with {x}")
        self.x = x

    @lru_cache(maxsize=None)
    @init_to_new(__init__)
    def __new__(cls, *args: Any, **kwargs: Any) -> Self:
        self: Final = super().__new__(cls)
        cls.__init__(self, *args, **kwargs)
        return self

    @classmethod
    def clear(cls) -> None:
        print("clearing cache")
        cls.__new__.cache_clear()  # type: ignore[attr-defined]

like image 21
dROOOze Avatar answered May 31 '26 19:05

dROOOze



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!