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.
Files changed (25) hide show
  1. {flagsmith-5.1.1 → flagsmith-5.3.0}/PKG-INFO +2 -2
  2. flagsmith-5.3.0/flagsmith/__init__.py +6 -0
  3. flagsmith-5.3.0/flagsmith/analytics.py +229 -0
  4. {flagsmith-5.1.1 → flagsmith-5.3.0}/flagsmith/flagsmith.py +96 -12
  5. {flagsmith-5.1.1 → flagsmith-5.3.0}/flagsmith/mappers.py +14 -19
  6. flagsmith-5.3.0/flagsmith/models.py +299 -0
  7. {flagsmith-5.1.1 → flagsmith-5.3.0}/pyproject.toml +2 -2
  8. flagsmith-5.1.1/flagsmith/__init__.py +0 -5
  9. flagsmith-5.1.1/flagsmith/analytics.py +0 -62
  10. flagsmith-5.1.1/flagsmith/models.py +0 -150
  11. {flagsmith-5.1.1 → flagsmith-5.3.0}/LICENSE +0 -0
  12. {flagsmith-5.1.1 → flagsmith-5.3.0}/README.md +0 -0
  13. {flagsmith-5.1.1 → flagsmith-5.3.0}/flagsmith/api/__init__.py +0 -0
  14. {flagsmith-5.1.1 → flagsmith-5.3.0}/flagsmith/api/types.py +0 -0
  15. {flagsmith-5.1.1 → flagsmith-5.3.0}/flagsmith/exceptions.py +0 -0
  16. {flagsmith-5.1.1 → flagsmith-5.3.0}/flagsmith/offline_handlers.py +0 -0
  17. {flagsmith-5.1.1 → flagsmith-5.3.0}/flagsmith/polling_manager.py +0 -0
  18. {flagsmith-5.1.1 → flagsmith-5.3.0}/flagsmith/py.typed +0 -0
  19. {flagsmith-5.1.1 → flagsmith-5.3.0}/flagsmith/streaming_manager.py +0 -0
  20. {flagsmith-5.1.1 → flagsmith-5.3.0}/flagsmith/types.py +0 -0
  21. {flagsmith-5.1.1 → flagsmith-5.3.0}/flagsmith/utils/__init__.py +0 -0
  22. {flagsmith-5.1.1 → flagsmith-5.3.0}/flagsmith/utils/datetime.py +0 -0
  23. {flagsmith-5.1.1 → flagsmith-5.3.0}/flagsmith/utils/identities.py +0 -0
  24. {flagsmith-5.1.1 → flagsmith-5.3.0}/flagsmith/version.py +0 -0
  25. {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.1.1
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.3,<11.0.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,6 @@
1
+ from flagsmith import webhooks
2
+ from flagsmith.analytics import PipelineAnalyticsConfig
3
+ from flagsmith.flagsmith import Flagsmith
4
+ from flagsmith.version import __version__
5
+
6
+ __all__ = ("Flagsmith", "PipelineAnalyticsConfig", "webhooks", "__version__")
@@ -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 AnalyticsProcessor
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._evaluation_context: typing.Optional[SDKEvaluationContext] = None
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
- if enable_analytics:
174
- self._analytics_processor = AnalyticsProcessor(
175
- environment_key, self.api_url, timeout=self.request_timeout_seconds
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
- evaluation_result = engine.get_evaluation_result(
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
- TraitConfig,
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.1.1"
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.3"
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,5 +0,0 @@
1
- from flagsmith import webhooks
2
- from flagsmith.flagsmith import Flagsmith
3
- from flagsmith.version import __version__
4
-
5
- __all__ = ("Flagsmith", "webhooks", "__version__")
@@ -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