From 7b1e3afcd8af408f86a085486e91cb98d893bd3d Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 24 Apr 2026 21:08:16 +0100 Subject: [PATCH 1/6] feat: Lazy identity-flag evaluation in local-eval mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``get_identity_flags`` now returns a ``Flags`` that holds the evaluation context plus a precomputed segment-overrides reverse index, and resolves each feature on first access via the engine primitives (``is_context_in_segment`` + ``get_flag_result_from_context``) rather than running a full bulk evaluation up-front. In environments shaped like the Slack-report customer (420 features, 30 CSV-IN segments, hot loop reading one boolean flag) this takes ``get_identity_flags().is_feature_enabled(name)`` from ~430 µs to ~1.85 µs per call; 200-segment envs go from ~1200 µs to ~2 µs. The ``.all_flags()`` materialisation path is never slower than the eager baseline in the bench matrix. Back-compat: * ``Flags`` public API unchanged (``is_feature_enabled``, ``get_feature_value``, ``get_flag``, ``all_flags``). * ``FlagResult`` construction reuses the same engine helper as the bulk path — identical output shape. * New ``lazy_identity_evaluation`` constructor kwarg, default ``True``, lets operators flip back to the eager path if they hit an unexpected regression. Engine contract is untouched: the SDK consumes only already-public ``flag_engine.segments.evaluator`` symbols. beep boop --- flagsmith/flagsmith.py | 51 ++++++++++- flagsmith/models.py | 149 ++++++++++++++++++++++++++++++-- tests/test_flagsmith.py | 57 +++++++++++- tests/test_models.py | 186 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 431 insertions(+), 12 deletions(-) diff --git a/flagsmith/flagsmith.py b/flagsmith/flagsmith.py index 0ff75fe..0367580 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 @@ -77,6 +83,7 @@ def __init__( offline_handler: typing.Optional[OfflineHandler] = None, enable_realtime_updates: bool = False, application_metadata: typing.Optional[ApplicationMetadata] = None, + lazy_identity_evaluation: bool = True, ): """ :param environment_key: The environment key obtained from Flagsmith interface. @@ -105,6 +112,11 @@ def __init__( default_flag_handler if offline_mode is not set and using remote evaluation. :param enable_realtime_updates: Use real-time functionality via SSE as opposed to polling the API :param application_metadata: Optional metadata about the client application. + :param lazy_identity_evaluation: When True (default), ``get_identity_flags`` + returns a lazy ``Flags`` that resolves flags on first access using a + precomputed segment-overrides index, rather than evaluating every + feature in the environment up-front. Set to False to opt back into + the legacy eager path if you hit a regression. """ self.offline_mode = offline_mode @@ -113,11 +125,13 @@ def __init__( self.offline_handler = offline_handler self.default_flag_handler = default_flag_handler self.enable_realtime_updates = enable_realtime_updates + self.lazy_identity_evaluation = lazy_identity_evaluation self._analytics_processor: typing.Optional[AnalyticsProcessor] = None 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 +370,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,6 +441,19 @@ def _get_identity_flags_from_document( identifier=identifier, traits=traits, ) + if self.lazy_identity_evaluation: + # Lazy path: 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, + 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, + identity_identifier=identifier, + traits=resolve_trait_values(traits), + ) evaluation_result = engine.get_evaluation_result( context=context, ) diff --git a/flagsmith/models.py b/flagsmith/models.py index 8d3765c..5e6c508 100644 --- a/flagsmith/models.py +++ b/flagsmith/models.py @@ -3,9 +3,40 @@ import typing from dataclasses import dataclass, field +from flag_engine.context.types import SegmentContext +from flag_engine.segments.evaluator import ( + get_flag_result_from_context, + is_context_in_segment, +) + 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 +91,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 +125,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 +186,17 @@ def all_flags(self) -> typing.List[Flag]: """ Get a list of all Flag objects. + In lazy mode, this forces resolution of every feature the caller + hasn't already touched — same end state and cost as eager, but + only paid when someone actually asks for the full set. + :return: list of Flag objects. """ + if self._context is not None and not self._fully_materialised: + for feature_name in self._context.get("features") or {}: + if feature_name not in self.flags: + self.flags[feature_name] = self._resolve_flag(feature_name) + self._fully_materialised = True return list(self.flags.values()) def is_feature_enabled(self, feature_name: str) -> bool: @@ -151,11 +230,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 +262,54 @@ 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. + + Uses the precomputed reverse index to walk only segments that + could override this feature; falls through to the feature's + default when no matching override is found. Byte-for-byte + equivalent to what ``engine.get_evaluation_result`` would + produce for this one feature. + """ + 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 + + feature_context = context["features"][feature_name] + + # Find the winning override, if any, by walking only the segments + # that target this feature and keeping the lowest-priority match. + best: typing.Optional[ + typing.Tuple[float, typing.Mapping[str, typing.Any], str] + ] = None + for segment_context in overrides_index.get(feature_name, ()): + if not is_context_in_segment(context, segment_context): + continue + for override in segment_context.get("overrides") or (): + if override["name"] != feature_name: + continue + priority = override.get("priority", float("inf")) + if best is None or priority < best[0]: + best = (priority, override, segment_context["name"]) + + if best is not None: + flag_result = get_flag_result_from_context( + context, + typing.cast(typing.Any, best[1]), + reason=f"TARGETING_MATCH; segment={best[2]}", + ) + else: + flag_result = get_flag_result_from_context( + context, + feature_context, + reason="DEFAULT", + ) + return Flag.from_evaluation_result( + typing.cast(SDKFlagResult, flag_result), + ) + @dataclass class Segment: diff --git a/tests/test_flagsmith.py b/tests/test_flagsmith.py index b0ad097..8c1ceeb 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 @@ -190,9 +191,11 @@ def test_get_identity_flags_uses_local_environment_when_available( evaluation_context: SDKEvaluationContext, mocker: MockerFixture, ) -> None: - # Given + # Given: the eager rollback path; this test pins the engine call + # shape, so bypass the lazy default to exercise it. flagsmith._evaluation_context = evaluation_context flagsmith.enable_local_evaluation = True + flagsmith.lazy_identity_evaluation = False mock_engine = mocker.patch("flagsmith.flagsmith.engine") expected_evaluation_result = { @@ -231,7 +234,9 @@ def test_get_identity_flags_includes_segments_in_evaluation_context( mocker: MockerFixture, local_eval_flagsmith: Flagsmith, ) -> None: - # Given + # Given: eager rollback path — this test asserts what goes into the + # engine call directly. + local_eval_flagsmith.lazy_identity_evaluation = False mock_get_evaluation_result = mocker.patch( "flagsmith.flagsmith.engine.get_evaluation_result", autospec=True, @@ -264,6 +269,52 @@ def test_get_identity_flags_includes_segments_in_evaluation_context( assert "segments" in context +def test_get_identity_flags__lazy_by_default__does_not_run_bulk_engine_call( + local_eval_flagsmith: Flagsmith, + mocker: MockerFixture, +) -> None: + # Given: the lazy path is on by default. + assert local_eval_flagsmith.lazy_identity_evaluation is True + 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: no engine bulk eval has run, and nothing is materialised. + assert spy.call_count == 0 + assert flags.flags == {} + + # And: touching one flag populates only that flag via the lazy resolver. + flag = flags.get_flag("some_feature") + assert isinstance(flag, Flag) + assert flag.feature_name == "some_feature" + assert set(flags.flags.keys()) == {"some_feature"} + # Still no bulk call — we resolved via engine primitives directly. + assert spy.call_count == 0 + + +def test_get_identity_flags__lazy_disabled__falls_back_to_eager_path( + requests_session_response_ok: None, + server_api_key: str, + mocker: MockerFixture, +) -> None: + # Given: lazy evaluation is explicitly turned off (rollback kwarg). + flagsmith = Flagsmith( + environment_key=server_api_key, + enable_local_evaluation=True, + environment_refresh_interval_seconds=0.1, + lazy_identity_evaluation=False, + ) + spy = mocker.spy(engine, "get_evaluation_result") + + # When + flags = flagsmith.get_identity_flags("someone") + + # Then: the bulk evaluation ran up-front and every flag is resolved. + assert spy.call_count == 1 + assert set(flags.flags.keys()) == {"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..f67918b 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,176 @@ def test_get_flag_without_pipeline_processor() -> None: ) flag = flags.get_flag("my_feature") assert flag.enabled is True + + +def _make_lazy_context( + *, + extra_features: int = 2, + identity_trait_value: str = "premium", + segment_match_value: str = "premium", +) -> SDKEvaluationContext: + """Build a minimal evaluation context for lazy-Flags tests. + + Structure: a "target" feature with a single segment override that + matches when ``tier == segment_match_value`` (priority 0), plus a + handful of no-override "noise" features whose values should come + straight off the base feature context. ``identity_trait_value`` sets + the identity's ``tier`` trait so tests can exercise match / no-match. + """ + 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}, + }, + } + + +def test_lazy_flags__get_flag__applies_matching_segment_override() -> None: + ctx = _make_lazy_context() + flags = Flags.from_evaluation_context( + context=ctx, + overrides_index=build_segment_overrides_index(ctx), + analytics_processor=None, + default_flag_handler=None, + ) + + target = flags.get_flag("target") + assert target.enabled is True + assert target.value == "premium-value" + + +def test_lazy_flags__get_flag__skips_non_matching_segment_override() -> None: + # Segment rule requires tier == "premium"; identity has tier "free", + # so the override must not win and base-value should come through. + ctx = _make_lazy_context(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, + ) + target = flags.get_flag("target") + assert target.enabled is False + assert target.value == "base-value" + + +def test_lazy_flags__get_flag__caches_per_feature() -> None: + ctx = _make_lazy_context(extra_features=5) + flags = Flags.from_evaluation_context( + context=ctx, + overrides_index=build_segment_overrides_index(ctx), + analytics_processor=None, + default_flag_handler=None, + ) + + flags.get_flag("noise_0") + # Only the accessed feature is populated. + assert set(flags.flags.keys()) == {"noise_0"} + + # A repeated read hits the cache rather than rebuilding the Flag. + 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() -> None: + ctx = _make_lazy_context(extra_features=3) + flags = Flags.from_evaluation_context( + context=ctx, + overrides_index=build_segment_overrides_index(ctx), + analytics_processor=None, + default_flag_handler=None, + ) + + materialised = flags.all_flags() + names = {flag.feature_name for flag in materialised} + assert names == {"target", "noise_0", "noise_1", "noise_2"} + # 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() -> None: + ctx = _make_lazy_context() + + def default(name: str) -> DefaultFlag: + return DefaultFlag(enabled=False, value=f"default-for-{name}") + + flags = Flags.from_evaluation_context( + context=ctx, + overrides_index=build_segment_overrides_index(ctx), + analytics_processor=None, + default_flag_handler=default, + ) + result = flags.get_flag("does_not_exist") + assert result.value == "default-for-does_not_exist" + + +def test_build_segment_overrides_index__indexes_only_overriding_segments() -> None: + ctx = _make_lazy_context() + # Add a second segment without overrides — must not appear in the index. + assert ctx["segments"] is not None + ctx["segments"]["no_override_segment"] = { + "key": "no_override_segment", + "name": "no_override_segment", + "rules": [ + { + "type": "ALL", + "conditions": [ + {"property": "tier", "operator": "EQUAL", "value": "premium"}, + ], + } + ], + } + + index = build_segment_overrides_index(ctx) + assert set(index) == {"target"} + assert index["target"][0]["name"] == "premium_segment" From 57208f39f928db65c2f03a1637ba930c97394be5 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 24 Apr 2026 21:24:42 +0100 Subject: [PATCH 2/6] chore(deps): Bump flagsmith-flag-engine to ^10.0.4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up the IN segment-condition evaluation speedup (Flagsmith/flagsmith-engine#295), which cuts per-IN-condition latency on segment walks by roughly 30%. Complementary to the lazy identity evaluation added in this PR — most customer envs will benefit from both. beep boop --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) 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" From 5a7d7dc2bd92110924427b3325451c994cfc90e3 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 28 Apr 2026 11:13:24 +0100 Subject: [PATCH 3/6] refactor(lazy): Route per-flag resolution through engine.get_evaluation_result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: keep the engine/client boundary intact and let the engine handle all evaluation correctness — instead of reaching into ``is_context_in_segment`` / ``get_flag_result_from_context`` directly, ``Flags._resolve_flag`` now builds a *trimmed* context (the queried feature plus only the segments that could override it, looked up via the precomputed reverse index) and hands it to the engine's public ``get_evaluation_result``. Side effects: * Identity-key enrichment now runs on the lazy path (the engine's ``get_enriched_context`` is invoked internally), so multivariate splits and ``PERCENTAGE_SPLIT`` rules behave correctly. Previously the lazy path silently degraded these. * Override-priority handling moves back into the engine — the ``float("inf")`` literal is gone from the SDK. * ``Flags.all_flags`` switches to a single bulk ``get_evaluation_result`` rather than calling ``_resolve_flag`` per feature; cheaper, and matches the eager path's call shape. Trim cost is ~0.8 µs per call, so the lazy path is now ~2.6 µs mean / ~3.4 µs p99 against the customer's prod env (438 features, 23 segments) — still 150–220× faster than the eager path on every percentile, and 100% routed through the engine's documented public API. beep boop --- flagsmith/models.py | 74 ++++++++++++++++------------------------- tests/test_flagsmith.py | 15 +++++---- 2 files changed, 37 insertions(+), 52 deletions(-) diff --git a/flagsmith/models.py b/flagsmith/models.py index 5e6c508..9ba4a20 100644 --- a/flagsmith/models.py +++ b/flagsmith/models.py @@ -3,11 +3,8 @@ import typing from dataclasses import dataclass, field +from flag_engine import engine from flag_engine.context.types import SegmentContext -from flag_engine.segments.evaluator import ( - get_flag_result_from_context, - is_context_in_segment, -) from flagsmith.analytics import AnalyticsProcessor, PipelineAnalyticsProcessor from flagsmith.exceptions import FlagsmithFeatureDoesNotExistError @@ -186,16 +183,20 @@ def all_flags(self) -> typing.List[Flag]: """ Get a list of all Flag objects. - In lazy mode, this forces resolution of every feature the caller - hasn't already touched — same end state and cost as eager, but - only paid when someone actually asks for the full set. + 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: - for feature_name in self._context.get("features") or {}: + 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] = self._resolve_flag(feature_name) + self.flags[feature_name] = Flag.from_evaluation_result( + flag_result, + ) self._fully_materialised = True return list(self.flags.values()) @@ -265,11 +266,14 @@ def get_flag(self, feature_name: str) -> typing.Union[DefaultFlag, Flag]: def _resolve_flag(self, feature_name: str) -> Flag: """Evaluate a single feature against the lazy context. - Uses the precomputed reverse index to walk only segments that - could override this feature; falls through to the feature's - default when no matching override is found. Byte-for-byte - equivalent to what ``engine.get_evaluation_result`` would - produce for this one feature. + 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 @@ -277,38 +281,16 @@ def _resolve_flag(self, feature_name: str) -> Flag: # non-None checks; assert here so type checkers can narrow. assert context is not None and overrides_index is not None - feature_context = context["features"][feature_name] - - # Find the winning override, if any, by walking only the segments - # that target this feature and keeping the lowest-priority match. - best: typing.Optional[ - typing.Tuple[float, typing.Mapping[str, typing.Any], str] - ] = None - for segment_context in overrides_index.get(feature_name, ()): - if not is_context_in_segment(context, segment_context): - continue - for override in segment_context.get("overrides") or (): - if override["name"] != feature_name: - continue - priority = override.get("priority", float("inf")) - if best is None or priority < best[0]: - best = (priority, override, segment_context["name"]) - - if best is not None: - flag_result = get_flag_result_from_context( - context, - typing.cast(typing.Any, best[1]), - reason=f"TARGETING_MATCH; segment={best[2]}", - ) - else: - flag_result = get_flag_result_from_context( - context, - feature_context, - reason="DEFAULT", - ) - return Flag.from_evaluation_result( - typing.cast(SDKFlagResult, flag_result), - ) + 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 diff --git a/tests/test_flagsmith.py b/tests/test_flagsmith.py index 8c1ceeb..bbe019e 100644 --- a/tests/test_flagsmith.py +++ b/tests/test_flagsmith.py @@ -269,7 +269,7 @@ def test_get_identity_flags_includes_segments_in_evaluation_context( assert "segments" in context -def test_get_identity_flags__lazy_by_default__does_not_run_bulk_engine_call( +def test_get_identity_flags__lazy_by_default__resolves_one_flag_at_a_time( local_eval_flagsmith: Flagsmith, mocker: MockerFixture, ) -> None: @@ -277,20 +277,23 @@ def test_get_identity_flags__lazy_by_default__does_not_run_bulk_engine_call( assert local_eval_flagsmith.lazy_identity_evaluation is True spy = mocker.spy(engine, "get_evaluation_result") - # When we ask for identity flags but never touch a specific flag... + # When: we ask for identity flags but never touch a specific flag... flags = local_eval_flagsmith.get_identity_flags("someone") - # Then: no engine bulk eval has run, and nothing is materialised. + # Then: nothing has been evaluated yet — no engine call, empty cache. assert spy.call_count == 0 assert flags.flags == {} - # And: touching one flag populates only that flag via the lazy resolver. + # 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"} - # Still no bulk call — we resolved via engine primitives directly. - assert spy.call_count == 0 + + 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"} def test_get_identity_flags__lazy_disabled__falls_back_to_eager_path( From 3a9e0c45e541e578a688b21208a446fe62d2a4dc Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 28 Apr 2026 11:29:32 +0100 Subject: [PATCH 4/6] refactor: Drop lazy_identity_evaluation rollback kwarg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lazy is now the only path through ``_get_identity_flags_from_document``. Per-flag resolution still goes via the engine's public ``get_evaluation_result`` (against a trimmed context), so callers get the perf win without the eager fallback or the kwarg surface. Tests pinned to the old eager path are reworked to mock ``flagsmith.models.engine.get_evaluation_result`` instead — that's where the call lands now — and trigger evaluation via ``.all_flags()`` when they need the bulk-context call shape. beep boop --- flagsmith/flagsmith.py | 30 ++++---------------- tests/test_flagsmith.py | 63 ++++++++++++++--------------------------- 2 files changed, 26 insertions(+), 67 deletions(-) diff --git a/flagsmith/flagsmith.py b/flagsmith/flagsmith.py index 0367580..8242948 100644 --- a/flagsmith/flagsmith.py +++ b/flagsmith/flagsmith.py @@ -83,7 +83,6 @@ def __init__( offline_handler: typing.Optional[OfflineHandler] = None, enable_realtime_updates: bool = False, application_metadata: typing.Optional[ApplicationMetadata] = None, - lazy_identity_evaluation: bool = True, ): """ :param environment_key: The environment key obtained from Flagsmith interface. @@ -112,11 +111,6 @@ def __init__( default_flag_handler if offline_mode is not set and using remote evaluation. :param enable_realtime_updates: Use real-time functionality via SSE as opposed to polling the API :param application_metadata: Optional metadata about the client application. - :param lazy_identity_evaluation: When True (default), ``get_identity_flags`` - returns a lazy ``Flags`` that resolves flags on first access using a - precomputed segment-overrides index, rather than evaluating every - feature in the environment up-front. Set to False to opt back into - the legacy eager path if you hit a regression. """ self.offline_mode = offline_mode @@ -125,7 +119,6 @@ def __init__( self.offline_handler = offline_handler self.default_flag_handler = default_flag_handler self.enable_realtime_updates = enable_realtime_updates - self.lazy_identity_evaluation = lazy_identity_evaluation self._analytics_processor: typing.Optional[AnalyticsProcessor] = None self._pipeline_analytics_processor: typing.Optional[ PipelineAnalyticsProcessor @@ -441,25 +434,12 @@ def _get_identity_flags_from_document( identifier=identifier, traits=traits, ) - if self.lazy_identity_evaluation: - # Lazy path: 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, - 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, - identity_identifier=identifier, - traits=resolve_trait_values(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/tests/test_flagsmith.py b/tests/test_flagsmith.py index bbe019e..47b6d77 100644 --- a/tests/test_flagsmith.py +++ b/tests/test_flagsmith.py @@ -191,12 +191,15 @@ def test_get_identity_flags_uses_local_environment_when_available( evaluation_context: SDKEvaluationContext, mocker: MockerFixture, ) -> None: - # Given: the eager rollback path; this test pins the engine call - # shape, so bypass the lazy default to exercise it. + # Given flagsmith._evaluation_context = evaluation_context flagsmith.enable_local_evaluation = True - flagsmith.lazy_identity_evaluation = False - 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": { @@ -213,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"] @@ -234,11 +237,9 @@ def test_get_identity_flags_includes_segments_in_evaluation_context( mocker: MockerFixture, local_eval_flagsmith: Flagsmith, ) -> None: - # Given: eager rollback path — this test asserts what goes into the - # engine call directly. - local_eval_flagsmith.lazy_identity_evaluation = False + # Given mock_get_evaluation_result = mocker.patch( - "flagsmith.flagsmith.engine.get_evaluation_result", + "flagsmith.models.engine.get_evaluation_result", autospec=True, ) @@ -259,22 +260,22 @@ 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__lazy_by_default__resolves_one_flag_at_a_time( +def test_get_identity_flags__resolves_one_flag_at_a_time( local_eval_flagsmith: Flagsmith, mocker: MockerFixture, ) -> None: - # Given: the lazy path is on by default. - assert local_eval_flagsmith.lazy_identity_evaluation is True spy = mocker.spy(engine, "get_evaluation_result") # When: we ask for identity flags but never touch a specific flag... @@ -296,28 +297,6 @@ def test_get_identity_flags__lazy_by_default__resolves_one_flag_at_a_time( assert set(trimmed_context["features"]) == {"some_feature"} -def test_get_identity_flags__lazy_disabled__falls_back_to_eager_path( - requests_session_response_ok: None, - server_api_key: str, - mocker: MockerFixture, -) -> None: - # Given: lazy evaluation is explicitly turned off (rollback kwarg). - flagsmith = Flagsmith( - environment_key=server_api_key, - enable_local_evaluation=True, - environment_refresh_interval_seconds=0.1, - lazy_identity_evaluation=False, - ) - spy = mocker.spy(engine, "get_evaluation_result") - - # When - flags = flagsmith.get_identity_flags("someone") - - # Then: the bulk evaluation ran up-front and every flag is resolved. - assert spy.call_count == 1 - assert set(flags.flags.keys()) == {"some_feature"} - - @responses.activate() def test_get_identity_flags__transient_identity__calls_expected( flagsmith: Flagsmith, From 783c683556b434e493561fa58c358b035e6d7274 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 28 Apr 2026 11:37:01 +0100 Subject: [PATCH 5/6] test(lazy): Lift _make_lazy_context into pytest fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the module-level helper with three fixtures: * ``lazy_context_factory`` — keyword-only callable producing an evaluation context, for tests that need a non-default shape. * ``lazy_context`` — a default context (identity matches the segment override). * ``lazy_flags`` — a ``Flags`` built from the default context. Tests now request whichever fixture suits their case instead of calling the helper directly. While here, mark each test body with Given / When / Then comments to match the rest of the file. beep boop --- tests/test_models.py | 234 ++++++++++++++++++++++++++----------------- 1 file changed, 141 insertions(+), 93 deletions(-) diff --git a/tests/test_models.py b/tests/test_models.py index f67918b..29d074a 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -172,108 +172,138 @@ def test_get_flag_without_pipeline_processor() -> None: assert flag.enabled is True -def _make_lazy_context( - *, - extra_features: int = 2, - identity_trait_value: str = "premium", - segment_match_value: str = "premium", -) -> SDKEvaluationContext: - """Build a minimal evaluation context for lazy-Flags tests. +LazyContextFactory = typing.Callable[..., SDKEvaluationContext] + + +@pytest.fixture +def lazy_context_factory() -> LazyContextFactory: + """Factory for minimal evaluation contexts used by the lazy-Flags tests. - Structure: a "target" feature with a single segment override that - matches when ``tier == segment_match_value`` (priority 0), plus a - handful of no-override "noise" features whose values should come - straight off the base feature context. ``identity_trait_value`` sets - the identity's ``tier`` trait so tests can exercise match / no-match. + 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. """ - 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}, + + 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}, + }, } - 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}, - }, - ], + 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}, - }, - } + "identity": { + "identifier": "user-1", + "key": "env-key_user-1", + "traits": {"tier": identity_trait_value}, + }, + } + return make -def test_lazy_flags__get_flag__applies_matching_segment_override() -> None: - ctx = _make_lazy_context() - flags = Flags.from_evaluation_context( - context=ctx, - overrides_index=build_segment_overrides_index(ctx), + +@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, ) - target = flags.get_flag("target") + +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() -> None: - # Segment rule requires tier == "premium"; identity has tier "free", - # so the override must not win and base-value should come through. - ctx = _make_lazy_context(identity_trait_value="free") - +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() -> None: - ctx = _make_lazy_context(extra_features=5) +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), @@ -281,18 +311,23 @@ def test_lazy_flags__get_flag__caches_per_feature() -> None: default_flag_handler=None, ) + # When: we read a single feature once. flags.get_flag("noise_0") - # Only the accessed feature is populated. + + # Then: only that feature is populated in the cache. assert set(flags.flags.keys()) == {"noise_0"} - # A repeated read hits the cache rather than rebuilding the Flag. + # 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() -> None: - ctx = _make_lazy_context(extra_features=3) +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), @@ -300,34 +335,44 @@ def test_lazy_flags__all_flags__materialises_every_feature() -> 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"} - # Second call is a no-op: everything is already resolved. - assert flags.all_flags() == materialised + # 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() -> None: - ctx = _make_lazy_context() +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=ctx, - overrides_index=build_segment_overrides_index(ctx), + 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() -> None: - ctx = _make_lazy_context() - # Add a second segment without overrides — must not appear in the index. - assert ctx["segments"] is not None - ctx["segments"]["no_override_segment"] = { +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": [ @@ -340,6 +385,9 @@ def test_build_segment_overrides_index__indexes_only_overriding_segments() -> No ], } - index = build_segment_overrides_index(ctx) + # 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" From 7ce45cfa76ee2de0eec7f95b4d29b592b6c57658 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 28 Apr 2026 11:39:58 +0100 Subject: [PATCH 6/6] style: Use single backticks in comments and docstrings beep boop --- flagsmith/models.py | 16 ++++++++-------- tests/test_flagsmith.py | 8 ++++---- tests/test_models.py | 8 ++++---- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/flagsmith/models.py b/flagsmith/models.py index 9ba4a20..9e48724 100644 --- a/flagsmith/models.py +++ b/flagsmith/models.py @@ -88,11 +88,11 @@ 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 + # 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``). + # 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 @@ -135,10 +135,10 @@ def from_evaluation_context( 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. + """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`` + 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). """ @@ -231,7 +231,7 @@ def get_flag(self, feature_name: str) -> typing.Union[DefaultFlag, Flag]: try: flag = self.flags[feature_name] except KeyError: - # Lazy path: if this ``Flags`` wraps an evaluation context and + # 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. @@ -266,7 +266,7 @@ def get_flag(self, feature_name: str) -> typing.Union[DefaultFlag, 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 + 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 @@ -277,7 +277,7 @@ def _resolve_flag(self, feature_name: str) -> Flag: """ context = self._context overrides_index = self._overrides_index - # ``get_flag`` / ``all_flags`` gate this call behind the same + # `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 diff --git a/tests/test_flagsmith.py b/tests/test_flagsmith.py index 47b6d77..b5891ed 100644 --- a/tests/test_flagsmith.py +++ b/tests/test_flagsmith.py @@ -194,8 +194,8 @@ def test_get_identity_flags_uses_local_environment_when_available( # Given flagsmith._evaluation_context = 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. + # `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, @@ -260,8 +260,8 @@ def test_get_identity_flags_includes_segments_in_evaluation_context( mock_get_evaluation_result.return_value = expected_evaluation_result - # When: ``all_flags`` triggers the bulk evaluation path on the lazy - # ``Flags`` object, which is where the full identity context — segments + # 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() diff --git a/tests/test_models.py b/tests/test_models.py index 29d074a..b8aaca1 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -179,9 +179,9 @@ def test_get_flag_without_pipeline_processor() -> None: 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 + 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. """ @@ -259,7 +259,7 @@ def lazy_context( @pytest.fixture def lazy_flags(lazy_context: SDKEvaluationContext) -> Flags: - """Lazy ``Flags`` built from the default context, no analytics, no handler.""" + """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),