From a0f6048cab2f20ae56f6879aece2b3dd8f63de9c Mon Sep 17 00:00:00 2001 From: PedroMMRAF Date: Tue, 6 Jan 2026 13:04:46 +0000 Subject: [PATCH 1/5] fix: introduce cache config option to specify if cache is process-isolated or not --- cachify/cache.py | 4 ++-- cachify/features/never_die.py | 1 + cachify/memory_cache.py | 1 + cachify/redis_cache.py | 1 + cachify/types/__init__.py | 3 ++- cachify/utils/arguments.py | 21 +++++++++++++++++---- 6 files changed, 24 insertions(+), 7 deletions(-) diff --git a/cachify/cache.py b/cachify/cache.py index 4cb7783..028e74c 100644 --- a/cachify/cache.py +++ b/cachify/cache.py @@ -18,7 +18,7 @@ def _async_decorator( @functools.wraps(function) async def async_wrapper(*args: Any, **kwargs: Any) -> Any: skip_cache = kwargs.pop("skip_cache", False) - cache_key = create_cache_key(function, cache_key_func, ignore_fields, args, kwargs) + cache_key = create_cache_key(function, cache_key_func, ignore_fields, args, kwargs, config.process_isolated) if cache_entry := await config.storage.aget(cache_key, skip_cache): return cache_entry.result @@ -49,7 +49,7 @@ def _sync_decorator( @functools.wraps(function) def sync_wrapper(*args: Any, **kwargs: Any) -> Any: skip_cache = kwargs.pop("skip_cache", False) - cache_key = create_cache_key(function, cache_key_func, ignore_fields, args, kwargs) + cache_key = create_cache_key(function, cache_key_func, ignore_fields, args, kwargs, config.process_isolated) if cache_entry := config.storage.get(cache_key, skip_cache): return cache_entry.result diff --git a/cachify/features/never_die.py b/cachify/features/never_die.py index fc7758c..2e6e58f 100644 --- a/cachify/features/never_die.py +++ b/cachify/features/never_die.py @@ -46,6 +46,7 @@ def cache_key(self) -> str: self.ignore_fields, self.args, self.kwargs, + self.config.process_isolated, ) def __eq__(self, other: Any) -> bool: diff --git a/cachify/memory_cache.py b/cachify/memory_cache.py index 8fc9aeb..9d714b9 100644 --- a/cachify/memory_cache.py +++ b/cachify/memory_cache.py @@ -13,6 +13,7 @@ storage=MemoryStorage, sync_lock=lambda cache_key: SYNC_LOCKS[cache_key], async_lock=lambda cache_key: ASYNC_LOCKS[cache_key], + process_isolated=True, ) diff --git a/cachify/redis_cache.py b/cachify/redis_cache.py index 26ddb15..546c464 100644 --- a/cachify/redis_cache.py +++ b/cachify/redis_cache.py @@ -9,6 +9,7 @@ storage=RedisStorage, sync_lock=RedisLockManager.sync_lock, async_lock=RedisLockManager.async_lock, + process_isolated=False, ) diff --git a/cachify/types/__init__.py b/cachify/types/__init__.py index ac75a3a..049db95 100644 --- a/cachify/types/__init__.py +++ b/cachify/types/__init__.py @@ -35,11 +35,12 @@ def is_expired(self) -> bool: @dataclass(frozen=True, slots=True) class CacheConfig: - """Configuration for cache, grouping storage, lock, and never_die registration.""" + """Configuration for cache, grouping storage, lock, never_die registration and process-isolated indicator.""" storage: "CacheStorage" sync_lock: Callable[[str], ContextManager] async_lock: Callable[[str], AsyncContextManager] + process_isolated: bool class CacheEntryProtocol(Protocol): diff --git a/cachify/utils/arguments.py b/cachify/utils/arguments.py index 72444d2..7108004 100644 --- a/cachify/utils/arguments.py +++ b/cachify/utils/arguments.py @@ -1,5 +1,6 @@ import hashlib import inspect +import logging import pickle from collections.abc import Callable, Generator from inspect import Signature @@ -9,8 +10,19 @@ from cachify.utils.functions import get_function_id -def _cache_key_fingerprint(value: object) -> str: - payload = pickle.dumps(value, protocol=pickle.HIGHEST_PROTOCOL) +def _cache_key_fingerprint(value: object, process_isolated: bool) -> str: + try: + payload = pickle.dumps(value, protocol=pickle.HIGHEST_PROTOCOL) + + except (pickle.PicklingError, TypeError) as exc: + if not process_isolated: + raise ValueError( + "Process-shared cache key contains non-picklable items - Consider ignoring suspect fields" + ) from exc + + payload = id(value).to_bytes(8, byteorder="big", signed=True) + logging.debug("Using process-isolated cache key generation for non-picklable cache key items.") + return hashlib.blake2b(payload, digest_size=16).hexdigest() @@ -48,17 +60,18 @@ def create_cache_key( ignore_fields: tuple[str, ...], args: tuple, kwargs: dict, + process_isolated: bool, ) -> str: function_id = get_function_id(function) if not cache_key_func: function_signature = inspect.signature(function) items = tuple(_iter_arguments(function_signature, args, kwargs, ignore_fields)) - return f"{function_id}:{_cache_key_fingerprint(items)}" + return f"{function_id}:{_cache_key_fingerprint(items, process_isolated)}" cache_key = cache_key_func(args, kwargs) try: - return f"{function_id}:{_cache_key_fingerprint(cache_key)}" + return f"{function_id}:{_cache_key_fingerprint(cache_key, process_isolated)}" except TypeError as exc: raise ValueError( "Cache key function must return a hashable cache key - be careful with mutable types (list, dict, set) and non built-in types" From f105bc961f7bfd8a2a3790f8518e138b49a8145e Mon Sep 17 00:00:00 2001 From: PedroMMRAF Date: Tue, 6 Jan 2026 13:05:00 +0000 Subject: [PATCH 2/5] chore: remove dead code --- cachify/utils/decorator_factory.py | 44 ------------------------------ 1 file changed, 44 deletions(-) delete mode 100644 cachify/utils/decorator_factory.py diff --git a/cachify/utils/decorator_factory.py b/cachify/utils/decorator_factory.py deleted file mode 100644 index eb259ff..0000000 --- a/cachify/utils/decorator_factory.py +++ /dev/null @@ -1,44 +0,0 @@ -import inspect -from typing import Callable - -from cachify._async import async_decorator -from cachify._sync import sync_decorator -from cachify.types import CacheConfig, CacheKeyFunction, F, Number - - -def create_cache_decorator( - ttl: Number, - never_die: bool, - cache_key_func: CacheKeyFunction | None, - ignore_fields: tuple[str, ...], - config: CacheConfig, -) -> Callable[[F], F]: - """ - Create a cache decorator with the given configuration. - - This is a shared factory used by both memory_cache and redis_cache - to avoid code duplication. - """ - if cache_key_func and ignore_fields: - raise ValueError("Either cache_key_func or ignore_fields can be provided, but not both") - - def decorator(function: F) -> F: - if inspect.iscoroutinefunction(function): - return async_decorator( - function=function, - ttl=ttl, - never_die=never_die, - cache_key_func=cache_key_func, - ignore_fields=ignore_fields, - config=config, - ) - return sync_decorator( - function=function, - ttl=ttl, - never_die=never_die, - cache_key_func=cache_key_func, - ignore_fields=ignore_fields, - config=config, - ) - - return decorator From a73d98485c04834868f7f9237d265e94296813c5 Mon Sep 17 00:00:00 2001 From: PedroMMRAF Date: Wed, 7 Jan 2026 10:09:24 +0000 Subject: [PATCH 3/5] fix: improve cache hash function --- cachify/utils/arguments.py | 59 +++++++++++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 11 deletions(-) diff --git a/cachify/utils/arguments.py b/cachify/utils/arguments.py index 7108004..b40f328 100644 --- a/cachify/utils/arguments.py +++ b/cachify/utils/arguments.py @@ -1,8 +1,8 @@ import hashlib import inspect -import logging import pickle -from collections.abc import Callable, Generator +from collections.abc import Callable, Generator, Mapping, Sequence, Set +from contextlib import suppress from inspect import Signature from typing import Any @@ -10,22 +10,59 @@ from cachify.utils.functions import get_function_id -def _cache_key_fingerprint(value: object, process_isolated: bool) -> str: +def _process_isolated_fingerprint(value: Any) -> str: + stack = [value] + seen = set() + result = hashlib.blake2b(digest_size=16) + + while stack: + current = stack.pop() + current_id = id(current) + + # Avoid processing the same object multiple times + if current_id in seen: + continue + seen.add(current_id) + + with suppress(TypeError): + result.update(hash(current).to_bytes(8, "big", signed=True)) + continue + + if isinstance(current, Sequence): + stack.extend(current) + continue + + if isinstance(current, Set): + stack.extend(sorted(current)) + continue + + if isinstance(current, Mapping): + stack.extend(current.items()) + continue + + result.update(current_id.to_bytes(8, "big", signed=True)) + + return result.hexdigest() + + +def _process_shared_fingerprint(value: Any) -> str: try: payload = pickle.dumps(value, protocol=pickle.HIGHEST_PROTOCOL) - except (pickle.PicklingError, TypeError) as exc: - if not process_isolated: - raise ValueError( - "Process-shared cache key contains non-picklable items - Consider ignoring suspect fields" - ) from exc - - payload = id(value).to_bytes(8, byteorder="big", signed=True) - logging.debug("Using process-isolated cache key generation for non-picklable cache key items.") + except (pickle.PicklingError, TypeError, AttributeError) as exc: + raise ValueError( + "Process-shared cache key contains non-picklable items - Consider ignoring suspect fields" + ) from exc return hashlib.blake2b(payload, digest_size=16).hexdigest() +def _cache_key_fingerprint(value: Any, process_isolated: bool) -> str: + if process_isolated: + return _process_isolated_fingerprint(value) + return _process_shared_fingerprint(value) + + def _iter_arguments( function_signature: Signature, args: tuple, From b0373b4d01c6e17994bd4383e0b17878f181625e Mon Sep 17 00:00:00 2001 From: PedroMMRAF Date: Wed, 7 Jan 2026 11:24:34 +0000 Subject: [PATCH 4/5] fix: properly handle nested structures and allow for same code functions to be hashed to the same key --- cachify/utils/arguments.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/cachify/utils/arguments.py b/cachify/utils/arguments.py index b40f328..ca85def 100644 --- a/cachify/utils/arguments.py +++ b/cachify/utils/arguments.py @@ -19,27 +19,34 @@ def _process_isolated_fingerprint(value: Any) -> str: current = stack.pop() current_id = id(current) - # Avoid processing the same object multiple times if current_id in seen: continue - seen.add(current_id) - with suppress(TypeError): - result.update(hash(current).to_bytes(8, "big", signed=True)) + with suppress(TypeError, AttributeError, pickle.PicklingError): + result.update(pickle.dumps(current, protocol=pickle.HIGHEST_PROTOCOL)) + continue + + # Handle code objects to differentiate different lambda functions + with suppress(AttributeError, TypeError): + result.update(current.__code__.co_code) continue if isinstance(current, Sequence): + seen.add(current_id) stack.extend(current) continue if isinstance(current, Set): + seen.add(current_id) stack.extend(sorted(current)) continue if isinstance(current, Mapping): + seen.add(current_id) stack.extend(current.items()) continue + # Last resort: use the id of the object result.update(current_id.to_bytes(8, "big", signed=True)) return result.hexdigest() From 9eb07622040804b9374ce9e57c7d959a948cabd2 Mon Sep 17 00:00:00 2001 From: PedroMMRAF Date: Wed, 7 Jan 2026 11:25:25 +0000 Subject: [PATCH 5/5] test: introduce various tests to new unpickleable object handling --- pytest/tests/redis/test_redis_unpickleable.py | 129 ++++++++ pytest/tests/unpickleable_objects/__init__.py | 0 .../test_async_unpickleable.py | 279 ++++++++++++++++++ .../test_sync_unpickleable.py | 267 +++++++++++++++++ 4 files changed, 675 insertions(+) create mode 100644 pytest/tests/redis/test_redis_unpickleable.py create mode 100644 pytest/tests/unpickleable_objects/__init__.py create mode 100644 pytest/tests/unpickleable_objects/test_async_unpickleable.py create mode 100644 pytest/tests/unpickleable_objects/test_sync_unpickleable.py diff --git a/pytest/tests/redis/test_redis_unpickleable.py b/pytest/tests/redis/test_redis_unpickleable.py new file mode 100644 index 0000000..6534c59 --- /dev/null +++ b/pytest/tests/redis/test_redis_unpickleable.py @@ -0,0 +1,129 @@ +import threading +import pytest +import redis + +from cachify import redis_cache + + +class TestUnpickleableArgumentsRaiseError: + """Tests that unpickleable arguments raise clear errors with Redis cache.""" + + def test_lambda_argument_raises_value_error(self, setup_sync_redis: redis.Redis): + """Redis cache should raise ValueError for lambda arguments.""" + + @redis_cache(ttl=60) + def cached_func(func) -> int: + return 1 + + with pytest.raises(ValueError): + cached_func(lambda x: x * 2) + + def test_lock_argument_raises_value_error(self, setup_sync_redis: redis.Redis): + """Redis cache should raise ValueError for Lock arguments.""" + + @redis_cache(ttl=60) + def cached_func(lock: threading.Lock) -> int: + return 1 + + with pytest.raises(ValueError): + cached_func(threading.Lock()) + + def test_nested_unpickleable_raises_value_error(self, setup_sync_redis: redis.Redis): + """Redis cache should raise ValueError for nested unpickleable objects.""" + + @redis_cache(ttl=60) + def cached_func(data: dict) -> int: + return 1 + + with pytest.raises(ValueError): + cached_func({"nested": {"lambda": lambda: None}}) + + def test_deeply_nested_unpickleable_raises_value_error(self, setup_sync_redis: redis.Redis): + """Redis cache should raise ValueError for deeply nested unpickleable objects.""" + + @redis_cache(ttl=60) + def cached_func(data: dict) -> int: + return 1 + + with pytest.raises(ValueError): + cached_func({"level1": {"level2": {"level3": [lambda: None, 1, 2]}}}) + + def test_mixed_nested_structures_with_unpickleable_raises(self, setup_sync_redis: redis.Redis): + """Redis cache should raise ValueError for mixed nested structures with unpickleable.""" + + @redis_cache(ttl=60) + def cached_func(data: dict) -> int: + return 1 + + mixed_data = { + "list": [1, {"inner_dict": [threading.Lock(), 2, 3]}, 4], + "tuple": (5, (6, {"deep": threading.Lock()})), + "simple": "value", + } + + with pytest.raises(ValueError): + cached_func(mixed_data) + + +class TestCacheKeyFuncUnpickleableRaisesError: + """Tests that cache_key_func returning unpickleable raises clear errors.""" + + def test_cache_key_func_returning_lambda_raises(self, setup_sync_redis: redis.Redis): + """Redis cache should raise ValueError when cache_key_func returns unpickleable.""" + key_lambda = lambda x: x # noqa: E731 + + @redis_cache(ttl=60, cache_key_func=lambda args, kwargs: key_lambda) + def cached_func(value: int) -> int: + return value + + with pytest.raises(ValueError): + cached_func(1) + + def test_cache_key_func_with_unpickleable_in_tuple_raises(self, setup_sync_redis: redis.Redis): + """Redis cache should raise ValueError when cache_key_func returns tuple with unpickleable.""" + lock = threading.Lock() + + @redis_cache(ttl=60, cache_key_func=lambda args, kwargs: (args[0], lock)) + def cached_func(value: int) -> int: + return value + + with pytest.raises(ValueError): + cached_func(1) + + +class TestIgnoreFieldsWithUnpickleable: + """Tests that ignore_fields can be used to work around unpickleable arguments.""" + + def test_ignore_unpickleable_field_allows_caching(self, setup_sync_redis: redis.Redis): + """Redis cache should work when unpickleable field is ignored.""" + call_count = 0 + + @redis_cache(ttl=60, ignore_fields=("callback",)) + def cached_func(value: int, callback) -> int: + nonlocal call_count + call_count += 1 + return value * 2 + + my_lambda = lambda x: x # noqa: E731 + result1 = cached_func(5, callback=my_lambda) + result2 = cached_func(5, callback=my_lambda) + + assert result1 == result2 == 10 + assert call_count == 1 + + def test_ignore_lock_field_allows_caching(self, setup_sync_redis: redis.Redis): + """Redis cache should work when lock field is ignored.""" + call_count = 0 + + @redis_cache(ttl=60, ignore_fields=("lock",)) + def cached_func(value: int, lock: threading.Lock) -> int: + nonlocal call_count + call_count += 1 + return value * 2 + + lock = threading.Lock() + result1 = cached_func(5, lock=lock) + result2 = cached_func(5, lock=lock) + + assert result1 == result2 == 10 + assert call_count == 1 diff --git a/pytest/tests/unpickleable_objects/__init__.py b/pytest/tests/unpickleable_objects/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pytest/tests/unpickleable_objects/test_async_unpickleable.py b/pytest/tests/unpickleable_objects/test_async_unpickleable.py new file mode 100644 index 0000000..43d69da --- /dev/null +++ b/pytest/tests/unpickleable_objects/test_async_unpickleable.py @@ -0,0 +1,279 @@ +import asyncio + +import pytest +from cachify.memory_cache import cache +from cachify.storage.memory_storage import MemoryStorage + +TTL = 0.1 + + +class TestUnpickleableArguments: + """Tests for async caching behavior with lambda arguments (unpickleable).""" + + @pytest.fixture(autouse=True) + def clear_cache(self): + MemoryStorage.clear() + + @pytest.mark.asyncio + async def test_lambda_argument_caches_successfully(self): + """Memory cache should handle lambda arguments without raising.""" + call_count = 0 + + @cache(ttl=TTL) + async def cached_func(func) -> int: + nonlocal call_count + call_count += 1 + return call_count + + my_lambda = lambda x: x # noqa: E731 + result1 = await cached_func(my_lambda) + result2 = await cached_func(my_lambda) + result3 = await cached_func(my_lambda) + + assert result1 == result2 == result3 + assert call_count == 1 + + @pytest.mark.asyncio + async def test_different_lambdas__code_produce_same_cache_keys(self): + """Different lambda objects with the same code should produce the same cache keys.""" + call_count = 0 + + @cache(ttl=TTL) + async def cached_func(func) -> int: + nonlocal call_count + call_count += 1 + return call_count + + result1 = await cached_func(lambda x: x) + result2 = await cached_func(lambda y: y) + + assert result1 == result2 + assert call_count == 1 + + @pytest.mark.asyncio + async def test_different_lambdas_different_code_produce_different_cache_keys(self): + """Different lambda objects with the different code should produce different cache keys.""" + call_count = 0 + + @cache(ttl=TTL) + async def cached_func(func) -> int: + nonlocal call_count + call_count += 1 + return call_count + + result1 = await cached_func(lambda x: x) + result2 = await cached_func(lambda y: y + 1) + + assert result1 != result2 + assert call_count == 2 + + @pytest.mark.asyncio + async def test_list_containing_lambda(self): + """Memory cache should handle lists containing lambdas.""" + call_count = 0 + + @cache(ttl=TTL) + async def cached_func(items: list) -> int: + nonlocal call_count + call_count += 1 + return call_count + + my_lambda = lambda x: x # noqa: E731 + result1 = await cached_func([1, 2, my_lambda]) + result2 = await cached_func([1, 2, my_lambda]) + + assert result1 == result2 + assert call_count == 1 + + @pytest.mark.asyncio + async def test_dict_containing_lock(self): + """Memory cache should handle dicts containing locks.""" + call_count = 0 + + @cache(ttl=TTL) + async def cached_func(data: dict) -> int: + nonlocal call_count + call_count += 1 + return call_count + + lock = asyncio.Lock() + result1 = await cached_func({"lock": lock, "value": 42}) + result2 = await cached_func({"lock": lock, "value": 42}) + + assert result1 == result2 + assert call_count == 1 + + @pytest.mark.asyncio + async def test_set_containing_hashable_unpickleable_objects(self): + """Memory cache should handle sets with hashable but unpickleable items.""" + call_count = 0 + + @cache(ttl=TTL) + async def cached_func(items: list) -> int: + nonlocal call_count + call_count += 1 + return call_count + + class UnpickleableHashable: + def __reduce__(self): + raise TypeError("Cannot pickle") + + obj1 = UnpickleableHashable() + obj2 = UnpickleableHashable() + + result1 = await cached_func([obj1, obj2]) + result2 = await cached_func([obj1, obj2]) + + assert result1 == result2 + assert call_count == 1 + + @pytest.mark.asyncio + async def test_deeply_nested_unpickleable_objects(self): + """Memory cache should handle deeply nested structures with unpickleable objects.""" + call_count = 0 + + @cache(ttl=TTL) + async def cached_func(data: dict) -> int: + nonlocal call_count + call_count += 1 + return call_count + + nested_data = {"level1": {"level2": {"level3": [lambda x: x, 1, 2]}}} + result1 = await cached_func(nested_data) + result2 = await cached_func(nested_data) + + assert result1 == result2 + assert call_count == 1 + + @pytest.mark.asyncio + async def test_mixed_nested_structures_with_unpickleable(self): + """Memory cache should handle mixed list/dict/set nesting with unpickleable.""" + call_count = 0 + + @cache(ttl=TTL) + async def cached_func(data: dict) -> int: + nonlocal call_count + call_count += 1 + return call_count + + mixed_data = { + "list": [1, {"inner_dict": [asyncio.Lock(), 2, 3]}, 4], + "tuple": (5, (6, {"deep": asyncio.Lock()})), + "simple": "value", + } + + result1 = await cached_func(mixed_data) + result2 = await cached_func(mixed_data) + + assert result1 == result2 + assert call_count == 1 + + +class TestDuplicateObjectReferences: + """Tests for cycle detection and duplicate object handling in async context.""" + + @pytest.fixture(autouse=True) + def clear_cache(self): + MemoryStorage.clear() + + @pytest.mark.asyncio + async def test_same_object_referenced_multiple_times(self): + """Cache should handle same unpickleable object referenced multiple times.""" + call_count = 0 + + @cache(ttl=TTL) + async def cached_func(items: list) -> int: + nonlocal call_count + call_count += 1 + return call_count + + lock = asyncio.Lock() + result1 = await cached_func([lock, lock, lock]) + result2 = await cached_func([lock, lock, lock]) + + assert result1 == result2 + assert call_count == 1 + + @pytest.mark.asyncio + async def test_same_object_referenced_different_times(self): + """Cache should handle same unpickleable object referenced multiple times.""" + call_count = 0 + + @cache(ttl=TTL) + async def cached_func(items: list) -> int: + nonlocal call_count + call_count += 1 + return call_count + + lock = asyncio.Lock() + result1 = await cached_func([lock, lock]) + result2 = await cached_func([lock, lock, lock]) + + assert result1 != result2 + assert call_count == 2 + + @pytest.mark.asyncio + async def test_circular_reference_in_dict(self): + """Cache should handle circular references in data structures.""" + call_count = 0 + + @cache(ttl=TTL) + async def cached_func(data: dict) -> int: + nonlocal call_count + call_count += 1 + return call_count + + circular_dict = {"value": 1, "self": None} + circular_dict["self"] = circular_dict + + result1 = await cached_func(circular_dict) + result2 = await cached_func(circular_dict) + + assert result1 == result2 + assert call_count == 1 + + +class TestCacheKeyFuncWithUnpickleable: + """Tests for cache_key_func returning unpickleable values in async context.""" + + @pytest.fixture(autouse=True) + def clear_cache(self): + MemoryStorage.clear() + + @pytest.mark.asyncio + async def test_cache_key_func_returning_lambda(self): + """Memory cache should handle cache_key_func returning unpickleable value.""" + call_count = 0 + key_lambda = lambda x: x # noqa: E731 + + @cache(ttl=TTL, cache_key_func=lambda args, kwargs: key_lambda) + async def cached_func(value: int) -> int: + nonlocal call_count + call_count += 1 + return call_count + + result1 = await cached_func(1) + result2 = await cached_func(2) + + assert result1 == result2 + assert call_count == 1 + + @pytest.mark.asyncio + async def test_cache_key_func_with_unpickleable_in_tuple(self): + """Memory cache should handle cache_key_func returning tuple with unpickleable.""" + call_count = 0 + lock = asyncio.Lock() + + @cache(ttl=TTL, cache_key_func=lambda args, kwargs: (args[0], lock)) + async def cached_func(value: int) -> int: + nonlocal call_count + call_count += 1 + return call_count + + result1 = await cached_func(1) + result2 = await cached_func(1) + result3 = await cached_func(2) + + assert result1 == result2 + assert result1 != result3 + assert call_count == 2 diff --git a/pytest/tests/unpickleable_objects/test_sync_unpickleable.py b/pytest/tests/unpickleable_objects/test_sync_unpickleable.py new file mode 100644 index 0000000..831f026 --- /dev/null +++ b/pytest/tests/unpickleable_objects/test_sync_unpickleable.py @@ -0,0 +1,267 @@ +import threading + +import pytest +from cachify.memory_cache import cache +from cachify.storage.memory_storage import MemoryStorage + +TTL = 0.1 + + +class TestUnpickleableArguments: + """Tests for caching behavior with lambda arguments (unpickleable).""" + + @pytest.fixture(autouse=True) + def clear_cache(self): + MemoryStorage.clear() + + def test_lambda_argument_caches_successfully(self): + """Memory cache should handle lambda arguments without raising.""" + call_count = 0 + + @cache(ttl=TTL) + def cached_func(func) -> int: + nonlocal call_count + call_count += 1 + return call_count + + my_lambda = lambda x: x # noqa: E731 + result1 = cached_func(my_lambda) + result2 = cached_func(my_lambda) + result3 = cached_func(my_lambda) + + assert result1 == result2 == result3 + assert call_count == 1 + + def test_different_lambdas_same_code_produce_same_cache_keys(self): + """Different lambda objects with the same code should produce the same cache keys.""" + call_count = 0 + + @cache(ttl=TTL) + def cached_func(func) -> int: + nonlocal call_count + call_count += 1 + return call_count + + result1 = cached_func(lambda x: x) + result2 = cached_func(lambda y: y) + + assert result1 == result2 + assert call_count == 1 + + def test_different_lambdas_different_code_produce_different_cache_keys(self): + """Different lambda objects with the different code should produce different cache keys.""" + call_count = 0 + + @cache(ttl=TTL) + def cached_func(func) -> int: + nonlocal call_count + call_count += 1 + return call_count + + result1 = cached_func(lambda x: x) + result2 = cached_func(lambda y: y + 1) + + assert result1 != result2 + assert call_count == 2 + + def test_list_containing_lambda(self): + """Memory cache should handle lists containing lambdas.""" + call_count = 0 + + @cache(ttl=TTL) + def cached_func(items: list) -> int: + nonlocal call_count + call_count += 1 + return call_count + + my_lambda = lambda x: x # noqa: E731 + result1 = cached_func([1, 2, my_lambda]) + result2 = cached_func([1, 2, my_lambda]) + + assert result1 == result2 + assert call_count == 1 + + def test_dict_containing_lock(self): + """Memory cache should handle dicts containing locks.""" + call_count = 0 + + @cache(ttl=TTL) + def cached_func(data: dict) -> int: + nonlocal call_count + call_count += 1 + return call_count + + lock = threading.Lock() + result1 = cached_func({"lock": lock, "value": 42}) + result2 = cached_func({"lock": lock, "value": 42}) + + assert result1 == result2 + assert call_count == 1 + + def test_set_containing_hashable_unpickleable_objects(self): + """Memory cache should handle sets with hashable but unpickleable items.""" + call_count = 0 + + @cache(ttl=TTL) + def cached_func(items: list) -> int: + nonlocal call_count + call_count += 1 + return call_count + + class UnpickleableHashable: + def __reduce__(self): + raise TypeError("Cannot pickle") + + obj1 = UnpickleableHashable() + obj2 = UnpickleableHashable() + result1 = cached_func([obj1, obj2]) + result2 = cached_func([obj1, obj2]) + + assert result1 == result2 + assert call_count == 1 + + def test_deeply_nested_unpickleable_objects(self): + """Memory cache should handle deeply nested structures with unpickleable objects.""" + call_count = 0 + + @cache(ttl=TTL) + def cached_func(data: dict) -> int: + nonlocal call_count + call_count += 1 + return call_count + + nested_data = {"level1": {"level2": {"level3": [lambda x: x, 1, 2]}}} + + result1 = cached_func(nested_data) + result2 = cached_func(nested_data) + + assert result1 == result2 + assert call_count == 1 + + def test_mixed_nested_structures_with_unpickleable(self): + """Memory cache should handle mixed list/dict/set nesting with unpickleable.""" + call_count = 0 + + @cache(ttl=TTL) + def cached_func(data: dict) -> int: + nonlocal call_count + call_count += 1 + return call_count + + mixed_data = { + "list": [1, {"inner_dict": [threading.Lock(), 2, 3]}, 4], + "tuple": (5, (6, {"deep": threading.Lock()})), + "simple": "value", + } + + result1 = cached_func(mixed_data) + result2 = cached_func(mixed_data) + + assert result1 == result2 + assert call_count == 1 + + +class TestDuplicateObjectReferences: + """Tests for cycle detection and duplicate object handling.""" + + @pytest.fixture(autouse=True) + def clear_cache(self): + MemoryStorage.clear() + + def test_same_object_referenced_multiple_times(self): + """Cache should handle same unpickleable object referenced multiple times.""" + call_count = 0 + + @cache(ttl=TTL) + def cached_func(items: list) -> int: + nonlocal call_count + call_count += 1 + return call_count + + lock = threading.Lock() + result1 = cached_func([lock, lock, lock]) + result2 = cached_func([lock, lock, lock]) + + assert result1 == result2 + assert call_count == 1 + + def test_same_object_referenced_different_times(self): + """Cache should handle same unpickleable object referenced multiple times.""" + call_count = 0 + + @cache(ttl=TTL) + def cached_func(items: list) -> int: + nonlocal call_count + call_count += 1 + return call_count + + lock = threading.Lock() + result1 = cached_func([lock, lock]) + result2 = cached_func([lock, lock, lock]) + + assert result1 != result2 + assert call_count == 2 + + def test_circular_reference_in_dict(self): + """Cache should handle circular references in data structures.""" + call_count = 0 + + @cache(ttl=TTL) + def cached_func(data: dict) -> int: + nonlocal call_count + call_count += 1 + return call_count + + circular_dict = {"value": 1, "self": None} + circular_dict["self"] = circular_dict + + result1 = cached_func(circular_dict) + result2 = cached_func(circular_dict) + + assert result1 == result2 + assert call_count == 1 + + +class TestCacheKeyFuncWithUnpickleable: + """Tests for cache_key_func returning unpickleable values.""" + + @pytest.fixture(autouse=True) + def clear_cache(self): + MemoryStorage.clear() + + def test_cache_key_func_returning_lambda(self): + """Memory cache should handle cache_key_func returning unpickleable value.""" + call_count = 0 + key_lambda = lambda x: x # noqa: E731 + + @cache(ttl=TTL, cache_key_func=lambda args, kwargs: key_lambda) + def cached_func(value: int) -> int: + nonlocal call_count + call_count += 1 + return call_count + + result1 = cached_func(1) + result2 = cached_func(2) + + # Same cache key (the lambda) for both calls + assert result1 == result2 + assert call_count == 1 + + def test_cache_key_func_with_unpickleable_in_tuple(self): + """Memory cache should handle cache_key_func returning tuple with unpickleable.""" + call_count = 0 + lock = threading.Lock() + + @cache(ttl=TTL, cache_key_func=lambda args, kwargs: (args[0], lock)) + def cached_func(value: int) -> int: + nonlocal call_count + call_count += 1 + return call_count + + result1 = cached_func(1) + result2 = cached_func(1) + result3 = cached_func(2) + + assert result1 == result2 + assert result1 != result3 + assert call_count == 2