├── .gitignore ├── LICENSE.txt ├── README.rst ├── gobiko ├── __init__.py └── apns │ ├── __init__.py │ ├── client.py │ ├── exceptions.py │ └── utils.py ├── requirements.txt ├── setup.cfg ├── setup.py └── tests.py /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/osx,python 3 | 4 | ### OSX ### 5 | *.DS_Store 6 | .AppleDouble 7 | .LSOverride 8 | 9 | # Icon must end with two \r 10 | Icon 11 | # Thumbnails 12 | ._* 13 | # Files that might appear in the root of a volume 14 | .DocumentRevisions-V100 15 | .fseventsd 16 | .Spotlight-V100 17 | .TemporaryItems 18 | .Trashes 19 | .VolumeIcon.icns 20 | .com.apple.timemachine.donotpresent 21 | # Directories potentially created on remote AFP share 22 | .AppleDB 23 | .AppleDesktop 24 | Network Trash Folder 25 | Temporary Items 26 | .apdisk 27 | 28 | 29 | ### Python ### 30 | # Byte-compiled / optimized / DLL files 31 | __pycache__/ 32 | *.py[cod] 33 | *$py.class 34 | 35 | # C extensions 36 | *.so 37 | 38 | # Distribution / packaging 39 | .Python 40 | env/ 41 | build/ 42 | develop-eggs/ 43 | dist/ 44 | downloads/ 45 | eggs/ 46 | .eggs/ 47 | lib/ 48 | lib64/ 49 | parts/ 50 | sdist/ 51 | var/ 52 | *.egg-info/ 53 | .installed.cfg 54 | *.egg 55 | 56 | # PyInstaller 57 | # Usually these files are written by a python script from a template 58 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 59 | *.manifest 60 | *.spec 61 | 62 | # Installer logs 63 | pip-log.txt 64 | pip-delete-this-directory.txt 65 | 66 | # Unit test / coverage reports 67 | htmlcov/ 68 | .tox/ 69 | .coverage 70 | .coverage.* 71 | .cache 72 | nosetests.xml 73 | coverage.xml 74 | *,cover 75 | .hypothesis/ 76 | 77 | # Translations 78 | *.mo 79 | *.pot 80 | 81 | # Django stuff: 82 | *.log 83 | local_settings.py 84 | 85 | # Flask stuff: 86 | instance/ 87 | .webassets-cache 88 | 89 | # Scrapy stuff: 90 | .scrapy 91 | 92 | # Sphinx documentation 93 | docs/_build/ 94 | 95 | # PyBuilder 96 | target/ 97 | 98 | # Jupyter Notebook 99 | .ipynb_checkpoints 100 | 101 | # pyenv 102 | .python-version 103 | 104 | # celery beat schedule file 105 | celerybeat-schedule 106 | 107 | # dotenv 108 | .env 109 | 110 | # virtualenv 111 | .venv/ 112 | venv/ 113 | ENV/ 114 | 115 | # Spyder project settings 116 | .spyderproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2016 Gene Sluder 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============================= 2 | python-apns 3 | ============================= 4 | 5 | A library for interacting with APNs using HTTP/2 and token based authentication. 6 | 7 | 8 | 9 | 10 | Installation 11 | ----------------- 12 | 13 | :: 14 | 15 | pip install gobiko.apns 16 | 17 | 18 | Usage 19 | ----------------- 20 | 21 | Create a client:: 22 | 23 | from gobiko.apns import APNsClient 24 | 25 | client = APNsClient( 26 | team_id=TEAM_ID, 27 | bundle_id=BUNDLE_ID, 28 | auth_key_id=APNS_KEY_ID, 29 | auth_key_filepath=APNS_KEY_FILEPATH, 30 | use_sandbox=True 31 | ) 32 | 33 | 34 | Alternatively, you can create a client with the contents of the auth key file directly:: 35 | 36 | client = APNsClient( 37 | team_id=TEAM_ID, 38 | bundle_id=BUNDLE_ID, 39 | auth_key_id=APNS_KEY_ID, 40 | auth_key=APNS_KEY, 41 | use_sandbox=True 42 | ) 43 | 44 | If you run into any problems deserializing the key, try wrapping it to 64 lines:: 45 | 46 | client = APNsClient( 47 | team_id=TEAM_ID, 48 | bundle_id=BUNDLE_ID, 49 | auth_key_id=APNS_KEY_ID, 50 | auth_key=APNS_KEY, 51 | use_sandbox=True, 52 | wrap_key=True 53 | ) 54 | 55 | In Python 2.x environments, you may need to force the communication protocol to 'h2':: 56 | 57 | client = APNsClient( 58 | team_id=TEAM_ID, 59 | bundle_id=BUNDLE_ID, 60 | auth_key_id=APNS_KEY_ID, 61 | auth_key=APNS_KEY, 62 | use_sandbox=True, 63 | force_proto='h2' 64 | ) 65 | 66 | Now you can send a message to a device by specifying its registration ID:: 67 | 68 | client.send_message( 69 | registration_id, 70 | "All your base are belong to us." 71 | ) 72 | 73 | Or you can send bulk messages to a list of devices:: 74 | 75 | client.send_bulk_message( 76 | [registration_id_1, registration_id_2], 77 | "You have no chance to survive, make your time." 78 | ) 79 | 80 | 81 | Payload 82 | ----------------- 83 | 84 | Additional APNs payload values can be passed as kwargs:: 85 | 86 | client.send_message( 87 | registration_id, 88 | "All your base are belong to us.", 89 | badge=None, 90 | sound=None, 91 | category=None, 92 | content_available=False, 93 | action_loc_key=None, 94 | loc_key=None, 95 | loc_args=[], 96 | extra={}, 97 | identifier=None, 98 | expiration=None, 99 | priority=10, 100 | topic=None 101 | ) 102 | 103 | 104 | Pruning 105 | ----------------- 106 | 107 | The legacy binary interface APNs provided an endpoint to check whether a registration ID had 108 | become inactive. Now the service returns a BadDeviceToken error when you attempt to deliver an 109 | alert to an inactive registration ID. If you need to prune inactive IDs from a database you 110 | can handle the BadDeviceToken exception to do so:: 111 | 112 | from gobiko.apns.exceptions import BadDeviceToken 113 | 114 | try: 115 | client.send_message(OLD_REGISTRATION_ID, "Message to an invalid registration ID.") 116 | except BadDeviceToken: 117 | # Handle invalid ID here 118 | pass 119 | 120 | Same approach if sending by bulk:: 121 | 122 | from gobiko.apns.exceptions import PartialBulkMessage 123 | 124 | try: 125 | client.send_bulk_message([registration_id1, registration_id2], "Message") 126 | except PartialBulkMessage as e: 127 | # Handle list of invalid IDs using e.bad_registration_ids 128 | pass 129 | 130 | 131 | Documentation 132 | ----------------- 133 | 134 | - More information on APNs and an explanation of the above can be found `in this blog post `_. 135 | 136 | - Apple documentation for APNs can be found `here `_. 137 | 138 | 139 | Credits 140 | ----------------- 141 | 142 | 143 | -------------------------------------------------------------------------------- /gobiko/__init__.py: -------------------------------------------------------------------------------- 1 | # See http://peak.telecommunity.com/DevCenter/setuptools#namespace-packages 2 | try: 3 | __import__('pkg_resources').declare_namespace(__name__) 4 | except ImportError: 5 | from pkgutil import extend_path 6 | __path__ = extend_path(__path__, __name__) 7 | -------------------------------------------------------------------------------- /gobiko/apns/__init__.py: -------------------------------------------------------------------------------- 1 | """A library for interacting with APNs using HTTP/2 and token based authentication. 2 | 3 | """ 4 | 5 | __author__ = "Gene Sluder" 6 | __email__ = "gene@gobiko.com" 7 | __version__ = "0.1.0" 8 | 9 | from .client import APNsClient 10 | 11 | -------------------------------------------------------------------------------- /gobiko/apns/client.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import json 3 | import jwt 4 | import time 5 | import uuid 6 | 7 | from collections import namedtuple 8 | from contextlib import closing 9 | from hyper import HTTP20Connection 10 | 11 | from .exceptions import ( 12 | InternalException, 13 | ImproperlyConfigured, 14 | PayloadTooLarge, 15 | BadDeviceToken, 16 | PartialBulkMessage, 17 | BadTopic, 18 | InvalidPushType, 19 | ) 20 | 21 | from .utils import validate_private_key, wrap_private_key 22 | 23 | 24 | ALGORITHM = 'ES256' 25 | SANDBOX_HOST = 'api.development.push.apple.com:443' 26 | PRODUCTION_HOST = 'api.push.apple.com:443' 27 | MAX_NOTIFICATION_SIZE = 4096 28 | 29 | APNS_PUSH_TYPES = ('alert', 'background', 'voip', 'complication', 'fileprovider', 'mdm') 30 | 31 | APNS_RESPONSE_CODES = { 32 | 'Success': 200, 33 | 'BadRequest': 400, 34 | 'TokenError': 403, 35 | 'MethodNotAllowed': 405, 36 | 'TokenInactive': 410, 37 | 'PayloadTooLarge': 413, 38 | 'TooManyRequests': 429, 39 | 'InternalServerError': 500, 40 | 'ServerUnavailable': 503, 41 | } 42 | APNSResponseStruct = namedtuple('APNSResponseStruct', APNS_RESPONSE_CODES.keys()) 43 | APNSResponse = APNSResponseStruct(**APNS_RESPONSE_CODES) 44 | 45 | 46 | class APNsClient(object): 47 | 48 | def __init__(self, team_id, auth_key_id, 49 | auth_key=None, auth_key_filepath=None, bundle_id=None, use_sandbox=False, force_proto=None, wrap_key=False 50 | ): 51 | 52 | if not (auth_key_filepath or auth_key): 53 | raise ImproperlyConfigured( 54 | 'You must provide either an auth key or a path to a file containing the auth key' 55 | ) 56 | 57 | if not auth_key: 58 | try: 59 | with open(auth_key_filepath, "r") as f: 60 | auth_key = f.read() 61 | 62 | except Exception as e: 63 | raise ImproperlyConfigured("The APNS auth key file at %r is not readable: %s" % (auth_key_filepath, e)) 64 | 65 | validate_private_key(auth_key) 66 | if wrap_key: 67 | auth_key = wrap_private_key(auth_key) # Some have had issues with keys that aren't wrappd to 64 lines 68 | 69 | self.team_id = team_id 70 | self.bundle_id = bundle_id 71 | self.auth_key = auth_key 72 | self.auth_key_id = auth_key_id 73 | self.force_proto = force_proto 74 | self.host = SANDBOX_HOST if use_sandbox else PRODUCTION_HOST 75 | 76 | def send_message(self, registration_id, alert, **kwargs): 77 | return self._send_message(registration_id, alert, **kwargs) 78 | 79 | def send_bulk_message(self, registration_ids, alert, **kwargs): 80 | good_registration_ids = [] 81 | bad_registration_ids = [] 82 | 83 | with closing(self._create_connection()) as connection: 84 | auth_token = self._get_token() 85 | 86 | for registration_id in registration_ids: 87 | try: 88 | res = self._send_message(registration_id, alert, connection=connection, auth_token=auth_token, **kwargs) 89 | good_registration_ids.append(registration_id) 90 | except: 91 | bad_registration_ids.append(registration_id) 92 | 93 | if not bad_registration_ids: 94 | return res 95 | 96 | elif not good_registration_ids: 97 | raise BadDeviceToken("None of the registration ids were accepted" 98 | "Rerun individual ids with ``send_message()``" 99 | "to get more details about why") 100 | 101 | else: 102 | raise PartialBulkMessage( 103 | "Some of the registration ids were accepted. Rerun individual " 104 | "ids with ``send_message()`` to get more details about why. " 105 | "The ones that failed: \n:" 106 | "{bad_string}\n" 107 | "The ones that were pushed successfully: \n:" 108 | "{good_string}\n".format( 109 | bad_string="\n".join(bad_registration_ids), 110 | good_string = "\n".join(good_registration_ids) 111 | ), 112 | bad_registration_ids 113 | ) 114 | 115 | def get_token_from_cache(self): 116 | """Do not use cache by default, just provide the function to be easily overridden""" 117 | return None 118 | 119 | def set_token_to_cache(self, token): 120 | """Do not use cache by default, just provide the function to be easily overridden""" 121 | pass 122 | 123 | def _get_token(self): 124 | token = self.get_token_from_cache() 125 | 126 | if token is None: 127 | token = self._create_token() 128 | self.set_token_to_cache(token) 129 | 130 | return token 131 | 132 | def _create_connection(self): 133 | return HTTP20Connection(self.host, force_proto=self.force_proto) 134 | 135 | def _create_token(self): 136 | token = jwt.encode( 137 | { 138 | 'iss': self.team_id, 139 | 'iat': time.time() 140 | }, 141 | self.auth_key, 142 | algorithm= ALGORITHM, 143 | headers={ 144 | 'alg': ALGORITHM, 145 | 'kid': self.auth_key_id, 146 | } 147 | ) 148 | 149 | if isinstance(token, bytes): 150 | token = token.decode('ascii') 151 | 152 | return token 153 | 154 | def _send_message(self, registration_id, alert, 155 | badge=None, sound=None, category=None, content_available=False, 156 | mutable_content=False, 157 | action_loc_key=None, loc_key=None, loc_args=[], extra={}, 158 | identifier=None, expiration=None, priority=10, 159 | connection=None, auth_token=None, bundle_id=None, topic=None, push_type='alert' 160 | ): 161 | topic = topic or bundle_id or self.bundle_id 162 | if not topic: 163 | raise ImproperlyConfigured( 164 | 'You must provide your bundle_id if you do not specify a topic' 165 | ) 166 | 167 | if push_type not in APNS_PUSH_TYPES: 168 | raise InvalidPushType('The push-type provided is not valid') 169 | 170 | if push_type == 'voip' and not topic.endswith('.voip'): 171 | raise BadTopic('Topic should be in the format .voip when using voip push_type') 172 | 173 | data = {} 174 | aps_data = {} 175 | 176 | if action_loc_key or loc_key or loc_args: 177 | alert = {"body": alert} if alert else {} 178 | if action_loc_key: 179 | alert["action-loc-key"] = action_loc_key 180 | if loc_key: 181 | alert["loc-key"] = loc_key 182 | if loc_args: 183 | alert["loc-args"] = loc_args 184 | 185 | if alert is not None: 186 | aps_data["alert"] = alert 187 | 188 | if badge is not None: 189 | aps_data["badge"] = badge 190 | 191 | if sound is not None: 192 | aps_data["sound"] = sound 193 | 194 | if category is not None: 195 | aps_data["category"] = category 196 | 197 | if content_available: 198 | aps_data["content-available"] = 1 199 | 200 | if mutable_content: 201 | aps_data["mutable-content"] = 1 202 | 203 | data["aps"] = aps_data 204 | data.update(extra) 205 | 206 | # Convert to json, avoiding unnecessary whitespace with separators (keys sorted for tests) 207 | json_data = json.dumps(data, separators=(",", ":"), sort_keys=True).encode("utf-8") 208 | 209 | if len(json_data) > MAX_NOTIFICATION_SIZE: 210 | raise PayloadTooLarge("Notification body cannot exceed %i bytes" % (MAX_NOTIFICATION_SIZE)) 211 | 212 | # If expiration isn't specified use 1 month from now 213 | expiration_time = expiration if expiration is not None else int(time.time()) + 2592000 214 | 215 | auth_token = auth_token or self._get_token() 216 | 217 | request_headers = { 218 | 'apns-expiration': str(expiration_time), 219 | 'apns-id': str(identifier or uuid.uuid4()), 220 | 'apns-priority': str(priority), 221 | 'apns-topic': topic, 222 | 'apns-push-type': push_type, 223 | 'authorization': 'bearer {0}'.format(auth_token) 224 | } 225 | 226 | if connection: 227 | response = self._send_push_request(connection, registration_id, json_data, request_headers) 228 | else: 229 | with closing(self._create_connection()) as connection: 230 | response = self._send_push_request(connection, registration_id, json_data, request_headers) 231 | 232 | return response 233 | 234 | def _send_push_request(self, connection, registration_id, json_data, request_headers): 235 | connection.request( 236 | 'POST', 237 | '/3/device/{0}'.format(registration_id), 238 | json_data, 239 | headers=request_headers 240 | ) 241 | response = connection.get_response() 242 | 243 | if response.status != APNSResponse.Success: 244 | body = json.loads(response.read().decode('utf-8')) 245 | reason = body.get("reason") 246 | 247 | if reason: 248 | exceptions_module = importlib.import_module("gobiko.apns.exceptions") 249 | # get exception class by name 250 | raise getattr(exceptions_module, reason, InternalException) 251 | 252 | return response 253 | -------------------------------------------------------------------------------- /gobiko/apns/exceptions.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | class APNsException(Exception): 4 | def __str__(self): 5 | return '{e.__class__.__name__}: {e.__doc__}'.format(e=self) 6 | 7 | 8 | class InternalException(APNsException): 9 | pass 10 | 11 | 12 | class ImproperlyConfigured(APNsException): 13 | pass 14 | 15 | 16 | class InvalidPushType(APNsException): 17 | pass 18 | 19 | 20 | class BadCollapseId(APNsException): 21 | "The collapse identifier exceeds the maximum allowed size" 22 | pass 23 | 24 | 25 | class BadDeviceToken(APNsException): 26 | "The specified device token was bad. Verify that the request contains a valid token and that the token matches the environment." 27 | pass 28 | 29 | 30 | class BadExpirationDate(APNsException): 31 | "The apns-expiration value is bad." 32 | pass 33 | 34 | 35 | class BadMessageId(APNsException): 36 | "The apns-id value is bad." 37 | pass 38 | 39 | class PartialBulkMessage(APNsException): 40 | def __init__(self, message, bad_registration_ids): 41 | super(APNsException, self).__init__(message) 42 | self.bad_registration_ids = bad_registration_ids 43 | 44 | class BadPriority(APNsException): 45 | "The apns-priority value is bad." 46 | pass 47 | 48 | 49 | class BadTopic(APNsException): 50 | "The apns-topic was invalid." 51 | pass 52 | 53 | 54 | class DeviceTokenNotForTopic(APNsException): 55 | "The device token does not match the specified topic." 56 | pass 57 | 58 | 59 | class DuplicateHeaders(APNsException): 60 | "One or more headers were repeated." 61 | pass 62 | 63 | 64 | class IdleTimeout(APNsException): 65 | "Idle time out." 66 | pass 67 | 68 | 69 | class MissingDeviceToken(APNsException): 70 | "The device token is not specified in the request :path. Verify that the :path header contains the device token." 71 | pass 72 | 73 | 74 | class MissingTopic(APNsException): 75 | "The apns-topic header of the request was not specified and was required. The apns-topic header is mandatory when the client is connected using a certificate that supports multiple topics." 76 | pass 77 | 78 | 79 | class PayloadEmpty(APNsException): 80 | "The message payload was empty." 81 | pass 82 | 83 | 84 | class TopicDisallowed(APNsException): 85 | "Pushing to this topic is not allowed." 86 | pass 87 | 88 | 89 | class BadCertificate(APNsException): 90 | "The certificate was bad." 91 | pass 92 | 93 | 94 | class BadCertificateEnvironment(APNsException): 95 | "The client certificate was for the wrong environment." 96 | pass 97 | 98 | 99 | class ExpiredProviderToken(APNsException): 100 | "The provider token is stale and a new token should be generated." 101 | pass 102 | 103 | 104 | class Forbidden(APNsException): 105 | "The specified action is not allowed." 106 | pass 107 | 108 | 109 | class InvalidProviderToken(APNsException): 110 | "The provider token is not valid or the token signature could not be verified." 111 | pass 112 | 113 | 114 | class MissingProviderToken(APNsException): 115 | "No provider certificate was used to connect to APNs and Authorization header was missing or no provider token was specified." 116 | pass 117 | 118 | 119 | class BadPath(APNsException): 120 | "The request contained a bad :path value." 121 | pass 122 | 123 | 124 | class MethodNotAllowed(APNsException): 125 | "The specified :method was not POST." 126 | pass 127 | 128 | 129 | class Unregistered(APNsException): 130 | "The device token is inactive for the specified topic. Expected HTTP/2 status code is 410; see Table 8-4." 131 | pass 132 | 133 | 134 | class PayloadTooLarge(APNsException): 135 | "The message payload was too large. See Creating the Remote Notification Payload for details on maximum payload size." 136 | pass 137 | 138 | 139 | class TooManyProviderTokenUpdates(APNsException): 140 | "The provider token is being updated too often." 141 | pass 142 | 143 | 144 | class TooManyRequests(APNsException): 145 | "Too many requests were made consecutively to the same device token." 146 | pass 147 | 148 | 149 | class InternalServerError(APNsException): 150 | "An internal server error occurred." 151 | pass 152 | 153 | 154 | class ServiceUnavailable(APNsException): 155 | "The service is unavailable." 156 | pass 157 | 158 | 159 | class Shutdown(APNsException): 160 | "The server is shutting down." 161 | pass 162 | 163 | -------------------------------------------------------------------------------- /gobiko/apns/utils.py: -------------------------------------------------------------------------------- 1 | 2 | from textwrap import wrap 3 | 4 | def validate_private_key(private_key): 5 | mode = "start" 6 | for line in private_key.split("\n"): 7 | if mode == "start": 8 | if "BEGIN PRIVATE KEY" in line: 9 | mode = "key" 10 | elif mode == "key": 11 | if "END PRIVATE KEY" in line: 12 | mode = "end" 13 | break 14 | if mode != "end": 15 | raise Exception("The auth key provided is not valid") 16 | 17 | 18 | def wrap_private_key(private_key): 19 | # Wrap key to 64 lines 20 | comps = private_key.split("\n") 21 | wrapped_key = "\n".join(wrap(comps[1], 64)) 22 | return "\n".join([comps[0], wrapped_key, comps[2]]) 23 | 24 | 25 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | cryptography==3.4.2 2 | hyper==0.7.0 3 | PyJWT==2.0.1 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.rst 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | setup( 3 | name = 'gobiko.apns', 4 | packages = [ 5 | 'gobiko', 6 | 'gobiko.apns' 7 | ], 8 | version = '0.1.6', 9 | description = 'A library for interacting with APNs using HTTP/2 and token-based authentication.', 10 | author = 'Gene Sluder', 11 | author_email = 'gene@gobiko.com', 12 | url = 'https://github.com/genesluder/python-apns', 13 | download_url = 'https://github.com/genesluder/python-apns/tarball/0.1.6', 14 | keywords = [ 15 | 'apns', 16 | 'push notifications', 17 | ], 18 | classifiers = [], 19 | install_requires=[ 20 | 'cryptography<=3.3.2', 21 | 'hyper', 22 | 'pyjwt', 23 | ], 24 | ) 25 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/genesluder/python-apns/3d732dbc4646dcdf85af9a04bdccc2fe1e745b92/tests.py --------------------------------------------------------------------------------