storage: handle function args with non-JSON-encodable keys
- ID
1a1ef6a- date
2026-11-07 17:56:41+00:00- author
Alex Chan <alex@alexwlchan.net>- parent
d524411- message
storage: handle function args with non-JSON-encodable keys Calling `json.dumps(…, default=str)` doesn't cover objects with keys that can't be serialised as JSON, like a dict with Path keys. Add an extra layer of serialisation step to ensure that functions that take this sort of argument can also be cached.- changed files
2 files, 33 additions, 10 deletions
Changed files
src/funkycache/storage.py (9672 → 10262)
diff --git a/src/funkycache/storage.py b/src/funkycache/storage.py
index 383ef82..bb44bef 100644
--- a/src/funkycache/storage.py
+++ b/src/funkycache/storage.py
@@ -73,18 +73,11 @@ class MemoryCacheStorage[V](CacheStorage[V]):
def __init__(self):
self._underlying: dict[str, V] = {}
- @staticmethod
- def _encode_key(key: Mapping[str, Any]) -> str:
- """
- Encode a mapping as a string key.
- """
- return json.dumps(key, sort_keys=True, default=str)
-
def set(self, key: Mapping[str, Any], value: V) -> None:
"""
Save a key-value pair in the cache.
"""
- enc_key = self._encode_key(key)
+ enc_key = _encode_key_as_json(key)
try:
if self._underlying[enc_key] != value:
@@ -96,7 +89,7 @@ class MemoryCacheStorage[V](CacheStorage[V]):
"""
Look up the value of a key in the cache.
"""
- enc_key = self._encode_key(key)
+ enc_key = _encode_key_as_json(key)
return self._underlying[enc_key]
@@ -260,7 +253,7 @@ class SQLiteCacheStorage[V](TypedCacheStorage[V]):
are serialised as JSON, then hashed to reduce it to a fixed size.
Using fixed-size keys improves SQLite performance.
"""
- json_key = json.dumps(key, sort_keys=True, default=str)
+ json_key = _encode_key_as_json(key)
return _hash(json_key)
def _set_str(self, key: Mapping[str, Any], value: str) -> None:
@@ -306,6 +299,30 @@ class SQLiteCacheStorage[V](TypedCacheStorage[V]):
raise KeyError(key)
+def _make_json_compatible(value: Any) -> Any:
+ """
+ Recursively walk an arbitrary structure and prepare it for JSON encoding.
+ """
+ if isinstance(value, dict):
+ return {str(k): _make_json_compatible(v) for k, v in value.items()}
+ elif isinstance(value, (list, tuple, set)):
+ return [_make_json_compatible(item) for item in value]
+ else:
+ return value
+
+
+def _encode_key_as_json(key: Mapping[str, Any]) -> str:
+ """
+ Encode a set of arguments to a Python function as a JSON string.
+
+ This is a lossy transformation that is designed to get a reasonable
+ serialisation as JSON. It is possible for values of different types
+ to collide.
+ """
+ clean_key = _make_json_compatible(key)
+ return json.dumps(clean_key, sort_keys=True, default=str)
+
+
@cache
def _hash(s: str) -> str:
"""
tests/test_storage.py (7192 → 7558)
diff --git a/tests/test_storage.py b/tests/test_storage.py
index 431ad02..6dc6027 100644
--- a/tests/test_storage.py
+++ b/tests/test_storage.py
@@ -46,6 +46,12 @@ class CacheStorageTestSuite(ABC):
# A Path isn't serialisable by json.dumps(…) with the
# default arguments, but we still want to cache it.
pytest.param({"n": Path("10.txt")}, id="path"),
+ #
+ # A dict with Path keys isn't serialisable by json.dumps(…),
+ # even if you pass default=str.
+ pytest.param({Path("n"): "numbers"}, id="path_key"),
+ pytest.param([{Path("n"): "numbers"}], id="path_key_nested_in_list"),
+ pytest.param({"1": {Path("n"): "numbers"}}, id="path_key_nested_in_obj"),
],
)
def test_cache(self, key: Mapping[str, Any], tmp_path: Path) -> None: