Skip to content

Commit

Permalink
Merge branch 'master' into test-data
Browse files Browse the repository at this point in the history
  • Loading branch information
eli-darkly committed Feb 14, 2022
2 parents de66bc7 + 26f7ec9 commit a8b1404
Show file tree
Hide file tree
Showing 20 changed files with 410 additions and 78 deletions.
13 changes: 11 additions & 2 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ jobs:
export PATH="/home/circleci/.local/bin:$PATH"
mypy --install-types --non-interactive ldclient testing
mypy --config-file mypy.ini ldclient testing
- unless:
condition: <<parameters.skip-sse-contract-tests>>
steps:
Expand All @@ -109,12 +109,21 @@ jobs:
- run:
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

- store_test_results:
path: test-reports
- store_artifacts:
path: test-reports


test-windows:
executor:
name: win/vs2019
Expand Down
23 changes: 23 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
TEMP_TEST_OUTPUT=/tmp/contract-test-service.log

# port 8000 and 9000 is already used in the CI environment because we're
# running a DynamoDB container and an SSE contract test
PORT=10000

build-contract-tests:
@cd contract-tests && pip install -r requirements.txt

start-contract-test-service:
@cd contract-tests && python service.py $(PORT)

start-contract-test-service-bg:
@echo "Test service output will be captured in $(TEMP_TEST_OUTPUT)"
@make start-contract-test-service >$(TEMP_TEST_OUTPUT) 2>&1 &

run-contract-tests:
@curl -s https://raw.githubusercontent.com/launchdarkly/sdk-test-harness/v1.0.0/downloader/run.sh \
| VERSION=v1 PARAMS="-url http://localhost:$(PORT) -debug -stop-service-at-end $(TEST_HARNESS_PARAMS)" sh

contract-tests: build-contract-tests start-contract-test-service-bg run-contract-tests

.PHONY: build-contract-tests start-contract-test-service run-contract-tests contract-tests
7 changes: 7 additions & 0 deletions contract-tests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# SDK contract test service

This directory contains an implementation of the cross-platform SDK testing protocol defined by https://github.com/launchdarkly/sdk-test-harness. See that project's `README` for details of this protocol, and the kinds of SDK capabilities that are relevant to the contract tests. This code should not need to be updated unless the SDK has added or removed such capabilities.

To run these tests locally, run `make contract-tests` from the SDK project root directory. This downloads the correct version of the test harness tool automatically.

Or, to test against an in-progress local version of the test harness, run `make start-contract-test-service` from the SDK project root directory; then, in the root directory of the `sdk-test-harness` project, build the test harness and run it from the command line.
86 changes: 86 additions & 0 deletions contract-tests/client_entity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import logging
import os
import sys

# Import ldclient from parent directory
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:
streaming = config["streaming"]
if "baseUri" in streaming:
opts["stream_uri"] = streaming["baseUri"]
if streaming.get("initialRetryDelayMs") is not None:
opts["initial_reconnect_delay"] = streaming["initialRetryDelayMs"] / 1000.0

if "events" in config:
events = config["events"]
if "baseUri" in events:
opts["events_uri"] = events["baseUri"]
if events.get("capacity", None) 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:
opts["flush_interval"] = events["flushIntervalMs"] / 1000.0
if "inlineUsers" in events:
opts["inline_users_in_events"] = events["inlineUsers"]
else:
opts["send_events"] = False

start_wait = config.get("startWaitTimeMs", 5000)
config = Config(**opts)

self.client = client.LDClient(config, start_wait / 1000.0)

def is_initializing(self) -> bool:
return self.client.is_initialized()

def evaluate(self, params) -> dict:
response = {}

if params.get("detail", False):
detail = self.client.variation_detail(params["flagKey"], params["user"], params["defaultValue"])
response["value"] = detail.value
response["variationIndex"] = detail.variation_index
response["reason"] = detail.reason
else:
response["value"] = self.client.variation(params["flagKey"], params["user"], params["defaultValue"])

return response

def evaluate_all(self, params):
opts = {}
opts["client_side_only"] = params.get("clientSideOnly", False)
opts["with_reasons"] = params.get("withReasons", False)
opts["details_only_for_tracked_flags"] = params.get("detailsOnlyForTrackedFlags", False)

state = self.client.all_flags_state(params["user"], **opts)

return {"state": state.to_json_dict()}

def track(self, params):
self.client.track(params["eventKey"], params["user"], params["data"], params.get("metricValue", None))

def identify(self, params):
self.client.identify(params["user"])

def alias(self, params):
self.client.alias(params["user"], params["previousUser"])

def flush(self):
self.client.flush()

def close(self):
self.client.close()
self.log.info('Test ended')
2 changes: 2 additions & 0 deletions contract-tests/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Flask==1.1.4
urllib3>=1.22.0
142 changes: 142 additions & 0 deletions contract-tests/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
from client_entity import ClientEntity

import json
import logging
import os
import sys
from flask import Flask, request, jsonify
from flask.logging import default_handler
from logging.config import dictConfig
from werkzeug.exceptions import HTTPException


default_port = 8000

