From a05ab130c6dd5ed4cb03f7d3ecb287a81466ee55 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Mon, 13 Oct 2025 15:27:57 +0100 Subject: [PATCH 1/6] feat: Generic segment metadata --- flag_engine/context/types.py | 19 ++++++++++++------- flag_engine/result/types.py | 14 ++++++++------ flag_engine/segments/types.py | 8 ++++++-- flag_engine/types/__init__.py | 0 4 files changed, 26 insertions(+), 15 deletions(-) delete mode 100644 flag_engine/types/__init__.py diff --git a/flag_engine/context/types.py b/flag_engine/context/types.py index 06e48480..0f66adca 100644 --- a/flag_engine/context/types.py +++ b/flag_engine/context/types.py @@ -4,11 +4,16 @@ from __future__ import annotations -from typing import Any, Dict, List, Literal, Optional, TypedDict, Union +from typing import Any, Dict, Generic, List, Literal, Optional, Union -from typing_extensions import NotRequired +from typing_extensions import NotRequired, TypedDict -from flag_engine.segments.types import ConditionOperator, ContextValue, RuleType +from flag_engine.segments.types import ( + ConditionOperator, + ContextValue, + MetadataT, + RuleType, +) class EnvironmentContext(TypedDict): @@ -58,16 +63,16 @@ class FeatureContext(TypedDict): priority: NotRequired[float] -class SegmentContext(TypedDict): +class SegmentContext(TypedDict, Generic[MetadataT]): key: str name: str rules: List[SegmentRule] overrides: NotRequired[List[FeatureContext]] - metadata: NotRequired[Dict[str, Any]] + metadata: NotRequired[MetadataT] -class EvaluationContext(TypedDict): +class EvaluationContext(TypedDict, Generic[MetadataT]): environment: EnvironmentContext identity: NotRequired[Optional[IdentityContext]] - segments: NotRequired[Dict[str, SegmentContext]] + segments: NotRequired[Dict[str, SegmentContext[MetadataT]]] features: NotRequired[Dict[str, FeatureContext]] diff --git a/flag_engine/result/types.py b/flag_engine/result/types.py index 1404d1c4..9a5671fe 100644 --- a/flag_engine/result/types.py +++ b/flag_engine/result/types.py @@ -4,9 +4,11 @@ from __future__ import annotations -from typing import Any, Dict, List, TypedDict +from typing import Any, Dict, Generic, List -from typing_extensions import NotRequired +from typing_extensions import NotRequired, TypedDict + +from flag_engine.segments.types import MetadataT class FlagResult(TypedDict): @@ -17,12 +19,12 @@ class FlagResult(TypedDict): reason: str -class SegmentResult(TypedDict): +class SegmentResult(TypedDict, Generic[MetadataT]): key: str name: str - metadata: NotRequired[Dict[str, Any]] + metadata: NotRequired[MetadataT] -class EvaluationResult(TypedDict): +class EvaluationResult(TypedDict, Generic[MetadataT]): flags: Dict[str, FlagResult] - segments: List[SegmentResult] + segments: List[SegmentResult[MetadataT]] diff --git a/flag_engine/segments/types.py b/flag_engine/segments/types.py index 01d46f47..35375fe0 100644 --- a/flag_engine/segments/types.py +++ b/flag_engine/segments/types.py @@ -1,6 +1,10 @@ -from typing import Any, Literal, Union, get_args +from __future__ import annotations -from typing_extensions import TypeGuard +from typing import Any, Dict, Literal, Union, get_args + +from typing_extensions import TypeGuard, TypeVar + +MetadataT = TypeVar("MetadataT", default=Dict[str, Any]) ConditionOperator = Literal[ "EQUAL", diff --git a/flag_engine/types/__init__.py b/flag_engine/types/__init__.py deleted file mode 100644 index e69de29b..00000000 From 53b867aae93d9d7e1e94548c6c0b6b477ac23da5 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Mon, 13 Oct 2025 15:53:15 +0100 Subject: [PATCH 2/6] typing fixes --- flag_engine/segments/evaluator.py | 29 +++++++++++++--------- flag_engine/segments/types.py | 2 +- tests/unit/test_engine.py | 40 +++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 12 deletions(-) diff --git a/flag_engine/segments/evaluator.py b/flag_engine/segments/evaluator.py index f82f2b30..35da5532 100644 --- a/flag_engine/segments/evaluator.py +++ b/flag_engine/segments/evaluator.py @@ -20,7 +20,12 @@ ) from flag_engine.result.types import EvaluationResult, FlagResult, SegmentResult from flag_engine.segments import constants -from flag_engine.segments.types import ConditionOperator, ContextValue, is_context_value +from flag_engine.segments.types import ( + ConditionOperator, + ContextValue, + MetadataT, + is_context_value, +) from flag_engine.segments.utils import escape_double_quotes, get_matching_function from flag_engine.utils.hashing import get_hashed_percentage_for_object_ids from flag_engine.utils.semver import is_semver @@ -32,14 +37,16 @@ class FeatureContextWithSegmentName(typing.TypedDict): segment_name: str -def get_evaluation_result(context: EvaluationContext) -> EvaluationResult: +def get_evaluation_result( + context: EvaluationContext[MetadataT], +) -> EvaluationResult[MetadataT]: """ Get the evaluation result for a given context. :param context: the evaluation context :return: EvaluationResult containing the context, flags, and segments """ - segments: list[SegmentResult] = [] + segments: list[SegmentResult[MetadataT]] = [] flags: dict[str, FlagResult] = {} segment_feature_contexts: dict[SupportsStr, FeatureContextWithSegmentName] = {} @@ -48,7 +55,7 @@ def get_evaluation_result(context: EvaluationContext) -> EvaluationResult: if not is_context_in_segment(context, segment_context): continue - segment_result: SegmentResult = { + segment_result: SegmentResult[MetadataT] = { "key": segment_context["key"], "name": segment_context["name"], } @@ -152,8 +159,8 @@ def get_flag_result_from_feature_context( def is_context_in_segment( - context: EvaluationContext, - segment_context: SegmentContext, + context: EvaluationContext[MetadataT], + segment_context: SegmentContext[MetadataT], ) -> bool: return bool(rules := segment_context["rules"]) and all( context_matches_rule( @@ -164,7 +171,7 @@ def is_context_in_segment( def context_matches_rule( - context: EvaluationContext, + context: EvaluationContext[MetadataT], rule: SegmentRule, segment_key: SupportsStr, ) -> bool: @@ -194,7 +201,7 @@ def context_matches_rule( def context_matches_condition( - context: EvaluationContext, + context: EvaluationContext[MetadataT], condition: SegmentCondition, segment_key: SupportsStr, ) -> bool: @@ -255,7 +262,7 @@ def context_matches_condition( def get_context_value( - context: EvaluationContext, + context: EvaluationContext[MetadataT], property: str, ) -> ContextValue: value = None @@ -353,7 +360,7 @@ def inner( @lru_cache def _get_context_value_getter( property: str, -) -> typing.Callable[[EvaluationContext], ContextValue]: +) -> typing.Callable[[EvaluationContext[MetadataT]], ContextValue]: """ Get a function to retrieve a context value based on property value, assumed to be either a JSONPath string or a trait key. @@ -370,7 +377,7 @@ def _get_context_value_getter( f'$.identity.traits["{escape_double_quotes(property)}"]', ) - def getter(context: EvaluationContext) -> ContextValue: + def getter(context: EvaluationContext[MetadataT]) -> ContextValue: if typing.TYPE_CHECKING: # pragma: no cover # Ugly hack to satisfy mypy :( data = dict(context) diff --git a/flag_engine/segments/types.py b/flag_engine/segments/types.py index 35375fe0..d8e3fd02 100644 --- a/flag_engine/segments/types.py +++ b/flag_engine/segments/types.py @@ -4,7 +4,7 @@ from typing_extensions import TypeGuard, TypeVar -MetadataT = TypeVar("MetadataT", default=Dict[str, Any]) +MetadataT = TypeVar("MetadataT", default=Dict[str, object]) ConditionOperator = Literal[ "EQUAL", diff --git a/tests/unit/test_engine.py b/tests/unit/test_engine.py index fe0fa96f..6837ff4b 100644 --- a/tests/unit/test_engine.py +++ b/tests/unit/test_engine.py @@ -1,4 +1,5 @@ import json +from typing import TypedDict from flag_engine.context.types import EvaluationContext, IdentityContext, SegmentContext from flag_engine.engine import get_evaluation_result @@ -357,3 +358,42 @@ def test_get_evaluation_result__segment_override__no_priority__returns_expected( {"key": "3", "name": "another_segment"}, ], } + + +def test_segment_metadata_generic_type__returns_expected() -> None: + # Given + class CustomMetadata(TypedDict): + foo: str + bar: int + + segment_metadata = CustomMetadata(foo="hello", bar=123) + + evaluation_context: EvaluationContext[CustomMetadata] = { + "environment": {"key": "api-key", "name": ""}, + "segments": { + "1": { + "key": "1", + "name": "my_segment", + "rules": [ + { + "type": "ALL", + "conditions": [ + { + "property": "$.environment.name", + "operator": "EQUAL", + "value": "", + } + ], + "rules": [], + } + ], + "metadata": segment_metadata, + }, + }, + } + + # When + result = get_evaluation_result(evaluation_context) + + # Then + assert result["segments"][0]["metadata"] is segment_metadata From 89eb36b7498ad049abfa5b27476eb5498e259dbc Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Mon, 13 Oct 2025 15:58:59 +0100 Subject: [PATCH 3/6] add test for default generic type --- tests/unit/test_engine.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/unit/test_engine.py b/tests/unit/test_engine.py index 6837ff4b..e7433a7e 100644 --- a/tests/unit/test_engine.py +++ b/tests/unit/test_engine.py @@ -397,3 +397,39 @@ class CustomMetadata(TypedDict): # Then assert result["segments"][0]["metadata"] is segment_metadata + + +def test_segment_metadata_generic_type__default__returns_expected() -> None: + # Given + segment_metadata = {"hello": object()} + + # we don't specify generic type, but mypy is happy with this + evaluation_context: EvaluationContext = { + "environment": {"key": "api-key", "name": ""}, + "segments": { + "1": { + "key": "1", + "name": "my_segment", + "rules": [ + { + "type": "ALL", + "conditions": [ + { + "property": "$.environment.name", + "operator": "EQUAL", + "value": "", + } + ], + "rules": [], + } + ], + "metadata": segment_metadata, + }, + }, + } + + # When + result = get_evaluation_result(evaluation_context) + + # Then + assert result["segments"][0]["metadata"] is segment_metadata From d1f00139361ec6379044e5a22fc4400f571cecbe Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Mon, 13 Oct 2025 16:08:35 +0100 Subject: [PATCH 4/6] add reveal_type tests --- tests/unit/test_engine.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_engine.py b/tests/unit/test_engine.py index e7433a7e..c99954dc 100644 --- a/tests/unit/test_engine.py +++ b/tests/unit/test_engine.py @@ -1,5 +1,10 @@ import json -from typing import TypedDict +from typing import TYPE_CHECKING, TypedDict + +if not TYPE_CHECKING: + + def reveal_type(x: object) -> None: ... + from flag_engine.context.types import EvaluationContext, IdentityContext, SegmentContext from flag_engine.engine import get_evaluation_result @@ -397,6 +402,7 @@ class CustomMetadata(TypedDict): # Then assert result["segments"][0]["metadata"] is segment_metadata + reveal_type(result["segments"][0]["metadata"]) # CustomMetadata def test_segment_metadata_generic_type__default__returns_expected() -> None: @@ -433,3 +439,4 @@ 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] From 7e9d00aec998cb115d4b8175660c79563042a502 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Mon, 13 Oct 2025 16:10:00 +0100 Subject: [PATCH 5/6] explain --- tests/unit/test_engine.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_engine.py b/tests/unit/test_engine.py index c99954dc..980c2002 100644 --- a/tests/unit/test_engine.py +++ b/tests/unit/test_engine.py @@ -2,7 +2,8 @@ from typing import TYPE_CHECKING, TypedDict if not TYPE_CHECKING: - + # `reveal_type` is a pseudo-builtin only available when type checking. + # Define a no-op version here so that we can call it in the tests. def reveal_type(x: object) -> None: ... From b38605876d9793bbb75c112f0248e07d896ca221 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Mon, 13 Oct 2025 17:39:54 +0100 Subject: [PATCH 6/6] improve type var naming --- flag_engine/context/types.py | 10 +++++----- flag_engine/result/types.py | 10 +++++----- flag_engine/segments/evaluator.py | 24 ++++++++++++------------ flag_engine/segments/types.py | 2 +- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/flag_engine/context/types.py b/flag_engine/context/types.py index 0f66adca..193e573a 100644 --- a/flag_engine/context/types.py +++ b/flag_engine/context/types.py @@ -11,8 +11,8 @@ from flag_engine.segments.types import ( ConditionOperator, ContextValue, - MetadataT, RuleType, + SegmentMetadataT, ) @@ -63,16 +63,16 @@ class FeatureContext(TypedDict): priority: NotRequired[float] -class SegmentContext(TypedDict, Generic[MetadataT]): +class SegmentContext(TypedDict, Generic[SegmentMetadataT]): key: str name: str rules: List[SegmentRule] overrides: NotRequired[List[FeatureContext]] - metadata: NotRequired[MetadataT] + metadata: NotRequired[SegmentMetadataT] -class EvaluationContext(TypedDict, Generic[MetadataT]): +class EvaluationContext(TypedDict, Generic[SegmentMetadataT]): environment: EnvironmentContext identity: NotRequired[Optional[IdentityContext]] - segments: NotRequired[Dict[str, SegmentContext[MetadataT]]] + segments: NotRequired[Dict[str, SegmentContext[SegmentMetadataT]]] features: NotRequired[Dict[str, FeatureContext]] diff --git a/flag_engine/result/types.py b/flag_engine/result/types.py index 9a5671fe..a6741e1c 100644 --- a/flag_engine/result/types.py +++ b/flag_engine/result/types.py @@ -8,7 +8,7 @@ from typing_extensions import NotRequired, TypedDict -from flag_engine.segments.types import MetadataT +from flag_engine.segments.types import SegmentMetadataT class FlagResult(TypedDict): @@ -19,12 +19,12 @@ class FlagResult(TypedDict): reason: str -class SegmentResult(TypedDict, Generic[MetadataT]): +class SegmentResult(TypedDict, Generic[SegmentMetadataT]): key: str name: str - metadata: NotRequired[MetadataT] + metadata: NotRequired[SegmentMetadataT] -class EvaluationResult(TypedDict, Generic[MetadataT]): +class EvaluationResult(TypedDict, Generic[SegmentMetadataT]): flags: Dict[str, FlagResult] - segments: List[SegmentResult[MetadataT]] + segments: List[SegmentResult[SegmentMetadataT]] diff --git a/flag_engine/segments/evaluator.py b/flag_engine/segments/evaluator.py index 35da5532..6cfc7999 100644 --- a/flag_engine/segments/evaluator.py +++ b/flag_engine/segments/evaluator.py @@ -23,7 +23,7 @@ from flag_engine.segments.types import ( ConditionOperator, ContextValue, - MetadataT, + SegmentMetadataT, is_context_value, ) from flag_engine.segments.utils import escape_double_quotes, get_matching_function @@ -38,15 +38,15 @@ class FeatureContextWithSegmentName(typing.TypedDict): def get_evaluation_result( - context: EvaluationContext[MetadataT], -) -> EvaluationResult[MetadataT]: + context: EvaluationContext[SegmentMetadataT], +) -> EvaluationResult[SegmentMetadataT]: """ Get the evaluation result for a given context. :param context: the evaluation context :return: EvaluationResult containing the context, flags, and segments """ - segments: list[SegmentResult[MetadataT]] = [] + segments: list[SegmentResult[SegmentMetadataT]] = [] flags: dict[str, FlagResult] = {} segment_feature_contexts: dict[SupportsStr, FeatureContextWithSegmentName] = {} @@ -55,7 +55,7 @@ def get_evaluation_result( if not is_context_in_segment(context, segment_context): continue - segment_result: SegmentResult[MetadataT] = { + segment_result: SegmentResult[SegmentMetadataT] = { "key": segment_context["key"], "name": segment_context["name"], } @@ -159,8 +159,8 @@ def get_flag_result_from_feature_context( def is_context_in_segment( - context: EvaluationContext[MetadataT], - segment_context: SegmentContext[MetadataT], + context: EvaluationContext[SegmentMetadataT], + segment_context: SegmentContext[SegmentMetadataT], ) -> bool: return bool(rules := segment_context["rules"]) and all( context_matches_rule( @@ -171,7 +171,7 @@ def is_context_in_segment( def context_matches_rule( - context: EvaluationContext[MetadataT], + context: EvaluationContext[SegmentMetadataT], rule: SegmentRule, segment_key: SupportsStr, ) -> bool: @@ -201,7 +201,7 @@ def context_matches_rule( def context_matches_condition( - context: EvaluationContext[MetadataT], + context: EvaluationContext[SegmentMetadataT], condition: SegmentCondition, segment_key: SupportsStr, ) -> bool: @@ -262,7 +262,7 @@ def context_matches_condition( def get_context_value( - context: EvaluationContext[MetadataT], + context: EvaluationContext[SegmentMetadataT], property: str, ) -> ContextValue: value = None @@ -360,7 +360,7 @@ def inner( @lru_cache def _get_context_value_getter( property: str, -) -> typing.Callable[[EvaluationContext[MetadataT]], ContextValue]: +) -> typing.Callable[[EvaluationContext[SegmentMetadataT]], ContextValue]: """ Get a function to retrieve a context value based on property value, assumed to be either a JSONPath string or a trait key. @@ -377,7 +377,7 @@ def _get_context_value_getter( f'$.identity.traits["{escape_double_quotes(property)}"]', ) - def getter(context: EvaluationContext[MetadataT]) -> ContextValue: + def getter(context: EvaluationContext[SegmentMetadataT]) -> ContextValue: if typing.TYPE_CHECKING: # pragma: no cover # Ugly hack to satisfy mypy :( data = dict(context) diff --git a/flag_engine/segments/types.py b/flag_engine/segments/types.py index d8e3fd02..118b0d37 100644 --- a/flag_engine/segments/types.py +++ b/flag_engine/segments/types.py @@ -4,7 +4,7 @@ from typing_extensions import TypeGuard, TypeVar -MetadataT = TypeVar("MetadataT", default=Dict[str, object]) +SegmentMetadataT = TypeVar("SegmentMetadataT", default=Dict[str, object]) ConditionOperator = Literal[ "EQUAL",