Skip to content

Commit

Permalink
Merge 5feca2c into 58c3857
Browse files Browse the repository at this point in the history
  • Loading branch information
thomaszurkan-optimizely committed Dec 3, 2020
2 parents 58c3857 + 5feca2c commit 563add9
Show file tree
Hide file tree
Showing 8 changed files with 500 additions and 14 deletions.
12 changes: 12 additions & 0 deletions optimizely/decision/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Copyright 2020, Optimizely
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
20 changes: 20 additions & 0 deletions optimizely/decision/decide_option.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Copyright 2020, Optimizely
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.


class DecideOption(object):
DISABLE_DECISION_EVENT = 'DISABLE_DECISION_EVENT'
ENABLED_FLAGS_ONLY = 'ENABLED_FLAGS_ONLY'
IGNORE_USER_PROFILE_SERVICE = 'IGNORE_USER_PROFILE_SERVICE'
INCLUDE_REASONS = 'INCLUDE_REASONS'
EXCLUDE_VARIABLES = 'EXCLUDE_VARIABLES'
24 changes: 24 additions & 0 deletions optimizely/decision/decision.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Copyright 2020, Optimizely
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.


class Decision(object):
def __init__(self, variation_key=None, enabled=None,
variables=None, rule_key=None, flag_key=None, user_context=None, reasons=None):
self.variation_key = variation_key
self.enabled = enabled or False
self.variables = variables or {}
self.rule_key = rule_key
self.flag_key = flag_key
self.user_context = user_context
self.reasons = reasons or []
18 changes: 18 additions & 0 deletions optimizely/decision/decision_message.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Copyright 2020, Optimizely
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.


class DecisionMessage(object):
SDK_NOT_READY = 'Optimizely SDK not configured properly yet.'
FLAG_KEY_INVALID = 'No flag was found for key "%s".'
VARIABLE_VALUE_INVALID = 'Variable value for key "%s" is invalid or wrong type.'
238 changes: 224 additions & 14 deletions optimizely/optimizely.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,31 +20,37 @@
from .config_manager import AuthDatafilePollingConfigManager
from .config_manager import PollingConfigManager
from .config_manager import StaticConfigManager
from .decision.decide_option import DecideOption
from .decision.decision import Decision
from .decision.decision_message import DecisionMessage
from .error_handler import NoOpErrorHandler as noop_error_handler
from .event import event_factory, user_event_factory
from .event.event_processor import ForwardingEventProcessor
from .event_dispatcher import EventDispatcher as default_event_dispatcher
from .helpers import enums, validator
from .helpers.enums import DecisionSources
from .notification_center import NotificationCenter
from .optimizely_config import OptimizelyConfigService
from .user_context import UserContext


class Optimizely(object):
""" Class encapsulating all SDK functionality. """

