Coverage for src/lexigram/admin/core/cache.py: 0%
97 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 13:43 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 13:43 +0800
1"""Cache integration for lexigram-admin.
3Provides ``AdminCacheService`` — a thin, Result-unwrapping facade over
4``CacheBackendProtocol`` — and ``CachedPermissionService``, which layers a
5process-local L1 cache in front of an optional distributed backend.
7Cross-request caching decorators (``@cache``, ``@remember``,
8``@invalidate_cache``) live in ``lexigram.cache.service.decorators``.
9Request-scoped caching lives in ``lexigram.cache.service.request_cache``.
10"""
12from __future__ import annotations
14import asyncio
15from datetime import UTC, datetime, timedelta
16import hashlib
17from typing import Any, Protocol, runtime_checkable
19from lexigram.contracts.infra.cache import CacheBackendProtocol
20from lexigram.di.decorators import inject
21from lexigram.serialization import dumps_str
23# ============================================================================
24# Cache Key Builders
25# ============================================================================
28def default_key_builder(func: Callable[..., Any], *args: Any, **kwargs: Any) -> str: # type: ignore[name-defined]
29 """Build cache key from function and arguments."""
30 # Get function identifier
31 func_name = f"{func.__module__}.{func.__qualname__}"
33 # Serialize arguments
34 key_parts = [func_name]
36 for arg in args:
37 if hasattr(arg, "id"):
38 key_parts.append(f"id:{arg.id}")
39 elif isinstance(arg, (str, int, float, bool)):
40 key_parts.append(str(arg))
41 else:
42 key_parts.append(hashlib.sha256(dumps_str(arg).encode()).hexdigest()[:8])
44 for k, v in sorted(kwargs.items()):
45 if isinstance(v, (str, int, float, bool)):
46 key_parts.append(f"{k}:{v}")
47 else:
48 key_parts.append(
49 f"{k}:{hashlib.sha256(dumps_str(v).encode()).hexdigest()[:8]}",
50 )
52 return ":".join(key_parts)
55# ============================================================================
56# Cache Service
57# ============================================================================
60@inject
61class AdminCacheService:
62 """Core caching service for the admin panel.
64 Provides a clean, unified API for caching using a
65 contract-based cache backend.
67 Example:
68 >>> cache = AdminCacheService(cache_backend=my_backend)
69 >>> await cache.set("user:1", {"name": "Admin"})
70 >>> user = await cache.get("user:1")
71 """
73 def __init__(
74 self,
75 cache_backend: CacheBackendProtocol,
76 ) -> None:
77 """Initialize the service.
79 Args:
80 cache_backend: Cache backend to use for storage
81 """
82 self._backend = cache_backend
84 async def get(self, key: str) -> Any | None:
85 """Get value from cache."""
86 res = await self._backend.get(key)
87 return res.unwrap() if res.is_ok() else None
89 async def set(self, key: str, value: Any, ttl: int | None = None) -> bool:
90 """Set value in cache."""
91 res = await self._backend.set(key, value, ttl)
92 return res.is_ok()
94 async def delete(self, key: str) -> bool:
95 """Delete key from cache."""
96 res = await self._backend.delete(key)
97 return res.unwrap() if res.is_ok() else False
99 async def delete_pattern(self, pattern: str) -> int:
100 """Delete keys matching pattern."""
101 res = await self._backend.delete_pattern(pattern)
102 return res.unwrap() if res.is_ok() else 0
104 async def get_or_set(
105 self,
106 key: str,
107 factory: Any,
108 ttl: int | None = None,
109 ) -> Any:
110 """Get from cache or compute and store."""
111 value = await self.get(key)
112 if value is not None:
113 return value
115 # Compute value
116 if asyncio.iscoroutinefunction(factory):
117 value = await factory()
118 else:
119 value = factory()
121 await self.set(key, value, ttl)
122 return value
125# ============================================================================
126# Cached Permission Service
127# ============================================================================
130@runtime_checkable
131class UserRoleProviderProtocol(Protocol):
132 """Protocol for user role providers."""
134 async def get_roles(self, user: Any) -> list[str]: ...
136 async def get_role_permissions(self, role: str) -> set[str]: ...
139@inject
140class CachedPermissionService:
141 """Permission service with caching.
143 Caches user permissions with configurable TTL.
145 Example:
146 >>> service = CachedPermissionService(role_provider, ttl=300)
147 >>> permissions = await service.get_permissions(user)
148 """
150 def __init__(
151 self,
152 role_provider: UserRoleProviderProtocol,
153 cache_backend: CacheBackendProtocol | None = None,
154 ttl: int = 300,
155 ):
156 self._role_provider = role_provider
157 self._cache: CacheBackendProtocol | None = cache_backend
158 self._ttl = ttl
159 self._local_cache: dict[Any, tuple[set[str], datetime]] = {}
161 async def get_permissions(self, user: Any) -> set[str]:
162 """Get user permissions with caching."""
163 user_id = getattr(user, "id", id(user))
164 cache_key = f"admin:perms:{user_id}"
166 # Check local cache first (process-level)
167 if user_id in self._local_cache:
168 perms, expires = self._local_cache[user_id]
169 if datetime.now(UTC) < expires:
170 return perms
171 del self._local_cache[user_id]
173 # Check distributed cache
174 if self._cache is not None:
175 res = await self._cache.get(cache_key)
176 if res.is_ok():
177 cached = res.unwrap()
178 if cached is not None:
179 self._local_cache[user_id] = (
180 cached,
181 datetime.now(UTC) + timedelta(seconds=self._ttl // 2),
182 )
183 return cached
185 # Compute permissions
186 permissions: set[str] = set()
187 roles = await self._role_provider.get_roles(user)
189 for role in roles:
190 role_permissions = await self._role_provider.get_role_permissions(role)
191 permissions.update(role_permissions)
193 # Cache permissions
194 if self._cache is not None:
195 await self._cache.set(cache_key, permissions, self._ttl)
196 self._local_cache[user_id] = (
197 permissions,
198 datetime.now(UTC) + timedelta(seconds=self._ttl // 2),
199 )
201 return permissions
203 async def invalidate_user(self, user_id: Any) -> None:
204 """Invalidate cached permissions for a user."""
205 cache_key = f"admin:perms:{user_id}"
206 if self._cache is not None:
207 await self._cache.delete(cache_key)
208 self._local_cache.pop(user_id, None)
210 async def invalidate_all(self) -> None:
211 """Invalidate all cached permissions."""
212 if self._cache is not None:
213 await self._cache.delete_pattern("admin:perms:*")
214 self._local_cache.clear()
216 def has_permission(self, user: Any, permission: str) -> bool:
217 """Check if user has permission (sync, uses local cache)."""
218 user_id = getattr(user, "id", id(user))
219 if user_id in self._local_cache:
220 perms, expires = self._local_cache[user_id]
221 if datetime.now(UTC) < expires:
222 return permission in perms
223 return False
226__all__ = [
227 # Service
228 "AdminCacheService",
229 # Backend protocol re-export for ergonomic imports
230 "CacheBackendProtocol",
231 # Permission caching
232 "CachedPermissionService",
233 "UserRoleProviderProtocol",
234 # Key builders
235 "default_key_builder",
236]