Skip to content

Commit a6cd22c

Browse files
authored
Merge pull request #55 from optimizely/devel
Merge devel into master
2 parents 6ba07f3 + 2dcc5ea commit a6cd22c

18 files changed

+1086
-243
lines changed

CONTRIBUTING.md

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
# Contributing to the Optimizely Python SDK
2+
23
We welcome contributions and feedback! All contributors must sign our [Contributor License Agreement (CLA)](https://docs.google.com/a/optimizely.com/forms/d/e/1FAIpQLSf9cbouWptIpMgukAKZZOIAhafvjFCV8hS00XJLWQnWDFtwtA/viewform) to be eligible to contribute. Please read the [README](README.md) to set up your development environment, then read the guidelines below for information on submitting your code.
34

45
## Development process
56

6-
1. Create a branch off of `devel`: `git checkout -b YOUR_NAME/branch_name`.
7+
1. Create a branch off of `master`: `git checkout -b YOUR_NAME/branch_name`.
78
2. Commit your changes. Make sure to add tests!
8-
3. Lint your changes before submitting with `pep8 YOUR_CHANGED_FILES.py`.
9+
3. Lint your changes before submitting: `pep8 YOUR_CHANGED_FILES.py`.
910
4. `git push` your changes to GitHub.
10-
5. Make sure that all unit tests are passing and that there are no merge conflicts between your branch and `devel`.
11-
6. Open a pull request from `YOUR_NAME/branch_name` to `devel`.
12-
7. A repository maintainer will review your pull request and, if all goes well, merge it!
11+
5. Make sure that all unit tests are passing and that there are no merge conflicts between your branch and `master`.
12+
6. Open a pull request from `YOUR_NAME/branch_name` to `master`.
13+
7. A repository maintainer will review your pull request and, if all goes well, squash and merge it!
1314

1415
## Pull request acceptance criteria
1516

@@ -19,20 +20,22 @@ We welcome contributions and feedback! All contributors must sign our [Contribut
1920
* Lint your code with PEP-8 before submitting.
2021

2122
## Style
23+
2224
We enforce PEP-8 rules with a few minor deviations.
2325

2426
## License
2527

2628
All contributions are under the CLA mentioned above. For this project, Optimizely uses the Apache 2.0 license, and so asks that by contributing your code, you agree to license your contribution under the terms of the [Apache License v2.0](http://www.apache.org/licenses/LICENSE-2.0). Your contributions should also include the following header:
2729

2830
```
29-
# Copyright 2017, Optimizely
31+
# Copyright YEAR, Optimizely, Inc. and contributors
32+
#
3033
# Licensed under the Apache License, Version 2.0 (the "License");
3134
# you may not use this file except in compliance with the License.
3235
# You may obtain a copy of the License at
3336
#
3437
# http://www.apache.org/licenses/LICENSE-2.0
35-
38+
#
3639
# Unless required by applicable law or agreed to in writing, software
3740
# distributed under the License is distributed on an "AS IS" BASIS,
3841
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@@ -43,4 +46,5 @@ All contributions are under the CLA mentioned above. For this project, Optimizel
4346
The YEAR above should be the year of the contribution. If work on the file has been done over multiple years, list each year in the section above. Example: Optimizely writes the file and releases it in 2014. No changes are made in 2015. Change made in 2016. YEAR should be “2014, 2016”.
4447

4548
## Contact
49+
4650
If you have questions, please contact [email protected].

optimizely/bucketer.py

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2016, Optimizely
1+
# Copyright 2016-2017, Optimizely
22
# Licensed under the Apache License, Version 2.0 (the "License");
33
# you may not use this file except in compliance with the License.
44
# You may obtain a copy of the License at
@@ -103,16 +103,6 @@ def bucket(self, experiment, user_id):
103103
if not experiment:
104104
return None
105105

106-
# Check if user is white-listed for a variation
107-
forced_variations = experiment.forcedVariations
108-
if forced_variations and user_id in forced_variations:
109-
variation_key = forced_variations.get(user_id)
110-
variation = self.config.get_variation_from_key(experiment.key, variation_key)
111-
if variation:
112-
self.config.logger.log(enums.LogLevels.INFO,
113-
'User "%s" is forced in variation "%s".' % (user_id, variation_key))
114-
return variation
115-
116106
# Determine if experiment is in a mutually exclusive group
117107
if experiment.groupPolicy in GROUP_POLICIES:
118108
group = self.config.get_group(experiment.groupId)

optimizely/decision_service.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# Copyright 2017, Optimizely
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
14+
import sys
15+
16+
from . import bucketer
17+
from .helpers import audience as audience_helper
18+
from .helpers import enums
19+
from .helpers import experiment as experiment_helper
20+
from .helpers import validator
21+
from .user_profile import UserProfile
22+
23+
24+
class DecisionService(object):
25+
""" Class encapsulating all decision related capabilities. """
26+
27+
def __init__(self, config, user_profile_service):
28+
self.bucketer = bucketer.Bucketer(config)
29+
self.user_profile_service = user_profile_service
30+
self.config = config
31+
self.logger = config.logger
32+
33+
def get_forced_variation(self, experiment, user_id):
34+
""" Determine if a user is forced into a variation for the given experiment and return that variation.
35+
36+
Args:
37+
experiment: Object representing the experiment for which user is to be bucketed.
38+
user_id: ID for the user.
39+
40+
Returns:
41+
Variation in which the user with ID user_id is forced into. None if no variation.
42+
"""
43+
44+
forced_variations = experiment.forcedVariations
45+
if forced_variations and user_id in forced_variations:
46+
variation_key = forced_variations.get(user_id)
47+
variation = self.config.get_variation_from_key(experiment.key, variation_key)
48+
if variation:
49+
self.config.logger.log(enums.LogLevels.INFO,
50+
'User "%s" is forced in variation "%s".' % (user_id, variation_key))
51+
return variation
52+
53+
return None
54+
55+
def get_stored_variation(self, experiment, user_profile):
56+
""" Determine if the user has a stored variation available for the given experiment and return that.
57+
58+
Args:
59+
experiment: Object representing the experiment for which user is to be bucketed.
60+
user_profile: UserProfile object representing the user's profile.
61+
62+
Returns:
63+
Variation if available. None otherwise.
64+
"""
65+
66+
user_id = user_profile.user_id
67+
variation_id = user_profile.get_variation_for_experiment(experiment.id)
68+
69+
if variation_id:
70+
variation = self.config.get_variation_from_id(experiment.key, variation_id)
71+
if variation:
72+
self.config.logger.log(enums.LogLevels.INFO,
73+
'Found a stored decision. User "%s" is in variation "%s" of experiment "%s".' %
74+
(user_id, variation.key, experiment.key))
75+
return variation
76+
77+
return None
78+
79+
def get_variation(self, experiment, user_id, attributes):
80+
""" Top-level function to help determine variation user should be put in.
81+
82+
First, check if experiment is running.
83+
Second, check if user is forced in a variation.
84+
Third, check if there is a stored decision for the user and return the corresponding variation.
85+
Fourth, figure out if user is in the experiment by evaluating audience conditions if any.
86+
Fifth, bucket the user and return the variation.
87+
88+
Args:
89+
experiment_key: Experiment for which user variation needs to be determined.
90+
user_id: ID for user.
91+
attributes: Dict representing user attributes.
92+
93+
Returns:
94+
Variation user should see. None if user is not in experiment or experiment is not running.
95+
"""
96+
97+
# Check if experiment is running
98+
if not experiment_helper.is_experiment_running(experiment):
99+
self.logger.log(enums.LogLevels.INFO, 'Experiment "%s" is not running.' % experiment.key)
100+
return None
101+
102+
# Check to see if user is white-listed for a certain variation
103+
variation = self.get_forced_variation(experiment, user_id)
104+
if variation:
105+
return variation
106+
107+
# Check to see if user has a decision available for the given experiment
108+
user_profile = UserProfile(user_id)
109+
if self.user_profile_service:
110+
try:
111+
retrieved_profile = self.user_profile_service.lookup(user_id)
112+
except:
113+
error = sys.exc_info()[1]
114+
self.logger.log(
115+
enums.LogLevels.ERROR,
116+
'Unable to retrieve user profile for user "%s" as lookup failed. Error: %s' % (user_id, str(error))
117+
)
118+
retrieved_profile = None
119+
120+
if validator.is_user_profile_valid(retrieved_profile):
121+
user_profile = UserProfile(**retrieved_profile)
122+
variation = self.get_stored_variation(experiment, user_profile)
123+
if variation:
124+
return variation
125+
else:
126+
self.logger.log(enums.LogLevels.WARNING, 'User profile has invalid format.')
127+
128+
# Bucket user and store the new decision
129+
if not audience_helper.is_user_in_experiment(self.config, experiment, attributes):
130+
self.logger.log(
131+
enums.LogLevels.INFO,
132+
'User "%s" does not meet conditions to be in experiment "%s".' % (user_id, experiment.key)
133+
)
134+
return None
135+
136+
variation = self.bucketer.bucket(experiment, user_id)
137+
138+
if variation:
139+
# Store this new decision and return the variation for the user
140+
if self.user_profile_service:
141+
try:
142+
user_profile.save_variation_for_experiment(experiment.id, variation.id)
143+
self.user_profile_service.save(user_profile.__dict__)
144+
except:
145+
error = sys.exc_info()[1]
146+
self.logger.log(enums.LogLevels.ERROR,
147+
'Unable to save user profile for user "%s". Error: %s' % (user_id, str(error)))
148+
return variation
149+
150+
return None

optimizely/entities.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,22 @@ def __init__(self, id, key, status, audienceIds, variations, forcedVariations,
5858
self.groupPolicy = groupPolicy
5959

6060

61+
class FeatureFlag(BaseEntity):
62+
63+
class Type(object):
64+
BOOLEAN = 'boolean'
65+
DOUBLE = 'double'
66+
INTEGER = 'integer'
67+
STRING = 'string'
68+
69+
70+
def __init__(self, id, key, type, defaultValue, **kwargs):
71+
self.id = id
72+
self.key = key
73+
self.type = type
74+
self.defaultValue = defaultValue
75+
76+
6177
class Group(BaseEntity):
6278

6379
def __init__(self, id, policy, experiments, trafficAllocation, **kwargs):
@@ -69,6 +85,8 @@ def __init__(self, id, policy, experiments, trafficAllocation, **kwargs):
6985

7086
class Variation(BaseEntity):
7187

72-
def __init__(self, id, key, **kwargs):
88+
def __init__(self, id, key, variables=None, featureFlagMap=None, **kwargs):
7389
self.id = id
7490
self.key = key
91+
self.variables = variables or []
92+
self.featureFlagMap = featureFlagMap or {}

optimizely/event_builder.py

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,8 @@ def __init__(self, url, params, http_verb=None, headers=None):
3232
class BaseEventBuilder(object):
3333
""" Base class which encapsulates methods to build events for tracking impressions and conversions. """
3434

35-
def __init__(self, config, bucketer):
35+
def __init__(self, config):
3636
self.config = config
37-
self.bucketer = bucketer
3837
self.params = {}
3938

4039
@abstractproperty
@@ -97,7 +96,7 @@ def _add_common_params(self, user_id, attributes):
9796

9897

9998
class EventBuilder(BaseEventBuilder):
100-
""" Class which encapsulates methods to build events for tracking
99+
""" Class which encapsulates methods to build events for tracking
101100
impressions and conversions using the new endpoints. """
102101

103102
IMPRESSION_ENDPOINT = 'https://logx.optimizely.com/log/decision'
@@ -183,14 +182,13 @@ def _add_required_params_for_impression(self, experiment, variation_id):
183182
self.EventParams.IS_LAYER_HOLDBACK: False
184183
}
185184

186-
def _add_required_params_for_conversion(self, event_key, user_id, event_tags, valid_experiments):
185+
def _add_required_params_for_conversion(self, event_key, event_tags, decisions):
187186
""" Add parameters that are required for the conversion event to register.
188187
189188
Args:
190189
event_key: Key representing the event which needs to be recorded.
191-
user_id: ID for user.
192190
event_tags: Dict representing metadata associated with the event.
193-
valid_experiments: List of tuples representing valid experiments for the event.
191+
decisions: List of tuples representing valid experiments IDs and variation IDs.
194192
"""
195193

196194
self.params[self.EventParams.IS_GLOBAL_HOLDBACK] = False
@@ -219,19 +217,18 @@ def _add_required_params_for_conversion(self, event_key, user_id, event_tags, va
219217
self.params[self.EventParams.EVENT_FEATURES].append(event_feature)
220218

221219
self.params[self.EventParams.LAYER_STATES] = []
222-
for experiment in valid_experiments:
223-
variation = self.bucketer.bucket(experiment, user_id)
224-
if variation:
225-
self.params[self.EventParams.LAYER_STATES].append({
226-
self.EventParams.LAYER_ID: experiment.layerId,
227-
self.EventParams.REVISION: self.config.get_revision(),
228-
self.EventParams.ACTION_TRIGGERED: True,
229-
self.EventParams.DECISION: {
230-
self.EventParams.EXPERIMENT_ID: experiment.id,
231-
self.EventParams.VARIATION_ID: variation.id,
232-
self.EventParams.IS_LAYER_HOLDBACK: False
233-
}
234-
})
220+
for experiment_id, variation_id in decisions:
221+
experiment = self.config.get_experiment_from_id(experiment_id)
222+
self.params[self.EventParams.LAYER_STATES].append({
223+
self.EventParams.LAYER_ID: experiment.layerId,
224+
self.EventParams.REVISION: self.config.get_revision(),
225+
self.EventParams.ACTION_TRIGGERED: True,
226+
self.EventParams.DECISION: {
227+
self.EventParams.EXPERIMENT_ID: experiment.id,
228+
self.EventParams.VARIATION_ID: variation_id,
229+
self.EventParams.IS_LAYER_HOLDBACK: False
230+
}
231+
})
235232

236233
self.params[self.EventParams.EVENT_ID] = self.config.get_event(event_key).id
237234
self.params[self.EventParams.EVENT_NAME] = event_key
@@ -257,23 +254,23 @@ def create_impression_event(self, experiment, variation_id, user_id, attributes)
257254
http_verb=self.HTTP_VERB,
258255
headers=self.HTTP_HEADERS)
259256

260-
def create_conversion_event(self, event_key, user_id, attributes, event_tags, valid_experiments):
257+
def create_conversion_event(self, event_key, user_id, attributes, event_tags, decisions):
261258
""" Create conversion Event to be sent to the logging endpoint.
262259
263260
Args:
264261
event_key: Key representing the event which needs to be recorded.
265262
user_id: ID for user.
266263
attributes: Dict representing user attributes and values.
267264
event_tags: Dict representing metadata associated with the event.
268-
valid_experiments: List of tuples representing valid experiments for the event.
265+
decisions: List of tuples representing experiments IDs and variation IDs.
269266
270267
Returns:
271268
Event object encapsulating the conversion event.
272269
"""
273270

274271
self.params = {}
275272
self._add_common_params(user_id, attributes)
276-
self._add_required_params_for_conversion(event_key, user_id, event_tags, valid_experiments)
273+
self._add_required_params_for_conversion(event_key, event_tags, decisions)
277274
return Event(self.CONVERSION_ENDPOINT,
278275
self.params,
279276
http_verb=self.HTTP_VERB,

optimizely/helpers/experiment.py

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,3 @@ def is_experiment_running(experiment):
2525
"""
2626

2727
return experiment.status in ALLOWED_EXPERIMENT_STATUS
28-
29-
30-
def is_user_in_forced_variation(forced_variations, user_id):
31-
""" Determine if the user is in a forced variation.
32-
33-
Args:
34-
forced_variations: Dict representing forced variations for the experiment.
35-
user_id: User to check for in whitelist.
36-
37-
Returns:
38-
Boolean depending on whether user is in forced variation or not.
39-
"""
40-
41-
if forced_variations and user_id in forced_variations:
42-
return True
43-
44-
return False

0 commit comments

Comments
 (0)