flagsmith 5.2.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flagsmith
3
- Version: 5.2.0
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)
@@ -21,7 +21,13 @@ from flagsmith.mappers import (
21
21
  map_segment_results_to_identity_segments,
22
22
  resolve_trait_values,
23
23
  )
24
- from flagsmith.models import DefaultFlag, Flags, Segment
24
+ from flagsmith.models import (
25
+ DefaultFlag,
26
+ Flags,
27
+ Segment,
28
+ SegmentOverridesIndex,
29
+ build_segment_overrides_index,
30
+ )
25
31
  from flagsmith.offline_handlers import OfflineHandler
26
32
  from flagsmith.polling_manager import EnvironmentDataPollingManager
27
33
  from flagsmith.streaming_manager import EventStreamManager
@@ -117,7 +123,8 @@ class Flagsmith:
117
123
  self._pipeline_analytics_processor: typing.Optional[
118
124
  PipelineAnalyticsProcessor
119
125
  ] = None
120
- self._evaluation_context: typing.Optional[SDKEvaluationContext] = None
126
+ self.__evaluation_context: typing.Optional[SDKEvaluationContext] = None
127
+ self._segment_overrides_index: SegmentOverridesIndex = {}
121
128
  self._environment_updated_at: typing.Optional[datetime] = None
122
129
 
123
130
  # argument validation
@@ -356,6 +363,26 @@ class Flagsmith:
356
363
  except (KeyError, TypeError, ValueError):
357
364
  logger.exception("Error parsing environment document")
358
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
+
359
386
  def _get_headers(
360
387
  self,
361
388
  environment_key: str,
@@ -407,12 +434,12 @@ class Flagsmith:
407
434
  identifier=identifier,
408
435
  traits=traits,
409
436
  )
410
- 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(
411
441
  context=context,
412
- )
413
-
414
- return Flags.from_evaluation_result(
415
- evaluation_result=evaluation_result,
442
+ overrides_index=self._segment_overrides_index,
416
443
  analytics_processor=self._analytics_processor,
417
444
  default_flag_handler=self.default_flag_handler,
418
445
  pipeline_analytics_processor=self._pipeline_analytics_processor,
@@ -3,9 +3,37 @@ from __future__ import annotations
3
3
  import typing
4
4
  from dataclasses import dataclass, field
5
5
 
6
+ from flag_engine import engine
7
+ from flag_engine.context.types import SegmentContext
8
+
6
9
  from flagsmith.analytics import AnalyticsProcessor, PipelineAnalyticsProcessor
7
10
  from flagsmith.exceptions import FlagsmithFeatureDoesNotExistError
8
- from flagsmith.types import SDKEvaluationResult, SDKFlagResult
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
9
37
 
10
38
 
11
39
  @dataclass
@@ -60,6 +88,14 @@ class Flags:
60
88
  _pipeline_analytics_processor: typing.Optional[PipelineAnalyticsProcessor] = None
61
89
  _identity_identifier: typing.Optional[str] = None
62
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
63
99
 
64
100
  @classmethod
65
101
  def from_evaluation_result(
@@ -86,6 +122,37 @@ class Flags:
86
122
  _traits=traits,
87
123
  )
88
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
+
89
156
  @classmethod
90
157
  def from_api_flags(
91
158
  cls,
@@ -116,8 +183,21 @@ class Flags:
116
183
  """
117
184
  Get a list of all Flag objects.
118
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
+
119
191
  :return: list of Flag objects.
120
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
121
201
  return list(self.flags.values())
122
202
 
123
203
  def is_feature_enabled(self, feature_name: str) -> bool:
@@ -151,11 +231,23 @@ class Flags:
151
231
  try:
152
232
  flag = self.flags[feature_name]
153
233
  except KeyError:
154
- if self.default_flag_handler:
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:
155
246
  return self.default_flag_handler(feature_name)
156
- raise FlagsmithFeatureDoesNotExistError(
157
- "Feature does not exist: %s" % feature_name
158
- )
247
+ else:
248
+ raise FlagsmithFeatureDoesNotExistError(
249
+ "Feature does not exist: %s" % feature_name
250
+ )
159
251
 
160
252
  if self._analytics_processor and hasattr(flag, "feature_name"):
161
253
  self._analytics_processor.track_feature(flag.feature_name)
@@ -171,6 +263,35 @@ class Flags:
171
263
 
172
264
  return flag
173
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
+
174
295
 
175
296
  @dataclass
176
297
  class Segment:
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "flagsmith"
3
- version = "5.2.0"
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"
File without changes
File without changes
File without changes
File without changes