flagsmith 3.5.0__tar.gz → 3.7.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,23 +1,22 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: flagsmith
3
- Version: 3.5.0
3
+ Version: 3.7.0
4
4
  Summary: Flagsmith Python SDK
5
5
  License: BSD3
6
6
  Keywords: feature,flag,flagsmith,remote,config
7
7
  Author: Flagsmith
8
8
  Author-email: support@flagsmith.com
9
- Requires-Python: >=3.7.0,<4
9
+ Requires-Python: >=3.8.1,<4
10
10
  Classifier: License :: Other/Proprietary License
11
11
  Classifier: Programming Language :: Python :: 3
12
- Classifier: Programming Language :: Python :: 3.7
13
- Classifier: Programming Language :: Python :: 3.8
14
12
  Classifier: Programming Language :: Python :: 3.9
15
13
  Classifier: Programming Language :: Python :: 3.10
16
14
  Classifier: Programming Language :: Python :: 3.11
17
15
  Classifier: Programming Language :: Python :: 3.12
18
- Requires-Dist: flagsmith-flag-engine (>=5.0.0,<6.0.0)
16
+ Requires-Dist: flagsmith-flag-engine (>=5.1.0,<6.0.0)
19
17
  Requires-Dist: requests (>=2.27.1,<3.0.0)
20
18
  Requires-Dist: requests-futures (>=1.0.0,<2.0.0)
19
+ Requires-Dist: sseclient-py (>=1.8.0,<2.0.0)
21
20
  Project-URL: Documentation, https://docs.flagsmith.com
22
21
  Description-Content-Type: text/markdown
23
22
 
