diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index d4eab45..cc1abe9 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -14,10 +14,11 @@ on: jobs: test: - runs-on: ubuntu-20.04 + runs-on: ${{ matrix.os }} strategy: matrix: - python-version: ["2.7", "3.5", "3.6", "3.7", "3.8", "3.9", "3.10", "3.11"] + python-version: ["2.7", "3.5", "3.6", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] + os: [ windows-latest, macos-latest, ubuntu-20.04 ] steps: - uses: actions/checkout@v3 @@ -36,7 +37,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install pytest pytest-cov mock flake8 + pip install pytest pytest-cov parameterized mock flake8 pip install -r requirements.txt - name: Lint with flake8 diff --git a/configcatclient/config.py b/configcatclient/config.py new file mode 100644 index 0000000..68eb08e --- /dev/null +++ b/configcatclient/config.py @@ -0,0 +1,293 @@ +from enum import IntEnum + +CONFIG_FILE_NAME = 'config_v6' +SERIALIZATION_FORMAT_VERSION = 'v2' + +# Config +PREFERENCES = 'p' +SEGMENTS = 's' +FEATURE_FLAGS = 'f' + +# Preferences +BASE_URL = 'u' +REDIRECT = 'r' +SALT = 's' + +# Segment +SEGMENT_NAME = 'n' # The first 4 characters of the Segment's name +SEGMENT_CONDITIONS = 'r' # The list of segment rule conditions (has a logical AND relation between the items). + +# Segment Condition (User Condition) +COMPARISON_ATTRIBUTE = 'a' # The attribute of the user object that should be used to evaluate this rule +COMPARATOR = 'c' + +# Feature flag (Evaluation Formula) +SETTING_TYPE = 't' # 0 = bool, 1 = string, 2 = int, 3 = double +PERCENTAGE_RULE_ATTRIBUTE = 'a' # Percentage rule evaluation hashes this attribute of the User object to calculate the buckets +TARGETING_RULES = 'r' # Targeting Rules (Logically connected by OR) +PERCENTAGE_OPTIONS = 'p' # Percentage Options without conditions +VALUE = 'v' +VARIATION_ID = 'i' +INLINE_SALT = 'inline_salt' + +# Targeting Rule (Evaluation Rule) +CONDITIONS = 'c' +SERVED_VALUE = 's' # Value and Variation ID +TARGETING_RULE_PERCENTAGE_OPTIONS = 'p' + +# Condition +USER_CONDITION = 'u' +SEGMENT_CONDITION = 's' # Segment targeting rule +PREREQUISITE_FLAG_CONDITION = 'p' # Prerequisite flag targeting rule + +# Segment Condition +SEGMENT_INDEX = 's' +SEGMENT_COMPARATOR = 'c' +INLINE_SEGMENT = 'inline_segment' + +# Prerequisite Flag Condition +PREREQUISITE_FLAG_KEY = 'f' +PREREQUISITE_COMPARATOR = 'c' + +# Percentage Option +PERCENTAGE = 'p' + +# Value +BOOL_VALUE = 'b' +STRING_VALUE = 's' +INT_VALUE = 'i' +DOUBLE_VALUE = 'd' +STRING_LIST_VALUE = 'l' +UNSUPPORTED_VALUE = 'unsupported_value' + + +def get_value(dictionary, setting_type): + value_descriptor = dictionary.get(VALUE) + if value_descriptor is None: + raise ValueError('Value is missing') + + if setting_type not in list(map(int, SettingType)): + raise ValueError('Unsupported setting type') + + expected_value_type, expected_py_type = SettingType.get_type_info(setting_type) + if expected_value_type is None: + raise ValueError('Unsupported setting type') + + value = value_descriptor.get(expected_value_type) + if value is None: + raise ValueError('Setting value is not of the expected type %s' % expected_py_type) + + return value + + +def get_value_type(dictionary): + value = dictionary.get(VALUE) + if value is not None: + if value.get(BOOL_VALUE) is not None: + return bool + if value.get(STRING_VALUE) is not None: + return str + if value.get(INT_VALUE) is not None: + return int + if value.get(DOUBLE_VALUE) is not None: + return float + + return None + + +class SettingType(IntEnum): + BOOL = 0 + STRING = 1 + INT = 2 + DOUBLE = 3 + + @staticmethod + def get_type_info(setting_type): + return setting_type_mapping.get(setting_type, (None, None)) + + @staticmethod + def from_type(object_type): + if object_type is bool: + return SettingType.BOOL + if object_type is str: + return SettingType.STRING + if object_type is int: + return SettingType.INT + if object_type is float: + return SettingType.DOUBLE + + return None + + @staticmethod + def to_type(setting_type): + return SettingType.get_type_info(setting_type)[1] + + @staticmethod + def to_value_type(setting_type): + return SettingType.get_type_info(setting_type)[0] + + +setting_type_mapping = { + SettingType.BOOL: (BOOL_VALUE, bool), + SettingType.STRING: (STRING_VALUE, str), + SettingType.INT: (INT_VALUE, int), + SettingType.DOUBLE: (DOUBLE_VALUE, float) +} + + +class PrerequisiteComparator(IntEnum): + EQUALS = 0 + NOT_EQUALS = 1 + + +class SegmentComparator(IntEnum): + IS_IN = 0 + IS_NOT_IN = 1 + + +class Comparator(IntEnum): + IS_ONE_OF = 0 + IS_NOT_ONE_OF = 1 + CONTAINS_ANY_OF = 2 + NOT_CONTAINS_ANY_OF = 3 + IS_ONE_OF_SEMVER = 4 + IS_NOT_ONE_OF_SEMVER = 5 + LESS_THAN_SEMVER = 6 + LESS_THAN_OR_EQUAL_SEMVER = 7 + GREATER_THAN_SEMVER = 8 + GREATER_THAN_OR_EQUAL_SEMVER = 9 + EQUALS_NUMBER = 10 + NOT_EQUALS_NUMBER = 11 + LESS_THAN_NUMBER = 12 + LESS_THAN_OR_EQUAL_NUMBER = 13 + GREATER_THAN_NUMBER = 14 + GREATER_THAN_OR_EQUAL_NUMBER = 15 + IS_ONE_OF_HASHED = 16 + IS_NOT_ONE_OF_HASHED = 17 + BEFORE_DATETIME = 18 + AFTER_DATETIME = 19 + EQUALS_HASHED = 20 + NOT_EQUALS_HASHED = 21 + STARTS_WITH_ANY_OF_HASHED = 22 + NOT_STARTS_WITH_ANY_OF_HASHED = 23 + ENDS_WITH_ANY_OF_HASHED = 24 + NOT_ENDS_WITH_ANY_OF_HASHED = 25 + ARRAY_CONTAINS_ANY_OF_HASHED = 26 + ARRAY_NOT_CONTAINS_ANY_OF_HASHED = 27 + EQUALS = 28 + NOT_EQUALS = 29 + STARTS_WITH_ANY_OF = 30 + NOT_STARTS_WITH_ANY_OF = 31 + ENDS_WITH_ANY_OF = 32 + NOT_ENDS_WITH_ANY_OF = 33 + ARRAY_CONTAINS_ANY_OF = 34 + ARRAY_NOT_CONTAINS_ANY_OF = 35 + + +COMPARATOR_TEXTS = [ + 'IS ONE OF', # IS_ONE_OF + 'IS NOT ONE OF', # IS_NOT_ONE_OF + 'CONTAINS ANY OF', # CONTAINS_ANY_OF + 'NOT CONTAINS ANY OF', # NOT_CONTAINS_ANY_OF + 'IS ONE OF', # IS_ONE_OF_SEMVER + 'IS NOT ONE OF', # IS_NOT_ONE_OF_SEMVER + '<', # LESS_THAN_SEMVER + '<=', # LESS_THAN_OR_EQUAL_SEMVER + '>', # GREATER_THAN_SEMVER + '>=', # GREATER_THAN_OR_EQUAL_SEMVER + '=', # EQUALS_NUMBER + '!=', # NOT_EQUALS_NUMBER + '<', # LESS_THAN_NUMBER + '<=', # LESS_THAN_OR_EQUAL_NUMBER + '>', # GREATER_THAN_NUMBER + '>=', # GREATER_THAN_OR_EQUAL_NUMBER + 'IS ONE OF', # IS_ONE_OF_HASHED + 'IS NOT ONE OF', # IS_NOT_ONE_OF_HASHED + 'BEFORE', # BEFORE_DATETIME + 'AFTER', # AFTER_DATETIME + 'EQUALS', # EQUALS_HASHED + 'NOT EQUALS', # NOT_EQUALS_HASHED + 'STARTS WITH ANY OF', # STARTS_WITH_ANY_OF_HASHED + 'NOT STARTS WITH ANY OF', # NOT_STARTS_WITH_ANY_OF_HASHED + 'ENDS WITH ANY OF', # ENDS_WITH_ANY_OF_HASHED + 'NOT ENDS WITH ANY OF', # NOT_ENDS_WITH_ANY_OF_HASHED + 'ARRAY CONTAINS ANY OF', # ARRAY_CONTAINS_ANY_OF_HASHED + 'ARRAY NOT CONTAINS ANY OF', # ARRAY_NOT_CONTAINS_ANY_OF_HASHED + 'EQUALS', # EQUALS + 'NOT EQUALS', # NOT_EQUALS + 'STARTS WITH ANY OF', # STARTS_WITH_ANY_OF + 'NOT STARTS WITH ANY OF', # NOT_STARTS_WITH_ANY_OF + 'ENDS WITH ANY OF', # ENDS_WITH_ANY_OF + 'NOT ENDS WITH ANY OF', # NOT_ENDS_WITH_ANY_OF + 'ARRAY CONTAINS ANY OF', # ARRAY_CONTAINS_ANY_OF + 'ARRAY NOT CONTAINS ANY OF' # ARRAY_NOT_CONTAINS_ANY_OF +] +COMPARISON_VALUES = [ + STRING_LIST_VALUE, # IS_ONE_OF + STRING_LIST_VALUE, # IS_NOT_ONE_OF + STRING_LIST_VALUE, # CONTAINS_ANY_OF + STRING_LIST_VALUE, # NOT_CONTAINS_ANY_OF + STRING_LIST_VALUE, # IS_ONE_OF_SEMVER + STRING_LIST_VALUE, # IS_NOT_ONE_OF_SEMVER + STRING_VALUE, # LESS_THAN_SEMVER + STRING_VALUE, # LESS_THAN_OR_EQUAL_SEMVER + STRING_VALUE, # GREATER_THAN_SEMVER + STRING_VALUE, # GREATER_THAN_OR_EQUAL_SEMVER + DOUBLE_VALUE, # EQUALS_NUMBER + DOUBLE_VALUE, # NOT_EQUALS_NUMBER + DOUBLE_VALUE, # LESS_THAN_NUMBER + DOUBLE_VALUE, # LESS_THAN_OR_EQUAL_NUMBER + DOUBLE_VALUE, # GREATER_THAN_NUMBER + DOUBLE_VALUE, # GREATER_THAN_OR_EQUAL_NUMBER + STRING_LIST_VALUE, # IS_ONE_OF_HASHED + STRING_LIST_VALUE, # IS_NOT_ONE_OF_HASHED + DOUBLE_VALUE, # BEFORE_DATETIME + DOUBLE_VALUE, # AFTER_DATETIME + STRING_VALUE, # EQUALS_HASHED + STRING_VALUE, # NOT_EQUALS_HASHED + STRING_LIST_VALUE, # STARTS_WITH_ANY_OF_HASHED + STRING_LIST_VALUE, # NOT_STARTS_WITH_ANY_OF_HASHED + STRING_LIST_VALUE, # ENDS_WITH_ANY_OF_HASHED + STRING_LIST_VALUE, # NOT_ENDS_WITH_ANY_OF_HASHED + STRING_LIST_VALUE, # ARRAY_CONTAINS_ANY_OF_HASHED + STRING_LIST_VALUE, # ARRAY_NOT_CONTAINS_ANY_OF_HASHED + STRING_VALUE, # EQUALS + STRING_VALUE, # NOT_EQUALS + STRING_LIST_VALUE, # STARTS_WITH_ANY_OF + STRING_LIST_VALUE, # NOT_STARTS_WITH_ANY_OF + STRING_LIST_VALUE, # ENDS_WITH_ANY_OF + STRING_LIST_VALUE, # NOT_ENDS_WITH_ANY_OF + STRING_LIST_VALUE, # ARRAY_CONTAINS_ANY_OF + STRING_LIST_VALUE # ARRAY_NOT_CONTAINS_ANY_OF +] +SEGMENT_COMPARATOR_TEXTS = ['IS IN SEGMENT', 'IS NOT IN SEGMENT'] +PREREQUISITE_COMPARATOR_TEXTS = ['EQUALS', 'DOES NOT EQUAL'] + + +def extend_config_with_inline_salt_and_segment(config): + """ + Adds the inline salt and segment to the config. + When using flag overrides, the original salt and segment indexes may become invalid. Therefore, we copy the + object references to the locations where they are referenced and use these references instead of the indexes. + """ + salt = config.get(PREFERENCES, {}).get(SALT, '') + segments = config.get(SEGMENTS, []) + settings = config.get(FEATURE_FLAGS, {}) + for setting in settings.values(): + if not isinstance(setting, dict): + continue + + # add salt + setting[INLINE_SALT] = salt + + # add segment to the segment conditions + targeting_rules = setting.get(TARGETING_RULES, []) + for targeting_rule in targeting_rules: + conditions = targeting_rule.get(CONDITIONS, []) + for condition in conditions: + + segment_condition = condition.get(SEGMENT_CONDITION) + if segment_condition: + segment_index = segment_condition.get(SEGMENT_INDEX) + segment = segments[segment_index] + segment_condition[INLINE_SEGMENT] = segment diff --git a/configcatclient/configcatclient.py b/configcatclient/configcatclient.py index a6d405c..5d3e989 100644 --- a/configcatclient/configcatclient.py +++ b/configcatclient/configcatclient.py @@ -1,9 +1,12 @@ +import logging +import sys from threading import Lock from . import utils from .configservice import ConfigService -from .constants import ROLLOUT_RULES, VARIATION_ID, VALUE, ROLLOUT_PERCENTAGE_ITEMS +from .config import TARGETING_RULES, VARIATION_ID, PERCENTAGE_OPTIONS, FEATURE_FLAGS, SERVED_VALUE, SETTING_TYPE from .evaluationdetails import EvaluationDetails +from .evaluationlogbuilder import EvaluationLogBuilder from .interfaces import ConfigCatClientException from .logger import Logger from .configfetcher import ConfigFetcher @@ -11,10 +14,11 @@ from .configcatoptions import ConfigCatOptions, Hooks from .overridedatasource import OverrideBehaviour from .refreshresult import RefreshResult -from .rolloutevaluator import RolloutEvaluator +from .rolloutevaluator import RolloutEvaluator, get_value from collections import namedtuple import copy from .utils import method_is_called_from, get_date_time +import re KeyValue = namedtuple('KeyValue', 'key value') @@ -57,7 +61,7 @@ def close_all(cls): """ with cls._lock: for key, value in list(cls._instances.items()): - value.__close_resources() + value._close_resources() cls._instances.clear() def __init__(self, @@ -74,14 +78,22 @@ def __init__(self, if sdk_key is None: raise ConfigCatClientException('SDK Key is required.') - self._sdk_key = sdk_key - self._default_user = options.default_user - self._rollout_evaluator = RolloutEvaluator(self.log) if options.flag_overrides: self._override_data_source = options.flag_overrides.create_data_source(self.log) else: self._override_data_source = None + # In case of local only flag overrides mode, we accept any SDK Key format. + if self._override_data_source is None or self._override_data_source.get_behaviour() != OverrideBehaviour.LocalOnly: + is_valid_sdk_key = re.match('^.{22}/.{22}$', sdk_key) is not None or \ + re.match('^configcat-sdk-1/.{22}/.{22}$', sdk_key) is not None or \ + (options.base_url and re.match('^configcat-proxy/.+$', sdk_key) is not None) + if not is_valid_sdk_key: + raise ConfigCatClientException('SDK Key `%s` is invalid.' % sdk_key) + + self._sdk_key = sdk_key + self._default_user = options.default_user + self._rollout_evaluator = RolloutEvaluator(self.log) config_cache = options.config_cache if options.config_cache is not None else NullConfigCache() if self._override_data_source and self._override_data_source.get_behaviour() == OverrideBehaviour.LocalOnly: @@ -112,8 +124,8 @@ def get_value(self, key, default_value, user=None): :param user: the user object to identify the caller. :return: the value. """ - settings, fetch_time = self.__get_settings() - if settings is None: + config, fetch_time = self._get_config() + if config is None or config.get(FEATURE_FLAGS) is None: message = 'Config JSON is not present when evaluating setting \'%s\'. ' \ 'Returning the `%s` parameter that you specified in your application: \'%s\'.' message_args = (key, 'default_value', str(default_value)) @@ -122,12 +134,12 @@ def get_value(self, key, default_value, user=None): EvaluationDetails.from_error(key, default_value, Logger.format(message, message_args))) return default_value - details = self.__evaluate(key=key, - user=user, - default_value=default_value, - default_variation_id=None, - settings=settings, - fetch_time=fetch_time) + details = self._evaluate(key=key, + user=user, + default_value=default_value, + default_variation_id=None, + config=config, + fetch_time=fetch_time) return details.value @@ -140,8 +152,8 @@ def get_value_details(self, key, default_value, user=None): :param user: the user object to identify the caller. :return: the evaluation details. """ - settings, fetch_time = self.__get_settings() - if settings is None: + config, fetch_time = self._get_config() + if config is None or config.get(FEATURE_FLAGS) is None: message = 'Config JSON is not present when evaluating setting \'%s\'. ' \ 'Returning the `%s` parameter that you specified in your application: \'%s\'.' message_args = (key, 'default_value', str(default_value)) @@ -150,12 +162,12 @@ def get_value_details(self, key, default_value, user=None): self._hooks.invoke_on_flag_evaluated(details) return details - details = self.__evaluate(key=key, - user=user, - default_value=default_value, - default_variation_id=None, - settings=settings, - fetch_time=fetch_time) + details = self._evaluate(key=key, + user=user, + default_value=default_value, + default_variation_id=None, + config=config, + fetch_time=fetch_time) return details @@ -165,11 +177,12 @@ def get_all_keys(self): :return: list of keys. """ - settings, _ = self.__get_settings() - if settings is None: + config, _ = self._get_config() + if config is None: self.log.error('Config JSON is not present. Returning %s.', 'empty list', event_id=1000) return [] + settings = config.get(FEATURE_FLAGS, {}) return list(settings) def get_key_and_value(self, variation_id): @@ -179,24 +192,31 @@ def get_key_and_value(self, variation_id): :param variation_id: variation ID :return: key and value """ - settings, _ = self.__get_settings() - if settings is None: + config, _ = self._get_config() + if config is None: self.log.error('Config JSON is not present. Returning %s.', 'None', event_id=1000) return None - for key, value in list(settings.items()): - if variation_id == value.get(VARIATION_ID): - return KeyValue(key, value[VALUE]) - - rollout_rules = value.get(ROLLOUT_RULES, []) - for rollout_rule in rollout_rules: - if variation_id == rollout_rule.get(VARIATION_ID): - return KeyValue(key, rollout_rule[VALUE]) - - rollout_percentage_items = value.get(ROLLOUT_PERCENTAGE_ITEMS, []) - for rollout_percentage_item in rollout_percentage_items: - if variation_id == rollout_percentage_item.get(VARIATION_ID): - return KeyValue(key, rollout_percentage_item[VALUE]) + settings = config.get(FEATURE_FLAGS, {}) + try: + for key, value in list(settings.items()): + setting_type = value.get(SETTING_TYPE) + if variation_id == value.get(VARIATION_ID): + return KeyValue(key, get_value(value, setting_type)) + + targeting_rules = value.get(TARGETING_RULES, []) + for targeting_rule in targeting_rules: + served_value = targeting_rule.get(SERVED_VALUE) + if served_value is not None and variation_id == served_value.get(VARIATION_ID): + return KeyValue(key, get_value(served_value, setting_type)) + + rollout_percentage_items = targeting_rule.get(PERCENTAGE_OPTIONS, []) + for rollout_percentage_item in rollout_percentage_items: + if variation_id == rollout_percentage_item.get(VARIATION_ID): + return KeyValue(key, get_value(rollout_percentage_item, setting_type)) + except Exception: + self.log.exception('Error occurred in the `' + __name__ + '` method. Returning None.', event_id=1002) + return None self.log.error('Could not find the setting for the specified variation ID: \'%s\'.', variation_id, event_id=2011) return None @@ -208,11 +228,12 @@ def get_all_values(self, user=None): :param user: the user object to identify the caller. :return: dictionary of values """ - settings, _ = self.__get_settings() - if settings is None: + config, _ = self._get_config() + if config is None: self.log.error('Config JSON is not present. Returning %s.', 'empty dictionary', event_id=1000) return {} + settings = config.get(FEATURE_FLAGS, {}) all_values = {} for key in list(settings): value = self.get_value(key, None, user) @@ -228,19 +249,20 @@ def get_all_value_details(self, user=None): :param user: the user object to identify the caller. :return: list of all evaluation details """ - settings, fetch_time = self.__get_settings() - if settings is None: + config, fetch_time = self._get_config() + if config is None: self.log.error('Config JSON is not present. Returning %s.', 'empty list', event_id=1000) return [] details_result = [] + settings = config.get(FEATURE_FLAGS, {}) for key in list(settings): - details = self.__evaluate(key=key, - user=user, - default_value=None, - default_variation_id=None, - settings=settings, - fetch_time=fetch_time) + details = self._evaluate(key=key, + user=user, + default_value=None, + default_variation_id=None, + config=config, + fetch_time=fetch_time) details_result.append(details) return details_result @@ -310,52 +332,79 @@ def close(self): Closes the underlying resources. """ with ConfigCatClient._lock: - self.__close_resources() + self._close_resources() ConfigCatClient._instances.pop(self._sdk_key) - def __close_resources(self): + def _close_resources(self): if self._config_service: self._config_service.close() self._hooks.clear() - def __get_settings(self): + def _get_config(self): if self._override_data_source: behaviour = self._override_data_source.get_behaviour() if behaviour == OverrideBehaviour.LocalOnly: return self._override_data_source.get_overrides(), utils.distant_past elif behaviour == OverrideBehaviour.RemoteOverLocal: - remote_settings, fetch_time = self._config_service.get_settings() - local_settings = self._override_data_source.get_overrides() - if not remote_settings: - remote_settings = {} - if not local_settings: - local_settings = {} - result = copy.deepcopy(local_settings) - if result: - result.update(remote_settings) + remote_config, fetch_time = self._config_service.get_config() + local_config = self._override_data_source.get_overrides() + if not remote_config: + remote_config = {FEATURE_FLAGS: {}} + if not local_config: + local_config = {FEATURE_FLAGS: {}} + result = copy.deepcopy(local_config) + result[FEATURE_FLAGS].update(remote_config[FEATURE_FLAGS]) return result, fetch_time elif behaviour == OverrideBehaviour.LocalOverRemote: - remote_settings, fetch_time = self._config_service.get_settings() - local_settings = self._override_data_source.get_overrides() - if not remote_settings: - remote_settings = {} - if not local_settings: - local_settings = {} - result = copy.deepcopy(remote_settings) - result.update(local_settings) + remote_config, fetch_time = self._config_service.get_config() + local_config = self._override_data_source.get_overrides() + if not remote_config: + remote_config = {FEATURE_FLAGS: {}} + if not local_config: + local_config = {FEATURE_FLAGS: {}} + result = copy.deepcopy(remote_config) + result[FEATURE_FLAGS].update(local_config[FEATURE_FLAGS]) return result, fetch_time - return self._config_service.get_settings() + return self._config_service.get_config() + + def _check_type_missmatch(self, value, default_value): + is_float_int_missmatch = \ + (type(value) is float and type(default_value) is int) or \ + (type(value) is int and type(default_value) is float) + + # On Python 2.7, do not log a warning if the type missmatch is between str and unicode. + # (ignore warning: unicode is undefined in Python 3) + is_str_unicode_missmatch = \ + (sys.version_info[0] == 2 and type(value) is unicode and type(default_value) is str) or \ + (sys.version_info[0] == 2 and type(value) is str and type(default_value) is unicode) # noqa: F821 + + if default_value is not None and type(value) is not type(default_value): + if not is_float_int_missmatch and not is_str_unicode_missmatch: + self.log.warning("The type of a setting does not match the type of the specified default value (%s). " + "Setting's type was %s but the default value's type was %s. " + "Please make sure that using a default value not matching the setting's type was intended." % + (default_value, type(value), type(default_value)), event_id=4002) - def __evaluate(self, key, user, default_value, default_variation_id, settings, fetch_time): + def _evaluate(self, key, user, default_value, default_variation_id, config, fetch_time): user = user if user is not None else self._default_user + + # Skip building the evaluation log if it won't be logged. + log_builder = EvaluationLogBuilder() if self.log.isEnabledFor(logging.INFO) else None + value, variation_id, rule, percentage_rule, error = self._rollout_evaluator.evaluate( key=key, user=user, default_value=default_value, default_variation_id=default_variation_id, - settings=settings) + config=config, + log_builder=log_builder) + + self._check_type_missmatch(value, default_value) + + if log_builder: + self.log.info(str(log_builder), event_id=5000) details = EvaluationDetails(key=key, value=value, @@ -364,7 +413,7 @@ def __evaluate(self, key, user, default_value, default_variation_id, settings, f user=user, is_default_value=True if error else False, error=error, - matched_evaluation_rule=rule, - matched_evaluation_percentage_rule=percentage_rule) + matched_targeting_rule=rule, + matched_percentage_option=percentage_rule) self._hooks.invoke_on_flag_evaluated(details) return details diff --git a/configcatclient/configentry.py b/configcatclient/configentry.py index 3fc8774..bfb7c24 100644 --- a/configcatclient/configentry.py +++ b/configcatclient/configentry.py @@ -1,14 +1,13 @@ import json +import sys from math import floor from . import utils +from .config import extend_config_with_inline_salt_and_segment +from .utils import unicode_to_utf8 class ConfigEntry(object): - CONFIG = 'config' - ETAG = 'etag' - FETCH_TIME = 'fetch_time' - def __init__(self, config=None, etag='', config_json_string='{}', fetch_time=utils.distant_past): self.config = config if config is not None else {} self.etag = etag @@ -42,6 +41,9 @@ def create_from_string(cls, string): try: config_json = string[etag_index + 1:] config = json.loads(config_json) + if sys.version_info[0] == 2: + config = unicode_to_utf8(config) # On Python 2.7, convert unicode to utf-8 + extend_config_with_inline_salt_and_segment(config) except ValueError as e: raise ValueError('Invalid config JSON: {}. {}'.format(config_json, str(e))) diff --git a/configcatclient/configfetcher.py b/configcatclient/configfetcher.py index 031db5e..fe9b9a1 100644 --- a/configcatclient/configfetcher.py +++ b/configcatclient/configfetcher.py @@ -5,11 +5,12 @@ from requests import HTTPError from requests import Timeout +from .config import extend_config_with_inline_salt_and_segment from .configentry import ConfigEntry -from .constants import CONFIG_FILE_NAME, PREFERENCES, BASE_URL, REDIRECT +from .config import CONFIG_FILE_NAME, PREFERENCES, BASE_URL, REDIRECT from .datagovernance import DataGovernance from .logger import Logger -from .utils import get_utc_now_seconds_since_epoch +from .utils import get_utc_now_seconds_since_epoch, unicode_to_utf8 from .version import CONFIGCATCLIENT_VERSION if sys.version_info < (2, 7, 9): @@ -151,7 +152,7 @@ def get_configuration(self, etag='', retries=0): # Retry the config download with the new base_url return self.get_configuration(etag, retries + 1) - def _fetch(self, etag): + def _fetch(self, etag): # noqa: C901 uri = self._base_url + '/' + BASE_PATH + self._sdk_key + BASE_EXTENSION headers = self._headers if etag: @@ -165,12 +166,19 @@ def _fetch(self, etag): response.raise_for_status() if response.status_code in [200, 201, 202, 203, 204]: - response_etag = response.headers.get('Etag') + response_etag = response.headers.get('ETag') if response_etag is None: response_etag = '' config = response.json() + extend_config_with_inline_salt_and_segment(config) + if sys.version_info[0] == 2: + config = unicode_to_utf8(config) # On Python 2.7, convert unicode to utf-8 + config_json_string = response.text.encode('utf-8') + else: + config_json_string = response.text + return FetchResponse.success( - ConfigEntry(config, response_etag, response.text, get_utc_now_seconds_since_epoch())) + ConfigEntry(config, response_etag, config_json_string, get_utc_now_seconds_since_epoch())) elif response.status_code == 304: return FetchResponse.not_modified() elif response.status_code in [404, 403]: diff --git a/configcatclient/configservice.py b/configcatclient/configservice.py index 1212194..addd181 100644 --- a/configcatclient/configservice.py +++ b/configcatclient/configservice.py @@ -2,8 +2,8 @@ from threading import Thread, Event, Lock from . import utils +from .config import FEATURE_FLAGS, CONFIG_FILE_NAME, SERIALIZATION_FORMAT_VERSION from .configentry import ConfigEntry -from .constants import CONFIG_FILE_NAME, FEATURE_FLAGS, SERIALIZATION_FORMAT_VERSION from .pollingmode import AutoPollingMode, LazyLoadingMode from .refreshresult import RefreshResult @@ -31,11 +31,11 @@ def __init__(self, sdk_key, polling_mode, hooks, config_fetcher, log, config_cac else: self._set_initialized() - def get_settings(self): + def get_config(self): if isinstance(self._polling_mode, LazyLoadingMode): entry, _ = self._fetch_if_older( utils.get_utc_now_seconds_since_epoch() - self._polling_mode.cache_refresh_interval_seconds) - return (entry.config.get(FEATURE_FLAGS, {}), entry.fetch_time) \ + return (entry.config, entry.fetch_time) \ if not entry.is_empty() \ else (None, utils.distant_past) elif isinstance(self._polling_mode, AutoPollingMode) and not self._initialized.is_set(): @@ -46,12 +46,13 @@ def get_settings(self): # Max wait time expired without result, notify subscribers with the cached config. if not self._initialized.is_set(): self._set_initialized() - return (self._cached_entry.config.get(FEATURE_FLAGS, {}), self._cached_entry.fetch_time) \ + return (self._cached_entry.config, self._cached_entry.fetch_time) \ if not self._cached_entry.is_empty() \ else (None, utils.distant_past) - entry, _ = self._fetch_if_older(utils.distant_past, prefer_cache=True) - return (entry.config.get(FEATURE_FLAGS, {}), entry.fetch_time) \ + # If we are initialized, we prefer the cached results + entry, _ = self._fetch_if_older(utils.distant_past, prefer_cache=self._initialized.is_set()) + return (entry.config, entry.fetch_time) \ if not entry.is_empty() \ else (None, utils.distant_past) @@ -59,6 +60,11 @@ def refresh(self): """ :return: RefreshResult object """ + if self.is_offline(): + offline_warning = 'Client is in offline mode, it cannot initiate HTTP calls.' + self.log.warning(offline_warning, event_id=3200) + return RefreshResult(is_success=False, error=offline_warning) + _, error = self._fetch_if_older(utils.distant_future) return RefreshResult(is_success=error is None, error=error) @@ -92,35 +98,26 @@ def close(self): if isinstance(self._polling_mode, AutoPollingMode): self._stopped.set() - def _fetch_if_older(self, time, prefer_cache=False): + def _fetch_if_older(self, threshold, prefer_cache=False): """ :return: Returns the ConfigEntry object and error message in case of any error. """ with self._lock: # Sync up with the cache and use it when it's not expired. - if self._cached_entry.is_empty() or self._cached_entry.fetch_time > time: - entry = self._read_cache() - if not entry.is_empty() and entry.etag != self._cached_entry.etag: - self._cached_entry = entry - self._hooks.invoke_on_config_changed(entry.config.get(FEATURE_FLAGS)) - - # Cache isn't expired - if self._cached_entry.fetch_time > time: - self._set_initialized() - return self._cached_entry, None + from_cache = self._read_cache() + if not from_cache.is_empty() and from_cache.etag != self._cached_entry.etag: + self._cached_entry = from_cache + self._hooks.invoke_on_config_changed(from_cache.config.get(FEATURE_FLAGS)) - # Use cache anyway (get calls on auto & manual poll must not initiate fetch). - # The initialized check ensures that we subscribe for the ongoing fetch during the - # max init wait time window in case of auto poll. - if prefer_cache and self._initialized.is_set(): + # Cache isn't expired + if self._cached_entry.fetch_time > threshold: + self._set_initialized() return self._cached_entry, None - # If we are in offline mode we are not allowed to initiate fetch. - if self._is_offline: - offline_warning = 'Client is in offline mode, it cannot initiate HTTP calls.' - self.log.warning(offline_warning, event_id=3200) - return self._cached_entry, offline_warning + # If we are in offline mode or the caller prefers cached values, do not initiate fetch. + if self._is_offline or prefer_cache: + return self._cached_entry, None # No fetch is running, initiate a new one. # Ensure only one fetch request is running at a time. diff --git a/configcatclient/constants.py b/configcatclient/constants.py deleted file mode 100644 index e1adaf8..0000000 --- a/configcatclient/constants.py +++ /dev/null @@ -1,16 +0,0 @@ -CONFIG_FILE_NAME = 'config_v5' -SERIALIZATION_FORMAT_VERSION = 'v2' - -PREFERENCES = 'p' -BASE_URL = 'u' -REDIRECT = 'r' - -FEATURE_FLAGS = 'f' -VALUE = 'v' -COMPARATOR = 't' -COMPARISON_ATTRIBUTE = 'a' -COMPARISON_VALUE = 'c' -ROLLOUT_PERCENTAGE_ITEMS = 'p' -PERCENTAGE = 'p' -ROLLOUT_RULES = 'r' -VARIATION_ID = 'i' diff --git a/configcatclient/evaluationcontext.py b/configcatclient/evaluationcontext.py new file mode 100644 index 0000000..2b38cae --- /dev/null +++ b/configcatclient/evaluationcontext.py @@ -0,0 +1,14 @@ +class EvaluationContext(object): + def __init__(self, + key, + setting_type, + user, + visited_keys=None, + is_missing_user_object_logged=False, + is_missing_user_object_attribute_logged=False): + self.key = key + self.setting_type = setting_type + self.user = user + self.visited_keys = visited_keys + self.is_missing_user_object_logged = is_missing_user_object_logged + self.is_missing_user_object_attribute_logged = is_missing_user_object_attribute_logged diff --git a/configcatclient/evaluationdetails.py b/configcatclient/evaluationdetails.py index 7517ca9..44a1a39 100644 --- a/configcatclient/evaluationdetails.py +++ b/configcatclient/evaluationdetails.py @@ -7,17 +7,35 @@ def __init__(self, user=None, is_default_value=False, error=None, - matched_evaluation_rule=None, - matched_evaluation_percentage_rule=None): + matched_targeting_rule=None, + matched_percentage_option=None): + # Key of the feature flag or setting. self.key = key + + # Evaluated value of the feature flag or setting. self.value = value + + # Variation ID of the feature flag or setting (if available). self.variation_id = variation_id + + # Time of last successful config download. self.fetch_time = fetch_time + + # The User Object used for the evaluation (if available). self.user = user + + # Indicates whether the default value passed to the setting evaluation methods like ConfigCatClient.get_value, + # ConfigCatClient.get_value_details, etc. is used as the result of the evaluation. self.is_default_value = is_default_value + + # Error message in case evaluation failed. self.error = error - self.matched_evaluation_rule = matched_evaluation_rule - self.matched_evaluation_percentage_rule = matched_evaluation_percentage_rule + + # The targeting rule (if any) that matched during the evaluation and was used to return the evaluated value. + self.matched_targeting_rule = matched_targeting_rule + + # The percentage option (if any) that was used to select the evaluated value. + self.matched_percentage_option = matched_percentage_option @staticmethod def from_error(key, value, error, variation_id=None): diff --git a/configcatclient/evaluationlogbuilder.py b/configcatclient/evaluationlogbuilder.py new file mode 100644 index 0000000..585114a --- /dev/null +++ b/configcatclient/evaluationlogbuilder.py @@ -0,0 +1,69 @@ +from .config import Comparator +from .utils import get_date_time + + +class EvaluationLogBuilder(object): + def __init__(self): + self.indent_level = 0 + self.text = '' + + @staticmethod + def trunc_comparison_value_if_needed(comparator, comparison_value): + if comparator in [Comparator.IS_ONE_OF_HASHED, + Comparator.IS_NOT_ONE_OF_HASHED, + Comparator.EQUALS_HASHED, + Comparator.NOT_EQUALS_HASHED, + Comparator.STARTS_WITH_ANY_OF_HASHED, + Comparator.NOT_STARTS_WITH_ANY_OF_HASHED, + Comparator.ENDS_WITH_ANY_OF_HASHED, + Comparator.NOT_ENDS_WITH_ANY_OF_HASHED, + Comparator.ARRAY_CONTAINS_ANY_OF_HASHED, + Comparator.ARRAY_NOT_CONTAINS_ANY_OF_HASHED]: + if isinstance(comparison_value, list): + length = len(comparison_value) + if length > 1: + return '[<{} hashed values>]'.format(length) + return '[<{} hashed value>]'.format(length) + + return "''" + + if isinstance(comparison_value, list): + length_limit = 10 + length = len(comparison_value) + if length > length_limit: + remaining = length - length_limit + if remaining == 1: + more_text = "<1 more value>" + else: + more_text = "<{} more values>".format(remaining) + + return str(comparison_value[:length_limit])[:-1] + ', ... ' + more_text + ']' + + return str(comparison_value) + + if comparator in [Comparator.BEFORE_DATETIME, Comparator.AFTER_DATETIME]: + time = get_date_time(comparison_value) + return "'%s' (%sZ UTC)" % (str(comparison_value), time.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3]) + + return "'%s'" % str(comparison_value) + + def increase_indent(self): + self.indent_level += 1 + return self + + def decrease_indent(self): + self.indent_level = max(0, self.indent_level - 1) + return self + + def append(self, text): + self.text += text + return self + + def new_line(self, text=None): + self.text += '\n' + ' ' * self.indent_level + if text: + self.text += text + return self + + def __str__(self): + return self.text diff --git a/configcatclient/localdictionarydatasource.py b/configcatclient/localdictionarydatasource.py index c2f8600..4ff67cd 100644 --- a/configcatclient/localdictionarydatasource.py +++ b/configcatclient/localdictionarydatasource.py @@ -1,5 +1,9 @@ -from .constants import VALUE +import sys + +from .config import VALUE, FEATURE_FLAGS, BOOL_VALUE, STRING_VALUE, INT_VALUE, DOUBLE_VALUE, SettingType, SETTING_TYPE, \ + UNSUPPORTED_VALUE from .overridedatasource import OverrideDataSource, FlagOverrides +from .utils import unicode_to_utf8 class LocalDictionaryFlagOverrides(FlagOverrides): @@ -7,6 +11,9 @@ def __init__(self, source, override_behaviour): self.source = source self.override_behaviour = override_behaviour + if sys.version_info[0] == 2: + self.source = unicode_to_utf8(self.source) # On Python 2.7, convert unicode to utf-8 + def create_data_source(self, log): return LocalDictionaryDataSource(self.source, self.override_behaviour, log) @@ -15,9 +22,26 @@ class LocalDictionaryDataSource(OverrideDataSource): def __init__(self, source, override_behaviour, log): OverrideDataSource.__init__(self, override_behaviour=override_behaviour) self.log = log - self._settings = {} + self._config = {} for key, value in source.items(): - self._settings[key] = {VALUE: value} + if isinstance(value, bool): + value_type = BOOL_VALUE + elif isinstance(value, str): + value_type = STRING_VALUE + elif isinstance(value, int): + value_type = INT_VALUE + elif isinstance(value, float): + value_type = DOUBLE_VALUE + else: + value_type = UNSUPPORTED_VALUE + + if FEATURE_FLAGS not in self._config: + self._config[FEATURE_FLAGS] = {} + + self._config[FEATURE_FLAGS][key] = {VALUE: {value_type: value}} + setting_type = SettingType.from_type(type(value)) + if setting_type is not None: + self._config[FEATURE_FLAGS][key][SETTING_TYPE] = int(setting_type) def get_overrides(self): - return self._settings + return self._config diff --git a/configcatclient/localfiledatasource.py b/configcatclient/localfiledatasource.py index 7d340db..e199af4 100644 --- a/configcatclient/localfiledatasource.py +++ b/configcatclient/localfiledatasource.py @@ -1,8 +1,13 @@ -from .constants import VALUE, FEATURE_FLAGS +import sys + +from .config import extend_config_with_inline_salt_and_segment, VALUE, FEATURE_FLAGS, BOOL_VALUE, STRING_VALUE, \ + INT_VALUE, DOUBLE_VALUE, SettingType, SETTING_TYPE, UNSUPPORTED_VALUE from .overridedatasource import OverrideDataSource, FlagOverrides import json import os +from .utils import unicode_to_utf8 + class LocalFileFlagOverrides(FlagOverrides): def __init__(self, file_path, override_behaviour): @@ -25,27 +30,46 @@ def __init__(self, file_path, override_behaviour, log): file_path, event_id=1300) self._file_path = file_path - self._settings = None + self._config = None self._cached_file_stamp = 0 def get_overrides(self): self._reload_file_content() - return self._settings + return self._config - def _reload_file_content(self): + def _reload_file_content(self): # noqa: C901 try: stamp = os.stat(self._file_path).st_mtime if stamp != self._cached_file_stamp: self._cached_file_stamp = stamp with open(self._file_path) as file: data = json.load(file) + + if sys.version_info[0] == 2: + data = unicode_to_utf8(data) # On Python 2.7, convert unicode to utf-8 + if 'flags' in data: - self._settings = {} + self._config = {FEATURE_FLAGS: {}} source = data['flags'] for key, value in source.items(): - self._settings[key] = {VALUE: value} + if isinstance(value, bool): + value_type = BOOL_VALUE + elif isinstance(value, str): + value_type = STRING_VALUE + elif isinstance(value, int): + value_type = INT_VALUE + elif isinstance(value, float): + value_type = DOUBLE_VALUE + else: + value_type = UNSUPPORTED_VALUE + + self._config[FEATURE_FLAGS][key] = {VALUE: {value_type: value}} + setting_type = SettingType.from_type(type(value)) + if setting_type is not None: + self._config[FEATURE_FLAGS][key][SETTING_TYPE] = int(setting_type) else: - self._settings = data[FEATURE_FLAGS] + extend_config_with_inline_salt_and_segment(data) + self._config = data except OSError: self.log.exception('Failed to read the local config file \'%s\'.', self._file_path, event_id=1302) except ValueError: diff --git a/configcatclient/rolloutevaluator.py b/configcatclient/rolloutevaluator.py index d7bf898..f233f26 100644 --- a/configcatclient/rolloutevaluator.py +++ b/configcatclient/rolloutevaluator.py @@ -1,43 +1,45 @@ +import json + import hashlib +import sys import semver +from .config import FEATURE_FLAGS, INLINE_SALT, TARGETING_RULES, PERCENTAGE_RULE_ATTRIBUTE, CONDITIONS, SERVED_VALUE, \ + get_value, VARIATION_ID, TARGETING_RULE_PERCENTAGE_OPTIONS, PERCENTAGE_OPTIONS, PERCENTAGE, USER_CONDITION, \ + SEGMENT_CONDITION, PREREQUISITE_FLAG_CONDITION, PREREQUISITE_FLAG_KEY, PREREQUISITE_COMPARATOR, \ + PrerequisiteComparator, INLINE_SEGMENT, SEGMENT_NAME, SEGMENT_COMPARATOR, SEGMENT_CONDITIONS, SegmentComparator, \ + COMPARISON_ATTRIBUTE, COMPARATOR, Comparator, COMPARATOR_TEXTS, PREREQUISITE_COMPARATOR_TEXTS, \ + SEGMENT_COMPARATOR_TEXTS, COMPARISON_VALUES, get_value_type, SETTING_TYPE, SettingType +from .evaluationcontext import EvaluationContext +from .evaluationlogbuilder import EvaluationLogBuilder from .logger import Logger +from datetime import datetime -from .constants import ROLLOUT_RULES, ROLLOUT_PERCENTAGE_ITEMS, VALUE, VARIATION_ID, COMPARISON_ATTRIBUTE, \ - COMPARISON_VALUE, COMPARATOR, PERCENTAGE from .user import User +from .utils import unicode_to_utf8, encode_utf8, get_seconds_since_epoch -class RolloutEvaluator(object): - SEMANTIC_VERSION_COMPARATORS = ['<', '<=', '>', '>='] - COMPARATOR_TEXTS = [ - 'IS ONE OF', - 'IS NOT ONE OF', - 'CONTAINS', - 'DOES NOT CONTAIN', - 'IS ONE OF (SemVer)', - 'IS NOT ONE OF (SemVer)', - '< (SemVer)', - '<= (SemVer)', - '> (SemVer)', - '>= (SemVer)', - '= (Number)', - '<> (Number)', - '< (Number)', - '<= (Number)', - '> (Number)', - '>= (Number)', - 'IS ONE OF (Sensitive)', - 'IS NOT ONE OF (Sensitive)' - ] +def sha256(value_utf8, salt, context_salt): + """ + Calculates the SHA256 hash of the given value with the given salt and context_salt. + """ + return hashlib.sha256(value_utf8 + salt.encode('utf-8') + context_salt.encode('utf-8')).hexdigest() + +class RolloutEvaluator(object): def __init__(self, log): self.log = log - def evaluate(self, key, user, default_value, default_variation_id, settings): # noqa: C901 + def evaluate(self, key, user, default_value, default_variation_id, config, log_builder, visited_keys=None): # noqa: C901 """ - returns value, variation_id, matched_evaluation_rule, matched_evaluation_percentage_rule, error + returns value, variation_id, matched_targeting_rule, matched_percentage_option, error """ + + if visited_keys is None: + visited_keys = [] + is_root_flag_evaluation = len(visited_keys) == 0 + + settings = config.get(FEATURE_FLAGS, {}) setting_descriptor = settings.get(key) if setting_descriptor is None: @@ -48,174 +50,676 @@ def evaluate(self, key, user, default_value, default_variation_id, settings): # self.log.error(error, *error_args, event_id=1001) return default_value, default_variation_id, None, None, Logger.format(error, error_args) - rollout_rules = setting_descriptor.get(ROLLOUT_RULES, []) - rollout_percentage_items = setting_descriptor.get(ROLLOUT_PERCENTAGE_ITEMS, []) + setting_type = setting_descriptor.get(SETTING_TYPE) + salt = setting_descriptor.get(INLINE_SALT, '') + targeting_rules = setting_descriptor.get(TARGETING_RULES, []) + percentage_rule_attribute = setting_descriptor.get(PERCENTAGE_RULE_ATTRIBUTE) - user_has_invalid_type = user is not None and type(user) is not User + context = EvaluationContext(key, setting_type, user, visited_keys) + + user_has_invalid_type = context.user is not None and not isinstance(context.user, User) if user_has_invalid_type: self.log.warning('Cannot evaluate targeting rules and %% options for setting \'%s\' ' - '(User Object is not an instance of User type).', + '(User Object is not an instance of User type). ' + 'You should pass a User Object to the evaluation methods like `get_value()` ' + 'in order to make targeting work properly. ' + 'Read more: https://configcat.com/docs/advanced/user-object/', key, event_id=4001) - user = None + # We set the user to None and won't log further missing user object warnings + context.user = None + context.is_missing_user_object_logged = True + + try: + if log_builder and is_root_flag_evaluation: + log_builder.append("Evaluating '{}'".format(key)) + log_builder.append(" for User '{}'".format(context.user) if context.user is not None else '') + log_builder.increase_indent() + + # Evaluate targeting rules (logically connected by OR) + if log_builder and len(targeting_rules) > 0: + log_builder.new_line('Evaluating targeting rules and applying the first match if any:') + for targeting_rule in targeting_rules: + conditions = targeting_rule.get(CONDITIONS, []) + + if len(conditions) > 0: + served_value = targeting_rule.get(SERVED_VALUE) + value = get_value(served_value, setting_type) if served_value is not None else None + + # Evaluate targeting rule conditions (logically connected by AND) + if self._evaluate_conditions(conditions, context, salt, config, log_builder, value): + if served_value is not None: + variation_id = served_value.get(VARIATION_ID, default_variation_id) + log_builder and is_root_flag_evaluation and log_builder.new_line("Returning '%s'." % value) + return value, variation_id, targeting_rule, None, None + else: + continue + + # Evaluate percentage options of the targeting rule + log_builder and log_builder.increase_indent() + percentage_options = targeting_rule.get(TARGETING_RULE_PERCENTAGE_OPTIONS, []) + percentage_evaluation_result, percentage_value, percentage_variation_id, percentage_option = \ + self._evaluate_percentage_options(percentage_options, context, percentage_rule_attribute, + default_variation_id, log_builder) + + if percentage_evaluation_result: + if log_builder: + log_builder.decrease_indent() + is_root_flag_evaluation and log_builder.new_line("Returning '%s'." % percentage_value) + return percentage_value, percentage_variation_id, targeting_rule, percentage_option, None + else: + if log_builder: + log_builder.new_line( + 'The current targeting rule is ignored and the evaluation continues with the next rule.') + log_builder.decrease_indent() + continue + + # Evaluate percentage options + percentage_options = setting_descriptor.get(PERCENTAGE_OPTIONS, []) + percentage_evaluation_result, percentage_value, percentage_variation_id, percentage_option = \ + self._evaluate_percentage_options(percentage_options, context, percentage_rule_attribute, + default_variation_id, log_builder) + if percentage_evaluation_result: + log_builder and is_root_flag_evaluation and log_builder.new_line("Returning '%s'." % percentage_value) + return percentage_value, percentage_variation_id, None, percentage_option, None + + return_value = get_value(setting_descriptor, setting_type) + return_variation_id = setting_descriptor.get(VARIATION_ID, default_variation_id) + log_builder and is_root_flag_evaluation and log_builder.new_line("Returning '%s'." % return_value) + return return_value, return_variation_id, None, None, None + except Exception as e: + # During the recursive evaluation of a prerequisite flag, we propagate the exceptions + # and let the root flag's evaluation code handle them. + if not is_root_flag_evaluation: + raise e + + error = 'Failed to evaluate setting \'%s\'. (%s). ' \ + 'Returning the `%s` parameter that you specified in your application: \'%s\'. ' + error_args = (key, str(e), 'default_value', str(default_value)) + self.log.error(error, *error_args, event_id=2001) + return default_value, default_variation_id, None, None, Logger.format(error, error_args) + + def _format_rule(self, comparison_attribute, comparator, comparison_value): + comparator_text = COMPARATOR_TEXTS[comparator] + return "User.%s %s %s" \ + % (comparison_attribute, comparator_text, + EvaluationLogBuilder.trunc_comparison_value_if_needed(comparator, comparison_value)) + + def _user_attribute_value_to_string(self, value): + if value is None: + return None + + if isinstance(value, datetime): + value = self._get_user_attribute_value_as_seconds_since_epoch(value) + elif isinstance(value, list): + value = self._get_user_attribute_value_as_string_list(value) + + return str(value) + + def _get_user_attribute_value_as_text(self, attribute_name, attribute_value, condition, key): + if isinstance(attribute_value, str): + return attribute_value + + if sys.version_info[0] == 2 and isinstance(attribute_value, unicode): # noqa: F821 + return attribute_value # Handle unicode strings on Python 2.7 + + self.log.warning('Evaluation of condition (%s) for setting \'%s\' may not produce the expected result ' + '(the User.%s attribute is not a string value, thus it was automatically converted to ' + 'the string value \'%s\'). Please make sure that using a non-string value was intended.', + condition, key, attribute_name, attribute_value, event_id=3005) + return self._user_attribute_value_to_string(attribute_value) + + def _convert_numeric_to_float(self, value): + if isinstance(value, str): + return float(value.replace(",", ".")) + + if sys.version_info[0] == 2 and isinstance(value, unicode): # noqa: F821 + return float(value.replace(",", ".")) # Handle unicode strings on Python 2.7 + + return float(value) + + def _get_user_attribute_value_as_seconds_since_epoch(self, attribute_value): + if isinstance(attribute_value, datetime): + return get_seconds_since_epoch(attribute_value) + + return self._convert_numeric_to_float(attribute_value) + + def _get_user_attribute_value_as_string_list(self, attribute_value): + if not isinstance(attribute_value, list): + attribute_value_list = json.loads(attribute_value) + else: + attribute_value_list = attribute_value + if not isinstance(attribute_value_list, list): + raise ValueError() + + return attribute_value_list + + def _handle_invalid_user_attribute(self, comparison_attribute, comparator, comparison_value, key, validation_error): + """ + returns: evaluation error message + """ + error = 'cannot evaluate, the User.%s attribute is invalid (%s)' % (comparison_attribute, validation_error) + self.log.warning('Cannot evaluate condition (%s) for setting \'%s\' ' + '(%s). Please check the User.%s attribute and make sure that its value corresponds to the ' + 'comparison operator.', + self._format_rule(comparison_attribute, comparator, comparison_value), key, + validation_error, comparison_attribute, event_id=3004) + return error + + def _evaluate_percentage_options(self, percentage_options, context, percentage_rule_attribute, default_variation_id, log_builder): # noqa: C901, E501 + """ + returns: evaluation_result, percentage_value, percentage_variation_id, percentage_option + """ + if len(percentage_options) == 0: + return False, None, None, None + + user = context.user + key = context.key if user is None: - if not user_has_invalid_type and (len(rollout_rules) > 0 or len(rollout_percentage_items) > 0): + if not context.is_missing_user_object_logged: self.log.warning('Cannot evaluate targeting rules and %% options for setting \'%s\' ' '(User Object is missing). ' 'You should pass a User Object to the evaluation methods like `get_value()` ' 'in order to make targeting work properly. ' 'Read more: https://configcat.com/docs/advanced/user-object/', key, event_id=3001) - return_value = setting_descriptor.get(VALUE, default_value) - return_variation_id = setting_descriptor.get(VARIATION_ID, default_variation_id) - self.log.info('%s', 'Returning [%s]' % str(return_value), event_id=5000) - return return_value, return_variation_id, None, None, None + context.is_missing_user_object_logged = True - log_entries = ['Evaluating get_value(\'%s\').' % key, 'User object:\n%s' % str(user)] + if log_builder: + log_builder.new_line('Skipping % options because the User Object is missing.') + return False, None, None, None - try: - # Evaluate targeting rules - for rollout_rule in rollout_rules: - comparison_attribute = rollout_rule.get(COMPARISON_ATTRIBUTE) - comparison_value = rollout_rule.get(COMPARISON_VALUE) - comparator = rollout_rule.get(COMPARATOR) - - user_value = user.get_attribute(comparison_attribute) - if user_value is None or not user_value: - log_entries.append( - self._format_no_match_rule(comparison_attribute, user_value, comparator, comparison_value) - ) - continue + user_attribute_name = percentage_rule_attribute if percentage_rule_attribute is not None else 'Identifier' + if percentage_rule_attribute is not None: + user_key = user.get_attribute(percentage_rule_attribute) + else: + user_key = user.get_identifier() + if percentage_rule_attribute is not None and user_key is None: + if not context.is_missing_user_object_attribute_logged: + self.log.warning('Cannot evaluate %% options for setting \'%s\' ' + '(the User.%s attribute is missing). You should set the User.%s attribute in order to make ' + 'targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/', + key, percentage_rule_attribute, percentage_rule_attribute, + event_id=3003) + context.is_missing_user_object_attribute_logged = True - value = rollout_rule.get(VALUE) - variation_id = rollout_rule.get(VARIATION_ID, default_variation_id) - - # IS ONE OF - if comparator == 0: - if str(user_value) in [x.strip() for x in str(comparison_value).split(',')]: - log_entries.append(self._format_match_rule(comparison_attribute, user_value, comparator, - comparison_value, value)) - return value, variation_id, rollout_rule, None, None - # IS NOT ONE OF - elif comparator == 1: - if str(user_value) not in [x.strip() for x in str(comparison_value).split(',')]: - log_entries.append(self._format_match_rule(comparison_attribute, user_value, comparator, - comparison_value, value)) - return value, variation_id, rollout_rule, None, None - # CONTAINS - elif comparator == 2: - if str(user_value).__contains__(str(comparison_value)): - log_entries.append(self._format_match_rule(comparison_attribute, user_value, comparator, - comparison_value, value)) - return value, variation_id, rollout_rule, None, None - # DOES NOT CONTAIN - elif comparator == 3: - if not str(user_value).__contains__(str(comparison_value)): - log_entries.append(self._format_match_rule(comparison_attribute, user_value, comparator, - comparison_value, value)) - return value, variation_id, rollout_rule, None, None - - # IS ONE OF, IS NOT ONE OF (Semantic version) - elif 4 <= comparator <= 5: - try: - match = False - for x in filter(None, [x.strip() for x in str(comparison_value).split(',')]): - match = semver.VersionInfo.parse(str(user_value).strip()).match('==' + x) or match - if (match and comparator == 4) or (not match and comparator == 5): - log_entries.append(self._format_match_rule(comparison_attribute, user_value, comparator, - comparison_value, value)) - return value, variation_id, rollout_rule, None, None - except ValueError as e: - message = self._format_validation_error_rule(comparison_attribute, user_value, comparator, - comparison_value, str(e)) - self.log.warning(message) - log_entries.append(message) - continue + if log_builder: + log_builder.new_line( + 'Skipping %% options because the User.%s attribute is missing.' % user_attribute_name) + return False, None, None, None - # LESS THAN, LESS THAN OR EQUALS TO, GREATER THAN, GREATER THAN OR EQUALS TO (Semantic version) - elif 6 <= comparator <= 9: - try: - if semver.VersionInfo.parse(str(user_value).strip()).match( - self.SEMANTIC_VERSION_COMPARATORS[comparator - 6] + str(comparison_value).strip() - ): - log_entries.append(self._format_match_rule(comparison_attribute, user_value, comparator, - comparison_value, value)) - return value, variation_id, rollout_rule, None, None - except ValueError as e: - message = self._format_validation_error_rule(comparison_attribute, user_value, comparator, - comparison_value, str(e)) - self.log.warning(message) - log_entries.append(message) - continue - elif 10 <= comparator <= 15: - try: - user_value_float = float(str(user_value).replace(",", ".")) - comparison_value_float = float(str(comparison_value).replace(",", ".")) - - if (comparator == 10 and user_value_float == comparison_value_float) \ - or (comparator == 11 and user_value_float != comparison_value_float) \ - or (comparator == 12 and user_value_float < comparison_value_float) \ - or (comparator == 13 and user_value_float <= comparison_value_float) \ - or (comparator == 14 and user_value_float > comparison_value_float) \ - or (comparator == 15 and user_value_float >= comparison_value_float): - log_entries.append(self._format_match_rule(comparison_attribute, user_value, comparator, - comparison_value, value)) - return value, variation_id, rollout_rule, None, None - except Exception as e: - message = self._format_validation_error_rule(comparison_attribute, user_value, comparator, - comparison_value, str(e)) - self.log.warning(message) - log_entries.append(message) - continue - # IS ONE OF (Sensitive) - elif comparator == 16: - if str(hashlib.sha1(user_value.encode('utf8')).hexdigest()) in [ # NOSONAR python:S4790 - x.strip() for x in str(comparison_value).split(',') - ]: - log_entries.append(self._format_match_rule(comparison_attribute, user_value, comparator, - comparison_value, value)) - return value, variation_id, rollout_rule, None, None - # IS NOT ONE OF (Sensitive) - elif comparator == 17: - if str(hashlib.sha1(user_value.encode('utf8')).hexdigest()) not in [ # NOSONAR python:S4790 - x.strip() for x in str(comparison_value).split(',') - ]: - log_entries.append(self._format_match_rule(comparison_attribute, user_value, comparator, - comparison_value, value)) - return value, variation_id, rollout_rule, None, None - - log_entries.append(self._format_no_match_rule(comparison_attribute, user_value, comparator, comparison_value)) - - # Evaluate variations - if len(rollout_percentage_items) > 0: - user_key = user.get_identifier() - hash_candidate = ('%s%s' % (key, user_key)).encode('utf-8') - hash_val = int(hashlib.sha1(hash_candidate).hexdigest()[:7], 16) % 100 - - bucket = 0 - for rollout_percentage_item in rollout_percentage_items or []: - bucket += rollout_percentage_item.get(PERCENTAGE, 0) - if hash_val < bucket: - percentage_value = rollout_percentage_item.get(VALUE) - variation_id = rollout_percentage_item.get(VARIATION_ID, default_variation_id) - log_entries.append('Evaluating %% options. Returning %s' % percentage_value) - return percentage_value, variation_id, None, rollout_percentage_item, None - - return_value = setting_descriptor.get(VALUE, default_value) - return_variation_id = setting_descriptor.get(VARIATION_ID, default_variation_id) - log_entries.append('Returning %s' % return_value) - return return_value, return_variation_id, None, None, None - finally: - self.log.info('%s', '\n'.join(log_entries), event_id=5000) + hash_candidate = ('%s%s' % (key, self._user_attribute_value_to_string(user_key))).encode('utf-8') + hash_val = int(hashlib.sha1(hash_candidate).hexdigest()[:7], 16) % 100 + + bucket = 0 + index = 1 + for percentage_option in percentage_options or []: + percentage = percentage_option.get(PERCENTAGE, 0) + bucket += percentage + if hash_val < bucket: + percentage_value = get_value(percentage_option, context.setting_type) + variation_id = percentage_option.get(VARIATION_ID, default_variation_id) + if log_builder: + log_builder.new_line('Evaluating %% options based on the User.%s attribute:' % + user_attribute_name) + log_builder.new_line('- Computing hash in the [0..99] range from User.%s => %s ' + '(this value is sticky and consistent across all SDKs)' % + (user_attribute_name, hash_val)) + log_builder.new_line("- Hash value %s selects %% option %s (%s%%), '%s'." % + (hash_val, index, percentage, percentage_value)) + return True, percentage_value, variation_id, percentage_option + index += 1 + + return False, None, None, None + + def _evaluate_conditions(self, conditions, context, salt, config, log_builder, value): # noqa: C901 + first_condition = True + condition_result = True + error = None + for condition in conditions: + user_condition = condition.get(USER_CONDITION) + segment_condition = condition.get(SEGMENT_CONDITION) + prerequisite_flag_condition = condition.get(PREREQUISITE_FLAG_CONDITION) + + if first_condition: + if log_builder: + log_builder.new_line('- IF ') + log_builder.increase_indent() + first_condition = False + else: + if log_builder: + log_builder.new_line('AND ') + + if user_condition is not None: + result, error = self._evaluate_user_condition(user_condition, context, context.key, salt, + log_builder) + if log_builder and len(conditions) > 1: + log_builder.append('=> {}'.format('true' if result else 'false')) + if not result: + log_builder.append(', skipping the remaining AND conditions') + + if not result or error: + condition_result = False + break + elif segment_condition is not None: + result, error = self._evaluate_segment_condition(segment_condition, context, salt, log_builder) + if log_builder: + if len(conditions) > 1: + log_builder.append(' => {}'.format('true' if result else 'false')) + if not result: + log_builder.append(', skipping the remaining AND conditions') + elif error is None: + log_builder.new_line() + + if not result or error: + condition_result = False + break + elif prerequisite_flag_condition is not None: + result = self._evaluate_prerequisite_flag_condition(prerequisite_flag_condition, context, config, log_builder) + if not result: + condition_result = False + break + + if log_builder: + if len(conditions) > 1: + log_builder.new_line() + if error: + log_builder.append('THEN %s => %s' % ( + "'" + str(value) + "'" if value is not None else '% options', + error)) + log_builder.new_line( + 'The current targeting rule is ignored and the evaluation continues with the next rule.') + else: + log_builder.append('THEN %s => %s' % ( + "'" + str(value) + "'" if value is not None else '% options', + 'MATCH, applying rule' if condition_result else 'no match')) + if len(conditions) > 0: + log_builder.decrease_indent() + + return condition_result + + def _evaluate_prerequisite_flag_condition(self, prerequisite_flag_condition, context, config, log_builder): # noqa: C901, E501 + prerequisite_key = prerequisite_flag_condition.get(PREREQUISITE_FLAG_KEY) + prerequisite_comparator = prerequisite_flag_condition.get(PREREQUISITE_COMPARATOR) + + # Check if the prerequisite key exists + settings = config.get(FEATURE_FLAGS, {}) + if prerequisite_key is None or settings.get(prerequisite_key) is None: + raise ValueError('Prerequisite flag key is missing or invalid.') + + prerequisite_condition_result = False + prerequisite_flag_setting_type = settings[prerequisite_key].get(SETTING_TYPE) + prerequisite_comparison_value_type = get_value_type(prerequisite_flag_condition) + + # Type mismatch check + if prerequisite_comparison_value_type != SettingType.to_type(prerequisite_flag_setting_type): + raise ValueError("Type mismatch between comparison value type %s and type %s of prerequisite flag '%s'" % + (prerequisite_comparison_value_type, SettingType.to_type(prerequisite_flag_setting_type), + prerequisite_key)) + + prerequisite_comparison_value = get_value(prerequisite_flag_condition, prerequisite_flag_setting_type) + + prerequisite_condition = ("Flag '%s' %s '%s'" % + (prerequisite_key, PREREQUISITE_COMPARATOR_TEXTS[prerequisite_comparator], + str(prerequisite_comparison_value))) + + # Circular dependency check + visited_keys = context.visited_keys + visited_keys.append(context.key) + if prerequisite_key in visited_keys: + depending_flags = ' -> '.join("'{}'".format(s) for s in list(visited_keys) + [prerequisite_key]) + raise ValueError('Circular dependency detected between the following depending flags: %s.' % depending_flags) + + if log_builder: + log_builder.append(prerequisite_condition) + log_builder.new_line('(').increase_indent() + log_builder.new_line("Evaluating prerequisite flag '%s':" % prerequisite_key) + + prerequisite_value, _, _, _, _ = self.evaluate(prerequisite_key, context.user, None, None, config, + log_builder, context.visited_keys) + + if visited_keys: + visited_keys.pop() + + if log_builder: + log_builder.new_line("Prerequisite flag evaluation result: '%s'." % str(prerequisite_value)) + log_builder.new_line("Condition (Flag '%s' %s '%s') evaluates to " % + (prerequisite_key, PREREQUISITE_COMPARATOR_TEXTS[prerequisite_comparator], + str(prerequisite_comparison_value))) + + # EQUALS + if prerequisite_comparator == PrerequisiteComparator.EQUALS: + if prerequisite_value == prerequisite_comparison_value: + prerequisite_condition_result = True + # DOES NOT EQUAL + elif prerequisite_comparator == PrerequisiteComparator.NOT_EQUALS: + if prerequisite_value != prerequisite_comparison_value: + prerequisite_condition_result = True + + if log_builder: + log_builder.append('%s.' % ('true' if prerequisite_condition_result else 'false')) + log_builder.decrease_indent().new_line(')').new_line() + + return prerequisite_condition_result + + def _evaluate_segment_condition(self, segment_condition, context, salt, log_builder): # noqa: C901 + user = context.user + key = context.key + + segment = segment_condition[INLINE_SEGMENT] + if segment is None: + raise ValueError('Segment reference is invalid.') + + segment_name = segment.get(SEGMENT_NAME, '') + segment_comparator = segment_condition.get(SEGMENT_COMPARATOR) + segment_conditions = segment.get(SEGMENT_CONDITIONS, []) + + if user is None: + if not context.is_missing_user_object_logged: + self.log.warning('Cannot evaluate targeting rules and %% options for setting \'%s\' ' + '(User Object is missing). ' + 'You should pass a User Object to the evaluation methods like `get_value()` ' + 'in order to make targeting work properly. ' + 'Read more: https://configcat.com/docs/advanced/user-object/', + key, event_id=3001) + context.is_missing_user_object_logged = True + if log_builder: + log_builder.append("User %s '%s' " % (SEGMENT_COMPARATOR_TEXTS[segment_comparator], segment_name)) + return False, 'cannot evaluate, User Object is missing' + + # IS IN SEGMENT, IS NOT IN SEGMENT + if segment_comparator in [SegmentComparator.IS_IN, SegmentComparator.IS_NOT_IN]: + if log_builder: + log_builder.append("User %s '%s'" % + (SEGMENT_COMPARATOR_TEXTS[segment_comparator], segment_name)) + log_builder.new_line('(').increase_indent() + log_builder.new_line("Evaluating segment '%s':" % segment_name) + + # Set initial condition result based on comparator + segment_condition_result = segment_comparator == SegmentComparator.IS_IN + + # Evaluate segment conditions (logically connected by AND) + first_segment_rule = True + error = None + for segment_condition in segment_conditions: + if first_segment_rule: + if log_builder: + log_builder.new_line('- IF ') + log_builder.increase_indent() + first_segment_rule = False + else: + if log_builder: + log_builder.new_line('AND ') + + result, error = self._evaluate_user_condition(segment_condition, context, segment_name, salt, log_builder) + if log_builder: + log_builder.append('=> {}'.format('true' if result else 'false')) + if not result: + log_builder.append(', skipping the remaining AND conditions') + + if not result: + segment_condition_result = False if segment_comparator == SegmentComparator.IS_IN else True + break + + if log_builder: + log_builder.decrease_indent() + segment_evaluation_result = segment_condition_result if segment_comparator == SegmentComparator.IS_IN \ + else not segment_condition_result + log_builder.new_line('Segment evaluation result: ') + if not error: + log_builder.append('User IS%sIN SEGMENT.' % (' ' if segment_evaluation_result else ' NOT ')) + else: + log_builder.append('%s.' % error) + + log_builder.new_line("Condition (User %s '%s') " % (SEGMENT_COMPARATOR_TEXTS[segment_comparator], + segment_name)) + if not error: + log_builder.append("evaluates to %s." % ('true' if segment_condition_result else 'false')) + else: + log_builder.append('failed to evaluate.') + + log_builder.decrease_indent().new_line(')') + if error: + log_builder.new_line() + + return segment_condition_result, error + + return False, None + + def _evaluate_user_condition(self, user_condition, context, context_salt, salt, log_builder): # noqa: C901, E501 + """ + returns result of user condition, error + """ + + user = context.user + key = context.key + + comparison_attribute = user_condition.get(COMPARISON_ATTRIBUTE) + comparator = user_condition.get(COMPARATOR) + comparison_value = user_condition.get(COMPARISON_VALUES[comparator]) + condition = self._format_rule(comparison_attribute, comparator, comparison_value) + error = None + + if comparison_attribute is None: + raise ValueError('Comparison attribute name is missing.') + + if log_builder: + log_builder.append(condition + ' ') + + if user is None: + if not context.is_missing_user_object_logged: + self.log.warning('Cannot evaluate targeting rules and %% options for setting \'%s\' ' + '(User Object is missing). ' + 'You should pass a User Object to the evaluation methods like `get_value()` ' + 'in order to make targeting work properly. ' + 'Read more: https://configcat.com/docs/advanced/user-object/', + key, event_id=3001) + context.is_missing_user_object_logged = True + error = 'cannot evaluate, User Object is missing' + return False, error + + user_value = user.get_attribute(comparison_attribute) + if user_value is None or not user_value: + self.log.warning('Cannot evaluate condition (%s) for setting \'%s\' ' + '(the User.%s attribute is missing). You should set the User.%s attribute in order to make ' + 'targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/', + condition, key, comparison_attribute, comparison_attribute, event_id=3003) + error = 'cannot evaluate, the User.{} attribute is missing'.format(comparison_attribute) + return False, error + + # IS ONE OF + if comparator == Comparator.IS_ONE_OF: + user_value = self._get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key) + if user_value in comparison_value: + return True, error + # IS NOT ONE OF + elif comparator == Comparator.IS_NOT_ONE_OF: + user_value = self._get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key) + if user_value not in comparison_value: + return True, error + # CONTAINS ANY OF + elif comparator == Comparator.CONTAINS_ANY_OF: + user_value = self._get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key) + for comparison in comparison_value: + if comparison in user_value: + return True, error + # NOT CONTAINS ANY OF + elif comparator == Comparator.NOT_CONTAINS_ANY_OF: + user_value = self._get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key) + if not any(comparison in user_value for comparison in comparison_value): + return True, error + # IS ONE OF, IS NOT ONE OF (Semantic version) + elif Comparator.IS_ONE_OF_SEMVER <= comparator <= Comparator.IS_NOT_ONE_OF_SEMVER: + try: + match = False + for x in filter(None, [x.strip() for x in comparison_value]): + match = semver.VersionInfo.parse(str(user_value).strip()).match('==' + str(x)) or match + if match == (comparator == Comparator.IS_ONE_OF_SEMVER): + return True, error + except ValueError: + validation_error = "'%s' is not a valid semantic version" % str(user_value).strip() + error = self._handle_invalid_user_attribute(comparison_attribute, comparator, comparison_value, key, + validation_error) + return False, error + # LESS THAN, LESS THAN OR EQUALS TO, GREATER THAN, GREATER THAN OR EQUALS TO (Semantic version) + elif Comparator.LESS_THAN_SEMVER <= comparator <= Comparator.GREATER_THAN_OR_EQUAL_SEMVER: + try: + if semver.VersionInfo.parse(str(user_value).strip()).match( + COMPARATOR_TEXTS[comparator] + str(comparison_value).strip() + ): + return True, error + except ValueError: + validation_error = "'%s' is not a valid semantic version" % str(user_value).strip() + error = self._handle_invalid_user_attribute(comparison_attribute, comparator, comparison_value, key, + validation_error) + return False, error + # =, <>, <, <=, >, >= (number) + elif Comparator.EQUALS_NUMBER <= comparator <= Comparator.GREATER_THAN_OR_EQUAL_NUMBER: + try: + user_value_float = self._convert_numeric_to_float(user_value) + except ValueError: + validation_error = "'%s' is not a valid decimal number" % str(user_value) + error = self._handle_invalid_user_attribute(comparison_attribute, comparator, comparison_value, key, + validation_error) + return False, error + + comparison_value_float = float(comparison_value) + + if (comparator == Comparator.EQUALS_NUMBER and user_value_float == comparison_value_float) \ + or (comparator == Comparator.NOT_EQUALS_NUMBER and user_value_float != comparison_value_float) \ + or (comparator == Comparator.LESS_THAN_NUMBER and user_value_float < comparison_value_float) \ + or (comparator == Comparator.LESS_THAN_OR_EQUAL_NUMBER and user_value_float <= comparison_value_float) \ + or (comparator == Comparator.GREATER_THAN_NUMBER and user_value_float > comparison_value_float) \ + or (comparator == Comparator.GREATER_THAN_OR_EQUAL_NUMBER and user_value_float >= comparison_value_float): + return True, error + # IS ONE OF (hashed) + elif comparator == Comparator.IS_ONE_OF_HASHED: + user_value = self._get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key) + if sha256(encode_utf8(user_value), salt, context_salt) in comparison_value: + return True, error + # IS NOT ONE OF (hashed) + elif comparator == Comparator.IS_NOT_ONE_OF_HASHED: + user_value = self._get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key) + if sha256(encode_utf8(user_value), salt, context_salt) not in comparison_value: + return True, error + # BEFORE, AFTER (UTC datetime) + elif Comparator.BEFORE_DATETIME <= comparator <= Comparator.AFTER_DATETIME: + try: + user_value_float = self._get_user_attribute_value_as_seconds_since_epoch(user_value) + except ValueError: + validation_error = "'%s' is not a valid Unix timestamp (number of seconds elapsed since Unix epoch)" % \ + str(user_value) + error = self._handle_invalid_user_attribute(comparison_attribute, comparator, comparison_value, key, + validation_error) + return False, error + + comparison_value_float = float(comparison_value) + + if (comparator == Comparator.BEFORE_DATETIME and user_value_float < comparison_value_float) \ + or (comparator == Comparator.AFTER_DATETIME and user_value_float > comparison_value_float): + return True, error + # EQUALS (hashed) + elif comparator == Comparator.EQUALS_HASHED: + user_value = self._get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key) + if sha256(encode_utf8(user_value), salt, context_salt) == comparison_value: + return True, error + # NOT EQUALS (hashed) + elif comparator == Comparator.NOT_EQUALS_HASHED: + user_value = self._get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key) + if sha256(encode_utf8(user_value), salt, context_salt) != comparison_value: + return True, error + # STARTS WITH ANY OF, NOT STARTS WITH ANY OF, ENDS WITH ANY OF, NOT ENDS WITH ANY OF (hashed) + elif Comparator.STARTS_WITH_ANY_OF_HASHED <= comparator <= Comparator.NOT_ENDS_WITH_ANY_OF_HASHED: + for comparison in comparison_value: + underscore_index = comparison.index('_') + length = int(comparison[:underscore_index]) + user_value = self._get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key) + user_value_utf8 = encode_utf8(user_value) + + if len(user_value_utf8) >= length: + comparison_string = comparison[underscore_index + 1:] + if (comparator == Comparator.STARTS_WITH_ANY_OF_HASHED + and sha256(user_value_utf8[:length], salt, context_salt) == comparison_string) or \ + (comparator == Comparator.ENDS_WITH_ANY_OF_HASHED + and sha256(user_value_utf8[-length:], salt, context_salt) == comparison_string): + return True, error + elif (comparator == Comparator.NOT_STARTS_WITH_ANY_OF_HASHED + and sha256(user_value_utf8[:length], salt, context_salt) == comparison_string) or \ + (comparator == Comparator.NOT_ENDS_WITH_ANY_OF_HASHED + and sha256(user_value_utf8[-length:], salt, context_salt) == comparison_string): + return False, None + + # If no matches were found for the NOT_* conditions, then return True + if comparator in [Comparator.NOT_STARTS_WITH_ANY_OF_HASHED, Comparator.NOT_ENDS_WITH_ANY_OF_HASHED]: + return True, error + # ARRAY CONTAINS ANY OF, ARRAY NOT CONTAINS ANY OF (hashed) + elif Comparator.ARRAY_CONTAINS_ANY_OF_HASHED <= comparator <= Comparator.ARRAY_NOT_CONTAINS_ANY_OF_HASHED: + try: + user_value_list = self._get_user_attribute_value_as_string_list(user_value) + + if sys.version_info[0] == 2: + user_value_list = unicode_to_utf8(user_value_list) # On Python 2.7, convert unicode to utf-8 + except ValueError: + validation_error = "'%s' is not a valid string array" % str(user_value) + error = self._handle_invalid_user_attribute(comparison_attribute, comparator, comparison_value, key, + validation_error) + return False, error + + hashed_user_values = [sha256(encode_utf8(x), salt, context_salt) for x in user_value_list] + if comparator == Comparator.ARRAY_CONTAINS_ANY_OF_HASHED: + for comparison in comparison_value: + if comparison in hashed_user_values: + return True, error + else: + for comparison in comparison_value: + if comparison in hashed_user_values: + return False, None + return True, error + # EQUALS + elif comparator == Comparator.EQUALS: + user_value = self._get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key) + if user_value == comparison_value: + return True, error + # NOT EQUALS + elif comparator == Comparator.NOT_EQUALS: + user_value = self._get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key) + if user_value != comparison_value: + return True, error + # STARTS WITH ANY OF, NOT STARTS WITH ANY OF, ENDS WITH ANY OF, NOT ENDS WITH ANY OF + elif Comparator.STARTS_WITH_ANY_OF <= comparator <= Comparator.NOT_ENDS_WITH_ANY_OF: + user_value = self._get_user_attribute_value_as_text(comparison_attribute, user_value, condition, key) + for comparison in comparison_value: + if (comparator == Comparator.STARTS_WITH_ANY_OF and user_value.startswith(comparison)) or \ + (comparator == Comparator.ENDS_WITH_ANY_OF and user_value.endswith(comparison)): + return True, error + elif (comparator == Comparator.NOT_STARTS_WITH_ANY_OF and user_value.startswith(comparison)) \ + or (comparator == Comparator.NOT_ENDS_WITH_ANY_OF and user_value.endswith(comparison)): + return False, None + + # If no matches were found for the NOT_* conditions, then return True + if comparator in [Comparator.NOT_STARTS_WITH_ANY_OF, Comparator.NOT_ENDS_WITH_ANY_OF]: + return True, error + # ARRAY CONTAINS ANY OF, ARRAY NOT CONTAINS ANY OF + elif Comparator.ARRAY_CONTAINS_ANY_OF <= comparator <= Comparator.ARRAY_NOT_CONTAINS_ANY_OF: + try: + user_value_list = self._get_user_attribute_value_as_string_list(user_value) - def _format_match_rule(self, comparison_attribute, user_value, comparator, comparison_value, value): - return 'Evaluating rule: [%s:%s] [%s] [%s] => match, returning: %s' \ - % (comparison_attribute, user_value, self.COMPARATOR_TEXTS[comparator], comparison_value, value) + if sys.version_info[0] == 2: + user_value_list = unicode_to_utf8(user_value_list) # On Python 2.7, convert unicode to utf-8 + except ValueError: + validation_error = "'%s' is not a valid string array" % str(user_value) + error = self._handle_invalid_user_attribute(comparison_attribute, comparator, comparison_value, key, + validation_error) + return False, error - def _format_no_match_rule(self, comparison_attribute, user_value, comparator, comparison_value): - return 'Evaluating rule: [%s:%s] [%s] [%s] => no match' \ - % (comparison_attribute, user_value, self.COMPARATOR_TEXTS[comparator], comparison_value) + if comparator == Comparator.ARRAY_CONTAINS_ANY_OF: + for comparison in comparison_value: + if comparison in user_value_list: + return True, error + else: + for comparison in comparison_value: + if comparison in user_value_list: + return False, None + return True, error - def _format_validation_error_rule(self, comparison_attribute, user_value, comparator, comparison_value, error): - return 'Evaluating rule: [%s:%s] [%s] [%s] => SKIP rule. Validation error: %s' \ - % (comparison_attribute, user_value, self.COMPARATOR_TEXTS[comparator], comparison_value, error) + return False, error diff --git a/configcatclient/user.py b/configcatclient/user.py index bb377d7..6eed4f2 100644 --- a/configcatclient/user.py +++ b/configcatclient/user.py @@ -2,13 +2,60 @@ __PREDEFINED__ = ['Identifier', 'Email', 'Country'] +from collections import OrderedDict +from datetime import datetime + class User(object): """ - The user object for variation evaluation + User Object. Contains user attributes which are used for evaluating targeting rules and percentage options. """ def __init__(self, identifier, email=None, country=None, custom=None): + """ + Initialize a User object. + + Args: + identifier: The unique identifier of the user or session (e.g. email address, primary key, session ID, etc.) + email: Email address of the user. + country: Country of the user. + custom: Custom attributes of the user for advanced targeting rule definitions (e.g. role, subscription type, etc.) + + All comparators support string values as User Object attribute (in some cases they need to be provided in a + specific format though, see below), but some of them also support other types of values. It depends on the + comparator how the values will be handled. The following rules apply: + + Text-based comparators (EQUALS, IS_ONE_OF, etc.) + * accept string values, + * all other values are automatically converted to string + (a warning will be logged but evaluation will continue as normal). + + SemVer-based comparators (IS_ONE_OF_SEMVER, LESS_THAN_SEMVER, GREATER_THAN_SEMVER, etc.) + * accept string values containing a properly formatted, valid semver value, + * all other values are considered invalid + (a warning will be logged and the currently evaluated targeting rule will be skipped). + + Number-based comparators (EQUALS_NUMBER, LESS_THAN_NUMBER, GREATER_THAN_OR_EQUAL_NUMBER, etc.) + * accept float values and all other numeric values which can safely be converted to float, + * accept string values containing a properly formatted, valid float value, + * all other values are considered invalid + (a warning will be logged and the currently evaluated targeting rule will be skipped). + + Date time-based comparators (BEFORE_DATETIME / AFTER_DATETIME) + * accept datetime values, which are automatically converted to a second-based Unix timestamp + (datetime values with naive timezone are considered to be in UTC), + * accept float values representing a second-based Unix timestamp + and all other numeric values which can safely be converted to float, + * accept string values containing a properly formatted, valid float value, + * all other values are considered invalid + (a warning will be logged and the currently evaluated targeting rule will be skipped). + + String array-based comparators (ARRAY_CONTAINS_ANY_OF / ARRAY_NOT_CONTAINS_ANY_OF) + * accept arrays of strings, + * accept string values containing a valid JSON string which can be deserialized to an array of strings, + * all other values are considered invalid + (a warning will be logged and the currently evaluated targeting rule will be skipped). + """ self.__identifier = identifier if identifier is not None else '' self.__data = {'Identifier': identifier, 'Email': email, 'Country': country} self.__custom = custom @@ -24,10 +71,19 @@ def get_attribute(self, attribute): return self.__custom.get(attribute) if self.__custom else None def __str__(self): - dump = { - 'Identifier': self.__identifier, - 'Email': self.__data.get('Email'), - 'Country': self.__data.get('Country'), - 'Custom': self.__custom, - } - return json.dumps(dump, indent=4) + def serializer(obj): + if isinstance(obj, datetime): + return obj.isoformat() + + raise TypeError("Type not serializable") + + dump = OrderedDict([ + ('Identifier', self.__identifier), + ('Email', self.__data.get('Email')), + ('Country', self.__data.get('Country')) + ]) + if self.__custom: + dump.update(self.__custom) + + filtered_dump = OrderedDict([(k, v) for k, v in dump.items() if v is not None]) + return json.dumps(filtered_dump, separators=(',', ':'), default=serializer) diff --git a/configcatclient/utils.py b/configcatclient/utils.py index dd53e45..5b12ba8 100644 --- a/configcatclient/utils.py +++ b/configcatclient/utils.py @@ -3,7 +3,12 @@ from qualname import qualname from datetime import datetime -epoch_time = datetime(1970, 1, 1) +try: + from datetime import timezone +except ImportError: + import pytz as timezone # On Python 2.7, datetime.timezone is not available. We use pytz instead. + +epoch_time = datetime(1970, 1, 1, tzinfo=timezone.utc) distant_future = sys.float_info.max distant_past = 0 @@ -57,16 +62,48 @@ def method_is_called_from(method, level=1): def get_utc_now(): - return datetime.utcnow() + return datetime.now(timezone.utc) def get_seconds_since_epoch(date_time): + # if there is no timezone info, assume UTC + if date_time.tzinfo is None: + date_time = date_time.replace(tzinfo=timezone.utc) + return (date_time - epoch_time).total_seconds() def get_date_time(seconds_since_epoch): - return datetime.utcfromtimestamp(seconds_since_epoch) + return datetime.fromtimestamp(seconds_since_epoch, timezone.utc) def get_utc_now_seconds_since_epoch(): return get_seconds_since_epoch(get_utc_now()) + + +def unicode_to_utf8(data): + """ + Convert unicode data in a collection to UTF-8 data. Used for supporting unicode config json strings on Python 2.7. + Once Python 2.7 is no longer supported, this function can be removed. + """ + if isinstance(data, dict): + return {unicode_to_utf8(key): unicode_to_utf8(value) for key, value in data.iteritems()} + elif isinstance(data, list): + return [unicode_to_utf8(element) for element in data] + elif isinstance(data, unicode): # noqa: F821 (ignore warning: unicode is undefined in Python 3) + return data.encode('utf-8') + else: + return data + + +def encode_utf8(value): + """ + Get the UTF-8 encoded value of a string. Used for supporting unicode config json strings on Python 2.7. + If the value is already UTF-8 encoded, it is returned as is. + Once Python 2.7 is no longer supported, this function can be removed. + The use of this function can be replaced with encode() method of the string: value.encode('utf-8') + """ + try: + return value.encode('utf-8') + except UnicodeDecodeError: + return value diff --git a/configcatclient/version.py b/configcatclient/version.py index 56f6e76..6e70da1 100644 --- a/configcatclient/version.py +++ b/configcatclient/version.py @@ -1 +1 @@ -CONFIGCATCLIENT_VERSION = "8.0.1" +CONFIGCATCLIENT_VERSION = "9.0.0" diff --git a/configcatclienttests/data/evaluation/1_targeting_rule.json b/configcatclienttests/data/evaluation/1_targeting_rule.json new file mode 100644 index 0000000..596bd2b --- /dev/null +++ b/configcatclienttests/data/evaluation/1_targeting_rule.json @@ -0,0 +1,41 @@ +{ + "configUrl": "https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d62463-86ec-8fde-f5b5-1c5c426fc830/244cf8b0-f604-11e8-b543-f23c917f9d8d", + "sdkKey": "PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A", + "tests": [ + { + "key": "stringContainsDogDefaultCat", + "defaultValue": "default", + "returnValue": "Cat", + "expectedLog": "1_rule_no_user.txt" + }, + { + "key": "stringContainsDogDefaultCat", + "defaultValue": "default", + "user": { + "Identifier": "12345" + }, + "returnValue": "Cat", + "expectedLog": "1_rule_no_targeted_attribute.txt" + }, + { + "key": "stringContainsDogDefaultCat", + "defaultValue": "default", + "user": { + "Identifier": "12345", + "Email": "joe@example.com" + }, + "returnValue": "Cat", + "expectedLog": "1_rule_not_matching_targeted_attribute.txt" + }, + { + "key": "stringContainsDogDefaultCat", + "defaultValue": "default", + "user": { + "Identifier": "12345", + "Email": "joe@configcat.com" + }, + "returnValue": "Dog", + "expectedLog": "1_rule_matching_targeted_attribute.txt" + } + ] +} diff --git a/configcatclienttests/data/evaluation/1_targeting_rule/1_rule_matching_targeted_attribute.txt b/configcatclienttests/data/evaluation/1_targeting_rule/1_rule_matching_targeted_attribute.txt new file mode 100644 index 0000000..f05c6f6 --- /dev/null +++ b/configcatclienttests/data/evaluation/1_targeting_rule/1_rule_matching_targeted_attribute.txt @@ -0,0 +1,4 @@ +INFO [5000] Evaluating 'stringContainsDogDefaultCat' for User '{"Identifier":"12345","Email":"joe@configcat.com"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN 'Dog' => MATCH, applying rule + Returning 'Dog'. diff --git a/configcatclienttests/data/evaluation/1_targeting_rule/1_rule_no_targeted_attribute.txt b/configcatclienttests/data/evaluation/1_targeting_rule/1_rule_no_targeted_attribute.txt new file mode 100644 index 0000000..80702e9 --- /dev/null +++ b/configcatclienttests/data/evaluation/1_targeting_rule/1_rule_no_targeted_attribute.txt @@ -0,0 +1,6 @@ +WARNING [3003] Cannot evaluate condition (User.Email CONTAINS ANY OF ['@configcat.com']) for setting 'stringContainsDogDefaultCat' (the User.Email attribute is missing). You should set the User.Email attribute in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'stringContainsDogDefaultCat' for User '{"Identifier":"12345"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN 'Dog' => cannot evaluate, the User.Email attribute is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Cat'. diff --git a/configcatclienttests/data/evaluation/1_targeting_rule/1_rule_no_user.txt b/configcatclienttests/data/evaluation/1_targeting_rule/1_rule_no_user.txt new file mode 100644 index 0000000..3b01ec8 --- /dev/null +++ b/configcatclienttests/data/evaluation/1_targeting_rule/1_rule_no_user.txt @@ -0,0 +1,6 @@ +WARNING [3001] Cannot evaluate targeting rules and % options for setting 'stringContainsDogDefaultCat' (User Object is missing). You should pass a User Object to the evaluation methods like `get_value()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'stringContainsDogDefaultCat' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN 'Dog' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Cat'. diff --git a/configcatclienttests/data/evaluation/1_targeting_rule/1_rule_not_matching_targeted_attribute.txt b/configcatclienttests/data/evaluation/1_targeting_rule/1_rule_not_matching_targeted_attribute.txt new file mode 100644 index 0000000..49d1252 --- /dev/null +++ b/configcatclienttests/data/evaluation/1_targeting_rule/1_rule_not_matching_targeted_attribute.txt @@ -0,0 +1,4 @@ +INFO [5000] Evaluating 'stringContainsDogDefaultCat' for User '{"Identifier":"12345","Email":"joe@example.com"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN 'Dog' => no match + Returning 'Cat'. diff --git a/configcatclienttests/data/evaluation/2_targeting_rules.json b/configcatclienttests/data/evaluation/2_targeting_rules.json new file mode 100644 index 0000000..5cf8a3c --- /dev/null +++ b/configcatclienttests/data/evaluation/2_targeting_rules.json @@ -0,0 +1,41 @@ +{ + "configUrl": "https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d62463-86ec-8fde-f5b5-1c5c426fc830/244cf8b0-f604-11e8-b543-f23c917f9d8d", + "sdkKey": "PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A", + "tests": [ + { + "key": "stringIsInDogDefaultCat", + "defaultValue": "default", + "returnValue": "Cat", + "expectedLog": "2_rules_no_user.txt" + }, + { + "key": "stringIsInDogDefaultCat", + "defaultValue": "default", + "user": { + "Identifier": "12345" + }, + "returnValue": "Cat", + "expectedLog": "2_rules_no_targeted_attribute.txt" + }, + { + "key": "stringIsInDogDefaultCat", + "defaultValue": "default", + "user": { + "Identifier": "12345", + "Custom1": "user" + }, + "returnValue": "Cat", + "expectedLog": "2_rules_not_matching_targeted_attribute.txt" + }, + { + "key": "stringIsInDogDefaultCat", + "defaultValue": "default", + "user": { + "Identifier": "12345", + "Custom1": "admin" + }, + "returnValue": "Dog", + "expectedLog": "2_rules_matching_targeted_attribute.txt" + } + ] +} diff --git a/configcatclienttests/data/evaluation/2_targeting_rules/2_rules_matching_targeted_attribute.txt b/configcatclienttests/data/evaluation/2_targeting_rules/2_rules_matching_targeted_attribute.txt new file mode 100644 index 0000000..d124a4f --- /dev/null +++ b/configcatclienttests/data/evaluation/2_targeting_rules/2_rules_matching_targeted_attribute.txt @@ -0,0 +1,7 @@ +WARNING [3003] Cannot evaluate condition (User.Email IS ONE OF ['a@configcat.com', 'b@configcat.com']) for setting 'stringIsInDogDefaultCat' (the User.Email attribute is missing). You should set the User.Email attribute in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'stringIsInDogDefaultCat' for User '{"Identifier":"12345","Custom1":"admin"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email IS ONE OF ['a@configcat.com', 'b@configcat.com'] THEN 'Dog' => cannot evaluate, the User.Email attribute is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Custom1 IS ONE OF ['admin'] THEN 'Dog' => MATCH, applying rule + Returning 'Dog'. diff --git a/configcatclienttests/data/evaluation/2_targeting_rules/2_rules_no_targeted_attribute.txt b/configcatclienttests/data/evaluation/2_targeting_rules/2_rules_no_targeted_attribute.txt new file mode 100644 index 0000000..0e02076 --- /dev/null +++ b/configcatclienttests/data/evaluation/2_targeting_rules/2_rules_no_targeted_attribute.txt @@ -0,0 +1,9 @@ +WARNING [3003] Cannot evaluate condition (User.Email IS ONE OF ['a@configcat.com', 'b@configcat.com']) for setting 'stringIsInDogDefaultCat' (the User.Email attribute is missing). You should set the User.Email attribute in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +WARNING [3003] Cannot evaluate condition (User.Custom1 IS ONE OF ['admin']) for setting 'stringIsInDogDefaultCat' (the User.Custom1 attribute is missing). You should set the User.Custom1 attribute in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'stringIsInDogDefaultCat' for User '{"Identifier":"12345"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email IS ONE OF ['a@configcat.com', 'b@configcat.com'] THEN 'Dog' => cannot evaluate, the User.Email attribute is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Custom1 IS ONE OF ['admin'] THEN 'Dog' => cannot evaluate, the User.Custom1 attribute is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Cat'. diff --git a/configcatclienttests/data/evaluation/2_targeting_rules/2_rules_no_user.txt b/configcatclienttests/data/evaluation/2_targeting_rules/2_rules_no_user.txt new file mode 100644 index 0000000..187d989 --- /dev/null +++ b/configcatclienttests/data/evaluation/2_targeting_rules/2_rules_no_user.txt @@ -0,0 +1,8 @@ +WARNING [3001] Cannot evaluate targeting rules and % options for setting 'stringIsInDogDefaultCat' (User Object is missing). You should pass a User Object to the evaluation methods like `get_value()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'stringIsInDogDefaultCat' + Evaluating targeting rules and applying the first match if any: + - IF User.Email IS ONE OF ['a@configcat.com', 'b@configcat.com'] THEN 'Dog' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Custom1 IS ONE OF ['admin'] THEN 'Dog' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Cat'. diff --git a/configcatclienttests/data/evaluation/2_targeting_rules/2_rules_not_matching_targeted_attribute.txt b/configcatclienttests/data/evaluation/2_targeting_rules/2_rules_not_matching_targeted_attribute.txt new file mode 100644 index 0000000..72217b2 --- /dev/null +++ b/configcatclienttests/data/evaluation/2_targeting_rules/2_rules_not_matching_targeted_attribute.txt @@ -0,0 +1,7 @@ +WARNING [3003] Cannot evaluate condition (User.Email IS ONE OF ['a@configcat.com', 'b@configcat.com']) for setting 'stringIsInDogDefaultCat' (the User.Email attribute is missing). You should set the User.Email attribute in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'stringIsInDogDefaultCat' for User '{"Identifier":"12345","Custom1":"user"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email IS ONE OF ['a@configcat.com', 'b@configcat.com'] THEN 'Dog' => cannot evaluate, the User.Email attribute is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Custom1 IS ONE OF ['admin'] THEN 'Dog' => no match + Returning 'Cat'. diff --git a/configcatclienttests/data/evaluation/and_rules.json b/configcatclienttests/data/evaluation/and_rules.json new file mode 100644 index 0000000..c6ed879 --- /dev/null +++ b/configcatclienttests/data/evaluation/and_rules.json @@ -0,0 +1,22 @@ +{ + "configUrl": "https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbc325-9d5e-4988-891c-fd4a45790bd1/08dbc325-9ebd-4587-8171-88f76a3004cb", + "sdkKey": "configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/ByMO9yZNn02kXcm72lnY1A", + "tests": [ + { + "key": "emailAnd", + "defaultValue": "default", + "returnValue": "Cat", + "expectedLog": "and_rules_no_user.txt" + }, + { + "key": "emailAnd", + "defaultValue": "default", + "user": { + "Identifier": "12345", + "Email": "jane@configcat.com" + }, + "returnValue": "Cat", + "expectedLog": "and_rules_user.txt" + } + ] +} diff --git a/configcatclienttests/data/evaluation/and_rules/and_rules_no_user.txt b/configcatclienttests/data/evaluation/and_rules/and_rules_no_user.txt new file mode 100644 index 0000000..6a05557 --- /dev/null +++ b/configcatclienttests/data/evaluation/and_rules/and_rules_no_user.txt @@ -0,0 +1,7 @@ +WARNING [3001] Cannot evaluate targeting rules and % options for setting 'emailAnd' (User Object is missing). You should pass a User Object to the evaluation methods like `get_value()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'emailAnd' + Evaluating targeting rules and applying the first match if any: + - IF User.Email STARTS WITH ANY OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN 'Dog' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Cat'. diff --git a/configcatclienttests/data/evaluation/and_rules/and_rules_user.txt b/configcatclienttests/data/evaluation/and_rules/and_rules_user.txt new file mode 100644 index 0000000..92c59ce --- /dev/null +++ b/configcatclienttests/data/evaluation/and_rules/and_rules_user.txt @@ -0,0 +1,7 @@ +INFO [5000] Evaluating 'emailAnd' for User '{"Identifier":"12345","Email":"jane@configcat.com"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email STARTS WITH ANY OF [<1 hashed value>] => true + AND User.Email CONTAINS ANY OF ['@'] => true + AND User.Email ENDS WITH ANY OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN 'Dog' => no match + Returning 'Cat'. diff --git a/configcatclienttests/data/evaluation/circular_dependency.json b/configcatclienttests/data/evaluation/circular_dependency.json new file mode 100644 index 0000000..cb50d22 --- /dev/null +++ b/configcatclienttests/data/evaluation/circular_dependency.json @@ -0,0 +1,14 @@ +{ + "jsonOverride": "circular_dependency_override.json", + "tests": [ + { + "key": "key1", + "defaultValue": "default", + "user": { + "Identifier": "1234" + }, + "returnValue": "first", + "expectedLog": "circular_dependency.txt" + } + ] +} diff --git a/configcatclienttests/data/evaluation/comparators.json b/configcatclienttests/data/evaluation/comparators.json new file mode 100644 index 0000000..5d5631e --- /dev/null +++ b/configcatclienttests/data/evaluation/comparators.json @@ -0,0 +1,20 @@ +{ + "configUrl": "https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbc325-9a6b-4947-84e2-91529248278a/08dbc325-9ebd-4587-8171-88f76a3004cb", + "sdkKey": "configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", + "tests": [ + { + "key": "allinone", + "defaultValue": "", + "user": { + "Identifier": "12345", + "Email": "joe@example.com", + "Country": "[\"USA\"]", + "Version": "1.0.0", + "Number": "1.0", + "Date": "1693497500" + }, + "returnValue": "default", + "expectedLog": "allinone.txt" + } + ] +} diff --git a/configcatclienttests/data/evaluation/comparators/allinone.txt b/configcatclienttests/data/evaluation/comparators/allinone.txt new file mode 100644 index 0000000..84e9b32 --- /dev/null +++ b/configcatclienttests/data/evaluation/comparators/allinone.txt @@ -0,0 +1,57 @@ +INFO [5000] Evaluating 'allinone' for User '{"Identifier":"12345","Email":"joe@example.com","Country":"[\"USA\"]","Version":"1.0.0","Number":"1.0","Date":"1693497500"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email EQUALS '' => true + AND User.Email NOT EQUALS '' => false, skipping the remaining AND conditions + THEN '1h' => no match + - IF User.Email EQUALS 'joe@example.com' => true + AND User.Email NOT EQUALS 'joe@example.com' => false, skipping the remaining AND conditions + THEN '1c' => no match + - IF User.Email IS ONE OF [<1 hashed value>] => true + AND User.Email IS NOT ONE OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN '2h' => no match + - IF User.Email IS ONE OF ['joe@example.com'] => true + AND User.Email IS NOT ONE OF ['joe@example.com'] => false, skipping the remaining AND conditions + THEN '2c' => no match + - IF User.Email STARTS WITH ANY OF [<1 hashed value>] => true + AND User.Email NOT STARTS WITH ANY OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN '3h' => no match + - IF User.Email STARTS WITH ANY OF ['joe@'] => true + AND User.Email NOT STARTS WITH ANY OF ['joe@'] => false, skipping the remaining AND conditions + THEN '3c' => no match + - IF User.Email ENDS WITH ANY OF [<1 hashed value>] => true + AND User.Email NOT ENDS WITH ANY OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN '4h' => no match + - IF User.Email ENDS WITH ANY OF ['@example.com'] => true + AND User.Email NOT ENDS WITH ANY OF ['@example.com'] => false, skipping the remaining AND conditions + THEN '4c' => no match + - IF User.Email CONTAINS ANY OF ['e@e'] => true + AND User.Email NOT CONTAINS ANY OF ['e@e'] => false, skipping the remaining AND conditions + THEN '5' => no match + - IF User.Version IS ONE OF ['1.0.0'] => true + AND User.Version IS NOT ONE OF ['1.0.0'] => false, skipping the remaining AND conditions + THEN '6' => no match + - IF User.Version < '1.0.1' => true + AND User.Version >= '1.0.1' => false, skipping the remaining AND conditions + THEN '7' => no match + - IF User.Version > '0.9.9' => true + AND User.Version <= '0.9.9' => false, skipping the remaining AND conditions + THEN '8' => no match + - IF User.Number = '1' => true + AND User.Number != '1' => false, skipping the remaining AND conditions + THEN '9' => no match + - IF User.Number < '1.1' => true + AND User.Number >= '1.1' => false, skipping the remaining AND conditions + THEN '10' => no match + - IF User.Number > '0.9' => true + AND User.Number <= '0.9' => false, skipping the remaining AND conditions + THEN '11' => no match + - IF User.Date BEFORE '1693497600' (2023-08-31T16:00:00.000Z UTC) => true + AND User.Date AFTER '1693497600' (2023-08-31T16:00:00.000Z UTC) => false, skipping the remaining AND conditions + THEN '12' => no match + - IF User.Country ARRAY CONTAINS ANY OF [<1 hashed value>] => true + AND User.Country ARRAY NOT CONTAINS ANY OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN '13h' => no match + - IF User.Country ARRAY CONTAINS ANY OF ['USA'] => true + AND User.Country ARRAY NOT CONTAINS ANY OF ['USA'] => false, skipping the remaining AND conditions + THEN '13c' => no match + Returning 'default'. diff --git a/configcatclienttests/data/evaluation/epoch_date_validation.json b/configcatclienttests/data/evaluation/epoch_date_validation.json new file mode 100644 index 0000000..e916d21 --- /dev/null +++ b/configcatclienttests/data/evaluation/epoch_date_validation.json @@ -0,0 +1,16 @@ +{ + "configUrl": "https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbc325-9a6b-4947-84e2-91529248278a/08dbc325-9ebd-4587-8171-88f76a3004cb", + "sdkKey": "configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", + "tests": [ + { + "key": "boolTrueIn202304", + "defaultValue": true, + "returnValue": false, + "expectedLog": "date_error.txt", + "user": { + "Identifier": "12345", + "Custom1": "2023.04.10" + } + } + ] +} diff --git a/configcatclienttests/data/evaluation/epoch_date_validation/date_error.txt b/configcatclienttests/data/evaluation/epoch_date_validation/date_error.txt new file mode 100644 index 0000000..fbde23f --- /dev/null +++ b/configcatclienttests/data/evaluation/epoch_date_validation/date_error.txt @@ -0,0 +1,7 @@ +WARNING [3004] Cannot evaluate condition (User.Custom1 AFTER '1680307200' (2023-04-01T00:00:00.000Z UTC)) for setting 'boolTrueIn202304' ('2023.04.10' is not a valid Unix timestamp (number of seconds elapsed since Unix epoch)). Please check the User.Custom1 attribute and make sure that its value corresponds to the comparison operator. +INFO [5000] Evaluating 'boolTrueIn202304' for User '{"Identifier":"12345","Custom1":"2023.04.10"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Custom1 AFTER '1680307200' (2023-04-01T00:00:00.000Z UTC) => false, skipping the remaining AND conditions + THEN 'True' => cannot evaluate, the User.Custom1 attribute is invalid ('2023.04.10' is not a valid Unix timestamp (number of seconds elapsed since Unix epoch)) + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'False'. diff --git a/configcatclienttests/data/evaluation/list_truncation.json b/configcatclienttests/data/evaluation/list_truncation.json new file mode 100644 index 0000000..64e9426 --- /dev/null +++ b/configcatclienttests/data/evaluation/list_truncation.json @@ -0,0 +1,14 @@ +{ + "jsonOverride": "test_list_truncation.json", + "tests": [ + { + "key": "booleanKey1", + "defaultValue": false, + "user": { + "Identifier": "12" + }, + "returnValue": true, + "expectedLog": "list_truncation.txt" + } + ] +} diff --git a/configcatclienttests/data/evaluation/list_truncation/list_truncation.txt b/configcatclienttests/data/evaluation/list_truncation/list_truncation.txt new file mode 100644 index 0000000..a07e52c --- /dev/null +++ b/configcatclienttests/data/evaluation/list_truncation/list_truncation.txt @@ -0,0 +1,7 @@ +INFO [5000] Evaluating 'booleanKey1' for User '{"Identifier":"12"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Identifier CONTAINS ANY OF ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10'] => true + AND User.Identifier CONTAINS ANY OF ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', ... <1 more value>] => true + AND User.Identifier CONTAINS ANY OF ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', ... <2 more values>] => true + THEN 'True' => MATCH, applying rule + Returning 'True'. diff --git a/configcatclienttests/data/evaluation/list_truncation/test_list_truncation.json b/configcatclienttests/data/evaluation/list_truncation/test_list_truncation.json new file mode 100644 index 0000000..6fdde45 --- /dev/null +++ b/configcatclienttests/data/evaluation/list_truncation/test_list_truncation.json @@ -0,0 +1,83 @@ +{ + "p": { + "u": "https://cdn-global.configcat.com", + "r": 0, + "s": "test-salt" + }, + "f": { + "booleanKey1": { + "t": 0, + "v": { + "b": false + }, + "r": [ + { + "c": [ + { + "u": { + "a": "Identifier", + "c": 2, + "l": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10" + ] + } + }, + { + "u": { + "a": "Identifier", + "c": 2, + "l": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11" + ] + } + }, + { + "u": { + "a": "Identifier", + "c": 2, + "l": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11", + "12" + ] + } + } + ], + "s": { + "v": { + "b": true + } + } + } + ] + } + } +} diff --git a/configcatclienttests/data/evaluation/number_validation.json b/configcatclienttests/data/evaluation/number_validation.json new file mode 100644 index 0000000..640cf3d --- /dev/null +++ b/configcatclienttests/data/evaluation/number_validation.json @@ -0,0 +1,16 @@ +{ + "configUrl": "https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d747f0-5986-c2ef-eef3-ec778e32e10a/244cf8b0-f604-11e8-b543-f23c917f9d8d", + "sdkKey": "PKDVCLf-Hq-h-kCzMp-L7Q/uGyK3q9_ckmdxRyI7vjwCw", + "tests": [ + { + "key": "number", + "defaultValue": "default", + "returnValue": "Default", + "expectedLog": "number_error.txt", + "user": { + "Identifier": "12345", + "Custom1": "not_a_number" + } + } + ] +} diff --git a/configcatclienttests/data/evaluation/number_validation/number_error.txt b/configcatclienttests/data/evaluation/number_validation/number_error.txt new file mode 100644 index 0000000..f936809 --- /dev/null +++ b/configcatclienttests/data/evaluation/number_validation/number_error.txt @@ -0,0 +1,6 @@ +WARNING [3004] Cannot evaluate condition (User.Custom1 != '5') for setting 'number' ('not_a_number' is not a valid decimal number). Please check the User.Custom1 attribute and make sure that its value corresponds to the comparison operator. +INFO [5000] Evaluating 'number' for User '{"Identifier":"12345","Custom1":"not_a_number"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Custom1 != '5' THEN '<>5' => cannot evaluate, the User.Custom1 attribute is invalid ('not_a_number' is not a valid decimal number) + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Default'. diff --git a/configcatclienttests/data/evaluation/options_after_targeting_rule.json b/configcatclienttests/data/evaluation/options_after_targeting_rule.json new file mode 100644 index 0000000..803840e --- /dev/null +++ b/configcatclienttests/data/evaluation/options_after_targeting_rule.json @@ -0,0 +1,41 @@ +{ + "configUrl": "https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d62463-86ec-8fde-f5b5-1c5c426fc830/244cf8b0-f604-11e8-b543-f23c917f9d8d", + "sdkKey": "PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A", + "tests": [ + { + "key": "integer25One25Two25Three25FourAdvancedRules", + "defaultValue": 42, + "returnValue": -1, + "expectedLog": "options_after_targeting_rule_no_user.txt" + }, + { + "key": "integer25One25Two25Three25FourAdvancedRules", + "defaultValue": 42, + "user": { + "Identifier": "12345" + }, + "returnValue": 2, + "expectedLog": "options_after_targeting_rule_no_targeted_attribute.txt" + }, + { + "key": "integer25One25Two25Three25FourAdvancedRules", + "defaultValue": 42, + "user": { + "Identifier": "12345", + "Email": "joe@example.com" + }, + "returnValue": 2, + "expectedLog": "options_after_targeting_rule_not_matching_targeted_attribute.txt" + }, + { + "key": "integer25One25Two25Three25FourAdvancedRules", + "defaultValue": 42, + "user": { + "Identifier": "12345", + "Email": "joe@configcat.com" + }, + "returnValue": 5, + "expectedLog": "options_after_targeting_rule_matching_targeted_attribute.txt" + } + ] +} diff --git a/configcatclienttests/data/evaluation/options_after_targeting_rule/options_after_targeting_rule_matching_targeted_attribute.txt b/configcatclienttests/data/evaluation/options_after_targeting_rule/options_after_targeting_rule_matching_targeted_attribute.txt new file mode 100644 index 0000000..6815fa3 --- /dev/null +++ b/configcatclienttests/data/evaluation/options_after_targeting_rule/options_after_targeting_rule_matching_targeted_attribute.txt @@ -0,0 +1,4 @@ +INFO [5000] Evaluating 'integer25One25Two25Three25FourAdvancedRules' for User '{"Identifier":"12345","Email":"joe@configcat.com"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN '5' => MATCH, applying rule + Returning '5'. diff --git a/configcatclienttests/data/evaluation/options_after_targeting_rule/options_after_targeting_rule_no_targeted_attribute.txt b/configcatclienttests/data/evaluation/options_after_targeting_rule/options_after_targeting_rule_no_targeted_attribute.txt new file mode 100644 index 0000000..8e6facb --- /dev/null +++ b/configcatclienttests/data/evaluation/options_after_targeting_rule/options_after_targeting_rule_no_targeted_attribute.txt @@ -0,0 +1,9 @@ +WARNING [3003] Cannot evaluate condition (User.Email CONTAINS ANY OF ['@configcat.com']) for setting 'integer25One25Two25Three25FourAdvancedRules' (the User.Email attribute is missing). You should set the User.Email attribute in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'integer25One25Two25Three25FourAdvancedRules' for User '{"Identifier":"12345"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN '5' => cannot evaluate, the User.Email attribute is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Evaluating % options based on the User.Identifier attribute: + - Computing hash in the [0..99] range from User.Identifier => 25 (this value is sticky and consistent across all SDKs) + - Hash value 25 selects % option 2 (25%), '2'. + Returning '2'. diff --git a/configcatclienttests/data/evaluation/options_after_targeting_rule/options_after_targeting_rule_no_user.txt b/configcatclienttests/data/evaluation/options_after_targeting_rule/options_after_targeting_rule_no_user.txt new file mode 100644 index 0000000..6262730 --- /dev/null +++ b/configcatclienttests/data/evaluation/options_after_targeting_rule/options_after_targeting_rule_no_user.txt @@ -0,0 +1,7 @@ +WARNING [3001] Cannot evaluate targeting rules and % options for setting 'integer25One25Two25Three25FourAdvancedRules' (User Object is missing). You should pass a User Object to the evaluation methods like `get_value()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'integer25One25Two25Three25FourAdvancedRules' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN '5' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Skipping % options because the User Object is missing. + Returning '-1'. diff --git a/configcatclienttests/data/evaluation/options_after_targeting_rule/options_after_targeting_rule_not_matching_targeted_attribute.txt b/configcatclienttests/data/evaluation/options_after_targeting_rule/options_after_targeting_rule_not_matching_targeted_attribute.txt new file mode 100644 index 0000000..c412e5a --- /dev/null +++ b/configcatclienttests/data/evaluation/options_after_targeting_rule/options_after_targeting_rule_not_matching_targeted_attribute.txt @@ -0,0 +1,7 @@ +INFO [5000] Evaluating 'integer25One25Two25Three25FourAdvancedRules' for User '{"Identifier":"12345","Email":"joe@example.com"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN '5' => no match + Evaluating % options based on the User.Identifier attribute: + - Computing hash in the [0..99] range from User.Identifier => 25 (this value is sticky and consistent across all SDKs) + - Hash value 25 selects % option 2 (25%), '2'. + Returning '2'. diff --git a/configcatclienttests/data/evaluation/options_based_on_custom_attr.json b/configcatclienttests/data/evaluation/options_based_on_custom_attr.json new file mode 100644 index 0000000..5f8d1c6 --- /dev/null +++ b/configcatclienttests/data/evaluation/options_based_on_custom_attr.json @@ -0,0 +1,31 @@ +{ + "configUrl": "https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbc325-9e4e-4f59-86b2-5da50924b6ca/08dbc325-9ebd-4587-8171-88f76a3004cb", + "sdkKey": "configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/P4e3fAz_1ky2-Zg2e4cbkw", + "tests": [ + { + "key": "string75Cat0Dog25Falcon0HorseCustomAttr", + "defaultValue": "default", + "returnValue": "Chicken", + "expectedLog": "options_custom_attribute_no_user.txt" + }, + { + "key": "string75Cat0Dog25Falcon0HorseCustomAttr", + "defaultValue": "default", + "user": { + "Identifier": "12345" + }, + "returnValue": "Chicken", + "expectedLog": "no_options_custom_attribute.txt" + }, + { + "key": "string75Cat0Dog25Falcon0HorseCustomAttr", + "defaultValue": "default", + "user": { + "Identifier": "12345", + "Country": "US" + }, + "returnValue": "Cat", + "expectedLog": "matching_options_custom_attribute.txt" + } + ] +} diff --git a/configcatclienttests/data/evaluation/options_based_on_custom_attr/matching_options_custom_attribute.txt b/configcatclienttests/data/evaluation/options_based_on_custom_attr/matching_options_custom_attribute.txt new file mode 100644 index 0000000..2621086 --- /dev/null +++ b/configcatclienttests/data/evaluation/options_based_on_custom_attr/matching_options_custom_attribute.txt @@ -0,0 +1,5 @@ +INFO [5000] Evaluating 'string75Cat0Dog25Falcon0HorseCustomAttr' for User '{"Identifier":"12345","Country":"US"}' + Evaluating % options based on the User.Country attribute: + - Computing hash in the [0..99] range from User.Country => 70 (this value is sticky and consistent across all SDKs) + - Hash value 70 selects % option 1 (75%), 'Cat'. + Returning 'Cat'. diff --git a/configcatclienttests/data/evaluation/options_based_on_custom_attr/no_options_custom_attribute.txt b/configcatclienttests/data/evaluation/options_based_on_custom_attr/no_options_custom_attribute.txt new file mode 100644 index 0000000..c92c5bc --- /dev/null +++ b/configcatclienttests/data/evaluation/options_based_on_custom_attr/no_options_custom_attribute.txt @@ -0,0 +1,4 @@ +WARNING [3003] Cannot evaluate % options for setting 'string75Cat0Dog25Falcon0HorseCustomAttr' (the User.Country attribute is missing). You should set the User.Country attribute in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'string75Cat0Dog25Falcon0HorseCustomAttr' for User '{"Identifier":"12345"}' + Skipping % options because the User.Country attribute is missing. + Returning 'Chicken'. diff --git a/configcatclienttests/data/evaluation/options_based_on_custom_attr/options_custom_attribute_no_user.txt b/configcatclienttests/data/evaluation/options_based_on_custom_attr/options_custom_attribute_no_user.txt new file mode 100644 index 0000000..97daf53 --- /dev/null +++ b/configcatclienttests/data/evaluation/options_based_on_custom_attr/options_custom_attribute_no_user.txt @@ -0,0 +1,4 @@ +WARNING [3001] Cannot evaluate targeting rules and % options for setting 'string75Cat0Dog25Falcon0HorseCustomAttr' (User Object is missing). You should pass a User Object to the evaluation methods like `get_value()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'string75Cat0Dog25Falcon0HorseCustomAttr' + Skipping % options because the User Object is missing. + Returning 'Chicken'. diff --git a/configcatclienttests/data/evaluation/options_based_on_user_id.json b/configcatclienttests/data/evaluation/options_based_on_user_id.json new file mode 100644 index 0000000..442f575 --- /dev/null +++ b/configcatclienttests/data/evaluation/options_based_on_user_id.json @@ -0,0 +1,21 @@ +{ + "configUrl": "https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d62463-86ec-8fde-f5b5-1c5c426fc830/244cf8b0-f604-11e8-b543-f23c917f9d8d", + "sdkKey": "PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A", + "tests": [ + { + "key": "string75Cat0Dog25Falcon0Horse", + "defaultValue": "default", + "returnValue": "Chicken", + "expectedLog": "options_user_attribute_no_user.txt" + }, + { + "key": "string75Cat0Dog25Falcon0Horse", + "defaultValue": "default", + "user": { + "Identifier": "12345" + }, + "returnValue": "Cat", + "expectedLog": "options_user_attribute_user.txt" + } + ] +} diff --git a/configcatclienttests/data/evaluation/options_based_on_user_id/options_user_attribute_no_user.txt b/configcatclienttests/data/evaluation/options_based_on_user_id/options_user_attribute_no_user.txt new file mode 100644 index 0000000..484e6b6 --- /dev/null +++ b/configcatclienttests/data/evaluation/options_based_on_user_id/options_user_attribute_no_user.txt @@ -0,0 +1,4 @@ +WARNING [3001] Cannot evaluate targeting rules and % options for setting 'string75Cat0Dog25Falcon0Horse' (User Object is missing). You should pass a User Object to the evaluation methods like `get_value()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'string75Cat0Dog25Falcon0Horse' + Skipping % options because the User Object is missing. + Returning 'Chicken'. diff --git a/configcatclienttests/data/evaluation/options_based_on_user_id/options_user_attribute_user.txt b/configcatclienttests/data/evaluation/options_based_on_user_id/options_user_attribute_user.txt new file mode 100644 index 0000000..dac8dd6 --- /dev/null +++ b/configcatclienttests/data/evaluation/options_based_on_user_id/options_user_attribute_user.txt @@ -0,0 +1,5 @@ +INFO [5000] Evaluating 'string75Cat0Dog25Falcon0Horse' for User '{"Identifier":"12345"}' + Evaluating % options based on the User.Identifier attribute: + - Computing hash in the [0..99] range from User.Identifier => 21 (this value is sticky and consistent across all SDKs) + - Hash value 21 selects % option 1 (75%), 'Cat'. + Returning 'Cat'. diff --git a/configcatclienttests/data/evaluation/options_within_targeting_rule.json b/configcatclienttests/data/evaluation/options_within_targeting_rule.json new file mode 100644 index 0000000..4c6c533 --- /dev/null +++ b/configcatclienttests/data/evaluation/options_within_targeting_rule.json @@ -0,0 +1,52 @@ +{ + "configUrl": "https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbc325-9e4e-4f59-86b2-5da50924b6ca/08dbc325-9ebd-4587-8171-88f76a3004cb", + "sdkKey": "configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/P4e3fAz_1ky2-Zg2e4cbkw", + "tests": [ + { + "key": "stringContainsString75Cat0Dog25Falcon0HorseDefaultCat", + "defaultValue": "default", + "returnValue": "Cat", + "expectedLog": "options_within_targeting_rule_no_user.txt" + }, + { + "key": "stringContainsString75Cat0Dog25Falcon0HorseDefaultCat", + "defaultValue": "default", + "user": { + "Identifier": "12345" + }, + "returnValue": "Cat", + "expectedLog": "options_within_targeting_rule_no_targeted_attribute.txt" + }, + { + "key": "stringContainsString75Cat0Dog25Falcon0HorseDefaultCat", + "defaultValue": "default", + "user": { + "Identifier": "12345", + "Email": "joe@example.com" + }, + "returnValue": "Cat", + "expectedLog": "options_within_targeting_rule_not_matching_targeted_attribute.txt" + }, + { + "key": "stringContainsString75Cat0Dog25Falcon0HorseDefaultCat", + "defaultValue": "default", + "user": { + "Identifier": "12345", + "Email": "joe@configcat.com" + }, + "returnValue": "Cat", + "expectedLog": "options_within_targeting_rule_matching_targeted_attribute_no_options_attribute.txt" + }, + { + "key": "stringContainsString75Cat0Dog25Falcon0HorseDefaultCat", + "defaultValue": "default", + "user": { + "Identifier": "12345", + "Email": "joe@configcat.com", + "Country": "US" + }, + "returnValue": "Cat", + "expectedLog": "options_within_targeting_rule_matching_targeted_attribute_options_attribute.txt" + } + ] +} diff --git a/configcatclienttests/data/evaluation/options_within_targeting_rule/options_within_targeting_rule_matching_targeted_attribute_no_options_attribute.txt b/configcatclienttests/data/evaluation/options_within_targeting_rule/options_within_targeting_rule_matching_targeted_attribute_no_options_attribute.txt new file mode 100644 index 0000000..db721f5 --- /dev/null +++ b/configcatclienttests/data/evaluation/options_within_targeting_rule/options_within_targeting_rule_matching_targeted_attribute_no_options_attribute.txt @@ -0,0 +1,7 @@ +WARNING [3003] Cannot evaluate % options for setting 'stringContainsString75Cat0Dog25Falcon0HorseDefaultCat' (the User.Country attribute is missing). You should set the User.Country attribute in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'stringContainsString75Cat0Dog25Falcon0HorseDefaultCat' for User '{"Identifier":"12345","Email":"joe@configcat.com"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN % options => MATCH, applying rule + Skipping % options because the User.Country attribute is missing. + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Cat'. diff --git a/configcatclienttests/data/evaluation/options_within_targeting_rule/options_within_targeting_rule_matching_targeted_attribute_options_attribute.txt b/configcatclienttests/data/evaluation/options_within_targeting_rule/options_within_targeting_rule_matching_targeted_attribute_options_attribute.txt new file mode 100644 index 0000000..8129521 --- /dev/null +++ b/configcatclienttests/data/evaluation/options_within_targeting_rule/options_within_targeting_rule_matching_targeted_attribute_options_attribute.txt @@ -0,0 +1,7 @@ +INFO [5000] Evaluating 'stringContainsString75Cat0Dog25Falcon0HorseDefaultCat' for User '{"Identifier":"12345","Email":"joe@configcat.com","Country":"US"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN % options => MATCH, applying rule + Evaluating % options based on the User.Country attribute: + - Computing hash in the [0..99] range from User.Country => 63 (this value is sticky and consistent across all SDKs) + - Hash value 63 selects % option 1 (75%), 'Cat'. + Returning 'Cat'. diff --git a/configcatclienttests/data/evaluation/options_within_targeting_rule/options_within_targeting_rule_no_targeted_attribute.txt b/configcatclienttests/data/evaluation/options_within_targeting_rule/options_within_targeting_rule_no_targeted_attribute.txt new file mode 100644 index 0000000..74f812f --- /dev/null +++ b/configcatclienttests/data/evaluation/options_within_targeting_rule/options_within_targeting_rule_no_targeted_attribute.txt @@ -0,0 +1,6 @@ +WARNING [3003] Cannot evaluate condition (User.Email CONTAINS ANY OF ['@configcat.com']) for setting 'stringContainsString75Cat0Dog25Falcon0HorseDefaultCat' (the User.Email attribute is missing). You should set the User.Email attribute in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'stringContainsString75Cat0Dog25Falcon0HorseDefaultCat' for User '{"Identifier":"12345"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN % options => cannot evaluate, the User.Email attribute is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Cat'. diff --git a/configcatclienttests/data/evaluation/options_within_targeting_rule/options_within_targeting_rule_no_user.txt b/configcatclienttests/data/evaluation/options_within_targeting_rule/options_within_targeting_rule_no_user.txt new file mode 100644 index 0000000..6224e5a --- /dev/null +++ b/configcatclienttests/data/evaluation/options_within_targeting_rule/options_within_targeting_rule_no_user.txt @@ -0,0 +1,6 @@ +WARNING [3001] Cannot evaluate targeting rules and % options for setting 'stringContainsString75Cat0Dog25Falcon0HorseDefaultCat' (User Object is missing). You should pass a User Object to the evaluation methods like `get_value()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'stringContainsString75Cat0Dog25Falcon0HorseDefaultCat' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN % options => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Cat'. diff --git a/configcatclienttests/data/evaluation/options_within_targeting_rule/options_within_targeting_rule_not_matching_targeted_attribute.txt b/configcatclienttests/data/evaluation/options_within_targeting_rule/options_within_targeting_rule_not_matching_targeted_attribute.txt new file mode 100644 index 0000000..dd6032e --- /dev/null +++ b/configcatclienttests/data/evaluation/options_within_targeting_rule/options_within_targeting_rule_not_matching_targeted_attribute.txt @@ -0,0 +1,4 @@ +INFO [5000] Evaluating 'stringContainsString75Cat0Dog25Falcon0HorseDefaultCat' for User '{"Identifier":"12345","Email":"joe@example.com"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Email CONTAINS ANY OF ['@configcat.com'] THEN % options => no match + Returning 'Cat'. diff --git a/configcatclienttests/data/evaluation/prerequisite_flag.json b/configcatclienttests/data/evaluation/prerequisite_flag.json new file mode 100644 index 0000000..674e2d3 --- /dev/null +++ b/configcatclienttests/data/evaluation/prerequisite_flag.json @@ -0,0 +1,35 @@ +{ + "configUrl": "https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbc325-9d5e-4988-891c-fd4a45790bd1/08dbc325-9ebd-4587-8171-88f76a3004cb", + "sdkKey": "configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/ByMO9yZNn02kXcm72lnY1A", + "tests": [ + { + "key": "dependentFeatureWithUserCondition", + "defaultValue": "default", + "returnValue": "Chicken", + "expectedLog": "prerequisite_flag_no_user_needed_by_dep.txt" + }, + { + "key": "dependentFeature", + "defaultValue": "default", + "returnValue": "Chicken", + "expectedLog": "prerequisite_flag_no_user_needed_by_prereq.txt" + }, + { + "key": "dependentFeatureWithUserCondition2", + "defaultValue": "default", + "returnValue": "Frog", + "expectedLog": "prerequisite_flag_no_user_needed_by_both.txt" + }, + { + "key": "dependentFeature", + "defaultValue": "default", + "user": { + "Identifier": "12345", + "Email": "kate@configcat.com", + "Country": "USA" + }, + "returnValue": "Horse", + "expectedLog": "prerequisite_flag.txt" + } + ] +} diff --git a/configcatclienttests/data/evaluation/prerequisite_flag/prerequisite_flag.txt b/configcatclienttests/data/evaluation/prerequisite_flag/prerequisite_flag.txt new file mode 100644 index 0000000..1d9022b --- /dev/null +++ b/configcatclienttests/data/evaluation/prerequisite_flag/prerequisite_flag.txt @@ -0,0 +1,32 @@ +INFO [5000] Evaluating 'dependentFeature' for User '{"Identifier":"12345","Email":"kate@configcat.com","Country":"USA"}' + Evaluating targeting rules and applying the first match if any: + - IF Flag 'mainFeature' EQUALS 'target' + ( + Evaluating prerequisite flag 'mainFeature': + Evaluating targeting rules and applying the first match if any: + - IF User.Email ENDS WITH ANY OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN 'private' => no match + - IF User.Country IS ONE OF [<1 hashed value>] => true + AND User IS NOT IN SEGMENT 'Beta Users' + ( + Evaluating segment 'Beta Users': + - IF User.Email IS ONE OF [<2 hashed values>] => false, skipping the remaining AND conditions + Segment evaluation result: User IS NOT IN SEGMENT. + Condition (User IS NOT IN SEGMENT 'Beta Users') evaluates to true. + ) => true + AND User IS NOT IN SEGMENT 'Developers' + ( + Evaluating segment 'Developers': + - IF User.Email IS ONE OF [<2 hashed values>] => false, skipping the remaining AND conditions + Segment evaluation result: User IS NOT IN SEGMENT. + Condition (User IS NOT IN SEGMENT 'Developers') evaluates to true. + ) => true + THEN 'target' => MATCH, applying rule + Prerequisite flag evaluation result: 'target'. + Condition (Flag 'mainFeature' EQUALS 'target') evaluates to true. + ) + THEN % options => MATCH, applying rule + Evaluating % options based on the User.Identifier attribute: + - Computing hash in the [0..99] range from User.Identifier => 78 (this value is sticky and consistent across all SDKs) + - Hash value 78 selects % option 4 (25%), 'Horse'. + Returning 'Horse'. diff --git a/configcatclienttests/data/evaluation/prerequisite_flag/prerequisite_flag_no_user_needed_by_both.txt b/configcatclienttests/data/evaluation/prerequisite_flag/prerequisite_flag_no_user_needed_by_both.txt new file mode 100644 index 0000000..aaf8b57 --- /dev/null +++ b/configcatclienttests/data/evaluation/prerequisite_flag/prerequisite_flag_no_user_needed_by_both.txt @@ -0,0 +1,38 @@ +WARNING [3001] Cannot evaluate targeting rules and % options for setting 'dependentFeatureWithUserCondition2' (User Object is missing). You should pass a User Object to the evaluation methods like `get_value()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +WARNING [3001] Cannot evaluate targeting rules and % options for setting 'mainFeature' (User Object is missing). You should pass a User Object to the evaluation methods like `get_value()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +WARNING [3001] Cannot evaluate targeting rules and % options for setting 'mainFeature' (User Object is missing). You should pass a User Object to the evaluation methods like `get_value()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'dependentFeatureWithUserCondition2' + Evaluating targeting rules and applying the first match if any: + - IF User.Email IS ONE OF [<2 hashed values>] THEN 'Dog' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF Flag 'mainFeature' EQUALS 'public' + ( + Evaluating prerequisite flag 'mainFeature': + Evaluating targeting rules and applying the first match if any: + - IF User.Email ENDS WITH ANY OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN 'private' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Country IS ONE OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN 'target' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Prerequisite flag evaluation result: 'public'. + Condition (Flag 'mainFeature' EQUALS 'public') evaluates to true. + ) + THEN % options => MATCH, applying rule + Skipping % options because the User Object is missing. + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF Flag 'mainFeature' EQUALS 'public' + ( + Evaluating prerequisite flag 'mainFeature': + Evaluating targeting rules and applying the first match if any: + - IF User.Email ENDS WITH ANY OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN 'private' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Country IS ONE OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN 'target' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Prerequisite flag evaluation result: 'public'. + Condition (Flag 'mainFeature' EQUALS 'public') evaluates to true. + ) + THEN 'Frog' => MATCH, applying rule + Returning 'Frog'. diff --git a/configcatclienttests/data/evaluation/prerequisite_flag/prerequisite_flag_no_user_needed_by_dep.txt b/configcatclienttests/data/evaluation/prerequisite_flag/prerequisite_flag_no_user_needed_by_dep.txt new file mode 100644 index 0000000..d69f2df --- /dev/null +++ b/configcatclienttests/data/evaluation/prerequisite_flag/prerequisite_flag_no_user_needed_by_dep.txt @@ -0,0 +1,15 @@ +WARNING [3001] Cannot evaluate targeting rules and % options for setting 'dependentFeatureWithUserCondition' (User Object is missing). You should pass a User Object to the evaluation methods like `get_value()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'dependentFeatureWithUserCondition' + Evaluating targeting rules and applying the first match if any: + - IF User.Email IS ONE OF [<2 hashed values>] THEN 'Dog' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF Flag 'mainFeatureWithoutUserCondition' EQUALS 'True' + ( + Evaluating prerequisite flag 'mainFeatureWithoutUserCondition': + Prerequisite flag evaluation result: 'True'. + Condition (Flag 'mainFeatureWithoutUserCondition' EQUALS 'True') evaluates to true. + ) + THEN % options => MATCH, applying rule + Skipping % options because the User Object is missing. + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Chicken'. diff --git a/configcatclienttests/data/evaluation/prerequisite_flag/prerequisite_flag_no_user_needed_by_prereq.txt b/configcatclienttests/data/evaluation/prerequisite_flag/prerequisite_flag_no_user_needed_by_prereq.txt new file mode 100644 index 0000000..9dfef8b --- /dev/null +++ b/configcatclienttests/data/evaluation/prerequisite_flag/prerequisite_flag_no_user_needed_by_prereq.txt @@ -0,0 +1,18 @@ +WARNING [3001] Cannot evaluate targeting rules and % options for setting 'mainFeature' (User Object is missing). You should pass a User Object to the evaluation methods like `get_value()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'dependentFeature' + Evaluating targeting rules and applying the first match if any: + - IF Flag 'mainFeature' EQUALS 'target' + ( + Evaluating prerequisite flag 'mainFeature': + Evaluating targeting rules and applying the first match if any: + - IF User.Email ENDS WITH ANY OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN 'private' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Country IS ONE OF [<1 hashed value>] => false, skipping the remaining AND conditions + THEN 'target' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Prerequisite flag evaluation result: 'public'. + Condition (Flag 'mainFeature' EQUALS 'target') evaluates to false. + ) + THEN % options => no match + Returning 'Chicken'. diff --git a/configcatclienttests/data/evaluation/segment.json b/configcatclienttests/data/evaluation/segment.json new file mode 100644 index 0000000..41744c2 --- /dev/null +++ b/configcatclienttests/data/evaluation/segment.json @@ -0,0 +1,41 @@ +{ + "configUrl": "https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d9f207-6883-43e5-868c-cbf677af3fe6/244cf8b0-f604-11e8-b543-f23c917f9d8d", + "sdkKey": "PKDVCLf-Hq-h-kCzMp-L7Q/LcYz135LE0qbcacz2mgXnA", + "tests": [ + { + "key": "featureWithSegmentTargeting", + "defaultValue": false, + "returnValue": false, + "expectedLog": "segment_no_user.txt" + }, + { + "key": "featureWithNegatedSegmentTargetingCleartext", + "defaultValue": false, + "user": { + "Identifier": "12345" + }, + "returnValue": false, + "expectedLog": "segment_no_targeted_attribute.txt" + }, + { + "key": "featureWithSegmentTargeting", + "defaultValue": false, + "user": { + "Identifier": "12345", + "Email": "jane@example.com" + }, + "returnValue": true, + "expectedLog": "segment_matching.txt" + }, + { + "key": "featureWithNegatedSegmentTargeting", + "defaultValue": false, + "user": { + "Identifier": "12345", + "Email": "jane@example.com" + }, + "returnValue": false, + "expectedLog": "segment_no_matching.txt" + } + ] +} diff --git a/configcatclienttests/data/evaluation/segment/segment_matching.txt b/configcatclienttests/data/evaluation/segment/segment_matching.txt new file mode 100644 index 0000000..ff46528 --- /dev/null +++ b/configcatclienttests/data/evaluation/segment/segment_matching.txt @@ -0,0 +1,11 @@ +INFO [5000] Evaluating 'featureWithSegmentTargeting' for User '{"Identifier":"12345","Email":"jane@example.com"}' + Evaluating targeting rules and applying the first match if any: + - IF User IS IN SEGMENT 'Beta users' + ( + Evaluating segment 'Beta users': + - IF User.Email IS ONE OF [<2 hashed values>] => true + Segment evaluation result: User IS IN SEGMENT. + Condition (User IS IN SEGMENT 'Beta users') evaluates to true. + ) + THEN 'True' => MATCH, applying rule + Returning 'True'. diff --git a/configcatclienttests/data/evaluation/segment/segment_no_matching.txt b/configcatclienttests/data/evaluation/segment/segment_no_matching.txt new file mode 100644 index 0000000..3723521 --- /dev/null +++ b/configcatclienttests/data/evaluation/segment/segment_no_matching.txt @@ -0,0 +1,11 @@ +INFO [5000] Evaluating 'featureWithNegatedSegmentTargeting' for User '{"Identifier":"12345","Email":"jane@example.com"}' + Evaluating targeting rules and applying the first match if any: + - IF User IS NOT IN SEGMENT 'Beta users' + ( + Evaluating segment 'Beta users': + - IF User.Email IS ONE OF [<2 hashed values>] => true + Segment evaluation result: User IS IN SEGMENT. + Condition (User IS NOT IN SEGMENT 'Beta users') evaluates to false. + ) + THEN 'True' => no match + Returning 'False'. diff --git a/configcatclienttests/data/evaluation/segment/segment_no_targeted_attribute.txt b/configcatclienttests/data/evaluation/segment/segment_no_targeted_attribute.txt new file mode 100644 index 0000000..9f39d8c --- /dev/null +++ b/configcatclienttests/data/evaluation/segment/segment_no_targeted_attribute.txt @@ -0,0 +1,13 @@ +WARNING [3003] Cannot evaluate condition (User.Email IS ONE OF ['jane@example.com', 'john@example.com']) for setting 'featureWithNegatedSegmentTargetingCleartext' (the User.Email attribute is missing). You should set the User.Email attribute in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'featureWithNegatedSegmentTargetingCleartext' for User '{"Identifier":"12345"}' + Evaluating targeting rules and applying the first match if any: + - IF User IS NOT IN SEGMENT 'Beta users (cleartext)' + ( + Evaluating segment 'Beta users (cleartext)': + - IF User.Email IS ONE OF ['jane@example.com', 'john@example.com'] => false, skipping the remaining AND conditions + Segment evaluation result: cannot evaluate, the User.Email attribute is missing. + Condition (User IS NOT IN SEGMENT 'Beta users (cleartext)') failed to evaluate. + ) + THEN 'True' => cannot evaluate, the User.Email attribute is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'False'. diff --git a/configcatclienttests/data/evaluation/segment/segment_no_user.txt b/configcatclienttests/data/evaluation/segment/segment_no_user.txt new file mode 100644 index 0000000..087f85e --- /dev/null +++ b/configcatclienttests/data/evaluation/segment/segment_no_user.txt @@ -0,0 +1,6 @@ +WARNING [3001] Cannot evaluate targeting rules and % options for setting 'featureWithSegmentTargeting' (User Object is missing). You should pass a User Object to the evaluation methods like `get_value()` in order to make targeting work properly. Read more: https://configcat.com/docs/advanced/user-object/ +INFO [5000] Evaluating 'featureWithSegmentTargeting' + Evaluating targeting rules and applying the first match if any: + - IF User IS IN SEGMENT 'Beta users' THEN 'True' => cannot evaluate, User Object is missing + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'False'. diff --git a/configcatclienttests/data/evaluation/semver_validation.json b/configcatclienttests/data/evaluation/semver_validation.json new file mode 100644 index 0000000..3a14fc6 --- /dev/null +++ b/configcatclienttests/data/evaluation/semver_validation.json @@ -0,0 +1,26 @@ +{ + "configUrl": "https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d745f1-f315-7daf-d163-5541d3786e6f/244cf8b0-f604-11e8-b543-f23c917f9d8d", + "sdkKey": "PKDVCLf-Hq-h-kCzMp-L7Q/BAr3KgLTP0ObzKnBTo5nhA", + "tests": [ + { + "key": "isNotOneOf", + "defaultValue": "default", + "returnValue": "Default", + "expectedLog": "semver_error.txt", + "user": { + "Identifier": "12345", + "Custom1": "wrong_semver" + } + }, + { + "key": "relations", + "defaultValue": "default", + "returnValue": "Default", + "expectedLog": "semver_relations_error.txt", + "user": { + "Identifier": "12345", + "Custom1": "wrong_semver" + } + } + ] +} diff --git a/configcatclienttests/data/evaluation/semver_validation/semver_error.txt b/configcatclienttests/data/evaluation/semver_validation/semver_error.txt new file mode 100644 index 0000000..e14cc95 --- /dev/null +++ b/configcatclienttests/data/evaluation/semver_validation/semver_error.txt @@ -0,0 +1,9 @@ +WARNING [3004] Cannot evaluate condition (User.Custom1 IS NOT ONE OF ['1.0.0', '1.0.1', '2.0.0', '2.0.1', '2.0.2', '']) for setting 'isNotOneOf' ('wrong_semver' is not a valid semantic version). Please check the User.Custom1 attribute and make sure that its value corresponds to the comparison operator. +WARNING [3004] Cannot evaluate condition (User.Custom1 IS NOT ONE OF ['1.0.0', '3.0.1']) for setting 'isNotOneOf' ('wrong_semver' is not a valid semantic version). Please check the User.Custom1 attribute and make sure that its value corresponds to the comparison operator. +INFO [5000] Evaluating 'isNotOneOf' for User '{"Identifier":"12345","Custom1":"wrong_semver"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Custom1 IS NOT ONE OF ['1.0.0', '1.0.1', '2.0.0', '2.0.1', '2.0.2', ''] THEN 'Is not one of (1.0.0, 1.0.1, 2.0.0 , 2.0.1, 2.0.2, )' => cannot evaluate, the User.Custom1 attribute is invalid ('wrong_semver' is not a valid semantic version) + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Custom1 IS NOT ONE OF ['1.0.0', '3.0.1'] THEN 'Is not one of (1.0.0, 3.0.1)' => cannot evaluate, the User.Custom1 attribute is invalid ('wrong_semver' is not a valid semantic version) + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Default'. diff --git a/configcatclienttests/data/evaluation/semver_validation/semver_relations_error.txt b/configcatclienttests/data/evaluation/semver_validation/semver_relations_error.txt new file mode 100644 index 0000000..8198c85 --- /dev/null +++ b/configcatclienttests/data/evaluation/semver_validation/semver_relations_error.txt @@ -0,0 +1,18 @@ +WARNING [3004] Cannot evaluate condition (User.Custom1 < '1.0.0,') for setting 'relations' ('wrong_semver' is not a valid semantic version). Please check the User.Custom1 attribute and make sure that its value corresponds to the comparison operator. +WARNING [3004] Cannot evaluate condition (User.Custom1 < '1.0.0') for setting 'relations' ('wrong_semver' is not a valid semantic version). Please check the User.Custom1 attribute and make sure that its value corresponds to the comparison operator. +WARNING [3004] Cannot evaluate condition (User.Custom1 <= '1.0.0') for setting 'relations' ('wrong_semver' is not a valid semantic version). Please check the User.Custom1 attribute and make sure that its value corresponds to the comparison operator. +WARNING [3004] Cannot evaluate condition (User.Custom1 > '2.0.0') for setting 'relations' ('wrong_semver' is not a valid semantic version). Please check the User.Custom1 attribute and make sure that its value corresponds to the comparison operator. +WARNING [3004] Cannot evaluate condition (User.Custom1 >= '2.0.0') for setting 'relations' ('wrong_semver' is not a valid semantic version). Please check the User.Custom1 attribute and make sure that its value corresponds to the comparison operator. +INFO [5000] Evaluating 'relations' for User '{"Identifier":"12345","Custom1":"wrong_semver"}' + Evaluating targeting rules and applying the first match if any: + - IF User.Custom1 < '1.0.0,' THEN '<1.0.0,' => cannot evaluate, the User.Custom1 attribute is invalid ('wrong_semver' is not a valid semantic version) + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Custom1 < '1.0.0' THEN '< 1.0.0' => cannot evaluate, the User.Custom1 attribute is invalid ('wrong_semver' is not a valid semantic version) + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Custom1 <= '1.0.0' THEN '<=1.0.0' => cannot evaluate, the User.Custom1 attribute is invalid ('wrong_semver' is not a valid semantic version) + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Custom1 > '2.0.0' THEN '>2.0.0' => cannot evaluate, the User.Custom1 attribute is invalid ('wrong_semver' is not a valid semantic version) + The current targeting rule is ignored and the evaluation continues with the next rule. + - IF User.Custom1 >= '2.0.0' THEN '>=2.0.0' => cannot evaluate, the User.Custom1 attribute is invalid ('wrong_semver' is not a valid semantic version) + The current targeting rule is ignored and the evaluation continues with the next rule. + Returning 'Default'. diff --git a/configcatclienttests/data/evaluation/simple_value.json b/configcatclienttests/data/evaluation/simple_value.json new file mode 100644 index 0000000..070d6f5 --- /dev/null +++ b/configcatclienttests/data/evaluation/simple_value.json @@ -0,0 +1,37 @@ +{ + "configUrl": "https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d62463-86ec-8fde-f5b5-1c5c426fc830/244cf8b0-f604-11e8-b543-f23c917f9d8d", + "sdkKey": "PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A", + "tests": [ + { + "key": "boolDefaultFalse", + "defaultValue": true, + "returnValue": false, + "expectedLog": "off_flag.txt" + }, + { + "key": "boolDefaultTrue", + "defaultValue": false, + "returnValue": true, + "expectedLog": "on_flag.txt" + }, + { + "key": "stringDefaultCat", + "defaultValue": "Default", + "returnValue": "Cat", + "expectedLog": "text_setting.txt" + }, + { + "key": "integerDefaultOne", + "defaultValue": 0, + "returnValue": 1, + "expectedLog": "int_setting.txt" + }, + { + "testName": "double_setting", + "key": "doubleDefaultPi", + "defaultValue": 0.0, + "returnValue": 3.1415, + "expectedLog": "double_setting.txt" + } + ] +} diff --git a/configcatclienttests/data/evaluation/simple_value/double_setting.txt b/configcatclienttests/data/evaluation/simple_value/double_setting.txt new file mode 100644 index 0000000..4a632f7 --- /dev/null +++ b/configcatclienttests/data/evaluation/simple_value/double_setting.txt @@ -0,0 +1,2 @@ +INFO [5000] Evaluating 'doubleDefaultPi' + Returning '3.1415'. diff --git a/configcatclienttests/data/evaluation/simple_value/int_setting.txt b/configcatclienttests/data/evaluation/simple_value/int_setting.txt new file mode 100644 index 0000000..1361843 --- /dev/null +++ b/configcatclienttests/data/evaluation/simple_value/int_setting.txt @@ -0,0 +1,2 @@ +INFO [5000] Evaluating 'integerDefaultOne' + Returning '1'. diff --git a/configcatclienttests/data/evaluation/simple_value/off_flag.txt b/configcatclienttests/data/evaluation/simple_value/off_flag.txt new file mode 100644 index 0000000..17c4a69 --- /dev/null +++ b/configcatclienttests/data/evaluation/simple_value/off_flag.txt @@ -0,0 +1,2 @@ +INFO [5000] Evaluating 'boolDefaultFalse' + Returning 'False'. diff --git a/configcatclienttests/data/evaluation/simple_value/on_flag.txt b/configcatclienttests/data/evaluation/simple_value/on_flag.txt new file mode 100644 index 0000000..a392fe1 --- /dev/null +++ b/configcatclienttests/data/evaluation/simple_value/on_flag.txt @@ -0,0 +1,2 @@ +INFO [5000] Evaluating 'boolDefaultTrue' + Returning 'True'. diff --git a/configcatclienttests/data/evaluation/simple_value/text_setting.txt b/configcatclienttests/data/evaluation/simple_value/text_setting.txt new file mode 100644 index 0000000..831d7c6 --- /dev/null +++ b/configcatclienttests/data/evaluation/simple_value/text_setting.txt @@ -0,0 +1,2 @@ +INFO [5000] Evaluating 'stringDefaultCat' + Returning 'Cat'. diff --git a/configcatclienttests/test-simple.json b/configcatclienttests/data/test-simple.json similarity index 100% rename from configcatclienttests/test-simple.json rename to configcatclienttests/data/test-simple.json diff --git a/configcatclienttests/data/test.json b/configcatclienttests/data/test.json new file mode 100644 index 0000000..63c93c7 --- /dev/null +++ b/configcatclienttests/data/test.json @@ -0,0 +1,24 @@ +{ + "f": { + "disabledFeature": { + "t": 0, + "v": { "b": false } + }, + "enabledFeature": { + "t": 0, + "v": { "b": true } + }, + "intSetting": { + "t": 2, + "v": { "i": 5 } + }, + "doubleSetting": { + "t": 3, + "v": { "d": 3.14 } + }, + "stringSetting": { + "t": 1, + "v": { "s": "test" } + } + } +} \ No newline at end of file diff --git a/configcatclienttests/data/test_circulardependency_v6.json b/configcatclienttests/data/test_circulardependency_v6.json new file mode 100644 index 0000000..a8a9e17 --- /dev/null +++ b/configcatclienttests/data/test_circulardependency_v6.json @@ -0,0 +1,80 @@ +{ + "p": { + "u": "https://cdn-global.configcat.com", + "r": 0 + }, + "f": { + "key1": { + "t": 1, + "v": { "s": "key1-value" }, + "r": [ + { + "c": [ + { + "p": { + "f": "key1", + "c": 0, + "v": { "s": "key1-prereq" } + } + } + ], + "s": { "v": { "s": "key1-prereq" } } + } + ] + }, + "key2": { + "t": 1, + "v": { "s": "key2-value" }, + "r": [ + { + "c": [ + { + "p": { + "f": "key3", + "c": 0, + "v": { "s": "key3-prereq" } + } + } + ], + "s": { "v": { "s": "key2-prereq" } } + } + ] + }, + "key3": { + "t": 1, + "v": { "s": "key3-value" }, + "r": [ + { + "c": [ + { + "p": { + "f": "key2", + "c": 0, + "v": { "s": "key2-prereq" } + } + } + ], + "s": { "v": { "s": "key3-prereq" } } + } + ] + }, + "key4": { + "t": 1, + "v": { "s": "key4-value" }, + "r": [ + { + "c": [ + { + "p": { + "f": "key3", + "c": 0, + "v": { "s": "key3-prereq" } + } + } + ], + "s": { "v": { "s": "key4-prereq" } } + } + ] + } + } +} diff --git a/configcatclienttests/data/test_override_flagdependency_v6.json b/configcatclienttests/data/test_override_flagdependency_v6.json new file mode 100644 index 0000000..62e159e --- /dev/null +++ b/configcatclienttests/data/test_override_flagdependency_v6.json @@ -0,0 +1,44 @@ +{ + "p": { + "u": "https://test-cdn-eu.configcat.com", + "r": 0, + "s": "TsTuRHo\u002BMHs8h8j16HQY83sooJsLg34Ir5KIVOletFU=" + }, + "f": { + "mainStringFlag": { + "t": 1, + "v": { + "s": "private" + }, + "i": "24c96275" + }, + "stringDependsOnInt": { + "t": 1, + "r": [ + { + "c": [ + { + "p": { + "f": "mainIntFlag", + "c": 0, + "v": { + "i": 42 + } + } + } + ], + "s": { + "v": { + "s": "Dog" + }, + "i": "12531eec" + } + } + ], + "v": { + "s": "Cat" + }, + "i": "e227d926" + } + } +} diff --git a/configcatclienttests/data/test_override_segments_v6.json b/configcatclienttests/data/test_override_segments_v6.json new file mode 100644 index 0000000..47bf15c --- /dev/null +++ b/configcatclienttests/data/test_override_segments_v6.json @@ -0,0 +1,66 @@ +{ + "p": { + "u": "https://test-cdn-eu.configcat.com", + "r": 0, + "s": "80xCU/SlDz1lCiWFaxIBjyJeJecWjq46T4eu6GtozkM=" + }, + "s": [ + { + "n": "Beta Users", + "r": [ + { + "a": "Email", + "c": 16, + "l": [ + "9189c42f6035bd1d2df5eda347a4f62926d27c80540a7aa6cc72cc75bc6757ff" + ] + } + ] + }, + { + "n": "Developers", + "r": [ + { + "a": "Email", + "c": 16, + "l": [ + "a7cdf54e74b5527bd2617889ec47f6d29b825ccfc97ff00832886bcb735abded" + ] + } + ] + } + ], + "f": { + "developerAndBetaUserSegment": { + "t": 0, + "r": [ + { + "c": [ + { + "s": { + "s": 1, + "c": 0 + } + }, + { + "s": { + "s": 0, + "c": 1 + } + } + ], + "s": { + "v": { + "b": true + }, + "i": "ddc50638" + } + } + ], + "v": { + "b": false + }, + "i": "6427f4b8" + } + } +} diff --git a/configcatclienttests/testmatrix.csv b/configcatclienttests/data/testmatrix.csv similarity index 100% rename from configcatclienttests/testmatrix.csv rename to configcatclienttests/data/testmatrix.csv diff --git a/configcatclienttests/data/testmatrix_and_or.csv b/configcatclienttests/data/testmatrix_and_or.csv new file mode 100644 index 0000000..5a149f4 --- /dev/null +++ b/configcatclienttests/data/testmatrix_and_or.csv @@ -0,0 +1,15 @@ +Identifier;Email;Country;Custom1;mainFeature;dependentFeature;emailAnd;emailOr +##null##;;;;public;Chicken;Cat;Cat +;;;;public;Chicken;Cat;Cat +jane@example.com;jane@example.com;##null##;##null##;public;Chicken;Cat;Jane +john@example.com;john@example.com;##null##;##null##;public;Chicken;Cat;John +a@example.com;a@example.com;USA;##null##;target;Cat;Cat;Cat +mark@example.com;mark@example.com;USA;##null##;target;Dog;Cat;Mark +nora@example.com;nora@example.com;USA;##null##;target;Falcon;Cat;Cat +stern@msn.com;stern@msn.com;USA;##null##;target;Horse;Cat;Cat +jane@sensitivecompany.com;jane@sensitivecompany.com;England;##null##;private;Chicken;Dog;Jane +anna@sensitivecompany.com;anna@sensitivecompany.com;France;##null##;private;Chicken;Cat;Cat +jane@sensitivecompany.com;jane@sensitivecompany.com;england;##null##;public;Chicken;Dog;Jane +jane;jane;##null##;##null##;public;Chicken;Cat;Cat +@sensitivecompany.com;@sensitivecompany.com;##null##;##null##;public;Chicken;Cat;Cat +jane.sensitivecompany.com;jane.sensitivecompany.com;##null##;##null##;public;Chicken;Cat;Cat diff --git a/configcatclienttests/data/testmatrix_comparators_v6.csv b/configcatclienttests/data/testmatrix_comparators_v6.csv new file mode 100644 index 0000000..d53efb5 --- /dev/null +++ b/configcatclienttests/data/testmatrix_comparators_v6.csv @@ -0,0 +1,24 @@ +Identifier;Email;Country;Custom1;boolTrueIn202304;stringEqualsDogDefaultCat;stringEqualsCleartextDogDefaultCat;stringDoseNotEqualDogDefaultCat;stringNotEqualsCleartextDogDefaultCat;stringStartsWithDogDefaultCat;stringNotStartsWithDogDefaultCat;stringEndsWithDogDefaultCat;stringNotEndsWithDogDefaultCat;arrayContainsDogDefaultCat;arrayDoesNotContainDogDefaultCat;arrayContainsCaseCheckDogDefaultCat;arrayDoesNotContainCaseCheckDogDefaultCat;customPercentageAttribute;missingPercentageAttribute;countryPercentageAttribute;stringContainsAnyOfDogDefaultCat;stringNotContainsAnyOfDogDefaultCat;stringStartsWithAnyOfDogDefaultCat;stringStartsWithAnyOfCleartextDogDefaultCat;stringNotStartsWithAnyOfDogDefaultCat;stringNotStartsWithAnyOfCleartextDogDefaultCat;stringEndsWithAnyOfDogDefaultCat;stringEndsWithAnyOfCleartextDogDefaultCat;stringNotEndsWithAnyOfDogDefaultCat;stringNotEndsWithAnyOfCleartextDogDefaultCat;stringArrayContainsAnyOfDogDefaultCat;stringArrayContainsAnyOfCleartextDogDefaultCat;stringArrayNotContainsAnyOfDogDefaultCat;stringArrayNotContainsAnyOfCleartextDogDefaultCat +##null##;;;;False;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Chicken;Chicken;Chicken;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat +;;;;False;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Chicken;Chicken;Chicken;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat;Cat +a@configcat.com;a@configcat.com;##null##;##null##;False;Dog;Dog;Dog;Dog;Dog;Cat;Dog;Cat;Cat;Cat;Cat;Cat;Chicken;NotFound;Chicken;Cat;Dog;Dog;Dog;Cat;Cat;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat +b@configcat.com;b@configcat.com;Hungary;0;False;Cat;Cat;Cat;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Horse;NotFound;Falcon;Cat;Dog;Dog;Dog;Cat;Cat;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat +c@configcat.com;c@configcat.com;United Kingdom;1680307199.9;False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Falcon;NotFound;Falcon;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat +anna@configcat.com;anna@configcat.com;Hungary;1681118000.56;True;Cat;Cat;Dog;Dog;Dog;Cat;Dog;Cat;Cat;Cat;Cat;Cat;Falcon;NotFound;Falcon;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat +bogjobber@verizon.net;bogjobber@verizon.net;##null##;1682899200.1;False;Cat;Cat;Dog;Dog;Cat;Dog;Cat;Dog;Cat;Cat;Cat;Cat;Horse;Chicken;Chicken;Dog;Cat;Cat;Cat;Dog;Dog;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Cat +cliffordj@aol.com;cliffordj@aol.com;Austria;1682999200;False;Cat;Cat;Dog;Dog;Cat;Dog;Cat;Dog;Cat;Cat;Cat;Cat;Falcon;Chicken;Falcon;Dog;Cat;Cat;Cat;Dog;Dog;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Cat +reader@configcat.com;reader@configcat.com;Bahamas;read,execute;False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Falcon;NotFound;Falcon;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat +writer@configcat.com;writer@configcat.com;Belgium;write, execute;False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Horse;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat +reader@configcat.com;reader@configcat.com;Canada;execute, Read;False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Horse;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat +writer@configcat.com;writer@configcat.com;China;Write;False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Falcon;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat +admin@configcat.com;admin@configcat.com;France;read, write,execute;False;Cat;Cat;Dog;Dog;Dog;Cat;Dog;Cat;Cat;Cat;Cat;Cat;Falcon;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat +user@configcat.com;user@configcat.com;Greece;,execute;False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Falcon;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat +reader@configcat.com;reader@configcat.com;Bahamas;["read","execute"];False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Dog;Falcon;NotFound;Falcon;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Dog;Dog;Cat;Cat +writer@configcat.com;writer@configcat.com;Belgium;["write", "execute"];False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Dog;Falcon;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Dog;Dog;Cat;Cat +reader@configcat.com;reader@configcat.com;Canada;["execute", "Read"];False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Dog;Horse;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Dog;Dog;Cat;Cat +writer@configcat.com;writer@configcat.com;China;["Write"];False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Cat;Dog;Cat;Cat;Horse;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog +admin@configcat.com;admin@configcat.com;France;["read", "write","execute"];False;Cat;Cat;Dog;Dog;Dog;Cat;Dog;Cat;Dog;Cat;Cat;Dog;Falcon;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Dog;Dog;Cat;Cat +admin@configcat.com;admin@configcat.com;France;["Read", "Write", "execute"];False;Cat;Cat;Dog;Dog;Dog;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Horse;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Dog;Dog;Cat;Cat +admin@configcat.com;admin@configcat.com;France;["Read", "Write", "eXecute"];False;Cat;Cat;Dog;Dog;Dog;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Falcon;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog +user@configcat.com;user@configcat.com;Greece;["","execute"];False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Cat;Dog;Cat;Dog;Horse;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Dog;Dog;Cat;Cat +user@configcat.com;user@configcat.com;Monaco;,null, ,,nil, None;False;Cat;Cat;Dog;Dog;Cat;Dog;Dog;Cat;Cat;Cat;Cat;Cat;Falcon;NotFound;Horse;Cat;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Dog;Dog;Cat;Cat;Cat;Cat diff --git a/configcatclienttests/testmatrix_number.csv b/configcatclienttests/data/testmatrix_number.csv similarity index 100% rename from configcatclienttests/testmatrix_number.csv rename to configcatclienttests/data/testmatrix_number.csv diff --git a/configcatclienttests/data/testmatrix_prerequisite_flag.csv b/configcatclienttests/data/testmatrix_prerequisite_flag.csv new file mode 100644 index 0000000..dcf68f4 --- /dev/null +++ b/configcatclienttests/data/testmatrix_prerequisite_flag.csv @@ -0,0 +1,5 @@ +Identifier;Email;Country;Custom1;mainBoolFlag;mainStringFlag;mainIntFlag;mainDoubleFlag;stringDependsOnBool;stringDependsOnString;stringDependsOnStringCaseCheck;stringDependsOnInt;stringDependsOnDouble;stringDependsOnDoubleIntValue;boolDependsOnBool;intDependsOnBool;doubleDependsOnBool;boolDependsOnBoolDependsOnBool;mainBoolFlagEmpty;stringDependsOnEmptyBool;stringInverseDependsOnEmptyBool;mainBoolFlagInverse;boolDependsOnBoolInverse +##null##;;;;True;public;42;3.14;Dog;Cat;Cat;Cat;Cat;Cat;True;1;1.1;False;True;EmptyOn;EmptyOn;False;True +;;;;True;public;42;3.14;Dog;Cat;Cat;Cat;Cat;Cat;True;1;1.1;False;True;EmptyOn;EmptyOn;False;True +john@sensitivecompany.com;john@sensitivecompany.com;##null##;##null##;False;private;2;0.1;Cat;Dog;Cat;Dog;Dog;Cat;False;42;3.14;True;True;EmptyOn;EmptyOn;True;False +jane@example.com;jane@example.com;##null##;##null##;True;public;42;3.14;Dog;Cat;Cat;Cat;Cat;Cat;True;1;1.1;False;True;EmptyOn;EmptyOn;False;True diff --git a/configcatclienttests/data/testmatrix_segments.csv b/configcatclienttests/data/testmatrix_segments.csv new file mode 100644 index 0000000..b59ba3a --- /dev/null +++ b/configcatclienttests/data/testmatrix_segments.csv @@ -0,0 +1,6 @@ +Identifier;Email;Country;Custom1;developerAndBetaUserSegment;developerAndBetaUserCleartextSegment;notDeveloperAndNotBetaUserSegment;notDeveloperAndNotBetaUserCleartextSegment +##null##;;;;False;False;False;False +;;;;False;False;False;False +john@example.com;john@example.com;##null##;##null##;False;False;False;False +jane@example.com;jane@example.com;##null##;##null##;False;False;False;False +kate@example.com;kate@example.com;##null##;##null##;True;True;True;True diff --git a/configcatclienttests/data/testmatrix_segments_old.csv b/configcatclienttests/data/testmatrix_segments_old.csv new file mode 100644 index 0000000..9fc605e --- /dev/null +++ b/configcatclienttests/data/testmatrix_segments_old.csv @@ -0,0 +1,6 @@ +Identifier;Email;Country;Custom1;featureWithSegmentTargeting;featureWithSegmentTargetingCleartext;featureWithNegatedSegmentTargeting;featureWithNegatedSegmentTargetingCleartext;featureWithSegmentTargetingInverse;featureWithSegmentTargetingInverseCleartext;featureWithNegatedSegmentTargetingInverse;featureWithNegatedSegmentTargetingInverseCleartext +##null##;;;;False;False;False;False;False;False;False;False +;;;;False;False;False;False;False;False;False;False +john@example.com;john@example.com;##null##;##null##;True;True;False;False;False;False;True;True +jane@example.com;jane@example.com;##null##;##null##;True;True;False;False;False;False;True;True +kate@example.com;kate@example.com;##null##;##null##;False;False;True;True;True;True;False;False diff --git a/configcatclienttests/testmatrix_semantic.csv b/configcatclienttests/data/testmatrix_semantic.csv similarity index 100% rename from configcatclienttests/testmatrix_semantic.csv rename to configcatclienttests/data/testmatrix_semantic.csv diff --git a/configcatclienttests/testmatrix_semantic_2.csv b/configcatclienttests/data/testmatrix_semantic_2.csv similarity index 100% rename from configcatclienttests/testmatrix_semantic_2.csv rename to configcatclienttests/data/testmatrix_semantic_2.csv diff --git a/configcatclienttests/testmatrix_sensitive.csv b/configcatclienttests/data/testmatrix_sensitive.csv similarity index 100% rename from configcatclienttests/testmatrix_sensitive.csv rename to configcatclienttests/data/testmatrix_sensitive.csv diff --git a/configcatclienttests/data/testmatrix_unicode.csv b/configcatclienttests/data/testmatrix_unicode.csv new file mode 100644 index 0000000..e5b01de --- /dev/null +++ b/configcatclienttests/data/testmatrix_unicode.csv @@ -0,0 +1,14 @@ +Identifier;Email;Country;🆃🅴🆇🆃;boolTextEqualsHashed;boolTextEqualsCleartext;boolTextNotEqualsHashed;boolTextNotEqualsCleartext;boolIsOneOfHashed;boolIsOneOfCleartext;boolIsNotOneOfHashed;boolIsNotOneOfCleartext;boolStartsWithHashed;boolStartsWithCleartext;boolNotStartsWithHashed;boolNotStartsWithCleartext;boolEndsWithHashed;boolEndsWithCleartext;boolNotEndsWithHashed;boolNotEndsWithCleartext;boolContainsCleartext;boolNotContainsCleartext;boolArrayContainsHashed;boolArrayContainsCleartext;boolArrayNotContainsHashed;boolArrayNotContainsCleartext +1;;;ʄǟռƈʏ ȶɛӼȶ;True;True;False;False;False;False;True;True;False;False;True;True;False;False;True;True;False;True;False;False;False;False +1;;;ʄaռƈʏ ȶɛӼȶ;False;False;True;True;False;False;True;True;False;False;True;True;False;False;True;True;False;True;False;False;False;False +1;;;ÁRVÍZTŰRŐ tükörfúrógép;False;False;True;True;True;True;False;False;True;True;False;False;True;True;False;False;True;False;False;False;False;False +1;;;árvíztűrő tükörfúrógép;False;False;True;True;False;False;True;True;False;False;True;True;True;True;False;False;True;False;False;False;False;False +1;;;ÁRVÍZTŰRŐ TÜKÖRFÚRÓGÉP;False;False;True;True;False;False;True;True;True;True;False;False;False;False;True;True;True;False;False;False;False;False +1;;;árvíztűrő TÜKÖRFÚRÓGÉP;False;False;True;True;False;False;True;True;False;False;True;True;False;False;True;True;False;True;False;False;False;False +1;;;u𝖓𝖎𝖈𝖔𝖉e;False;False;True;True;True;True;False;False;True;True;False;False;True;True;False;False;True;False;False;False;False;False +;;;𝖚𝖓𝖎𝖈𝖔𝖉e;False;False;True;True;False;False;True;True;False;False;True;True;True;True;False;False;True;False;False;False;False;False +;;;u𝖓𝖎𝖈𝖔𝖉𝖊;False;False;True;True;False;False;True;True;True;True;False;False;False;False;True;True;True;False;False;False;False;False +;;;𝖚𝖓𝖎𝖈𝖔𝖉𝖊;False;False;True;True;False;False;True;True;False;False;True;True;False;False;True;True;False;True;False;False;False;False +1;;;["ÁRVÍZTŰRŐ tükörfúrógép", "unicode"];False;False;True;True;False;False;True;True;False;False;True;True;False;False;True;True;True;False;True;True;False;False +1;;;["ÁRVÍZTŰRŐ", "tükörfúrógép", "u𝖓𝖎𝖈𝖔𝖉e"];False;False;True;True;False;False;True;True;False;False;True;True;False;False;True;True;True;False;True;True;False;False +1;;;["ÁRVÍZTŰRŐ", "tükörfúrógép", "unicode"];False;False;True;True;False;False;True;True;False;False;True;True;False;False;True;True;True;False;False;False;True;True diff --git a/configcatclienttests/testmatrix_variationId.csv b/configcatclienttests/data/testmatrix_variationId.csv similarity index 100% rename from configcatclienttests/testmatrix_variationId.csv rename to configcatclienttests/data/testmatrix_variationId.csv diff --git a/configcatclienttests/mocks.py b/configcatclienttests/mocks.py index 78ad6c5..d3879a8 100644 --- a/configcatclienttests/mocks.py +++ b/configcatclienttests/mocks.py @@ -1,6 +1,8 @@ import json import time +import logging +from configcatclient.config import SettingType from configcatclient.configentry import ConfigEntry from configcatclient.utils import get_utc_now_seconds_since_epoch, distant_past @@ -13,53 +15,54 @@ from configcatclient.configfetcher import FetchResponse, ConfigFetcher from configcatclient.interfaces import ConfigCache -TEST_JSON = '{' \ - ' "p": {' \ - ' "u": "https://cdn-global.configcat.com",' \ - ' "r": 0' \ - ' },' \ - ' "f": {' \ - ' "testKey": { "v": "testValue", "t": 1, "p": [], "r": [] }' \ - ' }' \ - '}' - -TEST_JSON_FORMAT = '{{ "f": {{ "testKey": {{ "v": {value}, "p": [], "r": [] }} }} }}' - -TEST_JSON2 = '{' \ - ' "p": {' \ - ' "u": "https://cdn-global.configcat.com",' \ - ' "r": 0' \ - ' },' \ - ' "f": {' \ - ' "testKey": { "v": "testValue", "t": 1, "p": [], "r": [] }, ' \ - ' "testKey2": { "v": "testValue2", "t": 1, "p": [], "r": [] }' \ - ' }' \ - '}' - -TEST_OBJECT = json.loads( - '{' - '"p": {' - '"u": "https://cdn-global.configcat.com",' - '"r": 0' - "}," - '"f": {' - '"testBoolKey": ' - '{"v": true, "t": 0, "p": [], "r": []},' - '"testStringKey": ' - '{"v": "testValue", "i": "id", "t": 1, "p": [], "r": [' - ' {"i":"id1","v":"fake1","a":"Identifier","t":2,"c":"@test1.com"},' - ' {"i":"id2","v":"fake2","a":"Identifier","t":2,"c":"@test2.com"}' - ']},' - '"testIntKey": ' - '{"v": 1, "t": 2, "p": [], "r": []},' - '"testDoubleKey": ' - '{"v": 1.1, "t": 3,"p": [], "r": []},' - '"key1": ' - '{"v": true, "i": "fakeId1","p": [], "r": []},' - '"key2": ' - '{"v": false, "i": "fakeId2","p": [], "r": []}' - '}' - '}') +TEST_SDK_KEY = 'configcat-sdk-test-key/0000000000000000000000' +TEST_SDK_KEY1 = 'configcat-sdk-test-key/0000000000000000000001' +TEST_SDK_KEY2 = 'configcat-sdk-test-key/0000000000000000000002' + +TEST_JSON = r'''{ + "p": { + "u": "https://cdn-global.configcat.com", + "r": 0 + }, + "f": { + "testKey": { "v": { "s": "testValue" }, "t": 1 } + } +}''' + +TEST_JSON_FORMAT = '{{ "f": {{ "testKey": {{ "t": {value_type}, "v": {value}, "p": [], "r": [] }} }} }}' + +TEST_JSON2 = r'''{ + "p": { + "u": "https://cdn-global.configcat.com", + "r": 0 + }, + "f": { + "testKey": { "v": { "s": "testValue" }, "t": 1 }, + "testKey2": { "v": { "s": "testValue2" }, "t": 1 } + } +}''' + +TEST_OBJECT = json.loads(r'''{ + "p": { + "u": "https://cdn-global.configcat.com", + "r": 0 + }, + "s": [ + {"n": "id1", "r": [{"a": "Identifier", "c": 2, "l": ["@test1.com"]}]}, + {"n": "id2", "r": [{"a": "Identifier", "c": 2, "l": ["@test2.com"]}]} + ], + "f": { + "testBoolKey": {"v": {"b": true}, "t": 0}, + "testStringKey": {"v": {"s": "testValue"}, "i": "id", "t": 1, "r": [ + {"c": [{"s": {"s": 0, "c": 0}}], "s": {"v": {"s": "fake1"}, "i": "id1"}}, + {"c": [{"s": {"s": 1, "c": 0}}], "s": {"v": {"s": "fake2"}, "i": "id2"}} + ]}, + "testIntKey": {"v": {"i": 1}, "t": 2}, + "testDoubleKey": {"v": {"d": 1.1}, "t": 3}, + "key1": {"v": {"b": true}, "t": 0, "i": "fakeId1"}, + "key2": {"v": {"b": false}, "t": 0, "i": "fakeId2"} + } +}''') class ConfigFetcherMock(ConfigFetcher): @@ -115,7 +118,8 @@ def __init__(self): def get_configuration(self, etag=''): self._value += 1 - config_json_string = TEST_JSON_FORMAT.format(value=self._value) + value_string = '{ "i": %s }' % self._value + config_json_string = TEST_JSON_FORMAT.format(value_type=SettingType.INT, value=value_string) config = json.loads(config_json_string) return FetchResponse.success(ConfigEntry(config, etag, config_json_string)) @@ -145,7 +149,7 @@ def __init__(self, etag): self.etag = etag def get(self, name): - if name == 'Etag': + if name == 'ETag': return self.etag return None @@ -197,3 +201,24 @@ def on_error(self, error): def callback_exception(self, *args, **kwargs): self.callback_exception_call_count += 1 raise Exception("error") + + +class MockLogHandler(logging.Handler): + def __init__(self, *args, **kwargs): + super(MockLogHandler, self).__init__(*args, **kwargs) + self.error_logs = [] + self.warning_logs = [] + self.info_logs = [] + + def clear(self): + self.error_logs = [] + self.warning_logs = [] + self.info_logs = [] + + def emit(self, record): + if record.levelno == logging.ERROR: + self.error_logs.append(record.getMessage()) + elif record.levelno == logging.WARNING: + self.warning_logs.append(record.getMessage()) + elif record.levelno == logging.INFO: + self.info_logs.append(record.getMessage()) diff --git a/configcatclienttests/test.json b/configcatclienttests/test.json deleted file mode 100644 index d547507..0000000 --- a/configcatclienttests/test.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "f": { - "disabledFeature": { - "v": false - }, - "enabledFeature": { - "v": true - }, - "intSetting": { - "v": 5 - }, - "doubleSetting": { - "v": 3.14 - }, - "stringSetting": { - "v": "test" - } - } -} \ No newline at end of file diff --git a/configcatclienttests/test_autopollingcachepolicy.py b/configcatclienttests/test_autopollingcachepolicy.py index 439d73b..9c8df19 100644 --- a/configcatclienttests/test_autopollingcachepolicy.py +++ b/configcatclienttests/test_autopollingcachepolicy.py @@ -10,7 +10,7 @@ from configcatclient.configentry import ConfigEntry from configcatclient.configfetcher import ConfigFetcher from configcatclient.configservice import ConfigService -from configcatclient.constants import VALUE +from configcatclient.config import VALUE, FEATURE_FLAGS, STRING_VALUE, INT_VALUE from configcatclient.logger import Logger from configcatclient.utils import get_utc_now_seconds_since_epoch from configcatclienttests.mocks import ConfigFetcherMock, ConfigFetcherWithErrorMock, ConfigFetcherWaitMock, \ @@ -39,8 +39,9 @@ def test_wrong_params(self): max_init_wait_time_seconds=-1), Hooks(), config_fetcher, log, config_cache, False) time.sleep(2) - settings, _ = cache_policy.get_settings() - self.assertEqual('testValue', settings.get('testKey').get(VALUE)) + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) + self.assertEqual('testValue', settings.get('testKey').get(VALUE).get(STRING_VALUE)) cache_policy.close() def test_init_wait_time_ok(self): @@ -49,8 +50,9 @@ def test_init_wait_time_ok(self): cache_policy = ConfigService('', PollingMode.auto_poll(poll_interval_seconds=60, max_init_wait_time_seconds=5), Hooks(), config_fetcher, log, config_cache, False) - settings, _ = cache_policy.get_settings() - self.assertEqual('testValue', settings.get('testKey').get(VALUE)) + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) + self.assertEqual('testValue', settings.get('testKey').get(VALUE).get(STRING_VALUE)) cache_policy.close() def test_init_wait_time_timeout(self): @@ -60,10 +62,10 @@ def test_init_wait_time_timeout(self): cache_policy = ConfigService('', PollingMode.auto_poll(poll_interval_seconds=60, max_init_wait_time_seconds=1), Hooks(), config_fetcher, log, config_cache, False) - settings, _ = cache_policy.get_settings() + config, _ = cache_policy.get_config() end_time = time.time() elapsed_time = end_time - start_time - self.assertEqual(settings, None) + self.assertEqual(config, None) self.assertTrue(elapsed_time > 1) self.assertTrue(elapsed_time < 2) cache_policy.close() @@ -76,8 +78,9 @@ def test_fetch_call_count(self): Hooks(), config_fetcher, log, config_cache, False) time.sleep(3) self.assertEqual(config_fetcher.get_call_count, 2) - settings, _ = cache_policy.get_settings() - self.assertEqual('testValue', settings.get('testKey').get(VALUE)) + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) + self.assertEqual('testValue', settings.get('testKey').get(VALUE).get(STRING_VALUE)) cache_policy.close() def test_updated_values(self): @@ -86,11 +89,13 @@ def test_updated_values(self): cache_policy = ConfigService('', PollingMode.auto_poll(poll_interval_seconds=2, max_init_wait_time_seconds=5), Hooks(), config_fetcher, log, config_cache, False) - settings, _ = cache_policy.get_settings() - self.assertEqual(1, settings.get('testKey').get(VALUE)) + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) + self.assertEqual(1, settings.get('testKey').get(VALUE).get(INT_VALUE)) time.sleep(2.200) - settings, _ = cache_policy.get_settings() - self.assertEqual(2, settings.get('testKey').get(VALUE)) + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) + self.assertEqual(2, settings.get('testKey').get(VALUE).get(INT_VALUE)) cache_policy.close() def test_error(self): @@ -101,8 +106,8 @@ def test_error(self): Hooks(), config_fetcher, log, config_cache, False) # Get value from Config Store, which indicates a config_fetcher call - settings, _ = cache_policy.get_settings() - self.assertEqual(settings, None) + config, _ = cache_policy.get_config() + self.assertEqual(config, None) cache_policy.close() def test_close(self): @@ -112,11 +117,13 @@ def test_close(self): max_init_wait_time_seconds=5), Hooks(), config_fetcher, log, config_cache, False) cache_policy.close() - settings, _ = cache_policy.get_settings() - self.assertEqual(1, settings.get('testKey').get(VALUE)) + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) + self.assertEqual(1, settings.get('testKey').get(VALUE).get(INT_VALUE)) time.sleep(2.200) - settings, _ = cache_policy.get_settings() - self.assertEqual(1, settings.get('testKey').get(VALUE)) + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) + self.assertEqual(1, settings.get('testKey').get(VALUE).get(INT_VALUE)) cache_policy.close() def test_rerun(self): @@ -186,8 +193,9 @@ def test_with_failed_refresh(self): cache_policy = ConfigService('', polling_mode, Hooks(), config_fetcher, log, NullConfigCache(), False) # first call - settings, _ = cache_policy.get_settings() - self.assertEqual('testValue', settings.get('testStringKey').get(VALUE)) + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) + self.assertEqual('testValue', settings.get('testStringKey').get(VALUE).get(STRING_VALUE)) response_mock.json.return_value = {} response_mock.status_code = 500 @@ -197,8 +205,9 @@ def test_with_failed_refresh(self): time.sleep(1.5) # previous value returned because of the refresh failure - settings, _ = cache_policy.get_settings() - self.assertEqual('testValue', settings.get('testStringKey').get(VALUE)) + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) + self.assertEqual('testValue', settings.get('testStringKey').get(VALUE).get(STRING_VALUE)) cache_policy.close() @@ -218,21 +227,23 @@ def test_return_cached_config_when_cache_is_not_expired(self): max_init_wait_time_seconds), Hooks(), config_fetcher, log, config_cache, False) - settings, _ = cache_policy.get_settings() + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) elapsed_time = time.time() - start_time # max init wait time should be ignored when cache is not expired self.assertLessEqual(elapsed_time, max_init_wait_time_seconds) - self.assertEqual('testValue', settings.get('testKey').get(VALUE)) + self.assertEqual('testValue', settings.get('testKey').get(VALUE).get(STRING_VALUE)) self.assertEqual(config_fetcher.get_call_count, 0) self.assertEqual(config_fetcher.get_fetch_count, 0) time.sleep(3) - settings, _ = cache_policy.get_settings() + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) - self.assertEqual('testValue', settings.get('testKey').get(VALUE)) + self.assertEqual('testValue', settings.get('testKey').get(VALUE).get(STRING_VALUE)) self.assertEqual(config_fetcher.get_call_count, 1) self.assertEqual(config_fetcher.get_fetch_count, 1) cache_policy.close() @@ -251,9 +262,10 @@ def test_fetch_config_when_cache_is_expired(self): max_init_wait_time_seconds), Hooks(), config_fetcher, log, config_cache, False) - settings, _ = cache_policy.get_settings() + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) - self.assertEqual('testValue', settings.get('testKey').get(VALUE)) + self.assertEqual('testValue', settings.get('testKey').get(VALUE).get(STRING_VALUE)) self.assertEqual(config_fetcher.get_call_count, 1) self.assertEqual(config_fetcher.get_fetch_count, 1) cache_policy.close() @@ -274,13 +286,14 @@ def test_init_wait_time_return_cached(self): max_init_wait_time_seconds), Hooks(), config_fetcher, log, config_cache, False) - settings, _ = cache_policy.get_settings() + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) elapsed_time = time.time() - start_time self.assertGreater(elapsed_time, max_init_wait_time_seconds) self.assertLess(elapsed_time, max_init_wait_time_seconds + 1) - self.assertEqual('testValue', settings.get('testKey').get(VALUE)) - self.assertEqual('testValue2', settings.get('testKey2').get(VALUE)) + self.assertEqual('testValue', settings.get('testKey').get(VALUE).get(STRING_VALUE)) + self.assertEqual('testValue2', settings.get('testKey2').get(VALUE).get(STRING_VALUE)) cache_policy.close() def test_online_offline(self): @@ -289,7 +302,7 @@ def test_online_offline(self): request_get.return_value = response_mock response_mock.json.return_value = TEST_OBJECT response_mock.status_code = 200 - response_mock.headers = {'Etag': 'test-etag'} + response_mock.headers = {'ETag': 'test-etag'} polling_mode = PollingMode.auto_poll(poll_interval_seconds=1) config_fetcher = ConfigFetcher('', log, polling_mode.identifier()) @@ -302,8 +315,9 @@ def test_online_offline(self): cache_policy.set_offline() self.assertTrue(cache_policy.is_offline()) - settings, _ = cache_policy.get_settings() - self.assertEqual('testValue', settings.get('testStringKey').get(VALUE)) + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) + self.assertEqual('testValue', settings.get('testStringKey').get(VALUE).get(STRING_VALUE)) self.assertEqual(2, request_get.call_count) time.sleep(2) @@ -323,7 +337,7 @@ def test_init_offline(self): request_get.return_value = response_mock response_mock.json.return_value = TEST_OBJECT response_mock.status_code = 200 - response_mock.headers = {'Etag': 'test-etag'} + response_mock.headers = {'ETag': 'test-etag'} polling_mode = PollingMode.auto_poll(poll_interval_seconds=1) config_fetcher = ConfigFetcher('', log, polling_mode.identifier()) @@ -332,14 +346,14 @@ def test_init_offline(self): Hooks(), config_fetcher, log, NullConfigCache(), True) self.assertTrue(cache_policy.is_offline()) - settings, _ = cache_policy.get_settings() - self.assertIsNone(settings) + config, _ = cache_policy.get_config() + self.assertIsNone(config) self.assertEqual(0, request_get.call_count) time.sleep(2) - settings, _ = cache_policy.get_settings() - self.assertIsNone(settings) + config, _ = cache_policy.get_config() + self.assertIsNone(config) self.assertEqual(0, request_get.call_count) cache_policy.set_online() @@ -347,8 +361,9 @@ def test_init_offline(self): time.sleep(2.5) - settings, _ = cache_policy.get_settings() - self.assertEqual('testValue', settings.get('testStringKey').get(VALUE)) + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) + self.assertEqual('testValue', settings.get('testStringKey').get(VALUE).get(STRING_VALUE)) self.assertGreaterEqual(request_get.call_count, 2) cache_policy.close() diff --git a/configcatclienttests/test_concurrency.py b/configcatclienttests/test_concurrency.py index 19fe9f3..cdfc3d9 100644 --- a/configcatclienttests/test_concurrency.py +++ b/configcatclienttests/test_concurrency.py @@ -1,8 +1,11 @@ import logging +import sys import unittest import multiprocessing from time import sleep +import pytest + import configcatclient from configcatclient.user import User @@ -16,6 +19,7 @@ def _manual_force_refresh(client, repeat=10, delay=0.1): class ConcurrencyTests(unittest.TestCase): + @pytest.mark.skipif(sys.platform == 'win32' or sys.platform == 'darwin', reason="TypeError: can't pickle _thread.lock objects") def test_concurrency_process(self): client = configcatclient.get('PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A') value = client.get_value('keySampleText', False, User('key')) diff --git a/configcatclienttests/test_configcache.py b/configcatclienttests/test_configcache.py index 716c1af..822fd52 100644 --- a/configcatclienttests/test_configcache.py +++ b/configcatclienttests/test_configcache.py @@ -3,12 +3,13 @@ import unittest from configcatclient import ConfigCatClient, ConfigCatOptions, PollingMode +from configcatclient.config import SettingType from configcatclient.configcache import InMemoryConfigCache from configcatclient.configcatoptions import Hooks from configcatclient.configentry import ConfigEntry from configcatclient.configservice import ConfigService from configcatclient.utils import get_utc_now_seconds_since_epoch -from configcatclienttests.mocks import TEST_JSON, SingleValueConfigCache, HookCallbacks, TEST_JSON_FORMAT +from configcatclienttests.mocks import TEST_JSON, SingleValueConfigCache, HookCallbacks, TEST_JSON_FORMAT, TEST_SDK_KEY logging.basicConfig() @@ -29,8 +30,8 @@ def test_cache(self): self.assertEqual(value2, None) def test_cache_key(self): - self.assertEqual("147c5b4c2b2d7c77e1605b1a4309f0ea6684a0c6", ConfigService._get_cache_key('test1')) - self.assertEqual("c09513b1756de9e4bc48815ec7a142b2441ed4d5", ConfigService._get_cache_key('test2')) + self.assertEqual("7f845c43ecc95e202b91e271435935e6d1391e5d", ConfigService._get_cache_key('test1')) + self.assertEqual("a78b7e323ef543a272c74540387566a22415148a", ConfigService._get_cache_key('test2')) def test_cache_payload(self): now_seconds = 1686756435.8449 @@ -41,7 +42,7 @@ def test_cache_payload(self): def test_invalid_cache_content(self): hook_callbacks = HookCallbacks() hooks = Hooks(on_error=hook_callbacks.on_error) - config_json_string = TEST_JSON_FORMAT.format(value='"test"') + config_json_string = TEST_JSON_FORMAT.format(value_type=SettingType.STRING, value='{"s": "test"}') config_cache = SingleValueConfigCache(ConfigEntry( config=json.loads(config_json_string), etag='test-etag', @@ -49,9 +50,9 @@ def test_invalid_cache_content(self): fetch_time=get_utc_now_seconds_since_epoch()).serialize() ) - client = ConfigCatClient.get('test', ConfigCatOptions(polling_mode=PollingMode.manual_poll(), - config_cache=config_cache, - hooks=hooks)) + client = ConfigCatClient.get(TEST_SDK_KEY, ConfigCatOptions(polling_mode=PollingMode.manual_poll(), + config_cache=config_cache, + hooks=hooks)) self.assertEqual('test', client.get_value('testKey', 'default')) self.assertEqual(0, hook_callbacks.error_call_count) @@ -59,14 +60,14 @@ def test_invalid_cache_content(self): # Invalid fetch time in cache config_cache._value = '\n'.join(['text', 'test-etag', - TEST_JSON_FORMAT.format(value='"test2"')]) + TEST_JSON_FORMAT.format(value_type=SettingType.STRING, value='{"s": "test2"}')]) self.assertEqual('test', client.get_value('testKey', 'default')) self.assertTrue('Error occurred while reading the cache.\nInvalid fetch time: text' in hook_callbacks.error) # Number of values is fewer than expected config_cache._value = '\n'.join([str(get_utc_now_seconds_since_epoch()), - TEST_JSON_FORMAT.format(value='"test2"')]) + TEST_JSON_FORMAT.format(value_type=SettingType.STRING, value='{"s": "test2"}')]) self.assertEqual('test', client.get_value('testKey', 'default')) self.assertTrue('Error occurred while reading the cache.\nNumber of values is fewer than expected.' diff --git a/configcatclienttests/test_configcatclient.py b/configcatclienttests/test_configcatclient.py index 11f4a56..75fe747 100644 --- a/configcatclienttests/test_configcatclient.py +++ b/configcatclienttests/test_configcatclient.py @@ -1,16 +1,22 @@ import datetime +import json import logging import unittest + +import pytest import requests +from parameterized import parameterized from configcatclient import ConfigCatClientException from configcatclient.configcatclient import ConfigCatClient -from configcatclient.constants import VALUE, COMPARATOR, COMPARISON_ATTRIBUTE, COMPARISON_VALUE +from configcatclient.configentry import ConfigEntry +from configcatclient.config import VALUE, SERVED_VALUE, STRING_VALUE from configcatclient.user import User -from configcatclient.configcatoptions import ConfigCatOptions +from configcatclient.configcatoptions import ConfigCatOptions, Hooks from configcatclient.pollingmode import PollingMode -from configcatclient.utils import get_utc_now -from configcatclienttests.mocks import ConfigCacheMock, TEST_OBJECT +from configcatclient.utils import get_utc_now, get_utc_now_seconds_since_epoch +from configcatclienttests.mocks import ConfigCacheMock, TEST_OBJECT, TEST_SDK_KEY, HookCallbacks, SingleValueConfigCache, \ + MockLogHandler # Python2/Python3 support try: @@ -23,18 +29,19 @@ from mock import Mock, ANY logging.basicConfig(level=logging.INFO) +logging.getLogger('configcat').setLevel(logging.INFO) class ConfigCatClientTests(unittest.TestCase): def test_ensure_singleton_per_sdk_key(self): - client1 = ConfigCatClient.get('test', ConfigCatOptions(polling_mode=PollingMode.manual_poll())) - client2 = ConfigCatClient.get('test', ConfigCatOptions(polling_mode=PollingMode.manual_poll())) + client1 = ConfigCatClient.get(TEST_SDK_KEY, ConfigCatOptions(polling_mode=PollingMode.manual_poll())) + client2 = ConfigCatClient.get(TEST_SDK_KEY, ConfigCatOptions(polling_mode=PollingMode.manual_poll())) self.assertEqual(client1, client2) ConfigCatClient.close_all() - client1 = ConfigCatClient.get('test', ConfigCatOptions(polling_mode=PollingMode.manual_poll())) + client1 = ConfigCatClient.get(TEST_SDK_KEY, ConfigCatOptions(polling_mode=PollingMode.manual_poll())) self.assertNotEqual(client1, client2) @@ -47,53 +54,105 @@ def test_without_sdk_key(self): except ConfigCatClientException: pass + def test_invalid_sdk_key(self): + with pytest.raises(ConfigCatClientException): + ConfigCatClient.get('key') + + with pytest.raises(ConfigCatClientException): + ConfigCatClient.get('configcat-proxy/key') + + with pytest.raises(ConfigCatClientException): + ConfigCatClient.get('1234567890abcdefghijkl01234567890abcdefghijkl') + + with pytest.raises(ConfigCatClientException): + ConfigCatClient.get('configcat-sdk-2/1234567890abcdefghijkl/1234567890abcdefghijkl') + + with pytest.raises(ConfigCatClientException): + ConfigCatClient.get('configcat/1234567890abcdefghijkl/1234567890abcdefghijkl') + + ConfigCatClient.get('1234567890abcdefghijkl/1234567890abcdefghijkl') + ConfigCatClient.get('configcat-sdk-1/1234567890abcdefghijkl/1234567890abcdefghijkl') + ConfigCatClient.get('configcat-proxy/key', options=ConfigCatOptions(base_url='base_url')) + def test_bool(self): - client = ConfigCatClient.get('test', ConfigCatOptions(polling_mode=PollingMode.manual_poll(), - config_cache=ConfigCacheMock())) + client = ConfigCatClient.get(TEST_SDK_KEY, ConfigCatOptions(polling_mode=PollingMode.manual_poll(), + config_cache=ConfigCacheMock())) self.assertEqual(True, client.get_value('testBoolKey', False)) client.close() def test_string(self): - client = ConfigCatClient.get('test', ConfigCatOptions(polling_mode=PollingMode.manual_poll(), - config_cache=ConfigCacheMock())) + client = ConfigCatClient.get(TEST_SDK_KEY, ConfigCatOptions(polling_mode=PollingMode.manual_poll(), + config_cache=ConfigCacheMock())) self.assertEqual('testValue', client.get_value('testStringKey', 'default')) client.close() def test_int(self): - client = ConfigCatClient.get('test', ConfigCatOptions(polling_mode=PollingMode.manual_poll(), - config_cache=ConfigCacheMock())) + client = ConfigCatClient.get(TEST_SDK_KEY, ConfigCatOptions(polling_mode=PollingMode.manual_poll(), + config_cache=ConfigCacheMock())) self.assertEqual(1, client.get_value('testIntKey', 0)) client.close() def test_double(self): - client = ConfigCatClient.get('test', ConfigCatOptions(polling_mode=PollingMode.manual_poll(), - config_cache=ConfigCacheMock())) + client = ConfigCatClient.get(TEST_SDK_KEY, ConfigCatOptions(polling_mode=PollingMode.manual_poll(), + config_cache=ConfigCacheMock())) self.assertEqual(1.1, client.get_value('testDoubleKey', 0.0)) client.close() def test_unknown(self): - client = ConfigCatClient.get('test', ConfigCatOptions(polling_mode=PollingMode.manual_poll(), - config_cache=ConfigCacheMock())) + client = ConfigCatClient.get(TEST_SDK_KEY, ConfigCatOptions(polling_mode=PollingMode.manual_poll(), + config_cache=ConfigCacheMock())) self.assertEqual('default', client.get_value('testUnknownKey', 'default')) client.close() def test_invalidation(self): - client = ConfigCatClient.get('test', ConfigCatOptions(polling_mode=PollingMode.manual_poll(), - config_cache=ConfigCacheMock())) + client = ConfigCatClient.get(TEST_SDK_KEY, ConfigCatOptions(polling_mode=PollingMode.manual_poll(), + config_cache=ConfigCacheMock())) self.assertEqual(True, client.get_value('testBoolKey', False)) client.close() + def test_incorrect_json(self): + config_json_string = r'''{ + "f": { + "testKey": { + "t": 0, + "r": [ { + "c": [ { "u": { "a": "Custom1", "c": 19, "d": "wrong_utc_timestamp" } } ], + "s": { "v": { "b": true } } + } ], + "v": { "b": false } + } + } + }''' + config_cache = SingleValueConfigCache(ConfigEntry( + config=json.loads(config_json_string), + etag='test-etag', + config_json_string=config_json_string, + fetch_time=get_utc_now_seconds_since_epoch()).serialize() + ) + + hook_callbacks = HookCallbacks() + hooks = Hooks( + on_error=hook_callbacks.on_error + ) + client = ConfigCatClient.get(TEST_SDK_KEY, ConfigCatOptions(polling_mode=PollingMode.manual_poll(), + config_cache=config_cache, + hooks=hooks)) + self.assertEqual(False, client.get_value('testKey', False, User('1234', custom={'Custom1': 1681118000.56}))) + self.assertEqual(1, hook_callbacks.error_call_count) + self.assertTrue(hook_callbacks.error.startswith("Failed to evaluate setting 'testKey'.")) + client.close() + def test_get_all_keys(self): - client = ConfigCatClient.get('test', ConfigCatOptions(polling_mode=PollingMode.manual_poll(), - config_cache=ConfigCacheMock())) + client = ConfigCatClient.get(TEST_SDK_KEY, ConfigCatOptions(polling_mode=PollingMode.manual_poll(), + config_cache=ConfigCacheMock())) # Two list should have exactly the same elements, order doesn't matter. self.assertEqual({'testBoolKey', 'testStringKey', 'testIntKey', 'testDoubleKey', 'key1', 'key2'}, set(client.get_all_keys())) client.close() def test_get_all_values(self): - client = ConfigCatClient.get('test', ConfigCatOptions(polling_mode=PollingMode.manual_poll(), - config_cache=ConfigCacheMock())) + client = ConfigCatClient.get(TEST_SDK_KEY, ConfigCatOptions(polling_mode=PollingMode.manual_poll(), + config_cache=ConfigCacheMock())) all_values = client.get_all_values() # Two dictionary should have exactly the same elements, order doesn't matter. self.assertEqual(6, len(all_values)) @@ -106,8 +165,8 @@ def test_get_all_values(self): client.close() def test_get_all_value_details(self): - client = ConfigCatClient.get('test', ConfigCatOptions(polling_mode=PollingMode.manual_poll(), - config_cache=ConfigCacheMock())) + client = ConfigCatClient.get(TEST_SDK_KEY, ConfigCatOptions(polling_mode=PollingMode.manual_poll(), + config_cache=ConfigCacheMock())) all_details = client.get_all_value_details() def details_by_key(all_details, key): @@ -154,7 +213,7 @@ def test_get_value_details(self): response_mock.status_code = 200 response_mock.headers = {} - client = ConfigCatClient.get('test', ConfigCatOptions(polling_mode=PollingMode.manual_poll())) + client = ConfigCatClient.get(TEST_SDK_KEY, ConfigCatOptions(polling_mode=PollingMode.manual_poll())) client.force_refresh() user = User('test@test1.com') @@ -165,11 +224,8 @@ def test_get_value_details(self): self.assertEqual('id1', details.variation_id) self.assertFalse(details.is_default_value) self.assertIsNone(details.error) - self.assertIsNone(details.matched_evaluation_percentage_rule) - self.assertEqual('fake1', details.matched_evaluation_rule[VALUE]) - self.assertEqual(2, details.matched_evaluation_rule[COMPARATOR]) - self.assertEqual('Identifier', details.matched_evaluation_rule[COMPARISON_ATTRIBUTE]) - self.assertEqual('@test1.com', details.matched_evaluation_rule[COMPARISON_VALUE]) + self.assertIsNone(details.matched_percentage_option) + self.assertEqual('fake1', details.matched_targeting_rule[SERVED_VALUE][VALUE][STRING_VALUE]) self.assertEqual(str(user), str(details.user)) now = get_utc_now() self.assertGreaterEqual(now, details.fetch_time) @@ -178,8 +234,8 @@ def test_get_value_details(self): client.close() def test_default_user_get_value(self): - client = ConfigCatClient.get('test', ConfigCatOptions(polling_mode=PollingMode.manual_poll(), - config_cache=ConfigCacheMock())) + client = ConfigCatClient.get(TEST_SDK_KEY, ConfigCatOptions(polling_mode=PollingMode.manual_poll(), + config_cache=ConfigCacheMock())) user1 = User("test@test1.com") user2 = User("test@test2.com") @@ -193,8 +249,8 @@ def test_default_user_get_value(self): client.close() def test_default_user_get_all_values(self): - client = ConfigCatClient.get('test', ConfigCatOptions(polling_mode=PollingMode.manual_poll(), - config_cache=ConfigCacheMock())) + client = ConfigCatClient.get(TEST_SDK_KEY, ConfigCatOptions(polling_mode=PollingMode.manual_poll(), + config_cache=ConfigCacheMock())) user1 = User("test@test1.com") user2 = User("test@test2.com") @@ -239,7 +295,7 @@ def test_online_offline(self): response_mock.status_code = 200 response_mock.headers = {} - client = ConfigCatClient.get('test', ConfigCatOptions(polling_mode=PollingMode.manual_poll())) + client = ConfigCatClient.get(TEST_SDK_KEY, ConfigCatOptions(polling_mode=PollingMode.manual_poll())) self.assertFalse(client.is_offline()) @@ -271,8 +327,8 @@ def test_init_offline(self): response_mock.status_code = 200 response_mock.headers = {} - client = ConfigCatClient.get('test', ConfigCatOptions(polling_mode=PollingMode.manual_poll(), - offline=True)) + client = ConfigCatClient.get(TEST_SDK_KEY, ConfigCatOptions(polling_mode=PollingMode.manual_poll(), + offline=True)) self.assertTrue(client.is_offline()) @@ -289,6 +345,52 @@ def test_init_offline(self): client.close() + @parameterized.expand([ + # no type mismatch warning + ('testStringKey', 'test@example.com', 'default', 'testValue', False), + ('testBoolKey', None, False, True, False), + ('testBoolKey', None, None, True, False), + ('testIntKey', None, 3.14, 1, False), + ('testIntKey', None, 42, 1, False), + ('testDoubleKey', None, 3.14, 1.1, False), + ('testDoubleKey', None, 42, 1.1, False), + # type mismatch warning + ('testStringKey', 'test@example.com', 0, 'testValue', True), + ('testStringKey', 'test@example.com', False, 'testValue', True), + ('testBoolKey', None, 0, True, True), + ('testBoolKey', None, 0.1, True, True), + ('testBoolKey', None, 'default', True, True), + ]) + def test_default_value_and_setting_type_mismatch(self, key, user_id, default_value, expected_value, is_warning): + with mock.patch.object(requests, 'get') as request_get: + response_mock = Mock() + request_get.return_value = response_mock + response_mock.json.return_value = TEST_OBJECT + response_mock.status_code = 200 + response_mock.headers = {} + + client = ConfigCatClient.get(TEST_SDK_KEY, ConfigCatOptions(polling_mode=PollingMode.manual_poll())) + client.force_refresh() + + logger = logging.getLogger('configcat') + log_handler = MockLogHandler() + logger.addHandler(log_handler) + + user = User(user_id) if user_id else None + self.assertEqual(expected_value, client.get_value(key, default_value, user)) + + if is_warning: + self.assertEqual(1, len(log_handler.warning_logs)) + warning = log_handler.warning_logs[0] + self.assertEqual("[4002] The type of a setting does not match the type of the specified default value (%s). " + "Setting's type was %s but the default value's type was %s. " + "Please make sure that using a default value not matching the setting's type was intended." % + (default_value, type(expected_value), type(default_value)), warning) + else: + self.assertEqual(0, len(log_handler.warning_logs)) + + client.close() + if __name__ == '__main__': unittest.main() diff --git a/configcatclienttests/test_configfetcher.py b/configcatclienttests/test_configfetcher.py index cb9b3b1..0dffa6c 100644 --- a/configcatclienttests/test_configfetcher.py +++ b/configcatclienttests/test_configfetcher.py @@ -43,7 +43,7 @@ def test_fetch_not_modified_etag(self): response_mock = Mock() response_mock.json.return_value = test_json response_mock.status_code = 200 - response_mock.headers = {'Etag': etag} + response_mock.headers = {'ETag': etag} request_get.return_value = response_mock fetch_response = fetcher.get_configuration() diff --git a/configcatclienttests/test_datagovernance.py b/configcatclienttests/test_datagovernance.py index 89e621f..ed95a78 100644 --- a/configcatclienttests/test_datagovernance.py +++ b/configcatclienttests/test_datagovernance.py @@ -26,7 +26,7 @@ # An organization with Global data_governance config.json representation def mocked_requests_get_global(*args, **kwargs): - if args[0] == 'https://cdn-global.configcat.com/configuration-files//config_v5.json': + if args[0] == 'https://cdn-global.configcat.com/configuration-files//config_v6.json': return MockResponse({ "p": { "u": "https://cdn-global.configcat.com", @@ -34,7 +34,7 @@ def mocked_requests_get_global(*args, **kwargs): }, "f": FEATURE_TEST_JSON }, 200) - elif args[0] == 'https://cdn-eu.configcat.com/configuration-files//config_v5.json': + elif args[0] == 'https://cdn-eu.configcat.com/configuration-files//config_v6.json': return MockResponse({ "p": { "u": "https://cdn-global.configcat.com", @@ -47,7 +47,7 @@ def mocked_requests_get_global(*args, **kwargs): # An organization with EuOnly data_governance config.json representation def mocked_requests_get_eu_only(*args, **kwargs): - if args[0] == 'https://cdn-global.configcat.com/configuration-files//config_v5.json': + if args[0] == 'https://cdn-global.configcat.com/configuration-files//config_v6.json': return MockResponse({ "p": { "u": "https://cdn-eu.configcat.com", @@ -55,7 +55,7 @@ def mocked_requests_get_eu_only(*args, **kwargs): }, "f": {} }, 200) - elif args[0] == 'https://cdn-eu.configcat.com/configuration-files//config_v5.json': + elif args[0] == 'https://cdn-eu.configcat.com/configuration-files//config_v6.json': return MockResponse({ "p": { "u": "https://cdn-eu.configcat.com", @@ -68,7 +68,7 @@ def mocked_requests_get_eu_only(*args, **kwargs): # An organization with Global data_governance config.json representation with custom baseurl def mocked_requests_get_custom(*args, **kwargs): - if args[0] == 'https://custom.configcat.com/configuration-files//config_v5.json': + if args[0] == 'https://custom.configcat.com/configuration-files//config_v6.json': return MockResponse({ "p": { "u": "https://cdn-global.configcat.com", @@ -81,7 +81,7 @@ def mocked_requests_get_custom(*args, **kwargs): # Redirect loop in config.json def mocked_requests_get_redirect_loop(*args, **kwargs): - if args[0] == 'https://cdn-global.configcat.com/configuration-files//config_v5.json': + if args[0] == 'https://cdn-global.configcat.com/configuration-files//config_v6.json': return MockResponse({ "p": { "u": "https://cdn-eu.configcat.com", @@ -89,7 +89,7 @@ def mocked_requests_get_redirect_loop(*args, **kwargs): }, "f": FEATURE_TEST_JSON }, 200) - elif args[0] == 'https://cdn-eu.configcat.com/configuration-files//config_v5.json': + elif args[0] == 'https://cdn-eu.configcat.com/configuration-files//config_v6.json': return MockResponse({ "p": { "u": "https://cdn-global.configcat.com", @@ -102,10 +102,10 @@ def mocked_requests_get_redirect_loop(*args, **kwargs): # An organization with forced=2 redirection config.json representation def mocked_requests_get_forced_2(*args, **kwargs): - if args[0] == 'https://custom.configcat.com/configuration-files//config_v5.json' \ - or args[0] == 'https://cdn-global.configcat.com/configuration-files//config_v5.json' \ - or args[0] == 'https://cdn-eu.configcat.com/configuration-files//config_v5.json'\ - or args[0] == 'https://forced.configcat.com/configuration-files//config_v5.json': + if args[0] == 'https://custom.configcat.com/configuration-files//config_v6.json' \ + or args[0] == 'https://cdn-global.configcat.com/configuration-files//config_v6.json' \ + or args[0] == 'https://cdn-eu.configcat.com/configuration-files//config_v6.json'\ + or args[0] == 'https://forced.configcat.com/configuration-files//config_v6.json': return MockResponse({ "p": { "u": "https://forced.configcat.com", @@ -116,13 +116,13 @@ def mocked_requests_get_forced_2(*args, **kwargs): return MockResponse(None, 404) -call_to_global = mock.call('https://cdn-global.configcat.com/configuration-files//config_v5.json', +call_to_global = mock.call('https://cdn-global.configcat.com/configuration-files//config_v6.json', auth=ANY, headers=ANY, proxies=ANY, timeout=ANY) -call_to_eu = mock.call('https://cdn-eu.configcat.com/configuration-files//config_v5.json', +call_to_eu = mock.call('https://cdn-eu.configcat.com/configuration-files//config_v6.json', auth=ANY, headers=ANY, proxies=ANY, timeout=ANY) -call_to_custom = mock.call('https://custom.configcat.com/configuration-files//config_v5.json', +call_to_custom = mock.call('https://custom.configcat.com/configuration-files//config_v6.json', auth=ANY, headers=ANY, proxies=ANY, timeout=ANY) -call_to_forced = mock.call('https://forced.configcat.com/configuration-files//config_v5.json', +call_to_forced = mock.call('https://forced.configcat.com/configuration-files//config_v6.json', auth=ANY, headers=ANY, proxies=ANY, timeout=ANY) diff --git a/configcatclienttests/test_evaluationlog.py b/configcatclienttests/test_evaluationlog.py new file mode 100644 index 0000000..2a9c652 --- /dev/null +++ b/configcatclienttests/test_evaluationlog.py @@ -0,0 +1,192 @@ +import json +import logging +import os +import unittest +import re +import sys + +try: + from cStringIO import StringIO # Python 2.7 +except ImportError: + from io import StringIO + +from configcatclient import ConfigCatClient, ConfigCatOptions, PollingMode +from configcatclient.localfiledatasource import LocalFileFlagOverrides +from configcatclient.overridedatasource import OverrideBehaviour +from configcatclient.user import User +from configcatclienttests.mocks import TEST_SDK_KEY + +logging.basicConfig(level=logging.INFO) + + +# Remove the u prefix from unicode strings on python 2.7. When we only support python 3 this can be removed. +def remove_unicode_prefix(string): + return re.sub(r"u'(.*?)'", r"'\1'", string) + + +class EvaluationLogTests(unittest.TestCase): + def test_simple_value(self): + self.assertTrue(self._evaluation_log('data/evaluation/simple_value.json')) + + def test_1_targeting_rule(self): + self.assertTrue(self._evaluation_log('data/evaluation/1_targeting_rule.json')) + + def test_2_targeting_rules(self): + self.assertTrue(self._evaluation_log('data/evaluation/2_targeting_rules.json')) + + def test_options_based_on_user_id(self): + self.assertTrue(self._evaluation_log('data/evaluation/options_based_on_user_id.json')) + + def test_options_based_on_custom_attr(self): + self.assertTrue(self._evaluation_log('data/evaluation/options_based_on_custom_attr.json')) + + def test_options_after_targeting_rule(self): + self.assertTrue(self._evaluation_log('data/evaluation/options_after_targeting_rule.json')) + + def test_options_within_targeting_rule(self): + self.assertTrue(self._evaluation_log('data/evaluation/options_within_targeting_rule.json')) + + def test_and_rules(self): + self.assertTrue(self._evaluation_log('data/evaluation/and_rules.json')) + + def test_segment(self): + self.assertTrue(self._evaluation_log('data/evaluation/segment.json')) + + def test_prerequisite_flag(self): + self.assertTrue(self._evaluation_log('data/evaluation/prerequisite_flag.json')) + + def test_semver_validation(self): + self.assertTrue(self._evaluation_log('data/evaluation/semver_validation.json')) + + def test_epoch_date_validation(self): + self.assertTrue(self._evaluation_log('data/evaluation/epoch_date_validation.json')) + + def test_number_validation(self): + self.assertTrue(self._evaluation_log('data/evaluation/number_validation.json')) + + def test_comparators_validation(self): + self.maxDiff = None + self.assertTrue(self._evaluation_log('data/evaluation/comparators.json')) + + def test_list_truncation_validation(self): + self.assertTrue(self._evaluation_log('data/evaluation/list_truncation.json')) + + def _evaluation_log(self, file_path, test_filter=None, generate_expected_log=False): + script_dir = os.path.dirname(__file__) + file_path = os.path.join(script_dir, file_path) + self.assertTrue(os.path.isfile(file_path)) + name = os.path.basename(file_path)[:-5] + file_dir = os.path.join(os.path.dirname(file_path), name) + + with open(file_path, 'r') as f: + data = json.load(f) + sdk_key = data.get('sdkKey') + base_url = data.get('baseUrl') + json_override = data.get('jsonOverride') + flag_overrides = None + if json_override: + flag_overrides = LocalFileFlagOverrides( + file_path=os.path.join(file_dir, json_override), + override_behaviour=OverrideBehaviour.LocalOnly + ) + if not sdk_key: + sdk_key = TEST_SDK_KEY + + client = ConfigCatClient.get(sdk_key, ConfigCatOptions( + polling_mode=PollingMode.manual_poll(), + flag_overrides=flag_overrides, + base_url=base_url + )) + client.force_refresh() + + # setup logging + log_stream = StringIO() + log_handler = logging.StreamHandler(log_stream) + log_handler.setFormatter(logging.Formatter('%(levelname)s %(message)s')) + logger = logging.getLogger('configcat') + logger.setLevel(logging.INFO) + logger.addHandler(log_handler) + + for test in data['tests']: + key = test.get('key') + default_value = test.get('defaultValue') + return_value = test.get('returnValue') + user = test.get('user') + expected_log_file = test.get('expectedLog') + test_name = expected_log_file[:-4] + + # apply test filter + if test_filter and test_name not in test_filter: + continue + + expected_log_file_path = os.path.join(file_dir, expected_log_file) + user_object = None + if user: + custom = {k: v for k, v in user.items() if k not in {'Identifier', 'Email', 'Country'}} + if len(custom) == 0: + custom = None + user_object = User(user.get('Identifier'), user.get('Email'), user.get('Country'), custom) + + # clear log + log_stream.seek(0) + log_stream.truncate() + + value = client.get_value(key, default_value, user_object) + log = remove_unicode_prefix(log_stream.getvalue()) + + if generate_expected_log: + # create directory if needed + if not os.path.exists(file_dir): + os.makedirs(file_dir) + + with open(expected_log_file_path, 'w') as file: + file.write(log) + else: + self.assertTrue(os.path.isfile(expected_log_file_path)) + with open(expected_log_file_path, 'r') as file: + expected_log = file.read() + + # On <= Python 3.5 the order of the keys in the serialized user object is random. + # We need to cut out the JSON part and compare the JSON objects separately. + if sys.version_info[:2] <= (3, 5): + if expected_log.startswith('INFO [5000]') and log.startswith('INFO [5000]'): + # Extract the JSON part from expected_log + match = re.search(r'(\{.*?\})', expected_log) + expected_log_json = None + if match: + expected_log_json = json.loads(match.group(1)) + # Remove the JSON-like part from the original string + expected_log = re.sub(r'\{.*?\}', '', expected_log) + + # Extract the JSON part from log + log_json = None + match = re.search(r'(\{.*?\})', log) + if match: + log_json = json.loads(match.group(1)) + # Remove the JSON-like part from the original string + log = re.sub(r'\{.*?\}', '', log) + + self.assertEqual(expected_log_json, log_json, 'User object mismatch for test: ' + test_name) + + self.assertEqual(expected_log, log, 'Log mismatch for test: ' + test_name) + self.assertEqual(return_value, value, 'Return value mismatch for test: ' + test_name) + + client.close() + return True + + return False + + +''' + def test_generate_all_evaluation_logs(self): + script_dir = os.path.dirname(__file__) + file_path = os.path.join(script_dir, 'data/evaluation') + self.assertTrue(os.path.isdir(file_path)) + for file in os.listdir(file_path): + if file.endswith('.json'): + self._evaluation_log(os.path.join('data/evaluation', file), generate_expected_log=True) +''' + + +if __name__ == '__main__': + unittest.main() diff --git a/configcatclienttests/test_hooks.py b/configcatclienttests/test_hooks.py index 7a3cd8d..d2afb84 100644 --- a/configcatclienttests/test_hooks.py +++ b/configcatclienttests/test_hooks.py @@ -4,12 +4,13 @@ import requests from configcatclient.configcatclient import ConfigCatClient -from configcatclient.constants import FEATURE_FLAGS, VALUE, COMPARATOR, COMPARISON_ATTRIBUTE, COMPARISON_VALUE +from configcatclient.config import FEATURE_FLAGS, VALUE, SERVED_VALUE, STRING_VALUE, \ + extend_config_with_inline_salt_and_segment from configcatclient.user import User from configcatclient.configcatoptions import ConfigCatOptions, Hooks from configcatclient.pollingmode import PollingMode from configcatclient.utils import get_utc_now -from configcatclienttests.mocks import ConfigCacheMock, HookCallbacks, TEST_OBJECT +from configcatclienttests.mocks import ConfigCacheMock, HookCallbacks, TEST_OBJECT, TEST_SDK_KEY # Python2/Python3 support try: @@ -36,16 +37,18 @@ def test_init(self): ) config_cache = ConfigCacheMock() - client = ConfigCatClient.get('test', ConfigCatOptions(polling_mode=PollingMode.manual_poll(), - config_cache=config_cache, - hooks=hooks)) + client = ConfigCatClient.get(TEST_SDK_KEY, ConfigCatOptions(polling_mode=PollingMode.manual_poll(), + config_cache=config_cache, + hooks=hooks)) value = client.get_value('testStringKey', '') self.assertEqual('testValue', value) self.assertTrue(hook_callbacks.is_ready) self.assertEqual(1, hook_callbacks.is_ready_call_count) - self.assertEqual(TEST_OBJECT.get(FEATURE_FLAGS), hook_callbacks.changed_config) + extended_config = TEST_OBJECT + extend_config_with_inline_salt_and_segment(extended_config) + self.assertEqual(extended_config.get(FEATURE_FLAGS), hook_callbacks.changed_config) self.assertEqual(1, hook_callbacks.changed_config_call_count) self.assertTrue(hook_callbacks.evaluation_details) self.assertEqual(1, hook_callbacks.evaluation_details_call_count) @@ -63,9 +66,9 @@ def test_subscribe(self): hooks.add_on_error(hook_callbacks.on_error) config_cache = ConfigCacheMock() - client = ConfigCatClient.get('test', ConfigCatOptions(polling_mode=PollingMode.manual_poll(), - config_cache=config_cache, - hooks=hooks)) + client = ConfigCatClient.get(TEST_SDK_KEY, ConfigCatOptions(polling_mode=PollingMode.manual_poll(), + config_cache=config_cache, + hooks=hooks)) value = client.get_value('testStringKey', '') @@ -90,7 +93,7 @@ def test_evaluation(self): response_mock.headers = {} hook_callbacks = HookCallbacks() - client = ConfigCatClient.get('test', ConfigCatOptions(polling_mode=PollingMode.manual_poll())) + client = ConfigCatClient.get(TEST_SDK_KEY, ConfigCatOptions(polling_mode=PollingMode.manual_poll())) client.get_hooks().add_on_flag_evaluated(hook_callbacks.on_flag_evaluated) @@ -106,11 +109,8 @@ def test_evaluation(self): self.assertEqual('id1', details.variation_id) self.assertFalse(details.is_default_value) self.assertIsNone(details.error) - self.assertIsNone(details.matched_evaluation_percentage_rule) - self.assertEqual('fake1', details.matched_evaluation_rule[VALUE]) - self.assertEqual(2, details.matched_evaluation_rule[COMPARATOR]) - self.assertEqual('Identifier', details.matched_evaluation_rule[COMPARISON_ATTRIBUTE]) - self.assertEqual('@test1.com', details.matched_evaluation_rule[COMPARISON_VALUE]) + self.assertIsNone(details.matched_percentage_option) + self.assertEqual('fake1', details.matched_targeting_rule[SERVED_VALUE][VALUE][STRING_VALUE]) self.assertEqual(str(user), str(details.user)) now = get_utc_now() self.assertGreaterEqual(now, details.fetch_time) @@ -133,8 +133,8 @@ def test_callback_exception(self): on_flag_evaluated=hook_callbacks.callback_exception, on_error=hook_callbacks.callback_exception ) - client = ConfigCatClient.get('test', ConfigCatOptions(polling_mode=PollingMode.manual_poll(), - hooks=hooks)) + client = ConfigCatClient.get(TEST_SDK_KEY, ConfigCatOptions(polling_mode=PollingMode.manual_poll(), + hooks=hooks)) client.force_refresh() diff --git a/configcatclienttests/test_integration_configcatclient.py b/configcatclienttests/test_integration_configcatclient.py index 4d24501..5bfcb75 100644 --- a/configcatclienttests/test_integration_configcatclient.py +++ b/configcatclienttests/test_integration_configcatclient.py @@ -42,12 +42,14 @@ def test_get_all_keys(self): keys = client.get_all_keys() self.assertEqual(5, len(keys)) self.assertTrue('keySampleText' in keys) + client.close() def test_get_all_values(self): client = configcatclient.get(_SDK_KEY) all_values = client.get_all_values() self.assertEqual(5, len(all_values)) self.assertEqual('This text came from ConfigCat', all_values['keySampleText']) + client.close() def test_force_refresh(self): client = configcatclient.get(_SDK_KEY) diff --git a/configcatclienttests/test_lazyloadingcachepolicy.py b/configcatclienttests/test_lazyloadingcachepolicy.py index 76dc732..e09f88c 100644 --- a/configcatclienttests/test_lazyloadingcachepolicy.py +++ b/configcatclienttests/test_lazyloadingcachepolicy.py @@ -10,7 +10,7 @@ from configcatclient.configentry import ConfigEntry from configcatclient.configfetcher import FetchResponse, ConfigFetcher from configcatclient.configservice import ConfigService -from configcatclient.constants import VALUE +from configcatclient.config import VALUE, FEATURE_FLAGS, STRING_VALUE, SettingType from configcatclient.logger import Logger from configcatclient.utils import get_seconds_since_epoch, get_utc_now_seconds_since_epoch @@ -26,7 +26,7 @@ from configcatclient.configcache import NullConfigCache from configcatclienttests.mocks import ConfigFetcherMock, ConfigFetcherWithErrorMock, TEST_JSON, SingleValueConfigCache, \ - TEST_OBJECT + TEST_OBJECT, TEST_JSON_FORMAT logging.basicConfig() log = Logger('configcat', Hooks()) @@ -37,8 +37,9 @@ def test_wrong_params(self): config_fetcher = ConfigFetcherMock() config_cache = NullConfigCache() cache_policy = ConfigService('', PollingMode.lazy_load(0), Hooks(), config_fetcher, log, config_cache, False) - settings, _ = cache_policy.get_settings() - self.assertEqual('testValue', settings.get('testKey').get(VALUE)) + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) + self.assertEqual('testValue', settings.get('testKey').get(VALUE).get(STRING_VALUE)) cache_policy.close() def test_get(self): @@ -47,19 +48,22 @@ def test_get(self): cache_policy = ConfigService('', PollingMode.lazy_load(1), Hooks(), config_fetcher, log, config_cache, False) # Get value from Config Store, which indicates a config_fetcher call - settings, _ = cache_policy.get_settings() - self.assertEqual('testValue', settings.get('testKey').get(VALUE)) + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) + self.assertEqual('testValue', settings.get('testKey').get(VALUE).get(STRING_VALUE)) self.assertEqual(config_fetcher.get_call_count, 1) # Get value from Config Store, which doesn't indicate a config_fetcher call (cache) - settings, _ = cache_policy.get_settings() - self.assertEqual('testValue', settings.get('testKey').get(VALUE)) + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) + self.assertEqual('testValue', settings.get('testKey').get(VALUE).get(STRING_VALUE)) self.assertEqual(config_fetcher.get_call_count, 1) # Get value from Config Store, which indicates a config_fetcher call - 1 sec cache TTL time.sleep(1) - settings, _ = cache_policy.get_settings() - self.assertEqual('testValue', settings.get('testKey').get(VALUE)) + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) + self.assertEqual('testValue', settings.get('testKey').get(VALUE).get(STRING_VALUE)) self.assertEqual(config_fetcher.get_call_count, 2) cache_policy.close() @@ -69,8 +73,9 @@ def test_refresh(self): cache_policy = ConfigService('', PollingMode.lazy_load(160), Hooks(), config_fetcher, log, config_cache, False) # Get value from Config Store, which indicates a config_fetcher call - settings, fetch_time = cache_policy.get_settings() - self.assertEqual('testValue', settings.get('testKey').get(VALUE)) + config, fetch_time = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) + self.assertEqual('testValue', settings.get('testKey').get(VALUE).get(STRING_VALUE)) self.assertEqual(config_fetcher.get_call_count, 1) with mock.patch('configcatclient.utils.get_utc_now_seconds_since_epoch') as mock_get_utc_now_since_epoch: @@ -79,8 +84,9 @@ def test_refresh(self): mock_get_utc_now_since_epoch.return_value = fetch_time + 161 # Get value from Config Store, which indicates a config_fetcher call after cache invalidation cache_policy.refresh() - settings, _ = cache_policy.get_settings() - self.assertEqual('testValue', settings.get('testKey').get(VALUE)) + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) + self.assertEqual('testValue', settings.get('testKey').get(VALUE).get(STRING_VALUE)) self.assertEqual(config_fetcher.get_call_count, 2) cache_policy.close() @@ -96,16 +102,16 @@ def test_get_skips_hitting_api_after_update_from_different_thread(self): now = datetime.datetime(2020, 5, 20, 0, 0, 0) mock_get_utc_now.return_value = now successful_fetch_response.entry.fetch_time = get_seconds_since_epoch(now) - cache_policy.get_settings() + cache_policy.get_config() self.assertEqual(config_fetcher.get_configuration.call_count, 1) # when the cache timeout is still within the limit skip any network # requests, as this could be that multiple threads have attempted # to acquire the lock at the same time, but only really one needs to update successful_fetch_response.entry.fetch_time = get_seconds_since_epoch(now - datetime.timedelta(seconds=159)) - cache_policy.get_settings() + cache_policy.get_config() self.assertEqual(config_fetcher.get_configuration.call_count, 1) successful_fetch_response.entry.fetch_time = get_seconds_since_epoch(now - datetime.timedelta(seconds=161)) - cache_policy.get_settings() + cache_policy.get_config() self.assertEqual(config_fetcher.get_configuration.call_count, 2) def test_error(self): @@ -114,8 +120,8 @@ def test_error(self): cache_policy = ConfigService('', PollingMode.lazy_load(160), Hooks(), config_fetcher, log, config_cache, False) # Get value from Config Store, which indicates a config_fetcher call - settings, _ = cache_policy.get_settings() - self.assertEqual(settings, None) + config, _ = cache_policy.get_config() + self.assertEqual(config, None) cache_policy.close() def test_return_cached_config_when_cache_is_not_expired(self): @@ -129,17 +135,19 @@ def test_return_cached_config_when_cache_is_not_expired(self): cache_policy = ConfigService('', PollingMode.lazy_load(1), Hooks(), config_fetcher, log, config_cache, False) - settings, _ = cache_policy.get_settings() + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) - self.assertEqual('testValue', settings.get('testKey').get(VALUE)) + self.assertEqual('testValue', settings.get('testKey').get(VALUE).get(STRING_VALUE)) self.assertEqual(config_fetcher.get_call_count, 0) self.assertEqual(config_fetcher.get_fetch_count, 0) time.sleep(1) - settings, _ = cache_policy.get_settings() + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) - self.assertEqual('testValue', settings.get('testKey').get(VALUE)) + self.assertEqual('testValue', settings.get('testKey').get(VALUE).get(STRING_VALUE)) self.assertEqual(config_fetcher.get_call_count, 1) self.assertEqual(config_fetcher.get_fetch_count, 1) @@ -163,12 +171,55 @@ def test_fetch_config_when_cache_is_expired(self): False ) - settings, _ = cache_policy.get_settings() + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) - self.assertEqual('testValue', settings.get('testKey').get(VALUE)) + self.assertEqual('testValue', settings.get('testKey').get(VALUE).get(STRING_VALUE)) self.assertEqual(config_fetcher.get_call_count, 1) self.assertEqual(config_fetcher.get_fetch_count, 1) + def test_cache_TTL_respects_external_cache(self): + with mock.patch.object(requests, 'get') as request_get: + response_mock = Mock() + request_get.return_value = response_mock + config_json_string_remote = TEST_JSON_FORMAT.format(value_type=SettingType.STRING, value='{"s": "test-remote"}') + response_mock.json.return_value = json.loads(config_json_string_remote) + response_mock.text = config_json_string_remote + response_mock.status_code = 200 + # response_mock.headers = {'ETag': 'etag'} + + config_json_string_local = TEST_JSON_FORMAT.format(value_type=SettingType.STRING, value='{"s": "test-local"}') + config_cache = SingleValueConfigCache(ConfigEntry( + config=json.loads(config_json_string_local), + etag='etag', + config_json_string=config_json_string_local, + fetch_time=get_utc_now_seconds_since_epoch()).serialize()) + + polling_mode = PollingMode.lazy_load(cache_refresh_interval_seconds=1) + config_fetcher = ConfigFetcherMock() + cache_policy = ConfigService('', polling_mode, Hooks(), config_fetcher, log, config_cache, False) + + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) + + self.assertEqual('test-local', settings.get('testKey').get(VALUE).get(STRING_VALUE)) + self.assertEqual(config_fetcher.get_fetch_count, 0) + + time.sleep(1) + + config_json_string_local = TEST_JSON_FORMAT.format(value_type=SettingType.STRING, value='{"s": "test-local2"}') + config_cache._value = ConfigEntry( + config=json.loads(config_json_string_local), + etag='etag2', + config_json_string=config_json_string_local, + fetch_time=get_utc_now_seconds_since_epoch()).serialize() + + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) + + self.assertEqual('test-local2', settings.get('testKey').get(VALUE).get(STRING_VALUE)) + self.assertEqual(config_fetcher.get_fetch_count, 0) + def test_online_offline(self): with mock.patch.object(requests, 'get') as request_get: response_mock = Mock() @@ -183,8 +234,9 @@ def test_online_offline(self): Hooks(), config_fetcher, log, NullConfigCache(), False) self.assertFalse(cache_policy.is_offline()) - settings, _ = cache_policy.get_settings() - self.assertEqual('testValue', settings.get('testStringKey').get(VALUE)) + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) + self.assertEqual('testValue', settings.get('testStringKey').get(VALUE).get(STRING_VALUE)) self.assertEqual(1, request_get.call_count) cache_policy.set_offline() @@ -192,15 +244,17 @@ def test_online_offline(self): time.sleep(1.5) - settings, _ = cache_policy.get_settings() - self.assertEqual('testValue', settings.get('testStringKey').get(VALUE)) + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) + self.assertEqual('testValue', settings.get('testStringKey').get(VALUE).get(STRING_VALUE)) self.assertEqual(1, request_get.call_count) cache_policy.set_online() self.assertFalse(cache_policy.is_offline()) - settings, _ = cache_policy.get_settings() - self.assertEqual('testValue', settings.get('testStringKey').get(VALUE)) + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) + self.assertEqual('testValue', settings.get('testStringKey').get(VALUE).get(STRING_VALUE)) self.assertEqual(2, request_get.call_count) cache_policy.close() @@ -219,21 +273,22 @@ def test_init_offline(self): Hooks(), config_fetcher, log, NullConfigCache(), True) self.assertTrue(cache_policy.is_offline()) - settings, _ = cache_policy.get_settings() - self.assertIsNone(settings) + config, _ = cache_policy.get_config() + self.assertIsNone(config) self.assertEqual(0, request_get.call_count) time.sleep(1.5) - settings, _ = cache_policy.get_settings() - self.assertIsNone(settings) + config, _ = cache_policy.get_config() + self.assertIsNone(config) self.assertEqual(0, request_get.call_count) cache_policy.set_online() self.assertFalse(cache_policy.is_offline()) - settings, _ = cache_policy.get_settings() - self.assertEqual('testValue', settings.get('testStringKey').get(VALUE)) + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) + self.assertEqual('testValue', settings.get('testStringKey').get(VALUE).get(STRING_VALUE)) self.assertEqual(1, request_get.call_count) cache_policy.close() diff --git a/configcatclienttests/test_manualpollingcachepolicy.py b/configcatclienttests/test_manualpollingcachepolicy.py index d55de9e..659ace7 100644 --- a/configcatclienttests/test_manualpollingcachepolicy.py +++ b/configcatclienttests/test_manualpollingcachepolicy.py @@ -9,7 +9,7 @@ from configcatclient.configcatoptions import Hooks from configcatclient.configfetcher import ConfigFetcher from configcatclient.configservice import ConfigService -from configcatclient.constants import VALUE +from configcatclient.config import VALUE, FEATURE_FLAGS, STRING_VALUE, SettingType from configcatclient.logger import Logger from configcatclient.utils import get_utc_now_seconds_since_epoch from configcatclienttests.mocks import ConfigFetcherMock, ConfigFetcherWithErrorMock, TEST_OBJECT, TEST_JSON_FORMAT @@ -33,8 +33,8 @@ def test_without_refresh(self): config_fetcher = ConfigFetcherMock() config_cache = NullConfigCache() cache_policy = ConfigService('', PollingMode.manual_poll(), Hooks(), config_fetcher, log, config_cache, False) - settings, _ = cache_policy.get_settings() - self.assertEqual(settings, None) + config, _ = cache_policy.get_config() + self.assertEqual(config, None) self.assertEqual(config_fetcher.get_call_count, 0) cache_policy.close() @@ -43,8 +43,9 @@ def test_with_refresh(self): config_cache = NullConfigCache() cache_policy = ConfigService('', PollingMode.manual_poll(), Hooks(), config_fetcher, log, config_cache, False) cache_policy.refresh() - settings, _ = cache_policy.get_settings() - self.assertEqual('testValue', settings.get('testKey').get(VALUE)) + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) + self.assertEqual('testValue', settings.get('testKey').get(VALUE).get(STRING_VALUE)) self.assertEqual(config_fetcher.get_call_count, 1) cache_policy.close() @@ -53,8 +54,8 @@ def test_with_refresh_error(self): config_cache = NullConfigCache() cache_policy = ConfigService('', PollingMode.manual_poll(), Hooks(), config_fetcher, log, config_cache, False) cache_policy.refresh() - settings, _ = cache_policy.get_settings() - self.assertEqual(settings, None) + config, _ = cache_policy.get_config() + self.assertEqual(config, None) cache_policy.close() def test_with_failed_refresh(self): @@ -70,16 +71,18 @@ def test_with_failed_refresh(self): cache_policy = ConfigService('', polling_mode, Hooks(), config_fetcher, log, NullConfigCache(), False) cache_policy.refresh() - settings, _ = cache_policy.get_settings() - self.assertEqual('testValue', settings.get('testStringKey').get(VALUE)) + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) + self.assertEqual('testValue', settings.get('testStringKey').get(VALUE).get(STRING_VALUE)) response_mock.json.return_value = {} response_mock.status_code = 500 response_mock.headers = {} cache_policy.refresh() - settings, _ = cache_policy.get_settings() - self.assertEqual('testValue', settings.get('testStringKey').get(VALUE)) + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) + self.assertEqual('testValue', settings.get('testStringKey').get(VALUE).get(STRING_VALUE)) cache_policy.close() @@ -87,11 +90,11 @@ def test_cache(self): with mock.patch.object(requests, 'get') as request_get: response_mock = Mock() request_get.return_value = response_mock - config_json_string = TEST_JSON_FORMAT.format(value='"test"') + config_json_string = TEST_JSON_FORMAT.format(value_type=SettingType.STRING, value='{"s": "test"}') response_mock.json.return_value = json.loads(config_json_string) response_mock.text = config_json_string response_mock.status_code = 200 - response_mock.headers = {'Etag': 'test-etag'} + response_mock.headers = {'ETag': 'test-etag'} polling_mode = PollingMode.manual_poll() config_cache = InMemoryConfigCache() @@ -100,8 +103,9 @@ def test_cache(self): start_time_milliseconds = int(get_utc_now_seconds_since_epoch() * 1000) cache_policy.refresh() - settings, _ = cache_policy.get_settings() - self.assertEqual('test', settings.get('testKey').get(VALUE)) + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) + self.assertEqual('test', settings.get('testKey').get(VALUE).get(STRING_VALUE)) self.assertEqual(1, request_get.call_count) self.assertEqual(1, len(config_cache._value)) @@ -113,14 +117,15 @@ def test_cache(self): self.assertEqual('test-etag', cache_tokens[1]) self.assertEqual(config_json_string, cache_tokens[2]) - config_json_string = TEST_JSON_FORMAT.format(value='"test2"') + config_json_string = TEST_JSON_FORMAT.format(value_type=SettingType.STRING, value='{"s": "test2"}') response_mock.json.return_value = json.loads(config_json_string) response_mock.text = config_json_string - start_time_milliseconds = get_utc_now_seconds_since_epoch() + start_time_milliseconds = int(get_utc_now_seconds_since_epoch() * 1000) cache_policy.refresh() - settings, _ = cache_policy.get_settings() - self.assertEqual('test2', settings.get('testKey').get(VALUE)) + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) + self.assertEqual('test2', settings.get('testKey').get(VALUE).get(STRING_VALUE)) self.assertEqual(2, request_get.call_count) self.assertEqual(1, len(config_cache._value)) @@ -148,8 +153,9 @@ def test_online_offline(self): self.assertFalse(cache_policy.is_offline()) self.assertTrue(cache_policy.refresh().is_success) - settings, _ = cache_policy.get_settings() - self.assertEqual('testValue', settings.get('testStringKey').get(VALUE)) + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) + self.assertEqual('testValue', settings.get('testStringKey').get(VALUE).get(STRING_VALUE)) self.assertEqual(1, request_get.call_count) cache_policy.set_offline() @@ -186,8 +192,9 @@ def test_init_offline(self): self.assertFalse(cache_policy.is_offline()) self.assertTrue(cache_policy.refresh().is_success) - settings, _ = cache_policy.get_settings() - self.assertEqual('testValue', settings.get('testStringKey').get(VALUE)) + config, _ = cache_policy.get_config() + settings = config.get(FEATURE_FLAGS) + self.assertEqual('testValue', settings.get('testStringKey').get(VALUE).get(STRING_VALUE)) self.assertEqual(1, request_get.call_count) cache_policy.close() diff --git a/configcatclienttests/test_override.py b/configcatclienttests/test_override.py index fd1b445..923f661 100644 --- a/configcatclienttests/test_override.py +++ b/configcatclienttests/test_override.py @@ -4,12 +4,14 @@ import tempfile import json import time +from parameterized import parameterized from configcatclient import ConfigCatClient from configcatclient.localdictionarydatasource import LocalDictionaryFlagOverrides from configcatclient.localfiledatasource import LocalFileFlagOverrides from configcatclient.overridedatasource import OverrideBehaviour -from configcatclienttests.mocks import MockResponse +from configcatclient.user import User +from configcatclienttests.mocks import MockResponse, TEST_SDK_KEY from configcatclient.configcatoptions import ConfigCatOptions from configcatclient.pollingmode import PollingMode @@ -27,7 +29,7 @@ def mocked_requests_get(*args, **kwargs): - return MockResponse({"f": {"fakeKey": {"v": False}}}, 200) + return MockResponse({"f": {"fakeKey": {"v": {"b": False}, "t": 0}, "fakeKey2": {"v": {"s": "test"}, "t": 1}}}, 200) class OverrideTests(unittest.TestCase): @@ -36,9 +38,9 @@ class OverrideTests(unittest.TestCase): def test_file(self): options = ConfigCatOptions(polling_mode=PollingMode.manual_poll(), flag_overrides=LocalFileFlagOverrides( - file_path=path.join(OverrideTests.script_dir, 'test.json'), + file_path=path.join(OverrideTests.script_dir, 'data/test.json'), override_behaviour=OverrideBehaviour.LocalOnly)) - client = ConfigCatClient.get(sdk_key='test', options=options) + client = ConfigCatClient.get(sdk_key='', options=options) self.assertTrue(client.get_value('enabledFeature', False)) self.assertFalse(client.get_value('disabledFeature', True)) @@ -50,7 +52,7 @@ def test_file(self): def test_simple_file(self): options = ConfigCatOptions(polling_mode=PollingMode.manual_poll(), flag_overrides=LocalFileFlagOverrides( - file_path=path.join(OverrideTests.script_dir, 'test-simple.json'), + file_path=path.join(OverrideTests.script_dir, 'data/test-simple.json'), override_behaviour=OverrideBehaviour.LocalOnly)) client = ConfigCatClient.get(sdk_key='test', options=options) @@ -66,12 +68,12 @@ def test_non_existent_file(self): flag_overrides=LocalFileFlagOverrides( file_path='non_existent.json', override_behaviour=OverrideBehaviour.LocalOnly)) - client = ConfigCatClient.get(sdk_key='test', options=options) + client = ConfigCatClient.get(sdk_key=TEST_SDK_KEY, options=options) self.assertFalse(client.get_value('enabledFeature', False)) client.close() def test_reload_file(self): - temp = tempfile.NamedTemporaryFile(mode="w") + temp = tempfile.NamedTemporaryFile(mode="w", delete=False) dictionary = {'flags': { 'enabledFeature': False }} @@ -82,7 +84,7 @@ def test_reload_file(self): flag_overrides=LocalFileFlagOverrides( file_path=temp.name, override_behaviour=OverrideBehaviour.LocalOnly)) - client = ConfigCatClient.get(sdk_key='test', options=options) + client = ConfigCatClient.get(sdk_key=TEST_SDK_KEY, options=options) self.assertFalse(client.get_value('enabledFeature', True)) @@ -100,9 +102,10 @@ def test_reload_file(self): self.assertTrue(client.get_value('enabledFeature', False)) client.close() + temp.close() def test_invalid_file(self): - temp = tempfile.NamedTemporaryFile(mode="w") + temp = tempfile.NamedTemporaryFile(mode="w", delete=False) temp.write('{"flags": {"enabledFeature": true}') temp.flush() @@ -110,11 +113,12 @@ def test_invalid_file(self): flag_overrides=LocalFileFlagOverrides( file_path=temp.name, override_behaviour=OverrideBehaviour.LocalOnly)) - client = ConfigCatClient.get(sdk_key='test', options=options) + client = ConfigCatClient.get(sdk_key=TEST_SDK_KEY, options=options) self.assertFalse(client.get_value('enabledFeature', False)) client.close() + temp.close() def test_dictionary(self): dictionary = { @@ -129,7 +133,7 @@ def test_dictionary(self): flag_overrides=LocalDictionaryFlagOverrides( source=dictionary, override_behaviour=OverrideBehaviour.LocalOnly)) - client = ConfigCatClient.get(sdk_key='test', options=options) + client = ConfigCatClient.get(sdk_key=TEST_SDK_KEY, options=options) self.assertTrue(client.get_value('enabledFeature', False)) self.assertFalse(client.get_value('disabledFeature', True)) @@ -149,10 +153,11 @@ def test_local_over_remote(self, mock_get): flag_overrides=LocalDictionaryFlagOverrides( source=dictionary, override_behaviour=OverrideBehaviour.LocalOverRemote)) - client = ConfigCatClient.get(sdk_key='test', options=options) + client = ConfigCatClient.get(sdk_key=TEST_SDK_KEY, options=options) client.force_refresh() self.assertTrue(client.get_value('fakeKey', False)) + self.assertEqual('test', client.get_value('fakeKey2', 'default')) self.assertTrue(client.get_value('nonexisting', False)) client.close() @@ -167,13 +172,73 @@ def test_remote_over_local(self, mock_get): flag_overrides=LocalDictionaryFlagOverrides( source=dictionary, override_behaviour=OverrideBehaviour.RemoteOverLocal)) - client = ConfigCatClient.get(sdk_key='test', options=options) + client = ConfigCatClient.get(sdk_key=TEST_SDK_KEY, options=options) client.force_refresh() self.assertFalse(client.get_value('fakeKey', True)) + self.assertEqual('test', client.get_value('fakeKey2', 'default')) self.assertTrue(client.get_value('nonexisting', False)) client.close() + @parameterized.expand([ + ('stringDependsOnString', '1', 'john@sensitivecompany.com', None, 'Dog'), + ('stringDependsOnString', '1', 'john@sensitivecompany.com', OverrideBehaviour.RemoteOverLocal, 'Dog'), + ('stringDependsOnString', '1', 'john@sensitivecompany.com', OverrideBehaviour.LocalOverRemote, 'Dog'), + ('stringDependsOnString', '1', 'john@sensitivecompany.com', OverrideBehaviour.LocalOnly, None), + ('stringDependsOnString', '2', 'john@notsensitivecompany.com', None, 'Cat'), + ('stringDependsOnString', '2', 'john@notsensitivecompany.com', OverrideBehaviour.RemoteOverLocal, 'Cat'), + ('stringDependsOnString', '2', 'john@notsensitivecompany.com', OverrideBehaviour.LocalOverRemote, 'Dog'), + ('stringDependsOnString', '2', 'john@notsensitivecompany.com', OverrideBehaviour.LocalOnly, None), + ('stringDependsOnInt', '1', 'john@sensitivecompany.com', None, 'Dog'), + ('stringDependsOnInt', '1', 'john@sensitivecompany.com', OverrideBehaviour.RemoteOverLocal, 'Dog'), + ('stringDependsOnInt', '1', 'john@sensitivecompany.com', OverrideBehaviour.LocalOverRemote, 'Cat'), + ('stringDependsOnInt', '1', 'john@sensitivecompany.com', OverrideBehaviour.LocalOnly, None), + ('stringDependsOnInt', '2', 'john@notsensitivecompany.com', None, 'Cat'), + ('stringDependsOnInt', '2', 'john@notsensitivecompany.com', OverrideBehaviour.RemoteOverLocal, 'Cat'), + ('stringDependsOnInt', '2', 'john@notsensitivecompany.com', OverrideBehaviour.LocalOverRemote, 'Dog'), + ('stringDependsOnInt', '2', 'john@notsensitivecompany.com', OverrideBehaviour.LocalOnly, None) + ]) + def test_prerequisite_flag_override(self, key, user_id, email, override_behaviour, expected_value): + # The flag override alters the definition of the following flags: + # * 'mainStringFlag': to check the case where a prerequisite flag is overridden (dependent flag: 'stringDependsOnString') + # * 'stringDependsOnInt': to check the case where a dependent flag is overridden (prerequisite flag: 'mainIntFlag') + options = ConfigCatOptions(polling_mode=PollingMode.manual_poll(), + flag_overrides=None if override_behaviour is None else LocalFileFlagOverrides( + file_path=path.join(OverrideTests.script_dir, 'data/test_override_flagdependency_v6.json'), + override_behaviour=override_behaviour)) + client = ConfigCatClient.get(sdk_key='configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/JoGwdqJZQ0K2xDy7LnbyOg', options=options) + client.force_refresh() + value = client.get_value(key, None, User(user_id, email)) + + self.assertEqual(expected_value, value) + client.close() + + @parameterized.expand([ + ('developerAndBetaUserSegment', '1', 'john@example.com', None, False), + ('developerAndBetaUserSegment', '1', 'john@example.com', OverrideBehaviour.RemoteOverLocal, False), + ('developerAndBetaUserSegment', '1', 'john@example.com', OverrideBehaviour.LocalOverRemote, True), + ('developerAndBetaUserSegment', '1', 'john@example.com', OverrideBehaviour.LocalOnly, True), + ('notDeveloperAndNotBetaUserSegment', '2', 'kate@example.com', None, True), + ('notDeveloperAndNotBetaUserSegment', '2', 'kate@example.com', OverrideBehaviour.RemoteOverLocal, True), + ('notDeveloperAndNotBetaUserSegment', '2', 'kate@example.com', OverrideBehaviour.LocalOverRemote, True), + ('notDeveloperAndNotBetaUserSegment', '2', 'kate@example.com', OverrideBehaviour.LocalOnly, None) + ]) + def test_config_salt_segment_override(self, key, user_id, email, override_behaviour, expected_value): + # The flag override uses a different config json salt than the downloaded one and + # overrides the following segments: + # * 'Beta Users': User.Email IS ONE OF ['jane@example.com'] + # * 'Developers': User.Email IS ONE OF ['john@example.com'] + options = ConfigCatOptions(polling_mode=PollingMode.manual_poll(), + flag_overrides=None if override_behaviour is None else LocalFileFlagOverrides( + file_path=path.join(OverrideTests.script_dir, 'data/test_override_segments_v6.json'), + override_behaviour=override_behaviour)) + client = ConfigCatClient.get(sdk_key='configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/h99HYXWWNE2bH8eWyLAVMA', options=options) + client.force_refresh() + value = client.get_value(key, None, User(user_id, email)) + + self.assertEqual(expected_value, value) + client.close() + if __name__ == '__main__': unittest.main() diff --git a/configcatclienttests/test_rollout.py b/configcatclienttests/test_rollout.py index dcf092d..c96f012 100644 --- a/configcatclienttests/test_rollout.py +++ b/configcatclienttests/test_rollout.py @@ -1,42 +1,112 @@ import logging +import sys import unittest +from datetime import datetime, timedelta from os import path +from parameterized import parameterized + +try: + from datetime import timezone + utc_plus_2 = timezone(timedelta(hours=2)) +except ImportError: + import pytz as timezone # On Python 2.7, datetime.timezone is not available. We use pytz instead. + utc_plus_2 = timezone.FixedOffset(120) # 120 minutes (2 hours) import configcatclient +from configcatclient import PollingMode, ConfigCatOptions, ConfigCatClient +from configcatclient.config import SettingType +from configcatclient.configcatoptions import Hooks +from configcatclient.localdictionarydatasource import LocalDictionaryFlagOverrides +from configcatclient.localfiledatasource import LocalFileDataSource +from configcatclient.logger import Logger +from configcatclient.overridedatasource import OverrideBehaviour +from configcatclient.rolloutevaluator import RolloutEvaluator from configcatclient.user import User +import codecs + +from configcatclient.utils import unicode_to_utf8 +from configcatclienttests.mocks import MockLogHandler logging.basicConfig(level=logging.WARNING) class RolloutTests(unittest.TestCase): - + script_dir = path.dirname(__file__) value_test_type = "value_test" variation_test_type = "variation_test" def test_matrix_text(self): - self._test_matrix('./testmatrix.csv', 'PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A', self.value_test_type) + # https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d62463-86ec-8fde-f5b5-1c5c426fc830/244cf8b0-f604-11e8-b543-f23c917f9d8d + self._test_matrix('data/testmatrix.csv', + 'configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/AG6C1ngVb0CvM07un6JisQ', self.value_test_type) def test_matrix_semantic(self): - self._test_matrix('./testmatrix_semantic.csv', 'PKDVCLf-Hq-h-kCzMp-L7Q/BAr3KgLTP0ObzKnBTo5nhA', self.value_test_type) + # https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d745f1-f315-7daf-d163-5541d3786e6f/244cf8b0-f604-11e8-b543-f23c917f9d8d + self._test_matrix('data/testmatrix_semantic.csv', + 'configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/iV8vH2MBakKxkFZylxHmTg', self.value_test_type) def test_matrix_semantic_2(self): - self._test_matrix('./testmatrix_semantic_2.csv', 'PKDVCLf-Hq-h-kCzMp-L7Q/q6jMCFIp-EmuAfnmZhPY7w', self.value_test_type) + # https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d77fa1-a796-85f9-df0c-57c448eb9934/244cf8b0-f604-11e8-b543-f23c917f9d8d + self._test_matrix('data/testmatrix_semantic_2.csv', + 'configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/U8nt3zEhDEO5S2ulubCopA', self.value_test_type) def test_matrix_number(self): - self._test_matrix('./testmatrix_number.csv', 'PKDVCLf-Hq-h-kCzMp-L7Q/uGyK3q9_ckmdxRyI7vjwCw', self.value_test_type) + # https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d747f0-5986-c2ef-eef3-ec778e32e10a/244cf8b0-f604-11e8-b543-f23c917f9d8d + self._test_matrix('data/testmatrix_number.csv', + 'configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw', self.value_test_type) def test_matrix_sensitive(self): - self._test_matrix('./testmatrix_sensitive.csv', 'PKDVCLf-Hq-h-kCzMp-L7Q/qX3TP2dTj06ZpCCT1h_SPA', self.value_test_type) + # https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d7b724-9285-f4a7-9fcd-00f64f1e83d5/244cf8b0-f604-11e8-b543-f23c917f9d8d + self._test_matrix('data/testmatrix_sensitive.csv', + 'configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/-0YmVOUNgEGKkgRF-rU65g', self.value_test_type) + + def test_matrix_comparators_v6(self): + # https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbc325-9a6b-4947-84e2-91529248278a/08dbc325-9ebd-4587-8171-88f76a3004cb + self._test_matrix('data/testmatrix_comparators_v6.csv', + 'configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ', self.value_test_type) + + def test_matrix_segments(self): + # https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbc325-9cfb-486f-8906-72a57c693615/08dbc325-9ebd-4587-8171-88f76a3004cb + self._test_matrix('data/testmatrix_segments.csv', + 'configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/h99HYXWWNE2bH8eWyLAVMA', self.value_test_type) + + def test_matrix_segments_old(self): + # https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d9f207-6883-43e5-868c-cbf677af3fe6/244cf8b0-f604-11e8-b543-f23c917f9d8d + self._test_matrix('data/testmatrix_segments_old.csv', + 'PKDVCLf-Hq-h-kCzMp-L7Q/LcYz135LE0qbcacz2mgXnA', self.value_test_type) + + def test_matrix_prerequisite_flag(self): + # https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbc325-9b74-45cb-86d0-4d61c25af1aa/08dbc325-9ebd-4587-8171-88f76a3004cb + self._test_matrix('data/testmatrix_prerequisite_flag.csv', + 'configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/JoGwdqJZQ0K2xDy7LnbyOg', self.value_test_type) + + def test_matrix_and_or(self): + # https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbc325-9d5e-4988-891c-fd4a45790bd1/08dbc325-9ebd-4587-8171-88f76a3004cb + self._test_matrix('data/testmatrix_and_or.csv', + 'configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/ByMO9yZNn02kXcm72lnY1A', self.value_test_type) def test_matrix_variation_id(self): - self._test_matrix('./testmatrix_variationId.csv', 'PKDVCLf-Hq-h-kCzMp-L7Q/nQ5qkhRAUEa6beEyyrVLBA', self.variation_test_type) + # https://app.configcat.com/08d5a03c-feb7-af1e-a1fa-40b3329f8bed/08d774b9-3d05-0027-d5f4-3e76c3dba752/244cf8b0-f604-11e8-b543-f23c917f9d8d + self._test_matrix('data/testmatrix_variationId.csv', + 'PKDVCLf-Hq-h-kCzMp-L7Q/nQ5qkhRAUEa6beEyyrVLBA', self.variation_test_type) - def _test_matrix(self, file_path, sdk_key, type): + def test_matrix_unicode(self): + # https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbd63c-9774-49d6-8187-5f2aab7bd606/08dbc325-9ebd-4587-8171-88f76a3004cb + self._test_matrix('data/testmatrix_unicode.csv', + 'configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/Da6w8dBbmUeMUBhh0iEeQQ', self.value_test_type) + + def _test_matrix(self, file_path, sdk_key, type, base_url=None): script_dir = path.dirname(__file__) file_path = path.join(script_dir, file_path) - with open(file_path, 'r') as f: - content = f.readlines() + # On Python 2.7, convert unicode to utf-8 + if sys.version_info[0] == 2: + with codecs.open(file_path, 'r', encoding='utf-8') as f: + content = f.readlines() + content = unicode_to_utf8(content) # On Python 2.7, convert unicode to utf-8 + else: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.readlines() # CSV header header = content[0].rstrip() @@ -44,7 +114,8 @@ def _test_matrix(self, file_path, sdk_key, type): custom_key = header.split(';')[3] content.pop(0) - client = configcatclient.get(sdk_key) + options = configcatclient.ConfigCatOptions(base_url=base_url) + client = configcatclient.get(sdk_key, options) errors = '' for line in content: user_descriptor = line.rstrip().split(';') @@ -86,22 +157,253 @@ def test_wrong_user_object(self): self.assertEqual('Cat', setting_value) configcatclient.close_all() + # https://app.configcat.com/v2/e7a75611-4256-49a5-9320-ce158755e3ba/08dbc325-7f69-4fd4-8af4-cf9f24ec8ac9/08dbc325-9e4e-4f59-86b2-5da50924b6ca/08dbc325-9ebd-4587-8171-88f76a3004cb + @parameterized.expand([ + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/P4e3fAz_1ky2-Zg2e4cbkw", "stringMatchedTargetingRuleAndOrPercentageOption", None, None, None, "Cat", False, False), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/P4e3fAz_1ky2-Zg2e4cbkw", "stringMatchedTargetingRuleAndOrPercentageOption", "12345", None, None, "Cat", False, False), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/P4e3fAz_1ky2-Zg2e4cbkw", "stringMatchedTargetingRuleAndOrPercentageOption", "12345", "a@example.com", None, "Dog", True, False), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/P4e3fAz_1ky2-Zg2e4cbkw", "stringMatchedTargetingRuleAndOrPercentageOption", "12345", "a@configcat.com", None, "Cat", False, False), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/P4e3fAz_1ky2-Zg2e4cbkw", "stringMatchedTargetingRuleAndOrPercentageOption", "12345", "a@configcat.com", "", "Frog", True, True), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/P4e3fAz_1ky2-Zg2e4cbkw", "stringMatchedTargetingRuleAndOrPercentageOption", "12345", "a@configcat.com", "US", "Fish", True, True), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/P4e3fAz_1ky2-Zg2e4cbkw", "stringMatchedTargetingRuleAndOrPercentageOption", "12345", "b@configcat.com", None, "Cat", False, False), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/P4e3fAz_1ky2-Zg2e4cbkw", "stringMatchedTargetingRuleAndOrPercentageOption", "12345", "b@configcat.com", "", "Falcon", False, True), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/P4e3fAz_1ky2-Zg2e4cbkw", "stringMatchedTargetingRuleAndOrPercentageOption", "12345", "b@configcat.com", "US", "Spider", False, True) + ]) + def test_evaluation_details_matched_evaluation_rule_and_percentage_option(self, sdk_key, key, user_id, email, percentage_base, expected_return_value, expected_matched_targeting_rule, expected_matched_percentage_option): + client = ConfigCatClient.get(sdk_key=sdk_key, options=ConfigCatOptions(polling_mode=PollingMode.manual_poll())) + client.force_refresh() + + user = User(user_id, email=email, custom={"PercentageBase": percentage_base}) if user_id is not None else None + + evaluation_details = client.get_value_details(key, None, user) + + self.assertEqual(expected_return_value, evaluation_details.value) + self.assertEqual(expected_matched_targeting_rule, evaluation_details.matched_targeting_rule is not None) + self.assertEqual(expected_matched_percentage_option, evaluation_details.matched_percentage_option is not None) + + def test_user_object_attribute_value_conversion_text_comparison(self): + client = ConfigCatClient.get(sdk_key='configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ', + options=ConfigCatOptions(polling_mode=PollingMode.manual_poll())) + client.force_refresh() + + logger = logging.getLogger('configcat') + log_handler = MockLogHandler() + logger.addHandler(log_handler) + + custom_attribute_name = 'Custom1' + custom_attribute_value = 42 + user = User('12345', custom={custom_attribute_name: custom_attribute_value}) + + key = 'boolTextEqualsNumber' + value = client.get_value(key, None, user) + + self.assertEqual(True, value) + + self.assertEqual(1, len(log_handler.warning_logs)) + warning_log = log_handler.warning_logs[0] + self.assertEqual("[3005] Evaluation of condition (User.%s EQUALS '%s') for setting '%s' may not produce the expected" + " result (the User.%s attribute is not a string value, thus it was automatically converted to the " + "string value '%s'). Please make sure that using a non-string value was intended." % + (custom_attribute_name, custom_attribute_value, key, custom_attribute_name, custom_attribute_value), + warning_log) + + def test_wrong_config_json_type_mismatch(self): + config = { + 'f': { + 'test': { + 't': 1, # SettingType.STRING + 'v': {'b': True}, # bool value instead of string (type mismatch) + 'p': [], + 'r': [] + } + } + } + + log = Logger('configcat', Hooks()) + logger = logging.getLogger('configcat') + log_handler = MockLogHandler() + logger.addHandler(log_handler) + evaluator = RolloutEvaluator(log) + value, _, _, _, _ = evaluator.evaluate('test', None, False, 'default_variation_id', config, None) + + self.assertFalse(value) + self.assertEqual(1, len(log_handler.error_logs)) + error = log_handler.error_logs[0] + if sys.version_info[0] == 2: + # On Python 2.7 the serializer returns instead of + self.assertTrue(error.startswith("[2001] Failed to evaluate setting 'test'. " + "(Setting value is not of the expected type )")) + else: + self.assertTrue(error.startswith("[2001] Failed to evaluate setting 'test'. " + "(Setting value is not of the expected type )")) + + @parameterized.expand([ + # SemVer-based comparisons + ("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/iV8vH2MBakKxkFZylxHmTg", "lessThanWithPercentage", "12345", "Custom1", "0.0", "20%"), + ("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/iV8vH2MBakKxkFZylxHmTg", "lessThanWithPercentage", "12345", "Custom1", "0.9.9", "< 1.0.0"), + ("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/iV8vH2MBakKxkFZylxHmTg", "lessThanWithPercentage", "12345", "Custom1", "1.0.0", "20%"), + ("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/iV8vH2MBakKxkFZylxHmTg", "lessThanWithPercentage", "12345", "Custom1", "1.1", "20%"), + ("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/iV8vH2MBakKxkFZylxHmTg", "lessThanWithPercentage", "12345", "Custom1", 0, "20%"), + ("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/iV8vH2MBakKxkFZylxHmTg", "lessThanWithPercentage", "12345", "Custom1", 0.9, "20%"), + ("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/iV8vH2MBakKxkFZylxHmTg", "lessThanWithPercentage", "12345", "Custom1", 2, "20%"), + # Number-based comparisons + ("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", float('-inf'), "<2.1"), + ("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", -1, "<2.1"), + ("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 2, "<2.1"), + ("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 2.1, "<=2,1"), + ("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 3, "<>4.2"), + ("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", 5, ">=5"), + ("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", float('inf'), ">5"), + ("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", float('nan'), "<>4.2"), + ("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "-inf", "<2.1"), + ("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "-1", "<2.1"), + ("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "2", "<2.1"), + ("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "2.1", "<=2,1"), + ("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "2,1", "<=2,1"), + ("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "3", "<>4.2"), + ("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "5", ">=5"), + ("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "inf", ">5"), + ("configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/FCWN-k1dV0iBf8QZrDgjdw", "numberWithPercentage", "12345", "Custom1", "nan", "<>4.2"), + # Date time-based comparisons + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", datetime(2023, 3, 31, 23, 59, 59, 999000), False), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", datetime(2023, 3, 31, 23, 59, 59, 999000, timezone.utc), False), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", datetime(2023, 4, 1, 1, 59, 59, 999000, utc_plus_2), False), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", datetime(2023, 4, 1, 0, 0, 0, 1000, timezone.utc), True), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", datetime(2023, 4, 1, 2, 0, 0, 1000, utc_plus_2), True), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", datetime(2023, 4, 30, 23, 59, 59, 999000, timezone.utc), True), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", datetime(2023, 5, 1, 1, 59, 59, 999000, utc_plus_2), True), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", datetime(2023, 5, 1, 0, 0, 0, 1000, timezone.utc), False), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", datetime(2023, 5, 1, 2, 0, 0, 1000, utc_plus_2), False), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", float('-inf'), False), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", 1680307199.999, False), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", 1680307200.001, True), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", 1682899199.999, True), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", 1682899200.001, False), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", float('inf'), False), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", float("nan"), False), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", 1680307199, False), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", 1680307201, True), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", 1682899199, True), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", 1682899201, False), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "-inf", False), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "1680307199.999", False), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "1680307200.001", True), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "1682899199.999", True), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "1682899200.001", False), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "+inf", False), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "boolTrueIn202304", "12345", "Custom1", "NaN", False), + # String array-based comparisons + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "stringArrayContainsAnyOfDogDefaultCat", "12345", "Custom1", ["x", "read"], "Dog"), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "stringArrayContainsAnyOfDogDefaultCat", "12345", "Custom1", ["x", "Read"], "Cat"), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "stringArrayContainsAnyOfDogDefaultCat", "12345", "Custom1", "[\"x\", \"read\"]", "Dog"), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "stringArrayContainsAnyOfDogDefaultCat", "12345", "Custom1", "[\"x\", \"Read\"]", "Cat"), + ("configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/OfQqcTjfFUGBwMKqtyEOrQ", "stringArrayContainsAnyOfDogDefaultCat", "12345", "Custom1", "x, read", "Cat") + ]) + def test_user_object_attribute_value_conversion_non_text_comparisons(self, sdk_key, key, user_id, custom_attribute_name, + custom_attribute_value, expected_return_value): + client = ConfigCatClient.get(sdk_key=sdk_key, + options=ConfigCatOptions(polling_mode=PollingMode.manual_poll())) + client.force_refresh() + user = User(user_id, custom={custom_attribute_name: custom_attribute_value}) + value = client.get_value(key, None, user) + + self.assertEqual(expected_return_value, value) + + @parameterized.expand([ + ("key1", "'key1' -> 'key1'"), + ("key2", "'key2' -> 'key3' -> 'key2'"), + ("key4", "'key4' -> 'key3' -> 'key2' -> 'key3'") + ]) + def test_prerequisite_flag_circular_dependency(self, key, dependency_cycle): + config = LocalFileDataSource(path.join(self.script_dir, 'data/test_circulardependency_v6.json'), + OverrideBehaviour.LocalOnly, None).get_overrides() + + log = Logger('configcat', Hooks()) + logger = logging.getLogger('configcat') + log_handler = MockLogHandler() + logger.addHandler(log_handler) + evaluator = RolloutEvaluator(log) + + value, _, _, _, _ = evaluator.evaluate(key, None, 'default_value', 'default_variation_id', config, None) + + self.assertEqual('default_value', value) + error_log = log_handler.error_logs[0] + self.assertTrue('Circular dependency detected' in error_log) + self.assertTrue(dependency_cycle in error_log) + + @parameterized.expand([ + ("stringDependsOnBool", bool, "mainBoolFlag", True, "Dog"), + ("stringDependsOnBool", bool, "mainBoolFlag", False, "Cat"), + ("stringDependsOnBool", bool, "mainBoolFlag", "1", None), + ("stringDependsOnBool", bool, "mainBoolFlag", 1, None), + ("stringDependsOnBool", bool, "mainBoolFlag", 1.0, None), + ("stringDependsOnBool", bool, "mainBoolFlag", [True], None), + ("stringDependsOnBool", bool, "mainBoolFlag", None, None), + ("stringDependsOnString", str, "mainStringFlag", "private", "Dog"), + ("stringDependsOnString", str, "mainStringFlag", "Private", "Cat"), + ("stringDependsOnString", str, "mainStringFlag", True, None), + ("stringDependsOnString", str, "mainStringFlag", 1, None), + ("stringDependsOnString", str, "mainStringFlag", 1.0, None), + ("stringDependsOnString", str, "mainStringFlag", ["private"], None), + ("stringDependsOnString", str, "mainStringFlag", None, None), + ("stringDependsOnInt", int, "mainIntFlag", 2, "Dog"), + ("stringDependsOnInt", int, "mainIntFlag", 1, "Cat"), + ("stringDependsOnInt", int, "mainIntFlag", "2", None), + ("stringDependsOnInt", int, "mainIntFlag", True, None), + ("stringDependsOnInt", int, "mainIntFlag", 2.0, None), + ("stringDependsOnInt", int, "mainIntFlag", [2], None), + ("stringDependsOnInt", int, "mainIntFlag", None, None), + ("stringDependsOnDouble", float, "mainDoubleFlag", 0.1, "Dog"), + ("stringDependsOnDouble", float, "mainDoubleFlag", 0.11, "Cat"), + ("stringDependsOnDouble", float, "mainDoubleFlag", "0.1", None), + ("stringDependsOnDouble", float, "mainDoubleFlag", True, None), + ("stringDependsOnDouble", float, "mainDoubleFlag", 1, None), + ("stringDependsOnDouble", float, "mainDoubleFlag", [0.1], None), + ("stringDependsOnDouble", float, "mainDoubleFlag", None, None) + ]) + def test_prerequisite_flag_comparison_value_type_mismatch(self, key, comparison_value_type, prerequisite_flag_key, prerequisite_flag_value, expected_value): + override_dictionary = {prerequisite_flag_key: prerequisite_flag_value} + options = ConfigCatOptions(polling_mode=PollingMode.manual_poll(), + flag_overrides=LocalDictionaryFlagOverrides( + source=override_dictionary, + override_behaviour=OverrideBehaviour.LocalOverRemote)) + client = ConfigCatClient.get(sdk_key='configcat-sdk-1/JcPbCGl_1E-K9M-fJOyKyQ/JoGwdqJZQ0K2xDy7LnbyOg', options=options) + client.force_refresh() + + logger = logging.getLogger('configcat') + log_handler = MockLogHandler() + logger.addHandler(log_handler) + + value = client.get_value(key, None) + + self.assertEqual(expected_value, value) + + if expected_value is None: + self.assertEqual(1, len(log_handler.error_logs)) + error_log = log_handler.error_logs[0] + prerequisite_flag_value_type = SettingType.to_type(SettingType.from_type(type(prerequisite_flag_value))) + + self.assertTrue(("Type mismatch between comparison value type %s and type %s of prerequisite flag '%s'" % + (comparison_value_type, prerequisite_flag_value_type, prerequisite_flag_key)) in error_log) + + client.close() + ''' def test_create_matrix_text(self): - self._test_create_matrix('./testmatrix.csv', './testmatrix_out.csv', + self._test_create_matrix('data/testmatrix.csv', 'data/testmatrix_out.csv', 'PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A') def test_create_matrix_semantic(self): - self._test_create_matrix('./testmatrix_semantic.csv', './testmatrix_semantic_out.csv', + self._test_create_matrix('data/testmatrix_semantic.csv', 'data/testmatrix_semantic_out.csv', 'PKDVCLf-Hq-h-kCzMp-L7Q/BAr3KgLTP0ObzKnBTo5nhA') def test_create_matrix_semnatic_2(self): - self._test_create_matrix('./testmatrix_input_semantic_2.csv', './testmatrix_semantic_2.csv', + self._test_create_matrix('data/testmatrix_input_semantic_2.csv', 'data/testmatrix_semantic_2.csv', 'PKDVCLf-Hq-h-kCzMp-L7Q/q6jMCFIp-EmuAfnmZhPY7w') def test_create_matrix_number(self): - self._test_create_matrix('./testmatrix_number.csv', './testmatrix_number_out.csv', + self._test_create_matrix('data/testmatrix_number.csv', 'data/testmatrix_number_out.csv', 'PKDVCLf-Hq-h-kCzMp-L7Q/uGyK3q9_ckmdxRyI7vjwCw') def _test_create_matrix(self, file_path, out_file_path, sdk_key): diff --git a/configcatclienttests/test_user.py b/configcatclienttests/test_user.py index 0491527..224c1b6 100644 --- a/configcatclienttests/test_user.py +++ b/configcatclienttests/test_user.py @@ -1,7 +1,16 @@ +import logging import unittest import json +from datetime import datetime from configcatclient.user import User +try: + from datetime import timezone +except ImportError: + import pytz as timezone # On Python 2.7, datetime.timezone is not available. We use pytz instead. + +logging.basicConfig() + class UserTests(unittest.TestCase): def test_empty_or_none_identifier(self): @@ -34,7 +43,12 @@ def test_to_str(self): user_id = 'id' email = 'test@test.com' country = 'country' - custom = {'custom': 'test'} + custom = { + 'string': 'test', + 'datetime': datetime(2023, 9, 19, 11, 1, 35, 999000, tzinfo=timezone.utc), + 'int': 42, + 'float': 3.14 + } user = User(identifier=user_id, email=email, country=country, custom=custom) user_json = json.loads(str(user)) @@ -42,4 +56,7 @@ def test_to_str(self): self.assertEqual(user_id, user_json['Identifier']) self.assertEqual(email, user_json['Email']) self.assertEqual(country, user_json['Country']) - self.assertEqual(custom, user_json['Custom']) + self.assertEqual('test', user_json['string']) + self.assertEqual(42, user_json['int']) + self.assertEqual(3.14, user_json['float']) + self.assertEqual("2023-09-19T11:01:35.999000+00:00", user_json['datetime']) diff --git a/configcatclienttests/test_variation_id.py b/configcatclienttests/test_variation_id.py index f562683..77ecb6c 100644 --- a/configcatclienttests/test_variation_id.py +++ b/configcatclienttests/test_variation_id.py @@ -2,7 +2,7 @@ import unittest from configcatclient.configcatclient import ConfigCatClient -from configcatclienttests.mocks import ConfigCacheMock +from configcatclienttests.mocks import ConfigCacheMock, TEST_SDK_KEY from configcatclient.configcatoptions import ConfigCatOptions from configcatclient.pollingmode import PollingMode @@ -11,27 +11,27 @@ class VariationIdTests(unittest.TestCase): def test_get_variation_id(self): - client = ConfigCatClient.get('test', ConfigCatOptions(polling_mode=PollingMode.manual_poll(), - config_cache=ConfigCacheMock())) + client = ConfigCatClient.get(TEST_SDK_KEY, ConfigCatOptions(polling_mode=PollingMode.manual_poll(), + config_cache=ConfigCacheMock())) self.assertEqual('fakeId1', client.get_value_details('key1', None).variation_id) self.assertEqual('fakeId2', client.get_value_details('key2', None).variation_id) client.close() def test_get_variation_id_not_found(self): - client = ConfigCatClient.get('test', ConfigCatOptions(polling_mode=PollingMode.manual_poll(), - config_cache=ConfigCacheMock())) + client = ConfigCatClient.get(TEST_SDK_KEY, ConfigCatOptions(polling_mode=PollingMode.manual_poll(), + config_cache=ConfigCacheMock())) self.assertEqual(None, client.get_value_details('nonexisting', 'default_value').variation_id) client.close() def test_get_variation_id_empty_config(self): - client = ConfigCatClient.get('test', ConfigCatOptions(polling_mode=PollingMode.manual_poll(), - config_cache=ConfigCacheMock())) + client = ConfigCatClient.get(TEST_SDK_KEY, ConfigCatOptions(polling_mode=PollingMode.manual_poll(), + config_cache=ConfigCacheMock())) self.assertEqual(None, client.get_value_details('nonexisting', 'default_value').variation_id) client.close() def test_get_key_and_value(self): - client = ConfigCatClient.get('test', ConfigCatOptions(polling_mode=PollingMode.manual_poll(), - config_cache=ConfigCacheMock())) + client = ConfigCatClient.get(TEST_SDK_KEY, ConfigCatOptions(polling_mode=PollingMode.manual_poll(), + config_cache=ConfigCacheMock())) result = client.get_key_and_value('fakeId1') self.assertEqual('key1', result.key) self.assertTrue(result.value) @@ -41,14 +41,14 @@ def test_get_key_and_value(self): client.close() def test_get_key_and_value_not_found(self): - client = ConfigCatClient.get('test', ConfigCatOptions(polling_mode=PollingMode.manual_poll(), - config_cache=ConfigCacheMock())) + client = ConfigCatClient.get(TEST_SDK_KEY, ConfigCatOptions(polling_mode=PollingMode.manual_poll(), + config_cache=ConfigCacheMock())) result = client.get_key_and_value('nonexisting') self.assertIsNone(result) client.close() def test_get_key_and_value_empty_config(self): - client = ConfigCatClient.get('test', ConfigCatOptions(polling_mode=PollingMode.manual_poll())) + client = ConfigCatClient.get(TEST_SDK_KEY, ConfigCatOptions(polling_mode=PollingMode.manual_poll())) result = client.get_key_and_value('nonexisting') self.assertIsNone(result) client.close() diff --git a/configcatclienttests/testmatrix_input_semantic_2.csv b/configcatclienttests/testmatrix_input_semantic_2.csv deleted file mode 100644 index eea1bbb..0000000 --- a/configcatclienttests/testmatrix_input_semantic_2.csv +++ /dev/null @@ -1,95 +0,0 @@ -Identifier;Email;Country;AppVersion;precedenceTests -dontcare;;;1.9.1-1 -dontcare;;;1.9.1-2 -dontcare;;;1.9.1-10 -dontcare;;;1.9.1-10a -dontcare;;;1.9.1-1a -dontcare;;;1.9.1-alpha -dontcare;;;1.9.99-alpha -dontcare;;;1.9.99-alpha+build1 -dontcare;;;1.9.99-alpha+build2 -dontcare;;;1.9.99-alpha2 -dontcare;;;1.9.99-beta -dontcare;;;1.9.99-rc -dontcare;;;1.9.99-rc.1 -dontcare;;;1.9.99-rc.2 -dontcare;;;1.9.99-rc.9 -dontcare;;;1.9.99-rc.20 -dontcare;;;1.9.99-rc.20a -dontcare;;;1.9.99-rc.2a -dontcare;;;1.9.99 -dontcare;;;1.9.100 -dontcare;;;1.10.0-alpha -dontcare;;;1.10.0 -dontcare;;;1.10.1 -dontcare;;;1.10.2 -dontcare;;;2.0.0 -dontcare;;;2.0.0+build3 -dontcare;;;2.0.0+001 -dontcare;;;2.0.0+20130313144700 -dontcare;;;2.0.0+exp.sha.5114f85 -dontcare;;;3.0.0 -dontcare;;;4.0.0 -dontcare;;;5.0.0 -dontcare;;;6.0.0 -dontcare;;;7.0.0-patch+metadata -dontcare;;;8.0.0-patch+metadata -dontcare;;;9.0.0-patch -dontcare;;;10.0.0 -dontcare;;;104.0.0 -dontcare;;;103.0.0 -dontcare;;;102.0.0 -dontcare;;;101.0.0 -dontcare;;;90.104.0 -dontcare;;;90.103.0 -dontcare;;;90.102.0 -dontcare;;;90.101.0 -dontcare;;;80.0.104 -dontcare;;;80.0.103 -dontcare;;;80.0.102 -dontcare;;;80.0.101 -dontcare;;;73.0.0 -dontcare;;;72.0.0 -dontcare;;;72.0.0-beta.2 -dontcare;;;72.0.0-beta.1 -dontcare;;;72.0.0-beta -dontcare;;;72.0.0-alpha -dontcare;;;72.0.0-1a -dontcare;;;72.0.0-10aa -dontcare;;;72.0.0-10a -dontcare;;;72.0.0-2 -dontcare;;;71.0.0+metadata -dontcare;;;71.0.0-patch3+metadata -dontcare;;;71.0.0-patch2+metadata -dontcare;;;71.0.0-patch1 -dontcare;;;60.73.0 -dontcare;;;60.72.0 -dontcare;;;60.72.0-beta.2 -dontcare;;;60.72.0-beta.1 -dontcare;;;60.72.0-beta -dontcare;;;60.72.0-alpha -dontcare;;;60.72.0-1a -dontcare;;;60.72.0-10aa -dontcare;;;60.72.0-10a -dontcare;;;60.72.0-2 -dontcare;;;60.71.0+metadata -dontcare;;;60.71.0-patch3+metadata -dontcare;;;60.71.0-patch2+metadata -dontcare;;;60.71.0-patch1 -dontcare;;;50.60.73 -dontcare;;;50.60.72 -dontcare;;;50.60.72-beta.2 -dontcare;;;50.60.72-beta.1 -dontcare;;;50.60.72-beta -dontcare;;;50.60.72-alpha -dontcare;;;50.60.72-1a -dontcare;;;50.60.72-10aa -dontcare;;;50.60.72-10a -dontcare;;;50.60.72-2 -dontcare;;;50.60.71+metadata -dontcare;;;50.60.71-patch3+metadata -dontcare;;;50.60.71-patch2+metadata -dontcare;;;50.60.71-patch1 -dontcare;;;50.60.71-patch1+anothermetadata -dontcare;;;40.0.0-patch -dontcare;;;30.0.0-beta \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 8d799d6..596c298 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ requests>=2.31.0; python_version >= "3.7" semver>=2.10.2 enum-compat>=0.0.3 qualname>=0.1.0 +pytz; python_version == "2.7" diff --git a/setup.py b/setup.py index b30d48e..f2635ba 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ def parse_requirements(filename): return [line for line in lines if line] -configcatclient_version = '8.0.1' +configcatclient_version = '9.0.0' requirements = parse_requirements('requirements.txt') @@ -39,6 +39,7 @@ def parse_requirements(filename): 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', 'Topic :: Software Development', 'Topic :: Software Development :: Libraries', ], diff --git a/tox.ini b/tox.ini index 1c354a9..501841f 100644 --- a/tox.ini +++ b/tox.ini @@ -6,6 +6,7 @@ passenv = LD_PRELOAD deps = pytest pytest-cov + parameterized commands = pytest --cov=configcatclient configcatclienttests @@ -20,4 +21,5 @@ commands = deps = pytest pytest-cov + parameterized mock