flagsmith 5.1.1__tar.gz → 5.3.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.
- {flagsmith-5.1.1 → flagsmith-5.3.0}/PKG-INFO +2 -2
- flagsmith-5.3.0/flagsmith/__init__.py +6 -0
- flagsmith-5.3.0/flagsmith/analytics.py +229 -0
- {flagsmith-5.1.1 → flagsmith-5.3.0}/flagsmith/flagsmith.py +96 -12
- {flagsmith-5.1.1 → flagsmith-5.3.0}/flagsmith/mappers.py +14 -19
- flagsmith-5.3.0/flagsmith/models.py +299 -0
- {flagsmith-5.1.1 → flagsmith-5.3.0}/pyproject.toml +2 -2
- flagsmith-5.1.1/flagsmith/__init__.py +0 -5
- flagsmith-5.1.1/flagsmith/analytics.py +0 -62
- flagsmith-5.1.1/flagsmith/models.py +0 -150
- {flagsmith-5.1.1 → flagsmith-5.3.0}/LICENSE +0 -0
- {flagsmith-5.1.1 → flagsmith-5.3.0}/README.md +0 -0
- {flagsmith-5.1.1 → flagsmith-5.3.0}/flagsmith/api/__init__.py +0 -0
- {flagsmith-5.1.1 → flagsmith-5.3.0}/flagsmith/api/types.py +0 -0
- {flagsmith-5.1.1 → flagsmith-5.3.0}/flagsmith/exceptions.py +0 -0
- {flagsmith-5.1.1 → flagsmith-5.3.0}/flagsmith/offline_handlers.py +0 -0
- {flagsmith-5.1.1 → flagsmith-5.3.0}/flagsmith/polling_manager.py +0 -0
- {flagsmith-5.1.1 → flagsmith-5.3.0}/flagsmith/py.typed +0 -0
- {flagsmith-5.1.1 → flagsmith-5.3.0}/flagsmith/streaming_manager.py +0 -0
- {flagsmith-5.1.1 → flagsmith-5.3.0}/flagsmith/types.py +0 -0
- {flagsmith-5.1.1 → flagsmith-5.3.0}/flagsmith/utils/__init__.py +0 -0
- {flagsmith-5.1.1 → flagsmith-5.3.0}/flagsmith/utils/datetime.py +0 -0
- {flagsmith-5.1.1 → flagsmith-5.3.0}/flagsmith/utils/identities.py +0 -0
- {flagsmith-5.1.1 → flagsmith-5.3.0}/flagsmith/version.py +0 -0
- {flagsmith-5.1.1 → flagsmith-5.3.0}/flagsmith/webhooks.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flagsmith
|
|
3
|
-
Version: 5.
|
|
3
|
+
Version: 5.3.0
|
|
4
4
|
Summary: Flagsmith Python SDK
|
|
5
5
|
License: BSD3
|
|
6
6
|
License-File: LICENSE
|
|
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.12
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.13
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
-
Requires-Dist: flagsmith-flag-engine (>=10.0.
|
|
19
|
+
Requires-Dist: flagsmith-flag-engine (>=10.0.4,<11.0.0)
|
|
20
20
|
Requires-Dist: iso8601 (>=2.1.0,<3.0.0) ; python_version < "3.11"
|
|
21
21
|
Requires-Dist: requests (>=2.32.3,<3.0.0)
|
|
22
22
|
Requires-Dist: requests-futures (>=1.0.1,<2.0.0)
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import atexit
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
import typing
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
|
|
10
|
+
from requests_futures.sessions import FuturesSession # type: ignore
|
|
11
|
+
|
|
12
|
+
from flagsmith.version import __version__
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
ANALYTICS_ENDPOINT: typing.Final[str] = "analytics/flags/"
|
|
17
|
+
|
|
18
|
+
# Used to control how often we send data(in seconds)
|
|
19
|
+
ANALYTICS_TIMER: typing.Final[int] = 10
|
|
20
|
+
|
|
21
|
+
session = FuturesSession(max_workers=4)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AnalyticsProcessor:
|
|
25
|
+
"""
|
|
26
|
+
AnalyticsProcessor is used to track how often individual Flags are evaluated within
|
|
27
|
+
the Flagsmith SDK. Docs: https://docs.flagsmith.com/advanced-use/flag-analytics.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self, environment_key: str, base_api_url: str, timeout: typing.Optional[int] = 3
|
|
32
|
+
):
|
|
33
|
+
"""
|
|
34
|
+
Initialise the AnalyticsProcessor to handle sending analytics on flag usage to
|
|
35
|
+
the Flagsmith API.
|
|
36
|
+
|
|
37
|
+
:param environment_key: environment key obtained from the Flagsmith UI
|
|
38
|
+
:param base_api_url: base api url to override when using self hosted version
|
|
39
|
+
:param timeout: used to tell requests to stop waiting for a response after a
|
|
40
|
+
given number of seconds
|
|
41
|
+
"""
|
|
42
|
+
self.analytics_endpoint = base_api_url + ANALYTICS_ENDPOINT
|
|
43
|
+
self.environment_key = environment_key
|
|
44
|
+
self._last_flushed = datetime.now()
|
|
45
|
+
self.analytics_data: typing.MutableMapping[str, typing.Any] = {}
|
|
46
|
+
self.timeout = timeout or 3
|
|
47
|
+
|
|
48
|
+
def flush(self) -> None:
|
|
49
|
+
"""
|
|
50
|
+
Sends all the collected data to the api asynchronously and resets the timer
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
if not self.analytics_data:
|
|
54
|
+
return
|
|
55
|
+
session.post(
|
|
56
|
+
self.analytics_endpoint,
|
|
57
|
+
data=json.dumps(self.analytics_data),
|
|
58
|
+
timeout=self.timeout,
|
|
59
|
+
headers={
|
|
60
|
+
"X-Environment-Key": self.environment_key,
|
|
61
|
+
"Content-Type": "application/json",
|
|
62
|
+
},
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
self.analytics_data.clear()
|
|
66
|
+
self._last_flushed = datetime.now()
|
|
67
|
+
|
|
68
|
+
def track_feature(self, feature_name: str) -> None:
|
|
69
|
+
self.analytics_data[feature_name] = self.analytics_data.get(feature_name, 0) + 1
|
|
70
|
+
if (datetime.now() - self._last_flushed).seconds > ANALYTICS_TIMER:
|
|
71
|
+
self.flush()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class PipelineAnalyticsConfig:
|
|
76
|
+
analytics_server_url: str
|
|
77
|
+
max_buffer_items: int = 1000
|
|
78
|
+
flush_interval_seconds: float = 10.0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class PipelineAnalyticsProcessor:
|
|
82
|
+
"""
|
|
83
|
+
Buffered analytics processor that sends per-evaluation and custom events
|
|
84
|
+
to the Flagsmith pipeline analytics endpoint in batches.
|
|
85
|
+
|
|
86
|
+
Evaluation events are deduplicated within each flush window. Events are
|
|
87
|
+
flushed periodically via a background timer or when the buffer is full.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
config: PipelineAnalyticsConfig,
|
|
93
|
+
environment_key: str,
|
|
94
|
+
) -> None:
|
|
95
|
+
url = config.analytics_server_url
|
|
96
|
+
if not url.endswith("/"):
|
|
97
|
+
url = f"{url}/"
|
|
98
|
+
self._batch_endpoint = f"{url}v1/analytics/batch"
|
|
99
|
+
self._environment_key = environment_key
|
|
100
|
+
self._max_buffer = config.max_buffer_items
|
|
101
|
+
self._flush_interval_seconds = config.flush_interval_seconds
|
|
102
|
+
|
|
103
|
+
self._buffer: typing.List[typing.Dict[str, typing.Any]] = []
|
|
104
|
+
self._dedup_keys: typing.Dict[str, str] = {}
|
|
105
|
+
self._lock = threading.Lock()
|
|
106
|
+
self._timer: typing.Optional[threading.Timer] = None
|
|
107
|
+
|
|
108
|
+
def record_evaluation_event(
|
|
109
|
+
self,
|
|
110
|
+
flag_key: str,
|
|
111
|
+
enabled: bool,
|
|
112
|
+
value: typing.Any,
|
|
113
|
+
identity_identifier: typing.Optional[str] = None,
|
|
114
|
+
traits: typing.Optional[typing.Dict[str, typing.Any]] = None,
|
|
115
|
+
) -> None:
|
|
116
|
+
fingerprint = f"{identity_identifier or 'none'}|{enabled}|{value}"
|
|
117
|
+
should_flush = False
|
|
118
|
+
|
|
119
|
+
with self._lock:
|
|
120
|
+
if self._dedup_keys.get(flag_key) == fingerprint:
|
|
121
|
+
return
|
|
122
|
+
self._dedup_keys[flag_key] = fingerprint
|
|
123
|
+
self._buffer.append(
|
|
124
|
+
{
|
|
125
|
+
"event_id": flag_key,
|
|
126
|
+
"event_type": "flag_evaluation",
|
|
127
|
+
"evaluated_at": int(time.time() * 1000),
|
|
128
|
+
"identity_identifier": identity_identifier,
|
|
129
|
+
"enabled": enabled,
|
|
130
|
+
"value": value,
|
|
131
|
+
"traits": dict(traits) if traits else None,
|
|
132
|
+
"metadata": {"sdk_version": __version__},
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
if len(self._buffer) >= self._max_buffer:
|
|
136
|
+
should_flush = True
|
|
137
|
+
|
|
138
|
+
if should_flush:
|
|
139
|
+
self.flush()
|
|
140
|
+
|
|
141
|
+
def record_custom_event(
|
|
142
|
+
self,
|
|
143
|
+
event_name: str,
|
|
144
|
+
identity_identifier: typing.Optional[str] = None,
|
|
145
|
+
traits: typing.Optional[typing.Dict[str, typing.Any]] = None,
|
|
146
|
+
metadata: typing.Optional[typing.Dict[str, typing.Any]] = None,
|
|
147
|
+
) -> None:
|
|
148
|
+
should_flush = False
|
|
149
|
+
|
|
150
|
+
with self._lock:
|
|
151
|
+
self._buffer.append(
|
|
152
|
+
{
|
|
153
|
+
"event_id": event_name,
|
|
154
|
+
"event_type": "custom_event",
|
|
155
|
+
"evaluated_at": int(time.time() * 1000),
|
|
156
|
+
"identity_identifier": identity_identifier,
|
|
157
|
+
"enabled": None,
|
|
158
|
+
"value": None,
|
|
159
|
+
"traits": dict(traits) if traits else None,
|
|
160
|
+
"metadata": {**(metadata or {}), "sdk_version": __version__},
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
if len(self._buffer) >= self._max_buffer:
|
|
164
|
+
should_flush = True
|
|
165
|
+
|
|
166
|
+
if should_flush:
|
|
167
|
+
self.flush()
|
|
168
|
+
|
|
169
|
+
def flush(self) -> None:
|
|
170
|
+
with self._lock:
|
|
171
|
+
if not self._buffer:
|
|
172
|
+
return
|
|
173
|
+
events = self._buffer
|
|
174
|
+
self._buffer = []
|
|
175
|
+
self._dedup_keys.clear()
|
|
176
|
+
|
|
177
|
+
payload = json.dumps(
|
|
178
|
+
{"events": events, "environment_key": self._environment_key}
|
|
179
|
+
)
|
|
180
|
+
try:
|
|
181
|
+
future = session.post(
|
|
182
|
+
self._batch_endpoint,
|
|
183
|
+
data=payload,
|
|
184
|
+
timeout=3,
|
|
185
|
+
headers={
|
|
186
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
187
|
+
"X-Environment-Key": self._environment_key,
|
|
188
|
+
"Flagsmith-SDK-User-Agent": f"flagsmith-python-client/{__version__}",
|
|
189
|
+
},
|
|
190
|
+
)
|
|
191
|
+
except RuntimeError:
|
|
192
|
+
logger.debug("Skipping flush: thread pool already shut down")
|
|
193
|
+
return
|
|
194
|
+
future.add_done_callback(lambda f: self._handle_flush_result(f, events))
|
|
195
|
+
|
|
196
|
+
def _handle_flush_result(
|
|
197
|
+
self,
|
|
198
|
+
future: typing.Any,
|
|
199
|
+
events: typing.List[typing.Dict[str, typing.Any]],
|
|
200
|
+
) -> None:
|
|
201
|
+
try:
|
|
202
|
+
response = future.result()
|
|
203
|
+
response.raise_for_status()
|
|
204
|
+
except Exception:
|
|
205
|
+
logger.warning(
|
|
206
|
+
"Failed to flush pipeline analytics, re-queuing events", exc_info=True
|
|
207
|
+
)
|
|
208
|
+
with self._lock:
|
|
209
|
+
self._buffer = events + self._buffer
|
|
210
|
+
self._buffer = self._buffer[: self._max_buffer]
|
|
211
|
+
|
|
212
|
+
def start(self) -> None:
|
|
213
|
+
self._schedule_flush()
|
|
214
|
+
atexit.register(self.stop)
|
|
215
|
+
|
|
216
|
+
def stop(self) -> None:
|
|
217
|
+
atexit.unregister(self.stop)
|
|
218
|
+
if self._timer is not None:
|
|
219
|
+
self._timer.cancel()
|
|
220
|
+
self.flush()
|
|
221
|
+
|
|
222
|
+
def _schedule_flush(self) -> None:
|
|
223
|
+
self._timer = threading.Timer(self._flush_interval_seconds, self._timer_flush)
|
|
224
|
+
self._timer.daemon = True
|
|
225
|
+
self._timer.start()
|
|
226
|
+
|
|
227
|
+
def _timer_flush(self) -> None:
|
|
228
|
+
self.flush()
|
|
229
|
+
self._schedule_flush()
|
|
@@ -8,15 +8,26 @@ from flag_engine import engine
|
|
|
8
8
|
from requests.adapters import HTTPAdapter
|
|
9
9
|
from urllib3 import Retry
|
|
10
10
|
|
|
11
|
-
from flagsmith.analytics import
|
|
11
|
+
from flagsmith.analytics import (
|
|
12
|
+
AnalyticsProcessor,
|
|
13
|
+
PipelineAnalyticsConfig,
|
|
14
|
+
PipelineAnalyticsProcessor,
|
|
15
|
+
)
|
|
12
16
|
from flagsmith.exceptions import FlagsmithAPIError, FlagsmithClientError
|
|
13
17
|
from flagsmith.mappers import (
|
|
14
18
|
map_context_and_identity_data_to_context,
|
|
15
19
|
map_environment_document_to_context,
|
|
16
20
|
map_environment_document_to_environment_updated_at,
|
|
17
21
|
map_segment_results_to_identity_segments,
|
|
22
|
+
resolve_trait_values,
|
|
23
|
+
)
|
|
24
|
+
from flagsmith.models import (
|
|
25
|
+
DefaultFlag,
|
|
26
|
+
Flags,
|
|
27
|
+
Segment,
|
|
28
|
+
SegmentOverridesIndex,
|
|
29
|
+
build_segment_overrides_index,
|
|
18
30
|
)
|
|
19
|
-
from flagsmith.models import DefaultFlag, Flags, Segment
|
|
20
31
|
from flagsmith.offline_handlers import OfflineHandler
|
|
21
32
|
from flagsmith.polling_manager import EnvironmentDataPollingManager
|
|
22
33
|
from flagsmith.streaming_manager import EventStreamManager
|
|
@@ -63,6 +74,7 @@ class Flagsmith:
|
|
|
63
74
|
environment_refresh_interval_seconds: typing.Union[int, float] = 60,
|
|
64
75
|
retries: typing.Optional[Retry] = None,
|
|
65
76
|
enable_analytics: bool = False,
|
|
77
|
+
pipeline_analytics_config: typing.Optional[PipelineAnalyticsConfig] = None,
|
|
66
78
|
default_flag_handler: typing.Optional[
|
|
67
79
|
typing.Callable[[str], DefaultFlag]
|
|
68
80
|
] = None,
|
|
@@ -108,7 +120,11 @@ class Flagsmith:
|
|
|
108
120
|
self.default_flag_handler = default_flag_handler
|
|
109
121
|
self.enable_realtime_updates = enable_realtime_updates
|
|
110
122
|
self._analytics_processor: typing.Optional[AnalyticsProcessor] = None
|
|
111
|
-
self.
|
|
123
|
+
self._pipeline_analytics_processor: typing.Optional[
|
|
124
|
+
PipelineAnalyticsProcessor
|
|
125
|
+
] = None
|
|
126
|
+
self.__evaluation_context: typing.Optional[SDKEvaluationContext] = None
|
|
127
|
+
self._segment_overrides_index: SegmentOverridesIndex = {}
|
|
112
128
|
self._environment_updated_at: typing.Optional[datetime] = None
|
|
113
129
|
|
|
114
130
|
# argument validation
|
|
@@ -170,10 +186,28 @@ class Flagsmith:
|
|
|
170
186
|
|
|
171
187
|
self._initialise_local_evaluation()
|
|
172
188
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
189
|
+
self._initialise_analytics(
|
|
190
|
+
environment_key=environment_key,
|
|
191
|
+
enable_analytics=enable_analytics,
|
|
192
|
+
pipeline_analytics_config=pipeline_analytics_config,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def _initialise_analytics(
|
|
196
|
+
self,
|
|
197
|
+
environment_key: str,
|
|
198
|
+
enable_analytics: bool,
|
|
199
|
+
pipeline_analytics_config: typing.Optional[PipelineAnalyticsConfig],
|
|
200
|
+
) -> None:
|
|
201
|
+
if enable_analytics:
|
|
202
|
+
self._analytics_processor = AnalyticsProcessor(
|
|
203
|
+
environment_key, self.api_url, timeout=self.request_timeout_seconds
|
|
204
|
+
)
|
|
205
|
+
if pipeline_analytics_config:
|
|
206
|
+
self._pipeline_analytics_processor = PipelineAnalyticsProcessor(
|
|
207
|
+
config=pipeline_analytics_config,
|
|
208
|
+
environment_key=environment_key,
|
|
209
|
+
)
|
|
210
|
+
self._pipeline_analytics_processor.start()
|
|
177
211
|
|
|
178
212
|
def _initialise_local_evaluation(self) -> None:
|
|
179
213
|
# To ensure that the environment is set before allowing subsequent
|
|
@@ -290,6 +324,25 @@ class Flagsmith:
|
|
|
290
324
|
|
|
291
325
|
return map_segment_results_to_identity_segments(evaluation_result["segments"])
|
|
292
326
|
|
|
327
|
+
def track_event(
|
|
328
|
+
self,
|
|
329
|
+
event_name: str,
|
|
330
|
+
identity_identifier: typing.Optional[str] = None,
|
|
331
|
+
traits: typing.Optional[TraitMapping] = None,
|
|
332
|
+
metadata: typing.Optional[typing.Dict[str, typing.Any]] = None,
|
|
333
|
+
) -> None:
|
|
334
|
+
if not self._pipeline_analytics_processor:
|
|
335
|
+
raise ValueError(
|
|
336
|
+
"Pipeline analytics is not configured. "
|
|
337
|
+
"Provide pipeline_analytics_config to use track_event."
|
|
338
|
+
)
|
|
339
|
+
self._pipeline_analytics_processor.record_custom_event(
|
|
340
|
+
event_name=event_name,
|
|
341
|
+
identity_identifier=identity_identifier,
|
|
342
|
+
traits=resolve_trait_values(traits),
|
|
343
|
+
metadata=metadata,
|
|
344
|
+
)
|
|
345
|
+
|
|
293
346
|
def update_environment(self) -> None:
|
|
294
347
|
try:
|
|
295
348
|
environment_data = self._get_json_response(
|
|
@@ -310,6 +363,26 @@ class Flagsmith:
|
|
|
310
363
|
except (KeyError, TypeError, ValueError):
|
|
311
364
|
logger.exception("Error parsing environment document")
|
|
312
365
|
|
|
366
|
+
@property
|
|
367
|
+
def _evaluation_context(self) -> typing.Optional[SDKEvaluationContext]:
|
|
368
|
+
return self.__evaluation_context
|
|
369
|
+
|
|
370
|
+
@_evaluation_context.setter
|
|
371
|
+
def _evaluation_context(
|
|
372
|
+
self, context: typing.Optional[SDKEvaluationContext]
|
|
373
|
+
) -> None:
|
|
374
|
+
"""Swap in a new evaluation context and rebuild the overrides index.
|
|
375
|
+
|
|
376
|
+
The index maps feature_name -> segments that override it. Built once
|
|
377
|
+
per refresh and reused across every subsequent per-identity lazy
|
|
378
|
+
resolution; rebuilding here keeps it in sync with the current doc
|
|
379
|
+
without any hot-path cost.
|
|
380
|
+
"""
|
|
381
|
+
self.__evaluation_context = context
|
|
382
|
+
self._segment_overrides_index = (
|
|
383
|
+
build_segment_overrides_index(context) if context is not None else {}
|
|
384
|
+
)
|
|
385
|
+
|
|
313
386
|
def _get_headers(
|
|
314
387
|
self,
|
|
315
388
|
environment_key: str,
|
|
@@ -345,6 +418,7 @@ class Flagsmith:
|
|
|
345
418
|
evaluation_result=evaluation_result,
|
|
346
419
|
analytics_processor=self._analytics_processor,
|
|
347
420
|
default_flag_handler=self.default_flag_handler,
|
|
421
|
+
pipeline_analytics_processor=self._pipeline_analytics_processor,
|
|
348
422
|
)
|
|
349
423
|
|
|
350
424
|
def _get_identity_flags_from_document(
|
|
@@ -360,14 +434,17 @@ class Flagsmith:
|
|
|
360
434
|
identifier=identifier,
|
|
361
435
|
traits=traits,
|
|
362
436
|
)
|
|
363
|
-
|
|
437
|
+
# Lazy: defer per-feature evaluation until the caller actually reads
|
|
438
|
+
# a flag. Hot for callers that only read one or a few flags out of a
|
|
439
|
+
# large environment.
|
|
440
|
+
return Flags.from_evaluation_context(
|
|
364
441
|
context=context,
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
return Flags.from_evaluation_result(
|
|
368
|
-
evaluation_result=evaluation_result,
|
|
442
|
+
overrides_index=self._segment_overrides_index,
|
|
369
443
|
analytics_processor=self._analytics_processor,
|
|
370
444
|
default_flag_handler=self.default_flag_handler,
|
|
445
|
+
pipeline_analytics_processor=self._pipeline_analytics_processor,
|
|
446
|
+
identity_identifier=identifier,
|
|
447
|
+
traits=resolve_trait_values(traits),
|
|
371
448
|
)
|
|
372
449
|
|
|
373
450
|
def _get_environment_flags_from_api(self) -> Flags:
|
|
@@ -379,6 +456,7 @@ class Flagsmith:
|
|
|
379
456
|
api_flags=json_response,
|
|
380
457
|
analytics_processor=self._analytics_processor,
|
|
381
458
|
default_flag_handler=self.default_flag_handler,
|
|
459
|
+
pipeline_analytics_processor=self._pipeline_analytics_processor,
|
|
382
460
|
)
|
|
383
461
|
except FlagsmithAPIError:
|
|
384
462
|
if self.offline_handler:
|
|
@@ -411,6 +489,9 @@ class Flagsmith:
|
|
|
411
489
|
api_flags=json_response["flags"],
|
|
412
490
|
analytics_processor=self._analytics_processor,
|
|
413
491
|
default_flag_handler=self.default_flag_handler,
|
|
492
|
+
pipeline_analytics_processor=self._pipeline_analytics_processor,
|
|
493
|
+
identity_identifier=identifier,
|
|
494
|
+
traits=resolve_trait_values(traits),
|
|
414
495
|
)
|
|
415
496
|
except FlagsmithAPIError:
|
|
416
497
|
if self.offline_handler:
|
|
@@ -443,3 +524,6 @@ class Flagsmith:
|
|
|
443
524
|
|
|
444
525
|
if hasattr(self, "event_stream_thread"):
|
|
445
526
|
self.event_stream_thread.stop()
|
|
527
|
+
|
|
528
|
+
if self._pipeline_analytics_processor:
|
|
529
|
+
self._pipeline_analytics_processor.stop()
|
|
@@ -12,7 +12,6 @@ from flag_engine.context.types import (
|
|
|
12
12
|
StrValueSegmentCondition,
|
|
13
13
|
)
|
|
14
14
|
from flag_engine.result.types import SegmentResult
|
|
15
|
-
from flag_engine.segments.types import ContextValue
|
|
16
15
|
|
|
17
16
|
from flagsmith.api.types import (
|
|
18
17
|
EnvironmentModel,
|
|
@@ -26,7 +25,7 @@ from flagsmith.types import (
|
|
|
26
25
|
SDKEvaluationContext,
|
|
27
26
|
SegmentMetadata,
|
|
28
27
|
StreamEvent,
|
|
29
|
-
|
|
28
|
+
TraitMapping,
|
|
30
29
|
)
|
|
31
30
|
from flagsmith.utils.datetime import fromisoformat
|
|
32
31
|
|
|
@@ -75,31 +74,27 @@ def map_environment_document_to_environment_updated_at(
|
|
|
75
74
|
return updated_at.astimezone(tz=timezone.utc)
|
|
76
75
|
|
|
77
76
|
|
|
77
|
+
def resolve_trait_values(
|
|
78
|
+
traits: typing.Optional[TraitMapping],
|
|
79
|
+
) -> typing.Optional[typing.Dict[str, typing.Any]]:
|
|
80
|
+
if not traits:
|
|
81
|
+
return None
|
|
82
|
+
return {
|
|
83
|
+
key: (val["value"] if isinstance(val, dict) else val)
|
|
84
|
+
for key, val in traits.items()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
78
88
|
def map_context_and_identity_data_to_context(
|
|
79
89
|
context: SDKEvaluationContext,
|
|
80
90
|
identifier: str,
|
|
81
|
-
traits: typing.Optional[
|
|
82
|
-
typing.Mapping[
|
|
83
|
-
str,
|
|
84
|
-
typing.Union[
|
|
85
|
-
ContextValue,
|
|
86
|
-
TraitConfig,
|
|
87
|
-
],
|
|
88
|
-
]
|
|
89
|
-
],
|
|
91
|
+
traits: typing.Optional[TraitMapping] = None,
|
|
90
92
|
) -> SDKEvaluationContext:
|
|
91
93
|
return {
|
|
92
94
|
**context,
|
|
93
95
|
"identity": {
|
|
94
96
|
"identifier": identifier,
|
|
95
|
-
"traits": {
|
|
96
|
-
trait_key: (
|
|
97
|
-
trait_value_or_config["value"]
|
|
98
|
-
if isinstance(trait_value_or_config, dict)
|
|
99
|
-
else trait_value_or_config
|
|
100
|
-
)
|
|
101
|
-
for trait_key, trait_value_or_config in (traits or {}).items()
|
|
102
|
-
},
|
|
97
|
+
"traits": resolve_trait_values(traits) or {},
|
|
103
98
|
},
|
|
104
99
|
}
|
|
105
100
|
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typing
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
from flag_engine import engine
|
|
7
|
+
from flag_engine.context.types import SegmentContext
|
|
8
|
+
|
|
9
|
+
from flagsmith.analytics import AnalyticsProcessor, PipelineAnalyticsProcessor
|
|
10
|
+
from flagsmith.exceptions import FlagsmithFeatureDoesNotExistError
|
|
11
|
+
from flagsmith.types import (
|
|
12
|
+
FeatureMetadata,
|
|
13
|
+
SDKEvaluationContext,
|
|
14
|
+
SDKEvaluationResult,
|
|
15
|
+
SDKFlagResult,
|
|
16
|
+
SegmentMetadata,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
SegmentOverridesIndex = typing.Dict[
|
|
20
|
+
str, typing.List[SegmentContext[SegmentMetadata, FeatureMetadata]]
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_segment_overrides_index(
|
|
25
|
+
context: SDKEvaluationContext,
|
|
26
|
+
) -> SegmentOverridesIndex:
|
|
27
|
+
"""Map feature_name -> segments that carry an override for that feature.
|
|
28
|
+
|
|
29
|
+
Computed once per environment-document refresh so the lazy eval path
|
|
30
|
+
can walk only the segments actually relevant to a given flag.
|
|
31
|
+
"""
|
|
32
|
+
index: SegmentOverridesIndex = {}
|
|
33
|
+
for segment_context in (context.get("segments") or {}).values():
|
|
34
|
+
for override in segment_context.get("overrides") or ():
|
|
35
|
+
index.setdefault(override["name"], []).append(segment_context)
|
|
36
|
+
return index
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class BaseFlag:
|
|
41
|
+
enabled: bool
|
|
42
|
+
value: typing.Union[str, int, float, bool, None]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class DefaultFlag(BaseFlag):
|
|
47
|
+
is_default: bool = field(default=True)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Flag(BaseFlag):
|
|
52
|
+
feature_id: int
|
|
53
|
+
feature_name: str
|
|
54
|
+
is_default: bool = field(default=False)
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_evaluation_result(
|
|
58
|
+
cls,
|
|
59
|
+
flag_result: SDKFlagResult,
|
|
60
|
+
) -> Flag:
|
|
61
|
+
if metadata := flag_result.get("metadata"):
|
|
62
|
+
return Flag(
|
|
63
|
+
enabled=flag_result["enabled"],
|
|
64
|
+
value=flag_result["value"],
|
|
65
|
+
feature_name=flag_result["name"],
|
|
66
|
+
feature_id=metadata["id"],
|
|
67
|
+
)
|
|
68
|
+
raise ValueError(
|
|
69
|
+
"FlagResult metadata is missing. Cannot create Flag instance. "
|
|
70
|
+
"This means a bug in the SDK, please report it."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def from_api_flag(cls, flag_data: typing.Mapping[str, typing.Any]) -> Flag:
|
|
75
|
+
return Flag(
|
|
76
|
+
enabled=flag_data["enabled"],
|
|
77
|
+
value=flag_data["feature_state_value"],
|
|
78
|
+
feature_name=flag_data["feature"]["name"],
|
|
79
|
+
feature_id=flag_data["feature"]["id"],
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class Flags:
|
|
85
|
+
flags: typing.Dict[str, Flag] = field(default_factory=dict)
|
|
86
|
+
default_flag_handler: typing.Optional[typing.Callable[[str], DefaultFlag]] = None
|
|
87
|
+
_analytics_processor: typing.Optional[AnalyticsProcessor] = None
|
|
88
|
+
_pipeline_analytics_processor: typing.Optional[PipelineAnalyticsProcessor] = None
|
|
89
|
+
_identity_identifier: typing.Optional[str] = None
|
|
90
|
+
_traits: typing.Optional[typing.Dict[str, typing.Any]] = None
|
|
91
|
+
# Lazy-evaluation state. When `_context` is set, `flags` is a
|
|
92
|
+
# per-feature memo rather than a fully-materialised snapshot; unseen
|
|
93
|
+
# features are resolved on demand via the engine primitives and
|
|
94
|
+
# cached back into `flags`. Left as `None` by the eager code
|
|
95
|
+
# paths (`from_evaluation_result` / `from_api_flags`).
|
|
96
|
+
_context: typing.Optional[SDKEvaluationContext] = None
|
|
97
|
+
_overrides_index: typing.Optional[SegmentOverridesIndex] = None
|
|
98
|
+
_fully_materialised: bool = False
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def from_evaluation_result(
|
|
102
|
+
cls,
|
|
103
|
+
evaluation_result: SDKEvaluationResult,
|
|
104
|
+
analytics_processor: typing.Optional[AnalyticsProcessor],
|
|
105
|
+
default_flag_handler: typing.Optional[typing.Callable[[str], DefaultFlag]],
|
|
106
|
+
pipeline_analytics_processor: typing.Optional[
|
|
107
|
+
PipelineAnalyticsProcessor
|
|
108
|
+
] = None,
|
|
109
|
+
identity_identifier: typing.Optional[str] = None,
|
|
110
|
+
traits: typing.Optional[typing.Dict[str, typing.Any]] = None,
|
|
111
|
+
) -> Flags:
|
|
112
|
+
return cls(
|
|
113
|
+
flags={
|
|
114
|
+
flag_name: flag
|
|
115
|
+
for flag_name, flag_result in evaluation_result["flags"].items()
|
|
116
|
+
if (flag := Flag.from_evaluation_result(flag_result))
|
|
117
|
+
},
|
|
118
|
+
default_flag_handler=default_flag_handler,
|
|
119
|
+
_analytics_processor=analytics_processor,
|
|
120
|
+
_pipeline_analytics_processor=pipeline_analytics_processor,
|
|
121
|
+
_identity_identifier=identity_identifier,
|
|
122
|
+
_traits=traits,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
def from_evaluation_context(
|
|
127
|
+
cls,
|
|
128
|
+
context: SDKEvaluationContext,
|
|
129
|
+
overrides_index: SegmentOverridesIndex,
|
|
130
|
+
analytics_processor: typing.Optional[AnalyticsProcessor],
|
|
131
|
+
default_flag_handler: typing.Optional[typing.Callable[[str], DefaultFlag]],
|
|
132
|
+
pipeline_analytics_processor: typing.Optional[
|
|
133
|
+
PipelineAnalyticsProcessor
|
|
134
|
+
] = None,
|
|
135
|
+
identity_identifier: typing.Optional[str] = None,
|
|
136
|
+
traits: typing.Optional[typing.Dict[str, typing.Any]] = None,
|
|
137
|
+
) -> Flags:
|
|
138
|
+
"""Build a lazy `Flags` backed by an evaluation context.
|
|
139
|
+
|
|
140
|
+
No engine work is done here — flags are resolved on first access
|
|
141
|
+
via :meth:`_resolve_flag`. Reusing the same `overrides_index`
|
|
142
|
+
across calls amortises its construction cost (it's rebuilt only
|
|
143
|
+
when the environment doc refreshes, not per identity).
|
|
144
|
+
"""
|
|
145
|
+
return cls(
|
|
146
|
+
flags={},
|
|
147
|
+
default_flag_handler=default_flag_handler,
|
|
148
|
+
_analytics_processor=analytics_processor,
|
|
149
|
+
_pipeline_analytics_processor=pipeline_analytics_processor,
|
|
150
|
+
_identity_identifier=identity_identifier,
|
|
151
|
+
_traits=traits,
|
|
152
|
+
_context=context,
|
|
153
|
+
_overrides_index=overrides_index,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
@classmethod
|
|
157
|
+
def from_api_flags(
|
|
158
|
+
cls,
|
|
159
|
+
api_flags: typing.Sequence[typing.Mapping[str, typing.Any]],
|
|
160
|
+
analytics_processor: typing.Optional[AnalyticsProcessor],
|
|
161
|
+
default_flag_handler: typing.Optional[typing.Callable[[str], DefaultFlag]],
|
|
162
|
+
pipeline_analytics_processor: typing.Optional[
|
|
163
|
+
PipelineAnalyticsProcessor
|
|
164
|
+
] = None,
|
|
165
|
+
identity_identifier: typing.Optional[str] = None,
|
|
166
|
+
traits: typing.Optional[typing.Dict[str, typing.Any]] = None,
|
|
167
|
+
) -> Flags:
|
|
168
|
+
flags = {
|
|
169
|
+
flag_data["feature"]["name"]: Flag.from_api_flag(flag_data)
|
|
170
|
+
for flag_data in api_flags
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return cls(
|
|
174
|
+
flags=flags,
|
|
175
|
+
default_flag_handler=default_flag_handler,
|
|
176
|
+
_analytics_processor=analytics_processor,
|
|
177
|
+
_pipeline_analytics_processor=pipeline_analytics_processor,
|
|
178
|
+
_identity_identifier=identity_identifier,
|
|
179
|
+
_traits=traits,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def all_flags(self) -> typing.List[Flag]:
|
|
183
|
+
"""
|
|
184
|
+
Get a list of all Flag objects.
|
|
185
|
+
|
|
186
|
+
In lazy mode, the caller has signalled they want every flag, so
|
|
187
|
+
we run the bulk evaluator once on the full context and copy the
|
|
188
|
+
results into the per-flag cache. Cheaper than asking the engine
|
|
189
|
+
for each feature one at a time.
|
|
190
|
+
|
|
191
|
+
:return: list of Flag objects.
|
|
192
|
+
"""
|
|
193
|
+
if self._context is not None and not self._fully_materialised:
|
|
194
|
+
result = engine.get_evaluation_result(self._context)
|
|
195
|
+
for feature_name, flag_result in result["flags"].items():
|
|
196
|
+
if feature_name not in self.flags:
|
|
197
|
+
self.flags[feature_name] = Flag.from_evaluation_result(
|
|
198
|
+
flag_result,
|
|
199
|
+
)
|
|
200
|
+
self._fully_materialised = True
|
|
201
|
+
return list(self.flags.values())
|
|
202
|
+
|
|
203
|
+
def is_feature_enabled(self, feature_name: str) -> bool:
|
|
204
|
+
"""
|
|
205
|
+
Check whether a given feature is enabled.
|
|
206
|
+
|
|
207
|
+
:param feature_name: the name of the feature to check if enabled.
|
|
208
|
+
:return: Boolean representing the enabled state of a given feature.
|
|
209
|
+
:raises FlagsmithClientError: if feature doesn't exist
|
|
210
|
+
"""
|
|
211
|
+
return self.get_flag(feature_name).enabled
|
|
212
|
+
|
|
213
|
+
def get_feature_value(self, feature_name: str) -> typing.Any:
|
|
214
|
+
"""
|
|
215
|
+
Get the value of a particular feature.
|
|
216
|
+
|
|
217
|
+
:param feature_name: the name of the feature to retrieve the value of.
|
|
218
|
+
:return: the value of the given feature.
|
|
219
|
+
:raises FlagsmithClientError: if feature doesn't exist
|
|
220
|
+
"""
|
|
221
|
+
return self.get_flag(feature_name).value
|
|
222
|
+
|
|
223
|
+
def get_flag(self, feature_name: str) -> typing.Union[DefaultFlag, Flag]:
|
|
224
|
+
"""
|
|
225
|
+
Get a specific flag given the feature name.
|
|
226
|
+
|
|
227
|
+
:param feature_name: the name of the feature to retrieve the flag for.
|
|
228
|
+
:return: DefaultFlag | Flag object.
|
|
229
|
+
:raises FlagsmithClientError: if feature doesn't exist
|
|
230
|
+
"""
|
|
231
|
+
try:
|
|
232
|
+
flag = self.flags[feature_name]
|
|
233
|
+
except KeyError:
|
|
234
|
+
# Lazy path: if this `Flags` wraps an evaluation context and
|
|
235
|
+
# the feature exists in it, resolve and memoise now. Otherwise
|
|
236
|
+
# fall through to the default_flag_handler / not-found error,
|
|
237
|
+
# preserving the eager-mode behaviour byte-for-byte.
|
|
238
|
+
if (
|
|
239
|
+
self._context is not None
|
|
240
|
+
and self._overrides_index is not None
|
|
241
|
+
and feature_name in (self._context.get("features") or {})
|
|
242
|
+
):
|
|
243
|
+
flag = self._resolve_flag(feature_name)
|
|
244
|
+
self.flags[feature_name] = flag
|
|
245
|
+
elif self.default_flag_handler:
|
|
246
|
+
return self.default_flag_handler(feature_name)
|
|
247
|
+
else:
|
|
248
|
+
raise FlagsmithFeatureDoesNotExistError(
|
|
249
|
+
"Feature does not exist: %s" % feature_name
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
if self._analytics_processor and hasattr(flag, "feature_name"):
|
|
253
|
+
self._analytics_processor.track_feature(flag.feature_name)
|
|
254
|
+
|
|
255
|
+
if self._pipeline_analytics_processor and hasattr(flag, "feature_name"):
|
|
256
|
+
self._pipeline_analytics_processor.record_evaluation_event(
|
|
257
|
+
flag_key=flag.feature_name,
|
|
258
|
+
enabled=flag.enabled,
|
|
259
|
+
value=flag.value,
|
|
260
|
+
identity_identifier=self._identity_identifier,
|
|
261
|
+
traits=self._traits,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
return flag
|
|
265
|
+
|
|
266
|
+
def _resolve_flag(self, feature_name: str) -> Flag:
|
|
267
|
+
"""Evaluate a single feature against the lazy context.
|
|
268
|
+
|
|
269
|
+
Goes through the engine's public `get_evaluation_result` so
|
|
270
|
+
identity-key enrichment, multivariate hashing, percentage-split
|
|
271
|
+
rules and override-priority handling all stay where they
|
|
272
|
+
belong (in the engine). The performance win comes from passing
|
|
273
|
+
a *trimmed* context — just the queried feature plus the segments
|
|
274
|
+
that could override it, looked up in O(1) via the precomputed
|
|
275
|
+
reverse index — so the engine's full pipeline runs against an
|
|
276
|
+
input small enough to evaluate in ~1 µs.
|
|
277
|
+
"""
|
|
278
|
+
context = self._context
|
|
279
|
+
overrides_index = self._overrides_index
|
|
280
|
+
# `get_flag` / `all_flags` gate this call behind the same
|
|
281
|
+
# non-None checks; assert here so type checkers can narrow.
|
|
282
|
+
assert context is not None and overrides_index is not None
|
|
283
|
+
|
|
284
|
+
trimmed: SDKEvaluationContext = {
|
|
285
|
+
**context,
|
|
286
|
+
"features": {feature_name: context["features"][feature_name]},
|
|
287
|
+
"segments": {
|
|
288
|
+
segment_context["key"]: segment_context
|
|
289
|
+
for segment_context in overrides_index.get(feature_name, ())
|
|
290
|
+
},
|
|
291
|
+
}
|
|
292
|
+
result = engine.get_evaluation_result(trimmed)
|
|
293
|
+
return Flag.from_evaluation_result(result["flags"][feature_name])
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@dataclass
|
|
297
|
+
class Segment:
|
|
298
|
+
id: int
|
|
299
|
+
name: str
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "flagsmith"
|
|
3
|
-
version = "5.
|
|
3
|
+
version = "5.3.0"
|
|
4
4
|
description = "Flagsmith Python SDK"
|
|
5
5
|
authors = ["Flagsmith <support@flagsmith.com>"]
|
|
6
6
|
license = "BSD3"
|
|
@@ -10,7 +10,7 @@ documentation = "https://docs.flagsmith.com"
|
|
|
10
10
|
packages = [{ include = "flagsmith" }]
|
|
11
11
|
|
|
12
12
|
[tool.poetry.dependencies]
|
|
13
|
-
flagsmith-flag-engine = "^10.0.
|
|
13
|
+
flagsmith-flag-engine = "^10.0.4"
|
|
14
14
|
iso8601 = { version = "^2.1.0", python = "<3.11" }
|
|
15
15
|
python = ">=3.9,<4"
|
|
16
16
|
requests = "^2.32.3"
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import typing
|
|
3
|
-
from datetime import datetime
|
|
4
|
-
|
|
5
|
-
from requests_futures.sessions import FuturesSession # type: ignore
|
|
6
|
-
|
|
7
|
-
ANALYTICS_ENDPOINT: typing.Final[str] = "analytics/flags/"
|
|
8
|
-
|
|
9
|
-
# Used to control how often we send data(in seconds)
|
|
10
|
-
ANALYTICS_TIMER: typing.Final[int] = 10
|
|
11
|
-
|
|
12
|
-
session = FuturesSession(max_workers=4)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
class AnalyticsProcessor:
|
|
16
|
-
"""
|
|
17
|
-
AnalyticsProcessor is used to track how often individual Flags are evaluated within
|
|
18
|
-
the Flagsmith SDK. Docs: https://docs.flagsmith.com/advanced-use/flag-analytics.
|
|
19
|
-
"""
|
|
20
|
-
|
|
21
|
-
def __init__(
|
|
22
|
-
self, environment_key: str, base_api_url: str, timeout: typing.Optional[int] = 3
|
|
23
|
-
):
|
|
24
|
-
"""
|
|
25
|
-
Initialise the AnalyticsProcessor to handle sending analytics on flag usage to
|
|
26
|
-
the Flagsmith API.
|
|
27
|
-
|
|
28
|
-
:param environment_key: environment key obtained from the Flagsmith UI
|
|
29
|
-
:param base_api_url: base api url to override when using self hosted version
|
|
30
|
-
:param timeout: used to tell requests to stop waiting for a response after a
|
|
31
|
-
given number of seconds
|
|
32
|
-
"""
|
|
33
|
-
self.analytics_endpoint = base_api_url + ANALYTICS_ENDPOINT
|
|
34
|
-
self.environment_key = environment_key
|
|
35
|
-
self._last_flushed = datetime.now()
|
|
36
|
-
self.analytics_data: typing.MutableMapping[str, typing.Any] = {}
|
|
37
|
-
self.timeout = timeout or 3
|
|
38
|
-
|
|
39
|
-
def flush(self) -> None:
|
|
40
|
-
"""
|
|
41
|
-
Sends all the collected data to the api asynchronously and resets the timer
|
|
42
|
-
"""
|
|
43
|
-
|
|
44
|
-
if not self.analytics_data:
|
|
45
|
-
return
|
|
46
|
-
session.post(
|
|
47
|
-
self.analytics_endpoint,
|
|
48
|
-
data=json.dumps(self.analytics_data),
|
|
49
|
-
timeout=self.timeout,
|
|
50
|
-
headers={
|
|
51
|
-
"X-Environment-Key": self.environment_key,
|
|
52
|
-
"Content-Type": "application/json",
|
|
53
|
-
},
|
|
54
|
-
)
|
|
55
|
-
|
|
56
|
-
self.analytics_data.clear()
|
|
57
|
-
self._last_flushed = datetime.now()
|
|
58
|
-
|
|
59
|
-
def track_feature(self, feature_name: str) -> None:
|
|
60
|
-
self.analytics_data[feature_name] = self.analytics_data.get(feature_name, 0) + 1
|
|
61
|
-
if (datetime.now() - self._last_flushed).seconds > ANALYTICS_TIMER:
|
|
62
|
-
self.flush()
|
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import typing
|
|
4
|
-
from dataclasses import dataclass, field
|
|
5
|
-
|
|
6
|
-
from flagsmith.analytics import AnalyticsProcessor
|
|
7
|
-
from flagsmith.exceptions import FlagsmithFeatureDoesNotExistError
|
|
8
|
-
from flagsmith.types import SDKEvaluationResult, SDKFlagResult
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
@dataclass
|
|
12
|
-
class BaseFlag:
|
|
13
|
-
enabled: bool
|
|
14
|
-
value: typing.Union[str, int, float, bool, None]
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
@dataclass
|
|
18
|
-
class DefaultFlag(BaseFlag):
|
|
19
|
-
is_default: bool = field(default=True)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
@dataclass
|
|
23
|
-
class Flag(BaseFlag):
|
|
24
|
-
feature_id: int
|
|
25
|
-
feature_name: str
|
|
26
|
-
is_default: bool = field(default=False)
|
|
27
|
-
|
|
28
|
-
@classmethod
|
|
29
|
-
def from_evaluation_result(
|
|
30
|
-
cls,
|
|
31
|
-
flag_result: SDKFlagResult,
|
|
32
|
-
) -> Flag:
|
|
33
|
-
if metadata := flag_result.get("metadata"):
|
|
34
|
-
return Flag(
|
|
35
|
-
enabled=flag_result["enabled"],
|
|
36
|
-
value=flag_result["value"],
|
|
37
|
-
feature_name=flag_result["name"],
|
|
38
|
-
feature_id=metadata["id"],
|
|
39
|
-
)
|
|
40
|
-
raise ValueError(
|
|
41
|
-
"FlagResult metadata is missing. Cannot create Flag instance. "
|
|
42
|
-
"This means a bug in the SDK, please report it."
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
@classmethod
|
|
46
|
-
def from_api_flag(cls, flag_data: typing.Mapping[str, typing.Any]) -> Flag:
|
|
47
|
-
return Flag(
|
|
48
|
-
enabled=flag_data["enabled"],
|
|
49
|
-
value=flag_data["feature_state_value"],
|
|
50
|
-
feature_name=flag_data["feature"]["name"],
|
|
51
|
-
feature_id=flag_data["feature"]["id"],
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
@dataclass
|
|
56
|
-
class Flags:
|
|
57
|
-
flags: typing.Dict[str, Flag] = field(default_factory=dict)
|
|
58
|
-
default_flag_handler: typing.Optional[typing.Callable[[str], DefaultFlag]] = None
|
|
59
|
-
_analytics_processor: typing.Optional[AnalyticsProcessor] = None
|
|
60
|
-
|
|
61
|
-
@classmethod
|
|
62
|
-
def from_evaluation_result(
|
|
63
|
-
cls,
|
|
64
|
-
evaluation_result: SDKEvaluationResult,
|
|
65
|
-
analytics_processor: typing.Optional[AnalyticsProcessor],
|
|
66
|
-
default_flag_handler: typing.Optional[typing.Callable[[str], DefaultFlag]],
|
|
67
|
-
) -> Flags:
|
|
68
|
-
return cls(
|
|
69
|
-
flags={
|
|
70
|
-
flag_name: flag
|
|
71
|
-
for flag_name, flag_result in evaluation_result["flags"].items()
|
|
72
|
-
if (flag := Flag.from_evaluation_result(flag_result))
|
|
73
|
-
},
|
|
74
|
-
default_flag_handler=default_flag_handler,
|
|
75
|
-
_analytics_processor=analytics_processor,
|
|
76
|
-
)
|
|
77
|
-
|
|
78
|
-
@classmethod
|
|
79
|
-
def from_api_flags(
|
|
80
|
-
cls,
|
|
81
|
-
api_flags: typing.Sequence[typing.Mapping[str, typing.Any]],
|
|
82
|
-
analytics_processor: typing.Optional[AnalyticsProcessor],
|
|
83
|
-
default_flag_handler: typing.Optional[typing.Callable[[str], DefaultFlag]],
|
|
84
|
-
) -> Flags:
|
|
85
|
-
flags = {
|
|
86
|
-
flag_data["feature"]["name"]: Flag.from_api_flag(flag_data)
|
|
87
|
-
for flag_data in api_flags
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
return cls(
|
|
91
|
-
flags=flags,
|
|
92
|
-
default_flag_handler=default_flag_handler,
|
|
93
|
-
_analytics_processor=analytics_processor,
|
|
94
|
-
)
|
|
95
|
-
|
|
96
|
-
def all_flags(self) -> typing.List[Flag]:
|
|
97
|
-
"""
|
|
98
|
-
Get a list of all Flag objects.
|
|
99
|
-
|
|
100
|
-
:return: list of Flag objects.
|
|
101
|
-
"""
|
|
102
|
-
return list(self.flags.values())
|
|
103
|
-
|
|
104
|
-
def is_feature_enabled(self, feature_name: str) -> bool:
|
|
105
|
-
"""
|
|
106
|
-
Check whether a given feature is enabled.
|
|
107
|
-
|
|
108
|
-
:param feature_name: the name of the feature to check if enabled.
|
|
109
|
-
:return: Boolean representing the enabled state of a given feature.
|
|
110
|
-
:raises FlagsmithClientError: if feature doesn't exist
|
|
111
|
-
"""
|
|
112
|
-
return self.get_flag(feature_name).enabled
|
|
113
|
-
|
|
114
|
-
def get_feature_value(self, feature_name: str) -> typing.Any:
|
|
115
|
-
"""
|
|
116
|
-
Get the value of a particular feature.
|
|
117
|
-
|
|
118
|
-
:param feature_name: the name of the feature to retrieve the value of.
|
|
119
|
-
:return: the value of the given feature.
|
|
120
|
-
:raises FlagsmithClientError: if feature doesn't exist
|
|
121
|
-
"""
|
|
122
|
-
return self.get_flag(feature_name).value
|
|
123
|
-
|
|
124
|
-
def get_flag(self, feature_name: str) -> typing.Union[DefaultFlag, Flag]:
|
|
125
|
-
"""
|
|
126
|
-
Get a specific flag given the feature name.
|
|
127
|
-
|
|
128
|
-
:param feature_name: the name of the feature to retrieve the flag for.
|
|
129
|
-
:return: DefaultFlag | Flag object.
|
|
130
|
-
:raises FlagsmithClientError: if feature doesn't exist
|
|
131
|
-
"""
|
|
132
|
-
try:
|
|
133
|
-
flag = self.flags[feature_name]
|
|
134
|
-
except KeyError:
|
|
135
|
-
if self.default_flag_handler:
|
|
136
|
-
return self.default_flag_handler(feature_name)
|
|
137
|
-
raise FlagsmithFeatureDoesNotExistError(
|
|
138
|
-
"Feature does not exist: %s" % feature_name
|
|
139
|
-
)
|
|
140
|
-
|
|
141
|
-
if self._analytics_processor and hasattr(flag, "feature_name"):
|
|
142
|
-
self._analytics_processor.track_feature(flag.feature_name)
|
|
143
|
-
|
|
144
|
-
return flag
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
@dataclass
|
|
148
|
-
class Segment:
|
|
149
|
-
id: int
|
|
150
|
-
name: str
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|