Coverage for src/lexigram/features/manager/flag_manager.py: 87%

145 statements  

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

1"""Central feature flag manager implementation. 

2 

3:class:`FlagManager` is the primary entry point for evaluating feature flags 

4in application code. It wraps any :class:`~lexigram.features.backends.base.AbstractFlagProvider` 

5and adds in-process TTL caching, runtime overrides, change listeners, variant 

6support, and a configurable default-enabled fallback. 

7""" 

8 

9from __future__ import annotations 

10 

11import asyncio 

12from dataclasses import dataclass, field 

13from datetime import UTC, datetime 

14import time 

15from typing import TYPE_CHECKING, Any 

16 

17from lexigram.concurrency.task_utils import create_tracked_task 

18from lexigram.features.backends.local import LocalProvider 

19from lexigram.features.types import FlagContext, FlagEvaluation 

20from lexigram.logging import get_logger 

21 

22logger = get_logger(__name__) 

23 

24if TYPE_CHECKING: 

25 from lexigram.contracts.events.protocols import EventBusProtocol 

26 from lexigram.features.backends.base import AbstractFlagProvider 

27 from lexigram.features.manager.types import ( 

28 AsyncFlagChangeListener, 

29 FlagChangeListener, 

30 ) 

31 

32 

33@dataclass 

34class FlagAuditEntry: 

35 """Records a single flag override change for compliance and observability. 

36 

37 Attributes: 

38 flag_name: The name of the feature flag that was changed. 

39 actor: Optional identifier of the entity that made the change. 

40 old_value: The override state before the change (``None`` if no prior override). 

41 new_value: The override state after the change. 

42 timestamp: UTC datetime when the change was recorded. 

43 """ 

44 

45 flag_name: str 

46 actor: str | None 

47 old_value: bool | None 

48 new_value: bool 

49 timestamp: datetime = field(default_factory=lambda: datetime.now(tz=UTC)) 

50 

51 

52class FlagManager: 

53 """Central feature flag manager with caching, overrides, and variant support. 

54 

55 Wraps an :class:`~lexigram.features.backends.base.AbstractFlagProvider` and 

56 handles cache invalidation, runtime overrides, and change notification. 

57 

58 Example:: 

59 

60 provider = LocalProvider({ 

61 "new_checkout": Flag("new_checkout", enabled=True), 

62 }) 

63 manager = FlagManager(provider, cache_ttl=60) 

64 

65 ctx = FlagContext(user_id="user-123") 

66 if await manager.is_enabled("new_checkout", ctx): 

67 ... 

68 """ 

69 

70 def __init__( 

71 self, 

72 provider: AbstractFlagProvider | None = None, 

73 *, 

74 cache_ttl: int = 300, 

75 default_enabled: bool = False, 

76 event_bus: EventBusProtocol | None = None, 

77 ) -> None: 

78 self._provider: AbstractFlagProvider = provider or LocalProvider() 

79 self._cache_ttl = cache_ttl 

80 self._default_enabled = default_enabled 

81 self._event_bus: EventBusProtocol | None = event_bus 

82 self._listeners: list[FlagChangeListener] = [] 

83 self._async_listeners: list[AsyncFlagChangeListener] = [] 

84 self._disabled_flags: set[str] = set() 

85 self._enabled_flags: set[str] = set() 

86 self._cache: dict[str, tuple[FlagEvaluation, float]] = {} 

87 self._background_tasks: set[asyncio.Task[Any]] = set() 

88 self._audit_log: list[FlagAuditEntry] = [] 

89 

90 # ------------------------------------------------------------------ 

91 # Public read API 

92 # ------------------------------------------------------------------ 

93 

94 async def is_enabled( 

95 self, 

96 name: str, 

97 context: FlagContext | None = None, 

98 *, 

99 default: bool | None = None, 

100 ) -> bool: 

101 """Return whether *name* is enabled for *context*. 

102 

103 Runtime overrides take precedence over the provider result. 

104 

105 Args: 

106 name: Flag name. 

107 context: Optional evaluation context. 

108 default: Override the instance's ``default_enabled`` for this call. 

109 

110 Returns: 

111 True if the flag is enabled. 

112 """ 

113 if name in self._enabled_flags: 

114 return True 

115 if name in self._disabled_flags: 

116 return False 

117 

118 evaluation = await self.evaluate(name, context) 

119 if evaluation.reason == "flag_not_found": 

120 if default is not None: 

121 return default 

122 return self._default_enabled 

123 return evaluation.enabled 

124 

125 async def evaluate( 

126 self, 

127 name: str, 

128 context: FlagContext | None = None, 

129 ) -> FlagEvaluation: 

