Skip to content

Commit 628de9a

Browse files
committed
Merge pull request #19 from hipchat/master
Add twisted support #18
2 parents 254f9fd + 24e117c commit 628de9a

14 files changed

Lines changed: 831 additions & 29 deletions

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
venv
2+
p2venv
23
*.pyc
34
dist
45
ldclient_py.egg-info
56
build/
6-
test.py
7+
.idea
8+
*.iml
9+
test.py

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,11 @@ Development information (for developing this module itself)
3232

3333
pip install -r requirements.txt
3434
pip install -r test-requirements.txt
35+
pip install -r twisted-requirements.txt
3536

3637
2. Run tests:
3738

38-
$ py.test
39+
$ py.test testing
3940

4041

4142
Learn more

circle.yml

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
dependencies:
22
pre:
3-
- pyenv shell 2.6.8; $(pyenv which pip) install --upgrade pip
3+
- pyenv shell 2.7.10; $(pyenv which pip) install --upgrade pip
44
- pyenv shell 3.3.3; $(pyenv which pip) install --upgrade pip
5-
- pyenv shell 2.6.8; $(pyenv which pip) install -r test-requirements.txt
5+
- pyenv shell 2.7.10; $(pyenv which pip) install -r test-requirements.txt
66
- pyenv shell 3.3.3; $(pyenv which pip) install -r test-requirements.txt
7-
- pyenv shell 2.6.8; $(pyenv which python) setup.py install
7+
- pyenv shell 2.7.10; $(pyenv which pip) install -r twisted-requirements.txt
8+
- pyenv shell 3.3.3; $(pyenv which pip) install -r twisted-requirements.txt
9+
- pyenv shell 2.7.10; $(pyenv which python) setup.py install
810
- pyenv shell 3.3.3; $(pyenv which python) setup.py install
911

1012
test:
1113
override:
12-
- pyenv shell 2.6.8; $(pyenv which py.test) testing
13-
- pyenv shell 3.3.3; $(pyenv which py.test) testing
14+
- pyenv shell 2.7.10; $(pyenv which py.test) testing
15+
- pyenv shell 3.3.3; $(pyenv which py.test) --ignore=testing/test_sse_twisted.py -s testing

demo/demo_twisted.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from __future__ import print_function
2+
from ldclient.twisted import TwistedLDClient
3+
from twisted.internet import task, defer
4+
5+
@defer.inlineCallbacks
6+
def main(reactor):
7+
apiKey = 'whatever'
8+
client = TwistedLDClient(apiKey)
9+
user = {
10+
u'key': u'xyz',
11+
u'custom': {
12+
u'bizzle': u'def'
13+
}
14+
}
15+
val = yield client.toggle('foo', user)
16+
yield client.flush()
17+
print("Value: {}".format(val))
18+
19+
if __name__ == '__main__':
20+
task.react(main)

ldclient/__init__.py

Lines changed: 41 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,14 @@ def __init__(self,
5555
capacity = 10000,
5656
stream_uri = 'https://stream.launchdarkly.com',
5757
stream = False,
58-
verify = True):
58+
verify = True,
59+
stream_processor_class = None,
60+
feature_store_class = None):
5961
self._base_uri = base_uri.rstrip('\\')
6062
self._stream_uri = stream_uri.rstrip('\\')
6163
self._stream = stream
64+
self._stream_processor_class = StreamProcessor if not stream_processor_class else stream_processor_class
65+
self._feature_store_class = InMemoryFeatureStore if not feature_store_class else feature_store_class
6266
self._connect = connect_timeout
6367
self._read = read_timeout
6468
self._upload_limit = upload_limit
@@ -135,34 +139,45 @@ def __init__(self, api_key, config):
135139
self.daemon = True
136140
self._api_key = api_key
137141
self._config = config
138-
self._store = InMemoryFeatureStore()
142+
self._store = config._feature_store_class()
143+
self._running = False
139144

140145
def run(self):
141146
log.debug("Starting stream processor")
147+
self._running = True
142148
hdrs = _stream_headers(self._api_key)
143149
uri = self._config._stream_uri + "/"
144150
messages = SSEClient(uri, verify = self._config._verify, headers = hdrs)
145151
for msg in messages:
146-
payload = json.loads(msg.data)
147-
if msg.event == 'put/features':
148-
self._store.init(payload)
149-
elif msg.event == 'patch/features':
150-
key = payload['path'][1:]
151-
feature = payload['data']
152-
self._store.upsert(key, feature)
153-
elif msg.event == 'delete/features':
154-
key = payload['path'][1:]
155-
version = payload['version']
156-
self._store.delete(key, version)
157-
else:
158-
log.warning('Unhandled event in stream processor: ' + msg.event)
152+
if not self._running:
153+
break
154+
self.process_message(self._store, msg)
159155

