33import typing
44from dataclasses import dataclass , field
55
6+ from flag_engine import engine
7+ from flag_engine .context .types import SegmentContext
8+
69from flagsmith .analytics import AnalyticsProcessor , PipelineAnalyticsProcessor
710from flagsmith .exceptions import FlagsmithFeatureDoesNotExistError
8- from flagsmith .types import SDKEvaluationResult , SDKFlagResult
11+ from flagsmith .types import (
12+ FeatureMetadata ,
13+ SDKEvaluationContext ,
14+ SDKEvaluationResult ,
15+ SDKFlagResult ,
16+ SegmentMetadata ,
17+ )
18+
19+ SegmentOverridesIndex = typing .Dict [
20+ str , typing .List [SegmentContext [SegmentMetadata , FeatureMetadata ]]
21+ ]
22+
23+
24+ def build_segment_overrides_index (
25+ context : SDKEvaluationContext ,
26+ ) -> SegmentOverridesIndex :
27+ """Map feature_name -> segments that carry an override for that feature.
28+
29+ Computed once per environment-document refresh so the lazy eval path
30+ can walk only the segments actually relevant to a given flag.
31+ """
32+ index : SegmentOverridesIndex = {}
33+ for segment_context in (context .get ("segments" ) or {}).values ():
34+ for override in segment_context .get ("overrides" ) or ():
35+ index .setdefault (override ["name" ], []).append (segment_context )
36+ return index
937
1038
1139@dataclass
@@ -60,6 +88,14 @@ class Flags:
6088 _pipeline_analytics_processor : typing .Optional [PipelineAnalyticsProcessor ] = None
6189 _identity_identifier : typing .Optional [str ] = None
6290 _traits : typing .Optional [typing .Dict [str , typing .Any ]] = None
91+ # Lazy-evaluation state. When `_context` is set, `flags` is a
92+ # per-feature memo rather than a fully-materialised snapshot; unseen
93+ # features are resolved on demand via the engine primitives and
94+ # cached back into `flags`. Left as `None` by the eager code
95+ # paths (`from_evaluation_result` / `from_api_flags`).
96+ _context : typing .Optional [SDKEvaluationContext ] = None
97+ _overrides_index : typing .Optional [SegmentOverridesIndex ] = None
98+ _fully_materialised : bool = False
6399
64100 @classmethod
65101 def from_evaluation_result (
@@ -86,6 +122,37 @@ def from_evaluation_result(
86122 _traits = traits ,
87123 )
88124
125+ @classmethod
126+ def from_evaluation_context (
127+ cls ,
128+ context : SDKEvaluationContext ,
129+ overrides_index : SegmentOverridesIndex ,
130+ analytics_processor : typing .Optional [AnalyticsProcessor ],
131+ default_flag_handler : typing .Optional [typing .Callable [[str ], DefaultFlag ]],
132+ pipeline_analytics_processor : typing .Optional [
133+ PipelineAnalyticsProcessor
134+ ] = None ,
135+ identity_identifier : typing .Optional [str ] = None ,
136+ traits : typing .Optional [typing .Dict [str , typing .Any ]] = None ,
137+ ) -> Flags :
138+ """Build a lazy `Flags` backed by an evaluation context.
139+
140+ No engine work is done here — flags are resolved on first access
141+ via :meth:`_resolve_flag`. Reusing the same `overrides_index`
142+ across calls amortises its construction cost (it's rebuilt only
143+ when the environment doc refreshes, not per identity).
144+ """
145+ return cls (
146+ flags = {},
147+ default_flag_handler = default_flag_handler ,
148+ _analytics_processor = analytics_processor ,
149+ _pipeline_analytics_processor = pipeline_analytics_processor ,
150+ _identity_identifier = identity_identifier ,
151+ _traits = traits ,
152+ _context = context ,
153+ _overrides_index = overrides_index ,
154+ )
155+
89156 @classmethod
90157 def from_api_flags (
91158 cls ,
@@ -116,8 +183,21 @@ def all_flags(self) -> typing.List[Flag]:
116183 """
117184 Get a list of all Flag objects.
118185
186+ In lazy mode, the caller has signalled they want every flag, so
187+ we run the bulk evaluator once on the full context and copy the
188+ results into the per-flag cache. Cheaper than asking the engine
189+ for each feature one at a time.
190+
119191 :return: list of Flag objects.
120192 """
193+ if self ._context is not None and not self ._fully_materialised :
194+ result = engine .get_evaluation_result (self ._context )
195+ for feature_name , flag_result in result ["flags" ].items ():
196+ if feature_name not in self .flags :
197+ self .flags [feature_name ] = Flag .from_evaluation_result (
198+ flag_result ,
199+ )
200+ self ._fully_materialised = True
121201 return list (self .flags .values ())
122202
123203 def is_feature_enabled (self , feature_name : str ) -> bool :
@@ -151,11 +231,23 @@ def get_flag(self, feature_name: str) -> typing.Union[DefaultFlag, Flag]:
151231 try :
152232 flag = self .flags [feature_name ]
153233 except KeyError :
154- if self .default_flag_handler :
234+ # Lazy path: if this `Flags` wraps an evaluation context and
235+ # the feature exists in it, resolve and memoise now. Otherwise
236+ # fall through to the default_flag_handler / not-found error,
237+ # preserving the eager-mode behaviour byte-for-byte.
238+ if (
239+ self ._context is not None
240+ and self ._overrides_index is not None
241+ and feature_name in (self ._context .get ("features" ) or {})
242+ ):
243+ flag = self ._resolve_flag (feature_name )
244+ self .flags [feature_name ] = flag
245+ elif self .default_flag_handler :
155246 return self .default_flag_handler (feature_name )
156- raise FlagsmithFeatureDoesNotExistError (
157- "Feature does not exist: %s" % feature_name
158- )
247+ else :
248+ raise FlagsmithFeatureDoesNotExistError (
249+ "Feature does not exist: %s" % feature_name
250+ )
159251
160252 if self ._analytics_processor and hasattr (flag , "feature_name" ):
161253 self ._analytics_processor .track_feature (flag .feature_name )
@@ -171,6 +263,35 @@ def get_flag(self, feature_name: str) -> typing.Union[DefaultFlag, Flag]:
171263
172264 return flag
173265
266+ def _resolve_flag (self , feature_name : str ) -> Flag :
267+ """Evaluate a single feature against the lazy context.
268+
269+ Goes through the engine's public `get_evaluation_result` so
270+ identity-key enrichment, multivariate hashing, percentage-split
271+ rules and override-priority handling all stay where they
272+ belong (in the engine). The performance win comes from passing
273+ a *trimmed* context — just the queried feature plus the segments
274+ that could override it, looked up in O(1) via the precomputed
275+ reverse index — so the engine's full pipeline runs against an
276+ input small enough to evaluate in ~1 µs.
277+ """
278+ context = self ._context
279+ overrides_index = self ._overrides_index
280+ # `get_flag` / `all_flags` gate this call behind the same
281+ # non-None checks; assert here so type checkers can narrow.
282+ assert context is not None and overrides_index is not None
283+
284+ trimmed : SDKEvaluationContext = {
285+ ** context ,
286+ "features" : {feature_name : context ["features" ][feature_name ]},
287+ "segments" : {
288+ segment_context ["key" ]: segment_context
289+ for segment_context in overrides_index .get (feature_name , ())
290+ },
291+ }
292+ result = engine .get_evaluation_result (trimmed )
293+ return Flag .from_evaluation_result (result ["flags" ][feature_name ])
294+
174295
175296@dataclass
176297class Segment :
0 commit comments