Skip to content

Commit 2654f94

Browse files
committed
Initial implementation of offline mode (single client class)
1 parent 459a690 commit 2654f94

File tree

3 files changed

+94
-34
lines changed

3 files changed

+94
-34
lines changed

flagsmith/flagsmith.py

Lines changed: 47 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from flagsmith.analytics import AnalyticsProcessor
1515
from flagsmith.exceptions import FlagsmithAPIError, FlagsmithClientError
1616
from flagsmith.models import DefaultFlag, Flags, Segment
17+
from flagsmith.offline_handlers import BaseOfflineModeHandler
1718
from flagsmith.polling_manager import EnvironmentDataPollingManager
1819
from flagsmith.utils.identities import generate_identities_data
1920

@@ -39,8 +40,8 @@ class Flagsmith:
3940

4041
def __init__(
4142
self,
42-
environment_key: str,
43-
api_url: str = DEFAULT_API_URL,
43+
environment_key: str = None,
44+
api_url: str = None,
4445
custom_headers: typing.Dict[str, typing.Any] = None,
4546
request_timeout_seconds: int = None,
4647
enable_local_evaluation: bool = False,
@@ -49,6 +50,8 @@ def __init__(
4950
enable_analytics: bool = False,
5051
default_flag_handler: typing.Callable[[str], DefaultFlag] = None,
5152
proxies: typing.Dict[str, str] = None,
53+
offline_mode: bool = True,
54+
offline_handler: BaseOfflineModeHandler = None,
5255
):
5356
"""
5457
:param environment_key: The environment key obtained from Flagsmith interface
@@ -69,37 +72,45 @@ def __init__(
6972
requested
7073
:param proxies: as per https://requests.readthedocs.io/en/latest/api/#requests.Session.proxies
7174
"""
72-
self.session = requests.Session()
73-
self.session.headers.update(
74-
**{"X-Environment-Key": environment_key}, **(custom_headers or {})
75-
)
76-
self.session.proxies.update(proxies or {})
77-
retries = retries or Retry(total=3, backoff_factor=0.1)
78-
79-
self.api_url = api_url if api_url.endswith("/") else f"{api_url}/"
80-
self.request_timeout_seconds = request_timeout_seconds
81-
self.session.mount(self.api_url, HTTPAdapter(max_retries=retries))
82-
83-
self.environment_flags_url = f"{self.api_url}flags/"
84-
self.identities_url = f"{self.api_url}identities/"
85-
self.environment_url = f"{self.api_url}environment-document/"
86-
87-
self._environment = None
88-
if enable_local_evaluation:
89-
if not environment_key.startswith("ser."):
90-
raise ValueError(
91-
"In order to use local evaluation, please generate a server key "
92-
"in the environment settings page."
93-
)
94-
95-
self.environment_data_polling_manager_thread = (
96-
EnvironmentDataPollingManager(
97-
main=self,
98-
refresh_interval_seconds=environment_refresh_interval_seconds,
99-
daemon=True, # noqa
100-
)
75+
if offline_mode:
76+
self.offline_mode = True
77+
self.offline_handler = offline_handler
78+
self.update_environment()
79+
elif not (environment_key and api_url):
80+
raise ValueError("environment_key and api_url are required.")
81+
else:
82+
self.offline_mode = False
83+
self.session = requests.Session()
84+
self.session.headers.update(
85+
**{"X-Environment-Key": environment_key}, **(custom_headers or {})
10186
)
102-
self.environment_data_polling_manager_thread.start()
87+
self.session.proxies.update(proxies or {})
88+
retries = retries or Retry(total=3, backoff_factor=0.1)
89+
90+
self.api_url = api_url if api_url.endswith("/") else f"{api_url}/"
91+
self.request_timeout_seconds = request_timeout_seconds
92+
self.session.mount(self.api_url, HTTPAdapter(max_retries=retries))
93+
94+
self.environment_flags_url = f"{self.api_url}flags/"
95+
self.identities_url = f"{self.api_url}identities/"
96+
self.environment_url = f"{self.api_url}environment-document/"
97+
98+
self._environment = None
99+
if enable_local_evaluation:
100+
if not environment_key.startswith("ser."):
101+
raise ValueError(
102+
"In order to use local evaluation, please generate a server key "
103+
"in the environment settings page."
104+
)
105+
106+
self.environment_data_polling_manager_thread = (
107+
EnvironmentDataPollingManager(
108+
main=self,
109+
refresh_interval_seconds=environment_refresh_interval_seconds,
110+
daemon=True, # noqa
111+
)
112+
)
113+
self.environment_data_polling_manager_thread.start()
103114

104115
self._analytics_processor = (
105116
AnalyticsProcessor(
@@ -164,7 +175,10 @@ def get_identity_segments(
164175
return [Segment(id=sm.id, name=sm.name) for sm in segment_models]
165176

166177
def update_environment(self):
167-
self._environment = self._get_environment_from_api()
178+
if self.offline_mode:
179+
self._environment = self.offline_handler.get_environment()
180+
else:
181+
self._environment = self._get_environment_from_api()
168182

169183
def _get_environment_from_api(self) -> EnvironmentModel:
170184
environment_data = self._get_json_response(self.environment_url, method="GET")

flagsmith/offline_handlers.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import json
2+
from abc import ABC, abstractmethod
3+
4+
from flag_engine.environments.builders import build_environment_model
5+
from flag_engine.environments.models import EnvironmentModel
6+
7+
8+
class BaseOfflineModeHandler(ABC):
9+
@abstractmethod
10+
def get_environment(self) -> EnvironmentModel:
11+
raise NotImplementedError()
12+
13+
14+
class LocalFileStorageHandler(BaseOfflineModeHandler):
15+
def __init__(self, environment_document_path: str):
16+
with open(environment_document_path) as environment_document:
17+
self.environment = build_environment_model(
18+
json.loads(environment_document.read())
19+
)
20+
21+
def get_environment(self) -> EnvironmentModel:
22+
return self.environment

tests/test_flagsmith.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22
import uuid
3+
from unittest.mock import mock_open, patch
34

45
import pytest
56
import requests
@@ -8,7 +9,8 @@
89

910
from flagsmith import Flagsmith
1011
from flagsmith.exceptions import FlagsmithAPIError
11-
from flagsmith.models import DefaultFlag
12+
from flagsmith.models import DefaultFlag, Flags
13+
from flagsmith.offline_handlers import LocalFileStorageHandler
1214

1315

1416
def test_flagsmith_starts_polling_manager_on_init_if_enabled(mocker, server_api_key):
@@ -378,3 +380,25 @@ def test_initialise_flagsmith_with_proxies():
378380

379381
# Then
380382
assert flagsmith.session.proxies == proxies
383+
384+
385+
def test_offline_mode(environment_json: str) -> None:
386+
# Given
387+
environment_document_file_path = "some/file/path/environment.json"
388+
389+
# When
390+
with patch("builtins.open", mock_open(read_data=environment_json)) as mock_file:
391+
flagsmith = Flagsmith(
392+
offline_mode=True,
393+
offline_handler=LocalFileStorageHandler(environment_document_file_path),
394+
)
395+
396+
# Then
397+
mock_file.assert_called_once_with(environment_document_file_path)
398+
399+
# and we can request the flags from the client successfully
400+
environment_flags: Flags = flagsmith.get_environment_flags()
401+
assert environment_flags.is_feature_enabled("some_feature") is True
402+
403+
identity_flags: Flags = flagsmith.get_identity_flags("identity")
404+
assert identity_flags.is_feature_enabled("some_feature") is True

0 commit comments

Comments
 (0)