flagsmith 3.6.0__tar.gz → 3.8.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.1
2
2
  Name: flagsmith
3
- Version: 3.6.0
3
+ Version: 3.8.0
4
4
  Summary: Flagsmith Python SDK
5
5
  License: BSD3
6
6
  Keywords: feature,flag,flagsmith,remote,config
@@ -14,9 +14,9 @@ Classifier: Programming Language :: Python :: 3.10
14
14
  Classifier: Programming Language :: Python :: 3.11
15
15
  Classifier: Programming Language :: Python :: 3.12
16
16
  Requires-Dist: flagsmith-flag-engine (>=5.1.0,<6.0.0)
17
- Requires-Dist: pytz (>=2023.4,<2024.0)
18
- Requires-Dist: requests (>=2.27.1,<3.0.0)
19
- Requires-Dist: requests-futures (>=1.0.0,<2.0.0)
17
+ Requires-Dist: pydantic (>=2,<3)
18
+ Requires-Dist: requests (>=2.32.3,<3.0.0)
19
+ Requires-Dist: requests-futures (>=1.0.1,<2.0.0)
20
20
  Requires-Dist: sseclient-py (>=1.8.0,<2.0.0)
21
21
  Project-URL: Documentation, https://docs.flagsmith.com
22
22
  Description-Content-Type: text/markdown
