Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions flagsmith/api/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import typing

from flag_engine.segments.types import ConditionOperator, RuleType
from typing_extensions import NotRequired


class SegmentConditionModel(typing.TypedDict):
operator: ConditionOperator
property_: str
value: str


class SegmentRuleModel(typing.TypedDict):
conditions: "list[SegmentConditionModel]"
rules: "list[SegmentRuleModel]"
type: RuleType


class SegmentModel(typing.TypedDict):
id: int
name: str
rules: list[SegmentRuleModel]
feature_states: "NotRequired[list[FeatureStateModel]]"


class ProjectModel(typing.TypedDict):
segments: list[SegmentModel]


class FeatureModel(typing.TypedDict):
id: int
name: str


class FeatureSegmentModel(typing.TypedDict):
priority: int


class MultivariateFeatureOptionModel(typing.TypedDict):
value: str


class MultivariateFeatureStateValueModel(typing.TypedDict):
id: typing.Optional[int]
multivariate_feature_option: MultivariateFeatureOptionModel
mv_fs_value_uuid: str
percentage_allocation: float


class FeatureStateModel(typing.TypedDict):
enabled: bool
feature_segment: NotRequired[FeatureSegmentModel]
feature_state_value: object
feature: FeatureModel
featurestate_uuid: str
multivariate_feature_state_values: list[MultivariateFeatureStateValueModel]


class IdentityModel(typing.TypedDict):
identifier: str
identity_features: list[FeatureStateModel]


class EnvironmentModel(typing.TypedDict):
api_key: str
feature_states: list[FeatureStateModel]
identity_overrides: list[IdentityModel]
name: str
project: ProjectModel
4 changes: 3 additions & 1 deletion flagsmith/flagsmith.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,9 @@ def __init__(
)

if self.offline_handler:
self._evaluation_context = self.offline_handler.get_evaluation_context()
self._evaluation_context = map_environment_document_to_context(
self.offline_handler.get_environment()
)

if not self.offline_mode:
if not environment_key:
Expand Down
31 changes: 17 additions & 14 deletions flagsmith/mappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,17 @@
FeatureContext,
SegmentContext,
SegmentRule,
StrValueSegmentCondition,
)
from flag_engine.result.types import SegmentResult
from flag_engine.segments.types import ContextValue

