Skip to content
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
c4ad0da
minor update by agents
Borda Oct 2, 2025
d3a13a1
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 2, 2025
3cb3972
drop it
Borda Oct 2, 2025
42f931c
linter
Borda Oct 2, 2025
3ff9acf
minor update by agents
Borda Oct 2, 2025
eae914d
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 2, 2025
2555317
drop it
Borda Oct 2, 2025
0007645
linter
Borda Oct 2, 2025
74f36af
Merge branch 'master' into update/agents
Borda Oct 2, 2025
64eee63
Merge branch 'update/agents' of https://github.com/python-cachier/cac…
Borda Oct 3, 2025
6e14b05
update
Borda Oct 3, 2025
aad1de0
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 3, 2025
ea8d186
update
Borda Oct 3, 2025
db9c131
update
Borda Oct 3, 2025
a75068f
Merge branch 'update/agents' of https://github.com/python-cachier/cac…
Borda Oct 3, 2025
c4f2da6
tests
Borda Oct 3, 2025
ee81a92
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 3, 2025
581df6f
tests
Borda Oct 3, 2025
076428b
Merge branch 'update/agents' of https://github.com/python-cachier/cac…
Borda Oct 3, 2025
4956562
tests
Borda Oct 3, 2025
174d8ba
tests
Borda Oct 3, 2025
a833a9c
tests
Borda Oct 3, 2025
ba13199
Merge branch 'master' into update/agents
Borda Jan 1, 2026
d065e5b
tests
Borda Jan 2, 2026
cd51d0c
tests
Borda Jan 2, 2026
c13f08e
Apply suggestions from code review
Borda Jan 2, 2026
b51e5ee
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 2, 2026
8e43f06
docs
Borda Jan 2, 2026
b14cf9b
Merge branch 'update/agents' of https://github.com/python-cachier/cac…
Borda Jan 2, 2026
d97a8d9
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 2, 2026
9c51b94
fix
Borda Jan 2, 2026
fb56f2b
lint
Borda Jan 2, 2026
b7deae2
tests
Borda Jan 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ repos:
# basic check
- id: ruff
name: Ruff check
args: ["--fix"]
args: ["--fix"] #, "--unsafe-fixes"
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The commented-out --unsafe-fixes argument suggests that unsafe fixes were considered but not enabled. While commenting is fine for documentation, it would be clearer to either remove the comment entirely or add an explanatory comment about why unsafe fixes are not enabled. The current inline comment without explanation may cause confusion for future maintainers.

Suggested change
args: ["--fix"] #, "--unsafe-fixes"
args: ["--fix"] # Unsafe fixes are intentionally disabled in CI; add "--unsafe-fixes" locally if you accept the risk.

Copilot uses AI. Check for mistakes.

# it needs to be after formatting hooks because the lines might be changed
- repo: https://github.com/pre-commit/mirrors-mypy
Expand Down
9 changes: 3 additions & 6 deletions src/cachier/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,11 +358,7 @@ def _call(*args, max_age: Optional[timedelta] = None, **kwds):
)
nonneg_max_age = False
else:
Comment thread
Borda marked this conversation as resolved.
max_allowed_age = (
min(_stale_after, max_age)
if max_age is not None
else _stale_after
)
max_allowed_age = min(_stale_after, max_age)
# note: if max_age < 0, we always consider a value stale
if nonneg_max_age and (now - entry.time <= max_allowed_age):
_print("And it is fresh!")
Expand All @@ -384,8 +380,9 @@ def _call(*args, max_age: Optional[timedelta] = None, **kwds):
_get_executor().submit(
_function_thread, core, key, func, args, kwds
)
finally:
except Exception:
core.mark_entry_not_calculated(key)
raise
return entry.value
_print("Calling decorated function and waiting")
return _calc_entry(core, key, func, args, kwds, _print)
Expand Down
11 changes: 8 additions & 3 deletions src/cachier/cores/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,17 @@ class RecalculationNeeded(Exception):


def _get_func_str(func: Callable) -> str:
return f".{func.__module__}.{func.__name__}"
"""Return a string identifier for the function (module + name).

We accept Any here because static analysis can't always prove that the
runtime object will have __module__ and __name__, but at runtime the
decorated functions always do.

"""
Comment thread
shaypal5 marked this conversation as resolved.
return f".{func.__module__}.{func.__name__}"

class _BaseCore:
__metaclass__ = abc.ABCMeta

