Skip to content

Commit ef0ccdf

Browse files
authored
Add feature flag parsing (#57)
1 parent de81f79 commit ef0ccdf

File tree

4 files changed

+811
-87
lines changed

4 files changed

+811
-87
lines changed

optimizely/entities.py

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -58,35 +58,57 @@ def __init__(self, id, key, status, audienceIds, variations, forcedVariations,
5858
self.groupPolicy = groupPolicy
5959

6060

61-
class FeatureFlag(BaseEntity):
61+
class Feature(BaseEntity):
62+
63+
def __init__(self, id, key, experimentIds, layerId, variables, groupId=None, **kwargs):
64+
self.id = id
65+
self.key = key
66+
self.experimentIds = experimentIds
67+
self.layerId = layerId
68+
self.variables = variables
69+
self.groupId = groupId
70+
71+
class Group(BaseEntity):
72+
73+
def __init__(self, id, policy, experiments, trafficAllocation, **kwargs):
74+
self.id = id
75+
self.policy = policy
76+
self.experiments = experiments
77+
self.trafficAllocation = trafficAllocation
78+
79+
80+
class Layer(BaseEntity):
81+
82+
def __init__(self, id, policy, experiments, **kwargs):
83+
self.id = id
84+
self.policy = policy
85+
self.experiments = experiments
86+
87+
88+
class Variable(BaseEntity):
6289

6390
class Type(object):
6491
BOOLEAN = 'boolean'
6592
DOUBLE = 'double'
6693
INTEGER = 'integer'
6794
STRING = 'string'
6895

69-
7096
def __init__(self, id, key, type, defaultValue, **kwargs):
7197
self.id = id
7298
self.key = key
7399
self.type = type
74100
self.defaultValue = defaultValue
75101

76102

77-
class Group(BaseEntity):
78-
79-
def __init__(self, id, policy, experiments, trafficAllocation, **kwargs):
80-
self.id = id
81-
self.policy = policy
82-
self.experiments = experiments
83-
self.trafficAllocation = trafficAllocation
103+
class Variation(BaseEntity):
84104

105+
class VariableUsage(BaseEntity):
85106

86-
class Variation(BaseEntity):
107+
def __init__(self, id, value, **kwards):
108+
self.id = id
109+
self.value = value
87110

88-
def __init__(self, id, key, variables=None, featureFlagMap=None, **kwargs):
111+
def __init__(self, id, key, variables=None, **kwargs):
89112
self.id = id
90113
self.key = key
91114
self.variables = variables or []
92-
self.featureFlagMap = featureFlagMap or {}

optimizely/project_config.py

Lines changed: 108 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -53,15 +53,20 @@ def __init__(self, datafile, logger, error_handler):
5353
self.events = config.get('events', [])
5454
self.attributes = config.get('attributes', [])
5555
self.audiences = config.get('audiences', [])
56-
self.feature_flags = config.get('variables', [])
56+
self.features = config.get('features', [])
57+
self.layers = config.get('layers', [])
5758

5859
# Utility maps for quick lookup
5960
self.group_id_map = self._generate_key_map(self.groups, 'id', entities.Group)
6061
self.experiment_key_map = self._generate_key_map(self.experiments, 'key', entities.Experiment)
6162
self.event_key_map = self._generate_key_map(self.events, 'key', entities.Event)
6263
self.attribute_key_map = self._generate_key_map(self.attributes, 'key', entities.Attribute)
6364
self.audience_id_map = self._generate_key_map(self.audiences, 'id', entities.Audience)
64-
self.feature_flag_id_map = self._generate_key_map(self.feature_flags, 'id', entities.FeatureFlag)
65+
self.layer_id_map = self._generate_key_map(self.layers, 'id', entities.Layer)
66+
for layer in self.layer_id_map.values():
67+
for experiment in layer.experiments:
68+
self.experiment_key_map[experiment['key']] = entities.Experiment(**experiment)
69+
6570
self.audience_id_map = self._deserialize_audience(self.audience_id_map)
6671
for group in self.group_id_map.values():
6772
experiments_in_group_key_map = self._generate_key_map(group.experiments, 'key', entities.Experiment)
@@ -75,16 +80,29 @@ def __init__(self, datafile, logger, error_handler):
7580
self.experiment_id_map = {}
7681
self.variation_key_map = {}
7782
self.variation_id_map = {}
83+
self.variation_variable_usage_map = {}
7884
for experiment in self.experiment_key_map.values():
7985
self.experiment_id_map[experiment.id] = experiment
8086
self.variation_key_map[experiment.key] = self._generate_key_map(
8187
experiment.variations, 'key', entities.Variation
8288
)
8389
self.variation_id_map[experiment.key] = {}
8490
for variation in self.variation_key_map.get(experiment.key).values():
85-
feature_flag_to_value_map = self._map_feature_flag_to_value(variation.variables, self.feature_flag_id_map)
86-
variation.featureFlagMap = feature_flag_to_value_map
8791
self.variation_id_map[experiment.key][variation.id] = variation
92+
if variation.variables:
93+
self.variation_variable_usage_map[variation.id] = self._generate_key_map(variation.variables, 'id', entities.Variation.VariableUsage)
94+
95+
self.feature_key_map = self._generate_key_map(self.features, 'key', entities.Feature)
96+
for feature in self.feature_key_map.values():
97+
feature.variables = self._generate_key_map(feature.variables, 'key', entities.Variable)
98+
99+
# Check if any of the experiments are in a group and add the group id for faster bucketing later on
100+
for exp_id in feature.experimentIds:
101+
experiment_in_feature = self.experiment_id_map[exp_id]
102+
if experiment_in_feature.groupId:
103+
feature.groupId = experiment_in_feature.groupId
104+
# Experiments in feature can only belong to one mutex group
105+
break
88106

89107
self.parsing_succeeded = True
90108

@@ -128,45 +146,25 @@ def _deserialize_audience(audience_map):
128146
return audience_map
129147

130148
def _get_typecast_value(self, value, type):
131-
""" Helper method to determine actual value based on type of feature flag.
149+
""" Helper method to determine actual value based on type of feature variable.
132150
133151
Args:
134152
value: Value in string form as it was parsed from datafile.
135153
type: Type denoting the feature flag type.
136154
137155
Return:
138-
Value type-casted based on type of feature flag.
156+
Value type-casted based on type of feature variable.
139157
"""
140158

141-
if type == entities.FeatureFlag.Type.BOOLEAN:
159+
if type == entities.Variable.Type.BOOLEAN:
142160
return value == 'true'
143-
elif type == entities.FeatureFlag.Type.INTEGER:
161+
elif type == entities.Variable.Type.INTEGER:
144162
return int(value)
145-
elif type == entities.FeatureFlag.Type.DOUBLE:
163+
elif type == entities.Variable.Type.DOUBLE:
146164
return float(value)
147165
else:
148166
return value
149167

150-
def _map_feature_flag_to_value(self, variables, feature_flag_id_map):
151-
""" Helper method to create map of feature flag key to associated value for a given variation's feature flag set.
152-
153-
Args:
154-
variables: List of dicts representing variables on an instance of Variation object.
155-
feature_flag_id_map: Dict mapping feature flag key to feature flag object.
156-
157-
Returns:
158-
Dict mapping values from feature flag key to value stored on the variation's variable.
159-
"""
160-
161-
feature_flag_value_map = {}
162-
for variable in variables:
163-
feature_flag = feature_flag_id_map[variable.get('id')]
164-
if not feature_flag:
165-
continue
166-
feature_flag_value_map[feature_flag.key] = self._get_typecast_value(variable.get('value'), feature_flag.type)
167-
168-
return feature_flag_value_map
169-
170168
def was_parsing_successful(self):
171169
""" Helper method to determine if parsing the datafile was successful.
172170
@@ -357,15 +355,6 @@ def get_event(self, event_key):
357355
self.error_handler.handle_error(exceptions.InvalidEventException(enums.Errors.INVALID_EVENT_KEY_ERROR))
358356
return None
359357

360-
def get_revenue_goal(self):
361-
""" Get the revenue goal for the project.
362-
363-
Returns:
364-
Revenue goal.
365-
"""
366-
367-
return self.get_event(REVENUE_GOAL_KEY)
368-
369358
def get_attribute(self, attribute_key):
370359
""" Get attribute for the provided attribute key.
371360
@@ -384,3 +373,84 @@ def get_attribute(self, attribute_key):
384373
self.logger.log(enums.LogLevels.ERROR, 'Attribute "%s" is not in datafile.' % attribute_key)
385374
self.error_handler.handle_error(exceptions.InvalidAttributeException(enums.Errors.INVALID_ATTRIBUTE_ERROR))
386375
return None
376+
377+
def get_feature_from_key(self, feature_key):
378+
""" Get feature for the provided feature key.
379+
380+
Args:
381+
feature_key: Feature key for which feature is to be fetched.
382+
383+
Returns:
384+
Feature corresponding to the provided feature key.
385+
"""
386+
feature = self.feature_key_map.get(feature_key)
387+
388+
if feature:
389+
return feature
390+
391+
self.logger.log(enums.LogLevels.ERROR, 'Feature "%s" is not in datafile.' % feature_key)
392+
return None
393+
394+
def get_layer_from_id(self, layer_id):
395+
""" Get layer for the provided layer id.
396+
397+
Args:
398+
layer_id: ID of the layer to be fetched.
399+
400+
Returns:
401+
Layer corresponding to the provided layer id.
402+
"""
403+
layer = self.layer_id_map.get(layer_id)
404+
405+
if layer:
406+
return layer
407+
408+
self.logger.log(enums.LogLevels.ERROR, 'Layer with ID "%s" is not in datafile.' % layer_id)
409+
return None
410+
411+
def get_variable_value_for_variation(self, variable, variation):
412+
""" Get the variable value for the given variation.
413+
414+
Args:
415+
Variable: The Variable for which we are getting the value.
416+
Variation: The Variation for which we are getting the variable value.
417+
418+
Returns:
419+
The type-casted variable value or None if any of the inputs are invalid.
420+
"""
421+
if not variable or not variation:
422+
return None
423+
424+
if variation.id not in self.variation_variable_usage_map:
425+
self.logger.log(enums.LogLevels.ERROR, 'Variation with ID "%s" is not in the datafile.' % variation.id)
426+
return None
427+
428+
# Get all variable usages for the given variation
429+
variable_usages = self.variation_variable_usage_map[variation.id]
430+
431+
# Find usage in given variation
432+
variable_usage = variable_usages[variable.id]
433+
434+
value = self._get_typecast_value(variable_usage.value, variable.type)
435+
return value
436+
437+
def get_variable_for_feature(self, feature_key, variable_key):
438+
""" Get the variable with the given variable key for the given feature
439+
440+
Args:
441+
feature_key: The key of the feature for which we are getting the variable.
442+
variable_key: The key of the variable we are getting.
443+
444+
Returns:
445+
Variable with the given key in the given variation.
446+
"""
447+
feature = self.feature_key_map.get(feature_key)
448+
if not feature:
449+
self.logger.log(enums.LogLevels.ERROR, 'Feature with key "%s" not found in the datafile.' % feature_key)
450+
return None
451+
452+
if variable_key not in feature.variables:
453+
self.logger.log(enums.LogLevels.ERROR, 'Variable with key "%s" not found in the datafile.' % variable_key)
454+
return None
455+
456+
return feature.variables.get(variable_key)

tests/base.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,5 +134,105 @@ def setUp(self):
134134
'projectId': '111001'
135135
}
136136

137+
# datafile version 4
138+
self.config_dict_with_features = {
139+
'revision': '1',
140+
'accountId': '12001',
141+
'projectId': '111111',
142+
'version': '4',
143+
'events': [{
144+
'key': 'test_event',
145+
'experimentIds': ['111127'],
146+
'id': '111095'
147+
}],
148+
'experiments': [{
149+
'key': 'test_experiment',
150+
'status': 'Running',
151+
'forcedVariations': {},
152+
'layerId': '111182',
153+
'audienceIds': [],
154+
'trafficAllocation': [{
155+
'entityId': '111128',
156+
'endOfRange': 5000
157+
}, {
158+
'entityId': '111129',
159+
'endOfRange': 9000
160+
}],
161+
'id': '111127',
162+
'variations': [{
163+
'key': 'control',
164+
'id': '111128',
165+
'variables': [{
166+
'id': '127', 'value': 'false'
167+
}, {
168+
'id': '128', 'value': 'prod'
169+
}]
170+
}, {
171+
'key': 'variation',
172+
'id': '111129'
173+
}]
174+
}],
175+
'groups': [],
176+
'attributes': [{
177+
'key': 'test_attribute',
178+
'id': '111094'
179+
}],
180+
'audiences': [{
181+
'name': 'Test attribute users',
182+
'conditions': '["and", ["or", ["or", '
183+
'{"name": "test_attribute", "type": "custom_attribute", "value": "test_value"}]]]',
184+
'id': '11154'
185+
}],
186+
'layers': [{
187+
'id': '211111',
188+
'policy': 'ordered',
189+
'experiments': [{
190+
'key': 'test_rollout_exp_1',
191+
'status': 'Running',
192+
'forcedVariations': {},
193+
'layerId': '211111',
194+
'audienceIds': ['11154'],
195+
'trafficAllocation': [{
196+
'entityId': '211128',
197+
'endOfRange': 5000
198+
}, {
199+
'entityId': '211129',
200+
'endOfRange': 9000
201+
}],
202+
'id': '211127',
203+
'variations': [{
204+
'key': 'control',
205+
'id': '211128'
206+
}, {
207+
'key': 'variation',
208+
'id': '211129'
209+
}]
210+
}]
211+
}],
212+
'features': [{
213+
'id': '91111',
214+
'key': 'test_feature_1',
215+
'experimentIds': ['111127'],
216+
'layerId': '',
217+
'variables': [{
218+
'id': '127',
219+
'key': 'is_working',
220+
'defaultValue': 'true',
221+
'type': 'boolean',
222+
}, {
223+
'id': '128',
224+
'key': 'environment',
225+
'defaultValue': 'devel',
226+
'type': 'string',
227+
}]
228+
}, {
229+
'id': '91112',
230+
'key': 'test_feature_2',
231+
'experimentIds': [],
232+
'layerId': '211111',
233+
'variables': [],
234+
}]
235+
}
236+
137237
self.optimizely = optimizely.Optimizely(json.dumps(self.config_dict))
138238
self.project_config = self.optimizely.config

0 commit comments

Comments
 (0)