├── .editorconfig ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── stale.yml └── workflows │ ├── release.yml │ ├── release_pr.yml │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── examples ├── trigger_aiohttp.py └── trigger_tornado.py ├── pusher ├── __init__.py ├── aiohttp.py ├── authentication_client.py ├── cacert.pem ├── client.py ├── crypto.py ├── errors.py ├── gae.py ├── http.py ├── pusher.py ├── pusher_client.py ├── requests.py ├── signature.py ├── tornado.py ├── util.py └── version.py ├── pusher_tests ├── __init__.py ├── aio │ └── aiohttp_adapter_test.py ├── test_aiohttp_adapter.py ├── test_authentication_client.py ├── test_client.py ├── test_crypto.py ├── test_gae_adapter.py ├── test_pusher.py ├── test_pusher_client.py ├── test_request.py ├── test_requests_adapter.py └── test_util.py ├── requirements.txt ├── setup.cfg └── setup.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | 11 | [*.py] 12 | indent_style = space 13 | indent_size = 4 14 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## What does this PR do? 2 | 3 | [Description here] 4 | 5 | - [ ] If you have changed dependencies, ensure _both_ `requirements.txt` and `setup.py` have been updated 6 | 7 | ## CHANGELOG 8 | 9 | - [CHANGED] Describe your change here. Look at CHANGELOG.md to see the format. 10 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Configuration for probot-stale - https://github.com/probot/stale 2 | 3 | # Number of days of inactivity before an Issue or Pull Request becomes stale 4 | daysUntilStale: 90 5 | 6 | # Number of days of inactivity before an Issue or Pull Request with the stale label is closed. 7 | # Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. 8 | daysUntilClose: 7 9 | 10 | # Only issues or pull requests with all of these labels are check if stale. Defaults to `[]` (disabled) 11 | onlyLabels: [] 12 | 13 | # Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable 14 | exemptLabels: 15 | - pinned 16 | - security 17 | 18 | # Set to true to ignore issues with an assignee (defaults to false) 19 | exemptAssignees: true 20 | 21 | # Comment to post when marking as stale. Set to `false` to disable 22 | markComment: > 23 | This issue has been automatically marked as stale because it has not had 24 | recent activity. It will be closed if no further activity occurs. If you'd 25 | like this issue to stay open please leave a comment indicating how this issue 26 | is affecting you. Thank you. 27 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [ master ] 4 | 5 | jobs: 6 | check-release-tag: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout code 10 | uses: actions/checkout@v2 11 | with: 12 | fetch-depth: 0 13 | - name: Prepare tag 14 | id: prepare_tag 15 | continue-on-error: true 16 | run: | 17 | export TAG=v$(awk '/VERSION =/ { gsub("'"\'"'",""); print $3 }' pusher/version.py) 18 | echo "TAG=$TAG" >> $GITHUB_ENV 19 | 20 | export CHECK_TAG=$(git tag | grep $TAG) 21 | if [[ $CHECK_TAG ]]; then 22 | echo "Skipping because release tag already exists" 23 | exit 1 24 | fi 25 | - name: Output 26 | id: release_output 27 | if: ${{ steps.prepare_tag.outcome == 'success' }} 28 | run: | 29 | echo "::set-output name=tag::${{ env.TAG }}" 30 | outputs: 31 | tag: ${{ steps.release_output.outputs.tag }} 32 | 33 | create-github-release: 34 | runs-on: ubuntu-latest 35 | needs: check-release-tag 36 | if: ${{ needs.check-release-tag.outputs.tag }} 37 | steps: 38 | - uses: actions/checkout@v2 39 | - name: Prepare tag 40 | run: | 41 | export TAG=v$(awk '/VERSION =/ { gsub("'"\'"'",""); print $3 }' pusher/version.py) 42 | echo "TAG=$TAG" >> $GITHUB_ENV 43 | - name: Setup git 44 | run: | 45 | git config user.email "pusher-ci@pusher.com" 46 | git config user.name "Pusher CI" 47 | - name: Prepare description 48 | run: | 49 | csplit -s CHANGELOG.md "/##/" {1} 50 | cat xx01 > CHANGELOG.tmp 51 | - name: Create Release 52 | uses: actions/create-release@v1 53 | env: 54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 55 | with: 56 | tag_name: ${{ env.TAG }} 57 | release_name: ${{ env.TAG }} 58 | body_path: CHANGELOG.tmp 59 | draft: false 60 | prerelease: false 61 | 62 | upload-to-PyPI: 63 | runs-on: ubuntu-latest 64 | needs: create-github-release 65 | steps: 66 | - uses: actions/checkout@v2 67 | - uses: actions/setup-python@v4 68 | with: 69 | python-version: '3.10' 70 | - name: Build package 71 | run: | 72 | pip install --user setuptools wheel twine 73 | rm -rf dist 74 | mkdir dist 75 | python setup.py sdist bdist_wheel 76 | - name: Publish a Python distribution to PyPI 77 | uses: pypa/gh-action-pypi-publish@release/v1 78 | with: 79 | password: ${{ secrets.PYPI_API_TOKEN }} 80 | -------------------------------------------------------------------------------- /.github/workflows/release_pr.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | pull_request: 5 | types: [ labeled ] 6 | branches: 7 | - master 8 | 9 | jobs: 10 | prepare-release: 11 | name: Prepare release 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Get current version 16 | shell: bash 17 | run: | 18 | CURRENT_VERSION=$(awk '/VERSION =/ { gsub("'"\'"'",""); print $3 }' pusher/version.py) 19 | echo "CURRENT_VERSION=$CURRENT_VERSION" >> $GITHUB_ENV 20 | - uses: actions/checkout@v2 21 | with: 22 | repository: pusher/public_actions 23 | path: .github/actions 24 | - uses: ./.github/actions/prepare-version-bump 25 | id: bump 26 | with: 27 | current_version: ${{ env.CURRENT_VERSION }} 28 | - name: Push 29 | shell: bash 30 | run: | 31 | perl -pi -e 's/${{env.CURRENT_VERSION}}/${{steps.bump.outputs.new_version}}/' pusher/version.py 32 | 33 | git add pusher/version.py CHANGELOG.md 34 | git commit -m "Bump to version ${{ steps.bump.outputs.new_version }}" 35 | git push 36 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [master, main] 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-20.04 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | python: [3.6, 3.7, 3.8, "3.10"] 15 | 16 | name: Python ${{ matrix.python }} Test 17 | 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v2 21 | 22 | - name: Setup Python 23 | uses: actions/setup-python@v2 24 | with: 25 | python-version: ${{ matrix.python }} 26 | 27 | - name: Install dependencies 28 | run: pip install -r requirements.txt 29 | 30 | - name: Run test suite 31 | run: python setup.py test 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.py[cod] 3 | 4 | .Python 5 | env/ 6 | build/ 7 | develop-eggs/ 8 | dist/ 9 | eggs/ 10 | lib/ 11 | lib64/ 12 | parts/ 13 | sdist/ 14 | var/ 15 | *.egg-info/ 16 | .installed.cfg 17 | *.egg 18 | .eggs/ 19 | 20 | pip-log.txt 21 | pip-delete-this-directory.txt 22 | 23 | .idea 24 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 3.3.2 4 | 5 | - [CHANGED] Utilities no longer escape non ascii characters. 6 | 7 | ## 3.3.1 8 | 9 | - [ADDED] Allow Client to accept float as a timeout 10 | - [CHANGED] the maximum event payload size permitted by this library has been increased. This change affects the library only: the Channels API still maintains a 10kb size limit and will return an error if the payload is too large. 11 | 12 | ## 3.3.0 13 | 14 | - [ADDED] terminate_user_connections method 15 | 16 | ## 3.2.0 2022-01-25 17 | 18 | * [FIXED] An issue where payload size wasn't being calculated properly 19 | 20 | ## 3.1.0 2021-10-07 21 | 22 | * [FIXED] Expired root certificates 23 | 24 | ## 3.0.0 2020-04-01 25 | 26 | * [ADDED] option `encryption_master_key_base64` 27 | * [DEPRECATED] option `encryption_master_key` 28 | 29 | * [REMOVED] old support for Push Notifications, see https://github.com/pusher/push-notifications-python 30 | 31 | ## 2.1.4 2019-08-09 32 | 33 | * [FIXED] TypeError in AuthenticationClient when using encrypted channels 34 | * [FIXED] RequestsDependencyWarning by updating `requests` 35 | * [FIXED] Suppress httpretty warnings 36 | * [ADDED] Tests for AuthenticationClient with encrypted channels 37 | 38 | ## 2.1.3 2019-02-26 39 | 40 | * Import Abstract Base Classes from collections.abc in Python versions >= 3.3 41 | 42 | ## 2.1.2 2019-01-02 43 | 44 | * Fixes issue where encryption_master_key wasn't passed to NotificationClient to initialise the parent class. 45 | 46 | ## 2.1.1 2018-12-13 47 | 48 | * Add pynacl as a dependency 49 | 50 | ## 2.1.0 2018-12-13 51 | 52 | * Added End-to-end Encryption 53 | * Fix ability to pass options to Tornado Backend 54 | 55 | ## 2.0.2 2018-11-05 56 | 57 | * Support Tornado 5, drop support for Tornado 4 58 | * Check for error responses with AsyncIO backend 59 | 60 | ## 2.0.1 2018-05-21 61 | 62 | * Fix issue where aiohttp ClientSession was not being closed 63 | 64 | ## 2.0.0 2018-05-03 65 | 66 | * Drop support for Python 2.6, 3.3 67 | * Drop support for Python 3.4 with the aiohttp adaptor 68 | 69 | ## 1.7.4 2018-02-05 70 | 71 | * Properly close client after request in aiohttp adaptor 72 | 73 | ## 1.7.3 2018-01-24 74 | 75 | * Replace `read_and_close` with `text` in aiohttp adaptor (method removed 76 | upstream) 77 | 78 | ## 1.7.2 2017-07-19 79 | 80 | * Remove `webhook_level` option to notify (depricated upstream) 81 | 82 | * Increase notify timeout to 30s 83 | 84 | ## 1.7.1 2017-06-12 85 | 86 | * Make python 2 and 3 support explicit in `setup.py` 87 | 88 | * Lift trigger channel limit to 100 for consistency with API 89 | 90 | ## 1.7.0 2017-05-12 91 | 92 | * Remove version freeze from urllib3 since upstream bugfix has been released. (See [here](https://github.com/shazow/urllib3/pull/987).) 93 | 94 | ## 1.6.0 1016-10-26 95 | 96 | * Path to cacert.pem has been added to the setup.py, resolving an oversight that led to errors upon SSL requests. 97 | * Internal changes to ease future maintenance. 98 | 99 | ## 1.5.0 2016-08-23 100 | 101 | * Add support for publishing push notifications on up to 10 interests. 102 | 103 | ## 1.4.0 2016-08-15 104 | 105 | * Add support for sending push notifications. 106 | 107 | ## 1.3.0 2016-05-24 108 | 109 | * Add support for batch events 110 | 111 | ## 1.2.3 2015-06-22 112 | 113 | * Fixes sharing default mutable argument between requests 114 | * Only load RequestsBackend when required (avoids issues on GAE) 115 | 116 | ## 1.2.2 2015-06-12 117 | 118 | Added Wheel file publishing. No functional changes. 119 | 120 | ## 1.2.1 2015-06-03 121 | 122 | Added cacert.pem to the package, getting rid of errors upon SSL calls. 123 | 124 | ## 1.2.0 2015-05-29 125 | 126 | * Renamed `URLFetchBackend` to `GAEBackend`, which specifically imports the Google App Engine urlfetch library. 127 | * Library creates an SSL context from certificate, addressing users receiving `InsecurePlatformWarning`s. 128 | 129 | ## 1.1.3 2015-05-12 130 | 131 | Tightened up socket_id validation regex. 132 | 133 | ## 1.1.2 2015-05-08 134 | 135 | Fixed oversight in socket_id validation regex. 136 | 137 | ## 1.1.1 2015-05-08 138 | 139 | * Library now validates `socket_id` for the `trigger` method. 140 | 141 | ## 1.1.0 2015-05-07 142 | 143 | * User can now specify a custom JSON encoder or decoder upon initializing Pusher. 144 | 145 | ## 1.0.0 2015-04-25 146 | 147 | * Python 2.6, 2.7 and 3.3 support 148 | * Adapters for various http libraries like requests, urlfetch, aiohttp and tornado. 149 | * WebHook validation 150 | * Signature generation for socket subscriptions 151 | 152 | ## 0.1.0 2014-09-01 153 | 154 | * First release 155 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Pusher Ltd. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pusher Channels HTTP Python Library 2 | 3 | [![Build Status](https://github.com/pusher/pusher-http-python/workflows/Tests/badge.svg)](https://github.com/pusher/pusher-http-python/actions?query=workflow%3ATests+branch%3Amaster) 4 | [![PyPI version](https://badge.fury.io/py/pusher.svg)](https://badge.fury.io/py/pusher) 5 | 6 | This package lets you trigger events to your client and query the state of your channels. When used with a server, you can validate webhooks and authenticate `private-` or `presence-` channels. 7 | 8 | In order to use this library, you need to have a free account on . After registering, you will need the application credentials for your app. 9 | 10 | ## Supported Platforms 11 | 12 | * Python - supports Python version 3.6 and above 13 | 14 | ## Features 15 | 16 | * Adapters for various http libraries like requests, urlfetch, aiohttp (requires Python >= 3.5.3) and tornado. 17 | * WebHook validation 18 | * Signature generation for socket subscriptions 19 | 20 | ### Table of Contents 21 | 22 | - [Installation](#installation) 23 | - [Getting started](#getting-started) 24 | - [Configuration](#configuration) 25 | - [Triggering Events](#triggering-events) 26 | - [Send a message to a specific user](#send-a-message-to-a-specific-user) 27 | - [Querying Application State](#querying-application-state) 28 | - [Getting Information For All Channels](#getting-information-for-all-channels) 29 | - [Getting Information For A Specific Channel](#getting-information-for-a-specific-channel) 30 | - [Getting User Information For A Presence Channel](#getting-user-information-for-a-presence-channel) 31 | - [Authenticating Channel Subscription](#authenticating-channel-subscription) 32 | - [Authenticating User](#authenticating-user) 33 | - [Terminating User Connections](#terminating-user-connections) 34 | - [End-to-end Encryption](#end-to-end-encryption) 35 | - [Receiving Webhooks](#receiving-webhooks) 36 | - [Request Library Configuration](#request-library-configuration) 37 | - [Google App Engine](#google-app-engine) 38 | - [Feature Support](#feature-support) 39 | - [Running the tests](#running-the-tests) 40 | - [License](#license) 41 | 42 | ## Installation 43 | 44 | You can install this module using your package management method or choice, 45 | normally `easy_install` or `pip`. For example: 46 | 47 | ```bash 48 | pip install pusher 49 | ``` 50 | 51 | Users on Python 2.x and older versions of pip may get a warning, due to pip compiling the optional `pusher.aiohttp` module, which uses Python 3 syntax. However, as `pusher.aiohttp` is not used by default, this does not affect the library's functionality. See [our Github issue](https://github.com/pusher/pusher-http-python/issues/52), as well as [this issue from Gunicorn](https://github.com/benoitc/gunicorn/issues/788) for more details. 52 | 53 | On Linux, you must ensure that OpenSSL is installed, e.g. on Debian/Ubuntu: 54 | 55 | ```sh 56 | $ sudo apt-get install build-essential libssl-dev libffi-dev 57 | ``` 58 | 59 | ## Getting started 60 | 61 | The minimum configuration required to use the `Pusher` object are the three 62 | constructor arguments which identify your Pusher Channels app. You can find them by 63 | going to "API Keys" on your app at . 64 | 65 | ```python 66 | import pusher 67 | pusher_client = pusher.Pusher(app_id=u'4', key=u'key', secret=u'secret', cluster=u'cluster') 68 | ``` 69 | 70 | You can then trigger events to channels. Channel and event names may only 71 | contain alphanumeric characters, `-` and `_`: 72 | 73 | ```python 74 | pusher_client.trigger(u'a_channel', u'an_event', {u'some': u'data'}) 75 | ``` 76 | 77 | ## Configuration 78 | 79 | ```python 80 | import pusher 81 | pusher_client = pusher.Pusher(app_id, key, secret, cluster=u'cluster') 82 | ``` 83 | 84 | |Argument |Description | 85 | |:-:|:-:| 86 | |app_id `String` |**Required**
The Pusher Channels application ID | 87 | |key `String` |**Required**
The Pusher Channels application key | 88 | |secret `String` |**Required**
The Pusher Channels application secret token | 89 | |cluster `String` | **Default:`mt1`**
The pusher application cluster. Will be overwritten if `host` is set | 90 | |host `String` | **Default:`None`**
The host to connect to | 91 | |port `int` | **Default:`None`**
Which port to connect to | 92 | |ssl `bool` | **Default:`True`**
Use HTTPS | 93 | |~~encryption_master_key~~ `String` | **Default:`None`**
*Deprecated*, see `encryption_master_key_base64` | 94 | |encryption_master_key_base64 `String` | **Default:`None`**
The encryption master key for End-to-end Encryption | 95 | |backend `Object` | an object that responds to the `send_request(request)` method. If none is provided, a `pusher.requests.RequestsBackend` instance is created. | 96 | |json_encoder `Object` | **Default: `None`**
Custom JSON encoder. | 97 | |json_decoder `Object` | **Default: `None`**
Custom JSON decoder. 98 | 99 | The constructor will throw a `TypeError` if it is called with parameters that don’t match the types listed above. 100 | 101 | ##### Example 102 | 103 | ```py 104 | import pusher 105 | pusher_client = pusher.Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True, cluster=u'cluster') 106 | ``` 107 | 108 | ## Triggering Events 109 | 110 | To trigger an event on one or more channels, use the `trigger` method on the `Pusher` object. 111 | 112 | #### `Pusher::trigger` 113 | 114 | |Argument |Description | 115 | |:-:|:-:| 116 | |channels `String` or `Collection` |**Required**
The name or list of names of the channel you wish to trigger events on | 117 | |event `String`| **Required**
The name of the event you wish to trigger. | 118 | |data `JSONable data` | **Required**
The event's payload | 119 | |socket_id `String` | **Default:`None`**
The socket_id of the connection you wish to exclude from receiving the event. You can read more [here](https://pusher.com/docs/channels/server_api/excluding-event-recipients/). | 120 | 121 | |Return Values |Description | 122 | |:-:|:-:| 123 | |buffered_events `Dict` | A parsed response that includes the event_id for each event published to a channel. See example. | 124 | 125 | `Pusher::trigger` will throw a `TypeError` if called with parameters of the wrong type; or a `ValueError` if called on more than 100 channels, with an event name longer than 200 characters, or with more than 10240 characters of data (post JSON serialisation). 126 | 127 | ##### Example 128 | 129 | This call will trigger to `'a_channel'` and `'another_channel'`, and exclude the recipient with socket_id `"1234.12"`. 130 | 131 | ```python 132 | pusher_client.trigger([u'a_channel', u'another_channel'], u'an_event', {u'some': u'data'}, "1234.12") 133 | ``` 134 | 135 | #### `Pusher::trigger_batch` 136 | 137 | It's also possible to send distinct messages in batches to limit the overhead 138 | of HTTP headers. There is a current limit of 10 events per batch on 139 | our multi-tenant clusters. 140 | 141 | |Argument |Description | 142 | |:-:|:-:| 143 | |batch `Array` of `Dict` |**Required**
A list of events to trigger | 144 | 145 | Events are a `Dict` with keys: 146 | 147 | |Argument |Description | 148 | |:-:|:-:| 149 | |channel `String`| **Required**
The name of the channel to publish to. | 150 | |name `String`| **Required**
The name of the event you wish to trigger. | 151 | |data `JSONable data` | **Required**
The event's payload | 152 | |socket_id `String` | **Default:`None`**
The socket_id of the connection you wish to exclude from receiving the event. You can read more [here](http://pusher.com/docs/duplicates). | 153 | 154 | |Return Values |Description | 155 | |:-:|:-:| 156 | |`Dict`| An empty dict on success | 157 | 158 | `Pusher::trigger_batch` will throw a `TypeError` if the data parameter is not JSONable. 159 | 160 | ##### Example 161 | 162 | ```python 163 | pusher_client.trigger_batch([ 164 | { u'channel': u'a_channel', u'name': u'an_event', u'data': {u'some': u'data'}, u'socket_id': '1234.12'}, 165 | { u'channel': u'a_channel', u'name': u'an_event', u'data': {u'some': u'other data'}} 166 | ]) 167 | ``` 168 | 169 | ### Send a message to a specific user 170 | 171 | #### `Pusher::send_to_user` 172 | 173 | |Argument |Description | 174 | |:-:|:-:| 175 | |user_id `String` |**Required**
The user id | 176 | |event `String`| **Required**
The name of the event you wish to trigger. | 177 | |data `JSONable data` | **Required**
The event's payload | 178 | 179 | |Return Values |Description | 180 | |:-:|:-:| 181 | |buffered_events `Dict` | A parsed response that includes the event_id for each event published to a channel. See example. | 182 | 183 | `Pusher::trigger` will throw a `TypeError` if called with parameters of the wrong type; or a `ValueError` if called on more than 100 channels, with an event name longer than 200 characters, or with more than 10240 characters of data (post JSON serialisation). 184 | 185 | ##### Example 186 | 187 | This call will send a message to the user with id `'123'`. 188 | 189 | ```python 190 | pusher_client.send_to_user( u'123', u'some_event', {u'message': u'hello worlds'}) 191 | ``` 192 | 193 | ## Querying Application State 194 | 195 | ### Getting Information For All Channels 196 | 197 | 198 | #### `Pusher::channels_info` 199 | 200 | |Argument |Description | 201 | |:-:|:-:| 202 | |prefix_filter `String` |**Default: `None`**
Filter the channels returned by their prefix | 203 | |attributes `Collection` | **Default: `[]`**
A collection of attributes which should be returned for each channel. If empty, an empty dictionary of attributes will be returned for each channel.
Available attributes: `"user_count"`. 204 | 205 | |Return Values |Description | 206 | |:-:|:-:| 207 | |channels `Dict` | A parsed response from the HTTP API. See example. | 208 | 209 | `Pusher::channels_info` will throw a `TypeError` if `prefix_filter` is not a `String`. 210 | 211 | ##### Example 212 | 213 | ```python 214 | channels = pusher_client.channels_info(u"presence-", [u'user_count']) 215 | 216 | #=> {u'channels': {u'presence-chatroom': {u'user_count': 2}, u'presence-notifications': {u'user_count': 1}}} 217 | ``` 218 | 219 | ### Getting Information For A Specific Channel 220 | 221 | #### `Pusher::channel_info` 222 | 223 | |Argument |Description | 224 | |:-:|:-:| 225 | |channel `String` |**Required**
The name of the channel you wish to query| 226 | |attributes `Collection` | **Default: `[]`**
A collection of attributes to be returned for the channel.

Available attributes:
`"user_count"` : Number of *distinct* users currently subscribed. **Applicable only to presence channels**.
`"subscription_count"`: Number of *connections* currently subscribed to the channel. Enable this feature in your Pusher dashboard's App Settings. 227 | 228 | |Return Values |Description | 229 | |:-:|:-:| 230 | |channel `Dict` | A parsed response from the HTTP API. See example. | 231 | 232 | `Pusher::channel_info` will throw a `ValueError` if `channel` is not a valid channel. 233 | 234 | ##### Example 235 | 236 | ```python 237 | channel = pusher_client.channel_info(u'presence-chatroom', [u"user_count"]) 238 | #=> {u'user_count': 42, u'occupied': True} 239 | ``` 240 | 241 | ### Getting User Information For A Presence Channel 242 | 243 | #### `Pusher::users_info` 244 | 245 | |Argument |Description | 246 | |:-:|:-:| 247 | |channel `String` |**Required**
The name of the *presence* channel you wish to query | 248 | 249 | |Return Values |Description | 250 | |:-:|:-:| 251 | |users `Dict` | A parsed response from the HTTP API. See example. | 252 | 253 | `Pusher::users_info` will throw a `ValueError` if `channel` is not a valid channel. 254 | 255 | ##### Example 256 | 257 | ```python 258 | pusher_client.users_info(u'presence-chatroom') 259 | #=> {u'users': [{u'id': u'1035'}, {u'id': u'4821'}]} 260 | ``` 261 | 262 | ## Authenticating Channel Subscription 263 | 264 | #### `Pusher::authenticate` 265 | 266 | In order for users to subscribe to a private- or presence-channel, they must be authenticated by your server. 267 | 268 | 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. 269 | 270 | 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. 271 | 272 | |Argument |Description | 273 | |:-:|:-:| 274 | |channel `String` |**Required**
The name of the channel, sent to you in the POST request | 275 | |socket_id `String` | **Required**
The channel's socket_id, also sent to you in the POST request | 276 | |custom_data `Dict` |**Required for presence channels**
This will be a dictionary containing the data you want associated with a member of a presence channel. A `"user_id"` key is *required*, and you can optionally pass in a `"user_info"` key. See the example below. | 277 | 278 | |Return Values |Description | 279 | |:-:|:-:| 280 | |response `Dict` | A dictionary to send as a response to the authentication request.| 281 | 282 | `Pusher::authenticate` will throw a `ValueError` if the `channel` or `socket_id` that it’s called with are invalid. 283 | 284 | ##### Example 285 | 286 | ###### Private Channels 287 | 288 | ```python 289 | auth = pusher_client.authenticate( 290 | 291 | channel=u"private-channel", 292 | 293 | socket_id=u"1234.12" 294 | ) 295 | # return `auth` as a response 296 | ``` 297 | 298 | ###### Presence Channels 299 | 300 | ```python 301 | auth = pusher_client.authenticate( 302 | 303 | channel=u"presence-channel", 304 | 305 | socket_id=u"1234.12", 306 | 307 | custom_data={ 308 | u'user_id': u'1', 309 | u'user_info': { 310 | u'twitter': '@pusher' 311 | } 312 | } 313 | ) 314 | # return `auth` as a response 315 | ``` 316 | 317 | ## Authenticating User 318 | 319 | #### `Pusher::authenticate_user` 320 | 321 | To authenticate users on Pusher Channels on your application, you can use the authenticate_user function: 322 | 323 | |Argument |Description | 324 | |:-:|:-:| 325 | |socket_id `String` | **Required**
The channel's socket_id, also sent to you in the POST request | 326 | |user_data `Dict` |**Required for presence channels**
This will be a dictionary containing the data you want associated with a user. An `"id"` key is *required* | 327 | 328 | |Return Values |Description | 329 | |:-:|:-:| 330 | |response `Dict` | A dictionary to send as a response to the authentication request.| 331 | 332 | For more information see: 333 | * [authenticating users](https://pusher.com/docs/channels/server_api/authenticating-users/) 334 | * [auth-signatures](https://pusher.com/docs/channels/library_auth_reference/auth-signatures/) 335 | 336 | ##### Example 337 | 338 | ###### User Authentication 339 | 340 | ```python 341 | auth = pusher_client.authenticate_user( 342 | socket_id=u"1234.12", 343 | user_data = { 344 | u'id': u'123', 345 | u'name': u'John Smith' 346 | } 347 | ) 348 | # return `auth` as a response 349 | ``` 350 | 351 | ## Terminating user connections 352 | 353 | TIn order to terminate a user's connections, the user must have been authenticated. Check the [Server user authentication docs](http://pusher.com/docs/authenticating_users) for the information on how to create a user authentication endpoint. 354 | 355 | To terminate all connections established by a given user, you can use the `terminate_user_connections` function: 356 | 357 | ```python 358 | pusher_client.terminate_user_connections(userId) 359 | ``` 360 | 361 | Please note, that it only terminates the user's active connections. This means, if nothing else is done, the user will be able to reconnect. For more information see: [Terminating user connections docs](https://pusher.com/docs/channels/server_api/terminating-user-connections/). 362 | 363 | ## End to End Encryption 364 | 365 | This library supports end to end encryption of your private channels. This 366 | means that only you and your connected clients will be able to read your 367 | messages. Pusher cannot decrypt them. You can enable this feature by following 368 | these steps: 369 | 370 | 1. You should first set up Private channels. This involves [creating an 371 | authentication endpoint on your 372 | server](https://pusher.com/docs/authenticating_users). 373 | 374 | 2. Next, generate a 32 byte master encryption key, base64 encode it and store 375 | it securely. 376 | 377 | This is secret and you should never share this with anyone. Not even Pusher. 378 | 379 | To generate a suitable key from a secure random source, you could use: 380 | 381 | ```bash 382 | openssl rand -base64 32 383 | ``` 384 | 385 | 3. Pass your master key to the SDK constructor 386 | 387 | ```python 388 | import pusher 389 | 390 | pusher_client = pusher.Pusher( 391 | app_id='yourappid', 392 | key='yourkey', 393 | secret='yoursecret', 394 | encryption_master_key_base64='', 395 | cluster='yourclustername', 396 | ssl=True 397 | ) 398 | 399 | pusher_client.trigger('private-encrypted-my-channel', 'my-event', { 400 | 'message': 'hello world' 401 | }) 402 | ``` 403 | 404 | 4. Channels where you wish to use end to end encryption must be prefixed with 405 | `private-encrypted-`. 406 | 407 | 5. Subscribe to these channels in your client, and you're done! You can verify 408 | it is working by checking out the debug console on the 409 | https://dashboard.pusher.com/ and seeing the scrambled ciphertext. 410 | 411 | **Important note: This will not encrypt messages on channels that are not prefixed by private-encrypted-.** 412 | 413 | More info on End-to-end Encrypted Channels [here](https://pusher.com/docs/client_api_guide/client_encrypted_channels). 414 | 415 | ## Receiving Webhooks 416 | 417 | 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. 418 | 419 | #### `Pusher::validate_webhook` 420 | 421 | |Argument |Description | 422 | |:-:|:-:| 423 | |key `String` | **Required**
Pass in the value sent in the request headers under the key "X-PUSHER-KEY". The method will check this matches your app key. | 424 | |signature `String` | **Required**
This is the value in the request headers under the key "X-PUSHER-SIGNATURE". The method will verify that this is the result of signing the request body against your app secret. | 425 | |body `String` | **Required**
The JSON string of the request body received. | 426 | 427 | |Return Values |Description | 428 | |:-:|:-:| 429 | |body_data `Dict` | If validation was successful, the return value will be the parsed payload. Otherwise, it will be `None`. | 430 | 431 | `Pusher::validate_webhook` will raise a `TypeError` if it is called with any parameters of the wrong type. 432 | 433 | ##### Example 434 | 435 | ```python 436 | webhook = pusher_client.validate_webhook( 437 | 438 | key="key_sent_in_header", 439 | 440 | signature="signature_sent_in_header", 441 | 442 | body="{ \"time_ms\": 1327078148132 \"events\": [ { \"name\": \"event_name\", \"some\": \"data\" } ]}" 443 | ) 444 | 445 | print webhook["events"] 446 | ``` 447 | 448 | ## Request Library Configuration 449 | 450 | Users can configure the library to use different backends to send calls to our API. The HTTP libraries we support are: 451 | 452 | * [Requests](https://requests.readthedocs.io/en/master/) (`pusher.requests.RequestsBackend`). This is used by default. 453 | * [Tornado](http://www.tornadoweb.org/en/stable/) (`pusher.tornado.TornadoBackend`). 454 | * [AsyncIO](https://docs.python.org/3/library/asyncio.html) (`pusher.aiohttp.AsyncIOBackend`). 455 | * [Google App Engine](https://cloud.google.com/appengine/docs/python/urlfetch/) (`pusher.gae.GAEBackend`). 456 | 457 | Upon initializing a `Pusher` instance, pass in any of these options to the `backend` keyword argument. 458 | 459 | ### Google App Engine 460 | 461 | GAE users are advised to use the `pusher.gae.GAEBackend` backend to ensure compatability. 462 | 463 | ## Feature Support 464 | 465 | Feature | Supported 466 | -------------------------------------------| :-------: 467 | Trigger event on single channel | *✔* 468 | Trigger event on multiple channels | *✔* 469 | Trigger event to a specifc user | *✔* 470 | Excluding recipients from events | *✔* 471 | Authenticating private channels | *✔* 472 | Authenticating presence channels | *✔* 473 | Authenticating users | *✔* 474 | Get the list of channels in an application | *✔* 475 | Get the state of a single channel | *✔* 476 | Get a list of users in a presence channel | *✔* 477 | WebHook validation | *✔* 478 | Terminate user connections | *✔* 479 | Heroku add-on support | *✔* 480 | Debugging & Logging | *✔* 481 | Cluster configuration | *✔* 482 | Timeouts | *✔* 483 | HTTPS | *✔* 484 | End-to-end Encryption | *✔* 485 | HTTP Proxy configuration | *✘* 486 | HTTP KeepAlive | *✘* 487 | 488 | #### Helper Functionality 489 | 490 | These are helpers that have been implemented to to ensure interactions with the HTTP API only occur if they will not be rejected e.g. [channel naming conventions](https://pusher.com/docs/channels/using_channels/channels#channel-naming-conventions). 491 | 492 | Helper Functionality | Supported 493 | -----------------------------------------| :-------: 494 | Channel name validation | ✔ 495 | Limit to 100 channels per trigger | ✔ 496 | Limit event name length to 200 chars | ✔ 497 | 498 | 499 | ## Running the tests 500 | 501 | To run the tests run `python setup.py test` 502 | 503 | ## License 504 | 505 | Copyright (c) 2015 Pusher Ltd. See [LICENSE](LICENSE) for details. 506 | 507 | -------------------------------------------------------------------------------- /examples/trigger_aiohttp.py: -------------------------------------------------------------------------------- 1 | import pusher 2 | import pusher.aiohttp 3 | import asyncio 4 | 5 | def main(): 6 | pusher_client = pusher.Pusher.from_env( 7 | backend=pusher.aiohttp.AsyncIOBackend, 8 | timeout=50 9 | ) 10 | print("before trigger") 11 | response = yield from pusher_client.trigger("hello", "world", dict(foo='bar')) 12 | print(response) 13 | 14 | asyncio.get_event_loop().run_until_complete(main()) 15 | -------------------------------------------------------------------------------- /examples/trigger_tornado.py: -------------------------------------------------------------------------------- 1 | import pusher 2 | import pusher.tornado 3 | import tornado.ioloop 4 | 5 | ioloop = tornado.ioloop.IOLoop.instance() 6 | 7 | def show_response(response): 8 | print(response.result()) 9 | ioloop.stop() 10 | 11 | pusher_client = pusher.Pusher.from_env( 12 | backend=pusher.tornado.TornadoBackend, 13 | timeout=50 14 | ) 15 | response = pusher_client.trigger("hello", "world", dict(foo='bar')) 16 | response.add_done_callback(show_response) 17 | print("Before start") 18 | ioloop.start() 19 | print("After start") 20 | -------------------------------------------------------------------------------- /pusher/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from .pusher import Pusher 4 | 5 | __all__ = ['Pusher'] 6 | -------------------------------------------------------------------------------- /pusher/aiohttp.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import ( 4 | print_function, 5 | unicode_literals, 6 | absolute_import, 7 | division) 8 | 9 | import aiohttp 10 | import asyncio 11 | 12 | from pusher.http import process_response 13 | 14 | 15 | class AsyncIOBackend: 16 | def __init__(self, client): 17 | """Adapter for the requests module. 18 | 19 | :param client: pusher.Client object 20 | """ 21 | self.client = client 22 | 23 | 24 | @asyncio.coroutine 25 | def send_request(self, request): 26 | session = response = None 27 | try: 28 | session = aiohttp.ClientSession() 29 | response = yield from session.request( 30 | request.method, 31 | "%s%s" % (request.base_url, request.path), 32 | params=request.query_params, 33 | data=request.body, 34 | headers=request.headers, 35 | timeout=self.client.timeout 36 | ) 37 | body = yield from response.text('utf-8') 38 | return process_response(response.status, body) 39 | finally: 40 | if response is not None: 41 | response.close() 42 | if session is not None: 43 | yield from session.close() 44 | -------------------------------------------------------------------------------- /pusher/authentication_client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import ( 4 | print_function, 5 | unicode_literals, 6 | absolute_import, 7 | division) 8 | 9 | import collections 10 | import hashlib 11 | import json 12 | import os 13 | import re 14 | import six 15 | import time 16 | import base64 17 | 18 | from pusher.util import ( 19 | ensure_text, 20 | ensure_binary, 21 | validate_channel, 22 | validate_socket_id, 23 | validate_user_data, 24 | channel_name_re 25 | ) 26 | 27 | from pusher.client import Client 28 | from pusher.http import GET, POST, Request, request_method 29 | from pusher.signature import sign, verify 30 | from pusher.crypto import * 31 | 32 | 33 | class AuthenticationClient(Client): 34 | def __init__( 35 | self, 36 | app_id, 37 | key, 38 | secret, 39 | ssl=True, 40 | host=None, 41 | port=None, 42 | timeout=5, 43 | cluster=None, 44 | encryption_master_key=None, 45 | encryption_master_key_base64=None, 46 | json_encoder=None, 47 | json_decoder=None, 48 | backend=None, 49 | **backend_options): 50 | 51 | super(AuthenticationClient, self).__init__( 52 | app_id, 53 | key, 54 | secret, 55 | ssl, 56 | host, 57 | port, 58 | timeout, 59 | cluster, 60 | encryption_master_key, 61 | encryption_master_key_base64, 62 | json_encoder, 63 | json_decoder, 64 | backend, 65 | **backend_options) 66 | 67 | def authenticate(self, channel, socket_id, custom_data=None): 68 | """Used to generate delegated client subscription token. 69 | 70 | :param channel: name of the channel to authorize subscription to 71 | :param socket_id: id of the socket that requires authorization 72 | :param custom_data: used on presence channels to provide user info 73 | """ 74 | channel = validate_channel(channel) 75 | 76 | if not channel_name_re.match(channel): 77 | raise ValueError('Channel should be a valid channel, got: %s' % channel) 78 | 79 | socket_id = validate_socket_id(socket_id) 80 | 81 | if custom_data: 82 | custom_data = json.dumps(custom_data, cls=self._json_encoder) 83 | 84 | string_to_sign = "%s:%s" % (socket_id, channel) 85 | 86 | if custom_data: 87 | string_to_sign += ":%s" % custom_data 88 | 89 | signature = sign(self.secret, string_to_sign) 90 | 91 | auth = "%s:%s" % (self.key, signature) 92 | response_payload = {"auth": auth} 93 | 94 | if is_encrypted_channel(channel): 95 | shared_secret = generate_shared_secret( 96 | ensure_binary(channel, "channel"), self._encryption_master_key) 97 | shared_secret_b64 = base64.b64encode(shared_secret) 98 | response_payload["shared_secret"] = shared_secret_b64 99 | 100 | if custom_data: 101 | response_payload['channel_data'] = custom_data 102 | 103 | return response_payload 104 | 105 | def authenticate_user(self, socket_id, user_data=None): 106 | """Creates a user authentication signature. 107 | 108 | :param socket_id: id of the socket that requires authorization 109 | :param user_data: used to provide user info 110 | """ 111 | validate_user_data(user_data) 112 | socket_id = validate_socket_id(socket_id) 113 | 114 | user_data_encoded = json.dumps(user_data, cls=self._json_encoder) 115 | 116 | string_to_sign = "%s::user::%s" % (socket_id, user_data_encoded) 117 | 118 | signature = sign(self.secret, string_to_sign) 119 | 120 | auth_response = "%s:%s" % (self.key, signature) 121 | response_payload = {"auth": auth_response, 'user_data': user_data_encoded} 122 | 123 | return response_payload 124 | 125 | def validate_webhook(self, key, signature, body): 126 | """Used to validate incoming webhook messages. When used it guarantees 127 | that the sender is Pusher and not someone else impersonating it. 128 | 129 | :param key: key used to sign the body 130 | :param signature: signature that was given with the body 131 | :param body: content that needs to be verified 132 | """ 133 | key = ensure_text(key, "key") 134 | signature = ensure_text(signature, "signature") 135 | body = ensure_text(body, "body") 136 | 137 | if key != self.key: 138 | return None 139 | 140 | if not verify(self.secret, body, signature): 141 | return None 142 | 143 | try: 144 | body_data = json.loads(body, cls=self._json_decoder) 145 | 146 | except ValueError: 147 | return None 148 | 149 | time_ms = body_data.get('time_ms') 150 | if not time_ms: 151 | return None 152 | 153 | if abs(time.time() * 1000 - time_ms) > 300000: 154 | return None 155 | 156 | return body_data 157 | 158 | -------------------------------------------------------------------------------- /pusher/client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import ( 4 | print_function, 5 | unicode_literals, 6 | absolute_import, 7 | division) 8 | 9 | import six 10 | 11 | from pusher.util import ensure_text, ensure_binary, app_id_re 12 | from pusher.crypto import parse_master_key 13 | 14 | 15 | class Client(object): 16 | def __init__( 17 | self, 18 | app_id, 19 | key, 20 | secret, 21 | ssl=True, 22 | host=None, 23 | port=None, 24 | timeout=5, 25 | cluster=None, 26 | encryption_master_key=None, 27 | encryption_master_key_base64=None, 28 | json_encoder=None, 29 | json_decoder=None, 30 | backend=None, 31 | **backend_options): 32 | 33 | if backend is None: 34 | from .requests import RequestsBackend 35 | backend = RequestsBackend 36 | 37 | self._app_id = ensure_text(app_id, "app_id") 38 | if not app_id_re.match(self._app_id): 39 | raise ValueError("Invalid app id") 40 | 41 | self._key = ensure_text(key, "key") 42 | self._secret = ensure_text(secret, "secret") 43 | 44 | if not isinstance(ssl, bool): 45 | raise TypeError("SSL should be a boolean") 46 | 47 | self._ssl = ssl 48 | 49 | if host: 50 | self._host = ensure_text(host, "host") 51 | elif cluster: 52 | self._host = ( 53 | six.text_type("api-%s.pusher.com") % 54 | ensure_text(cluster, "cluster")) 55 | else: 56 | self._host = six.text_type("api.pusherapp.com") 57 | 58 | if port and not isinstance(port, six.integer_types): 59 | raise TypeError("port should be an integer") 60 | 61 | self._port = port or (443 if ssl else 80) 62 | 63 | if not (isinstance(timeout, six.integer_types) or isinstance(timeout, float)): 64 | raise TypeError("timeout should be an integer or a float") 65 | 66 | self._timeout = timeout 67 | self._json_encoder = json_encoder 68 | self._json_decoder = json_decoder 69 | 70 | self._encryption_master_key = parse_master_key(encryption_master_key, encryption_master_key_base64) 71 | 72 | self.http = backend(self, **backend_options) 73 | 74 | 75 | @property 76 | def app_id(self): 77 | return self._app_id 78 | 79 | @property 80 | def key(self): 81 | return self._key 82 | 83 | @property 84 | def secret(self): 85 | return self._secret 86 | 87 | @property 88 | def host(self): 89 | return self._host 90 | 91 | @property 92 | def port(self): 93 | return self._port 94 | 95 | @property 96 | def timeout(self): 97 | return self._timeout 98 | 99 | @property 100 | def ssl(self): 101 | return self._ssl 102 | 103 | @property 104 | def scheme(self): 105 | return 'https' if self.ssl else 'http' 106 | -------------------------------------------------------------------------------- /pusher/crypto.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import ( 4 | print_function, 5 | unicode_literals, 6 | absolute_import, 7 | division) 8 | 9 | import hashlib 10 | import nacl 11 | import base64 12 | import binascii 13 | import warnings 14 | 15 | from pusher.util import ( 16 | ensure_text, 17 | ensure_binary, 18 | data_to_string, 19 | is_base64) 20 | 21 | import nacl.secret 22 | import nacl.utils 23 | 24 | # The prefix any e2e channel must have 25 | ENCRYPTED_PREFIX = 'private-encrypted-' 26 | 27 | def is_encrypted_channel(channel): 28 | """ 29 | is_encrypted_channel() checks if the channel is encrypted by verifying the prefix 30 | """ 31 | if channel.startswith(ENCRYPTED_PREFIX): 32 | return True 33 | return False 34 | 35 | def parse_master_key(encryption_master_key, encryption_master_key_base64): 36 | """ 37 | parse_master_key validates, parses and returns the bytes of the encryption master key 38 | from the constructor arguments. 39 | At present there is a deprecated "raw" key and a suggested base64 encoding. 40 | """ 41 | if encryption_master_key is not None and encryption_master_key_base64 is not None: 42 | raise ValueError("Do not provide both encryption_master_key and encryption_master_key_base64. " + 43 | "encryption_master_key is deprecated, provide only encryption_master_key_base64") 44 | 45 | if encryption_master_key is not None: 46 | warnings.warn("`encryption_master_key` is deprecated, please use `encryption_master_key_base64`") 47 | if len(encryption_master_key) == 32: 48 | return ensure_binary(encryption_master_key, "encryption_master_key") 49 | else: 50 | raise ValueError("encryption_master_key must be 32 bytes long") 51 | 52 | if encryption_master_key_base64 is not None: 53 | if is_base64(encryption_master_key_base64): 54 | decoded = base64.b64decode(encryption_master_key_base64) 55 | 56 | if len(decoded) == 32: 57 | return decoded 58 | else: 59 | raise ValueError("encryption_master_key_base64 must be a base64 string which decodes to 32 bytes") 60 | else: 61 | raise ValueError("encryption_master_key_base64 must be valid base64") 62 | 63 | return None 64 | 65 | def generate_shared_secret(channel, encryption_master_key): 66 | """ 67 | generate_shared_secret() takes a six.binary_type (python2 str or python3 bytes) channel name and encryption_master_key 68 | and returns the sha256 hash in six.binary_type format 69 | """ 70 | if encryption_master_key is None: 71 | raise ValueError("No master key was provided for use with encrypted channels. Please provide encryption_master_key_base64 as an argument to the Pusher SDK") 72 | 73 | hashable = channel + encryption_master_key 74 | return hashlib.sha256(hashable).digest() 75 | 76 | def encrypt(channel, data, encryption_master_key, nonce=None): 77 | """ 78 | encrypt() encrypts the provided payload specified in the 'data' parameter 79 | """ 80 | channel = ensure_binary(channel, "channel") 81 | shared_secret = generate_shared_secret(channel, encryption_master_key) 82 | # the box setup to seal/unseal data payload 83 | box = nacl.secret.SecretBox(shared_secret) 84 | 85 | if nonce is None: 86 | nonce = nacl.utils.random(nacl.secret.SecretBox.NONCE_SIZE) 87 | else: 88 | nonce = ensure_binary(nonce, "nonce") 89 | 90 | # convert nonce to base64 91 | nonce_b64 = base64.b64encode(nonce) 92 | 93 | # encrypt the data payload with nacl 94 | encrypted = box.encrypt(data.encode("utf-8"), nonce) 95 | 96 | # obtain the ciphertext 97 | cipher_text = encrypted.ciphertext 98 | # encode cipertext to base64 99 | cipher_text_b64 = base64.b64encode(cipher_text) 100 | 101 | # format output 102 | return { "nonce" : nonce_b64.decode("utf-8"), "ciphertext": cipher_text_b64.decode("utf-8") } 103 | -------------------------------------------------------------------------------- /pusher/errors.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import ( 4 | print_function, 5 | unicode_literals, 6 | absolute_import, 7 | division) 8 | 9 | 10 | class PusherError(Exception): 11 | pass 12 | 13 | 14 | class PusherBadRequest(PusherError): 15 | pass 16 | 17 | 18 | class PusherBadAuth(PusherError): 19 | pass 20 | 21 | 22 | class PusherForbidden(PusherError): 23 | pass 24 | 25 | 26 | class PusherBadStatus(PusherError): 27 | pass 28 | -------------------------------------------------------------------------------- /pusher/gae.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import ( 4 | print_function, 5 | unicode_literals, 6 | absolute_import, 7 | division) 8 | 9 | from google.appengine.api import urlfetch 10 | 11 | from pusher.http import process_response 12 | 13 | 14 | class GAEBackend(object): 15 | """ 16 | Adapter for the URLFetch Module. Necessary for using this library with 17 | Google App Engine 18 | """ 19 | def __init__(self, client, **options): 20 | self.client = client 21 | self.options = options 22 | 23 | 24 | def send_request(self, request): 25 | resp = urlfetch.fetch( 26 | url=request.url, 27 | headers=request.headers, 28 | method=request.method, 29 | payload=request.body, 30 | deadline=self.client.timeout, 31 | **self.options) 32 | 33 | return process_response(resp.status_code, resp.content) 34 | -------------------------------------------------------------------------------- /pusher/http.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import ( 4 | print_function, 5 | unicode_literals, 6 | absolute_import, 7 | division) 8 | 9 | import copy 10 | import hashlib 11 | import json 12 | import six 13 | import time 14 | 15 | from pusher.util import doc_string 16 | from pusher.errors import * 17 | from pusher.signature import sign 18 | from pusher.version import VERSION 19 | 20 | 21 | GET, POST, PUT, DELETE = "GET", "POST", "PUT", "DELETE" 22 | 23 | 24 | class RequestMethod(object): 25 | def __init__(self, client, f): 26 | self.client = client 27 | self.f = f 28 | 29 | 30 | def __call__(self, *args, **kwargs): 31 | return self.client.http.send_request(self.make_request(*args, **kwargs)) 32 | 33 | 34 | def make_request(self, *args, **kwargs): 35 | return self.f(self.client, *args, **kwargs) 36 | 37 | 38 | def request_method(f): 39 | @property 40 | @doc_string(f.__doc__) 41 | def wrapped(self): 42 | return RequestMethod(self, f) 43 | 44 | return wrapped 45 | 46 | 47 | def make_query_string(params): 48 | return '&'.join(map('='.join, sorted(params.items(), key=lambda x: x[0]))) 49 | 50 | 51 | def process_response(status, body): 52 | if status == 200 or status == 202: 53 | return json.loads(body) 54 | 55 | elif status == 400: 56 | raise PusherBadRequest(body) 57 | 58 | elif status == 401: 59 | raise PusherBadAuth(body) 60 | 61 | elif status == 403: 62 | raise PusherForbidden(body) 63 | 64 | else: 65 | raise PusherBadStatus("%s: %s" % (status, body)) 66 | 67 | 68 | class Request(object): 69 | """Represents the request to be made to the Pusher API. 70 | 71 | An instance of that object is passed to the backend's send_request method 72 | for each request. 73 | 74 | :param client: an instance of pusher.Client 75 | :param method: HTTP method as a string 76 | :param path: The target path on the destination host 77 | :param params: Query params or body depending on the method 78 | """ 79 | def __init__(self, client, method, path, params=None): 80 | if params is None: 81 | params = {} 82 | 83 | self.client = client 84 | self.method = method 85 | self.path = path 86 | self.params = copy.copy(params) 87 | if method == POST: 88 | self.body = six.text_type(json.dumps(params)).encode('utf8') 89 | self.query_params = {} 90 | 91 | elif method == GET: 92 | self.body = bytes() 93 | self.query_params = params 94 | 95 | else: 96 | raise NotImplementedError("Only GET and POST supported") 97 | 98 | self._generate_auth() 99 | 100 | 101 | def _generate_auth(self): 102 | self.body_md5 = hashlib.md5(self.body).hexdigest() 103 | self.query_params.update({ 104 | 'auth_key': self.client.key, 105 | 'body_md5': six.text_type(self.body_md5), 106 | 'auth_version': '1.0', 107 | 'auth_timestamp': '%.0f' % time.time()}) 108 | 109 | auth_string = '\n'.join([ 110 | self.method, 111 | self.path, 112 | make_query_string(self.query_params)]) 113 | 114 | self.query_params['auth_signature'] = sign( 115 | self.client.secret, auth_string) 116 | 117 | 118 | @property 119 | def query_string(self): 120 | return make_query_string(self.query_params) 121 | 122 | 123 | @property 124 | def signed_path(self): 125 | return "%s?%s" % (self.path, self.query_string) 126 | 127 | 128 | @property 129 | def url(self): 130 | return "%s%s" % (self.base_url, self.signed_path) 131 | 132 | 133 | @property 134 | def base_url(self): 135 | return ( 136 | "%s://%s:%s" % 137 | (self.client.scheme, self.client.host, self.client.port)) 138 | 139 | 140 | @property 141 | def headers(self): 142 | hdrs = {"X-Pusher-Library": "pusher-http-python " + VERSION} 143 | if self.method == POST: 144 | hdrs["Content-Type"] = "application/json" 145 | 146 | return hdrs 147 | -------------------------------------------------------------------------------- /pusher/pusher.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import ( 4 | print_function, 5 | unicode_literals, 6 | absolute_import, 7 | division) 8 | 9 | import collections 10 | import hashlib 11 | import os 12 | import re 13 | import six 14 | import time 15 | 16 | from pusher.util import ( 17 | ensure_text, 18 | pusher_url_re, 19 | doc_string, validate_user_id) 20 | 21 | from pusher.pusher_client import PusherClient 22 | from pusher.authentication_client import AuthenticationClient 23 | 24 | 25 | class Pusher(object): 26 | """Client for the Pusher HTTP API. 27 | 28 | This client supports various backend adapters to support various http 29 | libraries available in the python ecosystem. 30 | 31 | :param app_id: a pusher application identifier 32 | :param key: a pusher application key 33 | :param secret: a pusher application secret token 34 | :param ssl: Whenever to use SSL or plain HTTP 35 | :param host: Used for custom host destination 36 | :param port: Used for custom port destination 37 | :param timeout: Request timeout (in seconds) 38 | :param encryption_master_key: deprecated, use encryption_master_key_base64 39 | :param encryption_master_key_base64: Used to derive a shared secret 40 | between server and the clients for payload encryption/decryption 41 | :param cluster: Convention for clusters other than the original Pusher cluster. 42 | Eg: 'eu' will resolve to the api-eu.pusherapp.com host 43 | :param backend: an http adapter class (AsyncIOBackend, RequestsBackend, 44 | SynchronousBackend, TornadoBackend) 45 | :param backend_options: additional backend 46 | """ 47 | def __init__( 48 | self, 49 | app_id, 50 | key, 51 | secret, 52 | ssl=True, 53 | host=None, 54 | port=None, 55 | timeout=5, 56 | cluster=None, 57 | encryption_master_key=None, 58 | encryption_master_key_base64=None, 59 | json_encoder=None, 60 | json_decoder=None, 61 | backend=None, 62 | **backend_options): 63 | 64 | self._pusher_client = PusherClient( 65 | app_id, 66 | key, 67 | secret, 68 | ssl, 69 | host, 70 | port, 71 | timeout, 72 | cluster, 73 | encryption_master_key, 74 | encryption_master_key_base64, 75 | json_encoder, 76 | json_decoder, 77 | backend, 78 | **backend_options) 79 | 80 | self._authentication_client = AuthenticationClient( 81 | app_id, 82 | key, 83 | secret, 84 | ssl, 85 | host, 86 | port, 87 | timeout, 88 | cluster, 89 | encryption_master_key, 90 | encryption_master_key_base64, 91 | json_encoder, 92 | json_decoder, 93 | backend, 94 | **backend_options) 95 | 96 | @classmethod 97 | def from_url(cls, url, **options): 98 | """Alternative constructor that extracts the information from a URL. 99 | 100 | :param url: String containing a URL 101 | 102 | Usage:: 103 | 104 | >> from pusher import Pusher 105 | >> p = 106 | Pusher.from_url("http://mykey:mysecret@api.pusher.com/apps/432") 107 | """ 108 | m = pusher_url_re.match(ensure_text(url, "url")) 109 | if not m: 110 | raise Exception("Unparsable url: %s" % url) 111 | 112 | ssl = m.group(1) == 'https' 113 | 114 | options_ = { 115 | 'key': m.group(2), 116 | 'secret': m.group(3), 117 | 'host': m.group(4), 118 | 'app_id': m.group(5), 119 | 'ssl': ssl} 120 | 121 | options_.update(options) 122 | 123 | return cls(**options_) 124 | 125 | @classmethod 126 | def from_env(cls, env='PUSHER_URL', **options): 127 | """Alternative constructor that extracts the information from an URL 128 | stored in an environment variable. The pusher heroku addon will set 129 | the PUSHER_URL automatically when installed for example. 130 | 131 | :param env: Name of the environment variable 132 | 133 | Usage:: 134 | 135 | >> from pusher import Pusher 136 | >> c = Pusher.from_env("PUSHER_URL") 137 | """ 138 | val = os.environ.get(env) 139 | if not val: 140 | raise Exception("Environment variable %s not found" % env) 141 | 142 | return cls.from_url(val, **options) 143 | 144 | @doc_string(PusherClient.trigger.__doc__) 145 | def trigger(self, channels, event_name, data, socket_id=None): 146 | return self._pusher_client.trigger( 147 | channels, event_name, data, socket_id) 148 | 149 | @doc_string(PusherClient.trigger.__doc__) 150 | def send_to_user(self, user_id, event_name, data): 151 | validate_user_id(user_id) 152 | user_server_string = "#server-to-user-%s" % user_id 153 | return self._pusher_client.trigger([user_server_string], event_name, data) 154 | 155 | @doc_string(PusherClient.trigger_batch.__doc__) 156 | def trigger_batch(self, batch=[], already_encoded=False): 157 | return self._pusher_client.trigger_batch(batch, already_encoded) 158 | 159 | @doc_string(PusherClient.channels_info.__doc__) 160 | def channels_info(self, prefix_filter=None, attributes=[]): 161 | return self._pusher_client.channels_info(prefix_filter, attributes) 162 | 163 | @doc_string(PusherClient.channel_info.__doc__) 164 | def channel_info(self, channel, attributes=[]): 165 | return self._pusher_client.channel_info(channel, attributes) 166 | 167 | @doc_string(PusherClient.users_info.__doc__) 168 | def users_info(self, channel): 169 | return self._pusher_client.users_info(channel) 170 | 171 | @doc_string(PusherClient.terminate_user_connections.__doc__) 172 | def terminate_user_connections(self, user_id): 173 | return self._pusher_client.terminate_user_connections(user_id) 174 | 175 | @doc_string(AuthenticationClient.authenticate.__doc__) 176 | def authenticate(self, channel, socket_id, custom_data=None): 177 | return self._authentication_client.authenticate( 178 | channel, socket_id, custom_data) 179 | 180 | @doc_string(AuthenticationClient.authenticate_user.__doc__) 181 | def authenticate_user(self, socket_id, user_data=None): 182 | return self._authentication_client.authenticate_user( 183 | socket_id, user_data 184 | ) 185 | 186 | @doc_string(AuthenticationClient.validate_webhook.__doc__) 187 | def validate_webhook(self, key, signature, body): 188 | return self._authentication_client.validate_webhook( 189 | key, signature, body) 190 | -------------------------------------------------------------------------------- /pusher/pusher_client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import ( 4 | print_function, 5 | unicode_literals, 6 | absolute_import, 7 | division) 8 | 9 | import sys 10 | 11 | # Abstract Base Classes were moved into collections.abc in Python 3.3 12 | if sys.version_info >= (3, 3): 13 | import collections.abc as collections 14 | else: 15 | import collections 16 | import hashlib 17 | import os 18 | import re 19 | import six 20 | import time 21 | import json 22 | import string 23 | 24 | from pusher.util import ( 25 | ensure_text, 26 | validate_channel, 27 | validate_socket_id, 28 | validate_user_id, 29 | join_attributes, 30 | data_to_string) 31 | 32 | from pusher.client import Client 33 | from pusher.http import GET, POST, Request, request_method 34 | from pusher.crypto import * 35 | import random 36 | from datetime import datetime 37 | 38 | 39 | class PusherClient(Client): 40 | def __init__( 41 | self, 42 | app_id, 43 | key, 44 | secret, 45 | ssl=True, 46 | host=None, 47 | port=None, 48 | timeout=5, 49 | cluster=None, 50 | encryption_master_key=None, 51 | encryption_master_key_base64=None, 52 | json_encoder=None, 53 | json_decoder=None, 54 | backend=None, 55 | **backend_options): 56 | 57 | super(PusherClient, self).__init__( 58 | app_id, 59 | key, 60 | secret, 61 | ssl, 62 | host, 63 | port, 64 | timeout, 65 | cluster, 66 | encryption_master_key, 67 | encryption_master_key_base64, 68 | json_encoder, 69 | json_decoder, 70 | backend, 71 | **backend_options) 72 | 73 | @request_method 74 | def trigger(self, channels, event_name, data, socket_id=None): 75 | """Trigger an event on one or more channels, see: 76 | 77 | http://pusher.com/docs/rest_api#method-post-event 78 | """ 79 | if isinstance(channels, six.string_types): 80 | channels = [channels] 81 | 82 | if isinstance(channels, dict) or not isinstance( 83 | channels, (collections.Sized, collections.Iterable)): 84 | raise TypeError("Expected a single or a list of channels") 85 | 86 | if len(channels) > 100: 87 | raise ValueError("Too many channels") 88 | 89 | event_name = ensure_text(event_name, "event_name") 90 | if len(event_name) > 200: 91 | raise ValueError("event_name too long") 92 | 93 | data = data_to_string(data, self._json_encoder) 94 | if sys.getsizeof(data) > 30720: 95 | raise ValueError("Too much data") 96 | 97 | channels = list(map(validate_channel, channels)) 98 | 99 | if len(channels) > 1: 100 | for chan in channels: 101 | if is_encrypted_channel(chan): 102 | raise ValueError("You cannot trigger to multiple channels when using encrypted channels") 103 | 104 | if is_encrypted_channel(channels[0]): 105 | data = json.dumps(encrypt(channels[0], data, self._encryption_master_key), ensure_ascii=False) 106 | 107 | params = { 108 | 'name': event_name, 109 | 'channels': channels, 110 | 'data': data} 111 | 112 | if socket_id: 113 | params['socket_id'] = validate_socket_id(socket_id) 114 | 115 | return Request(self, POST, "/apps/%s/events" % self.app_id, params) 116 | 117 | @request_method 118 | def trigger_batch(self, batch=[], already_encoded=False): 119 | """Trigger multiple events with a single HTTP call. 120 | 121 | http://pusher.com/docs/rest_api#method-post-batch-events 122 | """ 123 | if not already_encoded: 124 | for event in batch: 125 | validate_channel(event['channel']) 126 | 127 | event_name = ensure_text(event['name'], "event_name") 128 | if len(event['name']) > 200: 129 | raise ValueError("event_name too long") 130 | 131 | event['data'] = data_to_string(event['data'], self._json_encoder) 132 | 133 | if sys.getsizeof(event['data']) > 10240: 134 | raise ValueError("Too much data") 135 | 136 | if is_encrypted_channel(event['channel']): 137 | event['data'] = json.dumps(encrypt(event['channel'], event['data'], self._encryption_master_key), ensure_ascii=False) 138 | 139 | params = { 140 | 'batch': batch} 141 | 142 | return Request( 143 | self, POST, "/apps/%s/batch_events" % self.app_id, params) 144 | 145 | @request_method 146 | def channels_info(self, prefix_filter=None, attributes=[]): 147 | """Get information on multiple channels, see: 148 | 149 | http://pusher.com/docs/rest_api#method-get-channels 150 | """ 151 | params = {} 152 | if attributes: 153 | params['info'] = join_attributes(attributes) 154 | 155 | if prefix_filter: 156 | params['filter_by_prefix'] = ensure_text( 157 | prefix_filter, "prefix_filter") 158 | 159 | return Request( 160 | self, GET, six.text_type("/apps/%s/channels") % self.app_id, params) 161 | 162 | @request_method 163 | def channel_info(self, channel, attributes=[]): 164 | """Get information on a specific channel, see: 165 | 166 | http://pusher.com/docs/rest_api#method-get-channel 167 | """ 168 | validate_channel(channel) 169 | 170 | params = {} 171 | if attributes: 172 | params['info'] = join_attributes(attributes) 173 | 174 | return Request( 175 | self, GET, "/apps/%s/channels/%s" % (self.app_id, channel), params) 176 | 177 | @request_method 178 | def users_info(self, channel): 179 | """Fetch user ids currently subscribed to a presence channel 180 | 181 | http://pusher.com/docs/rest_api#method-get-users 182 | """ 183 | validate_channel(channel) 184 | 185 | return Request( 186 | self, GET, "/apps/%s/channels/%s/users" % (self.app_id, channel)) 187 | 188 | @request_method 189 | def terminate_user_connections(self, user_id): 190 | validate_user_id(user_id) 191 | return Request( 192 | self, POST, "/users/{}/terminate_connections".format(user_id), {}) 193 | -------------------------------------------------------------------------------- /pusher/requests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import ( 4 | print_function, 5 | unicode_literals, 6 | absolute_import, 7 | division) 8 | 9 | from pusher.http import process_response 10 | 11 | import requests 12 | import sys 13 | import os 14 | 15 | 16 | if sys.version_info < (3,): 17 | import urllib3.contrib.pyopenssl 18 | urllib3.contrib.pyopenssl.inject_into_urllib3() 19 | 20 | CERT_PATH = os.path.dirname(os.path.abspath(__file__)) + '/cacert.pem' 21 | 22 | 23 | class RequestsBackend(object): 24 | """Adapter for the requests module. 25 | 26 | :param client: pusher.Client object 27 | :param options: key-value passed into the requests.request constructor 28 | """ 29 | def __init__(self, client, **options): 30 | self.client = client 31 | self.options = options 32 | if self.client.ssl: 33 | self.options.update({'verify': CERT_PATH}) 34 | self.session = requests.Session() 35 | 36 | 37 | def send_request(self, request): 38 | resp = self.session.request( 39 | request.method, 40 | request.url, 41 | headers=request.headers, 42 | data=request.body, 43 | timeout=self.client.timeout, 44 | **self.options) 45 | 46 | return process_response(resp.status_code, resp.text) 47 | -------------------------------------------------------------------------------- /pusher/signature.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import ( 4 | print_function, 5 | unicode_literals, 6 | absolute_import, 7 | division) 8 | 9 | import hashlib 10 | import hmac 11 | import six 12 | 13 | 14 | try: 15 | compare_digest = hmac.compare_digest 16 | 17 | except AttributeError: 18 | # Not secure when the length is supposed to be kept secret 19 | def compare_digest(a, b): 20 | if len(a) != len(b): 21 | return False 22 | 23 | return reduce( 24 | lambda x, y: x | y, [ord(x) ^ ord(y) for x, y in zip(a, b)]) == 0 25 | 26 | 27 | def sign(secret, string_to_sign): 28 | return six.text_type( 29 | hmac.new( 30 | secret.encode('utf8'), 31 | string_to_sign.encode('utf8'), 32 | hashlib.sha256).hexdigest()) 33 | 34 | 35 | def verify(secret, string_to_sign, signature): 36 | return compare_digest(signature, sign(secret, string_to_sign)) 37 | -------------------------------------------------------------------------------- /pusher/tornado.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import ( 4 | print_function, 5 | unicode_literals, 6 | absolute_import, 7 | division) 8 | 9 | import six 10 | import tornado 11 | import tornado.httpclient 12 | 13 | from tornado.concurrent import Future 14 | 15 | from pusher.http import process_response 16 | 17 | 18 | class TornadoBackend(object): 19 | """Adapter for the tornado.httpclient module. 20 | 21 | :param client: pusher.Client object 22 | :param options: options for the httpclient.HTTPClient constructor 23 | """ 24 | def __init__(self, client, **options): 25 | self.client = client 26 | self.options = options 27 | self.http = tornado.httpclient.AsyncHTTPClient() 28 | 29 | 30 | def send_request(self, request): 31 | method = request.method 32 | data = request.body 33 | headers = {'Content-Type': 'application/json'} 34 | future = Future() 35 | 36 | def process_response_future(response): 37 | if response.exception() is not None: 38 | future.set_exception(response.exception()) 39 | 40 | else: 41 | result = response.result() 42 | code = result.code 43 | body = (result.body or b'').decode('utf8') 44 | future.set_result(process_response(code, body)) 45 | 46 | request = tornado.httpclient.HTTPRequest( 47 | request.url, 48 | method=method, 49 | body=data, 50 | headers=headers, 51 | request_timeout=self.client.timeout, 52 | **self.options) 53 | 54 | response_future = self.http.fetch(request, raise_error=False) 55 | response_future.add_done_callback(process_response_future) 56 | 57 | return future 58 | -------------------------------------------------------------------------------- /pusher/util.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import ( 4 | print_function, 5 | unicode_literals, 6 | absolute_import, 7 | division) 8 | 9 | import json 10 | import re 11 | import six 12 | import sys 13 | import base64 14 | SERVER_TO_USER_PREFIX = "#server-to-user-" 15 | 16 | channel_name_re = re.compile(r'\A[-a-zA-Z0-9_=@,.;]+\Z') 17 | server_to_user_channel_re = re.compile(rf'\A{SERVER_TO_USER_PREFIX}[-a-zA-Z0-9_=@,.;]+\Z') 18 | app_id_re = re.compile(r'\A[0-9]+\Z') 19 | pusher_url_re = re.compile(r'\A(http|https)://(.*):(.*)@(.*)/apps/([0-9]+)\Z') 20 | socket_id_re = re.compile(r'\A\d+\.\d+\Z') 21 | 22 | if sys.version_info < (3,): 23 | text = 'a unicode string' 24 | else: 25 | text = 'a string' 26 | 27 | if sys.version_info < (3,): 28 | byte_type = 'a python2 str' 29 | else: 30 | byte_type = 'a python3 bytes' 31 | 32 | 33 | def ensure_text(obj, name): 34 | if isinstance(obj, six.text_type): 35 | return obj 36 | 37 | if isinstance(obj, six.string_types): 38 | return six.text_type(obj) 39 | 40 | if isinstance(obj, six.binary_type): 41 | return bytes(obj).decode('utf-8') 42 | 43 | raise TypeError("%s should be %s instead it is a %s" % (name, text, type(obj))) 44 | 45 | 46 | def ensure_binary(obj, name): 47 | """ 48 | ensure_binary() ensures that the value is a 49 | python2 str or python3 bytes 50 | more on this here: https://pythonhosted.org/six/#six.binary_type 51 | """ 52 | if isinstance(obj, six.binary_type): 53 | return obj 54 | 55 | if isinstance(obj, six.text_type) or isinstance(obj, six.string_types): 56 | return obj.encode("utf-8") 57 | 58 | raise TypeError("%s should be %s instead it is a %s" % (name, byte_type, type(obj))) 59 | 60 | 61 | def is_base64(s): 62 | """ 63 | is_base64 tests whether a string is valid base64 by testing that it round-trips accurately. 64 | This is required because python 2.7 does not have a Validate option to the decoder. 65 | """ 66 | try: 67 | s = six.ensure_binary(s, "utf-8") 68 | return base64.b64encode(base64.b64decode(s)) == s 69 | except Exception as e: 70 | return False 71 | 72 | 73 | def validate_user_id(user_id): 74 | user_id = ensure_text(user_id, "user_id") 75 | 76 | length = len(user_id) 77 | if length == 0: 78 | raise ValueError("User id is empty") 79 | 80 | if length > 200: 81 | raise ValueError("User id too long: '{}'".format(user_id)) 82 | 83 | if not channel_name_re.match(user_id): 84 | raise ValueError("Invalid user id: '{}'".format(user_id)) 85 | 86 | return user_id 87 | 88 | 89 | def validate_channel(channel): 90 | channel = ensure_text(channel, "channel") 91 | 92 | if len(channel) > 200: 93 | raise ValueError("Channel too long: %s" % channel) 94 | 95 | if channel.startswith(SERVER_TO_USER_PREFIX): 96 | if not server_to_user_channel_re.match(channel): 97 | raise ValueError("Invalid server to user Channel: %s" % channel) 98 | elif not channel_name_re.match(channel): 99 | raise ValueError("Invalid Channel: %s" % channel) 100 | 101 | return channel 102 | 103 | 104 | def validate_socket_id(socket_id): 105 | socket_id = ensure_text(socket_id, "socket_id") 106 | 107 | if not socket_id_re.match(socket_id): 108 | raise ValueError("Invalid socket ID: %s" % socket_id) 109 | 110 | return socket_id 111 | 112 | 113 | def validate_user_data(user_data: dict): 114 | if user_data is None: 115 | raise ValueError('user_data is null') 116 | if user_data.get('id') is None: 117 | raise ValueError('user_data has no id field') 118 | validate_user_id(user_data.get('id')) 119 | 120 | 121 | def join_attributes(attributes): 122 | return six.text_type(',').join(attributes) 123 | 124 | 125 | def data_to_string(data, json_encoder): 126 | if isinstance(data, six.string_types): 127 | return ensure_text(data, "data") 128 | 129 | else: 130 | return json.dumps(data, cls=json_encoder, ensure_ascii=False) 131 | 132 | 133 | def doc_string(doc): 134 | def decorator(f): 135 | f.__doc__ = doc 136 | return f 137 | 138 | return decorator 139 | -------------------------------------------------------------------------------- /pusher/version.py: -------------------------------------------------------------------------------- 1 | # Don't change the format of this line: the version is extracted by ../setup.py 2 | VERSION = '3.3.3' 3 | -------------------------------------------------------------------------------- /pusher_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pusher/pusher-http-python/10372d07123318775dbf250ea25b79c9b9bb0bbe/pusher_tests/__init__.py -------------------------------------------------------------------------------- /pusher_tests/aio/aiohttp_adapter_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function, absolute_import, division 2 | 3 | import pusher 4 | import pusher.aiohttp 5 | import asyncio 6 | import unittest 7 | import httpretty 8 | 9 | class TestAIOHTTPBackend(unittest.TestCase): 10 | 11 | def setUp(self): 12 | self.p = pusher.Pusher.from_url(u'http://key:secret@api.pusherapp.com/apps/4', 13 | backend=pusher.aiohttp.AsyncIOBackend) 14 | 15 | @httpretty.activate 16 | def test_trigger_aio_success(self): 17 | httpretty.register_uri(httpretty.POST, "http://api.pusherapp.com/apps/4/events", 18 | body="{}", 19 | content_type="application/json") 20 | response = yield from self.p.trigger(u'test_channel', u'test', {u'data': u'yolo'}) 21 | self.assertEqual(response, {}) 22 | 23 | if __name__ == '__main__': 24 | unittest.main() 25 | -------------------------------------------------------------------------------- /pusher_tests/test_aiohttp_adapter.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | if sys.version_info >= (3,5,3): 4 | from .aio.aiohttp_adapter_test import * 5 | -------------------------------------------------------------------------------- /pusher_tests/test_authentication_client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import print_function, absolute_import, division 4 | 5 | import os 6 | import six 7 | import hmac 8 | import json 9 | import hashlib 10 | import unittest 11 | import time 12 | from decimal import Decimal 13 | 14 | from pusher.authentication_client import AuthenticationClient 15 | from pusher.signature import sign, verify 16 | from pusher.util import ensure_binary 17 | 18 | try: 19 | import unittest.mock as mock 20 | except ImportError: 21 | import mock 22 | 23 | 24 | class TestAuthenticationClient(unittest.TestCase): 25 | 26 | def test_host_should_be_text(self): 27 | AuthenticationClient(app_id=u'4', key=u'key', secret=u'secret', ssl=True, host=u'foo') 28 | 29 | self.assertRaises(TypeError, lambda: AuthenticationClient(app_id=u'4', key=u'key', secret=u'secret', ssl=True, host=4)) 30 | 31 | def test_cluster_should_be_text(self): 32 | AuthenticationClient(app_id=u'4', key=u'key', secret=u'secret', ssl=True, cluster=u'eu') 33 | 34 | self.assertRaises(TypeError, lambda: AuthenticationClient(app_id=u'4', key=u'key', secret=u'secret', ssl=True, cluster=4)) 35 | 36 | def test_host_behaviour(self): 37 | conf = AuthenticationClient(app_id=u'4', key=u'key', secret=u'secret', ssl=True) 38 | self.assertEqual(conf.host, u'api.pusherapp.com', u'default host should be correct') 39 | 40 | conf = AuthenticationClient(app_id=u'4', key=u'key', secret=u'secret', ssl=True, cluster=u'eu') 41 | self.assertEqual(conf.host, u'api-eu.pusher.com', u'host should be overriden by cluster setting') 42 | 43 | conf = AuthenticationClient(app_id=u'4', key=u'key', secret=u'secret', ssl=True, host=u'foo') 44 | self.assertEqual(conf.host, u'foo', u'host should be overriden by host setting') 45 | 46 | conf = AuthenticationClient(app_id=u'4', key=u'key', secret=u'secret', ssl=True, cluster=u'eu', host=u'plah') 47 | self.assertEqual(conf.host, u'plah', u'host should be used in preference to cluster') 48 | 49 | def test_authenticate_for_private_channels(self): 50 | authenticationClient = AuthenticationClient( 51 | key=u'foo', secret=u'bar', host=u'host', app_id=u'4', ssl=True) 52 | 53 | expected = { 54 | u'auth': u"foo:89955e77e1b40e33df6d515a5ecbba86a01dc816a5b720da18a06fd26f7d92ff" 55 | } 56 | 57 | self.assertEqual(authenticationClient.authenticate(u'private-channel', u'345.23'), expected) 58 | 59 | def test_authenticate_for_private_encrypted_channels(self): 60 | # The authentication client receives the decoded bytes of the key 61 | # not the base64 representation 62 | master_key = u'OHRXNUZRTG5pUTFzQlFGd3J3N3Q2VFZFc0paZDEweVk=' 63 | authenticationClient = AuthenticationClient( 64 | key=u'foo', 65 | secret=u'bar', 66 | host=u'host', 67 | app_id=u'4', 68 | encryption_master_key_base64=master_key, 69 | ssl=True) 70 | 71 | expected = { 72 | u'auth': u'foo:fff0503dfe4929f5162efe4d1dacbce524b0d8e7e1331117a8651c0e74d369e3', 73 | u'shared_secret': b'VmIsNZtCSteh8kazd2toc+ofhohBtUouQRSDtRvuyVI=' 74 | } 75 | 76 | self.assertEqual(authenticationClient.authenticate(u'private-encrypted-channel', u'345.23'), expected) 77 | 78 | def test_authenticate_types(self): 79 | authenticationClient = AuthenticationClient( 80 | key=u'foo', secret=u'bar', host=u'host', app_id=u'4', ssl=True) 81 | 82 | self.assertRaises(TypeError, lambda: authenticationClient.authenticate(2423, u'34554')) 83 | self.assertRaises(TypeError, lambda: authenticationClient.authenticate(u'plah', 234234)) 84 | self.assertRaises(ValueError, lambda: authenticationClient.authenticate(u'::', u'345345')) 85 | 86 | def test_authenticate_for_presence_channels(self): 87 | authenticationClient = AuthenticationClient( 88 | key=u'foo', secret=u'bar', host=u'host', app_id=u'4', ssl=True) 89 | 90 | custom_data = { 91 | u'user_id': u'fred', 92 | u'user_info': { 93 | u'key': u'value' 94 | } 95 | } 96 | 97 | expected = { 98 | u'auth': u"foo:e80ba6439492c2113022c39297a87a948de14061cc67b5788e045645a68b8ccd", 99 | u'channel_data': u"{\"user_id\":\"fred\",\"user_info\":{\"key\":\"value\"}}" 100 | } 101 | 102 | with mock.patch('json.dumps', return_value=expected[u'channel_data']) as dumps_mock: 103 | actual = authenticationClient.authenticate(u'presence-channel', u'345.43245', custom_data) 104 | 105 | self.assertEqual(actual, expected) 106 | dumps_mock.assert_called_once_with(custom_data, cls=None) 107 | 108 | def test_authenticate_for_user(self): 109 | authentication_client = AuthenticationClient( 110 | key=u'thisisaauthkey', 111 | secret=u'thisisasecret', 112 | app_id=u'4') 113 | 114 | user_data = { 115 | u'id': u'123', 116 | u'name': u'John Smith' 117 | } 118 | 119 | expected = { 120 | 'auth': 'thisisaauthkey:0dddb208b53c7649f3fbbb86254a6e1986bc6f8b566423ea690c9ca773497373', 121 | "user_data": u"{\"id\":\"123\",\"name\":\"John Smith\"}" 122 | } 123 | 124 | with mock.patch('json.dumps', return_value=expected[u'user_data']) as dumps_mock: 125 | actual = authentication_client.authenticate_user(u'12345.6789', user_data) 126 | 127 | self.assertEqual(actual, expected) 128 | dumps_mock.assert_called_once_with(user_data, cls=None) 129 | 130 | def test_validate_webhook_success_case(self): 131 | authenticationClient = AuthenticationClient( 132 | key=u'foo', secret=u'bar', host=u'host', app_id=u'4', ssl=True) 133 | 134 | body = u'{"time_ms": 1000000}' 135 | signature = six.text_type(hmac.new(authenticationClient.secret.encode('utf8'), body.encode('utf8'), hashlib.sha256).hexdigest()) 136 | 137 | with mock.patch('time.time', return_value=1200): 138 | self.assertEqual(authenticationClient.validate_webhook(authenticationClient.key, signature, body), {u'time_ms': 1000000}) 139 | 140 | 141 | def test_validate_webhook_bad_types(self): 142 | authenticationClient = AuthenticationClient( 143 | key=u'foo', secret=u'bar', host=u'host', app_id=u'4', ssl=True) 144 | 145 | authenticationClient.validate_webhook(u'key', u'signature', u'body') 146 | 147 | # These things are meant to be human readable, so enforcing being 148 | # text is sensible. 149 | 150 | with mock.patch('time.time') as time_mock: 151 | self.assertRaises(TypeError, lambda: authenticationClient.validate_webhook(4, u'signature', u'body')) 152 | self.assertRaises(TypeError, lambda: authenticationClient.validate_webhook(u'key', 4, u'body')) 153 | self.assertRaises(TypeError, lambda: authenticationClient.validate_webhook(u'key', u'signature', 4)) 154 | 155 | time_mock.assert_not_called() 156 | 157 | def test_validate_webhook_bad_key(self): 158 | authenticationClient = AuthenticationClient( 159 | key=u'foo', secret=u'bar', host=u'host', app_id=u'4', ssl=True) 160 | 161 | body = u'some body' 162 | signature = six.text_type(hmac.new(authenticationClient.secret.encode(u'utf8'), body.encode(u'utf8'), hashlib.sha256).hexdigest()) 163 | 164 | with mock.patch('time.time') as time_mock: 165 | self.assertEqual(authenticationClient.validate_webhook(u'badkey', signature, body), None) 166 | 167 | time_mock.assert_not_called() 168 | 169 | def test_validate_webhook_bad_signature(self): 170 | authenticationClient = AuthenticationClient( 171 | key=u'foo', secret=u'bar', host=u'host', app_id=u'4', ssl=True) 172 | 173 | body = u'some body' 174 | signature = u'some signature' 175 | 176 | with mock.patch('time.time') as time_mock: 177 | self.assertEqual( 178 | authenticationClient.validate_webhook( 179 | authenticationClient.key, signature, body), None) 180 | 181 | time_mock.assert_not_called() 182 | 183 | def test_validate_webhook_bad_time(self): 184 | authenticationClient = AuthenticationClient( 185 | key=u'foo', secret=u'bar', host=u'host', app_id=u'4', ssl=True) 186 | 187 | body = u'{"time_ms": 1000000}' 188 | signature = six.text_type( 189 | hmac.new( 190 | authenticationClient.secret.encode('utf8'), 191 | body.encode('utf8'), hashlib.sha256).hexdigest()) 192 | 193 | with mock.patch('time.time', return_value=1301): 194 | self.assertEqual(authenticationClient.validate_webhook( 195 | authenticationClient.key, signature, body), None) 196 | 197 | 198 | class TestJson(unittest.TestCase): 199 | def setUp(self): 200 | class JSONEncoder(json.JSONEncoder): 201 | def default(self, o): 202 | if isinstance(o, Decimal): 203 | return str(o) 204 | 205 | return super(JSONEncoder, self).default(o) 206 | 207 | constants = {"NaN": 99999} 208 | 209 | class JSONDecoder(json.JSONDecoder): 210 | def __init__(self, **kwargs): 211 | super(JSONDecoder, self).__init__( 212 | parse_constant=constants.__getitem__) 213 | 214 | self.authentication_client = AuthenticationClient( 215 | "4", "key", "secret", host="somehost", json_encoder=JSONEncoder, 216 | json_decoder=JSONDecoder) 217 | 218 | def test_custom_json_decoder(self): 219 | t = 1000 * time.time() 220 | body = u'{"nan": NaN, "time_ms": %f}' % t 221 | signature = sign(self.authentication_client.secret, body) 222 | data = self.authentication_client.validate_webhook( 223 | self.authentication_client.key, signature, body) 224 | self.assertEqual({u"nan": 99999, u"time_ms": t}, data) 225 | 226 | def test_custom_json_encoder(self): 227 | expected = { 228 | u'channel_data': '{"money": "1.32"}', 229 | u'auth': u'key:7f2ae5922800a20b9615543ce7c8e7d1c97115d108939410825ea690f308a05f' 230 | } 231 | data = self.authentication_client.authenticate("presence-c1", "1.1", {"money": Decimal("1.32")}) 232 | self.assertEqual(expected, data) 233 | 234 | 235 | if __name__ == '__main__': 236 | unittest.main() 237 | -------------------------------------------------------------------------------- /pusher_tests/test_client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import ( 4 | print_function, 5 | unicode_literals, 6 | absolute_import, 7 | division) 8 | 9 | import hashlib 10 | import hmac 11 | import os 12 | import six 13 | import unittest 14 | 15 | from pusher.client import Client 16 | 17 | try: 18 | import unittest.mock as mock 19 | 20 | except ImportError: 21 | import mock 22 | 23 | 24 | class TestClient(unittest.TestCase): 25 | def test_should_be_constructable(self): 26 | Client(app_id=u'4', key=u'key', secret=u'secret', ssl=False) 27 | 28 | 29 | def test_app_id_should_be_text_if_present(self): 30 | self.assertRaises(TypeError, lambda: Client( 31 | app_id=4, key=u'key', secret=u'secret', ssl=False)) 32 | 33 | 34 | def test_key_should_be_text_if_present(self): 35 | self.assertRaises(TypeError, lambda: Client( 36 | app_id=u'4', key=4, secret=u'secret', ssl=False)) 37 | 38 | 39 | def test_secret_should_be_text_if_present(self): 40 | self.assertRaises(TypeError, lambda: Client( 41 | app_id=u'4', key=u'key', secret=4, ssl=False)) 42 | 43 | 44 | def test_ssl_should_be_boolean(self): 45 | Client(app_id=u'4', key=u'key', secret=u'secret', ssl=False) 46 | Client(app_id=u'4', key=u'key', secret=u'secret', ssl=True) 47 | 48 | self.assertRaises(TypeError, lambda: Client( 49 | app_id=u'4', key=u'key', secret=u'secret', ssl=4)) 50 | 51 | 52 | def test_port_should_be_number(self): 53 | Client(app_id=u'4', key=u'key', secret=u'secret', ssl=True, port=400) 54 | 55 | self.assertRaises(TypeError, lambda: Client( 56 | app_id=u'4', key=u'key', secret=u'secret', ssl=True, port=u'400')) 57 | 58 | def test_timeout_should_be_number(self): 59 | Client(app_id=u'4', key=u'key', secret=u'secret', timeout=1) 60 | Client(app_id=u'4', key=u'key', secret=u'secret', timeout=3.14) 61 | 62 | self.assertRaises(TypeError, lambda: Client( 63 | app_id=u'4', key=u'key', secret=u'secret', timeout=u'1')) 64 | 65 | 66 | def test_port_behaviour(self): 67 | conf = Client(app_id=u'4', key=u'key', secret=u'secret', ssl=True) 68 | self.assertEqual(conf.port, 443, u'port should be 443 for ssl') 69 | 70 | conf = Client(app_id=u'4', key=u'key', secret=u'secret', ssl=False) 71 | self.assertEqual(conf.port, 80, u'port should be 80 for non ssl') 72 | 73 | conf = Client( 74 | app_id=u'4', key=u'key', secret=u'secret', ssl=False, port=4000) 75 | 76 | self.assertEqual( 77 | conf.port, 4000, u'the port setting overrides the default') 78 | 79 | 80 | if __name__ == '__main__': 81 | unittest.main() 82 | -------------------------------------------------------------------------------- /pusher_tests/test_crypto.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import print_function, absolute_import 3 | 4 | import json 5 | import six 6 | import unittest 7 | 8 | from pusher import crypto 9 | from pusher.util import ensure_binary 10 | 11 | class TestCrypto(unittest.TestCase): 12 | 13 | def test_is_encrypted_channel(self): 14 | # testcases matrix with inputs and expected returning values 15 | testcases = [ 16 | { "input":"private-encrypted-djsahajkshdak", "expected":True }, 17 | { "input":"private-encrypted-djsahajkshdak-", "expected":True }, 18 | { "input":"private-encrypted--djsahajkshdak", "expected":True }, 19 | { "input":"private--encrypted--djsahajkshdak", "expected":False }, 20 | { "input":"private--encrypted--djsahajkshdak-", "expected":False }, 21 | { "input":"private-encr-djsahajkshdak", "expected":False }, 22 | { "input":"private-encrypteddjsahajkshdak", "expected":False }, 23 | { "input":"private-encrypte-djsahajkshdak", "expected":False }, 24 | { "input":"privateencrypte-djsahajkshdak", "expected":False }, 25 | { "input":"private-djsahajkshdak", "expected":False }, 26 | { "input":"--djsah private-encrypted-ajkshdak", "expected":False } 27 | ] 28 | 29 | for t in testcases: 30 | self.assertEqual( 31 | crypto.is_encrypted_channel( t["input"] ), 32 | t["expected"], 33 | ) 34 | 35 | def test_parse_master_key_successes(self): 36 | testcases = [ 37 | { "deprecated": "this is 32 bytes 123456789012345", "base64": None, "expected": b"this is 32 bytes 123456789012345" }, 38 | { "deprecated": "this key has nonprintable char \x00", "base64": None, "expected": b"this key has nonprintable char \x00"}, 39 | { "deprecated": None, "base64": "dGhpcyBpcyAzMiBieXRlcyAxMjM0NTY3ODkwMTIzNDU=", "expected": b"this is 32 bytes 123456789012345" }, 40 | { "deprecated": None, "base64": "dGhpcyBrZXkgaGFzIG5vbnByaW50YWJsZSBjaGFyIAA=", "expected": b"this key has nonprintable char \x00" }, 41 | ] 42 | 43 | for t in testcases: 44 | self.assertEqual( 45 | crypto.parse_master_key(t["deprecated"], t["base64"]), 46 | t["expected"] 47 | ) 48 | 49 | def test_parse_master_key_rejections(self): 50 | testcases = [ 51 | { "deprecated": "some bytes", "base64": "also some bytes", "expected": "both" }, 52 | { "deprecated": "this is 31 bytes 12345678901234", "base64": None, "expected": "32 bytes"}, 53 | { "deprecated": "this is 33 bytes 1234567890123456", "base64": None, "expected": "32 bytes"}, 54 | { "deprecated": None, "base64": "dGhpcyBpcyAzMSBieXRlcyAxMjM0NTY3ODkwMTIzNA==", "expected": "decodes to 32 bytes" }, 55 | { "deprecated": None, "base64": "dGhpcyBpcyAzMyBieXRlcyAxMjM0NTY3ODkwMTIzNDU2", "expected": "decodes to 32 bytes" }, 56 | { "deprecated": None, "base64": "dGhpcyBpcyAzMSBieXRlcyAxMjM0NTY3ODkwMTIzNA=", "expected": "valid base64" }, 57 | { "deprecated": None, "base64": "dGhpcyBpcyA!MiBieXRlcyAxMjM0NTY3OD#wMTIzNDU=", "expected": "valid base64" }, 58 | ] 59 | 60 | for t in testcases: 61 | six.assertRaisesRegex(self, ValueError, t["expected"], lambda: crypto.parse_master_key(t["deprecated"], t["base64"])) 62 | 63 | def test_generate_shared_secret(self): 64 | testcases = [ 65 | { 66 | "input": ["pvUPIk0YG6MnxCEMIUUVFrbDmQwbhICXUcy", 67 | "OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5"], 68 | "expected": b"p\x9e\xf3\t\n$\xbf\xa9\x83\x82\xcb]\x02\\\xa3b,\x82\xd3\x1f\xa2\x7f\x10\xb0\x05\xc0\xdc\xa2{\xaee\x16" 69 | } 70 | ] 71 | 72 | # do the actual testing 73 | for t in testcases: 74 | self.assertEqual( 75 | crypto.generate_shared_secret( ensure_binary(t["input"][0],'t["input"][0]'), ensure_binary(t["input"][1],'t["input"][1]') ), 76 | t["expected"] 77 | ) 78 | 79 | def test_encrypt(self): 80 | testcases = [ 81 | { 82 | "input": ["pvUPIk0YG6MnxCEMIUUVFrbDmQwbhICXUcy", "kkkT5OOkOkO5kT5TOO5TkOT5TTk5O55T", 83 | "OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5", "XAJI0Y6DPBHSAHXTHV3A3ZMF"], 84 | "expected": { 85 | "nonce": "WEFKSTBZNkRQQkhTQUhYVEhWM0EzWk1G", 86 | "ciphertext": "tsYJa2JgGDOpVIYFe4aNVWAvZlpB7z7CjN9mpIdbATE0Yc4izN8aM8D6VigBxnIQ" 87 | }, 88 | } 89 | ] 90 | 91 | # do the actual testing 92 | for t in testcases: 93 | channel_test_val = t["input"][0] 94 | payload_test_val = t["input"][1] 95 | encr_key_test_val = ensure_binary(t["input"][2],'t["input"][2]') 96 | nonce_test_val = t["input"][3] 97 | 98 | self.assertEqual( 99 | crypto.encrypt( 100 | channel_test_val, 101 | payload_test_val, 102 | encr_key_test_val, 103 | nonce_test_val 104 | ), 105 | t["expected"] 106 | ) 107 | 108 | def test_decrypt(self): 109 | self.assertEqual(True, True) 110 | -------------------------------------------------------------------------------- /pusher_tests/test_gae_adapter.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import print_function, absolute_import, division 4 | 5 | import pusher 6 | import httpretty 7 | import sys 8 | import os 9 | 10 | if (sys.version_info < (2,7)): 11 | import unittest2 as unittest 12 | else: 13 | import unittest 14 | 15 | skip_test = (sys.version_info[0:2] != (2,7)) or os.environ.get("CI") 16 | 17 | @unittest.skipIf(skip_test, "skip") 18 | class TestGAEBackend(unittest.TestCase): 19 | 20 | def setUp(self): 21 | import pusher.gae 22 | from google.appengine.api import apiproxy_stub_map, urlfetch_stub 23 | 24 | apiproxy_stub_map.apiproxy = apiproxy_stub_map.APIProxyStubMap() 25 | apiproxy_stub_map.apiproxy.RegisterStub('urlfetch', 26 | urlfetch_stub.URLFetchServiceStub()) 27 | 28 | self.p = pusher.Pusher.from_url(u'http://key:secret@api.pusherapp.com/apps/4', 29 | backend=pusher.gae.GAEBackend) 30 | 31 | @httpretty.activate 32 | def test_trigger_gae_success(self): 33 | httpretty.register_uri(httpretty.POST, "http://api.pusherapp.com/apps/4/events", 34 | body="{}", 35 | content_type="application/json") 36 | response = self.p.trigger(u'test_channel', u'test', {u'data': u'yolo'}) 37 | self.assertEqual(response, {}) 38 | 39 | 40 | if __name__ == '__main__': 41 | unittest.main() 42 | -------------------------------------------------------------------------------- /pusher_tests/test_pusher.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import ( 4 | print_function, 5 | unicode_literals, 6 | absolute_import, 7 | division) 8 | 9 | import hashlib 10 | import hmac 11 | import os 12 | import six 13 | import unittest 14 | 15 | from pusher.pusher import Pusher 16 | 17 | try: 18 | import unittest.mock as mock 19 | except ImportError: 20 | import mock 21 | 22 | 23 | class TestPusher(unittest.TestCase): 24 | def test_initialize_from_url(self): 25 | self.assertRaises(TypeError, lambda: Pusher.from_url(4)) 26 | self.assertRaises(Exception, lambda: Pusher.from_url(u'httpsahsutaeh')) 27 | 28 | pusher = Pusher.from_url(u'http://foo:bar@host/apps/4') 29 | self.assertEqual(pusher._pusher_client.ssl, False) 30 | self.assertEqual(pusher._pusher_client.key, u'foo') 31 | self.assertEqual(pusher._pusher_client.secret, u'bar') 32 | self.assertEqual(pusher._pusher_client.host, u'host') 33 | self.assertEqual(pusher._pusher_client.app_id, u'4') 34 | 35 | pusher = Pusher.from_url(u'https://foo:bar@host/apps/4') 36 | self.assertEqual(pusher._pusher_client.ssl, True) 37 | self.assertEqual(pusher._pusher_client.key, u'foo') 38 | self.assertEqual(pusher._pusher_client.secret, u'bar') 39 | self.assertEqual(pusher._pusher_client.host, u'host') 40 | self.assertEqual(pusher._pusher_client.app_id, u'4') 41 | 42 | def test_initialize_from_env(self): 43 | with mock.patch.object(os, 'environ', new={'PUSHER_URL':'https://plah:bob@somehost/apps/42'}): 44 | pusher = Pusher.from_env() 45 | self.assertEqual(pusher._pusher_client.ssl, True) 46 | self.assertEqual(pusher._pusher_client.key, u'plah') 47 | self.assertEqual(pusher._pusher_client.secret, u'bob') 48 | self.assertEqual(pusher._pusher_client.host, u'somehost') 49 | self.assertEqual(pusher._pusher_client.app_id, u'42') 50 | 51 | 52 | if __name__ == '__main__': 53 | unittest.main() 54 | -------------------------------------------------------------------------------- /pusher_tests/test_pusher_client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import print_function, absolute_import, division 4 | 5 | import os 6 | import six 7 | import hmac 8 | import json 9 | import hashlib 10 | import unittest 11 | import time 12 | from decimal import Decimal 13 | import random 14 | import nacl 15 | import base64 16 | 17 | from pusher.pusher_client import PusherClient 18 | from pusher.http import GET, POST 19 | from pusher.crypto import * 20 | 21 | try: 22 | import unittest.mock as mock 23 | except ImportError: 24 | import mock 25 | 26 | 27 | class TestPusherClient(unittest.TestCase): 28 | def setUp(self): 29 | self.pusher_client = PusherClient(app_id=u'4', key=u'key', secret=u'secret', host=u'somehost') 30 | 31 | def test_host_should_be_text(self): 32 | PusherClient(app_id=u'4', key=u'key', secret=u'secret', ssl=True, host=u'foo') 33 | 34 | self.assertRaises(TypeError, lambda: PusherClient(app_id=u'4', key=u'key', secret=u'secret', ssl=True, host=4)) 35 | 36 | def test_cluster_should_be_text(self): 37 | PusherClient(app_id=u'4', key=u'key', secret=u'secret', ssl=True, cluster=u'eu') 38 | 39 | self.assertRaises(TypeError, lambda: PusherClient(app_id=u'4', key=u'key', secret=u'secret', ssl=True, cluster=4)) 40 | 41 | def test_host_behaviour(self): 42 | conf = PusherClient(app_id=u'4', key=u'key', secret=u'secret', ssl=True) 43 | self.assertEqual(conf.host, u'api.pusherapp.com', u'default host should be correct') 44 | 45 | conf = PusherClient(app_id=u'4', key=u'key', secret=u'secret', ssl=True, cluster=u'eu') 46 | self.assertEqual(conf.host, u'api-eu.pusher.com', u'host should be overriden by cluster setting') 47 | 48 | conf = PusherClient(app_id=u'4', key=u'key', secret=u'secret', ssl=True, host=u'foo') 49 | self.assertEqual(conf.host, u'foo', u'host should be overriden by host setting') 50 | 51 | conf = PusherClient(app_id=u'4', key=u'key', secret=u'secret', ssl=True, cluster=u'eu', host=u'plah') 52 | self.assertEqual(conf.host, u'plah', u'host should be used in preference to cluster') 53 | 54 | def test_trigger_with_channels_list_success_case(self): 55 | json_dumped = u'{"message": "hello world"}' 56 | 57 | with mock.patch('json.dumps', return_value=json_dumped) as json_dumps_mock: 58 | request = self.pusher_client.trigger.make_request([u'some_channel'], u'some_event', {u'message': u'hello world'}) 59 | 60 | self.assertEqual(request.path, u'/apps/4/events') 61 | self.assertEqual(request.method, POST) 62 | 63 | expected_params = { 64 | u'channels': [u'some_channel'], 65 | u'data': json_dumped, 66 | u'name': u'some_event' 67 | } 68 | 69 | self.assertEqual(request.params, expected_params) 70 | 71 | # FIXME: broken 72 | # json_dumps_mock.assert_called_once_with({u'message': u'hello world'}) 73 | 74 | def test_trigger_with_channel_string_success_case(self): 75 | json_dumped = u'{"message": "hello worlds"}' 76 | 77 | with mock.patch('json.dumps', return_value=json_dumped) as json_dumps_mock: 78 | 79 | request = self.pusher_client.trigger.make_request(u'some_channel', u'some_event', {u'message': u'hello worlds'}) 80 | 81 | expected_params = { 82 | u'channels': [u'some_channel'], 83 | u'data': json_dumped, 84 | u'name': u'some_event' 85 | } 86 | 87 | self.assertEqual(request.params, expected_params) 88 | 89 | def test_trigger_batch_success_case(self): 90 | json_dumped = u'{"message": "something"}' 91 | 92 | with mock.patch('json.dumps', return_value=json_dumped) as json_dumps_mock: 93 | request = self.pusher_client.trigger_batch.make_request([{ 94 | u'channel': u'my-chan', 95 | u'name': u'my-event', 96 | u'data': {u'message': u'something'} 97 | }]) 98 | 99 | expected_params = { 100 | u'batch': [{ 101 | u'channel': u'my-chan', 102 | u'name': u'my-event', 103 | u'data': json_dumped 104 | }] 105 | } 106 | 107 | self.assertEqual(request.params, expected_params) 108 | 109 | def test_trigger_batch_success_case_2(self): 110 | json_dumped = u'{"message": "something"}' 111 | 112 | with mock.patch('json.dumps', return_value=json_dumped) as json_dumps_mock: 113 | request = self.pusher_client.trigger_batch.make_request( 114 | [{ 115 | u'channel': u'my-chan', 116 | u'name': u'my-event', 117 | u'data': {u'message': u'something'} 118 | },{ 119 | u'channel': u'my-chan-2', 120 | u'name': u'my-event-2', 121 | u'data': {u'message': u'something-else'} 122 | }]) 123 | 124 | expected_params = { 125 | u'batch': [{ 126 | u'channel': u'my-chan', 127 | u'name': u'my-event', 128 | u'data': json_dumped 129 | }, 130 | { 131 | u'channel': u'my-chan-2', 132 | u'name': u'my-event-2', 133 | u'data': json_dumped 134 | }] 135 | } 136 | 137 | self.assertEqual(request.params, expected_params) 138 | 139 | def test_trigger_batch_with_mixed_channels_success_case(self): 140 | json_dumped = u'{"message": "something"}' 141 | 142 | master_key = b'8tW5FQLniQ1sBQFwrw7t6TVEsJZd10yY' 143 | master_key_base64 = base64.b64encode(master_key) 144 | event_name_2 = "my-event-2" 145 | chan_2 = "private-encrypted-2" 146 | payload = {"message": "hello worlds"} 147 | 148 | pc = PusherClient( 149 | app_id=u'4', 150 | key=u'key', 151 | secret=u'secret', 152 | encryption_master_key_base64=master_key_base64, 153 | ssl=True) 154 | request = pc.trigger_batch.make_request( 155 | [{ 156 | u'channel': u'my-chan', 157 | u'name': u'my-event', 158 | u'data': {u'message': u'something'} 159 | },{ 160 | u'channel': chan_2, 161 | u'name': event_name_2, 162 | u'data': payload 163 | }] 164 | ) 165 | 166 | # simulate the same encryption process and check equality 167 | chan_2 = ensure_binary(chan_2, "chan_2") 168 | shared_secret = generate_shared_secret(chan_2, master_key) 169 | 170 | box = nacl.secret.SecretBox(shared_secret) 171 | 172 | nonce_b64 = json.loads(request.params["batch"][1]["data"])["nonce"].encode("utf-8") 173 | nonce = base64.b64decode(nonce_b64) 174 | 175 | encrypted = box.encrypt(json.dumps(payload, ensure_ascii=False).encode("utf'-8"), nonce) 176 | 177 | # obtain the ciphertext 178 | cipher_text = encrypted.ciphertext 179 | 180 | # encode cipertext to base64 181 | cipher_text_b64 = base64.b64encode(cipher_text) 182 | 183 | # format expected output 184 | json_dumped_2 = json.dumps({ "nonce" : nonce_b64.decode("utf-8"), "ciphertext": cipher_text_b64.decode("utf-8") }, ensure_ascii=False) 185 | 186 | expected_params = { 187 | u'batch': [{ 188 | u'channel': u'my-chan', 189 | u'name': u'my-event', 190 | u'data': json_dumped 191 | }, 192 | { 193 | u'channel': u'private-encrypted-2', 194 | u'name': event_name_2, 195 | u'data': json_dumped_2 196 | }] 197 | } 198 | 199 | self.assertEqual(request.params, expected_params) 200 | 201 | def test_trigger_with_private_encrypted_channel_string_fail_case_no_encryption_master_key_specified(self): 202 | pc = PusherClient(app_id=u'4', key=u'key', secret=u'secret', ssl=True) 203 | 204 | with self.assertRaises(ValueError): 205 | pc.trigger(u'private-encrypted-tst', u'some_event', {u'message': u'hello worlds'}) 206 | 207 | def test_trigger_too_much_data(self): 208 | pc = PusherClient(app_id=u'4', key=u'key', secret=u'secret', ssl=True) 209 | 210 | self.assertRaises(ValueError, lambda: pc.trigger(u'private-tst', u'some_event', u'a' * 30721)) 211 | 212 | def test_trigger_batch_too_much_data(self): 213 | pc = PusherClient(app_id=u'4', key=u'key', secret=u'secret', ssl=True) 214 | 215 | self.assertRaises(ValueError, lambda: pc.trigger_batch( 216 | [{u'channel': u'private-tst', u'name': u'some_event', u'data': u'a' * 30721}])) 217 | 218 | def test_trigger_str_shorter_than_30720_but_more_than_3kb_raising(self): 219 | pc = PusherClient(app_id=u'4', key=u'key', secret=u'secret', ssl=True) 220 | 221 | self.assertRaises(ValueError, lambda: pc.trigger.make_request(u'private-tst', u'some_event', u'你' * 30000)) 222 | 223 | def test_trigger_batch_str_shorter_than_30720_but_more_than_30kb_raising(self): 224 | pc = PusherClient(app_id=u'4', key=u'key', secret=u'secret', ssl=True) 225 | 226 | self.assertRaises(ValueError, lambda: pc.trigger_batch.make_request([{u'channel': u'private-tst', u'name': u'some_event', u'data': u'你' * 30000}])) 227 | 228 | def test_trigger_with_public_channel_with_encryption_master_key_specified_success(self): 229 | json_dumped = u'{"message": "something"}' 230 | 231 | pc = PusherClient( 232 | app_id=u'4', 233 | key=u'key', 234 | secret=u'secret', 235 | encryption_master_key_base64=u'OHRXNUZRTG5pUTFzQlFGd3J3N3Q2VFZFc0paZDEweVk=', 236 | ssl=True) 237 | 238 | with mock.patch('json.dumps', return_value=json_dumped) as json_dumps_mock: 239 | request = pc.trigger.make_request(u'donuts', u'some_event', {u'message': u'hello worlds'}) 240 | expected_params = { 241 | u'channels': [u'donuts'], 242 | u'data': json_dumped, 243 | u'name': u'some_event' 244 | } 245 | 246 | self.assertEqual(request.params, expected_params) 247 | 248 | def test_trigger_with_private_encrypted_channel_success(self): 249 | # instantiate a new client configured with the master encryption key 250 | master_key = b'8tW5FQLniQ1sBQFwrw7t6TVEsJZd10yY' 251 | master_key_base64 = base64.b64encode(master_key) 252 | pc = PusherClient( 253 | app_id=u'4', 254 | key=u'key', 255 | secret=u'secret', 256 | encryption_master_key_base64=master_key_base64, 257 | ssl=True) 258 | 259 | # trigger a request to a private-encrypted channel and capture the request to assert equality 260 | chan = "private-encrypted-tst" 261 | payload = {"message": "hello worlds"} 262 | event_name = 'some_event' 263 | request = pc.trigger.make_request(chan, event_name, payload) 264 | 265 | # simulate the same encryption process and check equality 266 | chan = ensure_binary(chan, "chan") 267 | shared_secret = generate_shared_secret(chan, master_key) 268 | 269 | box = nacl.secret.SecretBox(shared_secret) 270 | 271 | nonce_b64 = json.loads(request.params["data"])["nonce"].encode("utf-8") 272 | nonce = base64.b64decode(nonce_b64) 273 | 274 | encrypted = box.encrypt(json.dumps(payload, ensure_ascii=False).encode("utf'-8"), nonce) 275 | 276 | # obtain the ciphertext 277 | cipher_text = encrypted.ciphertext 278 | 279 | # encode cipertext to base64 280 | cipher_text_b64 = base64.b64encode(cipher_text) 281 | 282 | # format expected output 283 | json_dumped = json.dumps({"nonce": nonce_b64.decode("utf-8"), "ciphertext": cipher_text_b64.decode("utf-8")}) 284 | 285 | expected_params = { 286 | u'channels': [u'private-encrypted-tst'], 287 | u'data': json_dumped, 288 | u'name': u'some_event' 289 | } 290 | self.assertEqual(request.params, expected_params) 291 | 292 | def test_trigger_disallow_non_string_or_list_channels(self): 293 | self.assertRaises(TypeError, lambda: 294 | self.pusher_client.trigger.make_request({u'channels': u'test_channel'}, u'some_event', {u'message': u'hello world'})) 295 | 296 | def test_trigger_disallow_invalid_channels(self): 297 | self.assertRaises(ValueError, lambda: 298 | self.pusher_client.trigger.make_request([u'so/me_channel!'], u'some_event', {u'message': u'hello world'})) 299 | 300 | def test_trigger_disallow_private_encrypted_channel_with_multiple_channels(self): 301 | pc = PusherClient( 302 | app_id=u'4', 303 | key=u'key', 304 | secret=u'secret', 305 | encryption_master_key_base64=u'OHRXNUZRTG5pUTFzQlFGd3J3N3Q2VFZFc0paZDEweVk=', 306 | ssl=True) 307 | 308 | self.assertRaises(ValueError, lambda: 309 | self.pusher_client.trigger.make_request([u'my-chan', u'private-encrypted-pippo'], u'some_event', {u'message': u'hello world'})) 310 | 311 | def test_channels_info_default_success_case(self): 312 | request = self.pusher_client.channels_info.make_request() 313 | 314 | self.assertEqual(request.method, GET) 315 | self.assertEqual(request.path, u'/apps/4/channels') 316 | self.assertEqual(request.params, {}) 317 | 318 | def test_channels_info_with_prefix_success_case(self): 319 | request = self.pusher_client.channels_info.make_request(prefix_filter='test') 320 | 321 | self.assertEqual(request.method, GET) 322 | self.assertEqual(request.path, u'/apps/4/channels') 323 | self.assertEqual(request.params, {u'filter_by_prefix': u'test'}) 324 | 325 | def test_channels_info_with_attrs_success_case(self): 326 | request = self.pusher_client.channels_info.make_request(attributes=[u'attr1', u'attr2']) 327 | 328 | self.assertEqual(request.method, GET) 329 | self.assertEqual(request.path, u'/apps/4/channels') 330 | self.assertEqual(request.params, {u'info': u'attr1,attr2'}) 331 | 332 | def test_channel_info_success_case(self): 333 | request = self.pusher_client.channel_info.make_request(u'some_channel') 334 | 335 | self.assertEqual(request.method, GET) 336 | self.assertEqual(request.path, u'/apps/4/channels/some_channel') 337 | self.assertEqual(request.params, {}) 338 | 339 | def test_channel_info_with_attrs_success_case(self): 340 | request = self.pusher_client.channel_info.make_request(u'some_channel', attributes=[u'attr1', u'attr2']) 341 | 342 | self.assertEqual(request.method, GET) 343 | self.assertEqual(request.path, u'/apps/4/channels/some_channel') 344 | self.assertEqual(request.params, {u'info': u'attr1,attr2'}) 345 | 346 | def test_user_info_success_case(self): 347 | request = self.pusher_client.users_info.make_request(u'presence-channel') 348 | 349 | self.assertEqual(request.method, GET) 350 | self.assertEqual(request.path, u'/apps/4/channels/presence-channel/users') 351 | self.assertEqual(request.params, {}) 352 | 353 | def test_terminate_user_connection_success_case(self): 354 | request = self.pusher_client.terminate_user_connections.make_request('123') 355 | self.assertEqual(request.path, u'/users/123/terminate_connections') 356 | self.assertEqual(request.method, POST) 357 | self.assertEqual(request.params, {}) 358 | 359 | def test_terminate_user_connection_fail_case_invalid_user_id(self): 360 | with self.assertRaises(ValueError): 361 | self.pusher_client.terminate_user_connections("") 362 | 363 | 364 | if __name__ == '__main__': 365 | unittest.main() 366 | -------------------------------------------------------------------------------- /pusher_tests/test_request.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import print_function, absolute_import, division 4 | 5 | import unittest 6 | import re 7 | import sys 8 | 9 | from pusher import Pusher 10 | from pusher.http import Request 11 | 12 | try: 13 | import unittest.mock as mock 14 | except ImportError: 15 | import mock 16 | 17 | class TestRequest(unittest.TestCase): 18 | def test_get_signature_generation(self): 19 | conf = Pusher.from_url(u'http://key:secret@somehost/apps/4') 20 | 21 | expected = { 22 | u'auth_key': u'key', 23 | u'auth_signature': u'5c49f04a95eedc9028b1e0e8de7c2c7ad63504a0e3b5c145d2accaef6c14dbac', 24 | u'auth_timestamp': u'1000', 25 | u'auth_version': u'1.0', 26 | u'body_md5': u'd41d8cd98f00b204e9800998ecf8427e', 27 | u'foo': u'bar' 28 | } 29 | 30 | with mock.patch('time.time', return_value=1000): 31 | req = Request(conf._pusher_client, u'GET', u'/some/obscure/api', {u'foo': u'bar'}) 32 | self.assertEqual(req.query_params, expected) 33 | 34 | def test_post_signature_generation(self): 35 | conf = Pusher.from_url(u'http://key:secret@somehost/apps/4') 36 | 37 | expected = { 38 | u'auth_key': u'key', 39 | u'auth_signature': u'e05fa4cafee86311746ee3981d5581a5e4e87c27bbab0aeb1059e2df5c90258b', 40 | u'auth_timestamp': u'1000', 41 | u'auth_version': u'1.0', 42 | u'body_md5': u'94232c5b8fc9272f6f73a1e36eb68fcf' 43 | } 44 | 45 | with mock.patch('time.time', return_value=1000): 46 | # patching this, because json can be unambiguously parsed, but not 47 | # unambiguously generated (think whitespace). 48 | with mock.patch('json.dumps', return_value='{"foo": "bar"}') as json_dumps_mock: 49 | req = Request(conf._pusher_client, u'POST', u'/some/obscure/api', {u'foo': u'bar'}) 50 | self.assertEqual(req.query_params, expected) 51 | 52 | json_dumps_mock.assert_called_once_with({u"foo": u"bar"}) 53 | 54 | def test_x_pusher_library_header(self): 55 | conf = Pusher.from_url(u'http://key:secret@somehost/apps/4') 56 | req = Request(conf._pusher_client, u'GET', u'/some/obscure/api', {u'foo': u'bar'}) 57 | self.assertTrue('X-Pusher-Library' in req.headers) 58 | pusherLib = req.headers['X-Pusher-Library'] 59 | regex = r'^pusher-http-python \d+(\.\d+)+(rc\d+)?$' 60 | if sys.version_info < (3,): 61 | self.assertRegexpMatches(pusherLib, regex) 62 | else: 63 | self.assertRegex(pusherLib, regex) 64 | 65 | if __name__ == '__main__': 66 | unittest.main() 67 | -------------------------------------------------------------------------------- /pusher_tests/test_requests_adapter.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import print_function, absolute_import, division 4 | 5 | from pusher import Pusher 6 | import unittest 7 | import httpretty 8 | import sys 9 | 10 | class TestRequestsBackend(unittest.TestCase): 11 | 12 | def setUp(self): 13 | 14 | # temporary ignoring warnings until these are sorted: 15 | # https://github.com/gabrielfalcao/HTTPretty/issues/368 16 | if sys.version_info[0] >= 3: 17 | import warnings 18 | warnings.filterwarnings("ignore", category=ResourceWarning, message="unclosed file <_io.BufferedRandom name*") 19 | 20 | self.pusher = Pusher.from_url(u'http://key:secret@api.pusherapp.com/apps/4') 21 | 22 | @httpretty.activate 23 | def test_trigger_requests_success(self): 24 | httpretty.register_uri(httpretty.POST, "http://api.pusherapp.com/apps/4/events", 25 | body="{}", 26 | content_type="application/json") 27 | response = self.pusher.trigger(u'test_channel', u'test', {u'data': u'yolo'}) 28 | self.assertEqual(response, {}) 29 | 30 | 31 | if __name__ == '__main__': 32 | unittest.main() 33 | -------------------------------------------------------------------------------- /pusher_tests/test_util.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import pusher.util 4 | 5 | 6 | class TestUtil(unittest.TestCase): 7 | def test_validate_user_id(self): 8 | valid_user_ids = ["1", "12", "abc", "ab12", "ABCDEFG1234"] 9 | invalid_user_ids = ["", "x" * 201, "abc%&*"] 10 | 11 | for user_id in valid_user_ids: 12 | self.assertEqual(user_id, pusher.util.validate_user_id(user_id)) 13 | 14 | for user_id in invalid_user_ids: 15 | with self.assertRaises(ValueError): 16 | pusher.util.validate_user_id(user_id) 17 | 18 | def test_validate_channel(self): 19 | valid_channels = ["123", "xyz", "xyz123", "xyz_123", "xyz-123", "Channel@123", "channel_xyz", "channel-xyz", "channel,456", "channel;asd", "-abc_ABC@012.xpto,987;654"] 20 | 21 | invalid_channels = ["#123", "x" * 201, "abc%&*", "#server-to-user1234", "#server-to-users"] 22 | 23 | for channel in valid_channels: 24 | self.assertEqual(channel, pusher.util.validate_channel(channel)) 25 | 26 | for invalid_channel in invalid_channels: 27 | with self.assertRaises(ValueError): 28 | pusher.util.validate_channel(invalid_channel) 29 | 30 | def test_validate_server_to_user_channel(self): 31 | self.assertEqual("#server-to-user-123", pusher.util.validate_channel("#server-to-user-123")) 32 | self.assertEqual("#server-to-user-user123", pusher.util.validate_channel("#server-to-user-user123")) 33 | self.assertEqual("#server-to-user-ID-123", pusher.util.validate_channel("#server-to-user-ID-123")) 34 | 35 | with self.assertRaises(ValueError): 36 | pusher.util.validate_channel("#server-to-useR-123") 37 | pusher.util.validate_channel("#server-to-user1234") 38 | pusher.util.validate_channel("#server-to-users") 39 | 40 | 41 | if __name__ == '__main__': 42 | unittest.main() 43 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | asn1crypto==0.24.0; python_version < '3.10' 2 | certifi==2019.3.9; python_version < '3.10' 3 | cffi==1.15.0; python_version < '3.10' 4 | chardet==3.0.4; python_version < '3.10' 5 | cryptography==3.3.2; python_version < '3.10' 6 | httpretty==0.9.7; python_version < '3.10' 7 | idna==2.8; python_version < '3.10' 8 | mock==2.0.0; python_version < '3.10' 9 | ndg-httpsclient==0.5.1; python_version < '3.10' 10 | nose==1.3.7; python_version < '3.10' 11 | pbr==5.1.3; python_version < '3.10' 12 | pyasn1==0.4.5; python_version < '3.10' 13 | pycparser==2.19; python_version < '3.10' 14 | PyNaCl==1.3.0; python_version < '3.10' 15 | pyOpenSSL==19.0.0; python_version < '3.10' 16 | requests==2.22.0; python_version < '3.10' 17 | six==1.12.0; python_version < '3.10' 18 | urllib3==1.25.9; python_version < '3.10' 19 | aiohttp==3.5.4; python_version >= '3.5' and python_version < '3.10' 20 | aiohttp==3.8.1; python_version >= '3.10' 21 | aiosignal==1.2.0; python_version >= '3.10' 22 | async-timeout==3.0.1; python_version >= '3.5' and python_version < '3.10' 23 | async-timeout==4.0.2; python_version >= '3.10' 24 | attrs==19.1.0; python_version >= '3.5' and python_version < '3.10' 25 | attrs==21.4.0; python_version >= '3.10' 26 | certifi==2021.10.8; python_version >= '3.10' 27 | charset-normalizer==2.0.12; python_version >= '3.10' 28 | cryptography==41.0.0; python_version >= '3.10' 29 | frozenlist==1.3.0; python_version >= '3.10' 30 | httpretty==1.1.4; python_version >= '3.10' 31 | idna-ssl==1.1.0; python_version >= '3.5' and python_version < '3.7' 32 | idna==3.3; python_version >= '3.10' 33 | multidict==4.5.2; python_version >= '3.5' and python_version < '3.10' 34 | multidict==6.0.2; python_version >= '3.10' 35 | py==1.11.0; python_version >= '3.10' 36 | pycparser==2.21; python_version >= '3.10' 37 | PyNaCl==1.5.0; python_version >= '3.10' 38 | pyparsing==3.0.8; python_version >= '3.10' 39 | requests==2.27.1; python_version >= '3.10' 40 | six==1.16.0; python_version >= '3.10' 41 | tornado==5.1.1; python_version < '3.5' 42 | tornado==6.0.2; python_version >= '3.5' and python_version < '3.10' 43 | urllib3==1.26.9; python_version >= '3.10' 44 | yarl==1.3.0; python_version >= '3.5' and python_version < '3.10' 45 | yarl==1.7.2; python_version >= '3.10' 46 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | 4 | [bdist_wheel] 5 | universal = 1 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from setuptools import setup 3 | import os 4 | import re 5 | 6 | # Lovingly adapted from https://github.com/kennethreitz/requests/blob/39d693548892057adad703fda630f925e61ee557/setup.py#L50-L55 7 | with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'pusher/version.py'), 'r') as fd: 8 | VERSION = re.search(r'^VERSION = [\']([^\']*)[\']', 9 | fd.read(), re.MULTILINE).group(1) 10 | 11 | if not VERSION: 12 | raise RuntimeError('Ensure `VERSION` is correctly set in ./pusher/version.py') 13 | 14 | setup( 15 | name='pusher', 16 | version=VERSION, 17 | description='A Python library to interract with the Pusher Channels API', 18 | long_description='A Python library to interract with the Pusher Channels API', 19 | long_description_type='text/x-rst', 20 | url='https://github.com/pusher/pusher-http-python', 21 | author='Pusher', 22 | author_email='support@pusher.com', 23 | classifiers=[ 24 | 'License :: OSI Approved :: MIT License', 25 | 'Programming Language :: Python', 26 | 'Development Status :: 4 - Beta', 27 | 'Intended Audience :: Developers', 28 | 'Topic :: Internet :: WWW/HTTP', 29 | 'Programming Language :: Python :: 2', 30 | 'Programming Language :: Python :: 3', 31 | ], 32 | keywords='pusher rest realtime websockets service', 33 | license='MIT', 34 | 35 | packages=[ 36 | 'pusher' 37 | ], 38 | 39 | install_requires=[ 40 | 'six', 41 | 'requests>=2.3.0', 42 | 'urllib3', 43 | 'pyopenssl', 44 | 'ndg-httpsclient', 45 | 'pyasn1', 46 | 'pynacl' 47 | ], 48 | 49 | tests_require=['nose', 'mock', 'HTTPretty'], 50 | 51 | extras_require={ 52 | 'aiohttp': ['aiohttp>=0.20.0'], 53 | 'tornado': ['tornado>=5.0.0'] 54 | }, 55 | 56 | package_data={ 57 | 'pusher': ['cacert.pem'] 58 | }, 59 | 60 | test_suite='pusher_tests', 61 | ) 62 | --------------------------------------------------------------------------------