diff --git a/circle.yml b/circle.yml index 7250c793..42ebff9d 100644 --- a/circle.yml +++ b/circle.yml @@ -22,5 +22,5 @@ dependencies: test: override: - pyenv shell 2.7.10; $(pyenv which py.test) testing - - pyenv shell 3.3.3; $(pyenv which py.test) --ignore=testing/test_integration_twisted.py -s testing - - pyenv shell 3.4.2; $(pyenv which py.test) --ignore=testing/test_integration_twisted.py -s testing + - pyenv shell 3.3.3; $(pyenv which py.test) -s testing + - pyenv shell 3.4.2; $(pyenv which py.test) -s testing diff --git a/ldclient/__init__.py b/ldclient/__init__.py index 0d80a640..ee332a66 100644 --- a/ldclient/__init__.py +++ b/ldclient/__init__.py @@ -34,7 +34,7 @@ def get(): global client _lock.lock() if not client: - log.debug("Initializing LaunchDarkly Client") + log.info("Initializing LaunchDarkly Client") client = LDClient(api_key, config, start_wait) return client finally: diff --git a/ldclient/client.py b/ldclient/client.py index 60a0ecd6..f7d59b85 100644 --- a/ldclient/client.py +++ b/ldclient/client.py @@ -9,10 +9,11 @@ from ldclient.event_consumer import EventConsumerImpl from ldclient.feature_requester import FeatureRequesterImpl from ldclient.feature_store import InMemoryFeatureStore +from ldclient.flag import _get_off_variation, _evaluate_index, _get_variation, evaluate from ldclient.interfaces import FeatureStore from ldclient.polling import PollingUpdateProcessor from ldclient.streaming import StreamingUpdateProcessor -from ldclient.util import check_uwsgi, _evaluate, log +from ldclient.util import check_uwsgi, log # noinspection PyBroadException try: @@ -24,8 +25,8 @@ from cachecontrol import CacheControl from threading import Lock -GET_LATEST_FEATURES_PATH = '/api/eval/latest-features' -STREAM_FEATURES_PATH = '/features' +GET_LATEST_FEATURES_PATH = '/sdk/latest-flags' +STREAM_FEATURES_PATH = '/flags' class Config(object): @@ -176,57 +177,69 @@ def _send_event(self, event): def track(self, event_name, user, data=None): self._sanitize_user(user) - self._send_event({'kind': 'custom', 'key': event_name, - 'user': user, 'data': data}) + self._send_event({'kind': 'custom', 'key': event_name, 'user': user, 'data': data}) def identify(self, user): self._sanitize_user(user) - self._send_event({'kind': 'identify', 'key': user['key'], 'user': user}) + self._send_event({'kind': 'identify', 'key': user.get('key'), 'user': user}) def is_offline(self): return self._config.offline + def is_initialized(self): + return self.is_offline() or self._config.use_ldd or self._update_processor.initialized() + def flush(self): if self._config.offline or not self._config.events_enabled: return return self._event_consumer.flush() - def get_flag(self, key, user, default=False): - return self.toggle(key, user, default) - - def toggle(self, key, user, default=False): + def toggle(self, key, user, default): default = self._config.get_default(key, default) - - def send_event(value): - self._send_event({'kind': 'feature', 'key': key, - 'user': user, 'value': value, 'default': default}) + self._sanitize_user(user) if self._config.offline: return default - self._sanitize_user(user) + def send_event(value): + self._send_event({'kind': 'feature', 'key': key, + 'user': user, 'value': value, 'default': default}) - if 'key' in user and user['key']: - feature = self._store.get(key) - else: + if not self.is_initialized(): + log.warn("Feature Flag evaluation attempted before client has finished initializing! Returning default: " + + str(default) + " for feature key: " + key) send_event(default) - log.warning("Missing or empty User key when evaluating Feature Flag key: " + key + ". Returning default.") return default - if feature: - val = _evaluate(feature, user) - else: - log.warning("Feature Flag key: " + key + " not found in Feature Store. Returning default.") + if user.get('key', "") == "": + log.warn("Missing or empty User key when evaluating Feature Flag key: " + key + ". Returning default.") send_event(default) return default - if val is None: + flag = self._store.get(key) + if not flag: + log.warn("Feature Flag key: " + key + " not found in Feature Store. Returning default.") send_event(default) - log.warning("Feature Flag key: " + key + " evaluation returned None. Returning default.") return default - send_event(val) - return val + if flag.get('on', False): + value, prereq_events = evaluate(flag, user, self._store) + if not self._config.offline: + for e in prereq_events: + self._send_event(e) + + if value is not None: + send_event(value) + return value + + if 'offVariation' in flag and flag['offVariation']: + value = _get_variation(flag, flag['offVariation']) + send_event(value) + return value + + send_event(default) + return default + def _sanitize_user(self, user): if 'key' in user: diff --git a/ldclient/event_consumer.py b/ldclient/event_consumer.py index 98e94dc9..be101f2e 100644 --- a/ldclient/event_consumer.py +++ b/ldclient/event_consumer.py @@ -23,7 +23,7 @@ def __init__(self, event_queue, api_key, config): self._running = True def run(self): - log.debug("Starting event consumer") + log.info("Starting event consumer") self._running = True while self._running: self.send() diff --git a/ldclient/flag.py b/ldclient/flag.py new file mode 100644 index 00000000..9d5b5bfe --- /dev/null +++ b/ldclient/flag.py @@ -0,0 +1,151 @@ +import hashlib +import logging + +import six +import sys + +from ldclient import operators + +__LONG_SCALE__ = float(0xFFFFFFFFFFFFFFF) + +__BUILTINS__ = ["key", "ip", "country", "email", + "firstName", "lastName", "avatar", "name", "anonymous"] + +log = logging.getLogger(sys.modules[__name__].__name__) + + +def evaluate(flag, user, store, prereq_events=[]): + failed_prereq = None + for prereq in flag.get('prerequisites', []): + prereq_flag = store.get(prereq.get('key')) + if prereq_flag is None: + log.warn("Missing prereq flag: " + prereq.get('key')) + failed_prereq = prereq + break + if prereq_flag.get('on', False) is True: + prereq_value, prereq_events = evaluate(prereq_flag, user, store, prereq_events) + event = {'kind': 'feature', 'key': prereq.get('key'), 'user': user, 'value': prereq_value} + prereq_events.append(event) + variation = _get_variation(prereq_flag, prereq.get('variation')) + if prereq_value is None or not prereq_value == variation: + failed_prereq = prereq + else: + failed_prereq = prereq + + if failed_prereq is not None: + return None, prereq_events + + index = _evaluate_index(flag, user) + return _get_variation(flag, index), prereq_events + + +def _evaluate_index(feature, user): + # Check to see if any user targets match: + for target in feature.get('targets', []): + for value in target.get('values', []): + if value == user['key']: + return target.get('variation') + + # Now walk through the rules to see if any match + for rule in feature.get('rules', []): + if _rule_matches_user(rule, user): + return _variation_index_for_user(feature, rule, user) + + # Walk through fallthrough and see if it matches + if feature.get('fallthrough') is not None: + return _variation_index_for_user(feature, feature['fallthrough'], user) + + return None + + +def _get_variation(feature, index): + if index is not None and index < len(feature['variations']): + return feature['variations'][index] + return None + + +def _get_off_variation(feature): + if feature.get('offVariation') is not None: + return _get_variation(feature, feature.get('offVariation')) + return None + + +def _get_user_attribute(user, attr): + if attr is 'secondary': + return None, True + if attr in __BUILTINS__: + return user.get(attr), False + else: # custom attribute + if user.get('custom') is None or user['custom'].get(attr) is None: + return None, True + return user['custom'][attr], False + + +def _variation_index_for_user(feature, rule, user): + if rule.get('variation') is not None: + return rule['variation'] + + if rule.get('rollout') is not None: + bucket_by = 'key' + if rule['rollout'].get('bucketBy') is not None: + bucket_by = rule['rollout']['bucketBy'] + bucket = _bucket_user(user, feature, bucket_by) + sum = 0.0 + for wv in rule['rollout'].get('variations', []): + sum += wv.get('weight', 0.0) / 100000.0 + if bucket < sum: + return wv.get('variation') + + return None + + +def _bucket_user(user, feature, bucket_by): + u_value, should_pass = _get_user_attribute(user, bucket_by) + if should_pass is True or not isinstance(u_value, six.string_types): + return 0.0 + + id_hash = u_value + if user.get('secondary') is not None: + id_hash = id_hash + '.' + user['secondary'] + hash_key = '%s.%s.%s' % (feature['key'], feature['salt'], id_hash) + hash_val = int(hashlib.sha1(hash_key.encode('utf-8')).hexdigest()[:15], 16) + result = hash_val / __LONG_SCALE__ + return result + + +def _rule_matches_user(rule, user): + for clause in rule.get('clauses', []): + if clause.get('attribute') is not None: + if not _clause_matches_user(clause, user): + return False + return True + + +def _clause_matches_user(clause, user): + u_value, should_pass = _get_user_attribute(user, clause.get('attribute')) + if should_pass is True: + return False + if u_value is None: + return None + # is the attr an array? + op_fn = operators.ops[clause['op']] + if isinstance(u_value, (list, tuple)): + for u in u_value: + if _match_any(op_fn, u, clause.get('values', [])): + return _maybe_negate(clause, True) + return _maybe_negate(clause, True) + else: + return _maybe_negate(clause, _match_any(op_fn, u_value, clause.get('values', []))) + + +def _match_any(op_fn, u, vals): + for v in vals: + if op_fn(u, v): + return True + return False + + +def _maybe_negate(clause, val): + if clause.get('negate', False): + return not val + return val diff --git a/ldclient/operators.py b/ldclient/operators.py new file mode 100644 index 00000000..eacefd2b --- /dev/null +++ b/ldclient/operators.py @@ -0,0 +1,127 @@ +import logging +import re +import sys +from collections import defaultdict +from numbers import Number + +import six +import strict_rfc3339 + +log = logging.getLogger(sys.modules[__name__].__name__) + + +def _string_operator(u, c, fn): + if isinstance(u, six.string_types): + if isinstance(c, six.string_types): + return fn(u, c) + return False + + +def _numeric_operator(u, c, fn): + # bool is a subtype of int, and we don't want to try and compare it as a number. + if isinstance(input, bool): + log.warn("Got unexpected bool type when attempting to parse time") + return None + + if isinstance(u, Number): + if isinstance(c, Number): + return fn(u, c) + return False + + +def _parse_time(input): + """ + :param input: Either a number as milliseconds since Unix Epoch, or a string as a valid RFC3339 timestamp + :return: milliseconds since Unix epoch, or None if input was invalid. + """ + + # bool is a subtype of int, and we don't want to try and compare it as a time. + if isinstance(input, bool): + log.warn("Got unexpected bool type when attempting to parse time") + return None + + if isinstance(input, Number): + return float(input) + + if isinstance(input, six.string_types): + try: + timestamp = strict_rfc3339.rfc3339_to_timestamp(input) + return timestamp * 1000.0 + except Exception as e: + log.warn("Couldn't parse timestamp:" + str(input) + " with error: " + str(e)) + return None + + log.warn("Got unexpected type: " + type(input) + " with value: " + str(input) + " when attempting to parse time") + return None + + +def _time_operator(u, c, fn): + u_time = _parse_time(u) + if u_time is not None: + c_time = _parse_time(c) + if c_time is not None: + return fn(u_time, c_time) + return False + + +def _in(u, c): + if u == c: + return True + return False + + +def _starts_with(u, c): + return _string_operator(u, c, lambda u, c: u.startswith(c)) + + +def _ends_with(u, c): + return _string_operator(u, c, lambda u, c: u.endswith(c)) + + +def _contains(u, c): + return _string_operator(u, c, lambda u, c: c in u) + + +def _matches(u, c): + return _string_operator(u, c, lambda u, c: re.match(c, u)) + + +def _less_than(u, c): + return _numeric_operator(u, c, lambda u, c: u < c) + + +def _less_than_or_equal(u, c): + return _numeric_operator(u, c, lambda u, c: u <= c) + + +def _greater_than(u, c): + return _numeric_operator(u, c, lambda u, c: u > c) + + +def _greater_than_or_equal(u, c): + return _numeric_operator(u, c, lambda u, c: u >= c) + + +def _before(u, c): + return _time_operator(u, c, lambda u, c: u < c) + + +def _after(u, c): + return _time_operator(u, c, lambda u, c: u > c) + + +ops = { + "in": _in, + "endsWith": _ends_with, + "startsWith": _starts_with, + "matches": _matches, + "contains": _contains, + "lessThan": _less_than, + "lessThanOrEqual": _less_than_or_equal, + "greaterThan": _greater_than, + "greaterThanOrEqual": _greater_than_or_equal, + "before": _before, + "after": _after +} + +ops = defaultdict(lambda: False, ops) diff --git a/ldclient/util.py b/ldclient/util.py index d67a1f82..55b5862e 100644 --- a/ldclient/util.py +++ b/ldclient/util.py @@ -1,5 +1,5 @@ from __future__ import division, with_statement, absolute_import -import hashlib + import logging import sys @@ -43,57 +43,6 @@ def _stream_headers(api_key, client="PythonClient"): 'Accept': "text/event-stream"} -def _param_for_user(feature, user): - if 'key' in user and user['key']: - id_hash = user['key'] - else: - log.exception( - 'User does not have a valid key set. Returning default value for flag.') - return None - if 'secondary' in user: - id_hash += "." + user['secondary'] - hash_key = '%s.%s.%s' % (feature['key'], feature['salt'], id_hash) - hash_val = int(hashlib.sha1(hash_key.encode('utf-8')).hexdigest()[:15], 16) - result = hash_val / __LONG_SCALE__ - return result - - -def _match_target(target, user): - attr = target['attribute'] - if attr in __BUILTINS__: - if attr in user: - u_value = user[attr] - return u_value in target['values'] - else: - return False - else: # custom attribute - if 'custom' not in user: - return False - if attr not in user['custom']: - return False - u_value = user['custom'][attr] - if isinstance(u_value, __BASE_TYPES__): - return u_value in target['values'] - elif isinstance(u_value, (list, tuple)): - return len(set(u_value).intersection(target['values'])) > 0 - return False - - -def _match_user(variation, user): - if 'userTarget' in variation: - return _match_target(variation['userTarget'], user) - return False - - -def _match_variation(variation, user): - for target in variation['targets']: - if 'userTarget' in variation and target['attribute'] == 'key': - continue - if _match_target(target, user): - return True - return False - - def check_uwsgi(): if 'uwsgi' in sys.modules: # noinspection PyPackageRequirements,PyUnresolvedReferences @@ -104,31 +53,6 @@ def check_uwsgi(): 'To learn more, see http://docs.launchdarkly.com/v1.0/docs/python-sdk-reference#configuring-uwsgi') -def _evaluate(feature, user): - if feature is None: - return None - if not feature['on']: - return None - param = _param_for_user(feature, user) - if param is None: - return None - - for variation in feature['variations']: - if _match_user(variation, user): - return variation['value'] - - for variation in feature['variations']: - if _match_variation(variation, user): - return variation['value'] - - total = 0.0 - for variation in feature['variations']: - total += float(variation['weight']) / 100.0 - if param < total: - return variation['value'] - return None - - class Event(object): def __init__(self, data='', event='message', event_id=None, retry=None): diff --git a/ldclient/version.py b/ldclient/version.py index 3277f64c..21014090 100644 --- a/ldclient/version.py +++ b/ldclient/version.py @@ -1 +1 @@ -VERSION = "1.0.0" +VERSION = "2.0.0" diff --git a/requirements.txt b/requirements.txt index 4cdeaa9a..5d3cfbe1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ CacheControl>=0.10.2 requests>=2.10.0 sseclient>=0.0.12 -future>=0.15.2 \ No newline at end of file +future>=0.15.2 +strict-rfc3339>=0.7 \ No newline at end of file diff --git a/setup.py b/setup.py index 49b2f794..1094fa70 100644 --- a/setup.py +++ b/setup.py @@ -1,12 +1,11 @@ -import sys try: from setuptools import setup, Command except ImportError: from distutils.core import setup -from pip.req import parse_requirements import uuid +from pip.req import parse_requirements # parse_requirements() returns generator of pip.req.InstallRequirement objects install_reqs = parse_requirements('requirements.txt', session=uuid.uuid1()) @@ -40,7 +39,7 @@ def run(self): setup( name='ldclient-py', - version='1.0.0', + version='2.0.0', author='Catamorphic Co.', author_email='team@catamorphic.com', packages=['ldclient'], diff --git a/testing/test_integration.py b/testing/test_integration.py deleted file mode 100644 index 3dd7bbba..00000000 --- a/testing/test_integration.py +++ /dev/null @@ -1,75 +0,0 @@ -import logging -from ldclient.client import Config, LDClient -from ldclient.twisted_sse import Event -import pytest -from testing.server_util import SSEServer, GenericServer -from testing.sync_util import wait_until - -logging.basicConfig(level=logging.DEBUG) - - -@pytest.fixture() -def server(request): - server = GenericServer() - - def fin(): - server.shutdown() - - request.addfinalizer(fin) - return server - - -@pytest.fixture() -def stream(request): - server = SSEServer() - - def fin(): - server.shutdown() - - request.addfinalizer(fin) - return server - - -def test_toggle(server, stream): - stream.queue.put(Event(event="put", data=feature("foo", True))) - client = LDClient("apikey", Config(stream=True, base_uri=server.url, events_uri=server.url, stream_uri=stream.url)) - wait_until(lambda: client.toggle("foo", user('xyz'), False) is True) - -# Doesn't seem to handle disconnects? -# def test_sse_reconnect(server, stream): -# server.post_events() -# stream.queue.put(Event(event="put", data=feature("foo", "on"))) -# client = LDClient("apikey", TwistedConfig(stream=True, base_uri=server.url, stream_uri=stream.url)) -# wait_until(lambda: client.toggle("foo", user('xyz'), "blah") == "on") -# -# stream.stop() -# -# wait_until(lambda: client.toggle("foo", user('xyz'), "blah") == "on") -# -# stream.start() -# -# stream.queue.put(Event(event="put", data=feature("foo", "jim"))) -# client = LDClient("apikey", TwistedConfig(stream=True, base_uri=server.url, stream_uri=stream.url)) -# wait_until(lambda: client.toggle("foo", user('xyz'), "blah") == "jim") - - -def feature(key, val): - return { - key: {"name": "Feature {}".format(key), "key": key, "kind": "flag", "salt": "Zm9v", "on": val, - "variations": [{"value": val, "weight": 100, - "targets": [{"attribute": "key", "op": "in", "values": []}], - "userTarget": {"attribute": "key", "op": "in", "values": []}}, - {"value": False, "weight": 0, - "targets": [{"attribute": "key", "op": "in", "values": []}], - "userTarget": {"attribute": "key", "op": "in", "values": []}}], - "commitDate": "2015-09-08T21:24:16.712Z", - "creationDate": "2015-09-08T21:06:16.527Z", "version": 4}} - - -def user(name): - return { - u'key': name, - u'custom': { - u'bizzle': u'def' - } - } diff --git a/testing/test_integration_twisted.py b/testing/test_integration_twisted.py deleted file mode 100644 index 8f7d3a7f..00000000 --- a/testing/test_integration_twisted.py +++ /dev/null @@ -1,79 +0,0 @@ -import logging -from ldclient import LDClient, TwistedLDClient, TwistedConfig -from ldclient.twisted_sse import Event -import pytest -from testing.server_util import SSEServer, GenericServer -from testing.twisted_util import wait_until, is_equal - -logging.basicConfig(level=logging.DEBUG) - - -@pytest.fixture() -def server(request): - server = GenericServer() - - def fin(): - server.shutdown() - - request.addfinalizer(fin) - return server - - -@pytest.fixture() -def stream(request): - server = SSEServer() - - def fin(): - server.shutdown() - - request.addfinalizer(fin) - return server - -@pytest.inlineCallbacks -def test_sse_init(server, stream): - stream.queue.put(Event(event="put", data=feature("foo", "jim"))) - client = LDClient("apikey", TwistedConfig( - stream=True, base_uri=server.url, stream_uri=stream.url)) - yield wait_until(is_equal(lambda: client.toggle("foo", user('xyz'), "blah"), "jim")) - - -@pytest.inlineCallbacks -def test_sse_reconnect(server, stream): - server.post_events() - stream.queue.put(Event(event="put", data=feature("foo", "on"))) - client = LDClient("apikey", TwistedConfig( - stream=True, base_uri=server.url, stream_uri=stream.url)) - yield wait_until(is_equal(lambda: client.toggle("foo", user('xyz'), "blah"), "on")) - - stream.stop() - - yield wait_until(is_equal(lambda: client.toggle("foo", user('xyz'), "blah"), "on")) - - stream.start() - - stream.queue.put(Event(event="put", data=feature("foo", "jim"))) - client = LDClient("apikey", TwistedConfig( - stream=True, base_uri=server.url, stream_uri=stream.url)) - yield wait_until(is_equal(lambda: client.toggle("foo", user('xyz'), "blah"), "jim")) - - -def feature(key, val): - return { - key: {"name": "Feature {}".format(key), "key": key, "kind": "flag", "salt": "Zm9v", "on": val, - "variations": [{"value": val, "weight": 100, - "targets": [{"attribute": "key", "op": "in", "values": []}], - "userTarget": {"attribute": "key", "op": "in", "values": []}}, - {"value": False, "weight": 0, - "targets": [{"attribute": "key", "op": "in", "values": []}], - "userTarget": {"attribute": "key", "op": "in", "values": []}}], - "commitDate": "2015-09-08T21:24:16.712Z", - "creationDate": "2015-09-08T21:06:16.527Z", "version": 4}} - - -def user(name): - return { - u'key': name, - u'custom': { - u'bizzle': u'def' - } - } diff --git a/testing/test_ldclient.py b/testing/test_ldclient.py index c85abd63..363e949e 100644 --- a/testing/test_ldclient.py +++ b/testing/test_ldclient.py @@ -124,22 +124,8 @@ def wait_for_event(c, cb): return cb(e) -def test_toggle(): - assert client.toggle('feature.key', user, default=None) == True - - def test_toggle_offline(): - assert offline_client.toggle('feature.key', user, default=None) == None - - -def test_toggle_event(): - client.toggle('feature.key', user, default=None) - - def expected_event(e): - return e['kind'] == 'feature' and e['key'] == 'feature.key' and e['user'] == user and e['value'] == True \ - and e['default'] == None - - assert expected_event(client._queue.get(False)) + assert offline_client.toggle('feature.key', user, default=None) is None def test_sanitize_user(): @@ -147,16 +133,6 @@ def test_sanitize_user(): assert numeric_key_user == sanitized_numeric_key_user -def test_toggle_event_numeric_user_key(): - client.toggle('feature.key', numeric_key_user, default=None) - - def expected_event(e): - return e['kind'] == 'feature' and e['key'] == 'feature.key' and e['user'] == sanitized_numeric_key_user \ - and e['value'] == True and e['default'] == None - - assert expected_event(client._queue.get(False)) - - def test_toggle_event_offline(): offline_client.toggle('feature.key', user, default=None) assert offline_client._queue.empty() diff --git a/testing/test_operators.py b/testing/test_operators.py new file mode 100644 index 00000000..d7a201d6 --- /dev/null +++ b/testing/test_operators.py @@ -0,0 +1,13 @@ +from ldclient import operators + + +def test_date_operator(): + assert operators.ops.get("before")(0, 1) + assert operators.ops.get("before")(-100, 0) + assert operators.ops.get("before")("1970-01-01T00:00:00Z", 1000) + assert operators.ops.get("before")("1970-01-01T00:00:00.500Z", 1000) + assert not operators.ops.get("before")(True, 1000) + assert operators.ops.get("after")("1970-01-01T00:00:02.500Z", 1000) + assert not operators.ops.get("after")("1970-01-01 00:00:02.500Z", 1000) + + diff --git a/testing/test_util.py b/testing/test_util.py deleted file mode 100644 index e2e265ed..00000000 --- a/testing/test_util.py +++ /dev/null @@ -1,422 +0,0 @@ -from __future__ import division -import hashlib -from copy import copy -from math import floor -from ldclient import util - -minimal_feature = { - u'key': u'feature.key', - u'salt': u'abc', - u'on': True, - u'variations': [ - { - u'value': True, - u'weight': 100, - u'targets': [] - }, - { - u'value': False, - u'weight': 0, - u'targets': [] - } - ] -} - -user = { - u'key': u'xyz', - u'custom': { - u'bizzle': u'def' - } -} - - -def test_param_for_user_with_no_key(): - assert util._param_for_user(minimal_feature, {}) is None - - -def test_param_for_user_with_no_secondary(): - expected = int(hashlib.sha1('feature.key.abc.xyz'.encode( - 'utf-8')).hexdigest()[:15], 16) / float(0xFFFFFFFFFFFFFFF) - assert util._param_for_user(minimal_feature, {u'key': u'xyz'}) == expected - - -def test_match_target_key_mismatch(): - target = { - u'attribute': u'key', - u'op': u'in', - u'values': ['lmno'] - } - assert util._match_target(target, {'key': 'xyz'}) == False - - -def test_match_target_key_empty(): - target = { - u'attribute': u'key', - u'op': u'in', - u'values': [] - } - assert util._match_target(target, {'key': 'xyz'}) == False - - -def test_match_target_key_match(): - target = { - u'attribute': u'key', - u'op': u'in', - u'values': ['xyz'] - } - assert util._match_target(target, {'key': 'xyz'}) == True - - -def test_match_target_custom_match(): - target = { - u'attribute': u'bizzle', - u'op': u'in', - u'values': [u'def'] - } - assert util._match_target(target, user) == True - - -def test_match_target_custom_mismatch(): - target = { - u'attribute': u'bizzle', - u'op': u'in', - u'values': [u'ghi'] - } - assert util._match_target(target, user) == False - - -def test_match_target_custom_attribute_mismatch(): - target = { - u'attribute': u'bazzle', - u'op': u'in', - u'values': [u'def'] - } - assert util._match_target(target, user) == False - - -def test_match_variation_target_match(): - variation = { - u'userTarget': { - u'attribute': u'key', - u'op': u'in', - u'values': [] - }, - u'targets': [ - { - u'attribute': u'bazzle', - u'op': u'in', - u'values': [u'zyx'] - }, - { - u'attribute': u'bizzle', - u'op': u'in', - u'values': [u'def'] - } - ] - } - assert util._match_variation(variation, user) == True - - -def test_match_variation_target_mismatch(): - variation = { - u'userTarget': { - u'attribute': u'key', - u'op': u'in', - u'values': [] - }, - u'targets': [ - { - u'attribute': u'bazzle', - u'op': u'in', - u'values': [u'zyx'] - }, - { - u'attribute': u'bizzle', - u'op': u'in', - u'values': [u'abc'] - } - ] - } - assert util._match_variation(variation, user) == False - - -def test_evaluate_feature_off(): - feature = copy(minimal_feature) - feature['on'] = False - assert util._evaluate(feature, user) == None - - -def test_evaluate_first_variation_target_match(): - feature = copy(minimal_feature) - feature['variations'] = [ - { - u'value': True, - u'weight': 0, - u'userTarget': { - u'attribute': u'key', - u'op': u'in', - u'values': [] - }, - u'targets': [ - { - u'attribute': u'bazzle', - u'op': u'in', - u'values': [u'zyx'] - }, - { - u'attribute': u'bizzle', - u'op': u'in', - u'values': [u'def'] - } - ] - }, - { - u'value': False, - u'weight': 100, - u'userTarget': { - u'attribute': u'key', - u'op': u'in', - u'values': [] - }, - u'targets': [] - } - ] - assert util._evaluate(feature, user) == True - - -def test_evaluate_first_variation_both_targets_match(): - feature = copy(minimal_feature) - feature['variations'] = [ - { - u'value': True, - u'weight': 0, - u'userTarget': { - u'attribute': u'key', - u'op': u'in', - u'values': [] - }, - u'targets': [ - { - u'attribute': u'bazzle', - u'op': u'in', - u'values': [u'zyx'] - }, - { - u'attribute': u'bizzle', - u'op': u'in', - u'values': [u'def'] - } - ] - }, - { - u'value': False, - u'weight': 100, - u'userTarget': { - u'attribute': u'key', - u'op': u'in', - u'values': [] - }, - u'targets': [ - { - u'attribute': u'bazzle', - u'op': u'in', - u'values': [u'zyx'] - }, - { - u'attribute': u'bizzle', - u'op': u'in', - u'values': [u'def'] - } - ] - } - ] - assert util._evaluate(feature, user) == True - - -def test_evaluate_first_variation_both_targets_match_user_key_match_no_user_target(): - feature = copy(minimal_feature) - feature['variations'] = [ - { - u'value': True, - u'weight': 0, - u'targets': [ - { - u'attribute': u'key', - u'op': u'in', - u'values': ['xyz'] - }, - ] - }, - { - u'value': False, - u'weight': 100, - u'targets': [ - { - u'attribute': u'bazzle', - u'op': u'in', - u'values': [u'zyx'] - }, - { - u'attribute': u'bizzle', - u'op': u'in', - u'values': [u'def'] - } - ] - } - ] - assert util._evaluate(feature, user) == True - - -def test_evaluate_second_variation_user_match_both_targets_match(): - feature = copy(minimal_feature) - feature['variations'] = [ - { - u'value': True, - u'weight': 0, - u'userTarget': { - u'attribute': u'key', - u'op': u'in', - u'values': [] - }, - u'targets': [ - { - u'attribute': u'bazzle', - u'op': u'in', - u'values': [u'zyx'] - }, - { - u'attribute': u'bizzle', - u'op': u'in', - u'values': [u'def'] - } - ] - }, - { - u'value': False, - u'weight': 100, - u'userTarget': { - u'attribute': u'key', - u'op': u'in', - u'values': ['xyz'] - }, - u'targets': [ - { - u'attribute': u'bazzle', - u'op': u'in', - u'values': [u'zyx'] - }, - { - u'attribute': u'bizzle', - u'op': u'in', - u'values': [u'def'] - } - ] - } - ] - assert util._evaluate(feature, user) == False - - -def test_evaluate_second_variation_target_match(): - feature = copy(minimal_feature) - feature['variations'] = [ - { - u'value': True, - u'weight': 0, - u'targets': [ - { - u'attribute': u'bizzle', - u'op': u'in', - u'values': [u'defg'] - } - ] - }, - { - u'value': False, - u'weight': 100, - u'targets': [ - { - u'attribute': u'bazzle', - u'op': u'in', - u'values': [u'zyx'] - }, - { - u'attribute': u'bizzle', - u'op': u'in', - u'values': [u'def'] - } - ] - } - ] - assert util._evaluate(feature, user) == False - - -def test_evaluate_first_variation_no_target_match(): - feature = copy(minimal_feature) - hash_value = 100 * int(hashlib.sha1('feature.key.abc.xyz'.encode('utf-8')).hexdigest()[:15], 16) / float( - 0xFFFFFFFFFFFFFFF) - feature['variations'] = [ - { - u'value': True, - u'weight': floor(hash_value) + 1, - u'targets': [ - { - u'attribute': u'bizzle', - u'op': u'in', - u'values': [u'defg'] - } - ] - }, - { - u'value': False, - u'weight': 100 - (floor(hash_value) + 1), - u'targets': [ - { - u'attribute': u'bazzle', - u'op': u'in', - u'values': [u'zyx'] - }, - { - u'attribute': u'bizzle', - u'op': u'in', - u'values': [u'defg'] - } - ] - } - ] - assert util._evaluate(feature, user) == True - - -def test_evaluate_second_variation_no_target_match(): - feature = copy(minimal_feature) - hash_value = int(hashlib.sha1('feature.key.abc.xyz'.encode('utf-8')).hexdigest()[:15], 16) / float( - 0xFFFFFFFFFFFFFFF) - feature['variations'] = [ - { - u'value': True, - u'weight': floor(hash_value) - 1, - u'targets': [ - { - u'attribute': u'bizzle', - u'op': u'in', - u'values': [u'defg'] - } - ] - }, - { - u'value': False, - u'weight': 100 - (floor(hash_value) - 1), - u'targets': [ - { - u'attribute': u'bazzle', - u'op': u'in', - u'values': [u'zyx'] - }, - { - u'attribute': u'bizzle', - u'op': u'in', - u'values': [u'defg'] - } - ] - } - ] - assert util._evaluate(feature, user) == False