Skip to content
This repository was archived by the owner on Nov 8, 2024. It is now read-only.
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@
* Describe deprecated APIs in this version
-->

## [1.0.0] - 2022-06-08

#### New Features:
* 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.
```
client.get_assignment("<SUBJECT_KEY">, "<EXPERIMENT_KEY>", { "email": "user@example.com" });
```

#### Breaking Changes:
* The EppoClient `assign()` function is renamed to `get_assignment()`

## [0.0.3] - 2022-05-11

#### New Features
Expand Down
2 changes: 1 addition & 1 deletion eppo_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from eppo_client.http_client import HttpClient, SdkParams
from eppo_client.read_write_lock import ReadWriteLock

__version__ = "0.0.3"
__version__ = "1.0.0"

__client: Optional[EppoClient] = None
__lock = ReadWriteLock()
Expand Down
29 changes: 22 additions & 7 deletions eppo_client/client.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import hashlib
from typing import Optional
from typing import List, Optional
from eppo_client.configuration_requestor import (
ExperimentConfigurationDto,
ExperimentConfigurationRequestor,
)
from eppo_client.constants import POLL_INTERVAL_MILLIS, POLL_JITTER_MILLIS
from eppo_client.poller import Poller
from eppo_client.rules import Rule, matches_any_rule
from eppo_client.shard import get_shard, is_in_shard_range
from eppo_client.validation import validate_not_blank

Expand All @@ -20,29 +21,36 @@ def __init__(self, config_requestor: ExperimentConfigurationRequestor):
)
self.__poller.start()

def assign(self, subject: str, experiment_key: str) -> Optional[str]:
def get_assignment(
self, subject_key: str, experiment_key: str, subject_attributes=dict()
) -> Optional[str]:
"""Maps a subject to a variation for a given experiment
Returns None if the subject is not part of the experiment sample.

:param subject: an entity ID, e.g. userId
:param subject_key: an identifier of the experiment subject, for example a user ID.
:param experiment_key: an experiment identifier
:param subject_attributes: optional attributes associated with the subject, for example name and email.
The subject attributes are used for evaluating any targeting rules tied to the experiment.
"""
validate_not_blank("subject", subject)
validate_not_blank("subject_key", subject_key)
validate_not_blank("experiment_key", experiment_key)
experiment_config = self.__config_requestor.get_configuration(experiment_key)
if (
experiment_config is None
or not experiment_config.enabled
or not self._subject_attributes_satisfy_rules(
subject_attributes, experiment_config.rules
)
or not self._is_in_experiment_sample(
subject, experiment_key, experiment_config
subject_key, experiment_key, experiment_config
)
):
return None
override = self._get_subject_variation_override(experiment_config, subject)
override = self._get_subject_variation_override(experiment_config, subject_key)
if override:
return override
shard = get_shard(
"assignment-{}-{}".format(subject, experiment_key),
"assignment-{}-{}".format(subject_key, experiment_key),
experiment_config.subject_shards,
)
return next(
Expand All @@ -54,6 +62,13 @@ def assign(self, subject: str, experiment_key: str) -> Optional[str]:
None,
)

def _subject_attributes_satisfy_rules(
self, subject_attributes: dict, rules: List[Rule]
) -> bool:
if len(rules) == 0:
return True
return matches_any_rule(subject_attributes, rules)

