cloud-dog-cache 0.2.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,102 @@
1
+ # Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ """Redis cache backend (optional — requires ``redis`` package)."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from datetime import datetime
10
+ from typing import Optional
11
+
12
+ from cloud_dog_cache.models import CacheEntry
13
+ from cloud_dog_cache.stats import CacheStats
14
+
15
+
16
+ class RedisCacheBackend:
17
+ """Redis-backed cache with TTL and tag-based invalidation.
18
+
19
+ Requires the ``redis`` package: ``pip install redis``.
20
+ """
21
+
22
+ def __init__(self, *, redis_url: str, key_prefix: str = "cdc:") -> None:
23
+ """Initialise with Redis connection URL."""
24
+ import redis.asyncio as aioredis
25
+
26
+ self._client = aioredis.from_url(redis_url, decode_responses=True)
27
+ self._prefix = key_prefix
28
+ self._hits = 0
29
+ self._misses = 0
30
+ self._evictions = 0
31
+
32
+ def _key(self, key: str) -> str:
33
+ return f"{self._prefix}{key}"
34
+
35
+ def _tag_key(self, tag: str) -> str:
36
+ return f"{self._prefix}tag:{tag}"
37
+
38
+ async def get(self, key: str) -> Optional[CacheEntry]:
39
+ """Retrieve a cached entry from Redis."""
40
+ raw = await self._client.get(self._key(key))
41
+ if raw is None:
42
+ self._misses += 1
43
+ return None
44
+ data = json.loads(raw)
45
+ self._hits += 1
46
+ return CacheEntry(
47
+ key=key,
48
+ value=data["value"],
49
+ created_at=datetime.fromisoformat(data["created_at"]),
50
+ tags=tuple(data.get("tags", ())),
51
+ )
52
+
53
+ async def set(self, key: str, entry: CacheEntry, ttl: int) -> None:
54
+ """Store a cache entry in Redis with TTL."""
55
+ data = {
56
+ "value": entry.value,
57
+ "created_at": entry.created_at.isoformat(),
58
+ "tags": list(entry.tags),
59
+ }
60
+ pipe = self._client.pipeline()
61
+ pipe.setex(self._key(key), ttl, json.dumps(data, default=str))
62
+ for tag in entry.tags:
63
+ pipe.sadd(self._tag_key(tag), key)
64
+ pipe.expire(self._tag_key(tag), ttl)
65
+ await pipe.execute()
66
+
67
+ async def delete(self, key: str) -> None:
68
+ """Remove a single entry from Redis."""
69
+ await self._client.delete(self._key(key))
70
+
71
+ async def flush(self) -> None:
72
+ """Remove all entries with the configured prefix."""
73
+ keys = []
74
+ async for key in self._client.scan_iter(match=f"{self._prefix}*"):
75
+ keys.append(key)
76
+ if keys:
77
+ await self._client.delete(*keys)
78
+
79
+ async def stats(self) -> CacheStats:
80
+ """Return statistics (approximate for Redis)."""
81
+ count = 0
82
+ async for _ in self._client.scan_iter(match=f"{self._prefix}*"):
83
+ count += 1
84
+ return CacheStats(
85
+ hits=self._hits,
86
+ misses=self._misses,
87
+ entries=count,
88
+ evictions=self._evictions,
89
+ )
90
+
91
+ async def flush_by_tag(self, tag: str) -> int:
92
+ """Remove all entries tagged with the given tag."""
93
+ members = await self._client.smembers(self._tag_key(tag))
94
+ removed = 0
95
+ if members:
96
+ pipe = self._client.pipeline()
97
+ for member in members:
98
+ pipe.delete(self._key(member))
99
+ await pipe.execute()
100
+ removed = len(members)
101
+ await self._client.delete(self._tag_key(tag))
102
+ return removed
@@ -0,0 +1,94 @@
1
+ # Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ """@cached decorator for transparent function result caching."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import functools
9
+ import inspect
10
+ from typing import Any, Callable, Sequence
11
+
12
+ from cloud_dog_cache.keys import cache_key
13
+ from cloud_dog_cache.manager import get_cache_manager
14
+
15
+
16
+ def cached(
17
+ *,
18
+ ttl: int = 3600,
19
+ invalidate_on: Sequence[str] = (),
20
+ key_params: Sequence[str] | None = None,
21
+ context_hash_param: str = "",
22
+ model_config_hash_param: str = "",
23
+ prompt_hash_param: str = "",
24
+ ) -> Callable:
25
+ """Decorator that caches async function results.
26
+
27
+ Args:
28
+ ttl: Time-to-live in seconds for cached entries.
29
+ invalidate_on: Event names that trigger invalidation of this function's cache.
30
+ key_params: Explicit parameter names to include in the cache key.
31
+ If None, all parameters are included.
32
+ context_hash_param: Name of the parameter containing the context hash.
33
+ model_config_hash_param: Name of the parameter containing the model config hash.
34
+ prompt_hash_param: Name of the parameter containing the prompt hash.
35
+
36
+ Returns:
37
+ A decorator that wraps async functions with caching logic.
38
+ """
39
+
40
+ def decorator(fn: Callable) -> Callable:
41
+ fn_name = f"{fn.__module__}.{fn.__qualname__}"
42
+
43
+ @functools.wraps(fn)
44
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
45
+ manager = get_cache_manager()
46
+ if manager is None or not manager.enabled:
47
+ return await fn(*args, **kwargs)
48
+
49
+ # Build parameter dict from function signature
50
+ sig = inspect.signature(fn)
51
+ bound = sig.bind(*args, **kwargs)
52
+ bound.apply_defaults()
53
+ all_params = dict(bound.arguments)
54
+
55
+ # Select key params
56
+ if key_params is not None:
57
+ params = {k: all_params[k] for k in key_params if k in all_params}
58
+ else:
59
+ params = all_params
60
+
61
+ # Extract hash params
62
+ ctx_hash = (
63
+ str(params.pop(context_hash_param, "")) if context_hash_param else ""
64
+ )
65
+ model_hash = (
66
+ str(params.pop(model_config_hash_param, ""))
67
+ if model_config_hash_param
68
+ else ""
69
+ )
70
+ p_hash = str(params.pop(prompt_hash_param, "")) if prompt_hash_param else ""
71
+
72
+ key = cache_key(
73
+ fn_name,
74
+ params=params,
75
+ context_hash=ctx_hash,
76
+ model_config_hash=model_hash,
77
+ prompt_hash=p_hash,
78
+ )
79
+
80
+ # Check cache
81
+ cached_value = await manager.get(key)
82
+ if cached_value is not None:
83
+ return cached_value
84
+
85
+ # Execute and cache
86
+ result = await fn(*args, **kwargs)
87
+ tags = tuple(invalidate_on)
88
+ await manager.set(key, result, ttl=ttl, tags=tags)
89
+ return result
90
+
91
+ wrapper.__cache_function_name__ = fn_name
92
+ return wrapper
93
+
94
+ return decorator
@@ -0,0 +1,31 @@
1
+ # Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ """Event-based cache invalidation hooks."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # Well-known invalidation event names.
13
+ CONTEXT_REBUILD = "context_rebuild"
14
+ CONFIG_CHANGE = "config_change"
15
+ PROMPT_CHANGE = "prompt_change"
16
+ MANUAL_FLUSH = "manual_flush"
17
+
18
+
19
+ async def invalidate_event(event_name: str) -> int:
20
+ """Fire an invalidation event, flushing cache entries tagged with *event_name*.
21
+
22
+ Returns the number of entries removed.
23
+ """
24
+ from cloud_dog_cache.manager import get_cache_manager
25
+
26
+ manager = get_cache_manager()
27
+ if manager is None:
28
+ return 0
29
+ removed = await manager.backend.flush_by_tag(event_name)
30
+ logger.info("cache_invalidation", extra={"event": event_name, "removed": removed})
31
+ return removed
@@ -0,0 +1,59 @@
1
+ # Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ """Cache key generation and hashing."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import json
10
+ from typing import Any
11
+
12
+
13
+ def _stable_json(obj: Any) -> str:
14
+ """Produce a deterministic JSON representation for hashing."""
15
+ if isinstance(obj, dict):
16
+ return json.dumps(
17
+ {k: _stable_json(v) for k, v in sorted(obj.items())}, sort_keys=True
18
+ )
19
+ if isinstance(obj, (list, tuple)):
20
+ return json.dumps([_stable_json(v) for v in obj])
21
+ if isinstance(obj, set):
22
+ return json.dumps(sorted(str(v) for v in obj))
23
+ return json.dumps(obj, default=str)
24
+
25
+
26
+ def cache_key(
27
+ function_name: str,
28
+ *,
29
+ params: dict[str, Any] | None = None,
30
+ context_hash: str = "",
31
+ model_config_hash: str = "",
32
+ prompt_hash: str = "",
33
+ ) -> str:
34
+ """Generate a deterministic cache key from function identity and input hashes.
35
+
36
+ The key includes the function name, sorted input parameters, and optional
37
+ hashes for context state, model configuration, and prompt templates. Any
38
+ change in these components produces a different key, ensuring stale results
39
+ are never served.
40
+ """
41
+ parts = [
42
+ function_name,
43
+ _stable_json(params or {}),
44
+ context_hash,
45
+ model_config_hash,
46
+ prompt_hash,
47
+ ]
48
+ combined = "|".join(parts)
49
+ return hashlib.sha256(combined.encode("utf-8")).hexdigest()
50
+
51
+
52
+ def hash_text(text: str) -> str:
53
+ """Return a stable SHA-256 hex digest of the given text."""
54
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
55
+
56
+
57
+ def hash_config(config: dict[str, Any]) -> str:
58
+ """Return a stable hash of a configuration dict."""
59
+ return hashlib.sha256(_stable_json(config).encode("utf-8")).hexdigest()
@@ -0,0 +1,95 @@
1
+ # Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ """CacheManager — orchestrates cache backends, logging, and statistics."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ from typing import Any, Optional
10
+
11
+ from cloud_dog_cache.backends.memory import MemoryCacheBackend
12
+ from cloud_dog_cache.models import CacheConfig, CacheEntry
13
+ from cloud_dog_cache.stats import CacheStats
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ _MANAGER: CacheManager | None = None
18
+
19
+
20
+ class CacheManager:
21
+ """Central cache orchestrator.
22
+
23
+ Wraps a pluggable backend with logging, config-driven enable/disable,
24
+ and statistics collection.
25
+ """
26
+
27
+ def __init__(self, config: CacheConfig | None = None) -> None:
28
+ """Initialise with configuration."""
29
+ self.config = config or CacheConfig()
30
+ self.backend = self._build_backend()
31
+
32
+ def _build_backend(self) -> Any:
33
+ """Instantiate the configured backend."""
34
+ if self.config.backend == "redis" and self.config.redis_url:
35
+ from cloud_dog_cache.backends.redis import RedisCacheBackend
36
+
37
+ return RedisCacheBackend(redis_url=self.config.redis_url)
38
+ return MemoryCacheBackend(max_entries=self.config.max_entries)
39
+
40
+ @property
41
+ def enabled(self) -> bool:
42
+ """Return whether caching is active."""
43
+ return self.config.enabled
44
+
45
+ async def get(self, key: str) -> Optional[Any]:
46
+ """Retrieve a cached value. Return None on miss or when disabled."""
47
+ if not self.enabled:
48
+ return None
49
+ entry = await self.backend.get(key)
50
+ if entry is None:
51
+ logger.debug("cache_miss", extra={"key": key[:16]})
52
+ return None
53
+ logger.debug("cache_hit", extra={"key": key[:16]})
54
+ return entry.value
55
+
56
+ async def set(
57
+ self,
58
+ key: str,
59
+ value: Any,
60
+ *,
61
+ ttl: int | None = None,
62
+ tags: tuple[str, ...] = (),
63
+ ) -> None:
64
+ """Store a value in the cache."""
65
+ if not self.enabled:
66
+ return
67
+ effective_ttl = ttl if ttl is not None else self.config.ttl_seconds
68
+ entry = CacheEntry(key=key, value=value, tags=tags)
69
+ await self.backend.set(key, entry, effective_ttl)
70
+ logger.debug("cache_set", extra={"key": key[:16], "ttl": effective_ttl})
71
+
72
+ async def delete(self, key: str) -> None:
73
+ """Remove a single cache entry."""
74
+ await self.backend.delete(key)
75
+
76
+ async def flush(self) -> None:
77
+ """Remove all cache entries."""
78
+ await self.backend.flush()
79
+ logger.info("cache_flushed")
80
+
81
+ async def stats(self) -> CacheStats:
82
+ """Return current cache statistics."""
83
+ return await self.backend.stats()
84
+
85
+
86
+ def init_cache(config: CacheConfig | None = None) -> CacheManager:
87
+ """Initialise the global cache manager singleton."""
88
+ global _MANAGER
89
+ _MANAGER = CacheManager(config)
90
+ return _MANAGER
91
+
92
+
93
+ def get_cache_manager() -> CacheManager | None:
94
+ """Return the global cache manager, or None if not initialised."""
95
+ return _MANAGER
@@ -0,0 +1,206 @@
1
+ # Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ """Agent memory primitives — scoped namespaces, hierarchical TTL, tenant isolation.
5
+
6
+ W28B-305: Distributed memory on cloud_dog_cache. No new memory package.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import enum
12
+ import json
13
+ import time
14
+ from dataclasses import dataclass, field
15
+ from datetime import datetime, timezone
16
+ from typing import Any, Callable, Optional, Sequence
17
+
18
+ from cloud_dog_cache.manager import CacheManager, get_cache_manager
19
+
20
+
21
+ class MemoryScope(enum.Enum):
22
+ """Hierarchical memory scopes with default TTL ordering.
23
+
24
+ REQUEST < SESSION < USER_PROFILE < GLOBAL
25
+ """
26
+
27
+ REQUEST = "request"
28
+ SESSION = "session"
29
+ USER_PROFILE = "user_profile"
30
+ GLOBAL = "global"
31
+
32
+
33
+ # Default TTL per scope (seconds). Callers can override.
34
+ DEFAULT_SCOPE_TTL: dict[MemoryScope, int] = {
35
+ MemoryScope.REQUEST: 300, # 5 minutes
36
+ MemoryScope.SESSION: 3600, # 1 hour
37
+ MemoryScope.USER_PROFILE: 86400, # 24 hours
38
+ MemoryScope.GLOBAL: 604800, # 7 days
39
+ }
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class MemoryNamespace:
44
+ """A scoped, tenant-isolated namespace for agent memory.
45
+
46
+ Keys are prefixed with ``mem:{tenant}:{scope}:{namespace}:`` to ensure
47
+ isolation across tenants, scopes, and logical namespaces.
48
+ """
49
+
50
+ tenant_id: str
51
+ scope: MemoryScope
52
+ namespace: str = ""
53
+
54
+ def _prefix(self) -> str:
55
+ parts = ["mem", self.tenant_id, self.scope.value]
56
+ if self.namespace:
57
+ parts.append(self.namespace)
58
+ return ":".join(parts) + ":"
59
+
60
+ def full_key(self, key: str) -> str:
61
+ """Return the fully-qualified cache key."""
62
+ return f"{self._prefix()}{key}"
63
+
64
+ def tag(self) -> str:
65
+ """Return the invalidation tag for this namespace."""
66
+ return self._prefix().rstrip(":")
67
+
68
+
69
+ @dataclass
70
+ class MemoryEntry:
71
+ """A single memory entry with scope metadata."""
72
+
73
+ key: str
74
+ value: Any
75
+ scope: MemoryScope
76
+ tenant_id: str
77
+ namespace: str = ""
78
+ created_at: float = field(default_factory=time.time)
79
+ metadata: dict[str, Any] = field(default_factory=dict)
80
+
81
+
82
+ class MemoryStore:
83
+ """Agent memory store backed by CacheManager.
84
+
85
+ Provides scoped get/set/list/clear operations with tenant isolation
86
+ and hierarchical TTL defaults.
87
+ """
88
+
89
+ def __init__(
90
+ self,
91
+ tenant_id: str,
92
+ *,
93
+ manager: CacheManager | None = None,
94
+ scope_ttl: dict[MemoryScope, int] | None = None,
95
+ ) -> None:
96
+ self._tenant_id = tenant_id
97
+ self._manager = manager
98
+ self._scope_ttl = dict(DEFAULT_SCOPE_TTL)
99
+ if scope_ttl:
100
+ self._scope_ttl.update(scope_ttl)
101
+
102
+ @property
103
+ def manager(self) -> CacheManager:
104
+ if self._manager is not None:
105
+ return self._manager
106
+ m = get_cache_manager()
107
+ if m is None:
108
+ raise RuntimeError("CacheManager not initialised; call init_cache() first")
109
+ return m
110
+
111
+ def _ns(self, scope: MemoryScope, namespace: str = "") -> MemoryNamespace:
112
+ return MemoryNamespace(
113
+ tenant_id=self._tenant_id,
114
+ scope=scope,
115
+ namespace=namespace,
116
+ )
117
+
118
+ def _ttl(self, scope: MemoryScope, ttl: int | None = None) -> int:
119
+ if ttl is not None:
120
+ return ttl
121
+ return self._scope_ttl.get(scope, 3600)
122
+
123
+ async def get(
124
+ self,
125
+ key: str,
126
+ scope: MemoryScope = MemoryScope.SESSION,
127
+ namespace: str = "",
128
+ ) -> Any | None:
129
+ """Retrieve a memory value by key and scope."""
130
+ ns = self._ns(scope, namespace)
131
+ return await self.manager.get(ns.full_key(key))
132
+
133
+ async def set(
134
+ self,
135
+ key: str,
136
+ value: Any,
137
+ scope: MemoryScope = MemoryScope.SESSION,
138
+ namespace: str = "",
139
+ ttl: int | None = None,
140
+ metadata: dict[str, Any] | None = None,
141
+ ) -> None:
142
+ """Store a memory value with scope-appropriate TTL."""
143
+ ns = self._ns(scope, namespace)
144
+ effective_ttl = self._ttl(scope, ttl)
145
+ # Wrap value with metadata for richer retrieval
146
+ wrapped = {
147
+ "v": value,
148
+ "scope": scope.value,
149
+ "tenant": self._tenant_id,
150
+ "ns": namespace,
151
+ "ts": time.time(),
152
+ }
153
+ if metadata:
154
+ wrapped["meta"] = metadata
155
+ await self.manager.set(
156
+ ns.full_key(key),
157
+ wrapped,
158
+ ttl=effective_ttl,
159
+ tags=(ns.tag(),),
160
+ )
161
+
162
+ async def delete(
163
+ self,
164
+ key: str,
165
+ scope: MemoryScope = MemoryScope.SESSION,
166
+ namespace: str = "",
167
+ ) -> None:
168
+ """Remove a single memory entry."""
169
+ ns = self._ns(scope, namespace)
170
+ await self.manager.delete(ns.full_key(key))
171
+
172
+ async def clear_scope(
173
+ self,
174
+ scope: MemoryScope,
175
+ namespace: str = "",
176
+ ) -> int:
177
+ """Clear all entries in a scope/namespace. Returns count removed."""
178
+ ns = self._ns(scope, namespace)
179
+ return await self.manager.backend.flush_by_tag(ns.tag())
180
+
181
+ async def clear_tenant(self) -> int:
182
+ """Clear all entries for this tenant across all scopes."""
183
+ total = 0
184
+ for scope in MemoryScope:
185
+ total += await self.clear_scope(scope)
186
+ return total
187
+
188
+
189
+ class VectorSearchAdapter:
190
+ """Optional adapter for semantic search over memory entries.
191
+
192
+ This is a protocol/interface — concrete implementations connect to
193
+ index-retriever, Qdrant, ChromaDB, etc.
194
+ """
195
+
196
+ async def index(self, key: str, text: str, metadata: dict[str, Any] | None = None) -> None:
197
+ """Index a memory entry for semantic search."""
198
+ raise NotImplementedError
199
+
200
+ async def search(self, query: str, *, top_k: int = 5, filter: dict[str, Any] | None = None) -> list[dict[str, Any]]:
201
+ """Search memory entries by semantic similarity."""
202
+ raise NotImplementedError
203
+
204
+ async def delete(self, key: str) -> None:
205
+ """Remove a memory entry from the search index."""
206
+ raise NotImplementedError
@@ -0,0 +1,41 @@
1
+ # Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ """Cache entry and configuration models."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime, timezone
10
+ from typing import Any
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class CacheEntry:
15
+ """A single cached result with metadata."""
16
+
17
+ key: str
18
+ value: Any
19
+ created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
20
+ expires_at: datetime | None = None
21
+ hit_count: int = 0
22
+ tags: tuple[str, ...] = ()
23
+
24
+ @property
25
+ def expired(self) -> bool:
26
+ """Return True if this entry has passed its TTL."""
27
+ if self.expires_at is None:
28
+ return False
29
+ return datetime.now(timezone.utc) >= self.expires_at
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class CacheConfig:
34
+ """Runtime cache configuration."""
35
+
36
+ enabled: bool = True
37
+ backend: str = "memory"
38
+ ttl_seconds: int = 3600
39
+ max_entries: int = 1000
40
+ max_memory_mb: int = 256
41
+ redis_url: str = ""
@@ -0,0 +1,57 @@
1
+ # Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ """Runtime initialisation helper — reads cache config from cloud_dog_config."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ from cloud_dog_cache.manager import CacheManager, init_cache
11
+ from cloud_dog_cache.models import CacheConfig
12
+
13
+
14
+ def init_cache_from_config(config: Any = None) -> CacheManager:
15
+ """Initialise the global cache manager from cloud_dog_config.
16
+
17
+ Reads ``cache.enabled``, ``cache.backend``, ``cache.ttl_seconds``,
18
+ ``cache.max_entries``, ``cache.redis_url`` from the platform config.
19
+ """
20
+ if config is None:
21
+ try:
22
+ from cloud_dog_config import get_config
23
+
24
+ config = get_config
25
+ except ImportError:
26
+ config = lambda key, default=None: default # noqa: E731
27
+
28
+ def _get(key: str, default: Any = None) -> Any:
29
+ if callable(config):
30
+ return config(key) or default
31
+ return getattr(config, "get", lambda k, d=None: d)(key, default)
32
+
33
+ cache_config = CacheConfig(
34
+ enabled=_to_bool(_get("cache.enabled", True)),
35
+ backend=str(_get("cache.backend", "memory") or "memory"),
36
+ ttl_seconds=_to_int(_get("cache.ttl_seconds", 3600)),
37
+ max_entries=_to_int(_get("cache.max_entries", 1000)),
38
+ redis_url=str(_get("cache.redis_url", "") or ""),
39
+ )
40
+ return init_cache(cache_config)
41
+
42
+
43
+ def _to_bool(value: Any) -> bool:
44
+ """Coerce a config value to bool."""
45
+ if isinstance(value, bool):
46
+ return value
47
+ if value is None:
48
+ return True
49
+ return str(value).strip().lower() in {"1", "true", "yes", "on"}
50
+
51
+
52
+ def _to_int(value: Any) -> int:
53
+ """Coerce a config value to int."""
54
+ try:
55
+ return int(value)
56
+ except (TypeError, ValueError):
57
+ return 0