Skip to content

Commit

Permalink
Merge a837f03 into a1e31eb
Browse files Browse the repository at this point in the history
  • Loading branch information
Mat001 committed Nov 3, 2021
2 parents a1e31eb + a837f03 commit ca2cac2
Show file tree
Hide file tree
Showing 10 changed files with 1,902 additions and 818 deletions.
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

@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

0 comments on commit ca2cac2

Please sign in to comment.