Skip to content

Commit be782da

Browse files
committed
Let memoize's ignore set match a positional arg by name
ignore={'session'} only worked when session was passed as a keyword. Call the same function with session passed positionally instead and diskcache tried to build a cache key that included the raw argument, which is exactly the footgun described in #240: a caller has to know whether they always call with keywords, or ignore has to spell out the positional index too. memoize() now grabs the wrapped function's parameter names once via inspect.signature and passes them through to args_to_key, which checks a positional argument's name against ignore in addition to its index. Keyword matching was already fine and is untouched. args_to_key's new arg_names parameter defaults to an empty tuple, so the two other callers (DjangoCache.memoize, the memoize_stampede recipe) keep their existing index-only behavior unless they're updated separately. Added a regression test using an unpicklable value for the ignored argument, so a caller passing it positionally raises immediately if it leaks into the key instead of silently caching under a wrong key.
1 parent ebfa37c commit be782da

2 files changed

Lines changed: 38 additions & 4 deletions

File tree

‎diskcache/core.py‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import contextlib as cl
66
import errno
77
import functools as ft
8+
import inspect
89
import io
910
import json
1011
import os
@@ -384,18 +385,26 @@ class EmptyDirWarning(UserWarning):
384385
"""Warning used by Cache.check for empty directories."""
385386

386387

387-
def args_to_key(base, args, kwargs, typed, ignore):
388+
def args_to_key(base, args, kwargs, typed, ignore, arg_names=()):
388389
"""Create cache key out of function arguments.
389390
390391
:param tuple base: base of key
391392
:param tuple args: function arguments
392393
:param dict kwargs: function keyword arguments
393394
:param bool typed: include types in cache key
394395
:param set ignore: positional or keyword args to ignore
396+
:param tuple arg_names: names of the wrapped function's positional
397+
parameters, in order, so a name in `ignore` matches a positional
398+
argument the same way it matches a keyword argument (default ())
395399
:return: cache key tuple
396400
397401
"""
398-
args = tuple(arg for index, arg in enumerate(args) if index not in ignore)
402+
args = tuple(
403+
arg
404+
for index, arg in enumerate(args)
405+
if index not in ignore
406+
and not (index < len(arg_names) and arg_names[index] in ignore)
407+
)
399408
key = base + args + (None,)
400409

401410
if kwargs:
@@ -1853,7 +1862,10 @@ def memoize(
18531862
:param float expire: seconds until arguments expire
18541863
(default None, no expiry)
18551864
:param str tag: text to associate with arguments (default None)
1856-
:param set ignore: positional or keyword args to ignore (default ())
1865+
:param set ignore: positional or keyword args to ignore (default ()).
1866+
A parameter's name in this set is matched whether it was passed
1867+
positionally or by keyword; a positional index is also still
1868+
matched by position, same as before.
18571869
:return: callable decorator
18581870
18591871
"""
@@ -1865,6 +1877,14 @@ def decorator(func):
18651877
"""Decorator created by memoize() for callable `func`."""
18661878
base = (full_name(func),) if name is None else (name,)
18671879

1880+
try:
1881+
arg_names = tuple(inspect.signature(func).parameters)
1882+
except (TypeError, ValueError):
1883+
# Some callables (e.g. certain builtins) don't expose a
1884+
# signature; fall back to matching `ignore` by position only,
1885+
# same as before this parameter-name lookup was added.
1886+
arg_names = ()
1887+
18681888
@ft.wraps(func)
18691889
def wrapper(*args, **kwargs):
18701890
"""Wrapper for callable to cache arguments and return values."""
@@ -1880,7 +1900,7 @@ def wrapper(*args, **kwargs):
18801900

18811901
def __cache_key__(*args, **kwargs):
18821902
"""Make key for cache given function arguments."""
1883-
return args_to_key(base, args, kwargs, typed, ignore)
1903+
return args_to_key(base, args, kwargs, typed, ignore, arg_names)
18841904

18851905
wrapper.__cache_key__ = __cache_key__
18861906
return wrapper

‎tests/test_core.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1393,6 +1393,20 @@ def test(*args, **kwargs):
13931393
assert cache.stats() == (2, 1)
13941394

13951395

1396+
def test_memoize_ignore_by_name_regardless_of_call_style(cache):
1397+
# Regression test for GH #240: a name in `ignore` should be honored
1398+
# whether the caller passes that argument positionally or by keyword.
1399+
# `session` is deliberately something that can't be pickled, so if it
1400+
# ever leaks into the cache key this raises instead of quietly caching
1401+
# under two different keys.
1402+
@cache.memoize(ignore={'session'})
1403+
def get(entity_id, session):
1404+
return entity_id
1405+
1406+
assert get('a', session=threading.Lock())
1407+
assert get('b', threading.Lock())
1408+
1409+
13961410
def test_memoize_iter(cache):
13971411
@cache.memoize()
13981412
def test(*args, **kwargs):

0 commit comments

Comments
 (0)