@@ -33,11 +32,11 @@ The SDK for Python applications for [https://www.flagsmith.com/](https://www.fla
33
32
  ## Adding to your project
34
33
 
35
34
  For full documentation visit
36
- [https://docs.flagsmith.com/clients/server-side](https://docs.flagsmith.com/clients/server-side).
35
+ [https://docs.flagsmith.com/clients/server-side?language=python](https://docs.flagsmith.com/clients/server-side?language=python).
37
36
 
38
37
  ## Contributing
39
38
 
40
- Please read [CONTRIBUTING.md](https://gist.github.com/kyle-ssg/c36a03aebe492e45cbd3eefb21cb0486) for details on our code
39
+ Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code
41
40
  of conduct, and the process for submitting pull requests
42
41
 
43
42
  ## Getting Help
@@ -10,11 +10,11 @@ The SDK for Python applications for [https://www.flagsmith.com/](https://www.fla
10
10
  ## Adding to your project
11
11
 
12
12
  For full documentation visit
13
- [https://docs.flagsmith.com/clients/server-side](https://docs.flagsmith.com/clients/server-side).
13
+ [https://docs.flagsmith.com/clients/server-side?language=python](https://docs.flagsmith.com/clients/server-side?language=python).
14
14
 
15
15
  ## Contributing
16
16
 
17
- Please read [CONTRIBUTING.md](https://gist.github.com/kyle-ssg/c36a03aebe492e45cbd3eefb21cb0486) for details on our code
17
+ Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code
18
18
  of conduct, and the process for submitting pull requests
19
19
 
20
20
  ## Getting Help
@@ -0,0 +1,3 @@
1
+ from .flagsmith import Flagsmith
2
+
3
+ __all__ = ("Flagsmith",)
@@ -1,12 +1,13 @@
1
1
  import json
2
+ import typing
2
3
  from datetime import datetime
3
4
 
4
- from requests_futures.sessions import FuturesSession
5
+ from requests_futures.sessions import FuturesSession # type: ignore
5
6
 
6
- ANALYTICS_ENDPOINT = "analytics/flags/"
7
+ ANALYTICS_ENDPOINT: typing.Final[str] = "analytics/flags/"
7
8
 
8
9
  # Used to control how often we send data(in seconds)
9
- ANALYTICS_TIMER = 10
10
+ ANALYTICS_TIMER: typing.Final[int] = 10
10
11
 
11
12
  session = FuturesSession(max_workers=4)
12
13
 
@@ -17,7 +18,9 @@ class AnalyticsProcessor:
17
18
  the Flagsmith SDK. Docs: https://docs.flagsmith.com/advanced-use/flag-analytics.
18
19
  """
19
20
 
20
- def __init__(self, environment_key: str, base_api_url: str, timeout: int = 3):
21
+ def __init__(
22
+ self, environment_key: str, base_api_url: str, timeout: typing.Optional[int] = 3
23
+ ):
21
24
  """
22
25
  Initialise the AnalyticsProcessor to handle sending analytics on flag usage to
23
26
  the Flagsmith API.
@@ -30,10 +33,10 @@ class AnalyticsProcessor:
30
33
  self.analytics_endpoint = base_api_url + ANALYTICS_ENDPOINT
31
34
  self.environment_key = environment_key
32
35
  self._last_flushed = datetime.now()
33
- self.analytics_data = {}
34
- self.timeout = timeout
36
+ self.analytics_data: typing.MutableMapping[str, typing.Any] = {}
37
+ self.timeout = timeout or 3
35
38
 
36
- def flush(self):
39
+ def flush(self) -> None:
37
40
  """
38
41
  Sends all the collected data to the api asynchronously and resets the timer
39
42
  """
@@ -53,7 +56,7 @@ class AnalyticsProcessor:
53
56
  self.analytics_data.clear()
54
57
  self._last_flushed = datetime.now()
55
58
 
56
- def track_feature(self, feature_name: str):
59
+ def track_feature(self, feature_name: str) -> None:
57
60
  self.analytics_data[feature_name] = self.analytics_data.get(feature_name, 0) + 1
58
61
  if (datetime.now() - self._last_flushed).seconds > ANALYTICS_TIMER:
59
62
  self.flush()
@@ -4,3 +4,7 @@ class FlagsmithClientError(Exception):
4
4
 
5
5
  class FlagsmithAPIError(FlagsmithClientError):
6
6
  pass
7
+
8
+
9
+ class FlagsmithFeatureDoesNotExistError(FlagsmithClientError):
10
+ pass
@@ -1,11 +1,14 @@
1
+ import json
1
2
  import logging
2
3
  import typing
3
- from json import JSONDecodeError
4
+ from datetime import datetime, timezone
4
5
 
5
6
  import requests
6
7
  from flag_engine import engine
7
8
  from flag_engine.environments.models import EnvironmentModel
8
- from flag_engine.identities.models import IdentityModel, TraitModel
9
+ from flag_engine.identities.models import IdentityModel
10
+ from flag_engine.identities.traits.models import TraitModel
11
+ from flag_engine.identities.traits.types import TraitValue
9
12
  from flag_engine.segments.evaluator import get_identity_segments
10
13
  from requests.adapters import HTTPAdapter
11
14
  from urllib3 import Retry
@@ -15,11 +18,23 @@ from flagsmith.exceptions import FlagsmithAPIError, FlagsmithClientError
15
18
  from flagsmith.models import DefaultFlag, Flags, Segment
16
19
  from flagsmith.offline_handlers import BaseOfflineHandler
17
20
  from flagsmith.polling_manager import EnvironmentDataPollingManager
18
- from flagsmith.utils.identities import generate_identities_data
21
+ from flagsmith.streaming_manager import EventStreamManager, StreamEvent
22
+ from flagsmith.utils.identities import Identity, generate_identities_data
19
23
 
20
24
  logger = logging.getLogger(__name__)
21
25
 
22
26
  DEFAULT_API_URL = "https://edge.api.flagsmith.com/api/v1/"
27
+ DEFAULT_REALTIME_API_URL = "https://realtime.flagsmith.com/"
28
+
29
+ JsonType = typing.Union[
30
+ None,
31
+ int,
32
+ str,
33
+ bool,
34
+ typing.List["JsonType"],
35
+ typing.List[typing.Mapping[str, "JsonType"]],
36
+ typing.Dict[str, "JsonType"],
37
+ ]
23
38
 
24
39
 
25
40
  class Flagsmith:
@@ -39,23 +54,28 @@ class Flagsmith:
39
54
 
40
55
  def __init__(
41
56
  self,
42
- environment_key: str = None,
43
- api_url: str = None,
44
- custom_headers: typing.Dict[str, typing.Any] = None,
45
- request_timeout_seconds: int = None,
57
+ environment_key: typing.Optional[str] = None,
58
+ api_url: typing.Optional[str] = None,
59
+ realtime_api_url: typing.Optional[str] = None,
60
+ custom_headers: typing.Optional[typing.Dict[str, typing.Any]] = None,
61
+ request_timeout_seconds: typing.Optional[int] = None,
46
62
  enable_local_evaluation: bool = False,
47
63
  environment_refresh_interval_seconds: typing.Union[int, float] = 60,
48
- retries: Retry = None,
64
+ retries: typing.Optional[Retry] = None,
49
65
  enable_analytics: bool = False,
50
- default_flag_handler: typing.Callable[[str], DefaultFlag] = None,
51
- proxies: typing.Dict[str, str] = None,
66
+ default_flag_handler: typing.Optional[
67
+ typing.Callable[[str], DefaultFlag]
68
+ ] = None,
69
+ proxies: typing.Optional[typing.Dict[str, str]] = None,
52
70
  offline_mode: bool = False,
53
- offline_handler: BaseOfflineHandler = None,
71
+ offline_handler: typing.Optional[BaseOfflineHandler] = None,
72
+ enable_realtime_updates: bool = False,
54
73
  ):
55
74
  """
56
75
  :param environment_key: The environment key obtained from Flagsmith interface.
57
76
  Required unless offline_mode is True.
58
77
  :param api_url: Override the URL of the Flagsmith API to communicate with
78
+ :param realtime_api_url: Override the URL of the Flagsmith real-time API
59
79
  :param custom_headers: Additional headers to add to requests made to the
60
80
  Flagsmith API
61
81
  :param request_timeout_seconds: Number of seconds to wait for a request to
@@ -76,14 +96,18 @@ class Flagsmith:
76
96
  :param offline_handler: provide a handler for offline logic. Used to get environment
77
97
  document from another source when in offline_mode. Works in place of
78
98
  default_flag_handler if offline_mode is not set and using remote evaluation.
99
+ :param enable_realtime_updates: Use real-time functionality via SSE as opposed to polling the API
79
100
  """
80
101
 
81
102
  self.offline_mode = offline_mode
82
103
  self.enable_local_evaluation = enable_local_evaluation
104
+ self.environment_refresh_interval_seconds = environment_refresh_interval_seconds
83
105
  self.offline_handler = offline_handler
84
106
  self.default_flag_handler = default_flag_handler
85
- self._analytics_processor = None
86
- self._environment = None
107
+ self.enable_realtime_updates = enable_realtime_updates
108
+ self._analytics_processor: typing.Optional[AnalyticsProcessor] = None
109
+ self._environment: typing.Optional[EnvironmentModel] = None
110
+ self._identity_overrides_by_identifier: typing.Dict[str, IdentityModel] = {}
87
111
 
88
112
  # argument validation
89
113
  if offline_mode and not offline_handler:
@@ -93,6 +117,11 @@ class Flagsmith:
93
117
  "Cannot use both default_flag_handler and offline_handler."
94
118
  )
95
119
 
120
+ if enable_realtime_updates and not enable_local_evaluation:
121
+ raise ValueError(
122
+ "Can only use realtime updates when running in local evaluation mode."
123
+ )
124
+
96
125
  if self.offline_handler:
97
126
  self._environment = self.offline_handler.get_environment()
98
127
 
@@ -110,6 +139,13 @@ class Flagsmith:
110
139
  api_url = api_url or DEFAULT_API_URL
111
140
  self.api_url = api_url if api_url.endswith("/") else f"{api_url}/"
112
141
 
142
+ realtime_api_url = realtime_api_url or DEFAULT_REALTIME_API_URL
143
+ self.realtime_api_url = (
144
+ realtime_api_url
145
+ if realtime_api_url.endswith("/")
146
+ else f"{realtime_api_url}/"
147
+ )
148
+
113
149
  self.request_timeout_seconds = request_timeout_seconds
114
150
  self.session.mount(self.api_url, HTTPAdapter(max_retries=retries))
115
151
 
@@ -124,20 +160,70 @@ class Flagsmith:
124
160
  "in the environment settings page."
125
161
  )
126
162
 
127
- self.environment_data_polling_manager_thread = (
128
- EnvironmentDataPollingManager(
129
- main=self,
130
- refresh_interval_seconds=environment_refresh_interval_seconds,
131
- daemon=True, # noqa
132
- )
133
- )
134
- self.environment_data_polling_manager_thread.start()
163
+ self._initialise_local_evaluation()
135
164
 
136
165
  if enable_analytics:
137
166
  self._analytics_processor = AnalyticsProcessor(
138
167
  environment_key, self.api_url, timeout=self.request_timeout_seconds
139
168
  )
140
169
 
170
+ def _initialise_local_evaluation(self) -> None:
171
+ if self.enable_realtime_updates:
172
+ self.update_environment()
173
+ if not self._environment:
174
+ raise ValueError("Unable to get environment from API key")
175
+
176
+ stream_url = f"{self.realtime_api_url}sse/environments/{self._environment.api_key}/stream"
177
+
178
+ self.event_stream_thread = EventStreamManager(
179
+ stream_url=stream_url,
180
+ on_event=self.handle_stream_event,
181
+ daemon=True,
182
+ )
183
+
184
+ self.event_stream_thread.start()
185
+
186
+ else:
187
+ # To ensure that the environment is set before allowing subsequent
188
+ # method calls, update the environment manually.
189
+ self.update_environment()
190
+ self.environment_data_polling_manager_thread = (
191
+ EnvironmentDataPollingManager(
192
+ main=self,
193
+ refresh_interval_seconds=self.environment_refresh_interval_seconds,
194
+ daemon=True,
195
+ )
196
+ )
197
+
198
+ self.environment_data_polling_manager_thread.start()
199
+
200
+ def handle_stream_event(self, event: StreamEvent) -> None:
201
+ try:
202
+ event_data = json.loads(event.data)
203
+ except json.JSONDecodeError as e:
204
+ raise FlagsmithAPIError("Unable to get valid json from event data.") from e
205
+
206
+ try:
207
+ stream_updated_at = datetime.fromtimestamp(event_data.get("updated_at"))
208
+ except TypeError as e:
209
+ raise FlagsmithAPIError(
210
+ "Unable to get valid timestamp from event data."
211
+ ) from e
212
+
213
+ if stream_updated_at.tzinfo is None:
214
+ stream_updated_at = stream_updated_at.astimezone(timezone.utc)
215
+
216
+ if not self._environment:
217
+ raise ValueError(
218
+ "Unable to access environment. Environment should not be null"
219
+ )
220
+ environment_updated_at = self._environment.updated_at
221
+ if environment_updated_at.tzinfo is None:
222
+ environment_updated_at = environment_updated_at.astimezone(timezone.utc)
223
+
224
+ if stream_updated_at > environment_updated_at:
225
+ self.update_environment()
226
+
141
227
  def get_environment_flags(self) -> Flags:
142
228
  """
143
229
  Get all the default for flags for the current environment.
@@ -149,7 +235,9 @@ class Flagsmith:
149
235
  return self._get_environment_flags_from_api()
150
236
 
151
237
  def get_identity_flags(
152
- self, identifier: str, traits: typing.Dict[str, typing.Any] = None
238
+ self,
239
+ identifier: str,
240
+ traits: typing.Optional[typing.Mapping[str, TraitValue]] = None,
153
241
  ) -> Flags:
154
242
  """
155
243
  Get all the flags for the current environment for a given identity. Will also
@@ -168,7 +256,9 @@ class Flagsmith:
168
256
  return self._get_identity_flags_from_api(identifier, traits)
169
257
 
170
258
  def get_identity_segments(
171
- self, identifier: str, traits: typing.Dict[str, typing.Any] = None
259
+ self,
260
+ identifier: str,
261
+ traits: typing.Optional[typing.Mapping[str, TraitValue]] = None,
172
262
  ) -> typing.List[Segment]:
173
263
  """
174
264
  Get a list of segments that the given identity is in.
@@ -186,18 +276,29 @@ class Flagsmith:
186
276
  )
187
277
 
188
278
  traits = traits or {}
189
- identity_model = self._build_identity_model(identifier, **traits)
279
+ identity_model = self._get_identity_model(identifier, **traits)
190
280
  segment_models = get_identity_segments(self._environment, identity_model)
191
281
  return [Segment(id=sm.id, name=sm.name) for sm in segment_models]
192
282
 
193
- def update_environment(self):
283
+ def update_environment(self) -> None:
194
284
  self._environment = self._get_environment_from_api()
285
+ self._update_overrides()
286
+
287
+ def _update_overrides(self) -> None:
288
+ if not self._environment:
289
+ return
290
+ if overrides := self._environment.identity_overrides:
291
+ self._identity_overrides_by_identifier = {
292
+ identity.identifier: identity for identity in overrides
293
+ }
195
294
 
196
295
  def _get_environment_from_api(self) -> EnvironmentModel:
197
296
  environment_data = self._get_json_response(self.environment_url, method="GET")
198
297
  return EnvironmentModel.model_validate(environment_data)
199
298
 
200
299
  def _get_environment_flags_from_document(self) -> Flags:
300
+ if self._environment is None:
301
+ raise TypeError("No environment present")
201
302
  return Flags.from_feature_state_models(
202
303
  feature_states=engine.get_environment_feature_states(self._environment),
203
304
  analytics_processor=self._analytics_processor,
@@ -205,9 +306,11 @@ class Flagsmith:
205
306
  )
206
307
 
207
308
  def _get_identity_flags_from_document(
208
- self, identifier: str, traits: typing.Dict[str, typing.Any]
309
+ self, identifier: str, traits: typing.Mapping[str, TraitValue]
209
310
  ) -> Flags:
210
- identity_model = self._build_identity_model(identifier, **traits)
311
+ identity_model = self._get_identity_model(identifier, **traits)
312
+ if self._environment is None:
313
+ raise TypeError("No environment present")
211
314
  feature_states = engine.get_identity_feature_states(
212
315
  self._environment, identity_model
213
316
  )
@@ -220,11 +323,11 @@ class Flagsmith:
220
323
 
221
324
  def _get_environment_flags_from_api(self) -> Flags:
222
325
  try:
223
- api_flags = self._get_json_response(
224
- url=self.environment_flags_url, method="GET"
326
+ json_response: typing.List[typing.Mapping[str, JsonType]] = (
327
+ self._get_json_response(url=self.environment_flags_url, method="GET")
225
328
  )
226
329
  return Flags.from_api_flags(
227
- api_flags=api_flags,
330
+ api_flags=json_response,
228
331
  analytics_processor=self._analytics_processor,
229
332
  default_flag_handler=self.default_flag_handler,
230
333
  )
@@ -236,12 +339,14 @@ class Flagsmith:
236
339
  raise
237
340
 
238
341
  def _get_identity_flags_from_api(
239
- self, identifier: str, traits: typing.Dict[str, typing.Any]
342
+ self, identifier: str, traits: typing.Mapping[str, typing.Any]
240
343
  ) -> Flags:
241
344
  try:
242
345
  data = generate_identities_data(identifier, traits)
243
- json_response = self._get_json_response(
244
- url=self.identities_url, method="POST", body=data
346
+ json_response: typing.Dict[str, typing.List[typing.Dict[str, JsonType]]] = (
347
+ self._get_json_response(
348
+ url=self.identities_url, method="POST", body=data
349
+ )
245
350
  )
246
351
  return Flags.from_api_flags(
247
352
  api_flags=json_response["flags"],
@@ -255,7 +360,14 @@ class Flagsmith:
255
360
  return Flags(default_flag_handler=self.default_flag_handler)
256
361
  raise
257
362
 
258
- def _get_json_response(self, url: str, method: str, body: dict = None):
363
+ def _get_json_response(
364
+ self,
365
+ url: str,
366
+ method: str,
367
+ body: typing.Optional[
368
+ typing.Union[Identity, typing.Dict[str, JsonType]]
369
+ ] = None,
370
+ ) -> typing.Any:
259
371
  try:
260
372
  request_method = getattr(self.session, method.lower())
261
373
  response = request_method(
@@ -267,12 +379,16 @@ class Flagsmith:
267
379
  response.status_code,
268
380
  )
269
381
  return response.json()
270
- except (requests.ConnectionError, JSONDecodeError) as e:
382
+ except (requests.ConnectionError, json.JSONDecodeError) as e:
271
383
  raise FlagsmithAPIError(
272
384
  "Unable to get valid response from Flagsmith API."
273
385
  ) from e
274
386
 
275
- def _build_identity_model(self, identifier: str, **traits):
387
+ def _get_identity_model(
388
+ self,
389
+ identifier: str,
390
+ **traits: TraitValue,
391
+ ) -> IdentityModel:
276
392
  if not self._environment:
277
393
  raise FlagsmithClientError(
278
394
  "Unable to build identity model when no local environment present."
@@ -282,12 +398,20 @@ class Flagsmith:
282
398
  TraitModel(trait_key=key, trait_value=value)
283
399
  for key, value in traits.items()
284
400
  ]
401
+
402
+ if identity := self._identity_overrides_by_identifier.get(identifier):
403
+ identity.update_traits(trait_models)
404
+ return identity
405
+
285
406
  return IdentityModel(
286
407
  identifier=identifier,
287
408
  environment_api_key=self._environment.api_key,
288
409
  identity_traits=trait_models,
289
410
  )
290
411
 
291
- def __del__(self):
412
+ def __del__(self) -> None:
292
413
  if hasattr(self, "environment_data_polling_manager_thread"):
293
414
  self.environment_data_polling_manager_thread.stop()
415
+
416
+ if hasattr(self, "event_stream_thread"):
417
+ self.event_stream_thread.stop()
@@ -1,36 +1,37 @@
1
+ from __future__ import annotations
2
+
1
3
  import typing
2
4
  from dataclasses import dataclass, field
3
5
 
4
6
  from flag_engine.features.models import FeatureStateModel
5
7
 
6
8
  from flagsmith.analytics import AnalyticsProcessor
7
- from flagsmith.exceptions import FlagsmithClientError
9
+ from flagsmith.exceptions import FlagsmithFeatureDoesNotExistError
8
10
 
9
11
 
10
12
  @dataclass
11
13
  class BaseFlag:
12
14
  enabled: bool
13
- value: typing.Union[str, int, float, bool, type(None)]
14
- is_default: bool
15
+ value: typing.Union[str, int, float, bool, None]
15
16
 
16
17
 
18
+ @dataclass
17
19
  class DefaultFlag(BaseFlag):
18
- def __init__(self, *args, **kwargs):
19
- super().__init__(*args, is_default=True, **kwargs)
20
+ is_default: bool = field(default=True)
20
21
 
21
22
 
23
+ @dataclass
22
24
  class Flag(BaseFlag):
23
- def __init__(self, *args, feature_id: int, feature_name: str, **kwargs):
24
- super().__init__(*args, is_default=False, **kwargs)
25
- self.feature_id = feature_id
26
- self.feature_name = feature_name
25
+ feature_id: int
26
+ feature_name: str
27
+ is_default: bool = field(default=False)
27
28
 
28
29
  @classmethod
29
30
  def from_feature_state_model(
30
31
  cls,
31
32
  feature_state_model: FeatureStateModel,
32
- identity_id: typing.Union[str, int] = None,
33
- ) -> "Flag":
33
+ identity_id: typing.Optional[typing.Union[str, int]] = None,
34
+ ) -> Flag:
34
35
  return Flag(
35
36
  enabled=feature_state_model.enabled,
36
37
  value=feature_state_model.get_value(identity_id=identity_id),
@@ -39,7 +40,7 @@ class Flag(BaseFlag):
39
40
  )
40
41
 
41
42
  @classmethod
42
- def from_api_flag(cls, flag_data: dict) -> "Flag":
43
+ def from_api_flag(cls, flag_data: typing.Mapping[str, typing.Any]) -> Flag:
43
44
  return Flag(
44
45
  enabled=flag_data["enabled"],
45
46
  value=flag_data["feature_state_value"],
@@ -51,17 +52,17 @@ class Flag(BaseFlag):
51
52
  @dataclass
52
53
  class Flags:
53
54
  flags: typing.Dict[str, Flag] = field(default_factory=dict)
54
- default_flag_handler: typing.Callable[[str], DefaultFlag] = None
55
- _analytics_processor: AnalyticsProcessor = None
55
+ default_flag_handler: typing.Optional[typing.Callable[[str], DefaultFlag]] = None
56
+ _analytics_processor: typing.Optional[AnalyticsProcessor] = None
56
57
 
57
58
  @classmethod
58
59
  def from_feature_state_models(
59
60
  cls,
60
- feature_states: typing.List[FeatureStateModel],
61
- analytics_processor: AnalyticsProcessor,
62
- default_flag_handler: typing.Callable,
63
- identity_id: typing.Union[str, int] = None,
64
- ) -> "Flags":
61
+ feature_states: typing.Sequence[FeatureStateModel],
62
+ analytics_processor: typing.Optional[AnalyticsProcessor],
63
+ default_flag_handler: typing.Optional[typing.Callable[[str], DefaultFlag]],
64
+ identity_id: typing.Optional[typing.Union[str, int]] = None,
65
+ ) -> Flags:
65
66
  flags = {
66
67
  feature_state.feature.name: Flag.from_feature_state_model(
67
68
  feature_state, identity_id=identity_id
@@ -78,10 +79,10 @@ class Flags:
78
79
  @classmethod
79
80
  def from_api_flags(
80
81
  cls,
81
- api_flags: typing.List[dict],
82
- analytics_processor: AnalyticsProcessor,
83
- default_flag_handler: typing.Callable,
84
- ) -> "Flags":
82
+ api_flags: typing.Sequence[typing.Mapping[str, typing.Any]],
83
+ analytics_processor: typing.Optional[AnalyticsProcessor],
84
+ default_flag_handler: typing.Optional[typing.Callable[[str], DefaultFlag]],
85
+ ) -> Flags:
85
86
  flags = {
86
87
  flag_data["feature"]["name"]: Flag.from_api_flag(flag_data)
87
88
  for flag_data in api_flags
@@ -121,12 +122,12 @@ class Flags:
121
122
  """
122
123
  return self.get_flag(feature_name).value
123
124
 
124
- def get_flag(self, feature_name: str) -> BaseFlag:
125
+ def get_flag(self, feature_name: str) -> typing.Union[DefaultFlag, Flag]:
125
126
  """
126
127
  Get a specific flag given the feature name.
127
128
 
128
129
  :param feature_name: the name of the feature to retrieve the flag for.
129
- :return: BaseFlag object.
130
+ :return: DefaultFlag | Flag object.
130
131
  :raises FlagsmithClientError: if feature doesn't exist
131
132
  """
132
133
  try:
@@ -134,7 +135,9 @@ class Flags:
134
135
  except KeyError:
135
136
  if self.default_flag_handler:
136
137
  return self.default_flag_handler(feature_name)
137
- raise FlagsmithClientError("Feature does not exist: %s" % feature_name)
138
+ raise FlagsmithFeatureDoesNotExistError(
139
+ "Feature does not exist: %s" % feature_name
140
+ )
138
141
 
139
142
  if self._analytics_processor and hasattr(flag, "feature_name"):
140
143
  self._analytics_processor.track_feature(flag.feature_name)
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import logging
2
4
  import threading
3
5
  import time
@@ -16,10 +18,10 @@ logger = logging.getLogger(__name__)
16
18
  class EnvironmentDataPollingManager(threading.Thread):
17
19
  def __init__(
18
20
  self,
19
- *args,
20
- main: "Flagsmith",
21
+ *args: typing.Any,
22
+ main: Flagsmith,
21
23
  refresh_interval_seconds: typing.Union[int, float] = 10,
22
- **kwargs
24
+ **kwargs: typing.Any,
23
25
  ):
24
26
  super(EnvironmentDataPollingManager, self).__init__(*args, **kwargs)
25
27
  self._stop_event = threading.Event()
@@ -37,5 +39,5 @@ class EnvironmentDataPollingManager(threading.Thread):
37
39
  def stop(self) -> None:
38
40
  self._stop_event.set()
39
41
 
40
- def __del__(self):
42
+ def __del__(self) -> None:
41
43
  self._stop_event.set()
@@ -0,0 +1,58 @@
1
+ import logging
2
+ import threading
3
+ import typing
4
+ from typing import Callable, Generator, Optional, Protocol, cast
5
+
6
+ import requests
7
+ import sseclient
8
+
9
+ from flagsmith.exceptions import FlagsmithAPIError
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class StreamEvent(Protocol):
15
+ data: str
16
+
17
+
18
+ class EventStreamManager(threading.Thread):
19
+ def __init__(
20
+ self,
21
+ *args: typing.Any,
22
+ stream_url: str,
23
+ on_event: Callable[[StreamEvent], None],
24
+ request_timeout_seconds: Optional[int] = None,
25
+ **kwargs: typing.Any
26
+ ) -> None:
27
+ super().__init__(*args, **kwargs)
28
+ self._stop_event = threading.Event()
29
+ self.stream_url = stream_url
30
+ self.on_event = on_event
31
+ self.request_timeout_seconds = request_timeout_seconds
32
+
33
+ def run(self) -> None:
34
+ while not self._stop_event.is_set():
35
+ try:
36
+ with requests.get(
37
+ self.stream_url,
38
+ stream=True,
39
+ headers={"Accept": "application/json, text/event-stream"},
40
+ timeout=self.request_timeout_seconds,
41
+ ) as response:
42
+ sse_client = sseclient.SSEClient(
43
+ cast(Generator[bytes, None, None], response)
44
+ )
45
+ for event in sse_client.events():
46
+ self.on_event(event)
47
+
48
+ except requests.exceptions.ReadTimeout:
49
+ pass
50
+
51
+ except (FlagsmithAPIError, requests.RequestException):
52
+ logger.exception("Error handling event stream")
53
+
54
+ def stop(self) -> None:
55
+ self._stop_event.set()
56
+
57
+ def __del__(self) -> None:
58
+ self._stop_event.set()
File without changes
@@ -0,0 +1,21 @@
1
+ import typing
2
+
3
+ from flag_engine.identities.traits.types import TraitValue
4
+
5
+ Identity = typing.TypedDict(
6
+ "Identity",
7
+ {"identifier": str, "traits": typing.List[typing.Mapping[str, TraitValue]]},
8
+ )
9
+
10
+
11
+ def generate_identities_data(
12
+ identifier: str, traits: typing.Optional[typing.Mapping[str, TraitValue]] = None
13
+ ) -> Identity:
14
+ return {
15
+ "identifier": identifier,
16
+ "traits": (
17
+ [{"trait_key": k, "trait_value": v} for k, v in traits.items()]
18
+ if traits
19
+ else []
20
+ ),
21
+ }
@@ -1,31 +1,40 @@
1
1
  [tool.poetry]
2
2
  name = "flagsmith"
3
- version = "3.5.0"
3
+ version = "3.7.0"
4
4
  description = "Flagsmith Python SDK"
5
5
  authors = ["Flagsmith <support@flagsmith.com>"]
6
6
  license = "BSD3"
7
- readme = "Readme.md"
7
+ readme = "README.md"
8
8
  keywords = ["feature", "flag", "flagsmith", "remote", "config"]
9
9
  documentation = "https://docs.flagsmith.com"
10
10
  packages = [{ include = "flagsmith" }]
11
11
 
12
12
  [tool.poetry.dependencies]
13
- python = ">=3.7.0,<4"
13
+ python = ">=3.8.1,<4"
14
14
  requests = "^2.27.1"
15
15
  requests-futures = "^1.0.0"
16
- flagsmith-flag-engine = "^5.0.0"
16
+ flagsmith-flag-engine = "^5.1.0"
17
+ sseclient-py = "^1.8.0"
17
18
 
18
- [tool.poetry.dev-dependencies]
19
+ [tool.poetry.group.dev]
20
+ optional = true
21
+
22
+ [tool.poetry.group.dev.dependencies]
19
23
  pytest = "^7.4.0"
20
24
  pytest-mock = "^3.6.1"
21
- black = "^23.3.0"
25
+ black = ">=23.3,<25.0"
22
26
  pre-commit = "^2.17.0"
23
- responses = "^0.17.0"
24
- flake8 = "^4.0.1"
25
- isort = "^5.10.1"
27
+ responses = "^0.24.1"
28
+ flake8 = "^6.1.0"
29
+ isort = "^5.12.0"
30
+ mypy = "^1.7.1"
31
+ types-requests = "^2.31.0.10"
32
+ pytest-cov = "^4.1.0"
33
+
34
+ [tool.mypy]
35
+ plugins = ["pydantic.mypy"]
36
+ exclude = ["example/*"]
26
37
 
27
- [tool.poetry.group.dev.dependencies]
28
- pytest = "^7.4.0"
29
38
 
30
39
  [build-system]
31
40
  requires = ["poetry-core>=1.0.0"]
@@ -1 +0,0 @@
1
- from .flagsmith import Flagsmith # noqa
@@ -1,5 +0,0 @@
1
- def generate_identities_data(identifier: str, traits: dict = None):
2
- return {
3
- "identifier": identifier,
4
- "traits": [{"trait_key": k, "trait_value": v} for k, v in traits.items()],
5
- }
File without changes