diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index 3579cbf..6b02304 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -31,10 +31,10 @@ jobs: with: fetch-depth: 0 - - name: Set up Python 3.7 + - name: Set up Python 3.8 uses: actions/setup-python@v3 with: - python-version: 3.7 + python-version: 3.8 - name: Install dependencies run: python -m pip install build setuptools wheel twine amplitude_analytics parameterized python-dotenv~=0.21.1 python-semantic-release==7.34.6 diff --git a/.github/workflows/publish-to-test-pypi.yml b/.github/workflows/publish-to-test-pypi.yml index 06efae7..aef6f53 100644 --- a/.github/workflows/publish-to-test-pypi.yml +++ b/.github/workflows/publish-to-test-pypi.yml @@ -21,10 +21,10 @@ jobs: steps: - uses: actions/checkout@v3 - - name: Set up Python 3.7 + - name: Set up Python 3.8 uses: actions/setup-python@v3 with: - python-version: 3.7 + python-version: 3.8 - name: Install dependencies run: python -m pip install build setuptools wheel twine amplitude_analytics parameterized diff --git a/.github/workflows/test-arm.yml b/.github/workflows/test-arm.yml deleted file mode 100644 index 7ac803a..0000000 --- a/.github/workflows/test-arm.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Unit Test on Arm -on: [pull_request] - -jobs: - aarch_job: - runs-on: ubuntu-latest - environment: Unit Test - name: Test on Ubuntu aarch64 - steps: - - name: Checkout source code - uses: actions/checkout@v3 - - - name: Set up and run unit test on aarch64 - uses: uraimo/run-on-arch-action@v2 - id: runcmd - with: - env: | - API_KEY: ${{ secrets.API_KEY }} - SECRET_KEY: ${{ secrets.SECRET_KEY }} - EU_API_KEY: ${{ secrets.EU_API_KEY }} - EU_SECRET_KEY: ${{ secrets.EU_SECRET_KEY }} - arch: aarch64 - distro: ubuntu20.04 - githubToken: ${{ github.token }} - install: | - apt update - apt -y install python3 - apt -y install pip - apt -y install ca-certificates - run: | - pip install -r requirements.txt - pip install -r requirements-dev.txt - python3 -m unittest discover -s ./tests -p '*_test.py' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d78efe6..c92aab7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,7 @@ jobs: environment: Unit Test strategy: matrix: - python-version: [ "3.7" ] + python-version: [ "3.8" ] steps: - name: Checkout source code uses: actions/checkout@v3 diff --git a/requirements-dev.txt b/requirements-dev.txt index 8e4ee4b..9754e30 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,2 +1,3 @@ parameterized~=0.9.0 python-dotenv~=0.21.1 +requests~=2.31.0 diff --git a/requirements.txt b/requirements.txt index 26cc8cb..ef8c286 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ -amplitude_analytics~=1.1.1 \ No newline at end of file +amplitude_analytics~=1.1.1 +dataclasses-json~=0.6.7 diff --git a/src/amplitude_experiment/deployment/deployment_runner.py b/src/amplitude_experiment/deployment/deployment_runner.py index aa8aa64..e734127 100644 --- a/src/amplitude_experiment/deployment/deployment_runner.py +++ b/src/amplitude_experiment/deployment/deployment_runner.py @@ -56,12 +56,12 @@ def __update_flag_configs(self): self.logger.warning(f'Failed to fetch flag configs: {e}') raise e - flag_keys = {flag['key'] for flag in flag_configs} - self.flag_config_storage.remove_if(lambda f: f['key'] not in flag_keys) + flag_keys = {flag.key for flag in flag_configs} + self.flag_config_storage.remove_if(lambda f: f.key not in flag_keys) if not self.cohort_loader: for flag_config in flag_configs: - self.logger.debug(f"Putting non-cohort flag {flag_config['key']}") + self.logger.debug(f"Putting non-cohort flag {flag_config.key}") self.flag_config_storage.put_flag_config(flag_config) return @@ -83,11 +83,11 @@ def __update_flag_configs(self): # iterate through new flag configs and check if their required cohorts exist for flag_config in flag_configs: cohort_ids = get_all_cohort_ids_from_flag(flag_config) - self.logger.debug(f"Storing flag {flag_config['key']}") + self.logger.debug(f"Storing flag {flag_config.key}") self.flag_config_storage.put_flag_config(flag_config) missing_cohorts = cohort_ids - updated_cohort_ids if missing_cohorts: - self.logger.warning(f"Flag {flag_config['key']} - failed to load cohorts: {missing_cohorts}") + self.logger.warning(f"Flag {flag_config.key} - failed to load cohorts: {missing_cohorts}") # delete unused cohorts self._delete_unused_cohorts() diff --git a/src/amplitude_experiment/evaluation/__init__.py b/src/amplitude_experiment/evaluation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/amplitude_experiment/evaluation/engine.py b/src/amplitude_experiment/evaluation/engine.py new file mode 100644 index 0000000..265b525 --- /dev/null +++ b/src/amplitude_experiment/evaluation/engine.py @@ -0,0 +1,407 @@ +from typing import Any, Callable, List, Optional, Set, Union, Dict +import json +import re + +from .murmur3 import hash32x86 +from .select import select +from .types import EvaluationOperator, EvaluationFlag, EvaluationVariant, EvaluationSegment, EvaluationCondition +from .semantic_version import SemanticVersion + + +class EvaluationEngine: + """Feature flag evaluation engine.""" + + def evaluate( + self, + context: Dict[str, Any], + flags: List[EvaluationFlag] + ) -> Dict[str, EvaluationVariant]: + """Evaluate a list of feature flags against a context.""" + results: Dict[str, EvaluationVariant] = {} + target = { + 'context': context, + 'result': results + } + + for flag in flags: + variant = self.evaluate_flag(target, flag) + if variant: + results[flag.key] = variant + + return results + + def evaluate_flag( + self, + target: Dict[str, Any], + flag: EvaluationFlag + ) -> Optional[EvaluationVariant]: + """Evaluate a single feature flag.""" + result = None + for segment in flag.segments: + result = self.evaluate_segment(target, flag, segment) + if result: + # Merge all metadata into the result + metadata = {} + if flag.metadata: + metadata.update(flag.metadata) + if segment.metadata: + metadata.update(segment.metadata) + if result.metadata: + metadata.update(result.metadata) + result = EvaluationVariant( + key=result.key, + value=result.value, + payload=result.payload, + metadata=metadata + ) + break + return result + + def evaluate_segment( + self, + target: Dict[str, Any], + flag: EvaluationFlag, + segment: EvaluationSegment + ) -> Optional[EvaluationVariant]: + """Evaluate a segment of a feature flag.""" + if not segment.conditions: + # Null conditions always match + variant_key = self.bucket(target, segment) + if variant_key is not None: + return flag.variants.get(variant_key) + return None + + match = self.evaluate_conditions(target, segment.conditions) + + # On match, bucket the user + if match: + variant_key = self.bucket(target, segment) + if variant_key is not None: + return flag.variants.get(variant_key) + return None + + return None + + def evaluate_conditions( + self, + target: Dict[str, Any], + conditions: List[List[EvaluationCondition]] + ) -> bool: + """Evaluate conditions using OR/AND logic.""" + # Outer list logic is "or" (||) + for inner_conditions in conditions: + match = True + + for condition in inner_conditions: + match = self.match_condition(target, condition) + if not match: + break + + if match: + return True + + return False + + def match_condition( + self, + target: Dict[str, Any], + condition: EvaluationCondition + ) -> bool: + """Match a single condition.""" + prop_value = select(target, condition.selector) + + # We need special matching for null properties and set type prop values + # and operators. All other values are matched as strings, since the + # filter values are always strings. + if not prop_value: + return self.match_null(condition.op, condition.values) + elif self.is_set_operator(condition.op): + prop_value_string_list = self.coerce_string_array(prop_value) + if not prop_value_string_list: + return False + return self.match_set(prop_value_string_list, condition.op, condition.values) + else: + prop_value_string = self.coerce_string(prop_value) + if prop_value_string is not None: + return self.match_string( + prop_value_string, + condition.op, + condition.values + ) + return False + + def get_hash(self, key: str) -> int: + """Generate a hash value from a key.""" + return hash32x86(key) + + def bucket( + self, + target: Dict[str, Any], + segment: EvaluationSegment + ) -> Optional[str]: + """Bucket a target into a variant based on segment configuration.""" + if not segment.bucket: + # A null bucket means the segment is fully rolled out. Select the + # default variant. + return segment.variant + + # Select the bucketing value + bucketing_value = self.coerce_string( + select(target, segment.bucket.selector) + ) + if not bucketing_value or len(bucketing_value) == 0: + # A null or empty bucketing value cannot be bucketed. Select the + # default variant. + return segment.variant + + # Salt and hash the value, and compute the allocation and distribution + # values + key_to_hash = f"{segment.bucket.salt}/{bucketing_value}" + hash_value = self.get_hash(key_to_hash) + allocation_value = hash_value % 100 + distribution_value = hash_value // 100 + + for allocation in segment.bucket.allocations: + allocation_start = allocation.range[0] + allocation_end = allocation.range[1] + + if allocation_start <= allocation_value < allocation_end: + + for distribution in allocation.distributions: + distribution_start = distribution.range[0] + distribution_end = distribution.range[1] + + if distribution_start <= distribution_value < distribution_end: + + return distribution.variant + + return segment.variant + + def match_null(self, op: str, filter_values: List[str]) -> bool: + """Match null values against filter values.""" + contains_none = self.contains_none(filter_values) + + if op in { + EvaluationOperator.IS, + EvaluationOperator.CONTAINS, + EvaluationOperator.LESS_THAN, + EvaluationOperator.LESS_THAN_EQUALS, + EvaluationOperator.GREATER_THAN, + EvaluationOperator.GREATER_THAN_EQUALS, + EvaluationOperator.VERSION_LESS_THAN, + EvaluationOperator.VERSION_LESS_THAN_EQUALS, + EvaluationOperator.VERSION_GREATER_THAN, + EvaluationOperator.VERSION_GREATER_THAN_EQUALS, + EvaluationOperator.SET_IS, + EvaluationOperator.SET_CONTAINS, + EvaluationOperator.SET_CONTAINS_ANY, + }: + return contains_none + elif op in { + EvaluationOperator.IS_NOT, + EvaluationOperator.DOES_NOT_CONTAIN, + EvaluationOperator.SET_DOES_NOT_CONTAIN, + EvaluationOperator.SET_DOES_NOT_CONTAIN_ANY, + }: + return not contains_none + return False + + def match_set(self, prop_values: List[str], op: str, filter_values: List[str]) -> bool: + """Match set values against filter values.""" + if op == EvaluationOperator.SET_IS: + return self.set_equals(prop_values, filter_values) + elif op == EvaluationOperator.SET_IS_NOT: + return not self.set_equals(prop_values, filter_values) + elif op == EvaluationOperator.SET_CONTAINS: + return self.matches_set_contains_all(prop_values, filter_values) + elif op == EvaluationOperator.SET_DOES_NOT_CONTAIN: + return not self.matches_set_contains_all(prop_values, filter_values) + elif op == EvaluationOperator.SET_CONTAINS_ANY: + return self.matches_set_contains_any(prop_values, filter_values) + elif op == EvaluationOperator.SET_DOES_NOT_CONTAIN_ANY: + return not self.matches_set_contains_any(prop_values, filter_values) + return False + + def match_string(self, prop_value: str, op: str, filter_values: List[str]) -> bool: + """Match string values against filter values.""" + if op == EvaluationOperator.IS: + return self.matches_is(prop_value, filter_values) + elif op == EvaluationOperator.IS_NOT: + return not self.matches_is(prop_value, filter_values) + elif op == EvaluationOperator.CONTAINS: + return self.matches_contains(prop_value, filter_values) + elif op == EvaluationOperator.DOES_NOT_CONTAIN: + return not self.matches_contains(prop_value, filter_values) + elif op in { + EvaluationOperator.LESS_THAN, + EvaluationOperator.LESS_THAN_EQUALS, + EvaluationOperator.GREATER_THAN, + EvaluationOperator.GREATER_THAN_EQUALS, + }: + return self.matches_comparable( + prop_value, + op, + filter_values, + lambda x: self.parse_number(x), + self.comparator + ) + elif op in { + EvaluationOperator.VERSION_LESS_THAN, + EvaluationOperator.VERSION_LESS_THAN_EQUALS, + EvaluationOperator.VERSION_GREATER_THAN, + EvaluationOperator.VERSION_GREATER_THAN_EQUALS, + }: + return self.matches_comparable( + prop_value, + op, + filter_values, + lambda x: SemanticVersion.parse(x), + self.version_comparator + ) + elif op == EvaluationOperator.REGEX_MATCH: + return self.matches_regex(prop_value, filter_values) + elif op == EvaluationOperator.REGEX_DOES_NOT_MATCH: + return not self.matches_regex(prop_value, filter_values) + return False + + def matches_is(self, prop_value: str, filter_values: List[str]) -> bool: + """Match exact string values.""" + if self.contains_booleans(filter_values): + lower = prop_value.lower() + if lower in ('true', 'false'): + return any(value.lower() == lower for value in filter_values) + return any(prop_value == value for value in filter_values) + + def matches_contains(self, prop_value: str, filter_values: List[str]) -> bool: + """Match substring values.""" + prop_value_lower = prop_value.lower() + return any(filter_value.lower() in prop_value_lower for filter_value in filter_values) + + def matches_comparable( + self, + prop_value: str, + op: str, + filter_values: List[str], + type_transformer: Callable[[str], Any], + type_comparator: Callable[[Any, str, Any], bool] + ) -> bool: + """Match values after transforming them to comparable types.""" + # Transform property value + transformed_prop = type_transformer(prop_value) + + # Transform and filter out invalid values + transformed_filters = [] + for filter_val in filter_values: + transformed = type_transformer(filter_val) + if transformed is not None: + transformed_filters.append(transformed) + + # If either transformation failed, fall back to string comparison + if transformed_prop is None or not transformed_filters: + return any(self.comparator(prop_value, op, filter_value) + for filter_value in filter_values) + + # Compare using transformed values + return any(type_comparator(transformed_prop, op, filter_value) + for filter_value in transformed_filters) + + def comparator( + self, + prop_value: Union[str, int], + op: str, + filter_value: Union[str, int] + ) -> bool: + """Compare values using comparison operators.""" + if op in (EvaluationOperator.LESS_THAN, EvaluationOperator.VERSION_LESS_THAN): + return prop_value < filter_value + elif op in (EvaluationOperator.LESS_THAN_EQUALS, EvaluationOperator.VERSION_LESS_THAN_EQUALS): + return prop_value <= filter_value + elif op in (EvaluationOperator.GREATER_THAN, EvaluationOperator.VERSION_GREATER_THAN): + return prop_value > filter_value + elif op in (EvaluationOperator.GREATER_THAN_EQUALS, EvaluationOperator.VERSION_GREATER_THAN_EQUALS): + return prop_value >= filter_value + return False + + def version_comparator(self, prop_value: SemanticVersion, op: str, filter_value: SemanticVersion) -> bool: + """Compare semantic versions using comparison operators.""" + compare_to = prop_value.compare_to(filter_value) + if op in (EvaluationOperator.LESS_THAN, EvaluationOperator.VERSION_LESS_THAN): + return compare_to < 0 + elif op in (EvaluationOperator.LESS_THAN_EQUALS, EvaluationOperator.VERSION_LESS_THAN_EQUALS): + return compare_to <= 0 + elif op in (EvaluationOperator.GREATER_THAN, EvaluationOperator.VERSION_GREATER_THAN): + return compare_to > 0 + elif op in (EvaluationOperator.GREATER_THAN_EQUALS, EvaluationOperator.VERSION_GREATER_THAN_EQUALS): + return compare_to >= 0 + return False + + def matches_regex(self, prop_value: str, filter_values: List[str]) -> bool: + """Match values using regex patterns.""" + return any(bool(re.search(filter_value, prop_value)) for filter_value in filter_values) + + def contains_none(self, filter_values: List[str]) -> bool: + """Check if filter values contain '(none)'.""" + return any(filter_value == "(none)" for filter_value in filter_values) + + def contains_booleans(self, filter_values: List[str]) -> bool: + """Check if filter values contain boolean strings.""" + return any(filter_value.lower() in ('true', 'false') for filter_value in filter_values) + + def parse_number(self, value: str) -> Optional[float]: + """Parse string to number, return None if invalid.""" + try: + return float(value) + except (ValueError, TypeError): + return None + + def coerce_string(self, value: Any) -> Optional[str]: + """Coerce value to string, handling special cases.""" + if value is None: + return None + if isinstance(value, (dict, list)): + return json.dumps(value) + return str(value) + + def coerce_string_array(self, value: Any) -> Optional[List[str]]: + """Coerce value to string array, handling special cases.""" + if isinstance(value, list): + return [s for s in map(self.coerce_string, value) if s is not None] + + string_value = str(value) + try: + parsed_value = json.loads(string_value) + if isinstance(parsed_value, list): + return [s for s in map(self.coerce_string, value) if s is not None] + + s = self.coerce_string(string_value) + return [s] if s is not None else None + except json.JSONDecodeError: + s = self.coerce_string(string_value) + return [s] if s is not None else None + + def is_set_operator(self, op: str) -> bool: + """Check if operator is a set operator.""" + return op in { + EvaluationOperator.SET_IS, + EvaluationOperator.SET_IS_NOT, + EvaluationOperator.SET_CONTAINS, + EvaluationOperator.SET_DOES_NOT_CONTAIN, + EvaluationOperator.SET_CONTAINS_ANY, + EvaluationOperator.SET_DOES_NOT_CONTAIN_ANY, + } + + def set_equals(self, xa: List[str], ya: List[str]) -> bool: + """Check if two string lists are equal as sets.""" + xs: Set[str] = set(xa) + ys: Set[str] = set(ya) + return len(xs) == len(ys) and all(y in xs for y in ys) + + def matches_set_contains_all(self, prop_values: List[str], filter_values: List[str]) -> bool: + """Check if prop values contain all filter values.""" + if len(prop_values) < len(filter_values): + return False + return all(self.matches_is(filter_value, prop_values) for filter_value in filter_values) + + def matches_set_contains_any(self, prop_values: List[str], filter_values: List[str]) -> bool: + """Check if prop values contain any filter values.""" + return any(self.matches_is(filter_value, prop_values) for filter_value in filter_values) diff --git a/src/amplitude_experiment/evaluation/murmur3.py b/src/amplitude_experiment/evaluation/murmur3.py new file mode 100644 index 0000000..f965504 --- /dev/null +++ b/src/amplitude_experiment/evaluation/murmur3.py @@ -0,0 +1,92 @@ +C1_32 = 0xcc9e2d51 +C2_32 = 0x1b873593 +R1_32 = 15 +R2_32 = 13 +M_32 = 5 +N_32 = 0xe6546b64 + + +def hash32x86(input_str: str, seed: int = 0) -> int: + """Calculate 32-bit Murmur3 hash of a string.""" + data = input_str.encode('utf-8') + length = len(data) + n_blocks = length // 4 + hash_val = seed + + # body + for i in range(n_blocks): + index = i * 4 + k = read_int_le(data, index) + hash_val = mix32(k, hash_val) + + # tail + index = n_blocks * 4 + k1 = 0 + remaining = length - index + + if remaining == 3: + k1 ^= data[index + 2] << 16 + k1 ^= data[index + 1] << 8 + k1 ^= data[index] + k1 = (k1 * C1_32) & 0xffffffff + k1 = rotate_left(k1, R1_32) + k1 = (k1 * C2_32) & 0xffffffff + hash_val ^= k1 + elif remaining == 2: + k1 ^= data[index + 1] << 8 + k1 ^= data[index] + k1 = (k1 * C1_32) & 0xffffffff + k1 = rotate_left(k1, R1_32) + k1 = (k1 * C2_32) & 0xffffffff + hash_val ^= k1 + elif remaining == 1: + k1 ^= data[index] + k1 = (k1 * C1_32) & 0xffffffff + k1 = rotate_left(k1, R1_32) + k1 = (k1 * C2_32) & 0xffffffff + hash_val ^= k1 + + hash_val ^= length + return fmix32(hash_val) + + +def mix32(k: int, hash_val: int) -> int: + """Mix function for Murmur3.""" + k = (k * C1_32) & 0xffffffff + k = rotate_left(k, R1_32) + k = (k * C2_32) & 0xffffffff + hash_val ^= k + hash_val = rotate_left(hash_val, R2_32) + hash_val = (hash_val * M_32 + N_32) & 0xffffffff + return hash_val + + +def fmix32(hash_val: int) -> int: + """Final mix function for Murmur3.""" + hash_val ^= hash_val >> 16 + hash_val = (hash_val * 0x85ebca6b) & 0xffffffff + hash_val ^= hash_val >> 13 + hash_val = (hash_val * 0xc2b2ae35) & 0xffffffff + hash_val ^= hash_val >> 16 + return hash_val + + +def rotate_left(x: int, n: int, width: int = 32) -> int: + """Rotate a number left by n bits.""" + n = n % width if n > width else n + mask = (0xffffffff << (width - n)) & 0xffffffff + r = ((x & mask) >> (width - n)) & 0xffffffff + return ((x << n) | r) & 0xffffffff + +def read_int_le(data: bytes, index: int = 0) -> int: + """Read a little-endian 32-bit integer from bytes.""" + n = (data[index] << 24) | (data[index + 1] << 16) | \ + (data[index + 2] << 8) | data[index + 3] + return reverse_bytes(n) + +def reverse_bytes(n: int) -> int: + """Reverse the bytes of a 32-bit integer.""" + return (((n & 0xff000000) >> 24) | + ((n & 0x00ff0000) >> 8) | + ((n & 0x0000ff00) << 8) | + ((n & 0x000000ff) << 24)) & 0xffffffff diff --git a/src/amplitude_experiment/evaluation/select.py b/src/amplitude_experiment/evaluation/select.py new file mode 100644 index 0000000..cce8a12 --- /dev/null +++ b/src/amplitude_experiment/evaluation/select.py @@ -0,0 +1,60 @@ +from dataclasses import is_dataclass +from typing import Any, List, Optional +from typing_extensions import Protocol, runtime_checkable + + +@runtime_checkable +class Selectable(Protocol): + """Protocol for objects that can be selected from using a selector path.""" + def __getitem__(self, key: str) -> Any: + ... + + def get(self, key: str, default: Any = None) -> Any: + ... + + +def selectable(cls): + """ + Decorator to make dataclasses selectable using dict-like access. + Must be applied after @dataclass. + """ + if not is_dataclass(cls): + raise TypeError("selectable decorator must be used with dataclasses") + + def __getitem__(self, key: str) -> Any: + if hasattr(self, key): + return getattr(self, key) + raise KeyError(key) + + def get(self, key: str, default: Any = None) -> Any: + try: + return self[key] + except KeyError: + return default + + cls.__getitem__ = __getitem__ # type: ignore + cls.get = get # type: ignore + return cls + + +def select(selectable: Any, selector: Optional[List[str]]) -> Optional[Any]: + """Select a value from a nested dictionary or selectable object using a list of keys.""" + if not selector or len(selector) == 0: + return None + + for selector_element in selector: + if (not selector_element or + selectable is None or + not (isinstance(selectable, dict) or + isinstance(selectable, Selectable))): + return None + + if isinstance(selectable, dict): + selectable = selectable.get(selector_element) + else: + try: + selectable = selectable.get(selector_element) + except (AttributeError, KeyError): + return None + + return None if selectable is None else selectable diff --git a/src/amplitude_experiment/evaluation/semantic_version.py b/src/amplitude_experiment/evaluation/semantic_version.py new file mode 100644 index 0000000..c9f7b48 --- /dev/null +++ b/src/amplitude_experiment/evaluation/semantic_version.py @@ -0,0 +1,87 @@ +import re +from dataclasses import dataclass +from typing import Optional + +# Major and minor should be non-negative numbers separated by a dot +MAJOR_MINOR_REGEX = r'(\d+)\.(\d+)' + +# Patch should be a non-negative number +PATCH_REGEX = r'(\d+)' + +# Prerelease is optional. If provided, it should be a hyphen followed by a +# series of dot separated identifiers where an identifier can contain anything in [-0-9a-zA-Z] +PRERELEASE_REGEX = r'(-(([-\w]+\.?)*))?' + +# Version pattern should be major.minor(.patchAndPreRelease) where .patchAndPreRelease is optional +VERSION_PATTERN = f'^{MAJOR_MINOR_REGEX}(\.{PATCH_REGEX}{PRERELEASE_REGEX})?$' + +@dataclass +class SemanticVersion: + major: int + minor: int + patch: int + pre_release: Optional[str] = None + + @classmethod + def parse(cls, version: Optional[str]) -> Optional['SemanticVersion']: + """ + Parse a version string into a SemanticVersion object. + Returns None if the version string is invalid. + """ + if not version: + return None + + match = re.match(VERSION_PATTERN, version) + if not match: + return None + + try: + major = int(match.group(1)) + minor = int(match.group(2)) + patch = int(match.group(4)) if match.group(4) else 0 + pre_release = match.group(5) if match.group(5) else None + return cls(major, minor, patch, pre_release) + except (IndexError, ValueError): + return None + + def compare_to(self, other: 'SemanticVersion') -> int: + """ + Compare this version to another version. + Returns: + 1 if this version is greater than the other + -1 if this version is less than the other + 0 if the versions are equal + """ + if self.major > other.major: + return 1 + if self.major < other.major: + return -1 + + if self.minor > other.minor: + return 1 + if self.minor < other.minor: + return -1 + + if self.patch > other.patch: + return 1 + if self.patch < other.patch: + return -1 + + if self.pre_release and not other.pre_release: + return -1 + if not self.pre_release and other.pre_release: + return 1 + + if self.pre_release and other.pre_release: + if self.pre_release > other.pre_release: + return 1 + if self.pre_release < other.pre_release: + return -1 + return 0 + + return 0 + + def __eq__(self, other: object) -> bool: + if not isinstance(other, SemanticVersion): + return NotImplemented + return self.compare_to(other) == 0 diff --git a/src/amplitude_experiment/evaluation/topological_sort.py b/src/amplitude_experiment/evaluation/topological_sort.py new file mode 100644 index 0000000..67b8088 --- /dev/null +++ b/src/amplitude_experiment/evaluation/topological_sort.py @@ -0,0 +1,70 @@ +from typing import Dict, List, Optional + +from .types import EvaluationFlag + + +class CycleException(Exception): + """ + Raised when topological sorting encounters a cycle between flag dependencies. + """ + + def __init__(self, path: List[str]): + self.path = path + + def __str__(self): + return f"Detected a cycle between flags {self.path}" + + +def topological_sort( + flags: Dict[str, EvaluationFlag], + flag_keys: Optional[List[str]] = None +) -> List[EvaluationFlag]: + """ + Perform a topological sort on feature flags based on their dependencies. + """ + available: Dict[str, EvaluationFlag] = flags.copy() + result: List[EvaluationFlag] = [] + starting_keys = flag_keys if flag_keys is not None else list(available.keys()) + + for flag_key in starting_keys: + traversal = _parent_traversal(flag_key, available) + if traversal: + result.extend(traversal) + + return result + + +def _parent_traversal( + flag_key: str, + available: Dict[str, EvaluationFlag], + path: Optional[List[str]] = None +) -> Optional[List[EvaluationFlag]]: + """ + Recursively traverse flag dependencies to build topologically sorted list. + """ + if path is None: + path = [] + + flag = available.get(flag_key) + if not flag: + return None + + if not flag.dependencies or len(flag.dependencies) == 0: + available.pop(flag.key) + return [flag] + + path.append(flag.key) + result: List[EvaluationFlag] = [] + + for parent_key in flag.dependencies: + if parent_key in path: + raise CycleException(path) + + traversal = _parent_traversal(parent_key, available, path) + if traversal: + result.extend(traversal) + + result.append(flag) + path.pop() + available.pop(flag.key) + return result diff --git a/src/amplitude_experiment/evaluation/types.py b/src/amplitude_experiment/evaluation/types.py new file mode 100644 index 0000000..7ab3895 --- /dev/null +++ b/src/amplitude_experiment/evaluation/types.py @@ -0,0 +1,95 @@ +from dataclasses import dataclass +from typing import Dict, List, Optional, Any +from dataclasses_json import dataclass_json + +from .select import selectable + + +@selectable +@dataclass_json +@dataclass +class EvaluationVariant: + """Represents a variant in a feature flag evaluation.""" + key: Optional[str] = None + value: Optional[Any] = None + payload: Optional[Any] = None + metadata: Optional[Dict[str, Any]] = None + + +@dataclass_json +@dataclass +class EvaluationDistribution: + """Represents distribution configuration for a variant.""" + variant: str + range: List[int] + + +@dataclass_json +@dataclass +class EvaluationAllocation: + """Represents allocation configuration for bucketing.""" + range: List[int] + distributions: List[EvaluationDistribution] + + +@dataclass_json +@dataclass +class EvaluationCondition: + """Represents a condition for flag evaluation.""" + selector: List[str] + op: str + values: List[str] + + +@dataclass_json +@dataclass +class EvaluationBucket: + """Represents bucketing configuration for a segment.""" + selector: List[str] + salt: str + allocations: List[EvaluationAllocation] + + +@dataclass_json +@dataclass +class EvaluationSegment: + """Represents a segment in a feature flag.""" + bucket: Optional[EvaluationBucket] = None + conditions: Optional[List[List[EvaluationCondition]]] = None + variant: Optional[str] = None + metadata: Optional[Dict[str, Any]] = None + + +@dataclass_json +@dataclass +class EvaluationFlag: + """Represents a complete feature flag configuration.""" + key: str + variants: Dict[str, EvaluationVariant] + segments: List[EvaluationSegment] + dependencies: Optional[List[str]] = None + metadata: Optional[Dict[str, Any]] = None + + +class EvaluationOperator: + """Constants for evaluation operators.""" + IS = 'is' + IS_NOT = 'is not' + CONTAINS = 'contains' + DOES_NOT_CONTAIN = 'does not contain' + LESS_THAN = 'less' + LESS_THAN_EQUALS = 'less or equal' + GREATER_THAN = 'greater' + GREATER_THAN_EQUALS = 'greater or equal' + VERSION_LESS_THAN = 'version less' + VERSION_LESS_THAN_EQUALS = 'version less or equal' + VERSION_GREATER_THAN = 'version greater' + VERSION_GREATER_THAN_EQUALS = 'version greater or equal' + SET_IS = 'set is' + SET_IS_NOT = 'set is not' + SET_CONTAINS = 'set contains' + SET_DOES_NOT_CONTAIN = 'set does not contain' + SET_CONTAINS_ANY = 'set contains any' + SET_DOES_NOT_CONTAIN_ANY = 'set does not contain any' + REGEX_MATCH = 'regex match' + REGEX_DOES_NOT_MATCH = 'regex does not match' diff --git a/src/amplitude_experiment/flag/flag_config_api.py b/src/amplitude_experiment/flag/flag_config_api.py index 15db645..b27ea15 100644 --- a/src/amplitude_experiment/flag/flag_config_api.py +++ b/src/amplitude_experiment/flag/flag_config_api.py @@ -1,13 +1,14 @@ import json from typing import List +from ..evaluation.types import EvaluationFlag from ..version import __version__ from ..connection_pool import HTTPConnectionPool class FlagConfigApi: - def get_flag_configs(self) -> List: + def get_flag_configs(self) -> List[EvaluationFlag]: pass @@ -18,10 +19,10 @@ def __init__(self, deployment_key: str, server_url: str, flag_config_poller_requ self.flag_config_poller_request_timeout_millis = flag_config_poller_request_timeout_millis self.__setup_connection_pool() - def get_flag_configs(self) -> List: + def get_flag_configs(self) -> List[EvaluationFlag]: return self._get_flag_configs() - def _get_flag_configs(self) -> List: + def _get_flag_configs(self) -> List[EvaluationFlag]: conn = self._connection_pool.acquire() headers = { 'Authorization': f"Api-Key {self.deployment_key}", @@ -35,8 +36,8 @@ def _get_flag_configs(self) -> List: if response.status != 200: raise Exception( f"[Experiment] Get flagConfigs - received error response: ${response.status}: ${response_body}") - flags = json.loads(response_body) - return flags + response_json = json.loads(response_body) + return EvaluationFlag.schema().load(response_json, many=True) finally: self._connection_pool.release(conn) diff --git a/src/amplitude_experiment/flag/flag_config_storage.py b/src/amplitude_experiment/flag/flag_config_storage.py index 68b1a73..721e6a7 100644 --- a/src/amplitude_experiment/flag/flag_config_storage.py +++ b/src/amplitude_experiment/flag/flag_config_storage.py @@ -1,18 +1,20 @@ from typing import Dict, Callable from threading import Lock +from ..evaluation.types import EvaluationFlag + class FlagConfigStorage: - def get_flag_config(self, key: str) -> Dict: + def get_flag_config(self, key: str) -> EvaluationFlag: raise NotImplementedError - def get_flag_configs(self) -> Dict: + def get_flag_configs(self) -> Dict[str, EvaluationFlag]: raise NotImplementedError - def put_flag_config(self, flag_config: Dict): + def put_flag_config(self, flag_config: EvaluationFlag): raise NotImplementedError - def remove_if(self, condition: Callable[[Dict], bool]): + def remove_if(self, condition: Callable[[EvaluationFlag], bool]): raise NotImplementedError @@ -21,18 +23,18 @@ def __init__(self): self.flag_configs = {} self.flag_configs_lock = Lock() - def get_flag_config(self, key: str) -> Dict: + def get_flag_config(self, key: str) -> EvaluationFlag: with self.flag_configs_lock: return self.flag_configs.get(key) - def get_flag_configs(self) -> Dict[str, Dict]: + def get_flag_configs(self) -> Dict[str, EvaluationFlag]: with self.flag_configs_lock: return self.flag_configs.copy() - def put_flag_config(self, flag_config: Dict): + def put_flag_config(self, flag_config: EvaluationFlag): with self.flag_configs_lock: - self.flag_configs[flag_config['key']] = flag_config + self.flag_configs[flag_config.key] = flag_config - def remove_if(self, condition: Callable[[Dict], bool]): + def remove_if(self, condition: Callable[[EvaluationFlag], bool]): with self.flag_configs_lock: self.flag_configs = {key: value for key, value in self.flag_configs.items() if not condition(value)} diff --git a/src/amplitude_experiment/local/client.py b/src/amplitude_experiment/local/client.py index 069f1f9..12d7cc3 100644 --- a/src/amplitude_experiment/local/client.py +++ b/src/amplitude_experiment/local/client.py @@ -1,4 +1,3 @@ -import json import logging from threading import Lock from typing import Any, List, Dict, Set @@ -6,7 +5,6 @@ from amplitude import Amplitude from .config import LocalEvaluationConfig -from .topological_sort import topological_sort from ..assignment import Assignment, AssignmentFilter, AssignmentService from ..cohort.cohort import USER_GROUP_TYPE from ..cohort.cohort_download_api import DirectCohortDownloadApi @@ -17,11 +15,11 @@ from ..flag.flag_config_storage import InMemoryFlagConfigStorage from ..user import User from ..connection_pool import HTTPConnectionPool -from .evaluation.evaluation import evaluate +from ..evaluation.engine import EvaluationEngine +from ..evaluation.topological_sort import topological_sort from ..util import deprecated from ..util.flag_config import get_grouped_cohort_ids_from_flags, get_all_cohort_ids_from_flag from ..util.user import user_to_evaluation_context -from ..util.variant import evaluation_variants_json_to_variants from ..variant import Variant @@ -41,6 +39,7 @@ def __init__(self, api_key: str, config: LocalEvaluationConfig = None): if not api_key: raise ValueError("Experiment API key is empty") + self.engine = EvaluationEngine() self.api_key = api_key self.config = config or LocalEvaluationConfig() self.assignment_service = None @@ -96,7 +95,7 @@ def evaluate_v2(self, user: User, flag_keys: Set[str] = None) -> Dict[str, Varia if flag_configs is None or len(flag_configs) == 0: return {} self.logger.debug(f"[Experiment] Evaluate: user={user} - Flags: {flag_configs}") - sorted_flags = topological_sort(flag_configs, flag_keys) + sorted_flags = topological_sort(flag_configs, flag_keys and list(flag_keys)) if not sorted_flags: return {} @@ -106,19 +105,16 @@ def evaluate_v2(self, user: User, flag_keys: Set[str] = None) -> Dict[str, Varia user = self._enrich_user_with_cohorts(user, flag_configs) context = user_to_evaluation_context(user) - 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) - 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) + result = self.engine.evaluate(context, sorted_flags) + variants = { + k: Variant( + key=v.key, + value=v.value, + payload=v.payload, + metadata=v.metadata + ) for k, v in result.items() + } + self.logger.debug(f"[Experiment] Evaluate Result: {variants}") if self.assignment_service is not None: self.assignment_service.track(Assignment(user, variants)) return variants @@ -177,10 +173,10 @@ def _required_cohorts_in_storage(self, flag_configs: List) -> None: missing_cohorts = flag_cohort_ids - stored_cohort_ids if missing_cohorts: message = ( - f"Evaluating flag {flag['key']} dependent on cohorts {flag_cohort_ids} " + f"Evaluating flag {flag.key} dependent on cohorts {flag_cohort_ids} " f"without {missing_cohorts} in storage" if self.config.cohort_sync_config - else f"Evaluating flag {flag['key']} dependent on cohorts {flag_cohort_ids} without " + else f"Evaluating flag {flag.key} dependent on cohorts {flag_cohort_ids} without " f"cohort syncing configured" ) self.logger.warning(message) diff --git a/src/amplitude_experiment/local/evaluation/__init__.py b/src/amplitude_experiment/local/evaluation/__init__.py deleted file mode 100644 index d1d16e4..0000000 --- a/src/amplitude_experiment/local/evaluation/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -__pdoc__ = { - 'amplitude_experiment.local.evaluation.lib': False, -} diff --git a/src/amplitude_experiment/local/evaluation/evaluation.py b/src/amplitude_experiment/local/evaluation/evaluation.py deleted file mode 100644 index 6e9bc30..0000000 --- a/src/amplitude_experiment/local/evaluation/evaluation.py +++ /dev/null @@ -1,18 +0,0 @@ -from .libevaluation_interop import libevaluation_interop_symbols -from ctypes import cast, c_char_p - - -def evaluate(rules: str, context: str) -> str: - """ - Local evaluation wrapper. - Parameters: - rules (str): rules JSON string - context (str): context JSON string - - Returns: - Evaluation results with variants in JSON - """ - 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 deleted file mode 100755 index 544581e..0000000 Binary files a/src/amplitude_experiment/local/evaluation/lib/linuxArm64/libevaluation_interop.so and /dev/null 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 deleted file mode 100644 index 70485d1..0000000 --- a/src/amplitude_experiment/local/evaluation/lib/linuxArm64/libevaluation_interop_api.h +++ /dev/null @@ -1,110 +0,0 @@ -#ifndef KONAN_LIBEVALUATION_INTEROP_H -#define KONAN_LIBEVALUATION_INTEROP_H -#ifdef __cplusplus -extern "C" { -#endif -#ifdef __cplusplus -typedef bool libevaluation_interop_KBoolean; -#else -typedef _Bool libevaluation_interop_KBoolean; -#endif -typedef unsigned short libevaluation_interop_KChar; -typedef signed char libevaluation_interop_KByte; -typedef short libevaluation_interop_KShort; -typedef int libevaluation_interop_KInt; -typedef long long libevaluation_interop_KLong; -typedef unsigned char libevaluation_interop_KUByte; -typedef unsigned short libevaluation_interop_KUShort; -typedef unsigned int libevaluation_interop_KUInt; -typedef unsigned long long libevaluation_interop_KULong; -typedef float libevaluation_interop_KFloat; -typedef double libevaluation_interop_KDouble; -typedef float __attribute__ ((__vector_size__ (16))) libevaluation_interop_KVector128; -typedef void* libevaluation_interop_KNativePtr; -struct libevaluation_interop_KType; -typedef struct libevaluation_interop_KType libevaluation_interop_KType; - -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Byte; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Short; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Int; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Long; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Float; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Double; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Char; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Boolean; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Unit; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_UByte; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_UShort; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_UInt; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_ULong; - - -typedef struct { - /* Service functions. */ - void (*DisposeStablePointer)(libevaluation_interop_KNativePtr ptr); - void (*DisposeString)(const char* string); - libevaluation_interop_KBoolean (*IsInstance)(libevaluation_interop_KNativePtr ref, const libevaluation_interop_KType* type); - libevaluation_interop_kref_kotlin_Byte (*createNullableByte)(libevaluation_interop_KByte); - libevaluation_interop_KByte (*getNonNullValueOfByte)(libevaluation_interop_kref_kotlin_Byte); - libevaluation_interop_kref_kotlin_Short (*createNullableShort)(libevaluation_interop_KShort); - libevaluation_interop_KShort (*getNonNullValueOfShort)(libevaluation_interop_kref_kotlin_Short); - libevaluation_interop_kref_kotlin_Int (*createNullableInt)(libevaluation_interop_KInt); - libevaluation_interop_KInt (*getNonNullValueOfInt)(libevaluation_interop_kref_kotlin_Int); - libevaluation_interop_kref_kotlin_Long (*createNullableLong)(libevaluation_interop_KLong); - libevaluation_interop_KLong (*getNonNullValueOfLong)(libevaluation_interop_kref_kotlin_Long); - libevaluation_interop_kref_kotlin_Float (*createNullableFloat)(libevaluation_interop_KFloat); - libevaluation_interop_KFloat (*getNonNullValueOfFloat)(libevaluation_interop_kref_kotlin_Float); - libevaluation_interop_kref_kotlin_Double (*createNullableDouble)(libevaluation_interop_KDouble); - libevaluation_interop_KDouble (*getNonNullValueOfDouble)(libevaluation_interop_kref_kotlin_Double); - libevaluation_interop_kref_kotlin_Char (*createNullableChar)(libevaluation_interop_KChar); - libevaluation_interop_KChar (*getNonNullValueOfChar)(libevaluation_interop_kref_kotlin_Char); - libevaluation_interop_kref_kotlin_Boolean (*createNullableBoolean)(libevaluation_interop_KBoolean); - libevaluation_interop_KBoolean (*getNonNullValueOfBoolean)(libevaluation_interop_kref_kotlin_Boolean); - libevaluation_interop_kref_kotlin_Unit (*createNullableUnit)(void); - libevaluation_interop_kref_kotlin_UByte (*createNullableUByte)(libevaluation_interop_KUByte); - libevaluation_interop_KUByte (*getNonNullValueOfUByte)(libevaluation_interop_kref_kotlin_UByte); - libevaluation_interop_kref_kotlin_UShort (*createNullableUShort)(libevaluation_interop_KUShort); - libevaluation_interop_KUShort (*getNonNullValueOfUShort)(libevaluation_interop_kref_kotlin_UShort); - libevaluation_interop_kref_kotlin_UInt (*createNullableUInt)(libevaluation_interop_KUInt); - libevaluation_interop_KUInt (*getNonNullValueOfUInt)(libevaluation_interop_kref_kotlin_UInt); - libevaluation_interop_kref_kotlin_ULong (*createNullableULong)(libevaluation_interop_KULong); - libevaluation_interop_KULong (*getNonNullValueOfULong)(libevaluation_interop_kref_kotlin_ULong); - - /* User functions. */ - struct { - struct { - const char* (*evaluate)(const char* flags, const char* context); - } root; - } kotlin; -} libevaluation_interop_ExportedSymbols; -extern libevaluation_interop_ExportedSymbols* libevaluation_interop_symbols(void); -#ifdef __cplusplus -} /* extern "C" */ -#endif -#endif /* KONAN_LIBEVALUATION_INTEROP_H */ diff --git a/src/amplitude_experiment/local/evaluation/lib/linuxX64/libevaluation_interop.so b/src/amplitude_experiment/local/evaluation/lib/linuxX64/libevaluation_interop.so deleted file mode 100755 index f9af54d..0000000 Binary files a/src/amplitude_experiment/local/evaluation/lib/linuxX64/libevaluation_interop.so and /dev/null 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 deleted file mode 100644 index 70485d1..0000000 --- a/src/amplitude_experiment/local/evaluation/lib/linuxX64/libevaluation_interop_api.h +++ /dev/null @@ -1,110 +0,0 @@ -#ifndef KONAN_LIBEVALUATION_INTEROP_H -#define KONAN_LIBEVALUATION_INTEROP_H -#ifdef __cplusplus -extern "C" { -#endif -#ifdef __cplusplus -typedef bool libevaluation_interop_KBoolean; -#else -typedef _Bool libevaluation_interop_KBoolean; -#endif -typedef unsigned short libevaluation_interop_KChar; -typedef signed char libevaluation_interop_KByte; -typedef short libevaluation_interop_KShort; -typedef int libevaluation_interop_KInt; -typedef long long libevaluation_interop_KLong; -typedef unsigned char libevaluation_interop_KUByte; -typedef unsigned short libevaluation_interop_KUShort; -typedef unsigned int libevaluation_interop_KUInt; -typedef unsigned long long libevaluation_interop_KULong; -typedef float libevaluation_interop_KFloat; -typedef double libevaluation_interop_KDouble; -typedef float __attribute__ ((__vector_size__ (16))) libevaluation_interop_KVector128; -typedef void* libevaluation_interop_KNativePtr; -struct libevaluation_interop_KType; -typedef struct libevaluation_interop_KType libevaluation_interop_KType; - -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Byte; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Short; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Int; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Long; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Float; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Double; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Char; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Boolean; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Unit; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_UByte; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_UShort; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_UInt; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_ULong; - - -typedef struct { - /* Service functions. */ - void (*DisposeStablePointer)(libevaluation_interop_KNativePtr ptr); - void (*DisposeString)(const char* string); - libevaluation_interop_KBoolean (*IsInstance)(libevaluation_interop_KNativePtr ref, const libevaluation_interop_KType* type); - libevaluation_interop_kref_kotlin_Byte (*createNullableByte)(libevaluation_interop_KByte); - libevaluation_interop_KByte (*getNonNullValueOfByte)(libevaluation_interop_kref_kotlin_Byte); - libevaluation_interop_kref_kotlin_Short (*createNullableShort)(libevaluation_interop_KShort); - libevaluation_interop_KShort (*getNonNullValueOfShort)(libevaluation_interop_kref_kotlin_Short); - libevaluation_interop_kref_kotlin_Int (*createNullableInt)(libevaluation_interop_KInt); - libevaluation_interop_KInt (*getNonNullValueOfInt)(libevaluation_interop_kref_kotlin_Int); - libevaluation_interop_kref_kotlin_Long (*createNullableLong)(libevaluation_interop_KLong); - libevaluation_interop_KLong (*getNonNullValueOfLong)(libevaluation_interop_kref_kotlin_Long); - libevaluation_interop_kref_kotlin_Float (*createNullableFloat)(libevaluation_interop_KFloat); - libevaluation_interop_KFloat (*getNonNullValueOfFloat)(libevaluation_interop_kref_kotlin_Float); - libevaluation_interop_kref_kotlin_Double (*createNullableDouble)(libevaluation_interop_KDouble); - libevaluation_interop_KDouble (*getNonNullValueOfDouble)(libevaluation_interop_kref_kotlin_Double); - libevaluation_interop_kref_kotlin_Char (*createNullableChar)(libevaluation_interop_KChar); - libevaluation_interop_KChar (*getNonNullValueOfChar)(libevaluation_interop_kref_kotlin_Char); - libevaluation_interop_kref_kotlin_Boolean (*createNullableBoolean)(libevaluation_interop_KBoolean); - libevaluation_interop_KBoolean (*getNonNullValueOfBoolean)(libevaluation_interop_kref_kotlin_Boolean); - libevaluation_interop_kref_kotlin_Unit (*createNullableUnit)(void); - libevaluation_interop_kref_kotlin_UByte (*createNullableUByte)(libevaluation_interop_KUByte); - libevaluation_interop_KUByte (*getNonNullValueOfUByte)(libevaluation_interop_kref_kotlin_UByte); - libevaluation_interop_kref_kotlin_UShort (*createNullableUShort)(libevaluation_interop_KUShort); - libevaluation_interop_KUShort (*getNonNullValueOfUShort)(libevaluation_interop_kref_kotlin_UShort); - libevaluation_interop_kref_kotlin_UInt (*createNullableUInt)(libevaluation_interop_KUInt); - libevaluation_interop_KUInt (*getNonNullValueOfUInt)(libevaluation_interop_kref_kotlin_UInt); - libevaluation_interop_kref_kotlin_ULong (*createNullableULong)(libevaluation_interop_KULong); - libevaluation_interop_KULong (*getNonNullValueOfULong)(libevaluation_interop_kref_kotlin_ULong); - - /* User functions. */ - struct { - struct { - const char* (*evaluate)(const char* flags, const char* context); - } root; - } kotlin; -} libevaluation_interop_ExportedSymbols; -extern libevaluation_interop_ExportedSymbols* libevaluation_interop_symbols(void); -#ifdef __cplusplus -} /* extern "C" */ -#endif -#endif /* KONAN_LIBEVALUATION_INTEROP_H */ diff --git a/src/amplitude_experiment/local/evaluation/lib/macosArm64/libevaluation_interop.dylib b/src/amplitude_experiment/local/evaluation/lib/macosArm64/libevaluation_interop.dylib deleted file mode 100755 index 2c976b6..0000000 Binary files a/src/amplitude_experiment/local/evaluation/lib/macosArm64/libevaluation_interop.dylib and /dev/null 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 deleted file mode 100644 index 70485d1..0000000 --- a/src/amplitude_experiment/local/evaluation/lib/macosArm64/libevaluation_interop_api.h +++ /dev/null @@ -1,110 +0,0 @@ -#ifndef KONAN_LIBEVALUATION_INTEROP_H -#define KONAN_LIBEVALUATION_INTEROP_H -#ifdef __cplusplus -extern "C" { -#endif -#ifdef __cplusplus -typedef bool libevaluation_interop_KBoolean; -#else -typedef _Bool libevaluation_interop_KBoolean; -#endif -typedef unsigned short libevaluation_interop_KChar; -typedef signed char libevaluation_interop_KByte; -typedef short libevaluation_interop_KShort; -typedef int libevaluation_interop_KInt; -typedef long long libevaluation_interop_KLong; -typedef unsigned char libevaluation_interop_KUByte; -typedef unsigned short libevaluation_interop_KUShort; -typedef unsigned int libevaluation_interop_KUInt; -typedef unsigned long long libevaluation_interop_KULong; -typedef float libevaluation_interop_KFloat; -typedef double libevaluation_interop_KDouble; -typedef float __attribute__ ((__vector_size__ (16))) libevaluation_interop_KVector128; -typedef void* libevaluation_interop_KNativePtr; -struct libevaluation_interop_KType; -typedef struct libevaluation_interop_KType libevaluation_interop_KType; - -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Byte; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Short; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Int; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Long; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Float; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Double; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Char; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Boolean; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Unit; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_UByte; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_UShort; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_UInt; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_ULong; - - -typedef struct { - /* Service functions. */ - void (*DisposeStablePointer)(libevaluation_interop_KNativePtr ptr); - void (*DisposeString)(const char* string); - libevaluation_interop_KBoolean (*IsInstance)(libevaluation_interop_KNativePtr ref, const libevaluation_interop_KType* type); - libevaluation_interop_kref_kotlin_Byte (*createNullableByte)(libevaluation_interop_KByte); - libevaluation_interop_KByte (*getNonNullValueOfByte)(libevaluation_interop_kref_kotlin_Byte); - libevaluation_interop_kref_kotlin_Short (*createNullableShort)(libevaluation_interop_KShort); - libevaluation_interop_KShort (*getNonNullValueOfShort)(libevaluation_interop_kref_kotlin_Short); - libevaluation_interop_kref_kotlin_Int (*createNullableInt)(libevaluation_interop_KInt); - libevaluation_interop_KInt (*getNonNullValueOfInt)(libevaluation_interop_kref_kotlin_Int); - libevaluation_interop_kref_kotlin_Long (*createNullableLong)(libevaluation_interop_KLong); - libevaluation_interop_KLong (*getNonNullValueOfLong)(libevaluation_interop_kref_kotlin_Long); - libevaluation_interop_kref_kotlin_Float (*createNullableFloat)(libevaluation_interop_KFloat); - libevaluation_interop_KFloat (*getNonNullValueOfFloat)(libevaluation_interop_kref_kotlin_Float); - libevaluation_interop_kref_kotlin_Double (*createNullableDouble)(libevaluation_interop_KDouble); - libevaluation_interop_KDouble (*getNonNullValueOfDouble)(libevaluation_interop_kref_kotlin_Double); - libevaluation_interop_kref_kotlin_Char (*createNullableChar)(libevaluation_interop_KChar); - libevaluation_interop_KChar (*getNonNullValueOfChar)(libevaluation_interop_kref_kotlin_Char); - libevaluation_interop_kref_kotlin_Boolean (*createNullableBoolean)(libevaluation_interop_KBoolean); - libevaluation_interop_KBoolean (*getNonNullValueOfBoolean)(libevaluation_interop_kref_kotlin_Boolean); - libevaluation_interop_kref_kotlin_Unit (*createNullableUnit)(void); - libevaluation_interop_kref_kotlin_UByte (*createNullableUByte)(libevaluation_interop_KUByte); - libevaluation_interop_KUByte (*getNonNullValueOfUByte)(libevaluation_interop_kref_kotlin_UByte); - libevaluation_interop_kref_kotlin_UShort (*createNullableUShort)(libevaluation_interop_KUShort); - libevaluation_interop_KUShort (*getNonNullValueOfUShort)(libevaluation_interop_kref_kotlin_UShort); - libevaluation_interop_kref_kotlin_UInt (*createNullableUInt)(libevaluation_interop_KUInt); - libevaluation_interop_KUInt (*getNonNullValueOfUInt)(libevaluation_interop_kref_kotlin_UInt); - libevaluation_interop_kref_kotlin_ULong (*createNullableULong)(libevaluation_interop_KULong); - libevaluation_interop_KULong (*getNonNullValueOfULong)(libevaluation_interop_kref_kotlin_ULong); - - /* User functions. */ - struct { - struct { - const char* (*evaluate)(const char* flags, const char* context); - } root; - } kotlin; -} libevaluation_interop_ExportedSymbols; -extern libevaluation_interop_ExportedSymbols* libevaluation_interop_symbols(void); -#ifdef __cplusplus -} /* extern "C" */ -#endif -#endif /* KONAN_LIBEVALUATION_INTEROP_H */ diff --git a/src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop.dylib b/src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop.dylib deleted file mode 100755 index 1958d29..0000000 Binary files a/src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop.dylib and /dev/null 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 deleted file mode 100644 index 70485d1..0000000 --- a/src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h +++ /dev/null @@ -1,110 +0,0 @@ -#ifndef KONAN_LIBEVALUATION_INTEROP_H -#define KONAN_LIBEVALUATION_INTEROP_H -#ifdef __cplusplus -extern "C" { -#endif -#ifdef __cplusplus -typedef bool libevaluation_interop_KBoolean; -#else -typedef _Bool libevaluation_interop_KBoolean; -#endif -typedef unsigned short libevaluation_interop_KChar; -typedef signed char libevaluation_interop_KByte; -typedef short libevaluation_interop_KShort; -typedef int libevaluation_interop_KInt; -typedef long long libevaluation_interop_KLong; -typedef unsigned char libevaluation_interop_KUByte; -typedef unsigned short libevaluation_interop_KUShort; -typedef unsigned int libevaluation_interop_KUInt; -typedef unsigned long long libevaluation_interop_KULong; -typedef float libevaluation_interop_KFloat; -typedef double libevaluation_interop_KDouble; -typedef float __attribute__ ((__vector_size__ (16))) libevaluation_interop_KVector128; -typedef void* libevaluation_interop_KNativePtr; -struct libevaluation_interop_KType; -typedef struct libevaluation_interop_KType libevaluation_interop_KType; - -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Byte; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Short; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Int; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Long; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Float; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Double; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Char; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Boolean; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_Unit; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_UByte; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_UShort; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_UInt; -typedef struct { - libevaluation_interop_KNativePtr pinned; -} libevaluation_interop_kref_kotlin_ULong; - - -typedef struct { - /* Service functions. */ - void (*DisposeStablePointer)(libevaluation_interop_KNativePtr ptr); - void (*DisposeString)(const char* string); - libevaluation_interop_KBoolean (*IsInstance)(libevaluation_interop_KNativePtr ref, const libevaluation_interop_KType* type); - libevaluation_interop_kref_kotlin_Byte (*createNullableByte)(libevaluation_interop_KByte); - libevaluation_interop_KByte (*getNonNullValueOfByte)(libevaluation_interop_kref_kotlin_Byte); - libevaluation_interop_kref_kotlin_Short (*createNullableShort)(libevaluation_interop_KShort); - libevaluation_interop_KShort (*getNonNullValueOfShort)(libevaluation_interop_kref_kotlin_Short); - libevaluation_interop_kref_kotlin_Int (*createNullableInt)(libevaluation_interop_KInt); - libevaluation_interop_KInt (*getNonNullValueOfInt)(libevaluation_interop_kref_kotlin_Int); - libevaluation_interop_kref_kotlin_Long (*createNullableLong)(libevaluation_interop_KLong); - libevaluation_interop_KLong (*getNonNullValueOfLong)(libevaluation_interop_kref_kotlin_Long); - libevaluation_interop_kref_kotlin_Float (*createNullableFloat)(libevaluation_interop_KFloat); - libevaluation_interop_KFloat (*getNonNullValueOfFloat)(libevaluation_interop_kref_kotlin_Float); - libevaluation_interop_kref_kotlin_Double (*createNullableDouble)(libevaluation_interop_KDouble); - libevaluation_interop_KDouble (*getNonNullValueOfDouble)(libevaluation_interop_kref_kotlin_Double); - libevaluation_interop_kref_kotlin_Char (*createNullableChar)(libevaluation_interop_KChar); - libevaluation_interop_KChar (*getNonNullValueOfChar)(libevaluation_interop_kref_kotlin_Char); - libevaluation_interop_kref_kotlin_Boolean (*createNullableBoolean)(libevaluation_interop_KBoolean); - libevaluation_interop_KBoolean (*getNonNullValueOfBoolean)(libevaluation_interop_kref_kotlin_Boolean); - libevaluation_interop_kref_kotlin_Unit (*createNullableUnit)(void); - libevaluation_interop_kref_kotlin_UByte (*createNullableUByte)(libevaluation_interop_KUByte); - libevaluation_interop_KUByte (*getNonNullValueOfUByte)(libevaluation_interop_kref_kotlin_UByte); - libevaluation_interop_kref_kotlin_UShort (*createNullableUShort)(libevaluation_interop_KUShort); - libevaluation_interop_KUShort (*getNonNullValueOfUShort)(libevaluation_interop_kref_kotlin_UShort); - libevaluation_interop_kref_kotlin_UInt (*createNullableUInt)(libevaluation_interop_KUInt); - libevaluation_interop_KUInt (*getNonNullValueOfUInt)(libevaluation_interop_kref_kotlin_UInt); - libevaluation_interop_kref_kotlin_ULong (*createNullableULong)(libevaluation_interop_KULong); - libevaluation_interop_KULong (*getNonNullValueOfULong)(libevaluation_interop_kref_kotlin_ULong); - - /* User functions. */ - struct { - struct { - const char* (*evaluate)(const char* flags, const char* context); - } root; - } kotlin; -} libevaluation_interop_ExportedSymbols; -extern libevaluation_interop_ExportedSymbols* libevaluation_interop_symbols(void); -#ifdef __cplusplus -} /* extern "C" */ -#endif -#endif /* KONAN_LIBEVALUATION_INTEROP_H */ diff --git a/src/amplitude_experiment/local/evaluation/libevaluation_interop.py b/src/amplitude_experiment/local/evaluation/libevaluation_interop.py deleted file mode 100644 index de2b0c2..0000000 --- a/src/amplitude_experiment/local/evaluation/libevaluation_interop.py +++ /dev/null @@ -1,1187 +0,0 @@ -r"""Wrapper for libevaluation_interop_api.h - -Do not modify this file. -""" - -__docformat__ = "restructuredtext" - -# Begin preamble for Python - -import ctypes -import sys -from ctypes import * # noqa: F401, F403 - -_int_types = (ctypes.c_int16, ctypes.c_int32) -if hasattr(ctypes, "c_int64"): - # Some builds of ctypes apparently do not have ctypes.c_int64 - # defined; it's a pretty good bet that these builds do not - # have 64-bit pointers. - _int_types += (ctypes.c_int64,) -for t in _int_types: - if ctypes.sizeof(t) == ctypes.sizeof(ctypes.c_size_t): - c_ptrdiff_t = t -del t -del _int_types - - - -class UserString: - def __init__(self, seq): - if isinstance(seq, bytes): - self.data = seq - elif isinstance(seq, UserString): - self.data = seq.data[:] - else: - self.data = str(seq).encode() - - def __bytes__(self): - return self.data - - def __str__(self): - return self.data.decode() - - def __repr__(self): - return repr(self.data) - - def __int__(self): - return int(self.data.decode()) - - def __long__(self): - return int(self.data.decode()) - - def __float__(self): - return float(self.data.decode()) - - def __complex__(self): - return complex(self.data.decode()) - - def __hash__(self): - return hash(self.data) - - def __le__(self, string): - if isinstance(string, UserString): - return self.data <= string.data - else: - return self.data <= string - - def __lt__(self, string): - if isinstance(string, UserString): - return self.data < string.data - else: - return self.data < string - - def __ge__(self, string): - if isinstance(string, UserString): - return self.data >= string.data - else: - return self.data >= string - - def __gt__(self, string): - if isinstance(string, UserString): - return self.data > string.data - else: - return self.data > string - - def __eq__(self, string): - if isinstance(string, UserString): - return self.data == string.data - else: - return self.data == string - - def __ne__(self, string): - if isinstance(string, UserString): - return self.data != string.data - else: - return self.data != string - - def __contains__(self, char): - return char in self.data - - def __len__(self): - return len(self.data) - - def __getitem__(self, index): - return self.__class__(self.data[index]) - - def __getslice__(self, start, end): - start = max(start, 0) - end = max(end, 0) - return self.__class__(self.data[start:end]) - - def __add__(self, other): - if isinstance(other, UserString): - return self.__class__(self.data + other.data) - elif isinstance(other, bytes): - return self.__class__(self.data + other) - else: - return self.__class__(self.data + str(other).encode()) - - def __radd__(self, other): - if isinstance(other, bytes): - return self.__class__(other + self.data) - else: - return self.__class__(str(other).encode() + self.data) - - def __mul__(self, n): - return self.__class__(self.data * n) - - __rmul__ = __mul__ - - def __mod__(self, args): - return self.__class__(self.data % args) - - # the following methods are defined in alphabetical order: - def capitalize(self): - return self.__class__(self.data.capitalize()) - - def center(self, width, *args): - return self.__class__(self.data.center(width, *args)) - - def count(self, sub, start=0, end=sys.maxsize): - return self.data.count(sub, start, end) - - def decode(self, encoding=None, errors=None): # XXX improve this? - if encoding: - if errors: - return self.__class__(self.data.decode(encoding, errors)) - else: - return self.__class__(self.data.decode(encoding)) - else: - return self.__class__(self.data.decode()) - - def encode(self, encoding=None, errors=None): # XXX improve this? - if encoding: - if errors: - return self.__class__(self.data.encode(encoding, errors)) - else: - return self.__class__(self.data.encode(encoding)) - else: - return self.__class__(self.data.encode()) - - def endswith(self, suffix, start=0, end=sys.maxsize): - return self.data.endswith(suffix, start, end) - - def expandtabs(self, tabsize=8): - return self.__class__(self.data.expandtabs(tabsize)) - - def find(self, sub, start=0, end=sys.maxsize): - return self.data.find(sub, start, end) - - def index(self, sub, start=0, end=sys.maxsize): - return self.data.index(sub, start, end) - - def isalpha(self): - return self.data.isalpha() - - def isalnum(self): - return self.data.isalnum() - - def isdecimal(self): - return self.data.isdecimal() - - def isdigit(self): - return self.data.isdigit() - - def islower(self): - return self.data.islower() - - def isnumeric(self): - return self.data.isnumeric() - - def isspace(self): - return self.data.isspace() - - def istitle(self): - return self.data.istitle() - - def isupper(self): - return self.data.isupper() - - def join(self, seq): - return self.data.join(seq) - - def ljust(self, width, *args): - return self.__class__(self.data.ljust(width, *args)) - - def lower(self): - return self.__class__(self.data.lower()) - - def lstrip(self, chars=None): - return self.__class__(self.data.lstrip(chars)) - - def partition(self, sep): - return self.data.partition(sep) - - def replace(self, old, new, maxsplit=-1): - return self.__class__(self.data.replace(old, new, maxsplit)) - - def rfind(self, sub, start=0, end=sys.maxsize): - return self.data.rfind(sub, start, end) - - def rindex(self, sub, start=0, end=sys.maxsize): - return self.data.rindex(sub, start, end) - - def rjust(self, width, *args): - return self.__class__(self.data.rjust(width, *args)) - - def rpartition(self, sep): - return self.data.rpartition(sep) - - def rstrip(self, chars=None): - return self.__class__(self.data.rstrip(chars)) - - def split(self, sep=None, maxsplit=-1): - return self.data.split(sep, maxsplit) - - def rsplit(self, sep=None, maxsplit=-1): - return self.data.rsplit(sep, maxsplit) - - def splitlines(self, keepends=0): - return self.data.splitlines(keepends) - - def startswith(self, prefix, start=0, end=sys.maxsize): - return self.data.startswith(prefix, start, end) - - def strip(self, chars=None): - return self.__class__(self.data.strip(chars)) - - def swapcase(self): - return self.__class__(self.data.swapcase()) - - def title(self): - return self.__class__(self.data.title()) - - def translate(self, *args): - return self.__class__(self.data.translate(*args)) - - def upper(self): - return self.__class__(self.data.upper()) - - def zfill(self, width): - return self.__class__(self.data.zfill(width)) - - -class MutableString(UserString): - """mutable string objects - - Python strings are immutable objects. This has the advantage, that - strings may be used as dictionary keys. If this property isn't needed - and you insist on changing string values in place instead, you may cheat - and use MutableString. - - But the purpose of this class is an educational one: to prevent - people from inventing their own mutable string class derived - from UserString and than forget thereby to remove (override) the - __hash__ method inherited from UserString. This would lead to - errors that would be very hard to track down. - - A faster and better solution is to rewrite your program using lists.""" - - def __init__(self, string=""): - self.data = string - - def __hash__(self): - raise TypeError("unhashable type (it is mutable)") - - def __setitem__(self, index, sub): - if index < 0: - index += len(self.data) - if index < 0 or index >= len(self.data): - raise IndexError - self.data = self.data[:index] + sub + self.data[index + 1 :] - - def __delitem__(self, index): - if index < 0: - index += len(self.data) - if index < 0 or index >= len(self.data): - raise IndexError - self.data = self.data[:index] + self.data[index + 1 :] - - def __setslice__(self, start, end, sub): - start = max(start, 0) - end = max(end, 0) - if isinstance(sub, UserString): - self.data = self.data[:start] + sub.data + self.data[end:] - elif isinstance(sub, bytes): - self.data = self.data[:start] + sub + self.data[end:] - else: - self.data = self.data[:start] + str(sub).encode() + self.data[end:] - - def __delslice__(self, start, end): - start = max(start, 0) - end = max(end, 0) - self.data = self.data[:start] + self.data[end:] - - def immutable(self): - return UserString(self.data) - - def __iadd__(self, other): - if isinstance(other, UserString): - self.data += other.data - elif isinstance(other, bytes): - self.data += other - else: - self.data += str(other).encode() - return self - - def __imul__(self, n): - self.data *= n - return self - - -class String(MutableString, ctypes.Union): - - _fields_ = [("raw", ctypes.POINTER(ctypes.c_char)), ("data", ctypes.c_char_p)] - - def __init__(self, obj=b""): - if isinstance(obj, (bytes, UserString)): - self.data = bytes(obj) - else: - self.raw = obj - - def __len__(self): - return self.data and len(self.data) or 0 - - def from_param(cls, obj): - # Convert None or 0 - if obj is None or obj == 0: - return cls(ctypes.POINTER(ctypes.c_char)()) - - # Convert from String - elif isinstance(obj, String): - return obj - - # Convert from bytes - elif isinstance(obj, bytes): - return cls(obj) - - # Convert from str - elif isinstance(obj, str): - return cls(obj.encode()) - - # Convert from c_char_p - elif isinstance(obj, ctypes.c_char_p): - return obj - - # Convert from POINTER(ctypes.c_char) - elif isinstance(obj, ctypes.POINTER(ctypes.c_char)): - return obj - - # Convert from raw pointer - elif isinstance(obj, int): - return cls(ctypes.cast(obj, ctypes.POINTER(ctypes.c_char))) - - # Convert from ctypes.c_char array - elif isinstance(obj, ctypes.c_char * len(obj)): - return obj - - # Convert from object - else: - return String.from_param(obj._as_parameter_) - - from_param = classmethod(from_param) - - -def ReturnString(obj, func=None, arguments=None): - return String.from_param(obj) - - -# As of ctypes 1.0, ctypes does not support custom error-checking -# functions on callbacks, nor does it support custom datatypes on -# callbacks, so we must ensure that all callbacks return -# primitive datatypes. -# -# Non-primitive return values wrapped with UNCHECKED won't be -# typechecked, and will be converted to ctypes.c_void_p. -def UNCHECKED(type): - if hasattr(type, "_type_") and isinstance(type._type_, str) and type._type_ != "P": - return type - else: - return ctypes.c_void_p - - -# ctypes doesn't have direct support for variadic functions, so we have to write -# our own wrapper class -class _variadic_function(object): - def __init__(self, func, restype, argtypes, errcheck): - self.func = func - self.func.restype = restype - self.argtypes = argtypes - if errcheck: - self.func.errcheck = errcheck - - def _as_parameter_(self): - # So we can pass this variadic function as a function pointer - return self.func - - def __call__(self, *args): - fixed_args = [] - i = 0 - for argtype in self.argtypes: - # Typecheck what we can - fixed_args.append(argtype.from_param(args[i])) - i += 1 - return self.func(*fixed_args + list(args[i:])) - - -def ord_if_char(value): - """ - Simple helper used for casts to simple builtin types: if the argument is a - string type, it will be converted to it's ordinal value. - - This function will raise an exception if the argument is string with more - than one characters. - """ - return ord(value) if (isinstance(value, bytes) or isinstance(value, str)) else value - -# End preamble - -_libs = {} -_libdirs = ['./lib'] - -# Begin loader - -""" -Load libraries - appropriately for all our supported platforms -""" -# ---------------------------------------------------------------------------- -# Copyright (c) 2008 David James -# Copyright (c) 2006-2008 Alex Holkner -# All rights reserved. -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions -# are met: -# -# * Redistributions of source code must retain the above copyright -# notice, this list of conditions and the following disclaimer. -# * Redistributions in binary form must reproduce the above copyright -# notice, this list of conditions and the following disclaimer in -# the documentation and/or other materials provided with the -# distribution. -# * Neither the name of pyglet nor the names of its -# contributors may be used to endorse or promote products -# derived from this software without specific prior written -# permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE -# COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, -# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, -# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT -# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN -# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -# ---------------------------------------------------------------------------- - -import ctypes -import ctypes.util -import glob -import os.path -import platform -import re -import sys - - -def _environ_path(name): - """Split an environment variable into a path-like list elements""" - if name in os.environ: - return os.environ[name].split(":") - return [] - - -class LibraryLoader: - """ - A base class For loading of libraries ;-) - Subclasses load libraries for specific platforms. - """ - - # library names formatted specifically for platforms - name_formats = ["%s"] - - class Lookup: - """Looking up calling conventions for a platform""" - - mode = ctypes.DEFAULT_MODE - - def __init__(self, path): - super(LibraryLoader.Lookup, self).__init__() - self.access = dict(cdecl=ctypes.CDLL(path, self.mode)) - - def get(self, name, calling_convention="cdecl"): - """Return the given name according to the selected calling convention""" - if calling_convention not in self.access: - raise LookupError( - "Unknown calling convention '{}' for function '{}'".format( - calling_convention, name - ) - ) - return getattr(self.access[calling_convention], name) - - def has(self, name, calling_convention="cdecl"): - """Return True if this given calling convention finds the given 'name'""" - if calling_convention not in self.access: - return False - return hasattr(self.access[calling_convention], name) - - def __getattr__(self, name): - return getattr(self.access["cdecl"], name) - - def __init__(self): - self.other_dirs = [] - - def __call__(self, libname): - """Given the name of a library, load it.""" - paths = self.getpaths(libname) - - for path in paths: - # noinspection PyBroadException - try: - return self.Lookup(path) - except Exception: # pylint: disable=broad-except - pass - - raise ImportError("Could not load %s." % libname) - - def getpaths(self, libname): - """Return a list of paths where the library might be found.""" - if os.path.isabs(libname): - yield libname - else: - # search through a prioritized series of locations for the library - - # we first search any specific directories identified by user - for dir_i in self.other_dirs: - for fmt in self.name_formats: - # dir_i should be absolute already - yield os.path.join(dir_i, fmt % libname) - - # check if this code is even stored in a physical file - try: - this_file = __file__ - except NameError: - this_file = None - - # then we search the directory where the generated python interface is stored - if this_file is not None: - for fmt in self.name_formats: - yield os.path.abspath(os.path.join(os.path.dirname(__file__), fmt % libname)) - - # now, use the ctypes tools to try to find the library - for fmt in self.name_formats: - path = ctypes.util.find_library(fmt % libname) - if path: - yield path - - # then we search all paths identified as platform-specific lib paths - for path in self.getplatformpaths(libname): - yield path - - # Finally, we'll try the users current working directory - for fmt in self.name_formats: - yield os.path.abspath(os.path.join(os.path.curdir, fmt % libname)) - - def getplatformpaths(self, _libname): # pylint: disable=no-self-use - """Return all the library paths available in this platform""" - return [] - - -# Darwin (Mac OS X) - - -class DarwinLibraryLoader(LibraryLoader): - """Library loader for MacOS""" - - name_formats = [ - "lib%s.dylib", - "lib%s.so", - "lib%s.bundle", - "%s.dylib", - "%s.so", - "%s.bundle", - "%s", - ] - - class Lookup(LibraryLoader.Lookup): - """ - Looking up library files for this platform (Darwin aka MacOS) - """ - - # Darwin requires dlopen to be called with mode RTLD_GLOBAL instead - # of the default RTLD_LOCAL. Without this, you end up with - # libraries not being loadable, resulting in "Symbol not found" - # errors - mode = ctypes.RTLD_GLOBAL - - def getplatformpaths(self, libname): - if os.path.pathsep in libname: - names = [libname] - else: - names = [fmt % libname for fmt in self.name_formats] - - for directory in self.getdirs(libname): - for name in names: - yield os.path.join(directory, name) - - if platform.machine().startswith('arm'): - yield os.path.abspath( - os.path.join(os.path.dirname(os.path.realpath(__file__)), f"./lib/macosArm64/{libname}.dylib")) - else: - yield os.path.abspath( - os.path.join(os.path.dirname(os.path.realpath(__file__)), f"./lib/macosX64/{libname}.dylib")) - - @staticmethod - def getdirs(libname): - """Implements the dylib search as specified in Apple documentation: - - http://developer.apple.com/documentation/DeveloperTools/Conceptual/ - DynamicLibraries/Articles/DynamicLibraryUsageGuidelines.html - - Before commencing the standard search, the method first checks - the bundle's ``Frameworks`` directory if the application is running - within a bundle (OS X .app). - """ - - dyld_fallback_library_path = _environ_path("DYLD_FALLBACK_LIBRARY_PATH") - if not dyld_fallback_library_path: - dyld_fallback_library_path = [ - os.path.expanduser("~/lib"), - "/usr/local/lib", - "/usr/lib", - ] - - dirs = [] - - if "/" in libname: - dirs.extend(_environ_path("DYLD_LIBRARY_PATH")) - else: - dirs.extend(_environ_path("LD_LIBRARY_PATH")) - dirs.extend(_environ_path("DYLD_LIBRARY_PATH")) - dirs.extend(_environ_path("LD_RUN_PATH")) - - if hasattr(sys, "frozen") and getattr(sys, "frozen") == "macosx_app": - dirs.append(os.path.join(os.environ["RESOURCEPATH"], "..", "Frameworks")) - - dirs.extend(dyld_fallback_library_path) - - return dirs - - -# Posix - - -class PosixLibraryLoader(LibraryLoader): - """Library loader for POSIX-like systems (including Linux)""" - - _ld_so_cache = None - - _include = re.compile(r"^\s*include\s+(?P.*)") - - name_formats = ["lib%s.so", "%s.so", "%s"] - - class _Directories(dict): - """Deal with directories""" - - def __init__(self): - dict.__init__(self) - self.order = 0 - - def add(self, directory): - """Add a directory to our current set of directories""" - if len(directory) > 1: - directory = directory.rstrip(os.path.sep) - # only adds and updates order if exists and not already in set - if not os.path.exists(directory): - return - order = self.setdefault(directory, self.order) - if order == self.order: - self.order += 1 - - def extend(self, directories): - """Add a list of directories to our set""" - for a_dir in directories: - self.add(a_dir) - - def ordered(self): - """Sort the list of directories""" - return (i[0] for i in sorted(self.items(), key=lambda d: d[1])) - - def _get_ld_so_conf_dirs(self, conf, dirs): - """ - Recursive function to help parse all ld.so.conf files, including proper - handling of the `include` directive. - """ - - try: - with open(conf) as fileobj: - for dirname in fileobj: - dirname = dirname.strip() - if not dirname: - continue - - match = self._include.match(dirname) - if not match: - dirs.add(dirname) - else: - for dir2 in glob.glob(match.group("pattern")): - self._get_ld_so_conf_dirs(dir2, dirs) - except IOError: - pass - - def _create_ld_so_cache(self): - # Recreate search path followed by ld.so. This is going to be - # slow to build, and incorrect (ld.so uses ld.so.cache, which may - # not be up-to-date). Used only as fallback for distros without - # /sbin/ldconfig. - # - # We assume the DT_RPATH and DT_RUNPATH binary sections are omitted. - - directories = self._Directories() - for name in ( - "LD_LIBRARY_PATH", - "SHLIB_PATH", # HP-UX - "LIBPATH", # OS/2, AIX - "LIBRARY_PATH", # BE/OS - ): - if name in os.environ: - directories.extend(os.environ[name].split(os.pathsep)) - - self._get_ld_so_conf_dirs("/etc/ld.so.conf", directories) - - bitage = platform.architecture()[0] - - unix_lib_dirs_list = [] - if bitage.startswith("64"): - # prefer 64 bit if that is our arch - unix_lib_dirs_list += ["/lib64", "/usr/lib64"] - - # must include standard libs, since those paths are also used by 64 bit - # installs - unix_lib_dirs_list += ["/lib", "/usr/lib"] - if sys.platform.startswith("linux"): - # Try and support multiarch work in Ubuntu - # https://wiki.ubuntu.com/MultiarchSpec - if bitage.startswith("32"): - # Assume Intel/AMD x86 compat - unix_lib_dirs_list += ["/lib/i386-linux-gnu", "/usr/lib/i386-linux-gnu"] - elif bitage.startswith("64"): - # Assume Intel/AMD x86 compatible - unix_lib_dirs_list += [ - "/lib/x86_64-linux-gnu", - "/usr/lib/x86_64-linux-gnu", - ] - else: - # guess... - unix_lib_dirs_list += glob.glob("/lib/*linux-gnu") - directories.extend(unix_lib_dirs_list) - - cache = {} - lib_re = re.compile(r"lib(.*)\.s[ol]") - # ext_re = re.compile(r"\.s[ol]$") - for our_dir in directories.ordered(): - try: - for path in glob.glob("%s/*.s[ol]*" % our_dir): - file = os.path.basename(path) - - # Index by filename - cache_i = cache.setdefault(file, set()) - cache_i.add(path) - - # Index by library name - match = lib_re.match(file) - if match: - library = match.group(1) - cache_i = cache.setdefault(library, set()) - cache_i.add(path) - except OSError: - pass - - self._ld_so_cache = cache - - def getplatformpaths(self, libname): - if self._ld_so_cache is None: - self._create_ld_so_cache() - - result = self._ld_so_cache.get(libname, set()) - for i in result: - # we iterate through all found paths for library, since we may have - # actually found multiple architectures or other library types that - # may not load - yield i - if platform.machine().startswith(('arm', 'aarch64')): - yield os.path.abspath(os.path.join(os.path.dirname(os.path.realpath(__file__)), f"./lib/linuxArm64/{libname}.so")) - else: - yield os.path.abspath(os.path.join(os.path.dirname(os.path.realpath(__file__)), f"./lib/linuxX64/{libname}.so")) - - -# Windows - - -class WindowsLibraryLoader(LibraryLoader): - """Library loader for Microsoft Windows""" - - name_formats = ["%s.dll", "lib%s.dll", "%slib.dll", "%s"] - - class Lookup(LibraryLoader.Lookup): - """Lookup class for Windows libraries...""" - - def __init__(self, path): - super(WindowsLibraryLoader.Lookup, self).__init__(path) - self.access["stdcall"] = ctypes.windll.LoadLibrary(path) - - -# Platform switching - -# If your value of sys.platform does not appear in this dict, please contact -# the Ctypesgen maintainers. - -loaderclass = { - "darwin": DarwinLibraryLoader, - "cygwin": WindowsLibraryLoader, - "win32": WindowsLibraryLoader, - "msys": WindowsLibraryLoader, -} - -load_library = loaderclass.get(sys.platform, PosixLibraryLoader)() - - -def add_library_search_dirs(other_dirs): - """ - Add libraries to search paths. - If library paths are relative, convert them to absolute with respect to this - file's directory - """ - for path in other_dirs: - if not os.path.isabs(path): - path = os.path.abspath(path) - load_library.other_dirs.append(path) - - -del loaderclass - -# End loader - -add_library_search_dirs(['./lib']) - -# Begin libraries -_libs["libevaluation_interop"] = load_library("libevaluation_interop") - -# 1 libraries -# End libraries - -# No modules - -libevaluation_interop_KBoolean = c_bool# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 9 - -libevaluation_interop_KChar = c_ushort# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 11 - -libevaluation_interop_KByte = c_char# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 12 - -libevaluation_interop_KShort = c_short# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 13 - -libevaluation_interop_KInt = c_int# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 14 - -libevaluation_interop_KLong = c_longlong# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 15 - -libevaluation_interop_KUByte = c_ubyte# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 16 - -libevaluation_interop_KUShort = c_ushort# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 17 - -libevaluation_interop_KUInt = c_uint# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 18 - -libevaluation_interop_KULong = c_ulonglong# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 19 - -libevaluation_interop_KFloat = c_float# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 20 - -libevaluation_interop_KDouble = c_double# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 21 - -libevaluation_interop_KVector128 = c_float# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 22 - -libevaluation_interop_KNativePtr = POINTER(None)# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 23 - -# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 24 -class struct_libevaluation_interop_KType(Structure): - pass - -libevaluation_interop_KType = struct_libevaluation_interop_KType# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 25 - -# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 29 -class struct_anon_1(Structure): - pass - -struct_anon_1.__slots__ = [ - 'pinned', -] -struct_anon_1._fields_ = [ - ('pinned', libevaluation_interop_KNativePtr), -] - -libevaluation_interop_kref_kotlin_Byte = struct_anon_1# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 29 - -# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 32 -class struct_anon_2(Structure): - pass - -struct_anon_2.__slots__ = [ - 'pinned', -] -struct_anon_2._fields_ = [ - ('pinned', libevaluation_interop_KNativePtr), -] - -libevaluation_interop_kref_kotlin_Short = struct_anon_2# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 32 - -# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 35 -class struct_anon_3(Structure): - pass - -struct_anon_3.__slots__ = [ - 'pinned', -] -struct_anon_3._fields_ = [ - ('pinned', libevaluation_interop_KNativePtr), -] - -libevaluation_interop_kref_kotlin_Int = struct_anon_3# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 35 - -# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 38 -class struct_anon_4(Structure): - pass - -struct_anon_4.__slots__ = [ - 'pinned', -] -struct_anon_4._fields_ = [ - ('pinned', libevaluation_interop_KNativePtr), -] - -libevaluation_interop_kref_kotlin_Long = struct_anon_4# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 38 - -# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 41 -class struct_anon_5(Structure): - pass - -struct_anon_5.__slots__ = [ - 'pinned', -] -struct_anon_5._fields_ = [ - ('pinned', libevaluation_interop_KNativePtr), -] - -libevaluation_interop_kref_kotlin_Float = struct_anon_5# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 41 - -# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 44 -class struct_anon_6(Structure): - pass - -struct_anon_6.__slots__ = [ - 'pinned', -] -struct_anon_6._fields_ = [ - ('pinned', libevaluation_interop_KNativePtr), -] - -libevaluation_interop_kref_kotlin_Double = struct_anon_6# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 44 - -# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 47 -class struct_anon_7(Structure): - pass - -struct_anon_7.__slots__ = [ - 'pinned', -] -struct_anon_7._fields_ = [ - ('pinned', libevaluation_interop_KNativePtr), -] - -libevaluation_interop_kref_kotlin_Char = struct_anon_7# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 47 - -# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 50 -class struct_anon_8(Structure): - pass - -struct_anon_8.__slots__ = [ - 'pinned', -] -struct_anon_8._fields_ = [ - ('pinned', libevaluation_interop_KNativePtr), -] - -libevaluation_interop_kref_kotlin_Boolean = struct_anon_8# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 50 - -# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 53 -class struct_anon_9(Structure): - pass - -struct_anon_9.__slots__ = [ - 'pinned', -] -struct_anon_9._fields_ = [ - ('pinned', libevaluation_interop_KNativePtr), -] - -libevaluation_interop_kref_kotlin_Unit = struct_anon_9# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 53 - -# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 56 -class struct_anon_10(Structure): - pass - -struct_anon_10.__slots__ = [ - 'pinned', -] -struct_anon_10._fields_ = [ - ('pinned', libevaluation_interop_KNativePtr), -] - -libevaluation_interop_kref_kotlin_UByte = struct_anon_10# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 56 - -# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 59 -class struct_anon_11(Structure): - pass - -struct_anon_11.__slots__ = [ - 'pinned', -] -struct_anon_11._fields_ = [ - ('pinned', libevaluation_interop_KNativePtr), -] - -libevaluation_interop_kref_kotlin_UShort = struct_anon_11# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 59 - -# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 62 -class struct_anon_12(Structure): - pass - -struct_anon_12.__slots__ = [ - 'pinned', -] -struct_anon_12._fields_ = [ - ('pinned', libevaluation_interop_KNativePtr), -] - -libevaluation_interop_kref_kotlin_UInt = struct_anon_12# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 62 - -# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 65 -class struct_anon_13(Structure): - pass - -struct_anon_13.__slots__ = [ - 'pinned', -] -struct_anon_13._fields_ = [ - ('pinned', libevaluation_interop_KNativePtr), -] - -libevaluation_interop_kref_kotlin_ULong = struct_anon_13# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 65 - -# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 101 -class struct_anon_14(Structure): - pass - -struct_anon_14.__slots__ = [ - 'evaluate', -] -struct_anon_14._fields_ = [ - # NOTE(bgiori): Changed this line from `UNCHECKED(c_char_p)` to `UNCHECKED(c_void_p)` - # to help fix a memory leak. Strings returned from kotlin/native must - # be freed using the DisposeString function, but c_char_p converts the - # c value making it incompatible with the dispose function. - ('evaluate', CFUNCTYPE(UNCHECKED(c_void_p), String, String)), -] - -# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 100 -class struct_anon_15(Structure): - pass - -struct_anon_15.__slots__ = [ - 'root', -] -struct_anon_15._fields_ = [ - ('root', struct_anon_14), -] - -# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 105 -class struct_anon_16(Structure): - pass - -struct_anon_16.__slots__ = [ - 'DisposeStablePointer', - 'DisposeString', - 'IsInstance', - 'createNullableByte', - 'getNonNullValueOfByte', - 'createNullableShort', - 'getNonNullValueOfShort', - 'createNullableInt', - 'getNonNullValueOfInt', - 'createNullableLong', - 'getNonNullValueOfLong', - 'createNullableFloat', - 'getNonNullValueOfFloat', - 'createNullableDouble', - 'getNonNullValueOfDouble', - 'createNullableChar', - 'getNonNullValueOfChar', - 'createNullableBoolean', - 'getNonNullValueOfBoolean', - 'createNullableUnit', - 'createNullableUByte', - 'getNonNullValueOfUByte', - 'createNullableUShort', - 'getNonNullValueOfUShort', - 'createNullableUInt', - 'getNonNullValueOfUInt', - 'createNullableULong', - 'getNonNullValueOfULong', - 'kotlin', -] -struct_anon_16._fields_ = [ - ('DisposeStablePointer', CFUNCTYPE(UNCHECKED(None), libevaluation_interop_KNativePtr)), - ('DisposeString', CFUNCTYPE(UNCHECKED(None), String)), - ('IsInstance', CFUNCTYPE(UNCHECKED(libevaluation_interop_KBoolean), libevaluation_interop_KNativePtr, POINTER(libevaluation_interop_KType))), - ('createNullableByte', CFUNCTYPE(UNCHECKED(libevaluation_interop_kref_kotlin_Byte), libevaluation_interop_KByte)), - ('getNonNullValueOfByte', CFUNCTYPE(UNCHECKED(libevaluation_interop_KByte), libevaluation_interop_kref_kotlin_Byte)), - ('createNullableShort', CFUNCTYPE(UNCHECKED(libevaluation_interop_kref_kotlin_Short), libevaluation_interop_KShort)), - ('getNonNullValueOfShort', CFUNCTYPE(UNCHECKED(libevaluation_interop_KShort), libevaluation_interop_kref_kotlin_Short)), - ('createNullableInt', CFUNCTYPE(UNCHECKED(libevaluation_interop_kref_kotlin_Int), libevaluation_interop_KInt)), - ('getNonNullValueOfInt', CFUNCTYPE(UNCHECKED(libevaluation_interop_KInt), libevaluation_interop_kref_kotlin_Int)), - ('createNullableLong', CFUNCTYPE(UNCHECKED(libevaluation_interop_kref_kotlin_Long), libevaluation_interop_KLong)), - ('getNonNullValueOfLong', CFUNCTYPE(UNCHECKED(libevaluation_interop_KLong), libevaluation_interop_kref_kotlin_Long)), - ('createNullableFloat', CFUNCTYPE(UNCHECKED(libevaluation_interop_kref_kotlin_Float), libevaluation_interop_KFloat)), - ('getNonNullValueOfFloat', CFUNCTYPE(UNCHECKED(libevaluation_interop_KFloat), libevaluation_interop_kref_kotlin_Float)), - ('createNullableDouble', CFUNCTYPE(UNCHECKED(libevaluation_interop_kref_kotlin_Double), libevaluation_interop_KDouble)), - ('getNonNullValueOfDouble', CFUNCTYPE(UNCHECKED(libevaluation_interop_KDouble), libevaluation_interop_kref_kotlin_Double)), - ('createNullableChar', CFUNCTYPE(UNCHECKED(libevaluation_interop_kref_kotlin_Char), libevaluation_interop_KChar)), - ('getNonNullValueOfChar', CFUNCTYPE(UNCHECKED(libevaluation_interop_KChar), libevaluation_interop_kref_kotlin_Char)), - ('createNullableBoolean', CFUNCTYPE(UNCHECKED(libevaluation_interop_kref_kotlin_Boolean), libevaluation_interop_KBoolean)), - ('getNonNullValueOfBoolean', CFUNCTYPE(UNCHECKED(libevaluation_interop_KBoolean), libevaluation_interop_kref_kotlin_Boolean)), - ('createNullableUnit', CFUNCTYPE(UNCHECKED(libevaluation_interop_kref_kotlin_Unit), )), - ('createNullableUByte', CFUNCTYPE(UNCHECKED(libevaluation_interop_kref_kotlin_UByte), libevaluation_interop_KUByte)), - ('getNonNullValueOfUByte', CFUNCTYPE(UNCHECKED(libevaluation_interop_KUByte), libevaluation_interop_kref_kotlin_UByte)), - ('createNullableUShort', CFUNCTYPE(UNCHECKED(libevaluation_interop_kref_kotlin_UShort), libevaluation_interop_KUShort)), - ('getNonNullValueOfUShort', CFUNCTYPE(UNCHECKED(libevaluation_interop_KUShort), libevaluation_interop_kref_kotlin_UShort)), - ('createNullableUInt', CFUNCTYPE(UNCHECKED(libevaluation_interop_kref_kotlin_UInt), libevaluation_interop_KUInt)), - ('getNonNullValueOfUInt', CFUNCTYPE(UNCHECKED(libevaluation_interop_KUInt), libevaluation_interop_kref_kotlin_UInt)), - ('createNullableULong', CFUNCTYPE(UNCHECKED(libevaluation_interop_kref_kotlin_ULong), libevaluation_interop_KULong)), - ('getNonNullValueOfULong', CFUNCTYPE(UNCHECKED(libevaluation_interop_KULong), libevaluation_interop_kref_kotlin_ULong)), - ('kotlin', struct_anon_15), -] - -libevaluation_interop_ExportedSymbols = struct_anon_16# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 105 - -# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 106 -if _libs["libevaluation_interop"].has("libevaluation_interop_symbols", "cdecl"): - libevaluation_interop_symbols = _libs["libevaluation_interop"].get("libevaluation_interop_symbols", "cdecl") - libevaluation_interop_symbols.argtypes = [] - libevaluation_interop_symbols.restype = POINTER(libevaluation_interop_ExportedSymbols) - -libevaluation_interop_KType = struct_libevaluation_interop_KType# src/amplitude_experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h: 24 - -# No inserted files - -# No prefix-stripping - diff --git a/src/amplitude_experiment/local/topological_sort.py b/src/amplitude_experiment/local/topological_sort.py deleted file mode 100644 index ff4bb8e..0000000 --- a/src/amplitude_experiment/local/topological_sort.py +++ /dev/null @@ -1,57 +0,0 @@ -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/util/flag_config.py b/src/amplitude_experiment/util/flag_config.py index c64ac2c..ae57635 100644 --- a/src/amplitude_experiment/util/flag_config.py +++ b/src/amplitude_experiment/util/flag_config.py @@ -1,48 +1,49 @@ -from typing import List, Dict, Set, Any +from typing import List, Dict, Set from ..cohort.cohort import USER_GROUP_TYPE +from ..evaluation.types import EvaluationFlag, EvaluationSegment, EvaluationCondition, EvaluationOperator -def is_cohort_filter(condition: Dict[str, Any]) -> bool: +def is_cohort_filter(condition: EvaluationCondition) -> bool: return ( - condition['op'] in {"set contains any", "set does not contain any"} - and condition['selector'] - and condition['selector'][-1] == "cohort_ids" + condition.op in {EvaluationOperator.SET_CONTAINS_ANY, EvaluationOperator.SET_DOES_NOT_CONTAIN_ANY} + and condition.selector + and condition.selector[-1] == "cohort_ids" ) -def get_grouped_cohort_condition_ids(segment: Dict[str, Any]) -> Dict[str, Set[str]]: +def get_grouped_cohort_condition_ids(segment: EvaluationSegment) -> Dict[str, Set[str]]: cohort_ids = {} - conditions = segment.get('conditions', []) + conditions = segment.conditions or [] for outer in conditions: for condition in outer: if is_cohort_filter(condition): - if len(condition['selector']) > 2: - context_subtype = condition['selector'][1] + if len(condition.selector) > 2: + context_subtype = condition.selector[1] if context_subtype == "user": group_type = USER_GROUP_TYPE - elif "groups" in condition['selector']: - group_type = condition['selector'][2] + elif "groups" in condition.selector: + group_type = condition.selector[2] else: continue - cohort_ids.setdefault(group_type, set()).update(condition['values']) + cohort_ids.setdefault(group_type, set()).update(condition.values) return cohort_ids -def get_grouped_cohort_ids_from_flag(flag: Dict[str, Any]) -> Dict[str, Set[str]]: +def get_grouped_cohort_ids_from_flag(flag: EvaluationFlag) -> Dict[str, Set[str]]: cohort_ids = {} - segments = flag.get('segments', []) + segments = flag.segments or [] for segment in segments: for key, values in get_grouped_cohort_condition_ids(segment).items(): cohort_ids.setdefault(key, set()).update(values) return cohort_ids -def get_all_cohort_ids_from_flag(flag: Dict[str, Any]) -> Set[str]: +def get_all_cohort_ids_from_flag(flag: EvaluationFlag) -> Set[str]: return {cohort_id for values in get_grouped_cohort_ids_from_flag(flag).values() for cohort_id in values} -def get_grouped_cohort_ids_from_flags(flags: List[Dict[str, Any]]) -> Dict[str, Set[str]]: +def get_grouped_cohort_ids_from_flags(flags: List[EvaluationFlag]) -> Dict[str, Set[str]]: cohort_ids = {} for flag in flags: for key, values in get_grouped_cohort_ids_from_flag(flag).items(): @@ -50,5 +51,5 @@ def get_grouped_cohort_ids_from_flags(flags: List[Dict[str, Any]]) -> Dict[str, return cohort_ids -def get_all_cohort_ids_from_flags(flags: List[Dict[str, Any]]) -> Set[str]: +def get_all_cohort_ids_from_flags(flags: List[EvaluationFlag]) -> Set[str]: return {cohort_id for values in get_grouped_cohort_ids_from_flags(flags).values() for cohort_id in values} diff --git a/tests/evaluation/__init__.py b/tests/evaluation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/evaluation/evaluation_integration_test.py b/tests/evaluation/evaluation_integration_test.py new file mode 100644 index 0000000..0debc2d --- /dev/null +++ b/tests/evaluation/evaluation_integration_test.py @@ -0,0 +1,556 @@ +import unittest +from typing import Dict, Optional, Any, List +import requests + +from src.amplitude_experiment.evaluation.engine import EvaluationEngine +from src.amplitude_experiment.evaluation.types import EvaluationFlag + + +class EvaluationIntegrationTestCase(unittest.TestCase): + """Integration tests for the EvaluationEngine.""" + + @classmethod + def setUpClass(cls): + """Set up test fixtures before running tests.""" + cls.deployment_key = "server-NgJxxvg8OGwwBsWVXqyxQbdiflbhvugy" + cls.engine = EvaluationEngine() + cls.flags = cls.get_flags(cls.deployment_key) + + def test_off(self): + """Test off state.""" + user = self.user_context("user_id", "device_id") + result = self.engine.evaluate(user, self.flags)["test-off"] + self.assertEqual(result.key, "off") + + def test_on(self): + """Test on state.""" + user = self.user_context("user_id", "device_id") + result = self.engine.evaluate(user, self.flags)["test-on"] + self.assertEqual(result.key, "on") + + def test_individual_inclusions_match(self): + """Test individual inclusions matching.""" + # Match user ID + user = self.user_context("user_id") + result = self.engine.evaluate(user, self.flags)["test-individual-inclusions"] + self.assertEqual(result.key, "on") + self.assertEqual(result.metadata.get("segmentName"), "individual-inclusions") + + # Match device ID + user = self.user_context(None, "device_id") + result = self.engine.evaluate(user, self.flags)["test-individual-inclusions"] + self.assertEqual(result.key, "on") + self.assertEqual(result.metadata.get("segmentName"), "individual-inclusions") + + # Doesn't match user ID + user = self.user_context("not_user_id") + result = self.engine.evaluate(user, self.flags)["test-individual-inclusions"] + self.assertEqual(result.key, "off") + + # Doesn't match device ID + user = self.user_context(None, "not_device_id") + result = self.engine.evaluate(user, self.flags)["test-individual-inclusions"] + self.assertEqual(result.key, "off") + + def test_flag_dependencies_on(self): + """Test flag dependencies in on state.""" + user = self.user_context("user_id", "device_id") + result = self.engine.evaluate(user, self.flags)["test-flag-dependencies-on"] + self.assertEqual(result.key, "on") + + def test_flag_dependencies_off(self): + """Test flag dependencies in off state.""" + user = self.user_context("user_id", "device_id") + result = self.engine.evaluate(user, self.flags)["test-flag-dependencies-off"] + self.assertEqual(result.key, "off") + self.assertEqual(result.metadata.get("segmentName"), "flag-dependencies") + + def test_sticky_bucketing(self): + """Test sticky bucketing behavior.""" + # On + user = self.user_context("user_id", "device_id", None, { + "[Experiment] test-sticky-bucketing": "on" + }) + result = self.engine.evaluate(user, self.flags)["test-sticky-bucketing"] + self.assertEqual(result.key, "on") + self.assertEqual(result.metadata.get("segmentName"), "sticky-bucketing") + + # Off + user = self.user_context("user_id", "device_id", None, { + "[Experiment] test-sticky-bucketing": "off" + }) + result = self.engine.evaluate(user, self.flags)["test-sticky-bucketing"] + self.assertEqual(result.key, "off") + self.assertEqual(result.metadata.get("segmentName"), "All Other Users") + + # Non-variant + user = self.user_context("user_id", "device_id", None, { + "[Experiment] test-sticky-bucketing": "not-a-variant" + }) + result = self.engine.evaluate(user, self.flags)["test-sticky-bucketing"] + self.assertEqual(result.key, "off") + self.assertEqual(result.metadata.get("segmentName"), "All Other Users") + + def test_experiment(self): + """Test experiment behavior.""" + user = self.user_context("user_id", "device_id") + result = self.engine.evaluate(user, self.flags)["test-experiment"] + self.assertEqual(result.key, "on") + self.assertEqual(result.metadata.get("experimentKey"), "exp-1") + + def test_flag(self): + """Test flag behavior.""" + user = self.user_context("user_id", "device_id") + result = self.engine.evaluate(user, self.flags)["test-flag"] + self.assertEqual(result.key, "on") + self.assertIsNone(result.metadata.get("experimentKey")) + + def test_multiple_conditions_and_values(self): + """Test multiple conditions and values.""" + # All match + user = self.user_context("user_id", "device_id", None, { + "key-1": "value-1", + "key-2": "value-2", + "key-3": "value-3" + }) + result = self.engine.evaluate(user, self.flags)["test-multiple-conditions-and-values"] + self.assertEqual(result.key, "on") + + # Some match + user = self.user_context("user_id", "device_id", None, { + "key-1": "value-1", + "key-2": "value-2" + }) + result = self.engine.evaluate(user, self.flags)["test-multiple-conditions-and-values"] + self.assertEqual(result.key, "off") + + def test_amplitude_property_targeting(self): + """Test amplitude property targeting.""" + user = self.user_context("user_id") + result = self.engine.evaluate(user, self.flags)["test-amplitude-property-targeting"] + self.assertEqual(result.key, "on") + + def test_cohort_targeting(self): + """Test cohort targeting.""" + user = self.user_context(None, None, None, None, ["u0qtvwla", "12345678"]) + result = self.engine.evaluate(user, self.flags)["test-cohort-targeting"] + self.assertEqual(result.key, "on") + + user = self.user_context(None, None, None, None, ["12345678", "87654321"]) + result = self.engine.evaluate(user, self.flags)["test-cohort-targeting"] + self.assertEqual(result.key, "off") + + def test_group_name_targeting(self): + """Test group name targeting.""" + user = self.group_context("org name", "amplitude") + result = self.engine.evaluate(user, self.flags)["test-group-name-targeting"] + self.assertEqual(result.key, "on") + + def test_group_property_targeting(self): + """Test group property targeting.""" + user = self.group_context("org name", "amplitude", { + "org plan": "enterprise2" + }) + result = self.engine.evaluate(user, self.flags)["test-group-property-targeting"] + self.assertEqual(result.key, "on") + + def test_amplitude_id_bucketing(self): + """Test amplitude ID bucketing.""" + user = self.user_context(None, None, "1234567890") + result = self.engine.evaluate(user, self.flags)["test-amplitude-id-bucketing"] + self.assertEqual(result.key, "on") + + def test_user_id_bucketing(self): + """Test user ID bucketing.""" + user = self.user_context("user_id") + result = self.engine.evaluate(user, self.flags)["test-user-id-bucketing"] + self.assertEqual(result.key, "on") + + def test_device_id_bucketing(self): + """Test device ID bucketing.""" + user = self.user_context(None, "device_id") + result = self.engine.evaluate(user, self.flags)["test-device-id-bucketing"] + self.assertEqual(result.key, "on") + + def test_custom_user_property_bucketing(self): + """Test custom user property bucketing.""" + user = self.user_context(None, None, None, {"key": "value"}) + result = self.engine.evaluate(user, self.flags)["test-custom-user-property-bucketing"] + self.assertEqual(result.key, "on") + + def test_group_name_bucketing(self): + """Test group name bucketing.""" + user = self.group_context("org name", "amplitude") + result = self.engine.evaluate(user, self.flags)["test-group-name-bucketing"] + self.assertEqual(result.key, "on") + + def test_group_property_bucketing(self): + """Test group property bucketing.""" + user = self.group_context("org name", "amplitude", { + "org plan": "enterprise2" + }) + result = self.engine.evaluate(user, self.flags)["test-group-name-bucketing"] + self.assertEqual(result.key, "on") + + def test_1_percent_allocation(self): + """Test 1% allocation.""" + on_count = 0 + for i in range(10000): + user = self.user_context(None, str(i + 1)) + result = self.engine.evaluate(user, self.flags)["test-1-percent-allocation"] + if result.key == "on": + on_count += 1 + self.assertEqual(on_count, 107) + + def test_50_percent_allocation(self): + """Test 50% allocation.""" + on_count = 0 + for i in range(10000): + user = self.user_context(None, str(i + 1)) + result = self.engine.evaluate(user, self.flags)["test-50-percent-allocation"] + if result.key == "on": + on_count += 1 + self.assertEqual(on_count, 5009) + + def test_99_percent_allocation(self): + """Test 99% allocation.""" + on_count = 0 + for i in range(10000): + user = self.user_context(None, str(i + 1)) + result = self.engine.evaluate(user, self.flags)["test-99-percent-allocation"] + if result.key == "on": + on_count += 1 + self.assertEqual(on_count, 9900) + + def test_amplitude_id_bucketing(self): + """Test amplitude ID bucketing.""" + user = self.user_context(None, None, "1234567890") + result = self.engine.evaluate(user, self.flags)["test-amplitude-id-bucketing"] + self.assertEqual(result.key, "on") + + def test_user_id_bucketing(self): + """Test user ID bucketing.""" + user = self.user_context("user_id") + result = self.engine.evaluate(user, self.flags)["test-user-id-bucketing"] + self.assertEqual(result.key, "on") + + def test_device_id_bucketing(self): + """Test device ID bucketing.""" + user = self.user_context(None, "device_id") + result = self.engine.evaluate(user, self.flags)["test-device-id-bucketing"] + self.assertEqual(result.key, "on") + + def test_custom_user_property_bucketing(self): + """Test custom user property bucketing.""" + user = self.user_context(None, None, None, {"key": "value"}) + result = self.engine.evaluate(user, self.flags)["test-custom-user-property-bucketing"] + self.assertEqual(result.key, "on") + + def test_group_name_bucketing(self): + """Test group name bucketing.""" + user = self.group_context("org name", "amplitude") + result = self.engine.evaluate(user, self.flags)["test-group-name-bucketing"] + self.assertEqual(result.key, "on") + + def test_group_property_bucketing(self): + """Test group property bucketing.""" + user = self.group_context("org name", "amplitude", { + "org plan": "enterprise2" + }) + result = self.engine.evaluate(user, self.flags)["test-group-name-bucketing"] + self.assertEqual(result.key, "on") + + def test_1_percent_allocation(self): + """Test 1% allocation.""" + on_count = 0 + for i in range(10000): + user = self.user_context(None, str(i + 1)) + result = self.engine.evaluate(user, self.flags)["test-1-percent-allocation"] + if result.key == "on": + on_count += 1 + self.assertEqual(on_count, 107) + + def test_50_percent_allocation(self): + """Test 50% allocation.""" + on_count = 0 + for i in range(10000): + user = self.user_context(None, str(i + 1)) + result = self.engine.evaluate(user, self.flags)["test-50-percent-allocation"] + if result.key == "on": + on_count += 1 + self.assertEqual(on_count, 5009) + + def test_99_percent_allocation(self): + """Test 99% allocation.""" + on_count = 0 + for i in range(10000): + user = self.user_context(None, str(i + 1)) + result = self.engine.evaluate(user, self.flags)["test-99-percent-allocation"] + if result.key == "on": + on_count += 1 + self.assertEqual(on_count, 9900) + + def test_1_percent_distribution(self): + """Test 1% distribution.""" + control_count = 0 + treatment_count = 0 + for i in range(10000): + user = self.user_context(None, str(i + 1)) + result = self.engine.evaluate(user, self.flags)["test-1-percent-distribution"] + if result.key == "control": + control_count += 1 + elif result.key == "treatment": + treatment_count += 1 + self.assertEqual(control_count, 106) + self.assertEqual(treatment_count, 9894) + + def test_50_percent_distribution(self): + """Test 50% distribution.""" + control_count = 0 + treatment_count = 0 + for i in range(10000): + user = self.user_context(None, str(i + 1)) + result = self.engine.evaluate(user, self.flags)["test-50-percent-distribution"] + if result.key == "control": + control_count += 1 + elif result.key == "treatment": + treatment_count += 1 + self.assertEqual(control_count, 4990) + self.assertEqual(treatment_count, 5010) + + def test_99_percent_distribution(self): + """Test 99% distribution.""" + control_count = 0 + treatment_count = 0 + for i in range(10000): + user = self.user_context(None, str(i + 1)) + result = self.engine.evaluate(user, self.flags)["test-99-percent-distribution"] + if result.key == "control": + control_count += 1 + elif result.key == "treatment": + treatment_count += 1 + self.assertEqual(control_count, 9909) + self.assertEqual(treatment_count, 91) + + def test_multiple_distributions(self): + """Test multiple distributions.""" + a_count = 0 + b_count = 0 + c_count = 0 + d_count = 0 + for i in range(10000): + user = self.user_context(None, str(i + 1)) + result = self.engine.evaluate(user, self.flags)["test-multiple-distributions"] + if result.key == "a": + a_count += 1 + elif result.key == "b": + b_count += 1 + elif result.key == "c": + c_count += 1 + elif result.key == "d": + d_count += 1 + self.assertEqual(a_count, 2444) + self.assertEqual(b_count, 2634) + self.assertEqual(c_count, 2447) + self.assertEqual(d_count, 2475) + + def test_is(self): + """Test 'is' operator.""" + user = self.user_context(None, None, None, {"key": "value"}) + result = self.engine.evaluate(user, self.flags)["test-is"] + self.assertEqual(result.key, "on") + + def test_is_not(self): + """Test 'is not' operator.""" + user = self.user_context(None, None, None, {"key": "value"}) + result = self.engine.evaluate(user, self.flags)["test-is-not"] + self.assertEqual(result.key, "on") + + def test_contains(self): + """Test 'contains' operator.""" + user = self.user_context(None, None, None, {"key": "value"}) + result = self.engine.evaluate(user, self.flags)["test-contains"] + self.assertEqual(result.key, "on") + + def test_does_not_contain(self): + """Test 'does not contain' operator.""" + user = self.user_context(None, None, None, {"key": "value"}) + result = self.engine.evaluate(user, self.flags)["test-does-not-contain"] + self.assertEqual(result.key, "on") + + def test_less(self): + """Test 'less than' operator.""" + user = self.user_context(None, None, None, {"key": "-1"}) + result = self.engine.evaluate(user, self.flags)["test-less"] + self.assertEqual(result.key, "on") + + def test_less_or_equal(self): + """Test 'less than or equal' operator.""" + user = self.user_context(None, None, None, {"key": "0"}) + result = self.engine.evaluate(user, self.flags)["test-less-or-equal"] + self.assertEqual(result.key, "on") + + def test_greater(self): + """Test 'greater than' operator.""" + user = self.user_context(None, None, None, {"key": "1"}) + result = self.engine.evaluate(user, self.flags)["test-greater"] + self.assertEqual(result.key, "on") + + def test_greater_or_equal(self): + """Test 'greater than or equal' operator.""" + user = self.user_context(None, None, None, {"key": "0"}) + result = self.engine.evaluate(user, self.flags)["test-greater-or-equal"] + self.assertEqual(result.key, "on") + + def test_version_less(self): + """Test version 'less than' operator.""" + user = self.freeform_user_context({"version": "1.9.0"}) + result = self.engine.evaluate(user, self.flags)["test-version-less"] + self.assertEqual(result.key, "on") + + def test_version_less_or_equal(self): + """Test version 'less than or equal' operator.""" + user = self.freeform_user_context({"version": "1.10.0"}) + result = self.engine.evaluate(user, self.flags)["test-version-less-or-equal"] + self.assertEqual(result.key, "on") + + def test_version_greater(self): + """Test version 'greater than' operator.""" + user = self.freeform_user_context({"version": "1.10.0"}) + result = self.engine.evaluate(user, self.flags)["test-version-greater"] + self.assertEqual(result.key, "on") + + def test_version_greater_or_equal(self): + """Test version 'greater than or equal' operator.""" + user = self.freeform_user_context({"version": "1.9.0"}) + result = self.engine.evaluate(user, self.flags)["test-version-greater-or-equal"] + self.assertEqual(result.key, "on") + + def test_set_is(self): + """Test 'set is' operator.""" + user = self.user_context(None, None, None, {"key": ["1", "2", "3"]}) + result = self.engine.evaluate(user, self.flags)["test-set-is"] + self.assertEqual(result.key, "on") + + def test_set_is_not(self): + """Test 'set is not' operator.""" + user = self.user_context(None, None, None, {"key": ["1", "2"]}) + result = self.engine.evaluate(user, self.flags)["test-set-is-not"] + self.assertEqual(result.key, "on") + + def test_set_contains(self): + """Test 'set contains' operator.""" + user = self.user_context(None, None, None, {"key": ["1", "2", "3", "4"]}) + result = self.engine.evaluate(user, self.flags)["test-set-contains"] + self.assertEqual(result.key, "on") + + def test_set_does_not_contain(self): + """Test 'set does not contain' operator.""" + user = self.user_context(None, None, None, {"key": ["1", "2", "4"]}) + result = self.engine.evaluate(user, self.flags)["test-set-does-not-contain"] + self.assertEqual(result.key, "on") + + def test_set_contains_any(self): + """Test 'set contains any' operator.""" + user = self.user_context(None, None, None, None, ["u0qtvwla", "12345678"]) + result = self.engine.evaluate(user, self.flags)["test-set-contains-any"] + self.assertEqual(result.key, "on") + + def test_set_does_not_contain_any(self): + """Test 'set does not contain any' operator.""" + user = self.user_context(None, None, None, None, ["12345678", "87654321"]) + result = self.engine.evaluate(user, self.flags)["test-set-does-not-contain-any"] + self.assertEqual(result.key, "on") + + def test_glob_match(self): + """Test glob match operator.""" + user = self.user_context(None, None, None, {"key": "/path/1/2/3/end"}) + result = self.engine.evaluate(user, self.flags)["test-glob-match"] + self.assertEqual(result.key, "on") + + def test_glob_does_not_match(self): + """Test glob does not match operator.""" + user = self.user_context(None, None, None, {"key": "/path/1/2/3"}) + result = self.engine.evaluate(user, self.flags)["test-glob-does-not-match"] + self.assertEqual(result.key, "on") + + def test_is_with_booleans(self): + """Test 'is' operator with boolean values.""" + user = self.user_context(None, None, None, { + "true": "TRUE", + "false": "FALSE" + }) + result = self.engine.evaluate(user, self.flags)["test-is-with-booleans"] + self.assertEqual(result.key, "on") + + user = self.user_context(None, None, None, { + "true": "True", + "false": "False" + }) + result = self.engine.evaluate(user, self.flags)["test-is-with-booleans"] + self.assertEqual(result.key, "on") + + user = self.user_context(None, None, None, { + "true": "true", + "false": "false" + }) + result = self.engine.evaluate(user, self.flags)["test-is-with-booleans"] + self.assertEqual(result.key, "on") + + @staticmethod + def freeform_user_context(user: Dict[str, Any]) -> Dict[str, Any]: + """Create a freeform user context dictionary.""" + return {"user": user} + + @staticmethod + def user_context( + user_id: Optional[str] = None, + device_id: Optional[str] = None, + amplitude_id: Optional[str] = None, + user_properties: Optional[Dict[str, Any]] = None, + cohort_ids: Optional[List[str]] = None + ) -> Dict[str, Any]: + """Create a user context dictionary.""" + return { + "user": { + "user_id": user_id, + "device_id": device_id, + "amplitude_id": amplitude_id, + "user_properties": user_properties, + "cohort_ids": cohort_ids + } + } + + @staticmethod + def group_context( + group_type: str, + group_name: str, + group_properties: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """Create a group context dictionary.""" + return { + "groups": { + group_type: { + "group_name": group_name, + "group_properties": group_properties + } + } + } + + @staticmethod + def get_flags(deployment_key: str) -> List[EvaluationFlag]: + """Fetch flags from the server and convert to EvaluationFlag objects.""" + server_url = "https://api.lab.amplitude.com" + response = requests.get( + f"{server_url}/sdk/v2/flags?eval_mode=remote", + headers={"Authorization": f"Api-Key {deployment_key}"} + ) + + if response.status_code != 200: + raise Exception(f"Response error {response.status_code}") + + return EvaluationFlag.schema().load(response.json(), many=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/evaluation/selector_test.py b/tests/evaluation/selector_test.py new file mode 100644 index 0000000..d447392 --- /dev/null +++ b/tests/evaluation/selector_test.py @@ -0,0 +1,44 @@ +import unittest +from src.amplitude_experiment.evaluation.select import select + + +class SelectorTestCase(unittest.TestCase): + """Test cases for selector functionality.""" + + def setUp(self): + """Set up test fixtures before each test method.""" + self.primitive_object = { + "null": None, + "string": "value", + "number": 13, + "boolean": True + } + self.nested_object = { + **self.primitive_object, + "object": self.primitive_object + } + + def test_selector_evaluation_context_types(self): + """Test selector evaluation with different context types.""" + context = self.nested_object + + # Test non-existent path + self.assertIsNone(select(context, ["does", "not", "exist"])) + + # Test root level selections + self.assertIsNone(select(context, ["null"])) + self.assertEqual(select(context, ["string"]), "value") + self.assertEqual(select(context, ["number"]), 13) + self.assertEqual(select(context, ["boolean"]), True) + self.assertEqual(select(context, ["object"]), self.primitive_object) + + # Test nested selections + self.assertIsNone(select(context, ["object", "does", "not", "exist"])) + self.assertIsNone(select(context, ["object", "null"])) + self.assertEqual(select(context, ["object", "string"]), "value") + self.assertEqual(select(context, ["object", "number"]), 13) + self.assertEqual(select(context, ["object", "boolean"]), True) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/evaluation/semantic_version_test.py b/tests/evaluation/semantic_version_test.py new file mode 100644 index 0000000..37f42c8 --- /dev/null +++ b/tests/evaluation/semantic_version_test.py @@ -0,0 +1,121 @@ +import unittest +from src.amplitude_experiment.evaluation.semantic_version import SemanticVersion +from src.amplitude_experiment.evaluation.types import EvaluationOperator + +class SemanticVersionTestCase(unittest.TestCase): + def test_invalid_versions(self): + # Just major + self.assert_invalid_version("10") + + # Trailing dots + self.assert_invalid_version("10.") + self.assert_invalid_version("10..") + self.assert_invalid_version("10.2.") + self.assert_invalid_version("10.2.33.") + # Note: Trailing dots on prerelease tags are not handled because prerelease tags + # are considered strings anyway for comparison + + # Dots in the middle + self.assert_invalid_version("10..2.33") + self.assert_invalid_version("102...33") + + # Invalid characters + self.assert_invalid_version("a.2.3") + self.assert_invalid_version("23!") + self.assert_invalid_version("23.#5") + self.assert_invalid_version("") + self.assert_invalid_version(None) + + # More numbers + self.assert_invalid_version("2.3.4.567") + self.assert_invalid_version("2.3.4.5.6.7") + + # Prerelease if provided should always have major, minor, patch + self.assert_invalid_version("10.2.alpha") + self.assert_invalid_version("10.alpha") + self.assert_invalid_version("alpha-1.2.3") + + # Prerelease should be separated by a hyphen after patch + self.assert_invalid_version("10.2.3alpha") + self.assert_invalid_version("10.2.3alpha-1.2.3") + + # Negative numbers + self.assert_invalid_version("-10.1") + self.assert_invalid_version("10.-1") + + def test_valid_versions(self): + self.assert_valid_version("100.2") + self.assert_valid_version("0.102.39") + self.assert_valid_version("0.0.0") + + # Versions with leading 0s would be converted to int + self.assert_valid_version("01.02") + self.assert_valid_version("001.001100.000900") + + # Prerelease tags + self.assert_valid_version("10.20.30-alpha") + self.assert_valid_version("10.20.30-1.x.y") + self.assert_valid_version("10.20.30-aslkjd") + self.assert_valid_version("10.20.30-b894") + self.assert_valid_version("10.20.30-b8c9") + + def test_version_comparison(self): + # EQUALS case + self.assert_version_comparison("66.12.23", EvaluationOperator.IS, "66.12.23") + # Patch if not specified equals 0 + self.assert_version_comparison("5.6", EvaluationOperator.IS, "5.6.0") + # Leading 0s are not stored when parsed + self.assert_version_comparison("06.007.0008", EvaluationOperator.IS, "6.7.8") + # With pre-release + self.assert_version_comparison("1.23.4-b-1.x.y", EvaluationOperator.IS, "1.23.4-b-1.x.y") + + # DOES NOT EQUAL case + self.assert_version_comparison("1.23.4-alpha-1.2", EvaluationOperator.IS_NOT, "1.23.4-alpha-1") + # Trailing 0s aren't stripped + self.assert_version_comparison("1.2.300", EvaluationOperator.IS_NOT, "1.2.3") + self.assert_version_comparison("1.20.3", EvaluationOperator.IS_NOT, "1.2.3") + + # LESS THAN case + # Patch of .1 makes it greater + self.assert_version_comparison("50.2", EvaluationOperator.VERSION_LESS_THAN, "50.2.1") + # Minor 9 > minor 20 + self.assert_version_comparison("20.9", EvaluationOperator.VERSION_LESS_THAN, "20.20") + # Same version with pre-release should be lesser + self.assert_version_comparison("20.9.4-alpha1", EvaluationOperator.VERSION_LESS_THAN, "20.9.4") + # Compare prerelease as strings + self.assert_version_comparison("20.9.4-a-1.2.3", EvaluationOperator.VERSION_LESS_THAN, "20.9.4-a-1.3") + # Since prerelease is compared as strings a1.23 < a1.5 because 2 < 5 + self.assert_version_comparison("20.9.4-a1.23", EvaluationOperator.VERSION_LESS_THAN, "20.9.4-a1.5") + + # GREATER THAN case + self.assert_version_comparison("12.30.2", EvaluationOperator.VERSION_GREATER_THAN, "12.4.1") + # 100 > 1 + self.assert_version_comparison("7.100", EvaluationOperator.VERSION_GREATER_THAN, "7.1") + # 10 > 9 + self.assert_version_comparison("7.10", EvaluationOperator.VERSION_GREATER_THAN, "7.9") + # Converts to 7.10.20 > 7.9.1 + self.assert_version_comparison("07.010.0020", EvaluationOperator.VERSION_GREATER_THAN, "7.009.1") + # Patch comparison comes first + self.assert_version_comparison("20.5.6-b1.2.x", EvaluationOperator.VERSION_GREATER_THAN, "20.5.5") + + def assert_invalid_version(self, version: str): + assert SemanticVersion.parse(version) is None + + def assert_valid_version(self, version: str): + assert SemanticVersion.parse(version) is not None + + def assert_version_comparison(self, v1: str, op: EvaluationOperator, v2: str): + sv1 = SemanticVersion.parse(v1) + sv2 = SemanticVersion.parse(v2) + + assert sv1 is not None + assert sv2 is not None + + if op == EvaluationOperator.IS: + assert sv1.compare_to(sv2) == 0 + elif op == EvaluationOperator.IS_NOT: + assert sv1.compare_to(sv2) != 0 + elif op == EvaluationOperator.VERSION_LESS_THAN: + assert sv1.compare_to(sv2) < 0 + elif op == EvaluationOperator.VERSION_GREATER_THAN: + assert sv1.compare_to(sv2) > 0 diff --git a/tests/evaluation/topological_sort_test.py b/tests/evaluation/topological_sort_test.py new file mode 100644 index 0000000..94442ed --- /dev/null +++ b/tests/evaluation/topological_sort_test.py @@ -0,0 +1,244 @@ +import unittest +from typing import Dict, List, Optional + +from src.amplitude_experiment.evaluation.types import EvaluationFlag +from src.amplitude_experiment.evaluation.topological_sort import topological_sort, CycleException + + +class TopologicalSortTestCase(unittest.TestCase): + def test_empty(self): + # No flag keys + flags: Dict[str, EvaluationFlag] = {} + result = topological_sort(flags) + self.assertEqual(result, []) + + # With flag keys + flags = {} + result = topological_sort(flags, ["1"]) + self.assertEqual(result, []) + + def test_single_flag_no_dependencies(self): + # No flag keys + flags = {self._create_flag(1).key: self._create_flag(1)} + result = topological_sort(flags) + self.assertEqual(result, [self._create_flag(1)]) + + # With flag keys + flags = {self._create_flag(1).key: self._create_flag(1)} + result = topological_sort(flags, ["1"]) + self.assertEqual(result, [self._create_flag(1)]) + + # With flag keys, no match + flags = {self._create_flag(1).key: self._create_flag(1)} + result = topological_sort(flags, ["999"]) + self.assertEqual(result, []) + + def test_single_flag_with_dependencies(self): + # No flag keys + flags = {self._create_flag(1, [2]).key: self._create_flag(1, [2])} + result = topological_sort(flags) + self.assertEqual(result, [self._create_flag(1, [2])]) + + # With flag keys + flags = {self._create_flag(1, [2]).key: self._create_flag(1, [2])} + result = topological_sort(flags, ["1"]) + self.assertEqual(result, [self._create_flag(1, [2])]) + + # With flag keys, no match + flags = {self._create_flag(1, [2]).key: self._create_flag(1, [2])} + result = topological_sort(flags, ["999"]) + self.assertEqual(result, []) + + def test_multiple_flags_no_dependencies(self): + # No flag keys + flags = { + f.key: f for f in [self._create_flag(1), self._create_flag(2)] + } + result = topological_sort(flags) + self.assertEqual( + result, + [self._create_flag(1), self._create_flag(2)] + ) + + # With flag keys + flags = { + f.key: f for f in [self._create_flag(1), self._create_flag(2)] + } + result = topological_sort(flags, ["1", "2"]) + self.assertEqual( + result, + [self._create_flag(1), self._create_flag(2)] + ) + + # With flag keys, no match + flags = { + f.key: f for f in [self._create_flag(1), self._create_flag(2)] + } + result = topological_sort(flags, ["99", "999"]) + self.assertEqual(result, []) + + def test_multiple_flags_with_dependencies(self): + # No flag keys + flags = { + f.key: f for f in [ + self._create_flag(1, [2]), + self._create_flag(2, [3]), + self._create_flag(3) + ] + } + result = topological_sort(flags) + self.assertEqual( + result, + [ + self._create_flag(3), + self._create_flag(2, [3]), + self._create_flag(1, [2]) + ] + ) + + # With flag keys + flags = { + f.key: f for f in [ + self._create_flag(1, [2]), + self._create_flag(2, [3]), + self._create_flag(3) + ] + } + result = topological_sort(flags, ["1", "2"]) + self.assertEqual( + result, + [ + self._create_flag(3), + self._create_flag(2, [3]), + self._create_flag(1, [2]) + ] + ) + + # With flag keys, no match + flags = { + f.key: f for f in [ + self._create_flag(1, [2]), + self._create_flag(2, [3]), + self._create_flag(3) + ] + } + result = topological_sort(flags, ["99", "999"]) + self.assertEqual(result, []) + + def test_single_flag_cycle(self): + # No flag keys + flags = {self._create_flag(1, [1]).key: self._create_flag(1, [1])} + with self.assertRaisesRegex(CycleException, "Detected a cycle between flags \\['1'\\]"): + topological_sort(flags) + + # With flag keys + flags = {self._create_flag(1, [1]).key: self._create_flag(1, [1])} + with self.assertRaisesRegex(CycleException, "Detected a cycle between flags \\['1'\\]"): + topological_sort(flags, ["1"]) + + # With flag keys, no match + flags = {self._create_flag(1, [1]).key: self._create_flag(1, [1])} + result = topological_sort(flags, ["999"]) + self.assertEqual(result, []) + + def test_two_flag_cycle(self): + # No flag keys + flags = { + f.key: f for f in [ + self._create_flag(1, [2]), + self._create_flag(2, [1]) + ] + } + with self.assertRaisesRegex(CycleException, "Detected a cycle between flags \\['1', '2'\\]"): + topological_sort(flags) + + # With flag keys + flags = { + f.key: f for f in [ + self._create_flag(1, [2]), + self._create_flag(2, [1]) + ] + } + with self.assertRaisesRegex(CycleException, "Detected a cycle between flags \\['2', '1'\\]"): + topological_sort(flags, ["2"]) + + # With flag keys, no match + flags = { + f.key: f for f in [ + self._create_flag(1, [2]), + self._create_flag(2, [1]) + ] + } + result = topological_sort(flags, ["999"]) + self.assertEqual(result, []) + + def test_multiple_flags_complex_cycle(self): + flags = { + f.key: f for f in [ + self._create_flag(3, [1, 2]), + self._create_flag(1), + self._create_flag(4, [21, 3]), + self._create_flag(2), + self._create_flag(5, [3]), + self._create_flag(6), + self._create_flag(7), + self._create_flag(8, [9]), + self._create_flag(9), + self._create_flag(20, [4]), + self._create_flag(21, [20]) + ] + } + with self.assertRaisesRegex(CycleException, "Detected a cycle between flags \\['4', '21', '20'\\]"): + topological_sort(flags) + + def test_complex_no_cycle_starting_with_leaf(self): + flags = { + f.key: f for f in [ + self._create_flag(1, [6, 3]), + self._create_flag(2, [8, 5, 3, 1]), + self._create_flag(3, [6, 5]), + self._create_flag(4, [8, 7]), + self._create_flag(5, [10, 7]), + self._create_flag(7, [8]), + self._create_flag(6, [7, 4]), + self._create_flag(8), + self._create_flag(9, [10, 7, 5]), + self._create_flag(10, [7]), + self._create_flag(20), + self._create_flag(21, [20]), + self._create_flag(30) + ] + } + result = topological_sort(flags) + self.assertEqual( + result, + [ + self._create_flag(8), + self._create_flag(7, [8]), + self._create_flag(4, [8, 7]), + self._create_flag(6, [7, 4]), + self._create_flag(10, [7]), + self._create_flag(5, [10, 7]), + self._create_flag(3, [6, 5]), + self._create_flag(1, [6, 3]), + self._create_flag(2, [8, 5, 3, 1]), + self._create_flag(9, [10, 7, 5]), + self._create_flag(20), + self._create_flag(21, [20]), + self._create_flag(30) + ] + ) + + @staticmethod + def _create_flag(key: int, dependencies: Optional[List[int]] = None) -> EvaluationFlag: + """Helper method to create a flag with given key and dependencies.""" + return EvaluationFlag( + key=str(key), + variants={}, + segments=[], + dependencies=[str(d) for d in dependencies] if dependencies else None + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/local/topological_sort_test.py b/tests/local/topological_sort_test.py deleted file mode 100644 index b2a4a45..0000000 --- a/tests/local/topological_sort_test.py +++ /dev/null @@ -1,243 +0,0 @@ -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/util/flag_config_test.py b/tests/util/flag_config_test.py index 0b16ab9..0a04ac6 100644 --- a/tests/util/flag_config_test.py +++ b/tests/util/flag_config_test.py @@ -1,5 +1,7 @@ +import json import unittest +from src.amplitude_experiment.evaluation.types import EvaluationFlag from src.amplitude_experiment.util.flag_config import ( get_all_cohort_ids_from_flags, get_grouped_cohort_ids_from_flags, @@ -11,7 +13,7 @@ class CohortUtilsTestCase(unittest.TestCase): def setUp(self): - self.flags = [ + flags_dict = [ { 'key': 'flag-1', 'metadata': { @@ -127,6 +129,7 @@ def setUp(self): } } ] + self.flags = EvaluationFlag.schema().load(flags_dict, many=True) def test_get_all_cohort_ids(self): expected_cohort_ids = {'cohort1', 'cohort2', 'cohort3', 'cohort4', 'cohort5', 'cohort6', 'cohort7', 'cohort8'}