diff --git a/.circleci/config.yml b/.circleci/config.yml index e3d5b29c..007b5fb2 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -10,6 +10,7 @@ workflows: name: Python 3.5 docker-image: cimg/python:3.5 skip-sse-contract-tests: true # the test service app has dependencies that aren't available in 3.5, which is EOL anyway + skip-contract-tests: true # the test service app has dependencies that aren't available in 3.5, which is EOL anyway - test-linux: name: Python 3.6 docker-image: cimg/python:3.6 @@ -46,6 +47,9 @@ jobs: skip-sse-contract-tests: type: boolean default: false + skip-contract-tests: + type: boolean + default: false docker: - image: <> - image: redis @@ -109,13 +113,16 @@ jobs: name: run SSE contract tests command: cd sse-contract-tests && make run-contract-tests - - run: make build-contract-tests - - run: - command: make start-contract-test-service - background: true - - run: - name: run contract tests - command: TEST_HARNESS_PARAMS="-junit test-reports/contract-tests-junit.xml" make run-contract-tests + - unless: + condition: <> + steps: + - run: make build-contract-tests + - run: + command: make start-contract-test-service + background: true + - run: + name: run contract tests + command: TEST_HARNESS_PARAMS="-junit test-reports/contract-tests-junit.xml" make run-contract-tests - store_test_results: path: test-reports diff --git a/contract-tests/client_entity.py b/contract-tests/client_entity.py index f3bf22fc..5d2d5220 100644 --- a/contract-tests/client_entity.py +++ b/contract-tests/client_entity.py @@ -6,39 +6,36 @@ sys.path.insert(1, os.path.join(sys.path[0], '..')) from ldclient import * -def millis_to_seconds(t): - return None if t is None else t / 1000 - class ClientEntity: def __init__(self, tag, config): self.log = logging.getLogger(tag) opts = {"sdk_key": config["credential"]} - if "streaming" in config: + if config.get("streaming") is not None: streaming = config["streaming"] - if "baseUri" in streaming: + if streaming.get("baseUri") is not None: opts["stream_uri"] = streaming["baseUri"] if streaming.get("initialRetryDelayMs") is not None: opts["initial_reconnect_delay"] = streaming["initialRetryDelayMs"] / 1000.0 - if "events" in config: + if config.get("events") is not None: events = config["events"] - if "baseUri" in events: + if events.get("baseUri") is not None: opts["events_uri"] = events["baseUri"] - if events.get("capacity", None) is not None: + if events.get("capacity") is not None: opts["events_max_pending"] = events["capacity"] opts["diagnostic_opt_out"] = not events.get("enableDiagnostics", False) opts["all_attributes_private"] = events.get("allAttributesPrivate", False) opts["private_attribute_names"] = events.get("globalPrivateAttributes", {}) - if "flushIntervalMs" in events: + if events.get("flushIntervalMs") is not None: opts["flush_interval"] = events["flushIntervalMs"] / 1000.0 - if "inlineUsers" in events: + if events.get("inlineUsers") is not None: opts["inline_users_in_events"] = events["inlineUsers"] else: opts["send_events"] = False - start_wait = config.get("startWaitTimeMs", 5000) + start_wait = config.get("startWaitTimeMs") or 5000 config = Config(**opts) self.client = client.LDClient(config, start_wait / 1000.0) diff --git a/contract-tests/requirements.txt b/contract-tests/requirements.txt index f55a4204..0018e4c8 100644 --- a/contract-tests/requirements.txt +++ b/contract-tests/requirements.txt @@ -1,2 +1,2 @@ -Flask==1.1.4 +Flask==2.0.3 urllib3>=1.22.0 diff --git a/contract-tests/service.py b/contract-tests/service.py index b4728867..d9f8e0a5 100644 --- a/contract-tests/service.py +++ b/contract-tests/service.py @@ -4,7 +4,7 @@ import logging import os import sys -from flask import Flask, request, jsonify +from flask import Flask, request from flask.logging import default_handler from logging.config import dictConfig from werkzeug.exceptions import HTTPException @@ -132,7 +132,7 @@ def delete_client(id): return ('', 404) client.close() - return ('', 204) + return ('', 202) if __name__ == "__main__": port = default_port diff --git a/ldclient/impl/evaluator.py b/ldclient/impl/evaluator.py index d019f10d..0fa9f088 100644 --- a/ldclient/impl/evaluator.py +++ b/ldclient/impl/evaluator.py @@ -243,7 +243,12 @@ def _bucket_user(seed, user, key, salt, bucket_by): return result def _bucketable_string_value(u_value): - return str(u_value) if isinstance(u_value, (str, int)) else None + if isinstance(u_value, bool): + return None + elif isinstance(u_value, (str, int)): + return str(u_value) + + return None def _clause_matches_user_no_segments(clause, user): u_value, should_pass = _get_user_attribute(user, clause.get('attribute')) diff --git a/ldclient/impl/integrations/redis/redis_big_segment_store.py b/ldclient/impl/integrations/redis/redis_big_segment_store.py index 35b42b71..d3b4b767 100644 --- a/ldclient/impl/integrations/redis/redis_big_segment_store.py +++ b/ldclient/impl/integrations/redis/redis_big_segment_store.py @@ -26,7 +26,10 @@ def __init__(self, url: str, prefix: Optional[str], max_connections: int): def get_metadata(self) -> BigSegmentStoreMetadata: r = redis.Redis(connection_pool=self._pool) value = r.get(self._prefix + self.KEY_LAST_UP_TO_DATE) - return BigSegmentStoreMetadata(None if value is None else int(value)) + if value is None: + return BigSegmentStoreMetadata(None) + + return BigSegmentStoreMetadata(int(value)) def get_membership(self, user_hash: str) -> Optional[dict]: r = redis.Redis(connection_pool=self._pool) diff --git a/ldclient/impl/integrations/test_data/test_data_source.py b/ldclient/impl/integrations/test_data/test_data_source.py index db3ac729..e6272925 100644 --- a/ldclient/impl/integrations/test_data/test_data_source.py +++ b/ldclient/impl/integrations/test_data/test_data_source.py @@ -5,11 +5,13 @@ class _TestDataSource(): - def __init__(self, feature_store, test_data): + def __init__(self, feature_store, test_data, ready): self._feature_store = feature_store self._test_data = test_data + self._ready = ready def start(self): + self._ready.set() self._feature_store.init(self._test_data._make_init_data()) def stop(self): diff --git a/ldclient/integrations/test_data.py b/ldclient/integrations/test_data.py index a159eb12..0030cde6 100644 --- a/ldclient/integrations/test_data.py +++ b/ldclient/integrations/test_data.py @@ -51,7 +51,7 @@ def __init__(self): self._instances = [] def __call__(self, config, store, ready): - data_source = _TestDataSource(store, self) + data_source = _TestDataSource(store, self, ready) try: self._lock.lock() self._instances.append(data_source) @@ -485,7 +485,7 @@ def and_match(self, attribute: str, *values) -> 'FlagRuleBuilder': """ self._clauses.append({ 'attribute': attribute, - 'operator': 'in', + 'op': 'in', 'values': list(values), 'negate': False }) @@ -508,7 +508,7 @@ def and_not_match(self, attribute: str, *values) -> 'FlagRuleBuilder': """ self._clauses.append({ 'attribute': attribute, - 'operator': 'in', + 'op': 'in', 'values': list(values), 'negate': True }) diff --git a/sse-contract-tests/requirements.txt b/sse-contract-tests/requirements.txt index 2d1d2a7b..0018e4c8 100644 --- a/sse-contract-tests/requirements.txt +++ b/sse-contract-tests/requirements.txt @@ -1,2 +1,2 @@ -Flask==2.0.2 +Flask==2.0.3 urllib3>=1.22.0 diff --git a/sse-contract-tests/service.py b/sse-contract-tests/service.py index 6d07fc59..389b1a1f 100644 --- a/sse-contract-tests/service.py +++ b/sse-contract-tests/service.py @@ -81,7 +81,7 @@ def delete_stream(id): if stream is None: return ('', 404) stream.close() - return ('', 204) + return ('', 202) if __name__ == "__main__": port = default_port diff --git a/testing/integrations/test_test_data_source.py b/testing/integrations/test_test_data_source.py index e0db1208..47f0d025 100644 --- a/testing/integrations/test_test_data_source.py +++ b/testing/integrations/test_test_data_source.py @@ -285,7 +285,7 @@ def test_flagbuilder_can_build(): 'clauses': [ {'attribute': 'country', 'negate': False, - 'operator': 'in', + 'op': 'in', 'values': ['fr'] } ], @@ -297,3 +297,35 @@ def test_flagbuilder_can_build(): } assert flag._build(1) == expected_result + +def test_flag_can_evaluate_rules(): + td = TestData.data_source() + store = InMemoryFeatureStore() + + client = LDClient(config=Config('SDK_KEY', + update_processor_class = td, + send_events = False, + feature_store = store)) + + td.update(td.flag(key='test-flag') + .fallthrough_variation(False) + .if_match('firstName', 'Mike') + .and_not_match('country', 'gb') + .then_return(True)) + + # user1 should satisfy the rule (matching firstname, not matching country) + user1 = { 'key': 'user1', 'firstName': 'Mike', 'country': 'us' } + eval1 = client.variation_detail('test-flag', user1, default='default') + + assert eval1.value == True + assert eval1.variation_index == 0 + assert eval1.reason['kind'] == 'RULE_MATCH' + + # user2 should NOT satisfy the rule (not matching firstname despite not matching country) + user2 = { 'key': 'user2', 'firstName': 'Joe', 'country': 'us' } + eval2 = client.variation_detail('test-flag', user2, default='default') + + assert eval2.value == False + assert eval2.variation_index == 1 + assert eval2.reason['kind'] == 'FALLTHROUGH' +