Skip to content

Add forced-decisions APIs to OptimizelyUserContext #361

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 42 commits into from
Dec 6, 2021
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
c5d9dae
add maps to project config
Mat001 Sep 9, 2021
17ad742
initial code
Mat001 Sep 15, 2021
cee1fb8
Merge branch 'master' of github.com:optimizely/python-sdk into mpirno…
Mat001 Sep 16, 2021
58977d2
feat: add remaining implementation
Mat001 Oct 1, 2021
340cbce
WIP: addressed implementation PR comments and fixed failing unit tests
Mat001 Oct 19, 2021
c81a425
Fixed lint errors
Mat001 Oct 19, 2021
c89bc3c
fix failing tests in py 3.5
Mat001 Oct 20, 2021
2fe78ab
fixed failing logger import for Py2
Mat001 Oct 20, 2021
d80c555
add OptimizelyDecisionContext and OptmizelyForcedDecisions
Mat001 Oct 27, 2021
5ed2fb4
testcases added
ozayr-zaviar Nov 2, 2021
6003fdc
Update optimizely/optimizely_user_context.py
Mat001 Nov 2, 2021
e4dc745
Update optimizely/optimizely_user_context.py
Mat001 Nov 2, 2021
d75f389
Update optimizely/optimizely_user_context.py
Mat001 Nov 2, 2021
68146a1
make rule key optional in OptimizelyDecisionContext
Mat001 Nov 2, 2021
a261899
Mutex lock and testcases added
ozayr-zaviar Nov 3, 2021
a837f03
Merge branch 'master' into mpirnovar/forced_decisions
Mat001 Nov 3, 2021
de4a31c
Update optimizely/optimizely_user_context.py
Mat001 Nov 5, 2021
0c52707
use get() vs [] in remove_forced_decision
Mat001 Nov 5, 2021
4116b43
add self lock and keys(0
Mat001 Nov 5, 2021
081cd79
add missing colon
Mat001 Nov 7, 2021
e061abc
fix displaying reasons
Mat001 Nov 11, 2021
337f8d9
Update optimizely/optimizely.py
Mat001 Nov 12, 2021
a71f50e
address PR comments
Mat001 Nov 16, 2021
981cbe5
updating
Mat001 Nov 16, 2021
94d5af9
more PR review fixes
Mat001 Nov 17, 2021
e9cd304
fixed few more PR comments
Mat001 Nov 17, 2021
2dff4c6
added bucket reasons
Mat001 Nov 17, 2021
e2f1db3
FSC fixes
ozayr-zaviar Nov 18, 2021
6849c33
addressed more PR comments, fixed FSC test failuer about impressin ev…
Mat001 Nov 19, 2021
55fe98f
address more PR comments
Mat001 Nov 19, 2021
94a0c26
use is_valid check on opti client
Mat001 Nov 19, 2021
e5aaccb
addressed more PR comments
Mat001 Nov 19, 2021
44373e9
reasons and key name fixed
ozayr-zaviar Nov 22, 2021
e6c1772
create get_default method for empty experiment object
Mat001 Nov 22, 2021
ab40d9e
fixed further PR comments
Mat001 Nov 24, 2021
795b41a
fix logger so we use the top logger in optimizely client
Mat001 Dec 1, 2021
dbbc051
Refact: Refactored Forced decision (#365)
msohailhussain Dec 2, 2021
75fe2bb
coupl of corrections
Mat001 Dec 2, 2021
243d447
remove check on config
Mat001 Dec 2, 2021
1010ece
remove redundant import
Mat001 Dec 2, 2021
17efc27
remove redundant test about invalid datafile
Mat001 Dec 3, 2021
201548f
add reasons to return
Mat001 Dec 4, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
278 changes: 186 additions & 92 deletions optimizely/decision_service.py

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions optimizely/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ def __init__(self, id, policy, experiments, trafficAllocation, **kwargs):


class Layer(BaseEntity):
"""Layer acts as rollout."""
def __init__(self, id, experiments, **kwargs):
self.id = id
self.experiments = experiments
Expand Down
11 changes: 11 additions & 0 deletions optimizely/helpers/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,17 @@ class Errors(object):
UNSUPPORTED_DATAFILE_VERSION = 'This version of the Python SDK does not support the given datafile version: "{}".'


class ForcedDecisionLogs(object):
USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED = 'Variation ({}) is mapped to flag ({}), rule ({}) and user ({}) ' \
'in the forced decision map.'
USER_HAS_FORCED_DECISION_WITHOUT_RULE_SPECIFIED = 'Variation ({}) is mapped to flag ({}) and user ({}) ' \
'in the forced decision map.'
USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED_BUT_INVALID = 'Invalid variation is mapped to flag ({}), rule ({}) ' \
'and user ({}) in the forced decision map.'
USER_HAS_FORCED_DECISION_WITHOUT_RULE_SPECIFIED_BUT_INVALID = 'Invalid variation is mapped to flag ({}) ' \
'and user ({}) in the forced decision map.'


class HTTPHeaders(object):
AUTHORIZATION = 'Authorization'
IF_MODIFIED_SINCE = 'If-Modified-Since'
Expand Down
455 changes: 254 additions & 201 deletions optimizely/optimizely.py

Large diffs are not rendered by default.

176 changes: 175 additions & 1 deletion optimizely/optimizely_user_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,13 @@
# limitations under the License.
#

import copy
import threading

from . import logger
from .decision.optimizely_decision_message import OptimizelyDecisionMessage
from .helpers import enums


class OptimizelyUserContext(object):
"""
Expand All @@ -41,9 +46,38 @@ def __init__(self, optimizely_client, user_id, user_attributes=None):

self._user_attributes = user_attributes.copy() if user_attributes else {}
self.lock = threading.Lock()
with self.lock:
self.forced_decisions = {}
self.log = logger.SimpleLogger(min_level=enums.LogLevels.INFO)

# decision context
class OptimizelyDecisionContext(object):
def __init__(self, flag_key, rule_key=None):
self.flag_key = flag_key
self.rule_key = rule_key

def __hash__(self):
return hash((self.flag_key, self.rule_key))

def __eq__(self, other):
return (self.flag_key, self.rule_key) == (other.flag_key, other.rule_key)

# forced decision
class OptimizelyForcedDecision(object):
def __init__(self, variation_key):
self.variation_key = variation_key

def _clone(self):
return OptimizelyUserContext(self.client, self.user_id, self.get_user_attributes())
if not self.client:
return None

user_context = OptimizelyUserContext(self.client, self.user_id, self.get_user_attributes())

with self.lock:
if self.forced_decisions:
user_context.forced_decisions = copy.deepcopy(self.forced_decisions)

return user_context

def get_user_attributes(self):
with self.lock:
Expand Down Expand Up @@ -114,3 +148,143 @@ def as_json(self):
'user_id': self.user_id,
'attributes': self.get_user_attributes(),
}

def set_forced_decision(self, OptimizelyDecisionContext, OptimizelyForcedDecision):
"""
Sets the forced decision for a given decision context.

Args:
OptimizelyDecisionContext: a decision context.
OptimizelyForcedDecision: a forced decision.

Returns:
True if the forced decision has been set successfully.
"""
config = self.client.get_optimizely_config()

if self.client is None or config is None:
self.log.logger.error(OptimizelyDecisionMessage.SDK_NOT_READY)
return False

context = OptimizelyDecisionContext
decision = OptimizelyForcedDecision

with self.lock:
self.forced_decisions[context] = decision

return True

def get_forced_decision(self, OptimizelyDecisionContext):
"""
Gets the forced decision (variation key) for a given decision context.

Args:
OptimizelyDecisionContext: a decision context.

Returns:
A variation key or None if forced decisions are not set for the parameters.
"""
config = self.client.get_optimizely_config()

if self.client is None or config is None:
self.log.logger.error(OptimizelyDecisionMessage.SDK_NOT_READY)
return None

forced_decision_key = self.find_forced_decision(OptimizelyDecisionContext)

return forced_decision_key if forced_decision_key else None

def remove_forced_decision(self, OptimizelyDecisionContext):
"""
Removes the forced decision for a given flag and an optional rule.

Args:
OptimizelyDecisionContext: a decision context.

Returns:
Returns: true if the forced decision has been removed successfully.
"""
config = self.client.get_optimizely_config()

if self.client is None or config is None:
self.log.logger.error(OptimizelyDecisionMessage.SDK_NOT_READY)
return False

with self.lock:
if self.forced_decisions[OptimizelyDecisionContext]:
del self.forced_decisions[OptimizelyDecisionContext]
return True

return False

def remove_all_forced_decisions(self):
"""
Removes all forced decisions bound to this user context.

Returns:
True if forced decisions have been removed successfully.
"""
config = self.client.get_optimizely_config()

if self.client is None or config is None:
self.log.logger.error(OptimizelyDecisionMessage.SDK_NOT_READY)
return False

with self.lock:
self.forced_decisions.clear()

return True

def find_forced_decision(self, OptimizelyDecisionContext):

with self.lock:
if not self.forced_decisions:
return None

# must allow None to be returned for the Flags only case
return self.forced_decisions.get(OptimizelyDecisionContext)

def find_validated_forced_decision(self, OptimizelyDecisionContext, options):

reasons = []

forced_decision_response = self.find_forced_decision(OptimizelyDecisionContext)

flag_key = OptimizelyDecisionContext.flag_key
rule_key = OptimizelyDecisionContext.rule_key

if forced_decision_response:
variation = self.client.get_flag_variation_by_key(flag_key, forced_decision_response.variation_key)
if variation:
if rule_key:
user_has_forced_decision = enums.ForcedDecisionLogs \
.USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED.format(forced_decision_response.variation_key,
flag_key,
rule_key,
self.user_id)

else:
user_has_forced_decision = enums.ForcedDecisionLogs \
.USER_HAS_FORCED_DECISION_WITHOUT_RULE_SPECIFIED.format(forced_decision_response.variation_key,
flag_key,
self.user_id)

reasons.append(user_has_forced_decision)
self.log.logger.debug(user_has_forced_decision)

return variation, reasons

else:
if rule_key:
user_has_forced_decision_but_invalid = enums.ForcedDecisionLogs \
.USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED_BUT_INVALID.format(flag_key,
rule_key,
self.user_id)
else:
user_has_forced_decision_but_invalid = enums.ForcedDecisionLogs \
.USER_HAS_FORCED_DECISION_WITHOUT_RULE_SPECIFIED_BUT_INVALID.format(flag_key, self.user_id)

reasons.append(user_has_forced_decision_but_invalid)
self.log.logger.debug(user_has_forced_decision_but_invalid)

return None, reasons
95 changes: 78 additions & 17 deletions optimizely/project_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@
# limitations under the License.

import json
from collections import OrderedDict

from .helpers import condition as condition_helper
from .helpers import enums
from . import entities
from . import exceptions
from .helpers import condition as condition_helper
from .helpers import enums

SUPPORTED_VERSIONS = [
enums.DatafileVersions.V2,
Expand Down Expand Up @@ -134,11 +135,45 @@ def __init__(self, datafile, logger, error_handler):
self.experiment_feature_map = {}
for feature in self.feature_key_map.values():
feature.variables = self._generate_key_map(feature.variables, 'key', entities.Variable)

for exp_id in feature.experimentIds:
# Add this experiment in experiment-feature map.
self.experiment_feature_map[exp_id] = [feature.id]

# all rules(experiment rules and delivery rules) for each flag
self.flag_rules_map = {}
for flag in self.feature_flags:

experiments = []
if not flag['experimentIds'] == '':
for exp_id in flag['experimentIds']:
experiments.append(self.experiment_id_map[exp_id])
if not flag['rolloutId'] == '':
rollout = self.rollout_id_map[flag['rolloutId']]

rollout_experiments = self.get_rollout_experiments_map(rollout)

if rollout and rollout.experiments:
experiments.extend(rollout_experiments)

self.flag_rules_map[flag['key']] = experiments

# All variations for each flag
# Datafile does not contain a separate entity for this.
# We collect variations used in each rule (experiment rules and delivery rules)
self.flag_variations_map = {}

for flag_key, rules in self.flag_rules_map.items():
variations = []
for rule in rules:
# get variations as objects (rule.variations gives list)
variation_objects = self.variation_key_map[rule.key].values()

for variation in variation_objects:
if variation.id not in [var.id for var in variations]:
variations.append(variation)

self.flag_variations_map[flag_key] = variations
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add test cases for validating flag_variations_map if not done yet

  • builds variations map for all rules
  • discards redundant variations in the same flag

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in progress


@staticmethod
def _generate_key_map(entity_list, key, entity_class):
""" Helper method to generate map from key to entity object for given list of dicts.
Expand All @@ -152,7 +187,10 @@ def _generate_key_map(entity_list, key, entity_class):
Map mapping key to entity object.
"""

key_map = {}
# using ordered dict here to preserve insertion order of entities
# OrderedDict() is needed for Py versions 3.5 and less to work.
# Insertion order has been made default in dicts since Py 3.6
key_map = OrderedDict()
for obj in entity_list:
key_map[obj[key]] = entity_class(**obj)

Expand All @@ -175,6 +213,21 @@ def _deserialize_audience(audience_map):

return audience_map

def get_rollout_experiments_map(self, rollout):
""" Helper method to get rollout experiments as a map.

Args:
rollout: rollout

Returns:
Mapped rollout experiments.
"""

rollout_experiments_id_map = self._generate_key_map(rollout.experiments, 'id', entities.Experiment)
rollout_experiments = [exper for exper in rollout_experiments_id_map.values()]

return rollout_experiments

def get_typecast_value(self, value, type):
""" Helper method to determine actual value based on type of feature variable.

Expand Down Expand Up @@ -334,31 +387,40 @@ def get_audience(self, audience_id):
self.logger.error('Audience ID "%s" is not in datafile.' % audience_id)
self.error_handler.handle_error(exceptions.InvalidAudienceException((enums.Errors.INVALID_AUDIENCE)))

def get_variation_from_key(self, experiment_key, variation_key):
""" Get variation given experiment and variation key.
def get_variation_from_key(self, experiment_key, variation):
""" Get variation given experiment and variation.

Args:
experiment: Key representing parent experiment of variation.
variation_key: Key representing the variation.
Variation is of type variation object or None.

Returns
Object representing the variation.
"""

variation_map = self.variation_key_map.get(experiment_key)
variation_key = None

if variation_map:
variation = variation_map.get(variation_key)
if variation:
return variation
if isinstance(variation, tuple):
if isinstance(variation[0], entities.Variation):
variation_key, received_reasons = variation
else:
variation_map = self.variation_key_map.get(experiment_key)

if variation_map:
variation_key = variation_map.get(variation)
else:
self.logger.error('Variation key "%s" is not in datafile.' % variation_key)
self.error_handler.handle_error(exceptions.InvalidVariationException(enums.Errors.INVALID_VARIATION))
self.logger.error('Experiment key "%s" is not in datafile.' % experiment_key)
self.error_handler.handle_error(
exceptions.InvalidExperimentException(enums.Errors.INVALID_EXPERIMENT_KEY))
return None

self.logger.error('Experiment key "%s" is not in datafile.' % experiment_key)
self.error_handler.handle_error(exceptions.InvalidExperimentException(enums.Errors.INVALID_EXPERIMENT_KEY))
return None
if variation_key:
return variation_key
else:
self.logger.error('Variation key "%s" is not in datafile.' % variation)
self.error_handler.handle_error(exceptions.InvalidVariationException(enums.Errors.INVALID_VARIATION))
return None

def get_variation_from_id(self, experiment_key, variation_id):
""" Get variation given experiment and variation ID.
Expand Down Expand Up @@ -485,7 +547,6 @@ def get_variable_value_for_variation(self, variable, variation):

if not variable or not variation:
return None

if variation.id not in self.variation_variable_usage_map:
self.logger.error('Variation with ID "%s" is not in the datafile.' % variation.id)
return None
Expand Down
Loading