flagsmith 6.0.0__tar.gz → 6.2.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: 6.0.0
3
+ Version: 6.2.0
4
4
  Summary: Flagsmith Python SDK
5
5
  License: BSD3
6
6
  License-File: LICENSE
@@ -31,7 +31,11 @@ class AnalyticsProcessor:
31
31
  """
32
32
 
33
33
  def __init__(
34
- self, environment_key: str, base_api_url: str, timeout: typing.Optional[int] = 3
34
+ self,
35
+ environment_key: str,
36
+ base_api_url: str,
37
+ timeout: typing.Optional[int] = 3,
38
+ analytics_url: typing.Optional[str] = None,
35
39
  ):
36
40
  """
37
41
  Initialise the AnalyticsProcessor to handle sending analytics on flag usage to
@@ -41,8 +45,13 @@ class AnalyticsProcessor:
41
45
  :param base_api_url: base api url to override when using self hosted version
42
46
  :param timeout: used to tell requests to stop waiting for a response after a
43
47
  given number of seconds
48
+ :param analytics_url: full URL of the flag analytics endpoint, used to override
49
+ the default ``<base_api_url>/analytics/flags/``. Intended for deployments
50
+ where flag evaluation traffic and analytics traffic must go to different
51
+ hosts (for example, evaluating through the Edge Proxy while sending
52
+ analytics to the core API).
44
53
  """
45
- self.analytics_endpoint = base_api_url + ANALYTICS_ENDPOINT
54
+ self.analytics_endpoint = analytics_url or (base_api_url + ANALYTICS_ENDPOINT)
46
55
  self.environment_key = environment_key
47
56
  self._last_flushed = datetime.now()
48
57
  self.analytics_data: typing.MutableMapping[str, typing.Any] = {}
@@ -74,6 +83,12 @@ class AnalyticsProcessor:
74
83
  self.flush()
75
84
 
76
85
 
86
+ class ExposureKey(typing.NamedTuple):
87
+ feature_name: typing.Optional[str]
88
+ identifier: typing.Optional[str]
89
+ value: typing.Optional[str]
90
+
91
+
77
92
  @dataclass
78
93
  class EventProcessorConfig:
79
94
  events_api_url: str = DEFAULT_EVENT_API_URL
@@ -102,6 +117,7 @@ class EventProcessor:
102
117
  self._flush_interval_seconds = config.flush_interval_seconds
103
118
 
104
119
  self._buffer: typing.List[typing.Dict[str, typing.Any]] = []
120
+ self._buffered_exposure_keys: typing.Set[ExposureKey] = set()
105
121
  self._lock = threading.Lock()
106
122
  self._timer: typing.Optional[threading.Timer] = None
107
123
 
@@ -150,6 +166,24 @@ class EventProcessor:
150
166
  ) -> None:
151
167
  should_flush = False
152
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)
153
187
  self._buffer.append(
154
188
  {
155
189
  "event": event,
@@ -173,6 +207,7 @@ class EventProcessor:
173
207
  return
174
208
  events = self._buffer
175
209
  self._buffer = []
210
+ self._buffered_exposure_keys.clear()
176
211
 
177
212
  payload = json.dumps({"events": events})
178
213
  try:
@@ -70,6 +70,7 @@ class Flagsmith:
70
70
  environment_key: typing.Optional[str] = None,
71
71
  api_url: typing.Optional[str] = None,
72
72
  realtime_api_url: typing.Optional[str] = None,
73
+ analytics_url: typing.Optional[str] = None,
73
74
  custom_headers: typing.Optional[typing.Dict[str, typing.Any]] = None,
74
75
  request_timeout_seconds: typing.Optional[int] = 10,
75
76
  enable_local_evaluation: bool = False,
@@ -92,6 +93,11 @@ class Flagsmith:
92
93
  Required unless offline_mode is True.
93
94
  :param api_url: Override the URL of the Flagsmith API to communicate with
94
95
  :param realtime_api_url: Override the URL of the Flagsmith real-time API
96
+ :param analytics_url: Override the URL used for flag analytics requests when
97
+ enable_analytics is True. When unset, analytics are posted to
98
+ ``<api_url>/analytics/flags/``. Set this when api_url points at a host that
99
+ does not handle analytics (for example, the Edge Proxy) so analytics can be
100
+ sent directly to the core Flagsmith API.
95
101
  :param custom_headers: Additional headers to add to requests made to the
96
102
  Flagsmith API
97
103
  :param request_timeout_seconds: Number of seconds to wait for a request to
@@ -172,14 +178,12 @@ class Flagsmith:
172
178
  self.session.proxies.update(proxies or {})
173
179
  retries = retries or Retry(total=3, backoff_factor=0.1)
174
180
 
175
- api_url = api_url or DEFAULT_API_URL
176
- self.api_url = api_url if api_url.endswith("/") else f"{api_url}/"
177
-
178
- realtime_api_url = realtime_api_url or DEFAULT_REALTIME_API_URL
179
- self.realtime_api_url = (
180
- realtime_api_url
181
- if realtime_api_url.endswith("/")
182
- else f"{realtime_api_url}/"
181
+ self.api_url = self._ensure_trailing_slash(api_url or DEFAULT_API_URL)
182
+ self.realtime_api_url = self._ensure_trailing_slash(
183
+ realtime_api_url or DEFAULT_REALTIME_API_URL
184
+ )
185
+ self.analytics_url = (
186
+ self._ensure_trailing_slash(analytics_url) if analytics_url else None
183
187
  )
184
188
 
185
189
  self.request_timeout_seconds = request_timeout_seconds
@@ -201,6 +205,7 @@ class Flagsmith:
201
205
  self._initialise_analytics(
202
206
  environment_key=environment_key,
203
207
  enable_analytics=enable_analytics,
208
+ analytics_url=self.analytics_url,
204
209
  )
205
210
  self._initialise_events(
206
211
  environment_key=environment_key,
@@ -208,14 +213,22 @@ class Flagsmith:
208
213
  event_processor_config=event_processor_config,
209
214
  )
210
215
 
216
+ @staticmethod
217
+ def _ensure_trailing_slash(url: str) -> str:
218
+ return url if url.endswith("/") else f"{url}/"
219
+
211
220
  def _initialise_analytics(
212
221
  self,
213
222
  environment_key: str,
214
223
  enable_analytics: bool,
224
+ analytics_url: typing.Optional[str] = None,
215
225
  ) -> None:
216
226
  if enable_analytics:
217
227
  self._analytics_processor = AnalyticsProcessor(
218
- environment_key, self.api_url, timeout=self.request_timeout_seconds
228
+ environment_key,
229
+ self.api_url,
230
+ timeout=self.request_timeout_seconds,
231
+ analytics_url=analytics_url,
219
232
  )
220
233
 
221
234
  def _initialise_events(
@@ -355,19 +368,38 @@ class Flagsmith:
355
368
  """
