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

Commit e02e0c4

Browse files
authored
Migrate Python SDK to use V2 Randomization Endpoint (#13)
* Update to use new test data * Use V2 Randomization Endpoint * Log when no assigned variation * Update GH action for tests * remove conftest.py * experiment_key -> flag_key * bump version
1 parent 42dc863 commit e02e0c4

File tree

11 files changed

+272
-177
lines changed

11 files changed

+272
-177
lines changed

.github/workflows/test-and-lint-sdk.yml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ jobs:
6060
python -m pip install --upgrade pip setuptools wheel
6161
python -m pip install -r requirements.txt
6262
python -m pip install -r requirements-test.txt
63-
- name: "Run tox tests"
64-
run: |
65-
tox
63+
- name: 'Set up GCP SDK to download test data'
64+
uses: 'google-github-actions/setup-gcloud@v0'
65+
- name: "Run tests"
66+
run: make test

Makefile

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Make settings - @see https://tech.davis-hansson.com/p/make/
2+
SHELL := bash
3+
.ONESHELL:
4+
.SHELLFLAGS := -eu -o pipefail -c
5+
.DELETE_ON_ERROR:
6+
MAKEFLAGS += --warn-undefined-variables
7+
MAKEFLAGS += --no-builtin-rules
8+
9+
# Log levels
10+
DEBUG := $(shell printf "\e[2D\e[35m")
11+
INFO := $(shell printf "\e[2D\e[36m🔵 ")
12+
OK := $(shell printf "\e[2D\e[32m🟢 ")
13+
WARN := $(shell printf "\e[2D\e[33m🟡 ")
14+
ERROR := $(shell printf "\e[2D\e[31m🔴 ")
15+
END := $(shell printf "\e[0m")
16+
17+
.PHONY: default
18+
default: help
19+
20+
## help - Print help message.
21+
.PHONY: help
22+
help: Makefile
23+
@echo "usage: make <target>"
24+
@sed -n 's/^##//p' $<
25+
26+
## test-data
27+
testDataDir := test/test-data/
28+
.PHONY: test-data
29+
test-data:
30+
rm -rf $(testDataDir)
31+
mkdir -p $(testDataDir)
32+
gsutil cp gs://sdk-test-data/rac-experiments-v2.json $(testDataDir)
33+
gsutil cp -r gs://sdk-test-data/assignment-v2 $(testDataDir)
34+
35+
.PHONY: test
36+
test: test-data
37+
tox .

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__ = "1.0.4"
13+
__version__ = "1.1.0"
1414

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

eppo_client/client.py

Lines changed: 39 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
import hashlib
22
import datetime
33
import logging
4-
from typing import List, Optional
4+
from typing import Optional
55
from eppo_client.assignment_logger import AssignmentLogger
66
from eppo_client.configuration_requestor import (
77
ExperimentConfigurationDto,
88
ExperimentConfigurationRequestor,
99
)
1010
from eppo_client.constants import POLL_INTERVAL_MILLIS, POLL_JITTER_MILLIS
1111
from eppo_client.poller import Poller
12-
from eppo_client.rules import Rule, matches_any_rule
12+
from eppo_client.rules import find_matching_rule
1313
from eppo_client.shard import get_shard, is_in_shard_range
1414
from eppo_client.validation import validate_not_blank
1515

@@ -32,47 +32,65 @@ def __init__(
3232
self.__poller.start()
3333

3434
def get_assignment(
35-
self, subject_key: str, experiment_key: str, subject_attributes=dict()
35+
self, subject_key: str, flag_key: str, subject_attributes=dict()
3636
) -> Optional[str]:
3737
"""Maps a subject to a variation for a given experiment
3838
Returns None if the subject is not part of the experiment sample.
3939
4040
:param subject_key: an identifier of the experiment subject, for example a user ID.
41-
:param experiment_key: an experiment identifier
41+
:param flag_key: an experiment or feature flag identifier
4242
:param subject_attributes: optional attributes associated with the subject, for example name and email.
4343
The subject attributes are used for evaluating any targeting rules tied to the experiment.
4444
"""
4545
validate_not_blank("subject_key", subject_key)
46-
validate_not_blank("experiment_key", experiment_key)
47-
experiment_config = self.__config_requestor.get_configuration(experiment_key)
46+
validate_not_blank("flag_key", flag_key)
47+
experiment_config = self.__config_requestor.get_configuration(flag_key)
4848
override = self._get_subject_variation_override(experiment_config, subject_key)
4949
if override:
5050
return override
51-
if (
52-
experiment_config is None
53-
or not experiment_config.enabled
54-
or not self._subject_attributes_satisfy_rules(
55-
subject_attributes, experiment_config.rules
51+
52+
if experiment_config is None or not experiment_config.enabled:
53+
logger.info(
54+
"[Eppo SDK] No assigned variation. No active experiment or flag for key: "
55+
+ flag_key
5656
)
57-
or not self._is_in_experiment_sample(
58-
subject_key, experiment_key, experiment_config
57+
return None
58+
59+
matched_rule = find_matching_rule(subject_attributes, experiment_config.rules)
60+
if matched_rule is None:
61+
logger.info(
62+
"[Eppo SDK] No assigned variation. Subject attributes do not match targeting rules: {0}".format(
63+
subject_attributes
64+
)
5965
)
66+
return None
67+
68+
allocation = experiment_config.allocations[matched_rule.allocation_key]
69+
if not self._is_in_experiment_sample(
70+
subject_key,
71+
flag_key,
72+
experiment_config.subject_shards,
73+
allocation.percent_exposure,
6074
):
75+
logger.info(
76+
"[Eppo SDK] No assigned variation. Subject is not part of experiment sample population"
77+
)
6178
return None
79+
6280
shard = get_shard(
63-
"assignment-{}-{}".format(subject_key, experiment_key),
81+
"assignment-{}-{}".format(subject_key, flag_key),
6482
experiment_config.subject_shards,
6583
)
6684
assigned_variation = next(
6785
(
68-
variation.name
69-
for variation in experiment_config.variations
86+
variation.value
87+
for variation in allocation.variations
7088
if is_in_shard_range(shard, variation.shard_range)
7189
),
7290
None,
7391
)
7492
assignment_event = {
75-
"experiment": experiment_key,
93+
"experiment": flag_key,
7694
"variation": assigned_variation,
7795
"subject": subject_key,
7896
"timestamp": datetime.datetime.utcnow().isoformat(),
@@ -84,13 +102,6 @@ def get_assignment(
84102
logger.error("[Eppo SDK] Error logging assignment event: " + str(e))
85103
return assigned_variation
86104

87-
def _subject_attributes_satisfy_rules(
88-
self, subject_attributes: dict, rules: List[Rule]
89-
) -> bool:
90-
if len(rules) == 0:
91-
return True
92-
return matches_any_rule(subject_attributes, rules)
93-
94105
def _shutdown(self):
95106
"""Stops all background processes used by the client
96107
Do not use the client after calling this method.
@@ -112,13 +123,11 @@ def _is_in_experiment_sample(
112123
self,
113124
subject: str,
114125
experiment_key: str,
115-
experiment_config: ExperimentConfigurationDto,
126+
subject_shards: int,
127+
percent_exposure: float,
116128
):
117129
shard = get_shard(
118130
"exposure-{}-{}".format(subject, experiment_key),
119-
experiment_config.subject_shards,
120-
)
121-
return (
122-
shard
123-
<= experiment_config.percent_exposure * experiment_config.subject_shards
131+
subject_shards,
124132
)
133+
return shard <= percent_exposure * subject_shards

eppo_client/configuration_requestor.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import logging
2-
from typing import Dict, List, Optional, cast
2+
from typing import Any, Dict, List, Optional, cast
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
@@ -12,20 +12,25 @@
1212

1313
class VariationDto(SdkBaseModel):
1414
name: str
15+
value: Any
1516
shard_range: ShardRange
1617

1718

19+
class AllocationDto(SdkBaseModel):
20+
percent_exposure: float
21+
variations: List[VariationDto]
22+
23+
1824
class ExperimentConfigurationDto(SdkBaseModel):
1925
subject_shards: int
20-
percent_exposure: float
2126
enabled: bool
22-
variations: List[VariationDto]
2327
name: Optional[str]
2428
overrides: Dict[str, str] = {}
2529
rules: List[Rule] = []
30+
allocations: Dict[str, AllocationDto]
2631

2732

28-
RAC_ENDPOINT = "/randomized_assignment/config"
33+
RAC_ENDPOINT = "/randomized_assignment/v2/config"
2934

3035

3136
class ExperimentConfigurationRequestor:
@@ -46,9 +51,7 @@ def get_configuration(
4651

4752
def fetch_and_store_configurations(self) -> Dict[str, ExperimentConfigurationDto]:
4853
try:
49-
configs = cast(
50-
dict, self.__http_client.get(RAC_ENDPOINT).get("experiments", {})
51-
)
54+
configs = cast(dict, self.__http_client.get(RAC_ENDPOINT).get("flags", {}))
5255
for exp_key, exp_config in configs.items():
5356
configs[exp_key] = ExperimentConfigurationDto(**exp_config)
5457
self.__config_store.set_configurations(configs)

eppo_client/rules.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,15 @@ class Condition(SdkBaseModel):
2323

2424

2525
class Rule(SdkBaseModel):
26+
allocation_key: str
2627
conditions: List[Condition]
2728

2829

29-
def matches_any_rule(subject_attributes: dict, rules: List[Rule]):
30+
def find_matching_rule(subject_attributes: dict, rules: List[Rule]):
3031
for rule in rules:
3132
if matches_rule(subject_attributes, rule):
32-
return True
33-
return False
33+
return rule
34+
return None
3435

3536

3637
def matches_rule(subject_attributes: dict, rule: Rule):

requirements-test.txt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
tox
22
pytest
33
mypy
4-
google-cloud-storage
54
httpretty

0 commit comments

Comments
 (0)