@@ -33,12 +33,12 @@ The SDK for Python applications for [https://www.flagsmith.com/](https://www.fla
33
33
  ## Adding to your project
34
34
 
35
35
  For full documentation visit
36
- [https://docs.flagsmith.com/clients/server-side](https://docs.flagsmith.com/clients/server-side).
36
+ [https://docs.flagsmith.com/clients/server-side?language=python](https://docs.flagsmith.com/clients/server-side?language=python).
37
37
 
38
38
  ## Contributing
39
39
 
40
- Please read [CONTRIBUTING.md](https://gist.github.com/kyle-ssg/c36a03aebe492e45cbd3eefb21cb0486) for details on our code
41
- of conduct, and the process for submitting pull requests
40
+ Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct, and the process for submitting pull
41
+ requests
42
42
 
43
43
  ## Getting Help
44
44
 
@@ -10,12 +10,12 @@ 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
18
- of conduct, and the process for submitting pull requests
17
+ Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct, and the process for submitting pull
18
+ requests
19
19
 
20
20
  ## Getting Help
21
21
 
@@ -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,9 +1,8 @@
1
- import json
2
1
  import logging
3
2
  import typing
4
- from datetime import datetime
3
+ from datetime import timezone
5
4
 
6
- import pytz
5
+ import pydantic
7
6
  import requests
8
7
  from flag_engine import engine
9
8
  from flag_engine.environments.models import EnvironmentModel
@@ -20,23 +19,14 @@ from flagsmith.models import DefaultFlag, Flags, Segment
20
19
  from flagsmith.offline_handlers import BaseOfflineHandler
21
20
  from flagsmith.polling_manager import EnvironmentDataPollingManager
22
21
  from flagsmith.streaming_manager import EventStreamManager, StreamEvent
23
- from flagsmith.utils.identities import Identity, generate_identities_data
22
+ from flagsmith.types import JsonType, TraitConfig, TraitMapping
23
+ from flagsmith.utils.identities import generate_identity_data
24
24
 
25
25
  logger = logging.getLogger(__name__)
26
26
 
27
27
  DEFAULT_API_URL = "https://edge.api.flagsmith.com/api/v1/"
28
28
  DEFAULT_REALTIME_API_URL = "https://realtime.flagsmith.com/"
29
29
 
30
- JsonType = typing.Union[
31
- None,
32
- int,
33
- str,
34
- bool,
35
- typing.List["JsonType"],
36
- typing.List[typing.Mapping[str, "JsonType"]],
37
- typing.Dict[str, "JsonType"],
38
- ]
39
-
40
30
 
41
31
  class Flagsmith:
42
32
  """A Flagsmith client.
@@ -169,8 +159,10 @@ class Flagsmith:
169
159
  )
170
160
 
171
161
  def _initialise_local_evaluation(self) -> None:
162
+ # To ensure that the environment is set before allowing subsequent
163
+ # method calls, update the environment manually.
164
+ self.update_environment()
172
165
  if self.enable_realtime_updates:
173
- self.update_environment()
174
166
  if not self._environment:
175
167
  raise ValueError("Unable to get environment from API key")
176
168
 
@@ -185,9 +177,6 @@ class Flagsmith:
185
177
  self.event_stream_thread.start()
186
178
 
187
179
  else:
188
- # To ensure that the environment is set before allowing subsequent
189
- # method calls, update the environment manually.
190
- self.update_environment()
191
180
  self.environment_data_polling_manager_thread = (
192
181
  EnvironmentDataPollingManager(
193
182
  main=self,
@@ -199,30 +188,15 @@ class Flagsmith:
199
188
  self.environment_data_polling_manager_thread.start()
200
189
 
201
190
  def handle_stream_event(self, event: StreamEvent) -> None:
202
- try:
203
- event_data = json.loads(event.data)
204
- except json.JSONDecodeError as e:
205
- raise FlagsmithAPIError("Unable to get valid json from event data.") from e
206
-
207
- try:
208
- stream_updated_at = datetime.fromtimestamp(event_data.get("updated_at"))
209
- except TypeError as e:
210
- raise FlagsmithAPIError(
211
- "Unable to get valid timestamp from event data."
212
- ) from e
213
-
214
- if stream_updated_at.tzinfo is None:
215
- stream_updated_at = pytz.utc.localize(stream_updated_at)
216
-
217
191
  if not self._environment:
218
192
  raise ValueError(
219
193
  "Unable to access environment. Environment should not be null"
220
194
  )
221
195
  environment_updated_at = self._environment.updated_at
222
196
  if environment_updated_at.tzinfo is None:
223
- environment_updated_at = pytz.utc.localize(environment_updated_at)
197
+ environment_updated_at = environment_updated_at.astimezone(timezone.utc)
224
198
 
225
- if stream_updated_at > environment_updated_at:
199
+ if event.updated_at > environment_updated_at:
226
200
  self.update_environment()
227
201
 
228
202
  def get_environment_flags(self) -> Flags:
@@ -238,7 +212,9 @@ class Flagsmith:
238
212
  def get_identity_flags(
239
213
  self,
240
214
  identifier: str,
241
- traits: typing.Optional[typing.Mapping[str, TraitValue]] = None,
215
+ traits: typing.Optional[TraitMapping] = None,
216
+ *,
217
+ transient: bool = False,
242
218
  ) -> Flags:
243
219
  """
244
220
  Get all the flags for the current environment for a given identity. Will also
@@ -248,13 +224,20 @@ class Flagsmith:
248
224
  :param identifier: a unique identifier for the identity in the current
249
225
  environment, e.g. email address, username, uuid
250
226
  :param traits: a dictionary of traits to add / update on the identity in
251
- Flagsmith, e.g. {"num_orders": 10}
227
+ Flagsmith, e.g. `{"num_orders": 10}`. Envelope traits you don't want persisted
228
+ in a dictionary with `"transient"` and `"value"` keys, e.g.
229
+ `{"num_orders": 10, "color": {"value": "pink", "transient": True}}`.
230
+ :param transient: if `True`, the identity won't get persisted
252
231
  :return: Flags object holding all the flags for the given identity.
253
232
  """
254
233
  traits = traits or {}
255
234
  if (self.offline_mode or self.enable_local_evaluation) and self._environment:
256
235
  return self._get_identity_flags_from_document(identifier, traits)
257
- return self._get_identity_flags_from_api(identifier, traits)
236
+ return self._get_identity_flags_from_api(
237
+ identifier,
238
+ traits,
239
+ transient=transient,
240
+ )
258
241
 
259
242
  def get_identity_segments(
260
243
  self,
@@ -282,16 +265,15 @@ class Flagsmith:
282
265
  return [Segment(id=sm.id, name=sm.name) for sm in segment_models]
283
266
 
284
267
  def update_environment(self) -> None:
285
- self._environment = self._get_environment_from_api()
286
- self._update_overrides()
287
-
288
- def _update_overrides(self) -> None:
289
- if not self._environment:
290
- return
291
- if overrides := self._environment.identity_overrides:
292
- self._identity_overrides_by_identifier = {
293
- identity.identifier: identity for identity in overrides
294
- }
268
+ try:
269
+ self._environment = self._get_environment_from_api()
270
+ except (FlagsmithAPIError, pydantic.ValidationError):
271
+ logger.exception("Error updating environment")
272
+ else:
273
+ if overrides := self._environment.identity_overrides:
274
+ self._identity_overrides_by_identifier = {
275
+ identity.identifier: identity for identity in overrides
276
+ }
295
277
 
296
278
  def _get_environment_from_api(self) -> EnvironmentModel:
297
279
  environment_data = self._get_json_response(self.environment_url, method="GET")
@@ -307,7 +289,7 @@ class Flagsmith:
307
289
  )
308
290
 
309
291
  def _get_identity_flags_from_document(
310
- self, identifier: str, traits: typing.Mapping[str, TraitValue]
292
+ self, identifier: str, traits: TraitMapping
311
293
  ) -> Flags:
312
294
  identity_model = self._get_identity_model(identifier, **traits)
313
295
  if self._environment is None:
@@ -324,9 +306,9 @@ class Flagsmith:
324
306
 
325
307
  def _get_environment_flags_from_api(self) -> Flags:
326
308
  try:
327
- json_response: typing.List[
328
- typing.Mapping[str, JsonType]
329
- ] = self._get_json_response(url=self.environment_flags_url, method="GET")
309
+ json_response: typing.List[typing.Mapping[str, JsonType]] = (
310
+ self._get_json_response(url=self.environment_flags_url, method="GET")
311
+ )
330
312
  return Flags.from_api_flags(
331
313
  api_flags=json_response,
332
314
  analytics_processor=self._analytics_processor,
@@ -340,14 +322,24 @@ class Flagsmith:
340
322
  raise
341
323
 
342
324
  def _get_identity_flags_from_api(
343
- self, identifier: str, traits: typing.Mapping[str, typing.Any]
325
+ self,
326
+ identifier: str,
327
+ traits: TraitMapping,
328
+ *,
329
+ transient: bool = False,
344
330
  ) -> Flags:
331
+ request_body = generate_identity_data(
332
+ identifier,
333
+ traits,
334
+ transient=transient,
335
+ )
345
336
  try:
346
- data = generate_identities_data(identifier, traits)
347
- json_response: typing.Dict[
348
- str, typing.List[typing.Dict[str, JsonType]]
349
- ] = self._get_json_response(
350
- url=self.identities_url, method="POST", body=data
337
+ json_response: typing.Dict[str, typing.List[typing.Dict[str, JsonType]]] = (
338
+ self._get_json_response(
339
+ url=self.identities_url,
340
+ method="POST",
341
+ body=request_body,
342
+ )
351
343
  )
352
344
  return Flags.from_api_flags(
353
345
  api_flags=json_response["flags"],
@@ -365,22 +357,16 @@ class Flagsmith:
365
357
  self,
366
358
  url: str,
367
359
  method: str,
368
- body: typing.Optional[
369
- typing.Union[Identity, typing.Dict[str, JsonType]]
370
- ] = None,
360
+ body: typing.Optional[JsonType] = None,
371
361
  ) -> typing.Any:
372
362
  try:
373
363
  request_method = getattr(self.session, method.lower())
374
364
  response = request_method(
375
365
  url, json=body, timeout=self.request_timeout_seconds
376
366
  )
377
- if response.status_code != 200:
378
- raise FlagsmithAPIError(
379
- "Invalid request made to Flagsmith API. Response status code: %d",
380
- response.status_code,
381
- )
367
+ response.raise_for_status()
382
368
  return response.json()
383
- except (requests.ConnectionError, json.JSONDecodeError) as e:
369
+ except requests.RequestException as e:
384
370
  raise FlagsmithAPIError(
385
371
  "Unable to get valid response from Flagsmith API."
386
372
  ) from e
@@ -388,7 +374,7 @@ class Flagsmith:
388
374
  def _get_identity_model(
389
375
  self,
390
376
  identifier: str,
391
- **traits: TraitValue,
377
+ **traits: typing.Union[TraitValue, TraitConfig],
392
378
  ) -> IdentityModel:
393
379
  if not self._environment:
394
380
  raise FlagsmithClientError(
@@ -396,7 +382,10 @@ class Flagsmith:
396
382
  )
397
383
 
398
384
  trait_models = [
399
- TraitModel(trait_key=key, trait_value=value)
385
+ TraitModel(
386
+ trait_key=key,
387
+ trait_value=value["value"] if isinstance(value, dict) else value,
388
+ )
400
389
  for key, value in traits.items()
401
390
  ]
402
391
 
@@ -6,7 +6,7 @@ from dataclasses import dataclass, field
6
6
  from flag_engine.features.models import FeatureStateModel
7
7
 
8
8
  from flagsmith.analytics import AnalyticsProcessor
9
- from flagsmith.exceptions import FlagsmithClientError
9
+ from flagsmith.exceptions import FlagsmithFeatureDoesNotExistError
10
10
 
11
11
 
12
12
  @dataclass
@@ -135,7 +135,9 @@ class Flags:
135
135
  except KeyError:
136
136
  if self.default_flag_handler:
137
137
  return self.default_flag_handler(feature_name)
138
- raise FlagsmithClientError("Feature does not exist: %s" % feature_name)
138
+ raise FlagsmithFeatureDoesNotExistError(
139
+ "Feature does not exist: %s" % feature_name
140
+ )
139
141
 
140
142
  if self._analytics_processor and hasattr(flag, "feature_name"):
141
143
  self._analytics_processor.track_feature(flag.feature_name)
@@ -5,10 +5,6 @@ import threading
5
5
  import time
6
6
  import typing
7
7
 
8
- import requests
9
-
10
- from flagsmith.exceptions import FlagsmithAPIError
11
-
12
8
  if typing.TYPE_CHECKING:
13
9
  from flagsmith import Flagsmith
14
10
 
@@ -30,10 +26,7 @@ class EnvironmentDataPollingManager(threading.Thread):
30
26
 
31
27
  def run(self) -> None:
32
28
  while not self._stop_event.is_set():
33
- try:
34
- self.main.update_environment()
35
- except (FlagsmithAPIError, requests.RequestException):
36
- logger.exception("Failed to update environment")
29
+ self.main.update_environment()
37
30
  time.sleep(self.refresh_interval_seconds)
38
31
 
39
32
  def stop(self) -> None:
@@ -1,18 +1,17 @@
1
1
  import logging
2
2
  import threading
3
3
  import typing
4
- from typing import Callable, Generator, Optional, Protocol, cast
4
+ from typing import Callable, Optional
5
5
 
6
+ import pydantic
6
7
  import requests
7
8
  import sseclient
8
9
 
9
- from flagsmith.exceptions import FlagsmithAPIError
10
-
11
10
  logger = logging.getLogger(__name__)
12
11
 
13
12
 
14
- class StreamEvent(Protocol):
15
- data: str
13
+ class StreamEvent(pydantic.BaseModel):
14
+ updated_at: pydantic.AwareDatetime
16
15
 
17
16
 
18
17
  class EventStreamManager(threading.Thread):
@@ -22,7 +21,7 @@ class EventStreamManager(threading.Thread):
22
21
  stream_url: str,
23
22
  on_event: Callable[[StreamEvent], None],
24
23
  request_timeout_seconds: Optional[int] = None,
25
- **kwargs: typing.Any
24
+ **kwargs: typing.Any,
26
25
  ) -> None:
27
26
  super().__init__(*args, **kwargs)
28
27
  self._stop_event = threading.Event()
@@ -39,17 +38,12 @@ class EventStreamManager(threading.Thread):
39
38
  headers={"Accept": "application/json, text/event-stream"},
40
39
  timeout=self.request_timeout_seconds,
41
40
  ) as response:
42
- sse_client = sseclient.SSEClient(
43
- cast(Generator[bytes, None, None], response)
44
- )
41
+ sse_client = sseclient.SSEClient(chunk for chunk in response)
45
42
  for event in sse_client.events():
46
- self.on_event(event)
47
-
48
- except requests.exceptions.ReadTimeout:
49
- pass
43
+ self.on_event(StreamEvent.model_validate_json(event.data))
50
44
 
51
- except (FlagsmithAPIError, requests.RequestException):
52
- logger.exception("Error handling event stream")
45
+ except (requests.RequestException, pydantic.ValidationError):
46
+ logger.exception("Error opening or reading from the event stream")
53
47
 
54
48
  def stop(self) -> None:
55
49
  self._stop_event.set()
@@ -0,0 +1,25 @@
1
+ import typing
2
+
3
+ from flag_engine.identities.traits.types import TraitValue
4
+ from typing_extensions import TypeAlias
5
+
6
+ _JsonScalarType: TypeAlias = typing.Union[
7
+ int,
8
+ str,
9
+ float,
10
+ bool,
11
+ None,
12
+ ]
13
+ JsonType: TypeAlias = typing.Union[
14
+ _JsonScalarType,
15
+ typing.Dict[str, "JsonType"],
16
+ typing.List["JsonType"],
17
+ ]
18
+
19
+
20
+ class TraitConfig(typing.TypedDict):
21
+ value: TraitValue
22
+ transient: bool
23
+
24
+
25
+ TraitMapping: TypeAlias = typing.Mapping[str, typing.Union[TraitValue, TraitConfig]]
@@ -0,0 +1,26 @@
1
+ import typing
2
+
3
+ from flagsmith.types import JsonType, TraitMapping
4
+
5
+
6
+ def generate_identity_data(
7
+ identifier: str,
8
+ traits: TraitMapping,
9
+ *,
10
+ transient: bool,
11
+ ) -> JsonType:
12
+ identity_data: typing.Dict[str, JsonType] = {"identifier": identifier}
13
+ traits_data: typing.List[JsonType] = []
14
+ for trait_key, trait_value in traits.items():
15
+ trait_data: typing.Dict[str, JsonType] = {"trait_key": trait_key}
16
+ if isinstance(trait_value, dict):
17
+ trait_data["trait_value"] = trait_value["value"]
18
+ if trait_value.get("transient"):
19
+ trait_data["transient"] = True
20
+ else:
21
+ trait_data["trait_value"] = trait_value
22
+ traits_data.append(trait_data)
23
+ identity_data["traits"] = traits_data
24
+ if transient:
25
+ identity_data["transient"] = True
26
+ return identity_data
@@ -1,42 +1,39 @@
1
1
  [tool.poetry]
2
2
  name = "flagsmith"
3
- version = "3.6.0"
3
+ version = "3.8.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
13
  python = ">=3.8.1,<4"
14
- requests = "^2.27.1"
15
- requests-futures = "^1.0.0"
14
+ requests = "^2.32.3"
15
+ requests-futures = "^1.0.1"
16
16
  flagsmith-flag-engine = "^5.1.0"
17
17
  sseclient-py = "^1.8.0"
18
- pytz = "^2023.4"
18
+ pydantic = "^2"
19
19
 
20
20
  [tool.poetry.group.dev]
21
21
  optional = true
22
22
 
23
23
  [tool.poetry.group.dev.dependencies]
24
24
  pytest = "^7.4.0"
25
+ pytest-cov = "^4.1.0"
25
26
  pytest-mock = "^3.6.1"
26
- black = "^23.3.0"
27
27
  pre-commit = "^2.17.0"
28
28
  responses = "^0.24.1"
29
- flake8 = "^6.1.0"
30
- isort = "^5.12.0"
31
- mypy = "^1.7.1"
32
- types-requests = "^2.31.0.10"
33
- pytest-cov = "^4.1.0"
34
- types-pytz = "^2024.1.0.20240203"
29
+ types-requests = "^2.32"
35
30
 
36
31
  [tool.mypy]
37
32
  plugins = ["pydantic.mypy"]
38
33
  exclude = ["example/*"]
39
34
 
35
+ [tool.black]
36
+ target-version = ["py38"]
40
37
 
41
38
  [build-system]
42
39
  requires = ["poetry-core>=1.0.0"]
@@ -1,19 +0,0 @@
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": [{"trait_key": k, "trait_value": v} for k, v in traits.items()]
17
- if traits
18
- else [],
19
- }
File without changes
File without changes