Skip to content

Commit fd057a2

Browse files
author
Samuel Sutch
committed
first brush at new REST-based Python client
has no configuration built in by default
1 parent cb933dc commit fd057a2

File tree

5 files changed

+185
-19
lines changed

5 files changed

+185
-19
lines changed

pyapns/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
from .client import notify, provision, feedback, configure__version__ = "0.4.0"__author__ = "Samuel Sutch"__license__ = "MIT"__copyright__ = "Copyrighit 2012 Samuel Sutch"
1+
from .client import notify, provision, feedback, configure__version__ = "0.5.0"__author__ = "Samuel Sutch"__license__ = "MIT"__copyright__ = "Copyrighit 2013 Samuel Sutch"

pyapns/client.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,161 @@
33
import httplib
44
import functools
55
from sys import hexversion
6+
import requests
7+
from pyapns import _json as json
8+
from pyapns.model import Notification, DisconnectionEvent
9+
10+
11+
class ClientError(Exception):
12+
def __init__(self, message, response):
13+
super(ClientError, self).__init__(message)
14+
self.message = message
15+
self.response = response
16+
17+
18+
class Client(object):
19+
@property
20+
def connection(self):
21+
con = getattr(self, '_connection', None)
22+
if con is None:
23+
con = self._connection = requests.Session()
24+
return con
25+
26+
def __init__(self, host='http://localhost', port=8088, timeout=20):
27+
self.host = host.strip('/')
28+
self.port = port
29+
self.timeout = timeout
30+
31+
def provision(self, app_id, environment, certificate, timeout=15):
32+
"""
33+
Tells the pyapns server that we want set up an app to receive
34+
notifications from us.
35+
36+
:param app_id: An app id, you can use anything but it's
37+
recommended to just use the bundle identifier used in your app.
38+
:type app_id: string
39+
40+
:param environment: Which environment are you using? This value
41+
must be either "production" or "sandbox".
42+
:type environment: string
43+
44+
:param certificate: A path to a encoded, password-free .pem file
45+
on the pyapns host. This must be a path local to the host! You
46+
can also read an entire .pem file in and send it in this value
47+
as well.
48+
:type certificate: string
49+
50+
:returns: Dictionary-representation of the App record
51+
:rtype: dict
52+
"""
53+
status, resp = self._request(
54+
'POST', 'apps/{}/{}'.format(app_id, environment),
55+
data={'certificate': certificate, 'timeout': timeout}
56+
)
57+
if status != 201:
58+
raise ClientError('Unable to provision app id', resp)
59+
return resp['response']
60+
61+
def notify(self, app_id, environment, notifications):
62+
"""
63+
Sends notifications to clients via the pyapns server. The
64+
`app_id` and `environment` must be previously provisioned
65+
values--either by using the :py:meth:`provision` method or
66+
having been bootstrapped on the server.
67+
68+
`notifications` is a list of notification dictionaries that all
69+
must have the following keys:
70+
71+
* `payload` is the actual notification dict to be jsonified
72+
* `token` is the hexlified device token you scraped from
73+
the client
74+
* `identifier` is a unique id specific to this id. for this
75+
you may use any value--pyapns will generate its own
76+
internal ID to track it. The APN gateway only allows for
77+
this to be 4 bytes.
78+
* `expiry` is how long the notification should be retried
79+
for if for some reason the apple servers can not contact
80+
the device
81+
82+
You can also construct a :py:class:`Notification` object--the
83+
dict and class representations are interchangable here.
84+
85+
:param app_id: Which app id to use
86+
:type app_id: string
87+
88+
:param environmenet: The environment for the app_id
89+
:type environment: string
90+
91+
:param notifications: A list of notification dictionaries
92+
(see the discussion above)
93+
:type notifications: list
94+
95+
:returns: Empty response--this method doesn't return anything
96+
:rtype: dict
97+
"""
98+
notes = []
99+
for note in notifications:
100+
if isinstance(note, dict):
101+
notes.append(Notification.from_simple(note))
102+
elif isinstance(note, Notification):
103+
notes.append(note)
104+
else:
105+
raise ValueError('Unknown notification: {}'.format(repr(note)))
106+
data = [n.to_simple() for n in notes]
107+
108+
status, resp = self._request(
109+
'POST', 'apps/{}/{}/notifications'.format(app_id, environment),
110+
data=data
111+
)
112+
if status != 201:
113+
raise ClientError('Could not send notifications', resp)
114+
return resp['response']
115+
116+
def feedback(self, app_id, environment):
117+
"""
118+
"""
119+
status, feedbacks = self._request(
120+
'GET', 'apps/{}/{}/feedback'.format(app_id, environment)
121+
)
122+
if status != 200:
123+
raise ClientError('Could not fetch feedbacks', resp)
124+
return feedbacks['response']
125+
126+
def disconnections(self, app_id, environment):
127+
"""
128+
Retrieves a list of the 5000 most recent disconnection events
129+
recorded by pyapns. Each time apple severs the connection with
130+
pyapns it will try to send back an error packet describing which
131+
notification caused the error and the error that occurred.
132+
"""
133+
status, disconnects = self._request(
134+
'GET', 'apps/{}/{}/disconnections'.format(app_id, environment)
135+
)
136+
if status != 200:
137+
raise ClientError('Could not retrieve disconnections')
138+
ret = []
139+
for evt in disconnects['response']:
140+
ret.append(DisconnectionEvent.from_simple(evt))
141+
return ret
142+
143+
def _request(self, method, path, args=None, data=None):
144+
url = '{}:{}/{}'.format(self.host, self.port, path)
145+
kwargs = {'timeout': self.timeout}
146+
if args is not None:
147+
kwargs['params'] = args
148+
if data is not None:
149+
kwargs['data'] = json.dumps(data)
150+
151+
func = getattr(self.connection, method.lower())
152+
resp = func(url, **kwargs)
153+
if resp.headers['content-type'].startswith('application/json'):
154+
resp_data = json.loads(resp.content)
155+
else:
156+
resp_data = None
157+
return resp.status_code, resp_data
158+
159+
160+
## OLD XML-RPC INTERFACE ------------------------------------------------------
6161