# logging configuration
dictConfig({
'version': 1,
'formatters': {
'default': {
'format': '[%(asctime)s] [%(name)s] %(levelname)s: %(message)s',
}
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'default'
}
},
'root': {
'level': 'INFO',
'handlers': ['console']
},
'ldclient.util': {
'level': 'INFO',
'handlers': ['console']
},
'loggers': {
'werkzeug': { 'level': 'ERROR' } # disable irrelevant Flask app logging
}
})

app = Flask(__name__)
app.logger.removeHandler(default_handler)

client_counter = 0
clients = {}
global_log = logging.getLogger('testservice')


@app.errorhandler(Exception)
def handle_exception(e):
# pass through HTTP errors
if isinstance(e, HTTPException):
return e

return str(e), 500

@app.route('/', methods=['GET'])
def status():
body = {
'capabilities': [
'server-side',
'all-flags-with-reasons',
'all-flags-client-side-only',
'all-flags-details-only-for-tracked-flags',
]
}
return (json.dumps(body), 200, {'Content-type': 'application/json'})

@app.route('/', methods=['DELETE'])
def delete_stop_service():
global_log.info("Test service has told us to exit")
os._exit(0)

@app.route('/', methods=['POST'])
def post_create_client():
global client_counter, clients

options = request.get_json()

client_counter += 1
client_id = str(client_counter)
resource_url = '/clients/%s' % client_id

client = ClientEntity(options['tag'], options['configuration'])

if client.is_initializing() is False and options['configuration'].get('initCanFail', False) is False:
client.close()
return ("Failed to initialize", 500)

clients[client_id] = client
return ('', 201, {'Location': resource_url})


@app.route('/clients/<id>', methods=['POST'])
def post_client_command(id):
global clients

params = request.get_json()

client = clients[id]
if client is None:
return ('', 404)

if params.get('command') == "evaluate":
response = client.evaluate(params.get("evaluate"))
return (json.dumps(response), 200)
elif params.get("command") == "evaluateAll":
response = client.evaluate_all(params.get("evaluateAll"))
return (json.dumps(response), 200)
elif params.get("command") == "customEvent":
client.track(params.get("customEvent"))
return ('', 201)
elif params.get("command") == "identifyEvent":
client.identify(params.get("identifyEvent"))
return ('', 201)
elif params.get("command") == "aliasEvent":
client.alias(params.get("aliasEvent"))
return ('', 201)
elif params.get('command') == "flushEvents":
client.flush()
return ('', 201)

return ('', 400)

@app.route('/clients/<id>', methods=['DELETE'])
def delete_client(id):
global clients

client = clients[id]
if client is None:
return ('', 404)

client.close()
return ('', 204)

if __name__ == "__main__":
port = default_port
if sys.argv[len(sys.argv) - 1] != 'service.py':
port = int(sys.argv[len(sys.argv) - 1])
global_log.info('Listening on port %d', port)
app.run(host='0.0.0.0', port=port)
20 changes: 16 additions & 4 deletions ldclient/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ def identify(self, user: dict):
:param user: attributes of the user to register
"""
if user is None or user.get('key') is None:
if user is None or user.get('key') is None or len(str(user.get('key'))) == 0:
log.warning("Missing user or user key when calling identify().")
else:
self._send_event(self._event_factory_default.new_identify_event(user))
Expand Down Expand Up @@ -395,13 +395,25 @@ def all_flags_state(self, user: dict, **kwargs) -> FeatureFlagsState:
continue
try:
detail = self._evaluator.evaluate(flag, user, self._event_factory_default).detail
state.add_flag(flag, detail.value, detail.variation_index,
detail.reason if with_reasons else None, details_only_if_tracked)
except Exception as e:
log.error("Error evaluating flag \"%s\" in all_flags_state: %s" % (key, repr(e)))
log.debug(traceback.format_exc())
reason = {'kind': 'ERROR', 'errorKind': 'EXCEPTION'}
state.add_flag(flag, None, None, reason if with_reasons else None, details_only_if_tracked)
detail = EvaluationDetail(None, None, reason)

requires_experiment_data = _EventFactory.is_experiment(flag, detail.reason)
flag_state = {
'key': flag['key'],
'value': detail.value,
'variation': detail.variation_index,
'reason': detail.reason,
'version': flag['version'],
'trackEvents': flag['trackEvents'] or requires_experiment_data,
'trackReason': requires_experiment_data,
'debugEventsUntilDate': flag.get('debugEventsUntilDate', None),
}

state.add_flag(flag_state, with_reasons, details_only_if_tracked)

return state

Expand Down
6 changes: 3 additions & 3 deletions ldclient/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,9 +240,9 @@ def __init__(self,
"""
self.__sdk_key = sdk_key

self.__base_uri = base_uri.rstrip('\\')
self.__events_uri = events_uri.rstrip('\\')
self.__stream_uri = stream_uri.rstrip('\\')
self.__base_uri = base_uri.rstrip('/')
self.__events_uri = events_uri.rstrip('/')
self.__stream_uri = stream_uri.rstrip('/')
self.__update_processor_class = update_processor_class
self.__stream = stream
self.__initial_reconnect_delay = initial_reconnect_delay
Expand Down
Loading

0 comments on commit a8b1404

Please sign in to comment.