Skip to content

Commit 36fd7e9

Browse files
Implementation of offline mode (single client class) (#50)
* Initial implementation of offline mode (single client class) * Use offline handler if API request fails * Improve test coverage
1 parent faa17f7 commit 36fd7e9

File tree

4 files changed

+215
-43
lines changed

4 files changed

+215
-43
lines changed

flagsmith/flagsmith.py

Lines changed: 74 additions & 42 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 BaseOfflineHandler
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,9 +50,12 @@ 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 = False,
54+
offline_handler: BaseOfflineHandler = None,
5255
):
5356
"""
54-
:param environment_key: The environment key obtained from Flagsmith interface
57+
:param environment_key: The environment key obtained from Flagsmith interface.
58+
Required unless offline_mode is True.
5559
:param api_url: Override the URL of the Flagsmith API to communicate with
5660
:param custom_headers: Additional headers to add to requests made to the
5761
Flagsmith API
@@ -65,59 +69,83 @@ def __init__(
6569
:param enable_analytics: if enabled, sends additional requests to the Flagsmith
6670
API to power flag analytics charts
6771
:param default_flag_handler: callable which will be used in the case where
68-
flags cannot be retrieved from the API or a non existent feature is
72+
flags cannot be retrieved from the API or a non-existent feature is
6973
requested
7074
:param proxies: as per https://requests.readthedocs.io/en/latest/api/#requests.Session.proxies
75+
:param offline_mode: sets the client into offline mode. Relies on offline_handler for
76+
evaluating flags.
77+
:param offline_handler: provide a handler for offline logic. Used to get environment
78+
document from another source when in offline_mode. Works in place of
79+
default_flag_handler if offline_mode is not set and using remote evaluation.
7180
"""
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/"
8681

82+
self.offline_mode = offline_mode
83+
self.enable_local_evaluation = enable_local_evaluation
84+
self.offline_handler = offline_handler
85+
self.default_flag_handler = default_flag_handler
86+
self._analytics_processor = None
8787
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-
)
9488

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-
)
89+
# argument validation
90+
if offline_mode and not offline_handler:
91+
raise ValueError("offline_handler must be provided to use offline mode.")
92+
elif default_flag_handler and offline_handler:
93+
raise ValueError(
94+
"Cannot use both default_flag_handler and offline_handler."
10195
)
102-
self.environment_data_polling_manager_thread.start()
10396

