memory-reuse 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,44 @@
1
+ """memory-reuse — execution cache layer for AI agents.
2
+
3
+ Reduces LLM and tool call costs by caching results with exact-match hashing,
4
+ TTL support, multi-scope isolation (global / user / session), and first-class
5
+ LangGraph integration.
6
+
7
+ Quick start::
8
+
9
+ from memory_reuse import MemoryCache, CacheConfig
10
+
11
+ cache = MemoryCache() # in-memory backend, 1-hour TTL
12
+ cache.set_context(user_id="alice")
13
+
14
+ # LangGraph decorator
15
+ from memory_reuse.integrations import cached_tool
16
+
17
+ @cached_tool(cache, scope="user", ttl=300)
18
+ async def search(query: str) -> list[str]:
19
+ ...
20
+ """
21
+
22
+ from memory_reuse.config import CacheConfig
23
+ from memory_reuse.core import MemoryCache
24
+ from memory_reuse.exceptions import (
25
+ AgentMemoryError,
26
+ BackendConnectionError,
27
+ BackendNotAvailableError,
28
+ InvalidTTLError,
29
+ ScopeViolationError,
30
+ )
31
+ from memory_reuse.stats import CacheStats
32
+
33
+ __all__ = [
34
+ "MemoryCache",
35
+ "CacheConfig",
36
+ "CacheStats",
37
+ "AgentMemoryError",
38
+ "BackendConnectionError",
39
+ "BackendNotAvailableError",
40
+ "ScopeViolationError",
41
+ "InvalidTTLError",
42
+ ]
43
+
44
+ __version__ = "0.1.0"
memory_reuse/_utils.py ADDED
@@ -0,0 +1,125 @@
1
+ """Internal utilities for key building, hashing, and serialisation.
2
+
3
+ These are implementation details — do not import from outside this package.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import gzip
9
+ import hashlib
10
+ import json
11
+ import re
12
+ from typing import Any
13
+
14
+ # Characters allowed in cache key segments (alphanumeric, dash, underscore, dot)
15
+ _SAFE_KEY_RE = re.compile(r"[^a-zA-Z0-9_\-.]")
16
+
17
+
18
+ def sanitize_key(key: str) -> str:
19
+ """Replace unsafe characters in a key segment with underscores.
20
+
21
+ Args:
22
+ key: Raw key segment string.
23
+
24
+ Returns:
25
+ A string where any character not matching ``[a-zA-Z0-9_\\-.]`` is
26
+ replaced with ``_``.
27
+
28
+ Example::
29
+
30
+ >>> sanitize_key("user@example.com")
31
+ 'user_example.com'
32
+ """
33
+ return _SAFE_KEY_RE.sub("_", key)
34
+
35
+
36
+ def build_cache_key(prefix: str, scope: str, scope_id: str | None, *parts: Any) -> str:
37
+ """Build a namespaced, deterministic cache key.
38
+
39
+ The resulting key follows the pattern::
40
+
41
+ <prefix>:<scope>:<scope_id>:<hash_of_parts>
42
+
43
+ When ``scope`` is ``"global"`` the ``scope_id`` segment is omitted.
44
+
45
+ Args:
46
+ prefix: Top-level namespace (e.g. ``"agentmem"``).
47
+ scope: One of ``"global"``, ``"user"``, or ``"session"``.
48
+ scope_id: Identifier for the scope (user ID or session ID).
49
+ Must be provided for non-global scopes.
50
+ *parts: Arbitrary values that together identify the cached item.
51
+ They are JSON-serialised and hashed.
52
+
53
+ Returns:
54
+ A colon-separated cache key string.
55
+
56
+ Raises:
57
+ ValueError: If a non-global scope is used without a ``scope_id``.
58
+ """
59
+ if scope != "global" and not scope_id:
60
+ raise ValueError(f"scope_id is required for scope='{scope}'")
61
+
62
+ parts_hash = hash_value(list(parts))
63
+ safe_prefix = sanitize_key(prefix)
64
+ safe_scope = sanitize_key(scope)
65
+
66
+ if scope == "global":
67
+ return f"{safe_prefix}:{safe_scope}:{parts_hash}"
68
+
69
+ safe_scope_id = sanitize_key(scope_id) # type: ignore[arg-type]
70
+ return f"{safe_prefix}:{safe_scope}:{safe_scope_id}:{parts_hash}"
71
+
72
+
73
+ def hash_value(value: Any) -> str:
74
+ """Produce a 32-character SHA-256 hex digest of a JSON-serialised value.
75
+
76
+ Keys in mappings are sorted to ensure deterministic output regardless of
77
+ insertion order.
78
+
79
+ Args:
80
+ value: Any JSON-serialisable value.
81
+
82
+ Returns:
83
+ The first 32 hex characters of the SHA-256 digest.
84
+
85
+ Raises:
86
+ TypeError: If ``value`` cannot be JSON-serialised.
87
+ """
88
+ serialised = json.dumps(value, sort_keys=True, ensure_ascii=False, default=str)
89
+ digest = hashlib.sha256(serialised.encode("utf-8")).hexdigest()
90
+ return digest[:32]
91
+
92
+
93
+ def serialize_value(value: Any) -> bytes:
94
+ """Serialise and gzip-compress a value for storage in the backend.
95
+
96
+ Args:
97
+ value: Any JSON-serialisable value.
98
+
99
+ Returns:
100
+ gzip-compressed JSON bytes.
101
+
102
+ Raises:
103
+ TypeError: If ``value`` cannot be JSON-serialised.
104
+ """
105
+ raw = json.dumps(value, sort_keys=True, ensure_ascii=False, default=str).encode("utf-8")
106
+ return gzip.compress(raw, compresslevel=6)
107
+
108
+
109
+ def deserialize_value(data: bytes) -> Any:
110
+ """Decompress and deserialise bytes produced by :func:`serialize_value`.
111
+
112
+ Args:
113
+ data: gzip-compressed JSON bytes.
114
+
115
+ Returns:
116
+ The original Python object.
117
+
118
+ Raises:
119
+ ValueError: If the data cannot be decompressed or parsed.
120
+ """
121
+ try:
122
+ raw = gzip.decompress(data)
123
+ return json.loads(raw.decode("utf-8"))
124
+ except Exception as exc:
125
+ raise ValueError(f"Failed to deserialise cached value: {exc}") from exc
@@ -0,0 +1,21 @@
1
+ """Cache backend implementations for memory-reuse.
2
+
3
+ Available backends:
4
+
5
+ * :class:`~memory_reuse.backends.memory.InMemoryBackend` — zero-dependency,
6
+ in-process storage with LRU eviction and TTL support.
7
+ * :class:`~memory_reuse.backends.redis.RedisBackend` — Redis-backed storage
8
+ (requires ``pip install memory-reuse[redis]``).
9
+ """
10
+
11
+ from memory_reuse.backends.base import AbstractBackend
12
+ from memory_reuse.backends.memory import InMemoryBackend
13
+
14
+ __all__ = ["AbstractBackend", "InMemoryBackend"]
15
+
16
+ try:
17
+ from memory_reuse.backends.redis import RedisBackend # noqa: F401
18
+
19
+ __all__.append("RedisBackend")
20
+ except ImportError:
21
+ pass
@@ -0,0 +1,76 @@
1
+ """Abstract base class for cache backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+
7
+
8
+ class AbstractBackend(ABC):
9
+ """Interface that all cache backends must implement.
10
+
11
+ Every method is a coroutine so that network-bound backends (e.g. Redis)
12
+ can be awaited without blocking the event loop, while the in-memory
13
+ backend simply returns immediately.
14
+
15
+ Implementors should document their own connection-lifecycle behaviour
16
+ (lazy vs eager connection, reconnect logic, etc.).
17
+ """
18
+
19
+ @abstractmethod
20
+ async def get(self, key: str) -> bytes | None:
21
+ """Retrieve the raw bytes stored under ``key``.
22
+
23
+ Args:
24
+ key: The cache key to look up.
25
+
26
+ Returns:
27
+ The stored bytes, or ``None`` if the key does not exist or has
28
+ expired.
29
+ """
30
+
31
+ @abstractmethod
32
+ async def set(self, key: str, value: bytes, ttl: int | None = None) -> None:
33
+ """Store ``value`` under ``key``.
34
+
35
+ Args:
36
+ key: The cache key.
37
+ value: Raw bytes to store (typically gzip-compressed JSON).
38
+ ttl: Time-to-live in seconds. ``None`` means the entry never
39
+ expires. A backend may ignore this if it does not support TTL.
40
+ """
41
+
42
+ @abstractmethod
43
+ async def delete(self, key: str) -> None:
44
+ """Remove the entry for ``key`` if it exists.
45
+
46
+ Args:
47
+ key: The cache key to remove. A no-op if the key does not exist.
48
+ """
49
+
50
+ @abstractmethod
51
+ async def exists(self, key: str) -> bool:
52
+ """Check whether a non-expired entry exists for ``key``.
53
+
54
+ Args:
55
+ key: The cache key to check.
56
+
57
+ Returns:
58
+ ``True`` if the key exists and has not expired, ``False``
59
+ otherwise.
60
+ """
61
+
62
+ @abstractmethod
63
+ async def flush(self) -> None:
64
+ """Delete all entries managed by this backend instance.
65
+
66
+ Use with caution in production — this removes every cached value.
67
+ """
68
+
69
+ @abstractmethod
70
+ async def ping(self) -> bool:
71
+ """Check backend connectivity.
72
+
73
+ Returns:
74
+ ``True`` if the backend is reachable and functioning, ``False``
75
+ otherwise. Should not raise.
76
+ """
@@ -0,0 +1,170 @@
1
+ """In-memory cache backend with TTL support and LRU eviction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import time
8
+ from collections import OrderedDict
9
+ from dataclasses import dataclass
10
+
11
+ from memory_reuse.backends.base import AbstractBackend
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ _DEFAULT_MAX_ENTRIES = 10_000
16
+
17
+
18
+ @dataclass
19
+ class _Entry:
20
+ """Internal storage wrapper for a cached value with optional expiry."""
21
+
22
+ value: bytes
23
+ expires_at: float | None # Unix timestamp, or None for no expiry
24
+
25
+
26
+ class InMemoryBackend(AbstractBackend):
27
+ """Fully in-memory cache backend — no external dependencies required.
28
+
29
+ Features:
30
+
31
+ * **TTL** — entries are lazily expired on access.
32
+ * **LRU eviction** — when ``max_entries`` is reached the least-recently-used
33
+ entry is dropped to make room for the new one.
34
+ * **Thread-safe** — an :class:`asyncio.Lock` serialises all mutations.
35
+
36
+ Args:
37
+ max_entries: Maximum number of entries to hold before LRU eviction
38
+ kicks in. Defaults to 10 000.
39
+
40
+ Example::
41
+
42
+ backend = InMemoryBackend(max_entries=500)
43
+ await backend.set("key", b"value", ttl=60)
44
+ data = await backend.get("key")
45
+ """
46
+
47
+ def __init__(self, max_entries: int = _DEFAULT_MAX_ENTRIES) -> None:
48
+ if max_entries <= 0:
49
+ raise ValueError(f"max_entries must be positive, got {max_entries}")
50
+ self._max_entries = max_entries
51
+ # OrderedDict used as an ordered map: most-recently-used at the end
52
+ self._store: OrderedDict[str, _Entry] = OrderedDict()
53
+ self._lock = asyncio.Lock()
54
+
55
+ # ------------------------------------------------------------------
56
+ # AbstractBackend implementation
57
+ # ------------------------------------------------------------------
58
+
59
+ async def get(self, key: str) -> bytes | None:
60
+ """Return stored bytes for ``key``, or ``None`` on miss/expiry.
61
+
62
+ Args:
63
+ key: Cache key to look up.
64
+
65
+ Returns:
66
+ Stored bytes or ``None``.
67
+ """
68
+ async with self._lock:
69
+ entry = self._store.get(key)
70
+ if entry is None:
71
+ return None
72
+ if self._is_expired(entry):
73
+ del self._store[key]
74
+ logger.debug("InMemoryBackend: key expired, removed from store")
75
+ return None
76
+ # Move to end (most recently used)
77
+ self._store.move_to_end(key)
78
+ return entry.value
79
+
80
+ async def set(self, key: str, value: bytes, ttl: int | None = None) -> None:
81
+ """Store ``value`` under ``key`` with an optional TTL.
82
+
83
+ If the store is at capacity, the LRU entry is evicted first.
84
+
85
+ Args:
86
+ key: Cache key.
87
+ value: Bytes to store.
88
+ ttl: Time-to-live in seconds. ``None`` means no expiry.
89
+ """
90
+ expires_at: float | None = None
91
+ if ttl is not None:
92
+ expires_at = time.monotonic() + ttl
93
+
94
+ async with self._lock:
95
+ if key in self._store:
96
+ # Update in place and move to end
97
+ self._store[key] = _Entry(value=value, expires_at=expires_at)
98
+ self._store.move_to_end(key)
99
+ else:
100
+ if len(self._store) >= self._max_entries:
101
+ evicted_key, _ = self._store.popitem(last=False)
102
+ logger.debug("InMemoryBackend: LRU eviction triggered")
103
+ self._store[key] = _Entry(value=value, expires_at=expires_at)
104
+
105
+ async def delete(self, key: str) -> None:
106
+ """Remove the entry for ``key``.
107
+
108
+ Args:
109
+ key: Cache key to remove. No-op if the key does not exist.
110
+ """
111
+ async with self._lock:
112
+ self._store.pop(key, None)
113
+
114
+ async def exists(self, key: str) -> bool:
115
+ """Return ``True`` if ``key`` exists and has not expired.
116
+
117
+ Args:
118
+ key: Cache key to check.
119
+
120
+ Returns:
121
+ Boolean existence flag.
122
+ """
123
+ async with self._lock:
124
+ entry = self._store.get(key)
125
+ if entry is None:
126
+ return False
127
+ if self._is_expired(entry):
128
+ del self._store[key]
129
+ return False
130
+ return True
131
+
132
+ async def flush(self) -> None:
133
+ """Remove all entries from the store."""
134
+ async with self._lock:
135
+ self._store.clear()
136
+ logger.debug("InMemoryBackend: store flushed")
137
+
138
+ async def ping(self) -> bool:
139
+ """Always returns ``True`` — the in-memory backend is always available.
140
+
141
+ Returns:
142
+ ``True``
143
+ """
144
+ return True
145
+
146
+ # ------------------------------------------------------------------
147
+ # Helpers
148
+ # ------------------------------------------------------------------
149
+
150
+ def _is_expired(self, entry: _Entry) -> bool:
151
+ """Check whether an entry has passed its TTL.
152
+
153
+ Args:
154
+ entry: The :class:`_Entry` to inspect.
155
+
156
+ Returns:
157
+ ``True`` if the entry has an expiry time that is in the past.
158
+ """
159
+ if entry.expires_at is None:
160
+ return False
161
+ return time.monotonic() >= entry.expires_at
162
+
163
+ @property
164
+ def size(self) -> int:
165
+ """Current number of entries in the store (including not-yet-evicted expired ones).
166
+
167
+ Returns:
168
+ Integer entry count.
169
+ """
170
+ return len(self._store)
@@ -0,0 +1,217 @@
1
+ """Redis cache backend using redis.asyncio."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import logging
7
+ from typing import TYPE_CHECKING, cast
8
+
9
+ from memory_reuse.backends.base import AbstractBackend
10
+ from memory_reuse.exceptions import BackendConnectionError, BackendNotAvailableError
11
+
12
+ if TYPE_CHECKING:
13
+ # Only imported at runtime when redis is available
14
+ import redis.asyncio as aioredis
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ _MAX_CONNECTIONS = 20
19
+
20
+
21
+ class RedisBackend(AbstractBackend):
22
+ """Cache backend that stores data in Redis.
23
+
24
+ Requires the optional ``redis`` extra::
25
+
26
+ pip install memory-reuse[redis]
27
+
28
+ The connection is established lazily on the first operation.
29
+ Connection errors are converted to :exc:`BackendConnectionError` so
30
+ callers do not need to handle redis-specific exceptions.
31
+
32
+ Args:
33
+ url: Redis connection URL (e.g. ``redis://localhost:6379/0``).
34
+ Prefer reading this from the ``MEMORY_REUSE_REDIS_URL``
35
+ environment variable rather than hardcoding it.
36
+ max_connections: Maximum size of the underlying connection pool.
37
+ Defaults to 20.
38
+
39
+ Example::
40
+
41
+ import os
42
+ backend = RedisBackend(url=os.environ["MEMORY_REUSE_REDIS_URL"])
43
+ await backend.set("key", b"value", ttl=300)
44
+ data = await backend.get("key")
45
+ """
46
+
47
+ def __init__(self, url: str, max_connections: int = _MAX_CONNECTIONS) -> None:
48
+ self._url = url
49
+ self._max_connections = max_connections
50
+ self._client: aioredis.Redis | None = None # type: ignore[type-arg]
51
+
52
+ # ------------------------------------------------------------------
53
+ # Connection management
54
+ # ------------------------------------------------------------------
55
+
56
+ async def _get_client(self) -> aioredis.Redis: # type: ignore[type-arg]
57
+ """Return the Redis client, creating it on first call.
58
+
59
+ Returns:
60
+ An initialised ``redis.asyncio.Redis`` client.
61
+
62
+ Raises:
63
+ BackendNotAvailableError: If the ``redis`` package is not installed.
64
+ BackendConnectionError: If the connection attempt fails.
65
+ """
66
+ if self._client is not None:
67
+ return self._client
68
+
69
+ try:
70
+ import redis.asyncio as aioredis
71
+ except ImportError as exc:
72
+ raise BackendNotAvailableError(
73
+ "Redis backend requires 'redis' package. "
74
+ "Install it with: pip install memory-reuse[redis]"
75
+ ) from exc
76
+
77
+ try:
78
+ pool = aioredis.ConnectionPool.from_url(
79
+ self._url,
80
+ max_connections=self._max_connections,
81
+ decode_responses=False,
82
+ )
83
+ self._client = aioredis.Redis(connection_pool=pool)
84
+ # Validate connectivity
85
+ await self._client.ping()
86
+ except Exception as exc:
87
+ self._client = None
88
+ # Deliberately not logging self._url to avoid leaking credentials
89
+ logger.error("RedisBackend: failed to connect to Redis server")
90
+ raise BackendConnectionError(
91
+ "Could not connect to Redis. Check your connection URL and network."
92
+ ) from exc
93
+
94
+ return self._client
95
+
96
+ # ------------------------------------------------------------------
97
+ # AbstractBackend implementation
98
+ # ------------------------------------------------------------------
99
+
100
+ async def get(self, key: str) -> bytes | None:
101
+ """Retrieve raw bytes stored under ``key``.
102
+
103
+ Args:
104
+ key: Cache key.
105
+
106
+ Returns:
107
+ Stored bytes or ``None`` on miss.
108
+
109
+ Raises:
110
+ BackendConnectionError: On Redis connectivity failure.
111
+ """
112
+ client = await self._get_client()
113
+ try:
114
+ # The client is created with decode_responses=False, so values are
115
+ # always raw bytes (or None on a miss). Cast to satisfy the type
116
+ # checker, which infers the broader bytes | str | None union.
117
+ value = await client.get(key)
118
+ return cast("bytes | None", value)
119
+ except Exception as exc:
120
+ logger.error("RedisBackend: GET failed for key prefix '%s'", key[:8])
121
+ raise BackendConnectionError("Redis GET operation failed") from exc
122
+
123
+ async def set(self, key: str, value: bytes, ttl: int | None = None) -> None:
124
+ """Store ``value`` under ``key``.
125
+
126
+ Args:
127
+ key: Cache key.
128
+ value: Bytes to store.
129
+ ttl: Time-to-live in seconds. ``None`` means no expiry.
130
+
131
+ Raises:
132
+ BackendConnectionError: On Redis connectivity failure.
133
+ """
134
+ client = await self._get_client()
135
+ try:
136
+ if ttl is not None:
137
+ await client.setex(key, ttl, value)
138
+ else:
139
+ await client.set(key, value)
140
+ except Exception as exc:
141
+ logger.error("RedisBackend: SET failed for key prefix '%s'", key[:8])
142
+ raise BackendConnectionError("Redis SET operation failed") from exc
143
+
144
+ async def delete(self, key: str) -> None:
145
+ """Remove the entry for ``key``.
146
+
147
+ Args:
148
+ key: Cache key to remove.
149
+
150
+ Raises:
151
+ BackendConnectionError: On Redis connectivity failure.
152
+ """
153
+ client = await self._get_client()
154
+ try:
155
+ await client.delete(key)
156
+ except Exception as exc:
157
+ logger.error("RedisBackend: DELETE failed for key prefix '%s'", key[:8])
158
+ raise BackendConnectionError("Redis DELETE operation failed") from exc
159
+
160
+ async def exists(self, key: str) -> bool:
161
+ """Check whether ``key`` exists in Redis.
162
+
163
+ Args:
164
+ key: Cache key.
165
+
166
+ Returns:
167
+ ``True`` if the key exists (and has not expired in Redis).
168
+
169
+ Raises:
170
+ BackendConnectionError: On Redis connectivity failure.
171
+ """
172
+ client = await self._get_client()
173
+ try:
174
+ return bool(await client.exists(key))
175
+ except Exception as exc:
176
+ logger.error("RedisBackend: EXISTS failed for key prefix '%s'", key[:8])
177
+ raise BackendConnectionError("Redis EXISTS operation failed") from exc
178
+
179
+ async def flush(self) -> None:
180
+ """Delete all keys in the current Redis database.
181
+
182
+ Warning:
183
+ This calls ``FLUSHDB`` on the connected database. Use with care
184
+ in shared Redis environments.
185
+
186
+ Raises:
187
+ BackendConnectionError: On Redis connectivity failure.
188
+ """
189
+ client = await self._get_client()
190
+ try:
191
+ await client.flushdb()
192
+ logger.debug("RedisBackend: database flushed")
193
+ except Exception as exc:
194
+ raise BackendConnectionError("Redis FLUSHDB operation failed") from exc
195
+
196
+ async def ping(self) -> bool:
197
+ """Check Redis connectivity.
198
+
199
+ Returns:
200
+ ``True`` if Redis responds to PING, ``False`` on any error.
201
+ """
202
+ try:
203
+ client = await self._get_client()
204
+ return await client.ping()
205
+ except Exception:
206
+ return False
207
+
208
+ async def close(self) -> None:
209
+ """Close the connection pool gracefully.
210
+
211
+ Call this during application shutdown to release Redis connections.
212
+ """
213
+ if self._client is not None:
214
+ with contextlib.suppress(Exception):
215
+ await self._client.aclose()
216
+ self._client = None
217
+ logger.debug("RedisBackend: connection pool closed")
@@ -0,0 +1,12 @@
1
+ """Cache layer implementations for memory-reuse.
2
+
3
+ * :class:`~memory_reuse.cache.exact.ExactCache` — hash-keyed cache for
4
+ LLM responses and other deterministic lookups.
5
+ * :class:`~memory_reuse.cache.tool.ToolCache` — TTL-enforced cache for
6
+ tool/function call results.
7
+ """
8
+
9
+ from memory_reuse.cache.exact import ExactCache
10
+ from memory_reuse.cache.tool import ToolCache
11
+
12
+ __all__ = ["ExactCache", "ToolCache"]