Skip to content

Commit 68a6772

Browse files
Bordaclaude
andcommitted
feat: raise TypeError when @cachier is applied to an instance method (#170)
Applying @cachier to an instance method silently produces wrong results because 'self' is excluded from the cache key, causing all instances to share a single cached value. This change raises TypeError at decoration time by default. - Add allow_non_static_methods: bool = False to config.Params - Add allow_non_static_methods parameter to cachier() decorator - Guard fires in _cachier_decorator after core.set_func(); follows the existing mongo/redis/sql guard pattern - @staticmethod is unaffected (self is not its first parameter) - Opt out per-decorator: allow_non_static_methods=True - Opt out globally: set_global_params(allow_non_static_methods=True) - Declare func_is_method: bool = False in _BaseCore.__init__ - Update set_func docstring with the name-based heuristic and cls gap - Remove unreachable assert max_age is not None dead code (x2) - Fix test_config.py global state leak (stale_after=60 int pollution) - Update all existing instance-method tests to use the opt-out flag Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5c4f121 commit 68a6772

11 files changed

Lines changed: 134 additions & 21 deletions

File tree

README.rst

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ You can add a default, pickle-based, persistent cache to your function - meaning
9494
"""Your function now has a persistent cache mapped by argument values!"""
9595
return {'arg1': arg1, 'arg2': arg2}
9696
97-
Class and object methods can also be cached. Cachier will automatically ignore the `self` parameter when determining the cache key for an object method. **This means that methods will be cached across all instances of an object, which may not be what you want.**
97+
Class and object methods can also be cached. Cachier will automatically ignore the ``self`` parameter when determining the cache key for an object method. **This means that methods will be cached across all instances of an object, which may not be what you want.** Because this is a common source of bugs, ``@cachier`` raises a ``TypeError`` by default when applied to an instance method (a function whose first parameter is named ``self``). This error is raised when ``@cachier`` is applied (at class definition time), not when the method is called. To opt in to cross-instance cache sharing, pass ``allow_non_static_methods=True``.
9898

9999
.. code-block:: python
100100
@@ -107,18 +107,18 @@ Class and object methods can also be cached. Cachier will automatically ignore t
107107
return arg_1 + arg_2
108108
109109
# Instance method does not depend on object's internal state, so good to cache
110-
@cachier()
110+
@cachier(allow_non_static_methods=True)
111111
def good_usage_1(self, arg_1, arg_2):
112112
return arg_1 + arg_2
113113
114114
# Instance method is calling external service, probably okay to cache
115-
@cachier()
115+
@cachier(allow_non_static_methods=True)
116116
def good_usage_2(self, arg_1, arg_2):
117117
result = self.call_api(arg_1, arg_2)
118118
return result
119119
120120
# Instance method relies on object attribute, NOT good to cache
121-
@cachier()
121+
@cachier(allow_non_static_methods=True)
122122
def bad_usage(self, arg_1, arg_2):
123123
return arg_1 + arg_2 + self.arg_3
124124

src/cachier/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ class Params:
6666
cleanup_stale: bool = False
6767
cleanup_interval: timedelta = timedelta(days=1)
6868
entry_size_limit: Optional[int] = None
69+
allow_non_static_methods: bool = False
6970

7071

7172
_global_params = Params()
@@ -132,6 +133,11 @@ def set_global_params(**params: Any) -> None:
132133
calls that check calculation timeouts will fall back to the global
133134
'wait_for_calc_timeout' as well.
134135
136+
Note that ``allow_non_static_methods`` is a **decoration-time**
137+
parameter: it is checked once when the ``@cachier`` decorator is
138+
applied and is not re-read on each function call. Changing it via
139+
``set_global_params`` only affects decorators created after the call.
140+
135141
"""
136142
import cachier
137143

src/cachier/core.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ def cachier(
200200
cleanup_stale: Optional[bool] = None,
201201
cleanup_interval: Optional[timedelta] = None,
202202
entry_size_limit: Optional[Union[int, str]] = None,
203+
allow_non_static_methods: Optional[bool] = None,
203204
):
204205
"""Wrap as a persistent, stale-free memoization decorator.
205206
@@ -287,6 +288,13 @@ def cachier(
287288
Maximum serialized size of a cached value. Values exceeding the limit
288289
are returned but not cached. Human readable strings like ``"10MB"`` are
289290
allowed.
291+
allow_non_static_methods : bool, optional
292+
If True, allows ``@cachier`` to decorate instance methods (functions
293+
whose first parameter is named ``self``). By default, decorating an
294+
instance method raises ``TypeError`` because the ``self`` argument is
295+
ignored for cache-key computation, meaning all instances share the
296+
same cache -- which is rarely the intended behaviour. Set this to
297+
``True`` only when cross-instance cache sharing is intentional.
290298
291299
"""
292300
# Check for deprecated parameters
@@ -356,6 +364,25 @@ def cachier(
356364

357365
def _cachier_decorator(func):
358366
core.set_func(func)
367+
368+
# Guard: raise TypeError when decorating an instance method unless
369+
# explicitly opted in. The 'self' parameter is ignored for cache-key
370+
# computation, so all instances share the same cache.
371+
if core.func_is_method:
372+
_allow_methods = _update_with_defaults(
373+
allow_non_static_methods, "allow_non_static_methods"
374+
)
375+
if not _allow_methods:
376+
raise TypeError(
377+
f"@cachier cannot decorate instance method "
378+
f"'{func.__qualname__}' because the 'self' parameter is "
379+
f"excluded from cache-key computation and all instances "
380+
f"would share a single cache. Pass "
381+
f"allow_non_static_methods=True to the decorator (or "
382+
f"set_global_params) if cross-instance cache sharing is "
383+
f"intentional."
384+
)
385+
359386
is_coroutine = inspect.iscoroutinefunction(func)
360387

361388
if backend == "mongo":
@@ -468,7 +495,6 @@ def _call(*args, max_age: Optional[timedelta] = None, **kwds):
468495
_print("max_age is negative. Cached result considered stale.")
469496
nonneg_max_age = False
470497
else:
471-
assert max_age is not None # noqa: S101
472498
max_allowed_age = min(_stale_after, max_age)
473499
# note: if max_age < 0, we always consider a value stale
474500
if nonneg_max_age and (now - entry.time <= max_allowed_age):
@@ -557,7 +583,6 @@ async def _call_async(*args, max_age: Optional[timedelta] = None, **kwds):
557583
_print("max_age is negative. Cached result considered stale.")
558584
nonneg_max_age = False
559585
else:
560-
assert max_age is not None # noqa: S101
561586
max_allowed_age = min(_stale_after, max_age)
562587
# note: if max_age < 0, we always consider a value stale
563588
if nonneg_max_age and (now - entry.time <= max_allowed_age):

src/cachier/cores/base.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,22 @@ def __init__(
4848
self.wait_for_calc_timeout = wait_for_calc_timeout
4949
self.lock = threading.RLock()
5050
self.entry_size_limit = entry_size_limit
51+
self.func_is_method: bool = False
5152

5253
def set_func(self, func):
5354
"""Set the function this core will use.
5455
55-
This has to be set before any method is called. Also determine if the function is an object method.
56+
This must be called before any other method is invoked. In addition
57+
to storing ``func`` on the instance, this method inspects the
58+
function's signature and sets ``self.func_is_method`` to ``True``
59+
when the first parameter is named ``"self"``.
60+
61+
Notes
62+
-----
63+
Detection is name-based: only ``func_params[0] == "self"`` is
64+
checked. ``@classmethod`` functions whose first parameter is
65+
conventionally named ``cls`` are **not** detected as methods --
66+
this is a known gap.
5667
5768
"""
5869
# unwrap if the function is functools.partial

tests/mongo_tests/test_async_mongo_core.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ async def async_mongetter():
7676
call_count = 0
7777

7878
class _MongoMethods:
79-
@cachier(mongetter=async_mongetter)
79+
@cachier(mongetter=async_mongetter, allow_non_static_methods=True)
8080
async def async_cached_mongo_method_args_kwargs(self, x: int, y: int) -> int:
8181
nonlocal call_count
8282
call_count += 1

tests/redis_tests/test_async_redis_core.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ async def get_redis_client():
8484
call_count = 0
8585

8686
class _RedisMethods:
87-
@cachier(backend="redis", redis_client=get_redis_client)
87+
@cachier(backend="redis", redis_client=get_redis_client, allow_non_static_methods=True)
8888
async def async_cached_redis_method_args_kwargs(self, x: int, y: int) -> int:
8989
nonlocal call_count
9090
call_count += 1

tests/test_async_core.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,9 @@ class MyClass:
261261
def __init__(self, value):
262262
self.value = value
263263

264-
@cachier(backend="memory")
264+
# allow_non_static_methods=True: cross-instance cache sharing
265+
# is intentional in this test
266+
@cachier(backend="memory", allow_non_static_methods=True)
265267
async def async_method(self, x):
266268
await asyncio.sleep(0.1)
267269
return x * self.value
@@ -290,7 +292,9 @@ class MyClass:
290292
def __init__(self, value):
291293
self.value = value
292294

293-
@cachier(backend="memory")
295+
# allow_non_static_methods=True: cross-instance cache sharing
296+
# is intentional in this test
297+
@cachier(backend="memory", allow_non_static_methods=True)
294298
async def async_method(self, x):
295299
await asyncio.sleep(0.1)
296300
return x * self.value

tests/test_caching_regression.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ def __init__(self, cache_ttl=None):
105105
cachier.enable_caching()
106106

107107
# Use memory backend to avoid file cache persistence issues
108-
@cachier.cachier(backend="memory")
108+
@cachier.cachier(backend="memory", allow_non_static_methods=True)
109109
def test(self, param):
110110
self.counter += 1
111111
return param

tests/test_config.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@
22

33
import pytest
44

5-
from cachier.config import get_default_params, set_default_params
5+
from cachier.config import get_default_params, get_global_params, set_default_params, set_global_params
66

77

88
def test_set_default_params_deprecated():
99
"""Test that set_default_params shows deprecation warning."""
10-
# Test lines 103-111: deprecation warning
11-
with pytest.warns(DeprecationWarning, match="set_default_params.*deprecated.*set_global_params"):
12-
set_default_params(stale_after=60)
10+
original = get_global_params().stale_after
11+
try:
12+
with pytest.warns(DeprecationWarning, match="set_default_params.*deprecated.*set_global_params"):
13+
set_default_params(stale_after=60)
14+
finally:
15+
set_global_params(stale_after=original)
1316

1417

1518
def test_get_default_params_deprecated():

tests/test_general.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ def dummy_func(arg_1, arg_2):
185185
)
186186
def test_ignore_self_in_methods(mongetter, backend):
187187
class DummyClass:
188-
@cachier.cachier(backend=backend, mongetter=mongetter)
188+
@cachier.cachier(backend=backend, mongetter=mongetter, allow_non_static_methods=True)
189189
def takes_2_seconds(self, arg_1, arg_2):
190190
"""Some function."""
191191
sleep(2)
@@ -257,7 +257,7 @@ def test():
257257

258258
def test_global_disable_method():
259259
class Test:
260-
@cachier.cachier()
260+
@cachier.cachier(allow_non_static_methods=True)
261261
def test(self):
262262
return True
263263

@@ -270,7 +270,7 @@ def test(self):
270270

271271
def test_global_disable_method_with_args():
272272
class Test:
273-
@cachier.cachier()
273+
@cachier.cachier(allow_non_static_methods=True)
274274
def test(self, test):
275275
return test
276276

@@ -286,7 +286,7 @@ class Test:
286286
def __init__(self, val):
287287
self.val = val
288288

289-
@cachier.cachier()
289+
@cachier.cachier(allow_non_static_methods=True)
290290
def test(self, test=0):
291291
return self.val + test
292292

@@ -302,7 +302,7 @@ class Test:
302302
def __init__(self, val):
303303
self.val = val
304304

305-
@cachier.cachier()
305+
@cachier.cachier(allow_non_static_methods=True)
306306
def test(self, test1, test2=0):
307307
return self.val + test1 + test2
308308

0 commit comments

Comments
 (0)