Skip to main content

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: