Skip to content

Commit ab76809

Browse files
committed
Add twisted support launchdarkly#18
1 parent bc37ab3 commit ab76809

11 files changed

+335
-2
lines changed

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,6 @@ venv
22
*.pyc
33
dist
44
ldclient_py.egg-info
5-
build/
5+
build/
6+
.idea
7+
*.iml

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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ dependencies:
44
- pyenv shell 3.3.3; $(pyenv which pip) install --upgrade pip
55
- pyenv shell 2.6.8; $(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 pip) install -r twisted-requirements.txt
8+
- pyenv shell 3.3.3; $(pyenv which pip) install -r twisted-requirements.txt
79
- pyenv shell 2.6.8; $(pyenv which python) setup.py install
810
- pyenv shell 3.3.3; $(pyenv which python) setup.py install
911

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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ def _send(self, event):
161161
if self._queue.full():
162162
log.warning("Event queue is full-- dropped an event")
163163
else:
164+
print("putting in {}".format(event))
164165
self._queue.put(event)
165166

166167
def track(self, event_name, user, data = None):

ldclient/twisted.py

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

pytest.ini

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[pytest]
2+
twisted = 1

setup.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@
1111
# parse_requirements() returns generator of pip.req.InstallRequirement objects
1212
install_reqs = parse_requirements('requirements.txt', session=uuid.uuid1())
1313
test_reqs = parse_requirements('test-requirements.txt', session=uuid.uuid1())
14+
twisted_reqs = parse_requirements('twisted-requirements.txt', session=uuid.uuid1())
1415

1516
# reqs is a list of requirement
1617
# e.g. ['django==1.5.1', 'mezzanine==1.4.6']
1718
reqs = [str(ir.req) for ir in install_reqs]
1819
testreqs = [str(ir.req) for ir in test_reqs]
20+
txreqs = [str(ir.req) for ir in twisted_reqs]
1921

2022
class PyTest(Command):
2123
user_options = []
@@ -43,6 +45,9 @@ def run(self):
4345
'Operating System :: OS Independent',
4446
'Programming Language :: Python :: 2 :: Only',
4547
],
48+
extras_require={
49+
"twisted": txreqs
50+
},
4651
tests_require=testreqs,
4752
cmdclass = {'test': PyTest},
4853
)

test-requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
pytest==2.6.4
2+
pytest-twisted==1.5

testing/test_twisted.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
from builtins import next
2+
from builtins import filter
3+
from builtins import object
4+
import ldclient
5+
from ldclient.twisted import TwistedLDClient
6+
import pytest
7+
from twisted.internet import defer
8+
9+
try:
10+
import queue
11+
except:
12+
import Queue as queue
13+
14+
client = TwistedLDClient("API_KEY", ldclient.Config("http://localhost:3000"))
15+
16+
user = {
17+
u'key': u'xyz',
18+
u'custom': {
19+
u'bizzle': u'def'
20+
}
21+
}
22+
23+
class MockConsumer(object):
24+
def __init__(self):
25+
self._running = False
26+
27+
def stop(self):
28+
self._running = False
29+
30+
def start(self):
31+
self._running = True
32+
33+
def is_alive(self):
34+
return self._running
35+
36+
def flush(self):
37+
return defer.succeed(True)
38+
39+
40+
def mock_consumer():
41+
return MockConsumer()
42+
43+
def noop_consumer():
44+
return
45+
46+
def mock_toggle(key, user, default):
47+
hash = minimal_feature = {
48+
u'key': u'feature.key',
49+
u'salt': u'abc',
50+
u'on': True,
51+
u'variations': [
52+
{
53+
u'value': True,
54+
u'weight': 100,
55+
u'targets': []
56+
},
57+
{
58+
u'value': False,
59+
u'weight': 0,
60+
u'targets': []
61+
}
62+
]
63+
}
64+
val = ldclient._evaluate(hash, user)
65+
if val is None:
66+
return defer.succeed(default)
67+
return defer.succeed(val)
68+
69+
def setup_function(function):
70+
client.set_online()
71+
client._queue = queue.Queue(10)
72+
client._consumer = mock_consumer()
73+
74+
@pytest.fixture(autouse=True)
75+
def noop_check_consumer(monkeypatch):
76+
monkeypatch.setattr(client, '_check_consumer', noop_consumer)
77+
78+
@pytest.fixture(autouse=True)
79+
def no_remote_toggle(monkeypatch):
80+
monkeypatch.setattr(client, '_toggle', mock_toggle)
81+
82+
def test_set_offline():
83+
client.set_offline()
84+
assert client.is_offline() == True
85+
86+
def test_set_online():
87+
client.set_offline()
88+
client.set_online()
89+
assert client.is_offline() == False
90+
91+
@pytest.inlineCallbacks
92+
def test_toggle():
93+
result = yield client.toggle('xyz', user, default=None)
94+
assert result == True
95+
96+
@pytest.inlineCallbacks
97+
def test_toggle_offline():
98+
client.set_offline()
99+
assert (yield client.toggle('xyz', user, default=None)) == None
100+
101+
@pytest.inlineCallbacks
102+
def test_toggle_event():
103+
val = yield client.toggle('xyz', user, default=None)
104+
def expected_event(e):
105+
return e['kind'] == 'feature' and e['key'] == 'xyz' and e['user'] == user and e['value'] == True
106+
assert expected_event(client._queue.get(False))
107+
108+
@pytest.inlineCallbacks
109+
def test_toggle_event_offline():
110+
client.set_offline()
111+
yield client.toggle('xyz', user, default=None)
112+
assert client._queue.empty()
113+
114+
115+
def test_identify():
116+
client.identify(user)
117+
def expected_event(e):
118+
return e['kind'] == 'identify' and e['key'] == u'xyz' and e['user'] == user
119+
assert expected_event(client._queue.get(False))
120+
121+
def test_identify_offline():
122+
client.set_offline()
123+
client.identify(user)
124+
assert client._queue.empty()
125+
126+
def test_track():
127+
client.track('my_event', user, 42)
128+
def expected_event(e):
129+
return e['kind'] == 'custom' and e['key'] == 'my_event' and e['user'] == user and e['data'] == 42
130+
assert expected_event(client._queue.get(False))
131+
132+
def test_track_offline():
133+
client.set_offline()
134+
client.track('my_event', user, 42)
135+
assert client._queue.empty()
136+
137+
def drain(queue):
138+
while not queue.empty():
139+
queue.get()
140+
queue.task_done()
141+
return
142+
143+
@pytest.inlineCallbacks
144+
def test_flush_empties_queue():
145+
client.track('my_event', user, 42)
146+
client.track('my_event', user, 33)
147+
drain(client._queue)
148+
yield client.flush()
149+
assert client._queue.empty()
150+
151+
152+
@pytest.inlineCallbacks
153+
def test_flush_offline_does_not_empty_queue():
154+
client.track('my_event', user, 42)
155+
client.track('my_event', user, 33)
156+
client.set_offline()
157+
yield client.flush()
158+
assert not client._queue.empty()

twisted-requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
txrequests==0.9.2

0 commit comments

Comments
 (0)