def __init__(
self,
datafile=None,
event_dispatcher=None,
logger=None,
error_handler=None,
skip_json_validation=False,
user_profile_service=None,
sdk_key=None,
config_manager=None,
notification_center=None,
event_processor=None,
datafile_access_token=None,
self,
datafile=None,
event_dispatcher=None,
logger=None,
error_handler=None,
skip_json_validation=False,
user_profile_service=None,
sdk_key=None,
config_manager=None,
notification_center=None,
event_processor=None,
datafile_access_token=None,
default_decisions=None
):
""" Optimizely init method for managing Custom projects.
Expand All @@ -68,6 +74,7 @@ def __init__(
which simply forwards events to the event dispatcher.
To enable event batching configure and use optimizely.event.event_processor.BatchEventProcessor.
datafile_access_token: Optional string used to fetch authenticated datafile for a secure project environment.
default_decisions: Optional list of decide options used with the decide APIs.
"""
self.logger_name = '.'.join([__name__, self.__class__.__name__])
self.is_valid = True
Expand All @@ -79,6 +86,7 @@ def __init__(
self.event_processor = event_processor or ForwardingEventProcessor(
self.event_dispatcher, logger=self.logger, notification_center=self.notification_center,
)
self.default_decisions = default_decisions or []

try:
self._validate_instantiation_options()
Expand Down Expand Up @@ -192,7 +200,7 @@ def _send_impression_event(self, project_config, experiment, variation, flag_key
)

def _get_feature_variable_for_type(
self, project_config, feature_key, variable_key, variable_type, user_id, attributes,
self, project_config, feature_key, variable_key, variable_type, user_id, attributes,
):
""" Helper method to determine value for a certain variable attached to a feature flag based on type of variable.
Expand Down Expand Up @@ -296,7 +304,7 @@ def _get_feature_variable_for_type(
return actual_value

def _get_all_feature_variables_for_type(
self, project_config, feature_key, user_id, attributes,
self, project_config, feature_key, user_id, attributes,
):
""" Helper method to determine value for all variables attached to a feature flag.
Expand Down Expand Up @@ -913,3 +921,205 @@ def get_optimizely_config(self):
return self.config_manager.optimizely_config

return OptimizelyConfigService(project_config).get_config()

def create_user_context(self, user_id, attributes=None):
"""
We do not check for is_valid here as a user context can be created successfully
even when the SDK is not fully configured.
Args:
user_id: string to use as user id for user context
attributes: dictionary of attributes or None
Returns:
UserContext instance or None if the user id or attributes are invalid.
"""
if not isinstance(user_id, string_types):
self.logger.error(enums.Errors.INVALID_INPUT.format('user_id'))
return None

if attributes is not None and type(attributes) is not dict:
self.logger.error(enums.Errors.INVALID_INPUT.format('attributes'))
return None

user_context = UserContext(self, user_id, attributes)
return user_context

def decide(self, user_context, key, decide_options=None):
"""
decide calls optimizely decide with feature key provided
Args:
user_context: UserContent with userid and attributes
key: feature key
decide_options: list of DecideOption
Returns:
Decision object
"""

# raising on user context as it is internal and not provided directly by the user.
if not isinstance(user_context, UserContext):
raise exceptions.InvalidInputException(enums.Errors.INVALID_INPUT.format('user_context'))

reasons = []

# check if SDK is ready
if not self.is_valid:
self.logger.error(enums.Errors.INVALID_PROJECT_CONFIG.format('decide'))
reasons.append(DecisionMessage.SDK_NOT_READY)
return Decision(flag_key=key, user_context=user_context, reasons=reasons)

# validate that key is a string
if not isinstance(key, string_types):
self.logger.error('Key parameter is invalid')
reasons.append(DecisionMessage.FLAG_KEY_INVALID.format(key))
return Decision.new(flag_key=key, user_context=user_context, reasons=reasons)

# validate that key maps to a feature flag
config = self.config_manager.get_config()
if not config:
self.logger.error(enums.Errors.INVALID_PROJECT_CONFIG.format('decide'))
reasons.append(DecisionMessage.SDK_NOT_READY)
return Decision(flag_key=key, user_context=user_context, reasons=reasons)

feature_flag = None
for flag in config.feature_flags:
if flag['key'] == key:
feature_flag = flag
break
if feature_flag is None:
self.logger.error("No feature flag was found for key '#{key}'.")
reasons.push(DecisionMessage.FLAG_KEY_INVALID.format(key))
return Decision(flag_key=key, user_context=user_context, reasons=reasons)

# merge decide_options and default_decide_options
if isinstance(decide_options, list):
decide_options += self.default_decisions
else:
self.logger.debug('Provided decide options is not an array. Using default decide options.')
decide_options = self.default_decisions

# Create Optimizely Decision Result.
user_id = user_context.user_id
attributes = user_context.user_attributes
variation_key = None
feature_enabled = False
rule_key = None
flag_key = key
all_variables = {}
experiment = None
decision_source = DecisionSources.ROLLOUT
source_info = {}

decision = self.decision_service.get_variation_for_feature(config, feature_flag, user_context.user_id,
user_context.user_attributes)
# Fill in experiment and variation if returned (rollouts can have featureEnabled variables as well.)
if decision.experiment is not None:
experiment = decision.experiment
source_info["experiment"] = experiment
rule_key = experiment.key
if decision.variation is not None:
variation = decision.variation
variation_key = variation.key
feature_enabled = variation.featureEnabled
decision_source = decision.source
source_info["variation"] = variation

# Send impression event if Decision came from a feature
# test and decide options doesn't include disableDecisionEvent
if DecideOption.DISABLE_DECISION_EVENT not in decide_options:
if decision_source == DecisionSources.FEATURE_TEST or config.send_flag_decisions:
self._send_impression_event(config, experiment, variation, flag_key, rule_key or '',
feature_enabled, decision_source,
user_id, attributes)

# Generate all variables map if decide options doesn't include excludeVariables
if DecideOption.EXCLUDE_VARIABLES not in decide_options:
for v in feature_flag['variables']:
project_config = self.config_manager.get_config()
all_variables[v['key']] = self._get_feature_variable_for_type(project_config, feature_flag['key'],
v['key'], v['type'], user_id, attributes)

# Send notification
self.notification_center.send_notifications(
enums.NotificationTypes.DECISION,
enums.DecisionNotificationTypes.FEATURE,
user_id,
attributes or {},
{
'feature_key': key,
'feature_enabled': feature_enabled,
'source': decision.source,
'source_info': source_info,
},
)

include_reasons = []
if DecideOption.INCLUDE_REASONS in decide_options:
include_reasons = reasons

return Decision(variation_key=variation_key, enabled=feature_enabled, variables=all_variables,
rule_key=rule_key,
flag_key=flag_key, user_context=user_context, reasons=include_reasons)

def decide_all(self, user_context, decide_options=None):
"""
decide_all will return a decision for every feature key in the current config
Args:
user_context: UserContent object
decide_options: Array of DecisionOption
Returns:
A dictionary of feature key to Decision
"""
# raising on user context as it is internal and not provided directly by the user.
if not isinstance(user_context, UserContext):
raise exceptions.InvalidInputException(enums.Errors.INVALID_INPUT.format('user_context'))

# check if SDK is ready
if not self.is_valid:
self.logger.error(enums.Errors.INVALID_PROJECT_CONFIG.format('decide_all'))
return {}

config = self.config_manager.get_config()
reasons = []
if not config:
self.logger.error(enums.Errors.INVALID_PROJECT_CONFIG.format('decide'))
reasons.append(DecisionMessage.SDK_NOT_READY)
return Decision(user_context=user_context, reasons=reasons)

keys = []
for f in config.feature_flags:
keys.append(f['key'])

return self.decide_for_keys(user_context, keys, decide_options)

def decide_for_keys(self, user_context, keys, decide_options=[]):
"""
Args:
user_context: UserContent
keys: list of feature keys to run decide on.
decide_options: an array of DecisionOption objects
Returns:
An dictionary of feature key to Decision
"""
# raising on user context as it is internal and not provided directly by the user.
if not isinstance(user_context, UserContext):
raise exceptions.InvalidInputException(enums.Errors.INVALID_INPUT.format('user_context'))

# check if SDK is ready
if not self.is_valid:
self.logger.error(enums.Errors.INVALID_PROJECT_CONFIG.format('decide_for_keys'))
return {}

enabled_flags_only = DecideOption.ENABLED_FLAGS_ONLY in decide_options
decisions = {}
for key in keys:
decision = self.decide(user_context, key, decide_options)
if enabled_flags_only and not decision.enabled:
continue
decisions[key] = decision

return decisions

0 comments on commit 563add9

Please sign in to comment.