Coverage for src/lexigram/features/backends/base.py: 95%

110 statements  

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

1"""Abstract base class for rich flag providers. 

2 

3Defines :class:`AbstractFlagProvider` — an ABC that extends the simple 

4:class:`~lexigram.contracts.feature_flags.FlagProvider` protocol with a 

5richer ``evaluate`` coroutine and a complete suite of per-type evaluation 

6strategy helpers. 

7 

8Concrete providers (:class:`~lexigram.feature_flags.backends.local.LocalProvider`, 

9:class:`~lexigram.feature_flags.backends.env.EnvProvider`, etc.) inherit from 

10this class and override ``get_flag_definition`` and ``get_all_flags``; the 

11evaluation logic is shared here. 

12""" 

13 

14from __future__ import annotations 

15 

16from abc import ABC, abstractmethod 

17from datetime import UTC, datetime 

18import hashlib 

19from typing import Any 

20 

21from lexigram.features.constants import DEFAULT_ENABLED 

22from lexigram.features.types import ( 

23 Flag, 

24 FlagContext, 

25 FlagEvaluation, 

26 FlagType, 

27) 

28from lexigram.logging import get_logger 

29 

30logger = get_logger(__name__) 

31 

32_warned_empty_user_attribute_rules: set[str] = set() 

33 

34 

35class AbstractFlagProvider(ABC): 

36 """Rich abstract flag provider with full per-type evaluation logic. 

37 

38 Subclasses must implement :meth:`get_flag_definition` and 

39 :meth:`get_all_flags`. All evaluation helpers and the :meth:`evaluate` 

40 coroutine are provided here. 

41 

42 This class is *not* a :class:`~lexigram.contracts.feature_flags.FlagProvider` 

43 protocol implementation — ``get_flag_definition`` returns a 

44 :class:`~lexigram.feature_flags.types.Flag` model, not a bare boolean. 

45 Use :class:`~lexigram.features.backends.local.LocalProvider` 

46 when you only need the simple boolean protocol. 

47 """ 

48 

49 # ------------------------------------------------------------------ 

50 # Abstract interface — subclasses must implement these two methods. 

51 # ------------------------------------------------------------------ 

52 

53 @abstractmethod 

54 async def get_flag_definition(self, name: str) -> Flag | None: 

55 """Return the full :class:`Flag` definition or *None* if not found.""" 

56 

57 @abstractmethod 

58 async def get_all_flags(self) -> dict[str, Flag]: 

59 """Return all known flag definitions keyed by flag name.""" 

60 

61 # ------------------------------------------------------------------ 

62 # Primary public API 

63 # ------------------------------------------------------------------ 

64 

65 async def evaluate( 

66 self, 

67 name: str, 

68 context: FlagContext | None = None, 

69 ) -> FlagEvaluation: 

70 """Evaluate flag *name* for *context* and return a :class:`FlagEvaluation`. 

71 

72 Returns a ``flag_not_found`` evaluation (``enabled=False``) when the 

73 flag does not exist, so callers can safely treat unknown flags the same 

74 as disabled ones. 

75 """ 

76 flag = await self.get_flag_definition(name) 

77 if flag is None: 

78 return FlagEvaluation( 

79 flag_name=name, 

80 enabled=False, 

81 reason="flag_not_found", 

82 value=False, 

83 ) 

84 return self._evaluate_flag(flag, context or FlagContext()) 

85 

86 def evaluate_sync( 

87 self, 

88 name: str, 

89 context: FlagContext | None = None, 

90 ) -> FlagEvaluation: 

91 """Synchronous evaluation — only valid when the store is in-memory. 

92 

93 Subclasses that back their data with a sync structure should override 

94 this if they need to avoid the event loop. The default raises 

95 ``NotImplementedError`` to prevent accidental blocking I/O. 

96 """ 

97 raise NotImplementedError( 

98 f"{type(self).__name__} does not support synchronous evaluation", 

99 ) 

100 

101 # ------------------------------------------------------------------ 

102 # FlagProvider protocol bridge 

103 # ------------------------------------------------------------------ 

104 

105 async def get_flag( 

106 self, 

107 name: str, 

108 *, 

109 default: bool = False, 

110 context: dict[str, Any] | None = None, 

111 ) -> bool: 

112 """Asynchronous boolean access (primary) — delegates to evaluate.""" 

113 ctx = _dict_to_context(context) 

114 result = await self.evaluate(name, ctx) 