130 """Evaluate *name* for *context*, using the TTL cache when available. 

131 

132 Args: 

133 name: Flag name. 

134 context: Optional evaluation context. 

135 

136 Returns: 

137 A :class:`~lexigram.features.types.FlagEvaluation` result. 

138 """ 

139 cache_key = self._get_cache_key(name, context) 

140 if self._cache_ttl > 0 and cache_key in self._cache: 

141 evaluation, ts = self._cache[cache_key] 

142 if time.monotonic() - ts < self._cache_ttl: 

143 return evaluation 

144 

145 try: 

146 evaluation = await self._provider.evaluate(name, context) 

147 except Exception as exc: # noqa: BLE001 

148 logger.error( 

149 "flag_provider_error", 

150 flag=name, 

151 error=str(exc), 

152 ) 

153 return FlagEvaluation( 

154 flag_name=name, 

155 enabled=self._default_enabled, 

156 reason="provider_error", 

157 ) 

158 

159 if self._cache_ttl > 0: 

160 self._cache[cache_key] = (evaluation, time.monotonic()) 

161 return evaluation 

162 

163 async def get_variant( 

164 self, 

165 name: str, 

166 context: FlagContext | None = None, 

167 *, 

168 default: str = "", 

169 ) -> str: 

170 """Return the variant string for a VARIANT-type flag. 

171 

172 Returns *default* if the flag is not found, disabled, or is not a 

173 VARIANT flag. 

174 

175 Args: 

176 name: Flag name. 

177 context: Optional evaluation context. 

178 default: Fallback value when no variant string is available. 

179 

180 Returns: 

181 The variant string or *default*. 

182 """ 

183 evaluation = await self.evaluate(name, context) 

184 if isinstance(evaluation.value, str): 

185 return evaluation.value 

186 return default 

187 

188 def add_provider(self, provider: AbstractFlagProvider, priority: int = 50) -> None: 

189 """Register a flag provider at the given priority level.""" 

190 # For simplicity in this adaptation, just replace the default provider. 

191 # A robust implementation would support multiple chained providers. 

192 self._provider = provider 

193 

194 async def get_value( 

195 self, 

196 key: str, 

197 default: object, 

198 context: FlagContext | None = None, 

199 ) -> object: 

200 """Evaluate a feature flag and return its resolved value.""" 

201 from lexigram.features.backends.base import _dict_to_context 

202 

203 ctx = _dict_to_context(context) if isinstance(context, dict) else context 

204 evaluation = await self.evaluate(key, ctx) 

205 if evaluation.reason == "flag_not_found": 

206 return default 

207 return ( 

208 evaluation.value 

209 if hasattr(evaluation, "value") and evaluation.value is not None 

210 else default 

211 ) 

212 

213 async def get_all_flags( 

214 self, 

215 context: FlagContext | None = None, 

216 ) -> dict[str, FlagEvaluation]: 

217 """Return evaluations for all known flags. 

218 

219 Definitions come from the provider; each is evaluated against 

220 *context* here so the mapping honours the 

221 ``dict[str, FlagEvaluation]`` contract instead of leaking raw 

222 ``Flag`` definitions. 

223 """ 

224 if not hasattr(self._provider, "get_all_flags"): 

225 return {} 

226 

227 

228 definitions: dict[str, Any] = await self._provider.get_all_flags() 

229 evaluations: dict[str, FlagEvaluation] = {} 

230 for name in definitions: 

231 evaluation = await self._provider.evaluate(name, context) 

232 evaluations[name] = evaluation 

233 return evaluations 

234 

235 # ------------------------------------------------------------------ 

236 # Runtime overrides 

237 # ------------------------------------------------------------------ 

238 

239 def get_override_state(self, name: str) -> bool | None: 

240 """Return the current runtime override for *name*. 

241 

242 Returns: 

243 ``True`` if force-enabled, ``False`` if force-disabled, 

244 ``None`` if no override is active. 

245 """ 

246 if name in self._enabled_flags: 

247 return True 

248 if name in self._disabled_flags: 

249 return False 

250 return None 

251 

252 def enable(self, name: str, *, actor: str | None = None) -> None: 

253 """Force-enable *name* regardless of provider result.""" 

254 old_value: bool | None = self.get_override_state(name) 

255 self._enabled_flags.add(name) 

256 self._disabled_flags.discard(name) 

257 self._audit_log.append( 

258 FlagAuditEntry( 

259 flag_name=name, actor=actor, old_value=old_value, new_value=True 

260 ) 

261 ) 

262 self._notify_listeners(name, old_value is True, True, actor=actor) 

263 

264 def disable(self, name: str, *, actor: str | None = None) -> None: 

265 """Force-disable *name* regardless of provider result.""" 

