Skip to content

Commit 0608980

Browse files
committed
Merge branch 'perf/local-eval-regression' into feat/flags-context-values
# Conflicts: # flagsmith/local_evaluation.py # tests/test_flagsmith.py
2 parents d875def + 8fd7a17 commit 0608980

7 files changed

Lines changed: 111 additions & 91 deletions

File tree

‎flagsmith/flagsmith.py‎

Lines changed: 12 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@
3030
from flagsmith.types import (
3131
ApplicationMetadata,
3232
JsonType,
33-
SDKEvaluationContext,
3433
StreamEvent,
3534
TraitMapping,
3635
)
@@ -129,7 +128,6 @@ def __init__(
129128
self.enable_realtime_updates = enable_realtime_updates
130129
self._analytics_processor: typing.Optional[AnalyticsProcessor] = None
131130
self._event_processor: typing.Optional[EventProcessor] = None
132-
self.__evaluation_context: typing.Optional[SDKEvaluationContext] = None
133131
self._local_evaluation: typing.Optional[LocalEvaluation] = None
134132
self._environment_updated_at: typing.Optional[datetime] = None
135133

@@ -152,8 +150,10 @@ def __init__(
152150
)
153151

154152
if self.offline_handler:
155-
self._evaluation_context = map_environment_document_to_context(
156-
self.offline_handler.get_environment()
153+
self._local_evaluation = LocalEvaluation(
154+
map_environment_document_to_context(
155+
self.offline_handler.get_environment()
156+
)
157157
)
158158

159159
if not self.offline_mode:
@@ -242,12 +242,13 @@ def _initialise_local_evaluation(self) -> None:
242242
# method calls, update the environment manually.
243243
self.update_environment()
244244
if self.enable_realtime_updates:
245-
if not self._evaluation_context:
245+
if not self._local_evaluation:
246246
raise ValueError("Unable to get environment from API key")
247247

248+
environment_key = self._local_evaluation.context["environment"]["key"]
248249
stream_url = urljoin(
249250
self.realtime_api_url,
250-
f"sse/environments/{self._evaluation_context['environment']['key']}/stream",
251+
f"sse/environments/{environment_key}/stream",
251252
)
252253

253254
self.event_stream_thread = EventStreamManager(
@@ -285,7 +286,7 @@ def get_environment_flags(self) -> Flags:
285286
"""
286287
if (
287288
self.offline_mode or self.enable_local_evaluation
288-
) and self._evaluation_context:
289+
) and self._local_evaluation:
289290
return self._get_environment_flags_from_document()
290291
return self._get_environment_flags_from_api()
291292

@@ -313,7 +314,7 @@ def get_identity_flags(
313314
traits = traits or {}
314315
if (
315316
self.offline_mode or self.enable_local_evaluation
316-
) and self._evaluation_context:
317+
) and self._local_evaluation:
317318
return self._get_identity_flags_from_document(identifier, traits)
318319
return self._get_identity_flags_from_api(
319320
identifier,
@@ -343,7 +344,7 @@ def get_identity_segments(
343344
# Identity overrides segments are left out: they aren't segments
344345
# in Flagsmith, so they're never returned.
345346
context = map_context_and_identity_data_to_context(
346-
context=local_evaluation.base_context,
347+
context=local_evaluation.context,
347348
identifier=identifier,
348349
traits=traits,
349350
)
@@ -466,8 +467,8 @@ def update_environment(self) -> None:
466467
logger.exception("Error retrieving environment document from API")
467468
else:
468469
try:
469-
self._evaluation_context = map_environment_document_to_context(
470-
environment_data,
470+
self._local_evaluation = LocalEvaluation(
471+
map_environment_document_to_context(environment_data)
471472
)
472473
self._environment_updated_at = (
473474
map_environment_document_to_environment_updated_at(
@@ -477,25 +478,6 @@ def update_environment(self) -> None:
477478
except (KeyError, TypeError, ValueError):
478479
logger.exception("Error parsing environment document")
479480

480-
@property
481-
def _evaluation_context(self) -> typing.Optional[SDKEvaluationContext]:
482-
return self.__evaluation_context
483-
484-
@_evaluation_context.setter
485-
def _evaluation_context(
486-
self, context: typing.Optional[SDKEvaluationContext]
487-
) -> None:
488-
"""Swap in a new evaluation context and rebuild local evaluation state.
489-
490-
`LocalEvaluation` holds everything derived from the environment
491-
document, e.g. the segment overrides index. Rebuilt once per refresh
492-
and reused across every evaluation, without any hot-path cost.
493-
"""
494-
self._local_evaluation = (
495-
LocalEvaluation(context) if context is not None else None
496-
)
497-
self.__evaluation_context = context
498-
499481
def _get_headers(
500482
self,
501483
environment_key: str,

‎flagsmith/local_evaluation.py‎

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from flagsmith.mappers import (
1111
get_identity_overrides_identifiers,
1212
map_context_and_identity_data_to_context,
13-
map_identifiers_to_identity_overrides_rules,
13+
map_identifiers_to_identity_overrides_rule,
1414
)
1515
from flagsmith.models import (
1616
Flag,
@@ -68,10 +68,9 @@ class LocalEvaluation:
6868
"""
6969

7070
def __init__(self, context: SDKEvaluationContext) -> None:
71-
self.context = context
72-
7371
segments: SegmentContexts = {}
7472
self._identity_free_segments: SegmentContexts = {}
73+
self._identity_overrides_segments: SegmentContexts = {}
7574
reads_flags = reads_identity = False
7675
self._identity_overrides_segment_keys: typing.Dict[str, typing.List[str]] = {}
7776
for segment_key, segment_context in (context.get("segments") or {}).items():
@@ -84,6 +83,7 @@ def __init__(self, context: SDKEvaluationContext) -> None:
8483
if not reads.identity:
8584
self._identity_free_segments[segment_key] = segment_context
8685
continue
86+
self._identity_overrides_segments[segment_key] = segment_context
8787
for identifier in identifiers:
8888
self._identity_overrides_segment_keys.setdefault(identifier, []).append(
8989
segment_key
@@ -92,16 +92,16 @@ def __init__(self, context: SDKEvaluationContext) -> None:
9292
self.reads = _ContextReads(flags=reads_flags, identity=reads_identity)
9393

9494
# The context without identity overrides segments.
95-
self.base_context: SDKEvaluationContext = {**context, "segments": segments}
95+
self.context: SDKEvaluationContext = {**context, "segments": segments}
9696
self.overrides_index: SegmentOverridesIndex = build_segment_overrides_index(
97-
self.base_context
97+
self.context
9898
)
9999

100100
@cached_property
101101
def environment_flags(self) -> typing.Dict[str, Flag]:
102102
"""Environment flags, which depend on the environment document only."""
103103
evaluation_result = engine.get_evaluation_result(
104-
context={**self.base_context, "segments": self._identity_free_segments},
104+
context={**self.context, "segments": self._identity_free_segments},
105105
)
106106
return {
107107
feature_name: Flag.from_evaluation_result(flag_result)
@@ -120,7 +120,7 @@ def get_identity_context(
120120
many identities share the same overrides.
121121
"""
122122
context = map_context_and_identity_data_to_context(
123-
context=self.base_context,
123+
context=self.context,
124124
identifier=identifier,
125125
traits=traits,
126126
)
@@ -129,11 +129,10 @@ def get_identity_context(
129129

130130
identity_segments: SegmentContexts = {}
131131
identity_overrides_index: SegmentOverridesIndex = {}
132-
segment_contexts = self.context.get("segments") or {}
133132
for segment_key in segment_keys:
134133
segment_context: SegmentContext[SegmentMetadata, FeatureMetadata] = {
135-
**segment_contexts[segment_key],
136-
"rules": map_identifiers_to_identity_overrides_rules([identifier]),
134+
**self._identity_overrides_segments[segment_key],
135+
"rules": [map_identifiers_to_identity_overrides_rule([identifier])],
137136
}
138137
identity_segments[segment_key] = segment_context
139138
for override in segment_context.get("overrides") or ():

‎flagsmith/mappers.py‎

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from flag_engine.context.types import (
99
FeatureContext,
1010
FeatureValue,
11+
InOperatorSegmentCondition,
1112
SegmentContext,
1213
SegmentRule,
1314
StrValueSegmentCondition,
@@ -179,7 +180,7 @@ def _map_identity_overrides_to_segments(
179180
segment_contexts[segment_key] = SegmentContext(
180181
key=segment_key,
181182
name="identity_overrides",
182-
rules=map_identifiers_to_identity_overrides_rules(identifiers),
183+
rules=[map_identifiers_to_identity_overrides_rule(identifiers)],
183184
overrides=[
184185
{
185186
"key": "", # Identity overrides never carry multivariate options
@@ -196,21 +197,15 @@ def _map_identity_overrides_to_segments(
196197
return segment_contexts
197198

198199

199-
def map_identifiers_to_identity_overrides_rules(
200+
def map_identifiers_to_identity_overrides_rule(
200201
identifiers: typing.List[str],
201-
) -> typing.List[SegmentRule]:
202-
return [
203-
{
204-
"type": "ALL",
205-
"conditions": [
206-
{
207-
"property": "$.identity.identifier",
208-
"operator": "IN",
209-
"value": identifiers,
210-
}
211-
],
212-
}
213-
]
202+
) -> SegmentRule:
203+
condition: InOperatorSegmentCondition = {
204+
"property": "$.identity.identifier",
205+
"operator": "IN",
206+
"value": identifiers,
207+
}
208+
return {"type": "ALL", "conditions": [condition]}
214209

215210

216211
def get_identity_overrides_identifiers(
@@ -220,10 +215,9 @@ def get_identity_overrides_identifiers(
220215
any other segment."""
221216
if (segment_context.get("metadata") or {}).get("source") != "identity_overrides":
222217
return None
223-
# As built by `map_identifiers_to_identity_overrides_rules`.
224218
[rule] = segment_context["rules"]
225219
[condition] = rule["conditions"]
226-
return typing.cast(typing.List[str], condition["value"])
220+
return typing.cast(InOperatorSegmentCondition, condition)["value"]
227221

228222

229223
def _map_environment_document_rules_to_context_rules(

‎flagsmith/models.py‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@
1616
SegmentMetadata,
1717
)
1818

19-
# Segment contexts by their key in the evaluation context.
20-
SegmentContexts = typing.Dict[str, SegmentContext[SegmentMetadata, FeatureMetadata]]
21-
# Feature name -> segment contexts overriding that feature.
22-
SegmentOverridesIndex = typing.MutableMapping[str, SegmentContexts]
19+
SegmentKey = str
20+
FeatureName = str
21+
SegmentContexts = typing.Dict[
22+
SegmentKey, SegmentContext[SegmentMetadata, FeatureMetadata]
23+
]
24+
SegmentOverridesIndex = typing.MutableMapping[FeatureName, SegmentContexts]
2325

2426

2527
def build_segment_overrides_index(

‎tests/test_flagsmith.py‎

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
FlagsmithAPIError,
1818
FlagsmithFeatureDoesNotExistError,
1919
)
20+
from flagsmith.local_evaluation import LocalEvaluation
2021
from flagsmith.mappers import map_environment_document_to_context
2122
from flagsmith.models import DefaultFlag, ExperimentMetadata, Flag, Flags
2223
from flagsmith.offline_handlers import OfflineHandler
@@ -48,14 +49,16 @@ def test_update_environment_sets_environment(
4849
) -> None:
4950
# Given
5051
responses.add(method="GET", url=flagsmith.environment_url, body=environment_json)
51-
assert flagsmith._evaluation_context is None
52+
assert flagsmith._local_evaluation is None
5253

5354
# When
5455
flagsmith.update_environment()
5556

5657
# Then
57-
assert flagsmith._evaluation_context is not None
58-
assert flagsmith._evaluation_context == evaluation_context
58+
assert flagsmith._local_evaluation is not None
59+
assert flagsmith._local_evaluation.context == (
60+
LocalEvaluation(evaluation_context).context
61+
)
5962

6063

6164
@responses.activate()
@@ -85,7 +88,7 @@ def test_get_environment_flags_uses_local_environment_when_available(
8588
evaluation_context: SDKEvaluationContext,
8689
) -> None:
8790
# Given
88-
flagsmith._evaluation_context = evaluation_context
91+
flagsmith._local_evaluation = LocalEvaluation(evaluation_context)
8992
flagsmith.enable_local_evaluation = True
9093

9194
# When
@@ -150,7 +153,9 @@ def test_get_environment_flags__local_evaluation__includes_identity_free_segment
150153
# Given
151154
# the only segment has the given rules, plus identity overrides
152155
environment["project"]["segments"][0]["rules"] = rules
153-
flagsmith._evaluation_context = map_environment_document_to_context(environment)
156+
flagsmith._local_evaluation = LocalEvaluation(
157+
map_environment_document_to_context(environment)
158+
)
154159
flagsmith.enable_local_evaluation = True
155160
spy = mocker.spy(engine, "get_evaluation_result")
156161

@@ -168,7 +173,6 @@ def test_get_environment_flags__local_evaluation__includes_identity_free_segment
168173
def test_get_environment_flags__local_evaluation__evaluates_once_per_environment(
169174
mocker: MockerFixture,
170175
local_eval_flagsmith: Flagsmith,
171-
evaluation_context: SDKEvaluationContext,
172176
) -> None:
173177
# Given
174178
spy = mocker.spy(engine, "get_evaluation_result")
@@ -183,13 +187,22 @@ def test_get_environment_flags__local_evaluation__evaluates_once_per_environment
183187
assert first.flags == second.flags
184188
assert first.flags is not second.flags
185189

190+
191+
def test_get_environment_flags__environment_refreshed__evaluates_again(
192+
mocker: MockerFixture,
193+
local_eval_flagsmith: Flagsmith,
194+
evaluation_context: SDKEvaluationContext,
195+
) -> None:
196+
# Given
197+
local_eval_flagsmith.get_environment_flags()
198+
spy = mocker.spy(engine, "get_evaluation_result")
199+
186200
# When
187-
# the environment is refreshed
188-
local_eval_flagsmith._evaluation_context = evaluation_context
201+
local_eval_flagsmith._local_evaluation = LocalEvaluation(evaluation_context)
189202
local_eval_flagsmith.get_environment_flags()
190203

191204
# Then
192-
assert spy.call_count == 2
205+
spy.assert_called_once()
193206

194207

195208
@responses.activate()
@@ -254,7 +267,7 @@ def test_get_identity_flags_uses_local_environment_when_available(
254267
mocker: MockerFixture,
255268
) -> None:
256269
# Given
257-
flagsmith._evaluation_context = evaluation_context
270+
flagsmith._local_evaluation = LocalEvaluation(evaluation_context)
258271
flagsmith.enable_local_evaluation = True
259272
# `Flags` materialises identity flags via `engine.get_evaluation_result`
260273
# imported from `flagsmith.models`, so patch it where it's actually used.
@@ -350,7 +363,9 @@ def test_get_identity_flags__flags_condition__evaluates_full_context(
350363
"conditions": [_condition("$.flags.some_feature.enabled")],
351364
}
352365
]
353-
flagsmith._evaluation_context = map_environment_document_to_context(environment)
366+
flagsmith._local_evaluation = LocalEvaluation(
367+
map_environment_document_to_context(environment)
368+
)
354369
flagsmith.enable_local_evaluation = True
355370
spy = mocker.spy(engine, "get_evaluation_result")
356371

0 commit comments

Comments
 (0)