Skip to main content

storage: add an in-memory implementation of CacheStorage

ID
6d99bba
date
2026-11-05 07:16:21+00:00
author
Alex Chan <alex@alexwlchan.net>
parent
d51ee4e
message
storage: add an in-memory implementation of CacheStorage

This serialises function arguments as a JSON string, casting values that
can't be serialised using `json.dumps` to a string. We add an additional
test case that such keys can be properly cached.
changed files
2 files, 73 additions, 7 deletions

Changed files

src/funkycache/storage.py (1060 → 2069)

diff --git a/src/funkycache/storage.py b/src/funkycache/storage.py
index 4d62d10..8e5a473 100644
--- a/src/funkycache/storage.py
+++ b/src/funkycache/storage.py
@@ -4,6 +4,7 @@ Backing storage for cache, which records the input/output of Python functions.
 
 from abc import ABC, abstractmethod
 from collections.abc import Mapping
+import json
 from typing import Any
 
 
@@ -13,7 +14,7 @@ class CannotOverwriteValueException(ValueError):
     a different value.
     """
 
-    def __init__(self, key: str) -> None:
+    def __init__(self, key: Mapping[str, Any]) -> None:
         super().__init__(f"Key already exists: {key!r}")
 
 
@@ -39,3 +40,39 @@ class CacheStorage[V](ABC):
 
         Throws a KeyError if you look up a key which isn't in the cache.
         """
+
+
+class MemoryCacheStorage[V](CacheStorage[V]):
+    """
+    In-memory storage for a cache of the input/output of
+    a single Python function.
+    """
+
+    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)
+
+        try:
+            if self._underlying[enc_key] != value:
+                raise CannotOverwriteValueException(key)
+        except KeyError:
+            self._underlying[enc_key] = value
+
+    def get(self, key: Mapping[str, Any]) -> V:
+        """
+        Look up the value of a key in the cache.
+        """
+        enc_key = self._encode_key(key)
+        return self._underlying[enc_key]

tests/test_storage.py (1405 → 2236)

diff --git a/tests/test_storage.py b/tests/test_storage.py
index e1fa30e..626d742 100644
--- a/tests/test_storage.py
+++ b/tests/test_storage.py
@@ -3,11 +3,17 @@ Tests for backend storage implementations.
 """
 
 from abc import ABC, abstractmethod
-from collections.abc import Iterator
+from collections.abc import Iterator, Mapping
+from pathlib import Path
+from typing import Any
 
 import pytest
 
-from funkycache.storage import CacheStorage, CannotOverwriteValueException
+from funkycache.storage import (
+    CacheStorage,
+    MemoryCacheStorage,
+    CannotOverwriteValueException,
+)
 
 
 class CacheStorageTestSuite(ABC):
@@ -25,12 +31,21 @@ class CacheStorageTestSuite(ABC):
         Return an empty cache storage backend that holds integer values.
         """
 
-    def test_cache(self, storage: CacheStorage[int]) -> None:
+    @pytest.mark.parametrize(
+        "key",
+        [
+            pytest.param({"n": "10"}, id="str"),
+            pytest.param({"n": 10}, id="int"),
+            #
+            # 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"),
+        ],
+    )
+    def test_cache(self, storage: CacheStorage[int], key: Mapping[str, Any]) -> None:
         """
         Test the basic properties of a cache.
         """
-        key = {"n": "10"}
-
         fib10 = 55
         fib20 = 6765
 
@@ -43,11 +58,25 @@ class CacheStorageTestSuite(ABC):
         assert storage.get(key) == fib10
 
         # Overwriting an existing value is an error.
-        with pytest.raises(CannotOverwriteValueException):
+        with pytest.raises(CannotOverwriteValueException) as exc:
             storage.set(key, value=fib20)
+        assert repr(key) in exc.value.args[0]
 
         assert storage.get(key) == fib10
 
         # Writing a key with the same value as the already-stored
         # value is allowed.
         storage.set(key, value=fib10)
+
+
+class TestMemoryCacheStorage(CacheStorageTestSuite):
+    """
+    Tests for MemoryCacheStorage.
+    """
+
+    @pytest.fixture
+    def storage(self) -> Iterator[CacheStorage[int]]:
+        """
+        Return an empty in-memory cache that returns integer values.
+        """
+        yield MemoryCacheStorage[int]()