diff --git a/src/amplitude_experiment/__init__.py b/src/amplitude_experiment/__init__.py index cc94982..482e490 100644 --- a/src/amplitude_experiment/__init__.py +++ b/src/amplitude_experiment/__init__.py @@ -12,5 +12,4 @@ from .cookie import AmplitudeCookie from .local.client import LocalEvaluationClient from .local.config import LocalEvaluationConfig -from .flagresult import FlagResult from .assignment import AssignmentConfig diff --git a/src/amplitude_experiment/assignment/assignment.py b/src/amplitude_experiment/assignment/assignment.py index ff61390..4f5cfed 100644 --- a/src/amplitude_experiment/assignment/assignment.py +++ b/src/amplitude_experiment/assignment/assignment.py @@ -1,7 +1,7 @@ import time from typing import Dict -from ..flagresult import FlagResult +from .. import Variant from ..user import User DAY_MILLIS = 24 * 60 * 60 * 1000 @@ -9,7 +9,7 @@ class Assignment: - def __init__(self, user: User, results: Dict[str, FlagResult]): + def __init__(self, user: User, results: Dict[str, Variant]): self.user = user self.results = results self.timestamp = time.time() * 1000 @@ -18,8 +18,10 @@ def canonicalize(self) -> str: user = self.user.user_id.strip() if self.user.user_id else 'None' device = self.user.device_id.strip() if self.user.device_id else 'None' canonical = user + ' ' + device + ' ' - for key in sorted(self.results): - value = self.results[key].variant['key'].strip() if self.results[key] and self.results[key].variant and \ - self.results[key].variant.get('key') else 'None' - canonical += key.strip() + ' ' + value + ' ' + for flag_key in sorted(self.results): + variant = self.results[flag_key] + if variant.key is None: + continue + value = self.results[flag_key].key.strip() + canonical += flag_key.strip() + ' ' + value + ' ' return canonical diff --git a/src/amplitude_experiment/assignment/assignment_service.py b/src/amplitude_experiment/assignment/assignment_service.py index ac483fd..6ba48de 100644 --- a/src/amplitude_experiment/assignment/assignment_service.py +++ b/src/amplitude_experiment/assignment/assignment_service.py @@ -11,23 +11,35 @@ def to_event(assignment: Assignment) -> BaseEvent: event = BaseEvent(event_type='[Experiment] Assignment', user_id=assignment.user.user_id, device_id=assignment.user.device_id, event_properties={}, user_properties={}) - for key in sorted(assignment.results): - event.event_properties[key + '.variant'] = assignment.results[key].variant['key'] - set_props = {} unset_props = {} - for key in sorted(assignment.results): - if assignment.results[key].type == FLAG_TYPE_MUTUAL_EXCLUSION_GROUP: + for flag_key in sorted(assignment.results): + variant = assignment.results[flag_key] + if variant.key is None: + continue + # Get variant metadata + version: int = variant.metadata.get('flagVersion') if variant.metadata is not None else None + segment_name: str = variant.metadata.get('segmentName') if variant.metadata is not None else None + flag_type: str = variant.metadata.get('flagType') if variant.metadata is not None else None + default: bool = False + if variant.metadata is not None and variant.metadata.get('default') is not None: + default = variant.metadata.get('default') + # Set event properties + event.event_properties[flag_key + '.variant'] = variant.key + if version is not None and segment_name is not None: + event.event_properties[flag_key + '.details'] = f"v{version} rule:{segment_name}" + # Build user properties + if flag_type == FLAG_TYPE_MUTUAL_EXCLUSION_GROUP: continue - elif assignment.results[key].is_default_variant: - unset_props[f'[Experiment] {key}'] = '-' + elif default: + unset_props[f'[Experiment] {flag_key}'] = '-' else: - set_props[f'[Experiment] {key}'] = assignment.results[key].variant['key'] + set_props[f'[Experiment] {flag_key}'] = variant.key + # Set user properties and insert id event.user_properties['$set'] = set_props event.user_properties['$unset'] = unset_props - event.insert_id = f'{event.user_id} {event.device_id} {hash_code(assignment.canonicalize())} {int(assignment.timestamp / DAY_MILLIS)}' return event diff --git a/src/amplitude_experiment/flagresult.py b/src/amplitude_experiment/flagresult.py deleted file mode 100644 index 48294be..0000000 --- a/src/amplitude_experiment/flagresult.py +++ /dev/null @@ -1,8 +0,0 @@ -class FlagResult: - def __init__(self, result): - self.variant = result.get('variant') - self.description = result.get('description') - self.is_default_variant = result.get('isDefaultVariant') - self.exp_key = result.get('exp_key') - self.deployed = result.get('deployed') - self.type = result.get('type') diff --git a/src/amplitude_experiment/local/client.py b/src/amplitude_experiment/local/client.py index a641cf8..4db9e5b 100644 --- a/src/amplitude_experiment/local/client.py +++ b/src/amplitude_experiment/local/client.py @@ -1,18 +1,20 @@ import json import logging from threading import Lock -from typing import Any, List, Dict +from typing import Any, List, Dict, Set from amplitude import Amplitude from .config import LocalEvaluationConfig -from ..flagresult import FlagResult +from .topological_sort import topological_sort from ..assignment import Assignment, AssignmentFilter, AssignmentService -from ..assignment.assignment_service import FLAG_TYPE_MUTUAL_EXCLUSION_GROUP, FLAG_TYPE_HOLDOUT_GROUP from ..user import User from ..connection_pool import HTTPConnectionPool from .poller import Poller from .evaluation.evaluation import evaluate +from ..util import deprecated +from ..util.user import user_to_evaluation_context +from ..util.variant import evaluation_variants_json_to_variants from ..variant import Variant from ..version import __version__ @@ -57,37 +59,62 @@ def start(self): self.__do_flags() self.poller.start() - def evaluate(self, user: User, flag_keys: List[str] = None) -> Dict[str, Variant]: + def evaluate_v2(self, user: User, flag_keys: Set[str] = None) -> Dict[str, Variant]: """ - Locally evaluates flag variants for a user. - Parameters: + Locally evaluates flag variants for a user. + + This function will only evaluate flags for the keys specified in the flag_keys argument. If flag_keys is + missing or None, all flags are evaluated. This function differs from evaluate as it will return a default + variant object if the flag was evaluated but the user was not assigned (i.e. off). + + Parameters: user (User): The user to evaluate - flag_keys (List[str]): The flags to evaluate with the user. If empty, all flags from the flag cache are evaluated. + flag_keys (List[str]): The flags to evaluate with the user. If empty, all flags are evaluated. Returns: The evaluated variants. """ - variants = {} if self.flags is None or len(self.flags) == 0: - return variants - user_json = str(user) - self.logger.debug(f"[Experiment] Evaluate: User: {user_json} - Flags: {self.flags}") - result_json = evaluate(self.flags, user_json) + return {} + self.logger.debug(f"[Experiment] Evaluate: user={user} - Flags: {self.flags}") + context = user_to_evaluation_context(user) + sorted_flags = topological_sort(self.flags, flag_keys) + flags_json = json.dumps(sorted_flags) + context_json = json.dumps(context) + result_json = evaluate(flags_json, context_json) self.logger.debug(f"[Experiment] Evaluate Result: {result_json}") evaluation_result = json.loads(result_json) - filter_result = flag_keys is not None - assignment_result = {} - for key, value in evaluation_result.items(): - included = not filter_result or key in flag_keys - if not value.get('isDefaultVariant') and included: - variants[key] = Variant(value['variant'].get('key'), value['variant'].get('payload')) - if included or evaluation_result[key]['type'] == FLAG_TYPE_MUTUAL_EXCLUSION_GROUP or \ - evaluation_result[key]['type'] == FLAG_TYPE_HOLDOUT_GROUP: - assignment_result[key] = FlagResult(value) - if self.assignment_service: - self.assignment_service.track(Assignment(user, assignment_result)) + error = evaluation_result.get('error') + if error is not None: + self.logger.error(f"[Experiment] Evaluation failed: {error}") + return {} + result = evaluation_result.get('result') + if result is None: + return {} + variants = evaluation_variants_json_to_variants(result) + if self.assignment_service is not None: + self.assignment_service.track(Assignment(user, variants)) return variants + @deprecated("Use evaluate_v2") + def evaluate(self, user: User, flag_keys: List[str] = None) -> Dict[str, Variant]: + """ + Locally evaluates flag variants for a user. + + This function will only evaluate flags for the keys specified in the flag_keys argument. If flag_keys is + missing, all flags are evaluated. + + Parameters: + user (User): The user to evaluate + flag_keys (List[str]): The flags to evaluate with the user. If empty, all flags are evaluated. + + Returns: + The evaluated variants. + """ + flag_keys = set(flag_keys) if flag_keys is not None else None + variants = self.evaluate_v2(user, flag_keys) + return self.__filter_default_variants(variants) + def __do_flags(self): conn = self._connection_pool.acquire() headers = { @@ -98,14 +125,16 @@ def __do_flags(self): body = None self.logger.debug('[Experiment] Get flag configs') try: - response = conn.request('GET', '/sdk/v1/flags', body, headers) + response = conn.request('GET', '/sdk/v2/flags?v=0', body, headers) response_body = response.read().decode("utf8") if response.status != 200: raise Exception( f"[Experiment] Get flagConfigs - received error response: ${response.status}: ${response_body}") - self.logger.debug(f"[Experiment] Got flag configs: {response_body}") + flags = json.loads(response_body) + flags_dict = {flag['key']: flag for flag in flags} + self.logger.debug(f"[Experiment] Got flag configs: {flags}") self.lock.acquire() - self.flags = response_body + self.flags = flags_dict self.lock.release() finally: self._connection_pool.release(conn) @@ -128,3 +157,15 @@ def __enter__(self) -> 'LocalEvaluationClient': def __exit__(self, *exit_info: Any) -> None: self.stop() + + @staticmethod + def __filter_default_variants(variants: Dict[str, Variant]) -> Dict[str, Variant]: + def is_default_variant(variant: Variant) -> bool: + default = False if variant.metadata.get('default') is None else variant.metadata.get('default') + deployed = True if variant.metadata.get('deployed') is None else variant.metadata.get('deployed') + return default or not deployed + + return {key: variant for key, variant in variants.items() if not is_default_variant(variant)} + + + diff --git a/src/amplitude_experiment/local/evaluation/evaluation.py b/src/amplitude_experiment/local/evaluation/evaluation.py index 3454eb8..6e9bc30 100644 --- a/src/amplitude_experiment/local/evaluation/evaluation.py +++ b/src/amplitude_experiment/local/evaluation/evaluation.py @@ -1,17 +1,18 @@ from .libevaluation_interop import libevaluation_interop_symbols from ctypes import cast, c_char_p -def evaluate(rules: str, user: str) -> str: + +def evaluate(rules: str, context: str) -> str: """ Local evaluation wrapper. Parameters: rules (str): rules JSON string - user (str): user JSON string + context (str): context JSON string Returns: Evaluation results with variants in JSON """ - result = libevaluation_interop_symbols().contents.kotlin.root.evaluate(rules, user) + result = libevaluation_interop_symbols().contents.kotlin.root.evaluate(rules, context) py_result = cast(result, c_char_p).value libevaluation_interop_symbols().contents.DisposeString(result) return str(py_result, 'utf-8') diff --git a/src/amplitude_experiment/local/evaluation/lib/linuxArm64/libevaluation_interop.so b/src/amplitude_experiment/local/evaluation/lib/linuxArm64/libevaluation_interop.so index 1356439..544581e 100755 Binary files a/src/amplitude_experiment/local/evaluation/lib/linuxArm64/libevaluation_interop.so and b/src/amplitude_experiment/local/evaluation/lib/linuxArm64/libevaluation_interop.so differ diff --git a/src/amplitude_experiment/local/evaluation/lib/linuxArm64/libevaluation_interop_api.h b/src/amplitude_experiment/local/evaluation/lib/linuxArm64/libevaluation_interop_api.h index ea18cd4..70485d1 100644 --- a/src/amplitude_experiment/local/evaluation/lib/linuxArm64/libevaluation_interop_api.h +++ b/src/amplitude_experiment/local/evaluation/lib/linuxArm64/libevaluation_interop_api.h @@ -99,7 +99,7 @@ typedef struct { /* User functions. */ struct { struct { - const char* (*evaluate)(const char* rules, const char* user); + const char* (*evaluate)(const char* flags, const char* context); } root; } kotlin; } libevaluation_interop_ExportedSymbols; diff --git a/src/amplitude_experiment/local/evaluation/lib/linuxX64/libevaluation_interop.so b/src/amplitude_experiment/local/evaluation/lib/linuxX64/libevaluation_interop.so index 5fc288a..f9af54d 100755 Binary files a/src/amplitude_experiment/local/evaluation/lib/linuxX64/libevaluation_interop.so and b/src/amplitude_experiment/local/evaluation/lib/linuxX64/libevaluation_interop.so differ diff --git a/src/amplitude_experiment/local/evaluation/lib/linuxX64/libevaluation_interop_api.h b/src/amplitude_experiment/local/evaluation/lib/linuxX64/libevaluation_interop_api.h index ea18cd4..70485d1 100644 --- a/src/amplitude_experiment/local/evaluation/lib/linuxX64/libevaluation_interop_api.h +++ b/src/amplitude_experiment/local/evaluation/lib/linuxX64/libevaluation_interop_api.h @@ -99,7 +99,7 @@ typedef struct { /* User functions. */ struct { struct { - const char* (*evaluate)(const char* rules, const char* user); + const char* (*evaluate)(const char* flags, const char* context); } root; } kotlin; } libevaluation_interop_ExportedSymbols; diff --git a/src/amplitude_experiment/local/evaluation/lib/macosArm64/libevaluation_interop.dylib b/src/amplitude_experiment/local/evaluation/lib/macosArm64/libevaluation_interop.dylib index ec88ff7..2c976b6 100755 Binary files a/src/amplitude_experiment/local/evaluation/lib/macosArm64/libevaluation_interop.dylib and b/src/amplitude_experiment/local/evaluation/lib/macosArm64/libevaluation_interop.dylib differ diff --git a/src/amplitude_experiment/local/evaluation/lib/macosArm64/libevaluation_interop_api.h b/src/amplitude_experiment/local/evaluation/lib/macosArm64/libevaluation_interop_api.h index ea18cd4..70485d1 100644 --- a/src/amplitude_experiment/local/evaluation/lib/macosArm64/libevaluation_interop_api.h +++ b/src/amplitude_experiment/local/evaluation/lib/macosArm64/libevaluation_interop_api.h @@ -99,7 +99,7 @@ typedef struct { /* User functions. */ struct { struct { - const char* (*evaluate)(const char* rules, const char* user); + const char* (*evaluate)(const char* flags, const char* context); } root; } kotlin; } libevaluation_interop_ExportedSymbols; diff --git a/src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop.dylib b/src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop.dylib index 98d247e..1958d29 100755 Binary files a/src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop.dylib and b/src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop.dylib differ diff --git a/src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h b/src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h index ea18cd4..70485d1 100644 --- a/src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h +++ b/src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h @@ -99,7 +99,7 @@ typedef struct { /* User functions. */ struct { struct { - const char* (*evaluate)(const char* rules, const char* user); + const char* (*evaluate)(const char* flags, const char* context); } root; } kotlin; } libevaluation_interop_ExportedSymbols; diff --git a/src/amplitude_experiment/local/topological_sort.py b/src/amplitude_experiment/local/topological_sort.py new file mode 100644 index 0000000..ff4bb8e --- /dev/null +++ b/src/amplitude_experiment/local/topological_sort.py @@ -0,0 +1,57 @@ +from typing import Dict, Set, Any, List, Optional + + +class CycleException(Exception): + """ + Raised when topological sorting encounters a cycle between flag dependencies. + """ + + def __init__(self, path: Set[str]): + self.path = path + + def __str__(self): + return f"Detected a cycle between flags {self.path}" + + +def topological_sort( + flags: Dict[str, Dict[str, Any]], + keys: List[str] = None, + ordered: bool = False +) -> List[Dict[str, Any]]: + available = flags.copy() + result = [] + starting_keys = keys if keys is not None and len(keys) > 0 else list(flags.keys()) + # Used for testing to ensure consistency. + if ordered and (keys is None or len(keys) == 0): + starting_keys.sort() + for flag_key in starting_keys: + traversal = __parent_traversal(flag_key, available, set()) + if traversal is not None: + result.extend(traversal) + return result + + +def __parent_traversal( + flag_key: str, + available: Dict[str, Dict[str, Any]], + path: Set[str] +) -> Optional[List[Dict[str, Any]]]: + flag = available.get(flag_key) + if flag is None: + return None + dependencies = flag.get('dependencies') + if dependencies is None or len(dependencies) == 0: + available.pop(flag_key) + return [flag] + path.add(flag_key) + result = [] + for parent_key in dependencies: + if parent_key in path: + raise CycleException(path) + traversal = __parent_traversal(parent_key, available, path) + if traversal is not None: + result.extend(traversal) + result.append(flag) + path.remove(flag_key) + available.pop(flag_key) + return result diff --git a/src/amplitude_experiment/remote/client.py b/src/amplitude_experiment/remote/client.py index 1f3f066..e576bdf 100644 --- a/src/amplitude_experiment/remote/client.py +++ b/src/amplitude_experiment/remote/client.py @@ -3,11 +3,13 @@ import threading import time from time import sleep -from typing import Any +from typing import Any, Dict from .config import RemoteEvaluationConfig from ..connection_pool import HTTPConnectionPool from ..user import User +from ..util.deprecated import deprecated +from ..util.variant import evaluation_variants_json_to_variants from ..variant import Variant from ..version import __version__ @@ -35,11 +37,14 @@ def __init__(self, api_key, config=None): self.logger.setLevel(logging.DEBUG) self.__setup_connection_pool() - def fetch(self, user: User): + def fetch_v2(self, user: User): """ - Fetch all variants for a user synchronous.This method will automatically retry if configured. + Fetch all variants for a user synchronously. This method will automatically retry if configured, and throw if + all retries fail. This function differs from fetch as it will return a default variant object if the flag + was evaluated but the user was not assigned (i.e. off). + Parameters: - user (User): The Experiment User + user (User): The Experiment User to fetch variants for. Returns: Variants Dictionary. @@ -48,9 +53,9 @@ def fetch(self, user: User): return self.__fetch_internal(user) except Exception as e: self.logger.error(f"[Experiment] Failed to fetch variants: {e}") - return {} + raise e - def fetch_async(self, user: User, callback=None): + def fetch_async_v2(self, user: User, callback=None): """ Fetch all variants for a user asynchronous. Will trigger callback after fetch complete Parameters: @@ -60,6 +65,36 @@ def fetch_async(self, user: User, callback=None): thread = threading.Thread(target=self.__fetch_async_internal, args=(user, callback)) thread.start() + @deprecated("Use fetch_v2") + def fetch(self, user: User): + """ + Fetch all variants for a user synchronous. This method will automatically retry if configured. + Parameters: + user (User): The Experiment User + + Returns: + Variants Dictionary. + """ + try: + variants = self.fetch_v2(user) + return self.__filter_default_variants(variants) + except Exception: + return {} + + @deprecated("Use fetch_async_v2") + def fetch_async(self, user: User, callback=None): + """ + Fetch all variants for a user asynchronous. Will trigger callback after fetch complete + Parameters: + user (User): The Experiment User + callback (callable): Callback function, takes user and variants arguments + """ + def wrapper(u, v, e=None): + v = self.__filter_default_variants(v) + if callback is not None: + callback(u, v, e) + self.fetch_async_v2(user, wrapper) + def __fetch_async_internal(self, user, callback): try: variants = self.__fetch_internal(user) @@ -67,9 +102,8 @@ def __fetch_async_internal(self, user, callback): callback(user, variants) return variants except Exception as e: - self.logger.error(f"[Experiment] Failed to fetch variants: {e}") if callback: - callback(user, {}) + callback(user, {}, e) return {} def __fetch_internal(self, user): @@ -111,11 +145,11 @@ def __do_fetch(self, user): f"cannot be cached by CDN; must be < 8KB") self.logger.debug(f"[Experiment] Fetch variants for user: {str(user_context)}") try: - response = conn.request('POST', '/sdk/vardata', body, headers) + response = conn.request('POST', '/sdk/v2/vardata?v=0', body, headers) elapsed = '%.3f' % ((time.time() - start) * 1000) self.logger.debug(f"[Experiment] Fetch complete in {elapsed} ms") json_response = json.loads(response.read().decode("utf8")) - variants = self.__parse_json_variants(json_response) + variants = evaluation_variants_json_to_variants(json_response) self.logger.debug(f"[Experiment] Fetched variants: {json.dumps(variants, default=str)}") return variants finally: @@ -139,18 +173,22 @@ def __enter__(self) -> 'RemoteEvaluationClient': def __exit__(self, *exit_info: Any) -> None: self.close() - def __add_context(self, user): + @staticmethod + def __add_context(user): user = user or {} user.library = user.library or f"experiment-python-server/{__version__}" return user - def __parse_json_variants(self, json_response): - variants = {} - for key, value in json_response.items(): - variant_value = '' - if 'value' in value: - variant_value = value['value'] - elif 'key' in value: - variant_value = value['key'] - variants[key] = Variant(variant_value, value.get('payload')) - return variants + @staticmethod + def __filter_default_variants(variants: Dict[str, Variant]) -> Dict[str, Variant]: + def is_default_variant(variant: Variant) -> bool: + default = False + if variant.metadata is not None and variant.metadata.get('default') is not None: + default = variant.metadata.get('default') + deployed = True + if variant.metadata is not None and variant.metadata.get('deployed') is not None: + deployed = variant.metadata.get('deployed') + return default and not deployed + return {key: variant for key, variant in variants.items() if not is_default_variant(variant)} + + diff --git a/src/amplitude_experiment/user.py b/src/amplitude_experiment/user.py index ab53249..eed02c7 100644 --- a/src/amplitude_experiment/user.py +++ b/src/amplitude_experiment/user.py @@ -1,14 +1,35 @@ import json +from typing import Dict, Any + class User: """ Defines a user context for evaluation. `device_id` and `user_id` are used for identity resolution. All other predefined fields and user properties are used for rule based user targeting. """ - def __init__(self, device_id=None, user_id=None, country=None, city=None, region=None, dma=None, - language=None, platform=None, version=None, os=None, device_manufacturer=None, device_brand=None, - device_model=None, carrier=None, library=None, user_properties=None): + + def __init__( + self, + device_id: str = None, + user_id: str = None, + country: str = None, + city: str = None, + region: str = None, + dma: str = None, + language: str = None, + platform: str = None, + version: str = None, + os: str = None, + device_manufacturer: str = None, + device_brand: str = None, + device_model: str = None, + carrier: str = None, + library: str = None, + user_properties: Dict[str, Any] = None, + groups: Dict[str, str] = None, + group_properties: Dict[str, Dict[str, Dict[str, Any]]] = None + ): """ Initialize User instance Parameters: @@ -28,6 +49,8 @@ def __init__(self, device_id=None, user_id=None, country=None, city=None, region carrier (str): Predefined field, must be manually provided library (str): Predefined field, must be manually provided user_properties (dict): Custom user properties + groups (dict): Groups associated with the user + group_properties (dict): Properties for groups Returns: User object @@ -48,6 +71,8 @@ def __init__(self, device_id=None, user_id=None, country=None, city=None, region self.carrier = carrier self.library = library self.user_properties = user_properties + self.groups = groups + self.group_properties = group_properties def to_json(self): """Return user information as JSON string.""" diff --git a/src/amplitude_experiment/util/__init__.py b/src/amplitude_experiment/util/__init__.py index 011c633..dfa16f1 100644 --- a/src/amplitude_experiment/util/__init__.py +++ b/src/amplitude_experiment/util/__init__.py @@ -1,2 +1,3 @@ from .cache import Cache from .hash_code import hash_code +from .deprecated import deprecated diff --git a/src/amplitude_experiment/util/deprecated.py b/src/amplitude_experiment/util/deprecated.py new file mode 100644 index 0000000..d22f884 --- /dev/null +++ b/src/amplitude_experiment/util/deprecated.py @@ -0,0 +1,78 @@ +import functools +import inspect +import warnings + +string_types = (type(b''), type(u'')) + + +def deprecated(reason): + """ + This is a decorator which can be used to mark functions + as deprecated. It will result in a warning being emitted + when the function is used. + """ + + if isinstance(reason, string_types): + + # The @deprecated is used with a 'reason'. + # + # .. code-block:: python + # + # @deprecated("please, use another function") + # def old_function(x, y): + # pass + + def decorator(func1): + + if inspect.isclass(func1): + fmt1 = "Call to deprecated class {name} ({reason})." + else: + fmt1 = "Call to deprecated function {name} ({reason})." + + @functools.wraps(func1) + def new_func1(*args, **kwargs): + warnings.simplefilter('always', DeprecationWarning) + warnings.warn( + fmt1.format(name=func1.__name__, reason=reason), + category=DeprecationWarning, + stacklevel=2 + ) + warnings.simplefilter('default', DeprecationWarning) + return func1(*args, **kwargs) + + return new_func1 + + return decorator + + elif inspect.isclass(reason) or inspect.isfunction(reason): + + # The @deprecated is used without any 'reason'. + # + # .. code-block:: python + # + # @deprecated + # def old_function(x, y): + # pass + + func2 = reason + + if inspect.isclass(func2): + fmt2 = "Call to deprecated class {name}." + else: + fmt2 = "Call to deprecated function {name}." + + @functools.wraps(func2) + def new_func2(*args, **kwargs): + warnings.simplefilter('always', DeprecationWarning) + warnings.warn( + fmt2.format(name=func2.__name__), + category=DeprecationWarning, + stacklevel=2 + ) + warnings.simplefilter('default', DeprecationWarning) + return func2(*args, **kwargs) + + return new_func2 + + else: + raise TypeError(repr(type(reason))) \ No newline at end of file diff --git a/src/amplitude_experiment/util/user.py b/src/amplitude_experiment/util/user.py new file mode 100644 index 0000000..93e3fdd --- /dev/null +++ b/src/amplitude_experiment/util/user.py @@ -0,0 +1,31 @@ +from typing import Dict, Any + +from ..user import User + + +def user_to_evaluation_context(user: User) -> Dict[str, Any]: + user_groups = user.groups + user_group_properties = user.group_properties + user_dict = {key: value for key, value in user.__dict__.copy().items() if value is not None} + user_dict.pop('groups', None) + user_dict.pop('group_properties', None) + context = {'user': user_dict} if len(user_dict) > 0 else {} + if user_groups is None: + return context + groups: Dict[str, Dict[str, Any]] = {} + for group_type in user_groups: + group_name = user_groups[group_type] + if type(group_name) == list and len(group_name) > 0: + group_name = group_name[0] + groups[group_type] = {'group_name': group_name} + if user_group_properties is None: + continue + group_properties_type = user_group_properties[group_type] + if group_properties_type is None or type(group_properties_type) != dict: + continue + group_properties_name = group_properties_type[group_name] + if group_properties_name is None or type(group_properties_name) != dict: + continue + groups[group_type]['group_properties'] = group_properties_name + context['groups'] = groups + return context diff --git a/src/amplitude_experiment/util/variant.py b/src/amplitude_experiment/util/variant.py new file mode 100644 index 0000000..c2bf99d --- /dev/null +++ b/src/amplitude_experiment/util/variant.py @@ -0,0 +1,23 @@ +import json +from typing import Dict, Any + +from ..variant import Variant + + +def evaluation_variant_json_to_variant(variant_json: Dict[str, Any]) -> Variant: + value = variant_json.get('value') + if value is not None and type(value) != str: + value = json.dumps(value, separators=(',', ':')) + return Variant( + value=value, + payload=variant_json.get('payload'), + key=variant_json.get('key'), + metadata=variant_json.get('metadata') + ) + + +def evaluation_variants_json_to_variants(variants_json: Dict[str, Dict[str, Any]]) -> Dict[str, Variant]: + variants: Dict[str, Variant] = {} + for key, value in variants_json.items(): + variants[key] = evaluation_variant_json_to_variant(value) + return variants diff --git a/src/amplitude_experiment/variant.py b/src/amplitude_experiment/variant.py index 6861a07..3093608 100644 --- a/src/amplitude_experiment/variant.py +++ b/src/amplitude_experiment/variant.py @@ -1,18 +1,25 @@ +from typing import Dict, Any + + class Variant: """Variant Class""" - def __init__(self, value: str, payload=None): + def __init__(self, value: str = None, payload: Any = None, key: str = None, metadata: Dict[str, Any] = None): """ Initialize a Variant Parameters: value (str): The value of the variant determined by the flag configuration. payload (Any): The attached payload, if any. + key (str): The variant key. + metadata (Dict[str, Any]: Additional variant metadata used by the system. Returns: - Experiment User context containing a device_id and user_id (if available) + An experiment variant """ self.value = value self.payload = payload + self.key = key + self.metadata = metadata def __eq__(self, obj) -> bool: """ @@ -23,8 +30,10 @@ def __eq__(self, obj) -> bool: Returns: True if two variant equals, otherwise False """ - return self.value == obj.value and self.payload == obj.payload + if obj is None: + return False + return self.key == obj.key and self.value == obj.value and self.payload == obj.payload def __str__(self): """Return Variant as string""" - return f"value: {self.value}, payload: {self.payload}" + return f"key: {self.key}, value: {self.value}, payload: {self.payload}, metadata:{self.metadata}" diff --git a/tests/local/assignment/assignment_filter_test.py b/tests/local/assignment/assignment_filter_test.py index b532ce3..9f7eb3a 100644 --- a/tests/local/assignment/assignment_filter_test.py +++ b/tests/local/assignment/assignment_filter_test.py @@ -1,6 +1,8 @@ import time import unittest -from src.amplitude_experiment import User, FlagResult + +from src.amplitude_experiment import Variant +from src.amplitude_experiment import User from src.amplitude_experiment.assignment import Assignment, AssignmentFilter @@ -9,22 +11,20 @@ class AssignmentFilterTestCase(unittest.TestCase): def test_single_assignment(self): assignment_filter = AssignmentFilter(100) user = User(user_id='user', device_id='device') - results = {} - result1 = FlagResult({'variant': {'key': 'on'}, 'isDefaultVariant': False}) - result2 = FlagResult({'variant': {'key': 'control'}, 'isDefaultVariant': True}) - results['flag-key-1'] = result1 - results['flag-key-2'] = result2 + results = { + 'flag-key-1': Variant(key='on', value='on'), + 'flag-key-2': Variant(key='control', value='control'), + } assignment = Assignment(user, results) self.assertTrue(assignment_filter.should_track(assignment)) def test_duplicate_assignments(self): assignment_filter = AssignmentFilter(100) user = User(user_id='user', device_id='device') - results = {} - result1 = FlagResult({'variant': {'key': 'on'}, 'isDefaultVariant': False}) - result2 = FlagResult({'variant': {'key': 'control'}, 'isDefaultVariant': True}) - results['flag-key-1'] = result1 - results['flag-key-2'] = result2 + results = { + 'flag-key-1': Variant(key='on', value='on'), + 'flag-key-2': Variant(key='control', value='control'), + } assignment1 = Assignment(user, results) assignment2 = Assignment(user, results) self.assertTrue(assignment_filter.should_track(assignment1)) @@ -33,14 +33,14 @@ def test_duplicate_assignments(self): def test_same_user_different_results(self): assignment_filter = AssignmentFilter(100) user = User(user_id='user', device_id='device') - results1 = {} - results2 = {} - result1 = FlagResult({'variant': {'key': 'on'}, 'isDefaultVariant': False}) - result2 = FlagResult({'variant': {'key': 'control'}, 'isDefaultVariant': True}) - results1['flag-key-1'] = result1 - results1['flag-key-2'] = result2 - results2['flag-key-2'] = result1 - results2['flag-key-1'] = result2 + results1 = { + 'flag-key-1': Variant(key='on', value='on'), + 'flag-key-2': Variant(key='control', value='control'), + } + results2 = { + 'flag-key-1': Variant(key='control', value='control'), + 'flag-key-2': Variant(key='on', value='on'), + } assignment1 = Assignment(user, results1) assignment2 = Assignment(user, results2) self.assertTrue(assignment_filter.should_track(assignment1)) @@ -50,11 +50,10 @@ def test_same_results_different_users(self): assignment_filter = AssignmentFilter(100) user1 = User(user_id='user', device_id='device') user2 = User(user_id='different user', device_id='device') - results = {} - result1 = FlagResult({'variant': {'key': 'on'}, 'isDefaultVariant': False}) - result2 = FlagResult({'variant': {'key': 'control'}, 'isDefaultVariant': True}) - results['flag-key-1'] = result1 - results['flag-key-2'] = result2 + results = { + 'flag-key-1': Variant(key='on', value='on'), + 'flag-key-2': Variant(key='control', value='control'), + } assignment1 = Assignment(user1, results) assignment2 = Assignment(user2, results) self.assertTrue(assignment_filter.should_track(assignment1)) @@ -74,14 +73,14 @@ def test_empty_results(self): def test_duplicate_assignments_with_different_ordering(self): assignment_filter = AssignmentFilter(100) user = User(user_id='user', device_id='device') - results1 = {} - results2 = {} - result1 = FlagResult({'variant': {'key': 'on'}, 'isDefaultVariant': False}) - result2 = FlagResult({'variant': {'key': 'control'}, 'isDefaultVariant': True}) - results1['flag-key-1'] = result1 - results1['flag-key-2'] = result2 - results2['flag-key-2'] = result2 - results2['flag-key-1'] = result1 + results1 = { + 'flag-key-1': Variant(key='on', value='on'), + 'flag-key-2': Variant(key='control', value='control'), + } + results2 = { + 'flag-key-2': Variant(key='control', value='control'), + 'flag-key-1': Variant(key='on', value='on'), + } assignment1 = Assignment(user, results1) assignment2 = Assignment(user, results2) self.assertTrue(assignment_filter.should_track(assignment1)) @@ -92,11 +91,10 @@ def test_lru_replacement(self): user1 = User(user_id='user1', device_id='device') user2 = User(user_id='user2', device_id='device') user3 = User(user_id='user3', device_id='device') - results = {} - result1 = FlagResult({'variant': {'key': 'on'}, 'isDefaultVariant': False}) - result2 = FlagResult({'variant': {'key': 'control'}, 'isDefaultVariant': True}) - results['flag-key-1'] = result1 - results['flag-key-2'] = result2 + results = { + 'flag-key-1': Variant(key='on', value='on'), + 'flag-key-2': Variant(key='control', value='control'), + } assignment1 = Assignment(user1, results) assignment2 = Assignment(user2, results) assignment3 = Assignment(user3, results) @@ -109,11 +107,10 @@ def test_lru_expiration(self): assignment_filter = AssignmentFilter(100, 1000) user1 = User(user_id='user1', device_id='device') user2 = User(user_id='user2', device_id='device') - results = {} - result1 = FlagResult({'variant': {'key': 'on'}, 'isDefaultVariant': False}) - result2 = FlagResult({'variant': {'key': 'control'}, 'isDefaultVariant': True}) - results['flag-key-1'] = result1 - results['flag-key-2'] = result2 + results = { + 'flag-key-1': Variant(key='on', value='on'), + 'flag-key-2': Variant(key='control', value='control'), + } assignment1 = Assignment(user1, results) assignment2 = Assignment(user2, results) # assignment1 should be evicted @@ -126,3 +123,7 @@ def test_lru_expiration(self): self.assertFalse(assignment_filter.should_track(assignment2)) time.sleep(0.95) self.assertFalse(assignment_filter.should_track(assignment2)) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/local/assignment/assignment_service_test.py b/tests/local/assignment/assignment_service_test.py index 31a51dc..db95e44 100644 --- a/tests/local/assignment/assignment_service_test.py +++ b/tests/local/assignment/assignment_service_test.py @@ -3,7 +3,8 @@ from amplitude import Amplitude -from src.amplitude_experiment import User, FlagResult +from src.amplitude_experiment import Variant +from src.amplitude_experiment import User from src.amplitude_experiment.assignment import AssignmentFilter, Assignment, DAY_MILLIS, to_event, AssignmentService from src.amplitude_experiment.util import hash_code @@ -13,25 +14,85 @@ class AssignmentServiceTestCase(unittest.TestCase): def test_to_event(self): - results = {} - result1 = FlagResult({'variant': {'key': 'on'}, 'isDefaultVariant': False}) - result2 = FlagResult({'variant': {'key': 'control'}, 'isDefaultVariant': True}) - results['flag-key-1'] = result1 - results['flag-key-2'] = result2 + basic = Variant(key='control', value='control', metadata={ + 'segmentName': 'All Other Users', + 'flagType': 'experiment', + 'flagVersion': 10, + 'default': False + }) + different_value = Variant(key='on', value='control', metadata={ + 'segmentName': 'All Other Users', + 'flagType': 'experiment', + 'flagVersion': 10, + 'default': False + }) + default = Variant(key='off', value=None, metadata={ + 'segmentName': 'All Other Users', + 'flagType': 'experiment', + 'flagVersion': 10, + 'default': True + }) + mutex = Variant(key='slot-1', value='slot-1', metadata={ + 'segmentName': 'All Other Users', + 'flagType': 'mutual-exclusion-group', + 'flagVersion': 10, + 'default': False + }) + holdout = Variant(key='holdout', value='holdout', metadata={ + 'segmentName': 'All Other Users', + 'flagType': 'holdout-group', + 'flagVersion': 10, + 'default': False + }) + partial_metadata = Variant(key='on', value='on', metadata={ + 'segmentName': 'All Other Users', + 'flagType': 'release', + }) + empty_metadata = Variant(key='on', value='on') + empty_variant = Variant() + results = { + 'basic': basic, + 'different_value': different_value, + 'default': default, + 'mutex': mutex, + 'holdout': holdout, + 'partial_metadata': partial_metadata, + 'empty_metadata': empty_metadata, + 'empty_variant': empty_variant, + } assignment = Assignment(user, results) event = to_event(assignment) self.assertEqual(user.user_id, event.user_id) self.assertEqual(user.device_id, event.device_id) self.assertEqual('[Experiment] Assignment', event.event_type) + # Validate event properties event_properties = event.event_properties - self.assertEqual(2, len(event_properties)) - self.assertEqual('on', event_properties['flag-key-1.variant']) - self.assertEqual('control', event_properties['flag-key-2.variant']) + self.assertEqual('control', event_properties['basic.variant']) + self.assertEqual('v10 rule:All Other Users', event_properties['basic.details']) + self.assertEqual('on', event_properties['different_value.variant']) + self.assertEqual('v10 rule:All Other Users', event_properties['different_value.details']) + self.assertEqual('off', event_properties['default.variant']) + self.assertEqual('v10 rule:All Other Users', event_properties['default.details']) + self.assertEqual('slot-1', event_properties['mutex.variant']) + self.assertEqual('v10 rule:All Other Users', event_properties['mutex.details']) + self.assertEqual('holdout', event_properties['holdout.variant']) + self.assertEqual('v10 rule:All Other Users', event_properties['holdout.details']) + self.assertEqual('on', event_properties['partial_metadata.variant']) + self.assertEqual('on', event_properties['empty_metadata.variant']) + # Validate user properties user_properties = event.user_properties - self.assertEqual(2, len(user_properties)) - self.assertEqual(1, len(user_properties['$set'])) - self.assertEqual(1, len(user_properties['$unset'])) - canonicalization = 'user device flag-key-1 on flag-key-2 control ' + set_properties = user_properties['$set'] + self.assertEqual('control', set_properties['[Experiment] basic']) + self.assertEqual('on', set_properties['[Experiment] different_value']) + self.assertEqual('holdout', set_properties['[Experiment] holdout']) + self.assertEqual('on', set_properties['[Experiment] partial_metadata']) + self.assertEqual('on', set_properties['[Experiment] empty_metadata']) + unset_properties = user_properties['$unset'] + self.assertEqual('-', unset_properties['[Experiment] default']) + + # Validate insert id + canonicalization = 'user device basic control default off different_value on empty_metadata on holdout ' \ + 'holdout mutex slot-1 partial_metadata on ' expected = f'user device {hash_code(canonicalization)} {int(assignment.timestamp / DAY_MILLIS)}' self.assertEqual(expected, event.insert_id) @@ -39,8 +100,10 @@ def test_tracking_called(self): instance = Amplitude('') instance.track = MagicMock() service = AssignmentService(instance, AssignmentFilter(2)) - results = {} - result = FlagResult({'variant': {'key': 'on'}, 'isDefaultVariant': False}) - results['flag-key-1'] = result + results = {'flag-key-1': Variant(key='on')} service.track(Assignment(user, results)) self.assertTrue(instance.track.called) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/local/benchmark_test.py b/tests/local/benchmark_test.py index ab1aade..20e5937 100644 --- a/tests/local/benchmark_test.py +++ b/tests/local/benchmark_test.py @@ -53,7 +53,7 @@ def random_benchmark_flag(): return f"local-evaluation-benchmark-{n}" -@unittest.skipIf(platform.machine().startswith(('arm', 'aarch64')), "GHA aarch64 too slow") +@unittest.skip("github actions too slow") class BenchmarkTestCase(unittest.TestCase): _local_evaluation_client: LocalEvaluationClient = None diff --git a/tests/local/client_test.py b/tests/local/client_test.py index 448afda..00293db 100644 --- a/tests/local/client_test.py +++ b/tests/local/client_test.py @@ -23,12 +23,12 @@ def test_initialize_raise_error(self): def test_evaluate_all_flags_success(self): variants = self._local_evaluation_client.evaluate(test_user) - expected_variant = Variant('on', 'payload') + expected_variant = Variant(key='on', value='on', payload='payload') self.assertEqual(expected_variant, variants.get('sdk-local-evaluation-ci-test')) def test_evaluate_one_flag_success(self): variants = self._local_evaluation_client.evaluate(test_user, ['sdk-local-evaluation-ci-test']) - expected_variant = Variant('on', 'payload') + expected_variant = Variant(key='on', value='on', payload='payload') self.assertEqual(expected_variant, variants.get('sdk-local-evaluation-ci-test')) def test_invalid_api_key_throw_exception(self): @@ -38,12 +38,12 @@ def test_invalid_api_key_throw_exception(self): def test_evaluate_with_dependencies_success(self): variants = self._local_evaluation_client.evaluate(test_user_2) - expected_variant = Variant('control', None) + expected_variant = Variant(key='control', value='control') self.assertEqual(expected_variant, variants.get('sdk-ci-local-dependencies-test')) def test_evaluate_with_dependencies_and_flag_keys_success(self): variants = self._local_evaluation_client.evaluate(test_user_2, ['sdk-ci-local-dependencies-test']) - expected_variant = Variant('control', None) + expected_variant = Variant(key='control', value='control') self.assertEqual(expected_variant, variants.get('sdk-ci-local-dependencies-test')) def test_evaluate_with_dependencies_and_flag_keys_not_exist_no_variant(self): diff --git a/tests/local/evaluation/evaluation_test.py b/tests/local/evaluation/evaluation_test.py deleted file mode 100644 index 9fef616..0000000 --- a/tests/local/evaluation/evaluation_test.py +++ /dev/null @@ -1,137 +0,0 @@ -import unittest -from src.amplitude_experiment.local.evaluation.evaluation import evaluate - - -class EvaluationTestCase(unittest.TestCase): - def test_local_evaluation(self): - rules_json = ''' - [ - { - "allUsersTargetingConfig":{ - "allocations":[ - { - "percentage":6600, - "weights":{ - "control":1, - "treatment":1 - } - } - ], - "bucketingKey":"amplitude_id", - "conditions":[ - - ], - "name":"default-segment" - }, - "bucketingKey":"amplitude_id", - "bucketingSalt":"xIsm9BUj", - "customSegmentTargetingConfigs":[ - - ], - "defaultValue":"off", - "enabled":true, - "flagKey":"brian-bug-safari", - "flagName":"brian-bug-safari", - "flagVersion":15, - "globalHoldbackBucketingKey":"amplitude_id", - "globalHoldbackPct":0, - "globalHoldbackSalt":"5inHyVr4", - "mutualExclusionConfig":{ - "bucketingKey":"amplitude_id", - "groupSalt":"yYIqQGSY", - "lowerBound":0, - "percentage":5000 - }, - "useStickyBucketing":false, - "userProperty":"[Amplitude][Flag]brian-bug-safari", - "variants":[ - { - "key":"control", - "payload":{ - "asdf":"asdf" - } - }, - { - "key":"treatment", - "payload":[ - "array" - ] - } - ], - "variantsExclusions":null, - "variantsInclusions":null - }, - { - "allUsersTargetingConfig":{ - "allocations":[ - { - "percentage":0, - "weights":{ - "control":1, - "treatment":1 - } - } - ], - "bucketingKey":"amplitude_id", - "conditions":[ - - ], - "name":"default-segment" - }, - "bucketingKey":"amplitude_id", - "bucketingSalt":"LRVo9Day", - "customSegmentTargetingConfigs":[ - - ], - "defaultValue":"off", - "enabled":true, - "flagKey":"brian-bug-safari-2", - "flagName":"brian-bug-safari-2", - "flagVersion":6, - "globalHoldbackBucketingKey":"amplitude_id", - "globalHoldbackPct":0, - "globalHoldbackSalt":"5inHyVr4", - "mutualExclusionConfig":{ - "bucketingKey":"amplitude_id", - "groupSalt":"yYIqQGSY", - "lowerBound":5000, - "percentage":2500 - }, - "useStickyBucketing":false, - "userProperty":"[Experiment]brian-bug-safari-2", - "variants":[ - { - "key":"control", - "payload":null - }, - { - "key":"treatment", - "payload":null - } - ], - "variantsExclusions":null, - "variantsInclusions":null - } - ] - ''' - - user_json = ''' - { - "amplitude_id":1234567, - "user_id":"brian.giori@amplitude.com", - "device_brand":"asus", - "device_manufacturer":"asus", - "device_model":"asus_t00f1", - "language":"spanish(puertorico)" - } - ''' - - expected_result = '{"brian-bug-safari":{"variant":{"key":"treatment","payload":["array"]},"description":' \ - '"default-segment","isDefaultVariant":false,"deployed":true,"type":"release"},' \ - '"brian-bug-safari-2":{"variant":{"key":"off"},' \ - '"description":"default-segment","isDefaultVariant":true,"deployed":true,"type":"release"}}' - self.assertEqual(expected_result, evaluate(rules_json, user_json)) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/local/topological_sort_test.py b/tests/local/topological_sort_test.py new file mode 100644 index 0000000..b2a4a45 --- /dev/null +++ b/tests/local/topological_sort_test.py @@ -0,0 +1,243 @@ +import unittest + +from typing import Dict, Any, List + +from src.amplitude_experiment.local.topological_sort import topological_sort, CycleException + + +class TopologicalSortTestCase(unittest.TestCase): + + def test_empty(self): + flags = [] + # no flag keys + result = sort(flags) + self.assertEqual(result, []) + # with flag keys + result = sort(flags, [1]) + self.assertEqual(result, []) + + def test_single_flag_no_dependencies(self): + flags = [flag(1, [])] + # no flag keys + result = sort(flags) + self.assertEqual(result, flags) + # with flag keys + result = sort(flags, [1]) + self.assertEqual(result, flags) + # with flag keys, no match + result = sort(flags, [999]) + self.assertEqual(result, []) + + def test_single_flag_with_dependencies(self): + flags = [flag(1, [2])] + # no flag keys + result = sort(flags) + self.assertEqual(result, flags) + # with flag keys + result = sort(flags, [1]) + self.assertEqual(result, flags) + # with flag keys, no match + result = sort(flags, [999]) + self.assertEqual(result, []) + + def test_multiple_flags_no_dependencies(self): + flags = [flag(1, []), flag(2, [])] + # no flag keys + result = sort(flags) + self.assertEqual(result, flags) + # with flag keys + result = sort(flags, [1, 2]) + self.assertEqual(result, flags) + # with flag keys, no match + result = sort(flags, [99, 999]) + self.assertEqual(result, []) + + def test_multiple_flags_with_dependencies(self): + flags = [flag(1, [2]), flag(2, [3]), flag(3, [])] + # no flag keys + result = sort(flags) + self.assertEqual(result, [flag(3, []), flag(2, [3]), flag(1, [2])]) + # with flag keys + result = sort(flags, [1, 2]) + self.assertEqual(result, [flag(3, []), flag(2, [3]), flag(1, [2])]) + # with flag keys, no match + result = sort(flags, [99, 999]) + self.assertEqual(result, []) + + def test_single_flag_cycle(self): + flags = [flag(1, [1])] + # no flag keys + try: + sort(flags) + self.fail('Expected topological sort to fail.') + except CycleException as e: + self.assertEqual(e.path, {'1'}) + # with flag keys + try: + sort(flags, [1]) + self.fail('Expected topological sort to fail.') + except CycleException as e: + self.assertEqual(e.path, {'1'}) + # with flag keys, no match + try: + result = sort(flags, [999]) + self.assertEqual(result, []) + except CycleException as e: + self.fail(f"Did not expect exception {e}") + + def test_two_flag_cycle(self): + flags = [flag(1, [2]), flag(2, [1])] + # no flag keys + try: + sort(flags) + self.fail('Expected topological sort to fail.') + except CycleException as e: + self.assertEqual(e.path, {'1', '2'}) + # with flag keys + try: + sort(flags, [1, 2]) + self.fail('Expected topological sort to fail.') + except CycleException as e: + self.assertEqual(e.path, {'1', '2'}) + # with flag keys, no match + try: + result = sort(flags, [999]) + self.assertEqual(result, []) + except CycleException as e: + self.fail(f"Did not expect exception {e}") + + def test_multiple_flags_complex_cycle(self): + flags = [ + flag(3, [1, 2]), + flag(1, []), + flag(4, [21, 3]), + flag(2, []), + flag(5, [3]), + flag(6, []), + flag(7, []), + flag(8, [9]), + flag(9, []), + flag(20, [4]), + flag(21, [20]), + ] + try: + # use specific ordering + sort(flags, [3, 1, 4, 2, 5, 6, 7, 8, 9, 20, 21]) + except CycleException as e: + self.assertEqual({'4', '21', '20'}, e.path) + + def test_multiple_flags_complex_no_cycle_start_at_leaf(self): + flags = [ + flag(1, [6, 3]), + flag(2, [8, 5, 3, 1]), + flag(3, [6, 5]), + flag(4, [8, 7]), + flag(5, [10, 7]), + flag(7, [8]), + flag(6, [7, 4]), + flag(8, []), + flag(9, [10, 7, 5]), + flag(10, [7]), + flag(20, []), + flag(21, [20]), + flag(30, []), + ] + result = sort(flags, [1, 2, 3, 4, 5, 7, 6, 8, 9, 10, 20, 21, 30]) + expected = [ + flag(8, []), + flag(7, [8]), + flag(4, [8, 7]), + flag(6, [7, 4]), + flag(10, [7]), + flag(5, [10, 7]), + flag(3, [6, 5]), + flag(1, [6, 3]), + flag(2, [8, 5, 3, 1]), + flag(9, [10, 7, 5]), + flag(20, []), + flag(21, [20]), + flag(30, []), + ] + self.assertEqual(expected, result) + + def test_multiple_flags_complex_no_cycle_start_at_middle(self): + flags = [ + flag(6, [7, 4]), + flag(1, [6, 3]), + flag(2, [8, 5, 3, 1]), + flag(3, [6, 5]), + flag(4, [8, 7]), + flag(5, [10, 7]), + flag(7, [8]), + flag(8, []), + flag(9, [10, 7, 5]), + flag(10, [7]), + flag(20, []), + flag(21, [20]), + flag(30, []), + ] + result = sort(flags, [6, 1, 2, 3, 4, 5, 7, 8, 9, 10, 20, 21, 30]) + expected = [ + flag(8, []), + flag(7, [8]), + flag(4, [8, 7]), + flag(6, [7, 4]), + flag(10, [7]), + flag(5, [10, 7]), + flag(3, [6, 5]), + flag(1, [6, 3]), + flag(2, [8, 5, 3, 1]), + flag(9, [10, 7, 5]), + flag(20, []), + flag(21, [20]), + flag(30, []), + ] + self.assertEqual(expected, result) + + def test_multiple_flags_complex_no_cycle_start_at_root(self): + flags = [ + flag(8, []), + flag(1, [6, 3]), + flag(2, [8, 5, 3, 1]), + flag(3, [6, 5]), + flag(4, [8, 7]), + flag(5, [10, 7]), + flag(6, [7, 4]), + flag(7, [8]), + flag(9, [10, 7, 5]), + flag(10, [7]), + flag(20, []), + flag(21, [20]), + flag(30, []), + ] + result = sort(flags, [8, 1, 2, 3, 4, 5, 6, 7, 9, 10, 20, 21, 30]) + expected = [ + flag(8, []), + flag(7, [8]), + flag(4, [8, 7]), + flag(6, [7, 4]), + flag(10, [7]), + flag(5, [10, 7]), + flag(3, [6, 5]), + flag(1, [6, 3]), + flag(2, [8, 5, 3, 1]), + flag(9, [10, 7, 5]), + flag(20, []), + flag(21, [20]), + flag(30, []), + ] + self.assertEqual(expected, result) + + +def sort(flags: List[Dict[str, Any]], flag_keys: List[int] = None) -> List[Dict[str, Any]]: + flag_key_strings = [str(k) for k in flag_keys] if flag_keys is not None else None + flags_dict = {f['key']: f for f in flags} + return topological_sort(flags_dict, flag_key_strings, True) + + +def flag(key: int, dependencies: List[int]) -> Dict[str, Any]: + return {'key': str(key), 'dependencies': [str(d) for d in dependencies]} + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/remote/client_test.py b/tests/remote/client_test.py index f8df111..680c084 100644 --- a/tests/remote/client_test.py +++ b/tests/remote/client_test.py @@ -16,7 +16,7 @@ def test_initialize_raise_error(self): def test_fetch(self): with RemoteEvaluationClient(API_KEY) as client: - expected_variant = Variant('on', 'payload') + expected_variant = Variant(key='on', value='on', payload='payload') user = User(user_id='test_user') variants = client.fetch(user) variant_name = 'sdk-ci-test' diff --git a/tests/util/__init__.py b/tests/util/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/util/user_test.py b/tests/util/user_test.py new file mode 100644 index 0000000..61d0946 --- /dev/null +++ b/tests/util/user_test.py @@ -0,0 +1,98 @@ +import unittest + +from src.amplitude_experiment import User +from src.amplitude_experiment.util.user import user_to_evaluation_context + + +def test_user_to_evaluation_context(self): + user = User( + device_id='device_id', + user_id='user_id', + country='country', + city='city', + language='language', + platform='platform', + version='version', + user_properties={'k': 'v'}, + groups={'type': 'name'}, + group_properties={'type': {'name': {'gk': 'gv'}}}, + ) + context = user_to_evaluation_context(user) + self.assertEqual({ + 'user': { + 'device_id': 'device_id', + 'user_id': 'user_id', + 'country': 'country', + 'city': 'city', + 'language': 'language', + 'platform': 'platform', + 'version': 'version', + 'user_properties': {'k': 'v'}, + }, + 'groups': { + 'type': { + 'group_name': 'name', + 'group_properties': {'gk': 'gv'} + } + } + }, context) + + +def test_user_to_evaluation_context_only_user(self): + user = User( + device_id='device_id', + user_id='user_id', + country='country', + city='city', + language='language', + platform='platform', + version='version', + user_properties={'k': 'v'}, + ) + context = user_to_evaluation_context(user) + self.assertEqual({ + 'user': { + 'device_id': 'device_id', + 'user_id': 'user_id', + 'country': 'country', + 'city': 'city', + 'language': 'language', + 'platform': 'platform', + 'version': 'version', + 'user_properties': {'k': 'v'}, + }, + }, context) + + +def test_user_to_evaluation_context_only_groups(self): + user = User( + groups={'type': 'name'}, + group_properties={'type': {'name': {'gk': 'gv'}}}, + ) + context = user_to_evaluation_context(user) + self.assertEqual({ + 'groups': { + 'type': { + 'group_name': 'name', + 'group_properties': {'gk': 'gv'} + } + } + }, context) + + +def test_user_to_evaluation_context_only_groups_no_group_props(self): + user = User( + groups={'type': 'name'}, + ) + context = user_to_evaluation_context(user) + self.assertEqual({ + 'groups': { + 'type': { + 'group_name': 'name', + } + } + }, context) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/util/variant_test.py b/tests/util/variant_test.py new file mode 100644 index 0000000..a97abb9 --- /dev/null +++ b/tests/util/variant_test.py @@ -0,0 +1,74 @@ +import json +import unittest + +from src.amplitude_experiment import Variant +from src.amplitude_experiment.util.variant import evaluation_variant_json_to_variant, \ + evaluation_variants_json_to_variants + + +class VariantTestCase(unittest.TestCase): + def test_evaluation_variant_json_to_variant__string_value(self): + evaluation_variant = {'key': 'on', 'value': 'test'} + variant = evaluation_variant_json_to_variant(evaluation_variant) + self.assertEqual(variant, Variant(key='on', value='test')) + + def test_evaluation_variant_json_to_variant__boolean_value(self): + evaluation_variant = {'key': 'on', 'value': True} + variant = evaluation_variant_json_to_variant(evaluation_variant) + self.assertEqual(variant, Variant(key='on', value='true')) + + def test_evaluation_variant_json_to_variant__int_value(self): + evaluation_variant = {'key': 'on', 'value': 10} + variant = evaluation_variant_json_to_variant(evaluation_variant) + self.assertEqual(variant, Variant(key='on', value='10')) + + def test_evaluation_variant_json_to_variant__float_value(self): + evaluation_variant = {'key': 'on', 'value': 10.2} + variant = evaluation_variant_json_to_variant(evaluation_variant) + self.assertEqual(variant, Variant(key='on', value='10.2')) + + def test_evaluation_variant_json_to_variant__array_value(self): + evaluation_variant = {'key': 'on', 'value': [1, 2, 3]} + variant = evaluation_variant_json_to_variant(evaluation_variant) + self.assertEqual(variant, Variant(key='on', value='[1,2,3]')) + + def test_evaluation_variant_json_to_variant__object_value(self): + evaluation_variant = {'key': 'on', 'value': {'k': 'v'}} + variant = evaluation_variant_json_to_variant(evaluation_variant) + self.assertEqual(variant, Variant(key='on', value='{"k":"v"}')) + + def test_evaluation_variant_json_to_variant__null_value(self): + evaluation_variant = json.loads('{"key": "on", "value": null}') + variant = evaluation_variant_json_to_variant(evaluation_variant) + self.assertEqual(variant, Variant(key='on', value=None)) + + def test_evaluation_variant_json_to_variant__undefined_value(self): + evaluation_variant = {'key': 'on'} + variant = evaluation_variant_json_to_variant(evaluation_variant) + self.assertEqual(variant, Variant(key='on', value=None)) + + def test_evaluation_variants_json_to_variants(self): + evaluation_variants = { + 'string': {'key': 'on', 'value': 'test'}, + 'boolean': {'key': 'on', 'value': True}, + 'int': {'key': 'on', 'value': 10}, + 'float': {'key': 'on', 'value': 10.2}, + 'array': {'key': 'on', 'value': [1, 2, 3]}, + 'object': {'key': 'on', 'value': {'k': 'v'}}, + 'null': json.loads('{"key": "on", "value": null}'), + 'undefined': {'key': 'on'}, + } + variants = evaluation_variants_json_to_variants(evaluation_variants) + self.assertEqual(variants, { + 'string': Variant(key='on', value='test'), + 'boolean': Variant(key='on', value='true'), + 'int': Variant(key='on', value='10'), + 'float': Variant(key='on', value='10.2'), + 'array': Variant(key='on', value='[1,2,3]'), + 'object': Variant(key='on', value='{"k":"v"}'), + 'null': Variant(key='on', value=None), + 'undefined': Variant(key='on', value=None), + }) + +if __name__ == '__main__': + unittest.main()