Skip to content

Commit d9c7905

Browse files
feat: odp datafile parsing and audience evaluation (#393)
* swap user attributes for user_context * add integrations * add qualified segments
1 parent ec3d846 commit d9c7905

16 files changed

+856
-319
lines changed

optimizely/decision_service.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,6 @@ def get_variation(
268268
And an array of log messages representing decision making.
269269
"""
270270
user_id = user_context.user_id
271-
attributes = user_context.get_user_attributes()
272271

273272
if options:
274273
ignore_user_profile = OptimizelyDecideOption.IGNORE_USER_PROFILE_SERVICE in options
@@ -323,7 +322,7 @@ def get_variation(
323322
project_config, audience_conditions,
324323
enums.ExperimentAudienceEvaluationLogs,
325324
experiment.key,
326-
attributes, self.logger)
325+
user_context, self.logger)
327326
decide_reasons += reasons_received
328327
if not user_meets_audience_conditions:
329328
message = f'User "{user_id}" does not meet conditions to be in experiment "{experiment.key}".'
@@ -332,7 +331,7 @@ def get_variation(
332331
return None, decide_reasons
333332

334333
# Determine bucketing ID to be used
335-
bucketing_id, bucketing_id_reasons = self._get_bucketing_id(user_id, attributes)
334+
bucketing_id, bucketing_id_reasons = self._get_bucketing_id(user_id, user_context.get_user_attributes())
336335
decide_reasons += bucketing_id_reasons
337336
variation, bucket_reasons = self.bucketer.bucket(project_config, experiment, user_id, bucketing_id)
338337
decide_reasons += bucket_reasons
@@ -354,7 +353,7 @@ def get_variation(
354353
return None, decide_reasons
355354

356355
def get_variation_for_rollout(
357-
self, project_config: ProjectConfig, feature: entities.FeatureFlag, user: OptimizelyUserContext
356+
self, project_config: ProjectConfig, feature: entities.FeatureFlag, user_context: OptimizelyUserContext
358357
) -> tuple[Decision, list[str]]:
359358
""" Determine which experiment/variation the user is in for a given rollout.
360359
Returns the variation of the first experiment the user qualifies for.
@@ -371,8 +370,8 @@ def get_variation_for_rollout(
371370
array of log messages representing decision making.
372371
"""
373372
decide_reasons: list[str] = []
374-
user_id = user.user_id
375-
attributes = user.get_user_attributes()
373+
user_id = user_context.user_id
374+
attributes = user_context.get_user_attributes()
376375

377376
if not feature or not feature.rolloutId:
378377
return Decision(None, None, enums.DecisionSources.ROLLOUT), decide_reasons
@@ -401,7 +400,7 @@ def get_variation_for_rollout(
401400
rule = rollout_rules[index]
402401
optimizely_decision_context = OptimizelyUserContext.OptimizelyDecisionContext(feature.key, rule.key)
403402
forced_decision_variation, reasons_received = self.validated_forced_decision(
404-
project_config, optimizely_decision_context, user)
403+
project_config, optimizely_decision_context, user_context)
405404
decide_reasons += reasons_received
406405

407406
if forced_decision_variation:
@@ -422,7 +421,7 @@ def get_variation_for_rollout(
422421

423422
audience_decision_response, reasons_received_audience = audience_helper.does_user_meet_audience_conditions(
424423
project_config, audience_conditions, enums.RolloutRuleAudienceEvaluationLogs,
425-
logging_key, attributes, self.logger)
424+
logging_key, user_context, self.logger)
426425

427426
decide_reasons += reasons_received_audience
428427

optimizely/entities.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,16 @@ def __init__(
5252
self.conditionStructure = conditionStructure
5353
self.conditionList = conditionList
5454

55+
def get_segments(self) -> list[str]:
56+
""" Extract all audience segments used in the this audience's conditions.
57+
58+
Returns:
59+
List of segment names.
60+
"""
61+
if not self.conditionList:
62+
return []
63+
return list({c[1] for c in self.conditionList if c[3] == 'qualified'})
64+
5565

5666
class Event(BaseEntity):
5767
def __init__(self, id: str, key: str, experimentIds: list[str], **kwargs: Any):
@@ -175,3 +185,10 @@ def __init__(
175185

176186
def __str__(self) -> str:
177187
return self.key
188+
189+
190+
class Integration(BaseEntity):
191+
def __init__(self, key: str, host: Optional[str] = None, publicKey: Optional[str] = None):
192+
self.key = key
193+
self.host = host
194+
self.publicKey = publicKey

optimizely/helpers/audience.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def does_user_meet_audience_conditions(
3131
audience_conditions: Optional[Sequence[str | list[str]]],
3232
audience_logs: Type[ExperimentAudienceEvaluationLogs | RolloutRuleAudienceEvaluationLogs],
3333
logging_key: str,
34-
attributes: Optional[optimizely_user_context.UserAttributes],
34+
user_context: optimizely_user_context.OptimizelyUserContext,
3535
logger: Logger
3636
) -> tuple[bool, list[str]]:
3737
""" Determine for given experiment if user satisfies the audiences for the experiment.
@@ -62,15 +62,12 @@ def does_user_meet_audience_conditions(
6262

6363
return True, decide_reasons
6464

65-
if attributes is None:
66-
attributes = optimizely_user_context.UserAttributes({})
67-
6865
def evaluate_custom_attr(audience_id: str, index: int) -> Optional[bool]:
6966
audience = config.get_audience(audience_id)
7067
if not audience or audience.conditionList is None:
7168
return None
7269
custom_attr_condition_evaluator = condition_helper.CustomAttributeConditionEvaluator(
73-
audience.conditionList, attributes, logger
70+
audience.conditionList, user_context, logger
7471
)
7572

7673
return custom_attr_condition_evaluator.evaluate(index)

optimizely/helpers/condition.py

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,21 +55,23 @@ class ConditionMatchTypes:
5555
SEMVER_LE: Final = 'semver_le'
5656
SEMVER_LT: Final = 'semver_lt'
5757
SUBSTRING: Final = 'substring'
58+
QUALIFIED: Final = 'qualified'
5859

5960

6061
class CustomAttributeConditionEvaluator:
6162
""" Class encapsulating methods to be used in audience leaf condition evaluation. """
6263

63-
CUSTOM_ATTRIBUTE_CONDITION_TYPE: Final = 'custom_attribute'
64+
CONDITION_TYPES: Final = ('custom_attribute', 'third_party_dimension')
6465

6566
def __init__(
6667
self,
6768
condition_data: list[str | list[str]],
68-
attributes: Optional[optimizely_user_context.UserAttributes],
69+
user_context: optimizely_user_context.OptimizelyUserContext,
6970
logger: Logger
7071
):
7172
self.condition_data = condition_data
72-
self.attributes = attributes or optimizely_user_context.UserAttributes({})
73+
self.user_context = user_context
74+
self.attributes = user_context.get_user_attributes()
7375
self.logger = logger
7476

7577
def _get_condition_json(self, index: int) -> str:
@@ -613,7 +615,27 @@ def semver_greater_than_or_equal_evaluator(self, index: int) -> Optional[bool]:
613615

614616
return result >= 0
615617

616-
EVALUATORS_BY_MATCH_TYPE = {
618+
def qualified_evaluator(self, index: int) -> Optional[bool]:
619+
""" Check if the user is qualifed for the given segment.
620+
621+
Args:
622+
index: Index of the condition to be evaluated.
623+
624+
Returns:
625+
Boolean:
626+
- True if the user is qualified.
627+
- False if the user is not qualified.
628+
None: if the condition value isn't a string.
629+
"""
630+
condition_value = self.condition_data[index][1]
631+
632+
if not isinstance(condition_value, str):
633+
self.logger.warning(audience_logs.UNKNOWN_CONDITION_VALUE.format(self._get_condition_json(index),))
634+
return None
635+
636+
return self.user_context.is_qualified_for(condition_value)
637+
638+
EVALUATORS_BY_MATCH_TYPE: dict[str, Callable[[CustomAttributeConditionEvaluator, int], Optional[bool]]] = {
617639
ConditionMatchTypes.EXACT: exact_evaluator,
618640
ConditionMatchTypes.EXISTS: exists_evaluator,
619641
ConditionMatchTypes.GREATER_THAN: greater_than_evaluator,
@@ -625,7 +647,8 @@ def semver_greater_than_or_equal_evaluator(self, index: int) -> Optional[bool]:
625647
ConditionMatchTypes.SEMVER_GT: semver_greater_than_evaluator,
626648
ConditionMatchTypes.SEMVER_LE: semver_less_than_or_equal_evaluator,
627649
ConditionMatchTypes.SEMVER_LT: semver_less_than_evaluator,
628-
ConditionMatchTypes.SUBSTRING: substring_evaluator
650+
ConditionMatchTypes.SUBSTRING: substring_evaluator,
651+
ConditionMatchTypes.QUALIFIED: qualified_evaluator
629652
}
630653

631654
def split_version(self, version: str) -> Optional[list[str]]:
@@ -696,7 +719,7 @@ def evaluate(self, index: int) -> Optional[bool]:
696719
None: if the user attributes and condition can't be evaluated.
697720
"""
698721

699-
if self.condition_data[index][2] != self.CUSTOM_ATTRIBUTE_CONDITION_TYPE:
722+
if self.condition_data[index][2] not in self.CONDITION_TYPES:
700723
self.logger.warning(audience_logs.UNKNOWN_CONDITION_TYPE.format(self._get_condition_json(index)))
701724
return None
702725

@@ -708,7 +731,7 @@ def evaluate(self, index: int) -> Optional[bool]:
708731
self.logger.warning(audience_logs.UNKNOWN_MATCH_TYPE.format(self._get_condition_json(index)))
709732
return None
710733

711-
if condition_match != ConditionMatchTypes.EXISTS:
734+
if condition_match not in (ConditionMatchTypes.EXISTS, ConditionMatchTypes.QUALIFIED):
712735
attribute_key = self.condition_data[index][0]
713736
if attribute_key not in self.attributes:
714737
self.logger.debug(

optimizely/helpers/constants.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,14 @@
149149
},
150150
"version": {"type": "string"},
151151
"revision": {"type": "string"},
152+
"integrations": {
153+
"type": "array",
154+
"items": {
155+
"type": "object",
156+
"properties": {"key": {"type": "string"}, "host": {"type": "string"}, "publicKey": {"type": "string"}},
157+
"required": ["key"],
158+
}
159+
}
152160
},
153161
"required": [
154162
"projectId",

optimizely/helpers/types.py

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,43 +30,43 @@ class BaseEntity(TypedDict):
3030

3131

3232
class BaseDict(BaseEntity):
33-
'''Base type for parsed datafile json, before instantiation of class objects.'''
33+
"""Base type for parsed datafile json, before instantiation of class objects."""
3434
id: str
3535
key: str
3636

3737

3838
class EventDict(BaseDict):
39-
'''Event dict from parsed datafile json.'''
39+
"""Event dict from parsed datafile json."""
4040
experimentIds: list[str]
4141

4242

4343
class AttributeDict(BaseDict):
44-
'''Attribute dict from parsed datafile json.'''
44+
"""Attribute dict from parsed datafile json."""
4545
pass
4646

4747

4848
class TrafficAllocation(BaseEntity):
49-
'''Traffic Allocation dict from parsed datafile json.'''
49+
"""Traffic Allocation dict from parsed datafile json."""
5050
endOfRange: int
5151
entityId: str
5252

5353

5454
class VariableDict(BaseDict):
55-
'''Variable dict from parsed datafile json.'''
55+
"""Variable dict from parsed datafile json."""
5656
value: str
5757
type: str
5858
defaultValue: str
5959
subType: str
6060

6161

6262
class VariationDict(BaseDict):
63-
'''Variation dict from parsed datafile json.'''
63+
"""Variation dict from parsed datafile json."""
6464
variables: list[VariableDict]
6565
featureEnabled: Optional[bool]
6666

6767

6868
class ExperimentDict(BaseDict):
69-
'''Experiment dict from parsed datafile json.'''
69+
"""Experiment dict from parsed datafile json."""
7070
status: str
7171
forcedVariations: dict[str, str]
7272
variations: list[VariationDict]
@@ -77,28 +77,35 @@ class ExperimentDict(BaseDict):
7777

7878

7979
class RolloutDict(BaseEntity):
80-
'''Rollout dict from parsed datafile json.'''
80+
"""Rollout dict from parsed datafile json."""
8181
id: str
8282
experiments: list[ExperimentDict]
8383

8484

8585
class FeatureFlagDict(BaseDict):
86-
'''Feature flag dict from parsed datafile json.'''
86+
"""Feature flag dict from parsed datafile json."""
8787
rolloutId: str
8888
variables: list[VariableDict]
8989
experimentIds: list[str]
9090

9191

9292
class GroupDict(BaseEntity):
93-
'''Group dict from parsed datafile json.'''
93+
"""Group dict from parsed datafile json."""
9494
id: str
9595
policy: str
9696
experiments: list[ExperimentDict]
9797
trafficAllocation: list[TrafficAllocation]
9898

9999

100100
class AudienceDict(BaseEntity):
101-
'''Audience dict from parsed datafile json.'''
101+
"""Audience dict from parsed datafile json."""
102102
id: str
103103
name: str
104104
conditions: list[Any] | str
105+
106+
107+
class IntegrationDict(BaseEntity):
108+
"""Integration dict from parsed datafile json."""
109+
key: str
110+
host: str
111+
publicKey: str

optimizely/optimizely_user_context.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ def __init__(
5454
self.client = optimizely_client
5555
self.logger = logger
5656
self.user_id = user_id
57+
self._qualified_segments: list[str] = []
5758

5859
if not isinstance(user_attributes, dict):
5960
user_attributes = UserAttributes({})
@@ -94,7 +95,11 @@ def _clone(self) -> Optional[OptimizelyUserContext]:
9495

9596
with self.lock:
9697
if self.forced_decisions_map:
98+
# makes sure forced_decisions_map is duplicated without any references
9799
user_context.forced_decisions_map = copy.deepcopy(self.forced_decisions_map)
100+
if self._qualified_segments:
101+
# no need to use deepcopy here as qualified_segments does not contain anything other than strings
102+
user_context._qualified_segments = self._qualified_segments.copy()
98103

99104
return user_context
100105

@@ -248,3 +253,39 @@ def find_forced_decision(self, decision_context: OptimizelyDecisionContext) -> O
248253

249254
# must allow None to be returned for the Flags only case
250255
return self.forced_decisions_map.get(decision_context)
256+
257+
def is_qualified_for(self, segment: str) -> bool:
258+
"""
259+
Checks is the provided segment is in the qualified_segments list.
260+
261+
Args:
262+
segment: a segment name.
263+
264+
Returns:
265+
Returns: true if the segment is in the qualified segments list.
266+
"""
267+
with self.lock:
268+
return segment in self._qualified_segments
269+
270+
def get_qualified_segments(self) -> list[str]:
271+
"""
272+
Gets the qualified segments.
273+
274+
Returns:
275+
A list of qualified segment names.
276+
"""
277+
with self.lock:
278+
return self._qualified_segments.copy()
279+
280+
def set_qualified_segments(self, segments: list[str]) -> None:
281+
"""
282+
Replaces any qualified segments with the provided list of segments.
283+
284+
Args:
285+
segments: a list of segment names.
286+
287+
Returns:
288+
None.
289+
"""
290+
with self.lock:
291+
self._qualified_segments = segments.copy()

0 commit comments

Comments
 (0)