diff --git a/flagsmith/flagsmith.py b/flagsmith/flagsmith.py index 0ff75fe..8242948 100644 --- a/flagsmith/flagsmith.py +++ b/flagsmith/flagsmith.py @@ -21,7 +21,13 @@ map_segment_results_to_identity_segments, resolve_trait_values, ) -from flagsmith.models import DefaultFlag, Flags, Segment +from flagsmith.models import ( + DefaultFlag, + Flags, + Segment, + SegmentOverridesIndex, + build_segment_overrides_index, +) from flagsmith.offline_handlers import OfflineHandler from flagsmith.polling_manager import EnvironmentDataPollingManager from flagsmith.streaming_manager import EventStreamManager @@ -117,7 +123,8 @@ def __init__( self._pipeline_analytics_processor: typing.Optional[ PipelineAnalyticsProcessor ] = None - self._evaluation_context: typing.Optional[SDKEvaluationContext] = None + self.__evaluation_context: typing.Optional[SDKEvaluationContext] = None + self._segment_overrides_index: SegmentOverridesIndex = {} self._environment_updated_at: typing.Optional[datetime] = None # argument validation @@ -356,6 +363,26 @@ 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 the overrides index. + + 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. + """ + self.__evaluation_context = context + self._segment_overrides_index = ( + build_segment_overrides_index(context) if context is not None else {} + ) + def _get_headers( self, environment_key: str, @@ -407,12 +434,12 @@ def _get_identity_flags_from_document( identifier=identifier, traits=traits, ) - evaluation_result = engine.get_evaluation_result( + # Lazy: defer per-feature evaluation until the caller actually reads + # a flag. Hot for callers that only read one or a few flags out of a + # large environment. + return Flags.from_evaluation_context( context=context, - ) - - return Flags.from_evaluation_result( - evaluation_result=evaluation_result, + overrides_index=self._segment_overrides_index, analytics_processor=self._analytics_processor, default_flag_handler=self.default_flag_handler, pipeline_analytics_processor=self._pipeline_analytics_processor, diff --git a/flagsmith/models.py b/flagsmith/models.py index 8d3765c..9e48724 100644 --- a/flagsmith/models.py +++ b/flagsmith/models.py @@ -3,9 +3,37 @@ import typing from dataclasses import dataclass, field +from flag_engine import engine +from flag_engine.context.types import SegmentContext + from flagsmith.analytics import AnalyticsProcessor, PipelineAnalyticsProcessor from flagsmith.exceptions import FlagsmithFeatureDoesNotExistError -from flagsmith.types import SDKEvaluationResult, SDKFlagResult +from flagsmith.types import ( + FeatureMetadata, + SDKEvaluationContext, + SDKEvaluationResult, + SDKFlagResult, + SegmentMetadata, +) + +SegmentOverridesIndex = typing.Dict[ + str, typing.List[SegmentContext[SegmentMetadata, FeatureMetadata]] +] + + +def build_segment_overrides_index( + context: SDKEvaluationContext, +) -> SegmentOverridesIndex: + """Map feature_name -> segments that carry an override for that feature. + + Computed once per environment-document refresh so the lazy eval path + can walk only the segments actually relevant to a given flag. + """ + index: SegmentOverridesIndex = {} + for segment_context in (context.get("segments") or {}).values(): + for override in segment_context.get("overrides") or (): + index.setdefault(override["name"], []).append(segment_context) + return index @dataclass @@ -60,6 +88,14 @@ class Flags: _pipeline_analytics_processor: typing.Optional[PipelineAnalyticsProcessor] = None _identity_identifier: typing.Optional[str] = None _traits: typing.Optional[typing.Dict[str, typing.Any]] = None + # Lazy-evaluation state. When `_context` is set, `flags` is a + # per-feature memo rather than a fully-materialised snapshot; unseen + # features are resolved on demand via the engine primitives and + # 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 + _fully_materialised: bool = False @classmethod def from_evaluation_result( @@ -86,6 +122,37 @@ def from_evaluation_result( _traits=traits, ) + @classmethod + def from_evaluation_context( + cls, + context: SDKEvaluationContext, + overrides_index: SegmentOverridesIndex, + analytics_processor: typing.Optional[AnalyticsProcessor], + default_flag_handler: typing.Optional[typing.Callable[[str], DefaultFlag]], + pipeline_analytics_processor: typing.Optional[ + PipelineAnalyticsProcessor + ] = None, + identity_identifier: typing.Optional[str] = None, + traits: typing.Optional[typing.Dict[str, typing.Any]] = None, + ) -> Flags: + """Build a lazy `Flags` backed by an evaluation context. + + No engine work is done here — flags are resolved on first access + via :meth:`_resolve_flag`. Reusing the same `overrides_index` + across calls amortises its construction cost (it's rebuilt only + when the environment doc refreshes, not per identity). + """ + return cls( + flags={}, + default_flag_handler=default_flag_handler, + _analytics_processor=analytics_processor, + _pipeline_analytics_processor=pipeline_analytics_processor, + _identity_identifier=identity_identifier, + _traits=traits, + _context=context, + _overrides_index=overrides_index, + ) + @classmethod def from_api_flags( cls, @@ -116,8 +183,21 @@ def all_flags(self) -> typing.List[Flag]: """ Get a list of all Flag objects. + In lazy mode, the caller has signalled they want every flag, so + we run the bulk evaluator once on the full context and copy the + results into the per-flag cache. Cheaper than asking the engine + for each feature one at a time. + :return: list of Flag objects. """ + if self._context is not None and not self._fully_materialised: + result = engine.get_evaluation_result(self._context) + for feature_name, flag_result in result["flags"].items(): + if feature_name not in self.flags: + self.flags[feature_name] = Flag.from_evaluation_result( + flag_result, + ) + self._fully_materialised = True return list(self.flags.values()) def is_feature_enabled(self, feature_name: str) -> bool: @@ -151,11 +231,23 @@ def get_flag(self, feature_name: str) -> typing.Union[DefaultFlag, Flag]: try: flag = self.flags[feature_name] except KeyError: - if self.default_flag_handler: + # Lazy path: if this `Flags` wraps an evaluation context and + # the feature exists in it, resolve and memoise now. Otherwise + # fall through to the default_flag_handler / not-found error, + # preserving the eager-mode behaviour byte-for-byte. + if ( + self._context is not None + and self._overrides_index is not None + and feature_name in (self._context.get("features") or {}) + ): + flag = self._resolve_flag(feature_name) + self.flags[feature_name] = flag + elif self.default_flag_handler: return self.default_flag_handler(feature_name) - raise FlagsmithFeatureDoesNotExistError( - "Feature does not exist: %s" % feature_name - ) + else: + raise FlagsmithFeatureDoesNotExistError( + "Feature does not exist: %s" % feature_name + ) if self._analytics_processor and hasattr(flag, "feature_name"): self._analytics_processor.track_feature(flag.feature_name) @@ -171,6 +263,35 @@ def get_flag(self, feature_name: str) -> typing.Union[DefaultFlag, Flag]: return flag + def _resolve_flag(self, feature_name: str) -> Flag: + """Evaluate a single feature against the lazy context. + + Goes through the engine's public `get_evaluation_result` so + identity-key enrichment, multivariate hashing, percentage-split + rules and override-priority handling all stay where they + belong (in the engine). The performance win comes from passing + a *trimmed* context — just the queried feature plus the segments + that could override it, looked up in O(1) via the precomputed + reverse index — so the engine's full pipeline runs against an + input small enough to evaluate in ~1 µs. + """ + context = self._context + overrides_index = self._overrides_index + # `get_flag` / `all_flags` gate this call behind the same + # non-None checks; assert here so type checkers can narrow. + assert context is not None and overrides_index is not None + + trimmed: SDKEvaluationContext = { + **context, + "features": {feature_name: context["features"][feature_name]}, + "segments": { + segment_context["key"]: segment_context + for segment_context in overrides_index.get(feature_name, ()) + }, + } + result = engine.get_evaluation_result(trimmed) + return Flag.from_evaluation_result(result["flags"][feature_name]) + @dataclass class Segment: diff --git a/poetry.lock b/poetry.lock index 6be6213..62a7c99 100644 --- a/poetry.lock +++ b/poetry.lock @@ -273,14 +273,14 @@ files = [ [[package]] name = "flagsmith-flag-engine" -version = "10.0.3" +version = "10.0.4" description = "Flag engine for the Flagsmith API." optional = false python-versions = "*" groups = ["main"] files = [ - {file = "flagsmith_flag_engine-10.0.3-py3-none-any.whl", hash = "sha256:aed9009377fc1a6322483277f971f06d542668a69d93cbe4a3efd4baae78dfc1"}, - {file = "flagsmith_flag_engine-10.0.3.tar.gz", hash = "sha256:0aa449bb87bee54fc67b5c7ca25eca78246a7bbb5a6cc229260c3f262d58ac54"}, + {file = "flagsmith_flag_engine-10.0.4-py3-none-any.whl", hash = "sha256:3d9fc0eaf7ec9bc9251de781a652b77c962115bdcc81b2b8a800655849ccdc3f"}, + {file = "flagsmith_flag_engine-10.0.4.tar.gz", hash = "sha256:bf71712c5cce62311c7a9da01f1a7a7d7a97c86655a76f4efdfb6c975f93563c"}, ] [package.dependencies] @@ -977,4 +977,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.1" python-versions = ">=3.9,<4" -content-hash = "1a65acfb68f8c7f4226460c21adbcbb27a105635cb8287f6bbfc5aa9c900c5dd" +content-hash = "e91aea422e521889c402d406d22ac7541dea465a76097c131135c7ec046f1c9d" diff --git a/pyproject.toml b/pyproject.toml index dbc9780..9373e3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ documentation = "https://docs.flagsmith.com" packages = [{ include = "flagsmith" }] [tool.poetry.dependencies] -flagsmith-flag-engine = "^10.0.3" +flagsmith-flag-engine = "^10.0.4" iso8601 = { version = "^2.1.0", python = "<3.11" } python = ">=3.9,<4" requests = "^2.32.3" diff --git a/tests/test_flagsmith.py b/tests/test_flagsmith.py index b0ad097..b5891ed 100644 --- a/tests/test_flagsmith.py +++ b/tests/test_flagsmith.py @@ -5,6 +5,7 @@ import pytest import requests import responses +from flag_engine import engine from pytest_mock import MockerFixture from responses import matchers @@ -15,7 +16,7 @@ FlagsmithAPIError, FlagsmithFeatureDoesNotExistError, ) -from flagsmith.models import DefaultFlag, Flags +from flagsmith.models import DefaultFlag, Flag, Flags from flagsmith.offline_handlers import OfflineHandler from flagsmith.types import SDKEvaluationContext @@ -193,7 +194,12 @@ def test_get_identity_flags_uses_local_environment_when_available( # Given flagsmith._evaluation_context = evaluation_context flagsmith.enable_local_evaluation = True - mock_engine = mocker.patch("flagsmith.flagsmith.engine") + # `Flags` materialises identity flags via `engine.get_evaluation_result` + # imported from `flagsmith.models`, so patch it where it's actually used. + mock_get_evaluation_result = mocker.patch( + "flagsmith.models.engine.get_evaluation_result", + autospec=True, + ) expected_evaluation_result = { "flags": { @@ -210,15 +216,15 @@ def test_get_identity_flags_uses_local_environment_when_available( identifier = "identifier" traits = {"some_trait": "some_value"} - mock_engine.get_evaluation_result.return_value = expected_evaluation_result + mock_get_evaluation_result.return_value = expected_evaluation_result # When identity_flags = flagsmith.get_identity_flags(identifier, traits).all_flags() # Then - mock_engine.get_evaluation_result.assert_called_once() - call_args = mock_engine.get_evaluation_result.call_args - context = call_args[1]["context"] + mock_get_evaluation_result.assert_called_once() + call_args = mock_get_evaluation_result.call_args + context = call_args[0][0] if call_args.args else call_args[1]["context"] assert context["identity"]["identifier"] == identifier assert context["identity"]["traits"]["some_trait"] == "some_value" assert "some_trait" in context["identity"]["traits"] @@ -233,7 +239,7 @@ def test_get_identity_flags_includes_segments_in_evaluation_context( ) -> None: # Given mock_get_evaluation_result = mocker.patch( - "flagsmith.flagsmith.engine.get_evaluation_result", + "flagsmith.models.engine.get_evaluation_result", autospec=True, ) @@ -254,16 +260,43 @@ def test_get_identity_flags_includes_segments_in_evaluation_context( mock_get_evaluation_result.return_value = expected_evaluation_result - # When - local_eval_flagsmith.get_identity_flags(identifier, traits) + # When: `all_flags` triggers the bulk evaluation path on the lazy + # `Flags` object, which is where the full identity context — segments + # included — is passed to the engine. + local_eval_flagsmith.get_identity_flags(identifier, traits).all_flags() - # Then - # Verify segments are present in the context passed to the engine for identity flags + # Then: segments are present in the context passed to the engine for + # identity flags (in contrast to the env-flags path, which strips them). call_args = mock_get_evaluation_result.call_args - context = call_args[1]["context"] + context = call_args[0][0] if call_args.args else call_args[1]["context"] assert "segments" in context +def test_get_identity_flags__resolves_one_flag_at_a_time( + local_eval_flagsmith: Flagsmith, + mocker: MockerFixture, +) -> None: + spy = mocker.spy(engine, "get_evaluation_result") + + # When: we ask for identity flags but never touch a specific flag... + flags = local_eval_flagsmith.get_identity_flags("someone") + + # Then: nothing has been evaluated yet — no engine call, empty cache. + assert spy.call_count == 0 + assert flags.flags == {} + + # And: touching one flag triggers exactly one engine call against a + # *trimmed* context (the queried feature only), not the full env. + flag = flags.get_flag("some_feature") + assert isinstance(flag, Flag) + assert flag.feature_name == "some_feature" + assert set(flags.flags.keys()) == {"some_feature"} + + assert spy.call_count == 1 + trimmed_context = spy.call_args.kwargs.get("context") or spy.call_args.args[0] + assert set(trimmed_context["features"]) == {"some_feature"} + + @responses.activate() def test_get_identity_flags__transient_identity__calls_expected( flagsmith: Flagsmith, diff --git a/tests/test_models.py b/tests/test_models.py index 8c42093..b8aaca1 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -2,8 +2,17 @@ import pytest -from flagsmith.models import Flag, Flags -from flagsmith.types import SDKEvaluationResult, SDKFlagResult +from flagsmith.models import ( + DefaultFlag, + Flag, + Flags, + build_segment_overrides_index, +) +from flagsmith.types import ( + SDKEvaluationContext, + SDKEvaluationResult, + SDKFlagResult, +) def test_flag_from_evaluation_result() -> None: @@ -161,3 +170,224 @@ def test_get_flag_without_pipeline_processor() -> None: ) flag = flags.get_flag("my_feature") assert flag.enabled is True + + +LazyContextFactory = typing.Callable[..., SDKEvaluationContext] + + +@pytest.fixture +def lazy_context_factory() -> LazyContextFactory: + """Factory for minimal evaluation contexts used by the lazy-Flags tests. + + The returned context has a `target` feature with a single segment + override (matches when `tier == segment_match_value`, priority 0) + plus `extra_features` no-override "noise" features whose values + should come straight off the base feature context. + """ + + def make( + *, + extra_features: int = 2, + identity_trait_value: str = "premium", + segment_match_value: str = "premium", + ) -> SDKEvaluationContext: + features: typing.Dict[str, typing.Any] = { + "target": { + "key": "target", + "name": "target", + "enabled": False, + "value": "base-value", + "metadata": {"id": 1}, + }, + } + for i in range(extra_features): + features[f"noise_{i}"] = { + "key": f"noise_{i}", + "name": f"noise_{i}", + "enabled": True, + "value": f"noise-value-{i}", + "metadata": {"id": 100 + i}, + } + return { + "environment": {"key": "env-key", "name": "env"}, + "features": features, + "segments": { + "premium_segment": { + "key": "premium_segment", + "name": "premium_segment", + "rules": [ + { + "type": "ALL", + "conditions": [ + { + "property": "tier", + "operator": "EQUAL", + "value": segment_match_value, + }, + ], + } + ], + "overrides": [ + { + "key": "target", + "name": "target", + "enabled": True, + "value": "premium-value", + "priority": 0.0, + "metadata": {"id": 1}, + }, + ], + }, + }, + "identity": { + "identifier": "user-1", + "key": "env-key_user-1", + "traits": {"tier": identity_trait_value}, + }, + } + + return make + + +@pytest.fixture +def lazy_context( + lazy_context_factory: LazyContextFactory, +) -> SDKEvaluationContext: + """Default evaluation context: identity matches the segment override.""" + return lazy_context_factory() + + +@pytest.fixture +def lazy_flags(lazy_context: SDKEvaluationContext) -> Flags: + """Lazy `Flags` built from the default context, no analytics, no handler.""" + return Flags.from_evaluation_context( + context=lazy_context, + overrides_index=build_segment_overrides_index(lazy_context), + analytics_processor=None, + default_flag_handler=None, + ) + + +def test_lazy_flags__get_flag__applies_matching_segment_override( + lazy_flags: Flags, +) -> None: + # Given: identity matches the segment rule (default context). + # When: we read the targeted feature. + target = lazy_flags.get_flag("target") + # Then: the override wins over the base feature value. + assert target.enabled is True + assert target.value == "premium-value" + + +def test_lazy_flags__get_flag__skips_non_matching_segment_override( + lazy_context_factory: LazyContextFactory, +) -> None: + # Given: segment rule requires tier == "premium" but identity has tier "free". + ctx = lazy_context_factory(identity_trait_value="free") + flags = Flags.from_evaluation_context( + context=ctx, + overrides_index=build_segment_overrides_index(ctx), + analytics_processor=None, + default_flag_handler=None, + ) + + # When: we read the targeted feature. + target = flags.get_flag("target") + + # Then: the override doesn't win and base-value comes through. + assert target.enabled is False + assert target.value == "base-value" + + +def test_lazy_flags__get_flag__caches_per_feature( + lazy_context_factory: LazyContextFactory, +) -> None: + # Given: a context with several no-override features. + ctx = lazy_context_factory(extra_features=5) + flags = Flags.from_evaluation_context( + context=ctx, + overrides_index=build_segment_overrides_index(ctx), + analytics_processor=None, + default_flag_handler=None, + ) + + # When: we read a single feature once. + flags.get_flag("noise_0") + + # Then: only that feature is populated in the cache. + assert set(flags.flags.keys()) == {"noise_0"} + + # And: repeated reads return the same Flag instance, not a rebuild. + first = flags.get_flag("noise_0") + second = flags.get_flag("noise_0") + assert first is second + + +def test_lazy_flags__all_flags__materialises_every_feature( + lazy_context_factory: LazyContextFactory, +) -> None: + # Given: a context with three no-override features plus the target. + ctx = lazy_context_factory(extra_features=3) + flags = Flags.from_evaluation_context( + context=ctx, + overrides_index=build_segment_overrides_index(ctx), + analytics_processor=None, + default_flag_handler=None, + ) + + # When: the caller asks for the full set. + materialised = flags.all_flags() + + # Then: every feature in the context is present. + names = {flag.feature_name for flag in materialised} + assert names == {"target", "noise_0", "noise_1", "noise_2"} + + # And: a second call is a no-op — everything is already resolved. + assert flags.all_flags() == materialised + + +def test_lazy_flags__missing_feature__falls_through_to_default_handler( + lazy_context: SDKEvaluationContext, +) -> None: + # Given: a Flags wired to a default-flag handler. + def default(name: str) -> DefaultFlag: + return DefaultFlag(enabled=False, value=f"default-for-{name}") + + flags = Flags.from_evaluation_context( + context=lazy_context, + overrides_index=build_segment_overrides_index(lazy_context), + analytics_processor=None, + default_flag_handler=default, + ) + + # When: we ask for a feature that isn't in the context. + result = flags.get_flag("does_not_exist") + + # Then: the handler produces the default flag for that name. + assert result.value == "default-for-does_not_exist" + + +def test_build_segment_overrides_index__indexes_only_overriding_segments( + lazy_context: SDKEvaluationContext, +) -> None: + # Given: a second segment with no overrides on top of the default context. + assert lazy_context["segments"] is not None + lazy_context["segments"]["no_override_segment"] = { + "key": "no_override_segment", + "name": "no_override_segment", + "rules": [ + { + "type": "ALL", + "conditions": [ + {"property": "tier", "operator": "EQUAL", "value": "premium"}, + ], + } + ], + } + + # When: we build the reverse index. + index = build_segment_overrides_index(lazy_context) + + # Then: only segments that actually carry an override appear. + assert set(index) == {"target"} + assert index["target"][0]["name"] == "premium_segment"