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
diff --git a/.travis.yml b/.travis.yml
index ca461a5..5daf16d 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -5,5 +5,10 @@ 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"
+ - if [[ $TRAVIS_PYTHON_VERSION == '2.6' ]]; then pip install unittest2; fi
script: "python setup.py test"
diff --git a/README.md b/README.md
index d931212..d226cc1 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
@@ -50,6 +50,31 @@ contain alphanumeric characters, `-` and `_`:
pusher.trigger(u'a_channel', u'an_event', {u'some': u'data'})
```
+## Configuration
+
+```python
+from pusher import Pusher
+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 |
+|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
+
+```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
-----------------
@@ -159,13 +184,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 +207,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 +219,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 +238,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 +255,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 +275,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.
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..d194868 100644
--- a/pusher/aiohttp.py
+++ b/pusher/aiohttp.py
@@ -1,27 +1,29 @@
# -*- coding: utf-8 -*-
import aiohttp
-import pusher
+import asyncio
-class AsyncIOBackend:
- """Adapter for the aiohttp module.
-
- This backend is only availble for python 3 users and doesn't support SSL.
+from pusher.http import process_response
- :param config: pusher.Config instance
- """
+class AsyncIOBackend:
def __init__(self, config):
+ """Adapter for the requests module.
+
+ :param config: pusher.Pusher object
+ """
self.config = config
- if config.ssl:
- raise NotImplementedError("SSL not supported for this backend")
+ self.conn = aiohttp.TCPConnector()
def send_request(self, request):
method = request.method
- url = "http://%s:%s%s" % (self.config.host, self.config.port, request.path)
+ url = "%s%s" % (request.base_url, request.path)
params = request.query_params
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 pusher.process_response(response.status, body.decode('utf8'))
+ return process_response(response.status, body.decode('utf8'))
diff --git a/pusher/config.py b/pusher/config.py
deleted file mode 100644
index b8d5492..0000000
--- a/pusher/config.py
+++ /dev/null
@@ -1,193 +0,0 @@
-# -*- coding: utf-8 -*-
-
-from __future__ import (print_function, unicode_literals, absolute_import,
- division)
-from pusher.util import app_id_re, channel_name_re, text
-
-import hashlib
-import hmac
-import json
-import os
-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
- 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, 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")
-
- 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")
-
- 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)
-
- 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)
-
- @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):
- """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
- 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/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/request.py b/pusher/http.py
similarity index 60%
rename from pusher/request.py
rename to pusher/http.py
index 9ff108a..5b12130 100644
--- a/pusher/request.py
+++ b/pusher/http.py
@@ -2,25 +2,63 @@
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
import hashlib
-import hmac
import json
import six
import time
+GET, POST, PUT, DELETE = "GET", "POST", "PUT", "DELETE"
+
+class RequestMethod(object):
+ def __init__(self, pusher, f):
+ self.pusher = pusher
+ self.f = f
+
+ def __call__(self, *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)
+
+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])))
+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.
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
@@ -55,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'] = six.text_type(hmac.new(secret, message, hashlib.sha256).hexdigest())
+ self.query_params['auth_signature'] = sign(self.config.secret, auth_string)
@property
def query_string(self):
@@ -70,7 +105,11 @@ def signed_path(self):
@property
def url(self):
- 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 3d5f774..e4dd98c 100644
--- a/pusher/pusher.py
+++ b/pusher/pusher.py
@@ -2,44 +2,20 @@
from __future__ import (print_function, unicode_literals, absolute_import,
division)
-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.http import GET, POST, Request, request_method
+from pusher.signature import sign, verify
+from pusher.requests import RequestsBackend
+from pusher.util import ensure_text, validate_channel, app_id_re, pusher_url_re, channel_name_re
import collections
+import hashlib
import json
+import os
+import re
import six
-
-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
+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):
@@ -48,16 +24,93 @@ class Pusher(object):
This client supports various backend adapters to support various http
libraries available in the python ecosystem.
- :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.
+ :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 (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, config, backend=None):
- if not isinstance(config, Config):
- raise TypeError("config should be a pusher.Config object")
- self.backend = backend or SynchronousBackend(config)
- self.config = config
+ def __init__(self, app_id, key, secret, ssl=True, host=None, port=None, timeout=5, cluster=None, backend=RequestsBackend, **backend_options):
+ self._app_id = ensure_text(app_id, "app_id")
+ if not app_id_re.match(self._app_id):
+ raise ValueError("Invalid app id")
+
+ 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:
+ self._host = ensure_text(host, "host")
+ elif cluster:
+ self._host = six.text_type("api-%s.pusher.com") % ensure_text(cluster, "cluster")
+ else:
+ self._host = six.text_type("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)
+
+ if not isinstance(timeout, six.integer_types):
+ raise TypeError("timeout should be an integer")
+ self._timeout = timeout
+
+ self.http = backend(self, **backend_options)
+
+ @classmethod
+ def from_url(cls, url, **options):
+ """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")
+ """
+ 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': m.group(2),
+ 'secret': m.group(3),
+ 'host': m.group(4),
+ 'app_id': m.group(5),
+ 'ssl': ssl,
+ }
+ options_.update(options)
+
+
+ return cls(**options_)
+
+ @classmethod
+ 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.
+
+ :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)
+
+ return cls.from_url(val, **options)
@request_method
def trigger(self, channels, event_name, data, socket_id=None):
@@ -67,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:
@@ -97,10 +150,9 @@ 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
- return Request(self.config, POST, "/apps/%s/events" % self.config.app_id, params)
+ params['socket_id'] = ensure_text(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=[]):
@@ -113,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.config, GET, "/apps/%s/channels" % self.config.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=[]):
@@ -128,7 +180,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):
@@ -139,4 +191,101 @@ 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))
+
+ 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
+ :param socket_id: id of the socket that requires authorization
+ :param custom_data: used on presence channels to provide user info
+ """
+ channel = validate_channel(channel)
+
+ if not channel_name_re.match(channel):
+ raise ValueError('Channel should be a valid channel, got: %s' % channel)
+
+ socket_id = ensure_text(socket_id, "socket_id")
+
+ 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 = sign(self.secret, string_to_sign)
+
+ 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
+ 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
+ """
+ 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, body, 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
+
+ @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):
+ return 'https' if self.ssl else 'http'
diff --git a/pusher/requests.py b/pusher/requests.py
index 520d904..16fd2c7 100644
--- a/pusher/requests.py
+++ b/pusher/requests.py
@@ -2,26 +2,29 @@
from __future__ import (print_function, unicode_literals, absolute_import,
division)
-from pusher.util import process_response
+from pusher.http import process_response
import requests
class RequestsBackend(object):
"""Adapter for the requests module.
- :param config: an instance of pusher.Config
+ :param config: pusher.Pusher object
:param options: key-value passed into the requests.request constructor
"""
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,
data=request.body,
+ timeout=self.config.timeout,
**self.options
)
return process_response(resp.status_code, resp.text)
diff --git a/pusher/signature.py b/pusher/signature.py
new file mode 100644
index 0000000..146c5db
--- /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))
diff --git a/pusher/sync.py b/pusher/sync.py
deleted file mode 100644
index 70ff9f4..0000000
--- a/pusher/sync.py
+++ /dev/null
@@ -1,38 +0,0 @@
-# -*- coding: utf-8 -*-
-
-from __future__ import (print_function, unicode_literals, absolute_import,
- division)
-from pusher.util 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: 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
- 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:
- self.http = http_client.HTTPConnection(self.config.host, self.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()
- body = resp.read().decode('utf8')
- except http_client.HTTPException as e:
- raise PusherError(repr(e))
-
- return process_response(resp.status, body)
diff --git a/pusher/tornado.py b/pusher/tornado.py
index 3ccf439..662e6d4 100644
--- a/pusher/tornado.py
+++ b/pusher/tornado.py
@@ -2,26 +2,43 @@
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.
- :param config: a pusher.Config instance
- :param timeout: configurable timeout for the connection
+ :param config: pusher.Pusher object
+ :param kwargs: options for the httpclient.HTTPClient constructor
"""
- def __init__(self, config, timeout=None):
+ def __init__(self, config, **kwargs):
self.config = config
- self.timeout = timeout
- if self.config.ssl:
- raise NotImplementedError("SSL not supported for this backend")
+ self.http = tornado.httpclient.AsyncHTTPClient(**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'}
+ 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
- return tornado.httpclient.HTTPRequest(url, method=method, body=data, request_timeout=self.timeout, headers=headers)
diff --git a/pusher/urlfetch.py b/pusher/urlfetch.py
new file mode 100644
index 0000000..a507549
--- /dev/null
+++ b/pusher/urlfetch.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import (print_function, unicode_literals, absolute_import,
+ division)
+
+import urlfetch
+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/pusher/util.py b/pusher/util.py
index d48d300..d91b1df 100644
--- a/pusher/util.py
+++ b/pusher/util.py
@@ -10,47 +10,27 @@
channel_name_re = re.compile('^[-a-zA-Z0-9_=@,.;]+$')
app_id_re = re.compile('^[0-9]+$')
-
-GET, POST, PUT, DELETE = "GET", "POST", "PUT", "DELETE"
+pusher_url_re = re.compile('(http|https)://(.*):(.*)@(.*)/apps/([0-9]+)')
if sys.version_info < (3,):
text = 'a unicode string'
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)
- 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 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/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
new file mode 100644
index 0000000..60c6ce7
--- /dev/null
+++ b/pusher_tests/test_aiohttp_adapter.py
@@ -0,0 +1,4 @@
+import sys
+
+if sys.version_info >= (3,):
+ from .aio.aiohttp_adapter_test import *
diff --git a/pusher_tests/test_config.py b/pusher_tests/test_config.py
index 71e903d..8c2a5b3 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,201 +17,78 @@
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(self):
- self.assertRaises(TypeError, lambda: Config(key=u'key', secret=u'secret', ssl=False))
- 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_app_id_should_be_text_if_present(self):
+ self.assertRaises(TypeError, lambda: Pusher(app_id=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))
- 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_key_should_be_text_if_present(self):
+ self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=4, secret=u'secret', ssl=False))
- def test_secret_should_be_text(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))
-
- def test_ssl_should_be_required(self):
- self.assertRaises(TypeError, lambda: Config(app_id=u'4', key=u'key', secret=b'secret'))
+ 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))
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=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=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(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')
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')
-
- 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')
-
- 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 8217a8c..09f4436 100644
--- a/pusher_tests/test_pusher.py
+++ b/pusher_tests/test_pusher.py
@@ -2,10 +2,14 @@
from __future__ import print_function, absolute_import, division
+import os
+import six
+import hmac
+import hashlib
import unittest
-from pusher import Config, Pusher
-from pusher.util import GET
+from pusher import Pusher
+from pusher.http import GET
try:
import unittest.mock as mock
@@ -14,7 +18,35 @@
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'))
+
+ 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'))
+
+ def test_secret_should_be_text(self):
+ self.assertRaises(TypeError, lambda: Pusher(app_id=u'4', key=u'key', secret=4))
+
+ 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.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.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"}'
@@ -56,7 +88,99 @@ 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(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):
+ pusher = Pusher.from_url(u'http://foo:bar@host/apps/4')
+
+ expected = {
+ u'auth': u"foo:076740bd063f0299742a73bc5aac88900e5f35cb0185a1facbf45d326b5b204b"
+ }
+
+ self.assertEqual(pusher.authenticate(u'private-channel', u'34523'), expected)
+
+ def test_authenticate_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(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):
+ 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('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})
+
+ 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(u'key', 4, u'body'))
+ self.assertRaises(TypeError, lambda: pusher.validate_webhook(u'key', u'signature', 4))
+
+ 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.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.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.secret.encode('utf8'), body.encode('utf8'), hashlib.sha256).hexdigest())
+
+ with mock.patch('time.time', return_value=1301):
+ 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..21b1578 100644
--- a/pusher_tests/test_request.py
+++ b/pusher_tests/test_request.py
@@ -4,8 +4,8 @@
import unittest
-from pusher import Config
-from pusher.request import Request
+from pusher import Pusher
+from pusher.http import Request
try:
import unittest.mock as mock
@@ -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',
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/pusher_tests/test_urlfetch_adapter.py b/pusher_tests/test_urlfetch_adapter.py
new file mode 100644
index 0000000..9ada60e
--- /dev/null
+++ b/pusher_tests/test_urlfetch_adapter.py
@@ -0,0 +1,31 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import print_function, absolute_import, division
+
+import pusher
+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):
+
+ 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
diff --git a/setup.py b/setup.py
index ba5e4a4..1f8248d 100644
--- a/setup.py
+++ b/setup.py
@@ -1,10 +1,10 @@
# -*- 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='http://github.com/pusher/pusher-rest-python',
+ url='https://github.com/pusher/pusher-http-python',
author='Pusher',
author_email='support@pusher.com',
classifiers=[
@@ -17,15 +17,17 @@
keywords='pusher rest realtime websockets service',
license='MIT',
- packages=['pusher'],
+ packages=[
+ 'pusher'
+ ],
- install_requires=['six'],
- tests_require=['nose', 'mock'],
+ install_requires=['six', 'requests>=2.3.0'],
+ tests_require=['nose', 'mock', 'HTTPretty'],
extras_require={
'aiohttp': ["aiohttp>=0.9.0"],
- 'requests': ['requests>=2.3.0'],
'tornado': ['tornado>=4.0.0'],
+ 'urlfetch': ['urlfetch>=1.0']
},
test_suite='pusher_tests',
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")