From 7350d0988a4d2dc0e76546236af63a6deb3071b1 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 16 Oct 2025 12:55:32 +0100 Subject: [PATCH 1/7] feat: Support generic feature metadata --- flag_engine/context/types.py | 14 ++- flag_engine/result/types.py | 9 +- flag_engine/segments/evaluator.py | 64 +++++++---- flag_engine/segments/types.py | 5 +- tests/unit/test_engine.py | 180 +++++++++++++++++++++++++++++- 5 files changed, 231 insertions(+), 41 deletions(-) diff --git a/flag_engine/context/types.py b/flag_engine/context/types.py index cd41962..a6df2cf 100644 --- a/flag_engine/context/types.py +++ b/flag_engine/context/types.py @@ -11,6 +11,7 @@ from flag_engine.segments.types import ( ConditionOperator, ContextValue, + FeatureMetadataT, RuleType, SegmentMetadataT, ) @@ -54,7 +55,7 @@ class SegmentRule(TypedDict): rules: NotRequired[List[SegmentRule]] -class FeatureContext(TypedDict): +class FeatureContext(TypedDict, Generic[FeatureMetadataT]): key: str feature_key: str name: str @@ -62,18 +63,19 @@ class FeatureContext(TypedDict): value: Any variants: NotRequired[List[FeatureValue]] priority: NotRequired[float] + metadata: NotRequired[FeatureMetadataT] -class SegmentContext(TypedDict, Generic[SegmentMetadataT]): +class SegmentContext(TypedDict, Generic[SegmentMetadataT, FeatureMetadataT]): key: str name: str rules: List[SegmentRule] - overrides: NotRequired[List[FeatureContext]] + overrides: NotRequired[List[FeatureContext[FeatureMetadataT]]] metadata: NotRequired[SegmentMetadataT] -class EvaluationContext(TypedDict, Generic[SegmentMetadataT]): +class EvaluationContext(TypedDict, Generic[SegmentMetadataT, FeatureMetadataT]): environment: EnvironmentContext identity: NotRequired[Optional[IdentityContext]] - segments: NotRequired[Dict[str, SegmentContext[SegmentMetadataT]]] - features: NotRequired[Dict[str, FeatureContext]] + segments: NotRequired[Dict[str, SegmentContext[SegmentMetadataT, FeatureMetadataT]]] + features: NotRequired[Dict[str, FeatureContext[FeatureMetadataT]]] diff --git a/flag_engine/result/types.py b/flag_engine/result/types.py index a6741e1..21509e0 100644 --- a/flag_engine/result/types.py +++ b/flag_engine/result/types.py @@ -8,15 +8,16 @@ from typing_extensions import NotRequired, TypedDict -from flag_engine.segments.types import SegmentMetadataT +from flag_engine.segments.types import FeatureMetadataT, SegmentMetadataT -class FlagResult(TypedDict): +class FlagResult(TypedDict, Generic[FeatureMetadataT]): feature_key: str name: str enabled: bool value: Any reason: str + metadata: NotRequired[FeatureMetadataT] class SegmentResult(TypedDict, Generic[SegmentMetadataT]): @@ -25,6 +26,6 @@ class SegmentResult(TypedDict, Generic[SegmentMetadataT]): metadata: NotRequired[SegmentMetadataT] -class EvaluationResult(TypedDict, Generic[SegmentMetadataT]): - flags: Dict[str, FlagResult] +class EvaluationResult(TypedDict, Generic[SegmentMetadataT, FeatureMetadataT]): + flags: Dict[str, FlagResult[FeatureMetadataT]] segments: List[SegmentResult[SegmentMetadataT]] diff --git a/flag_engine/segments/evaluator.py b/flag_engine/segments/evaluator.py index 81e9fcc..ddb0591 100644 --- a/flag_engine/segments/evaluator.py +++ b/flag_engine/segments/evaluator.py @@ -23,6 +23,7 @@ from flag_engine.segments.types import ( ConditionOperator, ContextValue, + FeatureMetadataT, SegmentMetadataT, is_context_value, ) @@ -32,14 +33,14 @@ from flag_engine.utils.types import SupportsStr, get_casting_function -class FeatureContextWithSegmentName(typing.TypedDict): - feature_context: FeatureContext +class FeatureContextWithSegmentName(typing.TypedDict, typing.Generic[FeatureMetadataT]): + feature_context: FeatureContext[FeatureMetadataT] segment_name: str def get_evaluation_result( - context: EvaluationContext[SegmentMetadataT], -) -> EvaluationResult[SegmentMetadataT]: + context: EvaluationContext[SegmentMetadataT, FeatureMetadataT], +) -> EvaluationResult[SegmentMetadataT, FeatureMetadataT]: """ Get the evaluation result for a given context. @@ -47,9 +48,12 @@ def get_evaluation_result( :return: EvaluationResult containing the context, flags, and segments """ segments: list[SegmentResult[SegmentMetadataT]] = [] - flags: dict[str, FlagResult] = {} + flags: dict[str, FlagResult[FeatureMetadataT]] = {} - segment_feature_contexts: dict[SupportsStr, FeatureContextWithSegmentName] = {} + segment_feature_contexts: dict[ + SupportsStr, + FeatureContextWithSegmentName[FeatureMetadataT], + ] = {} for segment_context in (context.get("segments") or {}).values(): if not is_context_in_segment(context, segment_context): @@ -59,8 +63,8 @@ def get_evaluation_result( "key": segment_context["key"], "name": segment_context["name"], } - if metadata := segment_context.get("metadata"): - segment_result["metadata"] = metadata + if segment_metadata := segment_context.get("metadata"): + segment_result["metadata"] = segment_metadata segments.append(segment_result) if overrides := segment_context.get("overrides"): @@ -95,13 +99,16 @@ def get_evaluation_result( feature_context["feature_key"], ): feature_context = feature_context_with_segment_name["feature_context"] - flags[feature_name] = { + flag_result: FlagResult[FeatureMetadataT] + flags[feature_name] = flag_result = { "enabled": feature_context["enabled"], "feature_key": feature_context["feature_key"], "name": feature_context["name"], "reason": f"TARGETING_MATCH; segment={feature_context_with_segment_name['segment_name']}", "value": feature_context.get("value"), } + if feature_metadata := feature_context.get("metadata"): + flag_result["metadata"] = feature_metadata continue flags[feature_name] = get_flag_result_from_feature_context( feature_context=feature_context, @@ -115,9 +122,9 @@ def get_evaluation_result( def get_flag_result_from_feature_context( - feature_context: FeatureContext, + feature_context: FeatureContext[FeatureMetadataT], key: typing.Optional[SupportsStr], -) -> FlagResult: +) -> FlagResult[FeatureMetadataT]: """ Get a feature value from the feature context for a given key. @@ -126,6 +133,8 @@ def get_flag_result_from_feature_context( :param key: the key to get the value for :return: the value for the key in the feature context """ + flag_result: typing.Optional[FlagResult[FeatureMetadataT]] = None + if key is not None and (variants := feature_context.get("variants")): percentage_value = get_hashed_percentage_for_object_ids( [feature_context["key"], key] @@ -139,28 +148,35 @@ def get_flag_result_from_feature_context( ): limit = (weight := variant["weight"]) + start_percentage if start_percentage <= percentage_value < limit: - return { + flag_result = { "enabled": feature_context["enabled"], "feature_key": feature_context["feature_key"], "name": feature_context["name"], "reason": f"SPLIT; weight={weight}", "value": variant["value"], } + break start_percentage = limit - return { - "enabled": feature_context["enabled"], - "feature_key": feature_context["feature_key"], - "name": feature_context["name"], - "reason": "DEFAULT", - "value": feature_context["value"], - } + if flag_result is None: + flag_result = { + "enabled": feature_context["enabled"], + "feature_key": feature_context["feature_key"], + "name": feature_context["name"], + "reason": "DEFAULT", + "value": feature_context["value"], + } + + if metadata := feature_context.get("metadata"): + flag_result["metadata"] = metadata + + return flag_result def is_context_in_segment( - context: EvaluationContext[SegmentMetadataT], - segment_context: SegmentContext[SegmentMetadataT], + context: EvaluationContext[typing.Any, typing.Any], + segment_context: SegmentContext[typing.Any, typing.Any], ) -> bool: return bool(rules := segment_context["rules"]) and all( context_matches_rule( @@ -171,7 +187,7 @@ def is_context_in_segment( def context_matches_rule( - context: EvaluationContext[SegmentMetadataT], + context: EvaluationContext[typing.Any, typing.Any], rule: SegmentRule, segment_key: SupportsStr, ) -> bool: @@ -201,7 +217,7 @@ def context_matches_rule( def context_matches_condition( - context: EvaluationContext[SegmentMetadataT], + context: EvaluationContext[typing.Any, typing.Any], condition: SegmentCondition, segment_key: SupportsStr, ) -> bool: @@ -262,7 +278,7 @@ def context_matches_condition( def get_context_value( - context: EvaluationContext[SegmentMetadataT], + context: EvaluationContext[typing.Any, typing.Any], property: str, ) -> ContextValue: value = None diff --git a/flag_engine/segments/types.py b/flag_engine/segments/types.py index 118b0d3..f2b67b2 100644 --- a/flag_engine/segments/types.py +++ b/flag_engine/segments/types.py @@ -1,10 +1,11 @@ from __future__ import annotations -from typing import Any, Dict, Literal, Union, get_args +from typing import Any, Literal, Mapping, Union, get_args from typing_extensions import TypeGuard, TypeVar -SegmentMetadataT = TypeVar("SegmentMetadataT", default=Dict[str, object]) +SegmentMetadataT = TypeVar("SegmentMetadataT", default=Mapping[str, object]) +FeatureMetadataT = TypeVar("FeatureMetadataT", default=Mapping[str, object]) ConditionOperator = Literal[ "EQUAL", diff --git a/tests/unit/test_engine.py b/tests/unit/test_engine.py index 980c200..e236781 100644 --- a/tests/unit/test_engine.py +++ b/tests/unit/test_engine.py @@ -368,13 +368,13 @@ def test_get_evaluation_result__segment_override__no_priority__returns_expected( def test_segment_metadata_generic_type__returns_expected() -> None: # Given - class CustomMetadata(TypedDict): + class CustomSegmentMetadata(TypedDict): foo: str bar: int - segment_metadata = CustomMetadata(foo="hello", bar=123) + segment_metadata = CustomSegmentMetadata(foo="hello", bar=123) - evaluation_context: EvaluationContext[CustomMetadata] = { + evaluation_context: EvaluationContext[CustomSegmentMetadata] = { "environment": {"key": "api-key", "name": ""}, "segments": { "1": { @@ -403,7 +403,7 @@ class CustomMetadata(TypedDict): # Then assert result["segments"][0]["metadata"] is segment_metadata - reveal_type(result["segments"][0]["metadata"]) # CustomMetadata + reveal_type(result["segments"][0]["metadata"]) # CustomSegmentMetadata def test_segment_metadata_generic_type__default__returns_expected() -> None: @@ -440,4 +440,174 @@ def test_segment_metadata_generic_type__default__returns_expected() -> None: # Then assert result["segments"][0]["metadata"] is segment_metadata - reveal_type(result["segments"][0]["metadata"]) # Dict[str, object] + reveal_type(result["segments"][0]["metadata"]) # Mapping[str, object] + + +def test_feature_metadata_generic_type__returns_expected() -> None: + # Given + class CustomFeatureMetadata(TypedDict): + foo: str + bar: int + + feature_metadata = CustomFeatureMetadata(foo="hello", bar=123) + + evaluation_context: EvaluationContext[None, CustomFeatureMetadata] = { + "environment": {"key": "api-key", "name": ""}, + "features": { + "feature_1": { + "key": "1", + "feature_key": "1", + "name": "feature_1", + "enabled": False, + "value": None, + "metadata": feature_metadata, + }, + }, + "segments": { + "1": { + "key": "1", + "name": "my_segment", + "rules": [ + { + "type": "ALL", + "conditions": [ + { + "property": "$.environment.name", + "operator": "EQUAL", + "value": "", + } + ], + "rules": [], + } + ], + "overrides": [ + { + "key": "5", + "feature_key": "1", + "name": "feature_1", + "enabled": True, + "value": "overridden_for_identity", + "metadata": feature_metadata, + } + ], + }, + }, + } + + # When + result = get_evaluation_result(evaluation_context) + + # Then + assert result["flags"]["feature_1"]["metadata"] is feature_metadata + reveal_type(result["flags"]["feature_1"]["metadata"]) # CustomFeatureMetadata + + +def test_feature_metadata_generic_type__default__returns_expected() -> None: + # Given + feature_metadata = {"hello": object()} + + # we don't specify generic type, but mypy is happy with this + evaluation_context: EvaluationContext = { + "environment": {"key": "api-key", "name": ""}, + "features": { + "feature_1": { + "key": "1", + "feature_key": "1", + "name": "feature_1", + "enabled": False, + "value": None, + "metadata": feature_metadata, + }, + }, + "segments": { + "1": { + "key": "1", + "name": "my_segment", + "rules": [ + { + "type": "ALL", + "conditions": [ + { + "property": "$.environment.name", + "operator": "EQUAL", + "value": "", + } + ], + "rules": [], + } + ], + "overrides": [ + { + "key": "5", + "feature_key": "1", + "name": "feature_1", + "enabled": True, + "value": "overridden_for_identity", + "metadata": feature_metadata, + } + ], + }, + }, + } + + # When + result = get_evaluation_result(evaluation_context) + + # Then + assert result["flags"]["feature_1"]["metadata"] is feature_metadata + reveal_type(result["flags"]["feature_1"]["metadata"]) # Mapping[str, object] + + +def test_feature_metadata__override_has_metadata__returns_expected() -> None: + # Given + default_feature_metadata = {"hello": object()} + override_feature_metadata = {"hello": "override"} + + evaluation_context: EvaluationContext = { + "environment": {"key": "api-key", "name": ""}, + "features": { + "feature_1": { + "key": "1", + "feature_key": "1", + "name": "feature_1", + "enabled": False, + "value": None, + "metadata": default_feature_metadata, + }, + }, + "segments": { + "1": { + "key": "1", + "name": "my_segment", + "rules": [ + { + "type": "ALL", + "conditions": [ + { + "property": "$.environment.name", + "operator": "EQUAL", + "value": "", + } + ], + "rules": [], + } + ], + "overrides": [ + { + "key": "5", + "feature_key": "1", + "name": "feature_1", + "enabled": True, + "value": "overridden_for_identity", + "metadata": override_feature_metadata, + } + ], + }, + }, + } + + # When + result = get_evaluation_result(evaluation_context) + + # Then + assert result["flags"]["feature_1"]["metadata"] is override_feature_metadata From cf7c6ede34d1eebba964b50f02d4f9e0a0172af9 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 16 Oct 2025 13:00:02 +0100 Subject: [PATCH 2/7] fix coverage --- tests/unit/test_engine.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/unit/test_engine.py b/tests/unit/test_engine.py index e236781..a0748fe 100644 --- a/tests/unit/test_engine.py +++ b/tests/unit/test_engine.py @@ -558,6 +558,31 @@ def test_feature_metadata_generic_type__default__returns_expected() -> None: reveal_type(result["flags"]["feature_1"]["metadata"]) # Mapping[str, object] +def test_feature_metadata__no_override__returns_expected() -> None: + # Given + feature_metadata = {"hello": object()} + + evaluation_context: EvaluationContext = { + "environment": {"key": "api-key", "name": ""}, + "features": { + "feature_1": { + "key": "1", + "feature_key": "1", + "name": "feature_1", + "enabled": False, + "value": None, + "metadata": feature_metadata, + }, + }, + } + + # When + result = get_evaluation_result(evaluation_context) + + # Then + assert result["flags"]["feature_1"]["metadata"] is feature_metadata + + def test_feature_metadata__override_has_metadata__returns_expected() -> None: # Given default_feature_metadata = {"hello": object()} From d1002d636ca75dbd88194639f5dd16fd22de97ae Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 16 Oct 2025 13:04:12 +0100 Subject: [PATCH 3/7] =?UTF-8?q?=E2=9C=A8future=E2=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- flag_engine/segments/evaluator.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flag_engine/segments/evaluator.py b/flag_engine/segments/evaluator.py index ddb0591..0ab4b34 100644 --- a/flag_engine/segments/evaluator.py +++ b/flag_engine/segments/evaluator.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json import operator import re From 66e763e02df8f1cb026fd99c9797c97fc41d2d45 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 16 Oct 2025 13:07:27 +0100 Subject: [PATCH 4/7] more future --- flag_engine/segments/evaluator.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flag_engine/segments/evaluator.py b/flag_engine/segments/evaluator.py index 0ab4b34..2788338 100644 --- a/flag_engine/segments/evaluator.py +++ b/flag_engine/segments/evaluator.py @@ -10,6 +10,7 @@ import jsonpath_rfc9535 import semver +from typing_extensions import TypedDict from flag_engine.context.mappers import map_any_value_to_context_value from flag_engine.context.types import ( @@ -35,7 +36,7 @@ from flag_engine.utils.types import SupportsStr, get_casting_function -class FeatureContextWithSegmentName(typing.TypedDict, typing.Generic[FeatureMetadataT]): +class FeatureContextWithSegmentName(TypedDict, typing.Generic[FeatureMetadataT]): feature_context: FeatureContext[FeatureMetadataT] segment_name: str From ca989b7d3fb17a18620ecb32b676557474d1a1d6 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 16 Oct 2025 14:07:12 +0100 Subject: [PATCH 5/7] override tests moved to engine-test-data --- .gitmodules | 2 +- tests/engine_tests/engine-test-data | 2 +- tests/unit/test_engine.py | 80 ----------------------------- 3 files changed, 2 insertions(+), 82 deletions(-) diff --git a/.gitmodules b/.gitmodules index c4dd1d0..9c4cc97 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "tests/engine_tests/engine-test-data"] path = tests/engine_tests/engine-test-data url = https://github.com/flagsmith/engine-test-data.git - tag = v2.4.0 + branch = feat/feature-metadata diff --git a/tests/engine_tests/engine-test-data b/tests/engine_tests/engine-test-data index 6453b03..4ec6179 160000 --- a/tests/engine_tests/engine-test-data +++ b/tests/engine_tests/engine-test-data @@ -1 +1 @@ -Subproject commit 6453b0391344a4d677a97cc4a9d27a8b8e329787 +Subproject commit 4ec61796e646ebef2bf933957be75b91fb1b7503 diff --git a/tests/unit/test_engine.py b/tests/unit/test_engine.py index a0748fe..54b26df 100644 --- a/tests/unit/test_engine.py +++ b/tests/unit/test_engine.py @@ -556,83 +556,3 @@ def test_feature_metadata_generic_type__default__returns_expected() -> None: # Then assert result["flags"]["feature_1"]["metadata"] is feature_metadata reveal_type(result["flags"]["feature_1"]["metadata"]) # Mapping[str, object] - - -def test_feature_metadata__no_override__returns_expected() -> None: - # Given - feature_metadata = {"hello": object()} - - evaluation_context: EvaluationContext = { - "environment": {"key": "api-key", "name": ""}, - "features": { - "feature_1": { - "key": "1", - "feature_key": "1", - "name": "feature_1", - "enabled": False, - "value": None, - "metadata": feature_metadata, - }, - }, - } - - # When - result = get_evaluation_result(evaluation_context) - - # Then - assert result["flags"]["feature_1"]["metadata"] is feature_metadata - - -def test_feature_metadata__override_has_metadata__returns_expected() -> None: - # Given - default_feature_metadata = {"hello": object()} - override_feature_metadata = {"hello": "override"} - - evaluation_context: EvaluationContext = { - "environment": {"key": "api-key", "name": ""}, - "features": { - "feature_1": { - "key": "1", - "feature_key": "1", - "name": "feature_1", - "enabled": False, - "value": None, - "metadata": default_feature_metadata, - }, - }, - "segments": { - "1": { - "key": "1", - "name": "my_segment", - "rules": [ - { - "type": "ALL", - "conditions": [ - { - "property": "$.environment.name", - "operator": "EQUAL", - "value": "", - } - ], - "rules": [], - } - ], - "overrides": [ - { - "key": "5", - "feature_key": "1", - "name": "feature_1", - "enabled": True, - "value": "overridden_for_identity", - "metadata": override_feature_metadata, - } - ], - }, - }, - } - - # When - result = get_evaluation_result(evaluation_context) - - # Then - assert result["flags"]["feature_1"]["metadata"] is override_feature_metadata From b87afe1fb77b55196ec72572d9ebe57e1432758e Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 16 Oct 2025 15:19:23 +0100 Subject: [PATCH 6/7] use engine-test-data 2.5.0 --- .gitmodules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitmodules b/.gitmodules index 9c4cc97..3c6ced0 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "tests/engine_tests/engine-test-data"] path = tests/engine_tests/engine-test-data url = https://github.com/flagsmith/engine-test-data.git - branch = feat/feature-metadata + tag = 2.5.0 From 40718323ff740a45c90f8bba898e1e6f5f9a060c Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 16 Oct 2025 20:00:51 +0100 Subject: [PATCH 7/7] use correct tag --- .gitmodules | 2 +- tests/engine_tests/engine-test-data | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index 3c6ced0..fa58745 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "tests/engine_tests/engine-test-data"] path = tests/engine_tests/engine-test-data url = https://github.com/flagsmith/engine-test-data.git - tag = 2.5.0 + tag = v2.5.0 diff --git a/tests/engine_tests/engine-test-data b/tests/engine_tests/engine-test-data index 4ec6179..41c2021 160000 --- a/tests/engine_tests/engine-test-data +++ b/tests/engine_tests/engine-test-data @@ -1 +1 @@ -Subproject commit 4ec61796e646ebef2bf933957be75b91fb1b7503 +Subproject commit 41c202145e375c712600e318c439456de5b221d7