Skip to content

Commit d7b0c17

Browse files
shaypal5Daniel-Chinpre-commit-ci[bot]BordaCopilot
authored
Add feature: Call with freshness threshold (#262) (#271)
* Add feature: call with freshness threshold (#262) * add caller_with_freshness_threshold * fix type * simple test passed * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Apply suggestions from code review * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com> Co-authored-by: Shay Palachy-Affek <shaypal5@users.noreply.github.com> * fixin things up * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix max_age bug * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fixes * change max_age condition to strictly smalled * add a short sleep on test_max_age_zero * more sleep * neg max age means cached values are considered stale * some renaming * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * one of the max_age tests needs debugging * sleep for neg max_age test * Update src/cachier/core.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Daniel Chin <daniel.chin@nyu.edu> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent dedb52d commit d7b0c17

6 files changed

Lines changed: 557 additions & 3 deletions

File tree

.cursor/python.mdc

Lines changed: 347 additions & 0 deletions
Large diffs are not rendered by default.

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,5 @@ Pipfile.lock
106106
.DS_Store
107107

108108
tags
109+
110+
.cursor

README.rst

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ Features
5151
* Local caching using pickle files.
5252
* Cross-machine caching using MongoDB.
5353
* Thread-safety.
54+
* **Per-call max age:** Specify a maximum age for cached values per call.
5455

5556
Cachier is **NOT**:
5657

@@ -233,6 +234,27 @@ Per-function call arguments
233234

234235
Cachier also accepts several keyword arguments in the calls of the function it wraps rather than in the decorator call, allowing you to modify its behaviour for a specific function call.
235236

237+
**Max Age (max_age)**
238+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
239+
You can specify a maximum allowed age for a cached value on a per-call basis using the `max_age` keyword argument. If the cached value is older than this threshold, a recalculation is triggered. This is in addition to the `stale_after` parameter set at the decorator level; the strictest (smallest) threshold is enforced.
240+
241+
.. code-block:: python
242+
243+
from datetime import timedelta
244+
from cachier import cachier
245+
246+
@cachier(stale_after=timedelta(days=3))
247+
def add(a, b):
248+
return a + b
249+
250+
# Use a per-call max age:
251+
result = add(1, 2, max_age=timedelta(seconds=10)) # Only use cache if value is <10s old
252+
253+
**How it works:**
254+
- The effective max age threshold is the minimum of `stale_after` (from the decorator) and `max_age` (from the call).
255+
- If the cached value is older than this threshold, a new calculation is triggered and the cache is updated.
256+
- If not, the cached value is returned as usual.
257+
236258
Ignore Cache
237259
~~~~~~~~~~~~
238260

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ markers = [
172172
"memory: test the memory core",
173173
"pickle: test the pickle core",
174174
"sql: test the SQL core",
175+
"maxage: test the max_age functionality",
175176
]
176177

177178
# --- coverage ---

src/cachier/core.py

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131

3232
MAX_WORKERS_ENVAR_NAME = "CACHIER_MAX_WORKERS"
3333
DEFAULT_MAX_WORKERS = 8
34+
ZERO_TIMEDELTA = timedelta(seconds=0)
3435

3536

3637
def _max_workers():
@@ -225,8 +226,31 @@ def cachier(
225226
def _cachier_decorator(func):
226227
core.set_func(func)
227228

228-
@wraps(func)
229-
def func_wrapper(*args, **kwds):
229+
# ---
230+
# MAINTAINER NOTE: max_age parameter
231+
#
232+
# The _call function below supports a per-call 'max_age' parameter,
233+
# allowing users to specify a maximum allowed age for a cached value.
234+
# If the cached value is older than 'max_age',
235+
# a recalculation is triggered. This is in addition to the
236+
# per-decorator 'stale_after' parameter.
237+
#
238+
# The effective staleness threshold is the minimum of 'stale_after'
239+
# and 'max_age' (if provided).
240+
# This ensures that the strictest max age requirement is enforced.
241+
#
242+
# The main function wrapper is a standard function that passes
243+
# *args and **kwargs to _call. By default, max_age is None,
244+
# so only 'stale_after' is considered unless overridden.
245+
#
246+
# The user-facing API exposes:
247+
# - Per-call: myfunc(..., max_age=timedelta(...))
248+
#
249+
# This design allows both one-off (per-call) and default
250+
# (per-decorator) max age constraints.
251+
# ---
252+
253+
def _call(*args, max_age: Optional[timedelta] = None, **kwds):
230254
nonlocal allow_none
231255
_allow_none = _update_with_defaults(allow_none, "allow_none", kwds)
232256
# print('Inside general wrapper for {}.'.format(func.__name__))
@@ -271,7 +295,23 @@ def func_wrapper(*args, **kwds):
271295
if _allow_none or entry.value is not None:
272296
_print("Cached result found.")
273297
now = datetime.now()
274-
if now - entry.time <= _stale_after:
298+
max_allowed_age = _stale_after
299+
nonneg_max_age = True
300+
if max_age is not None:
301+
if max_age < ZERO_TIMEDELTA:
302+
_print(
303+
"max_age is negative. "
304+
"Cached result considered stale."
305+
)
306+
nonneg_max_age = False
307+
else:
308+
max_allowed_age = (
309+
min(_stale_after, max_age)
310+
if max_age is not None
311+
else _stale_after
312+
)
313+
# note: if max_age < 0, we always consider a value stale
314+
if nonneg_max_age and (now - entry.time <= max_allowed_age):
275315
_print("And it is fresh!")
276316
return entry.value
277317
_print("But it is stale... :(")
@@ -305,6 +345,14 @@ def func_wrapper(*args, **kwds):
305345
_print("No entry found. No current calc. Calling like a boss.")
306346
return _calc_entry(core, key, func, args, kwds)
307347

348+
# MAINTAINER NOTE: The main function wrapper is now a standard function
349+
# that passes *args and **kwargs to _call. This ensures that user
350+
# arguments are not shifted, and max_age is only settable via keyword
351+
# argument.
352+
@wraps(func)
353+
def func_wrapper(*args, **kwargs):
354+
return _call(*args, **kwargs)
355+
308356
def _clear_cache():
309357
"""Clear the cache."""
310358
core.clear_cache()

tests/test_call_with_max_age.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import time
2+
from datetime import timedelta
3+
4+
import pytest
5+
6+
import cachier
7+
8+
9+
@pytest.mark.maxage
10+
def test_call_with_max_age():
11+
@cachier.cachier()
12+
def test_func(a, b):
13+
return a + b
14+
15+
# First call: should compute and cache
16+
val1 = test_func(1, 2)
17+
assert val1 == 3
18+
# Second call: should use cache
19+
val2 = test_func(1, 2)
20+
assert val2 == 3
21+
# Wait for cache to become stale
22+
time.sleep(1.0)
23+
# Should trigger recalculation (stale)
24+
val3 = test_func(1, 2, max_age=timedelta(seconds=0.5))
25+
assert val3 == 3
26+
27+
28+
@pytest.mark.maxage
29+
def test_max_age_stricter_than_stale_after():
30+
import time
31+
32+
import cachier
33+
34+
@cachier.cachier(stale_after=timedelta(seconds=2))
35+
def f(x):
36+
return time.time()
37+
38+
f.clear_cache()
39+
v1 = f(1)
40+
v2 = f(1)
41+
assert v1 == v2 # cache hit
42+
time.sleep(1)
43+
v3 = f(1, max_age=timedelta(seconds=0.5))
44+
assert v3 != v1 # max_age stricter, triggers recalc
45+
46+
47+
@pytest.mark.maxage
48+
def test_max_age_looser_than_stale_after():
49+
import time
50+
51+
import cachier
52+
53+
@cachier.cachier(stale_after=timedelta(seconds=1))
54+
def f(x):
55+
return time.time()
56+
57+
f.clear_cache()
58+
v1 = f(1)
59+
v2 = f(1)
60+
assert v1 == v2
61+
time.sleep(1.1)
62+
v3 = f(1, max_age=timedelta(seconds=5))
63+
assert v3 != v1 # max_age looser, but stale_after still applies (stricter)
64+
65+
66+
@pytest.mark.maxage
67+
def test_max_age_none_defaults_to_stale_after():
68+
import time
69+
70+
import cachier
71+
72+
@cachier.cachier(stale_after=timedelta(seconds=1))
73+
def f(x):
74+
return time.time()
75+
76+
f.clear_cache()
77+
v1 = f(1)
78+
time.sleep(1.1)
79+
v2 = f(1, max_age=None)
80+
assert v2 != v1 # Should trigger recalc (stale_after applies)
81+
82+
83+
@pytest.mark.maxage
84+
def test_negative_max_age_triggers_recalc():
85+
import time
86+
87+
import cachier
88+
89+
@cachier.cachier(stale_after=timedelta(seconds=100))
90+
def f(x):
91+
return time.time()
92+
93+
f.clear_cache()
94+
v1 = f(1)
95+
time.sleep(0.5) # Ensure some time has passed
96+
v2 = f(1, max_age=timedelta(seconds=-1), cachier__verbose=True)
97+
assert v2 != v1 # Negative max_age always triggers recalc
98+
99+
100+
@pytest.mark.maxage
101+
def test_max_age_zero():
102+
import time
103+
104+
import cachier
105+
106+
@cachier.cachier(stale_after=timedelta(seconds=100))
107+
def f(x):
108+
return time.time()
109+
110+
f.clear_cache()
111+
v1 = f(1)
112+
# Add a small sleep to ensure measurable time difference on all platforms
113+
time.sleep(1)
114+
v2 = f(1, max_age=timedelta(seconds=0))
115+
assert v2 != v1 # Zero max_age always triggers recalc
116+
117+
118+
@pytest.mark.maxage
119+
def test_max_age_with_next_time():
120+
import time
121+
122+
import cachier
123+
124+
@cachier.cachier(stale_after=timedelta(seconds=1), next_time=True)
125+
def f(x):
126+
return time.time()
127+
128+
f.clear_cache()
129+
v1 = f(1)
130+
time.sleep(1.1)
131+
v2 = f(1, max_age=timedelta(seconds=0.5))
132+
# With next_time=True, should return stale value (v1) while
133+
# triggering a recalculation in the background
134+
assert v2 == v1

0 commit comments

Comments
 (0)