115 if result.reason == "flag_not_found": 

116 return default 

117 return result.enabled 

118 

119 def get_flag_sync( 

120 self, 

121 name: str, 

122 *, 

123 default: bool = False, 

124 context: dict[str, Any] | None = None, 

125 ) -> bool: 

126 """Synchronous boolean access — delegates to evaluate_sync.""" 

127 ctx = _dict_to_context(context) 

128 try: 

129 result = self.evaluate_sync(name, ctx) 

130 except NotImplementedError: 

131 return default 

132 

133 if result.reason == "flag_not_found": 

134 return default 

135 return result.enabled 

136 

137 async def get_variant( 

138 self, 

139 name: str, 

140 *, 

141 default: str = "", 

142 context: dict[str, Any] | None = None, 

143 ) -> str: 

144 """Asynchronous variant access (primary) — delegates to evaluate.""" 

145 ctx = _dict_to_context(context) 

146 result = await self.evaluate(name, ctx) 

147 if result.reason == "flag_not_found": 

148 return default 

149 if isinstance(result.value, str): 

150 return result.value 

151 return default 

152 

153 def get_variant_sync( 

154 self, 

155 name: str, 

156 *, 

157 default: str = "", 

158 context: dict[str, Any] | None = None, 

159 ) -> str: 

160 """Synchronous variant access — delegates to evaluate_sync.""" 

161 ctx = _dict_to_context(context) 

162 try: 

163 result = self.evaluate_sync(name, ctx) 

164 except NotImplementedError: 

165 return default 

166 if isinstance(result.value, str): 

167 return result.value 

168 return default 

169 

170 # ------------------------------------------------------------------ 

171 # Per-type evaluation helpers — shared by all concrete providers. 

172 # ------------------------------------------------------------------ 

173 

174 def _evaluate_flag(self, flag: Flag, context: FlagContext) -> FlagEvaluation: 

175 """Dispatch evaluation based on flag type.""" 

176 if not flag.enabled: 

177 return FlagEvaluation( 

178 flag_name=flag.name, 

179 enabled=False, 

180 reason="flag_disabled", 

181 value=False, 

182 ) 

183 

184 dispatch = { 

185 FlagType.BOOLEAN: self._evaluate_boolean, 

186 FlagType.PERCENTAGE: self._evaluate_percentage, 

187 FlagType.USER_LIST: self._evaluate_user_list, 

188 FlagType.USER_ATTRIBUTE: self._evaluate_user_attribute, 

189 FlagType.TIME_BASED: self._evaluate_time_based, 

190 FlagType.VARIANT: self._evaluate_variant, 

191 } 

192 handler = dispatch.get(flag.type) 

193 if handler is None: 

194 return FlagEvaluation( 

195 flag_name=flag.name, 

196 enabled=False, 

197 reason=f"unsupported_type:{flag.type}", 

198 value=False, 

199 ) 

200 return handler(flag, context) 

201 

202 def _evaluate_boolean(self, flag: Flag, context: FlagContext) -> FlagEvaluation: 

203 """Evaluate a simple boolean flag.""" 

204 return FlagEvaluation( 

205 flag_name=flag.name, 

206 enabled=flag.enabled, 

207 reason="boolean", 

208 value=flag.enabled, 

209 ) 

210 

211 def _evaluate_percentage(self, flag: Flag, context: FlagContext) -> FlagEvaluation: 

212 """Evaluate a percentage-rollout flag. 

213 

214 Hash is computed from ``{user_id}:{flag_name}`` so each flag produces 

215 an independent, deterministic assignment per user. 

216 """ 

217 bucket_input = f"{context.user_id or ''}:{flag.name}" 

218 bucket = ( 

219 int( 

220 hashlib.md5(bucket_input.encode(), usedforsecurity=False).hexdigest(), 

221 16, 

222 ) 

223 % 100 

224 ) 

225 enabled = bucket < flag.percentage 

226 return FlagEvaluation( 

227 flag_name=flag.name, 

228 enabled=enabled, 

229 reason="percentage_rollout", 

230 value=enabled, 

231 ) 

232 

233 def _evaluate_user_list(self, flag: Flag, context: FlagContext) -> FlagEvaluation: 

234 """Evaluate a flag restricted to an explicit user-ID list.""" 

235 enabled = context.user_id is not None and context.user_id in flag.user_list 

