flagsmith 6.1.0__tar.gz → 6.2.1__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: 6.1.0
3
+ Version: 6.2.1
4
4
  Summary: Flagsmith Python SDK
5
5
  License: BSD3
6
6
  License-File: LICENSE
@@ -83,6 +83,12 @@ class AnalyticsProcessor:
83
83
  self.flush()
84
84
 
85
85
 
86
+ class ExposureKey(typing.NamedTuple):
87
+ feature_name: typing.Optional[str]
88
+ identifier: typing.Optional[str]
89
+ value: typing.Optional[str]
90
+
91
+
86
92
  @dataclass
87
93
  class EventProcessorConfig:
88
94
  events_api_url: str = DEFAULT_EVENT_API_URL
@@ -111,6 +117,7 @@ class EventProcessor:
111
117
  self._flush_interval_seconds = config.flush_interval_seconds
112
118
 
113
119
  self._buffer: typing.List[typing.Dict[str, typing.Any]] = []
120
+ self._buffered_exposure_keys: typing.Set[ExposureKey] = set()
114
121
  self._lock = threading.Lock()
115
122
  self._timer: typing.Optional[threading.Timer] = None
116
123
 
@@ -159,6 +166,24 @@ class EventProcessor:
159
166
  ) -> None:
160
167
  should_flush = False
161
168
  with self._lock:
