Coverage for src/lexigram/features/backends/cache.py: 43%

49 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-26 01:53 +0800

1"""Cache-backed feature flag provider. 

2 

3Stores flag definitions in a :class:`~lexigram.contracts.cache.protocols.CacheBackendProtocol` 

4(e.g. Redis, Memcached, or in-memory). Each flag is serialised as JSON under a 

5namespaced key so that multiple applications can share the same cache without 

6key collisions. 

7 

8Limitations 

9----------- 

10* :meth:`CacheBackendFlagProvider.list_flags` returns an empty list because 

11 most cache backends (including Redis ``CacheBackendProtocol`` implementations) do not 

12 expose a key-scan API through the standard protocol. Applications that need 

13 to list all flags should maintain a separate index or use a provider that 

14 supports enumeration (e.g. :class:`~lexigram.features.backends.local.LocalProvider`). 

15""" 

16 

17from __future__ import annotations 

18 

19from datetime import UTC 

20from typing import TYPE_CHECKING, Any 

21 

22from lexigram import serialization as json 

23from lexigram.features.exceptions import FlagNotFoundError 

24from lexigram.features.types import Flag, FlagType 

25from lexigram.logging import get_logger 

26 

27if TYPE_CHECKING: 

28 from lexigram.contracts.infra.cache.protocols import CacheBackendProtocol 

29 

30logger = get_logger(__name__) 

31 

32 

33class CacheBackendFlagProvider: 

34 """Feature-flag provider backed by a :class:`CacheBackendProtocol`. 

35 

36 Flags are stored as JSON-encoded objects in the cache under keys of the 

37 form ``{prefix}{name}``. Each entry is given an independent TTL so that 

38 flags expire and are treated as absent after the TTL elapses. 

39 

40 Args: 

41 cache: The cache backend to use for flag storage and retrieval. 

42 ttl: Time-to-live in seconds for each cached flag entry. Defaults 

43 to 3 600 seconds (1 hour). 

44 prefix: Key prefix applied to every flag name to avoid collisions 

45 with other cache users. Defaults to ``"lexigram:features:flag:"``. 

46 """ 

47 

48 def __init__( 

49 self, 

50 cache: CacheBackendProtocol, 

51 ttl: int = 3600, 

52 prefix: str = "lexigram:features:flag:", 

53 ) -> None: 

54 self._cache = cache 

55 self._ttl = ttl 

56 self._prefix = prefix 

57 

58 # ------------------------------------------------------------------ 

59 # Internal helpers 

60 # ------------------------------------------------------------------ 

61 

62 def _key(self, name: str) -> str: 

63 """Build the namespaced cache key for *name*.""" 

64 return f"{self._prefix}{name}" 

65 

66 @staticmethod 

67 def _flag_to_dict(flag: Flag) -> dict[str, Any]: 

68 """Serialise a :class:`Flag` to a JSON-compatible dict.""" 

69 return { 

70 "name": flag.name, 

71 "type": flag.type.value, 

72 "enabled": flag.enabled, 

73 "description": flag.description, 

74 "percentage": flag.percentage, 

75 "user_list": flag.user_list, 

76 "user_attributes": flag.user_attributes, 

77 "start_time": flag.start_time.isoformat() if flag.start_time else None, 

78 "end_time": flag.end_time.isoformat() if flag.end_time else None, 

79 "variants": flag.variants, 

80 "default_variant": flag.default_variant, 

81 "metadata": flag.metadata, 

82 } 

83 

84 @staticmethod 

85 def _dict_to_flag(data: dict[str, Any]) -> Flag: 

86 """Deserialise a dict produced by :meth:`_flag_to_dict` into a :class:`Flag`.""" 

87 from datetime import datetime 

88 

89 def _parse_dt(value: str | None) -> datetime | None: 

90 if value is None: 

91 return None 

92 return datetime.fromisoformat(value).replace(tzinfo=UTC) 

93 

