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

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
schmit committed Mar 5, 2024
1 parent a52c582 commit 72d1ee5
Show file tree
Hide file tree
Showing 13 changed files with 646 additions and 290 deletions.
4 changes: 2 additions & 2 deletions eppo_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
from eppo_client.client import EppoClient
from eppo_client.config import Config
from eppo_client.configuration_requestor import (
ExperimentConfigurationDto,
ExperimentConfigurationRequestor,
)
from eppo_client.configuration_store import ConfigurationStore
from eppo_client.constants import MAX_CACHE_ENTRIES
from eppo_client.http_client import HttpClient, SdkParams
from eppo_client.models import Flag
from eppo_client.read_write_lock import ReadWriteLock

__version__ = "1.3.1"
Expand All @@ -31,7 +31,7 @@ def init(config: Config) -> EppoClient:
apiKey=config.api_key, sdkName="python", sdkVersion=__version__
)
http_client = HttpClient(base_url=config.base_url, sdk_params=sdk_params)
config_store: ConfigurationStore[ExperimentConfigurationDto] = ConfigurationStore(
config_store: ConfigurationStore[Flag] = ConfigurationStore(
max_size=MAX_CACHE_ENTRIES
)
config_requestor = ExperimentConfigurationRequestor(
Expand Down
111 changes: 16 additions & 95 deletions eppo_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,15 @@
from numbers import Number
from eppo_client.assignment_logger import AssignmentLogger
from eppo_client.configuration_requestor import (
ExperimentConfigurationDto,
ExperimentConfigurationRequestor,
VariationDto,
)
from eppo_client.constants import POLL_INTERVAL_MILLIS, POLL_JITTER_MILLIS
from eppo_client.poller import Poller
from eppo_client.rules import find_matching_rule
from eppo_client.shard import ShardRange, get_shard, is_in_shard_range
from eppo_client.sharding import MD5Sharder
from eppo_client.validation import validate_not_blank
from eppo_client.variation_type import VariationType
from eppo_client.eval import Evaluator
from eppo_client.models import Variation, Flag

logger = logging.getLogger(__name__)

Expand All @@ -36,6 +35,7 @@ def __init__(
callback=config_requestor.fetch_and_store_configurations,
)
self.__poller.start()
self.__evaluator = Evaluator(sharder=MD5Sharder())

def get_string_assignment(
self, subject_key: str, flag_key: str, subject_attributes=dict()
Expand Down Expand Up @@ -172,7 +172,7 @@ def get_assignment_variation(
flag_key: str,
subject_attributes: Any,
expected_variation_type: Optional[str] = None,
) -> Optional[VariationDto]:
) -> Optional[Variation]:
"""Maps a subject to a variation for a given experiment
Returns None if the subject is not part of the experiment sample.
Expand All @@ -183,114 +183,35 @@ def get_assignment_variation(
"""
validate_not_blank("subject_key", subject_key)
validate_not_blank("flag_key", flag_key)
experiment_config = self.__config_requestor.get_configuration(flag_key)
override = self._get_subject_variation_override(experiment_config, subject_key)
if override:
if expected_variation_type is not None:
variation_is_expected_type = VariationType.is_expected_type(
override, expected_variation_type
)
if not variation_is_expected_type:
return None
return override
flag = self.__config_requestor.get_configuration(flag_key)

if experiment_config is None or not experiment_config.enabled:
if flag is None or not flag.enabled:
logger.info(
"[Eppo SDK] No assigned variation. No active experiment or flag for key: "
+ flag_key
"[Eppo SDK] No assigned variation. No active flag for key: " + flag_key
)
return None

matched_rule = find_matching_rule(subject_attributes, experiment_config.rules)
if matched_rule is None:
logger.info(
"[Eppo SDK] No assigned variation. Subject attributes do not match targeting rules: {0}".format(
subject_attributes
)
)
return None

allocation = experiment_config.allocations[matched_rule.allocation_key]
if not self._is_in_experiment_sample(
subject_key,
flag_key,
experiment_config.subject_shards,
allocation.percent_exposure,
):
logger.info(
"[Eppo SDK] No assigned variation. Subject is not part of experiment sample population"
)
return None

shard = get_shard(
"assignment-{}-{}".format(subject_key, flag_key),
experiment_config.subject_shards,
)
assigned_variation = next(
(
variation
for variation in allocation.variations
if is_in_shard_range(shard, variation.shard_range)
),
None,
)

assigned_variation_value_to_log = None
if assigned_variation is not None:
assigned_variation_value_to_log = assigned_variation.value
if expected_variation_type is not None:
variation_is_expected_type = VariationType.is_expected_type(
assigned_variation, expected_variation_type
)
if not variation_is_expected_type:
return None
result = self.__evaluator.evaluate_flag(flag, subject_key, subject_attributes)

assignment_event = {
"allocation": matched_rule.allocation_key,
"experiment": f"{flag_key}-{matched_rule.allocation_key}",
**result.extra_logging,
"allocation": result.allocation_key,
"experiment": f"{flag_key}-{result.allocation_key}",
"featureFlag": flag_key,
"variation": assigned_variation_value_to_log,
"variation": result.variation.key,
"subject": subject_key,
"timestamp": datetime.datetime.utcnow().isoformat(),
"subjectAttributes": subject_attributes,
}
try:
self.__assignment_logger.log_assignment(assignment_event)
if result.do_log:
self.__assignment_logger.log_assignment(assignment_event)
except Exception as e:
logger.error("[Eppo SDK] Error logging assignment event: " + str(e))
return assigned_variation
return result.variation

def _shutdown(self):
"""Stops all background processes used by the client
Do not use the client after calling this method.
"""
self.__poller.stop()

def _get_subject_variation_override(
self, experiment_config: Optional[ExperimentConfigurationDto], subject: str
) -> Optional[VariationDto]:
subject_hash = hashlib.md5(subject.encode("utf-8")).hexdigest()
if (
experiment_config is not None
and subject_hash in experiment_config.overrides
):
return VariationDto(
name="override",
value=experiment_config.overrides[subject_hash],
typed_value=experiment_config.typed_overrides[subject_hash],
shard_range=ShardRange(start=0, end=10000),
)
return None

def _is_in_experiment_sample(
self,
subject: str,
experiment_key: str,
subject_shards: int,
percent_exposure: float,
):
shard = get_shard(
"exposure-{}-{}".format(subject, experiment_key),
subject_shards,
)
return shard <= percent_exposure * subject_shards
2 changes: 1 addition & 1 deletion eppo_client/config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from eppo_client.assignment_logger import AssignmentLogger
from eppo_client.base_model import SdkBaseModel
from eppo_client.models import SdkBaseModel

from eppo_client.validation import validate_not_blank

Expand Down
34 changes: 5 additions & 29 deletions eppo_client/configuration_requestor.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,57 +4,33 @@
from eppo_client.configuration_store import ConfigurationStore
from eppo_client.http_client import HttpClient
from eppo_client.rules import Rule
from eppo_client.shard import ShardRange
from eppo_client.models import Flag

logger = logging.getLogger(__name__)


class VariationDto(SdkBaseModel):
name: str
value: str
typed_value: Any = None
shard_range: ShardRange


class AllocationDto(SdkBaseModel):
percent_exposure: float
variations: List[VariationDto]


class ExperimentConfigurationDto(SdkBaseModel):
subject_shards: int
enabled: bool
name: Optional[str] = None
overrides: Dict[str, str] = {}
typed_overrides: Dict[str, Any] = {}
rules: List[Rule] = []
allocations: Dict[str, AllocationDto]


RAC_ENDPOINT = "/randomized_assignment/v3/config"


class ExperimentConfigurationRequestor:
def __init__(
self,
http_client: HttpClient,
config_store: ConfigurationStore[ExperimentConfigurationDto],
config_store: ConfigurationStore[Flag],
):
self.__http_client = http_client
self.__config_store = config_store

def get_configuration(
self, experiment_key: str
) -> Optional[ExperimentConfigurationDto]:
def get_configuration(self, experiment_key: str) -> Optional[Flag]:
if self.__http_client.is_unauthorized():
raise ValueError("Unauthorized: please check your API key")
return self.__config_store.get_configuration(experiment_key)

def fetch_and_store_configurations(self) -> Dict[str, ExperimentConfigurationDto]:
def fetch_and_store_configurations(self) -> Dict[str, Flag]:
try:
configs = cast(dict, self.__http_client.get(RAC_ENDPOINT).get("flags", {}))
for exp_key, exp_config in configs.items():
configs[exp_key] = ExperimentConfigurationDto(**exp_config)
configs[exp_key] = Flag(**exp_config)
self.__config_store.set_configurations(configs)
return configs
except Exception as e:
Expand Down
61 changes: 61 additions & 0 deletions eppo_client/eval.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from typing import Dict, Optional
from eppo_client.sharding import Sharder
from eppo_client.models import Range, Shard, Variation
from eppo_client.rules import matches_rule
import hashlib
from dataclasses import dataclass
import logging


@dataclass
class EvalResult:
flag_key: str
allocation_key: str
variation: Variation
extra_logging: Dict[str, str]
do_log: bool


@dataclass
class Evaluator:
sharder: Sharder

def evaluate_flag(
self, flag, subject_key, subject_attributes
) -> Optional[EvalResult]:
for allocation in flag.allocations:
if not allocation.rules or any(
matches_rule(rule, subject_attributes) for rule in allocation.rules
):
for split in allocation.splits:
if all(
self.matches_shard(shard, subject_key, flag.total_shards)
for shard in split.shards
):
return EvalResult(
flag_key=flag.key,
allocation_key=allocation.key,
variation=flag.variations.get(split.variation_key),
extra_logging=split.extra_logging,
do_log=allocation.do_log,
)

return EvalResult(
flag_key=flag.key,
allocation_key=None,
variation=None,
extra_logging={},
do_log=True,
)

def matches_shard(self, shard: Shard, subject_key: str, total_shards: int) -> bool:
h = self.sharder.get_shard(seed(shard.salt, subject_key), total_shards)
return any(is_in_shard_range(h, r) for r in shard.ranges)


def is_in_shard_range(shard: int, range: Range) -> bool:
return range.start <= shard < range.end


def seed(salt, subject_key):
return f"{salt}-{subject_key}"
2 changes: 1 addition & 1 deletion eppo_client/http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import requests

from eppo_client.base_model import SdkBaseModel
from eppo_client.models import SdkBaseModel


class SdkParams(SdkBaseModel):
Expand Down
45 changes: 45 additions & 0 deletions eppo_client/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from datetime import datetime

from typing import Dict, List, Optional

from eppo_client.base_model import SdkBaseModel
from eppo_client.rules import Rule


class Variation(SdkBaseModel):
# TODO: generalize
key: str
value: str


class Range(SdkBaseModel):
start: int
end: int


class Shard(SdkBaseModel):
salt: str
ranges: List[Range]


class Split(SdkBaseModel):
shards: List[Shard]
variation_key: str
extra_logging: Dict[str, str] = {}


class Allocation(SdkBaseModel):
key: str
rules: List[Rule]
start_at: Optional[datetime] = None
end_at: Optional[datetime] = None
splits: List[Split]
do_log: bool = True


class Flag(SdkBaseModel):
key: str
enabled: bool
variations: Dict[str, Variation]
allocations: List[Allocation]
total_shards: int = 10_000
Loading

0 comments on commit 72d1ee5

Please sign in to comment.