1
+ import json
1
2
import logging
2
3
import typing
3
- from json import JSONDecodeError
4
+ from datetime import datetime
4
5
6
+ import pytz
5
7
import requests
6
8
from flag_engine import engine
7
9
from flag_engine .environments .models import EnvironmentModel
15
17
from flagsmith .models import DefaultFlag , Flags , Segment
16
18
from flagsmith .offline_handlers import BaseOfflineHandler
17
19
from flagsmith .polling_manager import EnvironmentDataPollingManager
20
+ from flagsmith .streaming_manager import EventStreamManager , StreamEvent
18
21
from flagsmith .utils .identities import generate_identities_data
19
22
20
23
logger = logging .getLogger (__name__ )
21
24
22
25
DEFAULT_API_URL = "https://edge.api.flagsmith.com/api/v1/"
26
+ DEFAULT_REALTIME_API_URL = "https://realtime.flagsmith.com/"
23
27
24
28
25
29
class Flagsmith :
@@ -41,6 +45,7 @@ def __init__(
41
45
self ,
42
46
environment_key : str = None ,
43
47
api_url : str = None ,
48
+ realtime_api_url : typing .Optional [str ] = None ,
44
49
custom_headers : typing .Dict [str , typing .Any ] = None ,
45
50
request_timeout_seconds : int = None ,
46
51
enable_local_evaluation : bool = False ,
@@ -51,11 +56,13 @@ def __init__(
51
56
proxies : typing .Dict [str , str ] = None ,
52
57
offline_mode : bool = False ,
53
58
offline_handler : BaseOfflineHandler = None ,
59
+ enable_realtime_updates : bool = False ,
54
60
):
55
61
"""
56
62
:param environment_key: The environment key obtained from Flagsmith interface.
57
63
Required unless offline_mode is True.
58
64
:param api_url: Override the URL of the Flagsmith API to communicate with
65
+ :param realtime_api_url: Override the URL of the Flagsmith real-time API
59
66
:param custom_headers: Additional headers to add to requests made to the
60
67
Flagsmith API
61
68
:param request_timeout_seconds: Number of seconds to wait for a request to
@@ -76,12 +83,15 @@ def __init__(
76
83
:param offline_handler: provide a handler for offline logic. Used to get environment
77
84
document from another source when in offline_mode. Works in place of
78
85
default_flag_handler if offline_mode is not set and using remote evaluation.
86
+ :param enable_realtime_updates: Use real-time functionality via SSE as opposed to polling the API
79
87
"""
80
88
81
89
self .offline_mode = offline_mode
82
90
self .enable_local_evaluation = enable_local_evaluation
91
+ self .environment_refresh_interval_seconds = environment_refresh_interval_seconds
83
92
self .offline_handler = offline_handler
84
93
self .default_flag_handler = default_flag_handler
94
+ self .enable_realtime_updates = enable_realtime_updates
85
95
self ._analytics_processor = None
86
96
self ._environment = None
87
97
@@ -93,6 +103,11 @@ def __init__(
93
103
"Cannot use both default_flag_handler and offline_handler."
94
104
)
95
105
106
+ if enable_realtime_updates and not enable_local_evaluation :
107
+ raise ValueError (
108
+ "Can only use realtime updates when running in local evaluation mode."
109
+ )
110
+
96
111
if self .offline_handler :
97
112
self ._environment = self .offline_handler .get_environment ()
98
113
@@ -110,6 +125,13 @@ def __init__(
110
125
api_url = api_url or DEFAULT_API_URL
111
126
self .api_url = api_url if api_url .endswith ("/" ) else f"{ api_url } /"
112
127
128
+ realtime_api_url = realtime_api_url or DEFAULT_REALTIME_API_URL
129
+ self .realtime_api_url = (
130
+ realtime_api_url
131
+ if realtime_api_url .endswith ("/" )
132
+ else f"{ realtime_api_url } /"
133
+ )
134
+
113
135
self .request_timeout_seconds = request_timeout_seconds
114
136
self .session .mount (self .api_url , HTTPAdapter (max_retries = retries ))
115
137
@@ -124,20 +146,60 @@ def __init__(
124
146
"in the environment settings page."
125
147
)
126
148
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 ()
149
+ self ._initialise_local_evaluation ()
135
150
136
151
if enable_analytics :
137
152
self ._analytics_processor = AnalyticsProcessor (
138
153
environment_key , self .api_url , timeout = self .request_timeout_seconds
139
154
)
140
155
156
+ def _initialise_local_evaluation (self ) -> None :
157
+ if self .enable_realtime_updates :
158
+ self .update_environment ()
159
+ stream_url = f"{ self .realtime_api_url } sse/environments/{ self ._environment .api_key } /stream"
160
+
161
+ self .event_stream_thread = EventStreamManager (
162
+ stream_url = stream_url ,
163
+ on_event = self .handle_stream_event ,
164
+ daemon = True ,
165
+ )
166
+
167
+ self .event_stream_thread .start ()
168
+
169
+ else :
170
+ self .environment_data_polling_manager_thread = (
171
+ EnvironmentDataPollingManager (
172
+ main = self ,
173
+ refresh_interval_seconds = self .environment_refresh_interval_seconds ,
174
+ daemon = True ,
175
+ )
176
+ )
177
+
178
+ self .environment_data_polling_manager_thread .start ()
179
+
180
+ def handle_stream_event (self , event : StreamEvent ) -> None :
181
+ try :
182
+ event_data = json .loads (event .data )
183
+ except json .JSONDecodeError as e :
184
+ raise FlagsmithAPIError ("Unable to get valid json from event data." ) from e
185
+
186
+ try :
187
+ stream_updated_at = datetime .fromtimestamp (event_data .get ("updated_at" ))
188
+ except TypeError as e :
189
+ raise FlagsmithAPIError (
190
+ "Unable to get valid timestamp from event data."
191
+ ) from e
192
+
193
+ if stream_updated_at .tzinfo is None :
194
+ stream_updated_at = pytz .utc .localize (stream_updated_at )
195
+
196
+ environment_updated_at = self ._environment .updated_at
197
+ if environment_updated_at .tzinfo is None :
198
+ environment_updated_at = pytz .utc .localize (environment_updated_at )
199
+
200
+ if stream_updated_at > environment_updated_at :
201
+ self .update_environment ()
202
+
141
203
def get_environment_flags (self ) -> Flags :
142
204
"""
143
205
Get all the default for flags for the current environment.
@@ -267,7 +329,7 @@ def _get_json_response(self, url: str, method: str, body: dict = None):
267
329
response .status_code ,
268
330
)
269
331
return response .json ()
270
- except (requests .ConnectionError , JSONDecodeError ) as e :
332
+ except (requests .ConnectionError , json . JSONDecodeError ) as e :
271
333
raise FlagsmithAPIError (
272
334
"Unable to get valid response from Flagsmith API."
273
335
) from e
@@ -291,3 +353,6 @@ def _build_identity_model(self, identifier: str, **traits):
291
353
def __del__ (self ):
292
354
if hasattr (self , "environment_data_polling_manager_thread" ):
293
355
self .environment_data_polling_manager_thread .stop ()
356
+
357
+ if hasattr (self , "event_stream_thread" ):
358
+ self .event_stream_thread .stop ()
0 commit comments