Skip to content
This repository was archived by the owner on Nov 8, 2024. It is now read-only.

Commit d180ace

Browse files
authored
implement rule evaluation logic (#8)
* implement rule evaluation logic * fix lint failure * increment version * fix lint failure * fix lint failure * refactor evaluate condition * add unit test * use object for subject * move subject model to separate module * update README * rename assign to get_assignment for consistency with other SDKs * update changelog * make subjectAttributes a second param
1 parent e311066 commit d180ace

File tree

7 files changed

+185
-16
lines changed

7 files changed

+185
-16
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@
1111
* Describe deprecated APIs in this version
1212
-->
1313

14+
## [1.0.0] - 2022-06-08
15+
16+
#### New Features:
17+
* Subject attributes: an optional `subject_attributes` param is added to the `get_assignment` function. The subject attributes may contains custom metadata about the subject. These attributes are used for evaluating any targeting rules defined on the experiment.
18+
```
19+
client.get_assignment("<SUBJECT_KEY">, "<EXPERIMENT_KEY>", { "email": "[email protected]" });
20+
```
21+
22+
#### Breaking Changes:
23+
* The EppoClient `assign()` function is renamed to `get_assignment()`
24+
1425
## [0.0.3] - 2022-05-11
1526

1627
#### New Features

eppo_client/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from eppo_client.http_client import HttpClient, SdkParams
1111
from eppo_client.read_write_lock import ReadWriteLock
1212

13-
__version__ = "0.0.3"
13+
__version__ = "1.0.0"
1414

1515
__client: Optional[EppoClient] = None
1616
__lock = ReadWriteLock()

eppo_client/client.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import hashlib
2-
from typing import Optional
2+
from typing import List, Optional
33
from eppo_client.configuration_requestor import (
44
ExperimentConfigurationDto,
55
ExperimentConfigurationRequestor,
66
)
77
from eppo_client.constants import POLL_INTERVAL_MILLIS, POLL_JITTER_MILLIS
88
from eppo_client.poller import Poller
9+
from eppo_client.rules import Rule, matches_any_rule
910
from eppo_client.shard import get_shard, is_in_shard_range
1011
from eppo_client.validation import validate_not_blank
1112

@@ -20,29 +21,36 @@ def __init__(self, config_requestor: ExperimentConfigurationRequestor):
2021
)
2122
self.__poller.start()
2223

23-
def assign(self, subject: str, experiment_key: str) -> Optional[str]:
24+
def get_assignment(
25+
self, subject_key: str, experiment_key: str, subject_attributes=dict()
26+
) -> Optional[str]:
2427
"""Maps a subject to a variation for a given experiment
2528
Returns None if the subject is not part of the experiment sample.
2629
27-
:param subject: an entity ID, e.g. userId
30+
:param subject_key: an identifier of the experiment subject, for example a user ID.
2831
:param experiment_key: an experiment identifier
32+
:param subject_attributes: optional attributes associated with the subject, for example name and email.
33+
The subject attributes are used for evaluating any targeting rules tied to the experiment.
2934
"""
30-
validate_not_blank("subject", subject)
35+
validate_not_blank("subject_key", subject_key)
3136
validate_not_blank("experiment_key", experiment_key)
3237
experiment_config = self.__config_requestor.get_configuration(experiment_key)
3338
if (
3439
experiment_config is None
3540
or not experiment_config.enabled
41+
or not self._subject_attributes_satisfy_rules(
42+
subject_attributes, experiment_config.rules
43+
)
3644
or not self._is_in_experiment_sample(
37-
subject, experiment_key, experiment_config
45+
subject_key, experiment_key, experiment_config
3846
)
3947
):
4048
return None
41-
override = self._get_subject_variation_override(experiment_config, subject)
49+
override = self._get_subject_variation_override(experiment_config, subject_key)
4250
if override:
4351
return override
4452
shard = get_shard(
45-
"assignment-{}-{}".format(subject, experiment_key),
53+
"assignment-{}-{}".format(subject_key, experiment_key),
4654
experiment_config.subject_shards,
4755
)
4856
return next(
@@ -54,6 +62,13 @@ def assign(self, subject: str, experiment_key: str) -> Optional[str]:
5462
None,
5563
)
5664