7162
OPTIONS = {'CONFIGURED': False, 'TIMEOUT': 20}
8163

pyapns/model.py

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -106,12 +106,14 @@ def feedback(self):
106106
d = self.connection.read()
107107
def decode(raw_feedback):
108108
feedbacks = decode_feedback(raw_feedback)
109-
return [{'type': 'feedback',
110-
'timestamp': (
109+
return [
110+
{
111+
'type': 'feedback',
112+
'timestamp': (
111113
float(calendar.timegm(ts.timetuple()))
112114
+ float(ts.microsecond) / 1e6
113-
),
114-
'token': tok
115+
),
116+
'token': tok
115117
} for ts, tok in feedbacks]
116118
d.addCallback(decode)
117119
return d
@@ -185,30 +187,27 @@ class Notification(object):
185187
* `identifier` is a unique id specific to this id. for this you
186188
may use a UUID--but we will generate our own internal ID to track
187189
it. The APN gateway only allows for this to be 4 bytes.
188-
189-
Identifier is actually optional--we'll generate one if not
190-
provided however then the disconnection log will be pretty much
191-
useless.
192190
* `expiry` is how long the notification should be retried for if
193191
for some reason the apple servers can not contact the device
194192
"""
195193

196194
__slots__ = ('token', 'payload', 'expiry', 'identifier', 'internal_identifier')
197195

198-
def __init__(self):
199-
self.token = None
200-
self.payload = None
201-
self.expiry = None
202-
self.identifier = None
203-
self.internal_identifier = None
196+
def __init__(self, token=None, payload=None, expiry=None, identifier=None,
197+
internal_identifier=None):
198+
self.token = token
199+
self.payload = payload
200+
self.expiry = expiry
201+
self.identifier = identifier
202+
self.internal_identifier = internal_identifier
204203

205204
@classmethod
206205
def from_simple(cls, data, instance=None):
207206
note = instance or cls()
208207
note.token = data['token']
209208
note.payload = data['payload']
210209
note.expiry = int(data['expiry'])
211-
note.identifier = int(data['identifier'])
210+
note.identifier = data['identifier']
212211
return note
213212

214213
def to_simple(self):
@@ -239,7 +238,7 @@ def binaryify(t):
239238

240239
def __repr__(self):
241240
return u'<Notification token={} identifier={} expiry={} payload={}>'.format(
242-
self.token, self.internal_identifier, self.expiry, self.payload
241+
self.token, self.identifier, self.expiry, self.payload
243242
)
244243

245244

@@ -282,6 +281,17 @@ def to_simple(self):
282281
'verbose_message': APNS_STATUS_CODES[self.code]
283282
}
284283

284+
@classmethod
285+
def from_simple(cls, data):
286+
evt = cls()
287+
evt.code = data['code']
288+
evt.identifier = data['internal_identifier']
289+
evt.timestamp = datetime.datetime.utcfromtimestamp(data['timestamp'])
290+
if 'offending_notification' in data:
291+
evt.offending_notification = \
292+
Notification.from_simple(data['offending_notification'])
293+
return evt
294+
285295
@classmethod
286296
def from_apn_wire_format(cls, packet):
287297
fmt = '!Bbl'

pyapns/rest_service.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ def render_POST(self, request):
161161
# returns a deferred but we're not making the client wait
162162
self.app.notify([Notification.from_simple(n) for n in notifications])
163163

164-
return json_response({}, request)
164+
return json_response({}, request, 201)
165165

166166

167167
class DisconnectionLogResource(AppEnvResourceBase):

requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
Twisted>=11.0.0
2-
pyOpenSSL>=0.10
2+
pyOpenSSL>=0.10
3+
requests>=1.0

0 commit comments

Comments
 (0)