266 old_value: bool | None = self.get_override_state(name) 

267 self._disabled_flags.add(name) 

268 self._enabled_flags.discard(name) 

269 self._audit_log.append( 

270 FlagAuditEntry( 

271 flag_name=name, actor=actor, old_value=old_value, new_value=False 

272 ) 

273 ) 

274 self._notify_listeners(name, old_value is True, False, actor=actor) 

275 

276 def set_override( 

277 self, name: str, enabled: bool, *, actor: str | None = None 

278 ) -> None: 

279 """Convenience wrapper that calls :meth:`enable` or :meth:`disable`.""" 

280 if enabled: 

281 self.enable(name, actor=actor) 

282 else: 

283 self.disable(name) 

284 

285 def clear_override(self, name: str) -> None: 

286 """Remove any runtime override for *name*, restoring provider control.""" 

287 self._enabled_flags.discard(name) 

288 self._disabled_flags.discard(name) 

289 

290 def get_audit_log(self) -> list[FlagAuditEntry]: 

291 """Return a copy of all recorded flag change audit entries. 

292 

293 Each entry captures the flag name, actor, old value, new value, and 

294 the UTC timestamp of the change. Entries are ordered oldest-first. 

295 

296 Returns: 

297 List of :class:`FlagAuditEntry` instances representing every 

298 :meth:`enable` / :meth:`disable` / :meth:`set_override` call 

299 since this manager was created. 

300 """ 

301 return list(self._audit_log) 

302 

303 # ------------------------------------------------------------------ 

304 # Cache management 

305 # ------------------------------------------------------------------ 

306 

307 async def clear_cache(self) -> None: 

308 """Flush all cached evaluations.""" 

309 self._cache.clear() 

310 

311 def clear_cache_for(self, name: str) -> None: 

312 """Flush cached evaluations for a single flag name.""" 

313 keys = [k for k in self._cache if k.startswith(f"flag:{name}")] 

314 for k in keys: 

315 del self._cache[k] 

316 

317 def _get_cache_key(self, name: str, context: FlagContext | None) -> str: 

318 if context is None: 

319 return f"flag:{name}" 

320 ctx_hash = context.context_hash() 

321 if not ctx_hash: 

322 return f"flag:{name}" 

323 return f"flag:{name}:ctx:{ctx_hash}" 

324 

325 # ------------------------------------------------------------------ 

326 # Change listeners 

327 # ------------------------------------------------------------------ 

328 

329 def add_listener_sync(self, fn: FlagChangeListener) -> None: 

330 """Register a sync callback invoked when a flag override changes.""" 

331 self._listeners.append(fn) 

332 

333 def remove_listener_sync(self, fn: FlagChangeListener) -> None: 

334 """Deregister a previously registered sync listener.""" 

335 self._listeners.remove(fn) 

336 

337 def add_listener(self, fn: AsyncFlagChangeListener) -> None: 

338 """Register an async callback invoked when a flag override changes.""" 

339 self._async_listeners.append(fn) 

340 

341 def remove_listener(self, fn: AsyncFlagChangeListener) -> None: 

342 """Deregister a previously registered async listener.""" 

343 self._async_listeners.remove(fn) 

344 

345 def _notify_listeners( 

346 self, 

347 name: str, 

348 old_enabled: bool, 

349 new_enabled: bool, 

350 actor: str | None = None, 

351 ) -> None: 

352 """Call all sync listeners synchronously; schedule async listeners.""" 

353 for fn in self._listeners: 

354 fn(name, old_enabled, new_enabled) 

355 try: 

356 asyncio.get_running_loop() 

357 if self._async_listeners: 

358 for afn in self._async_listeners: 

359 create_tracked_task( 

360 afn(name, old_enabled, new_enabled), 

361 self._background_tasks, 

362 name=f"flag_listener_{name}", 

363 ) 

364 if self._event_bus is not None: 

365 from lexigram.features.events import FlagChangeEvent 

366 

367 event = FlagChangeEvent( 

368 flag_name=name, 

369 old_enabled=old_enabled, 

370 new_enabled=new_enabled, 

371 actor=actor, 

372 ) 

373 create_tracked_task( 

374 self._event_bus.publish(event), 

375 self._background_tasks, 

376 name=f"flag_event_{name}", 

377 ) 

378 except RuntimeError: 

379 pass # No running event loop; async listeners silently skipped. 

380 

381 # ------------------------------------------------------------------ 

382 # Introspection 

383 # ------------------------------------------------------------------ 

384 

385 @property 

386 def provider(self) -> AbstractFlagProvider: 

387 """The underlying flag provider instance.""" 

388 return self._provider 

389 

390 

391__all__ = ["FlagManager"]