Skip to content

Commit 0df7dc4

Browse files
Bordaclaudepre-commit-ci[bot]Copilot
authored
feat: raise TypeError when @cachier is applied to an instance method (#368)
* 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> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * feat: add async guard and improve handling for instance methods in @cachier - Raise TypeError for async instance methods without `allow_non_static_methods` opt-in. - Document `allow_non_static_methods` usage in README and config. - Generalize hashing utilities to reduce redundancy in test code. * docs: document `allow_non_static_methods` in README and add test coverage --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 5c4f121 commit 0df7dc4

File tree

12 files changed

+151
-21
lines changed

12 files changed

+151
-21
lines changed

README.rst

Lines changed: 5 additions & 3 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,17 +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() would raise TypeError here -- this is intentional
121122
@cachier()
122123
def bad_usage(self, arg_1, arg_2):
123124
return arg_1 + arg_2 + self.arg_3
@@ -148,6 +149,7 @@ The following parameters will only be applied to decorators defined after `set_d
148149
* `pickle_reload`
149150
* `separate_files`
150151
* `entry_size_limit`
152+
* `allow_non_static_methods`
151153

152154
These parameters can be changed at any time and they will apply to all decorators:
153155

src/cachier/config.py

Lines changed: 9 additions & 1 deletion
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()
@@ -130,7 +131,14 @@ def set_global_params(**params: Any) -> None:
130131
'cleanup_interval', and 'caching_enabled'. In some cores, if the
131132
decorator was created without concrete value for 'wait_for_calc_timeout',
132133
calls that check calculation timeouts will fall back to the global
133-
'wait_for_calc_timeout' as well.
134+
'wait_for_calc_timeout' as well. 'allow_non_static_methods'
135+
(decoration-time only) controls whether instance methods are
136+
permitted; it is read once when @cachier is applied, not on each call.
137+
138+
Note that ``allow_non_static_methods`` is a **decoration-time**
139+
parameter: it is checked once when the ``@cachier`` decorator is
140+
applied and is not re-read on each function call. Changing it via
141+
``set_global_params`` only affects decorators created after the call.
134142
135143
"""
136144
import cachier

src/cachier/core.py

Lines changed: 25 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,23 @@ 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(allow_non_static_methods, "allow_non_static_methods")
373+
if not _allow_methods:
374+
raise TypeError(
375+
f"@cachier cannot decorate instance method "
376+
f"'{func.__qualname__}' because the 'self' parameter is "
377+
"excluded from cache-key computation and all instances "
378+
"would share a single cache. Pass allow_non_static_methods=True "
379+
"to the decorator or call "
380+
"set_global_params(allow_non_static_methods=True) if "
381+
"cross-instance cache sharing is intentional."
382+
)
383+
359384
is_coroutine = inspect.iscoroutinefunction(func)
360385

361386
if backend == "mongo":
@@ -468,7 +493,6 @@ def _call(*args, max_age: Optional[timedelta] = None, **kwds):
468493
_print("max_age is negative. Cached result considered stale.")
469494
nonneg_max_age = False
470495
else:
471-
assert max_age is not None # noqa: S101
472496
max_allowed_age = min(_stale_after, max_age)
473497
# note: if max_age < 0, we always consider a value stale
474498
if nonneg_max_age and (now - entry.time <= max_allowed_age):
@@ -557,7 +581,6 @@ async def _call_async(*args, max_age: Optional[timedelta] = None, **kwds):
557581
_print("max_age is negative. Cached result considered stale.")
558582
nonneg_max_age = False
559583
else:
560-
assert max_age is not None # noqa: S101
561584
max_allowed_age = min(_stale_after, max_age)
562585
# note: if max_age < 0, we always consider a value stale
563586
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: 15 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
@@ -311,6 +315,15 @@ async def async_method(self, x):
311315

312316
obj1.async_method.clear_cache()
313317

318+
async def test_guard_raises_without_opt_in(self):
319+
"""Test that @cachier raises TypeError for async instance methods without opt-in."""
320+
with pytest.raises(TypeError, match="allow_non_static_methods"):
321+
322+
class MyClass:
323+
@cachier(backend="memory")
324+
async def async_method(self, x):
325+
return x
326+
314327

315328
# =============================================================================
316329
# Sync Function Compatibility Tests

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_core_lookup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
def test_get_default_params():
99
params = get_global_params()
1010
assert sorted(vars(params).keys()) == [
11+
"allow_non_static_methods",
1112
"allow_none",
1213
"backend",
1314
"cache_dir",

0 commit comments

Comments
 (0)