From 498757f9b1ea41616ab48154e0d4ec1c6c04f431 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 16 Oct 2026 19:40:23 +0200 Subject: [PATCH 01/14] perf: Fix local evaluation regressions Identity overrides segments are indexed by identifier, and each identity is evaluated with its own only, narrowed to its identifier. Evaluation cost no longer grows with the number of identity overrides. Environment flags are evaluated once per environment document. Refs #198 --- flagsmith/flagsmith.py | 59 ++++++--------- flagsmith/local_evaluation.py | 118 ++++++++++++++++++++++++++++++ flagsmith/mappers.py | 43 +++++++---- flagsmith/models.py | 7 +- tests/test_flagsmith.py | 27 +++++++ tests/test_local_evaluation.py | 127 +++++++++++++++++++++++++++++++++ 6 files changed, 331 insertions(+), 50 deletions(-) create mode 100644 flagsmith/local_evaluation.py create mode 100644 tests/test_local_evaluation.py diff --git a/flagsmith/flagsmith.py b/flagsmith/flagsmith.py index d8f34b2..1c4717d 100644 --- a/flagsmith/flagsmith.py +++ b/flagsmith/flagsmith.py @@ -15,6 +15,7 @@ EventProcessorConfig, ) from flagsmith.exceptions import FlagsmithAPIError, FlagsmithClientError +from flagsmith.local_evaluation import LocalEvaluation from flagsmith.mappers import ( map_context_and_identity_data_to_context, map_environment_document_to_context, @@ -22,14 +23,7 @@ map_segment_results_to_identity_segments, resolve_trait_values, ) -from flagsmith.models import ( - DefaultFlag, - Flag, - Flags, - Segment, - SegmentOverridesIndex, - build_segment_overrides_index, -) +from flagsmith.models import DefaultFlag, Flag, Flags, Segment from flagsmith.offline_handlers import OfflineHandler from flagsmith.polling_manager import EnvironmentDataPollingManager from flagsmith.streaming_manager import EventStreamManager @@ -136,7 +130,7 @@ def __init__( self._analytics_processor: typing.Optional[AnalyticsProcessor] = None self._event_processor: typing.Optional[EventProcessor] = None self.__evaluation_context: typing.Optional[SDKEvaluationContext] = None - self._segment_overrides_index: SegmentOverridesIndex = {} + self._local_evaluation: typing.Optional[LocalEvaluation] = None self._environment_updated_at: typing.Optional[datetime] = None # argument validation @@ -341,13 +335,15 @@ def get_identity_segments( Flagsmith, e.g. {"num_orders": 10} :return: list of Segment objects that the identity is part of. """ - if not self._evaluation_context: + if not (local_evaluation := self._local_evaluation): raise FlagsmithClientError( "Local evaluation required to obtain identity segments." ) + # Identity overrides segments are left out: they aren't segments + # in Flagsmith, so they're never returned. context = map_context_and_identity_data_to_context( - context=self._evaluation_context, + context=local_evaluation.base_context, identifier=identifier, traits=traits, ) @@ -489,17 +485,16 @@ def _evaluation_context(self) -> typing.Optional[SDKEvaluationContext]: def _evaluation_context( self, context: typing.Optional[SDKEvaluationContext] ) -> None: - """Swap in a new evaluation context and rebuild the overrides index. + """Swap in a new evaluation context and rebuild local evaluation state. - The index maps feature_name -> segments that override it. Built once - per refresh and reused across every subsequent per-identity lazy - resolution; rebuilding here keeps it in sync with the current doc - without any hot-path cost. + `LocalEvaluation` holds everything derived from the environment + document, e.g. the segment overrides index. Rebuilt once per refresh + and reused across every evaluation, without any hot-path cost. """ - self.__evaluation_context = context - self._segment_overrides_index = ( - build_segment_overrides_index(context) if context is not None else {} + self._local_evaluation = ( + LocalEvaluation(context) if context is not None else None ) + self.__evaluation_context = context def _get_headers( self, @@ -520,22 +515,15 @@ def _get_headers( return headers def _get_environment_flags_from_document(self) -> Flags: - if self._evaluation_context is None: + if (local_evaluation := self._local_evaluation) is None: raise TypeError("No environment present") - # Omit segments from evaluation context for environment flags - # as they are only relevant for identity-specific evaluations - context_without_segments = self._evaluation_context.copy() - context_without_segments.pop("segments", None) - - evaluation_result = engine.get_evaluation_result( - context=context_without_segments, - ) - - return Flags.from_evaluation_result( - evaluation_result=evaluation_result, - analytics_processor=self._analytics_processor, + # Evaluated once per environment document. `Flag` instances are + # shared across calls; the mapping holding them is not. + return Flags( + flags=dict(local_evaluation.environment_flags), default_flag_handler=self.default_flag_handler, + _analytics_processor=self._analytics_processor, ) def _get_identity_flags_from_document( @@ -543,11 +531,10 @@ def _get_identity_flags_from_document( identifier: str, traits: TraitMapping, ) -> Flags: - if self._evaluation_context is None: + if (local_evaluation := self._local_evaluation) is None: raise TypeError("No environment present") - context = map_context_and_identity_data_to_context( - context=self._evaluation_context, + context, overrides_index = local_evaluation.get_identity_context( identifier=identifier, traits=traits, ) @@ -556,7 +543,7 @@ def _get_identity_flags_from_document( # large environment. return Flags.from_evaluation_context( context=context, - overrides_index=self._segment_overrides_index, + overrides_index=overrides_index, analytics_processor=self._analytics_processor, default_flag_handler=self.default_flag_handler, ) diff --git a/flagsmith/local_evaluation.py b/flagsmith/local_evaluation.py new file mode 100644 index 0000000..08ee3ac --- /dev/null +++ b/flagsmith/local_evaluation.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import typing +from collections import ChainMap +from functools import cached_property + +from flag_engine import engine +from flag_engine.context.types import SegmentContext + +from flagsmith.mappers import ( + get_identity_overrides_identifiers, + map_context_and_identity_data_to_context, + map_identifiers_to_identity_overrides_rules, +) +from flagsmith.models import ( + Flag, + SegmentOverridesIndex, + SegmentOverridesMapping, + build_segment_overrides_index, +) +from flagsmith.types import ( + FeatureMetadata, + SDKEvaluationContext, + SegmentMetadata, + TraitMapping, +) + + +class LocalEvaluation: + """Per-environment-document state for local evaluation. + + Built once each time the environment document is refreshed, so the + work here is amortised across every evaluation until the next refresh. + + Identity overrides are mapped to segments matching the identifiers + they apply to. Left in the context, every one of them would be + matched on every identity evaluation. Instead, they're indexed by + identifier, and each identity is evaluated with only its own. + """ + + def __init__(self, context: SDKEvaluationContext) -> None: + self.context = context + + segments: typing.Dict[ + str, SegmentContext[SegmentMetadata, FeatureMetadata] + ] = {} + self._identity_overrides_segment_keys: typing.Dict[str, typing.List[str]] = {} + for segment_key, segment_context in (context.get("segments") or {}).items(): + identifiers = get_identity_overrides_identifiers(segment_context) + if identifiers is None: + segments[segment_key] = segment_context + continue + for identifier in identifiers: + self._identity_overrides_segment_keys.setdefault( + identifier, [] + ).append(segment_key) + + #: The context without identity overrides segments. + self.base_context: SDKEvaluationContext = {**context, "segments": segments} + self.overrides_index: SegmentOverridesIndex = build_segment_overrides_index( + self.base_context + ) + + @cached_property + def environment_flags(self) -> typing.Dict[str, Flag]: + """Environment flags, which depend on the environment document only.""" + # Omit segments from evaluation context for environment flags + # as they are only relevant for identity-specific evaluations + context_without_segments = self.context.copy() + context_without_segments.pop("segments", None) + evaluation_result = engine.get_evaluation_result( + context=context_without_segments, + ) + return { + feature_name: Flag.from_evaluation_result(flag_result) + for feature_name, flag_result in evaluation_result["flags"].items() + } + + def get_identity_context( + self, + identifier: str, + traits: typing.Optional[TraitMapping] = None, + ) -> typing.Tuple[SDKEvaluationContext, SegmentOverridesMapping]: + """Evaluation context and overrides index for an identity. + + The context carries the identity's own identity overrides segments, + narrowed to its identifier, so that their size doesn't depend on how + many identities share the same overrides. + """ + context = map_context_and_identity_data_to_context( + context=self.base_context, + identifier=identifier, + traits=traits, + ) + if not (segment_keys := self._identity_overrides_segment_keys.get(identifier)): + return context, self.overrides_index + + identity_segments: typing.Dict[ + str, SegmentContext[SegmentMetadata, FeatureMetadata] + ] = {} + identity_overrides_index: SegmentOverridesIndex = {} + segment_contexts = self.context.get("segments") or {} + for segment_key in segment_keys: + segment_context: SegmentContext[SegmentMetadata, FeatureMetadata] = { + **segment_contexts[segment_key], + "rules": map_identifiers_to_identity_overrides_rules([identifier]), + } + identity_segments[segment_key] = segment_context + for override in segment_context.get("overrides") or (): + feature_name = override["name"] + if feature_name not in identity_overrides_index: + identity_overrides_index[feature_name] = dict( + self.overrides_index.get(feature_name) or {} + ) + identity_overrides_index[feature_name][segment_key] = segment_context + + context["segments"] = {**(context.get("segments") or {}), **identity_segments} + return context, ChainMap(identity_overrides_index, self.overrides_index) diff --git a/flagsmith/mappers.py b/flagsmith/mappers.py index 091cf3e..73d8454 100644 --- a/flagsmith/mappers.py +++ b/flagsmith/mappers.py @@ -179,18 +179,7 @@ def _map_identity_overrides_to_segments( segment_contexts[segment_key] = SegmentContext( key=segment_key, name="identity_overrides", - rules=[ - { - "type": "ALL", - "conditions": [ - { - "property": "$.identity.identifier", - "operator": "IN", - "value": identifiers, - } - ], - } - ], + rules=map_identifiers_to_identity_overrides_rules(identifiers), overrides=[ { "key": "", # Identity overrides never carry multivariate options @@ -207,6 +196,36 @@ def _map_identity_overrides_to_segments( return segment_contexts +def map_identifiers_to_identity_overrides_rules( + identifiers: typing.List[str], +) -> typing.List[SegmentRule]: + return [ + { + "type": "ALL", + "conditions": [ + { + "property": "$.identity.identifier", + "operator": "IN", + "value": identifiers, + } + ], + } + ] + + +def get_identity_overrides_identifiers( + segment_context: SegmentContext[SegmentMetadata, FeatureMetadata], +) -> typing.Optional[typing.List[str]]: + """Identifiers an identity-overrides segment applies to, or `None` for + any other segment.""" + if (segment_context.get("metadata") or {}).get("source") != "identity_overrides": + return None + # As built by `map_identifiers_to_identity_overrides_rules`. + [rule] = segment_context["rules"] + [condition] = rule["conditions"] + return typing.cast(typing.List[str], condition["value"]) + + def _map_environment_document_rules_to_context_rules( rules: list[SegmentRuleModel], ) -> list[SegmentRule]: diff --git a/flagsmith/models.py b/flagsmith/models.py index d29f86c..08fa0ce 100644 --- a/flagsmith/models.py +++ b/flagsmith/models.py @@ -19,6 +19,9 @@ SegmentOverridesIndex = typing.Dict[ str, typing.Dict[str, SegmentContext[SegmentMetadata, FeatureMetadata]] ] +SegmentOverridesMapping = typing.Mapping[ + str, typing.Dict[str, SegmentContext[SegmentMetadata, FeatureMetadata]] +] def build_segment_overrides_index( @@ -126,7 +129,7 @@ class Flags: # cached back into `flags`. Left as `None` by the eager code # paths (`from_evaluation_result` / `from_api_flags`). _context: typing.Optional[SDKEvaluationContext] = None - _overrides_index: typing.Optional[SegmentOverridesIndex] = None + _overrides_index: typing.Optional[SegmentOverridesMapping] = None _fully_materialised: bool = False @classmethod @@ -150,7 +153,7 @@ def from_evaluation_result( def from_evaluation_context( cls, context: SDKEvaluationContext, - overrides_index: SegmentOverridesIndex, + overrides_index: SegmentOverridesMapping, analytics_processor: typing.Optional[AnalyticsProcessor], default_flag_handler: typing.Optional[typing.Callable[[str], DefaultFlag]], ) -> Flags: diff --git a/tests/test_flagsmith.py b/tests/test_flagsmith.py index 9296feb..266d457 100644 --- a/tests/test_flagsmith.py +++ b/tests/test_flagsmith.py @@ -134,6 +134,33 @@ def test_get_environment_flags_omits_segments_from_evaluation_context( mock_get_evaluation_result.assert_called_once_with(context=context_without_segments) +def test_get_environment_flags__local_evaluation__evaluates_once_per_environment( + mocker: MockerFixture, + local_eval_flagsmith: Flagsmith, + evaluation_context: SDKEvaluationContext, +) -> None: + # Given + spy = mocker.spy(engine, "get_evaluation_result") + + # When + first = local_eval_flagsmith.get_environment_flags() + second = local_eval_flagsmith.get_environment_flags() + + # Then + # the environment is evaluated once, each call getting its own mapping + assert spy.call_count == 1 + assert first.flags == second.flags + assert first.flags is not second.flags + + # When + # the environment is refreshed + local_eval_flagsmith._evaluation_context = evaluation_context + local_eval_flagsmith.get_environment_flags() + + # Then + assert spy.call_count == 2 + + @responses.activate() def test_get_identity_flags_calls_api_when_no_local_environment_no_traits( flagsmith: Flagsmith, identities_json: str diff --git a/tests/test_local_evaluation.py b/tests/test_local_evaluation.py new file mode 100644 index 0000000..e67feaa --- /dev/null +++ b/tests/test_local_evaluation.py @@ -0,0 +1,127 @@ +import copy + +import pytest +from flag_engine import engine +from pytest_mock import MockerFixture + +from flagsmith.api.types import EnvironmentModel +from flagsmith.local_evaluation import LocalEvaluation +from flagsmith.mappers import map_environment_document_to_context + + +@pytest.fixture() +def local_evaluation(environment: EnvironmentModel) -> LocalEvaluation: + """From data/environment.json, plus an identity sharing the overrides of + `overridden-id`, so both map to the same identity overrides segment.""" + environment = copy.deepcopy(environment) + sharing_identity = copy.deepcopy(environment["identity_overrides"][0]) + sharing_identity["identifier"] = "sharing-overridden-id" + environment["identity_overrides"].append(sharing_identity) + return LocalEvaluation(map_environment_document_to_context(environment)) + + +def test_local_evaluation__base_context__excludes_identity_overrides_segments( + local_evaluation: LocalEvaluation, +) -> None: + # When + base_segments = local_evaluation.base_context["segments"] + + # Then + assert base_segments is not None + assert [segment["name"] for segment in base_segments.values()] == ["Test segment"] + assert local_evaluation.overrides_index == {} + + +def test_local_evaluation__get_identity_context__no_identity_overrides__returns_base( + local_evaluation: LocalEvaluation, +) -> None: + # When + context, overrides_index = local_evaluation.get_identity_context( + "not-overridden-id", {"foo": "bar"} + ) + + # Then + assert context["segments"] is local_evaluation.base_context["segments"] + assert overrides_index is local_evaluation.overrides_index + assert context["identity"] == { + "identifier": "not-overridden-id", + "traits": {"foo": "bar"}, + } + + +@pytest.mark.parametrize( + "identifier", ["overridden-id", "sharing-overridden-id", "another-overridden-id"] +) +def test_local_evaluation__get_identity_context__identity_overrides__returns_own_narrowed( + local_evaluation: LocalEvaluation, + identifier: str, +) -> None: + # When + context, overrides_index = local_evaluation.get_identity_context(identifier) + + # Then + # only the identity's own identity overrides segment is added, + # matching its identifier alone + assert context["segments"] is not None + identity_segments = [ + segment + for segment in context["segments"].values() + if segment["name"] == "identity_overrides" + ] + assert len(identity_segments) == 1 + [segment] = identity_segments + assert segment["rules"][0]["conditions"] == [ + { + "property": "$.identity.identifier", + "operator": "IN", + "value": [identifier], + } + ] + assert list(overrides_index) == ["some_feature"] + assert list(overrides_index["some_feature"].values()) == [segment] + + # And the shared state is left untouched + assert local_evaluation.overrides_index == {} + assert local_evaluation.base_context["segments"] is not None + assert len(local_evaluation.base_context["segments"]) == 1 + + +@pytest.mark.parametrize( + "identifier, expected_value", + [ + ("overridden-id", "some-overridden-value"), + ("sharing-overridden-id", "some-overridden-value"), + ("another-overridden-id", "another-overridden-value"), + ("not-overridden-id", "some-value"), + ], +) +def test_local_evaluation__get_identity_context__evaluates_to_identity_overrides( + local_evaluation: LocalEvaluation, + identifier: str, + expected_value: str, +) -> None: + # Given + context, _ = local_evaluation.get_identity_context(identifier) + + # When + result = engine.get_evaluation_result(context) + + # Then + assert result["flags"]["some_feature"]["value"] == expected_value + + +def test_local_evaluation__environment_flags__evaluated_once( + local_evaluation: LocalEvaluation, + mocker: MockerFixture, +) -> None: + # Given + spy = mocker.spy(engine, "get_evaluation_result") + + # When + first = local_evaluation.environment_flags + second = local_evaluation.environment_flags + + # Then + assert first is second + assert first["some_feature"].value == "some-value" + spy.assert_called_once() From 32392d1b05ebe621474ca3668b7472972ec83975 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:40:37 +0000 Subject: [PATCH 02/14] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- flagsmith/local_evaluation.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/flagsmith/local_evaluation.py b/flagsmith/local_evaluation.py index 08ee3ac..b208211 100644 --- a/flagsmith/local_evaluation.py +++ b/flagsmith/local_evaluation.py @@ -41,9 +41,9 @@ class LocalEvaluation: def __init__(self, context: SDKEvaluationContext) -> None: self.context = context - segments: typing.Dict[ - str, SegmentContext[SegmentMetadata, FeatureMetadata] - ] = {} + segments: typing.Dict[str, SegmentContext[SegmentMetadata, FeatureMetadata]] = ( + {} + ) self._identity_overrides_segment_keys: typing.Dict[str, typing.List[str]] = {} for segment_key, segment_context in (context.get("segments") or {}).items(): identifiers = get_identity_overrides_identifiers(segment_context) @@ -51,9 +51,9 @@ def __init__(self, context: SDKEvaluationContext) -> None: segments[segment_key] = segment_context continue for identifier in identifiers: - self._identity_overrides_segment_keys.setdefault( - identifier, [] - ).append(segment_key) + self._identity_overrides_segment_keys.setdefault(identifier, []).append( + segment_key + ) #: The context without identity overrides segments. self.base_context: SDKEvaluationContext = {**context, "segments": segments} From 7b72b4cd853e029267f43c94bbec4d90a0b5ec2f Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Mon, 19 Oct 2026 12:28:50 +0200 Subject: [PATCH 03/14] refactor: Use one segment overrides index type `SegmentOverridesIndex` is now the read-only mapping consumers take, so `SegmentOverridesMapping` goes. Indexes are built as dicts of a new `SegmentContexts` alias, which also shortens repeated annotations. --- flagsmith/local_evaluation.py | 18 +++++++----------- flagsmith/models.py | 18 ++++++++---------- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/flagsmith/local_evaluation.py b/flagsmith/local_evaluation.py index b208211..73cdcdf 100644 --- a/flagsmith/local_evaluation.py +++ b/flagsmith/local_evaluation.py @@ -14,8 +14,8 @@ ) from flagsmith.models import ( Flag, + SegmentContexts, SegmentOverridesIndex, - SegmentOverridesMapping, build_segment_overrides_index, ) from flagsmith.types import ( @@ -41,9 +41,7 @@ class LocalEvaluation: def __init__(self, context: SDKEvaluationContext) -> None: self.context = context - segments: typing.Dict[str, SegmentContext[SegmentMetadata, FeatureMetadata]] = ( - {} - ) + segments: SegmentContexts = {} self._identity_overrides_segment_keys: typing.Dict[str, typing.List[str]] = {} for segment_key, segment_context in (context.get("segments") or {}).items(): identifiers = get_identity_overrides_identifiers(segment_context) @@ -57,8 +55,8 @@ def __init__(self, context: SDKEvaluationContext) -> None: #: The context without identity overrides segments. self.base_context: SDKEvaluationContext = {**context, "segments": segments} - self.overrides_index: SegmentOverridesIndex = build_segment_overrides_index( - self.base_context + self.overrides_index: typing.Dict[str, SegmentContexts] = ( + build_segment_overrides_index(self.base_context) ) @cached_property @@ -80,7 +78,7 @@ def get_identity_context( self, identifier: str, traits: typing.Optional[TraitMapping] = None, - ) -> typing.Tuple[SDKEvaluationContext, SegmentOverridesMapping]: + ) -> typing.Tuple[SDKEvaluationContext, SegmentOverridesIndex]: """Evaluation context and overrides index for an identity. The context carries the identity's own identity overrides segments, @@ -95,10 +93,8 @@ def get_identity_context( if not (segment_keys := self._identity_overrides_segment_keys.get(identifier)): return context, self.overrides_index - identity_segments: typing.Dict[ - str, SegmentContext[SegmentMetadata, FeatureMetadata] - ] = {} - identity_overrides_index: SegmentOverridesIndex = {} + identity_segments: SegmentContexts = {} + identity_overrides_index: typing.Dict[str, SegmentContexts] = {} segment_contexts = self.context.get("segments") or {} for segment_key in segment_keys: segment_context: SegmentContext[SegmentMetadata, FeatureMetadata] = { diff --git a/flagsmith/models.py b/flagsmith/models.py index 08fa0ce..4208d02 100644 --- a/flagsmith/models.py +++ b/flagsmith/models.py @@ -16,17 +16,15 @@ SegmentMetadata, ) -SegmentOverridesIndex = typing.Dict[ - str, typing.Dict[str, SegmentContext[SegmentMetadata, FeatureMetadata]] -] -SegmentOverridesMapping = typing.Mapping[ - str, typing.Dict[str, SegmentContext[SegmentMetadata, FeatureMetadata]] -] +#: Segment contexts by their key in the evaluation context. +SegmentContexts = typing.Dict[str, SegmentContext[SegmentMetadata, FeatureMetadata]] +#: Feature name -> segment contexts overriding that feature. +SegmentOverridesIndex = typing.Mapping[str, SegmentContexts] def build_segment_overrides_index( context: SDKEvaluationContext, -) -> SegmentOverridesIndex: +) -> typing.Dict[str, SegmentContexts]: """Map feature_name -> segments that carry an override for that feature. Computed once per environment-document refresh so the lazy eval path @@ -37,7 +35,7 @@ def build_segment_overrides_index( the environment. Re-keying them by anything else risks collisions silently dropping segments. """ - index: SegmentOverridesIndex = {} + index: typing.Dict[str, SegmentContexts] = {} for segment_key, segment_context in (context.get("segments") or {}).items(): for override in segment_context.get("overrides") or (): index.setdefault(override["name"], {})[segment_key] = segment_context @@ -129,7 +127,7 @@ class Flags: # cached back into `flags`. Left as `None` by the eager code # paths (`from_evaluation_result` / `from_api_flags`). _context: typing.Optional[SDKEvaluationContext] = None - _overrides_index: typing.Optional[SegmentOverridesMapping] = None + _overrides_index: typing.Optional[SegmentOverridesIndex] = None _fully_materialised: bool = False @classmethod @@ -153,7 +151,7 @@ def from_evaluation_result( def from_evaluation_context( cls, context: SDKEvaluationContext, - overrides_index: SegmentOverridesMapping, + overrides_index: SegmentOverridesIndex, analytics_processor: typing.Optional[AnalyticsProcessor], default_flag_handler: typing.Optional[typing.Callable[[str], DefaultFlag]], ) -> Flags: From e6afb079d147385cc4ce66585116a9c89c8ebe6b Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Mon, 19 Oct 2026 12:31:48 +0200 Subject: [PATCH 04/14] refactor: Type segment overrides indexes as mutable mappings One type for every index, built or read: `dict` and the per-identity `ChainMap` are both mutable mappings. --- flagsmith/local_evaluation.py | 6 +++--- flagsmith/models.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/flagsmith/local_evaluation.py b/flagsmith/local_evaluation.py index 73cdcdf..c91eec4 100644 --- a/flagsmith/local_evaluation.py +++ b/flagsmith/local_evaluation.py @@ -55,8 +55,8 @@ def __init__(self, context: SDKEvaluationContext) -> None: #: The context without identity overrides segments. self.base_context: SDKEvaluationContext = {**context, "segments": segments} - self.overrides_index: typing.Dict[str, SegmentContexts] = ( - build_segment_overrides_index(self.base_context) + self.overrides_index: SegmentOverridesIndex = build_segment_overrides_index( + self.base_context ) @cached_property @@ -94,7 +94,7 @@ def get_identity_context( return context, self.overrides_index identity_segments: SegmentContexts = {} - identity_overrides_index: typing.Dict[str, SegmentContexts] = {} + identity_overrides_index: SegmentOverridesIndex = {} segment_contexts = self.context.get("segments") or {} for segment_key in segment_keys: segment_context: SegmentContext[SegmentMetadata, FeatureMetadata] = { diff --git a/flagsmith/models.py b/flagsmith/models.py index 4208d02..b1b5ded 100644 --- a/flagsmith/models.py +++ b/flagsmith/models.py @@ -19,12 +19,12 @@ #: Segment contexts by their key in the evaluation context. SegmentContexts = typing.Dict[str, SegmentContext[SegmentMetadata, FeatureMetadata]] #: Feature name -> segment contexts overriding that feature. -SegmentOverridesIndex = typing.Mapping[str, SegmentContexts] +SegmentOverridesIndex = typing.MutableMapping[str, SegmentContexts] def build_segment_overrides_index( context: SDKEvaluationContext, -) -> typing.Dict[str, SegmentContexts]: +) -> SegmentOverridesIndex: """Map feature_name -> segments that carry an override for that feature. Computed once per environment-document refresh so the lazy eval path @@ -35,7 +35,7 @@ def build_segment_overrides_index( the environment. Re-keying them by anything else risks collisions silently dropping segments. """ - index: typing.Dict[str, SegmentContexts] = {} + index: SegmentOverridesIndex = {} for segment_key, segment_context in (context.get("segments") or {}).items(): for override in segment_context.get("overrides") or (): index.setdefault(override["name"], {})[segment_key] = segment_context From 620a1dca843a1b3482aac730c8164b86eab1abaa Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 20 Oct 2026 11:19:04 +0200 Subject: [PATCH 05/14] style: Use plain comments --- flagsmith/local_evaluation.py | 2 +- flagsmith/models.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/flagsmith/local_evaluation.py b/flagsmith/local_evaluation.py index c91eec4..b6ee9c9 100644 --- a/flagsmith/local_evaluation.py +++ b/flagsmith/local_evaluation.py @@ -53,7 +53,7 @@ def __init__(self, context: SDKEvaluationContext) -> None: segment_key ) - #: The context without identity overrides segments. + # The context without identity overrides segments. self.base_context: SDKEvaluationContext = {**context, "segments": segments} self.overrides_index: SegmentOverridesIndex = build_segment_overrides_index( self.base_context diff --git a/flagsmith/models.py b/flagsmith/models.py index b1b5ded..7f3e9f7 100644 --- a/flagsmith/models.py +++ b/flagsmith/models.py @@ -16,9 +16,9 @@ SegmentMetadata, ) -#: Segment contexts by their key in the evaluation context. +# Segment contexts by their key in the evaluation context. SegmentContexts = typing.Dict[str, SegmentContext[SegmentMetadata, FeatureMetadata]] -#: Feature name -> segment contexts overriding that feature. +# Feature name -> segment contexts overriding that feature. SegmentOverridesIndex = typing.MutableMapping[str, SegmentContexts] From 8680c94196d0bebb373f484e3fb949a07eba0fa4 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 20 Oct 2026 16:35:15 +0200 Subject: [PATCH 06/14] refactor: Make `LocalEvaluation.context` the source of truth The client kept the evaluation context next to the `LocalEvaluation` built from it. It now keeps the `LocalEvaluation` only, whose `context` is the context without identity overrides segments, formerly `base_context`. Identity overrides segments are kept apart, by key. Also drops name mangling, as `__evaluation_context` goes. --- flagsmith/flagsmith.py | 42 ++++++++++----------------------- flagsmith/local_evaluation.py | 13 +++++----- tests/test_flagsmith.py | 15 +++++++----- tests/test_local_evaluation.py | 8 +++---- tests/test_streaming_manager.py | 6 ++--- 5 files changed, 34 insertions(+), 50 deletions(-) diff --git a/flagsmith/flagsmith.py b/flagsmith/flagsmith.py index 1c4717d..9dbe709 100644 --- a/flagsmith/flagsmith.py +++ b/flagsmith/flagsmith.py @@ -30,7 +30,6 @@ from flagsmith.types import ( ApplicationMetadata, JsonType, - SDKEvaluationContext, StreamEvent, TraitMapping, ) @@ -129,7 +128,6 @@ def __init__( self.enable_realtime_updates = enable_realtime_updates self._analytics_processor: typing.Optional[AnalyticsProcessor] = None self._event_processor: typing.Optional[EventProcessor] = None - self.__evaluation_context: typing.Optional[SDKEvaluationContext] = None self._local_evaluation: typing.Optional[LocalEvaluation] = None self._environment_updated_at: typing.Optional[datetime] = None @@ -152,8 +150,10 @@ def __init__( ) if self.offline_handler: - self._evaluation_context = map_environment_document_to_context( - self.offline_handler.get_environment() + self._local_evaluation = LocalEvaluation( + map_environment_document_to_context( + self.offline_handler.get_environment() + ) ) if not self.offline_mode: @@ -242,12 +242,13 @@ def _initialise_local_evaluation(self) -> None: # method calls, update the environment manually. self.update_environment() if self.enable_realtime_updates: - if not self._evaluation_context: + if not self._local_evaluation: raise ValueError("Unable to get environment from API key") + environment_key = self._local_evaluation.context["environment"]["key"] stream_url = urljoin( self.realtime_api_url, - f"sse/environments/{self._evaluation_context['environment']['key']}/stream", + f"sse/environments/{environment_key}/stream", ) self.event_stream_thread = EventStreamManager( @@ -285,7 +286,7 @@ def get_environment_flags(self) -> Flags: """ if ( self.offline_mode or self.enable_local_evaluation - ) and self._evaluation_context: + ) and self._local_evaluation: return self._get_environment_flags_from_document() return self._get_environment_flags_from_api() @@ -313,7 +314,7 @@ def get_identity_flags( traits = traits or {} if ( self.offline_mode or self.enable_local_evaluation - ) and self._evaluation_context: + ) and self._local_evaluation: return self._get_identity_flags_from_document(identifier, traits) return self._get_identity_flags_from_api( identifier, @@ -343,7 +344,7 @@ def get_identity_segments( # Identity overrides segments are left out: they aren't segments # in Flagsmith, so they're never returned. context = map_context_and_identity_data_to_context( - context=local_evaluation.base_context, + context=local_evaluation.context, identifier=identifier, traits=traits, ) @@ -466,8 +467,8 @@ def update_environment(self) -> None: logger.exception("Error retrieving environment document from API") else: try: - self._evaluation_context = map_environment_document_to_context( - environment_data, + self._local_evaluation = LocalEvaluation( + map_environment_document_to_context(environment_data) ) self._environment_updated_at = ( map_environment_document_to_environment_updated_at( @@ -477,25 +478,6 @@ def update_environment(self) -> None: except (KeyError, TypeError, ValueError): logger.exception("Error parsing environment document") - @property - def _evaluation_context(self) -> typing.Optional[SDKEvaluationContext]: - return self.__evaluation_context - - @_evaluation_context.setter - def _evaluation_context( - self, context: typing.Optional[SDKEvaluationContext] - ) -> None: - """Swap in a new evaluation context and rebuild local evaluation state. - - `LocalEvaluation` holds everything derived from the environment - document, e.g. the segment overrides index. Rebuilt once per refresh - and reused across every evaluation, without any hot-path cost. - """ - self._local_evaluation = ( - LocalEvaluation(context) if context is not None else None - ) - self.__evaluation_context = context - def _get_headers( self, environment_key: str, diff --git a/flagsmith/local_evaluation.py b/flagsmith/local_evaluation.py index b6ee9c9..83f0629 100644 --- a/flagsmith/local_evaluation.py +++ b/flagsmith/local_evaluation.py @@ -39,24 +39,24 @@ class LocalEvaluation: """ def __init__(self, context: SDKEvaluationContext) -> None: - self.context = context - segments: SegmentContexts = {} + self._identity_overrides_segments: SegmentContexts = {} self._identity_overrides_segment_keys: typing.Dict[str, typing.List[str]] = {} for segment_key, segment_context in (context.get("segments") or {}).items(): identifiers = get_identity_overrides_identifiers(segment_context) if identifiers is None: segments[segment_key] = segment_context continue + self._identity_overrides_segments[segment_key] = segment_context for identifier in identifiers: self._identity_overrides_segment_keys.setdefault(identifier, []).append( segment_key ) # The context without identity overrides segments. - self.base_context: SDKEvaluationContext = {**context, "segments": segments} + self.context: SDKEvaluationContext = {**context, "segments": segments} self.overrides_index: SegmentOverridesIndex = build_segment_overrides_index( - self.base_context + self.context ) @cached_property @@ -86,7 +86,7 @@ def get_identity_context( many identities share the same overrides. """ context = map_context_and_identity_data_to_context( - context=self.base_context, + context=self.context, identifier=identifier, traits=traits, ) @@ -95,10 +95,9 @@ def get_identity_context( identity_segments: SegmentContexts = {} identity_overrides_index: SegmentOverridesIndex = {} - segment_contexts = self.context.get("segments") or {} for segment_key in segment_keys: segment_context: SegmentContext[SegmentMetadata, FeatureMetadata] = { - **segment_contexts[segment_key], + **self._identity_overrides_segments[segment_key], "rules": map_identifiers_to_identity_overrides_rules([identifier]), } identity_segments[segment_key] = segment_context diff --git a/tests/test_flagsmith.py b/tests/test_flagsmith.py index 266d457..c68824f 100644 --- a/tests/test_flagsmith.py +++ b/tests/test_flagsmith.py @@ -17,6 +17,7 @@ FlagsmithAPIError, FlagsmithFeatureDoesNotExistError, ) +from flagsmith.local_evaluation import LocalEvaluation from flagsmith.models import DefaultFlag, ExperimentMetadata, Flag, Flags from flagsmith.offline_handlers import OfflineHandler from flagsmith.types import SDKEvaluationContext @@ -47,14 +48,16 @@ def test_update_environment_sets_environment( ) -> None: # Given responses.add(method="GET", url=flagsmith.environment_url, body=environment_json) - assert flagsmith._evaluation_context is None + assert flagsmith._local_evaluation is None # When flagsmith.update_environment() # Then - assert flagsmith._evaluation_context is not None - assert flagsmith._evaluation_context == evaluation_context + assert flagsmith._local_evaluation is not None + assert flagsmith._local_evaluation.context == ( + LocalEvaluation(evaluation_context).context + ) @responses.activate() @@ -84,7 +87,7 @@ def test_get_environment_flags_uses_local_environment_when_available( evaluation_context: SDKEvaluationContext, ) -> None: # Given - flagsmith._evaluation_context = evaluation_context + flagsmith._local_evaluation = LocalEvaluation(evaluation_context) flagsmith.enable_local_evaluation = True # When @@ -154,7 +157,7 @@ def test_get_environment_flags__local_evaluation__evaluates_once_per_environment # When # the environment is refreshed - local_eval_flagsmith._evaluation_context = evaluation_context + local_eval_flagsmith._local_evaluation = LocalEvaluation(evaluation_context) local_eval_flagsmith.get_environment_flags() # Then @@ -223,7 +226,7 @@ def test_get_identity_flags_uses_local_environment_when_available( mocker: MockerFixture, ) -> None: # Given - flagsmith._evaluation_context = evaluation_context + flagsmith._local_evaluation = LocalEvaluation(evaluation_context) flagsmith.enable_local_evaluation = True # `Flags` materialises identity flags via `engine.get_evaluation_result` # imported from `flagsmith.models`, so patch it where it's actually used. diff --git a/tests/test_local_evaluation.py b/tests/test_local_evaluation.py index e67feaa..8b68a6f 100644 --- a/tests/test_local_evaluation.py +++ b/tests/test_local_evaluation.py @@ -24,7 +24,7 @@ def test_local_evaluation__base_context__excludes_identity_overrides_segments( local_evaluation: LocalEvaluation, ) -> None: # When - base_segments = local_evaluation.base_context["segments"] + base_segments = local_evaluation.context["segments"] # Then assert base_segments is not None @@ -41,7 +41,7 @@ def test_local_evaluation__get_identity_context__no_identity_overrides__returns_ ) # Then - assert context["segments"] is local_evaluation.base_context["segments"] + assert context["segments"] is local_evaluation.context["segments"] assert overrides_index is local_evaluation.overrides_index assert context["identity"] == { "identifier": "not-overridden-id", @@ -82,8 +82,8 @@ def test_local_evaluation__get_identity_context__identity_overrides__returns_own # And the shared state is left untouched assert local_evaluation.overrides_index == {} - assert local_evaluation.base_context["segments"] is not None - assert len(local_evaluation.base_context["segments"]) == 1 + assert local_evaluation.context["segments"] is not None + assert len(local_evaluation.context["segments"]) == 1 @pytest.mark.parametrize( diff --git a/tests/test_streaming_manager.py b/tests/test_streaming_manager.py index 9b2c249..6ae2c08 100644 --- a/tests/test_streaming_manager.py +++ b/tests/test_streaming_manager.py @@ -44,7 +44,7 @@ def test_environment_updates_on_recent_event( mocker.patch("flagsmith.Flagsmith.update_environment") flagsmith = Flagsmith(environment_key=server_api_key) - flagsmith._evaluation_context = MagicMock() + flagsmith._local_evaluation = MagicMock() flagsmith._environment_updated_at = environment_updated_at flagsmith.handle_stream_event(event=StreamEvent(updated_at=stream_updated_at)) assert isinstance(flagsmith.update_environment, Mock) @@ -60,7 +60,7 @@ def test_environment_does_not_update_on_past_event( mocker.patch("flagsmith.Flagsmith.update_environment") flagsmith = Flagsmith(environment_key=server_api_key) - flagsmith._evaluation_context = MagicMock() + flagsmith._local_evaluation = MagicMock() flagsmith._environment_updated_at = environment_updated_at flagsmith.handle_stream_event(event=StreamEvent(updated_at=stream_updated_at)) @@ -77,7 +77,7 @@ def test_environment_does_not_update_on_same_event( mocker.patch("flagsmith.Flagsmith.update_environment") flagsmith = Flagsmith(environment_key=server_api_key) - flagsmith._evaluation_context = MagicMock() + flagsmith._local_evaluation = MagicMock() flagsmith._environment_updated_at = environment_updated_at flagsmith.handle_stream_event(event=StreamEvent(updated_at=stream_updated_at)) From 25617c1f5358fd6af9234c5248d94c2e7e9fc16a Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 20 Oct 2026 16:38:34 +0200 Subject: [PATCH 07/14] refactor: Map identifiers to a single identity overrides rule `map_identifiers_to_identity_overrides_rules` built a single rule, so it is now `map_identifiers_to_identity_overrides_rule`, returning that rule. --- flagsmith/local_evaluation.py | 4 ++-- flagsmith/mappers.py | 30 ++++++++++++++---------------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/flagsmith/local_evaluation.py b/flagsmith/local_evaluation.py index 83f0629..fce5fa2 100644 --- a/flagsmith/local_evaluation.py +++ b/flagsmith/local_evaluation.py @@ -10,7 +10,7 @@ from flagsmith.mappers import ( get_identity_overrides_identifiers, map_context_and_identity_data_to_context, - map_identifiers_to_identity_overrides_rules, + map_identifiers_to_identity_overrides_rule, ) from flagsmith.models import ( Flag, @@ -98,7 +98,7 @@ def get_identity_context( for segment_key in segment_keys: segment_context: SegmentContext[SegmentMetadata, FeatureMetadata] = { **self._identity_overrides_segments[segment_key], - "rules": map_identifiers_to_identity_overrides_rules([identifier]), + "rules": [map_identifiers_to_identity_overrides_rule([identifier])], } identity_segments[segment_key] = segment_context for override in segment_context.get("overrides") or (): diff --git a/flagsmith/mappers.py b/flagsmith/mappers.py index 73d8454..9925b5e 100644 --- a/flagsmith/mappers.py +++ b/flagsmith/mappers.py @@ -179,7 +179,7 @@ def _map_identity_overrides_to_segments( segment_contexts[segment_key] = SegmentContext( key=segment_key, name="identity_overrides", - rules=map_identifiers_to_identity_overrides_rules(identifiers), + rules=[map_identifiers_to_identity_overrides_rule(identifiers)], overrides=[ { "key": "", # Identity overrides never carry multivariate options @@ -196,21 +196,19 @@ def _map_identity_overrides_to_segments( return segment_contexts -def map_identifiers_to_identity_overrides_rules( +def map_identifiers_to_identity_overrides_rule( identifiers: typing.List[str], -) -> typing.List[SegmentRule]: - return [ - { - "type": "ALL", - "conditions": [ - { - "property": "$.identity.identifier", - "operator": "IN", - "value": identifiers, - } - ], - } - ] +) -> SegmentRule: + return { + "type": "ALL", + "conditions": [ + { + "property": "$.identity.identifier", + "operator": "IN", + "value": identifiers, + } + ], + } def get_identity_overrides_identifiers( @@ -220,7 +218,7 @@ def get_identity_overrides_identifiers( any other segment.""" if (segment_context.get("metadata") or {}).get("source") != "identity_overrides": return None - # As built by `map_identifiers_to_identity_overrides_rules`. + # As built by `map_identifiers_to_identity_overrides_rule`. [rule] = segment_context["rules"] [condition] = rule["conditions"] return typing.cast(typing.List[str], condition["value"]) From ed17cebfcc7389e2d4735b2cb88c2fa9cc68d633 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 20 Oct 2026 16:38:45 +0200 Subject: [PATCH 08/14] refactor: Type identity overrides conditions Identity overrides conditions are typed as `InOperatorSegmentCondition` where they are built, and identifiers are read back as such, rather than cast to a list of strings with a comment. --- flagsmith/mappers.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/flagsmith/mappers.py b/flagsmith/mappers.py index 9925b5e..176fc2a 100644 --- a/flagsmith/mappers.py +++ b/flagsmith/mappers.py @@ -8,6 +8,7 @@ from flag_engine.context.types import ( FeatureContext, FeatureValue, + InOperatorSegmentCondition, SegmentContext, SegmentRule, StrValueSegmentCondition, @@ -199,16 +200,12 @@ def _map_identity_overrides_to_segments( def map_identifiers_to_identity_overrides_rule( identifiers: typing.List[str], ) -> SegmentRule: - return { - "type": "ALL", - "conditions": [ - { - "property": "$.identity.identifier", - "operator": "IN", - "value": identifiers, - } - ], + condition: InOperatorSegmentCondition = { + "property": "$.identity.identifier", + "operator": "IN", + "value": identifiers, } + return {"type": "ALL", "conditions": [condition]} def get_identity_overrides_identifiers( @@ -218,10 +215,9 @@ def get_identity_overrides_identifiers( any other segment.""" if (segment_context.get("metadata") or {}).get("source") != "identity_overrides": return None - # As built by `map_identifiers_to_identity_overrides_rule`. [rule] = segment_context["rules"] [condition] = rule["conditions"] - return typing.cast(typing.List[str], condition["value"]) + return typing.cast(InOperatorSegmentCondition, condition)["value"] def _map_environment_document_rules_to_context_rules( From 2900a6e50b52b5076c21980db52e2808b7f1e7d1 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 20 Oct 2026 16:35:51 +0200 Subject: [PATCH 09/14] refactor: Name segment overrides index keys with type aliases Replaces comments explaining what the keys are. --- flagsmith/models.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/flagsmith/models.py b/flagsmith/models.py index 7f3e9f7..d4880cf 100644 --- a/flagsmith/models.py +++ b/flagsmith/models.py @@ -16,10 +16,12 @@ SegmentMetadata, ) -# Segment contexts by their key in the evaluation context. -SegmentContexts = typing.Dict[str, SegmentContext[SegmentMetadata, FeatureMetadata]] -# Feature name -> segment contexts overriding that feature. -SegmentOverridesIndex = typing.MutableMapping[str, SegmentContexts] +SegmentKey = str +FeatureName = str +SegmentContexts = typing.Dict[ + SegmentKey, SegmentContext[SegmentMetadata, FeatureMetadata] +] +SegmentOverridesIndex = typing.MutableMapping[FeatureName, SegmentContexts] def build_segment_overrides_index( From 029fcae4f6e00407e65b37005cb873bfe855b33a Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 20 Oct 2026 16:38:55 +0200 Subject: [PATCH 10/14] test: Split re-evaluating environment flags on refresh into its own test --- tests/test_flagsmith.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/test_flagsmith.py b/tests/test_flagsmith.py index c68824f..b3cbfdd 100644 --- a/tests/test_flagsmith.py +++ b/tests/test_flagsmith.py @@ -140,7 +140,6 @@ def test_get_environment_flags_omits_segments_from_evaluation_context( def test_get_environment_flags__local_evaluation__evaluates_once_per_environment( mocker: MockerFixture, local_eval_flagsmith: Flagsmith, - evaluation_context: SDKEvaluationContext, ) -> None: # Given spy = mocker.spy(engine, "get_evaluation_result") @@ -155,13 +154,22 @@ def test_get_environment_flags__local_evaluation__evaluates_once_per_environment assert first.flags == second.flags assert first.flags is not second.flags + +def test_get_environment_flags__environment_refreshed__evaluates_again( + mocker: MockerFixture, + local_eval_flagsmith: Flagsmith, + evaluation_context: SDKEvaluationContext, +) -> None: + # Given + local_eval_flagsmith.get_environment_flags() + spy = mocker.spy(engine, "get_evaluation_result") + # When - # the environment is refreshed local_eval_flagsmith._local_evaluation = LocalEvaluation(evaluation_context) local_eval_flagsmith.get_environment_flags() # Then - assert spy.call_count == 2 + spy.assert_called_once() @responses.activate() From ffd2d3e81d3d57c03fbea6e1108977954fa4bff1 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 20 Oct 2026 16:39:11 +0200 Subject: [PATCH 11/14] test: Name local evaluation tests with three parts Also drops `base` from test and variable names. --- tests/test_local_evaluation.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_local_evaluation.py b/tests/test_local_evaluation.py index 8b68a6f..e664ecb 100644 --- a/tests/test_local_evaluation.py +++ b/tests/test_local_evaluation.py @@ -20,19 +20,19 @@ def local_evaluation(environment: EnvironmentModel) -> LocalEvaluation: return LocalEvaluation(map_environment_document_to_context(environment)) -def test_local_evaluation__base_context__excludes_identity_overrides_segments( +def test_local_evaluation__context__excludes_identity_overrides_segments( local_evaluation: LocalEvaluation, ) -> None: # When - base_segments = local_evaluation.context["segments"] + segments = local_evaluation.context["segments"] # Then - assert base_segments is not None - assert [segment["name"] for segment in base_segments.values()] == ["Test segment"] + assert segments is not None + assert [segment["name"] for segment in segments.values()] == ["Test segment"] assert local_evaluation.overrides_index == {} -def test_local_evaluation__get_identity_context__no_identity_overrides__returns_base( +def test_local_evaluation__get_identity_context_no_identity_overrides__returns_context( local_evaluation: LocalEvaluation, ) -> None: # When @@ -52,7 +52,7 @@ def test_local_evaluation__get_identity_context__no_identity_overrides__returns_ @pytest.mark.parametrize( "identifier", ["overridden-id", "sharing-overridden-id", "another-overridden-id"] ) -def test_local_evaluation__get_identity_context__identity_overrides__returns_own_narrowed( +def test_local_evaluation__get_identity_context_identity_overrides__returns_own_narrowed( local_evaluation: LocalEvaluation, identifier: str, ) -> None: From 8fd7a17e3aa08cef076ba740eb48f8be29f583bf Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 20 Oct 2026 16:39:14 +0200 Subject: [PATCH 12/14] test: Assert identity overrides segments whole --- tests/test_local_evaluation.py | 42 ++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/tests/test_local_evaluation.py b/tests/test_local_evaluation.py index e664ecb..8e6dfef 100644 --- a/tests/test_local_evaluation.py +++ b/tests/test_local_evaluation.py @@ -1,4 +1,5 @@ import copy +from unittest import mock import pytest from flag_engine import engine @@ -50,11 +51,17 @@ def test_local_evaluation__get_identity_context_no_identity_overrides__returns_c @pytest.mark.parametrize( - "identifier", ["overridden-id", "sharing-overridden-id", "another-overridden-id"] + "identifier, expected_value", + [ + ("overridden-id", "some-overridden-value"), + ("sharing-overridden-id", "some-overridden-value"), + ("another-overridden-id", "another-overridden-value"), + ], ) def test_local_evaluation__get_identity_context_identity_overrides__returns_own_narrowed( local_evaluation: LocalEvaluation, identifier: str, + expected_value: str, ) -> None: # When context, overrides_index = local_evaluation.get_identity_context(identifier) @@ -68,15 +75,36 @@ def test_local_evaluation__get_identity_context_identity_overrides__returns_own_ for segment in context["segments"].values() if segment["name"] == "identity_overrides" ] - assert len(identity_segments) == 1 - [segment] = identity_segments - assert segment["rules"][0]["conditions"] == [ + assert identity_segments == [ { - "property": "$.identity.identifier", - "operator": "IN", - "value": [identifier], + "key": mock.ANY, + "name": "identity_overrides", + "rules": [ + { + "type": "ALL", + "conditions": [ + { + "property": "$.identity.identifier", + "operator": "IN", + "value": [identifier], + } + ], + } + ], + "overrides": [ + { + "key": "", + "name": "some_feature", + "enabled": False, + "value": expected_value, + "priority": float("-inf"), + "metadata": {"id": 1}, + } + ], + "metadata": {"source": "identity_overrides"}, } ] + [segment] = identity_segments assert list(overrides_index) == ["some_feature"] assert list(overrides_index["some_feature"].values()) == [segment] From 2276774b253c90d2eb3a533538a35dbb58e2e047 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 20 Oct 2026 17:12:02 +0200 Subject: [PATCH 13/14] perf: Map identity overrides by identifier once per environment document Identity overrides were mapped to segments, one per unique set of overrides, merged into the evaluation context, then picked back out of it by `LocalEvaluation` and narrowed to an identity on every evaluation. They are now mapped straight from the environment document to a segment per identifier, kept out of the evaluation context. `LocalEvaluation` indexes each one's overrides once per environment document, so an identity evaluation only adds its own segment. --- flagsmith/flagsmith.py | 11 +-- flagsmith/local_evaluation.py | 105 +++++++++++++------------ flagsmith/mappers.py | 135 +++++++++++---------------------- tests/test_local_evaluation.py | 16 +--- tests/test_mappers.py | 20 ----- 5 files changed, 106 insertions(+), 181 deletions(-) diff --git a/flagsmith/flagsmith.py b/flagsmith/flagsmith.py index 9dbe709..c1ceb60 100644 --- a/flagsmith/flagsmith.py +++ b/flagsmith/flagsmith.py @@ -18,7 +18,6 @@ from flagsmith.local_evaluation import LocalEvaluation from flagsmith.mappers import ( 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, resolve_trait_values, @@ -150,10 +149,8 @@ def __init__( ) if self.offline_handler: - self._local_evaluation = LocalEvaluation( - map_environment_document_to_context( - self.offline_handler.get_environment() - ) + self._local_evaluation = LocalEvaluation.from_environment_document( + self.offline_handler.get_environment() ) if not self.offline_mode: @@ -467,8 +464,8 @@ def update_environment(self) -> None: logger.exception("Error retrieving environment document from API") else: try: - self._local_evaluation = LocalEvaluation( - map_environment_document_to_context(environment_data) + self._local_evaluation = LocalEvaluation.from_environment_document( + environment_data ) self._environment_updated_at = ( map_environment_document_to_environment_updated_at( diff --git a/flagsmith/local_evaluation.py b/flagsmith/local_evaluation.py index fce5fa2..f1607f9 100644 --- a/flagsmith/local_evaluation.py +++ b/flagsmith/local_evaluation.py @@ -7,14 +7,14 @@ from flag_engine import engine from flag_engine.context.types import SegmentContext +from flagsmith.api.types import EnvironmentModel from flagsmith.mappers import ( - get_identity_overrides_identifiers, map_context_and_identity_data_to_context, - map_identifiers_to_identity_overrides_rule, + map_environment_document_to_context, + map_environment_document_to_identity_overrides_segments, ) from flagsmith.models import ( Flag, - SegmentContexts, SegmentOverridesIndex, build_segment_overrides_index, ) @@ -32,31 +32,40 @@ class LocalEvaluation: Built once each time the environment document is refreshed, so the work here is amortised across every evaluation until the next refresh. - Identity overrides are mapped to segments matching the identifiers - they apply to. Left in the context, every one of them would be - matched on every identity evaluation. Instead, they're indexed by - identifier, and each identity is evaluated with only its own. + Identity overrides are kept out of the context, by identifier. Each + identity is evaluated with its own identity overrides segment only. """ - def __init__(self, context: SDKEvaluationContext) -> None: - segments: SegmentContexts = {} - self._identity_overrides_segments: SegmentContexts = {} - self._identity_overrides_segment_keys: typing.Dict[str, typing.List[str]] = {} - for segment_key, segment_context in (context.get("segments") or {}).items(): - identifiers = get_identity_overrides_identifiers(segment_context) - if identifiers is None: - segments[segment_key] = segment_context - continue - self._identity_overrides_segments[segment_key] = segment_context - for identifier in identifiers: - self._identity_overrides_segment_keys.setdefault(identifier, []).append( - segment_key - ) - - # The context without identity overrides segments. - self.context: SDKEvaluationContext = {**context, "segments": segments} + def __init__( + self, + context: SDKEvaluationContext, + identity_overrides_segments: typing.Optional[ + typing.Mapping[str, SegmentContext[SegmentMetadata, FeatureMetadata]] + ] = None, + ) -> None: + self.context = context self.overrides_index: SegmentOverridesIndex = build_segment_overrides_index( - self.context + context + ) + self._identity_overrides = { + identifier: (segment_context, self._index_overrides(segment_context)) + for identifier, segment_context in ( + identity_overrides_segments or {} + ).items() + } + + @classmethod + def from_environment_document( + cls, + environment_document: EnvironmentModel, + ) -> LocalEvaluation: + return cls( + context=map_environment_document_to_context(environment_document), + identity_overrides_segments=( + map_environment_document_to_identity_overrides_segments( + environment_document + ) + ), ) @cached_property @@ -79,35 +88,33 @@ def get_identity_context( identifier: str, traits: typing.Optional[TraitMapping] = None, ) -> typing.Tuple[SDKEvaluationContext, SegmentOverridesIndex]: - """Evaluation context and overrides index for an identity. - - The context carries the identity's own identity overrides segments, - narrowed to its identifier, so that their size doesn't depend on how - many identities share the same overrides. - """ + """Evaluation context and overrides index for an identity, including + its own identity overrides segment, if any.""" context = map_context_and_identity_data_to_context( context=self.context, identifier=identifier, traits=traits, ) - if not (segment_keys := self._identity_overrides_segment_keys.get(identifier)): + if (identity_overrides := self._identity_overrides.get(identifier)) is None: return context, self.overrides_index - identity_segments: SegmentContexts = {} - identity_overrides_index: SegmentOverridesIndex = {} - for segment_key in segment_keys: - segment_context: SegmentContext[SegmentMetadata, FeatureMetadata] = { - **self._identity_overrides_segments[segment_key], - "rules": [map_identifiers_to_identity_overrides_rule([identifier])], - } - identity_segments[segment_key] = segment_context - for override in segment_context.get("overrides") or (): - feature_name = override["name"] - if feature_name not in identity_overrides_index: - identity_overrides_index[feature_name] = dict( - self.overrides_index.get(feature_name) or {} - ) - identity_overrides_index[feature_name][segment_key] = segment_context - - context["segments"] = {**(context.get("segments") or {}), **identity_segments} + segment_context, identity_overrides_index = identity_overrides + context["segments"] = { + **(context.get("segments") or {}), + segment_context["key"]: segment_context, + } return context, ChainMap(identity_overrides_index, self.overrides_index) + + def _index_overrides( + self, + segment_context: SegmentContext[SegmentMetadata, FeatureMetadata], + ) -> SegmentOverridesIndex: + """The overrides index, for the features `segment_context` overrides.""" + return { + feature_name: { + **(self.overrides_index.get(feature_name) or {}), + segment_context["key"]: segment_context, + } + for override in segment_context.get("overrides") or () + if (feature_name := override["name"]) + } diff --git a/flagsmith/mappers.py b/flagsmith/mappers.py index 176fc2a..1a8ca13 100644 --- a/flagsmith/mappers.py +++ b/flagsmith/mappers.py @@ -1,7 +1,6 @@ import json import typing import uuid -from collections import defaultdict from datetime import datetime, timezone import sseclient @@ -18,7 +17,6 @@ from flagsmith.api.types import ( EnvironmentModel, FeatureStateModel, - IdentityModel, SegmentRuleModel, ) from flagsmith.models import Segment @@ -31,13 +29,8 @@ ) from flagsmith.utils.datetime import fromisoformat -OverrideKey = typing.Tuple[ - int, - str, - bool, - typing.Any, -] -OverridesKey = typing.Tuple[OverrideKey, ...] +# Each identity is evaluated with its own identity overrides segment only. +IDENTITY_OVERRIDES_SEGMENT_KEY = "identity_overrides" def map_segment_results_to_identity_segments( @@ -116,85 +109,57 @@ def map_environment_document_to_context( ) }, "segments": { - **{ - (segment_key := str(segment_id := segment["id"])): { - "key": segment_key, - "name": segment["name"], - "rules": _map_environment_document_rules_to_context_rules( - segment["rules"] - ), - "overrides": list( - _map_environment_document_feature_states_to_feature_contexts( - segment.get("feature_states") or [] - ) - ), - "metadata": SegmentMetadata( - id=segment_id, - source="api", - ), - } - for segment in environment_document["project"]["segments"] - }, - **_map_identity_overrides_to_segments( - environment_document.get("identity_overrides") or [] - ), + (segment_key := str(segment_id := segment["id"])): { + "key": segment_key, + "name": segment["name"], + "rules": _map_environment_document_rules_to_context_rules( + segment["rules"] + ), + "overrides": list( + _map_environment_document_feature_states_to_feature_contexts( + segment.get("feature_states") or [] + ) + ), + "metadata": SegmentMetadata( + id=segment_id, + source="api", + ), + } + for segment in environment_document["project"]["segments"] }, } -def _map_identity_overrides_to_segments( - identity_overrides: list[IdentityModel], -) -> dict[str, SegmentContext[SegmentMetadata, FeatureMetadata]]: - features_to_identifiers: typing.Dict[ - OverridesKey, - typing.List[str], - ] = defaultdict(list) - for identity_override in identity_overrides: - identity_features = identity_override["identity_features"] - if not identity_features: - continue - overrides_key = tuple( - ( - feature_state["feature"]["id"], - feature_state["feature"]["name"], - feature_state["enabled"], - feature_state["feature_state_value"], - ) - for feature_state in sorted( - identity_features, - key=lambda feature_state: feature_state["feature"]["name"], - ) - ) - features_to_identifiers[overrides_key].append(identity_override["identifier"]) - 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 - segment_key = str(hash(overrides_key)) - segment_contexts[segment_key] = SegmentContext( - key=segment_key, - name="identity_overrides", - rules=[map_identifiers_to_identity_overrides_rule(identifiers)], - overrides=[ +def map_environment_document_to_identity_overrides_segments( + environment_document: EnvironmentModel, +) -> typing.Dict[str, SegmentContext[SegmentMetadata, FeatureMetadata]]: + """Map identity overrides to segment contexts by identifier, each matching + its identity alone and carrying its overrides.""" + return { + identity_override["identifier"]: { + "key": IDENTITY_OVERRIDES_SEGMENT_KEY, + "name": "identity_overrides", + "rules": [ + map_identifiers_to_identity_overrides_rule( + [identity_override["identifier"]] + ) + ], + "overrides": [ { "key": "", # Identity overrides never carry multivariate options - "name": feature_name, - "enabled": feature_enabled, - "value": feature_value, + "name": feature_state["feature"]["name"], + "enabled": feature_state["enabled"], + "value": feature_state["feature_state_value"], "priority": float("-inf"), # Highest possible priority - "metadata": {"id": feature_id}, + "metadata": {"id": feature_state["feature"]["id"]}, } - for feature_id, feature_name, feature_enabled, feature_value in overrides_key + for feature_state in identity_features ], - metadata=SegmentMetadata(source="identity_overrides"), - ) - return segment_contexts + "metadata": SegmentMetadata(source="identity_overrides"), + } + for identity_override in environment_document.get("identity_overrides") or [] + if (identity_features := identity_override["identity_features"]) + } def map_identifiers_to_identity_overrides_rule( @@ -208,18 +173,6 @@ def map_identifiers_to_identity_overrides_rule( return {"type": "ALL", "conditions": [condition]} -def get_identity_overrides_identifiers( - segment_context: SegmentContext[SegmentMetadata, FeatureMetadata], -) -> typing.Optional[typing.List[str]]: - """Identifiers an identity-overrides segment applies to, or `None` for - any other segment.""" - if (segment_context.get("metadata") or {}).get("source") != "identity_overrides": - return None - [rule] = segment_context["rules"] - [condition] = rule["conditions"] - return typing.cast(InOperatorSegmentCondition, condition)["value"] - - def _map_environment_document_rules_to_context_rules( rules: list[SegmentRuleModel], ) -> list[SegmentRule]: diff --git a/tests/test_local_evaluation.py b/tests/test_local_evaluation.py index 8e6dfef..96ae1ea 100644 --- a/tests/test_local_evaluation.py +++ b/tests/test_local_evaluation.py @@ -1,24 +1,14 @@ -import copy -from unittest import mock - import pytest from flag_engine import engine from pytest_mock import MockerFixture from flagsmith.api.types import EnvironmentModel from flagsmith.local_evaluation import LocalEvaluation -from flagsmith.mappers import map_environment_document_to_context @pytest.fixture() def local_evaluation(environment: EnvironmentModel) -> LocalEvaluation: - """From data/environment.json, plus an identity sharing the overrides of - `overridden-id`, so both map to the same identity overrides segment.""" - environment = copy.deepcopy(environment) - sharing_identity = copy.deepcopy(environment["identity_overrides"][0]) - sharing_identity["identifier"] = "sharing-overridden-id" - environment["identity_overrides"].append(sharing_identity) - return LocalEvaluation(map_environment_document_to_context(environment)) + return LocalEvaluation.from_environment_document(environment) def test_local_evaluation__context__excludes_identity_overrides_segments( @@ -54,7 +44,6 @@ def test_local_evaluation__get_identity_context_no_identity_overrides__returns_c "identifier, expected_value", [ ("overridden-id", "some-overridden-value"), - ("sharing-overridden-id", "some-overridden-value"), ("another-overridden-id", "another-overridden-value"), ], ) @@ -77,7 +66,7 @@ def test_local_evaluation__get_identity_context_identity_overrides__returns_own_ ] assert identity_segments == [ { - "key": mock.ANY, + "key": "identity_overrides", "name": "identity_overrides", "rules": [ { @@ -118,7 +107,6 @@ def test_local_evaluation__get_identity_context_identity_overrides__returns_own_ "identifier, expected_value", [ ("overridden-id", "some-overridden-value"), - ("sharing-overridden-id", "some-overridden-value"), ("another-overridden-id", "another-overridden-value"), ("not-overridden-id", "some-value"), ], diff --git a/tests/test_mappers.py b/tests/test_mappers.py index 651c010..fd73dca 100644 --- a/tests/test_mappers.py +++ b/tests/test_mappers.py @@ -55,23 +55,3 @@ def test_map_environment_document_to_context__null_variant_key__drops_key() -> N # Then - the null key is dropped, treated as no key variants = context["features"]["mv_feature"]["variants"] assert "key" not in variants[0] - - -def test_map_environment_document_to_context__identity_overrides__keys_are_unique( - environment: EnvironmentModel, -) -> None: - # Given / When - context = map_environment_document_to_context(environment) - - # Then - segments = context["segments"] or {} - identity_override_keys = { - segment_key - for segment_key, segment_context in segments.items() - if segment_context["name"] == "identity_overrides" - } - assert len(identity_override_keys) == 2 - assert all( - segments[segment_key]["key"] == segment_key - for segment_key in identity_override_keys - ) From e80d1c4be920f5d1b1044acee6c1606598428ef1 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 20 Oct 2026 17:59:20 +0200 Subject: [PATCH 14/14] chore: Apply review suggestions Drops comments, shortens the `LocalEvaluation` docstring, and specs the `LocalEvaluation` mocks in streaming manager tests. --- flagsmith/flagsmith.py | 4 ---- flagsmith/local_evaluation.py | 11 +---------- flagsmith/mappers.py | 1 - tests/test_streaming_manager.py | 7 ++++--- 4 files changed, 5 insertions(+), 18 deletions(-) diff --git a/flagsmith/flagsmith.py b/flagsmith/flagsmith.py index c1ceb60..ee028a2 100644 --- a/flagsmith/flagsmith.py +++ b/flagsmith/flagsmith.py @@ -338,8 +338,6 @@ def get_identity_segments( "Local evaluation required to obtain identity segments." ) - # Identity overrides segments are left out: they aren't segments - # in Flagsmith, so they're never returned. context = map_context_and_identity_data_to_context( context=local_evaluation.context, identifier=identifier, @@ -497,8 +495,6 @@ def _get_environment_flags_from_document(self) -> Flags: if (local_evaluation := self._local_evaluation) is None: raise TypeError("No environment present") - # Evaluated once per environment document. `Flag` instances are - # shared across calls; the mapping holding them is not. return Flags( flags=dict(local_evaluation.environment_flags), default_flag_handler=self.default_flag_handler, diff --git a/flagsmith/local_evaluation.py b/flagsmith/local_evaluation.py index f1607f9..1bfc632 100644 --- a/flagsmith/local_evaluation.py +++ b/flagsmith/local_evaluation.py @@ -27,14 +27,7 @@ class LocalEvaluation: - """Per-environment-document state for local evaluation. - - Built once each time the environment document is refreshed, so the - work here is amortised across every evaluation until the next refresh. - - Identity overrides are kept out of the context, by identifier. Each - identity is evaluated with its own identity overrides segment only. - """ + """Per-environment-document state for local evaluation.""" def __init__( self, @@ -71,8 +64,6 @@ def from_environment_document( @cached_property def environment_flags(self) -> typing.Dict[str, Flag]: """Environment flags, which depend on the environment document only.""" - # Omit segments from evaluation context for environment flags - # as they are only relevant for identity-specific evaluations context_without_segments = self.context.copy() context_without_segments.pop("segments", None) evaluation_result = engine.get_evaluation_result( diff --git a/flagsmith/mappers.py b/flagsmith/mappers.py index 1a8ca13..7e22a9b 100644 --- a/flagsmith/mappers.py +++ b/flagsmith/mappers.py @@ -29,7 +29,6 @@ ) from flagsmith.utils.datetime import fromisoformat -# Each identity is evaluated with its own identity overrides segment only. IDENTITY_OVERRIDES_SEGMENT_KEY = "identity_overrides" diff --git a/tests/test_streaming_manager.py b/tests/test_streaming_manager.py index 6ae2c08..7c43f84 100644 --- a/tests/test_streaming_manager.py +++ b/tests/test_streaming_manager.py @@ -7,6 +7,7 @@ from pytest_mock import MockerFixture from flagsmith import Flagsmith +from flagsmith.local_evaluation import LocalEvaluation from flagsmith.streaming_manager import EventStreamManager from flagsmith.types import StreamEvent @@ -44,7 +45,7 @@ def test_environment_updates_on_recent_event( mocker.patch("flagsmith.Flagsmith.update_environment") flagsmith = Flagsmith(environment_key=server_api_key) - flagsmith._local_evaluation = MagicMock() + flagsmith._local_evaluation = MagicMock(spec=LocalEvaluation) flagsmith._environment_updated_at = environment_updated_at flagsmith.handle_stream_event(event=StreamEvent(updated_at=stream_updated_at)) assert isinstance(flagsmith.update_environment, Mock) @@ -60,7 +61,7 @@ def test_environment_does_not_update_on_past_event( mocker.patch("flagsmith.Flagsmith.update_environment") flagsmith = Flagsmith(environment_key=server_api_key) - flagsmith._local_evaluation = MagicMock() + flagsmith._local_evaluation = MagicMock(spec=LocalEvaluation) flagsmith._environment_updated_at = environment_updated_at flagsmith.handle_stream_event(event=StreamEvent(updated_at=stream_updated_at)) @@ -77,7 +78,7 @@ def test_environment_does_not_update_on_same_event( mocker.patch("flagsmith.Flagsmith.update_environment") flagsmith = Flagsmith(environment_key=server_api_key) - flagsmith._local_evaluation = MagicMock() + flagsmith._local_evaluation = MagicMock(spec=LocalEvaluation) flagsmith._environment_updated_at = environment_updated_at flagsmith.handle_stream_event(event=StreamEvent(updated_at=stream_updated_at))