Skip to content

Commit 305161b

Browse files
Bordapre-commit-ci[bot]Copilot
authored
minor update by agents (#305)
* minor update by agents * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * drop it * linter * minor update by agents * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * drop it * linter * update * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * update * update * tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * tests * tests * tests * tests * tests * tests * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * docs * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix * lint * tests --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 9c36ee8 commit 305161b

7 files changed

Lines changed: 344 additions & 50 deletions

File tree

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ repos:
6363
# basic check
6464
- id: ruff
6565
name: Ruff check
66-
args: ["--fix"]
66+
args: ["--fix"] #, "--unsafe-fixes"
6767

6868
# it needs to be after formatting hooks because the lines might be changed
6969
- repo: https://github.com/pre-commit/mirrors-mypy

src/cachier/config.py

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,11 @@ def _update_with_defaults(
9898

9999

100100
def set_default_params(**params: Any) -> None:
101-
"""Configure default parameters applicable to all memoized functions."""
101+
"""Configure default parameters applicable to all memoized functions.
102+
103+
Deprecated, use :func:`~cachier.config.set_global_params` instead.
104+
105+
"""
102106
# It is kept for backwards compatibility with desperation warning
103107
import warnings
104108

@@ -115,13 +119,21 @@ def set_global_params(**params: Any) -> None:
115119
"""Configure global parameters applicable to all memoized functions.
116120
117121
This function takes the same keyword parameters as the ones defined in the
118-
decorator, which can be passed all at once or with multiple calls.
119-
Parameters given directly to a decorator take precedence over any values
120-
set by this function.
121-
122-
Only 'stale_after', 'next_time', and 'wait_for_calc_timeout' can be changed
123-
after the memoization decorator has been applied. Other parameters will
124-
only have an effect on decorators applied after this function is run.
122+
decorator. Parameters given directly to a decorator take precedence over
123+
any values set by this function.
124+
125+
Note on dynamic behavior:
126+
- If a decorator parameter is provided explicitly (not None), that value
127+
is used for the decorated function and is not affected by later changes
128+
to the global parameters.
129+
- If a decorator parameter is left as None, the decorator/core may read
130+
the corresponding value from the global params at call time. Parameters
131+
that are read dynamically (when decorator parameter was None) include:
132+
'stale_after', 'next_time', 'allow_none', 'cleanup_stale',
133+
'cleanup_interval', and 'caching_enabled'. In some cores, if the
134+
decorator was created without concrete value for 'wait_for_calc_timeout',
135+
calls that check calculation timeouts will fall back to the global
136+
'wait_for_calc_timeout' as well.
125137
126138
"""
127139
import cachier
@@ -138,7 +150,11 @@ def set_global_params(**params: Any) -> None:
138150

139151

140152
def get_default_params() -> Params:
141-
"""Get current set of default parameters."""
153+
"""Get current set of default parameters.
154+
155+
Deprecated, use :func:`~cachier.config.get_global_params` instead.
156+
157+
"""
142158
# It is kept for backwards compatibility with desperation warning
143159
import warnings
144160

src/cachier/core.py

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -134,23 +134,24 @@ def cachier(
134134
value is their id), equal objects across different sessions will not yield
135135
identical keys.
136136
137-
Arguments:
138-
---------
137+
Parameters
138+
----------
139139
hash_func : callable, optional
140140
A callable that gets the args and kwargs from the decorated function
141141
and returns a hash key for them. This parameter can be used to enable
142142
the use of cachier with functions that get arguments that are not
143143
automatically hashable by Python.
144144
hash_params : callable, optional
145+
Deprecated, use :func:`~cachier.core.cachier.hash_func` instead.
145146
backend : str, optional
146147
The name of the backend to use. Valid options currently include
147148
'pickle', 'mongo', 'memory', 'sql', and 'redis'. If not provided,
148149
defaults to 'pickle', unless a core-associated parameter is provided
149150
150151
mongetter : callable, optional
151152
A callable that takes no arguments and returns a pymongo.Collection
152-
object with writing permissions. If unset a local pickle cache is used
153-
instead.
153+
object with writing permissions. If provided, the backend is set to
154+
'mongo'.
154155
sql_engine : str, Engine, or callable, optional
155156
SQLAlchemy connection string, Engine, or callable returning an Engine.
156157
Used for the SQL backend.
@@ -177,8 +178,8 @@ def cachier(
177178
separate_files: bool, default False, for Pickle cores only
178179
Instead of a single cache file per-function, each function's cache is
179180
split between several files, one for each argument set. This can help
180-
if you per-function cache files become too large.
181-
wait_for_calc_timeout: int, optional, for MongoDB only
181+
if your per-function cache files become too large.
182+
wait_for_calc_timeout: int, optional
182183
The maximum time to wait for an ongoing calculation. When a
183184
process started to calculate the value setting being_calculated to
184185
True, any process trying to read the same entry will wait a maximum of
@@ -358,11 +359,8 @@ def _call(*args, max_age: Optional[timedelta] = None, **kwds):
358359
)
359360
nonneg_max_age = False
360361
else:
361-
max_allowed_age = (
362-
min(_stale_after, max_age)
363-
if max_age is not None
364-
else _stale_after
365-
)
362+
assert max_age is not None # noqa: S101
363+
max_allowed_age = min(_stale_after, max_age)
366364
# note: if max_age < 0, we always consider a value stale
367365
if nonneg_max_age and (now - entry.time <= max_allowed_age):
368366
_print("And it is fresh!")

src/cachier/cores/base.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,17 @@ class RecalculationNeeded(Exception):
2727

2828

2929
def _get_func_str(func: Callable) -> str:
30-
return f".{func.__module__}.{func.__name__}"
30+
"""Return a string identifier for the function (module + name).
31+
32+
We accept Any here because static analysis can't always prove that the
33+
runtime object will have __module__ and __name__, but at runtime the
34+
decorated functions always do.
3135
36+
"""
37+
return f".{func.__module__}.{func.__name__}"
3238

33-
class _BaseCore:
34-
__metaclass__ = abc.ABCMeta
3539

40+
class _BaseCore(metaclass=abc.ABCMeta):
3641
def __init__(
3742
self,
3843
hash_func: Optional[HashFunc],
@@ -90,8 +95,8 @@ def check_calc_timeout(self, time_spent):
9095
def get_entry_by_key(self, key: str) -> Tuple[str, Optional[CacheEntry]]:
9196
"""Get entry based on given key.
9297
93-
Return the result mapped to the given key in this core's cache, if such
94-
a mapping exists.
98+
Return the key and the :class:`~cachier.config.CacheEntry` mapped
99+
to the given key in this core's cache, if such a mapping exists.
95100
96101
"""
97102

src/cachier/cores/redis.py

Lines changed: 72 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,54 @@ def set_func(self, func):
7373
super().set_func(func)
7474
self._func_str = _get_func_str(func)
7575

76+
@staticmethod
77+
def _loading_pickle(raw_value) -> Any:
78+
"""Load pickled data with some recovery attempts."""
79+
try:
80+
if isinstance(raw_value, bytes):
81+
return pickle.loads(raw_value)
82+
elif isinstance(raw_value, str):
83+
# try to recover by encoding; prefer utf-8 but fall
84+
# back to latin-1 in case raw binary was coerced to str
85+
try:
86+
return pickle.loads(raw_value.encode("utf-8"))
87+
except Exception:
88+
return pickle.loads(raw_value.encode("latin-1"))
89+
else:
90+
# unexpected type; attempt pickle.loads directly
91+
try:
92+
return pickle.loads(raw_value)
93+
except Exception:
94+
return None
95+
except Exception as exc:
96+
warnings.warn(
97+
f"Redis value deserialization failed: {exc}",
98+
stacklevel=2,
99+
)
100+
return None
101+
102+
@staticmethod
103+
def _get_raw_field(cached_data, field: str):
104+
"""Fetch field from cached_data with bytes/str key handling."""
105+
# try bytes key first, then str key
106+
bkey = field.encode("utf-8")
107+
if bkey in cached_data:
108+
return cached_data[bkey]
109+
return cached_data.get(field)
110+
111+
@staticmethod
112+
def _get_bool_field(cached_data, name: str) -> bool:
113+
"""Fetch boolean field from cached_data."""
114+
raw = _RedisCore._get_raw_field(cached_data, name) or b"false"
115+
if isinstance(raw, bytes):
116+
try:
117+
s = raw.decode("utf-8")
118+
except Exception:
119+
s = raw.decode("latin-1", errors="ignore")
120+
else:
121+
s = str(raw)
122+
return s.lower() == "true"
123+
76124
def get_entry_by_key(self, key: str) -> Tuple[str, Optional[CacheEntry]]:
77125
"""Get entry based on given key from Redis."""
78126
redis_client = self._resolve_redis_client()
@@ -86,32 +134,28 @@ def get_entry_by_key(self, key: str) -> Tuple[str, Optional[CacheEntry]]:
86134

87135
# Deserialize the value
88136
value = None
89-
if cached_data.get(b"value"):
90-
value = pickle.loads(cached_data[b"value"])
137+
raw_value = _RedisCore._get_raw_field(cached_data, "value")
138+
if raw_value is not None:
139+
value = self._loading_pickle(raw_value)
91140

92141
# Parse timestamp
93-
timestamp_str = cached_data.get(b"timestamp", b"").decode("utf-8")
142+
raw_ts = _RedisCore._get_raw_field(cached_data, "timestamp") or b""
143+
if isinstance(raw_ts, bytes):
144+
try:
145+
timestamp_str = raw_ts.decode("utf-8")
146+
except UnicodeDecodeError:
147+
timestamp_str = raw_ts.decode("latin-1", errors="ignore")
148+
else:
149+
timestamp_str = str(raw_ts)
94150
timestamp = (
95151
datetime.fromisoformat(timestamp_str)
96152
if timestamp_str
97153
else datetime.now()
98154
)
99155

100-
# Parse boolean fields
101-
stale = (
102-
cached_data.get(b"stale", b"false").decode("utf-8").lower()
103-
== "true"
104-
)
105-
processing = (
106-
cached_data.get(b"processing", b"false")
107-
.decode("utf-8")
108-
.lower()
109-
== "true"
110-
)
111-
completed = (
112-
cached_data.get(b"completed", b"false").decode("utf-8").lower()
113-
== "true"
114-
)
156+
stale = _RedisCore._get_bool_field(cached_data, "stale")
157+
processing = _RedisCore._get_bool_field(cached_data, "processing")
158+
completed = _RedisCore._get_bool_field(cached_data, "completed")
115159

116160
entry = CacheEntry(
117161
value=value,
@@ -126,9 +170,9 @@ def get_entry_by_key(self, key: str) -> Tuple[str, Optional[CacheEntry]]:
126170
return key, None
127171

128172
def set_entry(self, key: str, func_res: Any) -> bool:
173+
"""Map the given result to the given key in Redis."""
129174
if not self._should_store(func_res):
130175
return False
131-
"""Map the given result to the given key in Redis."""
132176
redis_client = self._resolve_redis_client()
133177
redis_key = self._get_redis_key(key)
134178

@@ -242,8 +286,16 @@ def delete_stale_entries(self, stale_after: timedelta) -> None:
242286
ts = redis_client.hget(key, "timestamp")
243287
if ts is None:
244288
continue
289+
# ts may be bytes or str depending on client configuration
290+
if isinstance(ts, bytes):
291+
try:
292+
ts_s = ts.decode("utf-8")
293+
except Exception:
294+
ts_s = ts.decode("latin-1", errors="ignore")
295+
else:
296+
ts_s = str(ts)
245297
try:
246-
ts_val = datetime.fromisoformat(ts.decode("utf-8"))
298+
ts_val = datetime.fromisoformat(ts_s)
247299
except Exception as exc:
248300
warnings.warn(
249301
f"Redis timestamp parse failed: {exc}", stacklevel=2

tests/test_pickle_core.py

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from cachier import cachier
3535
from cachier.config import CacheEntry, _global_params
3636
from cachier.cores.pickle import _PickleCore
37+
from cachier.cores.redis import _RedisCore
3738

3839

3940
def _get_decorated_func(func, **kwargs):
@@ -42,9 +43,6 @@ def _get_decorated_func(func, **kwargs):
4243
return decorated_func
4344

4445

45-
# Pickle core tests
46-
47-
4846
def _takes_2_seconds(arg_1, arg_2):
4947
"""Some function."""
5048
sleep(2)
@@ -528,14 +526,14 @@ def _error_throwing_func(arg1):
528526
@pytest.mark.parametrize("separate_files", [True, False])
529527
def test_error_throwing_func(separate_files):
530528
# with
531-
_error_throwing_func.count = 0
532529
_error_throwing_func_decorated = _get_decorated_func(
533530
_error_throwing_func,
534531
stale_after=timedelta(seconds=1),
535532
next_time=True,
536533
separate_files=separate_files,
537534
)
538535
_error_throwing_func_decorated.clear_cache()
536+
_error_throwing_func.count = 0
539537
res1 = _error_throwing_func_decorated(4)
540538
sleep(1.5)
541539
res2 = _error_throwing_func_decorated(4)
@@ -1074,3 +1072,70 @@ def mock_func():
10741072
with patch("os.remove", side_effect=FileNotFoundError):
10751073
# Should not raise exception
10761074
core.delete_stale_entries(timedelta(hours=1))
1075+
1076+
1077+
# Redis core static method tests
1078+
@pytest.mark.parametrize(
1079+
("test_input", "expected"),
1080+
[
1081+
(pickle.dumps({"test": 123}), {"test": 123}), # valid string
1082+
# (pickle.dumps({"test": 123}).decode("utf-8"), {"test": 123}),
1083+
# (b"\x80\x04\x95", None), # corrupted bytes
1084+
(123, None), # unexpected type
1085+
# (b"corrupted", None), # triggers warning
1086+
],
1087+
)
1088+
def test_redis_loading_pickle(test_input, expected):
1089+
"""Test _RedisCore._loading_pickle with various inputs and exceptions."""
1090+
assert _RedisCore._loading_pickle(test_input) == expected
1091+
1092+
1093+
def test_redis_loading_pickle_failed():
1094+
"""Test _RedisCore._loading_pickle with various inputs and exceptions."""
1095+
with patch("pickle.loads", side_effect=Exception("Failed")):
1096+
assert _RedisCore._loading_pickle(123) is None
1097+
1098+
1099+
def test_redis_loading_pickle_latin1_fallback():
1100+
"""Test _RedisCore._loading_pickle with latin-1 fallback."""
1101+
valid_obj = {"test": 123}
1102+
with patch("pickle.loads") as mock_loads:
1103+
mock_loads.side_effect = [Exception("UTF-8 failed"), valid_obj]
1104+
result = _RedisCore._loading_pickle("invalid_utf8_string")
1105+
assert result == valid_obj
1106+
assert mock_loads.call_count == 2
1107+
1108+
1109+
@pytest.mark.parametrize(
1110+
("cached_data", "key", "expected"),
1111+
[
1112+
({b"field": b"value", "other": "data"}, "field", b"value"),
1113+
({"field": "value", b"other": b"data"}, "field", "value"),
1114+
({"other": "value"}, "field", None),
1115+
],
1116+
)
1117+
def test_redis_get_raw_field(cached_data, key, expected):
1118+
"""Test _RedisCore._get_raw_field with bytes and string keys."""
1119+
assert _RedisCore._get_raw_field(cached_data, key) == expected
1120+
1121+
1122+
@pytest.mark.parametrize(
1123+
("cached_data", "key", "expected"),
1124+
[
1125+
({b"flag": b"true"}, "flag", True),
1126+
({b"flag": b"false"}, "flag", False),
1127+
({"flag": "TRUE"}, "flag", True),
1128+
({}, "flag", False),
1129+
({b"flag": 123}, "flag", False),
1130+
],
1131+
)
1132+
def test_redis_get_bool_field(cached_data, key, expected):
1133+
"""Test _RedisCore._get_bool_field with various inputs."""
1134+
assert _RedisCore._get_bool_field(cached_data, key) == expected
1135+
1136+
1137+
def test_redis_get_bool_field_decode_fallback():
1138+
"""Test _RedisCore._get_bool_field with decoding fallback."""
1139+
with patch.object(_RedisCore, "_get_raw_field", return_value=b"\xff\xfe"):
1140+
result = _RedisCore._get_bool_field({}, "flag")
1141+
assert result is False

0 commit comments

Comments
 (0)