├── .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
--------------------------------------------------------------------------------