Skip to content

Commit 6176ab9

Browse files
prepare 7.3.1 release (#163)
1 parent 2112623 commit 6176ab9

19 files changed

+409
-77
lines changed

.circleci/config.yml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ jobs:
9595
export PATH="/home/circleci/.local/bin:$PATH"
9696
mypy --install-types --non-interactive ldclient testing
9797
mypy --config-file mypy.ini ldclient testing
98-
98+
9999
- unless:
100100
condition: <<parameters.skip-sse-contract-tests>>
101101
steps:
@@ -109,12 +109,21 @@ jobs:
109109
- run:
110110
name: run SSE contract tests
111111
command: cd sse-contract-tests && make run-contract-tests
112-
112+
113+
- run: make build-contract-tests
114+
- run:
115+
command: make start-contract-test-service
116+
background: true
117+
- run:
118+
name: run contract tests
119+
command: TEST_HARNESS_PARAMS="-junit test-reports/contract-tests-junit.xml" make run-contract-tests
120+
113121
- store_test_results:
114122
path: test-reports
115123
- store_artifacts:
116124
path: test-reports
117125

126+
118127
test-windows:
119128
executor:
120129
name: win/vs2019

Makefile

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
TEMP_TEST_OUTPUT=/tmp/contract-test-service.log
2+
3+
# port 8000 and 9000 is already used in the CI environment because we're
4+
# running a DynamoDB container and an SSE contract test
5+
PORT=10000
6+
7+
build-contract-tests:
8+
@cd contract-tests && pip install -r requirements.txt
9+
10+
start-contract-test-service:
11+
@cd contract-tests && python service.py $(PORT)
12+
13+
start-contract-test-service-bg:
14+
@echo "Test service output will be captured in $(TEMP_TEST_OUTPUT)"
15+
@make start-contract-test-service >$(TEMP_TEST_OUTPUT) 2>&1 &
16+
17+
run-contract-tests:
18+
@curl -s https://raw.githubusercontent.com/launchdarkly/sdk-test-harness/v1.0.0/downloader/run.sh \
19+
| VERSION=v1 PARAMS="-url http://localhost:$(PORT) -debug -stop-service-at-end $(TEST_HARNESS_PARAMS)" sh
20+
21+
contract-tests: build-contract-tests start-contract-test-service-bg run-contract-tests
22+
23+
.PHONY: build-contract-tests start-contract-test-service run-contract-tests contract-tests

contract-tests/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# SDK contract test service
2+
3+
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.
4+
5+
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.
6+
7+
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.

contract-tests/client_entity.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import logging
2+
import os
3+
import sys
4+
5+
# Import ldclient from parent directory
6+
sys.path.insert(1, os.path.join(sys.path[0], '..'))
7+
from ldclient import *
8+
9+
def millis_to_seconds(t):
10+
return None if t is None else t / 1000
11+
12+
13+
class ClientEntity:
14+
def __init__(self, tag, config):
15+
self.log = logging.getLogger(tag)
16+
opts = {"sdk_key": config["credential"]}
17+
18+
if "streaming" in config:
19+
streaming = config["streaming"]
20+
if "baseUri" in streaming:
21+
opts["stream_uri"] = streaming["baseUri"]
22+
if streaming.get("initialRetryDelayMs") is not None:
23+
opts["initial_reconnect_delay"] = streaming["initialRetryDelayMs"] / 1000.0
24+
25+
if "events" in config:
26+
events = config["events"]
27+
if "baseUri" in events:
28+
opts["events_uri"] = events["baseUri"]
29+
if events.get("capacity", None) is not None:
30+
opts["events_max_pending"] = events["capacity"]
31+
opts["diagnostic_opt_out"] = not events.get("enableDiagnostics", False)
32+
opts["all_attributes_private"] = events.get("allAttributesPrivate", False)
33+
opts["private_attribute_names"] = events.get("globalPrivateAttributes", {})
34+
if "flushIntervalMs" in events:
35+
opts["flush_interval"] = events["flushIntervalMs"] / 1000.0
36+
if "inlineUsers" in events:
37+
opts["inline_users_in_events"] = events["inlineUsers"]
38+
else:
39+
opts["send_events"] = False
40+
41+
start_wait = config.get("startWaitTimeMs", 5000)
42+
config = Config(**opts)
43+
44+
self.client = client.LDClient(config, start_wait / 1000.0)
45+
46+
def is_initializing(self) -> bool:
47+
return self.client.is_initialized()
48+
49+
def evaluate(self, params) -> dict:
50+
response = {}
51+
52+
if params.get("detail", False):
53+
detail = self.client.variation_detail(params["flagKey"], params["user"], params["defaultValue"])
54+
response["value"] = detail.value
55+
response["variationIndex"] = detail.variation_index
56+
response["reason"] = detail.reason
57+
else:
58+
response["value"] = self.client.variation(params["flagKey"], params["user"], params["defaultValue"])
59+
60+
return response
61+
62+
def evaluate_all(self, params):
63+
opts = {}
64+
opts["client_side_only"] = params.get("clientSideOnly", False)
65+
opts["with_reasons"] = params.get("withReasons", False)
66+
opts["details_only_for_tracked_flags"] = params.get("detailsOnlyForTrackedFlags", False)
67+
68+
state = self.client.all_flags_state(params["user"], **opts)
69+
70+
return {"state": state.to_json_dict()}
71+
72+
def track(self, params):
73+
self.client.track(params["eventKey"], params["user"], params["data"], params.get("metricValue", None))
74+
75+
def identify(self, params):
76+
self.client.identify(params["user"])
77+
78+
def alias(self, params):
79+
self.client.alias(params["user"], params["previousUser"])
80+
81+
def flush(self):
82+
self.client.flush()
83+
84+
def close(self):
85+
self.client.close()
86+
self.log.info('Test ended')

contract-tests/requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Flask==1.1.4
2+
urllib3>=1.22.0

contract-tests/service.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
from client_entity import ClientEntity
2+
3+
import json
4+
import logging
5+
import os
6+
import sys
7+
from flask import Flask, request, jsonify
8+
from flask.logging import default_handler
9+
from logging.config import dictConfig
10+
from werkzeug.exceptions import HTTPException
11+
12+
13+
default_port = 8000
14+
15+
# logging configuration
16+
dictConfig({
17+
'version': 1,
18+
'formatters': {
19+
'default': {
20+
'format': '[%(asctime)s] [%(name)s] %(levelname)s: %(message)s',
21+
}
22+
},
23+
'handlers': {
24+
'console': {
25+
'class': 'logging.StreamHandler',
26+
'formatter': 'default'
27+
}
28+
},
29+
'root': {
30+
'level': 'INFO',
31+
'handlers': ['console']
32+
},
33+
'ldclient.util': {
34+
'level': 'INFO',
35+
'handlers': ['console']
36+
},
37+
'loggers': {
38+
'werkzeug': { 'level': 'ERROR' } # disable irrelevant Flask app logging
39+
}
40+
})
41+
42+
app = Flask(__name__)
43+
app.logger.removeHandler(default_handler)
44+
45+
client_counter = 0
46+
clients = {}
47+
global_log = logging.getLogger('testservice')
48+
49+
50+
@app.errorhandler(Exception)
51+
def handle_exception(e):
52+
# pass through HTTP errors
53+
if isinstance(e, HTTPException):
54+
return e
55+
56+
return str(e), 500
57+
58+
@app.route('/', methods=['GET'])
59+
def status():
60+
body = {
61+
'capabilities': [
62+
'server-side',
63+
'all-flags-with-reasons',
64+
'all-flags-client-side-only',
65+
'all-flags-details-only-for-tracked-flags',
66+
]
67+
}
68+
return (json.dumps(body), 200, {'Content-type': 'application/json'})
69+
70+
@app.route('/', methods=['DELETE'])
71+
def delete_stop_service():
72+
global_log.info("Test service has told us to exit")
73+
os._exit(0)
74+
75+
@app.route('/', methods=['POST'])
76+
def post_create_client():
77+
global client_counter, clients
78+
79+
options = request.get_json()
80+
81+
client_counter += 1
82+
client_id = str(client_counter)
83+
resource_url = '/clients/%s' % client_id
84+
85+
client = ClientEntity(options['tag'], options['configuration'])
86+
87+
if client.is_initializing() is False and options['configuration'].get('initCanFail', False) is False:
88+
client.close()
89+
return ("Failed to initialize", 500)
90+
91+
clients[client_id] = client
92+
return ('', 201, {'Location': resource_url})
93+
94+
95+
@app.route('/clients/<id>', methods=['POST'])
96+
def post_client_command(id):
97+
global clients
98+
99+
params = request.get_json()
100+
101+
client = clients[id]
102+
if client is None:
103+
return ('', 404)
104+
105+
if params.get('command') == "evaluate":
106+
response = client.evaluate(params.get("evaluate"))
107+
return (json.dumps(response), 200)
108+
elif params.get("command") == "evaluateAll":
109+
response = client.evaluate_all(params.get("evaluateAll"))
110+
return (json.dumps(response), 200)
111+
elif params.get("command") == "customEvent":
112+
client.track(params.get("customEvent"))
113+
return ('', 201)
114+
elif params.get("command") == "identifyEvent":
115+
client.identify(params.get("identifyEvent"))
116+
return ('', 201)
117+
elif params.get("command") == "aliasEvent":
118+
client.alias(params.get("aliasEvent"))
119+
return ('', 201)
120+
elif params.get('command') == "flushEvents":
121+
client.flush()
122+
return ('', 201)
123+
124+
return ('', 400)
125+
126+
@app.route('/clients/<id>', methods=['DELETE'])
127+
def delete_client(id):
128+
global clients
129+
130+
client = clients[id]
131+
if client is None:
132+
return ('', 404)
133+
134+
client.close()
135+
return ('', 204)
136+
137+
if __name__ == "__main__":
138+
port = default_port
139+
if sys.argv[len(sys.argv) - 1] != 'service.py':
140+
port = int(sys.argv[len(sys.argv) - 1])
141+
global_log.info('Listening on port %d', port)
142+
app.run(host='0.0.0.0', port=port)

ldclient/client.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ def identify(self, user: dict):
226226
227227
:param user: attributes of the user to register
228228
"""
229-
if user is None or user.get('key') is None:
229+
if user is None or user.get('key') is None or len(str(user.get('key'))) == 0:
230230
log.warning("Missing user or user key when calling identify().")
231231
else:
232232
self._send_event(self._event_factory_default.new_identify_event(user))
@@ -395,13 +395,25 @@ def all_flags_state(self, user: dict, **kwargs) -> FeatureFlagsState:
395395
continue
396396
try:
397397
detail = self._evaluator.evaluate(flag, user, self._event_factory_default).detail
398-
state.add_flag(flag, detail.value, detail.variation_index,
399-
detail.reason if with_reasons else None, details_only_if_tracked)
400398
except Exception as e:
401399
log.error("Error evaluating flag \"%s\" in all_flags_state: %s" % (key, repr(e)))
402400
log.debug(traceback.format_exc())
403401
reason = {'kind': 'ERROR', 'errorKind': 'EXCEPTION'}
404-
state.add_flag(flag, None, None, reason if with_reasons else None, details_only_if_tracked)
402+
detail = EvaluationDetail(None, None, reason)
403+
404+
requires_experiment_data = _EventFactory.is_experiment(flag, detail.reason)
405+
flag_state = {
406+
'key': flag['key'],
407+
'value': detail.value,
408+
'variation': detail.variation_index,
409+
'reason': detail.reason,
410+
'version': flag['version'],
411+
'trackEvents': flag['trackEvents'] or requires_experiment_data,
412+
'trackReason': requires_experiment_data,
413+
'debugEventsUntilDate': flag.get('debugEventsUntilDate', None),
414+
}
415+
416+
state.add_flag(flag_state, with_reasons, details_only_if_tracked)
405417

406418
return state
407419

ldclient/config.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -240,9 +240,9 @@ def __init__(self,
240240
"""
241241
self.__sdk_key = sdk_key
242242

243-
self.__base_uri = base_uri.rstrip('\\')
244-
self.__events_uri = events_uri.rstrip('\\')
245-
self.__stream_uri = stream_uri.rstrip('\\')
243+
self.__base_uri = base_uri.rstrip('/')
244+
self.__events_uri = events_uri.rstrip('/')
245+
self.__stream_uri = stream_uri.rstrip('/')
246246
self.__update_processor_class = update_processor_class
247247
self.__stream = stream
248248
self.__initial_reconnect_delay = initial_reconnect_delay

0 commit comments

Comments
 (0)