Coverage for agentos/tools/feature_flag.py: 0%
76 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-03 08:37 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-03 08:37 +0800
1"""
2FeatureFlag — runtime feature toggle system with percentage rollout.
4Supports:
5 - Boolean flags
6 - Percentage-based rollouts
7 - Target rules (by user ID, group, environment)
8 - Flag dependencies (flag A requires flag B enabled)
9 - Overrides (force-on / force-off per context)
10 - Thread-safe reads/writes
11"""
13from __future__ import annotations
15import hashlib
16import threading
17from typing import Any, Callable, Dict, List, Optional, Set
20class FeatureFlag:
21 """Runtime feature toggle engine.
23 Usage:
24 ff = FeatureFlag()
26 # Define flags
27 ff.define("dark_mode", default=False)
28 ff.define("new_checkout", default=False, rollout=10) # 10% users
29 ff.define("beta_search", default=False, targets=["beta-users"])
30 ff.define("analytics_v2", default=True, depends_on=["new_checkout"])
32 # Evaluate
33 ff.is_enabled("dark_mode", context={"user_id": "user123"})
34 ff.is_enabled("new_checkout", context={"user_id": "user123"})
35 """
37 def __init__(self):
38 self._flags: Dict[str, _FlagDef] = {}
39 self._lock = threading.RLock()
41 # ---------- Define ----------
43 def define(
44 self,
45 name: str,
46 default: bool = False,
47 rollout: int = 0,
48 targets: Optional[List[str]] = None,
49 depends_on: Optional[List[str]] = None,
50 ):
51 """Register a feature flag.
53 Args:
54 name: Flag name
55 default: Default value when no rules match
56 rollout: Percentage (0-100) of users who get the flag
57 targets: User groups that get this flag
58 depends_on: Other flags that must be enabled first
59 """
60 if not (0 <= rollout <= 100):
61 raise ValueError("rollout must be 0-100")
63 with self._lock:
64 self._flags[name] = _FlagDef(
65 name=name,
66 default=default,
67 rollout=rollout,
68 targets=set(targets or []),
69 depends_on=set(depends_on or []),
70 overrides={},
71 )
73 # ---------- Evaluate ----------
75 def is_enabled(self, name: str, context: Optional[dict] = None) -> bool:
76 """Check whether a feature flag is enabled for the given context.
78 Context may include:
79 user_id: str
80 groups: List[str]
81 """
82 context = context or {}
84 with self._lock:
85 if name not in self._flags:
86 return False
88 flag = self._flags[name]
89 user_id = context.get("user_id", "")
90 groups = set(context.get("groups", []))
92 # Check overrides
93 override_key = user_id
94 if override_key and override_key in flag.overrides:
95 return flag.overrides[override_key]
97 # Check group targets
98 if flag.targets and flag.targets & groups:
99 return True
101 # Check percentage rollout
102 if flag.rollout > 0 and user_id:
103 if self._in_rollout(user_id, name, flag.rollout):
104 return True
106 # Check dependencies
107 if flag.depends_on:
108 if not all(self.is_enabled(d, context) for d in flag.depends_on):
109 return False
111 return flag.default
113 # ---------- Override ----------
115 def set_override(self, name: str, user_id: str, value: bool):
116 """Force a flag on/off for a specific user."""
117 with self._lock:
118 if name not in self._flags:
119 raise KeyError(f"Unknown flag: {name}")
120 self._flags[name].overrides[user_id] = value
122 def clear_override(self, name: str, user_id: str):
123 """Remove override for a user."""
124 with self._lock:
125 if name in self._flags:
126 self._flags[name].overrides.pop(user_id, None)
128 def clear_all_overrides(self, name: Optional[str] = None):
129 """Clear all overrides, optionally for a specific flag."""
130 with self._lock:
131 if name:
132 if name in self._flags:
133 self._flags[name].overrides.clear()
134 else:
135 for flag in self._flags.values():
136 flag.overrides.clear()
138 # ---------- Query ----------
140 def list_flags(self) -> List[str]:
141 with self._lock:
142 return list(self._flags.keys())
144 def get_definition(self, name: str) -> Optional[dict]:
145 with self._lock:
146 flag = self._flags.get(name)
147 if not flag:
148 return None
149 return {
150 "name": flag.name,
151 "default": flag.default,
152 "rollout": flag.rollout,
153 "targets": list(flag.targets),
154 "depends_on": list(flag.depends_on),
155 }
157 def remove(self, name: str):
158 with self._lock:
159 self._flags.pop(name, None)
161 # ---------- Internal ----------
163 @staticmethod
164 def _in_rollout(user_id: str, flag_name: str, percentage: int) -> bool:
165 """Deterministic percentage-based rollout.
167 Uses MD5 hash of (user_id + flag_name) to produce stable grouping.
168 """
169 key = f"{user_id}:{flag_name}"
170 h = hashlib.md5(key.encode()).hexdigest()
171 bucket = int(h[:8], 16) % 100
172 return bucket < percentage
175class _FlagDef:
176 __slots__ = ("name", "default", "rollout", "targets", "depends_on", "overrides")
178 def __init__(self, name, default, rollout, targets, depends_on, overrides):
179 self.name = name
180 self.default = default
181 self.rollout = rollout
182 self.targets: Set[str] = targets
183 self.depends_on: Set[str] = depends_on
184 self.overrides: Dict[str, bool] = overrides or {}