from flagsmith.api.types import (
EnvironmentModel,
FeatureStateModel,
IdentityModel,
SegmentRuleModel,
)
from flagsmith.models import Segment
from flagsmith.types import (
SDKEvaluationContext,
Expand Down Expand Up @@ -99,7 +106,7 @@ def map_context_and_identity_data_to_context(


def map_environment_document_to_context(
environment_document: dict[str, typing.Any],
environment_document: EnvironmentModel,
) -> SDKEvaluationContext:
return {
"environment": {
Expand Down Expand Up @@ -140,16 +147,14 @@ def map_environment_document_to_context(


def _map_identity_overrides_to_segments(
identity_overrides: list[dict[str, typing.Any]],
identity_overrides: list[IdentityModel],
) -> dict[str, SegmentContext[SegmentMetadata]]:
features_to_identifiers: typing.Dict[
OverridesKey,
typing.List[str],
] = defaultdict(list)
for identity_override in identity_overrides:
identity_features: list[dict[str, typing.Any]] = identity_override[
"identity_features"
]
identity_features = identity_override["identity_features"]
if not identity_features:
continue
overrides_key = tuple(
Expand Down Expand Up @@ -202,14 +207,14 @@ def _map_identity_overrides_to_segments(


def _map_environment_document_rules_to_context_rules(
rules: list[dict[str, typing.Any]],
rules: list[SegmentRuleModel],
) -> list[SegmentRule]:
return [
dict(
type=rule["type"],
conditions=[
dict(
property=condition.get("property_"),
StrValueSegmentCondition(
property=condition.get("property_") or "",
operator=condition["operator"],
value=condition["value"],
)
Expand All @@ -224,7 +229,7 @@ def _map_environment_document_rules_to_context_rules(


def _map_environment_document_feature_states_to_feature_contexts(
feature_states: list[dict[str, typing.Any]],
feature_states: list[FeatureStateModel],
) -> typing.Iterable[FeatureContext]:
for feature_state in feature_states:
feature_context = FeatureContext(
Expand All @@ -251,10 +256,8 @@ def _map_environment_document_feature_states_to_feature_contexts(
key=itemgetter("id"),
)
]
if (
priority := (feature_state.get("feature_segment") or {}).get("priority")
is not None
):
feature_context["priority"] = priority

if "feature_segment" in feature_state:
feature_context["priority"] = feature_state["feature_segment"]["priority"]

yield feature_context
47 changes: 14 additions & 33 deletions flagsmith/offline_handlers.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,23 @@
import json
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Protocol

from flagsmith.api.types import EnvironmentModel
from flagsmith.mappers import map_environment_document_to_context
from flagsmith.types import SDKEvaluationContext


class OfflineHandler(Protocol):
def get_evaluation_context(self) -> SDKEvaluationContext: ...
def get_environment(self) -> EnvironmentModel: ...


class EvaluationContextLocalFileHandler:
"""
Handler to load evaluation context from a local JSON file.
The JSON file should contain the full evaluation context as per Flagsmith Engine's specification.

JSON schema:
https://raw.githubusercontent.com/Flagsmith/flagsmith/main/sdk/evaluation-context.json
"""

def __init__(self, file_path: str) -> None:
self.evaluation_context: SDKEvaluationContext = json.loads(
Path(file_path).read_text(),
)

def get_evaluation_context(self) -> SDKEvaluationContext:
return self.evaluation_context
class BaseOfflineHandler(ABC):
@abstractmethod
def get_environment(self) -> EnvironmentModel:
raise NotImplementedError()


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

def __init__(self, file_path: str) -> None:
self.evaluation_context: SDKEvaluationContext = (
map_environment_document_to_context(
json.loads(
Path(file_path).read_text(),
),
)
)

def get_evaluation_context(self) -> SDKEvaluationContext:
return self.evaluation_context

environment_document = json.loads(Path(file_path).read_text())
# Make sure the document can be used for evaluation
map_environment_document_to_context(environment_document)
self.environment_document: EnvironmentModel = environment_document

# For backward compatibility, use the old class name for
# the local file handler implementation dependant on the environment document.
LocalFileHandler = EnvironmentDocumentLocalFileHandler
def get_environment(self) -> EnvironmentModel:
return self.environment_document
8 changes: 4 additions & 4 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ documentation = "https://docs.flagsmith.com"
packages = [{ include = "flagsmith" }]

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

[tool.poetry.group.dev]
optional = true
Expand Down
11 changes: 9 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from flagsmith import Flagsmith
from flagsmith.analytics import AnalyticsProcessor
from flagsmith.api.types import EnvironmentModel
from flagsmith.mappers import map_environment_document_to_context
from flagsmith.types import SDKEvaluationContext

Expand Down Expand Up @@ -74,8 +75,14 @@ def local_eval_flagsmith(


@pytest.fixture()
def evaluation_context(environment_json: str) -> SDKEvaluationContext:
return map_environment_document_to_context(json.loads(environment_json))
def environment(environment_json: str) -> EnvironmentModel:
ret: EnvironmentModel = json.loads(environment_json)
return ret


@pytest.fixture()
def evaluation_context(environment: EnvironmentModel) -> SDKEvaluationContext:
return map_environment_document_to_context(environment)


@pytest.fixture()
Expand Down
19 changes: 10 additions & 9 deletions tests/test_flagsmith.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from responses import matchers

from flagsmith import Flagsmith, __version__
from flagsmith.api.types import EnvironmentModel
from flagsmith.exceptions import (
FlagsmithAPIError,
FlagsmithFeatureDoesNotExistError,
Expand Down Expand Up @@ -545,11 +546,11 @@ def test_initialise_flagsmith_with_proxies() -> None:
assert flagsmith.session.proxies == proxies


def test_offline_mode(evaluation_context: SDKEvaluationContext) -> None:
def test_offline_mode(environment: EnvironmentModel) -> None:
# Given
class DummyOfflineHandler:
def get_evaluation_context(self) -> SDKEvaluationContext:
return evaluation_context
def get_environment(self) -> EnvironmentModel:
return environment

# When
flagsmith = Flagsmith(offline_mode=True, offline_handler=DummyOfflineHandler())
Expand All @@ -566,12 +567,12 @@ def get_evaluation_context(self) -> SDKEvaluationContext:
@responses.activate()
def test_flagsmith_uses_offline_handler_if_set_and_no_api_response(
mocker: MockerFixture,
evaluation_context: SDKEvaluationContext,
environment: EnvironmentModel,
) -> None:
# Given
api_url = "http://some.flagsmith.com/api/v1/"
mock_offline_handler = mocker.MagicMock(spec=OfflineHandler)
mock_offline_handler.get_evaluation_context.return_value = evaluation_context
mock_offline_handler.get_environment.return_value = environment

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

# Then
mock_offline_handler.get_evaluation_context.assert_called_once_with()
mock_offline_handler.get_environment.assert_called_once_with()

assert environment_flags.is_feature_enabled("some_feature") is True
assert environment_flags.get_feature_value("some_feature") == "some-value"
Expand All @@ -599,13 +600,13 @@ def test_flagsmith_uses_offline_handler_if_set_and_no_api_response(
@responses.activate()
def test_offline_mode__local_evaluation__correct_fallback(
mocker: MockerFixture,
evaluation_context: SDKEvaluationContext,
environment: EnvironmentModel,
caplog: pytest.LogCaptureFixture,
) -> None:
# Given
api_url = "http://some.flagsmith.com/api/v1/"
mock_offline_handler = mocker.MagicMock(spec=OfflineHandler)
mock_offline_handler.get_evaluation_context.return_value = evaluation_context
mock_offline_handler.get_environment.return_value = environment

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

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

# Then
mock_offline_handler.get_evaluation_context.assert_called_once_with()
mock_offline_handler.get_environment.assert_called_once_with()

assert environment_flags.is_feature_enabled("some_feature") is True
assert environment_flags.get_feature_value("some_feature") == "some-value"
Expand Down
Loading