160156
def initialized(self):
161157
return self._store.initialized()
162158

163159
def get_feature(self, key):
164160
return self._store.get(key)
165161

162+
def stop(self):
163+
self._running = False
164+
165+
@staticmethod
166+
def process_message(store, msg):
167+
payload = json.loads(msg.data)
168+
if msg.event == 'put':
169+
store.init(payload)
170+
elif msg.event == 'patch':
171+
key = payload['path'][1:]
172+
feature = payload['data']
173+
store.upsert(key, feature)
174+
elif msg.event == 'delete':
175+
key = payload['path'][1:]
176+
version = payload['version']
177+
store.delete(key, version)
178+
else:
179+
log.warning('Unhandled event in stream processor: ' + msg.event)
180+
166181
class Consumer(Thread):
167182
def __init__(self, queue, api_key, config):
168183
Thread.__init__(self)
@@ -251,8 +266,9 @@ def __init__(self, api_key, config = None):
251266
self._consumer = None
252267
self._offline = False
253268
self._lock = Lock()
269+
self._stream_processor = None
254270
if self._config._stream:
255-
self._stream_processor = StreamProcessor(api_key, config)
271+
self._stream_processor = config._stream_processor_class(api_key, config)
256272
self._stream_processor.start()
257273

258274
def _check_consumer(self):
@@ -261,9 +277,11 @@ def _check_consumer(self):
261277
self._consumer = Consumer(self._queue, self._api_key, self._config)
262278
self._consumer.start()
263279

264-
def _stop_consumer(self):
280+
def _stop_consumers(self):
265281
if self._consumer and self._consumer.is_alive():
266282
self._consumer.stop()
283+
if self._stream_processor and self._stream_processor.is_alive():
284+
self._stream_processor.stop()
267285

268286
def _send(self, event):
269287
if self._offline:
@@ -283,7 +301,7 @@ def identify(self, user):
283301

284302
def set_offline(self):
285303
self._offline = True
286-
self._stop_consumer()
304+
self._stop_consumers()
287305

288306
def set_online(self):
289307
self._offline = False
@@ -339,8 +357,11 @@ def _toggle(self, key, user, default):
339357
def _headers(api_key):
340358
return {'Authorization': 'api_key ' + api_key, 'User-Agent': 'PythonClient/' + __version__, 'Content-Type': "application/json"}
341359

342-
def _stream_headers(api_key):
343-
return {'Authorization': 'api_key ' + api_key, 'User-Agent': 'PythonClient/' + __version__, 'Accept': "text/event-stream"}
360+
def _stream_headers(api_key, client="PythonClient"):
361+
return {'Authorization': 'api_key ' + api_key,
362+
'User-Agent': 'PythonClient/' + __version__,
363+
'Cache-Control': 'no-cache',
364+
'Accept': "text/event-stream"}
344365

345366
def _param_for_user(feature, user):
346367
if 'key' in user and user['key']:
@@ -420,5 +441,4 @@ def _evaluate(feature, user):
420441
total += float(variation['weight']) / 100.0
421442
if param < total:
422443
return variation['value']
423-
424444
return None

