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.
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: togglemesh
3
+ Version: 0.2.0
4
+ Summary: ToggleMesh Python SDK
5
+ Author: ToggleMesh Team
6
+ Requires-Python: >=3.7
7
+ Requires-Dist: requests>=2.25.0
8
+ Requires-Dist: packaging>=21.3
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ from .models import ToggleMeshOptions, FlagState
2
+ from .client import ToggleMeshClient
3
+
4
+ __all__ = ["ToggleMeshOptions", "FlagState", "ToggleMeshClient"]
@@ -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,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: togglemesh
3
+ Version: 0.2.0
4
+ Summary: ToggleMesh Python SDK
5
+ Author: ToggleMesh Team
6
+ Requires-Python: >=3.7
7
+ Requires-Dist: requests>=2.25.0
8
+ Requires-Dist: packaging>=21.3
@@ -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,2 @@
1
+ requests>=2.25.0
2
+ packaging>=21.3
@@ -0,0 +1 @@
1
+ togglemesh