Skip to content

Commit

Permalink
Add forced-decisions APIs to OptimizelyUserContext (#361)
Browse files Browse the repository at this point in the history
* 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 <54209343+ozayr-zaviar@users.noreply.github.com>

* Update optimizely/optimizely_user_context.py

Co-authored-by: ozayr-zaviar <54209343+ozayr-zaviar@users.noreply.github.com>

* Update optimizely/optimizely_user_context.py

Co-authored-by: ozayr-zaviar <54209343+ozayr-zaviar@users.noreply.github.com>

* make rule key optional in OptimizelyDecisionContext

* Mutex lock and testcases added

* Update optimizely/optimizely_user_context.py

Co-authored-by: ozayr-zaviar <54209343+ozayr-zaviar@users.noreply.github.com>

* use get() vs [] in remove_forced_decision

* add missing colon

* fix displaying reasons

* Update optimizely/optimizely.py

Co-authored-by: Jae Kim <45045038+jaeopt@users.noreply.github.com>

* 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 <uzairzaviar@gmail.com>

* 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 <uzairzaviar@gmail.com>
Co-authored-by: ozayr-zaviar <54209343+ozayr-zaviar@users.noreply.github.com>
Co-authored-by: Jae Kim <45045038+jaeopt@users.noreply.github.com>
Co-authored-by: msohailhussain <mirza.sohailhussain@gmail.com>
  • Loading branch information
5 people authored Dec 6, 2021
1 parent a1e31eb commit d85d272
Show file tree
Hide file tree
Showing 11 changed files with 1,943 additions and 895 deletions.
371 changes: 206 additions & 165 deletions optimizely/decision_service.py

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions optimizely/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,23 @@ def get_audience_conditions_or_ids(self):
def __str__(self):
return self.key

@staticmethod
def get_default():
""" returns an empty experiment object. """
experiment = Experiment(
id='',
key='',
layerId='',
status='',
variations=[],
trafficAllocation=[],
audienceIds=[],
audienceConditions=[],
forcedVariations={}
)

return experiment


class FeatureFlag(BaseEntity):
def __init__(self, id, key, experimentIds, rolloutId, variables, groupId=None, **kwargs):
Expand All @@ -94,6 +111,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
3 changes: 3 additions & 0 deletions optimizely/event/user_event_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ def create_impression_event(

if variation_id and experiment_id:
variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id)
# need this condition when we send events involving forced decisions
elif variation_id and flag_key:
variation = project_config.get_flag_variation(flag_key, 'id', variation_id)
event_context = user_event.EventContext(
project_config.account_id, project_config.project_id, project_config.revision, project_config.anonymize_ip,
)
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
426 changes: 225 additions & 201 deletions optimizely/optimizely.py

Large diffs are not rendered by default.

173 changes: 171 additions & 2 deletions optimizely/optimizely_user_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,23 @@
# limitations under the License.
#

import copy
import threading

from .helpers import enums


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

def __init__(self, optimizely_client, user_id, user_attributes=None):
def __init__(self, optimizely_client, logger, user_id, user_attributes=None):
""" Create an instance of the Optimizely User Context.
Args:
optimizely_client: client used when calling decisions for this user context
logger: logger for logging
user_id: user id of this user context
user_attributes: user attributes to use for this user context
Expand All @@ -34,16 +38,48 @@ def __init__(self, optimizely_client, user_id, user_attributes=None):
"""

self.client = optimizely_client
self.logger = logger
self.user_id = user_id

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

self._user_attributes = user_attributes.copy() if user_attributes else {}
self.lock = threading.Lock()
self.forced_decisions_map = {}

# decision context
class OptimizelyDecisionContext(object):
""" Using class with attributes here instead of namedtuple because
class is extensible, it's easy to add another attribute if we wanted
to extend decision context.
"""
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.logger, self.user_id, self.get_user_attributes())

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

return user_context

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

def set_forced_decision(self, decision_context, decision):
"""
Sets the forced decision for a given decision context.
Args:
decision_context: a decision context.
decision: a forced decision.
Returns:
True if the forced decision has been set successfully.
"""
with self.lock:
self.forced_decisions_map[decision_context] = decision

return True

def get_forced_decision(self, decision_context):
"""
Gets the forced decision (variation key) for a given decision context.
Args:
decision_context: a decision context.
Returns:
A forced_decision or None if forced decisions are not set for the parameters.
"""
forced_decision = self.find_forced_decision(decision_context)
return forced_decision

def remove_forced_decision(self, decision_context):
"""
Removes the forced decision for a given decision context.
Args:
decision_context: a decision context.
Returns:
Returns: true if the forced decision has been removed successfully.
"""
with self.lock:
if decision_context in self.forced_decisions_map:
del self.forced_decisions_map[decision_context]
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.
"""
with self.lock:
self.forced_decisions_map.clear()

return True

def find_forced_decision(self, decision_context):
"""
Gets forced decision from forced decision map.
Args:
decision_context: a decision context.
Returns:
Forced decision.
"""
with self.lock:
if not self.forced_decisions_map:
return None

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

def find_validated_forced_decision(self, decision_context):
"""
Gets forced decisions based on flag key, rule key and variation.
Args:
decision context: a decision context
Returns:
Variation of the forced decision.
"""
reasons = []

forced_decision = self.find_forced_decision(decision_context)

flag_key = decision_context.flag_key
rule_key = decision_context.rule_key

if forced_decision:
# we use config here so we can use get_flag_variation() function which is defined in project_config
# otherwise we would us self.client instead of config
config = self.client.config_manager.get_config() if self.client else None
if not config:
return None, reasons
variation = config.get_flag_variation(flag_key, 'key', forced_decision.variation_key)
if variation:
if rule_key:
user_has_forced_decision = enums.ForcedDecisionLogs \
.USER_HAS_FORCED_DECISION_WITH_RULE_SPECIFIED.format(forced_decision.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.variation_key,
flag_key,
self.user_id)

reasons.append(user_has_forced_decision)
self.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.logger.debug(user_has_forced_decision_but_invalid)

return None, reasons
Loading

0 comments on commit d85d272

Please sign in to comment.