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.
- {flagsmith-3.5.0 → flagsmith-3.7.0}/PKG-INFO +6 -7
- flagsmith-3.5.0/Readme.md → flagsmith-3.7.0/README.md +2 -2
- flagsmith-3.7.0/flagsmith/__init__.py +3 -0
- {flagsmith-3.5.0 → flagsmith-3.7.0}/flagsmith/analytics.py +11 -8
- {flagsmith-3.5.0 → flagsmith-3.7.0}/flagsmith/exceptions.py +4 -0
- {flagsmith-3.5.0 → flagsmith-3.7.0}/flagsmith/flagsmith.py +161 -37
- {flagsmith-3.5.0 → flagsmith-3.7.0}/flagsmith/models.py +29 -26
- {flagsmith-3.5.0 → flagsmith-3.7.0}/flagsmith/polling_manager.py +6 -4
- flagsmith-3.7.0/flagsmith/streaming_manager.py +58 -0
- flagsmith-3.7.0/flagsmith/utils/__init__.py +0 -0
- flagsmith-3.7.0/flagsmith/utils/identities.py +21 -0
- {flagsmith-3.5.0 → flagsmith-3.7.0}/pyproject.toml +20 -11
- flagsmith-3.5.0/flagsmith/__init__.py +0 -1
- flagsmith-3.5.0/flagsmith/utils/identities.py +0 -5
- {flagsmith-3.5.0 → flagsmith-3.7.0}/LICENSE +0 -0
- {flagsmith-3.5.0 → flagsmith-3.7.0}/flagsmith/offline_handlers.py +0 -0
- /flagsmith-3.5.0/flagsmith/utils/__init__.py → /flagsmith-3.7.0/flagsmith/py.typed +0 -0
|
@@ -1,23 +1,22 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: flagsmith
|
|
3
|
-
Version: 3.
|
|
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.
|
|
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.
|
|
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](
|
|
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](
|
|
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
|
|
@@ -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__(
|
|
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()
|
|
@@ -1,11 +1,14 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import logging
|
|
2
3
|
import typing
|
|
3
|
-
from
|
|
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
|
|
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.
|
|
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
|
-
|
|
45
|
-
|
|
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.
|
|
51
|
-
|
|
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.
|
|
86
|
-
self.
|
|
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.
|
|
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,
|
|
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,
|
|
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.
|
|
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.
|
|
309
|
+
self, identifier: str, traits: typing.Mapping[str, TraitValue]
|
|
209
310
|
) -> Flags:
|
|
210
|
-
identity_model = self.
|
|
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
|
-
|
|
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=
|
|
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.
|
|
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 =
|
|
244
|
-
|
|
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(
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
) ->
|
|
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:
|
|
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.
|
|
61
|
-
analytics_processor: AnalyticsProcessor,
|
|
62
|
-
default_flag_handler: typing.Callable,
|
|
63
|
-
identity_id: typing.Union[str, int] = None,
|
|
64
|
-
) ->
|
|
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.
|
|
82
|
-
analytics_processor: AnalyticsProcessor,
|
|
83
|
-
default_flag_handler: typing.Callable,
|
|
84
|
-
) ->
|
|
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) ->
|
|
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:
|
|
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
|
|
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:
|
|
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.
|
|
3
|
+
version = "3.7.0"
|
|
4
4
|
description = "Flagsmith Python SDK"
|
|
5
5
|
authors = ["Flagsmith <support@flagsmith.com>"]
|
|
6
6
|
license = "BSD3"
|
|
7
|
-
readme = "
|
|
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.
|
|
13
|
+
python = ">=3.8.1,<4"
|
|
14
14
|
requests = "^2.27.1"
|
|
15
15
|
requests-futures = "^1.0.0"
|
|
16
|
-
flagsmith-flag-engine = "^5.
|
|
16
|
+
flagsmith-flag-engine = "^5.1.0"
|
|
17
|
+
sseclient-py = "^1.8.0"
|
|
17
18
|
|
|
18
|
-
[tool.poetry.dev
|
|
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 = "
|
|
25
|
+
black = ">=23.3,<25.0"
|
|
22
26
|
pre-commit = "^2.17.0"
|
|
23
|
-
responses = "^0.
|
|
24
|
-
flake8 = "^
|
|
25
|
-
isort = "^5.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|