ldclient/twisted.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
from __future__ import absolute_import
2+
from functools import partial
3+
4+
import json
5+
from queue import Empty
6+
import errno
7+
from cachecontrol import CacheControl
8+
from ldclient import LDClient, _headers, log, _evaluate, _stream_headers, StreamProcessor, Config
9+
from ldclient.twisted_sse import TwistedSSEClient
10+
from requests.packages.urllib3.exceptions import ProtocolError
11+
from twisted.internet import task, defer
12+
import txrequests
13+
14+
15+
class TwistedLDClient(LDClient):
16+
def __init__(self, api_key, config=None):
17+
if config is None:
18+
config = TwistedConfig.default()
19+
super(TwistedLDClient, self).__init__(api_key, config)
20+
self._session = CacheControl(txrequests.Session())
21+
22+
def _check_consumer(self):
23+
if not self._consumer or not self._consumer.is_alive():
24+
self._consumer = TwistedConsumer(self._session, self._queue, self._api_key, self._config)
25+
self._consumer.start()
26+
27+
def flush(self):
28+
if self._offline:
29+
return defer.succeed(True)
30+
self._check_consumer()
31+
return self._consumer.flush()
32+
33+
def toggle(self, key, user, default=False):
34+
@defer.inlineCallbacks
35+
def run(should_retry):
36+
# noinspection PyBroadException
37+
try:
38+
if self._offline:
39+
defer.returnValue(default)
40+
val = yield self._toggle(key, user, default)
41+
self._send({'kind': 'feature', 'key': key, 'user': user, 'value': val})
42+
defer.returnValue(val)
43+
except ProtocolError as e:
44+
inner = e.args[1]
45+
if inner.errno == errno.ECONNRESET and should_retry:
46+
log.warning('ProtocolError exception caught while getting flag. Retrying.')
47+
d = yield run(False)
48+
defer.returnValue(d)
49+
else:
50+
log.exception('Unhandled exception. Returning default value for flag.')
51+
defer.returnValue(default)
52+
except Exception:
53+
log.exception('Unhandled exception. Returning default value for flag.')
54+
defer.returnValue(default)
55+
56+
return run(True)
57+
58+
@defer.inlineCallbacks
59+
def _toggle(self, key, user, default):
60+
if self._config._stream and self._stream_processor.initialized():
61+
feature = self._stream_processor.get_feature(key)
62+
else:
63+
hdrs = _headers(self._api_key)
64+
uri = self._config._base_uri + '/api/eval/features/' + key
65+
r = yield self._session.get(uri, headers=hdrs, timeout=(self._config._connect, self._config._read))
66+
r.raise_for_status()
67+
feature = r.json()
68+
val = _evaluate(feature, user)
69+
if val is None:
70+
val = default
71+
defer.returnValue(val)
72+
73+
74+
class TwistedConfig(Config):
75+
def __init__(self, *args, **kwargs):
76+
super(TwistedConfig, self).__init__(*args, **kwargs)
77+
self._stream_processor_class = TwistedStreamProcessor
78+
79+
80+
class TwistedStreamProcessor(object):
81+
82+
def __init__(self, api_key, config):
83+
self._store = config._feature_store_class()
84+
self.sse_client = TwistedSSEClient(config._stream_uri + "/", headers=_stream_headers(api_key,
85+
"PythonTwistedClient"),
86+
verify=config._verify,
87+
on_event=partial(StreamProcessor.process_message, self._store))
88+
self.running = False
89+
90+
def start(self):
91+
self.sse_client.start()
92+
self.running = True
93+
94+
def stop(self):
95+
self.sse_client.stop()
96+
97+
def get_feature(self, key):
98+
return self._store.get(key)
99+
100+
def initialized(self):
101+
return self._store.initialized()
102+
103+
def is_alive(self):
104+
return self.running
105+
106+
107+
class TwistedConsumer(object):
108+
def __init__(self, session, queue, api_key, config):
109+
self._queue = queue
110+
""" @type: queue.Queue """
111+
self._session = session
112+
""" :type: txrequests.Session """
113+
114+
self._api_key = api_key
115+
self._config = config
116+
""" :type: Deferred """
117+
self._looping_call = None
118+
""" :type: LoopingCall"""
119+
120+
def start(self):
121+
self._flushed = defer.Deferred()
122+
self._looping_call = task.LoopingCall(self._consume)
123+
self._looping_call.start(5)
124+
125+
def stop(self):
126+
self._looping_call.stop()
127+
128+
def is_alive(self):
129+
return self._looping_call is not None and self._looping_call.running
130+
131+
def flush(self):
132+
return self._consume()
133+
134+
def _consume(self):
135+
items = []
136+
try:
137+
while True:
138+
items.append(self._queue.get_nowait())
139+
except Empty:
140+
pass
141+
142+
if items:
143+
return self.send_batch(items)
144+
145+
@defer.inlineCallbacks
146+
def send_batch(self, events):
147+
@defer.inlineCallbacks
148+
def do_send(should_retry):
149+
# noinspection PyBroadException
150+
try:
151+
if isinstance(events, dict):
152+
body = [events]
153+
else:
154+
body = events
155+
hdrs = _headers(self._api_key)
156+
uri = self._config._base_uri + '/api/events/bulk'
157+
r = yield self._session.post(uri, headers=hdrs, timeout=(self._config._connect, self._config._read),
158+
data=json.dumps(body))
159+
r.raise_for_status()
160+
except ProtocolError as e:
161+
inner = e.args[1]
162+
if inner.errno == errno.ECONNRESET and should_retry:
163+
log.warning('ProtocolError exception caught while sending events. Retrying.')
164+
yield do_send(False)
165+
else:
166+
log.exception('Unhandled exception in event consumer. Analytics events were not processed.')
167+
except:
168+
log.exception('Unhandled exception in event consumer. Analytics events were not processed.')
169+
try:
170+
yield do_send(True)
171+
finally:
172+
for _ in events:
173+
self._queue.task_done()

0 commit comments

Comments
 (0)