Skip to content

Commit 43c01c2

Browse files
authored
Merge pull request #154 from launchdarkly/eb/sc-132580/big-seg-2-impl
(big segments 2) implement evaluation, refactor eval logic & modules
2 parents 104b7ef + 7fc6fed commit 43c01c2

14 files changed

+791
-535
lines changed

docs/api-deprecated.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
Deprecated modules
2+
===============================
3+
4+
ldclient.flag module
5+
--------------------
6+
7+
This module is deprecated. For the :class:`~ldclient.evaluation.EvaluationDetail` type, please use :mod:`ldclient.evaluation`.
8+
9+
ldclient.flags_state module
10+
---------------------------
11+
12+
This module is deprecated. For the :class:`~ldclient.evaluation.FeatureFlagsState` type, please use :mod:`ldclient.evaluation`.

docs/api-main.rst

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,8 @@ ldclient.config module
1919
.. automodule:: ldclient.config
2020
:members:
2121

22-
ldclient.flag module
23-
--------------------
22+
ldclient.evaluation module
23+
--------------------------
2424

25-
.. automodule:: ldclient.flag
26-
:members: EvaluationDetail
27-
28-
ldclient.flags_state module
29-
---------------------------
30-
31-
.. automodule:: ldclient.flags_state
25+
.. automodule:: ldclient.evaluation
3226
:members:
33-
:exclude-members: __init__, add_flag

docs/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@ For more information, see LaunchDarkly's `Quickstart <https://docs.launchdarkly.
1919
api-main
2020
api-integrations
2121
api-extending
22+
api-deprecated

ldclient/client.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@
1515
from ldclient.event_processor import DefaultEventProcessor
1616
from ldclient.feature_requester import FeatureRequesterImpl
1717
from ldclient.feature_store import _FeatureStoreDataSetSorter
18-
from ldclient.flag import EvaluationDetail, evaluate, error_reason
19-
from ldclient.flags_state import FeatureFlagsState
18+
from ldclient.evaluation import EvaluationDetail, FeatureFlagsState
2019
from ldclient.impl.big_segments import NullBigSegmentStoreStatusProvider
20+
from ldclient.impl.evaluator import Evaluator, error_reason
2121
from ldclient.impl.event_factory import _EventFactory
2222
from ldclient.impl.stubs import NullEventProcessor, NullUpdateProcessor
2323
from ldclient.interfaces import BigSegmentStoreStatusProvider, FeatureStore
@@ -86,9 +86,16 @@ def __init__(self, config: Config, start_wait: float=5):
8686
self._event_factory_default = _EventFactory(False)
8787
self._event_factory_with_reasons = _EventFactory(True)
8888

89-
self._store = _FeatureStoreClientWrapper(self._config.feature_store)
89+
store = _FeatureStoreClientWrapper(self._config.feature_store)
90+
self._store = store
9091
""" :type: FeatureStore """
9192

93+
self._evaluator = Evaluator(
94+
lambda key: store.get(FEATURES, key, lambda x: x),
95+
lambda key: store.get(SEGMENTS, key, lambda x: x),
96+
lambda key: None # temporary - haven't yet implemented the component that does the big segments queries
97+
)
98+
9299
if self._config.offline:
93100
log.info("Started LaunchDarkly Client in offline mode")
94101

@@ -313,7 +320,7 @@ def _evaluate_internal(self, key, user, default, event_factory):
313320
return EvaluationDetail(default, None, reason)
314321

315322
try:
316-
result = evaluate(flag, user, self._store, event_factory)
323+
result = self._evaluator.evaluate(flag, user, event_factory)
317324
for event in result.events or []:
318325
self._send_event(event)
319326
detail = result.detail
@@ -384,7 +391,7 @@ def all_flags_state(self, user: dict, **kwargs) -> FeatureFlagsState:
384391
if client_only and not flag.get('clientSide', False):
385392
continue
386393
try:
387-
detail = evaluate(flag, user, self._store, self._event_factory_default).detail
394+
detail = self._evaluator.evaluate(flag, user, self._event_factory_default).detail
388395
state.add_flag(flag, detail.value, detail.variation_index,
389396
detail.reason if with_reasons else None, details_only_if_tracked)
390397
except Exception as e:

ldclient/evaluation.py

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import json
2+
import time
3+
from typing import Any, Dict, Optional
4+
5+
class EvaluationDetail:
6+
"""
7+
The return type of :func:`ldclient.client.LDClient.variation_detail()`, combining the result of a
8+
flag evaluation with information about how it was calculated.
9+
"""
10+
def __init__(self, value: object, variation_index: Optional[int], reason: dict):
11+
"""Constructs an instance.
12+
"""
13+
self.__value = value
14+
self.__variation_index = variation_index
15+
self.__reason = reason
16+
17+
@property
18+
def value(self) -> object:
19+
"""The result of the flag evaluation. This will be either one of the flag's
20+
variations or the default value that was passed to the
21+
:func:`ldclient.client.LDClient.variation_detail()` method.
22+
"""
23+
return self.__value
24+
25+
@property
26+
def variation_index(self) -> Optional[int]:
27+
"""The index of the returned value within the flag's list of variations, e.g.
28+
0 for the first variation -- or None if the default value was returned.
29+
"""
30+
return self.__variation_index
31+
32+
@property
33+
def reason(self) -> dict:
34+
"""A dictionary describing the main factor that influenced the flag evaluation value.
35+
It contains the following properties:
36+
37+
* ``kind``: The general category of reason, as follows:
38+
39+
* ``"OFF"``: the flag was off
40+
* ``"FALLTHROUGH"``: the flag was on but the user did not match any targets or rules
41+
* ``"TARGET_MATCH"``: the user was specifically targeted for this flag
42+
* ``"RULE_MATCH"``: the user matched one of the flag's rules
43+
* ``"PREREQUISITE_FAILED"``: the flag was considered off because it had at least one
44+
prerequisite flag that did not return the desired variation
45+
* ``"ERROR"``: the flag could not be evaluated due to an unexpected error.
46+
47+
* ``ruleIndex``, ``ruleId``: The positional index and unique identifier of the matched
48+
rule, if the kind was ``RULE_MATCH``
49+
50+
* ``prerequisiteKey``: The flag key of the prerequisite that failed, if the kind was
51+
``PREREQUISITE_FAILED``
52+
53+
* ``errorKind``: further describes the nature of the error if the kind was ``ERROR``,
54+
e.g. ``"FLAG_NOT_FOUND"``
55+
56+
* ``bigSegmentsStatus``: describes the validity of Big Segment information, if and only if
57+
the flag evaluation required querying at least one Big Segment; otherwise it returns None.
58+
Allowable values are defined in :class:`BigSegmentsStatus`. For more information, read the
59+
LaunchDarkly documentation: https://docs.launchdarkly.com/home/users/big-segments
60+
"""
61+
return self.__reason
62+
63+
def is_default_value(self) -> bool:
64+
"""Returns True if the flag evaluated to the default value rather than one of its
65+
variations.
66+
"""
67+
return self.__variation_index is None
68+
69+
def __eq__(self, other) -> bool:
70+
return self.value == other.value and self.variation_index == other.variation_index and self.reason == other.reason
71+
72+
def __ne__(self, other) -> bool:
73+
return not self.__eq__(other)
74+
75+
def __str__(self) -> str:
76+
return "(value=%s, variation_index=%s, reason=%s)" % (self.value, self.variation_index, self.reason)
77+
78+
def __repr__(self) -> str:
79+
return self.__str__()
80+
81+
82+
class BigSegmentsStatus:
83+
"""
84+
Indicates that the Big Segment query involved in the flag evaluation was successful, and
85+
the segment state is considered up to date.
86+
"""
87+
HEALTHY = "HEALTHY"
88+
89+
"""
90+
Indicates that the Big Segment query involved in the flag evaluation was successful, but
91+
segment state may not be up to date.
92+
"""
93+
STALE = "STALE"
94+
95+
"""
96+
Indicates that Big Segments could not be queried for the flag evaluation because the SDK
97+
configuration did not include a Big Segment store.
98+
"""
99+
NOT_CONFIGURED = "NOT_CONFIGURED"
100+
101+
"""
102+
Indicates that the Big Segment query involved in the flag evaluation failed, for
103+
instance due to a database error.
104+
"""
105+
STORE_ERROR = "STORE_ERROR"
106+
107+
108+
class FeatureFlagsState:
109+
"""
110+
A snapshot of the state of all feature flags with regard to a specific user, generated by
111+
calling the :func:`ldclient.client.LDClient.all_flags_state()` method. Serializing this
112+
object to JSON, using the :func:`to_json_dict` method or ``jsonpickle``, will produce the
113+
appropriate data structure for bootstrapping the LaunchDarkly JavaScript client. See the
114+
JavaScript SDK Reference Guide on `Bootstrapping <https://docs.launchdarkly.com/sdk/features/bootstrapping#javascript>`_.
115+
"""
116+
def __init__(self, valid: bool):
117+
self.__flag_values = {} # type: Dict[str, Any]
118+
self.__flag_metadata = {} # type: Dict[str, Any]
119+
self.__valid = valid
120+
121+
# Used internally to build the state map
122+
def add_flag(self, flag, value, variation, reason, details_only_if_tracked):
123+
key = flag['key']
124+
self.__flag_values[key] = value
125+
meta = {}
126+
with_details = (not details_only_if_tracked) or flag.get('trackEvents')
127+
if not with_details:
128+
if flag.get('debugEventsUntilDate'):
129+
now = int(time.time() * 1000)
130+
with_details = (flag.get('debugEventsUntilDate') > now)
131+
if with_details:
132+
meta['version'] = flag.get('version')
133+
if reason is not None:
134+
meta['reason'] = reason
135+
if variation is not None:
136+
meta['variation'] = variation
137+
if flag.get('trackEvents'):
138+
meta['trackEvents'] = True
139+
if flag.get('debugEventsUntilDate') is not None:
140+
meta['debugEventsUntilDate'] = flag.get('debugEventsUntilDate')
141+
self.__flag_metadata[key] = meta
142+
143+
@property
144+
def valid(self) -> bool:
145+
"""True if this object contains a valid snapshot of feature flag state, or False if the
146+
state could not be computed (for instance, because the client was offline or there was no user).
147+
"""
148+
return self.__valid
149+
150+
151+
def get_flag_value(self, key: str) -> object:
152+
"""Returns the value of an individual feature flag at the time the state was recorded.
153+
154+
:param key: the feature flag key
155+
:return: the flag's value; None if the flag returned the default value, or if there was no such flag
156+
"""
157+
return self.__flag_values.get(key)
158+
159+
def get_flag_reason(self, key: str) -> Optional[dict]:
160+
"""Returns the evaluation reason for an individual feature flag at the time the state was recorded.
161+
162+
:param key: the feature flag key
163+
:return: a dictionary describing the reason; None if reasons were not recorded, or if there was no
164+
such flag
165+
"""
166+
meta = self.__flag_metadata.get(key)
167+
return None if meta is None else meta.get('reason')
168+
169+
def to_values_map(self) -> dict:
170+
"""Returns a dictionary of flag keys to flag values. If the flag would have evaluated to the
171+
default value, its value will be None.
172+
173+
Do not use this method if you are passing data to the front end to "bootstrap" the JavaScript client.
174+
Instead, use :func:`to_json_dict()`.
175+
"""
176+
return self.__flag_values
177+
178+
def to_json_dict(self) -> dict:
179+
"""Returns a dictionary suitable for passing as JSON, in the format used by the LaunchDarkly
180+
JavaScript SDK. Use this method if you are passing data to the front end in order to
181+
"bootstrap" the JavaScript client.
182+
"""
183+
ret = self.__flag_values.copy()
184+
ret['$flagsState'] = self.__flag_metadata
185+
ret['$valid'] = self.__valid
186+
return ret
187+
188+
def to_json_string(self) -> str:
189+
"""Same as to_json_dict, but serializes the JSON structure into a string.
190+
"""
191+
return json.dumps(self.to_json_dict())
192+
193+
def __getstate__(self) -> dict:
194+
"""Equivalent to to_json_dict() - used if you are serializing the object with jsonpickle.
195+
"""
196+
return self.to_json_dict()

0 commit comments

Comments
 (0)