94 return Flag( 

95 name=data["name"], 

96 type=FlagType(data.get("type", FlagType.BOOLEAN)), 

97 enabled=data.get("enabled", True), 

98 description=data.get("description", ""), 

99 percentage=data.get("percentage", 0), 

100 user_list=data.get("user_list", []), 

101 user_attributes=data.get("user_attributes", {}), 

102 start_time=_parse_dt(data.get("start_time")), 

103 end_time=_parse_dt(data.get("end_time")), 

104 variants=data.get("variants", {}), 

105 default_variant=data.get("default_variant", ""), 

106 metadata=data.get("metadata", {}), 

107 ) 

108 

109 # ------------------------------------------------------------------ 

110 # Public API 

111 # ------------------------------------------------------------------ 

112 

113 async def get_flag(self, name: str) -> Flag: 

114 """Return the :class:`Flag` definition stored under *name*. 

115 

116 Args: 

117 name: The flag name to look up. 

118 

119 Returns: 

120 The :class:`Flag` stored in the cache. 

121 

122 Raises: 

123 FlagNotFoundError: If no flag with *name* exists in the cache. 

124 """ 

125 raw_result = await self._cache.get(self._key(name)) 

126 if not raw_result.is_ok(): 

127 raise FlagNotFoundError(name) 

128 raw = raw_result.unwrap() 

129 if raw is None: 

130 raise FlagNotFoundError(name) 

131 data: dict[str, Any] = json.loads(raw) if isinstance(raw, (str, bytes)) else raw 

132 return self._dict_to_flag(data) 

133 

134 async def add_flag(self, flag: Flag) -> None: 

135 """Write *flag* to the cache with the configured TTL. 

136 

137 Args: 

138 flag: The :class:`Flag` definition to persist. 

139 """ 

140 payload = json.dumps(self._flag_to_dict(flag)) 

141 await self._cache.set(self._key(flag.name), payload, ttl=self._ttl) 

142 logger.debug("cache_flag_added", flag=flag.name, ttl=self._ttl) 

143 

144 async def remove_flag(self, name: str) -> None: 

145 """Delete the flag with *name* from the cache. 

146 

147 A no-op if the flag does not exist (the cache backend returns False 

148 from ``delete``, which is silently ignored here). 

149 

150 Args: 

151 name: The flag name to remove. 

152 """ 

153 await self._cache.delete(self._key(name)) 

154 logger.debug("cache_flag_removed", flag=name) 

155 

156 async def list_flags(self) -> list[Flag]: 

157 """Return all known flags. 

158 

159 .. note:: 

160 This method always returns an **empty list** because the standard 

161 :class:`~lexigram.contracts.cache.protocols.CacheBackendProtocol` protocol 

162 does not expose a key-scan or key-enumeration operation (e.g. Redis 

163 ``SCAN``). If you need to enumerate flags, maintain a separate 

164 index in the cache (a set under a dedicated key) or use a provider 

165 that natively supports enumeration such as 

166 :class:`~lexigram.features.backends.local.LocalProvider`. 

167 

168 Returns: 

169 Always an empty :class:`list`. 

170 """ 

171 logger.debug( 

172 "cache_flag_list_unsupported", 

173 message=( 

174 "CacheBackendFlagProvider.list_flags() returns [] — " 

175 "key enumeration is not part of the CacheBackendProtocol protocol." 

176 ), 

177 ) 

178 return [] 

179 

180 async def evaluate(self, name: str, context: dict[str, Any] | None = None) -> bool: 

181 """Evaluate whether the flag *name* is enabled. 

182 

183 Fetches the flag from the cache and returns its ``enabled`` field. 

184 This is a simple boolean check; for richer evaluation (percentage 

185 rollout, user-list, variant, etc.) use a full 

186 :class:`~lexigram.features.backends.base.AbstractFlagProvider`. 

187 

188 Args: 

189 name: The flag to evaluate. 

190 context: Optional context dict (not used by this provider; included 

191 for API consistency with other providers). 

192 

193 Returns: 

194 ``True`` if the flag exists and is enabled; ``False`` if the flag 

195 is disabled. 

196 

197 Raises: 

198 FlagNotFoundError: If the flag does not exist in the cache. 

199 """ 

200 flag = await self.get_flag(name) 

201 return flag.enabled 

202 

203 

204__all__ = ["CacheBackendFlagProvider"]