diff --git a/Makefile b/Makefile index c6e63f5..166f3eb 100644 --- a/Makefile +++ b/Makefile @@ -34,8 +34,7 @@ test-data: rm -rf $(testDataDir) mkdir -p $(tempDir) git clone -b ${branchName} --depth 1 --single-branch ${githubRepoLink} ${gitDataDir} - cp ${gitDataDir}rac-experiments-v3.json ${testDataDir} - cp -r ${gitDataDir}assignment-v2 ${testDataDir} + cp -r ${gitDataDir}ufc ${testDataDir} rm -rf ${tempDir} .PHONY: test diff --git a/eppo_client/__init__.py b/eppo_client/__init__.py index 67196d9..d0185aa 100644 --- a/eppo_client/__init__.py +++ b/eppo_client/__init__.py @@ -2,15 +2,15 @@ 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 +from eppo_client.version import __version__ -__version__ = "1.3.1" __client: Optional[EppoClient] = None __lock = ReadWriteLock() @@ -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( diff --git a/eppo_client/client.py b/eppo_client/client.py index 1db6ddf..2200bc6 100644 --- a/eppo_client/client.py +++ b/eppo_client/client.py @@ -1,21 +1,20 @@ -import hashlib import datetime import logging +import json from typing import Any, Dict, Optional -from typing_extensions import deprecated -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.models import VariationType 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.sharders import MD5Sharder +from eppo_client.types import SubjectAttributes, ValueType from eppo_client.validation import validate_not_blank -from eppo_client.variation_type import VariationType +from eppo_client.eval import FlagEvaluation, Evaluator, none_result +from eppo_client.version import __version__ + logger = logging.getLogger(__name__) @@ -36,229 +35,205 @@ 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() - ) -> Optional[str]: - try: - assigned_variation = self.get_assignment_variation( - subject_key, flag_key, subject_attributes, VariationType.STRING - ) - return ( - assigned_variation.typed_value - if assigned_variation is not None - else assigned_variation - ) - except ValueError as e: - # allow ValueError to bubble up as it is a validation error - raise e - except Exception as e: - if self.__is_graceful_mode: - logger.error("[Eppo SDK] Error getting assignment: " + str(e)) - return None - raise e + self, + flag_key: str, + subject_key: str, + subject_attributes: SubjectAttributes, + default: str, + ) -> str: + return self.get_assignment_variation( + flag_key, + subject_key, + subject_attributes, + default, + VariationType.STRING, + ) + + def get_integer_assignment( + self, + flag_key: str, + subject_key: str, + subject_attributes: SubjectAttributes, + default: int, + ) -> int: + return self.get_assignment_variation( + flag_key, + subject_key, + subject_attributes, + default, + VariationType.INTEGER, + ) def get_numeric_assignment( - self, subject_key: str, flag_key: str, subject_attributes=dict() - ) -> Optional[Number]: - try: - assigned_variation = self.get_assignment_variation( - subject_key, flag_key, subject_attributes, VariationType.NUMERIC - ) - return ( - assigned_variation.typed_value - if assigned_variation is not None - else assigned_variation + self, + flag_key: str, + subject_key: str, + subject_attributes: SubjectAttributes, + default: float, + ) -> float: + # convert to float in case we get an int + return float( + self.get_assignment_variation( + flag_key, + subject_key, + subject_attributes, + default, + VariationType.NUMERIC, ) - except ValueError as e: - # allow ValueError to bubble up as it is a validation error - raise e - except Exception as e: - if self.__is_graceful_mode: - logger.error("[Eppo SDK] Error getting assignment: " + str(e)) - return None - raise e + ) def get_boolean_assignment( - self, subject_key: str, flag_key: str, subject_attributes=dict() - ) -> Optional[bool]: - try: - assigned_variation = self.get_assignment_variation( - subject_key, flag_key, subject_attributes, VariationType.BOOLEAN - ) - return ( - assigned_variation.typed_value - if assigned_variation is not None - else assigned_variation - ) - except ValueError as e: - # allow ValueError to bubble up as it is a validation error - raise e - except Exception as e: - if self.__is_graceful_mode: - logger.error("[Eppo SDK] Error getting assignment: " + str(e)) - return None - raise e + self, + flag_key: str, + subject_key: str, + subject_attributes: SubjectAttributes, + default: bool, + ) -> bool: + return self.get_assignment_variation( + flag_key, + subject_key, + subject_attributes, + default, + VariationType.BOOLEAN, + ) - def get_parsed_json_assignment( - self, subject_key: str, flag_key: str, subject_attributes=dict() - ) -> Optional[Dict[Any, Any]]: - try: - assigned_variation = self.get_assignment_variation( - subject_key, flag_key, subject_attributes, VariationType.JSON - ) - return ( - assigned_variation.typed_value - if assigned_variation is not None - else assigned_variation - ) - except ValueError as e: - # allow ValueError to bubble up as it is a validation error - raise e - except Exception as e: - if self.__is_graceful_mode: - logger.error("[Eppo SDK] Error getting assignment: " + str(e)) - return None - raise e + def get_json_assignment( + self, + flag_key: str, + subject_key: str, + subject_attributes: SubjectAttributes, + default: Dict[Any, Any], + ) -> Dict[Any, Any]: + json_value = self.get_assignment_variation( + flag_key, + subject_key, + subject_attributes, + None, + VariationType.JSON, + ) + if json_value is None: + return default - def get_json_string_assignment( - self, subject_key: str, flag_key: str, subject_attributes=dict() - ) -> Optional[str]: - try: - assigned_variation = self.get_assignment_variation( - subject_key, flag_key, subject_attributes, VariationType.JSON - ) - return ( - assigned_variation.value - if assigned_variation is not None - else assigned_variation - ) - except ValueError as e: - # allow ValueError to bubble up as it is a validation error - raise e - except Exception as e: - if self.__is_graceful_mode: - logger.error("[Eppo SDK] Error getting assignment: " + str(e)) - return None - raise e + return json.loads(json_value) - @deprecated( - "get_assignment is deprecated in favor of the typed get__assignment methods" - ) - def get_assignment( - self, subject_key: str, flag_key: str, subject_attributes=dict() - ) -> Optional[str]: + def get_assignment_variation( + self, + flag_key: str, + subject_key: str, + subject_attributes: SubjectAttributes, + default: Optional[ValueType], + expected_variation_type: VariationType, + ): try: - assigned_variation = self.get_assignment_variation( - subject_key, flag_key, subject_attributes - ) - return ( - assigned_variation.value - if assigned_variation is not None - else assigned_variation + result = self.get_assignment_detail( + flag_key, subject_key, subject_attributes, expected_variation_type ) + if not result or not result.variation: + return default + return result.variation.value except ValueError as e: # allow ValueError to bubble up as it is a validation error raise e except Exception as e: if self.__is_graceful_mode: logger.error("[Eppo SDK] Error getting assignment: " + str(e)) - return None + return default raise e - def get_assignment_variation( + def get_assignment_detail( self, - subject_key: str, flag_key: str, - subject_attributes: Any, - expected_variation_type: Optional[str] = None, - ) -> Optional[VariationDto]: - """Maps a subject to a variation for a given experiment - Returns None if the subject is not part of the experiment sample. + subject_key: str, + subject_attributes: SubjectAttributes, + expected_variation_type: VariationType, + ) -> FlagEvaluation: + """Maps a subject to a variation for a given flag + Returns None if the subject is not allocated in the flag - :param subject_key: an identifier of the experiment subject, for example a user ID. - :param flag_key: an experiment or feature flag identifier + :param subject_key: an identifier of the subject, for example a user ID. + :param flag_key: a feature flag 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. + The subject attributes are used for evaluating any targeting rules tied + to the flag and logged in the logging callback. """ 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 + if subject_attributes is None: + subject_attributes = {} - if experiment_config is None or not experiment_config.enabled: - logger.info( - "[Eppo SDK] No assigned variation. No active experiment or flag for key: " - + flag_key + flag = self.__config_requestor.get_configuration(flag_key) + + if flag is None: + logger.warning( + "[Eppo SDK] No assigned variation. Flag not found: " + flag_key + ) + return none_result( + flag_key, expected_variation_type, subject_key, subject_attributes ) - 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 - ) + if not check_type_match(expected_variation_type, flag.variation_type): + raise TypeError( + f"Variation value does not have the correct type." + f" Found: {flag.variation_type} != {expected_variation_type}" ) - 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, - ): + if not flag.enabled: logger.info( - "[Eppo SDK] No assigned variation. Subject is not part of experiment sample population" + "[Eppo SDK] No assigned variation. Flag is disabled: " + flag_key + ) + return none_result( + flag_key, expected_variation_type, subject_key, subject_attributes ) - 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, - ) + result = self.__evaluator.evaluate_flag(flag, subject_key, subject_attributes) - 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 + if result.variation and not check_value_type_match( + expected_variation_type, result.variation.value + ): + logger.error( + "[Eppo SDK] Variation value does not have the correct type for the flag: " + f"{flag_key} and variation key {result.variation.key}" + ) + return none_result( + flag_key, flag.variation_type, subject_key, subject_attributes + ) assignment_event = { - "allocation": matched_rule.allocation_key, - "experiment": f"{flag_key}-{matched_rule.allocation_key}", + **(result.extra_logging if result else {}), + "allocation": result.allocation_key if result else None, + "experiment": f"{flag_key}-{result.allocation_key}" if result else None, "featureFlag": flag_key, - "variation": assigned_variation_value_to_log, + "variation": result.variation.key if result and result.variation else None, "subject": subject_key, "timestamp": datetime.datetime.utcnow().isoformat(), "subjectAttributes": subject_attributes, + "metaData": {"sdkLanguage": "python", "sdkVersion": __version__}, } try: - self.__assignment_logger.log_assignment(assignment_event) + if result and 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 + + def get_flag_keys(self): + """ + Returns a list of all flag keys that have been initialized. + This can be useful to debug the initialization process. + + Note that it is generally not a good idea to pre-load all flag configurations. + """ + return self.__config_requestor.get_flag_keys() + + def is_initialized(self): + """ + Returns True if the client has successfully initialized + the flag configuration and is ready to serve requests. + """ + return self.__config_requestor.is_initialized() def _shutdown(self): """Stops all background processes used by the client @@ -266,31 +241,25 @@ def _shutdown(self): """ 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 +def check_type_match( + expected_type: Optional[VariationType], actual_type: VariationType +): + return expected_type is None or actual_type == expected_type + + +def check_value_type_match( + expected_type: Optional[VariationType], value: ValueType +) -> bool: + if expected_type is None: + return True + if expected_type in [VariationType.JSON, VariationType.STRING]: + return isinstance(value, str) + if expected_type == VariationType.INTEGER: + return isinstance(value, int) + if expected_type == VariationType.NUMERIC: + # we can convert int to float + return isinstance(value, float) or isinstance(value, int) + if expected_type == VariationType.BOOLEAN: + return isinstance(value, bool) + return False diff --git a/eppo_client/configuration_requestor.py b/eppo_client/configuration_requestor.py index a8fb2ee..9b1d5dd 100644 --- a/eppo_client/configuration_requestor.py +++ b/eppo_client/configuration_requestor.py @@ -1,62 +1,45 @@ import logging -from typing import Any, Dict, List, Optional, cast -from eppo_client.base_model import SdkBaseModel +from typing import Dict, Optional, cast 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" +UFC_ENDPOINT = "/flag-config/v1/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 + self.__is_initialized = False - def get_configuration( - self, experiment_key: str - ) -> Optional[ExperimentConfigurationDto]: + def get_configuration(self, flag_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) + return self.__config_store.get_configuration(flag_key) - def fetch_and_store_configurations(self) -> Dict[str, ExperimentConfigurationDto]: + def get_flag_keys(self): + return self.__config_store.get_keys() + + 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_dict = cast( + dict, self.__http_client.get(UFC_ENDPOINT).get("flags", {}) + ) + configs = {key: Flag(**config) for key, config in configs_dict.items()} self.__config_store.set_configurations(configs) + self.__is_initialized = True return configs except Exception as e: - logger.error("Error retrieving assignment configurations: " + str(e)) + logger.error("Error retrieving flag configurations: " + str(e)) return {} + + def is_initialized(self): + return self.__is_initialized diff --git a/eppo_client/configuration_store.py b/eppo_client/configuration_store.py index f9c57f4..b54ff7c 100644 --- a/eppo_client/configuration_store.py +++ b/eppo_client/configuration_store.py @@ -27,3 +27,6 @@ def set_configurations(self, configs: Dict[str, T]): self.__cache[key] = config finally: self.__lock.release_write() + + def get_keys(self): + return list(self.__cache.keys()) diff --git a/eppo_client/eval.py b/eppo_client/eval.py new file mode 100644 index 0000000..ceaa907 --- /dev/null +++ b/eppo_client/eval.py @@ -0,0 +1,111 @@ +from typing import Dict, Optional +from eppo_client.sharders import Sharder +from eppo_client.models import Flag, Range, Shard, Variation, VariationType +from eppo_client.rules import matches_rule +from dataclasses import dataclass +import datetime + +from eppo_client.types import SubjectAttributes + + +@dataclass +class FlagEvaluation: + flag_key: str + variation_type: VariationType + subject_key: str + subject_attributes: SubjectAttributes + allocation_key: Optional[str] + variation: Optional[Variation] + extra_logging: Dict[str, str] + do_log: bool + + +@dataclass +class Evaluator: + sharder: Sharder + + def evaluate_flag( + self, + flag: Flag, + subject_key: str, + subject_attributes: SubjectAttributes, + ) -> FlagEvaluation: + if not flag.enabled: + return none_result( + flag.key, flag.variation_type, subject_key, subject_attributes + ) + + now = utcnow() + for allocation in flag.allocations: + # Skip allocations that are not active + if allocation.start_at and now < allocation.start_at: + continue + if allocation.end_at and now > allocation.end_at: + continue + + if matches_rules( + allocation.rules, {"id": subject_key, **subject_attributes} + ): + for split in allocation.splits: + # Split needs to match all shards + if all( + self.matches_shard(shard, subject_key, flag.total_shards) + for shard in split.shards + ): + return FlagEvaluation( + flag_key=flag.key, + variation_type=flag.variation_type, + subject_key=subject_key, + subject_attributes=subject_attributes, + allocation_key=allocation.key, + variation=flag.variations.get(split.variation_key), + extra_logging=split.extra_logging, + do_log=allocation.do_log, + ) + + # No allocations matched, return the None result + return none_result( + flag.key, flag.variation_type, subject_key, subject_attributes + ) + + def matches_shard(self, shard: Shard, subject_key: str, total_shards: int) -> bool: + assert total_shards > 0, "Expect total_shards to be strictly positive" + h = self.sharder.get_shard(hash_key(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 hash_key(salt: str, subject_key: str) -> str: + return f"{salt}-{subject_key}" + + +def matches_rules(rules, subject_attributes): + # Skip allocations when none of the rules match + # So we look for (rule 1) OR (rule 2) OR (rule 3) etc. + # If there are no rules, then we always match + return not rules or any(matches_rule(rule, subject_attributes) for rule in rules) + + +def none_result( + flag_key: str, + variation_type: VariationType, + subject_key: str, + subject_attributes: SubjectAttributes, +) -> FlagEvaluation: + return FlagEvaluation( + flag_key=flag_key, + variation_type=variation_type, + subject_key=subject_key, + subject_attributes=subject_attributes, + allocation_key=None, + variation=None, + extra_logging={}, + do_log=False, + ) + + +def utcnow() -> datetime.datetime: + return datetime.datetime.now(datetime.timezone.utc) diff --git a/eppo_client/http_client.py b/eppo_client/http_client.py index 4b6ea52..8ef4a1c 100644 --- a/eppo_client/http_client.py +++ b/eppo_client/http_client.py @@ -5,7 +5,7 @@ import requests -from eppo_client.base_model import SdkBaseModel +from eppo_client.models import SdkBaseModel class SdkParams(SdkBaseModel): @@ -43,7 +43,7 @@ def get(self, resource: str) -> Any: try: response = self.__session.get( self.__base_url + resource, - params=self.__sdk_params.dict(), + params=self.__sdk_params.model_dump(), timeout=REQUEST_TIMEOUT_SECONDS, ) self.__is_unauthorized = response.status_code == HTTPStatus.UNAUTHORIZED diff --git a/eppo_client/models.py b/eppo_client/models.py new file mode 100644 index 0000000..601bc86 --- /dev/null +++ b/eppo_client/models.py @@ -0,0 +1,54 @@ +from datetime import datetime +from enum import Enum +from typing import Dict, List, Optional + +from eppo_client.base_model import SdkBaseModel +from eppo_client.rules import Rule +from eppo_client.types import ValueType + + +class VariationType(Enum): + STRING = "STRING" + INTEGER = "INTEGER" + NUMERIC = "NUMERIC" + BOOLEAN = "BOOLEAN" + JSON = "JSON" + + +class Variation(SdkBaseModel): + key: str + value: ValueType + + +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 + variation_type: VariationType + variations: Dict[str, Variation] + allocations: List[Allocation] + total_shards: int = 10_000 diff --git a/eppo_client/read_write_lock.py b/eppo_client/read_write_lock.py index 7f0c5fe..ef03431 100644 --- a/eppo_client/read_write_lock.py +++ b/eppo_client/read_write_lock.py @@ -26,7 +26,7 @@ def release_read(self): try: self._readers -= 1 if not self._readers: - self._read_ready.notifyAll() + self._read_ready.notify_all() finally: self._read_ready.release() diff --git a/eppo_client/rules.py b/eppo_client/rules.py index 84b1055..bc7c969 100644 --- a/eppo_client/rules.py +++ b/eppo_client/rules.py @@ -4,82 +4,96 @@ from enum import Enum from typing import Any, List -from eppo_client.base_model import SdkBaseModel +from eppo_client.models import SdkBaseModel +from eppo_client.types import ConditionValueType, SubjectAttributes class OperatorType(Enum): MATCHES = "MATCHES" + NOT_MATCHES = "NOT_MATCHES" GTE = "GTE" GT = "GT" LTE = "LTE" LT = "LT" ONE_OF = "ONE_OF" NOT_ONE_OF = "NOT_ONE_OF" + IS_NULL = "IS_NULL" class Condition(SdkBaseModel): operator: OperatorType - attribute: str - value: Any = None + attribute: Any + value: ConditionValueType class Rule(SdkBaseModel): - allocation_key: str conditions: List[Condition] -def find_matching_rule(subject_attributes: dict, rules: List[Rule]): - for rule in rules: - if matches_rule(subject_attributes, rule): - return rule - return None +def matches_rule(rule: Rule, subject_attributes: SubjectAttributes) -> bool: + return all( + evaluate_condition(condition, subject_attributes) + for condition in rule.conditions + ) -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: +def evaluate_condition( + condition: Condition, subject_attributes: SubjectAttributes +) -> bool: subject_value = subject_attributes.get(condition.attribute, None) + if condition.operator == OperatorType.IS_NULL: + if condition.value: + return subject_value is None + return subject_value is not None + if subject_value is not None: if condition.operator == OperatorType.MATCHES: - return bool(re.match(condition.value, str(subject_value))) + return isinstance(condition.value, str) and bool( + re.match(condition.value, str(subject_value)) + ) + if condition.operator == OperatorType.NOT_MATCHES: + return isinstance(condition.value, str) and not bool( + re.match(condition.value, str(subject_value)) + ) elif condition.operator == OperatorType.ONE_OF: - return str(subject_value).lower() in [ - value.lower() for value in condition.value + return isinstance(condition.value, list) and str(subject_value).lower() in [ + str(value).lower() for value in condition.value ] elif condition.operator == OperatorType.NOT_ONE_OF: - return str(subject_value).lower() not in [ - value.lower() for value in condition.value - ] + return isinstance(condition.value, list) and str( + subject_value + ).lower() not in [str(value).lower() for value in condition.value] else: # Numeric operator: value could be numeric or semver. if isinstance(subject_value, numbers.Number): return evaluate_numeric_condition(subject_value, condition) - elif is_valid_semver(subject_value): + elif isinstance(subject_value, str) and is_valid_semver(subject_value): return compare_semver( subject_value, condition.value, condition.operator ) return False -def evaluate_numeric_condition(subject_value: numbers.Number, condition: Condition): - if condition.operator == OperatorType.GT: - return subject_value > condition.value +def evaluate_numeric_condition( + subject_value: numbers.Number, condition: Condition +) -> bool: + if not isinstance(condition.value, numbers.Number): + # this ensures we are comparing numbers to numbers below + # but mypy is not smart enough to tell, so we ignore types below + return False + elif condition.operator == OperatorType.GT: + return subject_value > condition.value # type: ignore elif condition.operator == OperatorType.GTE: - return subject_value >= condition.value + return subject_value >= condition.value # type: ignore elif condition.operator == OperatorType.LT: - return subject_value < condition.value + return subject_value < condition.value # type: ignore elif condition.operator == OperatorType.LTE: - return subject_value <= condition.value + return subject_value <= condition.value # type: ignore return False -def is_valid_semver(value: str): +def is_valid_semver(value: str) -> bool: try: # Parse the string. If it's a valid semver, it will return without errors. semver.VersionInfo.parse(value) @@ -89,7 +103,9 @@ def is_valid_semver(value: str): return False -def compare_semver(attribute_value: Any, condition_value: Any, operator: OperatorType): +def compare_semver( + attribute_value: Any, condition_value: Any, operator: OperatorType +) -> bool: if not is_valid_semver(attribute_value) or not is_valid_semver(condition_value): return False diff --git a/eppo_client/shard.py b/eppo_client/shard.py deleted file mode 100644 index 5183ba5..0000000 --- a/eppo_client/shard.py +++ /dev/null @@ -1,20 +0,0 @@ -import hashlib - -from eppo_client.base_model import SdkBaseModel - - -def get_shard(input: str, subject_shards: int): - hash_output = hashlib.md5(input.encode("utf-8")).hexdigest() - # get the first 4 bytes of the md5 hex string and parse it using base 16 - # (8 hex characters represent 4 bytes, e.g. 0xffffffff represents the max 4-byte integer) - int_from_hash = int(hash_output[0:8], 16) - return int_from_hash % subject_shards - - -class ShardRange(SdkBaseModel): - start: int - end: int - - -def is_in_shard_range(shard: int, range: ShardRange) -> bool: - return shard >= range.start and shard < range.end diff --git a/eppo_client/sharders.py b/eppo_client/sharders.py new file mode 100644 index 0000000..8271a4e --- /dev/null +++ b/eppo_client/sharders.py @@ -0,0 +1,30 @@ +from abc import ABC, abstractmethod +from typing import Dict +import hashlib + + +class Sharder(ABC): + @abstractmethod + def get_shard(self, input: str, total_shards: int) -> int: ... + + +class MD5Sharder(Sharder): + def get_shard(self, input: str, total_shards: int) -> int: + hash_output = hashlib.md5(input.encode("utf-8")).hexdigest() + # get the first 4 bytes of the md5 hex string and parse it using base 16 + # (8 hex characters represent 4 bytes, e.g. 0xffffffff represents the max 4-byte integer) + int_from_hash = int(hash_output[0:8], 16) + return int_from_hash % total_shards + + +class DeterministicSharder(Sharder): + """ + Deterministic sharding based on a look-up table + to simplify writing tests + """ + + def __init__(self, lookup: Dict[str, int]): + self.lookup = lookup + + def get_shard(self, input: str, total_shards: int) -> int: + return self.lookup.get(input, 0) diff --git a/eppo_client/types.py b/eppo_client/types.py new file mode 100644 index 0000000..dcc4a76 --- /dev/null +++ b/eppo_client/types.py @@ -0,0 +1,6 @@ +from typing import Dict, List, Union + +ValueType = Union[str, int, float, bool] +AttributeType = Union[str, int, float, bool] +ConditionValueType = Union[AttributeType, List[AttributeType]] +SubjectAttributes = Dict[str, AttributeType] diff --git a/eppo_client/variation_type.py b/eppo_client/variation_type.py deleted file mode 100644 index 0b7a013..0000000 --- a/eppo_client/variation_type.py +++ /dev/null @@ -1,31 +0,0 @@ -import json -from numbers import Number -from eppo_client.configuration_requestor import VariationDto - - -class VariationType: - STRING = "string" - NUMERIC = "numeric" - BOOLEAN = "boolean" - JSON = "json" - - @classmethod - def is_expected_type( - cls, assigned_variation: VariationDto, expected_variation_type: str - ) -> bool: - if expected_variation_type == cls.STRING: - return isinstance(assigned_variation.typed_value, str) - elif expected_variation_type == cls.NUMERIC: - return isinstance( - assigned_variation.typed_value, Number - ) and not isinstance(assigned_variation.typed_value, bool) - elif expected_variation_type == cls.BOOLEAN: - return isinstance(assigned_variation.typed_value, bool) - elif expected_variation_type == cls.JSON: - try: - parsed_json = json.loads(assigned_variation.value) - json.dumps(assigned_variation.typed_value) - return parsed_json == assigned_variation.typed_value - except (json.JSONDecodeError, TypeError): - pass - return False diff --git a/eppo_client/version.py b/eppo_client/version.py new file mode 100644 index 0000000..528787c --- /dev/null +++ b/eppo_client/version.py @@ -0,0 +1 @@ +__version__ = "3.0.0" diff --git a/requirements-test.txt b/requirements-test.txt index 7f2a005..c66caf9 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,4 +1,5 @@ tox pytest +pytest-mock mypy -httpretty \ No newline at end of file +httpretty diff --git a/setup.cfg b/setup.cfg index eff59e3..cf6bf4b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = eppo-server-sdk -version = attr: eppo_client.__version__ +version = attr: eppo_client.version.__version__ author = Eppo author_email = eppo-team@geteppo.com description = Eppo SDK for Python diff --git a/test/client_test.py b/test/client_test.py index 36d970e..a260c88 100644 --- a/test/client_test.py +++ b/test/client_test.py @@ -5,20 +5,28 @@ import httpretty # type: ignore import pytest from eppo_client.assignment_logger import AssignmentLogger -from eppo_client.client import EppoClient +from eppo_client.client import EppoClient, check_type_match, check_value_type_match from eppo_client.config import Config -from eppo_client.configuration_requestor import ( - AllocationDto, - ExperimentConfigurationDto, - VariationDto, +from eppo_client.models import ( + Allocation, + Flag, + Range, + Shard, + Split, + VariationType, + Variation, ) -from eppo_client.rules import Condition, OperatorType, Rule -from eppo_client.shard import ShardRange from eppo_client import init, get_instance +import logging + +logger = logging.getLogger(__name__) + +TEST_DIR = "test/test-data/ufc/tests" +CONFIG_FILE = "test/test-data/ufc/flags-v1.json" test_data = [] -for file_name in [file for file in os.listdir("test/test-data/assignment-v2")]: - with open("test/test-data/assignment-v2/{}".format(file_name)) as test_case_json: +for file_name in [file for file in os.listdir(TEST_DIR)]: + with open("{}/{}".format(TEST_DIR, file_name)) as test_case_json: test_case_dict = json.load(test_case_json) test_data.append(test_case_dict) @@ -28,11 +36,11 @@ @pytest.fixture(scope="session", autouse=True) def init_fixture(): httpretty.enable() - with open("test/test-data/rac-experiments-v3.json") as mock_rac_response: + with open(CONFIG_FILE) as mock_ufc_response: httpretty.register_uri( httpretty.GET, - MOCK_BASE_URL + "/randomized_assignment/v3/config", - body=json.dumps(json.load(mock_rac_response)), + MOCK_BASE_URL + "/flag-config/v1/config", + body=json.dumps(json.load(mock_ufc_response)), ) client = init( Config( @@ -47,13 +55,18 @@ def init_fixture(): httpretty.disable() +def test_is_initialized(): + client = get_instance() + assert client.is_initialized() + + @patch("eppo_client.configuration_requestor.ExperimentConfigurationRequestor") -def test_assign_blank_experiment(mock_config_requestor): +def test_assign_blank_flag_key(mock_config_requestor): client = EppoClient( config_requestor=mock_config_requestor, assignment_logger=AssignmentLogger() ) with pytest.raises(Exception) as exc_info: - client.get_assignment("subject-1", "") + client.get_string_assignment("", "subject-1", {}, "default value") assert exc_info.value.args[0] == "Invalid value for flag_key: cannot be blank" @@ -63,199 +76,98 @@ def test_assign_blank_subject(mock_config_requestor): config_requestor=mock_config_requestor, assignment_logger=AssignmentLogger() ) with pytest.raises(Exception) as exc_info: - client.get_assignment("", "experiment-1") + client.get_string_assignment("experiment-1", "", {}, "default value") assert exc_info.value.args[0] == "Invalid value for subject_key: cannot be blank" -@patch("eppo_client.configuration_requestor.ExperimentConfigurationRequestor") -def test_assign_subject_not_in_sample(mock_config_requestor): - allocation = AllocationDto( - percent_exposure=0, - variations=[ - VariationDto( - name="control", - value="control", - shard_range=ShardRange(start=0, end=10000), - ) - ], - ) - mock_config_requestor.get_configuration.return_value = ExperimentConfigurationDto( - subject_shards=10000, - enabled=True, - name="recommendation_algo", - overrides=dict(), - allocations={"allocation": allocation}, - ) - client = EppoClient( - config_requestor=mock_config_requestor, assignment_logger=AssignmentLogger() - ) - assert client.get_assignment("user-1", "experiment-key-1") is None - - @patch("eppo_client.assignment_logger.AssignmentLogger") @patch("eppo_client.configuration_requestor.ExperimentConfigurationRequestor") def test_log_assignment(mock_config_requestor, mock_logger): - allocation = AllocationDto( - percent_exposure=1, - variations=[ - VariationDto( - name="control", - value="control", - shard_range=ShardRange(start=0, end=10000), + flag = Flag( + key="flag-key", + enabled=True, + variation_type=VariationType.STRING, + variations={"control": Variation(key="control", value="control")}, + allocations=[ + Allocation( + key="allocation", + splits=[ + Split( + variation_key="control", + shards=[Shard(salt="salt", ranges=[Range(start=0, end=10000)])], + ) + ], ) ], + total_shards=10_000, ) - mock_config_requestor.get_configuration.return_value = ExperimentConfigurationDto( - allocations={"allocation": allocation}, - rules=[Rule(conditions=[], allocation_key="allocation")], - subject_shards=10000, - enabled=True, - name="recommendation_algo", - overrides=dict(), - ) + + mock_config_requestor.get_configuration.return_value = flag client = EppoClient( config_requestor=mock_config_requestor, assignment_logger=mock_logger ) - assert client.get_assignment("user-1", "experiment-key-1") == "control" + assert ( + client.get_string_assignment("falg-key", "user-1", {}, "default value") + == "control" + ) assert mock_logger.log_assignment.call_count == 1 @patch("eppo_client.assignment_logger.AssignmentLogger") @patch("eppo_client.configuration_requestor.ExperimentConfigurationRequestor") def test_get_assignment_handles_logging_exception(mock_config_requestor, mock_logger): - allocation = AllocationDto( - percent_exposure=1, - variations=[ - VariationDto( - name="control", - value="control", - shard_range=ShardRange(start=0, end=10000), + flag = Flag( + key="flag-key", + enabled=True, + variation_type=VariationType.STRING, + variations={"control": Variation(key="control", value="control")}, + allocations=[ + Allocation( + key="allocation", + rules=[], + splits=[ + Split( + variation_key="control", + shards=[Shard(salt="salt", ranges=[Range(start=0, end=10000)])], + ) + ], ) ], - ) - mock_config_requestor.get_configuration.return_value = ExperimentConfigurationDto( - subject_shards=10000, - allocations={"allocation": allocation}, - enabled=True, - rules=[Rule(conditions=[], allocation_key="allocation")], - name="recommendation_algo", - overrides=dict(), - ) - mock_logger.log_assignment.side_effect = ValueError("logging error") - client = EppoClient( - config_requestor=mock_config_requestor, assignment_logger=mock_logger + total_shards=10_000, ) - assert client.get_assignment("user-1", "experiment-key-1") == "control" - + mock_config_requestor.get_configuration.return_value = flag + mock_logger.log_assignment.side_effect = ValueError("logging error") -@patch("eppo_client.configuration_requestor.ExperimentConfigurationRequestor") -def test_assign_subject_with_with_attributes_and_rules(mock_config_requestor): - allocation = AllocationDto( - percent_exposure=1, - variations=[ - VariationDto( - name="control", - value="control", - shard_range=ShardRange(start=0, end=10000), - ) - ], - ) - matches_email_condition = Condition( - operator=OperatorType.MATCHES, value=".*@eppo.com", attribute="email" - ) - text_rule = Rule(conditions=[matches_email_condition], allocation_key="allocation") - mock_config_requestor.get_configuration.return_value = ExperimentConfigurationDto( - subject_shards=10000, - allocations={"allocation": allocation}, - enabled=True, - name="experiment-key-1", - overrides=dict(), - rules=[text_rule], - ) client = EppoClient( - config_requestor=mock_config_requestor, assignment_logger=AssignmentLogger() - ) - 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 + config_requestor=mock_config_requestor, assignment_logger=mock_logger ) assert ( - client.get_assignment("user1", "experiment-key-1", {"email": "test@eppo.com"}) + client.get_string_assignment("flag-key", "user-1", {}, "default value") == "control" ) -@patch("eppo_client.configuration_requestor.ExperimentConfigurationRequestor") -def test_with_subject_in_overrides(mock_config_requestor): - allocation = AllocationDto( - percent_exposure=1, - variations=[ - VariationDto( - name="control", - value="control", - shard_range=ShardRange(start=0, end=10000), - ) - ], - ) - mock_config_requestor.get_configuration.return_value = ExperimentConfigurationDto( - subject_shards=10000, - allocations={"allocation": allocation}, - enabled=True, - rules=[Rule(conditions=[], allocation_key="allocation")], - name="recommendation_algo", - overrides={"d6d7705392bc7af633328bea8c4c6904": "override-variation"}, - typed_overrides={"d6d7705392bc7af633328bea8c4c6904": "override-variation"}, - ) - client = EppoClient( - config_requestor=mock_config_requestor, assignment_logger=AssignmentLogger() - ) - assert client.get_assignment("user-1", "experiment-key-1") == "override-variation" - - -@patch("eppo_client.configuration_requestor.ExperimentConfigurationRequestor") -def test_with_subject_in_overrides_exp_disabled(mock_config_requestor): - allocation = AllocationDto( - percent_exposure=0, - variations=[ - VariationDto( - name="control", - value="control", - shard_range=ShardRange(start=0, end=10000), - ) - ], - ) - mock_config_requestor.get_configuration.return_value = ExperimentConfigurationDto( - subject_shards=10000, - allocations={"allocation": allocation}, - enabled=False, - rules=[Rule(conditions=[], allocation_key="allocation")], - name="recommendation_algo", - overrides={"d6d7705392bc7af633328bea8c4c6904": "override-variation"}, - typed_overrides={"d6d7705392bc7af633328bea8c4c6904": "override-variation"}, - ) - client = EppoClient( - config_requestor=mock_config_requestor, assignment_logger=AssignmentLogger() - ) - assert client.get_assignment("user-1", "experiment-key-1") == "override-variation" - - @patch("eppo_client.configuration_requestor.ExperimentConfigurationRequestor") def test_with_null_experiment_config(mock_config_requestor): mock_config_requestor.get_configuration.return_value = None client = EppoClient( config_requestor=mock_config_requestor, assignment_logger=AssignmentLogger() ) - assert client.get_assignment("user-1", "experiment-key-1") is None + assert ( + client.get_string_assignment("flag-key-1", "user-1", {}, "default value") + == "default value" + ) + assert ( + client.get_string_assignment("flag-key-1", "user-1", {}, "hello world") + == "hello world" + ) @patch("eppo_client.configuration_requestor.ExperimentConfigurationRequestor") -@patch.object(EppoClient, "get_assignment_variation") -def test_graceful_mode_on(mock_get_assignment_variation, mock_config_requestor): - mock_get_assignment_variation.side_effect = Exception("This is a mock exception!") +@patch.object(EppoClient, "get_assignment_detail") +def test_graceful_mode_on(get_assignment_detail, mock_config_requestor): + get_assignment_detail.side_effect = Exception("This is a mock exception!") client = EppoClient( config_requestor=mock_config_requestor, @@ -263,18 +175,29 @@ def test_graceful_mode_on(mock_get_assignment_variation, mock_config_requestor): is_graceful_mode=True, ) - assert client.get_assignment("user-1", "experiment-key-1") is None - assert client.get_boolean_assignment("user-1", "experiment-key-1") is None - assert client.get_json_string_assignment("user-1", "experiment-key-1") is None - assert client.get_numeric_assignment("user-1", "experiment-key-1") is None - assert client.get_string_assignment("user-1", "experiment-key-1") is None - assert client.get_parsed_json_assignment("user-1", "experiment-key-1") is None + assert ( + client.get_assignment_variation( + "experiment-key-1", "user-1", {}, "default", VariationType.STRING + ) + == "default" + ) + assert client.get_boolean_assignment("experiment-key-1", "user-1", {}, default=True) + assert client.get_numeric_assignment("experiment-key-1", "user-1", {}, 1.0) == 1.0 + assert ( + client.get_string_assignment( + "experiment-key-1", "user-1", {}, default="control" + ) + == "control" + ) + assert client.get_json_assignment( + "experiment-key-1", "user-1", {}, {"hello": "world"} + ) == {"hello": "world"} @patch("eppo_client.configuration_requestor.ExperimentConfigurationRequestor") -@patch.object(EppoClient, "get_assignment_variation") -def test_graceful_mode_off(mock_get_assignment_variation, mock_config_requestor): - mock_get_assignment_variation.side_effect = Exception("This is a mock exception!") +@patch.object(EppoClient, "get_assignment_detail") +def test_graceful_mode_off(mock_get_assignment_detail, mock_config_requestor): + mock_get_assignment_detail.side_effect = Exception("This is a mock exception!") client = EppoClient( config_requestor=mock_config_requestor, @@ -283,49 +206,85 @@ def test_graceful_mode_off(mock_get_assignment_variation, mock_config_requestor) ) with pytest.raises(Exception): - client.get_assignment("user-1", "experiment-key-1") - client.get_boolean_assignment("user-1", "experiment-key-1") - client.get_json_string_assignment("user-1", "experiment-key-1") - client.get_numeric_assignment("user-1", "experiment-key-1") - client.get_string_assignment("user-1", "experiment-key-1") - client.get_parsed_json_assignment("user-1", "experiment-key-1") + client.get_boolean_assignment("experiment-key-1", "user-1", {}, True) + client.get_numeric_assignment("experiment-key-1", "user-1", {}, 0.0) + client.get_integer_assignment("experiment-key-1", "user-1", {}, 1) + client.get_string_assignment("experiment-key-1", "user-1", {}, "default value") + client.get_json_assignment("experiment-key-1", "user-1", {}, {"hello": "world"}) + + +def test_client_has_flags(): + client = get_instance() + assert len(client.get_flag_keys()) > 0, "No flags have been loaded by the client" @pytest.mark.parametrize("test_case", test_data) def test_assign_subject_in_sample(test_case): - print("---- Test case for {} Experiment".format(test_case["experiment"])) - assignments = get_assignments(test_case=test_case) - assert assignments == test_case["expectedAssignments"] + client = get_instance() + print("---- Test case for {} Experiment".format(test_case["flag"])) + + get_typed_assignment = { + "STRING": client.get_string_assignment, + "INTEGER": client.get_integer_assignment, + "NUMERIC": client.get_numeric_assignment, + "BOOLEAN": client.get_boolean_assignment, + "JSON": client.get_json_assignment, + }[test_case["variationType"]] + assignments = get_assignments(test_case, get_typed_assignment) + for subject, assigned_variation in assignments: + assert ( + assigned_variation == subject["assignment"] + ), f"expected <{subject['assignment']}> for subject {subject['subjectKey']}, found <{assigned_variation}>" -def get_assignments(test_case): + +def get_assignments(test_case, get_assignment_fn): client = get_instance() - get_typed_assignment = { - "string": client.get_string_assignment, - "numeric": client.get_numeric_assignment, - "boolean": client.get_boolean_assignment, - "json": client.get_json_string_assignment, - }[test_case["valueType"]] - - return [ - get_typed_assignment(subjectKey, test_case["experiment"]) - for subjectKey in test_case.get("subjects", []) - ] + [ - get_typed_assignment( - subject_key=subject["subjectKey"], - flag_key=test_case["experiment"], - subject_attributes=subject["subjectAttributes"], + client.__is_graceful_mode = False + + print(test_case["flag"]) + assignments = [] + for subject in test_case.get("subjects", []): + variation = get_assignment_fn( + test_case["flag"], + subject["subjectKey"], + subject["subjectAttributes"], + test_case["defaultValue"], ) - for subject in test_case.get("subjectsWithAttributes", []) - ] + assignments.append((subject, variation)) + return assignments @pytest.mark.parametrize("test_case", test_data) def test_get_numeric_assignment_on_bool_feature_flag_should_return_none(test_case): - if test_case["valueType"] == "boolean": - assignments = get_assignments(test_case=test_case) - assert assignments == test_case["expectedAssignments"] - # Change to get_numeric_assignment and try again - test_case["valueType"] = "numeric" - assignments = get_assignments(test_case=test_case) - assert assignments == [None] * len(test_case["expectedAssignments"]) + client = get_instance() + if test_case["variationType"] == "boolean": + assignments = get_assignments( + test_case=test_case, get_assignment_fn=client.get_numeric_assignment + ) + for _, assigned_variation in assignments: + assert assigned_variation is None + + assignments = get_assignments( + test_case=test_case, get_assignment_fn=client.get_integer_assignment + ) + for _, assigned_variation in assignments: + assert assigned_variation is None + + +def test_check_type_match(): + assert check_type_match(VariationType.STRING, VariationType.STRING) + assert check_type_match(None, VariationType.STRING) + + +def test_check_value_type_match(): + assert check_value_type_match(VariationType.STRING, "hello") + assert check_value_type_match(VariationType.INTEGER, 1) + assert check_value_type_match(VariationType.NUMERIC, 1.0) + assert check_value_type_match(VariationType.NUMERIC, 1) + assert check_value_type_match(VariationType.BOOLEAN, True) + assert check_value_type_match(VariationType.JSON, '{"hello": "world"}') + + assert not check_type_match(VariationType.STRING, 1) + assert not check_type_match(VariationType.INTEGER, 1.0) + assert not check_type_match(VariationType.BOOLEAN, "true") diff --git a/test/configuration_store_test.py b/test/configuration_store_test.py index 40c4beb..0cc0d4a 100644 --- a/test/configuration_store_test.py +++ b/test/configuration_store_test.py @@ -1,41 +1,39 @@ -from eppo_client.configuration_requestor import ( - AllocationDto, - ExperimentConfigurationDto, -) +from eppo_client.models import Flag from eppo_client.configuration_store import ConfigurationStore +from eppo_client.models import VariationType -test_exp = ExperimentConfigurationDto( - subject_shards=1000, - enabled=True, - name="randomization_algo", - allocations={"allocation-1": AllocationDto(percent_exposure=1, variations=[])}, -) TEST_MAX_SIZE = 10 -store: ConfigurationStore[ExperimentConfigurationDto] = ConfigurationStore( - max_size=TEST_MAX_SIZE +store: ConfigurationStore[str] = ConfigurationStore(max_size=TEST_MAX_SIZE) +mock_flag = Flag( + key="mock_flag", + variation_type=VariationType.STRING, + enabled=True, + variations={}, + allocations=[], + total_shards=10000, ) def test_get_configuration_unknown_key(): - store.set_configurations({"randomization_algo": test_exp}) + store.set_configurations({"flag": mock_flag}) assert store.get_configuration("unknown_exp") is None def test_get_configuration_known_key(): - store.set_configurations({"randomization_algo": test_exp}) - assert store.get_configuration("randomization_algo") == test_exp + store.set_configurations({"flag": mock_flag}) + assert store.get_configuration("flag") == mock_flag def test_evicts_old_entries_when_max_size_exceeded(): - store.set_configurations({"item_to_be_evicted": test_exp}) - assert store.get_configuration("item_to_be_evicted") == test_exp + store.set_configurations({"item_to_be_evicted": mock_flag}) + assert store.get_configuration("item_to_be_evicted") == mock_flag configs = {} for i in range(0, TEST_MAX_SIZE): - configs["test-entry-{}".format(i)] = test_exp + configs["test-entry-{}".format(i)] = mock_flag store.set_configurations(configs) assert store.get_configuration("item_to_be_evicted") is None assert ( - store.get_configuration("test-entry-{}".format(TEST_MAX_SIZE - 1)) == test_exp + store.get_configuration("test-entry-{}".format(TEST_MAX_SIZE - 1)) == mock_flag ) diff --git a/test/eval_test.py b/test/eval_test.py new file mode 100644 index 0000000..7eb3475 --- /dev/null +++ b/test/eval_test.py @@ -0,0 +1,484 @@ +import datetime + +from eppo_client.models import ( + Flag, + Allocation, + Range, + VariationType, + Variation, + Split, + Shard, +) +from eppo_client.eval import ( + Evaluator, + FlagEvaluation, + is_in_shard_range, + hash_key, + matches_rules, +) +from eppo_client.rules import Condition, OperatorType, Rule +from eppo_client.sharders import DeterministicSharder, MD5Sharder + +VARIATION_A = Variation(key="a", value="A") +VARIATION_B = Variation(key="b", value="B") +VARIATION_C = Variation(key="c", value="C") + + +def test_disabled_flag_returns_none_result(): + flag = Flag( + key="disabled_flag", + enabled=False, + variation_type=VariationType.STRING, + variations={"a": VARIATION_A}, + allocations=[ + Allocation( + key="default", rules=[], splits=[Split(variation_key="a", shards=[])] + ) + ], + total_shards=10, + ) + + evaluator = Evaluator(sharder=MD5Sharder()) + result = evaluator.evaluate_flag(flag, "subject_key", {}) + assert result.flag_key == "disabled_flag" + assert result.allocation_key is None + assert result.variation is None + assert not result.do_log + + +def test_matches_shard_full_range(): + shard = Shard( + salt="a", + ranges=[Range(start=0, end=100)], + ) + + evaluator = Evaluator(sharder=MD5Sharder()) + assert evaluator.matches_shard(shard, "subject_key", 100) is True + + +def test_matches_shard_full_range_split(): + shard = Shard( + salt="a", + ranges=[Range(start=0, end=50), Range(start=50, end=100)], + ) + + evaluator = Evaluator(sharder=MD5Sharder()) + assert evaluator.matches_shard(shard, "subject_key", 100) is True + + deterministic_evaluator = Evaluator(sharder=DeterministicSharder({"subject": 50})) + assert deterministic_evaluator.matches_shard(shard, "subject_key", 100) is True + + +def test_matches_shard_no_match(): + shard = Shard( + salt="a", + ranges=[Range(start=0, end=50)], + ) + + evaluator = Evaluator(sharder=DeterministicSharder({"a-subject_key": 99})) + assert evaluator.matches_shard(shard, "subject_key", 100) is False + + +def test_eval_empty_flag(): + empty_flag = Flag( + key="empty", + enabled=True, + variation_type=VariationType.STRING, + variations={ + "a": VARIATION_A, + "b": VARIATION_B, + }, + allocations=[], + total_shards=10, + ) + + evaluator = Evaluator(sharder=MD5Sharder()) + assert evaluator.evaluate_flag(empty_flag, "subject_key", {}) == FlagEvaluation( + flag_key="empty", + variation_type=VariationType.STRING, + subject_key="subject_key", + subject_attributes={}, + allocation_key=None, + variation=None, + extra_logging={}, + do_log=False, + ) + + +def test_simple_flag(): + flag = Flag( + key="flag-key", + enabled=True, + variation_type=VariationType.STRING, + variations={"control": Variation(key="control", value="control")}, + allocations=[ + Allocation( + key="allocation", + rules=[], + splits=[ + Split( + variation_key="control", + shards=[Shard(salt="salt", ranges=[Range(start=0, end=10000)])], + ) + ], + ) + ], + total_shards=10_000, + ) + + evaluator = Evaluator(sharder=MD5Sharder()) + result = evaluator.evaluate_flag(flag, "user-1", {}) + assert result.variation == Variation(key="control", value="control") + + +def test_flag_target_on_id(): + flag = Flag( + key="flag-key", + enabled=True, + variation_type=VariationType.STRING, + variations={"control": Variation(key="control", value="control")}, + allocations=[ + Allocation( + key="allocation", + rules=[ + Rule( + conditions=[ + Condition( + operator=OperatorType.ONE_OF, + attribute="id", + value=["user-1", "user-2"], + ) + ] + ) + ], + splits=[ + Split( + variation_key="control", + shards=[], + ) + ], + ) + ], + total_shards=10_000, + ) + + evaluator = Evaluator(sharder=MD5Sharder()) + result = evaluator.evaluate_flag(flag, "user-1", {}) + assert result.variation == Variation(key="control", value="control") + result = evaluator.evaluate_flag(flag, "user-3", {}) + assert result.variation is None + + +def test_catch_all_allocation(): + flag = Flag( + key="flag", + enabled=True, + variation_type=VariationType.STRING, + variations={ + "a": VARIATION_A, + "b": VARIATION_B, + }, + allocations=[ + Allocation( + key="default", + rules=[], + splits=[Split(variation_key="a", shards=[])], + ) + ], + total_shards=10, + ) + + evaluator = Evaluator(sharder=MD5Sharder()) + result = evaluator.evaluate_flag(flag, "subject_key", {}) + assert result.flag_key == "flag" + assert result.allocation_key == "default" + assert result.variation == VARIATION_A + assert result.do_log + + +def test_match_first_allocation_rule(): + flag = Flag( + key="flag", + enabled=True, + variation_type=VariationType.STRING, + variations={ + "a": VARIATION_A, + "b": VARIATION_B, + }, + allocations=[ + Allocation( + key="first", + rules=[ + Rule( + conditions=[ + Condition( + operator=OperatorType.MATCHES, + attribute="email", + value=".*@example.com", + ) + ] + ) + ], + splits=[Split(variation_key="b", shards=[])], + ), + Allocation( + key="default", + rules=[], + splits=[Split(variation_key="a", shards=[])], + ), + ], + total_shards=10, + ) + + evaluator = Evaluator(sharder=MD5Sharder()) + result = evaluator.evaluate_flag(flag, "subject_key", {"email": "eppo@example.com"}) + assert result.flag_key == "flag" + assert result.allocation_key == "first" + assert result.variation == VARIATION_B + + +def test_do_not_match_first_allocation_rule(): + flag = Flag( + key="flag", + enabled=True, + variation_type=VariationType.STRING, + variations={ + "a": VARIATION_A, + "b": VARIATION_B, + }, + allocations=[ + Allocation( + key="first", + rules=[ + Rule( + conditions=[ + Condition( + operator=OperatorType.MATCHES, + attribute="email", + value=".*@example.com", + ) + ] + ) + ], + splits=[Split(variation_key="b", shards=[])], + ), + Allocation( + key="default", + rules=[], + splits=[Split(variation_key="a", shards=[])], + ), + ], + total_shards=10, + ) + + evaluator = Evaluator(sharder=MD5Sharder()) + result = evaluator.evaluate_flag(flag, "subject_key", {"email": "eppo@test.com"}) + assert result.flag_key == "flag" + assert result.allocation_key == "default" + assert result.variation == VARIATION_A + + +def test_eval_sharding(): + flag = Flag( + key="flag", + enabled=True, + variation_type=VariationType.STRING, + variations={ + "a": VARIATION_A, + "b": VARIATION_B, + "c": VARIATION_C, + }, + allocations=[ + Allocation( + key="first", + rules=[], + splits=[ + Split( + variation_key="a", + shards=[ + Shard(salt="traffic", ranges=[Range(start=0, end=5)]), + Shard(salt="split", ranges=[Range(start=0, end=3)]), + ], + ), + Split( + variation_key="b", + shards=[ + Shard(salt="traffic", ranges=[Range(start=0, end=5)]), + Shard(salt="split", ranges=[Range(start=3, end=6)]), + ], + ), + ], + ), + Allocation( + key="default", + rules=[], + splits=[Split(variation_key="c", shards=[])], + ), + ], + total_shards=10, + ) + + evaluator = Evaluator( + sharder=DeterministicSharder( + { + "traffic-alice": 2, + "traffic-bob": 3, + "traffic-charlie": 4, + "traffic-dave": 7, + "split-alice": 1, + "split-bob": 4, + "split-charlie": 8, + "split-dave": 1, + } + ) + ) + + result = evaluator.evaluate_flag(flag, "alice", {}) + assert result.allocation_key == "first" + assert result.variation == VARIATION_A + + result = evaluator.evaluate_flag(flag, "bob", {}) + assert result.allocation_key == "first" + assert result.variation == VARIATION_B + + # charlie matches on traffic but not on split and falls through + result = evaluator.evaluate_flag(flag, "charlie", {}) + assert result.allocation_key == "default" + assert result.variation == VARIATION_C + + # dave does not match traffic + result = evaluator.evaluate_flag(flag, "dave", {}) + assert result.allocation_key == "default" + assert result.variation == VARIATION_C + + +def test_eval_prior_to_alloc(mocker): + flag = Flag( + key="flag", + enabled=True, + variation_type=VariationType.STRING, + variations={"a": VARIATION_A}, + allocations=[ + Allocation( + key="default", + start_at=datetime.datetime(2024, 1, 1), + end_at=datetime.datetime(2024, 2, 1), + rules=[], + splits=[Split(variation_key="a", shards=[])], + ) + ], + total_shards=10, + ) + + evaluator = Evaluator(sharder=MD5Sharder()) + mocker.patch("eppo_client.eval.utcnow", return_value=datetime.datetime(2023, 1, 1)) + result = evaluator.evaluate_flag(flag, "subject_key", {}) + assert result.flag_key == "flag" + assert result.allocation_key is None + assert result.variation is None + + +def test_eval_during_alloc(mocker): + flag = Flag( + key="flag", + enabled=True, + variation_type=VariationType.STRING, + variations={"a": VARIATION_A}, + allocations=[ + Allocation( + key="default", + start_at=datetime.datetime(2024, 1, 1), + end_at=datetime.datetime(2024, 2, 1), + rules=[], + splits=[Split(variation_key="a", shards=[])], + ) + ], + total_shards=10, + ) + + evaluator = Evaluator(sharder=MD5Sharder()) + mocker.patch("eppo_client.eval.utcnow", return_value=datetime.datetime(2024, 1, 5)) + result = evaluator.evaluate_flag(flag, "subject_key", {}) + assert result.flag_key == "flag" + assert result.allocation_key == "default" + assert result.variation == VARIATION_A + + +def test_eval_after_alloc(mocker): + flag = Flag( + key="flag", + enabled=True, + variation_type=VariationType.STRING, + variations={"a": VARIATION_A}, + allocations=[ + Allocation( + key="default", + start_at=datetime.datetime(2024, 1, 1), + end_at=datetime.datetime(2024, 2, 1), + rules=[], + splits=[Split(variation_key="a", shards=[])], + ) + ], + total_shards=10, + ) + + evaluator = Evaluator(sharder=MD5Sharder()) + mocker.patch("eppo_client.eval.utcnow", return_value=datetime.datetime(2024, 2, 5)) + result = evaluator.evaluate_flag(flag, "subject_key", {}) + assert result.flag_key == "flag" + assert result.allocation_key is None + assert result.variation is None + + +def test_matches_rules_empty(): + rules = [] + subject_attributes = {"size": 10} + assert matches_rules(rules, subject_attributes) + + +def test_matches_rules_with_conditions(): + rules = [ + Rule( + conditions=[ + Condition(attribute="size", operator=OperatorType.IS_NULL, value=True) + ] + ), + Rule( + conditions=[ + Condition( + attribute="country", operator=OperatorType.ONE_OF, value=["UK"] + ) + ] + ), + ] + subject_attributes_1 = {"size": None, "country": "US"} + subject_attributes_2 = {"size": 10, "country": "UK"} + subject_attributes_3 = {"size": 5, "country": "US"} + subject_attributes_4 = {"country": "US"} + + assert ( + matches_rules(rules, subject_attributes_1) is True + ), f"{subject_attributes_1} should match first rule" + assert ( + matches_rules(rules, subject_attributes_2) is True + ), f"{subject_attributes_2} should match second rule" + assert ( + matches_rules(rules, subject_attributes_3) is False + ), f"{subject_attributes_3} should not match any rule" + assert ( + matches_rules(rules, subject_attributes_4) is True + ), f"{subject_attributes_4} should match first rule" + + +def test_seed(): + assert hash_key("salt", "subject") == "salt-subject" + + +def test_is_in_shard_range(): + assert is_in_shard_range(5, Range(start=0, end=10)) is True + assert is_in_shard_range(10, Range(start=0, end=10)) is False + assert is_in_shard_range(0, Range(start=0, end=10)) is True + assert is_in_shard_range(0, Range(start=0, end=0)) is False + assert is_in_shard_range(0, Range(start=0, end=1)) is True + assert is_in_shard_range(1, Range(start=0, end=1)) is False + assert is_in_shard_range(1, Range(start=1, end=1)) is False diff --git a/test/rules_test.py b/test/rules_test.py index 65089d2..7741b48 100644 --- a/test/rules_test.py +++ b/test/rules_test.py @@ -2,169 +2,371 @@ OperatorType, Rule, Condition, - find_matching_rule, + evaluate_condition, + matches_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( - allocation_key="allocation", conditions=[less_than_condition, greater_than_condition], ) matches_email_condition = Condition( operator=OperatorType.MATCHES, value=".*@email.com", attribute="email" ) -text_rule = Rule(allocation_key="allocation", conditions=[matches_email_condition]) +text_rule = Rule(conditions=[matches_email_condition]) -rule_with_empty_conditions = Rule(allocation_key="allocation", conditions=[]) +rule_with_empty_conditions = Rule(conditions=[]) -def test_find_matching_rule_with_empty_rules(): - subject_attributes = {"age": 20, "country": "US"} - assert find_matching_rule(subject_attributes, []) is None +def test_matches_rule_with_empty_rule(): + assert matches_rule(Rule(conditions=[]), {}) -def test_find_matching_rule_when_no_rules_match(): - subject_attributes = {"age": 99, "country": "US", "email": "test@example.com"} - assert find_matching_rule(subject_attributes, [text_rule]) is None +def test_matches_rule_with_single_condition(): + assert matches_rule( + Rule( + conditions=[Condition(operator=OperatorType.GT, value=10, attribute="age")] + ), + {"age": 11}, + ) + + +def test_matches_rule_with_single_condition_missing_attribute(): + assert not matches_rule( + Rule( + conditions=[Condition(operator=OperatorType.GT, value=10, attribute="age")] + ), + {"name": "alice"}, + ) + + +def test_matches_rule_with_single_false_condition(): + assert not matches_rule( + Rule( + conditions=[Condition(operator=OperatorType.GT, value=10, attribute="age")] + ), + {"age": 9}, + ) + + +def test_matches_rule_with_two_conditions(): + assert matches_rule( + Rule( + conditions=[ + Condition(operator=OperatorType.GT, value=10, attribute="age"), + Condition(operator=OperatorType.LT, value=100, attribute="age"), + ] + ), + {"age": 20}, + ) + + +def test_matches_rule_with_true_and_false_condition(): + assert not matches_rule( + Rule( + conditions=[ + Condition(operator=OperatorType.GT, value=10, attribute="age"), + Condition(operator=OperatorType.LT, value=20, attribute="age"), + ] + ), + {"age": 30}, + ) + + +def test_evaluate_condition_one_of(): + assert evaluate_condition( + Condition( + operator=OperatorType.ONE_OF, value=["alice", "bob"], attribute="name" + ), + {"name": "alice"}, + ) + assert evaluate_condition( + Condition( + operator=OperatorType.ONE_OF, value=["alice", "bob"], attribute="name" + ), + {"name": "bob"}, + ) + assert not evaluate_condition( + Condition( + operator=OperatorType.ONE_OF, value=["alice", "bob"], attribute="name" + ), + {"name": "charlie"}, + ) + + +def test_evaluate_condition_not_one_of(): + assert not evaluate_condition( + Condition( + operator=OperatorType.NOT_ONE_OF, value=["alice", "bob"], attribute="name" + ), + {"name": "alice"}, + ) + assert not evaluate_condition( + Condition( + operator=OperatorType.NOT_ONE_OF, value=["alice", "bob"], attribute="name" + ), + {"name": "bob"}, + ) + assert evaluate_condition( + Condition( + operator=OperatorType.NOT_ONE_OF, value=["alice", "bob"], attribute="name" + ), + {"name": "charlie"}, + ) + + # NOT_ONE_OF fails when attribute is not specified + assert not evaluate_condition( + Condition( + operator=OperatorType.NOT_ONE_OF, value=["alice", "bob"], attribute="name" + ), + {}, + ) + + assert not evaluate_condition( + Condition( + operator=OperatorType.NOT_ONE_OF, value=["alice", "bob"], attribute="name" + ), + {"name": None}, + ) + + +def test_evaluate_condition_matches(): + assert evaluate_condition( + Condition(operator=OperatorType.MATCHES, value="^test.*", attribute="email"), + {"email": "test@example.com"}, + ) + assert not evaluate_condition( + Condition(operator=OperatorType.MATCHES, value="^test.*", attribute="email"), + {"email": "example@test.com"}, + ) + + +def test_evaluate_condition_not_matches(): + assert not evaluate_condition( + Condition( + operator=OperatorType.NOT_MATCHES, value="^test.*", attribute="email" + ), + {"email": "test@example.com"}, + ) + assert not evaluate_condition( + Condition( + operator=OperatorType.NOT_MATCHES, value="^test.*", attribute="email" + ), + {}, + ) + assert evaluate_condition( + Condition( + operator=OperatorType.NOT_MATCHES, value="^test.*", attribute="email" + ), + {"email": "example@test.com"}, + ) + + +def test_evaluate_condition_gte(): + assert evaluate_condition( + Condition(operator=OperatorType.GTE, value=18, attribute="age"), + {"age": 18}, + ) + assert not evaluate_condition( + Condition(operator=OperatorType.GTE, value=18, attribute="age"), + {"age": 17}, + ) -def test_find_matching_rule_on_match(): - assert find_matching_rule({"age": 99}, [numeric_rule]) == numeric_rule - assert find_matching_rule({"email": "testing@email.com"}, [text_rule]) == text_rule +def test_evaluate_condition_gt(): + assert evaluate_condition( + Condition(operator=OperatorType.GT, value=18, attribute="age"), + {"age": 19}, + ) + assert not evaluate_condition( + Condition(operator=OperatorType.GT, value=18, attribute="age"), + {"age": 18}, + ) -def test_find_matching_rule_if_no_attribute_for_condition(): - assert find_matching_rule({}, [numeric_rule]) is None +def test_evaluate_condition_lte(): + assert evaluate_condition( + Condition(operator=OperatorType.LTE, value=18, attribute="age"), + {"age": 18}, + ) + assert not evaluate_condition( + Condition(operator=OperatorType.LTE, value=18, attribute="age"), + {"age": 19}, + ) -def test_find_matching_rule_if_no_conditions_for_rule(): - assert ( - find_matching_rule({}, [rule_with_empty_conditions]) - == rule_with_empty_conditions +def test_evaluate_condition_lt(): + assert evaluate_condition( + Condition(operator=OperatorType.LT, value=18, attribute="age"), + {"age": 17}, + ) + assert not evaluate_condition( + Condition(operator=OperatorType.LT, value=18, attribute="age"), + {"age": 18}, ) -def test_find_matching_rule_if_numeric_operator_with_string(): - assert find_matching_rule({"age": "99"}, [numeric_rule]) is None +def test_evaluate_condition_semver_gte(): + assert evaluate_condition( + Condition(operator=OperatorType.GTE, value="1.0.0", attribute="version"), + {"version": "1.0.1"}, + ) + assert evaluate_condition( + Condition(operator=OperatorType.GTE, value="1.0.0", attribute="version"), + {"version": "1.0.0"}, + ) -def test_find_matching_rule_with_numeric_value_and_regex(): - condition = Condition( - operator=OperatorType.MATCHES, value="[0-9]+", attribute="age" + assert not evaluate_condition( + Condition(operator=OperatorType.GTE, value="1.10.0", attribute="version"), + {"version": "1.2.0"}, ) - rule = Rule(conditions=[condition], allocation_key="allocation") - assert find_matching_rule({"age": 99}, [rule]) == rule + assert evaluate_condition( + Condition(operator=OperatorType.GTE, value="1.5.0", attribute="version"), + {"version": "1.13.0"}, + ) -def test_find_matching_rule_with_semver(): - semver_greater_than_condition = Condition( - operator=OperatorType.GTE, value="1.0.0", attribute="version" + assert not evaluate_condition( + Condition(operator=OperatorType.GTE, value="1.0.0", attribute="version"), + {"version": "0.9.9"}, ) - semver_less_than_condition = Condition( - operator=OperatorType.LTE, value="2.0.0", attribute="version" + + +def test_evaluate_condition_semver_gt(): + assert evaluate_condition( + Condition(operator=OperatorType.GT, value="1.0.0", attribute="version"), + {"version": "1.0.1"}, ) - semver_rule = Rule( - allocation_key="allocation", - conditions=[semver_less_than_condition, semver_greater_than_condition], + + assert not evaluate_condition( + Condition(operator=OperatorType.GT, value="1.0.0", attribute="version"), + {"version": "1.0.0"}, ) - assert find_matching_rule({"version": "1.1.0"}, [semver_rule]) is semver_rule - assert find_matching_rule({"version": "2.0.0"}, [semver_rule]) is semver_rule - assert find_matching_rule({"version": "2.1.0"}, [semver_rule]) is None + assert not evaluate_condition( + Condition(operator=OperatorType.GT, value="1.10.0", attribute="version"), + {"version": "1.2.903"}, + ) + assert evaluate_condition( + Condition(operator=OperatorType.GT, value="1.5.0", attribute="version"), + {"version": "1.13.0"}, + ) -def test_one_of_operator_with_boolean(): - oneOfRule = Rule( - allocation_key="allocation", - conditions=[ - Condition(operator=OperatorType.ONE_OF, value=["True"], attribute="enabled") - ], + assert not evaluate_condition( + Condition(operator=OperatorType.GT, value="1.0.0", attribute="version"), + {"version": "0.9.9"}, ) - notOneOfRule = Rule( - allocation_key="allocation", - conditions=[ - Condition( - operator=OperatorType.NOT_ONE_OF, value=["True"], attribute="enabled" - ) - ], + + +def test_evaluate_condition_semver_lte(): + assert not evaluate_condition( + Condition(operator=OperatorType.LTE, value="1.0.0", attribute="version"), + {"version": "1.0.1"}, ) - assert find_matching_rule({"enabled": True}, [oneOfRule]) == oneOfRule - assert find_matching_rule({"enabled": False}, [oneOfRule]) is None - assert find_matching_rule({"enabled": True}, [notOneOfRule]) is None - assert find_matching_rule({"enabled": False}, [notOneOfRule]) == notOneOfRule + assert evaluate_condition( + Condition(operator=OperatorType.LTE, value="1.0.0", attribute="version"), + {"version": "1.0.0"}, + ) -def test_one_of_operator_case_insensitive(): - oneOfRule = Rule( - allocation_key="allocation", - conditions=[ - Condition( - operator=OperatorType.ONE_OF, value=["1Ab", "Ron"], attribute="name" - ) - ], + assert evaluate_condition( + Condition(operator=OperatorType.LTE, value="1.10.0", attribute="version"), + {"version": "1.2.0"}, ) - assert find_matching_rule({"name": "ron"}, [oneOfRule]) == oneOfRule - assert find_matching_rule({"name": "1AB"}, [oneOfRule]) == oneOfRule + assert not evaluate_condition( + Condition(operator=OperatorType.LTE, value="1.5.0", attribute="version"), + {"version": "1.13.0"}, + ) -def test_not_one_of_operator_case_insensitive(): - notOneOf = Rule( - allocation_key="allocation", - conditions=[ - Condition( - operator=OperatorType.NOT_ONE_OF, - value=["bbB", "1.1.ab"], - attribute="name", - ) - ], + assert evaluate_condition( + Condition(operator=OperatorType.LTE, value="1.0.0", attribute="version"), + {"version": "0.9.9"}, ) - assert find_matching_rule({"name": "BBB"}, [notOneOf]) is None - assert find_matching_rule({"name": "1.1.AB"}, [notOneOf]) is None -def test_one_of_operator_with_string(): - oneOfRule = Rule( - allocation_key="allocation", - conditions=[ - Condition( - operator=OperatorType.ONE_OF, value=["john", "ron"], attribute="name" - ) - ], +def test_evaluate_condition_semver_lt(): + assert not evaluate_condition( + Condition(operator=OperatorType.LT, value="1.0.0", attribute="version"), + {"version": "1.0.1"}, ) - notOneOfRule = Rule( - allocation_key="allocation", - conditions=[ - Condition(operator=OperatorType.NOT_ONE_OF, value=["ron"], attribute="name") - ], + + assert not evaluate_condition( + Condition(operator=OperatorType.LT, value="1.0.0", attribute="version"), + {"version": "1.0.0"}, ) - assert find_matching_rule({"name": "john"}, [oneOfRule]) == oneOfRule - assert find_matching_rule({"name": "ron"}, [oneOfRule]) == oneOfRule - assert find_matching_rule({"name": "sam"}, [oneOfRule]) is None - assert find_matching_rule({"name": "ron"}, [notOneOfRule]) is None - assert find_matching_rule({"name": "sam"}, [notOneOfRule]) == notOneOfRule + + assert evaluate_condition( + Condition(operator=OperatorType.LT, value="1.10.0", attribute="version"), + {"version": "1.2.0"}, + ) + + assert not evaluate_condition( + Condition(operator=OperatorType.LT, value="1.5.0", attribute="version"), + {"version": "1.13.0"}, + ) + + assert evaluate_condition( + Condition(operator=OperatorType.LT, value="1.0.0", attribute="version"), + {"version": "0.9.9"}, + ) + + +def test_evaluate_condition_one_of_int(): + one_of_condition_int = Condition( + operator=OperatorType.ONE_OF, value=[10, 20, 30], attribute="number" + ) + assert evaluate_condition(one_of_condition_int, {"number": 20}) + assert not evaluate_condition(one_of_condition_int, {"number": 40}) + assert not evaluate_condition(one_of_condition_int, {}) + + +def test_evaluate_condition_one_of_boolean(): + one_of_condition_boolean = Condition( + operator=OperatorType.ONE_OF, value=[True, False], attribute="status" + ) + assert evaluate_condition(one_of_condition_boolean, {"status": False}) + assert evaluate_condition(one_of_condition_boolean, {"status": "False"}) + assert not evaluate_condition(one_of_condition_boolean, {"status": "Maybe"}) + assert not evaluate_condition(one_of_condition_boolean, {"status": 0}) + assert not evaluate_condition(one_of_condition_boolean, {"status": 1}) + assert not evaluate_condition(one_of_condition_boolean, {}) def test_one_of_operator_with_number(): - oneOfRule = Rule( - allocation_key="allocation", - conditions=[ - Condition( - operator=OperatorType.ONE_OF, value=["14", "15.11"], attribute="number" - ) - ], - ) - notOneOfRule = Rule( - allocation_key="allocation", - conditions=[ - Condition( - operator=OperatorType.NOT_ONE_OF, value=["10"], attribute="number" - ) - ], - ) - assert find_matching_rule({"number": "14"}, [oneOfRule]) == oneOfRule - assert find_matching_rule({"number": 15.11}, [oneOfRule]) == oneOfRule - assert find_matching_rule({"number": "10"}, [oneOfRule]) is None - assert find_matching_rule({"number": "10"}, [notOneOfRule]) is None - assert find_matching_rule({"number": 11}, [notOneOfRule]) == notOneOfRule + one_of_condition = Condition( + operator=OperatorType.ONE_OF, value=["14", "15.11"], attribute="number" + ) + not_one_of_condition = Condition( + operator=OperatorType.NOT_ONE_OF, value=["10"], attribute="number" + ) + assert evaluate_condition(one_of_condition, {"number": "14"}) + assert evaluate_condition(one_of_condition, {"number": 14}) + assert not evaluate_condition(one_of_condition, {"number": 10}) + assert not evaluate_condition(one_of_condition, {"number": "10"}) + assert not evaluate_condition(not_one_of_condition, {"number": "10"}) + assert not evaluate_condition(not_one_of_condition, {"number": 10}) + assert evaluate_condition(not_one_of_condition, {"number": "11"}) + assert evaluate_condition(not_one_of_condition, {"number": 11}) + + +def test_is_null_operator(): + is_null_condition = Condition( + operator=OperatorType.IS_NULL, value=True, attribute="size" + ) + assert evaluate_condition(is_null_condition, {"size": None}) + assert not evaluate_condition(is_null_condition, {"size": 10}) + assert evaluate_condition(is_null_condition, {}) + + +def test_is_not_null_operator(): + is_not_null_condition = Condition( + operator=OperatorType.IS_NULL, value=False, attribute="size" + ) + assert not evaluate_condition(is_not_null_condition, {"size": None}) + assert evaluate_condition(is_not_null_condition, {"size": 10}) + assert not evaluate_condition(is_not_null_condition, {}) diff --git a/test/sharders_test.py b/test/sharders_test.py new file mode 100644 index 0000000..55b0c89 --- /dev/null +++ b/test/sharders_test.py @@ -0,0 +1,30 @@ +from eppo_client.sharders import MD5Sharder, DeterministicSharder + + +def test_md5_sharder(): + sharder = MD5Sharder() + inputs = [ + ("test-input", 5619), + ("alice", 3170), + ("bob", 7420), + ("charlie", 7497), + ] + total_shards = 10000 + for input, expected_shard in inputs: + assert sharder.get_shard(input, total_shards) == expected_shard + + +def test_deterministic_sharder_present(): + lookup = {"test-input": 5} + sharder = DeterministicSharder(lookup) + input = "test-input" + total_shards = 10 # totalShards is ignored in DeterministicSharder + assert sharder.get_shard(input, total_shards) == 5 + + +def test_deterministic_sharder_absent(): + lookup = {"some-other-input": 7} + sharder = DeterministicSharder(lookup) + input = "test-input-not-in-lookup" + total_shards = 10 # totalShards is ignored in DeterministicSharder + assert sharder.get_shard(input, total_shards) == 0