diff --git a/flagsmith/flagsmith.py b/flagsmith/flagsmith.py index cdc7b10..a27c2c4 100644 --- a/flagsmith/flagsmith.py +++ b/flagsmith/flagsmith.py @@ -14,6 +14,7 @@ map_context_and_identity_data_to_context, map_environment_document_to_context, map_environment_document_to_environment_updated_at, + map_segment_results_to_identity_segments, ) from flagsmith.models import DefaultFlag, Flags, Segment from flagsmith.offline_handlers import OfflineHandler @@ -22,6 +23,7 @@ from flagsmith.types import ( ApplicationMetadata, JsonType, + SDKEvaluationContext, StreamEvent, TraitMapping, ) @@ -106,7 +108,7 @@ def __init__( self.default_flag_handler = default_flag_handler self.enable_realtime_updates = enable_realtime_updates self._analytics_processor: typing.Optional[AnalyticsProcessor] = None - self._evaluation_context: typing.Optional[engine.EvaluationContext] = None + self._evaluation_context: typing.Optional[SDKEvaluationContext] = None self._environment_updated_at: typing.Optional[datetime] = None # argument validation @@ -283,10 +285,8 @@ def get_identity_segments( evaluation_result = engine.get_evaluation_result( context=context, ) - return [ - Segment(id=int(segment_result["key"]), name=segment_result["name"]) - for segment_result in evaluation_result["segments"] - ] + + return map_segment_results_to_identity_segments(evaluation_result["segments"]) def update_environment(self) -> None: try: diff --git a/flagsmith/mappers.py b/flagsmith/mappers.py index 3b44f96..95aa5ed 100644 --- a/flagsmith/mappers.py +++ b/flagsmith/mappers.py @@ -6,14 +6,20 @@ import sseclient from flag_engine.context.types import ( - EvaluationContext, FeatureContext, SegmentContext, SegmentRule, ) +from flag_engine.result.types import SegmentResult from flag_engine.segments.types import ContextValue -from flagsmith.types import StreamEvent, TraitConfig +from flagsmith.models import Segment +from flagsmith.types import ( + SDKEvaluationContext, + SegmentMetadata, + StreamEvent, + TraitConfig, +) OverrideKey = typing.Tuple[ str, @@ -24,6 +30,24 @@ OverridesKey = typing.Tuple[OverrideKey, ...] +def map_segment_results_to_identity_segments( + segment_results: list[SegmentResult[SegmentMetadata]], +) -> list[Segment]: + identity_segments: list[Segment] = [] + for segment_result in segment_results: + if metadata := segment_result.get("metadata"): + if metadata.get("source") == "api" and ( + (flagsmith_id := metadata.get("flagsmith_id")) is not None + ): + identity_segments.append( + Segment( + id=flagsmith_id, + name=segment_result["name"], + ) + ) + return identity_segments + + def map_sse_event_to_stream_event(event: sseclient.Event) -> StreamEvent: event_data = json.loads(event.data) return { @@ -45,7 +69,7 @@ def map_environment_document_to_environment_updated_at( def map_context_and_identity_data_to_context( - context: EvaluationContext, + context: SDKEvaluationContext, identifier: str, traits: typing.Optional[ typing.Mapping[ @@ -56,7 +80,7 @@ def map_context_and_identity_data_to_context( ], ] ], -) -> EvaluationContext: +) -> SDKEvaluationContext: return { **context, "identity": { @@ -76,7 +100,7 @@ def map_context_and_identity_data_to_context( def map_environment_document_to_context( environment_document: dict[str, typing.Any], -) -> EvaluationContext: +) -> SDKEvaluationContext: return { "environment": { "key": environment_document["api_key"], @@ -90,7 +114,7 @@ def map_environment_document_to_context( }, "segments": { **{ - (segment_key := str(segment["id"])): { + (segment_key := str(segment_id := segment["id"])): { "key": segment_key, "name": segment["name"], "rules": _map_environment_document_rules_to_context_rules( @@ -101,6 +125,10 @@ def map_environment_document_to_context( segment.get("feature_states") or [] ) ), + "metadata": SegmentMetadata( + flagsmith_id=segment_id, + source="api", + ), } for segment in environment_document["project"]["segments"] }, @@ -113,7 +141,7 @@ def map_environment_document_to_context( def _map_identity_overrides_to_segments( identity_overrides: list[dict[str, typing.Any]], -) -> dict[str, SegmentContext]: +) -> dict[str, SegmentContext[SegmentMetadata]]: features_to_identifiers: typing.Dict[ OverridesKey, typing.List[str], @@ -137,7 +165,7 @@ def _map_identity_overrides_to_segments( ) ) features_to_identifiers[overrides_key].append(identity_override["identifier"]) - segment_contexts: typing.Dict[str, SegmentContext] = {} + segment_contexts: typing.Dict[str, SegmentContext[SegmentMetadata]] = {} for overrides_key, identifiers in features_to_identifiers.items(): # Create a segment context for each unique set of overrides # Generate a unique key to avoid collisions @@ -168,6 +196,7 @@ def _map_identity_overrides_to_segments( } for feature_key, feature_name, feature_enabled, feature_value in overrides_key ], + metadata=SegmentMetadata(source="identity_overrides"), ) return segment_contexts diff --git a/flagsmith/models.py b/flagsmith/models.py index 3a9ffed..0728e24 100644 --- a/flagsmith/models.py +++ b/flagsmith/models.py @@ -3,10 +3,11 @@ import typing from dataclasses import dataclass, field -from flag_engine.result.types import EvaluationResult, FlagResult +from flag_engine.result.types import FlagResult from flagsmith.analytics import AnalyticsProcessor from flagsmith.exceptions import FlagsmithFeatureDoesNotExistError +from flagsmith.types import SDKEvaluationResult @dataclass @@ -57,19 +58,19 @@ class Flags: @classmethod def from_evaluation_result( cls, - evaluation_result: EvaluationResult, + evaluation_result: SDKEvaluationResult, analytics_processor: typing.Optional[AnalyticsProcessor], default_flag_handler: typing.Optional[typing.Callable[[str], DefaultFlag]], ) -> Flags: return cls( flags={ - flag["name"]: Flag( + flag_name: Flag( enabled=flag["enabled"], value=flag["value"], feature_name=flag["name"], feature_id=int(flag["feature_key"]), ) - for flag in evaluation_result["flags"] + for flag_name, flag in evaluation_result["flags"].items() }, default_flag_handler=default_flag_handler, _analytics_processor=analytics_processor, diff --git a/flagsmith/offline_handlers.py b/flagsmith/offline_handlers.py index 3248339..6511234 100644 --- a/flagsmith/offline_handlers.py +++ b/flagsmith/offline_handlers.py @@ -2,13 +2,12 @@ from pathlib import Path from typing import Protocol -from flag_engine.context.types import EvaluationContext - from flagsmith.mappers import map_environment_document_to_context +from flagsmith.types import SDKEvaluationContext class OfflineHandler(Protocol): - def get_evaluation_context(self) -> EvaluationContext: ... + def get_evaluation_context(self) -> SDKEvaluationContext: ... class EvaluationContextLocalFileHandler: @@ -21,11 +20,11 @@ class EvaluationContextLocalFileHandler: """ def __init__(self, file_path: str) -> None: - self.evaluation_context: EvaluationContext = json.loads( + self.evaluation_context: SDKEvaluationContext = json.loads( Path(file_path).read_text(), ) - def get_evaluation_context(self) -> EvaluationContext: + def get_evaluation_context(self) -> SDKEvaluationContext: return self.evaluation_context @@ -39,7 +38,7 @@ class EnvironmentDocumentLocalFileHandler: """ def __init__(self, file_path: str) -> None: - self.evaluation_context: EvaluationContext = ( + self.evaluation_context: SDKEvaluationContext = ( map_environment_document_to_context( json.loads( Path(file_path).read_text(), @@ -47,7 +46,7 @@ def __init__(self, file_path: str) -> None: ) ) - def get_evaluation_context(self) -> EvaluationContext: + def get_evaluation_context(self) -> SDKEvaluationContext: return self.evaluation_context diff --git a/flagsmith/types.py b/flagsmith/types.py index d5c4b0f..ed624f3 100644 --- a/flagsmith/types.py +++ b/flagsmith/types.py @@ -1,7 +1,9 @@ import typing from datetime import datetime +from flag_engine.context.types import EvaluationContext from flag_engine.engine import ContextValue +from flag_engine.result.types import EvaluationResult from typing_extensions import NotRequired, TypeAlias _JsonScalarType: TypeAlias = typing.Union[ @@ -33,3 +35,14 @@ class TraitConfig(typing.TypedDict): class ApplicationMetadata(typing.TypedDict): name: NotRequired[str] version: NotRequired[str] + + +class SegmentMetadata(typing.TypedDict): + flagsmith_id: NotRequired[int] + """The ID of the segment used in Flagsmith API.""" + source: NotRequired[typing.Literal["api", "identity_overrides"]] + """The source of the segment, e.g. 'api', 'identity_overrides'.""" + + +SDKEvaluationContext = EvaluationContext[SegmentMetadata] +SDKEvaluationResult = EvaluationResult[SegmentMetadata] diff --git a/poetry.lock b/poetry.lock index a90c934..ef293e0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -259,21 +259,25 @@ files = [ [[package]] name = "flagsmith-flag-engine" -version = "7.0.0" +version = "8.0.0" description = "Flag engine for the Flagsmith API." optional = false python-versions = "*" groups = ["main"] -files = [ - {file = "flagsmith_flag_engine-7.0.0-py3-none-any.whl", hash = "sha256:59c1f13eb920de7182e1992bf4504b7cbdf991fe2391ce42dd8cdda55554ab3a"}, - {file = "flagsmith_flag_engine-7.0.0.tar.gz", hash = "sha256:7db6e6515786a949ceb9036c1b0edcd5cb441aff3d083b4a9648bfd035eab9b5"}, -] +files = [] +develop = false [package.dependencies] jsonpath-rfc9535 = ">=0.1.5,<1" semver = ">=3.0.4,<4" typing-extensions = ">=4.14.1,<5" +[package.source] +type = "git" +url = "https://github.com/Flagsmith/flagsmith-engine.git" +reference = "feat/generic-metadata" +resolved_reference = "7e9d00aec998cb115d4b8175660c79563042a502" + [[package]] name = "identify" version = "2.6.13" @@ -947,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 = "3738927be08947fe27e40be874c3c1429dc98ce072f84468c205e208670bf724" +content-hash = "07576ed467449cc3df826bcec81a02fbd13ab1eeb17a0b6591c451945d3c3239" diff --git a/pyproject.toml b/pyproject.toml index decf306..c589f71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ packages = [{ include = "flagsmith" }] python = ">=3.9,<4" requests = "^2.32.3" requests-futures = "^1.0.1" -flagsmith-flag-engine = "^7.0.0" +flagsmith-flag-engine = { git = "https://github.com/Flagsmith/flagsmith-engine.git", branch = "feat/generic-metadata" } sseclient-py = "^1.8.0" [tool.poetry.group.dev] diff --git a/tests/conftest.py b/tests/conftest.py index 38b98d9..ef7ab22 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,13 +7,13 @@ import pytest import responses -from flag_engine.engine import EvaluationContext from pyfakefs.fake_filesystem import FakeFilesystem from pytest_mock import MockerFixture from flagsmith import Flagsmith from flagsmith.analytics import AnalyticsProcessor from flagsmith.mappers import map_environment_document_to_context +from flagsmith.types import SDKEvaluationContext DATA_DIR = os.path.join(os.path.dirname(__file__), "data") @@ -74,7 +74,7 @@ def local_eval_flagsmith( @pytest.fixture() -def evaluation_context(environment_json: str) -> EvaluationContext: +def evaluation_context(environment_json: str) -> SDKEvaluationContext: return map_environment_document_to_context(json.loads(environment_json)) diff --git a/tests/test_flagsmith.py b/tests/test_flagsmith.py index cae9701..27f3e13 100644 --- a/tests/test_flagsmith.py +++ b/tests/test_flagsmith.py @@ -5,7 +5,6 @@ import pytest import requests import responses -from flag_engine.engine import EvaluationContext from pytest_mock import MockerFixture from responses import matchers @@ -16,6 +15,7 @@ ) from flagsmith.models import DefaultFlag, Flags from flagsmith.offline_handlers import OfflineHandler +from flagsmith.types import SDKEvaluationContext def test_flagsmith_starts_polling_manager_on_init_if_enabled( @@ -39,7 +39,7 @@ def test_flagsmith_starts_polling_manager_on_init_if_enabled( def test_update_environment_sets_environment( flagsmith: Flagsmith, environment_json: str, - evaluation_context: EvaluationContext, + evaluation_context: SDKEvaluationContext, ) -> None: # Given responses.add(method="GET", url=flagsmith.environment_url, body=environment_json) @@ -76,7 +76,7 @@ def test_get_environment_flags_calls_api_when_no_local_environment( @responses.activate() def test_get_environment_flags_uses_local_environment_when_available( flagsmith: Flagsmith, - evaluation_context: EvaluationContext, + evaluation_context: SDKEvaluationContext, ) -> None: # Given flagsmith._evaluation_context = evaluation_context @@ -150,7 +150,7 @@ def test_get_identity_flags_calls_api_when_no_local_environment_with_traits( @responses.activate() def test_get_identity_flags_uses_local_environment_when_available( flagsmith: Flagsmith, - evaluation_context: EvaluationContext, + evaluation_context: SDKEvaluationContext, mocker: MockerFixture, ) -> None: # Given @@ -159,14 +159,14 @@ def test_get_identity_flags_uses_local_environment_when_available( mock_engine = mocker.patch("flagsmith.flagsmith.engine") expected_evaluation_result = { - "flags": [ - { + "flags": { + "some_feature": { "name": "some_feature", "enabled": True, "value": "some-feature-state-value", "feature_key": "1", } - ], + }, "segments": [], } @@ -509,6 +509,26 @@ def test_get_identity_segments_with_valid_trait( assert segments[0].name == "Test segment" # obtained from data/environment.json +def test_get_identity_segments__identity_overrides__returns_expected( + local_eval_flagsmith: Flagsmith, +) -> None: + # Given + # the identifier matches the identity override in data/environment.json + identifier = "overridden-id" + # traits match the "Test segment" segment in data/environment.json + traits = {"foo": "bar"} + + # When + segments = local_eval_flagsmith.get_identity_segments(identifier, traits) + + # Then + # identity override virtual segment is not returned, + # only the segment matching the traits + assert len(segments) == 1 + assert segments[0].id == 1 + assert segments[0].name == "Test segment" + + def test_local_evaluation_requires_server_key() -> None: with pytest.raises(ValueError): Flagsmith(environment_key="not-a-server-key", enable_local_evaluation=True) @@ -525,10 +545,10 @@ def test_initialise_flagsmith_with_proxies() -> None: assert flagsmith.session.proxies == proxies -def test_offline_mode(evaluation_context: EvaluationContext) -> None: +def test_offline_mode(evaluation_context: SDKEvaluationContext) -> None: # Given class DummyOfflineHandler: - def get_evaluation_context(self) -> EvaluationContext: + def get_evaluation_context(self) -> SDKEvaluationContext: return evaluation_context # When @@ -546,7 +566,7 @@ def get_evaluation_context(self) -> EvaluationContext: @responses.activate() def test_flagsmith_uses_offline_handler_if_set_and_no_api_response( mocker: MockerFixture, - evaluation_context: EvaluationContext, + evaluation_context: SDKEvaluationContext, ) -> None: # Given api_url = "http://some.flagsmith.com/api/v1/" @@ -579,7 +599,7 @@ 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: EvaluationContext, + evaluation_context: SDKEvaluationContext, caplog: pytest.LogCaptureFixture, ) -> None: # Given diff --git a/tests/test_models.py b/tests/test_models.py index b249268..7a3c48e 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,18 +1,20 @@ import typing import pytest -from flag_engine.result.types import EvaluationResult, FlagResult +from flag_engine.result.types import FlagResult from flagsmith.models import Flag, Flags +from flagsmith.types import SDKEvaluationResult def test_flag_from_evaluation_result() -> None: # Given flag_result: FlagResult = { - "name": "test_feature", "enabled": True, - "value": "test-value", "feature_key": "123", + "name": "test_feature", + "reason": "DEFAULT", + "value": "test-value", } # When @@ -29,55 +31,71 @@ def test_flag_from_evaluation_result() -> None: @pytest.mark.parametrize( "flags_result,expected_count,expected_names", [ - ([], 0, []), + ({}, 0, []), ( - [ - { - "name": "feature1", + { + "feature1": { "enabled": True, - "value": "value1", "feature_key": "1", + "name": "feature1", + "reason": "DEFAULT", + "value": "value1", } - ], + }, 1, ["feature1"], ), ( - [ - { - "name": "feature1", + { + "feature1": { "enabled": True, + "feature_key": "1", + "name": "feature1", + "reason": "DEFAULT", "value": "value1", + } + }, + 1, + ["feature1"], + ), + ( + { + "feature1": { + "enabled": True, "feature_key": "1", + "name": "feature1", + "reason": "DEFAULT", + "value": "value1", }, - { - "name": "feature2", - "enabled": False, - "value": None, + "feature2": { + "enabled": True, "feature_key": "2", + "name": "feature2", + "reason": "DEFAULT", + "value": "value2", + }, + "feature3": { + "enabled": True, + "feature_key": "3", + "name": "feature3", + "reason": "DEFAULT", + "value": 42, }, - {"name": "feature3", "enabled": True, "value": 42, "feature_key": "3"}, - ], + }, 3, ["feature1", "feature2", "feature3"], ), ], ) def test_flags_from_evaluation_result( - flags_result: typing.List[FlagResult], + flags_result: typing.Dict[str, FlagResult], expected_count: int, expected_names: typing.List[str], ) -> None: # Given - evaluation_result: EvaluationResult = { + evaluation_result: SDKEvaluationResult = { "flags": flags_result, "segments": [], - "context": { - "environment": { - "name": "test_environment", - "key": "test_environment_key", - } - }, } # When @@ -113,10 +131,11 @@ def test_flag_from_evaluation_result_value_types( ) -> None: # Given flag_result: FlagResult = { - "name": "test_feature", "enabled": True, - "value": value, "feature_key": "123", + "name": "test_feature", + "reason": "DEFAULT", + "value": value, } # When diff --git a/tests/test_offline_handlers.py b/tests/test_offline_handlers.py index 4d77ff3..9da5dc9 100644 --- a/tests/test_offline_handlers.py +++ b/tests/test_offline_handlers.py @@ -1,12 +1,12 @@ -from flag_engine.engine import EvaluationContext 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: EvaluationContext, + evaluation_context: SDKEvaluationContext, environment_json: str, ) -> None: # Given