├── tests ├── __init__.py ├── fixtures.py ├── test_gcm.py └── test_apns.py ├── requirements.txt ├── docs ├── license.rst ├── authors.rst ├── changelog.rst ├── contributing.rst ├── _static │ └── theme_override.css ├── install.rst ├── index.rst ├── versioning.rst ├── kudos.rst ├── conf.py ├── upgrading.rst ├── Makefile └── api.rst ├── setup.py ├── .travis.yml ├── MANIFEST.in ├── pyproject.toml ├── src └── pushjack │ ├── __version__.py │ ├── utils.py │ ├── _compat.py │ ├── __init__.py │ ├── exceptions.py │ ├── gcm.py │ └── apns.py ├── AUTHORS.rst ├── .gitignore ├── LICENSE.rst ├── setup.cfg ├── CONTRIBUTING.rst ├── tox.ini ├── README.rst └── CHANGELOG.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e .[dev] 2 | -------------------------------------------------------------------------------- /docs/license.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../LICENSE.rst -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/_static/theme_override.css: -------------------------------------------------------------------------------- 1 | 2 | .wy-table-responsive table td, 3 | .wy-table-responsive table th { 4 | /* Get ride of nowrap for table cells. */ 5 | white-space: normal; 6 | } 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup, find_packages 4 | 5 | 6 | setup( 7 | package_dir={'': 'src'}, 8 | packages=find_packages('src'), 9 | include_package_data=True 10 | ) 11 | -------------------------------------------------------------------------------- /docs/install.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | **pushjack** requires Python >= 2.6 or >= 3.3. 5 | 6 | To install from `PyPi `_: 7 | 8 | :: 9 | 10 | pip install pushjack 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Config file for automatic testing at travis-ci.org 2 | sudo: false 3 | language: python 4 | python: 5 | - "2.7" 6 | - "3.4" 7 | - "3.5" 8 | - "3.6" 9 | - "3.7" 10 | - "3.8" 11 | install: 12 | - pip install tox-travis 13 | script: 14 | - tox 15 | after_success: 16 | - pip install coveralls 17 | - coveralls 18 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CONTRIBUTING.rst 3 | include CHANGELOG.rst 4 | include LICENSE.rst 5 | include README.rst 6 | include requirements.txt 7 | include requirements-dev.txt 8 | 9 | recursive-include tests * 10 | recursive-include docs *.rst conf.py Makefile make.bat 11 | 12 | recursive-exclude * __pycache__ 13 | recursive-exclude * *.py[cod] 14 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=40.0", 4 | "wheel", 5 | ] 6 | 7 | 8 | [tool.black] 9 | line-length = 88 10 | include = '\.pyi?$' 11 | exclude = ''' 12 | /( 13 | \.git 14 | | \.mypy_cache 15 | | \.tox 16 | | \.venv 17 | | \.cache 18 | | _build 19 | | build 20 | | dist 21 | | tests/python_projects 22 | )/ 23 | ''' 24 | -------------------------------------------------------------------------------- /src/pushjack/__version__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Project version information.""" 3 | 4 | from pkg_resources import get_distribution, DistributionNotFound 5 | 6 | 7 | try: 8 | __version__ = get_distribution(__name__.split(".")[0]).version 9 | except DistributionNotFound: # pragma: no cover 10 | # Package is not installed. 11 | __version__ = None 12 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. pushjack documentation master file 2 | 3 | .. include:: ../README.rst 4 | 5 | 6 | Guide 7 | ===== 8 | 9 | .. toctree:: 10 | :maxdepth: 3 11 | 12 | install 13 | upgrading 14 | api 15 | 16 | 17 | Project Info 18 | ============ 19 | 20 | .. toctree:: 21 | :maxdepth: 1 22 | 23 | license 24 | versioning 25 | changelog 26 | authors 27 | contributing 28 | kudos 29 | 30 | 31 | Indices and Tables 32 | ================== 33 | 34 | - :ref:`genindex` 35 | - :ref:`modindex` 36 | - :ref:`search` 37 | 38 | -------------------------------------------------------------------------------- /docs/versioning.rst: -------------------------------------------------------------------------------- 1 | Versioning 2 | ========== 3 | 4 | This project follows `Semantic Versioning`_ with the following caveats: 5 | 6 | - Only the public API (i.e. the objects imported into the ``pushjack`` module) will maintain backwards compatibility between MINOR version bumps. 7 | - Objects within any other parts of the library are not guaranteed to not break between MINOR version bumps. 8 | 9 | With that in mind, it is recommended to only use or import objects from the main module, ``pushjack``. 10 | 11 | 12 | .. _Semantic Versioning: http://semver.org/ 13 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | Authors 2 | ======= 3 | 4 | 5 | Lead 6 | ---- 7 | 8 | - Derrick Gilland, dgilland@gmail.com, `dgilland@github `_ 9 | 10 | 11 | Contributors 12 | ------------ 13 | 14 | - Brad Montgomery, `bradmontgomery@github `_ 15 | - Julius Seporaitis, `seporaitis@github `_ 16 | - Ahmed Khedr, `aakhedr@github `_ 17 | - Jakub Kleň, `kukosk@github `_ 18 | - Lukas Anzinger, `Lukas0907@github `_ 19 | -------------------------------------------------------------------------------- /docs/kudos.rst: -------------------------------------------------------------------------------- 1 | Kudos 2 | ===== 3 | 4 | This project started as a port of `django-push-notifications`_ with the goal of just separating the APNS and GCM modules from the Django related items. However, the implementation details, internals, and API interface have changed in ``pushjack`` and is no longer compatible with `django-push-notifications`_. But a special thanks goes out to the author and contributors of `django-push-notifications`_ who unknowingly helped start this project along. 5 | 6 | 7 | .. _django-push-notifications: https://github.com/jleclanche/django-push-notifications 8 | -------------------------------------------------------------------------------- /src/pushjack/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Utility functions.""" 3 | 4 | try: 5 | import simplejson as json 6 | except ImportError: 7 | import json 8 | 9 | from ._compat import range_ as range, string_types, iteritems 10 | 11 | 12 | def chunk(seq, size): 13 | """Return generator that yields chunks of length `size` from `seq`.""" 14 | return (seq[pos : pos + size] for pos in range(0, len(seq), size)) 15 | 16 | 17 | def compact_dict(dct): 18 | return dict((key, value) for key, value in iteritems(dct) if value is not None) 19 | 20 | 21 | def json_dumps(data): 22 | """Standardized json.dumps function with separators and sorted keys set.""" 23 | return json.dumps(data, separators=(",", ":"), sort_keys=True).encode("utf8") 24 | 25 | 26 | def json_loads(string): 27 | """Standardized json.loads function.""" 28 | if not isinstance(string, (string_types)): 29 | string = string.decode("utf8") 30 | return json.loads(str(string)) 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | venv/ 13 | *.egg 14 | *.egg-info/ 15 | ?eggs/ 16 | dist/ 17 | build/ 18 | parts/ 19 | var/ 20 | sdist/ 21 | develop-eggs/ 22 | .installed.cfg 23 | 24 | # PyInstaller 25 | # Usually these files are written by a python script from a template 26 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 27 | *.manifest 28 | *.spec 29 | 30 | # Installer logs 31 | pip-log.txt 32 | pip-delete-this-directory.txt 33 | 34 | # Unit test / coverage reports 35 | htmlcov/ 36 | .tox/ 37 | .coverage 38 | .coverage.* 39 | .cache 40 | .pytest_cache 41 | nosetests.xml 42 | coverage.xml 43 | junit.xml 44 | *,cover 45 | .hypothesis/ 46 | 47 | # Translations 48 | *.mo 49 | *.pot 50 | 51 | # Django stuff: 52 | *.log 53 | 54 | # Mr Developer 55 | .mr.developer.cfg 56 | .project 57 | .pydevproject 58 | .idea 59 | 60 | # Sphinx documentation 61 | docs/_build/ 62 | 63 | # PyBuilder 64 | target/ 65 | -------------------------------------------------------------------------------- /LICENSE.rst: -------------------------------------------------------------------------------- 1 | License 2 | ======= 3 | 4 | The MIT License (MIT) 5 | 6 | Copyright (c) 2015 Derrick Gilland 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy 9 | of this software and associated documentation files (the "Software"), to deal 10 | in the Software without restriction, including without limitation the rights 11 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | copies of the Software, and to permit persons to whom the Software is 13 | furnished to do so, subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be included in all 16 | copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | SOFTWARE. 25 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = pushjack 3 | version = 1.6.0 4 | author = Derrick Gilland 5 | author_email = dgilland@gmail.com 6 | url = https://github.com/dgilland/pushjack 7 | description = Push notifications for APNS (iOS) and GCM (Android) 8 | long_description = file: README.rst, CHANGELOG.rst, LICENSE.rst 9 | keywords = apns ios gcm android push notifications 10 | license = MIT License 11 | classifiers = 12 | Development Status :: 5 - Production/Stable 13 | Intended Audience :: Developers 14 | Operating System :: OS Independent 15 | Programming Language :: Python 16 | License :: OSI Approved :: MIT License 17 | Topic :: Communications 18 | Topic :: Internet 19 | Topic :: Software Development :: Libraries :: Python Modules 20 | Topic :: Utilities 21 | Programming Language :: Python 22 | Programming Language :: Python :: 2 23 | Programming Language :: Python :: 2.7 24 | Programming Language :: Python :: 3 25 | Programming Language :: Python :: 3.4 26 | Programming Language :: Python :: 3.5 27 | Programming Language :: Python :: 3.6 28 | Programming Language :: Python :: 3.7 29 | Programming Language :: Python :: 3.8 30 | 31 | [options] 32 | install_requires = 33 | requests 34 | 35 | [options.extras_require] 36 | dev = 37 | coverage 38 | flake8 39 | httmock 40 | invoke 41 | mock 42 | pylint 43 | pytest 44 | pytest-cov 45 | Sphinx 46 | sphinx-rtd-theme 47 | tox 48 | twine 49 | wheel 50 | 51 | 52 | [bdist_wheel] 53 | universal = 1 54 | -------------------------------------------------------------------------------- /src/pushjack/_compat.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # flake8: noqa 3 | # pylint: skip-file 4 | """ 5 | Python 2/3 compatibility. 6 | 7 | Some py2/py3 compatibility support based on a stripped down 8 | version of six so we don't have to depend on a specific version 9 | of it. 10 | 11 | Borrowed from 12 | https://github.com/mitsuhiko/flask/blob/master/flask/_compat.py 13 | """ 14 | 15 | import sys 16 | from decimal import Decimal 17 | 18 | 19 | PY3 = sys.version_info[0] == 3 20 | PY26 = sys.version_info[0:2] == (2, 6) 21 | 22 | 23 | def _identity(x): 24 | return x 25 | 26 | 27 | if PY3: 28 | text_type = str 29 | string_types = (str,) 30 | integer_types = (int,) 31 | number_types = (int, float, Decimal) 32 | 33 | def iterkeys(d): 34 | return iter(d.keys()) 35 | 36 | def itervalues(d): 37 | return iter(d.values()) 38 | 39 | def iteritems(d): 40 | return iter(d.items()) 41 | 42 | range_ = range 43 | 44 | implements_to_string = _identity 45 | else: 46 | text_type = unicode 47 | string_types = (str, unicode) 48 | integer_types = (int, long) 49 | number_types = (int, long, float, Decimal) 50 | 51 | def iterkeys(d): 52 | return d.iterkeys() 53 | 54 | def itervalues(d): 55 | return d.itervalues() 56 | 57 | def iteritems(d): 58 | return d.iteritems() 59 | 60 | range_ = xrange 61 | 62 | def implements_to_string(cls): 63 | cls.__unicode__ = cls.__str__ 64 | cls.__str__ = lambda x: x.__unicode__().encode("utf-8") 65 | return cls 66 | -------------------------------------------------------------------------------- /src/pushjack/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Pushjack module.""" 3 | 4 | from .__version__ import __version__ 5 | 6 | from .apns import ( 7 | APNSClient, 8 | APNSSandboxClient, 9 | APNSResponse, 10 | APNSExpiredToken, 11 | ) 12 | 13 | from .gcm import ( 14 | GCMClient, 15 | GCMResponse, 16 | GCMCanonicalID, 17 | ) 18 | 19 | from .exceptions import ( 20 | APNSError, 21 | APNSAuthError, 22 | APNSServerError, 23 | APNSProcessingError, 24 | APNSMissingTokenError, 25 | APNSMissingTopicError, 26 | APNSMissingPayloadError, 27 | APNSInvalidTokenSizeError, 28 | APNSInvalidTopicSizeError, 29 | APNSInvalidPayloadSizeError, 30 | APNSInvalidTokenError, 31 | APNSShutdownError, 32 | APNSProtocolError, 33 | APNSUnknownError, 34 | APNSTimeoutError, 35 | APNSUnsendableError, 36 | GCMError, 37 | GCMAuthError, 38 | GCMServerError, 39 | GCMMissingRegistrationError, 40 | GCMInvalidRegistrationError, 41 | GCMUnregisteredDeviceError, 42 | GCMInvalidPackageNameError, 43 | GCMMismatchedSenderError, 44 | GCMMessageTooBigError, 45 | GCMInvalidDataKeyError, 46 | GCMInvalidTimeToLiveError, 47 | GCMTimeoutError, 48 | GCMInternalServerError, 49 | GCMDeviceMessageRateExceededError, 50 | NotificationError, 51 | ServerError, 52 | ) 53 | 54 | # Set default logging handler to avoid "No handler found" warnings. 55 | import logging 56 | 57 | try: # Python 2.7+ 58 | from logging import NullHandler # pylint: disable=no-name-in-module 59 | except ImportError: # pragma: no cover 60 | 61 | class NullHandler(logging.Handler): 62 | def emit(self, record): 63 | pass 64 | 65 | 66 | logging.getLogger(__name__).addHandler(NullHandler()) 67 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Contributions are welcome, and they are greatly appreciated! Every little bit helps, and credit will always be given. 5 | 6 | You can contribute in many ways: 7 | 8 | 9 | Types of Contributions 10 | ---------------------- 11 | 12 | Report Bugs 13 | +++++++++++ 14 | 15 | Report bugs at https://github.com/dgilland/pushjack. 16 | 17 | If you are reporting a bug, please include: 18 | 19 | - Your operating system name and version. 20 | - Any details about your local setup that might be helpful in troubleshooting. 21 | - Detailed steps to reproduce the bug. 22 | 23 | 24 | Fix Bugs 25 | ++++++++ 26 | 27 | Look through the GitHub issues for bugs. Anything tagged with "bug" is open to whoever wants to implement it. 28 | 29 | 30 | Implement Features 31 | ++++++++++++++++++ 32 | 33 | Look through the GitHub issues for features. Anything tagged with "enhancement" or "help wanted" is open to whoever wants to implement it. 34 | 35 | 36 | Write Documentation 37 | +++++++++++++++++++ 38 | 39 | Pushjack could always use more documentation, whether as part of the official pushjack docs, in docstrings, or even on the web in blog posts, articles, and such. 40 | 41 | 42 | Submit Feedback 43 | +++++++++++++++ 44 | 45 | The best way to send feedback is to file an issue at https://github.com/dgilland/pushjack. 46 | 47 | If you are proposing a feature: 48 | 49 | - Explain in detail how it would work. 50 | - Keep the scope as narrow as possible, to make it easier to implement. 51 | - Remember that this is a volunteer-driven project, and that contributions are welcome :) 52 | 53 | 54 | Get Started! 55 | ------------ 56 | 57 | Ready to contribute? Here's how to set up ``pushjack`` for local development. 58 | 59 | 1. Fork the ``pushjack`` repo on GitHub. 60 | 2. Clone your fork locally:: 61 | 62 | $ git clone git@github.com:your_name_here/pushjack.git 63 | 64 | 3. Install your local copy into a virtualenv. Assuming you have virtualenv installed, this is how you set up your fork for local development:: 65 | 66 | $ cd pushjack 67 | $ pip install -r requirements.txt 68 | 69 | 4. Create a branch for local development:: 70 | 71 | $ git checkout -b name-of-your-bugfix-or-feature 72 | 73 | Now you can make your changes locally. 74 | 75 | 5. When you're done making changes, check that your changes pass linting and all unit tests by testing with tox across all supported Python versions:: 76 | 77 | $ tox 78 | 79 | 6. Add yourself to ``AUTHORS.rst``. 80 | 81 | 7. Commit your changes and push your branch to GitHub:: 82 | 83 | $ git add . 84 | $ git commit -m "Detailed description of your changes." 85 | $ git push origin name-of-your-bugfix-or-feature 86 | 87 | 8. Submit a pull request through the GitHub website. 88 | 89 | 90 | Pull Request Guidelines 91 | ----------------------- 92 | 93 | Before you submit a pull request, check that it meets these guidelines: 94 | 95 | 1. The pull request should include tests. 96 | 2. The pull request should work for all versions Python that this project supports. Check https://travis-ci.org/dgilland/pushjack/pull_requests and make sure that the all environments pass. 97 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27, py34, py35, py36, py37, py38, lint, docs, build 3 | 4 | [testenv] 5 | whitelist_externals = * 6 | passenv = * 7 | extras = dev 8 | commands = {[testenv:unit]commands} 9 | 10 | [travis] 11 | python = 12 | 2.7: py27 13 | 3.4: py34 14 | 3.5: py35 15 | 3.6: py36 16 | 3.7: py37 17 | 3.8: py38, lint, docs, build 18 | 19 | [testenv:unit] 20 | commands = pytest {posargs:--cov={envsitepackagesdir}/pushjack {envsitepackagesdir}/pushjack tests} 21 | 22 | [testenv:flake8] 23 | deps = 24 | flake8 25 | commands = flake8 src/pushjack tests 26 | 27 | [testenv:pylint] 28 | deps = 29 | pylint 30 | commands = pylint -E -j 4 -d not-callable,no-self-argument,no-member,no-value-for-parameter,method-hidden src/pushjack 31 | 32 | [testenv:lint] 33 | deps = 34 | {[testenv:flake8]deps} 35 | {[testenv:pylint]deps} 36 | commands = 37 | {[testenv:flake8]commands} 38 | {[testenv:pylint]commands} 39 | 40 | [testenv:test] 41 | deps = 42 | {[testenv:build]deps} 43 | commands = 44 | {[testenv:lint]commands} 45 | {[testenv:unit]commands} 46 | {[testenv:docs]commands} 47 | {[testenv:build]commands} 48 | 49 | [testenv:docs] 50 | commands = sphinx-build -q -W -b html -d {envtmpdir}/doctrees {toxinidir}/docs {envtmpdir}/html 51 | 52 | [testenv:servedocs] 53 | changedir = {envtmpdir}/html 54 | commands = 55 | {[testenv:docs]commands} 56 | python -m http.server {posargs} 57 | 58 | [testenv:black] 59 | skip_install = true 60 | deps = 61 | black 62 | commands = 63 | black src/pushjack tests 64 | 65 | [testenv:docformatter] 66 | skip_install = true 67 | deps = 68 | docformatter 69 | commands = 70 | docformatter src/pushjack tests --in-place --recursive --pre-summary-newline --wrap-summaries 88 --wrap-descriptions 88 71 | 72 | [testenv:auto] 73 | deps = 74 | {[testenv:black]deps} 75 | {[testenv:docformatter]deps} 76 | commands = 77 | {[testenv:black]commands} 78 | {[testenv:docformatter]commands} 79 | 80 | [testenv:build] 81 | deps = 82 | wheel 83 | commands = 84 | rm -rf dist build 85 | python setup.py -q sdist bdist_wheel 86 | 87 | [testenv:release] 88 | deps = 89 | {[testenv:build]deps} 90 | twine 91 | commands = 92 | {[testenv:build]commands} 93 | twine upload dist/* 94 | 95 | [testenv:clean] 96 | skip_install = true 97 | deps = 98 | commands = 99 | bash -c "find . | grep -E '(__pycache__|\.pyc|\.pyo|\.pyd$)' | xargs rm -rf" 100 | rm -rf .tox .coverage .cache .egg* *.egg* dist build 101 | 102 | 103 | # Various CLI tool configurations. 104 | 105 | [pytest] 106 | junit_family = xunit2 107 | addopts = 108 | --doctest-modules -v -s --color=yes 109 | --cov-report=xml --cov-report=term-missing 110 | --junitxml=junit.xml 111 | 112 | [coverage:run] 113 | omit = 114 | */tests/* 115 | */test_* 116 | */_compat.py 117 | 118 | [flake8] 119 | exclude = .tox,env 120 | max-line-length = 88 121 | # F401 - `module` imported but unused 122 | # F811 - redefinition of unused `name` from line `N` 123 | # E203 - whitespace before ':' 124 | # W503 - line break before binary operator 125 | ignore = F401,F811,E203,W503 126 | -------------------------------------------------------------------------------- /tests/fixtures.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import binascii 4 | from contextlib import contextmanager 5 | import hashlib 6 | import socket 7 | import struct 8 | import threading 9 | import time 10 | 11 | try: 12 | # py3 13 | import socketserver 14 | except ImportError: 15 | # py2 16 | import SocketServer as socketserver 17 | 18 | import pytest 19 | import httmock 20 | import mock 21 | 22 | import pushjack 23 | from pushjack import ( 24 | APNSClient, 25 | GCMClient, 26 | ) 27 | from pushjack.apns import APNS_ERROR_RESPONSE_COMMAND 28 | from pushjack.utils import json_dumps, json_loads 29 | 30 | 31 | # pytest.mark is a generator so create alias for convenience 32 | parametrize = pytest.mark.parametrize 33 | 34 | 35 | TCP_HOST = "0.0.0.0" 36 | TCP_PORT = 12345 37 | TCP_CONNECT = (TCP_HOST, TCP_PORT) 38 | 39 | 40 | class TCPHandler(socketserver.BaseRequestHandler): 41 | def handle(self): 42 | self.data = self.request.recv(4096) 43 | 44 | 45 | class TCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer): 46 | allow_reuse_address = True 47 | block_on_close = False 48 | 49 | 50 | class TCPClientServer(object): 51 | def __init__(self, connect=None): 52 | self.server = TCPServer(connect, TCPHandler) 53 | self.server_thread = threading.Thread(target=self.server.serve_forever) 54 | self.server_thread.daemon = True 55 | self.server_thread.start() 56 | 57 | self.client = socket.socket() 58 | self.client.connect(connect) 59 | 60 | def shutdown(self): 61 | self.client.close() 62 | self.server.server_close() 63 | self.server.shutdown() 64 | self.server_thread.join() 65 | 66 | 67 | @pytest.fixture 68 | def apns_client(): 69 | """Return APNS client.""" 70 | return APNSClient(certificate=None, default_error_timeout=0) 71 | 72 | 73 | def apns_socket_factory(connect=None): 74 | sock = mock.MagicMock() 75 | 76 | if connect: 77 | sock._client_server = TCPClientServer(connect) 78 | sock._sock = sock._client_server.client 79 | else: 80 | sock._sock = socket.socket() 81 | 82 | sock.fileno = lambda: sock._sock.fileno() 83 | 84 | return sock 85 | 86 | 87 | def apns_socket_error_factory(return_status): 88 | sock = apns_socket_factory() 89 | sock.read = lambda n: struct.pack( 90 | ">BBI", APNS_ERROR_RESPONSE_COMMAND, return_status, 0 91 | ) 92 | return sock 93 | 94 | 95 | def apns_feedback_socket_factory(tokens): 96 | data = {"stream": b""} 97 | 98 | for token in tokens: 99 | token = binascii.unhexlify(token) 100 | data["stream"] += struct.pack("!LH", int(time.time()), len(token)) 101 | data["stream"] += struct.pack("{0}s".format(len(token)), token) 102 | 103 | def read(n): 104 | out = data["stream"][:n] 105 | data["stream"] = data["stream"][n:] 106 | return out 107 | 108 | sock = apns_socket_factory() 109 | sock.read = read 110 | 111 | return sock 112 | 113 | 114 | def apns_tokens(num=1): 115 | tokens = [hashlib.sha256(str(n).encode("utf8")).hexdigest() for n in range(num)] 116 | return tokens[0] if num == 1 else tokens 117 | 118 | 119 | @pytest.fixture 120 | def apns_socket(): 121 | with mock.patch("pushjack.apns.create_socket") as create_socket: 122 | sock = apns_socket_factory(TCP_CONNECT) 123 | create_socket.return_value = sock 124 | 125 | yield create_socket() 126 | 127 | sock._client_server.shutdown() 128 | 129 | 130 | @contextmanager 131 | def apns_create_error_socket(code): 132 | with mock.patch("pushjack.apns.create_socket") as create_socket: 133 | sock = apns_socket_error_factory(code) 134 | create_socket.return_value = sock 135 | 136 | yield create_socket 137 | 138 | sock._sock.close() 139 | 140 | 141 | def gcm_server_response_factory(content, status_code=200): 142 | @httmock.all_requests 143 | def response(url, request): 144 | headers = {"content-type": "application/json"} 145 | return httmock.response(status_code, content, headers, None, 1, request) 146 | 147 | return response 148 | 149 | 150 | @httmock.all_requests 151 | def gcm_server_response(url, request): 152 | payload = json_loads(request.body) 153 | headers = {"content-type": "application/json"} 154 | registration_ids = gcm_registration_ids(payload) 155 | content = { 156 | "multicast_id": 1, 157 | "success": len(registration_ids), 158 | "failure": 0, 159 | "canonical_ids": 0, 160 | "results": [], 161 | } 162 | 163 | content["results"] = [ 164 | {"message_id": registration_id} for registration_id in registration_ids 165 | ] 166 | 167 | return httmock.response(200, content, headers, None, 1, request) 168 | 169 | 170 | @pytest.fixture 171 | def gcm_client(): 172 | """Return GCM client.""" 173 | return GCMClient(api_key="1234") 174 | 175 | 176 | def gcm_registration_ids(payload): 177 | if "registration_ids" in payload: 178 | ids = payload["registration_ids"] 179 | else: 180 | ids = [payload["to"]] 181 | 182 | return ids 183 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ******** 2 | pushjack 3 | ******** 4 | 5 | |version| |travis| |coveralls| |license| 6 | 7 | Push notifications for APNS (iOS) and GCM (Android). 8 | 9 | *****WARNING: PROJECT DEPRECATED AND NO LONGER MAINTAINED***** 10 | 11 | 12 | Links 13 | ===== 14 | 15 | - Project: https://github.com/dgilland/pushjack 16 | - Documentation: https://pushjack.readthedocs.io 17 | - PyPi: https://pypi.python.org/pypi/pushjack/ 18 | - TravisCI: https://travis-ci.org/dgilland/pushjack 19 | 20 | 21 | Quickstart 22 | ========== 23 | 24 | Install using pip: 25 | 26 | 27 | :: 28 | 29 | pip install pushjack 30 | 31 | 32 | Whether using ``APNS`` or ``GCM``, pushjack provides clients for each. 33 | 34 | 35 | APNS 36 | ---- 37 | 38 | Send notifications using the ``APNSClient`` class: 39 | 40 | 41 | .. code-block:: python 42 | 43 | from pushjack import APNSClient 44 | 45 | client = APNSClient(certificate='', 46 | default_error_timeout=10, 47 | default_expiration_offset=2592000, 48 | default_batch_size=100, 49 | default_retries=5) 50 | 51 | token = '' 52 | alert = 'Hello world.' 53 | 54 | # Send to single device. 55 | # NOTE: Keyword arguments are optional. 56 | res = client.send(token, 57 | alert, 58 | badge='badge count', 59 | sound='sound to play', 60 | category='category', 61 | content_available=True, 62 | title='Title', 63 | title_loc_key='t_loc_key', 64 | title_loc_args='t_loc_args', 65 | action_loc_key='a_loc_key', 66 | loc_key='loc_key', 67 | launch_image='path/to/image.jpg', 68 | extra={'custom': 'data'}) 69 | 70 | # Send to multiple devices by passing a list of tokens. 71 | client.send([token], alert, **options) 72 | 73 | 74 | Access response data. 75 | 76 | .. code-block:: python 77 | 78 | # List of all tokens sent. 79 | res.tokens 80 | 81 | # List of errors as APNSServerError objects 82 | res.errors 83 | 84 | # Dict mapping errors as token => APNSServerError object. 85 | res.token_errors 86 | 87 | 88 | Override defaults for error_timeout, expiration_offset, and batch_size. 89 | 90 | .. code-block:: python 91 | 92 | client.send(token, 93 | alert, 94 | expiration=int(time.time() + 604800), 95 | error_timeout=5, 96 | batch_size=200) 97 | 98 | 99 | Send a low priority message. 100 | 101 | .. code-block:: python 102 | 103 | # The default is low_priority == False 104 | client.send(token, alert, low_priority=True) 105 | 106 | 107 | Get expired tokens. 108 | 109 | .. code-block:: python 110 | 111 | expired_tokens = client.get_expired_tokens() 112 | 113 | 114 | Close APNS connection. 115 | 116 | .. code-block:: python 117 | 118 | client.close() 119 | 120 | 121 | For the APNS sandbox, use ``APNSSandboxClient`` instead: 122 | 123 | 124 | .. code-block:: python 125 | 126 | from pushjack import APNSSandboxClient 127 | 128 | 129 | GCM 130 | --- 131 | 132 | Send notifications using the ``GCMClient`` class: 133 | 134 | 135 | .. code-block:: python 136 | 137 | from pushjack import GCMClient 138 | 139 | client = GCMClient(api_key='') 140 | 141 | registration_id = '' 142 | alert = 'Hello world.' 143 | notification = {'title': 'Title', 'body': 'Body', 'icon': 'icon'} 144 | 145 | # Send to single device. 146 | # NOTE: Keyword arguments are optional. 147 | res = client.send(registration_id, 148 | alert, 149 | notification=notification, 150 | collapse_key='collapse_key', 151 | delay_while_idle=True, 152 | time_to_live=604800) 153 | 154 | # Send to multiple devices by passing a list of ids. 155 | client.send([registration_id], alert, **options) 156 | 157 | 158 | Alert can also be be a dictionary with data fields. 159 | 160 | .. code-block:: python 161 | 162 | alert = {'message': 'Hello world', 'custom_field': 'Custom Data'} 163 | 164 | 165 | Alert can also contain the notification payload. 166 | 167 | .. code-block:: python 168 | 169 | alert = {'message': 'Hello world', 'notification': notification} 170 | 171 | 172 | Send a low priority message. 173 | 174 | .. code-block:: python 175 | 176 | # The default is low_priority == False 177 | client.send(registration_id, alert, low_priority=True) 178 | 179 | 180 | Access response data. 181 | 182 | .. code-block:: python 183 | 184 | # List of requests.Response objects from GCM Server. 185 | res.responses 186 | 187 | # List of messages sent. 188 | res.messages 189 | 190 | # List of registration ids sent. 191 | res.registration_ids 192 | 193 | # List of server response data from GCM. 194 | res.data 195 | 196 | # List of successful registration ids. 197 | res.successes 198 | 199 | # List of failed registration ids. 200 | res.failures 201 | 202 | # List of exceptions. 203 | res.errors 204 | 205 | # List of canonical ids (registration ids that have changed). 206 | res.canonical_ids 207 | 208 | 209 | For more details, please see the full documentation at https://pushjack.readthedocs.io. 210 | 211 | 212 | .. |version| image:: http://img.shields.io/pypi/v/pushjack.svg?style=flat-square 213 | :target: https://pypi.python.org/pypi/pushjack/ 214 | 215 | .. |travis| image:: http://img.shields.io/travis/dgilland/pushjack/master.svg?style=flat-square 216 | :target: https://travis-ci.org/dgilland/pushjack 217 | 218 | .. |coveralls| image:: http://img.shields.io/coveralls/dgilland/pushjack/master.svg?style=flat-square 219 | :target: https://coveralls.io/r/dgilland/pushjack 220 | 221 | .. |license| image:: http://img.shields.io/pypi/l/pushjack.svg?style=flat-square 222 | :target: https://pypi.python.org/pypi/pushjack/ 223 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Configuration file for the Sphinx documentation builder. 5 | # 6 | # This file does only contain a selection of the most common options. For a 7 | # full list see the documentation: 8 | # http://www.sphinx-doc.org/en/master/config 9 | 10 | # -- Path setup -------------------------------------------------------------- 11 | 12 | # If extensions (or modules to document with autodoc) are in another directory, 13 | # add these directories to sys.path here. If the directory is relative to the 14 | # documentation root, use os.path.abspath to make it absolute, like shown here. 15 | # 16 | import os 17 | import sys 18 | 19 | 20 | sys.path.insert(0, os.path.abspath('..')) 21 | 22 | 23 | # -- Project information ----------------------------------------------------- 24 | 25 | from email import message_from_string 26 | from pkg_resources import get_distribution 27 | 28 | dist = get_distribution('pushjack') 29 | 30 | if hasattr(dist, '_parsed_pkg_info'): 31 | pkg_info = dict(dist._parsed_pkg_info) 32 | else: 33 | pkg_info = dict( 34 | message_from_string('\n'.join(dist._get_metadata('PKG-INFO')))) 35 | 36 | project = pkg_info['Name'] 37 | author = pkg_info['Author'] 38 | description = pkg_info['Summary'] 39 | copyright = '2014, ' + author 40 | 41 | # The short X.Y version 42 | version = pkg_info['Version'] 43 | # The full version, including alpha/beta/rc tags 44 | release = version 45 | 46 | 47 | # -- General configuration --------------------------------------------------- 48 | 49 | # If your documentation needs a minimal Sphinx version, state it here. 50 | # 51 | #needs_sphinx = '1.0' 52 | 53 | # Add any Sphinx extension module names here, as strings. They can be 54 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 55 | # ones. 56 | extensions = [ 57 | 'sphinx.ext.autodoc', 58 | 'sphinx.ext.doctest', 59 | 'sphinx.ext.coverage', 60 | 'sphinx.ext.viewcode', 61 | 'sphinx.ext.napoleon' 62 | ] 63 | 64 | # Add any paths that contain templates here, relative to this directory. 65 | templates_path = ['_templates'] 66 | 67 | # The suffix(es) of source filenames. 68 | # You can specify multiple suffix as a list of string: 69 | # 70 | # source_suffix = ['.rst', '.md'] 71 | source_parsers = {} 72 | source_suffix = ['.rst'] 73 | 74 | # The master toctree document. 75 | master_doc = 'index' 76 | 77 | # The language for content autogenerated by Sphinx. Refer to documentation 78 | # for a list of supported languages. 79 | # 80 | # This is also used if you do content translation via gettext catalogs. 81 | # Usually you set "language" from the command line for these cases. 82 | language = None 83 | 84 | # List of patterns, relative to source directory, that match files and 85 | # directories to ignore when looking for source files. 86 | # This pattern also affects html_static_path and html_extra_path. 87 | exclude_patterns = ['_build'] 88 | 89 | # The name of the Pygments (syntax highlighting) style to use. 90 | pygments_style = 'sphinx' 91 | 92 | # If true, `todo` and `todoList` produce output, else they produce nothing. 93 | todo_include_todos = False 94 | 95 | 96 | # -- Options for HTML output ------------------------------------------------- 97 | 98 | # The theme to use for HTML and HTML Help pages. See the documentation for 99 | # a list of builtin themes. 100 | # 101 | # on_rtd is whether we are on readthedocs.org, this line of code grabbed from 102 | # docs.readthedocs.org. 103 | on_rtd = os.environ.get('READTHEDOCS', None) == 'True' 104 | 105 | if on_rtd: 106 | html_theme = 'default' 107 | else: 108 | import sphinx_rtd_theme 109 | 110 | html_theme = 'sphinx_rtd_theme' 111 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 112 | 113 | def setup(app): 114 | app.add_stylesheet('theme_override.css') 115 | 116 | # Theme options are theme-specific and customize the look and feel of a theme 117 | # further. For a list of options available for each theme, see the 118 | # documentation. 119 | # 120 | #html_theme_options = {} 121 | 122 | # Add any paths that contain custom static files (such as style sheets) here, 123 | # relative to this directory. They are copied after the builtin static files, 124 | # so a file named "default.css" will overwrite the builtin "default.css". 125 | html_static_path = ['_static'] 126 | 127 | # Custom sidebar templates, must be a dictionary that maps document names 128 | # to template names. 129 | # 130 | # The default sidebars (for documents that don't match any pattern) are 131 | # defined by theme itself. Builtin themes are using these templates by 132 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 133 | # 'searchbox.html']``. 134 | # 135 | #html_sidebars = {} 136 | 137 | 138 | # -- Options for HTMLHelp output --------------------------------------------- 139 | 140 | # Output file base name for HTML help builder. 141 | htmlhelp_basename = project + 'doc' 142 | 143 | 144 | # -- Options for LaTeX output ------------------------------------------------ 145 | 146 | latex_elements = { 147 | # The paper size ('letterpaper' or 'a4paper'). 148 | # 149 | #'papersize': 'letterpaper', 150 | 151 | # The font size ('10pt', '11pt' or '12pt'). 152 | # 153 | #'pointsize': '10pt', 154 | 155 | # Additional stuff for the LaTeX preamble. 156 | # 157 | #'preamble': '', 158 | 159 | # Latex figure (float) alignment 160 | # 161 | #'figure_align': 'htbp', 162 | } 163 | 164 | 165 | # Grouping the document tree into LaTeX files. List of tuples 166 | # (source start file, target name, title, 167 | # author, documentclass [howto, manual, or own class]). 168 | latex_documents = [ 169 | (master_doc, project + '.tex', project + ' Documentation', author, 170 | 'manual'), 171 | ] 172 | 173 | 174 | # -- Options for manual page output ------------------------------------------ 175 | 176 | # One entry per manual page. List of tuples 177 | # (source start file, name, description, authors, manual section). 178 | man_pages = [ 179 | (master_doc, project, project + ' Documentation', [author], 1) 180 | ] 181 | 182 | 183 | # -- Options for Texinfo output ---------------------------------------------- 184 | 185 | # Grouping the document tree into Texinfo files. List of tuples 186 | # (source start file, target name, title, author, 187 | # dir menu entry, description, category) 188 | texinfo_documents = [ 189 | (master_doc, project, project + ' Documentation', author, project, 190 | description, 'Miscellaneous'), 191 | ] 192 | 193 | 194 | # -- Extension configuration ------------------------------------------------- 195 | 196 | -------------------------------------------------------------------------------- /docs/upgrading.rst: -------------------------------------------------------------------------------- 1 | .. _upgrading: 2 | 3 | Upgrading 4 | ========= 5 | 6 | 7 | From v0.5.0 to v1.0.0 8 | --------------------- 9 | 10 | There were several, major breaking changes in ``v1.0.0``: 11 | 12 | - Make APNS always return ``APNSResponse`` object instead of only raising ``APNSSendError`` when errors encountered. (**breaking change**) 13 | - Remove APNS/GCM module send functions and only support client interfaces. (**breaking change**) 14 | - Remove ``config`` argument from ``APNSClient`` and use individual function parameters as mapped below instead: (**breaking change**) 15 | 16 | - ``APNS_ERROR_TIMEOUT`` => ``default_error_timeout`` 17 | - ``APNS_DEFAULT_EXPIRATION_OFFSET`` => ``default_expiration_offset`` 18 | - ``APNS_DEFAULT_BATCH_SIZE`` => ``default_batch_size`` 19 | 20 | - Remove ``config`` argument from ``GCMClient`` and use individual functionm parameters as mapped below instead: (**breaking change**) 21 | 22 | - ``GCM_API_KEY`` => ``api_key`` 23 | 24 | - Remove ``pushjack.clients`` module. (**breaking change**) 25 | - Remove ``pushjack.config`` module. (**breaking change**) 26 | - Rename ``GCMResponse.payloads`` to ``GCMResponse.messages``. (**breaking change**) 27 | 28 | The motiviation behind these drastic changes were to eliminate multiple methods for sending tokens (removing module functions in favor of using client classes) and to simplify the overall implementation (eliminating a separate configuration module/implementation and instead passing config parameters directly into client class). This has lead to a smaller, easier to maintain codebase with fewer implementation details. 29 | 30 | The module send functions are no longer implemented: 31 | 32 | .. code-block:: python 33 | 34 | # This no longer works on v1.0.0. 35 | from pushjack import apns, gcm 36 | 37 | apns.send(...) 38 | gcm.send(...) 39 | 40 | 41 | Instead, the respective client classes must be used instead: 42 | 43 | .. code-block:: python 44 | 45 | # This works on v1.0.0. 46 | from pushjack import APNSClient, APNSSandboxClient, GCMClient 47 | 48 | apns = APNSClient(...) 49 | apns.send(...) 50 | 51 | apns_sandbox = APNSSandboxClient(...) 52 | apns_sandbox.send(...) 53 | 54 | gcm = GCMClient(...) 55 | gcm.send(...) 56 | 57 | 58 | The configuration module has been eliminated: 59 | 60 | .. code-block:: python 61 | 62 | # This fails on v1.0.0. 63 | from pushjack import APNSClient, GCMClient, create_apns_config, create_gcm_config 64 | 65 | apns = APNSClient(create_apns_config({ 66 | 'APNS_CERTIFICATE': '', 67 | 'APNS_ERROR_TIMEOUT': 10, 68 | 'APNS_DEFAULT_EXPIRATION_OFFSET: 60 * 60 * 24 * 30, 69 | 'APNS_DEFAULT_BATCH_SIZE': 100 70 | })) 71 | apns.send(tokens, alert, **options) 72 | 73 | gcm = GCMClient(create_gcm_config({ 74 | 'GCM_API_KEY': '' 75 | })) 76 | gcm.send(tokens, alert, **options) 77 | 78 | 79 | Instead, configuration values are passed directly during class instance creation: 80 | 81 | .. code-block:: python 82 | 83 | # This works on v1.0.0. 84 | from pushjack import APNSClient, APNSSandboxClient, GCMClient 85 | 86 | apns = APNSClient('', 87 | default_error_timeout=10, 88 | default_expiration_offset=60 * 60 * 24 * 30, 89 | default_batch_size=100) 90 | 91 | # or if wanting to use the sandbox: 92 | # sandbox = APNSSandboxClient(...) 93 | 94 | apns.send(tokens, alert, **options) 95 | 96 | gcm = GCMClient('') 97 | gcm.send(tokens, alert, **options) 98 | 99 | 100 | APNS sending no longer raises an ``APNSSendError`` when error encountered: 101 | 102 | .. code-block:: python 103 | 104 | # This fails on v1.0.0 105 | from pushjack APNSSendError 106 | 107 | try: 108 | apns.send(tokens, alert, **options) 109 | except APNSSendError as ex: 110 | ex.errors 111 | 112 | 113 | Instead, APNS sending returns an :class:`pushjack.apns.APNSResponse` object: 114 | 115 | .. code-block:: python 116 | 117 | # This works on v1.0.0 118 | res = apns.send(tokens, alert, **options) 119 | res.errors 120 | res.error_tokens 121 | 122 | 123 | From v0.4.0 to v0.5.0 124 | --------------------- 125 | 126 | There were two breaking changes in ``v0.5.0``: 127 | 128 | - Make APNS ``send`` raise an ``APNSSendError`` when one or more error responses received. ``APNSSendError`` contains an aggregation of errors, all tokens attempted, failed tokens, and successful tokens. (**breaking change**) 129 | - Replace ``priority`` argument to APNS ``send`` with ``low_priority=False``. (**breaking change**) 130 | 131 | The new exception ``APNSSendError`` replaces individually raised APNS server errors. So instead of catching the base server exception, ``APNSServerError``, catch ``APNSSendError`` instead: 132 | 133 | 134 | .. code-block:: python 135 | 136 | from pushjack import apns 137 | 138 | # On v0.4.0 139 | try: 140 | apns.send(tokens, **options) 141 | except APNSServerError: 142 | pass 143 | 144 | # Updated for v0.5.0 145 | try: 146 | apns.send(tokens, **options) 147 | except APNSSendError: 148 | pass 149 | 150 | 151 | The new ``low_priority`` argument makes setting the APNS notification priority more straight-forward: 152 | 153 | 154 | .. code-block:: python 155 | 156 | from pushjack import apns 157 | 158 | # On v0.4.0 159 | 160 | ## High priority (the default) 161 | apns.send(tokens, alert) 162 | apns.send(tokens, alert, priority=10) 163 | 164 | ## Low priority 165 | apns.send(tokens, alert, priority=5) 166 | 167 | # Updated for v0.5.0 168 | 169 | ## High priority (the default) 170 | apns.send(tokens, alert) 171 | apns.send(tokens, alert, low_priority=False) 172 | 173 | ## Low priority 174 | apns.send(tokens, alert, low_priority=True) 175 | 176 | 177 | From v0.3.0 to v0.4.0 178 | --------------------- 179 | 180 | There were several breaking changes in ``v0.4.0``: 181 | 182 | - Remove ``request`` argument from GCM send function. (**breaking change**) 183 | - Remove ``sock`` argument from APNS send function. (**breaking change**) 184 | - Remove APNS and GCM ``send_bulk`` function. Modify ``send`` to support bulk notifications. (**breaking change**) 185 | 186 | The first two items should be fairly minor as these arguments were not well documented nor encouraged. In ``v0.4.0`` the APNS socket and GCM request objects are now managed within the send functions. 187 | 188 | The last item is more likely to break code since ``send_bulk`` was removed. However, replacing ``send_bulk`` with ``send`` will fix it: 189 | 190 | 191 | .. code-block:: python 192 | 193 | from pushjack import apns, gcm 194 | 195 | # On v0.3.0 196 | apns.send_bulk(tokens, **options) 197 | gcm.send_bulk(tokens, **options) 198 | 199 | # Updated for v0.4.0 200 | apns.send(tokens, **options) 201 | gcm.send(tokens, **options) 202 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/pushjack.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/pushjack.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/pushjack" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/pushjack" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | .. _api: 2 | 3 | API Reference 4 | ============= 5 | 6 | 7 | APNS 8 | ---- 9 | 10 | .. automodule:: pushjack.apns 11 | :members: 12 | 13 | 14 | Exceptions 15 | ++++++++++ 16 | 17 | The :class:`.APNSServerError` class of exceptions represent error responses from APNS. These exceptions will contain attributes for ``code``, ``description``, and ``identifier``. The ``identifier`` attribute is the list index of the token that failed. However, none of these exceptions will be raised directly. Instead, APNS server errors are collected and packaged into a :class:`.APNSResponse` object and returned by :meth:`.APNSClient.send`. This object provides a list of the raw exceptions as well as a mapping of the actual token and its associated error. 18 | 19 | Below is a listing of APNS Server exceptions: 20 | 21 | 22 | ===================================== ==== ==================== 23 | Exception Code Description 24 | ===================================== ==== ==================== 25 | :class:`.APNSProcessingError` 1 Processing error 26 | :class:`.APNSMissingTokenError` 2 Missing token 27 | :class:`.APNSMissingTopicError` 3 Missing topic 28 | :class:`.APNSMissingPayloadError` 4 Missing payload 29 | :class:`.APNSInvalidTokenSizeError` 5 Invalid token size 30 | :class:`.APNSInvalidTopicSizeError` 6 Invalid topic size 31 | :class:`.APNSInvalidPayloadSizeError` 7 Invalid payload size 32 | :class:`.APNSInvalidTokenError` 8 Invalid token 33 | :class:`.APNSShutdownError` 10 Shutdown 34 | :class:`.APNSUnknownError` 255 Unknown 35 | ===================================== ==== ==================== 36 | 37 | 38 | .. autoclass:: pushjack.exceptions.APNSError 39 | :members: 40 | 41 | .. autoclass:: pushjack.exceptions.APNSAuthError 42 | :members: 43 | 44 | .. autoclass:: pushjack.exceptions.APNSServerError 45 | :members: 46 | 47 | .. autoclass:: pushjack.exceptions.APNSProcessingError 48 | :members: 49 | 50 | .. autoclass:: pushjack.exceptions.APNSMissingTokenError 51 | :members: 52 | 53 | .. autoclass:: pushjack.exceptions.APNSMissingTopicError 54 | :members: 55 | 56 | .. autoclass:: pushjack.exceptions.APNSMissingPayloadError 57 | :members: 58 | 59 | .. autoclass:: pushjack.exceptions.APNSInvalidTokenSizeError 60 | :members: 61 | 62 | .. autoclass:: pushjack.exceptions.APNSInvalidTopicSizeError 63 | :members: 64 | 65 | .. autoclass:: pushjack.exceptions.APNSInvalidPayloadSizeError 66 | :members: 67 | 68 | .. autoclass:: pushjack.exceptions.APNSInvalidTokenError 69 | :members: 70 | 71 | .. autoclass:: pushjack.exceptions.APNSShutdownError 72 | :members: 73 | 74 | .. autoclass:: pushjack.exceptions.APNSUnknownError 75 | :members: 76 | 77 | 78 | GCM 79 | --- 80 | 81 | .. automodule:: pushjack.gcm 82 | :members: 83 | 84 | 85 | Exceptions 86 | ++++++++++ 87 | 88 | The :class:`.GCMServerError` class of exceptions are contained in :attr:`.GCMResponse.errors`. Each exception contains attributes for ``code``, ``description``, and ``identifier`` (i.e. the registration ID that failed). 89 | 90 | Below is a listing of GCM Server exceptions: 91 | 92 | 93 | =========================================== ========================= ======================= 94 | Exception Code Description 95 | =========================================== ========================= ======================= 96 | :class:`.GCMMissingRegistrationError` MissingRegistration Missing registration ID 97 | :class:`.GCMInvalidRegistrationError` InvalidRegistration Invalid registration ID 98 | :class:`.GCMUnregisteredDeviceError` NotRegistered Device not registered 99 | :class:`.GCMInvalidPackageNameError` InvalidPackageName Invalid package name 100 | :class:`.GCMMismatchedSenderError` MismatchSenderId Mismatched sender ID 101 | :class:`.GCMMessageTooBigError` MessageTooBig Message too big 102 | :class:`.GCMInvalidDataKeyError` InvalidDataKey Invalid data key 103 | :class:`.GCMInvalidTimeToLiveError` InvalidTtl Invalid time to live 104 | :class:`.GCMTimeoutError` Unavailable Timeout 105 | :class:`.GCMInternalServerError` InternalServerError Internal server error 106 | :class:`.GCMDeviceMessageRateExceededError` DeviceMessageRateExceeded Message rate exceeded 107 | =========================================== ========================= ======================= 108 | 109 | 110 | .. autoclass:: pushjack.exceptions.GCMError 111 | :members: 112 | 113 | .. autoclass:: pushjack.exceptions.GCMAuthError 114 | :members: 115 | 116 | .. autoclass:: pushjack.exceptions.GCMServerError 117 | :members: 118 | 119 | .. autoclass:: pushjack.exceptions.GCMMissingRegistrationError 120 | :members: 121 | 122 | .. autoclass:: pushjack.exceptions.GCMInvalidRegistrationError 123 | :members: 124 | 125 | .. autoclass:: pushjack.exceptions.GCMUnregisteredDeviceError 126 | :members: 127 | 128 | .. autoclass:: pushjack.exceptions.GCMInvalidPackageNameError 129 | :members: 130 | 131 | .. autoclass:: pushjack.exceptions.GCMMismatchedSenderError 132 | :members: 133 | 134 | .. autoclass:: pushjack.exceptions.GCMMessageTooBigError 135 | :members: 136 | 137 | .. autoclass:: pushjack.exceptions.GCMInvalidDataKeyError 138 | :members: 139 | 140 | .. autoclass:: pushjack.exceptions.GCMInvalidTimeToLiveError 141 | :members: 142 | 143 | .. autoclass:: pushjack.exceptions.GCMTimeoutError 144 | :members: 145 | 146 | .. autoclass:: pushjack.exceptions.GCMInternalServerError 147 | :members: 148 | 149 | .. autoclass:: pushjack.exceptions.GCMDeviceMessageRateExceededError 150 | :members: 151 | 152 | 153 | Logging 154 | ------- 155 | 156 | Internal logging is handled with the `logging module `_. The logger names used are: 157 | 158 | - ``pushjack`` 159 | - ``pushjack.apns`` 160 | - ``pushjack.gcm`` 161 | 162 | 163 | Enabling 164 | ++++++++ 165 | 166 | To enable logging using an imperative approach: 167 | 168 | .. code-block:: python 169 | 170 | import logging 171 | import pushjack 172 | 173 | logger = logging.getLogger('pushjack') 174 | logger.setLevel(logging.DEBUG) 175 | 176 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 177 | stream_handler = logging.StreamHandler() 178 | stream_handler.setFormatter(formatter) 179 | 180 | logger.addHandler(stream_handler) 181 | 182 | 183 | To enable logging using a configuration approach: 184 | 185 | .. code-block:: python 186 | 187 | import logging 188 | import logging.config 189 | import pushjack 190 | 191 | logging.config.dictConfig({ 192 | 'version': 1, 193 | 'disable_existing_loggers': False, 194 | 'handlers': { 195 | 'console': { 196 | 'class': 'logging.StreamHandler', 197 | 'level': 'DEBUG' 198 | } 199 | }, 200 | 'loggers': { 201 | 'pushjack': { 202 | 'handlers': ['console'] 203 | } 204 | } 205 | }) 206 | 207 | For additional configuration options, you may wish to install `logconfig `_: 208 | 209 | :: 210 | 211 | pip install logconfig 212 | 213 | 214 | .. code-block:: python 215 | 216 | import logconfig 217 | import pushjack 218 | 219 | logconfig.from_yaml('path/to/logconfig.yml') 220 | -------------------------------------------------------------------------------- /src/pushjack/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Exceptions module.""" 3 | 4 | from ._compat import iteritems 5 | 6 | 7 | __all__ = ( 8 | "APNSError", 9 | "APNSAuthError", 10 | "APNSServerError", 11 | "APNSProcessingError", 12 | "APNSMissingTokenError", 13 | "APNSMissingTopicError", 14 | "APNSMissingPayloadError", 15 | "APNSInvalidTokenSizeError", 16 | "APNSInvalidTopicSizeError", 17 | "APNSInvalidPayloadSizeError", 18 | "APNSInvalidTokenError", 19 | "APNSShutdownError", 20 | "APNSProtocolError", 21 | "APNSUnknownError", 22 | "APNSTimeoutError", 23 | "APNSUnsendableError", 24 | "GCMError", 25 | "GCMAuthError", 26 | "GCMServerError", 27 | "GCMMissingRegistrationError", 28 | "GCMInvalidRegistrationError", 29 | "GCMUnregisteredDeviceError", 30 | "GCMInvalidPackageNameError", 31 | "GCMMismatchedSenderError", 32 | "GCMMessageTooBigError", 33 | "GCMInvalidDataKeyError", 34 | "GCMInvalidTimeToLiveError", 35 | "GCMTimeoutError", 36 | "GCMInternalServerError", 37 | "GCMDeviceMessageRateExceededError", 38 | "NotificationError", 39 | "ServerError", 40 | ) 41 | 42 | 43 | class NotificationError(Exception): 44 | """Base exception for all notification errors.""" 45 | 46 | code = None 47 | description = None 48 | fatal = False 49 | 50 | 51 | class ServerError(NotificationError): 52 | """Base exception for server errors.""" 53 | 54 | def __init__(self, identifier): 55 | super(ServerError, self).__init__(self.code, self.description, identifier) 56 | self.identifier = identifier 57 | 58 | def __str__(self): # pragma: no cover 59 | return "{0} (code={1}): {2} for identifier {3}".format( 60 | self.__class__.__name__, self.code, self.description, self.identifier 61 | ) 62 | 63 | def __repr__(self): # pragma: no cover 64 | return str(self) 65 | 66 | 67 | class APNSError(NotificationError): 68 | """Base exception for APNS errors.""" 69 | 70 | pass 71 | 72 | 73 | class APNSAuthError(APNSError): 74 | """Exception with APNS certificate.""" 75 | 76 | pass 77 | 78 | 79 | class APNSServerError(ServerError): 80 | """Base exception for APNS Server errors.""" 81 | 82 | pass 83 | 84 | 85 | class APNSProcessingError(APNSServerError): 86 | """Exception for APNS processing error.""" 87 | 88 | code = 1 89 | description = "Processing error" 90 | 91 | 92 | class APNSMissingTokenError(APNSServerError): 93 | """Exception for APNS missing token error.""" 94 | 95 | code = 2 96 | description = "Missing token" 97 | 98 | 99 | class APNSMissingTopicError(APNSServerError): 100 | """Exception for APNS missing topic error.""" 101 | 102 | code = 3 103 | description = "Missing topic" 104 | fatal = True 105 | 106 | 107 | class APNSMissingPayloadError(APNSServerError): 108 | """Exception for APNS payload error.""" 109 | 110 | code = 4 111 | description = "Missing payload" 112 | fatal = True 113 | 114 | 115 | class APNSInvalidTokenSizeError(APNSServerError): 116 | """Exception for APNS invalid token size error.""" 117 | 118 | code = 5 119 | description = "Invalid token size" 120 | 121 | 122 | class APNSInvalidTopicSizeError(APNSServerError): 123 | """Exception for APNS invalid topic size error.""" 124 | 125 | code = 6 126 | description = "Invalid topic size" 127 | fatal = True 128 | 129 | 130 | class APNSInvalidPayloadSizeError(APNSServerError): 131 | """Exception for APNS invalid payload size error.""" 132 | 133 | code = 7 134 | description = "Invalid payload size" 135 | fatal = True 136 | 137 | 138 | class APNSInvalidTokenError(APNSServerError): 139 | """Exception for APNS invalid token error.""" 140 | 141 | code = 8 142 | description = "Invalid token" 143 | 144 | 145 | class APNSShutdownError(APNSServerError): 146 | """Exception for APNS shutdown error.""" 147 | 148 | code = 10 149 | description = "Shutdown" 150 | 151 | 152 | class APNSProtocolError(APNSServerError): 153 | """Exception for APNS protocol error.""" 154 | 155 | code = 128 156 | description = "Protocol" 157 | 158 | 159 | class APNSUnknownError(APNSServerError): 160 | """Exception for APNS unknown error.""" 161 | 162 | code = 255 163 | description = "Unknown" 164 | 165 | 166 | class APNSTimeoutError(APNSServerError): 167 | """Exception for APNS connection timeout error.""" 168 | 169 | description = "Timeout" 170 | fatal = True 171 | 172 | 173 | class APNSUnsendableError(APNSServerError): 174 | """Exception for when notification can't be send due to previous error for another 175 | notification.""" 176 | 177 | description = "Unable to send due to previous error" 178 | 179 | 180 | class GCMError(NotificationError): 181 | """Base exception for GCM errors.""" 182 | 183 | pass 184 | 185 | 186 | class GCMAuthError(GCMError): 187 | """Exception for error with GCM API key.""" 188 | 189 | pass 190 | 191 | 192 | class GCMServerError(ServerError): 193 | """Base exception for GCM Server errors.""" 194 | 195 | pass 196 | 197 | 198 | class GCMMissingRegistrationError(GCMServerError): 199 | """Exception for missing registration ID.""" 200 | 201 | code = "MissingRegistration" 202 | description = "Missing registration ID" 203 | 204 | 205 | class GCMInvalidRegistrationError(GCMServerError): 206 | """Exception for invalid registration ID.""" 207 | 208 | code = "InvalidRegistration" 209 | description = "Invalid registration ID" 210 | 211 | 212 | class GCMUnregisteredDeviceError(GCMServerError): 213 | """Exception for unregistered device.""" 214 | 215 | code = "NotRegistered" 216 | description = "Device not registered" 217 | 218 | 219 | class GCMInvalidPackageNameError(GCMServerError): 220 | """Exception for invalid package name.""" 221 | 222 | code = "InvalidPackageName" 223 | description = "Invalid package name" 224 | 225 | 226 | class GCMMismatchedSenderError(GCMServerError): 227 | """Exception for mismatched sender.""" 228 | 229 | code = "MismatchSenderId" 230 | description = "Mismatched sender ID" 231 | 232 | 233 | class GCMMessageTooBigError(GCMServerError): 234 | """Exception for message too big.""" 235 | 236 | code = "MessageTooBig" 237 | description = "Message too big" 238 | 239 | 240 | class GCMInvalidDataKeyError(GCMServerError): 241 | """Exception for invalid data key.""" 242 | 243 | code = "InvalidDataKey" 244 | description = "Invalid data key" 245 | 246 | 247 | class GCMInvalidTimeToLiveError(GCMServerError): 248 | """Exception for invalid time to live.""" 249 | 250 | code = "InvalidTtl" 251 | description = "Invalid time to live" 252 | 253 | 254 | class GCMTimeoutError(GCMServerError): 255 | """Exception for server timeout.""" 256 | 257 | code = "Unavailable" 258 | description = "Timeout" 259 | 260 | 261 | class GCMInternalServerError(GCMServerError): 262 | """Exception for internal server error.""" 263 | 264 | code = "InternalServerError" 265 | description = "Internal server error" 266 | 267 | 268 | class GCMDeviceMessageRateExceededError(GCMServerError): 269 | """Exception for device message rate exceeded.""" 270 | 271 | code = "DeviceMessageRateExceeded" 272 | description = "Device message rate exceeded" 273 | 274 | 275 | class Raiser(object): 276 | """Helper class for raising an exception based on error class name prefix and 277 | exception code.""" 278 | 279 | prefix = None 280 | fallback_exception = None 281 | 282 | def __init__(self, mapping): 283 | self.mapping = mapping 284 | 285 | def __call__(self, code, identifier): 286 | if code not in self.mapping: # pragma: no cover 287 | # pylint: disable=not-callable 288 | raise self.fallback_exception(identifier) 289 | 290 | raise self.mapping[code](identifier) 291 | 292 | 293 | class APNSServerRasier(Raiser): 294 | """Exception raiser class for APNS errors.""" 295 | 296 | prefix = "APNS" 297 | fallback_exception = APNSUnknownError 298 | 299 | 300 | class GCMServerRaiser(Raiser): 301 | """Exception raiser classs for GCM server errors.""" 302 | 303 | prefix = "GCM" 304 | fallback_exception = GCMServerError 305 | 306 | 307 | def map_errors(prefix): 308 | mapping = {} 309 | for name, obj in iteritems(globals()): 310 | if name.startswith(prefix) and getattr(obj, "code", None) is not None: 311 | mapping[obj.code] = obj 312 | return mapping 313 | 314 | 315 | apns_server_errors = map_errors("APNS") 316 | gcm_server_errors = map_errors("GCM") 317 | 318 | 319 | #: Helper method to raise GCM server errors. 320 | raise_gcm_server_error = GCMServerRaiser(gcm_server_errors) 321 | 322 | #: Helper method to raise APNS server errors. 323 | raise_apns_server_error = APNSServerRasier(apns_server_errors) 324 | -------------------------------------------------------------------------------- /tests/test_gcm.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import httmock 4 | import mock 5 | import pytest 6 | 7 | from pushjack import GCMClient, GCMError, exceptions 8 | from pushjack.gcm import GCMConnection 9 | from pushjack.utils import json_dumps 10 | 11 | from .fixtures import ( 12 | gcm_client, 13 | gcm_server_response, 14 | gcm_server_response_factory, 15 | parametrize, 16 | ) 17 | 18 | 19 | @parametrize( 20 | "tokens,data,extra,message", 21 | [ 22 | ( 23 | "abc", 24 | "Hello world", 25 | {}, 26 | {"to": "abc", "data": {"message": "Hello world"}, "priority": "high"}, 27 | ), 28 | ( 29 | "abc", 30 | "Hello world", 31 | {"low_priority": True}, 32 | {"to": "abc", "data": {"message": "Hello world"}}, 33 | ), 34 | ( 35 | "abc", 36 | {"message": "Hello world"}, 37 | { 38 | "delay_while_idle": True, 39 | "time_to_live": 3600, 40 | "collapse_key": "key", 41 | "restricted_package_name": "name", 42 | "dry_run": True, 43 | }, 44 | { 45 | "to": "abc", 46 | "data": {"message": "Hello world"}, 47 | "delay_while_idle": True, 48 | "time_to_live": 3600, 49 | "collapse_key": "key", 50 | "priority": "high", 51 | "restricted_package_name": "name", 52 | "dry_run": True, 53 | }, 54 | ), 55 | ( 56 | "abc", 57 | { 58 | "message": "Hello world", 59 | "custom": { 60 | "key0": ["value0_0"], 61 | "key1": "value1", 62 | "key2": {"key2_": "value2_0"}, 63 | }, 64 | }, 65 | {}, 66 | { 67 | "to": "abc", 68 | "data": { 69 | "message": "Hello world", 70 | "custom": { 71 | "key0": ["value0_0"], 72 | "key1": "value1", 73 | "key2": {"key2_": "value2_0"}, 74 | }, 75 | }, 76 | "priority": "high", 77 | }, 78 | ), 79 | ( 80 | "abc", 81 | "Hello world", 82 | {"notification": {"title": "Title", "body": "Body", "icon": "Icon"}}, 83 | { 84 | "to": "abc", 85 | "data": {"message": "Hello world"}, 86 | "priority": "high", 87 | "notification": {"title": "Title", "body": "Body", "icon": "Icon"}, 88 | }, 89 | ), 90 | ( 91 | "abc", 92 | { 93 | "message": "Hello world", 94 | "notification": {"title": "Title", "body": "Body", "icon": "Icon"}, 95 | }, 96 | {}, 97 | { 98 | "to": "abc", 99 | "data": {"message": "Hello world"}, 100 | "priority": "high", 101 | "notification": {"title": "Title", "body": "Body", "icon": "Icon"}, 102 | }, 103 | ), 104 | ( 105 | ["abc", "def", "ghi"], 106 | "Hello world", 107 | {}, 108 | { 109 | "registration_ids": ["abc", "def", "ghi"], 110 | "data": {"message": "Hello world"}, 111 | "priority": "high", 112 | }, 113 | ), 114 | ( 115 | ["abc", "def", "ghi"], 116 | {"message": "Hello world"}, 117 | { 118 | "delay_while_idle": True, 119 | "time_to_live": 3600, 120 | "collapse_key": "key", 121 | "restricted_package_name": "name", 122 | "dry_run": True, 123 | }, 124 | { 125 | "registration_ids": ["abc", "def", "ghi"], 126 | "data": {"message": "Hello world"}, 127 | "delay_while_idle": True, 128 | "time_to_live": 3600, 129 | "collapse_key": "key", 130 | "priority": "high", 131 | "restricted_package_name": "name", 132 | "dry_run": True, 133 | }, 134 | ), 135 | ( 136 | ["abc", "def", "ghi"], 137 | { 138 | "message": "Hello world", 139 | "custom": { 140 | "key0": ["value0_0"], 141 | "key1": "value1", 142 | "key2": {"key2_": "value2_0"}, 143 | }, 144 | }, 145 | {}, 146 | { 147 | "registration_ids": ["abc", "def", "ghi"], 148 | "data": { 149 | "message": "Hello world", 150 | "custom": { 151 | "key0": ["value0_0"], 152 | "key1": "value1", 153 | "key2": {"key2_": "value2_0"}, 154 | }, 155 | }, 156 | "priority": "high", 157 | }, 158 | ), 159 | ], 160 | ) 161 | def test_gcm_send(gcm_client, tokens, data, extra, message): 162 | with httmock.HTTMock(gcm_server_response): 163 | res = gcm_client.send(tokens, data, **extra) 164 | 165 | if not isinstance(tokens, list): 166 | tokens = [tokens] 167 | 168 | assert len(res.responses) == 1 169 | assert res.registration_ids == tokens 170 | assert res.data == [ 171 | { 172 | "multicast_id": 1, 173 | "success": len(tokens), 174 | "failure": 0, 175 | "canonical_ids": 0, 176 | "results": [{"message_id": token} for token in tokens], 177 | } 178 | ] 179 | assert res.successes == tokens 180 | assert res.messages == [message] 181 | assert res.errors == [] 182 | assert res.canonical_ids == [] 183 | 184 | 185 | @parametrize( 186 | "tokens,status_code,results,expected", 187 | [ 188 | ( 189 | [1, 2, 3, 4, 5], 190 | 200, 191 | [ 192 | {"error": "MissingRegistration"}, 193 | {"message_id": 2}, 194 | {"error": "DeviceMessageRateExceeded"}, 195 | {"message_id": 4, "registration_id": 44}, 196 | {"message_id": 5, "registration_id": 55}, 197 | ], 198 | { 199 | "registration_ids": [1, 2, 3, 4, 5], 200 | "errors": [ 201 | (exceptions.GCMMissingRegistrationError, 1), 202 | (exceptions.GCMDeviceMessageRateExceededError, 3), 203 | ], 204 | "failures": [1, 3], 205 | "successes": [2, 4, 5], 206 | "canonical_ids": [(4, 44), (5, 55)], 207 | }, 208 | ), 209 | ( 210 | [1, 2, 3, 4, 5], 211 | 500, 212 | [], 213 | { 214 | "registration_ids": [1, 2, 3, 4, 5], 215 | "errors": [ 216 | (exceptions.GCMInternalServerError, 1), 217 | (exceptions.GCMInternalServerError, 2), 218 | (exceptions.GCMInternalServerError, 3), 219 | (exceptions.GCMInternalServerError, 4), 220 | (exceptions.GCMInternalServerError, 5), 221 | ], 222 | "failures": [1, 2, 3, 4, 5], 223 | "successes": [], 224 | "canonical_ids": [], 225 | }, 226 | ), 227 | ], 228 | ) 229 | def test_gcm_response(gcm_client, tokens, status_code, results, expected): 230 | content = {"results": results} 231 | response = gcm_server_response_factory(content, status_code) 232 | 233 | with httmock.HTTMock(response): 234 | res = gcm_client.send(tokens, {}) 235 | assert res.registration_ids == expected["registration_ids"] 236 | assert res.failures == expected["failures"] 237 | assert res.successes == expected["successes"] 238 | assert res.canonical_ids == expected["canonical_ids"] 239 | 240 | assert len(res.errors) == len(expected["errors"]) 241 | 242 | for i, (ex_class, registration_id) in enumerate(expected["errors"]): 243 | error = res.errors[i] 244 | assert isinstance(error, ex_class) 245 | assert error.identifier == registration_id 246 | 247 | 248 | def test_gcm_invalid_api_key(gcm_client): 249 | gcm_client.api_key = None 250 | with pytest.raises(GCMError): 251 | gcm_client.send("abc", {}) 252 | 253 | 254 | @parametrize( 255 | "tokens,data,extra,auth,expected", 256 | [ 257 | ( 258 | "abc", 259 | {}, 260 | {}, 261 | mock.call().headers.update( 262 | {"Authorization": "key=1234", "Content-Type": "application/json"} 263 | ), 264 | mock.call().post( 265 | "https://fcm.googleapis.com/fcm/send", 266 | b'{"data":{},"priority":"high","to":"abc"}', 267 | ), 268 | ), 269 | ( 270 | ["abc"], 271 | {}, 272 | {}, 273 | mock.call().headers.update( 274 | {"Authorization": "key=1234", "Content-Type": "application/json"} 275 | ), 276 | mock.call().post( 277 | "https://fcm.googleapis.com/fcm/send", 278 | b'{"data":{},"priority":"high","to":"abc"}', 279 | ), 280 | ), 281 | ], 282 | ) 283 | def test_gcm_connection_call(gcm_client, tokens, data, extra, auth, expected): 284 | with mock.patch("requests.Session") as Session: 285 | gcm_client.send(tokens, data, **extra) 286 | assert auth in Session.mock_calls 287 | assert expected in Session.mock_calls 288 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | .. _changelog: 2 | 3 | Changelog 4 | ========= 5 | 6 | 7 | v1.6.0 (2019-03-15) 8 | ------------------- 9 | 10 | - apns: Remove TLS version flag and default to ``ssl.PROTOCOL_TLS`` when creating APNS socket connection. Thanks `Tanner Stirrat`_! 11 | 12 | 13 | v1.5.0 (2018-07-29) 14 | ------------------- 15 | 16 | - gcm: Use FCM URL instead of deprecated GCM URL. Thanks `Lukas Anzinger`_! 17 | 18 | 19 | v1.4.1 (2018-06-18) 20 | ------------------- 21 | 22 | - apns: Remove restriction on token length due to incorrect assumption about tokens always being 64 characters long. 23 | 24 | 25 | v1.4.0 (2017-11-09) 26 | ------------------- 27 | 28 | - apns: Add exceptions ``APNSProtocolError`` and ``APNSTimeoutError``. Thanks `Jakub Kleň`_! 29 | - apns: Add retry mechanism to ``APNSClient.send``. Thanks `Jakub Kleň`_! 30 | 31 | - Add ``default_retries`` argument to ``APNSClient`` initialization. Defaults to ``5``. 32 | - Add ``retries`` argument to ``APNSClient.send``. By default will use ``APNSClient.default_retries`` unless explicitly passed in. 33 | - If unable to send after ``retries``, an ``APNSTimeoutError`` will be raised. 34 | 35 | - apns: Fix bug in bulk ``APNSClient.send`` that resulted in an off-by-one error for message identifier in returned errors. Thanks `Jakub Kleň`_! 36 | - apns: Add max payload truncation option to ``APNSClient.send``. Thanks `Jakub Kleň`_! 37 | 38 | - Add ``default_max_payload_length`` argument to ``APNSClient`` initialization. Defaults to ``0`` which disabled max payload length check. 39 | - Add ``max_payload_length`` argument to ``APNSClient.send``. By default will use ``APNSClient.default_max_payload_length`` unless explicitly passed in. 40 | - When ``max_payload_length`` set, messages will be truncated to fit within the length restriction by trimming the "message" text and appending it with "...". 41 | 42 | 43 | v1.3.0 (2017-03-11) 44 | ------------------- 45 | 46 | - apns: Optimize reading from APNS Feedback so that the number of bytes read are based on header and token lengths. 47 | - apns: Explicitly close connection to APNS Feedback service after reading data. 48 | - apns: Add support for ``mutable-content`` field (Apple Notification Service Extension) via ``mutable_content`` argument to ``APNSClient.send()``. Thanks `Ahmed Khedr`_! 49 | - apns: Add support for ``thread-id`` field (group identifier in Notification Center) via ``thread_id`` argument to ``APNSClient.send()``. Thanks `Ahmed Khedr`_! 50 | 51 | 52 | v1.2.1 (2015-12-14) 53 | ------------------- 54 | 55 | - apns: Fix implementation of empty APNS notifications and allow notifications with ``{"aps": {}}`` to be sent. Thanks `Julius Seporaitis`_! 56 | 57 | 58 | v1.2.0 (2015-12-04) 59 | ------------------- 60 | 61 | - gcm: Add support for ``priority`` field to GCM messages via ``low_priority`` keyword argument. Default behavior is for all messages to be ``"high"`` priority. This is the opposite of GCM messages but mirrors the behavior in the APNS module where the default priority is ``"high"``. 62 | 63 | 64 | v1.1.0 (2015-10-22) 65 | ------------------- 66 | 67 | - gcm: Add support for ``notification`` field to GCM messages. 68 | - gcm: Replace ``registration_ids`` field with ``to`` field when sending to a single recipient since ``registration_ids`` field has been deprecated for single recipients. 69 | 70 | 71 | v1.0.1 (2015-05-07) 72 | ------------------- 73 | 74 | - gcm: Fix incorrect authorization header in GCM client. Thanks `Brad Montgomery`_! 75 | 76 | 77 | v1.0.0 (2015-04-28) 78 | ------------------- 79 | 80 | - apns: Add ``APNSSandboxClient`` for sending notifications to APNS sandbox server. 81 | - apns: Add ``message`` attribute to ``APNSResponse``. 82 | - pushjack: Add internal logging. 83 | - apns: Fix APNS error checking to properly handle reading when no data returned. 84 | - apns: Make APNS sending stop during iteration if a fatal error is received from APNS server (e.g. invalid topic, invalid payload size, etc). 85 | - apns/gcm: Make APNS and GCM clients maintain an active connection to server. 86 | - apns: Make APNS always return ``APNSResponse`` object instead of only raising ``APNSSendError`` when errors encountered. (**breaking change**) 87 | - apns/gcm: Remove APNS/GCM module send functions and only support client interfaces. (**breaking change**) 88 | - apns: Remove ``config`` argument from ``APNSClient`` and use individual method parameters as mapped below instead: (**breaking change**) 89 | 90 | - ``APNS_ERROR_TIMEOUT`` => ``default_error_timeout`` 91 | - ``APNS_DEFAULT_EXPIRATION_OFFSET`` => ``default_expiration_offset`` 92 | - ``APNS_DEFAULT_BATCH_SIZE`` => ``default_batch_size`` 93 | 94 | - gcm: Remove ``config`` argument from ``GCMClient`` and use individual method parameters as mapped below instead: (**breaking change**) 95 | 96 | - ``GCM_API_KEY`` => ``api_key`` 97 | 98 | - pushjack: Remove ``pushjack.clients`` module. (**breaking change**) 99 | - pushjack: Remove ``pushjack.config`` module. (**breaking change**) 100 | - gcm: Rename ``GCMResponse.payloads`` to ``GCMResponse.messages``. (**breaking change**) 101 | 102 | 103 | v0.5.0 (2015-04-22) 104 | ------------------- 105 | 106 | - apns: Add new APNS configuration value ``APNS_DEFAULT_BATCH_SIZE`` and set to ``100``. 107 | - apns: Add ``batch_size`` parameter to APNS ``send`` that can be used to override ``APNS_DEFAULT_BATCH_SIZE``. 108 | - apns: Make APNS ``send`` batch multiple notifications into a single payload. Previously, individual socket writes were performed for each token. Now, socket writes are batched based on either the ``APNS_DEFAULT_BATCH_SIZE`` configuration value or the ``batch_size`` function argument value. 109 | - apns: Make APNS ``send`` resume sending from after the failed token when an error response is received. 110 | - apns: Make APNS ``send`` raise an ``APNSSendError`` when one or more error responses received. ``APNSSendError`` contains an aggregation of errors, all tokens attempted, failed tokens, and successful tokens. (**breaking change**) 111 | - apns: Replace ``priority`` argument to APNS ``send`` with ``low_priority=False``. (**breaking change**) 112 | 113 | 114 | v0.4.0 (2015-04-15) 115 | ------------------- 116 | 117 | - apns: Improve error handling in APNS so that errors aren't missed. 118 | - apns: Improve handling of APNS socket connection during bulk sending so that connection is re-established when lost. 119 | - apns: Make APNS socket read/writes non-blocking. 120 | - apns: Make APNS socket frame packing easier to grok. 121 | - apns/gmc: Remove APNS and GCM ``send_bulk`` function. Modify ``send`` to support bulk notifications. (**breaking change**) 122 | - apns: Remove ``APNS_MAX_NOTIFICATION_SIZE`` as config option. 123 | - gcm: Remove ``GCM_MAX_RECIPIENTS`` as config option. 124 | - gcm: Remove ``request`` argument from GCM send function. (**breaking change**) 125 | - apns: Remove ``sock`` argument from APNS send function. (**breaking change**) 126 | - gcm: Return namedtuple for GCM canonical ids. 127 | - apns: Return namedtuple class for APNS expired tokens. 128 | 129 | 130 | v0.3.0 (2015-04-01) 131 | ------------------- 132 | 133 | - gcm: Add ``restricted_package_name`` and ``dry_run`` fields to GCM sending. 134 | - gcm: Add exceptions for all GCM server error responses. 135 | - apns: Make ``apns.get_expired_tokens`` and ``APNSClient.get_expired_tokens`` accept an optional ``sock`` argument to provide a custom socket connection. 136 | - apns: Raise ``APNSAuthError`` instead of ``APNSError`` if certificate file cannot be read. 137 | - apns: Raise ``APNSInvalidPayloadSizeError`` instead of ``APNSDataOverflow``. (**breaking change**) 138 | - apns: Raise ``APNSInvalidTokenError`` instead of ``APNSError``. 139 | - gcm: Raise ``GCMAuthError`` if ``GCM_API_KEY`` is not set. 140 | - pushjack: Rename several function parameters: (**breaking change**) 141 | 142 | - gcm: ``alert`` to ``data`` 143 | - gcm: ``token``/``tokens`` to ``registration_id``/``registration_ids`` 144 | - gcm: ``Dispatcher``/``dispatcher`` to ``GCMRequest``/``request`` 145 | - Clients: ``registration_id`` to ``device_id`` 146 | 147 | - gcm: Return ``GCMResponse`` object for ``GCMClient.send/send_bulk``. (**breaking change**) 148 | - gcm: Return ``requests.Response`` object(s) for ``gcm.send/send_bulk``. (**breaking change**) 149 | 150 | 151 | v0.2.2 (2015-03-30) 152 | ------------------- 153 | 154 | - apns: Fix payload key assigments for ``title-loc``, ``title-loc-args``, and ``launch-image``. Previously, ``'_'`` was used in place of ``'-'``. 155 | 156 | 157 | v0.2.1 (2015-03-28) 158 | ------------------- 159 | 160 | - apns: Fix incorrect variable reference in ``apns.receive_feedback``. 161 | 162 | 163 | v0.2.0 (2015-03-28) 164 | ------------------- 165 | 166 | - pushjack: Fix handling of ``config`` in clients when ``config`` is a class object and subclass of ``Config``. 167 | - apns: Make ``apns.send/send_bulk`` accept additional ``alert`` fields: ``title``, ``title-loc``, ``title-loc-args``, and ``launch-image``. 168 | - gcm: Make ``gcm.send/send_bulk`` raise a ``GCMError`` exception if ``GCM_API_KEY`` is not set. 169 | - gcm: Make gcm payload creation cast ``data`` to dict if isn't not passed in as one. Original value of ``data`` is then set to ``{'message': data}``. (**breaking change**) 170 | - gcm: Make gcm payload creation not set defaults for optional keyword arguments. (**breaking change**) 171 | 172 | 173 | v0.1.0 (2015-03-26) 174 | ------------------- 175 | 176 | - pushjack: Rename ``pushjack.settings`` module to ``pushjack.config``. (**breaking change**) 177 | - apns/gcm: Allow config settings overrides to be passed into ``create_gcm_config``, ``create_apns_config``, and ``create_apns_sandbox_config``. 178 | - pushjack: Override ``Config``'s ``update()`` method with custom method that functions similarly to ``from_object()`` except that it accepts a ``dict`` instead. 179 | 180 | 181 | v0.0.1 (2015-03-25) 182 | ------------------- 183 | 184 | - First release. 185 | 186 | 187 | .. _Brad Montgomery: https://github.com/bradmontgomery 188 | .. _Julius Seporaitis: https://github.com/seporaitis 189 | .. _Ahmed Khedr: https://github.com/aakhedr 190 | .. _Jakub Kleň: https://github.com/kukosk 191 | .. _Lukas Anzinger: https://github.com/Lukas0907 192 | .. _Tanner Stirrat: https://github.com/tstirrat15 193 | -------------------------------------------------------------------------------- /tests/test_apns.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import datetime 4 | import socket 5 | 6 | import mock 7 | import pytest 8 | 9 | from pushjack import apns, exceptions 10 | from pushjack.utils import json_dumps 11 | 12 | from .fixtures import ( 13 | apns_client, 14 | apns_feedback_socket_factory, 15 | apns_create_error_socket, 16 | apns_socket, 17 | apns_tokens, 18 | parametrize, 19 | TCP_HOST, 20 | TCP_PORT, 21 | ) 22 | 23 | 24 | @parametrize( 25 | "tokens,alert,extra,expected", 26 | [ 27 | ( 28 | apns_tokens(1), 29 | "Hello world", 30 | { 31 | "badge": 1, 32 | "sound": "chime", 33 | "category": "Pushjack", 34 | "content_available": True, 35 | "extra": {"custom_data": 12345}, 36 | "expiration": 3, 37 | "low_priority": True, 38 | }, 39 | ( 40 | json_dumps( 41 | { 42 | "aps": { 43 | "alert": "Hello world", 44 | "badge": 1, 45 | "sound": "chime", 46 | "category": "Pushjack", 47 | "content-available": 1, 48 | }, 49 | "custom_data": 12345, 50 | } 51 | ), 52 | 0, 53 | 3, 54 | 5, 55 | ), 56 | ), 57 | ( 58 | apns_tokens(1), 59 | None, 60 | { 61 | "loc_key": "lk", 62 | "action_loc_key": "alk", 63 | "loc_args": "la", 64 | "expiration": 3, 65 | }, 66 | ( 67 | json_dumps( 68 | { 69 | "aps": { 70 | "alert": { 71 | "action-loc-key": "alk", 72 | "loc-args": "la", 73 | "loc-key": "lk", 74 | } 75 | } 76 | } 77 | ), 78 | 0, 79 | 3, 80 | 10, 81 | ), 82 | ), 83 | ( 84 | apns_tokens(5), 85 | "Hello world", 86 | { 87 | "loc_key": "lk", 88 | "action_loc_key": "alk", 89 | "loc_args": "la", 90 | "expiration": 3, 91 | }, 92 | ( 93 | json_dumps( 94 | { 95 | "aps": { 96 | "alert": { 97 | "body": "Hello world", 98 | "action-loc-key": "alk", 99 | "loc-args": "la", 100 | "loc-key": "lk", 101 | } 102 | } 103 | } 104 | ), 105 | 0, 106 | 3, 107 | 10, 108 | ), 109 | ), 110 | ( 111 | apns_tokens(5), 112 | "Hello world", 113 | { 114 | "title": "title", 115 | "title_loc_key": "tlk", 116 | "title_loc_args": "tla", 117 | "launch_image": "image", 118 | "expiration": 3, 119 | }, 120 | ( 121 | json_dumps( 122 | { 123 | "aps": { 124 | "alert": { 125 | "body": "Hello world", 126 | "title": "title", 127 | "title-loc-key": "tlk", 128 | "title-loc-args": "tla", 129 | "launch-image": "image", 130 | } 131 | } 132 | } 133 | ), 134 | 0, 135 | 3, 136 | 10, 137 | ), 138 | ), 139 | ], 140 | ) 141 | def test_apns_send(apns_client, apns_socket, tokens, alert, extra, expected): 142 | with mock.patch("pushjack.apns.APNSMessageStream.pack") as pack_frame: 143 | apns_client.send(tokens, alert, **extra) 144 | 145 | if not isinstance(tokens, list): 146 | tokens = [tokens] 147 | 148 | for identifier, token in enumerate(tokens): 149 | call = mock.call(token, identifier, expected[0], *expected[2:]) 150 | assert call in pack_frame.mock_calls 151 | 152 | apns_client.close() 153 | 154 | 155 | @parametrize( 156 | "tokens,identifiers,exception", 157 | [ 158 | (apns_tokens(50), [1], exceptions.APNSProcessingError), 159 | (apns_tokens(50), [1, 5, 7], exceptions.APNSProcessingError), 160 | (apns_tokens(500), [1, 5, 99, 150, 210], exceptions.APNSProcessingError), 161 | ], 162 | ) 163 | def test_apns_resend(apns_client, apns_socket, tokens, identifiers, exception): 164 | tracker = {"errors": []} 165 | 166 | def sendall(data): 167 | for ident in identifiers: 168 | if ident not in tracker["errors"]: 169 | tracker["errors"].append(ident) 170 | raise exceptions.APNSProcessingError(ident) 171 | 172 | apns_socket.sendall = sendall 173 | 174 | res = apns_client.send(tokens, "foo") 175 | 176 | expected_failures = [token for i, token in enumerate(tokens) if i in identifiers] 177 | expected_successes = [ 178 | token for i, token in enumerate(tokens) if i not in identifiers 179 | ] 180 | 181 | assert isinstance(res, apns.APNSResponse) 182 | assert res.tokens == tokens 183 | assert isinstance(res.message, apns.APNSMessage) 184 | assert all([isinstance(error, exception) for error in res.errors]) 185 | assert res.failures == expected_failures 186 | assert res.successes == expected_successes 187 | assert set(res.failures) == set(res.token_errors.keys()) 188 | assert set(res.errors) == set(res.token_errors.values()) 189 | 190 | 191 | @parametrize("token", ["1" * 64, "1" * 108, "abcdef0123456789" * 4]) 192 | def test_valid_token(apns_client, apns_socket, token): 193 | apns_client.send(token, "") 194 | assert apns_socket.sendall.called 195 | 196 | 197 | @parametrize("token", ["1", "x" * 64, "x" * 108]) 198 | def test_invalid_token(apns_client, apns_socket, token): 199 | with pytest.raises(exceptions.APNSInvalidTokenError) as exc_info: 200 | apns_client.send(token, "") 201 | 202 | assert "Invalid token format" in str(exc_info.value) 203 | 204 | 205 | def test_apns_use_extra(apns_client, apns_socket): 206 | test_token = apns_tokens(1) 207 | 208 | with mock.patch("pushjack.apns.APNSMessageStream.pack") as pack_frame: 209 | apns_client.send(test_token, "sample", extra={"foo": "bar"}, expiration=30) 210 | 211 | expected_payload = b'{"aps":{"alert":"sample"},"foo":"bar"}' 212 | pack_frame.assert_called_once_with(test_token, 0, expected_payload, 30, 10) 213 | 214 | 215 | def test_apns_socket_write(apns_client, apns_socket): 216 | apns_client.send("1" * 64, "sample", extra={"foo": "bar"}, expiration=30) 217 | 218 | expected = mock.call.sendall( 219 | b"\x02\x00\x00\x00^\x01\x00 \x11\x11\x11\x11\x11" 220 | b"\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11" 221 | b"\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11" 222 | b"\x11\x11\x11\x11\x11\x02\x00&" 223 | b'{"aps":{"alert":"sample"},"foo":"bar"}' 224 | b"\x03\x00\x04\x00\x00\x00\x00\x04\x00\x04\x00\x00" 225 | b"\x00\x1e\x05\x00\x01\n" 226 | ) 227 | 228 | assert expected in apns_socket.mock_calls 229 | 230 | 231 | @parametrize("exception,alert", [(exceptions.APNSInvalidPayloadSizeError, "_" * 2049)]) 232 | def test_apns_invalid_payload_size(apns_client, exception, alert): 233 | with mock.patch("pushjack.apns.APNSMessageStream.pack") as pack_frame: 234 | with pytest.raises(exception): 235 | apns_client.send(apns_tokens(1), alert) 236 | 237 | assert not pack_frame.called 238 | 239 | 240 | @parametrize("alert", [("_" * 2049)]) 241 | def test_apns_max_payload_length(apns_client, apns_socket, alert): 242 | with mock.patch("pushjack.apns.APNSMessageStream.pack") as pack_frame: 243 | apns_client.send(apns_tokens(1), alert, max_payload_length=2048) 244 | assert pack_frame.called 245 | apns_client.close() 246 | 247 | 248 | @parametrize( 249 | "code,exception", 250 | [ 251 | (1, exceptions.APNSProcessingError), 252 | (2, exceptions.APNSMissingTokenError), 253 | (3, exceptions.APNSMissingTopicError), 254 | (4, exceptions.APNSMissingPayloadError), 255 | (5, exceptions.APNSInvalidTokenSizeError), 256 | (6, exceptions.APNSInvalidTopicSizeError), 257 | (7, exceptions.APNSInvalidPayloadSizeError), 258 | (8, exceptions.APNSInvalidTokenError), 259 | (10, exceptions.APNSShutdownError), 260 | (255, exceptions.APNSUnknownError), 261 | ], 262 | ) 263 | def test_apns_error_handling(apns_client, code, exception): 264 | with apns_create_error_socket(code): 265 | res = apns_client.send(apns_tokens(1), "foo") 266 | assert isinstance(res.errors[0], exception) 267 | 268 | 269 | def test_apns_send_timeout_error(apns_client): 270 | def throw(*args, **kargs): 271 | raise socket.error("socket error") 272 | 273 | with mock.patch.object(apns_client.conn, "write", side_effect=throw): 274 | res = apns_client.send(apns_tokens(1), "foo") 275 | 276 | assert isinstance(res.errors[0], exceptions.APNSTimeoutError) 277 | 278 | 279 | @parametrize("tokens", [["1" * 64, "2" * 64, "3" * 64]]) 280 | def test_apns_get_expired_tokens(apns_client, tokens): 281 | with mock.patch("pushjack.apns.create_socket") as create_socket: 282 | create_socket.return_value = apns_feedback_socket_factory(tokens) 283 | expired_tokens = apns_client.get_expired_tokens() 284 | 285 | assert len(expired_tokens) == len(tokens) 286 | 287 | for i, token in enumerate(tokens): 288 | expired_token, timestamp = expired_tokens[i] 289 | 290 | assert expired_token == token 291 | assert ( 292 | datetime.datetime.utcfromtimestamp(timestamp) 293 | < datetime.datetime.utcnow() 294 | ) 295 | 296 | 297 | def test_apns_create_socket(tmpdir): 298 | certificate = tmpdir.join("certifiate.pem") 299 | certificate.write("content") 300 | 301 | with mock.patch("ssl.wrap_socket") as wrap_socket: 302 | wrap_socket.do_handshake = lambda: True 303 | 304 | apns.create_socket(TCP_HOST, TCP_PORT, str(certificate)) 305 | 306 | assert wrap_socket.called 307 | 308 | expected = {"certfile": str(certificate), "do_handshake_on_connect": False} 309 | 310 | assert wrap_socket.mock_calls[0][2] == expected 311 | 312 | 313 | def test_apns_create_socket_missing_certificate(): 314 | with pytest.raises(exceptions.APNSAuthError): 315 | apns.create_socket(TCP_HOST, TCP_PORT, "missing.pem") 316 | 317 | 318 | def test_apns_create_socket_no_certificate(): 319 | with pytest.raises(exceptions.APNSAuthError): 320 | apns.create_socket(TCP_HOST, TCP_PORT, None) 321 | 322 | 323 | def test_apns_create_socket_empty_certificate(tmpdir): 324 | certificate = tmpdir.join("certificate.pem") 325 | 326 | with pytest.raises(exceptions.APNSAuthError): 327 | apns.create_socket(TCP_HOST, TCP_PORT, str(certificate)) 328 | -------------------------------------------------------------------------------- /src/pushjack/gcm.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Client module for Google Cloud Messaging service. 4 | 5 | By default, sending notifications is optimized to deliver notifications to the 6 | maximum number of allowable recipients per HTTP request (currently 1,000 7 | recipients as specified in the GCM documentation). 8 | 9 | The return from a send operation will contain a response object that parses all 10 | GCM HTTP responses and groups them by errors, successful registration ids, 11 | failed registration ids, canonical ids, and the raw responses from each 12 | request. 13 | 14 | For more details regarding Google's GCM documentation, consult the following: 15 | 16 | - `GCM for Android `_ 17 | - `GCM Server Reference `_ 18 | """ 19 | 20 | from collections import namedtuple 21 | import logging 22 | 23 | import requests 24 | 25 | from .utils import chunk, compact_dict, json_loads, json_dumps 26 | from .exceptions import GCMError, GCMAuthError, gcm_server_errors 27 | from ._compat import iteritems 28 | 29 | 30 | __all__ = ( 31 | "GCMClient", 32 | "GCMResponse", 33 | "GCMCanonicalID", 34 | ) 35 | 36 | 37 | log = logging.getLogger(__name__) 38 | 39 | 40 | GCM_URL = "https://fcm.googleapis.com/fcm/send" 41 | 42 | # GCM only allows up to 1000 reg ids per bulk message. 43 | GCM_MAX_RECIPIENTS = 1000 44 | 45 | #: Indicates that the push message should be sent with low priority. Low 46 | #: priority optimizes the client app's battery consumption, and should be used 47 | #: unless immediate delivery is required. For messages with low priority, the 48 | #: app may receive the message with unspecified delay. 49 | GCM_LOW_PRIORITY = "normal" 50 | 51 | #: Indicates that the push message should be sent with a high priority. When a 52 | #: message is sent with high priority, it is sent immediately, and the app can 53 | #: wake a sleeping device and open a network connection to your server. 54 | GCM_HIGH_PRIORITY = "high" 55 | 56 | 57 | class GCMClient(object): 58 | """GCM client class.""" 59 | 60 | url = GCM_URL 61 | 62 | def __init__(self, api_key): 63 | self.api_key = api_key 64 | self._conn = None 65 | 66 | @property 67 | def conn(self): 68 | """Reference to lazy GCM connection.""" 69 | if not self._conn: 70 | self._conn = self.create_connection() 71 | return self._conn 72 | 73 | def create_connection(self): 74 | """Create and return new GCM connection.""" 75 | return GCMConnection(self.api_key, self.url) 76 | 77 | def send(self, ids, message, **options): 78 | """ 79 | Send push notification to single or multiple recipients. 80 | 81 | Args: 82 | ids (list): GCM device registration IDs. 83 | message (str|dict): Message string or dictionary. If ``message`` 84 | is a dict and contains the field ``notification``, then it will 85 | be used for the ``notification`` payload. 86 | 87 | Keyword Args: 88 | notification (dict, optional): Notification payload. Can include 89 | the fields ``body``, ``title``, and ``icon``. 90 | collapse_key (str, optional): Identifier for a group of messages 91 | that can be collapsed so that only the last message gets sent 92 | when delivery can be resumed. Defaults to ``None``. 93 | delay_while_idle (bool, optional): If ``True`` indicates that the 94 | message should not be sent until the device becomes active. 95 | time_to_live (int, optional): How long (in seconds) the message 96 | should be kept in GCM storage if the device is offline. The 97 | maximum time to live supported is 4 weeks. Defaults to ``None`` 98 | which uses the GCM default of 4 weeks. 99 | low_priority (boolean, optional): Whether to send notification with 100 | the low priority flag. Defaults to ``False``. 101 | restricted_package_name (str, optional): Package name of the 102 | application where the registration IDs must match in order to 103 | receive the message. Defaults to ``None``. 104 | dry_run (bool, optional): If ``True`` no message will be sent but 105 | request will be tested. 106 | 107 | Returns: 108 | :class:`GCMResponse`: Response from GCM server. 109 | 110 | Raises: 111 | GCMAuthError: If :attr:`api_key` not set. 112 | :class:`.GCMAuthError` 113 | 114 | .. versionadded:: 0.0.1 115 | 116 | .. versionchanged:: 0.4.0 117 | - Added support for bulk sending. 118 | - Removed `request` argument. 119 | 120 | .. versionchanged:: 1.2.0 121 | - Added ``low_priority`` argument. 122 | """ 123 | if not self.api_key: 124 | raise GCMAuthError("Missing GCM API key.") 125 | 126 | if not isinstance(ids, (list, tuple)): 127 | ids = [ids] 128 | 129 | message = GCMMessage(ids, message, **options) 130 | response = self.conn.send(GCMMessageStream(message)) 131 | 132 | return response 133 | 134 | 135 | class GCMConnection(object): 136 | """Wrapper around requests session bound to GCM config.""" 137 | 138 | def __init__(self, api_key, url=GCM_URL): 139 | self.api_key = api_key 140 | self.url = url 141 | 142 | self.session = requests.Session() 143 | self.session.headers.update( 144 | { 145 | "Authorization": "key={0}".format(self.api_key), 146 | "Content-Type": "application/json", 147 | } 148 | ) 149 | 150 | def post(self, message): 151 | """Send single POST request with message to GCM server.""" 152 | log.debug( 153 | "Sending GCM notification batch containing {0} bytes.".format(len(message)) 154 | ) 155 | return self.session.post(self.url, message) 156 | 157 | def send(self, stream): 158 | """Send messages to GCM server and return list of responses.""" 159 | log.debug("Preparing to send {0} notifications to GCM.".format(len(stream))) 160 | 161 | response = GCMResponse([self.post(message) for message in stream]) 162 | 163 | log.debug("Sent {0} notifications to GCM.".format(len(stream))) 164 | 165 | if response.failures: 166 | log.debug( 167 | "Encountered {0} errors while sending to GCM.".format( 168 | len(response.failures) 169 | ) 170 | ) 171 | 172 | return response 173 | 174 | 175 | class GCMMessage(object): 176 | """GCM message object that serializes to JSON.""" 177 | 178 | def __init__( 179 | self, 180 | registration_ids, 181 | message, 182 | notification=None, 183 | collapse_key=None, 184 | delay_while_idle=None, 185 | time_to_live=None, 186 | restricted_package_name=None, 187 | low_priority=None, 188 | dry_run=None, 189 | ): 190 | self.registration_ids = registration_ids 191 | self.message = message 192 | self.collapse_key = collapse_key 193 | self.delay_while_idle = delay_while_idle 194 | self.time_to_live = time_to_live 195 | self.restricted_package_name = restricted_package_name 196 | self.dry_run = dry_run 197 | self.notification = notification 198 | self.data = {} 199 | 200 | if low_priority: 201 | self.priority = None 202 | else: 203 | self.priority = GCM_HIGH_PRIORITY 204 | 205 | self._parse_message() 206 | 207 | def _parse_message(self): 208 | """Parse and filter :attr:`message` to set :attr:`data` and 209 | :attr:`notification`. 210 | """ 211 | if not isinstance(self.message, dict): 212 | self.data["message"] = self.message 213 | else: 214 | if "notification" in self.message: 215 | self.notification = self.message["notification"] 216 | 217 | self.message = dict( 218 | (key, value) 219 | for key, value in iteritems(self.message) 220 | if key not in ("notification",) 221 | ) 222 | 223 | self.data.update(self.message) 224 | 225 | def to_dict(self): 226 | """Return message as dictionary.""" 227 | return compact_dict( 228 | { 229 | "registration_ids": self.registration_ids, 230 | "notification": self.notification, 231 | "data": self.data, 232 | "collapse_key": self.collapse_key, 233 | "delay_while_idle": self.delay_while_idle, 234 | "time_to_live": self.time_to_live, 235 | "priority": self.priority, 236 | "restricted_package_name": self.restricted_package_name, 237 | "dry_run": True if self.dry_run else None, 238 | } 239 | ) 240 | 241 | def to_json(self): # pragma: no cover 242 | """Return message as JSON string.""" 243 | return json_dumps(self.to_dict()) 244 | 245 | 246 | class GCMMessageStream(object): 247 | """Iterable object that yields GCM messages in chunks.""" 248 | 249 | def __init__(self, message): 250 | self.message = message 251 | 252 | def __len__(self): 253 | """Return count of number of notifications.""" 254 | return len(self.message.registration_ids) 255 | 256 | def __iter__(self): 257 | """Iterate through and yield chunked messages.""" 258 | message = self.message.to_dict() 259 | del message["registration_ids"] 260 | 261 | for ids in chunk(self.message.registration_ids, GCM_MAX_RECIPIENTS): 262 | for id in ids: 263 | log.debug("Preparing notification for GCM id {0}".format(id)) 264 | 265 | if len(ids) > 1: 266 | to_field = "registration_ids" 267 | else: 268 | to_field = "to" 269 | ids = ids[0] 270 | 271 | message[to_field] = ids 272 | 273 | yield json_dumps(message) 274 | 275 | 276 | class GCMResponse(object): 277 | """GCM server response with results parsed into :attr:`responses`, 278 | :attr:`messages`, :attr:`registration_ids`, :attr:`data`, 279 | :attr:`successes`, :attr:`failures`, :attr:`errors`, and 280 | :attr:`canonical_ids`. 281 | 282 | Attributes: 283 | responses (list): List of ``requests.Response`` objects from each GCM 284 | request. 285 | messages (list): List of message data sent in each GCM request. 286 | registration_ids (list): Combined list of all recipient registration 287 | IDs. 288 | data (list): List of each GCM server response data. 289 | successes (list): List of registration IDs that were sent successfully. 290 | failures (list): List of registration IDs that failed. 291 | errors (list): List of exception objects correponding to the 292 | registration IDs that ere not sent successfully. See 293 | :mod:`pushjack.exceptions`. 294 | canonical_ids (list): List of registration IDs that have been 295 | reassigned a new ID. Each element is an instance of 296 | :class:`GCMCanonicalID`. 297 | """ 298 | 299 | def __init__(self, responses): 300 | if not isinstance(responses, (list, tuple)): # pragma: no cover 301 | responses = [responses] 302 | 303 | self.responses = responses 304 | self.messages = [] 305 | self.registration_ids = [] 306 | self.data = [] 307 | self.successes = [] 308 | self.failures = [] 309 | self.errors = [] 310 | self.canonical_ids = [] 311 | 312 | self._parse_responses() 313 | 314 | def _parse_responses(self): 315 | """Parse each server response.""" 316 | for response in self.responses: 317 | try: 318 | message = json_loads(response.request.body) 319 | except (TypeError, ValueError): 320 | message = None 321 | 322 | self.messages.append(message) 323 | message = message or {} 324 | 325 | if "registration_ids" in message: 326 | registration_ids = message["registration_ids"] 327 | elif "to" in message: 328 | registration_ids = [message["to"]] 329 | else: 330 | registration_ids = [] 331 | 332 | if not registration_ids: 333 | continue 334 | 335 | self.registration_ids.extend(registration_ids) 336 | 337 | if response.status_code == 200: 338 | data = response.json() 339 | self.data.append(data) 340 | self._parse_results(registration_ids, data.get("results", [])) 341 | elif response.status_code == 500: 342 | for registration_id in registration_ids: 343 | self._add_failure(registration_id, "InternalServerError") 344 | 345 | def _parse_results(self, registration_ids, results): 346 | """Parse the results key from the server response into errors, failures, and 347 | successes.""" 348 | for index, result in enumerate(results): 349 | registration_id = registration_ids[index] 350 | 351 | if "error" in result: 352 | self._add_failure(registration_id, result["error"]) 353 | else: 354 | self._add_success(registration_id) 355 | 356 | if "registration_id" in result: 357 | self._add_canonical_id(registration_id, result["registration_id"]) 358 | 359 | def _add_success(self, registration_id): 360 | """Add `registration_id` to :attr:`successes` list.""" 361 | self.successes.append(registration_id) 362 | 363 | def _add_failure(self, registration_id, error_code): 364 | """Add `registration_id` to :attr:`failures` list and exception to errors 365 | list.""" 366 | self.failures.append(registration_id) 367 | 368 | if error_code in gcm_server_errors: 369 | self.errors.append(gcm_server_errors[error_code](registration_id)) 370 | 371 | def _add_canonical_id(self, registration_id, canonical_id): 372 | """Add `registration_id` and `canonical_id` to :attr:`canonical_ids` list as 373 | tuple.""" 374 | self.canonical_ids.append(GCMCanonicalID(registration_id, canonical_id)) 375 | 376 | 377 | class GCMCanonicalID(namedtuple("GCMCanonicalID", ["old_id", "new_id"])): 378 | """ 379 | Represents a canonical ID returned by the GCM Server. This object indicates that a 380 | previously registered ID has changed to a new one. 381 | 382 | Attributes: 383 | old_id (str): Previously registered ID. 384 | new_id (str): New registration ID that should replace :attr:`old_id`. 385 | """ 386 | 387 | pass 388 | -------------------------------------------------------------------------------- /src/pushjack/apns.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Client module for Apple Push Notification service. 4 | 5 | The algorithm used to send bulk push notifications is optimized to eagerly 6 | check for errors using a single thread. Error checking is performed after each 7 | batch send (bulk notifications may be broken up into multiple batches) and is 8 | non-blocking until the last notification is sent. A final, blocking error check 9 | is performed using a customizable error timeout. This style of error checking 10 | is done to ensure that no errors are missed (e.g. by waiting too long to check 11 | errors before the connection is closed by the APNS server) without having to 12 | use two threads to read and write. 13 | 14 | The return from a send operation will contain a response object that includes 15 | any errors encountered while sending. These errors will be associated with the 16 | failed tokens. 17 | 18 | For more details regarding Apple's APNS documentation, consult the following: 19 | 20 | - `Apple Push Notification Service `_ 21 | - `Provider Communication with APNS `_ 22 | """ 23 | 24 | from binascii import hexlify, unhexlify 25 | from collections import namedtuple 26 | import logging 27 | import select 28 | import socket 29 | import ssl 30 | import struct 31 | import time 32 | 33 | from .utils import json_dumps, chunk, compact_dict 34 | from .exceptions import ( 35 | APNSError, 36 | APNSAuthError, 37 | APNSInvalidTokenError, 38 | APNSInvalidPayloadSizeError, 39 | APNSMissingPayloadError, 40 | APNSServerError, 41 | APNSTimeoutError, 42 | APNSUnsendableError, 43 | raise_apns_server_error, 44 | ) 45 | 46 | 47 | __all__ = ( 48 | "APNSClient", 49 | "APNSSandboxClient", 50 | "APNSResponse", 51 | "APNSExpiredToken", 52 | ) 53 | 54 | 55 | log = logging.getLogger(__name__) 56 | 57 | 58 | APNS_HOST = "gateway.push.apple.com" 59 | APNS_SANDBOX_HOST = "gateway.sandbox.push.apple.com" 60 | APNS_PORT = 2195 61 | APNS_FEEDBACK_HOST = "feedback.push.apple.com" 62 | APNS_FEEDBACK_SANDBOX_HOST = "feedback.sandbox.push.apple.com" 63 | APNS_FEEDBACK_PORT = 2196 64 | 65 | APNS_DEFAULT_EXPIRATION_OFFSET = 60 * 60 * 24 * 30 # 1 month 66 | APNS_DEFAULT_BATCH_SIZE = 100 67 | APNS_DEFAULT_ERROR_TIMEOUT = 10 68 | APNS_DEFAULT_MAX_PAYLOAD_LENGTH = 0 69 | APNS_DEFAULT_RETRIES = 5 70 | 71 | # Constants derived from http://goo.gl/wFVr2S 72 | APNS_PUSH_COMMAND = 2 73 | APNS_PUSH_FRAME_ITEM_COUNT = 5 74 | APNS_PUSH_FRAME_ITEM_PREFIX_LEN = 3 75 | APNS_PUSH_IDENTIFIER_LEN = 4 76 | APNS_PUSH_EXPIRATION_LEN = 4 77 | APNS_PUSH_PRIORITY_LEN = 1 78 | 79 | APNS_ERROR_RESPONSE_COMMAND = 8 80 | APNS_ERROR_RESPONSE_LEN = 6 81 | APNS_FEEDBACK_HEADER_LEN = 6 82 | APNS_MAX_NOTIFICATION_SIZE = 2048 83 | 84 | #: Indicates that the push message should be sent at a time that conserves 85 | #: power on the device receiving it. 86 | APNS_LOW_PRIORITY = 5 87 | 88 | #: Indicates that the push message should be sent immediately. The remote 89 | #: notification must trigger an alert, sound, or badge on the device. It is an 90 | #: error to use this priority for a push that contains only the 91 | #: ``content_available`` key. 92 | APNS_HIGH_PRIORITY = 10 93 | 94 | 95 | class APNSClient(object): 96 | """APNS client class.""" 97 | 98 | host = APNS_HOST 99 | port = APNS_PORT 100 | feedback_host = APNS_FEEDBACK_HOST 101 | feedback_port = APNS_FEEDBACK_PORT 102 | 103 | def __init__( 104 | self, 105 | certificate, 106 | default_error_timeout=APNS_DEFAULT_ERROR_TIMEOUT, 107 | default_expiration_offset=APNS_DEFAULT_EXPIRATION_OFFSET, 108 | default_batch_size=APNS_DEFAULT_BATCH_SIZE, 109 | default_max_payload_length=APNS_DEFAULT_MAX_PAYLOAD_LENGTH, 110 | default_retries=APNS_DEFAULT_RETRIES, 111 | ): 112 | self.certificate = certificate 113 | self.default_error_timeout = default_error_timeout 114 | self.default_expiration_offset = default_expiration_offset 115 | self.default_batch_size = default_batch_size 116 | self.default_max_payload_length = default_max_payload_length 117 | self.default_retries = default_retries 118 | self._conn = None 119 | 120 | @property 121 | def conn(self): 122 | """Reference to lazy APNS connection.""" 123 | if not self._conn: 124 | self._conn = self.create_connection() 125 | return self._conn 126 | 127 | def create_connection(self): 128 | """Create and return new APNS connection to push server.""" 129 | return APNSConnection(self.host, self.port, self.certificate) 130 | 131 | def create_feedback_connection(self): 132 | """Create and return new APNS connection to feedback server.""" 133 | return APNSConnection(self.feedback_host, self.feedback_port, self.certificate) 134 | 135 | def close(self): 136 | """Close APNS connection.""" 137 | self.conn.close() 138 | 139 | def send( 140 | self, 141 | ids, 142 | message=None, 143 | expiration=None, 144 | low_priority=None, 145 | batch_size=None, 146 | error_timeout=None, 147 | max_payload_length=None, 148 | retries=None, 149 | **options 150 | ): 151 | """ 152 | Send push notification to single or multiple recipients. 153 | 154 | Args: 155 | ids (list): APNS device tokens. Each item is expected to be a hex 156 | string. 157 | message (str|dict): Message string or APS dictionary. Set to 158 | ``None`` to send an empty alert notification. 159 | expiration (int, optional): Expiration time of message in seconds 160 | offset from now. Defaults to ``None`` which uses 161 | :attr:`default_expiration_offset`. 162 | low_priority (boolean, optional): Whether to send notification with 163 | the low priority flag. Defaults to ``False``. 164 | batch_size (int, optional): Number of notifications to group 165 | together when sending. Defaults to ``None`` which uses 166 | attr:`default_batch_size`. 167 | error_timeout (int, optional): Time in seconds to wait for the 168 | error response after sending messages. Defaults to ``None`` 169 | which uses attr:`default_error_timeout`. 170 | max_payload_length (int, optional): The maximum length of the 171 | payload to send. Message will be trimmed if the size is 172 | exceeded. Use 0 to turn off. Defaults to ``None`` which uses 173 | attr:`default_max_payload_length`. 174 | retries (int, optional): Number of times to retry when the send 175 | operation fails. Defaults to ``None`` which uses 176 | :attr:`default_retries`. 177 | 178 | Keyword Args: 179 | badge (int, optional): Badge number count for alert. Defaults to 180 | ``None``. 181 | sound (str, optional): Name of the sound file to play for alert. 182 | Defaults to ``None``. 183 | category (str, optional): Name of category. Defaults to ``None``. 184 | content_available (bool, optional): If ``True``, indicate that new 185 | content is available. Defaults to ``None``. 186 | title (str, optional): Alert title. 187 | title_loc_key (str, optional): The key to a title string in the 188 | ``Localizable.strings`` file for the current localization. 189 | title_loc_args (list, optional): List of string values to appear in 190 | place of the format specifiers in `title_loc_key`. 191 | action_loc_key (str, optional): Display an alert that includes the 192 | ``Close`` and ``View`` buttons. The string is used as a key to 193 | get a localized string in the current localization to use for 194 | the right button’s title instead of ``"View"``. 195 | loc_key (str, optional): A key to an alert-message string in a 196 | ``Localizable.strings`` file for the current localization. 197 | loc_args (list, optional): List of string values to appear in place 198 | of the format specifiers in ``loc_key``. 199 | launch_image (str, optional): The filename of an image file in the 200 | app bundle; it may include the extension or omit it. 201 | mutable_content (bool, optional): if ``True``, triggers Apple 202 | Notification Service Extension. Defaults to ``None``. 203 | thread_id (str, optional): Identifier for grouping notifications. 204 | iOS groups notifications with the same thread identifier 205 | together in Notification Center. Defaults to ``None``. 206 | extra (dict, optional): Extra data to include with the alert. 207 | 208 | Returns: 209 | :class:`APNSResponse`: Response from APNS containing tokens sent 210 | and any errors encountered. 211 | 212 | Raises: 213 | APNSInvalidTokenError: Invalid token format. 214 | :class:`.APNSInvalidTokenError` 215 | APNSInvalidPayloadSizeError: Notification payload size too large. 216 | :class:`.APNSInvalidPayloadSizeError` 217 | APNSMissingPayloadError: Notificationpayload is empty. 218 | :class:`.APNSMissingPayloadError` 219 | 220 | .. versionadded:: 0.0.1 221 | 222 | .. versionchanged:: 0.4.0 223 | - Added support for bulk sending. 224 | - Made sending and error checking non-blocking. 225 | - Removed `sock`, `payload`, and `identifer` arguments. 226 | 227 | .. versionchanged:: 0.5.0 228 | - Added ``batch_size`` argument. 229 | - Added ``error_timeout`` argument. 230 | - Replaced ``priority`` argument with ``low_priority=False``. 231 | - Resume sending notifications when a sent token has an error 232 | response. 233 | - Raise ``APNSSendError`` if any tokens 234 | have an error response. 235 | 236 | .. versionchanged:: 1.0.0 237 | - Return :class:`APNSResponse` instead of raising 238 | ``APNSSendError``. 239 | - Raise :class:`.APNSMissingPayloadError` if 240 | payload is empty. 241 | 242 | .. versionchanged:: 1.4.0 243 | Added ``retries`` argument. 244 | """ 245 | if not isinstance(ids, (list, tuple)): 246 | ids = [ids] 247 | 248 | if max_payload_length is None: 249 | max_payload_length = self.default_max_payload_length 250 | 251 | message = APNSMessage(message, max_payload_length=max_payload_length, **options) 252 | 253 | validate_tokens(ids) 254 | validate_message(message) 255 | 256 | if low_priority: 257 | priority = APNS_LOW_PRIORITY 258 | else: 259 | priority = APNS_HIGH_PRIORITY 260 | 261 | if expiration is None: 262 | expiration = int(time.time() + self.default_expiration_offset) 263 | 264 | if batch_size is None: 265 | batch_size = self.default_batch_size 266 | 267 | if error_timeout is None: 268 | error_timeout = self.default_error_timeout 269 | 270 | if retries is None: 271 | retries = self.default_retries 272 | 273 | stream = APNSMessageStream(ids, message, expiration, priority, batch_size) 274 | 275 | return self.conn.sendall(stream, error_timeout, retries=retries) 276 | 277 | def get_expired_tokens(self): 278 | """ 279 | Return inactive device tokens that are no longer registered to receive 280 | notifications. 281 | 282 | Returns: 283 | list: List of :class:`APNSExpiredToken` instances. 284 | 285 | .. versionadded:: 0.0.1 286 | """ 287 | log.debug("Preparing to check for expired APNS tokens.") 288 | 289 | conn = self.create_feedback_connection() 290 | tokens = list(APNSFeedbackStream(conn)) 291 | conn.close() 292 | 293 | log.debug("Received {0} expired APNS tokens.".format(len(tokens))) 294 | 295 | return tokens 296 | 297 | 298 | class APNSSandboxClient(APNSClient): 299 | """APNS client class for sandbox server.""" 300 | 301 | host = APNS_SANDBOX_HOST 302 | feedback_host = APNS_FEEDBACK_SANDBOX_HOST 303 | 304 | 305 | class APNSConnection(object): 306 | """Manager for APNS socket connection.""" 307 | 308 | def __init__(self, host, port, certificate): 309 | self.host = host 310 | self.port = port 311 | self.certificate = certificate 312 | self.sock = None 313 | 314 | def connect(self): 315 | """ 316 | Lazily connect to APNS server. 317 | 318 | Re-establish connection if previously closed. 319 | """ 320 | if self.sock: 321 | return 322 | 323 | log.debug( 324 | "Establishing connection to APNS on {0}:{1} using " 325 | "certificate at {2}".format(self.host, self.port, self.certificate) 326 | ) 327 | 328 | self.sock = create_socket(self.host, self.port, self.certificate) 329 | 330 | log.debug( 331 | "Established connection to APNS on {0}:{1}.".format(self.host, self.port) 332 | ) 333 | 334 | def close(self): 335 | """Disconnect from APNS server.""" 336 | if self.sock: 337 | log.debug("Closing connection to APNS.") 338 | self.sock.close() 339 | self.sock = None 340 | 341 | @property 342 | def client(self): 343 | """Return client socket connection to APNS server.""" 344 | self.connect() 345 | return self.sock 346 | 347 | def writable(self, timeout): 348 | """Return whether connection is writable.""" 349 | try: 350 | return select.select([], [self.client], [], timeout)[1] 351 | except Exception: # pragma: no cover 352 | log.debug("Error while waiting for APNS socket to become " "writable.") 353 | self.close() 354 | raise 355 | 356 | def readable(self, timeout): 357 | """Return whether connection is readable.""" 358 | try: 359 | return select.select([self.client], [], [], timeout)[0] 360 | except Exception: # pragma: no cover 361 | log.debug("Error while waiting for APNS socket to become " "readable.") 362 | self.close() 363 | raise 364 | 365 | def read(self, buffsize, timeout=10): 366 | """Return read data up to `buffsize`.""" 367 | data = b"" 368 | 369 | while True: 370 | if not self.readable(timeout): # pragma: no cover 371 | self.close() 372 | raise socket.timeout 373 | 374 | chunk = self.client.read(buffsize - len(data)) 375 | data += chunk 376 | 377 | if not chunk or len(data) >= buffsize or not timeout: 378 | # Either we've read all data or this is a nonblocking read. 379 | break 380 | 381 | return data 382 | 383 | def write(self, data, timeout=10): 384 | """Write data to socket.""" 385 | if not self.writable(timeout): # pragma: no cover 386 | self.close() 387 | raise socket.timeout 388 | 389 | log.debug( 390 | "Sending APNS notification batch containing {0} bytes.".format(len(data)) 391 | ) 392 | 393 | return self.client.sendall(data) 394 | 395 | def check_error(self, timeout=10): 396 | """Check for APNS errors.""" 397 | if not self.readable(timeout): 398 | # No error response. 399 | return 400 | 401 | try: 402 | data = self.read(APNS_ERROR_RESPONSE_LEN, timeout=0) 403 | except socket.error as ex: # pragma: no cover 404 | log.error("Could not read response: {0}.".format(ex)) 405 | self.close() 406 | return 407 | 408 | if not data: # pragma: no cover 409 | return 410 | 411 | command = struct.unpack(">B", data[:1])[0] 412 | 413 | if command != APNS_ERROR_RESPONSE_COMMAND: # pragma: no cover 414 | self.close() 415 | return 416 | 417 | code, identifier = struct.unpack(">BI", data[1:]) 418 | 419 | log.debug( 420 | "Received APNS error response with " 421 | "code={0} for identifier={1}.".format(code, identifier) 422 | ) 423 | 424 | self.close() 425 | raise_apns_server_error(code, identifier) 426 | 427 | def send(self, frames, retries=APNS_DEFAULT_RETRIES): 428 | """Send stream of frames to APNS server.""" 429 | if retries <= 0: # pragma: no cover 430 | retries = 1 431 | 432 | current_identifier = frames.next_identifier 433 | 434 | for frame in frames: 435 | success = False 436 | last_ex = None 437 | 438 | while not success and retries: 439 | try: 440 | self.write(frame) 441 | success = True 442 | except socket.error as ex: 443 | last_ex = ex 444 | log.warning( 445 | "Could not send frame to server: {0}. " 446 | "Retrying send operation.".format(ex) 447 | ) 448 | self.close() 449 | retries -= 1 450 | 451 | if not success: 452 | log.error("Could not send frame to server: {0}.".format(last_ex)) 453 | raise APNSTimeoutError(current_identifier) 454 | 455 | self.check_error(0) 456 | current_identifier = frames.next_identifier 457 | 458 | def sendall( 459 | self, 460 | stream, 461 | error_timeout=APNS_DEFAULT_ERROR_TIMEOUT, 462 | retries=APNS_DEFAULT_RETRIES, 463 | ): 464 | """ 465 | Send all notifications while handling errors. 466 | 467 | If an error occurs, then resume sending starting from after the token that 468 | failed. If any tokens failed, raise an error after sending all tokens. 469 | """ 470 | log.debug("Preparing to send {0} notifications to APNS.".format(len(stream))) 471 | 472 | errors = [] 473 | 474 | while True: 475 | try: 476 | self.send(stream, retries=retries) 477 | 478 | # Perform the final error check here before exiting. A large 479 | # enough timeout should be used so that no errors are missed. 480 | self.check_error(error_timeout) 481 | except APNSServerError as ex: 482 | errors.append(ex) 483 | next_identifier = ex.identifier + 1 484 | stream.seek(next_identifier) 485 | 486 | if ex.fatal: 487 | # We can't continue due to a fatal error. Go ahead and 488 | # convert remaining notifications to errors. 489 | errors += [ 490 | APNSUnsendableError(next_identifier + i) 491 | for i, _ in enumerate(stream.peek()) 492 | ] 493 | break 494 | 495 | if stream.eof(): 496 | break 497 | 498 | log.debug("Sent {0} notifications to APNS.".format(len(stream))) 499 | 500 | if errors: 501 | log.debug( 502 | "Encountered {0} errors while sending to APNS.".format(len(errors)) 503 | ) 504 | 505 | return APNSResponse(stream.tokens, stream.message, errors) 506 | 507 | 508 | class APNSMessage(object): 509 | """APNs message object that serializes to JSON.""" 510 | 511 | def __init__( 512 | self, 513 | message=None, 514 | badge=None, 515 | sound=None, 516 | category=None, 517 | content_available=None, 518 | title=None, 519 | title_loc_key=None, 520 | title_loc_args=None, 521 | action_loc_key=None, 522 | loc_key=None, 523 | loc_args=None, 524 | launch_image=None, 525 | mutable_content=None, 526 | thread_id=None, 527 | extra=None, 528 | max_payload_length=None, 529 | ): 530 | self.message = message 531 | self.badge = badge 532 | self.sound = sound 533 | self.category = category 534 | self.content_available = content_available 535 | self.title = title 536 | self.title_loc_key = title_loc_key 537 | self.title_loc_args = title_loc_args 538 | self.action_loc_key = action_loc_key 539 | self.loc_key = loc_key 540 | self.loc_args = loc_args 541 | self.launch_image = launch_image 542 | self.mutable_content = mutable_content 543 | self.thread_id = thread_id 544 | self.extra = extra 545 | self.max_payload_length = max_payload_length 546 | 547 | def _construct_dict(self, message=None): 548 | """Return message as dictionary, overriding message.""" 549 | msg = {} 550 | 551 | if any( 552 | [ 553 | self.title, 554 | self.title_loc_key, 555 | self.title_loc_args, 556 | self.action_loc_key, 557 | self.loc_key, 558 | self.loc_args, 559 | self.launch_image, 560 | ] 561 | ): 562 | alert = { 563 | "body": message, 564 | "title": self.title, 565 | "title-loc-key": self.title_loc_key, 566 | "title-loc-args": self.title_loc_args, 567 | "action-loc-key": self.action_loc_key, 568 | "loc-key": self.loc_key, 569 | "loc-args": self.loc_args, 570 | "launch-image": self.launch_image, 571 | } 572 | 573 | alert = compact_dict(alert) 574 | else: 575 | alert = message 576 | 577 | msg.update(self.extra or {}) 578 | msg["aps"] = compact_dict( 579 | { 580 | "alert": alert, 581 | "badge": self.badge, 582 | "sound": self.sound, 583 | "category": self.category, 584 | "content-available": 1 if self.content_available else None, 585 | "mutable-content": 1 if self.mutable_content else None, 586 | "thread-id": self.thread_id, 587 | } 588 | ) 589 | 590 | return msg 591 | 592 | def _construct_truncated_dict(self, message): 593 | """Return truncated message as dictionary.""" 594 | msg = None 595 | ending = "" 596 | 597 | while message: 598 | data = self._construct_dict(message + ending) 599 | 600 | if len(json_dumps(data)) <= self.max_payload_length: 601 | msg = data 602 | break 603 | 604 | message = message[0:-1] 605 | ending = "..." 606 | 607 | if msg is None: # pragma: no cover 608 | msg = self._construct_dict() 609 | 610 | return msg 611 | 612 | def to_dict(self): 613 | """Return message as dictionary, truncating if needed.""" 614 | if self.message and self.max_payload_length: 615 | return self._construct_truncated_dict(self.message) 616 | 617 | return self._construct_dict(self.message) 618 | 619 | def to_json(self): 620 | """Return message as JSON string.""" 621 | return json_dumps(self.to_dict()) 622 | 623 | def __len__(self): 624 | """Return length of serialized message.""" 625 | return len(self.to_json()) 626 | 627 | 628 | class APNSMessageStream(object): 629 | """Iterable object that yields a binary APNS socket frame for each device token.""" 630 | 631 | def __init__(self, tokens, message, expiration, priority, batch_size=1): 632 | self.tokens = tokens 633 | self.message = message 634 | self.expiration = expiration 635 | self.priority = priority 636 | self.batch_size = batch_size 637 | self.next_identifier = 0 638 | 639 | def seek(self, identifier): 640 | """ 641 | Move token index to resume processing after token with index equal to 642 | `identifier`. 643 | 644 | Typically, `identifier` will be the token index that generated an error 645 | during send. Seeking to this identifier will result in processing the 646 | tokens that come after the error-causing token. 647 | 648 | Args: 649 | identifier (int): Index of tokens to skip. 650 | """ 651 | self.next_identifier = identifier 652 | 653 | def peek(self, n=None): 654 | return self.tokens[self.next_identifier : n] 655 | 656 | def eof(self): 657 | """Return whether all tokens have been processed.""" 658 | return self.next_identifier >= len(self.tokens) 659 | 660 | def pack(self, token, identifier, message, expiration, priority): 661 | """Return a packed APNS socket frame for given token.""" 662 | token_bin = unhexlify(token) 663 | token_len = len(token_bin) 664 | message_len = len(message) 665 | 666 | # |CMD|FRAMELEN|{token}|{message}|{id:4}|{expiration:4}|{priority:1} 667 | # 5 items, each 3 bytes prefix, then each item length 668 | frame_len = ( 669 | APNS_PUSH_FRAME_ITEM_COUNT * APNS_PUSH_FRAME_ITEM_PREFIX_LEN 670 | + token_len 671 | + message_len 672 | + APNS_PUSH_IDENTIFIER_LEN 673 | + APNS_PUSH_EXPIRATION_LEN 674 | + APNS_PUSH_PRIORITY_LEN 675 | ) 676 | frame_fmt = ">BIBH{0}sBH{1}sBHIBHIBHB".format(token_len, message_len) 677 | 678 | # NOTE: Each bare int below is the corresponding frame item ID. 679 | frame = struct.pack( 680 | frame_fmt, 681 | APNS_PUSH_COMMAND, 682 | frame_len, # BI 683 | 1, 684 | token_len, 685 | token_bin, # BH{token_len}s 686 | 2, 687 | message_len, 688 | message, # BH{message_len}s 689 | 3, 690 | APNS_PUSH_IDENTIFIER_LEN, 691 | identifier, # BHI 692 | 4, 693 | APNS_PUSH_EXPIRATION_LEN, 694 | expiration, # BHI 695 | 5, 696 | APNS_PUSH_PRIORITY_LEN, 697 | priority, 698 | ) # BHB 699 | 700 | return frame 701 | 702 | def __len__(self): 703 | """Return count of number of notifications.""" 704 | return len(self.tokens) 705 | 706 | def __iter__(self): 707 | """Iterate through each device token and yield APNS socket frame.""" 708 | message = self.message.to_json() 709 | 710 | data = b"" 711 | tokens = self.tokens[self.next_identifier :] 712 | 713 | for token_chunk in chunk(tokens, self.batch_size): 714 | for token in token_chunk: 715 | log.debug("Preparing notification for APNS token {0}".format(token)) 716 | 717 | data += self.pack( 718 | token, self.next_identifier, message, self.expiration, self.priority 719 | ) 720 | self.next_identifier += 1 721 | 722 | yield data 723 | 724 | data = b"" 725 | 726 | 727 | class APNSFeedbackStream(object): 728 | """An iterable object that yields an expired device token.""" 729 | 730 | def __init__(self, conn): 731 | self.conn = conn 732 | 733 | def __iter__(self): 734 | """Iterate through and yield expired device tokens.""" 735 | header_format = "!LH" 736 | 737 | while True: 738 | data = self.conn.read(APNS_FEEDBACK_HEADER_LEN) 739 | 740 | if not data: 741 | break 742 | 743 | timestamp, token_len = struct.unpack(header_format, data) 744 | token_data = self.conn.read(token_len) 745 | 746 | if token_data: 747 | token = struct.unpack("{0}s".format(token_len), token_data) 748 | token = hexlify(token[0]).decode("utf8") 749 | 750 | yield APNSExpiredToken(token, timestamp) 751 | 752 | 753 | class APNSResponse(object): 754 | """ 755 | Response from APNS after sending tokens. 756 | 757 | Attributes: 758 | tokens (list): List of all tokens sent during bulk sending. 759 | message (APNSMessage): :class:`APNSMessage` object sent. 760 | errors (list): List of APNS exceptions for each failed token. 761 | failures (list): List of all failed tokens. 762 | successes (list): List of all successful tokens. 763 | token_errors (dict): Dict mapping the failed tokens to their respective 764 | APNS exception. 765 | 766 | .. versionadded:: 1.0.0 767 | """ 768 | 769 | def __init__(self, tokens, message, errors): 770 | self.tokens = tokens 771 | self.message = message 772 | self.errors = errors 773 | self.failures = [] 774 | self.successes = [] 775 | self.token_errors = {} 776 | 777 | for err in errors: 778 | tok = tokens[err.identifier] 779 | self.failures.append(tok) 780 | self.token_errors[tok] = err 781 | 782 | self.successes = [token for token in tokens if token not in self.failures] 783 | 784 | 785 | class APNSExpiredToken(namedtuple("APNSExpiredToken", ["token", "timestamp"])): 786 | """ 787 | Represents an expired APNS token with the timestamp of when it expired. 788 | 789 | Attributes: 790 | token (str): Expired APNS token. 791 | timestamp (int): Epoch timestamp. 792 | """ 793 | 794 | pass 795 | 796 | 797 | def create_socket(host, port, certificate): 798 | """Create a socket connection to the APNS server.""" 799 | try: 800 | with open(certificate, "r") as fileobj: 801 | fileobj.read() 802 | except Exception as ex: 803 | raise APNSAuthError( 804 | "The certificate at {0} is not readable: {1}".format(certificate, ex) 805 | ) 806 | 807 | sock = socket.socket() 808 | 809 | sock = ssl.wrap_socket(sock, certfile=certificate, do_handshake_on_connect=False) 810 | sock.connect((host, port)) 811 | sock.setblocking(0) 812 | 813 | log.debug("Performing SSL handshake with APNS on {0}:{1}".format(host, port)) 814 | 815 | do_ssl_handshake(sock) 816 | 817 | return sock 818 | 819 | 820 | def do_ssl_handshake(sock): 821 | """Perform SSL socket handshake for non-blocking socket.""" 822 | while True: 823 | try: 824 | sock.do_handshake() 825 | break 826 | except ssl.SSLError as ex: # pragma: no cover 827 | # For some reason, pylint on TravisCI's Python 2.7 platform 828 | # complains that these members don't exist. Add a disable flag to 829 | # bypass this. 830 | # pylint: disable=no-member 831 | if ex.args[0] == ssl.SSL_ERROR_WANT_READ: 832 | select.select([sock], [], []) 833 | elif ex.args[0] == ssl.SSL_ERROR_WANT_WRITE: 834 | select.select([], [sock], []) 835 | else: 836 | raise 837 | 838 | 839 | def valid_token(token): 840 | """Return whether token is in valid format.""" 841 | try: 842 | valid = token and unhexlify(token) 843 | except Exception: 844 | valid = False 845 | 846 | return valid 847 | 848 | 849 | def invalid_tokens(tokens): 850 | """Return list of invalid APNS tokens.""" 851 | return [token for token in tokens if not valid_token(token)] 852 | 853 | 854 | def validate_tokens(tokens): 855 | """Check whether `tokens` are all valid.""" 856 | invalid = invalid_tokens(tokens) 857 | 858 | if invalid: 859 | raise APNSInvalidTokenError( 860 | "Invalid token format. " 861 | "Expected hex string: {0}".format(", ".join(invalid)) 862 | ) 863 | 864 | 865 | def validate_message(message): 866 | """Check whether `message` is valid.""" 867 | if len(message) > APNS_MAX_NOTIFICATION_SIZE: 868 | raise APNSInvalidPayloadSizeError( 869 | "Notification body cannot exceed " 870 | "{0} bytes".format(APNS_MAX_NOTIFICATION_SIZE) 871 | ) 872 | --------------------------------------------------------------------------------