104-
self._analytics_processor = (
105-
AnalyticsProcessor(
106-
environment_key, self.api_url, timeout=self.request_timeout_seconds
97+
if self.offline_handler:
98+
self._environment = self.offline_handler.get_environment()
99+
100+
if not self.offline_mode:
101+
if not environment_key:
102+
raise ValueError("environment_key is required.")
103+
104+
self.session = requests.Session()
105+
self.session.headers.update(
106+
**{"X-Environment-Key": environment_key}, **(custom_headers or {})
107107
)
108-
if enable_analytics
109-
else None
110-
)
108+
self.session.proxies.update(proxies or {})
109+
retries = retries or Retry(total=3, backoff_factor=0.1)
110+
111+
api_url = api_url or DEFAULT_API_URL
112+
self.api_url = api_url if api_url.endswith("/") else f"{api_url}/"
113+
114+
self.request_timeout_seconds = request_timeout_seconds
115+
self.session.mount(self.api_url, HTTPAdapter(max_retries=retries))
116+
117+
self.environment_flags_url = f"{self.api_url}flags/"
118+
self.identities_url = f"{self.api_url}identities/"
119+
self.environment_url = f"{self.api_url}environment-document/"
120+
121+
if self.enable_local_evaluation:
122+
if not environment_key.startswith("ser."):
123+
raise ValueError(
124+
"In order to use local evaluation, please generate a server key "
125+
"in the environment settings page."
126+
)
127+
128+
self.environment_data_polling_manager_thread = (
129+
EnvironmentDataPollingManager(
130+
main=self,
131+
refresh_interval_seconds=environment_refresh_interval_seconds,
132+
daemon=True, # noqa
133+
)
134+
)
135+
self.environment_data_polling_manager_thread.start()
111136

112-
self.default_flag_handler = default_flag_handler
137+
if enable_analytics:
138+
self._analytics_processor = AnalyticsProcessor(
139+
environment_key, self.api_url, timeout=self.request_timeout_seconds
140+
)
113141

114142
def get_environment_flags(self) -> Flags:
115143
"""
116144
Get all the default for flags for the current environment.
117145
118146
:return: Flags object holding all the flags for the current environment.
119147
"""
120-
if self._environment:
148+
if (self.offline_mode or self.enable_local_evaluation) and self._environment:
121149
return self._get_environment_flags_from_document()
122150
return self._get_environment_flags_from_api()
123151

@@ -136,7 +164,7 @@ def get_identity_flags(
136164
:return: Flags object holding all the flags for the given identity.
137165
"""
138166
traits = traits or {}
139-
if self._environment:
167+
if (self.offline_mode or self.enable_local_evaluation) and self._environment:
140168
return self._get_identity_flags_from_document(identifier, traits)
141169
return self._get_identity_flags_from_api(identifier, traits)
142170

@@ -202,7 +230,9 @@ def _get_environment_flags_from_api(self) -> Flags:
202230
default_flag_handler=self.default_flag_handler,
203231
)
204232
except FlagsmithAPIError:
205-
if self.default_flag_handler:
233+
if self.offline_handler:
234+
return self._get_environment_flags_from_document()
235+
elif self.default_flag_handler:
206236
return Flags(default_flag_handler=self.default_flag_handler)
207237
raise
208238

@@ -220,7 +250,9 @@ def _get_identity_flags_from_api(
220250
default_flag_handler=self.default_flag_handler,
221251
)
222252
except FlagsmithAPIError:
223-
if self.default_flag_handler:
253+
if self.offline_handler:
254+
return self._get_identity_flags_from_document(identifier, traits)
255+
elif self.default_flag_handler:
224256
return Flags(default_flag_handler=self.default_flag_handler)
225257
raise
226258

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 BaseOfflineHandler(ABC):
9+
@abstractmethod
10+
def get_environment(self) -> EnvironmentModel:
11+
raise NotImplementedError()
12+
13+
14+
class LocalFileHandler(BaseOfflineHandler):
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: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
import typing
23
import uuid
34

45
import pytest
@@ -8,7 +9,12 @@
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 BaseOfflineHandler
14+
15+
if typing.TYPE_CHECKING:
16+
from flag_engine.environments.models import EnvironmentModel
17+
from pytest_mock import MockerFixture
1218

1319

1420
def test_flagsmith_starts_polling_manager_on_init_if_enabled(mocker, server_api_key):
@@ -68,6 +74,7 @@ def test_get_environment_flags_uses_local_environment_when_available(
6874
):
6975
# Given
7076
flagsmith._environment = environment_model
77+
flagsmith.enable_local_evaluation = True
7178

7279
# When
7380
all_flags = flagsmith.get_environment_flags().all_flags()
@@ -134,6 +141,7 @@ def test_get_identity_flags_uses_local_environment_when_available(
134141
):
135142
# Given
136143
flagsmith._environment = environment_model
144+
flagsmith.enable_local_evaluation = True
137145
mock_engine = mocker.patch("flagsmith.flagsmith.engine")
138146

139147
feature_state = FeatureStateModel(
@@ -378,3 +386,91 @@ def test_initialise_flagsmith_with_proxies():
378386

379387
# Then
380388
assert flagsmith.session.proxies == proxies
389+
390+
391+
def test_offline_mode(environment_model: "EnvironmentModel") -> None:
392+
# Given
393+
class DummyOfflineHandler(BaseOfflineHandler):
394+
def get_environment(self) -> "EnvironmentModel":
395+
return environment_model
396+
397+
# When
398+
flagsmith = Flagsmith(offline_mode=True, offline_handler=DummyOfflineHandler())
399+
400+
# Then
401+
# we can request the flags from the client successfully
402+
environment_flags: Flags = flagsmith.get_environment_flags()
403+
assert environment_flags.is_feature_enabled("some_feature") is True
404+
405+
identity_flags: Flags = flagsmith.get_identity_flags("identity")
406+
assert identity_flags.is_feature_enabled("some_feature") is True
407+
408+
409+
@responses.activate()
410+
def test_flagsmith_uses_offline_handler_if_set_and_no_api_response(
411+
mocker: "MockerFixture", environment_model: "EnvironmentModel"
412+
) -> None:
413+
# Given
414+
api_url = "http://some.flagsmith.com/api/v1/"
415+
mock_offline_handler = mocker.MagicMock(spec=BaseOfflineHandler)
416+
mock_offline_handler.get_environment.return_value = environment_model
417+
418+
flagsmith = Flagsmith(
419+
environment_key="some-key",
420+
api_url=api_url,
421+
offline_handler=mock_offline_handler,
422+
)
423+
424+
responses.add(flagsmith.environment_flags_url, status=500)
425+
responses.add(flagsmith.identities_url, status=500)
426+
427+
# When
428+
environment_flags = flagsmith.get_environment_flags()
429+
identity_flags = flagsmith.get_identity_flags("identity", traits={})
430+
431+
# Then
432+
mock_offline_handler.get_environment.assert_called_once_with()
433+
434+
assert environment_flags.is_feature_enabled("some_feature") is True
435+
assert environment_flags.get_feature_value("some_feature") == "some-value"
436+
437+
assert identity_flags.is_feature_enabled("some_feature") is True
438+
assert identity_flags.get_feature_value("some_feature") == "some-value"
439+
440+
441+
def test_cannot_use_offline_mode_without_offline_handler():
442+
with pytest.raises(ValueError) as e:
443+
# When
444+
Flagsmith(offline_mode=True, offline_handler=None)
445+
446+
# Then
447+
assert (
448+
e.exconly()
449+
== "ValueError: offline_handler must be provided to use offline mode."
450+
)
451+
452+
453+
def test_cannot_use_default_handler_and_offline_handler(mocker):
454+
# When
455+
with pytest.raises(ValueError) as e:
456+
Flagsmith(
457+
offline_handler=mocker.MagicMock(spec=BaseOfflineHandler),
458+
default_flag_handler=lambda flag_name: DefaultFlag(
459+
enabled=True, value="foo"
460+
),
461+
)
462+
463+
# Then
464+
assert (
465+
e.exconly()
466+
== "ValueError: Cannot use both default_flag_handler and offline_handler."
467+
)
468+
469+
470+
def test_cannot_create_flagsmith_client_in_remote_evaluation_without_api_key():
471+
# When
472+
with pytest.raises(ValueError) as e:
473+
Flagsmith()
474+
475+
# Then
476+
assert e.exconly() == "ValueError: environment_key is required."

tests/test_offline_handlers.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from unittest.mock import mock_open, patch
2+
3+
from flag_engine.environments.models import EnvironmentModel
4+
5+
from flagsmith.offline_handlers import LocalFileHandler
6+
7+
8+
def test_local_file_handler(environment_json):
9+
with patch("builtins.open", mock_open(read_data=environment_json)) as mock_file:
10+
# Given
11+
environment_document_file_path = "/some/path/environment.json"
12+
local_file_handler = LocalFileHandler(environment_document_file_path)
13+
14+
# When
15+
environment_model = local_file_handler.get_environment()
16+
17+
# Then
18+
assert isinstance(environment_model, EnvironmentModel)
19+
assert (
20+
environment_model.api_key == "B62qaMZNwfiqT76p38ggrQ"
21+
) # hard coded from json file
22+
mock_file.assert_called_once_with(environment_document_file_path)

0 commit comments

Comments
 (0)