Skip to content

Commit d85d272

Browse files
Mat001ozayr-zaviarjaeoptmsohailhussain
authored
Add forced-decisions APIs to OptimizelyUserContext (#361)
* add maps to project config * initial code * feat: add remaining implementation * WIP: addressed implementation PR comments and fixed failing unit tests * Fixed lint errors * fix failing tests in py 3.5 * fixed failing logger import for Py2 * add OptimizelyDecisionContext and OptmizelyForcedDecisions * testcases added * Update optimizely/optimizely_user_context.py Co-authored-by: ozayr-zaviar <[email protected]> * Update optimizely/optimizely_user_context.py Co-authored-by: ozayr-zaviar <[email protected]> * Update optimizely/optimizely_user_context.py Co-authored-by: ozayr-zaviar <[email protected]> * make rule key optional in OptimizelyDecisionContext * Mutex lock and testcases added * Update optimizely/optimizely_user_context.py Co-authored-by: ozayr-zaviar <[email protected]> * use get() vs [] in remove_forced_decision * add missing colon * fix displaying reasons * Update optimizely/optimizely.py Co-authored-by: Jae Kim <[email protected]> * address PR comments * more PR review fixes * fixed few more PR comments * added bucket reasons * FSC fixes * addressed more PR comments, fixed FSC test failuer about impressin events * address more PR comments * use is_valid check on opti client * addressed more PR comments * reasons and key name fixed * create get_default method for empty experiment object * fixed further PR comments * fix logger so we use the top logger in optimizely client * Refact: Refactored Forced decision (#365) * project config refactor * use existing loop to generate flag_variation_map * get_variation_from_experiment_rule and get_variation_from_delivery_rule removed * fsc test fix * comment addressed * commented code removed * comments from main forced decision PR resolved Co-authored-by: ozayr-zaviar <[email protected]> * coupl of corrections * remove check on config * remove redundant import * remove redundant test about invalid datafile * add reasons to return Co-authored-by: ozayr-zaviar <[email protected]> Co-authored-by: ozayr-zaviar <[email protected]> Co-authored-by: Jae Kim <[email protected]> Co-authored-by: msohailhussain <[email protected]>
1 parent a1e31eb commit d85d272

11 files changed

+1943
-895
lines changed

optimizely/decision_service.py

Lines changed: 206 additions & 165 deletions
Large diffs are not rendered by default.

optimizely/entities.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,23 @@ def get_audience_conditions_or_ids(self):
7474
def __str__(self):
7575
return self.key
7676

77+
@staticmethod
78+
def get_default():
79+
""" returns an empty experiment object. """
80+
experiment = Experiment(
81+
id='',
82+
key='',
83+
layerId='',
84+
status='',
85+
variations=[],
86+
trafficAllocation=[],
87+
audienceIds=[],
88+
audienceConditions=[],
89+
forcedVariations={}
90+
)
91+
92+
return experiment
93+
7794

7895
class FeatureFlag(BaseEntity):
7996
def __init__(self, id, key, experimentIds, rolloutId, variables, groupId=None, **kwargs):
@@ -94,6 +111,7 @@ def __init__(self, id, policy, experiments, trafficAllocation, **kwargs):
94111

95112

96113
class Layer(BaseEntity):
114+
"""Layer acts as rollout."""
97115
def __init__(self, id, experiments, **kwargs):
98116
self.id = id
99117
self.experiments = experiments

optimizely/event/user_event_factory.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ def create_impression_event(
5151

5252
if variation_id and experiment_id:
5353
variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id)
54+
# need this condition when we send events involving forced decisions
55+
elif variation_id and flag_key:
56+
variation = project_config.get_flag_variation(flag_key, 'id', variation_id)
5457
event_context = user_event.EventContext(
5558
project_config.account_id, project_config.project_id, project_config.revision, project_config.anonymize_ip,
5659
)

optimizely/helpers/enums.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,17 @@ class Errors(object):
115115
UNSUPPORTED_DATAFILE_VERSION = 'This version of the Python SDK does not support the given datafile version: "{}".'
116116

117117

118+
class ForcedDecisionLogs(object):
119+
USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED = 'Variation ({}) is mapped to flag ({}), rule ({}) and user ({}) ' \
120+
'in the forced decision map.'
121+
USER_HAS_FORCED_DECISION_WITHOUT_RULE_SPECIFIED = 'Variation ({}) is mapped to flag ({}) and user ({}) ' \
122+
'in the forced decision map.'
123+
USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED_BUT_INVALID = 'Invalid variation is mapped to flag ({}), rule ({}) ' \
124+
'and user ({}) in the forced decision map.'
125+
USER_HAS_FORCED_DECISION_WITHOUT_RULE_SPECIFIED_BUT_INVALID = 'Invalid variation is mapped to flag ({}) ' \
126+
'and user ({}) in the forced decision map.'
127+
128+
118129
class HTTPHeaders(object):
119130
AUTHORIZATION = 'Authorization'
120131
IF_MODIFIED_SINCE = 'If-Modified-Since'

optimizely/optimizely.py

Lines changed: 225 additions & 201 deletions
Large diffs are not rendered by default.

optimizely/optimizely_user_context.py

Lines changed: 171 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,23 @@
1313
# limitations under the License.
1414
#
1515

16+
import copy
1617
import threading
1718

19+
from .helpers import enums
20+
1821

1922
class OptimizelyUserContext(object):
2023
"""
2124
Representation of an Optimizely User Context using which APIs are to be called.
2225
"""
2326

24-
def __init__(self, optimizely_client, user_id, user_attributes=None):
27+
def __init__(self, optimizely_client, logger, user_id, user_attributes=None):
2528
""" Create an instance of the Optimizely User Context.
2629
2730
Args:
2831
optimizely_client: client used when calling decisions for this user context
32+
logger: logger for logging
2933
user_id: user id of this user context
3034
user_attributes: user attributes to use for this user context
3135
@@ -34,16 +38,48 @@ def __init__(self, optimizely_client, user_id, user_attributes=None):
3438
"""
3539

3640
self.client = optimizely_client
41+
self.logger = logger
3742
self.user_id = user_id
3843

3944
if not isinstance(user_attributes, dict):
4045
user_attributes = {}
4146

4247
self._user_attributes = user_attributes.copy() if user_attributes else {}
4348
self.lock = threading.Lock()
49+
self.forced_decisions_map = {}
50+
51+
# decision context
52+
class OptimizelyDecisionContext(object):
53+
""" Using class with attributes here instead of namedtuple because
54+
class is extensible, it's easy to add another attribute if we wanted
55+
to extend decision context.
56+
"""
57+
def __init__(self, flag_key, rule_key=None):
58+
self.flag_key = flag_key
59+
self.rule_key = rule_key
60+
61+
def __hash__(self):
62+
return hash((self.flag_key, self.rule_key))
63+
64+
def __eq__(self, other):
65+
return (self.flag_key, self.rule_key) == (other.flag_key, other.rule_key)
66+
67+
# forced decision
68+
class OptimizelyForcedDecision(object):
69+
def __init__(self, variation_key):
70+
self.variation_key = variation_key
4471

4572
def _clone(self):
46-
return OptimizelyUserContext(self.client, self.user_id, self.get_user_attributes())
73+
if not self.client:
74+
return None
75+
76+
user_context = OptimizelyUserContext(self.client, self.logger, self.user_id, self.get_user_attributes())
77+
78+
with self.lock:
79+
if self.forced_decisions_map:
80+
user_context.forced_decisions_map = copy.deepcopy(self.forced_decisions_map)
81+
82+
return user_context
4783

4884
def get_user_attributes(self):
4985
with self.lock:
@@ -114,3 +150,136 @@ def as_json(self):
114150
'user_id': self.user_id,
115151
'attributes': self.get_user_attributes(),
116152
}
153+
154+
def set_forced_decision(self, decision_context, decision):
155+
"""
156+
Sets the forced decision for a given decision context.
157+
158+
Args:
159+
decision_context: a decision context.
160+
decision: a forced decision.
161+
162+
Returns:
163+
True if the forced decision has been set successfully.
164+
"""
165+
with self.lock:
166+
self.forced_decisions_map[decision_context] = decision
167+
168+
return True
169+
170+
def get_forced_decision(self, decision_context):
171+
"""
172+
Gets the forced decision (variation key) for a given decision context.
173+
174+
Args:
175+
decision_context: a decision context.
176+
177+
Returns:
178+
A forced_decision or None if forced decisions are not set for the parameters.
179+
"""
180+
forced_decision = self.find_forced_decision(decision_context)
181+
return forced_decision
182+
183+
def remove_forced_decision(self, decision_context):
184+
"""
185+
Removes the forced decision for a given decision context.
186+
187+
Args:
188+
decision_context: a decision context.
189+
190+
Returns:
191+
Returns: true if the forced decision has been removed successfully.
192+
"""
193+
with self.lock:
194+
if decision_context in self.forced_decisions_map:
195+
del self.forced_decisions_map[decision_context]
196+
return True
197+
198+
return False
199+
200+
def remove_all_forced_decisions(self):
201+
"""
202+
Removes all forced decisions bound to this user context.
203+
204+
Returns:
205+
True if forced decisions have been removed successfully.
206+
"""
207+
with self.lock:
208+
self.forced_decisions_map.clear()
209+
210+
return True
211+
212+
def find_forced_decision(self, decision_context):
213+
"""
214+
Gets forced decision from forced decision map.
215+
216+
Args:
217+
decision_context: a decision context.
218+
219+
Returns:
220+
Forced decision.
221+
"""
222+
with self.lock:
223+
if not self.forced_decisions_map:
224+
return None
225+
226+
# must allow None to be returned for the Flags only case
227+
return self.forced_decisions_map.get(decision_context)
228+
229+
def find_validated_forced_decision(self, decision_context):
230+
"""
231+
Gets forced decisions based on flag key, rule key and variation.
232+
233+
Args:
234+
decision context: a decision context
235+
236+
Returns:
237+
Variation of the forced decision.
238+
"""
239+
reasons = []
240+
241+
forced_decision = self.find_forced_decision(decision_context)
242+
243+
flag_key = decision_context.flag_key
244+
rule_key = decision_context.rule_key
245+
246+
if forced_decision:
247+
# we use config here so we can use get_flag_variation() function which is defined in project_config
248+
# otherwise we would us self.client instead of config
249+
config = self.client.config_manager.get_config() if self.client else None
250+
if not config:
251+
return None, reasons
252+
variation = config.get_flag_variation(flag_key, 'key', forced_decision.variation_key)
253+
if variation:
254+
if rule_key:
255+
user_has_forced_decision = enums.ForcedDecisionLogs \
256+
.USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED.format(forced_decision.variation_key,
257+
flag_key,
258+
rule_key,
259+
self.user_id)
260+
261+
else:
262+
user_has_forced_decision = enums.ForcedDecisionLogs \
263+
.USER_HAS_FORCED_DECISION_WITHOUT_RULE_SPECIFIED.format(forced_decision.variation_key,
264+
flag_key,
265+
self.user_id)
266+
267+
reasons.append(user_has_forced_decision)
268+
self.logger.debug(user_has_forced_decision)
269+
270+
return variation, reasons
271+
272+
else:
273+
if rule_key:
274+
user_has_forced_decision_but_invalid = enums.ForcedDecisionLogs \
275+
.USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED_BUT_INVALID.format(flag_key,
276+
rule_key,
277+
self.user_id)
278+
else:
279+
user_has_forced_decision_but_invalid = enums.ForcedDecisionLogs \
280+
.USER_HAS_FORCED_DECISION_WITHOUT_RULE_SPECIFIED_BUT_INVALID.format(flag_key, self.user_id)
281+
282+
reasons.append(user_has_forced_decision_but_invalid)
283+
self.logger.debug(user_has_forced_decision_but_invalid)
284+
285+
return None, reasons

0 commit comments

Comments
 (0)