def _shutdown(self):
"""Stops all background processes used by the client
Do not use the client after calling this method.
Expand Down
2 changes: 2 additions & 0 deletions eppo_client/configuration_requestor.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from eppo_client.base_model import SdkBaseModel
from eppo_client.configuration_store import ConfigurationStore
from eppo_client.http_client import HttpClient, HttpRequestError
from eppo_client.rules import Rule

from eppo_client.shard import ShardRange

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


RAC_ENDPOINT = "/randomized_assignment/config"
Expand Down
62 changes: 62 additions & 0 deletions eppo_client/rules.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import numbers
import re
from enum import Enum
from typing import Any, List

from eppo_client.base_model import SdkBaseModel


class OperatorType(Enum):
MATCHES = "MATCHES"
GTE = "GTE"
GT = "GT"
LTE = "LTE"
LT = "LT"


class Condition(SdkBaseModel):
operator: OperatorType
attribute: str
value: Any


class Rule(SdkBaseModel):
conditions: List[Condition]


def matches_any_rule(subject_attributes: dict, rules: List[Rule]):
for rule in rules:
if matches_rule(subject_attributes, rule):
return True
return False


def matches_rule(subject_attributes: dict, rule: Rule):
for condition in rule.conditions:
if not evaluate_condition(subject_attributes, condition):
return False
return True


def evaluate_condition(subject_attributes: dict, condition: Condition) -> bool:
subject_value = subject_attributes.get(condition.attribute, None)
if subject_value:
if condition.operator == OperatorType.MATCHES:
return bool(re.match(condition.value, str(subject_value)))
else:
return isinstance(
subject_value, numbers.Number
) and evaluate_numeric_condition(subject_value, condition)
return False


def evaluate_numeric_condition(subject_value: numbers.Number, condition: Condition):
if condition.operator == OperatorType.GT:
return subject_value > condition.value
elif condition.operator == OperatorType.GTE:
return subject_value >= condition.value
elif condition.operator == OperatorType.LT:
return subject_value < condition.value
elif condition.operator == OperatorType.LTE:
return subject_value <= condition.value
return False
48 changes: 40 additions & 8 deletions test/client_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
ExperimentConfigurationDto,
VariationDto,
)
from eppo_client.rules import Condition, OperatorType, Rule
from eppo_client.shard import ShardRange
from eppo_client import init, get_instance

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


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


@patch("eppo_client.configuration_requestor.ExperimentConfigurationRequestor")
Expand All @@ -72,13 +73,44 @@ def test_assign_subject_not_in_sample(mock_config_requestor):
percentExposure=0,
enabled=True,
variations=[
VariationDto(name="control", shardRange=ShardRange(start=0, end=100))
VariationDto(name="control", shardRange=ShardRange(start=0, end=10000))
],
name="recommendation_algo",
overrides=dict(),
)
client = EppoClient(config_requestor=mock_config_requestor)
assert client.assign("user-1", "experiment-key-1") is None
assert client.get_assignment("user-1", "experiment-key-1") is None


@patch("eppo_client.configuration_requestor.ExperimentConfigurationRequestor")
def test_assign_subject_with_with_attributes_and_rules(mock_config_requestor):
matches_email_condition = Condition(
operator=OperatorType.MATCHES, value=".*@eppo.com", attribute="email"
)
text_rule = Rule(conditions=[matches_email_condition])
mock_config_requestor.get_configuration.return_value = ExperimentConfigurationDto(
subjectShards=10000,
percentExposure=100,
enabled=True,
variations=[
VariationDto(name="control", shardRange=ShardRange(start=0, end=10000))
],
name="experiment-key-1",
overrides=dict(),
rules=[text_rule],
)
client = EppoClient(config_requestor=mock_config_requestor)
assert client.get_assignment("user-1", "experiment-key-1") is None
assert (
client.get_assignment(
"user1", "experiment-key-1", {"email": "test@example.com"}
)
is None
)
assert (
client.get_assignment("user1", "experiment-key-1", {"email": "test@eppo.com"})
== "control"
)


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


@pytest.mark.parametrize("test_case", test_data)
def test_assign_subject_in_sample(test_case):
print("---- Test case for {} Experiment".format(test_case["experiment"]))
client = get_instance()
assignments = [
client.assign(subject, test_case["experiment"])
for subject in test_case["subjects"]
client.get_assignment(key, test_case["experiment"])
for key in test_case["subjects"]
]
assert assignments == test_case["expectedAssignments"]
47 changes: 47 additions & 0 deletions test/rules_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from eppo_client.rules import OperatorType, Rule, Condition, matches_any_rule

greater_than_condition = Condition(operator=OperatorType.GT, value=10, attribute="age")
less_than_condition = Condition(operator=OperatorType.LT, value=100, attribute="age")
numeric_rule = Rule(conditions=[less_than_condition, greater_than_condition])

matches_email_condition = Condition(
operator=OperatorType.MATCHES, value=".*@email.com", attribute="email"
)
text_rule = Rule(conditions=[matches_email_condition])

rule_with_empty_conditions = Rule(conditions=[])


def test_matches_rules_false_with_empty_rules():
subject_attributes = {"age": 20, "country": "US"}
assert matches_any_rule(subject_attributes, []) is False


def test_matches_rules_false_when_no_rules_match():
subject_attributes = {"age": 99, "country": "US", "email": "test@example.com"}
assert matches_any_rule(subject_attributes, [text_rule]) is False


def test_matches_rules_true_on_match():
assert matches_any_rule({"age": 99}, [numeric_rule]) is True
assert matches_any_rule({"email": "testing@email.com"}, [text_rule]) is True


def test_matches_rules_false_if_no_attribute_for_condition():
assert matches_any_rule({}, [numeric_rule]) is False


def test_matches_rules_true_if_no_conditions_for_rule():
assert matches_any_rule({}, [rule_with_empty_conditions]) is True


def test_matches_rules_false_if_numeric_operator_with_string():
assert matches_any_rule({"age": "99"}, [numeric_rule]) is False


def test_matches_rules_true_with_numeric_value_and_regex():
condition = Condition(
operator=OperatorType.MATCHES, value="[0-9]+", attribute="age"
)
rule = Rule(conditions=[condition])
assert matches_any_rule({"age": 99}, [rule]) is True