From 315f10bce4257de9ec4559db9f7f015290918ff0 Mon Sep 17 00:00:00 2001 From: leggetter Date: Tue, 31 Mar 2015 20:01:40 +0100 Subject: [PATCH 01/39] Pusher ctr now requires app_id, key, secret --- pusher/pusher.py | 63 +++++++++++++++++++++++++++++++++++-- pusher_tests/test_pusher.py | 34 +++++++++++++++++++- 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/pusher/pusher.py b/pusher/pusher.py index c59ba40..a3b88ea 100644 --- a/pusher/pusher.py +++ b/pusher/pusher.py @@ -5,8 +5,9 @@ from pusher.config import Config from pusher.request import Request from pusher.sync import SynchronousBackend -from pusher.util import GET, POST, text, validate_channel +from pusher.util import GET, POST, text, validate_channel, app_id_re +import os import collections import json import six @@ -48,16 +49,74 @@ class Pusher(object): This client supports various backend adapters to support various http libraries available in the python ecosystem. + :param app_id: a pusher application identifier + :param key: a pusher application key + :param secret: a pusher application secret token :param config: a pusher.Config instance :param backend: an object that responds to the send_request(request) method. If none is provided, a python.sync.SynchronousBackend instance is created. """ - def __init__(self, config, backend=None): + def __init__(self, app_id, key, secret, config=None, backend=None): + + if not isinstance(app_id, six.text_type): + raise TypeError("App ID should be %s" % text) + if not app_id_re.match(app_id): + raise ValueError("Invalid app id") + + if not isinstance(key, six.text_type): + raise TypeError("Key should be %s" % text) + + if not isinstance(secret, six.text_type): + raise TypeError("Secret should be %s" % text) + + if not config: + config=Config(app_id=app_id, key=key, secret=secret) + else: + config.app_id=app_id + config.key=key + config.secret=secret + if not isinstance(config, Config): raise TypeError("config should be a pusher.Config object") + self.backend = backend or SynchronousBackend(config) self.config = config + + @classmethod + def from_url(cls, url): + """Alternate constructor that extracts the information from a URL. + + :param url: String containing a URL + + Usage:: + + >> from pusher import Pusher + >> p = Pusher.from_url("http://mykey:mysecret@api.pusher.com/apps/432") + """ + config=Config.from_url(url) + + return cls(config.app_id, config.key, config.secret, config) + + @classmethod + def from_env(cls, env='PUSHER_URL'): + """Alternate constructor that extracts the information from an URL + stored in an environment variable. The pusher heroku addon will set + the PUSHER_URL automatically when installed for example. + + :param env: Name of the environment variable + + Usage:: + + >> from pusher import Pusher + >> c = Pusher.from_env("PUSHER_URL") + """ + val = os.environ.get(env) + if not val: + raise Exception("Environment variable %s not found" % env) + + config=Config.from_url(six.text_type(val)) + return cls(config.app_id, config.key, config.secret, config) @request_method def trigger(self, channels, event_name, data, socket_id=None): diff --git a/pusher_tests/test_pusher.py b/pusher_tests/test_pusher.py index 2c00c3e..2af9883 100644 --- a/pusher_tests/test_pusher.py +++ b/pusher_tests/test_pusher.py @@ -2,6 +2,7 @@ from __future__ import print_function, absolute_import, division +import os import unittest from pusher import Config, Pusher @@ -14,7 +15,38 @@ class TestPusher(unittest.TestCase): def setUp(self): - self.pusher = Pusher(config=Config.from_url(u'http://key:secret@somehost/apps/4')) + self.pusher = Pusher.from_url(u'http://key:secret@somehost/apps/4') + + def test_app_id_should_be_text(self): + self.assertRaises(TypeError, lambda: Pusher(key=u'key', secret=u'secret', ssl=False)) + self.assertRaises(TypeError, lambda: Pusher(app_id=4, key=u'key', secret=u'secret')) + self.assertRaises(TypeError, lambda: Pusher(app_id=b'4', key=u'key', secret=u'secret')) + + def test_key_should_be_text(self): + self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', secret=u'secret')) + self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=4, secret=u'secret')) + self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=b'key', secret=u'secret')) + + def test_secret_should_be_text(self): + self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=u'key', secret=4)) + self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=u'key', secret=b'secret')) + + def test_initialize_from_env(self): + with mock.patch.object(os, 'environ', new={'PUSHER_URL':'https://plah:bob@somehost/apps/42'}): + pusher = Pusher.from_env() + self.assertEqual(pusher.conf.ssl, True) + self.assertEqual(pusher.conf.key, u'plah') + self.assertEqual(pusher.conf.secret, u'bob') + self.assertEqual(pusher.conf.host, u'somehost') + self.assertEqual(pusher.conf.app_id, u'42') + + with mock.patch.object(os, 'environ', new={'PUSHER_DSN':'https://plah:bob@somehost/apps/42'}): + pusher = Pusher.from_env('PUSHER_DSN') + self.assertEqual(pusher.conf.ssl, True) + self.assertEqual(pusher.conf.key, u'plah') + self.assertEqual(pusher.conf.secret, u'bob') + self.assertEqual(pusher.conf.host, u'somehost') + self.assertEqual(pusher.conf.app_id, u'42') def test_trigger_success_case(self): json_dumped = u'{"message": "hello world"}' From 5a140777d8eff4df27e9ef28c66541a10595b58c Mon Sep 17 00:00:00 2001 From: leggetter Date: Tue, 31 Mar 2015 20:02:24 +0100 Subject: [PATCH 02/39] app_id, key, secret optional on Config ctr --- pusher/config.py | 54 ++++++++++++++----------------------- pusher_tests/test_config.py | 25 +++-------------- 2 files changed, 23 insertions(+), 56 deletions(-) diff --git a/pusher/config.py b/pusher/config.py index b8d5492..affdfd1 100644 --- a/pusher/config.py +++ b/pusher/config.py @@ -7,7 +7,6 @@ import hashlib import hmac import json -import os import re import six import time @@ -39,18 +38,26 @@ class Config(object): >> from pusher import Config >> c = Config('455', 'mykey', 'mysecret') """ - def __init__(self, app_id, key, secret, ssl=False, host=None, port=None, cluster=None): - if not isinstance(app_id, six.text_type): - raise TypeError("App ID should be %s" % text) - - if not isinstance(key, six.text_type): - raise TypeError("Key should be %s" % text) - - if not isinstance(secret, six.text_type): - raise TypeError("Secret should be %s" % text) - - if not app_id_re.match(app_id): - raise ValueError("Invalid app id") + def __init__(self, app_id=None, key=None, secret=None, ssl=False, host=None, port=None, cluster=None): + if app_id: + if not isinstance(app_id, six.text_type): + raise TypeError("App ID should be %s" % text) + if not app_id_re.match(app_id): + raise ValueError("Invalid app id") + + self.app_id = app_id + + if key: + if not isinstance(key, six.text_type): + raise TypeError("Key should be %s" % text) + + self.key = key + + if secret: + if not isinstance(secret, six.text_type): + raise TypeError("Secret should be %s" % text) + + self.secret = secret if port and not isinstance(port, six.integer_types): raise TypeError("Port should be a number") @@ -58,10 +65,6 @@ def __init__(self, app_id, key, secret, ssl=False, host=None, port=None, cluster if not isinstance(ssl, bool): raise TypeError("SSL should be a boolean") - self.app_id = app_id - self.key = key - self.secret = secret - if host: if not isinstance(host, six.text_type): raise TypeError("Host should be %s" % text) @@ -95,23 +98,6 @@ def from_url(cls, url): ssl = m.group(1) == 'https' return cls(key=m.group(2), secret=m.group(3), host=m.group(4), app_id=m.group(5), ssl=ssl) - @classmethod - def from_env(cls, env='PUSHER_URL'): - """Alternate constructor that extracts the information from an URL - stored in an environment variable. The pusher heroku addon will set - the PUSHER_URL automatically when installed for example. - - :param env: Name of the environment variable - - Usage:: - - >> from pusher import Config - >> c = Config.from_env("PUSHER_URL") - """ - val = os.environ.get(env) - if not val: - raise Exception("Environment variable %s not found" % env) - return cls.from_url(six.text_type(val)) @property def scheme(self): diff --git a/pusher_tests/test_config.py b/pusher_tests/test_config.py index 71e903d..8f00c08 100644 --- a/pusher_tests/test_config.py +++ b/pusher_tests/test_config.py @@ -19,17 +19,15 @@ class TestConfig(unittest.TestCase): def test_should_be_constructable(self): Config(app_id=u'4', key=u'key', secret=u'secret', ssl=False) - def test_app_id_should_be_text(self): - self.assertRaises(TypeError, lambda: Config(key=u'key', secret=u'secret', ssl=False)) + def test_app_id_should_be_text_if_present(self): self.assertRaises(TypeError, lambda: Config(app_id=4, key=u'key', secret=u'secret', ssl=False)) self.assertRaises(TypeError, lambda: Config(app_id=b'4', key=u'key', secret=u'secret', ssl=False)) - def test_key_should_be_text(self): - self.assertRaises(TypeError, lambda: Config(app_id=u'4', secret=u'secret', ssl=False)) + def test_key_should_be_text_if_present(self): self.assertRaises(TypeError, lambda: Config(app_id=u'4', key=4, secret=u'secret', ssl=False)) self.assertRaises(TypeError, lambda: Config(app_id=u'4', key=b'key', secret=u'secret', ssl=False)) - def test_secret_should_be_text(self): + def test_secret_should_be_text_if_present(self): self.assertRaises(TypeError, lambda: Config(app_id=u'4', key=u'key', secret=4, ssl=False)) self.assertRaises(TypeError, lambda: Config(app_id=u'4', key=u'key', secret=b'secret', ssl=False)) @@ -101,23 +99,6 @@ def test_initialize_from_url(self): self.assertEqual(conf.host, u'host') self.assertEqual(conf.app_id, u'4') - def test_initialize_from_env(self): - with mock.patch.object(os, 'environ', new={'PUSHER_URL':'https://plah:bob@somehost/apps/42'}): - conf = Config.from_env() - self.assertEqual(conf.ssl, True) - self.assertEqual(conf.key, u'plah') - self.assertEqual(conf.secret, u'bob') - self.assertEqual(conf.host, u'somehost') - self.assertEqual(conf.app_id, u'42') - - with mock.patch.object(os, 'environ', new={'PUSHER_DSN':'https://plah:bob@somehost/apps/42'}): - conf = Config.from_env('PUSHER_DSN') - self.assertEqual(conf.ssl, True) - self.assertEqual(conf.key, u'plah') - self.assertEqual(conf.secret, u'bob') - self.assertEqual(conf.host, u'somehost') - self.assertEqual(conf.app_id, u'42') - def test_authenticate_subscription_types(self): conf = Config.from_url(u'http://foo:bar@host/apps/4') From a0d359e9126c51ec8e7d1d0c73bdeadd75681546 Mon Sep 17 00:00:00 2001 From: leggetter Date: Tue, 31 Mar 2015 20:08:19 +0100 Subject: [PATCH 03/39] Move authenticate_subscription. Config -> Pusher --- pusher/config.py | 35 +--------------------------------- pusher/pusher.py | 38 ++++++++++++++++++++++++++++++++++++- pusher_tests/test_config.py | 37 ------------------------------------ pusher_tests/test_pusher.py | 37 ++++++++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 72 deletions(-) diff --git a/pusher/config.py b/pusher/config.py index affdfd1..9da5dc2 100644 --- a/pusher/config.py +++ b/pusher/config.py @@ -2,7 +2,7 @@ from __future__ import (print_function, unicode_literals, absolute_import, division) -from pusher.util import app_id_re, channel_name_re, text +from pusher.util import app_id_re, text import hashlib import hmac @@ -104,39 +104,6 @@ def scheme(self): """Returns "http" or "https" scheme depending on the ssl setting.""" return 'https' if self.ssl else 'http' - def authenticate_subscription(self, channel, socket_id, custom_data=None): - """Used to generate delegated client subscription token. - - :param channel: name of the channel to authorize subscription to - :param socket_id: id of the socket that requires authorization - :param custom_data: used on presence channels to provide user info - """ - if not isinstance(channel, six.text_type): - raise TypeError('Channel should be %s' % text) - - if not channel_name_re.match(channel): - raise ValueError('Channel should be a valid channel, got: %s' % channel) - - if not isinstance(socket_id, six.text_type): - raise TypeError('Socket ID should %s' % text) - - if custom_data: - custom_data = json.dumps(custom_data) - - string_to_sign = "%s:%s" % (socket_id, channel) - - if custom_data: - string_to_sign += ":%s" % custom_data - - signature = hmac.new(self.secret.encode('utf8'), string_to_sign.encode('utf8'), hashlib.sha256).hexdigest() - - auth = "%s:%s" % (self.key, signature) - result = {'auth': auth} - - if custom_data: - result['channel_data'] = custom_data - - return result def validate_webhook(self, key, signature, body): """Used to validate incoming webhook messages. When used it guarantees diff --git a/pusher/pusher.py b/pusher/pusher.py index a3b88ea..bc28693 100644 --- a/pusher/pusher.py +++ b/pusher/pusher.py @@ -5,12 +5,14 @@ from pusher.config import Config from pusher.request import Request from pusher.sync import SynchronousBackend -from pusher.util import GET, POST, text, validate_channel, app_id_re +from pusher.util import GET, POST, text, validate_channel, app_id_re, channel_name_re import os import collections import json import six +import hmac +import hashlib class RequestMethod(object): def __init__(self, pusher, f): @@ -156,6 +158,40 @@ def trigger(self, channels, event_name, data, socket_id=None): raise TypeError("Socket ID should be %s" % text) params['socket_id'] = socket_id return Request(self.config, POST, "/apps/%s/events" % self.config.app_id, params) + + def authenticate_subscription(self, channel, socket_id, custom_data=None): + """Used to generate delegated client subscription token. + + :param channel: name of the channel to authorize subscription to + :param socket_id: id of the socket that requires authorization + :param custom_data: used on presence channels to provide user info + """ + if not isinstance(channel, six.text_type): + raise TypeError('Channel should be %s' % text) + + if not channel_name_re.match(channel): + raise ValueError('Channel should be a valid channel, got: %s' % channel) + + if not isinstance(socket_id, six.text_type): + raise TypeError('Socket ID should %s' % text) + + if custom_data: + custom_data = json.dumps(custom_data) + + string_to_sign = "%s:%s" % (socket_id, channel) + + if custom_data: + string_to_sign += ":%s" % custom_data + + signature = hmac.new(self.config.secret.encode('utf8'), string_to_sign.encode('utf8'), hashlib.sha256).hexdigest() + + auth = "%s:%s" % (self.config.key, signature) + result = {'auth': auth} + + if custom_data: + result['channel_data'] = custom_data + + return result @request_method def channels_info(self, prefix_filter=None, attributes=[]): diff --git a/pusher_tests/test_config.py b/pusher_tests/test_config.py index 8f00c08..05bbf76 100644 --- a/pusher_tests/test_config.py +++ b/pusher_tests/test_config.py @@ -99,43 +99,6 @@ def test_initialize_from_url(self): self.assertEqual(conf.host, u'host') self.assertEqual(conf.app_id, u'4') - def test_authenticate_subscription_types(self): - conf = Config.from_url(u'http://foo:bar@host/apps/4') - - self.assertRaises(TypeError, lambda: conf.authenticate_subscription(b'plah', u'34554')) - self.assertRaises(TypeError, lambda: conf.authenticate_subscription(u'plah', b'324435')) - self.assertRaises(ValueError, lambda: conf.authenticate_subscription(u'::', u'345345')) - - def test_authenticate_subscription_for_private_channels(self): - conf = Config.from_url(u'http://foo:bar@host/apps/4') - - expected = { - u'auth': u"foo:076740bd063f0299742a73bc5aac88900e5f35cb0185a1facbf45d326b5b204b" - } - - self.assertEqual(conf.authenticate_subscription(u'private-channel', u'34523'), expected) - - def test_authenticate_subscription_for_presence_channels(self): - conf = Config.from_url(u'http://foo:bar@host/apps/4') - - custom_data = { - u'user_id': u'fred', - u'user_info': { - u'key': u'value' - } - } - - expected = { - u'auth': u"foo:fbbc6d8acc85fc807bba060e2df45aba33deb8ad44cbee1633675b3ce73f4817", - u'channel_data': u"{\"user_id\":\"fred\",\"user_info\":{\"key\":\"value\"}}" - } - - with mock.patch('json.dumps', return_value=expected[u'channel_data']) as dumps_mock: - actual = conf.authenticate_subscription(u'presence-channel', u'34543245', custom_data) - - self.assertEqual(actual, expected) - dumps_mock.assert_called_once_with(custom_data) - def test_validate_webhook_success_case(self): conf = Config.from_url(u'http://foo:bar@host/apps/4') diff --git a/pusher_tests/test_pusher.py b/pusher_tests/test_pusher.py index 2af9883..f6f6c0c 100644 --- a/pusher_tests/test_pusher.py +++ b/pusher_tests/test_pusher.py @@ -74,6 +74,43 @@ def test_trigger_disallow_single_channel(self): def test_trigger_disallow_invalid_channels(self): self.assertRaises(ValueError, lambda: self.pusher.trigger.make_request([u'some_channel!'], u'some_event', {u'message': u'hello world'})) + + def test_authenticate_subscription_types(self): + pusher = Pusher.from_url(u'http://foo:bar@host/apps/4') + + self.assertRaises(TypeError, lambda: pusher.authenticate_subscription(b'plah', u'34554')) + self.assertRaises(TypeError, lambda: pusher.authenticate_subscription(u'plah', b'324435')) + self.assertRaises(ValueError, lambda: pusher.authenticate_subscription(u'::', u'345345')) + + def test_authenticate_subscription_for_private_channels(self): + pusher = Pusher.from_url(u'http://foo:bar@host/apps/4') + + expected = { + u'auth': u"foo:076740bd063f0299742a73bc5aac88900e5f35cb0185a1facbf45d326b5b204b" + } + + self.assertEqual(pusher.authenticate_subscription(u'private-channel', u'34523'), expected) + + def test_authenticate_subscription_for_presence_channels(self): + pusher = Pusher.from_url(u'http://foo:bar@host/apps/4') + + custom_data = { + u'user_id': u'fred', + u'user_info': { + u'key': u'value' + } + } + + expected = { + u'auth': u"foo:fbbc6d8acc85fc807bba060e2df45aba33deb8ad44cbee1633675b3ce73f4817", + u'channel_data': u"{\"user_id\":\"fred\",\"user_info\":{\"key\":\"value\"}}" + } + + with mock.patch('json.dumps', return_value=expected[u'channel_data']) as dumps_mock: + actual = pusher.authenticate_subscription(u'presence-channel', u'34543245', custom_data) + + self.assertEqual(actual, expected) + dumps_mock.assert_called_once_with(custom_data) def test_channels_info_default_success_case(self): request = self.pusher.channels_info.make_request() From 547c7521bec45cb32115463f40e4e92d2cfe386f Mon Sep 17 00:00:00 2001 From: leggetter Date: Tue, 31 Mar 2015 20:09:27 +0100 Subject: [PATCH 04/39] authenticate_subscription -> authenticate --- pusher/pusher.py | 2 +- pusher_tests/test_pusher.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pusher/pusher.py b/pusher/pusher.py index bc28693..9dacb5d 100644 --- a/pusher/pusher.py +++ b/pusher/pusher.py @@ -159,7 +159,7 @@ def trigger(self, channels, event_name, data, socket_id=None): params['socket_id'] = socket_id return Request(self.config, POST, "/apps/%s/events" % self.config.app_id, params) - def authenticate_subscription(self, channel, socket_id, custom_data=None): + def authenticate(self, channel, socket_id, custom_data=None): """Used to generate delegated client subscription token. :param channel: name of the channel to authorize subscription to diff --git a/pusher_tests/test_pusher.py b/pusher_tests/test_pusher.py index f6f6c0c..8945080 100644 --- a/pusher_tests/test_pusher.py +++ b/pusher_tests/test_pusher.py @@ -75,23 +75,23 @@ def test_trigger_disallow_invalid_channels(self): self.assertRaises(ValueError, lambda: self.pusher.trigger.make_request([u'some_channel!'], u'some_event', {u'message': u'hello world'})) - def test_authenticate_subscription_types(self): + def test_authenticate_types(self): pusher = Pusher.from_url(u'http://foo:bar@host/apps/4') - self.assertRaises(TypeError, lambda: pusher.authenticate_subscription(b'plah', u'34554')) - self.assertRaises(TypeError, lambda: pusher.authenticate_subscription(u'plah', b'324435')) - self.assertRaises(ValueError, lambda: pusher.authenticate_subscription(u'::', u'345345')) + self.assertRaises(TypeError, lambda: pusher.authenticate(b'plah', u'34554')) + self.assertRaises(TypeError, lambda: pusher.authenticate(u'plah', b'324435')) + self.assertRaises(ValueError, lambda: pusher.authenticate(u'::', u'345345')) - def test_authenticate_subscription_for_private_channels(self): + def test_authenticate_for_private_channels(self): pusher = Pusher.from_url(u'http://foo:bar@host/apps/4') expected = { u'auth': u"foo:076740bd063f0299742a73bc5aac88900e5f35cb0185a1facbf45d326b5b204b" } - self.assertEqual(pusher.authenticate_subscription(u'private-channel', u'34523'), expected) + self.assertEqual(pusher.authenticate(u'private-channel', u'34523'), expected) - def test_authenticate_subscription_for_presence_channels(self): + def test_authenticate_for_presence_channels(self): pusher = Pusher.from_url(u'http://foo:bar@host/apps/4') custom_data = { @@ -107,7 +107,7 @@ def test_authenticate_subscription_for_presence_channels(self): } with mock.patch('json.dumps', return_value=expected[u'channel_data']) as dumps_mock: - actual = pusher.authenticate_subscription(u'presence-channel', u'34543245', custom_data) + actual = pusher.authenticate(u'presence-channel', u'34543245', custom_data) self.assertEqual(actual, expected) dumps_mock.assert_called_once_with(custom_data) From 5bd4ac5607d3381ce7d1729bb069581014a827ce Mon Sep 17 00:00:00 2001 From: leggetter Date: Tue, 31 Mar 2015 20:20:06 +0100 Subject: [PATCH 05/39] Config.validate_webhook -> Pusher.validate_webhook --- pusher/config.py | 53 -------------------------------- pusher/pusher.py | 50 ++++++++++++++++++++++++++++++ pusher_tests/test_config.py | 58 ----------------------------------- pusher_tests/test_pusher.py | 61 +++++++++++++++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 111 deletions(-) diff --git a/pusher/config.py b/pusher/config.py index 9da5dc2..3975456 100644 --- a/pusher/config.py +++ b/pusher/config.py @@ -4,21 +4,9 @@ division) from pusher.util import app_id_re, text -import hashlib -import hmac import json import re import six -import time - -try: - compare_digest = hmac.compare_digest -except AttributeError: - # Not secure when the length is supposed to be kept secret - def compare_digest(a, b): - if len(a) != len(b): - return False - return reduce(lambda x, y: x | y, [ord(x) ^ ord(y) for x, y in zip(a, b)]) == 0 class Config(object): """The Config class holds the pusher credentials and other connection @@ -103,44 +91,3 @@ def from_url(cls, url): def scheme(self): """Returns "http" or "https" scheme depending on the ssl setting.""" return 'https' if self.ssl else 'http' - - - def validate_webhook(self, key, signature, body): - """Used to validate incoming webhook messages. When used it guarantees - that the sender is Pusher and not someone else impersonating it. - - :param key: key used to sign the body - :param signature: signature that was given with the body - :param body: content that needs to be verified - """ - if not isinstance(key, six.text_type): - raise TypeError('key should be %s' % text) - - if not isinstance(signature, six.text_type): - raise TypeError('signature should be %s' % text) - - if not isinstance(body, six.text_type): - raise TypeError('body should be %s' % text) - - if key != self.key: - return None - - generated_signature = six.text_type(hmac.new(self.secret.encode('utf8'), body.encode('utf8'), hashlib.sha256).hexdigest()) - - if not compare_digest(generated_signature, signature): - return None - - try: - body_data = json.loads(body) - except ValueError: - return None - - time_ms = body_data.get('time_ms') - if not time_ms: - return None - - print(abs(time.time()*1000 - time_ms)) - if abs(time.time()*1000 - time_ms) > 300000: - return None - - return body_data diff --git a/pusher/pusher.py b/pusher/pusher.py index 9dacb5d..57ddb87 100644 --- a/pusher/pusher.py +++ b/pusher/pusher.py @@ -13,6 +13,16 @@ import six import hmac import hashlib +import time + +try: + compare_digest = hmac.compare_digest +except AttributeError: + # Not secure when the length is supposed to be kept secret + def compare_digest(a, b): + if len(a) != len(b): + return False + return reduce(lambda x, y: x | y, [ord(x) ^ ord(y) for x, y in zip(a, b)]) == 0 class RequestMethod(object): def __init__(self, pusher, f): @@ -192,6 +202,46 @@ def authenticate(self, channel, socket_id, custom_data=None): result['channel_data'] = custom_data return result + + def validate_webhook(self, key, signature, body): + """Used to validate incoming webhook messages. When used it guarantees + that the sender is Pusher and not someone else impersonating it. + + :param key: key used to sign the body + :param signature: signature that was given with the body + :param body: content that needs to be verified + """ + if not isinstance(key, six.text_type): + raise TypeError('key should be %s' % text) + + if not isinstance(signature, six.text_type): + raise TypeError('signature should be %s' % text) + + if not isinstance(body, six.text_type): + raise TypeError('body should be %s' % text) + + if key != self.config.key: + return None + + generated_signature = six.text_type(hmac.new(self.config.secret.encode('utf8'), body.encode('utf8'), hashlib.sha256).hexdigest()) + + if not compare_digest(generated_signature, signature): + return None + + try: + body_data = json.loads(body) + except ValueError: + return None + + time_ms = body_data.get('time_ms') + if not time_ms: + return None + + print(abs(time.time()*1000 - time_ms)) + if abs(time.time()*1000 - time_ms) > 300000: + return None + + return body_data @request_method def channels_info(self, prefix_filter=None, attributes=[]): diff --git a/pusher_tests/test_config.py b/pusher_tests/test_config.py index 05bbf76..267ed71 100644 --- a/pusher_tests/test_config.py +++ b/pusher_tests/test_config.py @@ -99,63 +99,5 @@ def test_initialize_from_url(self): self.assertEqual(conf.host, u'host') self.assertEqual(conf.app_id, u'4') - def test_validate_webhook_success_case(self): - conf = Config.from_url(u'http://foo:bar@host/apps/4') - - body = u'{"time_ms": 1000000}' - signature = six.text_type(hmac.new(conf.secret.encode(u'utf8'), body.encode(u'utf8'), hashlib.sha256).hexdigest()) - - with mock.patch('time.time', return_value=1200): - self.assertEqual(conf.validate_webhook(conf.key, signature, body), {u'time_ms': 1000000}) - - def test_validate_webhook_bad_types(self): - conf = Config.from_url(u'http://foo:bar@host/apps/4') - - conf.validate_webhook(u'key', u'signature', u'body') - - # These things are meant to be human readable, so enforcing being text is - # sensible. - - with mock.patch('time.time') as time_mock: - self.assertRaises(TypeError, lambda: conf.validate_webhook(4, u'signature', u'body')) - self.assertRaises(TypeError, lambda: conf.validate_webhook(b'test', u'signature', u'body')) - self.assertRaises(TypeError, lambda: conf.validate_webhook(u'key', 4, u'body')) - self.assertRaises(TypeError, lambda: conf.validate_webhook(u'key', b'signature', u'body')) - self.assertRaises(TypeError, lambda: conf.validate_webhook(u'key', u'signature', 4)) - self.assertRaises(TypeError, lambda: conf.validate_webhook(u'key', u'signature', b'body')) - - time_mock.assert_not_called() - - def test_validate_webhook_bad_key(self): - conf = Config.from_url(u'http://foo:bar@host/apps/4') - - body = u'some body' - signature = six.text_type(hmac.new(conf.secret.encode(u'utf8'), body.encode(u'utf8'), hashlib.sha256).hexdigest()) - - with mock.patch('time.time') as time_mock: - self.assertEqual(conf.validate_webhook(u'badkey', signature, body), None) - - time_mock.assert_not_called() - - def test_validate_webhook_bad_signature(self): - conf = Config.from_url(u'http://foo:bar@host/apps/4') - - body = u'some body' - signature = u'some signature' - - with mock.patch('time.time') as time_mock: - self.assertEqual(conf.validate_webhook(conf.key, signature, body), None) - - time_mock.assert_not_called() - - def test_validate_webhook_bad_time(self): - conf = Config.from_url(u'http://foo:bar@host/apps/4') - - body = u'{"time_ms": 1000000}' - signature = six.text_type(hmac.new(conf.secret.encode('utf8'), body.encode('utf8'), hashlib.sha256).hexdigest()) - - with mock.patch('time.time', return_value=1301): - self.assertEqual(conf.validate_webhook(conf.key, signature, body), None) - if __name__ == '__main__': unittest.main() diff --git a/pusher_tests/test_pusher.py b/pusher_tests/test_pusher.py index 8945080..6ed0c44 100644 --- a/pusher_tests/test_pusher.py +++ b/pusher_tests/test_pusher.py @@ -3,6 +3,9 @@ from __future__ import print_function, absolute_import, division import os +import six +import hmac +import hashlib import unittest from pusher import Config, Pusher @@ -112,6 +115,64 @@ def test_authenticate_for_presence_channels(self): self.assertEqual(actual, expected) dumps_mock.assert_called_once_with(custom_data) + def test_validate_webhook_success_case(self): + pusher = Pusher.from_url(u'http://foo:bar@host/apps/4') + + body = u'{"time_ms": 1000000}' + signature = six.text_type(hmac.new(pusher.config.secret.encode(u'utf8'), body.encode(u'utf8'), hashlib.sha256).hexdigest()) + + with mock.patch('time.time', return_value=1200): + self.assertEqual(pusher.validate_webhook(pusher.config.key, signature, body), {u'time_ms': 1000000}) + + def test_validate_webhook_bad_types(self): + pusher = Pusher.from_url(u'http://foo:bar@host/apps/4') + + pusher.validate_webhook(u'key', u'signature', u'body') + + # These things are meant to be human readable, so enforcing being text is + # sensible. + + with mock.patch('time.time') as time_mock: + self.assertRaises(TypeError, lambda: pusher.validate_webhook(4, u'signature', u'body')) + self.assertRaises(TypeError, lambda: pusher.validate_webhook(b'test', u'signature', u'body')) + self.assertRaises(TypeError, lambda: pusher.validate_webhook(u'key', 4, u'body')) + self.assertRaises(TypeError, lambda: pusher.validate_webhook(u'key', b'signature', u'body')) + self.assertRaises(TypeError, lambda: pusher.validate_webhook(u'key', u'signature', 4)) + self.assertRaises(TypeError, lambda: pusher.validate_webhook(u'key', u'signature', b'body')) + + time_mock.assert_not_called() + + def test_validate_webhook_bad_key(self): + pusher = Pusher.from_url(u'http://foo:bar@host/apps/4') + + body = u'some body' + signature = six.text_type(hmac.new(pusher.config.secret.encode(u'utf8'), body.encode(u'utf8'), hashlib.sha256).hexdigest()) + + with mock.patch('time.time') as time_mock: + self.assertEqual(pusher.validate_webhook(u'badkey', signature, body), None) + + time_mock.assert_not_called() + + def test_validate_webhook_bad_signature(self): + pusher = Pusher.from_url(u'http://foo:bar@host/apps/4') + + body = u'some body' + signature = u'some signature' + + with mock.patch('time.time') as time_mock: + self.assertEqual(pusher.validate_webhook(pusher.config.key, signature, body), None) + + time_mock.assert_not_called() + + def test_validate_webhook_bad_time(self): + pusher = Pusher.from_url(u'http://foo:bar@host/apps/4') + + body = u'{"time_ms": 1000000}' + signature = six.text_type(hmac.new(pusher.config.secret.encode('utf8'), body.encode('utf8'), hashlib.sha256).hexdigest()) + + with mock.patch('time.time', return_value=1301): + self.assertEqual(pusher.validate_webhook(pusher.config.key, signature, body), None) + def test_channels_info_default_success_case(self): request = self.pusher.channels_info.make_request() From 2a5473416212bff8a954078e40c20f29d30198c7 Mon Sep 17 00:00:00 2001 From: leggetter Date: Tue, 31 Mar 2015 20:21:45 +0100 Subject: [PATCH 06/39] pusher.conf refs to pusher.config --- pusher_tests/test_pusher.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/pusher_tests/test_pusher.py b/pusher_tests/test_pusher.py index 6ed0c44..5ab4dec 100644 --- a/pusher_tests/test_pusher.py +++ b/pusher_tests/test_pusher.py @@ -37,19 +37,19 @@ def test_secret_should_be_text(self): def test_initialize_from_env(self): with mock.patch.object(os, 'environ', new={'PUSHER_URL':'https://plah:bob@somehost/apps/42'}): pusher = Pusher.from_env() - self.assertEqual(pusher.conf.ssl, True) - self.assertEqual(pusher.conf.key, u'plah') - self.assertEqual(pusher.conf.secret, u'bob') - self.assertEqual(pusher.conf.host, u'somehost') - self.assertEqual(pusher.conf.app_id, u'42') + self.assertEqual(pusher.config.ssl, True) + self.assertEqual(pusher.config.key, u'plah') + self.assertEqual(pusher.config.secret, u'bob') + self.assertEqual(pusher.config.host, u'somehost') + self.assertEqual(pusher.config.app_id, u'42') with mock.patch.object(os, 'environ', new={'PUSHER_DSN':'https://plah:bob@somehost/apps/42'}): pusher = Pusher.from_env('PUSHER_DSN') - self.assertEqual(pusher.conf.ssl, True) - self.assertEqual(pusher.conf.key, u'plah') - self.assertEqual(pusher.conf.secret, u'bob') - self.assertEqual(pusher.conf.host, u'somehost') - self.assertEqual(pusher.conf.app_id, u'42') + self.assertEqual(pusher.config.ssl, True) + self.assertEqual(pusher.config.key, u'plah') + self.assertEqual(pusher.config.secret, u'bob') + self.assertEqual(pusher.config.host, u'somehost') + self.assertEqual(pusher.config.app_id, u'42') def test_trigger_success_case(self): json_dumped = u'{"message": "hello world"}' From c836f1bd6b802951a0ac620e394f4ff02ca8842f Mon Sep 17 00:00:00 2001 From: leggetter Date: Tue, 31 Mar 2015 20:50:50 +0100 Subject: [PATCH 07/39] Use functions on Pusher and not Config --- README.md | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index d931212..96fb81c 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,8 @@ constructor arguments which identify your Pusher app. You can find them by going to "API Keys" on your app at https://app.pusher.com. ```python -from pusher import Config, Pusher -pusher = Pusher(config=Config(app_id=u'4', key=u'key', secret=u'secret')) +from pusher import Pusher +pusher = Pusher(app_id=u'4', key=u'key', secret=u'secret') ``` You can then trigger events to channels. Channel and event names may only @@ -159,13 +159,13 @@ pusher.users_info(u'presence-chatroom') Authenticating Channel Subscription ----------------- -#### `Config::authenticate_subscription` +#### `Pusher::authenticate` In order for users to subscribe to a private- or presence-channel, they must be authenticated by your server. The client will make a POST request to an endpoint (either "/pusher/auth" or any which you specify) with a body consisting of the channel's name and socket_id. -Using your `Config` instance, with which you initialized `Pusher`, you can generate an authentication signature. Having responded to the request with this signature, the subscription will be authenticated. +Using your `Pusher` instance, with which you initialized `Pusher`, you can generate an authentication signature. Having responded to the request with this signature, the subscription will be authenticated. |Argument |Description | |:-:|:-:| @@ -182,8 +182,7 @@ Using your `Config` instance, with which you initialized `Pusher`, you can gener ###### Private Channels ```python -config = pusher.config -auth = config.authenticate_subscription( +auth = pusher.authenticate_subscription( channel=u"private-channel", @@ -195,9 +194,7 @@ auth = config.authenticate_subscription( ###### Presence Channels ```python -config = pusher.config - -auth = config.authenticate_subscription( +auth = pusher.authenticate_subscription( channel=u"presence-channel", @@ -216,9 +213,9 @@ auth = config.authenticate_subscription( Receiving Webhooks ----------------- -If you have webhooks set up to POST a payload to a specified endpoint, you may wish to validate that these are actually from Pusher. The `Config` object achieves this by checking the authentication signature in the request body using your application credentials. +If you have webhooks set up to POST a payload to a specified endpoint, you may wish to validate that these are actually from Pusher. The `Pusher` object achieves this by checking the authentication signature in the request body using your application credentials. -#### `Config::validate_webhook` +#### `Pusher::validate_webhook` |Argument |Description | |:-:|:-:| @@ -233,7 +230,7 @@ If you have webhooks set up to POST a payload to a specified endpoint, you may w ##### Example ```python -webhook = pusher.config.validate_webhook( +webhook = pusher.validate_webhook( key="key_sent_in_header", @@ -253,4 +250,4 @@ To run the tests run `python setup.py test` License ------- -Copyright (c) 2014 Pusher Ltd. See LICENSE for details. +Copyright (c) 2015 Pusher Ltd. See LICENSE for details. From ec16a0d19e33ff232383fa763260e6f776b36641 Mon Sep 17 00:00:00 2001 From: leggetter Date: Tue, 31 Mar 2015 22:28:05 +0100 Subject: [PATCH 08/39] Adding additional configuration docs --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README.md b/README.md index 96fb81c..341bd26 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,32 @@ contain alphanumeric characters, `-` and `_`: pusher.trigger(u'a_channel', u'an_event', {u'some': u'data'}) ``` +## Configuration + +```python +from pusher import Pusher, Config +pusher = Pusher(app_id, key, secret, config=None, backend=None) +``` + +|Argument |Description | +|:-:|:-:| +|app_id `String` |**Required**
The Pusher application ID | +|key `String` |**Required**
The Pusher application key | +|secret `String` |**Required**
The Pusher application secret token | +|config `Config` | **Default:`None`**
Additional Configuration via a `Config` object | +|config.ssl `bool` | **Default:`False`**
Use HTTPS | +|config.host `String` | **Default:`None`**
The host to connect to | +|config.port `int` | **Default:`None`**
Which port to connect to | +|config.cluster `String` | **Default:`None`**
Convention for other clusters than the main Pusher-one. Eg: 'eu' will resolve to the api-eu.pusherapp.com host | +|backend `Object` | an object that responds to the send_request(request) method. If none is provided, a `python.sync.SynchronousBackend` instance is created. | + +##### Example + +```py +from pusher import Pusher, Config +pusher = Pusher(app_id=u'4', key=u'key', secret=u'secret', Config(ssl=True, cluster=u'eu')) +``` + Triggering Events ----------------- From 9b3439f9d8cfee05c3cdc923e4ba86ab5658db1d Mon Sep 17 00:00:00 2001 From: zimbatm Date: Wed, 1 Apr 2015 08:20:10 +0100 Subject: [PATCH 09/39] Move signature code into it's own module --- pusher/pusher.py | 17 +++-------------- pusher/request.py | 4 ++-- pusher/signature.py | 27 +++++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 16 deletions(-) create mode 100644 pusher/signature.py diff --git a/pusher/pusher.py b/pusher/pusher.py index c7f974b..a3510c5 100644 --- a/pusher/pusher.py +++ b/pusher/pusher.py @@ -4,6 +4,7 @@ division) from pusher.config import Config from pusher.request import Request +from pusher.signature import sign, verify from pusher.sync import SynchronousBackend from pusher.util import GET, POST, text, validate_channel, app_id_re, channel_name_re @@ -11,19 +12,9 @@ import collections import json import six -import hmac import hashlib import time -try: - compare_digest = hmac.compare_digest -except AttributeError: - # Not secure when the length is supposed to be kept secret - def compare_digest(a, b): - if len(a) != len(b): - return False - return reduce(lambda x, y: x | y, [ord(x) ^ ord(y) for x, y in zip(a, b)]) == 0 - class RequestMethod(object): def __init__(self, pusher, f): self.pusher = pusher @@ -197,7 +188,7 @@ def authenticate(self, channel, socket_id, custom_data=None): if custom_data: string_to_sign += ":%s" % custom_data - signature = hmac.new(self.config.secret.encode('utf8'), string_to_sign.encode('utf8'), hashlib.sha256).hexdigest() + signature = sign(self.config.secret, string_to_sign) auth = "%s:%s" % (self.config.key, signature) result = {'auth': auth} @@ -227,9 +218,7 @@ def validate_webhook(self, key, signature, body): if key != self.config.key: return None - generated_signature = six.text_type(hmac.new(self.config.secret.encode('utf8'), body.encode('utf8'), hashlib.sha256).hexdigest()) - - if not compare_digest(generated_signature, signature): + if not verify(self.config.secret, body, signature): return None try: diff --git a/pusher/request.py b/pusher/request.py index 9ff108a..f668f43 100644 --- a/pusher/request.py +++ b/pusher/request.py @@ -3,10 +3,10 @@ from __future__ import (print_function, unicode_literals, absolute_import, division) from pusher.util import GET, POST +from pusher.signature import sign import copy import hashlib -import hmac import json import six import time @@ -58,7 +58,7 @@ def _generate_auth(self): secret = self.config.secret.encode('utf8') message = auth_string.encode('utf8') - self.query_params['auth_signature'] = six.text_type(hmac.new(secret, message, hashlib.sha256).hexdigest()) + self.query_params['auth_signature'] = sign(secret, message) @property def query_string(self): diff --git a/pusher/signature.py b/pusher/signature.py new file mode 100644 index 0000000..5b60d63 --- /dev/null +++ b/pusher/signature.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- + +import hashlib +import hmac +import six + +try: + compare_digest = hmac.compare_digest +except AttributeError: + # Not secure when the length is supposed to be kept secret + def compare_digest(a, b): + if len(a) != len(b): + return False + return reduce(lambda x, y: x | y, [ord(x) ^ ord(y) for x, y in zip(a, b)]) == 0 + +def sign(secret, string_to_sign): + return six.text_type( + hmac.new( + secret.encode('utf8'), + string_to_sign.encode('utf8'), + hashlib.sha256 + ) + .hexdigest() + ) + +def verify(secret, string_to_sign, signature): + return compare_digest(signature, sign(secret, string_to_sign)) From 773950fd207bbf27555778ead93c0e275b93614b Mon Sep 17 00:00:00 2001 From: zimbatm Date: Wed, 1 Apr 2015 08:20:24 +0100 Subject: [PATCH 10/39] Remove artificial restriction on sync+SSL --- pusher/sync.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/pusher/sync.py b/pusher/sync.py index 70ff9f4..51f7ac1 100644 --- a/pusher/sync.py +++ b/pusher/sync.py @@ -19,9 +19,6 @@ def __init__(self, config, timeout=socket._GLOBAL_DEFAULT_TIMEOUT): self.config = config self.timeout = timeout if config.ssl: - if sys.version_info < (3,4): - raise NotImplementedError("SSL requires python >= 3.4, earlier versions don't support certificate validation") - ctx = ssl.create_default_context() self.http = http_client.HTTPSConnection(self.config.host, self.config.port, timeout=self.timeout, context=ctx) else: From 4a07ced13ad0f2e907125d1ed4fa1cacb125d911 Mon Sep 17 00:00:00 2001 From: zimbatm Date: Wed, 1 Apr 2015 08:22:46 +0100 Subject: [PATCH 11/39] Move request_method to pusher.request module --- pusher/pusher.py | 26 +------------------------- pusher/request.py | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/pusher/pusher.py b/pusher/pusher.py index a3510c5..e2d31a9 100644 --- a/pusher/pusher.py +++ b/pusher/pusher.py @@ -3,7 +3,7 @@ from __future__ import (print_function, unicode_literals, absolute_import, division) from pusher.config import Config -from pusher.request import Request +from pusher.request import Request, request_method from pusher.signature import sign, verify from pusher.sync import SynchronousBackend from pusher.util import GET, POST, text, validate_channel, app_id_re, channel_name_re @@ -15,30 +15,6 @@ import hashlib import time -class RequestMethod(object): - def __init__(self, pusher, f): - self.pusher = pusher - self.f = f - - def __call__(self, *args, **kwargs): - return self.pusher.backend.send_request(self.make_request(*args, **kwargs)) - - def make_request(self, *args, **kwargs): - return self.f(self.pusher, *args, **kwargs) - -def doc_string(doc): - def decorator(f): - f.__doc__ = doc - return f - return decorator - -def request_method(f): - @property - @doc_string(f.__doc__) - def wrapped(self): - return RequestMethod(self, f) - return wrapped - def join_attributes(attributes): for attr in attributes: if not isinstance(attr, six.text_type): diff --git a/pusher/request.py b/pusher/request.py index f668f43..20da5e7 100644 --- a/pusher/request.py +++ b/pusher/request.py @@ -11,6 +11,30 @@ import six import time +class RequestMethod(object): + def __init__(self, pusher, f): + self.pusher = pusher + self.f = f + + def __call__(self, *args, **kwargs): + return self.pusher.backend.send_request(self.make_request(*args, **kwargs)) + + def make_request(self, *args, **kwargs): + return self.f(self.pusher, *args, **kwargs) + +def doc_string(doc): + def decorator(f): + f.__doc__ = doc + return f + return decorator + +def request_method(f): + @property + @doc_string(f.__doc__) + def wrapped(self): + return RequestMethod(self, f) + return wrapped + def make_query_string(params): return '&'.join(map('='.join, sorted(params.items(), key=lambda x: x[0]))) From 9c56ccee19ca6b4f350b7be3596594e97b61ab6f Mon Sep 17 00:00:00 2001 From: zimbatm Date: Wed, 1 Apr 2015 08:50:58 +0100 Subject: [PATCH 12/39] Get rid of the Config object --- README.md | 13 +++-- pusher/__init__.py | 2 - pusher/aiohttp.py | 13 +---- pusher/config.py | 93 ------------------------------------ pusher/pusher.py | 85 +++++++++++++++++++------------- pusher/request.py | 5 +- pusher/requests.py | 5 +- pusher/sync.py | 19 ++++---- pusher/tornado.py | 12 ++--- pusher_tests/test_config.py | 64 ++++++++++++------------- pusher_tests/test_pusher.py | 34 ++++++------- pusher_tests/test_request.py | 6 +-- 12 files changed, 132 insertions(+), 219 deletions(-) delete mode 100644 pusher/config.py diff --git a/README.md b/README.md index 341bd26..d226cc1 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ pusher.trigger(u'a_channel', u'an_event', {u'some': u'data'}) ## Configuration ```python -from pusher import Pusher, Config +from pusher import Pusher pusher = Pusher(app_id, key, secret, config=None, backend=None) ``` @@ -62,12 +62,11 @@ pusher = Pusher(app_id, key, secret, config=None, backend=None) |app_id `String` |**Required**
The Pusher application ID | |key `String` |**Required**
The Pusher application key | |secret `String` |**Required**
The Pusher application secret token | -|config `Config` | **Default:`None`**
Additional Configuration via a `Config` object | -|config.ssl `bool` | **Default:`False`**
Use HTTPS | -|config.host `String` | **Default:`None`**
The host to connect to | -|config.port `int` | **Default:`None`**
Which port to connect to | -|config.cluster `String` | **Default:`None`**
Convention for other clusters than the main Pusher-one. Eg: 'eu' will resolve to the api-eu.pusherapp.com host | -|backend `Object` | an object that responds to the send_request(request) method. If none is provided, a `python.sync.SynchronousBackend` instance is created. | +|host `String` | **Default:`None`**
The host to connect to | +|port `int` | **Default:`None`**
Which port to connect to | +|ssl `bool` | **Default:`False`**
Use HTTPS | +|cluster `String` | **Default:`None`**
Convention for other clusters than the main Pusher-one. Eg: 'eu' will resolve to the api-eu.pusherapp.com host | +|backend `Object` | an object that responds to the send_request(request) method. If none is provided, a `python.sync.SynchronousBackend` instance is created. | ##### Example diff --git a/pusher/__init__.py b/pusher/__init__.py index 9a313d7..8cc9630 100644 --- a/pusher/__init__.py +++ b/pusher/__init__.py @@ -1,9 +1,7 @@ # -*- coding: utf-8 -*- -from .config import Config from .pusher import Pusher __all__ = [ - 'Config', 'Pusher', ] diff --git a/pusher/aiohttp.py b/pusher/aiohttp.py index f43eabb..8eedbff 100644 --- a/pusher/aiohttp.py +++ b/pusher/aiohttp.py @@ -4,20 +4,11 @@ import pusher class AsyncIOBackend: - """Adapter for the aiohttp module. - - This backend is only availble for python 3 users and doesn't support SSL. - - :param config: pusher.Config instance - """ - def __init__(self, config): - self.config = config + def send_request(_, config, request): if config.ssl: raise NotImplementedError("SSL not supported for this backend") - - def send_request(self, request): method = request.method - url = "http://%s:%s%s" % (self.config.host, self.config.port, request.path) + url = "http://%s:%s%s" % (config.host, config.port, request.path) params = request.query_params data = request.body headers = request.headers diff --git a/pusher/config.py b/pusher/config.py deleted file mode 100644 index 3975456..0000000 --- a/pusher/config.py +++ /dev/null @@ -1,93 +0,0 @@ -# -*- coding: utf-8 -*- - -from __future__ import (print_function, unicode_literals, absolute_import, - division) -from pusher.util import app_id_re, text - -import json -import re -import six - -class Config(object): - """The Config class holds the pusher credentials and other connection - infos to the HTTP API. - - :param app_id: The Pusher application ID - :param key: The Pusher application key - :param secret: The Pusher application secret - :param ssl: Whenever to use SSL or plain HTTP - :param host: Used for custom host destination - :param port: Used for custom port destination - :param cluster: Convention for other clusters than the main Pusher-one. - Eg: 'eu' will resolve to the api-eu.pusherapp.com host - - Usage:: - - >> from pusher import Config - >> c = Config('455', 'mykey', 'mysecret') - """ - def __init__(self, app_id=None, key=None, secret=None, ssl=False, host=None, port=None, cluster=None): - if app_id: - if not isinstance(app_id, six.text_type): - raise TypeError("App ID should be %s" % text) - if not app_id_re.match(app_id): - raise ValueError("Invalid app id") - - self.app_id = app_id - - if key: - if not isinstance(key, six.text_type): - raise TypeError("Key should be %s" % text) - - self.key = key - - if secret: - if not isinstance(secret, six.text_type): - raise TypeError("Secret should be %s" % text) - - self.secret = secret - - if port and not isinstance(port, six.integer_types): - raise TypeError("Port should be a number") - - if not isinstance(ssl, bool): - raise TypeError("SSL should be a boolean") - - if host: - if not isinstance(host, six.text_type): - raise TypeError("Host should be %s" % text) - - self.host = host - elif cluster: - if not isinstance(cluster, six.text_type): - raise TypeError("Cluster should be %s" % text) - - self.host = "api-%s.pusher.com" % cluster - else: - self.host = "api.pusherapp.com" - - self.port = port or (443 if ssl else 80) - self.ssl = ssl - - @classmethod - def from_url(cls, url): - """Alternate constructor that extracts the information from a URL. - - :param url: String containing a URL - - Usage:: - - >> from pusher import Config - >> c = Config.from_url("http://mykey:mysecret@api.pusher.com/apps/432") - """ - m = re.match("(http|https)://(.*):(.*)@(.*)/apps/([0-9]+)", url) - if not m: - raise Exception("Unparsable url: %s" % url) - ssl = m.group(1) == 'https' - return cls(key=m.group(2), secret=m.group(3), host=m.group(4), app_id=m.group(5), ssl=ssl) - - - @property - def scheme(self): - """Returns "http" or "https" scheme depending on the ssl setting.""" - return 'https' if self.ssl else 'http' diff --git a/pusher/pusher.py b/pusher/pusher.py index e2d31a9..b9765b4 100644 --- a/pusher/pusher.py +++ b/pusher/pusher.py @@ -2,17 +2,17 @@ from __future__ import (print_function, unicode_literals, absolute_import, division) -from pusher.config import Config from pusher.request import Request, request_method from pusher.signature import sign, verify from pusher.sync import SynchronousBackend from pusher.util import GET, POST, text, validate_channel, app_id_re, channel_name_re -import os import collections +import hashlib import json +import os +import re import six -import hashlib import time def join_attributes(attributes): @@ -28,39 +28,57 @@ class Pusher(object): This client supports various backend adapters to support various http libraries available in the python ecosystem. - :param app_id: a pusher application identifier - :param key: a pusher application key - :param secret: a pusher application secret token - :param config: a pusher.Config instance + :param app_id: a pusher application identifier + :param key: a pusher application key + :param secret: a pusher application secret token + :param ssl: Whenever to use SSL or plain HTTP + :param host: Used for custom host destination + :param port: Used for custom port destination + :param timeout: Request timeout + :param cluster: Convention for other clusters than the main Pusher-one. + Eg: 'eu' will resolve to the api-eu.pusherapp.com host :param backend: an object that responds to the send_request(request) method. If none is provided, a python.sync.SynchronousBackend instance is created. """ - def __init__(self, app_id, key, secret, config=None, backend=None): + def __init__(self, app_id, key, secret, ssl=True, host=None, port=None, timeout=None, cluster=None, backend=None): if not isinstance(app_id, six.text_type): raise TypeError("App ID should be %s" % text) if not app_id_re.match(app_id): raise ValueError("Invalid app id") + self.app_id = app_id if not isinstance(key, six.text_type): raise TypeError("Key should be %s" % text) + self.key = key if not isinstance(secret, six.text_type): raise TypeError("Secret should be %s" % text) - - if not config: - config=Config(app_id=app_id, key=key, secret=secret) + self.secret = secret + + if not isinstance(ssl, bool): + raise TypeError("SSL should be a boolean") + self.ssl = ssl + + if host: + if not isinstance(host, six.text_type): + raise TypeError("Host should be %s" % text) + + self.host = host + elif cluster: + if not isinstance(cluster, six.text_type): + raise TypeError("Cluster should be %s" % text) + + self.host = "api-%s.pusher.com" % cluster else: - config.app_id=app_id - config.key=key - config.secret=secret - - if not isinstance(config, Config): - raise TypeError("config should be a pusher.Config object") - - self.backend = backend or SynchronousBackend(config) - self.config = config + self.host = "api.pusherapp.com" + + if port and not isinstance(port, six.integer_types): + raise TypeError("Port should be a number") + self.port = port or (443 if ssl else 80) + + self.backend = backend or SynchronousBackend() @classmethod def from_url(cls, url): @@ -73,9 +91,11 @@ def from_url(cls, url): >> from pusher import Pusher >> p = Pusher.from_url("http://mykey:mysecret@api.pusher.com/apps/432") """ - config=Config.from_url(url) - - return cls(config.app_id, config.key, config.secret, config) + m = re.match("(http|https)://(.*):(.*)@(.*)/apps/([0-9]+)", url) + if not m: + raise Exception("Unparsable url: %s" % url) + ssl = m.group(1) == 'https' + return cls(key=m.group(2), secret=m.group(3), host=m.group(4), app_id=m.group(5), ssl=ssl) @classmethod def from_env(cls, env='PUSHER_URL'): @@ -94,8 +114,7 @@ def from_env(cls, env='PUSHER_URL'): if not val: raise Exception("Environment variable %s not found" % env) - config=Config.from_url(six.text_type(val)) - return cls(config.app_id, config.key, config.secret, config) + return cls.from_url(six.text_type(val)) @request_method def trigger(self, channels, event_name, data, socket_id=None): @@ -138,7 +157,7 @@ def trigger(self, channels, event_name, data, socket_id=None): if not isinstance(socket_id, six.text_type): raise TypeError("Socket ID should be %s" % text) params['socket_id'] = socket_id - return Request(self.config, POST, "/apps/%s/events" % self.config.app_id, params) + return Request(self, POST, "/apps/%s/events" % self.app_id, params) def authenticate(self, channel, socket_id, custom_data=None): """Used to generate delegated client subscription token. @@ -164,9 +183,9 @@ def authenticate(self, channel, socket_id, custom_data=None): if custom_data: string_to_sign += ":%s" % custom_data - signature = sign(self.config.secret, string_to_sign) + signature = sign(self.secret, string_to_sign) - auth = "%s:%s" % (self.config.key, signature) + auth = "%s:%s" % (self.key, signature) result = {'auth': auth} if custom_data: @@ -191,10 +210,10 @@ def validate_webhook(self, key, signature, body): if not isinstance(body, six.text_type): raise TypeError('body should be %s' % text) - if key != self.config.key: + if key != self.key: return None - if not verify(self.config.secret, body, signature): + if not verify(self.secret, body, signature): return None try: @@ -224,7 +243,7 @@ def channels_info(self, prefix_filter=None, attributes=[]): params['info'] = join_attributes(attributes) if prefix_filter: params['filter_by_prefix'] = prefix_filter - return Request(self.config, GET, "/apps/%s/channels" % self.config.app_id, params) + return Request(self, GET, "/apps/%s/channels" % self.app_id, params) @request_method def channel_info(self, channel, attributes=[]): @@ -238,7 +257,7 @@ def channel_info(self, channel, attributes=[]): params = {} if attributes: params['info'] = join_attributes(attributes) - return Request(self.config, GET, "/apps/%s/channels/%s" % (self.config.app_id, channel), params) + return Request(self, GET, "/apps/%s/channels/%s" % (self.app_id, channel), params) @request_method def users_info(self, channel): @@ -249,4 +268,4 @@ def users_info(self, channel): ''' validate_channel(channel) - return Request(self.config, GET, "/apps/%s/channels/%s/users" % (self.config.app_id, channel)) + return Request(self, GET, "/apps/%s/channels/%s/users" % (self.app_id, channel)) diff --git a/pusher/request.py b/pusher/request.py index 20da5e7..1d3a107 100644 --- a/pusher/request.py +++ b/pusher/request.py @@ -17,7 +17,7 @@ def __init__(self, pusher, f): self.f = f def __call__(self, *args, **kwargs): - return self.pusher.backend.send_request(self.make_request(*args, **kwargs)) + return self.pusher.backend.send_request(self.pusher, self.make_request(*args, **kwargs)) def make_request(self, *args, **kwargs): return self.f(self.pusher, *args, **kwargs) @@ -44,7 +44,7 @@ class Request(object): An instance of that object is passed to the backend's send_request method for each request. - :param config: an instance of pusher.Config + :param config: an instance of pusher.Pusher :param method: HTTP method as a string :param path: The target path on the destination host :param params: Query params or body depending on the method @@ -94,6 +94,7 @@ def signed_path(self): @property def url(self): + scheme = 'https' if self.config.ssl else 'http' return "%s://%s:%s%s" % (self.config.scheme, self.config.host, self.config.port, self.signed_path) @property diff --git a/pusher/requests.py b/pusher/requests.py index 520d904..1c551a5 100644 --- a/pusher/requests.py +++ b/pusher/requests.py @@ -9,14 +9,13 @@ class RequestsBackend(object): """Adapter for the requests module. - :param config: an instance of pusher.Config :param options: key-value passed into the requests.request constructor """ - def __init__(self, config, **options): + def __init__(self, **options): self.options = {'verify': True} self.options.update(options) - def send_request(self, request): + def send_request(self, config, request): resp = requests.request( request.method, request.url, diff --git a/pusher/sync.py b/pusher/sync.py index 51f7ac1..badb1f9 100644 --- a/pusher/sync.py +++ b/pusher/sync.py @@ -12,19 +12,20 @@ class SynchronousBackend(object): """Adapter for the standard-library http client. - :param config: an instance of pusher.Config :param timeout: configurable timeout for the TCP connection """ - def __init__(self, config, timeout=socket._GLOBAL_DEFAULT_TIMEOUT): - self.config = config + def __init__(self, timeout=socket._GLOBAL_DEFAULT_TIMEOUT): self.timeout = timeout - if config.ssl: - ctx = ssl.create_default_context() - self.http = http_client.HTTPSConnection(self.config.host, self.config.port, timeout=self.timeout, context=ctx) - else: - self.http = http_client.HTTPConnection(self.config.host, self.config.port, timeout=self.timeout) + self.http = None + + def send_request(self, config, request): + if self.http is None: + if config.ssl: + ctx = ssl.create_default_context() + self.http = http_client.HTTPSConnection(config.host, config.port, timeout=self.timeout, context=ctx) + else: + self.http = http_client.HTTPConnection(config.host, config.port, timeout=self.timeout) - def send_request(self, request): try: self.http.request(request.method, request.signed_path, request.body, {"Content-Type": "application/json"}) resp = self.http.getresponse() diff --git a/pusher/tornado.py b/pusher/tornado.py index 3ccf439..a1dfdc0 100644 --- a/pusher/tornado.py +++ b/pusher/tornado.py @@ -9,18 +9,16 @@ class TornadoBackend(object): """Adapter for the tornado.httpclient module. - :param config: a pusher.Config instance :param timeout: configurable timeout for the connection """ - def __init__(self, config, timeout=None): - self.config = config + def __init__(self, timeout=None): self.timeout = timeout - if self.config.ssl: - raise NotImplementedError("SSL not supported for this backend") - def send_request(self, request): + def send_request(self, config, request): + if config.ssl: + raise NotImplementedError("SSL not supported for this backend") method = request.method - url = "http://%s:%s%s?%s" % (self.config.host, self.config.port, request.path, request.query_string) + url = "http://%s:%s%s?%s" % (config.host, config.port, request.path, request.query_string) data = request.body headers = {'Content-Type': 'application/json'} diff --git a/pusher_tests/test_config.py b/pusher_tests/test_config.py index 267ed71..9b76e41 100644 --- a/pusher_tests/test_config.py +++ b/pusher_tests/test_config.py @@ -8,7 +8,7 @@ import six import unittest -from pusher import Config +from pusher import Pusher try: import unittest.mock as mock @@ -17,82 +17,82 @@ class TestConfig(unittest.TestCase): def test_should_be_constructable(self): - Config(app_id=u'4', key=u'key', secret=u'secret', ssl=False) + Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=False) def test_app_id_should_be_text_if_present(self): - self.assertRaises(TypeError, lambda: Config(app_id=4, key=u'key', secret=u'secret', ssl=False)) - self.assertRaises(TypeError, lambda: Config(app_id=b'4', key=u'key', secret=u'secret', ssl=False)) + self.assertRaises(TypeError, lambda: Pusher(app_id=4, key=u'key', secret=u'secret', ssl=False)) + self.assertRaises(TypeError, lambda: Pusher(app_id=b'4', key=u'key', secret=u'secret', ssl=False)) def test_key_should_be_text_if_present(self): - self.assertRaises(TypeError, lambda: Config(app_id=u'4', key=4, secret=u'secret', ssl=False)) - self.assertRaises(TypeError, lambda: Config(app_id=u'4', key=b'key', secret=u'secret', ssl=False)) + self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=4, secret=u'secret', ssl=False)) + self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=b'key', secret=u'secret', ssl=False)) def test_secret_should_be_text_if_present(self): - self.assertRaises(TypeError, lambda: Config(app_id=u'4', key=u'key', secret=4, ssl=False)) - self.assertRaises(TypeError, lambda: Config(app_id=u'4', key=u'key', secret=b'secret', ssl=False)) + self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=u'key', secret=4, ssl=False)) + self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=u'key', secret=b'secret', ssl=False)) def test_ssl_should_be_required(self): - self.assertRaises(TypeError, lambda: Config(app_id=u'4', key=u'key', secret=b'secret')) + self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=u'key', secret=b'secret')) def test_ssl_should_be_boolean(self): - Config(app_id=u'4', key=u'key', secret=u'secret', ssl=False) - Config(app_id=u'4', key=u'key', secret=u'secret', ssl=True) + Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=False) + Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True) - self.assertRaises(TypeError, lambda: Config(app_id=u'4', key=u'key', secret=u'secret', ssl=4)) + self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=4)) def test_host_should_be_text(self): - Config(app_id=u'4', key=u'key', secret=u'secret', ssl=True, host=u'foo') + Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True, host=u'foo') - self.assertRaises(TypeError, lambda: Config(app_id=u'4', key=u'key', secret=u'secret', ssl=True, host=b'foo')) - self.assertRaises(TypeError, lambda: Config(app_id=u'4', key=u'key', secret=u'secret', ssl=True, host=4)) + self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True, host=b'foo')) + self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True, host=4)) def test_port_should_be_number(self): - Config(app_id=u'4', key=u'key', secret=u'secret', ssl=True, port=400) + Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True, port=400) - self.assertRaises(TypeError, lambda: Config(app_id=u'4', key=u'key', secret=u'secret', ssl=True, port=u'400')) + self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True, port=u'400')) def test_cluster_should_be_text(self): - Config(app_id=u'4', key=u'key', secret=u'secret', ssl=True, cluster=u'eu') + Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True, cluster=u'eu') - self.assertRaises(TypeError, lambda: Config(app_id=u'4', key=u'key', secret=u'secret', ssl=True, cluster=b'eu')) - self.assertRaises(TypeError, lambda: Config(app_id=u'4', key=u'key', secret=u'secret', ssl=True, cluster=4)) + self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True, cluster=b'eu')) + self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True, cluster=4)) def test_host_behaviour(self): - conf = Config(app_id=u'4', key=u'key', secret=u'secret', ssl=True) + conf = Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True) self.assertEqual(conf.host, u'api.pusherapp.com', u'default host should be correct') - conf = Config(app_id=u'4', key=u'key', secret=u'secret', ssl=True, cluster=u'eu') + conf = Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True, cluster=u'eu') self.assertEqual(conf.host, u'api-eu.pusher.com', u'host should be overriden by cluster setting') - conf = Config(app_id=u'4', key=u'key', secret=u'secret', ssl=True, host=u'foo') + conf = Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True, host=u'foo') self.assertEqual(conf.host, u'foo', u'host should be overriden by host setting') - conf = Config(app_id=u'4', key=u'key', secret=u'secret', ssl=True, cluster=u'eu', host=u'plah') + conf = Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True, cluster=u'eu', host=u'plah') self.assertEqual(conf.host, u'plah', u'host should be used in preference to cluster') def test_port_behaviour(self): - conf = Config(app_id=u'4', key=u'key', secret=u'secret', ssl=True) + conf = Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True) self.assertEqual(conf.port, 443, u'port should be 443 for ssl') - conf = Config(app_id=u'4', key=u'key', secret=u'secret', ssl=False) + conf = Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=False) self.assertEqual(conf.port, 80, u'port should be 80 for non ssl') - conf = Config(app_id=u'4', key=u'key', secret=u'secret', ssl=False, port=4000) + conf = Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=False, port=4000) self.assertEqual(conf.port, 4000, u'the port setting override the default') def test_initialize_from_url(self): - self.assertRaises(TypeError, lambda: Config.from_url(4)) - self.assertRaises(TypeError, lambda: Config.from_url(b'http://foo:bar@host/apps/4')) - self.assertRaises(Exception, lambda: Config.from_url(u'httpsahsutaeh')) + self.assertRaises(TypeError, lambda: Pusher.from_url(4)) + self.assertRaises(TypeError, lambda: Pusher.from_url(b'http://foo:bar@host/apps/4')) + self.assertRaises(Exception, lambda: Pusher.from_url(u'httpsahsutaeh')) - conf = Config.from_url(u'http://foo:bar@host/apps/4') + conf = Pusher.from_url(u'http://foo:bar@host/apps/4') self.assertEqual(conf.ssl, False) self.assertEqual(conf.key, u'foo') self.assertEqual(conf.secret, u'bar') self.assertEqual(conf.host, u'host') self.assertEqual(conf.app_id, u'4') - conf = Config.from_url(u'https://foo:bar@host/apps/4') + conf = Pusher.from_url(u'https://foo:bar@host/apps/4') self.assertEqual(conf.ssl, True) self.assertEqual(conf.key, u'foo') self.assertEqual(conf.secret, u'bar') diff --git a/pusher_tests/test_pusher.py b/pusher_tests/test_pusher.py index b4268e1..e419d84 100644 --- a/pusher_tests/test_pusher.py +++ b/pusher_tests/test_pusher.py @@ -8,7 +8,7 @@ import hashlib import unittest -from pusher import Config, Pusher +from pusher import Pusher from pusher.util import GET try: @@ -37,19 +37,19 @@ def test_secret_should_be_text(self): def test_initialize_from_env(self): with mock.patch.object(os, 'environ', new={'PUSHER_URL':'https://plah:bob@somehost/apps/42'}): pusher = Pusher.from_env() - self.assertEqual(pusher.config.ssl, True) - self.assertEqual(pusher.config.key, u'plah') - self.assertEqual(pusher.config.secret, u'bob') - self.assertEqual(pusher.config.host, u'somehost') - self.assertEqual(pusher.config.app_id, u'42') + self.assertEqual(pusher.ssl, True) + self.assertEqual(pusher.key, u'plah') + self.assertEqual(pusher.secret, u'bob') + self.assertEqual(pusher.host, u'somehost') + self.assertEqual(pusher.app_id, u'42') with mock.patch.object(os, 'environ', new={'PUSHER_DSN':'https://plah:bob@somehost/apps/42'}): pusher = Pusher.from_env('PUSHER_DSN') - self.assertEqual(pusher.config.ssl, True) - self.assertEqual(pusher.config.key, u'plah') - self.assertEqual(pusher.config.secret, u'bob') - self.assertEqual(pusher.config.host, u'somehost') - self.assertEqual(pusher.config.app_id, u'42') + self.assertEqual(pusher.ssl, True) + self.assertEqual(pusher.key, u'plah') + self.assertEqual(pusher.secret, u'bob') + self.assertEqual(pusher.host, u'somehost') + self.assertEqual(pusher.app_id, u'42') def test_trigger_with_channels_list_success_case(self): json_dumped = u'{"message": "hello world"}' @@ -134,10 +134,10 @@ def test_validate_webhook_success_case(self): pusher = Pusher.from_url(u'http://foo:bar@host/apps/4') body = u'{"time_ms": 1000000}' - signature = six.text_type(hmac.new(pusher.config.secret.encode(u'utf8'), body.encode(u'utf8'), hashlib.sha256).hexdigest()) + signature = six.text_type(hmac.new(pusher.secret.encode(u'utf8'), body.encode(u'utf8'), hashlib.sha256).hexdigest()) with mock.patch('time.time', return_value=1200): - self.assertEqual(pusher.validate_webhook(pusher.config.key, signature, body), {u'time_ms': 1000000}) + self.assertEqual(pusher.validate_webhook(pusher.key, signature, body), {u'time_ms': 1000000}) def test_validate_webhook_bad_types(self): pusher = Pusher.from_url(u'http://foo:bar@host/apps/4') @@ -161,7 +161,7 @@ def test_validate_webhook_bad_key(self): pusher = Pusher.from_url(u'http://foo:bar@host/apps/4') body = u'some body' - signature = six.text_type(hmac.new(pusher.config.secret.encode(u'utf8'), body.encode(u'utf8'), hashlib.sha256).hexdigest()) + signature = six.text_type(hmac.new(pusher.secret.encode(u'utf8'), body.encode(u'utf8'), hashlib.sha256).hexdigest()) with mock.patch('time.time') as time_mock: self.assertEqual(pusher.validate_webhook(u'badkey', signature, body), None) @@ -175,7 +175,7 @@ def test_validate_webhook_bad_signature(self): signature = u'some signature' with mock.patch('time.time') as time_mock: - self.assertEqual(pusher.validate_webhook(pusher.config.key, signature, body), None) + self.assertEqual(pusher.validate_webhook(pusher.key, signature, body), None) time_mock.assert_not_called() @@ -183,10 +183,10 @@ def test_validate_webhook_bad_time(self): pusher = Pusher.from_url(u'http://foo:bar@host/apps/4') body = u'{"time_ms": 1000000}' - signature = six.text_type(hmac.new(pusher.config.secret.encode('utf8'), body.encode('utf8'), hashlib.sha256).hexdigest()) + signature = six.text_type(hmac.new(pusher.secret.encode('utf8'), body.encode('utf8'), hashlib.sha256).hexdigest()) with mock.patch('time.time', return_value=1301): - self.assertEqual(pusher.validate_webhook(pusher.config.key, signature, body), None) + self.assertEqual(pusher.validate_webhook(pusher.key, signature, body), None) def test_channels_info_default_success_case(self): request = self.pusher.channels_info.make_request() diff --git a/pusher_tests/test_request.py b/pusher_tests/test_request.py index e468870..84b6ca3 100644 --- a/pusher_tests/test_request.py +++ b/pusher_tests/test_request.py @@ -4,7 +4,7 @@ import unittest -from pusher import Config +from pusher import Pusher from pusher.request import Request try: @@ -14,7 +14,7 @@ class TestRequest(unittest.TestCase): def test_get_signature_generation(self): - conf = Config.from_url(u'http://key:secret@somehost/apps/4') + conf = Pusher.from_url(u'http://key:secret@somehost/apps/4') expected = { u'auth_key': u'key', @@ -30,7 +30,7 @@ def test_get_signature_generation(self): self.assertEqual(req.query_params, expected) def test_post_signature_generation(self): - conf = Config.from_url(u'http://key:secret@somehost/apps/4') + conf = Pusher.from_url(u'http://key:secret@somehost/apps/4') expected = { u'auth_key': u'key', From 90ab769f852858af2a28045fab6d319ab1257c61 Mon Sep 17 00:00:00 2001 From: zimbatm Date: Wed, 1 Apr 2015 08:56:13 +0100 Subject: [PATCH 13/39] Keep all the @request_method together --- pusher/pusher.py | 80 ++++++++++++++++++++++++------------------------ 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/pusher/pusher.py b/pusher/pusher.py index b9765b4..934ece0 100644 --- a/pusher/pusher.py +++ b/pusher/pusher.py @@ -158,7 +158,46 @@ def trigger(self, channels, event_name, data, socket_id=None): raise TypeError("Socket ID should be %s" % text) params['socket_id'] = socket_id return Request(self, POST, "/apps/%s/events" % self.app_id, params) - + + @request_method + def channels_info(self, prefix_filter=None, attributes=[]): + ''' + Get information on multiple channels, see: + + http://pusher.com/docs/rest_api#method-get-channels + ''' + params = {} + if attributes: + params['info'] = join_attributes(attributes) + if prefix_filter: + params['filter_by_prefix'] = prefix_filter + return Request(self, GET, "/apps/%s/channels" % self.app_id, params) + + @request_method + def channel_info(self, channel, attributes=[]): + ''' + Get information on a specific channel, see: + + http://pusher.com/docs/rest_api#method-get-channel + ''' + validate_channel(channel) + + params = {} + if attributes: + params['info'] = join_attributes(attributes) + return Request(self, GET, "/apps/%s/channels/%s" % (self.app_id, channel), params) + + @request_method + def users_info(self, channel): + ''' + Fetch user ids currently subscribed to a presence channel + + http://pusher.com/docs/rest_api#method-get-users + ''' + validate_channel(channel) + + return Request(self, GET, "/apps/%s/channels/%s/users" % (self.app_id, channel)) + def authenticate(self, channel, socket_id, custom_data=None): """Used to generate delegated client subscription token. @@ -230,42 +269,3 @@ def validate_webhook(self, key, signature, body): return None return body_data - - @request_method - def channels_info(self, prefix_filter=None, attributes=[]): - ''' - Get information on multiple channels, see: - - http://pusher.com/docs/rest_api#method-get-channels - ''' - params = {} - if attributes: - params['info'] = join_attributes(attributes) - if prefix_filter: - params['filter_by_prefix'] = prefix_filter - return Request(self, GET, "/apps/%s/channels" % self.app_id, params) - - @request_method - def channel_info(self, channel, attributes=[]): - ''' - Get information on a specific channel, see: - - http://pusher.com/docs/rest_api#method-get-channel - ''' - validate_channel(channel) - - params = {} - if attributes: - params['info'] = join_attributes(attributes) - return Request(self, GET, "/apps/%s/channels/%s" % (self.app_id, channel), params) - - @request_method - def users_info(self, channel): - ''' - Fetch user ids currently subscribed to a presence channel - - http://pusher.com/docs/rest_api#method-get-users - ''' - validate_channel(channel) - - return Request(self, GET, "/apps/%s/channels/%s/users" % (self.app_id, channel)) From 7e3fa88cc1175f5720bb0564050ed137c4b29dd0 Mon Sep 17 00:00:00 2001 From: zimbatm Date: Wed, 1 Apr 2015 09:00:40 +0100 Subject: [PATCH 14/39] Move errors into their own modules As a developer I would be looking for errors there --- pusher/errors.py | 16 ++++++++++++++++ pusher/util.py | 17 ++--------------- 2 files changed, 18 insertions(+), 15 deletions(-) create mode 100644 pusher/errors.py diff --git a/pusher/errors.py b/pusher/errors.py new file mode 100644 index 0000000..462e355 --- /dev/null +++ b/pusher/errors.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- + +class PusherError(Exception): + pass + +class PusherBadRequest(PusherError): + pass + +class PusherBadAuth(PusherError): + pass + +class PusherForbidden(PusherError): + pass + +class PusherBadStatus(PusherError): + pass \ No newline at end of file diff --git a/pusher/util.py b/pusher/util.py index d48d300..8d54a29 100644 --- a/pusher/util.py +++ b/pusher/util.py @@ -3,6 +3,8 @@ from __future__ import (print_function, unicode_literals, absolute_import, division) +from pusher.errors import * + import json import re import six @@ -18,21 +20,6 @@ else: text = 'a string' -class PusherError(Exception): - pass - -class PusherBadRequest(PusherError): - pass - -class PusherBadAuth(PusherError): - pass - -class PusherForbidden(PusherError): - pass - -class PusherBadStatus(PusherError): - pass - def process_response(status, body): if status == 200: return json.loads(body) From b0e2645b95aacc4f6aecda0866e8d67d34f8374f Mon Sep 17 00:00:00 2001 From: zimbatm Date: Wed, 1 Apr 2015 09:06:51 +0100 Subject: [PATCH 15/39] Move all the http abstractions into a single pusher.http module --- pusher/aiohttp.py | 5 +++-- pusher/{request.py => http.py} | 16 +++++++++++++++- pusher/pusher.py | 4 ++-- pusher/requests.py | 2 +- pusher/sync.py | 2 +- pusher/util.py | 16 ---------------- pusher_tests/test_pusher.py | 2 +- pusher_tests/test_request.py | 2 +- 8 files changed, 24 insertions(+), 25 deletions(-) rename pusher/{request.py => http.py} (87%) diff --git a/pusher/aiohttp.py b/pusher/aiohttp.py index 8eedbff..f856681 100644 --- a/pusher/aiohttp.py +++ b/pusher/aiohttp.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- import aiohttp -import pusher + +from pusher.http import process_response class AsyncIOBackend: def send_request(_, config, request): @@ -15,4 +16,4 @@ def send_request(_, config, request): response = yield from aiohttp.request(method, url, params=params, data=data, headers=headers) body = yield from response.read_and_close() - return pusher.process_response(response.status, body.decode('utf8')) + return process_response(response.status, body.decode('utf8')) diff --git a/pusher/request.py b/pusher/http.py similarity index 87% rename from pusher/request.py rename to pusher/http.py index 1d3a107..859c302 100644 --- a/pusher/request.py +++ b/pusher/http.py @@ -2,7 +2,7 @@ from __future__ import (print_function, unicode_literals, absolute_import, division) -from pusher.util import GET, POST +from pusher.errors import * from pusher.signature import sign import copy @@ -11,6 +11,8 @@ import six import time +GET, POST, PUT, DELETE = "GET", "POST", "PUT", "DELETE" + class RequestMethod(object): def __init__(self, pusher, f): self.pusher = pusher @@ -38,6 +40,18 @@ def wrapped(self): def make_query_string(params): return '&'.join(map('='.join, sorted(params.items(), key=lambda x: x[0]))) +def process_response(status, body): + if status == 200: + return json.loads(body) + elif status == 400: + raise PusherBadRequest(body) + elif status == 401: + raise PusherBadAuth(body) + elif status == 403: + raise PusherForbidden(body) + else: + raise PusherBadStatus("%s: %s" % (status, body)) + class Request(object): """Represents the request to be made to the Pusher API. diff --git a/pusher/pusher.py b/pusher/pusher.py index 934ece0..b186f96 100644 --- a/pusher/pusher.py +++ b/pusher/pusher.py @@ -2,10 +2,10 @@ from __future__ import (print_function, unicode_literals, absolute_import, division) -from pusher.request import Request, request_method +from pusher.http import GET, POST, Request, request_method from pusher.signature import sign, verify from pusher.sync import SynchronousBackend -from pusher.util import GET, POST, text, validate_channel, app_id_re, channel_name_re +from pusher.util import text, validate_channel, app_id_re, channel_name_re import collections import hashlib diff --git a/pusher/requests.py b/pusher/requests.py index 1c551a5..2860582 100644 --- a/pusher/requests.py +++ b/pusher/requests.py @@ -2,7 +2,7 @@ from __future__ import (print_function, unicode_literals, absolute_import, division) -from pusher.util import process_response +from pusher.http import process_response import requests diff --git a/pusher/sync.py b/pusher/sync.py index badb1f9..2da290d 100644 --- a/pusher/sync.py +++ b/pusher/sync.py @@ -2,7 +2,7 @@ from __future__ import (print_function, unicode_literals, absolute_import, division) -from pusher.util import PusherError, process_response +from pusher.http import PusherError, process_response from six.moves import http_client import socket diff --git a/pusher/util.py b/pusher/util.py index 8d54a29..430b2b0 100644 --- a/pusher/util.py +++ b/pusher/util.py @@ -3,8 +3,6 @@ from __future__ import (print_function, unicode_literals, absolute_import, division) -from pusher.errors import * - import json import re import six @@ -13,25 +11,11 @@ channel_name_re = re.compile('^[-a-zA-Z0-9_=@,.;]+$') app_id_re = re.compile('^[0-9]+$') -GET, POST, PUT, DELETE = "GET", "POST", "PUT", "DELETE" - if sys.version_info < (3,): text = 'a unicode string' else: text = 'a string' -def process_response(status, body): - if status == 200: - return json.loads(body) - elif status == 400: - raise PusherBadRequest(body) - elif status == 401: - raise PusherBadAuth(body) - elif status == 403: - raise PusherForbidden(body) - else: - raise PusherBadStatus("%s: %s" % (status, body)) - def validate_channel(channel): if not isinstance(channel, six.text_type): raise TypeError("Channel should be %s" % text) diff --git a/pusher_tests/test_pusher.py b/pusher_tests/test_pusher.py index e419d84..568ecee 100644 --- a/pusher_tests/test_pusher.py +++ b/pusher_tests/test_pusher.py @@ -9,7 +9,7 @@ import unittest from pusher import Pusher -from pusher.util import GET +from pusher.http import GET try: import unittest.mock as mock diff --git a/pusher_tests/test_request.py b/pusher_tests/test_request.py index 84b6ca3..21b1578 100644 --- a/pusher_tests/test_request.py +++ b/pusher_tests/test_request.py @@ -5,7 +5,7 @@ import unittest from pusher import Pusher -from pusher.request import Request +from pusher.http import Request try: import unittest.mock as mock From 329a75d3d8c0fa5c205c0563010b0d48670f30fd Mon Sep 17 00:00:00 2001 From: zimbatm Date: Wed, 1 Apr 2015 10:20:17 +0100 Subject: [PATCH 16/39] Restore two-step adapter initialisation Allows to configure persistent connections --- pusher/aiohttp.py | 12 ++++++++---- pusher/http.py | 9 ++++++--- pusher/pusher.py | 24 ++++++++++++++++++------ pusher/requests.py | 5 +++-- pusher/sync.py | 17 ++++++++--------- pusher/tornado.py | 10 ++++++---- 6 files changed, 49 insertions(+), 28 deletions(-) diff --git a/pusher/aiohttp.py b/pusher/aiohttp.py index f856681..e8e2513 100644 --- a/pusher/aiohttp.py +++ b/pusher/aiohttp.py @@ -5,11 +5,15 @@ from pusher.http import process_response class AsyncIOBackend: - def send_request(_, config, request): - if config.ssl: - raise NotImplementedError("SSL not supported for this backend") + def __init__(_, _): + """Adapter for the requests module. + + :param config: pusher.Pusher object + """ + + def send_request(_, request): method = request.method - url = "http://%s:%s%s" % (config.host, config.port, request.path) + url = "%s%s" % (request.base_url, request.path) params = request.query_params data = request.body headers = request.headers diff --git a/pusher/http.py b/pusher/http.py index 859c302..3c86258 100644 --- a/pusher/http.py +++ b/pusher/http.py @@ -19,7 +19,7 @@ def __init__(self, pusher, f): self.f = f def __call__(self, *args, **kwargs): - return self.pusher.backend.send_request(self.pusher, self.make_request(*args, **kwargs)) + return self.pusher.http.send_request(self.pusher, self.make_request(*args, **kwargs)) def make_request(self, *args, **kwargs): return self.f(self.pusher, *args, **kwargs) @@ -108,8 +108,11 @@ def signed_path(self): @property def url(self): - scheme = 'https' if self.config.ssl else 'http' - return "%s://%s:%s%s" % (self.config.scheme, self.config.host, self.config.port, self.signed_path) + return "%s%s" % (self.base_url, self.signed_path) + + @property + def base_url(self): + return "%s://%s:%s" % (self.config.scheme, self.config.host, self.config.port) @property def headers(self): diff --git a/pusher/pusher.py b/pusher/pusher.py index b186f96..4581477 100644 --- a/pusher/pusher.py +++ b/pusher/pusher.py @@ -37,11 +37,10 @@ class Pusher(object): :param timeout: Request timeout :param cluster: Convention for other clusters than the main Pusher-one. Eg: 'eu' will resolve to the api-eu.pusherapp.com host - :param backend: an object that responds to the send_request(request) - method. If none is provided, a - python.sync.SynchronousBackend instance is created. + :param backend: an http adapter class (AsyncIOBackend, RequestsBackend, SynchronousBackend, TornadoBackend) + :param backend_options: additional backend """ - def __init__(self, app_id, key, secret, ssl=True, host=None, port=None, timeout=None, cluster=None, backend=None): + def __init__(self, app_id, key, secret, ssl=True, host=None, port=None, timeout=None, cluster=None, backend=SynchronousBackend, **backend_options): if not isinstance(app_id, six.text_type): raise TypeError("App ID should be %s" % text) @@ -78,8 +77,10 @@ def __init__(self, app_id, key, secret, ssl=True, host=None, port=None, timeout= raise TypeError("Port should be a number") self.port = port or (443 if ssl else 80) - self.backend = backend or SynchronousBackend() - + self.backend = backend + self.backend_options = backend_options + self.setup_http() + @classmethod def from_url(cls, url): """Alternate constructor that extracts the information from a URL. @@ -269,3 +270,14 @@ def validate_webhook(self, key, signature, body): return None return body_data + + def setup_http(self): + """ + Used to configure the http client. Call this if any config has + changed on the object. + """ + self.http = self.backend(self, **self.backend_options) + + @property + def scheme(self): + return 'https' if self.ssl else 'http' diff --git a/pusher/requests.py b/pusher/requests.py index 2860582..6b752cd 100644 --- a/pusher/requests.py +++ b/pusher/requests.py @@ -9,13 +9,14 @@ class RequestsBackend(object): """Adapter for the requests module. + :param config: pusher.Pusher object :param options: key-value passed into the requests.request constructor """ - def __init__(self, **options): + def __init__(self, _, **options): self.options = {'verify': True} self.options.update(options) - def send_request(self, config, request): + def send_request(self, request): resp = requests.request( request.method, request.url, diff --git a/pusher/sync.py b/pusher/sync.py index 2da290d..617e430 100644 --- a/pusher/sync.py +++ b/pusher/sync.py @@ -12,20 +12,19 @@ class SynchronousBackend(object): """Adapter for the standard-library http client. + :param config: pusher.Pusher object :param timeout: configurable timeout for the TCP connection """ - def __init__(self, timeout=socket._GLOBAL_DEFAULT_TIMEOUT): + def __init__(self, config, timeout=socket._GLOBAL_DEFAULT_TIMEOUT): self.timeout = timeout - self.http = None + if config.ssl: + ctx = ssl.create_default_context() + self.http = http_client.HTTPSConnection(config.host, config.port, timeout=self.timeout, context=ctx) + else: + self.http = http_client.HTTPConnection(config.host, config.port, timeout=self.timeout) - def send_request(self, config, request): - if self.http is None: - if config.ssl: - ctx = ssl.create_default_context() - self.http = http_client.HTTPSConnection(config.host, config.port, timeout=self.timeout, context=ctx) - else: - self.http = http_client.HTTPConnection(config.host, config.port, timeout=self.timeout) + def send_request(self, request): try: self.http.request(request.method, request.signed_path, request.body, {"Content-Type": "application/json"}) resp = self.http.getresponse() diff --git a/pusher/tornado.py b/pusher/tornado.py index a1dfdc0..cc7c50d 100644 --- a/pusher/tornado.py +++ b/pusher/tornado.py @@ -9,16 +9,18 @@ class TornadoBackend(object): """Adapter for the tornado.httpclient module. + :param config: pusher.Pusher object :param timeout: configurable timeout for the connection """ - def __init__(self, timeout=None): + def __init__(self, config, timeout=None): + if config.ssl: + raise NotImplementedError("SSL not supported for this backend") self.timeout = timeout + self.config = config def send_request(self, config, request): - if config.ssl: - raise NotImplementedError("SSL not supported for this backend") method = request.method - url = "http://%s:%s%s?%s" % (config.host, config.port, request.path, request.query_string) + url = "http://%s:%s%s?%s" % (self.config.host, self.config.port, request.path, request.query_string) data = request.body headers = {'Content-Type': 'application/json'} From 4941054970d60d0c1da02412461d59e20854206c Mon Sep 17 00:00:00 2001 From: zimbatm Date: Wed, 1 Apr 2015 10:26:51 +0100 Subject: [PATCH 17/39] Global config timeout --- pusher/pusher.py | 10 +++++++--- pusher/sync.py | 8 +++----- pusher/tornado.py | 8 +++----- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/pusher/pusher.py b/pusher/pusher.py index 4581477..048065d 100644 --- a/pusher/pusher.py +++ b/pusher/pusher.py @@ -34,13 +34,13 @@ class Pusher(object): :param ssl: Whenever to use SSL or plain HTTP :param host: Used for custom host destination :param port: Used for custom port destination - :param timeout: Request timeout + :param timeout: Request timeout (in seconds) :param cluster: Convention for other clusters than the main Pusher-one. Eg: 'eu' will resolve to the api-eu.pusherapp.com host :param backend: an http adapter class (AsyncIOBackend, RequestsBackend, SynchronousBackend, TornadoBackend) :param backend_options: additional backend """ - def __init__(self, app_id, key, secret, ssl=True, host=None, port=None, timeout=None, cluster=None, backend=SynchronousBackend, **backend_options): + def __init__(self, app_id, key, secret, ssl=True, host=None, port=None, timeout=5, cluster=None, backend=SynchronousBackend, **backend_options): if not isinstance(app_id, six.text_type): raise TypeError("App ID should be %s" % text) @@ -74,9 +74,13 @@ def __init__(self, app_id, key, secret, ssl=True, host=None, port=None, timeout= self.host = "api.pusherapp.com" if port and not isinstance(port, six.integer_types): - raise TypeError("Port should be a number") + raise TypeError("Port should be an integer") self.port = port or (443 if ssl else 80) + if not isinstance(timeout, six.integer_types): + raise TypeError("Timeout should be an integer") + self.timeout = timeout + self.backend = backend self.backend_options = backend_options self.setup_http() diff --git a/pusher/sync.py b/pusher/sync.py index 617e430..b3abd69 100644 --- a/pusher/sync.py +++ b/pusher/sync.py @@ -13,15 +13,13 @@ class SynchronousBackend(object): """Adapter for the standard-library http client. :param config: pusher.Pusher object - :param timeout: configurable timeout for the TCP connection """ - def __init__(self, config, timeout=socket._GLOBAL_DEFAULT_TIMEOUT): - self.timeout = timeout + def __init__(self, config): if config.ssl: ctx = ssl.create_default_context() - self.http = http_client.HTTPSConnection(config.host, config.port, timeout=self.timeout, context=ctx) + self.http = http_client.HTTPSConnection(config.host, config.port, timeout=config.timeout, context=ctx) else: - self.http = http_client.HTTPConnection(config.host, config.port, timeout=self.timeout) + self.http = http_client.HTTPConnection(config.host, config.port, timeout=config.timeout) def send_request(self, request): diff --git a/pusher/tornado.py b/pusher/tornado.py index cc7c50d..a4d7f99 100644 --- a/pusher/tornado.py +++ b/pusher/tornado.py @@ -10,18 +10,16 @@ class TornadoBackend(object): """Adapter for the tornado.httpclient module. :param config: pusher.Pusher object - :param timeout: configurable timeout for the connection """ - def __init__(self, config, timeout=None): + def __init__(self, config): if config.ssl: raise NotImplementedError("SSL not supported for this backend") - self.timeout = timeout self.config = config - def send_request(self, config, request): + def send_request(self, request): method = request.method url = "http://%s:%s%s?%s" % (self.config.host, self.config.port, request.path, request.query_string) data = request.body headers = {'Content-Type': 'application/json'} - return tornado.httpclient.HTTPRequest(url, method=method, body=data, request_timeout=self.timeout, headers=headers) + return tornado.httpclient.HTTPRequest(url, method=method, body=data, request_timeout=self.config.timeout, headers=headers) From 474a44c3c08874a07c93d04258de02d2cab85ff4 Mon Sep 17 00:00:00 2001 From: zimbatm Date: Wed, 1 Apr 2015 10:44:03 +0100 Subject: [PATCH 18/39] Tornado cleanup --- pusher/tornado.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pusher/tornado.py b/pusher/tornado.py index a4d7f99..21e28d7 100644 --- a/pusher/tornado.py +++ b/pusher/tornado.py @@ -10,16 +10,16 @@ class TornadoBackend(object): """Adapter for the tornado.httpclient module. :param config: pusher.Pusher object + :param kwargs: options for the httpclient.HTTPClient constructor """ - def __init__(self, config): - if config.ssl: - raise NotImplementedError("SSL not supported for this backend") + def __init__(self, config, **kwargs): self.config = config + self.http = httpclient.HTTPClient(**kwargs) def send_request(self, request): method = request.method - url = "http://%s:%s%s?%s" % (self.config.host, self.config.port, request.path, request.query_string) data = request.body headers = {'Content-Type': 'application/json'} - return tornado.httpclient.HTTPRequest(url, method=method, body=data, request_timeout=self.config.timeout, headers=headers) + request = self.http.request.httpclient.HTTPRequest(request.url, method=method, body=data, headers=headers, request_timeout=self.config.timeout) + return self.http.fetch(request, raise_error=False) From d2004fc2221da4e4375514bff4e34cd0f067aeac Mon Sep 17 00:00:00 2001 From: zimbatm Date: Wed, 1 Apr 2015 10:46:43 +0100 Subject: [PATCH 19/39] Add timeout to requests adapter --- pusher/requests.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pusher/requests.py b/pusher/requests.py index 6b752cd..f20629b 100644 --- a/pusher/requests.py +++ b/pusher/requests.py @@ -12,7 +12,8 @@ class RequestsBackend(object): :param config: pusher.Pusher object :param options: key-value passed into the requests.request constructor """ - def __init__(self, _, **options): + def __init__(self, config, **options): + self.config = config self.options = {'verify': True} self.options.update(options) @@ -22,6 +23,7 @@ def send_request(self, request): request.url, headers=request.headers, data=request.body, + timeout=self.config.timeout, **self.options ) return process_response(resp.status_code, resp.text) From 8fa7f44b3b7de08664b5133f968df8173d029284 Mon Sep 17 00:00:00 2001 From: zimbatm Date: Wed, 1 Apr 2015 10:50:29 +0100 Subject: [PATCH 20/39] Add timeout and Keep-Alive to the aiohttp adapter --- pusher/aiohttp.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pusher/aiohttp.py b/pusher/aiohttp.py index e8e2513..43b31dc 100644 --- a/pusher/aiohttp.py +++ b/pusher/aiohttp.py @@ -1,15 +1,18 @@ # -*- coding: utf-8 -*- import aiohttp +import asyncio from pusher.http import process_response class AsyncIOBackend: - def __init__(_, _): + def __init__(self, config): """Adapter for the requests module. :param config: pusher.Pusher object """ + self.config = config + self.conn = aiohttp.TCPConnector() def send_request(_, request): method = request.method @@ -18,6 +21,9 @@ def send_request(_, request): data = request.body headers = request.headers - response = yield from aiohttp.request(method, url, params=params, data=data, headers=headers) + response = yield from asyncio.wait_for( + aiohttp.request(method, url, params=params, data=data, headers=headers, connector=self.conn), + timeout=self.config.timeout + ) body = yield from response.read_and_close() return process_response(response.status, body.decode('utf8')) From 7a5161e4bb7a03c4c36d10775b82f06488e35d4f Mon Sep 17 00:00:00 2001 From: Jamie Patel Date: Mon, 6 Apr 2015 19:53:19 +0100 Subject: [PATCH 21/39] 'Fixed' tests --- pusher/pusher.py | 4 ++-- pusher/signature.py | 4 ++-- pusher/sync.py | 2 ++ 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/pusher/pusher.py b/pusher/pusher.py index 048065d..9e9cf46 100644 --- a/pusher/pusher.py +++ b/pusher/pusher.py @@ -227,7 +227,7 @@ def authenticate(self, channel, socket_id, custom_data=None): if custom_data: string_to_sign += ":%s" % custom_data - signature = sign(self.secret, string_to_sign) + signature = sign(self.secret.encode('utf8'), string_to_sign.encode('utf8')) auth = "%s:%s" % (self.key, signature) result = {'auth': auth} @@ -257,7 +257,7 @@ def validate_webhook(self, key, signature, body): if key != self.key: return None - if not verify(self.secret, body, signature): + if not verify(self.secret.encode('utf8'), body.encode('utf8'), signature): return None try: diff --git a/pusher/signature.py b/pusher/signature.py index 5b60d63..a4a10b4 100644 --- a/pusher/signature.py +++ b/pusher/signature.py @@ -16,8 +16,8 @@ def compare_digest(a, b): def sign(secret, string_to_sign): return six.text_type( hmac.new( - secret.encode('utf8'), - string_to_sign.encode('utf8'), + secret, + string_to_sign, hashlib.sha256 ) .hexdigest() diff --git a/pusher/sync.py b/pusher/sync.py index b3abd69..9367132 100644 --- a/pusher/sync.py +++ b/pusher/sync.py @@ -16,6 +16,8 @@ class SynchronousBackend(object): """ def __init__(self, config): if config.ssl: + if sys.version_info < (3,4): + raise NotImplementedError("SSL requires python >= 3.4, earlier versions don't support certificate validation") ctx = ssl.create_default_context() self.http = http_client.HTTPSConnection(config.host, config.port, timeout=config.timeout, context=ctx) else: From 2ef4de46c15721b2d9559e8a765fbabc60a644ba Mon Sep 17 00:00:00 2001 From: zimbatm Date: Wed, 8 Apr 2015 13:50:21 +0100 Subject: [PATCH 22/39] Make all the properties read-only --- pusher/pusher.py | 55 ++++++++++++++++++++++++++++++++---------------- 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/pusher/pusher.py b/pusher/pusher.py index 9e9cf46..be605a9 100644 --- a/pusher/pusher.py +++ b/pusher/pusher.py @@ -46,44 +46,42 @@ def __init__(self, app_id, key, secret, ssl=True, host=None, port=None, timeout= raise TypeError("App ID should be %s" % text) if not app_id_re.match(app_id): raise ValueError("Invalid app id") - self.app_id = app_id + self._app_id = app_id if not isinstance(key, six.text_type): raise TypeError("Key should be %s" % text) - self.key = key + self._key = key if not isinstance(secret, six.text_type): raise TypeError("Secret should be %s" % text) - self.secret = secret + self._secret = secret if not isinstance(ssl, bool): raise TypeError("SSL should be a boolean") - self.ssl = ssl + self._ssl = ssl if host: if not isinstance(host, six.text_type): raise TypeError("Host should be %s" % text) - self.host = host + self._host = host elif cluster: if not isinstance(cluster, six.text_type): raise TypeError("Cluster should be %s" % text) - self.host = "api-%s.pusher.com" % cluster + self._host = "api-%s.pusher.com" % cluster else: - self.host = "api.pusherapp.com" + self._host = "api.pusherapp.com" if port and not isinstance(port, six.integer_types): raise TypeError("Port should be an integer") - self.port = port or (443 if ssl else 80) + self._port = port or (443 if ssl else 80) if not isinstance(timeout, six.integer_types): raise TypeError("Timeout should be an integer") - self.timeout = timeout + self._timeout = timeout - self.backend = backend - self.backend_options = backend_options - self.setup_http() + self.http = backend(self, **backend_options) @classmethod def from_url(cls, url): @@ -275,12 +273,33 @@ def validate_webhook(self, key, signature, body): return body_data - def setup_http(self): - """ - Used to configure the http client. Call this if any config has - changed on the object. - """ - self.http = self.backend(self, **self.backend_options) + @property + def app_id(self): + return self._app_id + + @property + def key(self): + return self._key + + @property + def secret(self): + return self._secret + + @property + def host(self): + return self._host + + @property + def port(self): + return self._port + + @property + def timeout(self): + return self._timeout + + @property + def ssl(self): + return self._ssl @property def scheme(self): From ec1a9f040109fe09760ca7998d6738173fadef2c Mon Sep 17 00:00:00 2001 From: zimbatm Date: Wed, 8 Apr 2015 13:52:38 +0100 Subject: [PATCH 23/39] Make requests the default backend The stdlib http library doesn't support SSL properly in most python versions. Plus requests is almost a standard now. --- pusher/pusher.py | 4 ++-- setup.py | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/pusher/pusher.py b/pusher/pusher.py index be605a9..d757601 100644 --- a/pusher/pusher.py +++ b/pusher/pusher.py @@ -4,7 +4,7 @@ division) from pusher.http import GET, POST, Request, request_method from pusher.signature import sign, verify -from pusher.sync import SynchronousBackend +from pusher.requests import RequestsBackend from pusher.util import text, validate_channel, app_id_re, channel_name_re import collections @@ -40,7 +40,7 @@ class Pusher(object): :param backend: an http adapter class (AsyncIOBackend, RequestsBackend, SynchronousBackend, TornadoBackend) :param backend_options: additional backend """ - def __init__(self, app_id, key, secret, ssl=True, host=None, port=None, timeout=5, cluster=None, backend=SynchronousBackend, **backend_options): + def __init__(self, app_id, key, secret, ssl=True, host=None, port=None, timeout=5, cluster=None, backend=RequestsBackend, **backend_options): if not isinstance(app_id, six.text_type): raise TypeError("App ID should be %s" % text) diff --git a/setup.py b/setup.py index ba5e4a4..2c0ec70 100644 --- a/setup.py +++ b/setup.py @@ -19,12 +19,11 @@ packages=['pusher'], - install_requires=['six'], + install_requires=['six', 'requests>=2.3.0'], tests_require=['nose', 'mock'], extras_require={ 'aiohttp': ["aiohttp>=0.9.0"], - 'requests': ['requests>=2.3.0'], 'tornado': ['tornado>=4.0.0'], }, From e5ea61534403354f4af258dc3afee11c3af4899c Mon Sep 17 00:00:00 2001 From: zimbatm Date: Wed, 8 Apr 2015 14:16:56 +0100 Subject: [PATCH 24/39] Add keep-alive support to requests backend --- pusher/requests.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pusher/requests.py b/pusher/requests.py index f20629b..16fd2c7 100644 --- a/pusher/requests.py +++ b/pusher/requests.py @@ -16,9 +16,10 @@ def __init__(self, config, **options): self.config = config self.options = {'verify': True} self.options.update(options) + self.session = requests.Session() def send_request(self, request): - resp = requests.request( + resp = self.session.request( request.method, request.url, headers=request.headers, From 2614a6290fa2b1089771ad732752b5251164c2a6 Mon Sep 17 00:00:00 2001 From: zimbatm Date: Wed, 8 Apr 2015 14:34:25 +0100 Subject: [PATCH 25/39] Allow to pass options to Pusher.from_env and Pusher.from_url --- pusher/http.py | 2 +- pusher/pusher.py | 21 ++++++++++++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/pusher/http.py b/pusher/http.py index 3c86258..d8ef343 100644 --- a/pusher/http.py +++ b/pusher/http.py @@ -19,7 +19,7 @@ def __init__(self, pusher, f): self.f = f def __call__(self, *args, **kwargs): - return self.pusher.http.send_request(self.pusher, self.make_request(*args, **kwargs)) + return self.pusher.http.send_request(self.make_request(*args, **kwargs)) def make_request(self, *args, **kwargs): return self.f(self.pusher, *args, **kwargs) diff --git a/pusher/pusher.py b/pusher/pusher.py index d757601..580790f 100644 --- a/pusher/pusher.py +++ b/pusher/pusher.py @@ -84,7 +84,7 @@ def __init__(self, app_id, key, secret, ssl=True, host=None, port=None, timeout= self.http = backend(self, **backend_options) @classmethod - def from_url(cls, url): + def from_url(cls, url, **options): """Alternate constructor that extracts the information from a URL. :param url: String containing a URL @@ -94,14 +94,25 @@ def from_url(cls, url): >> from pusher import Pusher >> p = Pusher.from_url("http://mykey:mysecret@api.pusher.com/apps/432") """ - m = re.match("(http|https)://(.*):(.*)@(.*)/apps/([0-9]+)", url) + m = re.match("(http|https)://(.*):(.*)@(.*)/apps/([0-9]+)", six.text_type(url)) if not m: raise Exception("Unparsable url: %s" % url) ssl = m.group(1) == 'https' - return cls(key=m.group(2), secret=m.group(3), host=m.group(4), app_id=m.group(5), ssl=ssl) + + options_ = { + 'key': six.text_type(m.group(2)), + 'secret': six.text_type(m.group(3)), + 'host': six.text_type(m.group(4)), + 'app_id': six.text_type(m.group(5)), + 'ssl': ssl, + } + options_.update(options) + + + return cls(**options_) @classmethod - def from_env(cls, env='PUSHER_URL'): + def from_env(cls, env='PUSHER_URL', **options): """Alternate constructor that extracts the information from an URL stored in an environment variable. The pusher heroku addon will set the PUSHER_URL automatically when installed for example. @@ -117,7 +128,7 @@ def from_env(cls, env='PUSHER_URL'): if not val: raise Exception("Environment variable %s not found" % env) - return cls.from_url(six.text_type(val)) + return cls.from_url(six.text_type(val), **options) @request_method def trigger(self, channels, event_name, data, socket_id=None): From 8e72996f96da12ec92879ff9baf7a25c2c835ee8 Mon Sep 17 00:00:00 2001 From: zimbatm Date: Wed, 8 Apr 2015 15:10:06 +0100 Subject: [PATCH 26/39] Editorconfig --- .editorconfig | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..fd9c726 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true + +[*.py] +indent_style = space +indent_size = 4 From 3ee8aa9941389724192a7e1c8a4c9732d9b778d8 Mon Sep 17 00:00:00 2001 From: zimbatm Date: Wed, 8 Apr 2015 15:07:46 +0100 Subject: [PATCH 27/39] Rework text handling If it's any of the string types try to convert to string/text, otherwise raise a TypeError --- pusher/http.py | 5 +- pusher/pusher.py | 96 ++++++++++++++----------------------- pusher/signature.py | 18 +++---- pusher/util.py | 15 ++++-- pusher_tests/test_config.py | 9 ---- pusher_tests/test_pusher.py | 14 ++---- 6 files changed, 61 insertions(+), 96 deletions(-) diff --git a/pusher/http.py b/pusher/http.py index d8ef343..5b12130 100644 --- a/pusher/http.py +++ b/pusher/http.py @@ -93,10 +93,7 @@ def _generate_auth(self): make_query_string(self.query_params) ]) - secret = self.config.secret.encode('utf8') - message = auth_string.encode('utf8') - - self.query_params['auth_signature'] = sign(secret, message) + self.query_params['auth_signature'] = sign(self.config.secret, auth_string) @property def query_string(self): diff --git a/pusher/pusher.py b/pusher/pusher.py index 580790f..e4dd98c 100644 --- a/pusher/pusher.py +++ b/pusher/pusher.py @@ -5,7 +5,7 @@ from pusher.http import GET, POST, Request, request_method from pusher.signature import sign, verify from pusher.requests import RequestsBackend -from pusher.util import text, validate_channel, app_id_re, channel_name_re +from pusher.util import ensure_text, validate_channel, app_id_re, pusher_url_re, channel_name_re import collections import hashlib @@ -16,10 +16,6 @@ import time def join_attributes(attributes): - for attr in attributes: - if not isinstance(attr, six.text_type): - raise TypeError('Each attr should be %s' % text) - return six.text_type(',').join(attributes) class Pusher(object): @@ -41,44 +37,30 @@ class Pusher(object): :param backend_options: additional backend """ def __init__(self, app_id, key, secret, ssl=True, host=None, port=None, timeout=5, cluster=None, backend=RequestsBackend, **backend_options): - - if not isinstance(app_id, six.text_type): - raise TypeError("App ID should be %s" % text) - if not app_id_re.match(app_id): + self._app_id = ensure_text(app_id, "app_id") + if not app_id_re.match(self._app_id): raise ValueError("Invalid app id") - self._app_id = app_id - - if not isinstance(key, six.text_type): - raise TypeError("Key should be %s" % text) - self._key = key - if not isinstance(secret, six.text_type): - raise TypeError("Secret should be %s" % text) - self._secret = secret + self._key = ensure_text(key, "key") + self._secret = ensure_text(secret, "secret") if not isinstance(ssl, bool): raise TypeError("SSL should be a boolean") self._ssl = ssl if host: - if not isinstance(host, six.text_type): - raise TypeError("Host should be %s" % text) - - self._host = host + self._host = ensure_text(host, "host") elif cluster: - if not isinstance(cluster, six.text_type): - raise TypeError("Cluster should be %s" % text) - - self._host = "api-%s.pusher.com" % cluster + self._host = six.text_type("api-%s.pusher.com") % ensure_text(cluster, "cluster") else: - self._host = "api.pusherapp.com" + self._host = six.text_type("api.pusherapp.com") if port and not isinstance(port, six.integer_types): - raise TypeError("Port should be an integer") + raise TypeError("port should be an integer") self._port = port or (443 if ssl else 80) if not isinstance(timeout, six.integer_types): - raise TypeError("Timeout should be an integer") + raise TypeError("timeout should be an integer") self._timeout = timeout self.http = backend(self, **backend_options) @@ -94,16 +76,16 @@ def from_url(cls, url, **options): >> from pusher import Pusher >> p = Pusher.from_url("http://mykey:mysecret@api.pusher.com/apps/432") """ - m = re.match("(http|https)://(.*):(.*)@(.*)/apps/([0-9]+)", six.text_type(url)) + m = pusher_url_re.match(ensure_text(url, "url")) if not m: raise Exception("Unparsable url: %s" % url) ssl = m.group(1) == 'https' options_ = { - 'key': six.text_type(m.group(2)), - 'secret': six.text_type(m.group(3)), - 'host': six.text_type(m.group(4)), - 'app_id': six.text_type(m.group(5)), + 'key': m.group(2), + 'secret': m.group(3), + 'host': m.group(4), + 'app_id': m.group(5), 'ssl': ssl, } options_.update(options) @@ -128,7 +110,7 @@ def from_env(cls, env='PUSHER_URL', **options): if not val: raise Exception("Environment variable %s not found" % env) - return cls.from_url(six.text_type(val), **options) + return cls.from_url(val, **options) @request_method def trigger(self, channels, event_name, data, socket_id=None): @@ -138,25 +120,25 @@ def trigger(self, channels, event_name, data, socket_id=None): http://pusher.com/docs/rest_api#method-post-event ''' - if isinstance(channels, dict) or not (isinstance(channels, six.string_types) or isinstance(channels, (collections.Sized, collections.Iterable))): - raise TypeError("Expected a single string or collection of channels (each channel should be %s)" % text) - if isinstance(channels, six.string_types): channels = [channels] + if isinstance(channels, dict) or not isinstance(channels, (collections.Sized, collections.Iterable)): + raise TypeError("Expected a single or a list of channels") + if len(channels) > 10: raise ValueError("Too many channels") - for channel in channels: - validate_channel(channel) + channels = list(map(validate_channel, channels)) - if not isinstance(event_name, six.text_type): - raise TypeError("event_name should be %s" % text) + event_name = ensure_text(event_name, "event_name") if len(event_name) > 200: raise ValueError("event_name too long") - if not isinstance(data, six.text_type): + if isinstance(data, six.string_types): + data = ensure_text(data, "data") + else: data = json.dumps(data) if len(data) > 10240: @@ -168,9 +150,8 @@ def trigger(self, channels, event_name, data, socket_id=None): 'data': data } if socket_id: - if not isinstance(socket_id, six.text_type): - raise TypeError("Socket ID should be %s" % text) - params['socket_id'] = socket_id + params['socket_id'] = ensure_text(socket_id, "socket_id") + return Request(self, POST, "/apps/%s/events" % self.app_id, params) @request_method @@ -184,8 +165,8 @@ def channels_info(self, prefix_filter=None, attributes=[]): if attributes: params['info'] = join_attributes(attributes) if prefix_filter: - params['filter_by_prefix'] = prefix_filter - return Request(self, GET, "/apps/%s/channels" % self.app_id, params) + params['filter_by_prefix'] = ensure_text(prefix_filter, "prefix_filter") + return Request(self, GET, six.text_type("/apps/%s/channels") % self.app_id, params) @request_method def channel_info(self, channel, attributes=[]): @@ -219,14 +200,12 @@ def authenticate(self, channel, socket_id, custom_data=None): :param socket_id: id of the socket that requires authorization :param custom_data: used on presence channels to provide user info """ - if not isinstance(channel, six.text_type): - raise TypeError('Channel should be %s' % text) + channel = validate_channel(channel) if not channel_name_re.match(channel): raise ValueError('Channel should be a valid channel, got: %s' % channel) - if not isinstance(socket_id, six.text_type): - raise TypeError('Socket ID should %s' % text) + socket_id = ensure_text(socket_id, "socket_id") if custom_data: custom_data = json.dumps(custom_data) @@ -236,7 +215,7 @@ def authenticate(self, channel, socket_id, custom_data=None): if custom_data: string_to_sign += ":%s" % custom_data - signature = sign(self.secret.encode('utf8'), string_to_sign.encode('utf8')) + signature = sign(self.secret, string_to_sign) auth = "%s:%s" % (self.key, signature) result = {'auth': auth} @@ -254,19 +233,14 @@ def validate_webhook(self, key, signature, body): :param signature: signature that was given with the body :param body: content that needs to be verified """ - if not isinstance(key, six.text_type): - raise TypeError('key should be %s' % text) - - if not isinstance(signature, six.text_type): - raise TypeError('signature should be %s' % text) - - if not isinstance(body, six.text_type): - raise TypeError('body should be %s' % text) + key = ensure_text(key, "key") + signature = ensure_text(signature, "signature") + body = ensure_text(body, "body") if key != self.key: return None - if not verify(self.secret.encode('utf8'), body.encode('utf8'), signature): + if not verify(self.secret, body, signature): return None try: diff --git a/pusher/signature.py b/pusher/signature.py index a4a10b4..146c5db 100644 --- a/pusher/signature.py +++ b/pusher/signature.py @@ -14,14 +14,14 @@ def compare_digest(a, b): return reduce(lambda x, y: x | y, [ord(x) ^ ord(y) for x, y in zip(a, b)]) == 0 def sign(secret, string_to_sign): - return six.text_type( - hmac.new( - secret, - string_to_sign, - hashlib.sha256 - ) - .hexdigest() - ) + return six.text_type( + hmac.new( + secret.encode('utf8'), + string_to_sign.encode('utf8'), + hashlib.sha256 + ) + .hexdigest() + ) def verify(secret, string_to_sign, signature): - return compare_digest(signature, sign(secret, string_to_sign)) + return compare_digest(signature, sign(secret, string_to_sign)) diff --git a/pusher/util.py b/pusher/util.py index 430b2b0..d91b1df 100644 --- a/pusher/util.py +++ b/pusher/util.py @@ -10,18 +10,27 @@ channel_name_re = re.compile('^[-a-zA-Z0-9_=@,.;]+$') app_id_re = re.compile('^[0-9]+$') +pusher_url_re = re.compile('(http|https)://(.*):(.*)@(.*)/apps/([0-9]+)') if sys.version_info < (3,): text = 'a unicode string' else: text = 'a string' +def ensure_text(obj, name): + if isinstance(obj, six.text_type): + return obj + if isinstance(obj, six.string_types): + return six.text_type(obj) + raise TypeError("%s should be %s" % (name, text)) + def validate_channel(channel): - if not isinstance(channel, six.text_type): - raise TypeError("Channel should be %s" % text) + channel = ensure_text(channel, "channel") if len(channel) > 200: - raise ValueError("Channel too long") + raise ValueError("Channel too long: %s" % channel) if not channel_name_re.match(channel): raise ValueError("Invalid Channel: %s" % channel) + + return channel diff --git a/pusher_tests/test_config.py b/pusher_tests/test_config.py index 9b76e41..8c2a5b3 100644 --- a/pusher_tests/test_config.py +++ b/pusher_tests/test_config.py @@ -21,18 +21,12 @@ def test_should_be_constructable(self): def test_app_id_should_be_text_if_present(self): self.assertRaises(TypeError, lambda: Pusher(app_id=4, key=u'key', secret=u'secret', ssl=False)) - self.assertRaises(TypeError, lambda: Pusher(app_id=b'4', key=u'key', secret=u'secret', ssl=False)) def test_key_should_be_text_if_present(self): self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=4, secret=u'secret', ssl=False)) - self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=b'key', secret=u'secret', ssl=False)) def test_secret_should_be_text_if_present(self): self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=u'key', secret=4, ssl=False)) - self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=u'key', secret=b'secret', ssl=False)) - - def test_ssl_should_be_required(self): - self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=u'key', secret=b'secret')) def test_ssl_should_be_boolean(self): Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=False) @@ -43,7 +37,6 @@ def test_ssl_should_be_boolean(self): def test_host_should_be_text(self): Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True, host=u'foo') - self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True, host=b'foo')) self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True, host=4)) def test_port_should_be_number(self): @@ -54,7 +47,6 @@ def test_port_should_be_number(self): def test_cluster_should_be_text(self): Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True, cluster=u'eu') - self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True, cluster=b'eu')) self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True, cluster=4)) def test_host_behaviour(self): @@ -82,7 +74,6 @@ def test_port_behaviour(self): def test_initialize_from_url(self): self.assertRaises(TypeError, lambda: Pusher.from_url(4)) - self.assertRaises(TypeError, lambda: Pusher.from_url(b'http://foo:bar@host/apps/4')) self.assertRaises(Exception, lambda: Pusher.from_url(u'httpsahsutaeh')) conf = Pusher.from_url(u'http://foo:bar@host/apps/4') diff --git a/pusher_tests/test_pusher.py b/pusher_tests/test_pusher.py index 568ecee..09f4436 100644 --- a/pusher_tests/test_pusher.py +++ b/pusher_tests/test_pusher.py @@ -23,16 +23,13 @@ def setUp(self): def test_app_id_should_be_text(self): self.assertRaises(TypeError, lambda: Pusher(key=u'key', secret=u'secret', ssl=False)) self.assertRaises(TypeError, lambda: Pusher(app_id=4, key=u'key', secret=u'secret')) - self.assertRaises(TypeError, lambda: Pusher(app_id=b'4', key=u'key', secret=u'secret')) def test_key_should_be_text(self): self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', secret=u'secret')) self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=4, secret=u'secret')) - self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=b'key', secret=u'secret')) def test_secret_should_be_text(self): self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=u'key', secret=4)) - self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=u'key', secret=b'secret')) def test_initialize_from_env(self): with mock.patch.object(os, 'environ', new={'PUSHER_URL':'https://plah:bob@somehost/apps/42'}): @@ -91,13 +88,13 @@ def test_trigger_disallow_non_string_or_list_channels(self): def test_trigger_disallow_invalid_channels(self): self.assertRaises(ValueError, lambda: - self.pusher.trigger.make_request([u'some_channel!'], u'some_event', {u'message': u'hello world'})) + self.pusher.trigger.make_request([u'so/me_channel!'], u'some_event', {u'message': u'hello world'})) def test_authenticate_types(self): pusher = Pusher.from_url(u'http://foo:bar@host/apps/4') - self.assertRaises(TypeError, lambda: pusher.authenticate(b'plah', u'34554')) - self.assertRaises(TypeError, lambda: pusher.authenticate(u'plah', b'324435')) + self.assertRaises(TypeError, lambda: pusher.authenticate(2423, u'34554')) + self.assertRaises(TypeError, lambda: pusher.authenticate(u'plah', 234234)) self.assertRaises(ValueError, lambda: pusher.authenticate(u'::', u'345345')) def test_authenticate_for_private_channels(self): @@ -134,7 +131,7 @@ def test_validate_webhook_success_case(self): pusher = Pusher.from_url(u'http://foo:bar@host/apps/4') body = u'{"time_ms": 1000000}' - signature = six.text_type(hmac.new(pusher.secret.encode(u'utf8'), body.encode(u'utf8'), hashlib.sha256).hexdigest()) + signature = six.text_type(hmac.new(pusher.secret.encode('utf8'), body.encode('utf8'), hashlib.sha256).hexdigest()) with mock.patch('time.time', return_value=1200): self.assertEqual(pusher.validate_webhook(pusher.key, signature, body), {u'time_ms': 1000000}) @@ -149,11 +146,8 @@ def test_validate_webhook_bad_types(self): with mock.patch('time.time') as time_mock: self.assertRaises(TypeError, lambda: pusher.validate_webhook(4, u'signature', u'body')) - self.assertRaises(TypeError, lambda: pusher.validate_webhook(b'test', u'signature', u'body')) self.assertRaises(TypeError, lambda: pusher.validate_webhook(u'key', 4, u'body')) - self.assertRaises(TypeError, lambda: pusher.validate_webhook(u'key', b'signature', u'body')) self.assertRaises(TypeError, lambda: pusher.validate_webhook(u'key', u'signature', 4)) - self.assertRaises(TypeError, lambda: pusher.validate_webhook(u'key', u'signature', b'body')) time_mock.assert_not_called() From 259699c1e0952b38332a5db54946d1647a89f6d0 Mon Sep 17 00:00:00 2001 From: zimbatm Date: Wed, 8 Apr 2015 17:40:54 +0100 Subject: [PATCH 28/39] Fix packages list in setup.py --- setup.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 2c0ec70..b0e1cb7 100644 --- a/setup.py +++ b/setup.py @@ -17,7 +17,16 @@ keywords='pusher rest realtime websockets service', license='MIT', - packages=['pusher'], + packages=[ + 'pusher', + 'pusher.aiohttp', + 'pusher.errors', + 'pusher.http', + 'pusher.requests', + 'pusher.signature', + 'pusher.tornado', + 'pusher.util', + ], install_requires=['six', 'requests>=2.3.0'], tests_require=['nose', 'mock'], From 06b5d05b967b71b2f298b023534d123aa10df6a9 Mon Sep 17 00:00:00 2001 From: zimbatm Date: Wed, 8 Apr 2015 17:45:42 +0100 Subject: [PATCH 29/39] Remove Sync backend. Requests backend is enough --- pusher/sync.py | 35 ----------------------------------- 1 file changed, 35 deletions(-) delete mode 100644 pusher/sync.py diff --git a/pusher/sync.py b/pusher/sync.py deleted file mode 100644 index 9367132..0000000 --- a/pusher/sync.py +++ /dev/null @@ -1,35 +0,0 @@ -# -*- coding: utf-8 -*- - -from __future__ import (print_function, unicode_literals, absolute_import, - division) -from pusher.http import PusherError, process_response -from six.moves import http_client - -import socket -import ssl -import sys - -class SynchronousBackend(object): - """Adapter for the standard-library http client. - - :param config: pusher.Pusher object - """ - def __init__(self, config): - if config.ssl: - if sys.version_info < (3,4): - raise NotImplementedError("SSL requires python >= 3.4, earlier versions don't support certificate validation") - ctx = ssl.create_default_context() - self.http = http_client.HTTPSConnection(config.host, config.port, timeout=config.timeout, context=ctx) - else: - self.http = http_client.HTTPConnection(config.host, config.port, timeout=config.timeout) - - - def send_request(self, request): - try: - self.http.request(request.method, request.signed_path, request.body, {"Content-Type": "application/json"}) - resp = self.http.getresponse() - body = resp.read().decode('utf8') - except http_client.HTTPException as e: - raise PusherError(repr(e)) - - return process_response(resp.status, body) From 6ce72d6c14a68d433877bbfab489ff855a6c6a1e Mon Sep 17 00:00:00 2001 From: zimbatm Date: Wed, 8 Apr 2015 22:25:48 +0100 Subject: [PATCH 30/39] Fix tornado and asyncio backends --- pusher/aiohttp.py | 2 +- pusher/tornado.py | 25 ++++++++++++++++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/pusher/aiohttp.py b/pusher/aiohttp.py index 43b31dc..d194868 100644 --- a/pusher/aiohttp.py +++ b/pusher/aiohttp.py @@ -14,7 +14,7 @@ def __init__(self, config): self.config = config self.conn = aiohttp.TCPConnector() - def send_request(_, request): + def send_request(self, request): method = request.method url = "%s%s" % (request.base_url, request.path) params = request.query_params diff --git a/pusher/tornado.py b/pusher/tornado.py index 21e28d7..662e6d4 100644 --- a/pusher/tornado.py +++ b/pusher/tornado.py @@ -2,9 +2,12 @@ from __future__ import (print_function, unicode_literals, absolute_import, division) +from pusher.http import process_response +import six import tornado import tornado.httpclient +from tornado.concurrent import TracebackFuture class TornadoBackend(object): """Adapter for the tornado.httpclient module. @@ -14,12 +17,28 @@ class TornadoBackend(object): """ def __init__(self, config, **kwargs): self.config = config - self.http = httpclient.HTTPClient(**kwargs) + self.http = tornado.httpclient.AsyncHTTPClient(**kwargs) def send_request(self, request): method = request.method data = request.body headers = {'Content-Type': 'application/json'} + future = TracebackFuture() + + def process_response_future(response): + if response.exc_info() is not None: + future.set_exc_info(response.exc_info()) + elif response.exception() is not None: + future.set_exception(response.exception()) + else: + result = response.result() + code = result.code + body = (result.body or b'').decode('utf8') + future.set_result(process_response(code, body)) + + request = tornado.httpclient.HTTPRequest(request.url, method=method, body=data, headers=headers, request_timeout=self.config.timeout) + response_future = self.http.fetch(request, raise_error=False) + response_future.add_done_callback(process_response_future) + + return future - request = self.http.request.httpclient.HTTPRequest(request.url, method=method, body=data, headers=headers, request_timeout=self.config.timeout) - return self.http.fetch(request, raise_error=False) From 2c9570d97cc8e1e2007da1dc3f8958f4aa789a3f Mon Sep 17 00:00:00 2001 From: zimbatm Date: Wed, 8 Apr 2015 22:26:01 +0100 Subject: [PATCH 31/39] Add trigger examples --- trigger_aiohttp.py | 14 ++++++++++++++ trigger_tornado.py | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 trigger_aiohttp.py create mode 100644 trigger_tornado.py diff --git a/trigger_aiohttp.py b/trigger_aiohttp.py new file mode 100644 index 0000000..be2cb3c --- /dev/null +++ b/trigger_aiohttp.py @@ -0,0 +1,14 @@ +import pusher +import pusher.aiohttp +import asyncio + +def main(): + client = pusher.Pusher.from_env( + backend=pusher.aiohttp.AsyncIOBackend, + timeout=50 + ) + print("before trigger") + response = yield from client.trigger("hello", "world", dict(foo='bar')) + print(response) + +asyncio.get_event_loop().run_until_complete(main()) diff --git a/trigger_tornado.py b/trigger_tornado.py new file mode 100644 index 0000000..d5af180 --- /dev/null +++ b/trigger_tornado.py @@ -0,0 +1,19 @@ +import pusher +import pusher.tornado +import tornado.ioloop + +ioloop = tornado.ioloop.IOLoop.instance() + +def show_response(response): + print(response.result()) + ioloop.stop() + +client = pusher.Pusher.from_env( + backend=pusher.tornado.TornadoBackend, + timeout=50 + ) +response = client.trigger("hello", "world", dict(foo='bar')) +response.add_done_callback(show_response) +print("Before start") +ioloop.start() +print("After start") From f6d060501ad12ecc1ba8e9e2693ec20a7055e733 Mon Sep 17 00:00:00 2001 From: zimbatm Date: Wed, 8 Apr 2015 22:27:05 +0100 Subject: [PATCH 32/39] https --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b0e1cb7..d638bd4 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ name='pusher-rest', version='0.1.0', description='A Python library to interract with the Pusher API', - url='http://github.com/pusher/pusher-rest-python', + url='https://github.com/pusher/pusher-rest-python', author='Pusher', author_email='support@pusher.com', classifiers=[ From b0970ab543c2c72b32e9917bb9d37342983a0a0a Mon Sep 17 00:00:00 2001 From: Jamie Patel Date: Thu, 23 Apr 2015 11:07:14 +0100 Subject: [PATCH 33/39] Started adding tests for http adapters. --- pusher_tests/test_aiohttp_adapter.py | 24 ++++++++++++++++++++++++ pusher_tests/test_requests_adapter.py | 23 +++++++++++++++++++++++ setup.py | 2 +- 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 pusher_tests/test_aiohttp_adapter.py create mode 100644 pusher_tests/test_requests_adapter.py diff --git a/pusher_tests/test_aiohttp_adapter.py b/pusher_tests/test_aiohttp_adapter.py new file mode 100644 index 0000000..8d8ab29 --- /dev/null +++ b/pusher_tests/test_aiohttp_adapter.py @@ -0,0 +1,24 @@ +from __future__ import print_function, absolute_import, division + +import pusher +import pusher.aiohttp +import asyncio +import unittest +import httpretty + +class TestAIOHTTPBackend(unittest.TestCase): + + def setUp(self): + self.p = pusher.Pusher.from_url(u'http://key:secret@api.pusherapp.com/apps/4', + backend=pusher.aiohttp.AsyncIOBackend) + + @httpretty.activate + def test_trigger_aio_success(self): + httpretty.register_uri(httpretty.POST, "http://api.pusherapp.com/apps/4/events", + body="{}", + content_type="application/json") + response = yield from self.p.trigger(u'test_channel', u'test', {u'data': u'yolo'}) + self.assertEqual(response, {}) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/pusher_tests/test_requests_adapter.py b/pusher_tests/test_requests_adapter.py new file mode 100644 index 0000000..cbef43b --- /dev/null +++ b/pusher_tests/test_requests_adapter.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- + +from __future__ import print_function, absolute_import, division + +from pusher import Pusher +import unittest +import httpretty + +class TestRequestsBackend(unittest.TestCase): + + def setUp(self): + self.pusher = Pusher.from_url(u'http://key:secret@api.pusherapp.com/apps/4') + + @httpretty.activate + def test_trigger_requests_success(self): + httpretty.register_uri(httpretty.POST, "http://api.pusherapp.com/apps/4/events", + body="{}", + content_type="application/json") + response = self.pusher.trigger(u'test_channel', u'test', {u'data': u'yolo'}) + self.assertEqual(response, {}) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/setup.py b/setup.py index d638bd4..77643ee 100644 --- a/setup.py +++ b/setup.py @@ -29,7 +29,7 @@ ], install_requires=['six', 'requests>=2.3.0'], - tests_require=['nose', 'mock'], + tests_require=['nose', 'mock', 'HTTPretty'], extras_require={ 'aiohttp': ["aiohttp>=0.9.0"], From 0b878fe8deebc6c556c9ff5571316cd6b3e8406a Mon Sep 17 00:00:00 2001 From: zimbatm Date: Fri, 24 Apr 2015 11:03:21 +0100 Subject: [PATCH 34/39] Fixes loading and testing issues in python 2.6 --- pusher_tests/aio/aiohttp_adapter_test.py | 24 ++++++++++++++++++++++ pusher_tests/test_aiohttp_adapter.py | 26 +++--------------------- setup.py | 9 +------- 3 files changed, 28 insertions(+), 31 deletions(-) create mode 100644 pusher_tests/aio/aiohttp_adapter_test.py diff --git a/pusher_tests/aio/aiohttp_adapter_test.py b/pusher_tests/aio/aiohttp_adapter_test.py new file mode 100644 index 0000000..6fe84d2 --- /dev/null +++ b/pusher_tests/aio/aiohttp_adapter_test.py @@ -0,0 +1,24 @@ +from __future__ import print_function, absolute_import, division + +import pusher +import pusher.aiohttp +import asyncio +import unittest +import httpretty + +class TestAIOHTTPBackend(unittest.TestCase): + + def setUp(self): + self.p = pusher.Pusher.from_url(u'http://key:secret@api.pusherapp.com/apps/4', + backend=pusher.aiohttp.AsyncIOBackend) + + @httpretty.activate + def test_trigger_aio_success(self): + httpretty.register_uri(httpretty.POST, "http://api.pusherapp.com/apps/4/events", + body="{}", + content_type="application/json") + response = yield from self.p.trigger(u'test_channel', u'test', {u'data': u'yolo'}) + self.assertEqual(response, {}) + +if __name__ == '__main__': + unittest.main() diff --git a/pusher_tests/test_aiohttp_adapter.py b/pusher_tests/test_aiohttp_adapter.py index 8d8ab29..60c6ce7 100644 --- a/pusher_tests/test_aiohttp_adapter.py +++ b/pusher_tests/test_aiohttp_adapter.py @@ -1,24 +1,4 @@ -from __future__ import print_function, absolute_import, division +import sys -import pusher -import pusher.aiohttp -import asyncio -import unittest -import httpretty - -class TestAIOHTTPBackend(unittest.TestCase): - - def setUp(self): - self.p = pusher.Pusher.from_url(u'http://key:secret@api.pusherapp.com/apps/4', - backend=pusher.aiohttp.AsyncIOBackend) - - @httpretty.activate - def test_trigger_aio_success(self): - httpretty.register_uri(httpretty.POST, "http://api.pusherapp.com/apps/4/events", - body="{}", - content_type="application/json") - response = yield from self.p.trigger(u'test_channel', u'test', {u'data': u'yolo'}) - self.assertEqual(response, {}) - -if __name__ == '__main__': - unittest.main() \ No newline at end of file +if sys.version_info >= (3,): + from .aio.aiohttp_adapter_test import * diff --git a/setup.py b/setup.py index 77643ee..08ef87b 100644 --- a/setup.py +++ b/setup.py @@ -18,14 +18,7 @@ license='MIT', packages=[ - 'pusher', - 'pusher.aiohttp', - 'pusher.errors', - 'pusher.http', - 'pusher.requests', - 'pusher.signature', - 'pusher.tornado', - 'pusher.util', + 'pusher' ], install_requires=['six', 'requests>=2.3.0'], From a6e8e0ebd51b35d32b042ed2b3284bb931a2ce72 Mon Sep 17 00:00:00 2001 From: Jamie Patel Date: Fri, 24 Apr 2015 11:48:04 +0100 Subject: [PATCH 35/39] Renamed package to pusher and updated version number --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 08ef87b..7efb797 100644 --- a/setup.py +++ b/setup.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- from setuptools import setup setup( - name='pusher-rest', - version='0.1.0', + name='pusher', + version='1.0.0', description='A Python library to interract with the Pusher API', url='https://github.com/pusher/pusher-rest-python', author='Pusher', From ba9860189b1d8ad3e05b7b931f51287bfc602173 Mon Sep 17 00:00:00 2001 From: Jamie Patel Date: Fri, 24 Apr 2015 17:02:54 +0100 Subject: [PATCH 36/39] Added urlfetch adapter --- pusher/urlfetch.py | 27 +++++++++++++++++++++++++++ setup.py | 3 ++- 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 pusher/urlfetch.py diff --git a/pusher/urlfetch.py b/pusher/urlfetch.py new file mode 100644 index 0000000..398bd07 --- /dev/null +++ b/pusher/urlfetch.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- + +from __future__ import (print_function, unicode_literals, absolute_import, + division) + +import urlfetch +import json +from pusher.http import process_response + +class URLFetchBackend(object): + """Adapter for the URLFetch Module. Necessary for using this library with Google + App Engine""" + + def __init__(self, config, **options): + self.config = config + self.options = options + + def send_request(self, request): + resp = urlfetch.fetch( + url=request.url, + headers=request.headers, + method=request.method, + data=request.body, + deadline=self.config.timeout, + **self.options + ) + return process_response(resp.status_code, resp.content) \ No newline at end of file diff --git a/setup.py b/setup.py index 7efb797..1f8248d 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ name='pusher', version='1.0.0', description='A Python library to interract with the Pusher API', - url='https://github.com/pusher/pusher-rest-python', + url='https://github.com/pusher/pusher-http-python', author='Pusher', author_email='support@pusher.com', classifiers=[ @@ -27,6 +27,7 @@ extras_require={ 'aiohttp': ["aiohttp>=0.9.0"], 'tornado': ['tornado>=4.0.0'], + 'urlfetch': ['urlfetch>=1.0'] }, test_suite='pusher_tests', From b0b40a55e66beecdff76e29eb8cb5764dc5b1339 Mon Sep 17 00:00:00 2001 From: Jamie Patel Date: Fri, 24 Apr 2015 18:04:50 +0100 Subject: [PATCH 37/39] Added tests for urlfetch adapter --- pusher/urlfetch.py | 1 - pusher_tests/urlfetch_adapter_test.py | 27 +++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 pusher_tests/urlfetch_adapter_test.py diff --git a/pusher/urlfetch.py b/pusher/urlfetch.py index 398bd07..a507549 100644 --- a/pusher/urlfetch.py +++ b/pusher/urlfetch.py @@ -4,7 +4,6 @@ division) import urlfetch -import json from pusher.http import process_response class URLFetchBackend(object): diff --git a/pusher_tests/urlfetch_adapter_test.py b/pusher_tests/urlfetch_adapter_test.py new file mode 100644 index 0000000..390e283 --- /dev/null +++ b/pusher_tests/urlfetch_adapter_test.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- + +from __future__ import print_function, absolute_import, division + +import pusher +import unittest +import httpretty +import sys + +@unittest.skipIf(sys.version_info >= (3,), "skip") +class TestURLFetchBackend(unittest.TestCase): + + def setUp(self): + import pusher.urlfetch + self.p = pusher.Pusher.from_url(u'http://key:secret@api.pusherapp.com/apps/4', + backend=pusher.urlfetch.URLFetchBackend) + + @httpretty.activate + def test_trigger_urlfetch_success(self): + httpretty.register_uri(httpretty.POST, "http://api.pusherapp.com/apps/4/events", + body="{}", + content_type="application/json") + response = self.p.trigger(u'test_channel', u'test', {u'data': u'yolo'}) + self.assertEqual(response, {}) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file From ab6e93e8435f436447b5d5ae6111d11d98da823f Mon Sep 17 00:00:00 2001 From: Jamie Patel Date: Fri, 24 Apr 2015 21:05:34 +0100 Subject: [PATCH 38/39] Amended .travis.yml to account for extras --- .travis.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index ca461a5..0686814 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,5 +5,9 @@ python: - "2.7" # - "3.2" - "3.3" -install: "python setup.py develop" +install: + - "python setup.py develop" + - "pip install aiohttp" + - "pip install tornado" + - "pip install urlfetch" script: "python setup.py test" From 3a926223e03646b18f4e9e2c57177956f113ce80 Mon Sep 17 00:00:00 2001 From: Jamie Patel Date: Fri, 24 Apr 2015 21:23:34 +0100 Subject: [PATCH 39/39] Renamed urlfetch adapter test and made it import unittest2 if v2.6 --- .travis.yml | 1 + .../{urlfetch_adapter_test.py => test_urlfetch_adapter.py} | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) rename pusher_tests/{urlfetch_adapter_test.py => test_urlfetch_adapter.py} (90%) diff --git a/.travis.yml b/.travis.yml index 0686814..5daf16d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,4 +10,5 @@ install: - "pip install aiohttp" - "pip install tornado" - "pip install urlfetch" + - if [[ $TRAVIS_PYTHON_VERSION == '2.6' ]]; then pip install unittest2; fi script: "python setup.py test" diff --git a/pusher_tests/urlfetch_adapter_test.py b/pusher_tests/test_urlfetch_adapter.py similarity index 90% rename from pusher_tests/urlfetch_adapter_test.py rename to pusher_tests/test_urlfetch_adapter.py index 390e283..9ada60e 100644 --- a/pusher_tests/urlfetch_adapter_test.py +++ b/pusher_tests/test_urlfetch_adapter.py @@ -3,10 +3,14 @@ from __future__ import print_function, absolute_import, division import pusher -import unittest import httpretty import sys +if (sys.version_info < (2,7)): + import unittest2 as unittest +else: + import unittest + @unittest.skipIf(sys.version_info >= (3,), "skip") class TestURLFetchBackend(unittest.TestCase):