65+
def _subject_attributes_satisfy_rules(
66+
self, subject_attributes: dict, rules: List[Rule]
67+
) -> bool:
68+
if len(rules) == 0:
69+
return True
70+
return matches_any_rule(subject_attributes, rules)
71+
5772
def _shutdown(self):
5873
"""Stops all background processes used by the client
5974
Do not use the client after calling this method.

eppo_client/configuration_requestor.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from eppo_client.base_model import SdkBaseModel
44
from eppo_client.configuration_store import ConfigurationStore
55
from eppo_client.http_client import HttpClient, HttpRequestError
6+
from eppo_client.rules import Rule
67

78
from eppo_client.shard import ShardRange
89

@@ -21,6 +22,7 @@ class ExperimentConfigurationDto(SdkBaseModel):
2122
variations: List[VariationDto]
2223
name: Optional[str]
2324
overrides: Dict[str, str] = {}
25+
rules: List[Rule] = []
2426

2527

2628
RAC_ENDPOINT = "/randomized_assignment/config"

eppo_client/rules.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import numbers
2+
import re
3+
from enum import Enum
4+
from typing import Any, List
5+
6+
from eppo_client.base_model import SdkBaseModel
7+
8+
9+
class OperatorType(Enum):
10+
MATCHES = "MATCHES"
11+
GTE = "GTE"
12+
GT = "GT"
13+
LTE = "LTE"
14+
LT = "LT"
15+
16+
17+
class Condition(SdkBaseModel):
18+
operator: OperatorType
19+
attribute: str
20+
value: Any
21+
22+
23+
class Rule(SdkBaseModel):
24+
conditions: List[Condition]
25+
26+
27+
def matches_any_rule(subject_attributes: dict, rules: List[Rule]):
28+
for rule in rules:
29+
if matches_rule(subject_attributes, rule):
30+
return True
31+
return False
32+
33+
34+
def matches_rule(subject_attributes: dict, rule: Rule):
35+
for condition in rule.conditions:
36+
if not evaluate_condition(subject_attributes, condition):
37+
return False
38+
return True
39+
40+
41+
def evaluate_condition(subject_attributes: dict, condition: Condition) -> bool:
42+
subject_value = subject_attributes.get(condition.attribute, None)
43+
if subject_value:
44+
if condition.operator == OperatorType.MATCHES:
45+
return bool(re.match(condition.value, str(subject_value)))
46+
else:
47+
return isinstance(
48+
subject_value, numbers.Number
49+
) and evaluate_numeric_condition(subject_value, condition)
50+
return False
51+
52+
53+
def evaluate_numeric_condition(subject_value: numbers.Number, condition: Condition):
54+
if condition.operator == OperatorType.GT:
55+
return subject_value > condition.value
56+
elif condition.operator == OperatorType.GTE:
57+
return subject_value >= condition.value
58+
elif condition.operator == OperatorType.LT:
59+
return subject_value < condition.value
60+
elif condition.operator == OperatorType.LTE:
61+
return subject_value <= condition.value
62+
return False

test/client_test.py

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
ExperimentConfigurationDto,
1111
VariationDto,
1212
)
13+
from eppo_client.rules import Condition, OperatorType, Rule
1314
from eppo_client.shard import ShardRange
1415
from eppo_client import init, get_instance
1516

@@ -53,16 +54,16 @@ def init_fixture():
5354
def test_assign_blank_experiment(mock_config_requestor):
5455
client = EppoClient(config_requestor=mock_config_requestor)
5556
with pytest.raises(Exception) as exc_info:
56-
client.assign("subject-1", "")
57+
client.get_assignment("subject-1", "")
5758
assert exc_info.value.args[0] == "Invalid value for experiment_key: cannot be blank"
5859

5960

6061
@patch("eppo_client.configuration_requestor.ExperimentConfigurationRequestor")
6162
def test_assign_blank_subject(mock_config_requestor):
6263
client = EppoClient(config_requestor=mock_config_requestor)
6364
with pytest.raises(Exception) as exc_info:
64-
client.assign("", "experiment-1")
65-
assert exc_info.value.args[0] == "Invalid value for subject: cannot be blank"
65+
client.get_assignment("", "experiment-1")
66+
assert exc_info.value.args[0] == "Invalid value for subject_key: cannot be blank"
6667

6768

6869
@patch("eppo_client.configuration_requestor.ExperimentConfigurationRequestor")
@@ -72,13 +73,44 @@ def test_assign_subject_not_in_sample(mock_config_requestor):
7273
percentExposure=0,
7374
enabled=True,
7475
variations=[
75-
VariationDto(name="control", shardRange=ShardRange(start=0, end=100))
76+
VariationDto(name="control", shardRange=ShardRange(start=0, end=10000))
7677
],
7778
name="recommendation_algo",
7879
overrides=dict(),
7980
)
8081
client = EppoClient(config_requestor=mock_config_requestor)
81-
assert client.assign("user-1", "experiment-key-1") is None
82+
assert client.get_assignment("user-1", "experiment-key-1") is None
83+
84+
85+
@patch("eppo_client.configuration_requestor.ExperimentConfigurationRequestor")
86+
def test_assign_subject_with_with_attributes_and_rules(mock_config_requestor):
87+
matches_email_condition = Condition(
88+
operator=OperatorType.MATCHES, value=".*@eppo.com", attribute="email"
89+
)
90+
text_rule = Rule(conditions=[matches_email_condition])
91+
mock_config_requestor.get_configuration.return_value = ExperimentConfigurationDto(
92+
subjectShards=10000,
93+
percentExposure=100,
94+
enabled=True,
95+
variations=[
96+
VariationDto(name="control", shardRange=ShardRange(start=0, end=10000))
97+
],
98+
name="experiment-key-1",
99+
overrides=dict(),
100+
rules=[text_rule],
101+
)
102+
client = EppoClient(config_requestor=mock_config_requestor)
103+
assert client.get_assignment("user-1", "experiment-key-1") is None
104+
assert (
105+
client.get_assignment(
106+
"user1", "experiment-key-1", {"email": "[email protected]"}
107+
)
108+
is None
109+
)
110+
assert (
111+
client.get_assignment("user1", "experiment-key-1", {"email": "[email protected]"})
112+
== "control"
113+
)
82114

83115

84116
@patch("eppo_client.configuration_requestor.ExperimentConfigurationRequestor")
@@ -94,15 +126,15 @@ def test_with_subject_in_overrides(mock_config_requestor):
94126
overrides={"d6d7705392bc7af633328bea8c4c6904": "override-variation"},
95127
)
96128
client = EppoClient(config_requestor=mock_config_requestor)
97-
assert client.assign("user-1", "experiment-key-1") == "override-variation"
129+
assert client.get_assignment("user-1", "experiment-key-1") == "override-variation"
98130

99131

100132
@pytest.mark.parametrize("test_case", test_data)
101133
def test_assign_subject_in_sample(test_case):
102134
print("---- Test case for {} Experiment".format(test_case["experiment"]))
103135
client = get_instance()
104136
assignments = [
105-
client.assign(subject, test_case["experiment"])
106-
for subject in test_case["subjects"]
137+
client.get_assignment(key, test_case["experiment"])
138+
for key in test_case["subjects"]
107139
]
108140
assert assignments == test_case["expectedAssignments"]

test/rules_test.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from eppo_client.rules import OperatorType, Rule, Condition, matches_any_rule
2+
3+
greater_than_condition = Condition(operator=OperatorType.GT, value=10, attribute="age")
4+
less_than_condition = Condition(operator=OperatorType.LT, value=100, attribute="age")
5+
numeric_rule = Rule(conditions=[less_than_condition, greater_than_condition])
6+
7+
matches_email_condition = Condition(
8+
operator=OperatorType.MATCHES, value=".*@email.com", attribute="email"
9+
)
10+
text_rule = Rule(conditions=[matches_email_condition])
11+
12+
rule_with_empty_conditions = Rule(conditions=[])
13+
14+
15+
def test_matches_rules_false_with_empty_rules():
16+
subject_attributes = {"age": 20, "country": "US"}
17+
assert matches_any_rule(subject_attributes, []) is False
18+
19+
20+
def test_matches_rules_false_when_no_rules_match():
21+
subject_attributes = {"age": 99, "country": "US", "email": "[email protected]"}
22+
assert matches_any_rule(subject_attributes, [text_rule]) is False
23+
24+
25+
def test_matches_rules_true_on_match():
26+
assert matches_any_rule({"age": 99}, [numeric_rule]) is True
27+
assert matches_any_rule({"email": "[email protected]"}, [text_rule]) is True
28+
29+
30+
def test_matches_rules_false_if_no_attribute_for_condition():
31+
assert matches_any_rule({}, [numeric_rule]) is False
32+
33+
34+
def test_matches_rules_true_if_no_conditions_for_rule():
35+
assert matches_any_rule({}, [rule_with_empty_conditions]) is True
36+
37+
38+
def test_matches_rules_false_if_numeric_operator_with_string():
39+
assert matches_any_rule({"age": "99"}, [numeric_rule]) is False
40+
41+
42+
def test_matches_rules_true_with_numeric_value_and_regex():
43+
condition = Condition(
44+
operator=OperatorType.MATCHES, value="[0-9]+", attribute="age"
45+
)
46+
rule = Rule(conditions=[condition])
47+
assert matches_any_rule({"age": 99}, [rule]) is True

0 commit comments

Comments
 (0)