169
+ if event == FLAG_EXPOSURE_EVENT:
170
+ # An exposure is defined by who saw which variant of which
171
+ # feature; equal exposures within one flush interval add no
172
+ # information, so only the first is buffered.
173
+ exposure_key = ExposureKey(
174
+ feature_name=feature_name,
175
+ identifier=identifier,
176
+ value=str(value) if value is not None else None,
177
+ )
178
+ if exposure_key in self._buffered_exposure_keys:
179
+ logger.debug(
180
+ "Skipping duplicate %s event for feature %s, identifier %s",
181
+ FLAG_EXPOSURE_EVENT,
182
+ feature_name,
183
+ identifier,
184
+ )
185
+ return
186
+ self._buffered_exposure_keys.add(exposure_key)
162
187
  self._buffer.append(
163
188
  {
164
189
  "event": event,
@@ -182,6 +207,7 @@ class EventProcessor:
182
207
  return
183
208
  events = self._buffer
184
209
  self._buffer = []
210
+ self._buffered_exposure_keys.clear()
185
211
 
186
212
  payload = json.dumps({"events": events})
187
213
  try:
@@ -368,19 +368,38 @@ class Flagsmith:
368
368
  """
369
369
  Resolve a flag for an identity and record an exposure event.
370
370
 
371
- Skips the exposure event when the resolved flag is a `DefaultFlag`
372
- (i.e. the feature was not present and was served via the
373
- `default_flag_handler`) or when the feature is disabled, to keep
374
- experimentation data clean.
371
+ The exposure event's ``value`` is the flag's variant key. It is only
372
+ sent when the flag exists, is enabled and carries a variant; any
373
+ other outcome is logged and skipped to keep experimentation data
374
+ clean. A `DefaultFlag` served via the `default_flag_handler` counts
375
+ as the feature not existing.
375
376
  """
376
377
  if not self._event_processor:
377
378
  raise ValueError("Events must be enabled to use experiment flags.")
378
379
  flag = self.get_identity_flags(identifier, traits).get_flag(feature_name)
379
- if isinstance(flag, Flag) and flag.enabled:
380
+ if not isinstance(flag, Flag):
381
+ logger.debug(
382
+ "Not sending %s for feature %s: feature not found.",
383
+ FLAG_EXPOSURE_EVENT,
384
+ feature_name,
385
+ )
386
+ elif not flag.enabled:
387
+ logger.debug(
388
+ "Not sending %s for feature %s: feature is disabled.",
389
+ FLAG_EXPOSURE_EVENT,
390
+ feature_name,
391
+ )
392
+ elif flag.variant is None:
393
+ logger.debug(
394
+ "Not sending %s for feature %s: flag has no variant.",
395
+ FLAG_EXPOSURE_EVENT,
396
+ feature_name,
397
+ )
398
+ else:
380
399
  self.track_exposure_event(
381
400
  feature_name=feature_name,
382
401
  identifier=identifier,
383
- value=flag.variant if flag.variant is not None else flag.value,
402
+ value=flag.variant,
384
403
  traits=traits,
385
404
  )
386
405
  return flag
@@ -418,6 +437,14 @@ class Flagsmith:
418
437
  ) -> None:
419
438
  if not self._event_processor:
420
439
  raise ValueError("Events must be enabled to track exposure events.")
440
+ if not identifier:
441
+ logger.warning(
442
+ "Not sending %s for feature %s: an exposure requires an"
443
+ " identifier to reconcile with conversion events.",
444
+ FLAG_EXPOSURE_EVENT,
445
+ feature_name,
446
+ )
447
+ return
421
448
  self._event_processor.track_exposure_event(
422
449
  feature_name=feature_name,
423
450
  identifier=identifier,
@@ -177,7 +177,7 @@ def _map_identity_overrides_to_segments(
177
177
  # Generate a unique key to avoid collisions
178
178
  segment_key = str(hash(overrides_key))
179
179
  segment_contexts[segment_key] = SegmentContext(
180
- key="", # Identity override segments never use % Split operator
180
+ key=segment_key,
181
181
  name="identity_overrides",
182
182
  rules=[
183
183
  {
@@ -17,7 +17,7 @@ from flagsmith.types import (
17
17
  )
18
18
 
19
19
  SegmentOverridesIndex = typing.Dict[
20
- str, typing.List[SegmentContext[SegmentMetadata, FeatureMetadata]]
20
+ str, typing.Dict[str, SegmentContext[SegmentMetadata, FeatureMetadata]]
21
21
  ]
22
22
 
23
23
 
@@ -28,11 +28,16 @@ def build_segment_overrides_index(
28
28
 
29
29
  Computed once per environment-document refresh so the lazy eval path
30
30
  can walk only the segments actually relevant to a given flag.
31
+
32
+ Each entry keeps the context's own segment keys, so the trimmed
33
+ contexts built by `Flags._resolve_flag` stay a faithful subset of
34
+ the environment. Re-keying them by anything else risks collisions
35
+ silently dropping segments.
31
36
  """
32
37
  index: SegmentOverridesIndex = {}
33
- for segment_context in (context.get("segments") or {}).values():
38
+ for segment_key, segment_context in (context.get("segments") or {}).items():
34
39
  for override in segment_context.get("overrides") or ():
35
- index.setdefault(override["name"], []).append(segment_context)
40
+ index.setdefault(override["name"], {})[segment_key] = segment_context
36
41
  return index
37
42
 
38
43
 
@@ -52,6 +57,7 @@ class Flag(BaseFlag):
52
57
  feature_id: int
53
58
  feature_name: str
54
59
  variant: typing.Optional[str] = None
60
+ reason: typing.Optional[str] = None
55
61
  is_default: bool = field(default=False)
56
62
 
57
63
  @classmethod
@@ -66,6 +72,7 @@ class Flag(BaseFlag):
66
72
  feature_name=flag_result["name"],
67
73
  feature_id=metadata["id"],
68
74
  variant=flag_result.get("variant"),
75
+ reason=flag_result["reason"],
69
76
  )
70
77
  raise ValueError(
71
78
  "FlagResult metadata is missing. Cannot create Flag instance. "
@@ -80,6 +87,7 @@ class Flag(BaseFlag):
80
87
  feature_name=flag_data["feature"]["name"],
81
88
  feature_id=flag_data["feature"]["id"],
82
89
  variant=flag_data.get("variant"),
90
+ reason=flag_data.get("reason"),
83
91
  )
84
92
 
85
93
 
@@ -251,10 +259,7 @@ class Flags:
251
259
  trimmed: SDKEvaluationContext = {
252
260
  **context,
253
261
  "features": {feature_name: context["features"][feature_name]},
254
- "segments": {
255
- segment_context["key"]: segment_context
256
- for segment_context in overrides_index.get(feature_name, ())
257
- },
262
+ "segments": overrides_index.get(feature_name, {}),
258
263
  }
259
264
  result = engine.get_evaluation_result(trimmed)
260
265
  return Flag.from_evaluation_result(result["flags"][feature_name])
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "flagsmith"
3
- version = "6.1.0"
3
+ version = "6.2.1"
4
4
  description = "Flagsmith Python SDK"
5
5
  authors = ["Flagsmith <support@flagsmith.com>"]
6
6
  license = "BSD3"
File without changes
File without changes
File without changes
File without changes