236 return FlagEvaluation( 

237 flag_name=flag.name, 

238 enabled=enabled, 

239 reason="user_list", 

240 value=enabled, 

241 ) 

242 

243 def _evaluate_user_attribute( 

244 self, 

245 flag: Flag, 

246 context: FlagContext, 

247 ) -> FlagEvaluation: 

248 """Evaluate a flag gate by matching all required user attribute pairs. 

249 

250 An empty rule set evaluates disabled (fail-closed) because an 

251 unconfigured rule is a misconfiguration, not a grant-all. 

252 """ 

253 if not flag.user_attributes: 

254 if flag.name not in _warned_empty_user_attribute_rules: 

255 _warned_empty_user_attribute_rules.add(flag.name) 

256 logger.warning( 

257 "user_attribute_empty_rule_denied", 

258 flag=flag.name, 

259 ) 

260 return FlagEvaluation( 

261 flag_name=flag.name, 

262 enabled=DEFAULT_ENABLED, 

263 reason="user_attribute_empty_rule_denied", 

264 value=DEFAULT_ENABLED, 

265 ) 

266 attrs = context.user_attributes or {} 

267 enabled = all(attrs.get(k) == v for k, v in flag.user_attributes.items()) 

268 return FlagEvaluation( 

269 flag_name=flag.name, 

270 enabled=enabled, 

271 reason="user_attribute", 

272 value=enabled, 

273 ) 

274 

275 def _evaluate_time_based(self, flag: Flag, context: FlagContext) -> FlagEvaluation: 

276 """Evaluate a time-windowed flag against UTC now (or context timestamp).""" 

277 if context.timestamp is not None: 

278 now = datetime.fromtimestamp(context.timestamp, tz=UTC) 

279 else: 

280 now = datetime.now(UTC) 

281 

282 after_start = flag.start_time is None or now >= flag.start_time 

283 before_end = flag.end_time is None or now <= flag.end_time 

284 enabled = after_start and before_end 

285 

286 return FlagEvaluation( 

287 flag_name=flag.name, 

288 enabled=enabled, 

289 reason="time_based", 

290 value=enabled, 

291 ) 

292 

293 def _evaluate_variant(self, flag: Flag, context: FlagContext) -> FlagEvaluation: 

294 """Assign a variant deterministically using weighted bucket hashing. 

295 

296 Variants are sorted by name for deterministic ordering, then a 

297 cumulative weight range is used to pick the variant for this user. 

298 """ 

299 if not flag.variants: 

300 variant = flag.default_variant or "" 

301 return FlagEvaluation( 

302 flag_name=flag.name, 

303 enabled=bool(variant), 

304 reason="variant_no_config", 

305 value=variant or False, 

306 ) 

307 

308 bucket_input = f"{context.user_id or ''}:{flag.name}" 

309 bucket = ( 

310 int( 

311 hashlib.md5(bucket_input.encode(), usedforsecurity=False).hexdigest(), 

312 16, 

313 ) 

314 % 100 

315 ) 

316 

317 cumulative = 0 

318 chosen: str | None = None 

319 for variant_name in sorted(flag.variants): 

320 cumulative += flag.variants[variant_name] 

321 if bucket < cumulative: 

322 chosen = variant_name 

323 break 

324 

325 if chosen is None: 

326 chosen = flag.default_variant or next(iter(sorted(flag.variants)), "") 

327 

328 return FlagEvaluation( 

329 flag_name=flag.name, 

330 enabled=True, 

331 reason="variant", 

332 value=chosen, 

333 ) 

334 

335 

336# --------------------------------------------------------------------------- 

337# Helpers 

338# --------------------------------------------------------------------------- 

339 

340 

341def _dict_to_context(d: dict[str, Any] | None) -> FlagContext | None: 

342 """Convert a plain dict context to a :class:`FlagContext` if given.""" 

343 if d is None: 

344 return None 

345 return FlagContext( 

346 user_id=d.get("user_id"), 

347 user_attributes=d.get("user_attributes"), 

348 session_id=d.get("session_id"), 

349 request_id=d.get("request_id"), 

350 timestamp=d.get("timestamp"), 

351 custom={ 

352 k: v 

353 for k, v in d.items() 

354 if k 

355 not in { 

356 "user_id", 

357 "user_attributes", 

358 "session_id", 

359 "request_id", 

360 "timestamp", 

361 } 

362 } 

363 or None, 

364 ) 

365 

366 

367__all__ = ["AbstractFlagProvider"]