cachetools
cachetools copied to clipboard
Calling a cached function with default arguments does not read from cache
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)
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)
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!
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)
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)