From 565a75edc749d0fcc16b1d75cc54995323a19f06 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 16 Oct 2025 02:12:02 +0100 Subject: [PATCH 1/3] feat!: Restore prior`OfflineHandler` interface --- flagsmith/api/types.py | 69 ++++++++++++++++++++++++++++++++++ flagsmith/flagsmith.py | 4 +- flagsmith/mappers.py | 31 ++++++++------- flagsmith/offline_handlers.py | 44 +++++----------------- poetry.lock | 8 ++-- pyproject.toml | 3 +- tests/conftest.py | 10 ++++- tests/test_flagsmith.py | 19 +++++----- tests/test_offline_handlers.py | 21 +++++++++-- 9 files changed, 139 insertions(+), 70 deletions(-) create mode 100644 flagsmith/api/types.py diff --git a/flagsmith/api/types.py b/flagsmith/api/types.py new file mode 100644 index 0000000..fda43fe --- /dev/null +++ b/flagsmith/api/types.py @@ -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 diff --git a/flagsmith/flagsmith.py b/flagsmith/flagsmith.py index a27c2c4..8eaf847 100644 --- a/flagsmith/flagsmith.py +++ b/flagsmith/flagsmith.py @@ -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: diff --git a/flagsmith/mappers.py b/flagsmith/mappers.py index 95aa5ed..1bdd8ae 100644 --- a/flagsmith/mappers.py +++ b/flagsmith/mappers.py @@ -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, @@ -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": { @@ -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( @@ -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"], ) @@ -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( @@ -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 diff --git a/flagsmith/offline_handlers.py b/flagsmith/offline_handlers.py index 6511234..725a4df 100644 --- a/flagsmith/offline_handlers.py +++ b/flagsmith/offline_handlers.py @@ -2,33 +2,15 @@ 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 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. @@ -38,18 +20,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 diff --git a/poetry.lock b/poetry.lock index ef293e0..5ee3591 100644 --- a/poetry.lock +++ b/poetry.lock @@ -898,14 +898,14 @@ urllib3 = ">=2" [[package]] name = "typing-extensions" -version = "4.14.1" +version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"}, - {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] [[package]] @@ -951,4 +951,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.1" python-versions = ">=3.9,<4" -content-hash = "07576ed467449cc3df826bcec81a02fbd13ab1eeb17a0b6591c451945d3c3239" +content-hash = "702545ad27e44d6d5bdc0a4cef9517a70a2548858cc8ea5ca2410d78ef296b9e" diff --git a/pyproject.toml b/pyproject.toml index c589f71..f3c228a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index ef7ab22..7abecbb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 @@ -74,8 +75,13 @@ 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: + return json.loads(environment_json) + + +@pytest.fixture() +def evaluation_context(environment: EnvironmentModel) -> SDKEvaluationContext: + return map_environment_document_to_context(environment) @pytest.fixture() diff --git a/tests/test_flagsmith.py b/tests/test_flagsmith.py index 27f3e13..00d1ae8 100644 --- a/tests/test_flagsmith.py +++ b/tests/test_flagsmith.py @@ -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, @@ -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()) @@ -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", @@ -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" @@ -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") @@ -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" diff --git a/tests/test_offline_handlers.py b/tests/test_offline_handlers.py index 9da5dc9..81e9cda 100644 --- a/tests/test_offline_handlers.py +++ b/tests/test_offline_handlers.py @@ -1,12 +1,13 @@ +import json + +import pytest from pyfakefs.fake_filesystem import FakeFilesystem from flagsmith.offline_handlers import LocalFileHandler -from flagsmith.types import SDKEvaluationContext def test_local_file_handler( fs: FakeFilesystem, - evaluation_context: SDKEvaluationContext, environment_json: str, ) -> None: # Given @@ -15,7 +16,19 @@ def test_local_file_handler( local_file_handler = LocalFileHandler(environment_document_file_path) # When - result = local_file_handler.get_evaluation_context() + result = local_file_handler.get_environment() # Then - assert result == evaluation_context + assert result == json.loads(environment_json) + + +def test_local_file_handler__invalid_contents__raises_expected( + fs: FakeFilesystem, +) -> None: + # Given + environment_document_file_path = "/some/path/environment.json" + fs.create_file(environment_document_file_path, contents="{}") + + # When & Then + with pytest.raises(KeyError): + LocalFileHandler(environment_document_file_path) From cd9208f5a634f38abb80d95f3af56329dc2edc7a Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 16 Oct 2025 02:14:26 +0100 Subject: [PATCH 2/3] bring back ABC --- flagsmith/offline_handlers.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/flagsmith/offline_handlers.py b/flagsmith/offline_handlers.py index 725a4df..3920120 100644 --- a/flagsmith/offline_handlers.py +++ b/flagsmith/offline_handlers.py @@ -1,4 +1,5 @@ import json +from abc import ABC, abstractmethod from pathlib import Path from typing import Protocol @@ -10,6 +11,12 @@ class OfflineHandler(Protocol): def get_environment(self) -> EnvironmentModel: ... +class BaseOfflineHandler(ABC): + @abstractmethod + def get_environment(self) -> EnvironmentModel: + raise NotImplementedError() + + class LocalFileHandler: """ Handler to load evaluation context from a local JSON file containing the environment document. From 2c75805343b23dc3e3c81be1a67e9888a9498101 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 16 Oct 2025 02:25:49 +0100 Subject: [PATCH 3/3] cast --- tests/conftest.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 7abecbb..ce1153c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -76,7 +76,8 @@ def local_eval_flagsmith( @pytest.fixture() def environment(environment_json: str) -> EnvironmentModel: - return json.loads(environment_json) + ret: EnvironmentModel = json.loads(environment_json) + return ret @pytest.fixture()