class _BaseCore(metaclass=abc.ABCMeta):
def __init__(
self,
hash_func: Optional[HashFunc],
Expand Down
92 changes: 72 additions & 20 deletions src/cachier/cores/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,54 @@ def set_func(self, func):
super().set_func(func)
self._func_str = _get_func_str(func)

@staticmethod
def _loading_pickle(raw_value) -> Any:
Comment thread
shaypal5 marked this conversation as resolved.
"""Load pickled data with some recovery attempts."""
try:
if isinstance(raw_value, bytes):
return pickle.loads(raw_value)
elif isinstance(raw_value, str):
# try to recover by encoding; prefer utf-8 but fall
# back to latin-1 in case raw binary was coerced to str
try:
return pickle.loads(raw_value.encode("utf-8"))
except Exception:
return pickle.loads(raw_value.encode("latin-1"))
else:
# unexpected type; attempt pickle.loads directly
try:
return pickle.loads(raw_value)
except Exception:
return None
except Exception as exc:
warnings.warn(
f"Redis value deserialization failed: {exc}",
stacklevel=2,
)
return None

@staticmethod
def _get_raw_field(cached_data, field: str):
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The static method _get_raw_field is missing a type annotation for the cached_data parameter. Based on the implementation, it should be typed as something like Dict[Union[bytes, str], Any] or a more specific type. The coding guidelines require "all new code must include full type annotations."

Copilot generated this review using guidance from repository custom instructions.
"""Fetch field from cached_data with bytes/str key handling."""
# try bytes key first, then str key
bkey = field.encode("utf-8")
if bkey in cached_data:
return cached_data[bkey]
return cached_data.get(field)

@staticmethod
def _get_bool_field(cached_data, name: str) -> bool:
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The static method _get_bool_field is missing a type annotation for the cached_data parameter. It should be typed consistently with _get_raw_field. The coding guidelines require "all new code must include full type annotations."

Copilot generated this review using guidance from repository custom instructions.
"""Fetch boolean field from cached_data."""
raw = _RedisCore._get_raw_field(cached_data, name) or b"false"
if isinstance(raw, bytes):
try:
s = raw.decode("utf-8")
except Exception:
s = raw.decode("latin-1", errors="ignore")
else:
s = str(raw)
return s.lower() == "true"
Comment on lines +76 to +122
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstrings for the new static methods (_loading_pickle, _get_raw_field, _get_bool_field) do not follow numpy docstring conventions as required by the coding guidelines. Numpy-style docstrings should include sections like Parameters, Returns, and potentially Notes or Examples. For example, _loading_pickle should document the raw_value parameter, describe what it returns, and explain the recovery attempts it makes.

Copilot generated this review using guidance from repository custom instructions.

def get_entry_by_key(self, key: str) -> Tuple[str, Optional[CacheEntry]]:
"""Get entry based on given key from Redis."""
redis_client = self._resolve_redis_client()
Expand All @@ -86,32 +134,28 @@ def get_entry_by_key(self, key: str) -> Tuple[str, Optional[CacheEntry]]:

# Deserialize the value
value = None
if cached_data.get(b"value"):
value = pickle.loads(cached_data[b"value"])
raw_value = _RedisCore._get_raw_field(cached_data, "value")
if raw_value is not None:
value = self._loading_pickle(raw_value)

# Parse timestamp
timestamp_str = cached_data.get(b"timestamp", b"").decode("utf-8")
raw_ts = _RedisCore._get_raw_field(cached_data, "timestamp") or b""
if isinstance(raw_ts, bytes):
try:
timestamp_str = raw_ts.decode("utf-8")
except Exception:
Comment thread
Borda marked this conversation as resolved.
Outdated
timestamp_str = raw_ts.decode("latin-1", errors="ignore")
else:
timestamp_str = str(raw_ts)
timestamp = (
datetime.fromisoformat(timestamp_str)
if timestamp_str
else datetime.now()
)

# Parse boolean fields
stale = (
cached_data.get(b"stale", b"false").decode("utf-8").lower()
== "true"
)
processing = (
cached_data.get(b"processing", b"false")
.decode("utf-8")
.lower()
== "true"
)
completed = (
cached_data.get(b"completed", b"false").decode("utf-8").lower()
== "true"
)
stale = _RedisCore._get_bool_field(cached_data, "stale")
processing = _RedisCore._get_bool_field(cached_data, "processing")
completed = _RedisCore._get_bool_field(cached_data, "completed")

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

def set_entry(self, key: str, func_res: Any) -> bool:
"""Map the given result to the given key in Redis."""
if not self._should_store(func_res):
return False
"""Map the given result to the given key in Redis."""
redis_client = self._resolve_redis_client()
redis_key = self._get_redis_key(key)

Expand Down Expand Up @@ -242,8 +286,16 @@ def delete_stale_entries(self, stale_after: timedelta) -> None:
ts = redis_client.hget(key, "timestamp")
if ts is None:
continue
# ts may be bytes or str depending on client configuration
if isinstance(ts, bytes):
try:
ts_s = ts.decode("utf-8")
except Exception:
ts_s = ts.decode("latin-1", errors="ignore")
else:
ts_s = str(ts)
try:
ts_val = datetime.fromisoformat(ts.decode("utf-8"))
ts_val = datetime.fromisoformat(ts_s)
except Exception as exc:
warnings.warn(
f"Redis timestamp parse failed: {exc}", stacklevel=2
Expand Down
73 changes: 69 additions & 4 deletions tests/test_pickle_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from cachier import cachier
from cachier.config import CacheEntry, _global_params
from cachier.cores.pickle import _PickleCore
from cachier.cores.redis import _RedisCore
Comment thread
Borda marked this conversation as resolved.


def _get_decorated_func(func, **kwargs):
Expand All @@ -42,9 +43,6 @@ def _get_decorated_func(func, **kwargs):
return decorated_func


# Pickle core tests


def _takes_2_seconds(arg_1, arg_2):
"""Some function."""
sleep(2)
Expand Down Expand Up @@ -528,14 +526,14 @@ def _error_throwing_func(arg1):
@pytest.mark.parametrize("separate_files", [True, False])
def test_error_throwing_func(separate_files):
# with
_error_throwing_func.count = 0
_error_throwing_func_decorated = _get_decorated_func(
_error_throwing_func,
stale_after=timedelta(seconds=1),
next_time=True,
separate_files=separate_files,
)
_error_throwing_func_decorated.clear_cache()
_error_throwing_func.count = 0
res1 = _error_throwing_func_decorated(4)
sleep(1.5)
res2 = _error_throwing_func_decorated(4)
Expand Down Expand Up @@ -1074,3 +1072,70 @@ def mock_func():
with patch("os.remove", side_effect=FileNotFoundError):
# Should not raise exception
core.delete_stale_entries(timedelta(hours=1))


# Redis core static method tests
@pytest.mark.parametrize(
("test_input", "expected"),
[
(pickle.dumps({"test": 123}), {"test": 123}), # valid string
# (pickle.dumps({"test": 123}).decode("utf-8"), {"test": 123}),
# (b"\x80\x04\x95", None), # corrupted bytes
(123, None), # unexpected type
# (b"corrupted", None), # triggers warning
],
)
def test_redis_loading_pickle(test_input, expected):
"""Test _RedisCore._loading_pickle with various inputs and exceptions."""
assert _RedisCore._loading_pickle(test_input) == expected


def test_redis_loading_pickle_failed():
"""Test _RedisCore._loading_pickle with various inputs and exceptions."""
with patch("pickle.loads", side_effect=Exception("Failed")):
assert _RedisCore._loading_pickle(123) is None


def test_redis_loading_pickle_latin1_fallback():
"""Test _RedisCore._loading_pickle with latin-1 fallback."""
valid_obj = {"test": 123}
with patch("pickle.loads") as mock_loads:
mock_loads.side_effect = [Exception("UTF-8 failed"), valid_obj]
result = _RedisCore._loading_pickle("invalid_utf8_string")
assert result == valid_obj
assert mock_loads.call_count == 2


@pytest.mark.parametrize(
("cached_data", "key", "expected"),
[
({b"field": b"value", "other": "data"}, "field", b"value"),
({"field": "value", b"other": b"data"}, "field", "value"),
({"other": "value"}, "field", None),
],
)
def test_redis_get_raw_field(cached_data, key, expected):
"""Test _RedisCore._get_raw_field with bytes and string keys."""
assert _RedisCore._get_raw_field(cached_data, key) == expected


@pytest.mark.parametrize(
("cached_data", "key", "expected"),
[
({b"flag": b"true"}, "flag", True),
({b"flag": b"false"}, "flag", False),
({"flag": "TRUE"}, "flag", True),
({}, "flag", False),
({b"flag": 123}, "flag", False),
],
)
def test_redis_get_bool_field(cached_data, key, expected):
"""Test _RedisCore._get_bool_field with various inputs."""
assert _RedisCore._get_bool_field(cached_data, key) == expected


def test_redis_get_bool_field_decode_fallback():
"""Test _RedisCore._get_bool_field with decoding fallback."""
with patch.object(_RedisCore, "_get_raw_field", return_value=b"\xff\xfe"):
result = _RedisCore._get_bool_field({}, "flag")
assert result is False
Loading
Loading