flagsmith 3.7.0__tar.gz → 3.9.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.7.0
3
+ Version: 3.9.0
4
4
  Summary: Flagsmith Python SDK
5
5
  License: BSD3
6
6
  Keywords: feature,flag,flagsmith,remote,config
@@ -12,10 +12,10 @@ Classifier: Programming Language :: Python :: 3
12
12
  Classifier: Programming Language :: Python :: 3.9
13
13
  Classifier: Programming Language :: Python :: 3.10
14
14
  Classifier: Programming Language :: Python :: 3.11
15
- Classifier: Programming Language :: Python :: 3.12
16
15
  Requires-Dist: flagsmith-flag-engine (>=5.1.0,<6.0.0)
17
- Requires-Dist: requests (>=2.27.1,<3.0.0)
18
- Requires-Dist: requests-futures (>=1.0.0,<2.0.0)
16
+ Requires-Dist: pydantic (>=2,<3)
17
+ Requires-Dist: requests (>=2.32.3,<3.0.0)
18
+ Requires-Dist: requests-futures (>=1.0.1,<2.0.0)
19
19
  Requires-Dist: sseclient-py (>=1.8.0,<2.0.0)
20
20
  Project-URL: Documentation, https://docs.flagsmith.com
21
21
  Description-Content-Type: text/markdown
@@ -36,8 +36,8 @@ For full documentation visit
36
36
 
37
37
  ## Contributing
38
38
 
39
- Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code
40
- of conduct, and the process for submitting pull requests
39
+ Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct, and the process for submitting pull
40
+ requests
41
41
 
42
42
  ## Getting Help
43
43
 
@@ -14,8 +14,8 @@ For full documentation visit
14
14
 
15
15
  ## Contributing
16
16
 
17
- Please read [CONTRIBUTING.md](CONTRIBUTING.md) 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
 
@@ -0,0 +1,4 @@
1
+ from . import webhooks
2
+ from .flagsmith import Flagsmith
3
+
4
+ __all__ = ("Flagsmith", "webhooks")
@@ -1,8 +1,8 @@
1
- import json
2
1
  import logging
3
2
  import typing
4
- from datetime import datetime, timezone
3
+ from datetime import timezone
5
4
 
5
+ import pydantic
6
6
  import requests
7
7
  from flag_engine import engine
8
8
  from flag_engine.environments.models import EnvironmentModel
@@ -19,23 +19,14 @@ from flagsmith.models import DefaultFlag, Flags, Segment
19
19
  from flagsmith.offline_handlers import BaseOfflineHandler
20
20
  from flagsmith.polling_manager import EnvironmentDataPollingManager
21
21
  from flagsmith.streaming_manager import EventStreamManager, StreamEvent
22
- 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
23
24
 
24
25
  logger = logging.getLogger(__name__)
25
26
 
26
27
  DEFAULT_API_URL = "https://edge.api.flagsmith.com/api/v1/"
27
28
  DEFAULT_REALTIME_API_URL = "https://realtime.flagsmith.com/"
28
29
 
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
- ]
38
-
39
30
 
40
31
  class Flagsmith:
41
32
  """A Flagsmith client.
@@ -168,8 +159,10 @@ class Flagsmith:
168
159
  )
169
160
 
170
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()
171
165
  if self.enable_realtime_updates:
172
- self.update_environment()
173
166
  if not self._environment:
174
167
  raise ValueError("Unable to get environment from API key")
175
168
 
@@ -184,9 +177,6 @@ class Flagsmith:
184
177
  self.event_stream_thread.start()
185
178
 
186
179
  else:
187
- # To ensure that the environment is set before allowing subsequent
188
- # method calls, update the environment manually.
189
- self.update_environment()
190
180
  self.environment_data_polling_manager_thread = (
191
181
  EnvironmentDataPollingManager(
192
182
  main=self,
@@ -198,21 +188,6 @@ class Flagsmith:
198
188
  self.environment_data_polling_manager_thread.start()
199
189
 
200
190
  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
191
  if not self._environment:
217
192
  raise ValueError(
218
193
  "Unable to access environment. Environment should not be null"
@@ -221,7 +196,7 @@ class Flagsmith:
221
196
  if environment_updated_at.tzinfo is None:
222
197
  environment_updated_at = environment_updated_at.astimezone(timezone.utc)
223
198
 
224
- if stream_updated_at > environment_updated_at:
199
+ if event.updated_at > environment_updated_at:
225
200
  self.update_environment()
226
201
 
227
202
  def get_environment_flags(self) -> Flags:
@@ -237,7 +212,9 @@ class Flagsmith:
237
212
  def get_identity_flags(
238
213
  self,
239
214
  identifier: str,
240
- traits: typing.Optional[typing.Mapping[str, TraitValue]] = None,
215
+ traits: typing.Optional[TraitMapping] = None,
216
+ *,
217
+ transient: bool = False,
241
218
  ) -> Flags:
242
219
  """
