Skip to content

Commit 374e292

Browse files
authored
feat!: Restore v3 OfflineHandler interface (#162)
1 parent 68d44a1 commit 374e292

File tree

9 files changed

+145
-68
lines changed

9 files changed

+145
-68
lines changed

flagsmith/api/types.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import typing
2+
3+
from flag_engine.segments.types import ConditionOperator, RuleType
4+
from typing_extensions import NotRequired
5+
6+
7+
class SegmentConditionModel(typing.TypedDict):
8+
operator: ConditionOperator
9+
property_: str
10+
value: str
11+
12+
13+
class SegmentRuleModel(typing.TypedDict):
14+
conditions: "list[SegmentConditionModel]"
15+
rules: "list[SegmentRuleModel]"
16+
type: RuleType
17+
18+
19+
class SegmentModel(typing.TypedDict):
20+
id: int
21+
name: str
22+
rules: list[SegmentRuleModel]
23+
feature_states: "NotRequired[list[FeatureStateModel]]"
24+
25+
26+
class ProjectModel(typing.TypedDict):
27+
segments: list[SegmentModel]
28+
29+
30+
class FeatureModel(typing.TypedDict):
31+
id: int
32+
name: str
33+
34+
35+
class FeatureSegmentModel(typing.TypedDict):
36+
priority: int
37+
38+
39+
class MultivariateFeatureOptionModel(typing.TypedDict):
40+
value: str
41+
42+
43+
class MultivariateFeatureStateValueModel(typing.TypedDict):
44+
id: typing.Optional[int]
45+
multivariate_feature_option: MultivariateFeatureOptionModel
46+
mv_fs_value_uuid: str
47+
percentage_allocation: float
48+
49+
50+
class FeatureStateModel(typing.TypedDict):
51+
enabled: bool
52+
feature_segment: NotRequired[FeatureSegmentModel]
53+
feature_state_value: object
54+
feature: FeatureModel
55+
featurestate_uuid: str
56+
multivariate_feature_state_values: list[MultivariateFeatureStateValueModel]
57+
58+
59+
class IdentityModel(typing.TypedDict):
60+
identifier: str
61+
identity_features: list[FeatureStateModel]
62+
63+
64+
class EnvironmentModel(typing.TypedDict):
65+
api_key: str
66+
feature_states: list[FeatureStateModel]
67+
identity_overrides: list[IdentityModel]
68+
name: str
69+
project: ProjectModel

flagsmith/flagsmith.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,9 @@ def __init__(
125125
)
126126

127127
if self.offline_handler:
128-
self._evaluation_context = self.offline_handler.get_evaluation_context()
128+
self._evaluation_context = map_environment_document_to_context(
129+
self.offline_handler.get_environment()
130+
)
129131

130132
if not self.offline_mode:
131133
if not environment_key:

flagsmith/mappers.py

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,17 @@
99
FeatureContext,
1010
SegmentContext,
1111
SegmentRule,
12+
StrValueSegmentCondition,
1213
)
1314
from flag_engine.result.types import SegmentResult
1415
from flag_engine.segments.types import ContextValue
1516

