togglemesh 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- togglemesh-0.2.0/PKG-INFO +8 -0
- togglemesh-0.2.0/README.md +41 -0
- togglemesh-0.2.0/pyproject.toml +16 -0
- togglemesh-0.2.0/setup.cfg +4 -0
- togglemesh-0.2.0/togglemesh/__init__.py +4 -0
- togglemesh-0.2.0/togglemesh/client.py +377 -0
- togglemesh-0.2.0/togglemesh/models.py +63 -0
- togglemesh-0.2.0/togglemesh/operators.py +236 -0
- togglemesh-0.2.0/togglemesh/rules.py +131 -0
- togglemesh-0.2.0/togglemesh.egg-info/PKG-INFO +8 -0
- togglemesh-0.2.0/togglemesh.egg-info/SOURCES.txt +12 -0
- togglemesh-0.2.0/togglemesh.egg-info/dependency_links.txt +1 -0
- togglemesh-0.2.0/togglemesh.egg-info/requires.txt +2 -0
- togglemesh-0.2.0/togglemesh.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# ToggleMesh Python SDK (Local Evaluation)
|
|
2
|
+
|
|
3
|
+
A lightweight, robust Python SDK for evaluating feature flags with ToggleMesh.
|
|
4
|
+
|
|
5
|
+
This SDK uses **Local Evaluation**. It connects to the ToggleMesh control plane once to fetch all rules, establishes an SSE (Server-Sent Events) connection to listen for updates, and evaluates all flags locally in-memory without making network requests. This ensures ultra-fast responses (sub-millisecond) suitable for high-throughput backend services.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install togglemesh
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import time
|
|
17
|
+
from togglemesh import ToggleMeshClient, ToggleMeshOptions
|
|
18
|
+
|
|
19
|
+
client = ToggleMeshClient(ToggleMeshOptions(
|
|
20
|
+
base_url="http://localhost:5000",
|
|
21
|
+
client_key="YOUR_API_KEY"
|
|
22
|
+
))
|
|
23
|
+
|
|
24
|
+
# 1. Identify the user
|
|
25
|
+
client.identify("user-123", {"tenant": "acme_corp", "plan": "enterprise"})
|
|
26
|
+
|
|
27
|
+
# 2. Check a flag locally (Zero network latency!)
|
|
28
|
+
if client.is_enabled("new-feature", default_value=False):
|
|
29
|
+
print("Feature is ON")
|
|
30
|
+
else:
|
|
31
|
+
print("Feature is OFF")
|
|
32
|
+
|
|
33
|
+
# 3. Stop background threads when the app shuts down
|
|
34
|
+
client.stop()
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Supported Features
|
|
38
|
+
- Percentage Rollouts (Murmur3 / FNV-1a hashing)
|
|
39
|
+
- Operator matching (Strings, Numbers, Dates, SemVer, Regex)
|
|
40
|
+
- Real-time SSE updates
|
|
41
|
+
- Contextual Rollouts
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "togglemesh"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "ToggleMesh Python SDK"
|
|
9
|
+
authors = [
|
|
10
|
+
{ name="ToggleMesh Team" },
|
|
11
|
+
]
|
|
12
|
+
dependencies = [
|
|
13
|
+
"requests>=2.25.0",
|
|
14
|
+
"packaging>=21.3",
|
|
15
|
+
]
|
|
16
|
+
requires-python = ">=3.7"
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
import os
|
|
7
|
+
import queue
|
|
8
|
+
import hashlib
|
|
9
|
+
from typing import Dict, Any, Callable, Set, Optional, List
|
|
10
|
+
import requests
|
|
11
|
+
from requests.adapters import HTTPAdapter
|
|
12
|
+
from urllib3.util.retry import Retry
|
|
13
|
+
|
|
14
|
+
from .models import ToggleMeshOptions, FeatureFlagDto, SegmentDto, RuleDto
|
|
15
|
+
from .rules import RuleEngine, CachedFlag, CachedSegment, evaluate_rollout
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("togglemesh")
|
|
18
|
+
|
|
19
|
+
def to_snake_case(obj):
|
|
20
|
+
if isinstance(obj, list):
|
|
21
|
+
return [to_snake_case(i) for i in obj]
|
|
22
|
+
elif isinstance(obj, dict):
|
|
23
|
+
return { re.sub(r'(?<!^)(?=[A-Z])', '_', k).lower(): to_snake_case(v) for k, v in obj.items() }
|
|
24
|
+
return obj
|
|
25
|
+
|
|
26
|
+
class FlagMetrics:
|
|
27
|
+
def __init__(self):
|
|
28
|
+
self.true_count = 0
|
|
29
|
+
self.false_count = 0
|
|
30
|
+
|
|
31
|
+
class ToggleMeshClient:
|
|
32
|
+
def __init__(self, options: ToggleMeshOptions):
|
|
33
|
+
self.options = options
|
|
34
|
+
self.base_url = options.base_url.rstrip("/")
|
|
35
|
+
|
|
36
|
+
self._flags_cache: Dict[str, CachedFlag] = {}
|
|
37
|
+
self._segments_cache: Dict[str, CachedSegment] = {}
|
|
38
|
+
self.listeners: Set[Callable[[], None]] = set()
|
|
39
|
+
|
|
40
|
+
self.rule_engine = RuleEngine(segment_provider=self)
|
|
41
|
+
|
|
42
|
+
self._polling_thread = None
|
|
43
|
+
self._metrics_thread = None
|
|
44
|
+
self._events_thread = None
|
|
45
|
+
|
|
46
|
+
self._stop_event = threading.Event()
|
|
47
|
+
self._lock = threading.Lock()
|
|
48
|
+
|
|
49
|
+
self._metrics_buffer: Dict[str, FlagMetrics] = {}
|
|
50
|
+
self._metrics_lock = threading.Lock()
|
|
51
|
+
self._events_queue = queue.Queue(maxsize=10000)
|
|
52
|
+
|
|
53
|
+
self._session = self._create_session()
|
|
54
|
+
self._fallback_path = self._resolve_fallback_path()
|
|
55
|
+
|
|
56
|
+
if self.options.use_fallback_file:
|
|
57
|
+
self._load_fallback()
|
|
58
|
+
|
|
59
|
+
self._sync_state()
|
|
60
|
+
self._start_threads()
|
|
61
|
+
|
|
62
|
+
def _create_session(self) -> requests.Session:
|
|
63
|
+
session = requests.Session()
|
|
64
|
+
session.verify = not self.options.disable_ssl_verification
|
|
65
|
+
retry = Retry(
|
|
66
|
+
total=3,
|
|
67
|
+
read=False,
|
|
68
|
+
backoff_factor=1,
|
|
69
|
+
status_forcelist=[500, 502, 503, 504],
|
|
70
|
+
allowed_methods=["GET", "POST"]
|
|
71
|
+
)
|
|
72
|
+
adapter = HTTPAdapter(max_retries=retry)
|
|
73
|
+
session.mount("http://", adapter)
|
|
74
|
+
session.mount("https://", adapter)
|
|
75
|
+
return session
|
|
76
|
+
|
|
77
|
+
def _resolve_fallback_path(self) -> Optional[str]:
|
|
78
|
+
if not self.options.use_fallback_file:
|
|
79
|
+
return None
|
|
80
|
+
if self.options.fallback_file_path:
|
|
81
|
+
return self.options.fallback_file_path
|
|
82
|
+
|
|
83
|
+
safe_key = hashlib.sha256(self.options.client_key.encode('utf-8')).hexdigest()[:12]
|
|
84
|
+
base_dir = os.path.join(os.getcwd(), ".togglemesh")
|
|
85
|
+
return os.path.join(base_dir, f"{safe_key}.json")
|
|
86
|
+
|
|
87
|
+
def get_segment_rules(self, segment_id: str):
|
|
88
|
+
segment = self._segments_cache.get(segment_id)
|
|
89
|
+
return segment.groups if segment else None
|
|
90
|
+
|
|
91
|
+
def is_enabled(self, flag_key: str, default_value: bool = False, *, identity: str = None, context: Dict[str, str] = None) -> bool:
|
|
92
|
+
with self._lock:
|
|
93
|
+
flag = self._flags_cache.get(flag_key)
|
|
94
|
+
context = context or {}
|
|
95
|
+
|
|
96
|
+
if not flag:
|
|
97
|
+
return default_value
|
|
98
|
+
|
|
99
|
+
active_rollout_percentage = flag.rollout_percentage
|
|
100
|
+
|
|
101
|
+
if flag.parsed_contextual_rollouts and flag.original_dto.context_partition_keys:
|
|
102
|
+
parts = []
|
|
103
|
+
for key in flag.original_dto.context_partition_keys:
|
|
104
|
+
parts.append(str(context.get(key, "null")))
|
|
105
|
+
slice_key = "|".join(parts)
|
|
106
|
+
if slice_key in flag.parsed_contextual_rollouts:
|
|
107
|
+
active_rollout_percentage = flag.parsed_contextual_rollouts[slice_key]
|
|
108
|
+
|
|
109
|
+
if not flag.is_enabled or not self.rule_engine.evaluate(flag.groups, context):
|
|
110
|
+
result = False
|
|
111
|
+
else:
|
|
112
|
+
result = evaluate_rollout(active_rollout_percentage, flag_key, identity)
|
|
113
|
+
|
|
114
|
+
self._update_metrics(flag_key, result)
|
|
115
|
+
|
|
116
|
+
if identity and flag.is_experiment_active:
|
|
117
|
+
self._queue_event(
|
|
118
|
+
evt_type=0,
|
|
119
|
+
identity=identity,
|
|
120
|
+
flag_key=flag_key,
|
|
121
|
+
result=result,
|
|
122
|
+
properties=context
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return result
|
|
126
|
+
|
|
127
|
+
def track(self, event_name: str, properties: Any = None, value: float = None, *, identity: str = None):
|
|
128
|
+
if not identity or not event_name:
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
self._queue_event(
|
|
132
|
+
evt_type=1,
|
|
133
|
+
identity=identity,
|
|
134
|
+
event_name=event_name,
|
|
135
|
+
properties=properties,
|
|
136
|
+
value=value
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def subscribe(self, listener: Callable[[], None]) -> Callable[[], None]:
|
|
140
|
+
with self._lock:
|
|
141
|
+
self.listeners.add(listener)
|
|
142
|
+
def unsubscribe():
|
|
143
|
+
with self._lock:
|
|
144
|
+
self.listeners.discard(listener)
|
|
145
|
+
return unsubscribe
|
|
146
|
+
|
|
147
|
+
def _notify_listeners(self):
|
|
148
|
+
with self._lock:
|
|
149
|
+
callbacks = list(self.listeners)
|
|
150
|
+
for cb in callbacks:
|
|
151
|
+
try:
|
|
152
|
+
cb()
|
|
153
|
+
except Exception as e:
|
|
154
|
+
logger.error(f"[ToggleMesh] Listener callback error: {e}")
|
|
155
|
+
|
|
156
|
+
def _update_metrics(self, flag_key: str, result: bool):
|
|
157
|
+
if not self.options.is_metrics_enabled: return
|
|
158
|
+
with self._metrics_lock:
|
|
159
|
+
if flag_key not in self._metrics_buffer:
|
|
160
|
+
self._metrics_buffer[flag_key] = FlagMetrics()
|
|
161
|
+
if result:
|
|
162
|
+
self._metrics_buffer[flag_key].true_count += 1
|
|
163
|
+
else:
|
|
164
|
+
self._metrics_buffer[flag_key].false_count += 1
|
|
165
|
+
|
|
166
|
+
def _queue_event(self, evt_type: int, identity: str, flag_key: str = None,
|
|
167
|
+
result: bool = False, event_name: str = None,
|
|
168
|
+
properties: Any = None, value: float = None):
|
|
169
|
+
if not self.options.is_metrics_enabled: return
|
|
170
|
+
try:
|
|
171
|
+
evt = {
|
|
172
|
+
"Type": evt_type,
|
|
173
|
+
"Timestamp": int(time.time() * 1000),
|
|
174
|
+
"Identity": identity,
|
|
175
|
+
"Properties": properties
|
|
176
|
+
}
|
|
177
|
+
if flag_key: evt["FlagKey"] = flag_key
|
|
178
|
+
if result is not None: evt["Result"] = result
|
|
179
|
+
if event_name: evt["EventName"] = event_name
|
|
180
|
+
if value is not None: evt["Value"] = value
|
|
181
|
+
|
|
182
|
+
self._events_queue.put_nowait(evt)
|
|
183
|
+
except queue.Full:
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
def _sync_state(self) -> None:
|
|
187
|
+
url = f"{self.base_url}/api/v1/sdk/flags"
|
|
188
|
+
headers = {"x-api-key": self.options.client_key}
|
|
189
|
+
try:
|
|
190
|
+
response = self._session.get(url, headers=headers, timeout=10)
|
|
191
|
+
if response.status_code != 200:
|
|
192
|
+
logger.warning(f"[ToggleMesh] Failed to sync state: {response.status_code}")
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
data = to_snake_case(response.json())
|
|
196
|
+
|
|
197
|
+
with self._lock:
|
|
198
|
+
self._flags_cache.clear()
|
|
199
|
+
self._segments_cache.clear()
|
|
200
|
+
|
|
201
|
+
for f_data in data.get("flags", []):
|
|
202
|
+
rules = [RuleDto.from_dict(r) for r in f_data.get("rules", [])]
|
|
203
|
+
f_data["rules"] = rules
|
|
204
|
+
dto = FeatureFlagDto.from_dict(f_data)
|
|
205
|
+
self._cache_flag(dto)
|
|
206
|
+
|
|
207
|
+
for s_data in data.get("segments", []):
|
|
208
|
+
rules = [RuleDto.from_dict(r) for r in s_data.get("rules", [])]
|
|
209
|
+
s_data["rules"] = rules
|
|
210
|
+
dto = SegmentDto.from_dict(s_data)
|
|
211
|
+
self._cache_segment(dto)
|
|
212
|
+
|
|
213
|
+
self._notify_listeners()
|
|
214
|
+
self._save_fallback(data)
|
|
215
|
+
except Exception as e:
|
|
216
|
+
logger.error(f"[ToggleMesh] Error syncing state: {e}")
|
|
217
|
+
|
|
218
|
+
def _cache_flag(self, dto: FeatureFlagDto):
|
|
219
|
+
groups = self.rule_engine.compile_rules(dto.rules)
|
|
220
|
+
self._flags_cache[dto.key] = CachedFlag(dto, groups)
|
|
221
|
+
|
|
222
|
+
def _cache_segment(self, dto: SegmentDto):
|
|
223
|
+
groups = self.rule_engine.compile_rules(dto.rules)
|
|
224
|
+
self._segments_cache[dto.id] = CachedSegment(dto, groups)
|
|
225
|
+
|
|
226
|
+
def _load_fallback(self):
|
|
227
|
+
if not self._fallback_path or not os.path.exists(self._fallback_path):
|
|
228
|
+
return
|
|
229
|
+
try:
|
|
230
|
+
with open(self._fallback_path, 'r', encoding='utf-8') as f:
|
|
231
|
+
data = to_snake_case(json.load(f))
|
|
232
|
+
with self._lock:
|
|
233
|
+
for f_data in data.get("flags", []):
|
|
234
|
+
rules = [RuleDto.from_dict(r) for r in f_data.get("rules", [])]
|
|
235
|
+
f_data["rules"] = rules
|
|
236
|
+
self._cache_flag(FeatureFlagDto.from_dict(f_data))
|
|
237
|
+
for s_data in data.get("segments", []):
|
|
238
|
+
rules = [RuleDto.from_dict(r) for r in s_data.get("rules", [])]
|
|
239
|
+
s_data["rules"] = rules
|
|
240
|
+
self._cache_segment(SegmentDto.from_dict(s_data))
|
|
241
|
+
logger.info("[ToggleMesh] Loaded fallback state.")
|
|
242
|
+
except Exception as e:
|
|
243
|
+
logger.error(f"[ToggleMesh] Failed to load fallback: {e}")
|
|
244
|
+
|
|
245
|
+
def _save_fallback(self, data: Any):
|
|
246
|
+
if not self._fallback_path: return
|
|
247
|
+
try:
|
|
248
|
+
os.makedirs(os.path.dirname(self._fallback_path), exist_ok=True)
|
|
249
|
+
with open(self._fallback_path + '.tmp', 'w', encoding='utf-8') as f:
|
|
250
|
+
json.dump(data, f)
|
|
251
|
+
os.replace(self._fallback_path + '.tmp', self._fallback_path)
|
|
252
|
+
except Exception as e:
|
|
253
|
+
logger.error(f"[ToggleMesh] Failed to save fallback: {e}")
|
|
254
|
+
|
|
255
|
+
def _start_threads(self) -> None:
|
|
256
|
+
self._stop_event.clear()
|
|
257
|
+
self._polling_thread = threading.Thread(target=self._sse_loop, daemon=True)
|
|
258
|
+
self._polling_thread.start()
|
|
259
|
+
|
|
260
|
+
if self.options.is_metrics_enabled:
|
|
261
|
+
self._metrics_thread = threading.Thread(target=self._metrics_flusher, daemon=True)
|
|
262
|
+
self._metrics_thread.start()
|
|
263
|
+
|
|
264
|
+
self._events_thread = threading.Thread(target=self._events_flusher, daemon=True)
|
|
265
|
+
self._events_thread.start()
|
|
266
|
+
|
|
267
|
+
def _sse_loop(self) -> None:
|
|
268
|
+
url = f"{self.base_url}/api/v1/stream"
|
|
269
|
+
headers = {"x-api-key": self.options.client_key, "Accept": "text/event-stream"}
|
|
270
|
+
backoff = 1
|
|
271
|
+
|
|
272
|
+
while not self._stop_event.is_set():
|
|
273
|
+
try:
|
|
274
|
+
with self._session.get(url, headers=headers, stream=True, timeout=60) as response:
|
|
275
|
+
if response.status_code == 401:
|
|
276
|
+
logger.critical("[ToggleMesh] Invalid API Key. Background sync loop stopped permanently.")
|
|
277
|
+
self.stop()
|
|
278
|
+
break
|
|
279
|
+
|
|
280
|
+
if response.status_code != 200:
|
|
281
|
+
raise Exception(f"Bad status code {response.status_code}")
|
|
282
|
+
|
|
283
|
+
backoff = 1
|
|
284
|
+
for line in response.iter_lines(decode_unicode=True):
|
|
285
|
+
if self._stop_event.is_set():
|
|
286
|
+
break
|
|
287
|
+
if line and line.startswith("data: "):
|
|
288
|
+
self._handle_sse_event(line[6:])
|
|
289
|
+
except Exception as e:
|
|
290
|
+
logger.debug(f"[ToggleMesh] SSE connection error: {e}. Reconnecting in {backoff}s...")
|
|
291
|
+
self._stop_event.wait(backoff)
|
|
292
|
+
backoff = min(backoff * 2, 30)
|
|
293
|
+
self._sync_state()
|
|
294
|
+
|
|
295
|
+
def _handle_sse_event(self, data: str):
|
|
296
|
+
try:
|
|
297
|
+
doc = json.loads(data)
|
|
298
|
+
event_name = doc.get("EventName")
|
|
299
|
+
if event_name == "FlagUpdated" and "Payload" in doc:
|
|
300
|
+
payload = doc["Payload"]
|
|
301
|
+
if isinstance(payload, str): payload = json.loads(payload)
|
|
302
|
+
|
|
303
|
+
payload = to_snake_case(payload)
|
|
304
|
+
|
|
305
|
+
payload["rules"] = [RuleDto.from_dict(r) for r in payload.get("rules", [])]
|
|
306
|
+
dto = FeatureFlagDto.from_dict(payload)
|
|
307
|
+
with self._lock:
|
|
308
|
+
self._cache_flag(dto)
|
|
309
|
+
self._notify_listeners()
|
|
310
|
+
elif event_name == "StateReloadRequired":
|
|
311
|
+
self._sync_state()
|
|
312
|
+
except Exception as e:
|
|
313
|
+
logger.error(f"[ToggleMesh] Failed to parse SSE event: {e}")
|
|
314
|
+
|
|
315
|
+
def _metrics_flusher(self):
|
|
316
|
+
url = f"{self.base_url}/api/v1/sdk/metrics"
|
|
317
|
+
headers = {"x-api-key": self.options.client_key, "Content-Type": "application/json"}
|
|
318
|
+
|
|
319
|
+
while not self._stop_event.is_set():
|
|
320
|
+
if self._stop_event.wait(10):
|
|
321
|
+
break
|
|
322
|
+
|
|
323
|
+
payload = []
|
|
324
|
+
with self._metrics_lock:
|
|
325
|
+
for key, m in list(self._metrics_buffer.items()):
|
|
326
|
+
if m.true_count > 0 or m.false_count > 0:
|
|
327
|
+
payload.append({"Key": key, "TrueCount": m.true_count, "FalseCount": m.false_count})
|
|
328
|
+
m.true_count = 0
|
|
329
|
+
m.false_count = 0
|
|
330
|
+
|
|
331
|
+
if not payload: continue
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
self._session.post(url, json=payload, headers=headers, timeout=5)
|
|
335
|
+
except Exception as e:
|
|
336
|
+
logger.debug(f"[ToggleMesh] Failed to flush metrics: {e}")
|
|
337
|
+
with self._metrics_lock:
|
|
338
|
+
for item in payload:
|
|
339
|
+
if item["Key"] not in self._metrics_buffer:
|
|
340
|
+
self._metrics_buffer[item["Key"]] = FlagMetrics()
|
|
341
|
+
self._metrics_buffer[item["Key"]].true_count += item["TrueCount"]
|
|
342
|
+
self._metrics_buffer[item["Key"]].false_count += item["FalseCount"]
|
|
343
|
+
|
|
344
|
+
def _events_flusher(self):
|
|
345
|
+
url = f"{self.base_url}/api/v1/sdk/events"
|
|
346
|
+
headers = {"x-api-key": self.options.client_key, "Content-Type": "application/json"}
|
|
347
|
+
|
|
348
|
+
while not self._stop_event.is_set():
|
|
349
|
+
if self._stop_event.wait(10):
|
|
350
|
+
break
|
|
351
|
+
|
|
352
|
+
batch = []
|
|
353
|
+
while not self._events_queue.empty() and len(batch) < 1000:
|
|
354
|
+
try:
|
|
355
|
+
batch.append(self._events_queue.get_nowait())
|
|
356
|
+
except queue.Empty:
|
|
357
|
+
break
|
|
358
|
+
|
|
359
|
+
if not batch: continue
|
|
360
|
+
|
|
361
|
+
try:
|
|
362
|
+
self._session.post(url, json={"Events": batch}, headers=headers, timeout=5)
|
|
363
|
+
except Exception as e:
|
|
364
|
+
logger.debug(f"[ToggleMesh] Failed to flush events: {e}")
|
|
365
|
+
for evt in batch:
|
|
366
|
+
try:
|
|
367
|
+
self._events_queue.put_nowait(evt)
|
|
368
|
+
except queue.Empty:
|
|
369
|
+
pass
|
|
370
|
+
except queue.Full:
|
|
371
|
+
break
|
|
372
|
+
|
|
373
|
+
def stop(self):
|
|
374
|
+
self._stop_event.set()
|
|
375
|
+
if self._polling_thread: self._polling_thread.join(timeout=2.0)
|
|
376
|
+
if self._metrics_thread: self._metrics_thread.join(timeout=2.0)
|
|
377
|
+
if self._events_thread: self._events_thread.join(timeout=2.0)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import List, Dict, Any, Optional
|
|
3
|
+
import inspect
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class ToggleMeshOptions:
|
|
7
|
+
base_url: str
|
|
8
|
+
client_key: str
|
|
9
|
+
refresh_interval: int = 60
|
|
10
|
+
is_metrics_enabled: bool = True
|
|
11
|
+
use_fallback_file: bool = False
|
|
12
|
+
fallback_file_path: Optional[str] = None
|
|
13
|
+
disable_ssl_verification: bool = False
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class FlagState:
|
|
17
|
+
key: str
|
|
18
|
+
is_enabled: bool
|
|
19
|
+
|
|
20
|
+
def _from_dict(cls, data):
|
|
21
|
+
valid_keys = inspect.signature(cls).parameters.keys()
|
|
22
|
+
filtered = {k: v for k, v in data.items() if k in valid_keys}
|
|
23
|
+
return cls(**filtered)
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class RuleDto:
|
|
27
|
+
group_id: int
|
|
28
|
+
attribute: str
|
|
29
|
+
operator: str
|
|
30
|
+
value: str
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_dict(cls, data):
|
|
34
|
+
return _from_dict(cls, data)
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class FeatureFlagDto:
|
|
38
|
+
key: str
|
|
39
|
+
is_enabled: bool
|
|
40
|
+
is_experiment_active: bool
|
|
41
|
+
rollout_percentage: Optional[int]
|
|
42
|
+
context_partition_keys: Optional[List[str]]
|
|
43
|
+
contextual_rollouts: Optional[Dict[str, int]]
|
|
44
|
+
rules: List[RuleDto]
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_dict(cls, data):
|
|
48
|
+
return _from_dict(cls, data)
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class SegmentDto:
|
|
52
|
+
id: str
|
|
53
|
+
name: str
|
|
54
|
+
rules: List[RuleDto]
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_dict(cls, data):
|
|
58
|
+
return _from_dict(cls, data)
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class SdkGetFlagsResponse:
|
|
62
|
+
flags: List[FeatureFlagDto]
|
|
63
|
+
segments: List[SegmentDto]
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Any, Optional, Set
|
|
4
|
+
from packaging.version import Version, InvalidVersion
|
|
5
|
+
|
|
6
|
+
class RuleOperator:
|
|
7
|
+
@property
|
|
8
|
+
def name(self) -> str:
|
|
9
|
+
raise NotImplementedError()
|
|
10
|
+
|
|
11
|
+
def compile(self, rule_value: str) -> Any:
|
|
12
|
+
return rule_value
|
|
13
|
+
|
|
14
|
+
def evaluate(self, user_value: str, compiled_rule_value: Any) -> bool:
|
|
15
|
+
raise NotImplementedError()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EqualsOperator(RuleOperator):
|
|
19
|
+
@property
|
|
20
|
+
def name(self) -> str: return "Equals"
|
|
21
|
+
def evaluate(self, user_value: str, compiled_rule_value: Any) -> bool:
|
|
22
|
+
return user_value == compiled_rule_value
|
|
23
|
+
|
|
24
|
+
class NotEqualsOperator(RuleOperator):
|
|
25
|
+
@property
|
|
26
|
+
def name(self) -> str: return "NotEquals"
|
|
27
|
+
def evaluate(self, user_value: str, compiled_rule_value: Any) -> bool:
|
|
28
|
+
return user_value != compiled_rule_value
|
|
29
|
+
|
|
30
|
+
class ContainsOperator(RuleOperator):
|
|
31
|
+
@property
|
|
32
|
+
def name(self) -> str: return "Contains"
|
|
33
|
+
def evaluate(self, user_value: str, compiled_rule_value: Any) -> bool:
|
|
34
|
+
return str(compiled_rule_value) in user_value
|
|
35
|
+
|
|
36
|
+
class StartsWithOperator(RuleOperator):
|
|
37
|
+
@property
|
|
38
|
+
def name(self) -> str: return "StartsWith"
|
|
39
|
+
def evaluate(self, user_value: str, compiled_rule_value: Any) -> bool:
|
|
40
|
+
return user_value.startswith(str(compiled_rule_value))
|
|
41
|
+
|
|
42
|
+
class EndsWithOperator(RuleOperator):
|
|
43
|
+
@property
|
|
44
|
+
def name(self) -> str: return "EndsWith"
|
|
45
|
+
def evaluate(self, user_value: str, compiled_rule_value: Any) -> bool:
|
|
46
|
+
return user_value.endswith(str(compiled_rule_value))
|
|
47
|
+
|
|
48
|
+
class InListOperator(RuleOperator):
|
|
49
|
+
@property
|
|
50
|
+
def name(self) -> str: return "InList"
|
|
51
|
+
def compile(self, rule_value: str) -> Set[str]:
|
|
52
|
+
return {x.strip() for x in rule_value.split(",")} if rule_value else set()
|
|
53
|
+
def evaluate(self, user_value: str, compiled_rule_value: Any) -> bool:
|
|
54
|
+
return user_value in compiled_rule_value
|
|
55
|
+
|
|
56
|
+
class RegexOperator(RuleOperator):
|
|
57
|
+
@property
|
|
58
|
+
def name(self) -> str: return "Regex"
|
|
59
|
+
def compile(self, rule_value: str) -> Any:
|
|
60
|
+
try:
|
|
61
|
+
return re.compile(rule_value)
|
|
62
|
+
except Exception:
|
|
63
|
+
return None
|
|
64
|
+
def evaluate(self, user_value: str, compiled_rule_value: Any) -> bool:
|
|
65
|
+
if compiled_rule_value is None:
|
|
66
|
+
return False
|
|
67
|
+
return compiled_rule_value.match(user_value) is not None
|
|
68
|
+
|
|
69
|
+
class NumberOperatorBase(RuleOperator):
|
|
70
|
+
def compile(self, rule_value: str) -> Any:
|
|
71
|
+
try:
|
|
72
|
+
return float(rule_value)
|
|
73
|
+
except ValueError:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
class GreaterThanOperator(NumberOperatorBase):
|
|
77
|
+
@property
|
|
78
|
+
def name(self) -> str: return "GreaterThan"
|
|
79
|
+
def evaluate(self, user_value: str, compiled_rule_value: Any) -> bool:
|
|
80
|
+
if compiled_rule_value is None: return False
|
|
81
|
+
try:
|
|
82
|
+
return float(user_value) > compiled_rule_value
|
|
83
|
+
except ValueError:
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
class GreaterThanOrEqualOperator(NumberOperatorBase):
|
|
87
|
+
@property
|
|
88
|
+
def name(self) -> str: return "GreaterThanOrEqual"
|
|
89
|
+
def evaluate(self, user_value: str, compiled_rule_value: Any) -> bool:
|
|
90
|
+
if compiled_rule_value is None: return False
|
|
91
|
+
try:
|
|
92
|
+
return float(user_value) >= compiled_rule_value
|
|
93
|
+
except ValueError:
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
class LessThanOperator(NumberOperatorBase):
|
|
97
|
+
@property
|
|
98
|
+
def name(self) -> str: return "LessThan"
|
|
99
|
+
def evaluate(self, user_value: str, compiled_rule_value: Any) -> bool:
|
|
100
|
+
if compiled_rule_value is None: return False
|
|
101
|
+
try:
|
|
102
|
+
return float(user_value) < compiled_rule_value
|
|
103
|
+
except ValueError:
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
class LessThanOrEqualOperator(NumberOperatorBase):
|
|
107
|
+
@property
|
|
108
|
+
def name(self) -> str: return "LessThanOrEqual"
|
|
109
|
+
def evaluate(self, user_value: str, compiled_rule_value: Any) -> bool:
|
|
110
|
+
if compiled_rule_value is None: return False
|
|
111
|
+
try:
|
|
112
|
+
return float(user_value) <= compiled_rule_value
|
|
113
|
+
except ValueError:
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
class DateOperatorBase(RuleOperator):
|
|
117
|
+
def compile(self, rule_value: str) -> Any:
|
|
118
|
+
try:
|
|
119
|
+
return datetime.fromisoformat(rule_value.replace('Z', '+00:00'))
|
|
120
|
+
except ValueError:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
class DateAfterOperator(DateOperatorBase):
|
|
124
|
+
@property
|
|
125
|
+
def name(self) -> str: return "DateAfter"
|
|
126
|
+
def evaluate(self, user_value: str, compiled_rule_value: Any) -> bool:
|
|
127
|
+
if compiled_rule_value is None: return False
|
|
128
|
+
try:
|
|
129
|
+
uv = datetime.fromisoformat(user_value.replace('Z', '+00:00'))
|
|
130
|
+
return uv > compiled_rule_value
|
|
131
|
+
except ValueError:
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
class DateBeforeOperator(DateOperatorBase):
|
|
135
|
+
@property
|
|
136
|
+
def name(self) -> str: return "DateBefore"
|
|
137
|
+
def evaluate(self, user_value: str, compiled_rule_value: Any) -> bool:
|
|
138
|
+
if compiled_rule_value is None: return False
|
|
139
|
+
try:
|
|
140
|
+
uv = datetime.fromisoformat(user_value.replace('Z', '+00:00'))
|
|
141
|
+
return uv < compiled_rule_value
|
|
142
|
+
except ValueError:
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
class SemVerOperatorBase(RuleOperator):
|
|
146
|
+
def compile(self, rule_value: str) -> Any:
|
|
147
|
+
try:
|
|
148
|
+
return Version(rule_value)
|
|
149
|
+
except InvalidVersion:
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
class SemVerEqualOperator(SemVerOperatorBase):
|
|
153
|
+
@property
|
|
154
|
+
def name(self) -> str: return "SemVerEqual"
|
|
155
|
+
def evaluate(self, user_value: str, compiled_rule_value: Any) -> bool:
|
|
156
|
+
if compiled_rule_value is None: return False
|
|
157
|
+
try:
|
|
158
|
+
return Version(user_value) == compiled_rule_value
|
|
159
|
+
except InvalidVersion:
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
class SemVerGreaterThanOperator(SemVerOperatorBase):
|
|
163
|
+
@property
|
|
164
|
+
def name(self) -> str: return "SemVerGreaterThan"
|
|
165
|
+
def evaluate(self, user_value: str, compiled_rule_value: Any) -> bool:
|
|
166
|
+
if compiled_rule_value is None: return False
|
|
167
|
+
try:
|
|
168
|
+
return Version(user_value) > compiled_rule_value
|
|
169
|
+
except InvalidVersion:
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
class SemVerGreaterThanOrEqualOperator(SemVerOperatorBase):
|
|
173
|
+
@property
|
|
174
|
+
def name(self) -> str: return "SemVerGreaterThanOrEqual"
|
|
175
|
+
def evaluate(self, user_value: str, compiled_rule_value: Any) -> bool:
|
|
176
|
+
if compiled_rule_value is None: return False
|
|
177
|
+
try:
|
|
178
|
+
return Version(user_value) >= compiled_rule_value
|
|
179
|
+
except InvalidVersion:
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
class SemVerLessThanOperator(SemVerOperatorBase):
|
|
183
|
+
@property
|
|
184
|
+
def name(self) -> str: return "SemVerLessThan"
|
|
185
|
+
def evaluate(self, user_value: str, compiled_rule_value: Any) -> bool:
|
|
186
|
+
if compiled_rule_value is None: return False
|
|
187
|
+
try:
|
|
188
|
+
return Version(user_value) < compiled_rule_value
|
|
189
|
+
except InvalidVersion:
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
class SemVerLessThanOrEqualOperator(SemVerOperatorBase):
|
|
193
|
+
@property
|
|
194
|
+
def name(self) -> str: return "SemVerLessThanOrEqual"
|
|
195
|
+
def evaluate(self, user_value: str, compiled_rule_value: Any) -> bool:
|
|
196
|
+
if compiled_rule_value is None: return False
|
|
197
|
+
try:
|
|
198
|
+
return Version(user_value) <= compiled_rule_value
|
|
199
|
+
except InvalidVersion:
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class FalseOperator(RuleOperator):
|
|
204
|
+
@property
|
|
205
|
+
def name(self) -> str: return "False"
|
|
206
|
+
def compile(self, rule_value: str) -> Any: return None
|
|
207
|
+
def evaluate(self, user_value: str, compiled_rule_value: Any) -> bool: return False
|
|
208
|
+
|
|
209
|
+
class InSegmentOperator(RuleOperator):
|
|
210
|
+
@property
|
|
211
|
+
def name(self) -> str: return "InSegment"
|
|
212
|
+
def compile(self, rule_value: str) -> Any: return rule_value
|
|
213
|
+
def evaluate(self, user_value: str, compiled_rule_value: Any) -> bool: return False
|
|
214
|
+
|
|
215
|
+
ALL_OPERATORS = [
|
|
216
|
+
EqualsOperator(),
|
|
217
|
+
NotEqualsOperator(),
|
|
218
|
+
ContainsOperator(),
|
|
219
|
+
StartsWithOperator(),
|
|
220
|
+
EndsWithOperator(),
|
|
221
|
+
InListOperator(),
|
|
222
|
+
RegexOperator(),
|
|
223
|
+
GreaterThanOperator(),
|
|
224
|
+
GreaterThanOrEqualOperator(),
|
|
225
|
+
LessThanOperator(),
|
|
226
|
+
LessThanOrEqualOperator(),
|
|
227
|
+
DateAfterOperator(),
|
|
228
|
+
DateBeforeOperator(),
|
|
229
|
+
SemVerEqualOperator(),
|
|
230
|
+
SemVerGreaterThanOperator(),
|
|
231
|
+
SemVerGreaterThanOrEqualOperator(),
|
|
232
|
+
SemVerLessThanOperator(),
|
|
233
|
+
SemVerLessThanOrEqualOperator()
|
|
234
|
+
]
|
|
235
|
+
|
|
236
|
+
OPERATOR_MAP = {op.name.lower(): op for op in ALL_OPERATORS}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import List, Dict, Any, Optional
|
|
4
|
+
|
|
5
|
+
from .models import FeatureFlagDto, RuleDto, SegmentDto
|
|
6
|
+
from .operators import OPERATOR_MAP, RuleOperator, FalseOperator, InSegmentOperator
|
|
7
|
+
|
|
8
|
+
def calculate_fnv1a_hash(text: str) -> int:
|
|
9
|
+
offset_basis = 2166136261
|
|
10
|
+
prime = 16777619
|
|
11
|
+
hash_val = offset_basis
|
|
12
|
+
|
|
13
|
+
encoded = text.encode("utf-8")
|
|
14
|
+
for byte in encoded:
|
|
15
|
+
hash_val ^= byte
|
|
16
|
+
hash_val = (hash_val * prime) & 0xFFFFFFFF
|
|
17
|
+
|
|
18
|
+
return hash_val
|
|
19
|
+
|
|
20
|
+
def evaluate_rollout(rollout_percentage: Optional[int], flag_key: str, identity: str) -> bool:
|
|
21
|
+
if rollout_percentage is None:
|
|
22
|
+
return True
|
|
23
|
+
if rollout_percentage <= 0:
|
|
24
|
+
return False
|
|
25
|
+
if rollout_percentage >= 100:
|
|
26
|
+
return True
|
|
27
|
+
if not identity:
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
hash_val = calculate_fnv1a_hash(flag_key + identity)
|
|
31
|
+
bucket = hash_val % 100
|
|
32
|
+
return bucket < rollout_percentage
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class CompiledRule:
|
|
37
|
+
attribute: str
|
|
38
|
+
operator: RuleOperator
|
|
39
|
+
compiled_value: Any
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class CompiledRuleGroup:
|
|
43
|
+
rules: List[CompiledRule]
|
|
44
|
+
|
|
45
|
+
class CachedFlag:
|
|
46
|
+
def __init__(self, dto: FeatureFlagDto, groups: List[CompiledRuleGroup]):
|
|
47
|
+
self.key = dto.key
|
|
48
|
+
self.is_enabled = dto.is_enabled
|
|
49
|
+
self.rollout_percentage = dto.rollout_percentage
|
|
50
|
+
self.contextual_rollouts = dto.contextual_rollouts
|
|
51
|
+
self.is_experiment_active = dto.is_experiment_active
|
|
52
|
+
self.groups = groups
|
|
53
|
+
self.original_dto = dto
|
|
54
|
+
|
|
55
|
+
self.parsed_contextual_rollouts = {}
|
|
56
|
+
if dto.contextual_rollouts and dto.context_partition_keys:
|
|
57
|
+
for k, v in dto.contextual_rollouts.items():
|
|
58
|
+
try:
|
|
59
|
+
d = json.loads(k)
|
|
60
|
+
parts = []
|
|
61
|
+
for key in dto.context_partition_keys:
|
|
62
|
+
parts.append(str(d.get(key, "null")))
|
|
63
|
+
slice_key = "|".join(parts)
|
|
64
|
+
self.parsed_contextual_rollouts[slice_key] = v
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
class CachedSegment:
|
|
69
|
+
def __init__(self, dto: SegmentDto, groups: List[CompiledRuleGroup]):
|
|
70
|
+
self.id = dto.id
|
|
71
|
+
self.name = dto.name
|
|
72
|
+
self.groups = groups
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class RuleEngine:
|
|
76
|
+
def __init__(self, segment_provider=None):
|
|
77
|
+
self.segment_provider = segment_provider
|
|
78
|
+
|
|
79
|
+
def compile_rules(self, rules: List[RuleDto]) -> List[CompiledRuleGroup]:
|
|
80
|
+
if not rules:
|
|
81
|
+
return []
|
|
82
|
+
|
|
83
|
+
groups_dict = {}
|
|
84
|
+
for r in rules:
|
|
85
|
+
if r.group_id not in groups_dict:
|
|
86
|
+
groups_dict[r.group_id] = []
|
|
87
|
+
groups_dict[r.group_id].append(r)
|
|
88
|
+
|
|
89
|
+
compiled_groups = []
|
|
90
|
+
for g_id, g_rules in groups_dict.items():
|
|
91
|
+
compiled_rules = []
|
|
92
|
+
for r in g_rules:
|
|
93
|
+
if r.operator.lower() == "insegment":
|
|
94
|
+
op = InSegmentOperator()
|
|
95
|
+
else:
|
|
96
|
+
op = OPERATOR_MAP.get(r.operator.lower(), FalseOperator())
|
|
97
|
+
|
|
98
|
+
compiled_rules.append(CompiledRule(
|
|
99
|
+
attribute=r.attribute,
|
|
100
|
+
operator=op,
|
|
101
|
+
compiled_value=op.compile(r.value)
|
|
102
|
+
))
|
|
103
|
+
compiled_groups.append(CompiledRuleGroup(rules=compiled_rules))
|
|
104
|
+
|
|
105
|
+
return compiled_groups
|
|
106
|
+
|
|
107
|
+
def evaluate(self, groups: List[CompiledRuleGroup], context: Dict[str, str]) -> bool:
|
|
108
|
+
if not groups:
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
for group in groups:
|
|
112
|
+
group_passed = True
|
|
113
|
+
for rule in group.rules:
|
|
114
|
+
if isinstance(rule.operator, InSegmentOperator):
|
|
115
|
+
if self.segment_provider:
|
|
116
|
+
segment_rules = self.segment_provider.get_segment_rules(rule.compiled_value)
|
|
117
|
+
if segment_rules is not None and self.evaluate(segment_rules, context):
|
|
118
|
+
continue
|
|
119
|
+
else:
|
|
120
|
+
user_value = context.get(rule.attribute)
|
|
121
|
+
if user_value is not None:
|
|
122
|
+
if rule.operator.evaluate(str(user_value), rule.compiled_value):
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
group_passed = False
|
|
126
|
+
break
|
|
127
|
+
|
|
128
|
+
if group_passed:
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
return False
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
togglemesh/__init__.py
|
|
4
|
+
togglemesh/client.py
|
|
5
|
+
togglemesh/models.py
|
|
6
|
+
togglemesh/operators.py
|
|
7
|
+
togglemesh/rules.py
|
|
8
|
+
togglemesh.egg-info/PKG-INFO
|
|
9
|
+
togglemesh.egg-info/SOURCES.txt
|
|
10
|
+
togglemesh.egg-info/dependency_links.txt
|
|
11
|
+
togglemesh.egg-info/requires.txt
|
|
12
|
+
togglemesh.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
togglemesh
|