243
220
  Get all the flags for the current environment for a given identity. Will also
@@ -247,13 +224,20 @@ class Flagsmith:
247
224
  :param identifier: a unique identifier for the identity in the current
248
225
  environment, e.g. email address, username, uuid
249
226
  :param traits: a dictionary of traits to add / update on the identity in
250
- 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
251
231
  :return: Flags object holding all the flags for the given identity.
252
232
  """
253
233
  traits = traits or {}
254
234
  if (self.offline_mode or self.enable_local_evaluation) and self._environment:
255
235
  return self._get_identity_flags_from_document(identifier, traits)
256
- 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
+ )
257
241
 
258
242
  def get_identity_segments(
259
243
  self,
@@ -281,16 +265,15 @@ class Flagsmith:
281
265
  return [Segment(id=sm.id, name=sm.name) for sm in segment_models]
282
266
 
283
267
  def update_environment(self) -> None:
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
- }
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
+ }
294
277
 
295
278
  def _get_environment_from_api(self) -> EnvironmentModel:
296
279
  environment_data = self._get_json_response(self.environment_url, method="GET")
@@ -306,7 +289,7 @@ class Flagsmith:
306
289
  )
307
290
 
308
291
  def _get_identity_flags_from_document(
309
- self, identifier: str, traits: typing.Mapping[str, TraitValue]
292
+ self, identifier: str, traits: TraitMapping
310
293
  ) -> Flags:
311
294
  identity_model = self._get_identity_model(identifier, **traits)
312
295
  if self._environment is None:
@@ -339,13 +322,23 @@ class Flagsmith:
339
322
  raise
340
323
 
341
324
  def _get_identity_flags_from_api(
342
- self, identifier: str, traits: typing.Mapping[str, typing.Any]
325
+ self,
326
+ identifier: str,
327
+ traits: TraitMapping,
328
+ *,
329
+ transient: bool = False,
343
330
  ) -> Flags:
331
+ request_body = generate_identity_data(
332
+ identifier,
333
+ traits,
334
+ transient=transient,
335
+ )
344
336
  try:
345
- data = generate_identities_data(identifier, traits)
346
337
  json_response: typing.Dict[str, typing.List[typing.Dict[str, JsonType]]] = (
347
338
  self._get_json_response(
348
- url=self.identities_url, method="POST", body=data
339
+ url=self.identities_url,
340
+ method="POST",
341
+ body=request_body,
349
342
  )
350
343
  )
351
344
  return Flags.from_api_flags(
@@ -364,22 +357,16 @@ class Flagsmith:
364
357
  self,
365
358
  url: str,
366
359
  method: str,
367
- body: typing.Optional[
368
- typing.Union[Identity, typing.Dict[str, JsonType]]
369
- ] = None,
360
+ body: typing.Optional[JsonType] = None,
370
361
  ) -> typing.Any:
371
362
  try:
372
363
  request_method = getattr(self.session, method.lower())
373
364
  response = request_method(
374
365
  url, json=body, timeout=self.request_timeout_seconds
375
366
  )
376
- if response.status_code != 200:
377
- raise FlagsmithAPIError(
378
- "Invalid request made to Flagsmith API. Response status code: %d",
379
- response.status_code,
380
- )
367
+ response.raise_for_status()
381
368
  return response.json()
382
- except (requests.ConnectionError, json.JSONDecodeError) as e:
369
+ except requests.RequestException as e:
383
370
  raise FlagsmithAPIError(
384
371
  "Unable to get valid response from Flagsmith API."
385
372
  ) from e
@@ -387,7 +374,7 @@ class Flagsmith:
387
374
  def _get_identity_model(
388
375
  self,
389
376
  identifier: str,
390
- **traits: TraitValue,
377
+ **traits: typing.Union[TraitValue, TraitConfig],
391
378
  ) -> IdentityModel:
392
379
  if not self._environment:
393
380
  raise FlagsmithClientError(
@@ -395,7 +382,10 @@ class Flagsmith:
395
382
  )
396
383
 
397
384
  trait_models = [
398
- 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
+ )
399
389
  for key, value in traits.items()
400
390
  ]
401
391
 
@@ -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
@@ -0,0 +1,41 @@
1
+ import hashlib
2
+ import hmac
3
+ from typing import Union
4
+
5
+
6
+ def generate_signature(
7
+ request_body: Union[str, bytes],
8
+ shared_secret: str,
9
+ ) -> str:
10
+ """Generates a signature for a webhook request body using HMAC-SHA256.
11
+
12
+ :param request_body: The raw request body, as string or bytes.
13
+ :param shared_secret: The shared secret configured for this specific webhook.
14
+ :return: The hex-encoded signature.
15
+ """
16
+ if isinstance(request_body, str):
17
+ request_body = request_body.encode()
18
+
19
+ shared_secret_bytes = shared_secret.encode()
20
+
21
+ return hmac.new(
22
+ key=shared_secret_bytes,
23
+ msg=request_body,
24
+ digestmod=hashlib.sha256,
25
+ ).hexdigest()
26
+
27
+
28
+ def verify_signature(
29
+ request_body: Union[str, bytes],
30
+ received_signature: str,
31
+ shared_secret: str,
32
+ ) -> bool:
33
+ """Verifies a webhook's signature to determine if the request was sent by Flagsmith.
34
+
35
+ :param request_body: The raw request body, as string or bytes.
36
+ :param received_signature: The signature as received in the X-Flagsmith-Signature request header.
37
+ :param shared_secret: The shared secret configured for this specific webhook.
38
+ :return: True if the signature is valid, False otherwise.
39
+ """
40
+ expected_signature = generate_signature(request_body, shared_secret)
41
+ return hmac.compare_digest(expected_signature, received_signature)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "flagsmith"
3
- version = "3.7.0"
3
+ version = "3.9.0"
4
4
  description = "Flagsmith Python SDK"
5
5
  authors = ["Flagsmith <support@flagsmith.com>"]
6
6
  license = "BSD3"
@@ -11,30 +11,29 @@ 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
+ pydantic = "^2"
18
19
 
19
20
  [tool.poetry.group.dev]
20
21
  optional = true
21
22
 
22
23
  [tool.poetry.group.dev.dependencies]
23
24
  pytest = "^7.4.0"
25
+ pytest-cov = "^4.1.0"
24
26
  pytest-mock = "^3.6.1"
25
- black = ">=23.3,<25.0"
26
27
  pre-commit = "^2.17.0"
27
28
  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"
29
+ types-requests = "^2.32"
33
30
 
34
31
  [tool.mypy]
35
32
  plugins = ["pydantic.mypy"]
36
33
  exclude = ["example/*"]
37
34
 
35
+ [tool.black]
36
+ target-version = ["py38"]
38
37
 
39
38
  [build-system]
40
39
  requires = ["poetry-core>=1.0.0"]
@@ -1,3 +0,0 @@
1
- from .flagsmith import Flagsmith
2
-
3
- __all__ = ("Flagsmith",)
@@ -1,21 +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": (
17
- [{"trait_key": k, "trait_value": v} for k, v in traits.items()]
18
- if traits
19
- else []
20
- ),
21
- }
File without changes
File without changes
File without changes