flagura-sdk 1.0.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.
- flagura_sdk-1.0.0/PKG-INFO +96 -0
- flagura_sdk-1.0.0/README.md +78 -0
- flagura_sdk-1.0.0/flagura/__init__.py +5 -0
- flagura_sdk-1.0.0/flagura/client.py +234 -0
- flagura_sdk-1.0.0/flagura/openfeature_provider.py +266 -0
- flagura_sdk-1.0.0/flagura_sdk.egg-info/PKG-INFO +96 -0
- flagura_sdk-1.0.0/flagura_sdk.egg-info/SOURCES.txt +10 -0
- flagura_sdk-1.0.0/flagura_sdk.egg-info/dependency_links.txt +1 -0
- flagura_sdk-1.0.0/flagura_sdk.egg-info/requires.txt +7 -0
- flagura_sdk-1.0.0/flagura_sdk.egg-info/top_level.txt +1 -0
- flagura_sdk-1.0.0/pyproject.toml +30 -0
- flagura_sdk-1.0.0/setup.cfg +4 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flagura-sdk
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python SDK and OpenFeature Provider for Flagura
|
|
5
|
+
Author-email: Dhawal Dyavanpalli <dhawalhost@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/dhawalhost/flagura
|
|
8
|
+
Project-URL: Repository, https://github.com/dhawalhost/flagura
|
|
9
|
+
Keywords: feature-flags,feature-toggles,openfeature,flagura,a-b-testing
|
|
10
|
+
Requires-Python: >=3.8
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: requests>=2.28.0
|
|
13
|
+
Requires-Dist: openfeature-sdk>=0.7.0
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
16
|
+
Requires-Dist: build>=1.0.0; extra == "dev"
|
|
17
|
+
Requires-Dist: twine>=4.0.0; extra == "dev"
|
|
18
|
+
|
|
19
|
+
# ⚡ Flagura Python SDK
|
|
20
|
+
|
|
21
|
+
Official Python client for the **Flagura Feature Flag Platform**, supporting:
|
|
22
|
+
- High-performance evaluations
|
|
23
|
+
- **Real-Time SSE Flag Streaming (`<5ms` sync)**
|
|
24
|
+
- **CNCF OpenFeature Provider**
|
|
25
|
+
- FastAPI, Django, Flask, Celery, and AI Agent compatibility
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## 📦 Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install flagura
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## 🚀 Quickstart
|
|
38
|
+
|
|
39
|
+
### 1. Direct Client with Real-Time SSE Streaming
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from flagura import FlaguraClient, EvaluationContext
|
|
43
|
+
|
|
44
|
+
# Initialize client with real-time SSE streaming
|
|
45
|
+
client = FlaguraClient(
|
|
46
|
+
endpoint="https://flagura.dhawalhost.com",
|
|
47
|
+
api_key="your-api-key",
|
|
48
|
+
project_id="proj_default", # optional: project scoping
|
|
49
|
+
default_environment="production",
|
|
50
|
+
enable_streaming=True, # <5ms live flag updates
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Register real-time change listener
|
|
54
|
+
client.on_update(lambda flags: print(f"Flags updated in real-time! Count: {len(flags)}"))
|
|
55
|
+
|
|
56
|
+
# Evaluate flag
|
|
57
|
+
context = EvaluationContext(
|
|
58
|
+
user_id="usr_dhawal_01",
|
|
59
|
+
email="dhawal@flagura.dev",
|
|
60
|
+
tier="enterprise",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if client.is_enabled("ai-smart-search", context):
|
|
64
|
+
variant = client.get_variant("ai-smart-search", context)
|
|
65
|
+
print(f"AI Smart Search is ON! Variant: {variant}")
|
|
66
|
+
|
|
67
|
+
# Cleanup on shutdown
|
|
68
|
+
client.close()
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
### 2. OpenFeature Universal Provider
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from openfeature import api
|
|
77
|
+
from openfeature.evaluation_context import EvaluationContext
|
|
78
|
+
from flagura.openfeature_provider import FlaguraOpenFeatureProvider
|
|
79
|
+
|
|
80
|
+
# Register Flagura as OpenFeature provider
|
|
81
|
+
api.set_provider(FlaguraOpenFeatureProvider(
|
|
82
|
+
endpoint="https://flagura.dhawalhost.com",
|
|
83
|
+
api_key="your-api-key",
|
|
84
|
+
enable_streaming=True,
|
|
85
|
+
))
|
|
86
|
+
of_client = api.get_client()
|
|
87
|
+
|
|
88
|
+
# Evaluate with OpenFeature standard APIs
|
|
89
|
+
ctx = EvaluationContext(targeting_key="usr_dhawal_01", attributes={"email": "dhawal@flagura.dev"})
|
|
90
|
+
is_enabled = of_client.get_boolean_value("ai-smart-search", False, ctx)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## 📄 License
|
|
96
|
+
MIT
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# ⚡ Flagura Python SDK
|
|
2
|
+
|
|
3
|
+
Official Python client for the **Flagura Feature Flag Platform**, supporting:
|
|
4
|
+
- High-performance evaluations
|
|
5
|
+
- **Real-Time SSE Flag Streaming (`<5ms` sync)**
|
|
6
|
+
- **CNCF OpenFeature Provider**
|
|
7
|
+
- FastAPI, Django, Flask, Celery, and AI Agent compatibility
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 📦 Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install flagura
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 🚀 Quickstart
|
|
20
|
+
|
|
21
|
+
### 1. Direct Client with Real-Time SSE Streaming
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from flagura import FlaguraClient, EvaluationContext
|
|
25
|
+
|
|
26
|
+
# Initialize client with real-time SSE streaming
|
|
27
|
+
client = FlaguraClient(
|
|
28
|
+
endpoint="https://flagura.dhawalhost.com",
|
|
29
|
+
api_key="your-api-key",
|
|
30
|
+
project_id="proj_default", # optional: project scoping
|
|
31
|
+
default_environment="production",
|
|
32
|
+
enable_streaming=True, # <5ms live flag updates
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Register real-time change listener
|
|
36
|
+
client.on_update(lambda flags: print(f"Flags updated in real-time! Count: {len(flags)}"))
|
|
37
|
+
|
|
38
|
+
# Evaluate flag
|
|
39
|
+
context = EvaluationContext(
|
|
40
|
+
user_id="usr_dhawal_01",
|
|
41
|
+
email="dhawal@flagura.dev",
|
|
42
|
+
tier="enterprise",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if client.is_enabled("ai-smart-search", context):
|
|
46
|
+
variant = client.get_variant("ai-smart-search", context)
|
|
47
|
+
print(f"AI Smart Search is ON! Variant: {variant}")
|
|
48
|
+
|
|
49
|
+
# Cleanup on shutdown
|
|
50
|
+
client.close()
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
### 2. OpenFeature Universal Provider
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from openfeature import api
|
|
59
|
+
from openfeature.evaluation_context import EvaluationContext
|
|
60
|
+
from flagura.openfeature_provider import FlaguraOpenFeatureProvider
|
|
61
|
+
|
|
62
|
+
# Register Flagura as OpenFeature provider
|
|
63
|
+
api.set_provider(FlaguraOpenFeatureProvider(
|
|
64
|
+
endpoint="https://flagura.dhawalhost.com",
|
|
65
|
+
api_key="your-api-key",
|
|
66
|
+
enable_streaming=True,
|
|
67
|
+
))
|
|
68
|
+
of_client = api.get_client()
|
|
69
|
+
|
|
70
|
+
# Evaluate with OpenFeature standard APIs
|
|
71
|
+
ctx = EvaluationContext(targeting_key="usr_dhawal_01", attributes={"email": "dhawal@flagura.dev"})
|
|
72
|
+
is_enabled = of_client.get_boolean_value("ai-smart-search", False, ctx)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## 📄 License
|
|
78
|
+
MIT
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
3
|
+
import urllib.request
|
|
4
|
+
import urllib.error
|
|
5
|
+
import json
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import datetime
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class EvaluationContext:
|
|
13
|
+
user_id: str
|
|
14
|
+
email: Optional[str] = None
|
|
15
|
+
country: Optional[str] = None
|
|
16
|
+
role: Optional[str] = None
|
|
17
|
+
tier: Optional[str] = None
|
|
18
|
+
environment: str = "production"
|
|
19
|
+
custom: Dict[str, Any] = field(default_factory=dict)
|
|
20
|
+
|
|
21
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
22
|
+
d: Dict[str, Any] = {
|
|
23
|
+
"user_id": self.user_id,
|
|
24
|
+
"environment": self.environment,
|
|
25
|
+
}
|
|
26
|
+
if self.email:
|
|
27
|
+
d["email"] = self.email
|
|
28
|
+
if self.country:
|
|
29
|
+
d["country"] = self.country
|
|
30
|
+
if self.role:
|
|
31
|
+
d["role"] = self.role
|
|
32
|
+
if self.tier:
|
|
33
|
+
d["tier"] = self.tier
|
|
34
|
+
if self.custom:
|
|
35
|
+
d["custom"] = self.custom
|
|
36
|
+
return d
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class EvaluationResult:
|
|
41
|
+
flag_key: str
|
|
42
|
+
enabled: bool
|
|
43
|
+
variant: str = "off"
|
|
44
|
+
value: Any = False
|
|
45
|
+
reason: str = ""
|
|
46
|
+
bucket: Optional[float] = None
|
|
47
|
+
latency_ns: int = 0
|
|
48
|
+
latency_us: float = 0.0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class FlaguraClient:
|
|
52
|
+
"""Official Python Client for Flagura Feature Flag Platform."""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
endpoint: str = "http://localhost:3000",
|
|
57
|
+
api_key: Optional[str] = None,
|
|
58
|
+
project_id: Optional[str] = None,
|
|
59
|
+
default_environment: str = "production",
|
|
60
|
+
timeout: float = 5.0,
|
|
61
|
+
enable_streaming: bool = False,
|
|
62
|
+
):
|
|
63
|
+
self.endpoint = endpoint.rstrip("/")
|
|
64
|
+
self.api_key = api_key
|
|
65
|
+
self.project_id = project_id
|
|
66
|
+
self.default_environment = default_environment
|
|
67
|
+
self.timeout = timeout
|
|
68
|
+
self._local_flags: Dict[str, Any] = {}
|
|
69
|
+
self._listeners: List[Callable[[Dict[str, Any]], None]] = []
|
|
70
|
+
self._stop_event = threading.Event()
|
|
71
|
+
self._stream_thread: Optional[threading.Thread] = None
|
|
72
|
+
|
|
73
|
+
if enable_streaming:
|
|
74
|
+
self._start_streaming()
|
|
75
|
+
|
|
76
|
+
def on_update(self, callback: Callable[[Dict[str, Any]], None]) -> None:
|
|
77
|
+
"""Register a callback invoked when feature flags are updated in real time."""
|
|
78
|
+
self._listeners.append(callback)
|
|
79
|
+
|
|
80
|
+
def _start_streaming(self) -> None:
|
|
81
|
+
self._stream_thread = threading.Thread(target=self._stream_worker, daemon=True)
|
|
82
|
+
self._stream_thread.start()
|
|
83
|
+
|
|
84
|
+
def _stream_worker(self) -> None:
|
|
85
|
+
url = f"{self.endpoint}/api/v1/flags/stream"
|
|
86
|
+
headers = {"Accept": "text/event-stream"}
|
|
87
|
+
if self.api_key:
|
|
88
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
89
|
+
if self.project_id:
|
|
90
|
+
headers["X-Project-ID"] = self.project_id
|
|
91
|
+
|
|
92
|
+
while not self._stop_event.is_set():
|
|
93
|
+
try:
|
|
94
|
+
req = urllib.request.Request(url, headers=headers)
|
|
95
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
96
|
+
for line_bytes in resp:
|
|
97
|
+
if self._stop_event.is_set():
|
|
98
|
+
break
|
|
99
|
+
line = line_bytes.decode("utf-8").strip()
|
|
100
|
+
if line.startswith("data:"):
|
|
101
|
+
try:
|
|
102
|
+
data_str = line[5:].strip()
|
|
103
|
+
if data_str == "ping" or not data_str:
|
|
104
|
+
continue
|
|
105
|
+
payload = json.loads(data_str)
|
|
106
|
+
if isinstance(payload, list):
|
|
107
|
+
self._local_flags = {f["key"]: f for f in payload if "key" in f}
|
|
108
|
+
elif isinstance(payload, dict) and "flags" in payload:
|
|
109
|
+
raw_flags = payload["flags"]
|
|
110
|
+
if isinstance(raw_flags, list):
|
|
111
|
+
self._local_flags = {f["key"]: f for f in raw_flags if "key" in f}
|
|
112
|
+
elif isinstance(raw_flags, dict):
|
|
113
|
+
self._local_flags = raw_flags
|
|
114
|
+
for listener in self._listeners:
|
|
115
|
+
listener(dict(self._local_flags))
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
118
|
+
except Exception:
|
|
119
|
+
if not self._stop_event.is_set():
|
|
120
|
+
time.sleep(3.0)
|
|
121
|
+
|
|
122
|
+
def is_enabled(self, flag_key: str, context: EvaluationContext) -> bool:
|
|
123
|
+
"""Check if a boolean flag is enabled for the given context."""
|
|
124
|
+
try:
|
|
125
|
+
res = self.evaluate(flag_key, context)
|
|
126
|
+
return bool(res.enabled)
|
|
127
|
+
except Exception:
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
def get_variant(self, flag_key: str, context: EvaluationContext, fallback: str = "off") -> str:
|
|
131
|
+
"""Retrieve the assigned variant string for a multivariate flag."""
|
|
132
|
+
try:
|
|
133
|
+
res = self.evaluate(flag_key, context)
|
|
134
|
+
return res.variant if res.variant and res.variant != "off" else fallback
|
|
135
|
+
except Exception:
|
|
136
|
+
return fallback
|
|
137
|
+
|
|
138
|
+
def evaluate(self, flag_key: str, context: EvaluationContext) -> EvaluationResult:
|
|
139
|
+
"""Evaluate a single feature flag."""
|
|
140
|
+
results = self.evaluate_batch([flag_key], context)
|
|
141
|
+
if flag_key in results:
|
|
142
|
+
return results[flag_key]
|
|
143
|
+
return EvaluationResult(
|
|
144
|
+
flag_key=flag_key,
|
|
145
|
+
enabled=False,
|
|
146
|
+
variant="off",
|
|
147
|
+
value=False,
|
|
148
|
+
reason="FLAG_NOT_FOUND",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def evaluate_batch(self, flag_keys: List[str], context: EvaluationContext) -> Dict[str, EvaluationResult]:
|
|
152
|
+
"""Evaluate multiple feature flags concurrently in a single HTTP payload."""
|
|
153
|
+
ctx_dict = context.to_dict()
|
|
154
|
+
if "environment" not in ctx_dict or not ctx_dict["environment"]:
|
|
155
|
+
ctx_dict["environment"] = self.default_environment
|
|
156
|
+
|
|
157
|
+
payload = {
|
|
158
|
+
"flags": flag_keys,
|
|
159
|
+
"context": ctx_dict,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
req_data = json.dumps(payload).encode("utf-8")
|
|
163
|
+
headers = {"Content-Type": "application/json"}
|
|
164
|
+
if self.api_key:
|
|
165
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
166
|
+
if self.project_id:
|
|
167
|
+
headers["X-Project-ID"] = self.project_id
|
|
168
|
+
|
|
169
|
+
req = urllib.request.Request(
|
|
170
|
+
f"{self.endpoint}/api/v1/evaluate",
|
|
171
|
+
data=req_data,
|
|
172
|
+
headers=headers,
|
|
173
|
+
method="POST",
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
178
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
179
|
+
results: Dict[str, EvaluationResult] = {}
|
|
180
|
+
raw_results = data.get("results", {})
|
|
181
|
+
|
|
182
|
+
for k, v in raw_results.items():
|
|
183
|
+
results[k] = EvaluationResult(
|
|
184
|
+
flag_key=v.get("flag_key", k),
|
|
185
|
+
enabled=v.get("enabled", False),
|
|
186
|
+
variant=v.get("variant", "off"),
|
|
187
|
+
value=v.get("value", False),
|
|
188
|
+
reason=v.get("reason", ""),
|
|
189
|
+
bucket=v.get("bucket"),
|
|
190
|
+
latency_ns=v.get("latency_ns", 0),
|
|
191
|
+
latency_us=v.get("latency_us", 0.0),
|
|
192
|
+
)
|
|
193
|
+
return results
|
|
194
|
+
except Exception as e:
|
|
195
|
+
raise RuntimeError(f"Flagura evaluation request failed: {e}") from e
|
|
196
|
+
|
|
197
|
+
def track(self, flag_key: str, variant: str, metric_name: str, value: float = 1.0, user_id: str = "") -> None:
|
|
198
|
+
"""Track an experiment conversion or numeric metric event."""
|
|
199
|
+
payload = {
|
|
200
|
+
"events": [
|
|
201
|
+
{
|
|
202
|
+
"flag_key": flag_key,
|
|
203
|
+
"project_id": self.project_id,
|
|
204
|
+
"variant": variant,
|
|
205
|
+
"metric_name": metric_name,
|
|
206
|
+
"value": value,
|
|
207
|
+
"user_id": user_id,
|
|
208
|
+
"environment": self.default_environment,
|
|
209
|
+
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
210
|
+
}
|
|
211
|
+
]
|
|
212
|
+
}
|
|
213
|
+
req_data = json.dumps(payload).encode("utf-8")
|
|
214
|
+
headers = {"Content-Type": "application/json"}
|
|
215
|
+
if self.api_key:
|
|
216
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
217
|
+
if self.project_id:
|
|
218
|
+
headers["X-Project-ID"] = self.project_id
|
|
219
|
+
|
|
220
|
+
req = urllib.request.Request(
|
|
221
|
+
f"{self.endpoint}/api/v1/telemetry/events",
|
|
222
|
+
data=req_data,
|
|
223
|
+
headers=headers,
|
|
224
|
+
method="POST",
|
|
225
|
+
)
|
|
226
|
+
try:
|
|
227
|
+
with urllib.request.urlopen(req, timeout=self.timeout):
|
|
228
|
+
pass
|
|
229
|
+
except Exception:
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
def close(self) -> None:
|
|
233
|
+
"""Close active background stream threads."""
|
|
234
|
+
self._stop_event.set()
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
from typing import Any, Dict, Optional
|
|
2
|
+
from .client import FlaguraClient, EvaluationContext as FlaguraContext
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ResolutionDetails:
|
|
6
|
+
"""Represents flag resolution details conforming to OpenFeature spec."""
|
|
7
|
+
|
|
8
|
+
def __init__(
|
|
9
|
+
self,
|
|
10
|
+
value: Any,
|
|
11
|
+
variant: Optional[str] = None,
|
|
12
|
+
reason: Optional[str] = None,
|
|
13
|
+
error_code: Optional[str] = None,
|
|
14
|
+
error_message: Optional[str] = None,
|
|
15
|
+
):
|
|
16
|
+
self.value = value
|
|
17
|
+
self.variant = variant
|
|
18
|
+
self.reason = reason
|
|
19
|
+
self.error_code = error_code
|
|
20
|
+
self.error_message = error_message
|
|
21
|
+
|
|
22
|
+
def __repr__(self) -> str:
|
|
23
|
+
return f"ResolutionDetails(value={self.value!r}, variant={self.variant!r}, reason={self.reason!r})"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FlaguraOpenFeatureProvider:
|
|
27
|
+
"""Official Flagura OpenFeature Provider for Python applications."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, client: Optional[FlaguraClient] = None, **kwargs: Any):
|
|
30
|
+
if client is not None:
|
|
31
|
+
self.client = client
|
|
32
|
+
else:
|
|
33
|
+
self.client = FlaguraClient(**kwargs)
|
|
34
|
+
self.name = "flagura-python-provider"
|
|
35
|
+
|
|
36
|
+
def get_metadata(self) -> Dict[str, str]:
|
|
37
|
+
return {"name": self.name}
|
|
38
|
+
|
|
39
|
+
def _map_context(self, context: Any) -> FlaguraContext:
|
|
40
|
+
if context is None:
|
|
41
|
+
return FlaguraContext(user_id="anonymous")
|
|
42
|
+
|
|
43
|
+
# OpenFeature evaluation context in Python can be an object with targeting_key and attributes dict
|
|
44
|
+
user_id = getattr(context, "targeting_key", None)
|
|
45
|
+
attributes = getattr(context, "attributes", {})
|
|
46
|
+
|
|
47
|
+
if isinstance(context, dict):
|
|
48
|
+
user_id = context.get("targeting_key") or context.get("user_id") or context.get("userId")
|
|
49
|
+
attributes = context
|
|
50
|
+
|
|
51
|
+
if not user_id:
|
|
52
|
+
user_id = attributes.get("user_id") or attributes.get("userId") or "anonymous"
|
|
53
|
+
|
|
54
|
+
return FlaguraContext(
|
|
55
|
+
user_id=str(user_id),
|
|
56
|
+
email=attributes.get("email"),
|
|
57
|
+
country=attributes.get("country"),
|
|
58
|
+
role=attributes.get("role"),
|
|
59
|
+
tier=attributes.get("tier"),
|
|
60
|
+
environment=attributes.get("environment", self.client.default_environment),
|
|
61
|
+
custom={
|
|
62
|
+
k: v
|
|
63
|
+
for k, v in attributes.items()
|
|
64
|
+
if k not in {"targeting_key", "user_id", "userId", "email", "country", "role", "tier", "environment"}
|
|
65
|
+
},
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def _map_reason(self, reason: str) -> str:
|
|
69
|
+
r = (reason or "").upper()
|
|
70
|
+
if "TARGETING" in r or "RULE" in r:
|
|
71
|
+
return "TARGETING_MATCH"
|
|
72
|
+
if "PERCENTAGE" in r or "MULTIVARIATE" in r or "BUCKET" in r:
|
|
73
|
+
return "SPLIT"
|
|
74
|
+
if "KILL_SWITCH" in r or "ENV_DISABLED" in r or "DISABLED" in r:
|
|
75
|
+
return "DISABLED"
|
|
76
|
+
if "DEFAULT" in r:
|
|
77
|
+
return "DEFAULT"
|
|
78
|
+
return "STATIC"
|
|
79
|
+
|
|
80
|
+
def resolve_boolean_details(
|
|
81
|
+
self, flag_key: str, default_value: bool, evaluation_context: Any = None
|
|
82
|
+
) -> ResolutionDetails:
|
|
83
|
+
try:
|
|
84
|
+
ctx = self._map_context(evaluation_context)
|
|
85
|
+
res = self.client.evaluate(flag_key, ctx)
|
|
86
|
+
|
|
87
|
+
if res.reason == "FLAG_NOT_FOUND":
|
|
88
|
+
return ResolutionDetails(
|
|
89
|
+
value=default_value,
|
|
90
|
+
reason="ERROR",
|
|
91
|
+
error_code="FLAG_NOT_FOUND",
|
|
92
|
+
error_message=f"Flag '{flag_key}' not found",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if not res.enabled:
|
|
96
|
+
return ResolutionDetails(
|
|
97
|
+
value=default_value,
|
|
98
|
+
variant=res.variant or "off",
|
|
99
|
+
reason=self._map_reason(res.reason),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
val = res.value if isinstance(res.value, bool) else bool(res.value) if res.value is not None else True
|
|
103
|
+
return ResolutionDetails(
|
|
104
|
+
value=val,
|
|
105
|
+
variant=res.variant or "treatment",
|
|
106
|
+
reason=self._map_reason(res.reason),
|
|
107
|
+
)
|
|
108
|
+
except Exception as e:
|
|
109
|
+
return ResolutionDetails(
|
|
110
|
+
value=default_value,
|
|
111
|
+
reason="ERROR",
|
|
112
|
+
error_code="GENERAL",
|
|
113
|
+
error_message=str(e),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def resolve_string_details(
|
|
117
|
+
self, flag_key: str, default_value: str, evaluation_context: Any = None
|
|
118
|
+
) -> ResolutionDetails:
|
|
119
|
+
try:
|
|
120
|
+
ctx = self._map_context(evaluation_context)
|
|
121
|
+
res = self.client.evaluate(flag_key, ctx)
|
|
122
|
+
|
|
123
|
+
if res.reason == "FLAG_NOT_FOUND":
|
|
124
|
+
return ResolutionDetails(
|
|
125
|
+
value=default_value,
|
|
126
|
+
reason="ERROR",
|
|
127
|
+
error_code="FLAG_NOT_FOUND",
|
|
128
|
+
error_message=f"Flag '{flag_key}' not found",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if not res.enabled:
|
|
132
|
+
return ResolutionDetails(
|
|
133
|
+
value=default_value,
|
|
134
|
+
variant=res.variant or "off",
|
|
135
|
+
reason=self._map_reason(res.reason),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
val = str(res.value) if res.value else res.variant or default_value
|
|
139
|
+
return ResolutionDetails(
|
|
140
|
+
value=val,
|
|
141
|
+
variant=res.variant or "treatment",
|
|
142
|
+
reason=self._map_reason(res.reason),
|
|
143
|
+
)
|
|
144
|
+
except Exception as e:
|
|
145
|
+
return ResolutionDetails(
|
|
146
|
+
value=default_value,
|
|
147
|
+
reason="ERROR",
|
|
148
|
+
error_code="GENERAL",
|
|
149
|
+
error_message=str(e),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def resolve_integer_details(
|
|
153
|
+
self, flag_key: str, default_value: int, evaluation_context: Any = None
|
|
154
|
+
) -> ResolutionDetails:
|
|
155
|
+
try:
|
|
156
|
+
ctx = self._map_context(evaluation_context)
|
|
157
|
+
res = self.client.evaluate(flag_key, ctx)
|
|
158
|
+
|
|
159
|
+
if res.reason == "FLAG_NOT_FOUND":
|
|
160
|
+
return ResolutionDetails(
|
|
161
|
+
value=default_value,
|
|
162
|
+
reason="ERROR",
|
|
163
|
+
error_code="FLAG_NOT_FOUND",
|
|
164
|
+
error_message=f"Flag '{flag_key}' not found",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
if not res.enabled:
|
|
168
|
+
return ResolutionDetails(
|
|
169
|
+
value=default_value,
|
|
170
|
+
variant=res.variant or "off",
|
|
171
|
+
reason=self._map_reason(res.reason),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
val = int(res.value)
|
|
176
|
+
except (ValueError, TypeError):
|
|
177
|
+
val = default_value
|
|
178
|
+
|
|
179
|
+
return ResolutionDetails(
|
|
180
|
+
value=val,
|
|
181
|
+
variant=res.variant or "treatment",
|
|
182
|
+
reason=self._map_reason(res.reason),
|
|
183
|
+
)
|
|
184
|
+
except Exception as e:
|
|
185
|
+
return ResolutionDetails(
|
|
186
|
+
value=default_value,
|
|
187
|
+
reason="ERROR",
|
|
188
|
+
error_code="GENERAL",
|
|
189
|
+
error_message=str(e),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def resolve_float_details(
|
|
193
|
+
self, flag_key: str, default_value: float, evaluation_context: Any = None
|
|
194
|
+
) -> ResolutionDetails:
|
|
195
|
+
try:
|
|
196
|
+
ctx = self._map_context(evaluation_context)
|
|
197
|
+
res = self.client.evaluate(flag_key, ctx)
|
|
198
|
+
|
|
199
|
+
if res.reason == "FLAG_NOT_FOUND":
|
|
200
|
+
return ResolutionDetails(
|
|
201
|
+
value=default_value,
|
|
202
|
+
reason="ERROR",
|
|
203
|
+
error_code="FLAG_NOT_FOUND",
|
|
204
|
+
error_message=f"Flag '{flag_key}' not found",
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
if not res.enabled:
|
|
208
|
+
return ResolutionDetails(
|
|
209
|
+
value=default_value,
|
|
210
|
+
variant=res.variant or "off",
|
|
211
|
+
reason=self._map_reason(res.reason),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
val = float(res.value)
|
|
216
|
+
except (ValueError, TypeError):
|
|
217
|
+
val = default_value
|
|
218
|
+
|
|
219
|
+
return ResolutionDetails(
|
|
220
|
+
value=val,
|
|
221
|
+
variant=res.variant or "treatment",
|
|
222
|
+
reason=self._map_reason(res.reason),
|
|
223
|
+
)
|
|
224
|
+
except Exception as e:
|
|
225
|
+
return ResolutionDetails(
|
|
226
|
+
value=default_value,
|
|
227
|
+
reason="ERROR",
|
|
228
|
+
error_code="GENERAL",
|
|
229
|
+
error_message=str(e),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def resolve_object_details(
|
|
233
|
+
self, flag_key: str, default_value: Any, evaluation_context: Any = None
|
|
234
|
+
) -> ResolutionDetails:
|
|
235
|
+
try:
|
|
236
|
+
ctx = self._map_context(evaluation_context)
|
|
237
|
+
res = self.client.evaluate(flag_key, ctx)
|
|
238
|
+
|
|
239
|
+
if res.reason == "FLAG_NOT_FOUND":
|
|
240
|
+
return ResolutionDetails(
|
|
241
|
+
value=default_value,
|
|
242
|
+
reason="ERROR",
|
|
243
|
+
error_code="FLAG_NOT_FOUND",
|
|
244
|
+
error_message=f"Flag '{flag_key}' not found",
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
if not res.enabled:
|
|
248
|
+
return ResolutionDetails(
|
|
249
|
+
value=default_value,
|
|
250
|
+
variant=res.variant or "off",
|
|
251
|
+
reason=self._map_reason(res.reason),
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
val = res.value if res.value is not None else default_value
|
|
255
|
+
return ResolutionDetails(
|
|
256
|
+
value=val,
|
|
257
|
+
variant=res.variant or "treatment",
|
|
258
|
+
reason=self._map_reason(res.reason),
|
|
259
|
+
)
|
|
260
|
+
except Exception as e:
|
|
261
|
+
return ResolutionDetails(
|
|
262
|
+
value=default_value,
|
|
263
|
+
reason="ERROR",
|
|
264
|
+
error_code="GENERAL",
|
|
265
|
+
error_message=str(e),
|
|
266
|
+
)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flagura-sdk
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python SDK and OpenFeature Provider for Flagura
|
|
5
|
+
Author-email: Dhawal Dyavanpalli <dhawalhost@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/dhawalhost/flagura
|
|
8
|
+
Project-URL: Repository, https://github.com/dhawalhost/flagura
|
|
9
|
+
Keywords: feature-flags,feature-toggles,openfeature,flagura,a-b-testing
|
|
10
|
+
Requires-Python: >=3.8
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: requests>=2.28.0
|
|
13
|
+
Requires-Dist: openfeature-sdk>=0.7.0
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
16
|
+
Requires-Dist: build>=1.0.0; extra == "dev"
|
|
17
|
+
Requires-Dist: twine>=4.0.0; extra == "dev"
|
|
18
|
+
|
|
19
|
+
# ⚡ Flagura Python SDK
|
|
20
|
+
|
|
21
|
+
Official Python client for the **Flagura Feature Flag Platform**, supporting:
|
|
22
|
+
- High-performance evaluations
|
|
23
|
+
- **Real-Time SSE Flag Streaming (`<5ms` sync)**
|
|
24
|
+
- **CNCF OpenFeature Provider**
|
|
25
|
+
- FastAPI, Django, Flask, Celery, and AI Agent compatibility
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## 📦 Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install flagura
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## 🚀 Quickstart
|
|
38
|
+
|
|
39
|
+
### 1. Direct Client with Real-Time SSE Streaming
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from flagura import FlaguraClient, EvaluationContext
|
|
43
|
+
|
|
44
|
+
# Initialize client with real-time SSE streaming
|
|
45
|
+
client = FlaguraClient(
|
|
46
|
+
endpoint="https://flagura.dhawalhost.com",
|
|
47
|
+
api_key="your-api-key",
|
|
48
|
+
project_id="proj_default", # optional: project scoping
|
|
49
|
+
default_environment="production",
|
|
50
|
+
enable_streaming=True, # <5ms live flag updates
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Register real-time change listener
|
|
54
|
+
client.on_update(lambda flags: print(f"Flags updated in real-time! Count: {len(flags)}"))
|
|
55
|
+
|
|
56
|
+
# Evaluate flag
|
|
57
|
+
context = EvaluationContext(
|
|
58
|
+
user_id="usr_dhawal_01",
|
|
59
|
+
email="dhawal@flagura.dev",
|
|
60
|
+
tier="enterprise",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if client.is_enabled("ai-smart-search", context):
|
|
64
|
+
variant = client.get_variant("ai-smart-search", context)
|
|
65
|
+
print(f"AI Smart Search is ON! Variant: {variant}")
|
|
66
|
+
|
|
67
|
+
# Cleanup on shutdown
|
|
68
|
+
client.close()
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
### 2. OpenFeature Universal Provider
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from openfeature import api
|
|
77
|
+
from openfeature.evaluation_context import EvaluationContext
|
|
78
|
+
from flagura.openfeature_provider import FlaguraOpenFeatureProvider
|
|
79
|
+
|
|
80
|
+
# Register Flagura as OpenFeature provider
|
|
81
|
+
api.set_provider(FlaguraOpenFeatureProvider(
|
|
82
|
+
endpoint="https://flagura.dhawalhost.com",
|
|
83
|
+
api_key="your-api-key",
|
|
84
|
+
enable_streaming=True,
|
|
85
|
+
))
|
|
86
|
+
of_client = api.get_client()
|
|
87
|
+
|
|
88
|
+
# Evaluate with OpenFeature standard APIs
|
|
89
|
+
ctx = EvaluationContext(targeting_key="usr_dhawal_01", attributes={"email": "dhawal@flagura.dev"})
|
|
90
|
+
is_enabled = of_client.get_boolean_value("ai-smart-search", False, ctx)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## 📄 License
|
|
96
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
flagura/__init__.py
|
|
4
|
+
flagura/client.py
|
|
5
|
+
flagura/openfeature_provider.py
|
|
6
|
+
flagura_sdk.egg-info/PKG-INFO
|
|
7
|
+
flagura_sdk.egg-info/SOURCES.txt
|
|
8
|
+
flagura_sdk.egg-info/dependency_links.txt
|
|
9
|
+
flagura_sdk.egg-info/requires.txt
|
|
10
|
+
flagura_sdk.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
flagura
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "flagura-sdk"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Official Python SDK and OpenFeature Provider for Flagura"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Dhawal Dyavanpalli", email = "dhawalhost@gmail.com" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["feature-flags", "feature-toggles", "openfeature", "flagura", "a-b-testing"]
|
|
16
|
+
dependencies = [
|
|
17
|
+
"requests>=2.28.0",
|
|
18
|
+
"openfeature-sdk>=0.7.0",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
dev = [
|
|
23
|
+
"pytest>=7.0.0",
|
|
24
|
+
"build>=1.0.0",
|
|
25
|
+
"twine>=4.0.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/dhawalhost/flagura"
|
|
30
|
+
Repository = "https://github.com/dhawalhost/flagura"
|