From 313f500b6f88db199c1a2175aed98f6c9a7f8a6a Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 16 Oct 2025 16:22:56 +0100 Subject: [PATCH 1/8] feat: Support feature metadata --- flagsmith/mappers.py | 26 ++++++++++++++++++-------- flagsmith/models.py | 34 ++++++++++++++++------------------ flagsmith/types.py | 12 +++++++++--- poetry.lock | 16 ++++++++++------ pyproject.toml | 2 +- tests/test_flagsmith.py | 1 + tests/test_models.py | 33 ++++++++++++++++++++++----------- 7 files changed, 77 insertions(+), 47 deletions(-) diff --git a/flagsmith/mappers.py b/flagsmith/mappers.py index ec771c3..0ea712f 100644 --- a/flagsmith/mappers.py +++ b/flagsmith/mappers.py @@ -22,6 +22,7 @@ ) from flagsmith.models import Segment from flagsmith.types import ( + FeatureMetadata, SDKEvaluationContext, SegmentMetadata, StreamEvent, @@ -29,7 +30,7 @@ ) OverrideKey = typing.Tuple[ - str, + int, str, bool, typing.Any, @@ -148,7 +149,7 @@ def map_environment_document_to_context( def _map_identity_overrides_to_segments( identity_overrides: list[IdentityModel], -) -> dict[str, SegmentContext[SegmentMetadata]]: +) -> dict[str, SegmentContext[SegmentMetadata, FeatureMetadata]]: features_to_identifiers: typing.Dict[ OverridesKey, typing.List[str], @@ -159,7 +160,7 @@ def _map_identity_overrides_to_segments( continue overrides_key = tuple( ( - str(feature_state["feature"]["id"]), + feature_state["feature"]["id"], feature_state["feature"]["name"], feature_state["enabled"], feature_state["feature_state_value"], @@ -170,7 +171,13 @@ def _map_identity_overrides_to_segments( ) ) features_to_identifiers[overrides_key].append(identity_override["identifier"]) - segment_contexts: typing.Dict[str, SegmentContext[SegmentMetadata]] = {} + segment_contexts: typing.Dict[ + str, + SegmentContext[ + SegmentMetadata, + FeatureMetadata, + ], + ] = {} 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 @@ -193,13 +200,14 @@ def _map_identity_overrides_to_segments( overrides=[ { "key": "", # Identity overrides never carry multivariate options - "feature_key": feature_key, + "feature_key": str(flagsmith_id), "name": feature_name, "enabled": feature_enabled, "value": feature_value, "priority": float("-inf"), # Highest possible priority + "metadata": {"flagsmith_id": int(flagsmith_id)}, } - for feature_key, feature_name, feature_enabled, feature_value in overrides_key + for flagsmith_id, feature_name, feature_enabled, feature_value in overrides_key ], metadata=SegmentMetadata(source="identity_overrides"), ) @@ -230,9 +238,10 @@ def _map_environment_document_rules_to_context_rules( def _map_environment_document_feature_states_to_feature_contexts( feature_states: list[FeatureStateModel], -) -> typing.Iterable[FeatureContext]: +) -> typing.Iterable[FeatureContext[FeatureMetadata]]: for feature_state in feature_states: - feature_context = FeatureContext( + metadata: FeatureMetadata = {"flagsmith_id": feature_state["feature"]["id"]} + feature_context = FeatureContext[FeatureMetadata]( key=str( feature_state.get("django_id") or feature_state["featurestate_uuid"] ), @@ -240,6 +249,7 @@ def _map_environment_document_feature_states_to_feature_contexts( name=feature_state["feature"]["name"], enabled=feature_state["enabled"], value=feature_state["feature_state_value"], + metadata=metadata, ) if multivariate_feature_state_values := feature_state.get( "multivariate_feature_state_values" diff --git a/flagsmith/models.py b/flagsmith/models.py index 0728e24..3f4e7ec 100644 --- a/flagsmith/models.py +++ b/flagsmith/models.py @@ -3,11 +3,9 @@ import typing from dataclasses import dataclass, field -from flag_engine.result.types import FlagResult - from flagsmith.analytics import AnalyticsProcessor from flagsmith.exceptions import FlagsmithFeatureDoesNotExistError -from flagsmith.types import SDKEvaluationResult +from flagsmith.types import SDKEvaluationResult, SDKFlagResult @dataclass @@ -30,14 +28,18 @@ class Flag(BaseFlag): @classmethod def from_evaluation_result( cls, - flag: FlagResult, - ) -> Flag: - return Flag( - enabled=flag["enabled"], - value=flag["value"], - feature_name=flag["name"], - feature_id=int(flag["feature_key"]), - ) + flag_result: SDKFlagResult, + ) -> typing.Optional[Flag]: + if ( + flagsmith_id := (flag_result.get("metadata") or {}).get("flagsmith_id") + ) is not None: + return Flag( + enabled=flag_result["enabled"], + value=flag_result["value"], + feature_name=flag_result["name"], + feature_id=flagsmith_id, + ) + return None @classmethod def from_api_flag(cls, flag_data: typing.Mapping[str, typing.Any]) -> Flag: @@ -64,13 +66,9 @@ def from_evaluation_result( ) -> Flags: return cls( flags={ - flag_name: Flag( - enabled=flag["enabled"], - value=flag["value"], - feature_name=flag["name"], - feature_id=int(flag["feature_key"]), - ) - for flag_name, flag in evaluation_result["flags"].items() + flag_name: flag + for flag_name, flag_result in evaluation_result["flags"].items() + if (flag := Flag.from_evaluation_result(flag_result)) }, default_flag_handler=default_flag_handler, _analytics_processor=analytics_processor, diff --git a/flagsmith/types.py b/flagsmith/types.py index ed624f3..7710222 100644 --- a/flagsmith/types.py +++ b/flagsmith/types.py @@ -3,7 +3,7 @@ from flag_engine.context.types import EvaluationContext from flag_engine.engine import ContextValue -from flag_engine.result.types import EvaluationResult +from flag_engine.result.types import EvaluationResult, FlagResult from typing_extensions import NotRequired, TypeAlias _JsonScalarType: TypeAlias = typing.Union[ @@ -44,5 +44,11 @@ class SegmentMetadata(typing.TypedDict): """The source of the segment, e.g. 'api', 'identity_overrides'.""" -SDKEvaluationContext = EvaluationContext[SegmentMetadata] -SDKEvaluationResult = EvaluationResult[SegmentMetadata] +class FeatureMetadata(typing.TypedDict): + flagsmith_id: NotRequired[int] + """The ID of the feature used in Flagsmith API.""" + + +SDKEvaluationContext = EvaluationContext[SegmentMetadata, FeatureMetadata] +SDKEvaluationResult = EvaluationResult[SegmentMetadata, FeatureMetadata] +SDKFlagResult = FlagResult[FeatureMetadata] diff --git a/poetry.lock b/poetry.lock index 7173d15..682e0c3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -259,21 +259,25 @@ files = [ [[package]] name = "flagsmith-flag-engine" -version = "9.0.0" +version = "8.0.0" description = "Flag engine for the Flagsmith API." optional = false python-versions = "*" groups = ["main"] -files = [ - {file = "flagsmith_flag_engine-9.0.0-py3-none-any.whl", hash = "sha256:2775106adb09f2f6fdeaccdcc1e84a254df139352eb442f8cb66c536c44ca986"}, - {file = "flagsmith_flag_engine-9.0.0.tar.gz", hash = "sha256:0cf8450e9a006cffbc65e4442fbe73e39860f6101f04fc9629a569a354d857ad"}, -] +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/feature-metadata" +resolved_reference = "66e763e02df8f1cb026fd99c9797c97fc41d2d45" + [[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 = "66865353f740f4b6fa02982ead5929aaf5105201b8f9f590651e37fc424ff79d" +content-hash = "c8c65d1c10704c84cb7349b5ffef165de17bff0de270f803f1aa28fee9aa64a4" diff --git a/pyproject.toml b/pyproject.toml index 16528d0..61dfdc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ documentation = "https://docs.flagsmith.com" packages = [{ include = "flagsmith" }] [tool.poetry.dependencies] -flagsmith-flag-engine = "^9.0.0" +flagsmith-flag-engine = { git = "https://github.com/Flagsmith/flagsmith-engine.git", branch = "feat/feature-metadata" } python = ">=3.9,<4" requests = "^2.32.3" requests-futures = "^1.0.1" diff --git a/tests/test_flagsmith.py b/tests/test_flagsmith.py index 00d1ae8..6d5258c 100644 --- a/tests/test_flagsmith.py +++ b/tests/test_flagsmith.py @@ -166,6 +166,7 @@ def test_get_identity_flags_uses_local_environment_when_available( "enabled": True, "value": "some-feature-state-value", "feature_key": "1", + "metadata": {"flagsmith_id": 1}, } }, "segments": [], diff --git a/tests/test_models.py b/tests/test_models.py index 7a3c48e..687d067 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -4,23 +4,25 @@ from flag_engine.result.types import FlagResult from flagsmith.models import Flag, Flags -from flagsmith.types import SDKEvaluationResult +from flagsmith.types import SDKEvaluationResult, SDKFlagResult def test_flag_from_evaluation_result() -> None: # Given - flag_result: FlagResult = { + flag_result: SDKFlagResult = { "enabled": True, "feature_key": "123", "name": "test_feature", "reason": "DEFAULT", "value": "test-value", + "metadata": {"flagsmith_id": 123}, } # When - flag: Flag = Flag.from_evaluation_result(flag_result) + flag: typing.Optional[Flag] = Flag.from_evaluation_result(flag_result) # Then + assert flag assert flag.enabled is True assert flag.value == "test-value" assert flag.feature_name == "test_feature" @@ -29,9 +31,9 @@ def test_flag_from_evaluation_result() -> None: @pytest.mark.parametrize( - "flags_result,expected_count,expected_names", + "flags_result,expected_names", [ - ({}, 0, []), + ({}, []), ( { "feature1": { @@ -40,9 +42,9 @@ def test_flag_from_evaluation_result() -> None: "name": "feature1", "reason": "DEFAULT", "value": "value1", + "metadata": {"flagsmith_id": 1}, } }, - 1, ["feature1"], ), ( @@ -53,9 +55,9 @@ def test_flag_from_evaluation_result() -> None: "name": "feature1", "reason": "DEFAULT", "value": "value1", + "metadata": {"flagsmith_id": 1}, } }, - 1, ["feature1"], ), ( @@ -66,6 +68,7 @@ def test_flag_from_evaluation_result() -> None: "name": "feature1", "reason": "DEFAULT", "value": "value1", + "metadata": {"flagsmith_id": 1}, }, "feature2": { "enabled": True, @@ -73,6 +76,7 @@ def test_flag_from_evaluation_result() -> None: "name": "feature2", "reason": "DEFAULT", "value": "value2", + "metadata": {"flagsmith_id": 2}, }, "feature3": { "enabled": True, @@ -80,16 +84,22 @@ def test_flag_from_evaluation_result() -> None: "name": "feature3", "reason": "DEFAULT", "value": 42, + "metadata": {"flagsmith_id": 3}, + }, + "feature4": { + "enabled": True, + "feature_key": "4", + "name": "feature4", + "reason": "DEFAULT", + "value": 42, }, }, - 3, ["feature1", "feature2", "feature3"], ), ], ) def test_flags_from_evaluation_result( - flags_result: typing.Dict[str, FlagResult], - expected_count: int, + flags_result: typing.Dict[str, SDKFlagResult], expected_names: typing.List[str], ) -> None: # Given @@ -106,7 +116,7 @@ def test_flags_from_evaluation_result( ) # Then - assert len(flags.flags) == expected_count + assert len(flags.flags) == len(expected_names) for name in expected_names: assert name in flags.flags @@ -136,6 +146,7 @@ def test_flag_from_evaluation_result_value_types( "name": "test_feature", "reason": "DEFAULT", "value": value, + "metadata": {"flagsmith_id": 123}, } # When From fdc89d6dcb2121996927d84cc56f6a28283244b4 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 16 Oct 2025 18:45:25 +0100 Subject: [PATCH 2/8] fix typing --- tests/test_models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_models.py b/tests/test_models.py index 687d067..4fa3f17 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,7 +1,6 @@ import typing import pytest -from flag_engine.result.types import FlagResult from flagsmith.models import Flag, Flags from flagsmith.types import SDKEvaluationResult, SDKFlagResult @@ -140,7 +139,7 @@ def test_flag_from_evaluation_result_value_types( value: typing.Any, expected: typing.Any ) -> None: # Given - flag_result: FlagResult = { + flag_result: SDKFlagResult = { "enabled": True, "feature_key": "123", "name": "test_feature", @@ -150,7 +149,8 @@ def test_flag_from_evaluation_result_value_types( } # When - flag: Flag = Flag.from_evaluation_result(flag_result) + flag: typing.Optional[Flag] = Flag.from_evaluation_result(flag_result) # Then + assert flag assert flag.value == expected From 8bfe029033cff90f1620aeaa22a37c91e936b917 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 16 Oct 2025 18:52:01 +0100 Subject: [PATCH 3/8] improve test --- tests/test_models.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/tests/test_models.py b/tests/test_models.py index 4fa3f17..45e2116 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -115,13 +115,10 @@ def test_flags_from_evaluation_result( ) # Then - assert len(flags.flags) == len(expected_names) - - for name in expected_names: - assert name in flags.flags - flag: Flag = flags.flags[name] - assert isinstance(flag, Flag) - assert flag.feature_name == name + assert set(flags.flags.keys()) == set(expected_names) + assert set(flag.feature_name for flag in flags.flags.values()) == set( + expected_names + ) @pytest.mark.parametrize( From 9b23e749ec504eaa8777401aa8368cd495c966b2 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 16 Oct 2025 18:53:55 +0100 Subject: [PATCH 4/8] remove extra cast --- flagsmith/mappers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flagsmith/mappers.py b/flagsmith/mappers.py index 0ea712f..8d8179a 100644 --- a/flagsmith/mappers.py +++ b/flagsmith/mappers.py @@ -205,7 +205,7 @@ def _map_identity_overrides_to_segments( "enabled": feature_enabled, "value": feature_value, "priority": float("-inf"), # Highest possible priority - "metadata": {"flagsmith_id": int(flagsmith_id)}, + "metadata": {"flagsmith_id": flagsmith_id}, } for flagsmith_id, feature_name, feature_enabled, feature_value in overrides_key ], From b0aec1a118e2cc47ef7b261ebd05210798a5ce11 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 16 Oct 2025 20:13:02 +0100 Subject: [PATCH 5/8] improve metadata type --- flagsmith/models.py | 6 ++---- flagsmith/types.py | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/flagsmith/models.py b/flagsmith/models.py index 3f4e7ec..d447473 100644 --- a/flagsmith/models.py +++ b/flagsmith/models.py @@ -30,14 +30,12 @@ def from_evaluation_result( cls, flag_result: SDKFlagResult, ) -> typing.Optional[Flag]: - if ( - flagsmith_id := (flag_result.get("metadata") or {}).get("flagsmith_id") - ) is not None: + if metadata := flag_result.get("metadata"): return Flag( enabled=flag_result["enabled"], value=flag_result["value"], feature_name=flag_result["name"], - feature_id=flagsmith_id, + feature_id=metadata["flagsmith_id"], ) return None diff --git a/flagsmith/types.py b/flagsmith/types.py index 7710222..668d9df 100644 --- a/flagsmith/types.py +++ b/flagsmith/types.py @@ -45,7 +45,7 @@ class SegmentMetadata(typing.TypedDict): class FeatureMetadata(typing.TypedDict): - flagsmith_id: NotRequired[int] + flagsmith_id: int """The ID of the feature used in Flagsmith API.""" From d038a7383970f12c44b23bd3c11c1606cb1b6eec Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 16 Oct 2025 20:59:17 +0100 Subject: [PATCH 6/8] improve typing/logic --- flagsmith/models.py | 7 +++++-- tests/test_models.py | 22 +++++++++++++++------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/flagsmith/models.py b/flagsmith/models.py index d447473..ab69d2d 100644 --- a/flagsmith/models.py +++ b/flagsmith/models.py @@ -29,7 +29,7 @@ class Flag(BaseFlag): def from_evaluation_result( cls, flag_result: SDKFlagResult, - ) -> typing.Optional[Flag]: + ) -> Flag: if metadata := flag_result.get("metadata"): return Flag( enabled=flag_result["enabled"], @@ -37,7 +37,10 @@ def from_evaluation_result( feature_name=flag_result["name"], feature_id=metadata["flagsmith_id"], ) - return None + raise ValueError( + "FlagResult metadata is missing. Cannot create Flag instance. " + "This means a bug in the SDK, please report it." + ) @classmethod def from_api_flag(cls, flag_data: typing.Mapping[str, typing.Any]) -> Flag: diff --git a/tests/test_models.py b/tests/test_models.py index 45e2116..534256a 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -85,13 +85,6 @@ def test_flag_from_evaluation_result() -> None: "value": 42, "metadata": {"flagsmith_id": 3}, }, - "feature4": { - "enabled": True, - "feature_key": "4", - "name": "feature4", - "reason": "DEFAULT", - "value": 42, - }, }, ["feature1", "feature2", "feature3"], ), @@ -151,3 +144,18 @@ def test_flag_from_evaluation_result_value_types( # Then assert flag assert flag.value == expected + + +def test_flag_from_evaluation_result_missing_metadata__raises_expected() -> None: + # Given + flag_result: SDKFlagResult = { + "enabled": True, + "feature_key": "123", + "name": "test_feature", + "reason": "DEFAULT", + "value": "test-value", + } + + # When & Then + with pytest.raises(ValueError): + Flag.from_evaluation_result(flag_result) From e4dde890f4b056e866278d52b7a45854dbada0a5 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 16 Oct 2025 21:00:39 +0100 Subject: [PATCH 7/8] cleanup --- tests/test_models.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/test_models.py b/tests/test_models.py index 534256a..35d960d 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -18,10 +18,9 @@ def test_flag_from_evaluation_result() -> None: } # When - flag: typing.Optional[Flag] = Flag.from_evaluation_result(flag_result) + flag = Flag.from_evaluation_result(flag_result) # Then - assert flag assert flag.enabled is True assert flag.value == "test-value" assert flag.feature_name == "test_feature" @@ -139,10 +138,9 @@ def test_flag_from_evaluation_result_value_types( } # When - flag: typing.Optional[Flag] = Flag.from_evaluation_result(flag_result) + flag = Flag.from_evaluation_result(flag_result) # Then - assert flag assert flag.value == expected From d81d3b48fd1f04cc3a48b390602e9090ce6449ee Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 17 Oct 2025 19:55:36 +0100 Subject: [PATCH 8/8] bump flagsmith-engine --- poetry.lock | 16 ++++++---------- pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/poetry.lock b/poetry.lock index 682e0c3..0638cba 100644 --- a/poetry.lock +++ b/poetry.lock @@ -259,25 +259,21 @@ files = [ [[package]] name = "flagsmith-flag-engine" -version = "8.0.0" +version = "9.1.0" description = "Flag engine for the Flagsmith API." optional = false python-versions = "*" groups = ["main"] -files = [] -develop = false +files = [ + {file = "flagsmith_flag_engine-9.1.0-py3-none-any.whl", hash = "sha256:1afe9aa37469ce4208f5e62c2a90669e2fdada401d4b795b339ea9bf019da733"}, + {file = "flagsmith_flag_engine-9.1.0.tar.gz", hash = "sha256:d18f8daef0684f1b0224a9f98c279c966cafd29d52444682705c474247d3b4ce"}, +] [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/feature-metadata" -resolved_reference = "66e763e02df8f1cb026fd99c9797c97fc41d2d45" - [[package]] name = "identify" version = "2.6.13" @@ -951,4 +947,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.1" python-versions = ">=3.9,<4" -content-hash = "c8c65d1c10704c84cb7349b5ffef165de17bff0de270f803f1aa28fee9aa64a4" +content-hash = "7feced3e0ab64b956db1c6eaf112aa2553494bf4a27127a5afd0a8062d952917" diff --git a/pyproject.toml b/pyproject.toml index 61dfdc0..c1df2ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ documentation = "https://docs.flagsmith.com" packages = [{ include = "flagsmith" }] [tool.poetry.dependencies] -flagsmith-flag-engine = { git = "https://github.com/Flagsmith/flagsmith-engine.git", branch = "feat/feature-metadata" } +flagsmith-flag-engine = "^9.1.0" python = ">=3.9,<4" requests = "^2.32.3" requests-futures = "^1.0.1"