17+
from flagsmith.api.types import (
18+
EnvironmentModel,
19+
FeatureStateModel,
20+
IdentityModel,
21+
SegmentRuleModel,
22+
)
1623
from flagsmith.models import Segment
1724
from flagsmith.types import (
1825
SDKEvaluationContext,
@@ -99,7 +106,7 @@ def map_context_and_identity_data_to_context(
99106

100107

101108
def map_environment_document_to_context(
102-
environment_document: dict[str, typing.Any],
109+
environment_document: EnvironmentModel,
103110
) -> SDKEvaluationContext:
104111
return {
105112
"environment": {
@@ -140,16 +147,14 @@ def map_environment_document_to_context(
140147

141148

142149
def _map_identity_overrides_to_segments(
143-
identity_overrides: list[dict[str, typing.Any]],
150+
identity_overrides: list[IdentityModel],
144151
) -> dict[str, SegmentContext[SegmentMetadata]]:
145152
features_to_identifiers: typing.Dict[
146153
OverridesKey,
147154
typing.List[str],
148155
] = defaultdict(list)
149156
for identity_override in identity_overrides:
150-
identity_features: list[dict[str, typing.Any]] = identity_override[
151-
"identity_features"
152-
]
157+
identity_features = identity_override["identity_features"]
153158
if not identity_features:
154159
continue
155160
overrides_key = tuple(
@@ -202,14 +207,14 @@ def _map_identity_overrides_to_segments(
202207

203208

204209
def _map_environment_document_rules_to_context_rules(
205-
rules: list[dict[str, typing.Any]],
210+
rules: list[SegmentRuleModel],
206211
) -> list[SegmentRule]:
207212
return [
208213
dict(
209214
type=rule["type"],
210215
conditions=[
211-
dict(
212-
property=condition.get("property_"),
216+
StrValueSegmentCondition(
217+
property=condition.get("property_") or "",
213218
operator=condition["operator"],
214219
value=condition["value"],
215220
)
@@ -224,7 +229,7 @@ def _map_environment_document_rules_to_context_rules(
224229

225230

226231
def _map_environment_document_feature_states_to_feature_contexts(
227-
feature_states: list[dict[str, typing.Any]],
232+
feature_states: list[FeatureStateModel],
228233
) -> typing.Iterable[FeatureContext]:
229234
for feature_state in feature_states:
230235
feature_context = FeatureContext(
@@ -251,10 +256,8 @@ def _map_environment_document_feature_states_to_feature_contexts(
251256
key=itemgetter("id"),
252257
)
253258
]
254-
if (
255-
priority := (feature_state.get("feature_segment") or {}).get("priority")
256-
is not None
257-
):
258-
feature_context["priority"] = priority
259+
260+
if "feature_segment" in feature_state:
261+
feature_context["priority"] = feature_state["feature_segment"]["priority"]
259262

260263
yield feature_context

flagsmith/offline_handlers.py

Lines changed: 14 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,23 @@
11
import json
2+
from abc import ABC, abstractmethod
23
from pathlib import Path
34
from typing import Protocol
45

6+
from flagsmith.api.types import EnvironmentModel
57
from flagsmith.mappers import map_environment_document_to_context
6-
from flagsmith.types import SDKEvaluationContext
78

89

910
class OfflineHandler(Protocol):
10-
def get_evaluation_context(self) -> SDKEvaluationContext: ...
11+
def get_environment(self) -> EnvironmentModel: ...
1112

1213

13-
class EvaluationContextLocalFileHandler:
14-
"""
15-
Handler to load evaluation context from a local JSON file.
16-
The JSON file should contain the full evaluation context as per Flagsmith Engine's specification.
17-
18-
JSON schema:
19-
https://raw.githubusercontent.com/Flagsmith/flagsmith/main/sdk/evaluation-context.json
20-
"""
21-
22-
def __init__(self, file_path: str) -> None:
23-
self.evaluation_context: SDKEvaluationContext = json.loads(
24-
Path(file_path).read_text(),
25-
)
26-
27-
def get_evaluation_context(self) -> SDKEvaluationContext:
28-
return self.evaluation_context
14+
class BaseOfflineHandler(ABC):
15+
@abstractmethod
16+
def get_environment(self) -> EnvironmentModel:
17+
raise NotImplementedError()
2918

3019

31-
class EnvironmentDocumentLocalFileHandler:
20+
class LocalFileHandler:
3221
"""
3322
Handler to load evaluation context from a local JSON file containing the environment document.
3423
The JSON file should contain the environment document as returned by the Flagsmith API.
@@ -38,18 +27,10 @@ class EnvironmentDocumentLocalFileHandler:
3827
"""
3928

4029
def __init__(self, file_path: str) -> None:
41-
self.evaluation_context: SDKEvaluationContext = (
42-
map_environment_document_to_context(
43-
json.loads(
44-
Path(file_path).read_text(),
45-
),
46-
)
47-
)
48-
49-
def get_evaluation_context(self) -> SDKEvaluationContext:
50-
return self.evaluation_context
51-
30+
environment_document = json.loads(Path(file_path).read_text())
31+
# Make sure the document can be used for evaluation
32+
map_environment_document_to_context(environment_document)
33+
self.environment_document: EnvironmentModel = environment_document
5234

53-
# For backward compatibility, use the old class name for
54-
# the local file handler implementation dependant on the environment document.
55-
LocalFileHandler = EnvironmentDocumentLocalFileHandler
35+
def get_environment(self) -> EnvironmentModel:
36+
return self.environment_document

poetry.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@ documentation = "https://docs.flagsmith.com"
1010
packages = [{ include = "flagsmith" }]
1111

1212
[tool.poetry.dependencies]
13+
flagsmith-flag-engine = { git = "https://github.com/Flagsmith/flagsmith-engine.git", branch = "feat/generic-metadata" }
1314
python = ">=3.9,<4"
1415
requests = "^2.32.3"
1516
requests-futures = "^1.0.1"
16-
flagsmith-flag-engine = { git = "https://github.com/Flagsmith/flagsmith-engine.git", branch = "feat/generic-metadata" }
1717
sseclient-py = "^1.8.0"
18+
typing-extensions = "^4.15.0"
1819

1920
[tool.poetry.group.dev]
2021
optional = true

tests/conftest.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from flagsmith import Flagsmith
1414
from flagsmith.analytics import AnalyticsProcessor
15+
from flagsmith.api.types import EnvironmentModel
1516
from flagsmith.mappers import map_environment_document_to_context
1617
from flagsmith.types import SDKEvaluationContext
1718

@@ -74,8 +75,14 @@ def local_eval_flagsmith(
7475

7576

7677
@pytest.fixture()
77-
def evaluation_context(environment_json: str) -> SDKEvaluationContext:
78-
return map_environment_document_to_context(json.loads(environment_json))
78+
def environment(environment_json: str) -> EnvironmentModel:
79+
ret: EnvironmentModel = json.loads(environment_json)
80+
return ret
81+
82+
83+
@pytest.fixture()
84+
def evaluation_context(environment: EnvironmentModel) -> SDKEvaluationContext:
85+
return map_environment_document_to_context(environment)
7986

8087

8188
@pytest.fixture()

tests/test_flagsmith.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from responses import matchers
1010

1111
from flagsmith import Flagsmith, __version__
12+
from flagsmith.api.types import EnvironmentModel
1213
from flagsmith.exceptions import (
1314
FlagsmithAPIError,
1415
FlagsmithFeatureDoesNotExistError,
@@ -545,11 +546,11 @@ def test_initialise_flagsmith_with_proxies() -> None:
545546
assert flagsmith.session.proxies == proxies
546547

547548

548-
def test_offline_mode(evaluation_context: SDKEvaluationContext) -> None:
549+
def test_offline_mode(environment: EnvironmentModel) -> None:
549550
# Given
550551
class DummyOfflineHandler:
551-
def get_evaluation_context(self) -> SDKEvaluationContext:
552-
return evaluation_context
552+
def get_environment(self) -> EnvironmentModel:
553+
return environment
553554

554555
# When
555556
flagsmith = Flagsmith(offline_mode=True, offline_handler=DummyOfflineHandler())
@@ -566,12 +567,12 @@ def get_evaluation_context(self) -> SDKEvaluationContext:
566567
@responses.activate()
567568
def test_flagsmith_uses_offline_handler_if_set_and_no_api_response(
568569
mocker: MockerFixture,
569-
evaluation_context: SDKEvaluationContext,
570+
environment: EnvironmentModel,
570571
) -> None:
571572
# Given
572573
api_url = "http://some.flagsmith.com/api/v1/"
573574
mock_offline_handler = mocker.MagicMock(spec=OfflineHandler)
574-
mock_offline_handler.get_evaluation_context.return_value = evaluation_context
575+
mock_offline_handler.get_environment.return_value = environment
575576

576577
flagsmith = Flagsmith(
577578
environment_key="some-key",
@@ -587,7 +588,7 @@ def test_flagsmith_uses_offline_handler_if_set_and_no_api_response(
587588
identity_flags = flagsmith.get_identity_flags("identity", traits={})
588589

589590
# Then
590-
mock_offline_handler.get_evaluation_context.assert_called_once_with()
591+
mock_offline_handler.get_environment.assert_called_once_with()
591592

592593
assert environment_flags.is_feature_enabled("some_feature") is True
593594
assert environment_flags.get_feature_value("some_feature") == "some-value"
@@ -599,13 +600,13 @@ def test_flagsmith_uses_offline_handler_if_set_and_no_api_response(
599600
@responses.activate()
600601
def test_offline_mode__local_evaluation__correct_fallback(
601602
mocker: MockerFixture,
602-
evaluation_context: SDKEvaluationContext,
603+
environment: EnvironmentModel,
603604
caplog: pytest.LogCaptureFixture,
604605
) -> None:
605606
# Given
606607
api_url = "http://some.flagsmith.com/api/v1/"
607608
mock_offline_handler = mocker.MagicMock(spec=OfflineHandler)
608-
mock_offline_handler.get_evaluation_context.return_value = evaluation_context
609+
mock_offline_handler.get_environment.return_value = environment
609610

610611
mocker.patch("flagsmith.flagsmith.EnvironmentDataPollingManager")
611612

@@ -623,7 +624,7 @@ def test_offline_mode__local_evaluation__correct_fallback(
623624
identity_flags = flagsmith.get_identity_flags("identity", traits={})
624625

625626
# Then
626-
mock_offline_handler.get_evaluation_context.assert_called_once_with()
627+
mock_offline_handler.get_environment.assert_called_once_with()
627628

628629
assert environment_flags.is_feature_enabled("some_feature") is True
629630
assert environment_flags.get_feature_value("some_feature") == "some-value"

0 commit comments

Comments
 (0)