356
369
  Resolve a flag for an identity and record an exposure event.
357
370
 
358
- Skips the exposure event when the resolved flag is a `DefaultFlag`
359
- (i.e. the feature was not present and was served via the
360
- `default_flag_handler`) or when the feature is disabled, to keep
361
- 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.
362
376
  """
363
377
  if not self._event_processor:
364
378
  raise ValueError("Events must be enabled to use experiment flags.")
365
379
  flag = self.get_identity_flags(identifier, traits).get_flag(feature_name)
366
- 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:
367
399
  self.track_exposure_event(
368
400
  feature_name=feature_name,
369
401
  identifier=identifier,
370
- value=flag.variant if flag.variant is not None else flag.value,
402
+ value=flag.variant,
371
403
  traits=traits,
372
404
  )
373
405
  return flag
@@ -405,6 +437,14 @@ class Flagsmith:
405
437
  ) -> None:
406
438
  if not self._event_processor:
407
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
408
448
  self._event_processor.track_exposure_event(
409
449
  feature_name=feature_name,
410
450
  identifier=identifier,
@@ -52,6 +52,7 @@ class Flag(BaseFlag):
52
52
  feature_id: int
53
53
  feature_name: str
54
54
  variant: typing.Optional[str] = None
55
+ reason: typing.Optional[str] = None
55
56
  is_default: bool = field(default=False)
56
57
 
57
58
  @classmethod
@@ -66,6 +67,7 @@ class Flag(BaseFlag):
66
67
  feature_name=flag_result["name"],
67
68
  feature_id=metadata["id"],
68
69
  variant=flag_result.get("variant"),
70
+ reason=flag_result["reason"],
69
71
  )
70
72
  raise ValueError(
71
73
  "FlagResult metadata is missing. Cannot create Flag instance. "
@@ -80,6 +82,7 @@ class Flag(BaseFlag):
80
82
  feature_name=flag_data["feature"]["name"],
81
83
  feature_id=flag_data["feature"]["id"],
82
84
  variant=flag_data.get("variant"),
85
+ reason=flag_data.get("reason"),
83
86
  )
84
87
 
85
88
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "flagsmith"
3
- version = "6.0.0"
3
+ version = "6.2.0"
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