cachetools icon indicating copy to clipboard operation
cachetools copied to clipboard

Calling a cached function with default arguments does not read from cache

Open zmeir opened this issue 7 months ago • 2 comments

Given the following simple example:

@cachetools.cached(cachetools.Cache(maxsize=100))
def foo(a=1):
    print(a)

This is the result of calling foo(), foo(1) and foo(a=1):

>>> foo()
1
>>> foo()
>>> foo(1)
1
>>> foo(1)
>>> foo(a=1)
1
>>> foo(a=1)

One would expect that foo would only be executed once instead of 3 times:

>>> foo()
1
>>> foo()
>>> foo(1)
>>> foo(1)
>>> foo(a=1)
>>> foo(a=1)

zmeir avatar Apr 06 '25 07:04 zmeir

As a very rough draft, I think this can be handled in the decorator wrapper using the inspect module to bind and apply the default arguments before calculating the cache key and accessing the cache:

import inspect

def func(a, b=1, *args, c, d=2, **kwargs):
    print(a, b, args, c, d, kwargs)


def wrapper(*args, **kwargs):
    bound_args = inspect.signature(func).bind_partial(*args, **kwargs)
    bound_args.apply_defaults()

    args = []
    kwargs = {}
    collecting_args = True
    for key, val in bound_args.arguments.items():
        if collecting_args:
            if key == "args":
                args.extend(val)
                collecting_args = False
            else:
                args.append(val)
        else:
            if key == "kwargs":
                kwargs.update(val)
            else:
                kwargs[key] = val
    args = tuple(args)

    ... # handle cache as before

    return func(*args, **kwargs)

zmeir avatar Apr 06 '25 08:04 zmeir

Good point, and thanks for sharing!

It's an issue with the way decorators handle their arguments, which also affects the Standard Library's functools.lru_cache implementation, that I, quite frankly, haven't been aware of until now...

However, since this is the first time in 10+ years of cachetools development that this has been brought up, AFAICR, and I do not think it is something that'll come up too often in practice, so instead of going through hoops to solve this using inspect etc., I think a side note in the documentation would be sufficient.

A documentation PR would be welcome!

tkem avatar Apr 07 '25 19:04 tkem

I just came across this myself - first time using cachetools 😆 It is a little more insidious. The cache distinguishes between default arguments, args-passed and keyword-passed values

In my case I wanted to cache an api request, and had to ensure that the defaulted parameters were all passed (or not passed) the same way

>>> @cached(cache=TTLCache(maxsize=1024, ttl=600))
... def foo(a: int, b: int = None) -> int:
...         print(a, b)
...
>>> foo(1)
1 None
>>> foo(1, None)
1 None
>>> foo(1, b=None)
1 None
>>> foo(1, b=None)
>>> foo(1, None)
>>> foo(1)

MikeD72 avatar Oct 03 '25 06:10 MikeD72

My way around this is to use a wrapper function around a private cached implementation with strict argument requirements

>>> def foo(a: int, b: int = None) -> int:
...         _foo(a=a, b=b)
...
>>> @cached(cache=TTLCache(maxsize=1024, ttl=600))
... def _foo(*, a: int, b: int) -> int:
...         print(a, b)
...
>>> foo(1)
1 None
>>> foo(1, None)
>>> foo(1, b=None)
>>> foo(1, b=None)
>>> foo(1, None)
>>> foo(1)

zmeir avatar Oct 03 '25 08:10 zmeir