From b167479e33dd3ec136de9538e8947864d1646def Mon Sep 17 00:00:00 2001 From: Samuel Sutch Date: Thu, 24 Jan 2013 13:58:39 -0800 Subject: [PATCH 01/40] ran autopep8 --- pyapns/_json.py | 28 +-- pyapns/client.py | 254 ++++++++++---------- pyapns/server.py | 590 ++++++++++++++++++++++++----------------------- 3 files changed, 452 insertions(+), 420 deletions(-) diff --git a/pyapns/_json.py b/pyapns/_json.py index dfaa881..0d2d713 100644 --- a/pyapns/_json.py +++ b/pyapns/_json.py @@ -1,21 +1,21 @@ try: - import json + import json except (ImportError, NameError): - try: - from django.utils import simplejson as json - except (ImportError, NameError): - import simplejson as json + try: + from django.utils import simplejson as json + except (ImportError, NameError): + import simplejson as json try: - json.dumps - json.loads + json.dumps + json.loads except AttributeError: - try: # monkey patching for python-json package - json.dumps = lambda obj, *args, **kwargs: json.write(obj) - json.loads = lambda str, *args, **kwargs: json.read(str) - except AttributeError: - raise ImportError('Could not load an apropriate JSON library ' - 'currently supported are simplejson, ' - 'python2.6+ json and python-json') + try: # monkey patching for python-json package + json.dumps = lambda obj, *args, **kwargs: json.write(obj) + json.loads = lambda str, *args, **kwargs: json.read(str) + except AttributeError: + raise ImportError('Could not load an apropriate JSON library ' + 'currently supported are simplejson, ' + 'python2.6+ json and python-json') loads = json.loads dumps = json.dumps diff --git a/pyapns/client.py b/pyapns/client.py index ca106a2..08632b4 100644 --- a/pyapns/client.py +++ b/pyapns/client.py @@ -6,127 +6,141 @@ OPTIONS = {'CONFIGURED': False, 'TIMEOUT': 20} + def configure(opts): - if not OPTIONS['CONFIGURED']: - try: # support for django - import django.conf - OPTIONS.update(django.conf.settings.PYAPNS_CONFIG) - OPTIONS['CONFIGURED'] = True - except: - pass - if not OPTIONS['CONFIGURED']: - try: # support for programatic configuration - OPTIONS.update(opts) - OPTIONS['CONFIGURED'] = True - except: - pass if not OPTIONS['CONFIGURED']: - try: # pylons support - import pylons.config - OPTIONS.update({'HOST': pylons.config.get('pyapns_host')}) - try: - OPTIONS.update({'TIMEOUT': int(pylons.config.get('pyapns_timeout'))}) + try: # support for django + import django.conf + OPTIONS.update(django.conf.settings.PYAPNS_CONFIG) + OPTIONS['CONFIGURED'] = True except: - pass # ignore, an optional value - OPTIONS['CONFIGURED'] = True - except: - pass - # provision initial app_ids - if 'INITIAL' in OPTIONS: - for args in OPTIONS['INITIAL']: - provision(*args) - return OPTIONS['CONFIGURED'] + pass + if not OPTIONS['CONFIGURED']: + try: # support for programatic configuration + OPTIONS.update(opts) + OPTIONS['CONFIGURED'] = True + except: + pass + if not OPTIONS['CONFIGURED']: + try: # pylons support + import pylons.config + OPTIONS.update({'HOST': pylons.config.get('pyapns_host')}) + try: + OPTIONS.update( + {'TIMEOUT': int(pylons.config.get('pyapns_timeout'))}) + except: + pass # ignore, an optional value + OPTIONS['CONFIGURED'] = True + except: + pass + # provision initial app_ids + if 'INITIAL' in OPTIONS: + for args in OPTIONS['INITIAL']: + provision(*args) + return OPTIONS['CONFIGURED'] + + +class UnknownAppID(Exception): + pass -class UnknownAppID(Exception): pass -class APNSNotConfigured(Exception): pass +class APNSNotConfigured(Exception): + pass + def reprovision_and_retry(func): - """ - Wraps the `errback` callback of the API functions, automatically trying to - re-provision if the app ID can not be found during the operation. If that's - unsuccessful, it will raise the UnknownAppID error. - """ - @functools.wraps(func) - def wrapper(*a, **kw): - errback = kw.get('errback', None) - if errback is None: - def errback(e): - raise e - def errback_wrapper(e): - if isinstance(e, UnknownAppID) and 'INITIAL' in OPTIONS: - try: - for initial in OPTIONS['INITIAL']: - provision(*initial) # retry provisioning the initial setup - func(*a, **kw) # and try the function once more - except Exception, new_exc: - errback(new_exc) # throwing the new exception - else: - errback(e) # not an instance of UnknownAppID - nothing we can do here - kw['errback'] = errback_wrapper - return func(*a, **kw) - return wrapper + """ + Wraps the `errback` callback of the API functions, automatically trying to + re-provision if the app ID can not be found during the operation. If that's + unsuccessful, it will raise the UnknownAppID error. + """ + @functools.wraps(func) + def wrapper(*a, **kw): + errback = kw.get('errback', None) + if errback is None: + def errback(e): + raise e + + def errback_wrapper(e): + if isinstance(e, UnknownAppID) and 'INITIAL' in OPTIONS: + try: + for initial in OPTIONS['INITIAL']: + provision( + *initial) # retry provisioning the initial setup + func(*a, **kw) # and try the function once more + except Exception, new_exc: + errback(new_exc) # throwing the new exception + else: + errback(e) # not an instance of UnknownAppID - nothing we can do here + kw['errback'] = errback_wrapper + return func(*a, **kw) + return wrapper + def default_callback(func): - @functools.wraps(func) - def wrapper(*a, **kw): - if 'callback' not in kw: - kw['callback'] = lambda c: c - return func(*a, **kw) - return wrapper + @functools.wraps(func) + def wrapper(*a, **kw): + if 'callback' not in kw: + kw['callback'] = lambda c: c + return func(*a, **kw) + return wrapper + @default_callback @reprovision_and_retry -def provision(app_id, path_to_cert, environment, timeout=15, async=False, +def provision(app_id, path_to_cert, environment, timeout=15, async=False, callback=None, errback=None): - args = [app_id, path_to_cert, environment, timeout] - f_args = ['provision', args, callback, errback] - if not async: - return _xmlrpc_thread(*f_args) - t = threading.Thread(target=_xmlrpc_thread, args=f_args) - t.daemon = True - t.start() + args = [app_id, path_to_cert, environment, timeout] + f_args = ['provision', args, callback, errback] + if not async: + return _xmlrpc_thread(*f_args) + t = threading.Thread(target=_xmlrpc_thread, args=f_args) + t.daemon = True + t.start() + @default_callback @reprovision_and_retry -def notify(app_id, tokens, notifications, async=False, callback=None, +def notify(app_id, tokens, notifications, async=False, callback=None, errback=None): - args = [app_id, tokens, notifications] - f_args = ['notify', args, callback, errback] - if not async: - return _xmlrpc_thread(*f_args) - t = threading.Thread(target=_xmlrpc_thread, args=f_args) - t.daemon = True - t.start() + args = [app_id, tokens, notifications] + f_args = ['notify', args, callback, errback] + if not async: + return _xmlrpc_thread(*f_args) + t = threading.Thread(target=_xmlrpc_thread, args=f_args) + t.daemon = True + t.start() + @default_callback @reprovision_and_retry def feedback(app_id, async=False, callback=None, errback=None): - args = [app_id] - f_args = ['feedback', args, callback, errback] - if not async: - return _xmlrpc_thread(*f_args) - t = threading.Thread(target=_xmlrpc_thread, args=f_args) - t.daemon = True - t.start() + args = [app_id] + f_args = ['feedback', args, callback, errback] + if not async: + return _xmlrpc_thread(*f_args) + t = threading.Thread(target=_xmlrpc_thread, args=f_args) + t.daemon = True + t.start() + def _xmlrpc_thread(method, args, callback, errback=None): - if not configure({}): - raise APNSNotConfigured('APNS Has not been configured.') - proxy = ServerProxy(OPTIONS['HOST'], allow_none=True, use_datetime=True, - timeout=OPTIONS['TIMEOUT']) - try: - parts = method.strip().split('.') - for part in parts: - proxy = getattr(proxy, part) - return callback(proxy(*args)) - except xmlrpclib.Fault, e: - if e.faultCode == 404: - e = UnknownAppID() - if errback is not None: - errback(e) - else: - raise e + if not configure({}): + raise APNSNotConfigured('APNS Has not been configured.') + proxy = ServerProxy(OPTIONS['HOST'], allow_none=True, use_datetime=True, + timeout=OPTIONS['TIMEOUT']) + try: + parts = method.strip().split('.') + for part in parts: + proxy = getattr(proxy, part) + return callback(proxy(*args)) + except xmlrpclib.Fault, e: + if e.faultCode == 404: + e = UnknownAppID() + if errback is not None: + errback(e) + else: + raise e ## -------------------------------------------------------------- @@ -135,29 +149,31 @@ def _xmlrpc_thread(method, args, callback, errback=None): ## -------------------------------------------------------------- def ServerProxy(url, *args, **kwargs): - t = TimeoutTransport() - t.timeout = kwargs.pop('timeout', 20) - kwargs['transport'] = t - return xmlrpclib.ServerProxy(url, *args, **kwargs) + t = TimeoutTransport() + t.timeout = kwargs.pop('timeout', 20) + kwargs['transport'] = t + return xmlrpclib.ServerProxy(url, *args, **kwargs) + class TimeoutTransport(xmlrpclib.Transport): - def make_connection(self, host): - if hexversion < 0x02070000: - conn = TimeoutHTTP(host) - conn.set_timeout(self.timeout) - else: - conn = TimeoutHTTPConnection(host) - conn.timeout = self.timeout - return conn + def make_connection(self, host): + if hexversion < 0x02070000: + conn = TimeoutHTTP(host) + conn.set_timeout(self.timeout) + else: + conn = TimeoutHTTPConnection(host) + conn.timeout = self.timeout + return conn + class TimeoutHTTPConnection(httplib.HTTPConnection): - def connect(self): - httplib.HTTPConnection.connect(self) - self.sock.settimeout(self.timeout) - + def connect(self): + httplib.HTTPConnection.connect(self) + self.sock.settimeout(self.timeout) + + class TimeoutHTTP(httplib.HTTP): - _connection_class = TimeoutHTTPConnection - - def set_timeout(self, timeout): - self._conn.timeout = timeout - \ No newline at end of file + _connection_class = TimeoutHTTPConnection + + def set_timeout(self, timeout): + self._conn.timeout = timeout diff --git a/pyapns/server.py b/pyapns/server.py index a4ec928..a6b0539 100644 --- a/pyapns/server.py +++ b/pyapns/server.py @@ -7,7 +7,7 @@ from OpenSSL import SSL, crypto from twisted.internet import reactor, defer from twisted.internet.protocol import ( - ReconnectingClientFactory, ClientFactory, Protocol, ServerFactory) + ReconnectingClientFactory, ClientFactory, Protocol, ServerFactory) from twisted.internet.ssl import ClientContextFactory from twisted.application import service from twisted.protocols.basic import LineReceiver @@ -23,328 +23,344 @@ FEEDBACK_SERVER_HOSTNAME = "feedback.push.apple.com" FEEDBACK_SERVER_PORT = 2196 -app_ids = {} # {'app_id': APNSService()} +app_ids = {} # {'app_id': APNSService()} + class StringIO(_StringIO): - """Add context management protocol to StringIO - ie: http://bugs.python.org/issue1286 - """ - - def __enter__(self): - if self.closed: - raise ValueError('I/O operation on closed file') - return self - - def __exit__(self, exc, value, tb): - self.close() + """Add context management protocol to StringIO + ie: http://bugs.python.org/issue1286 + """ + + def __enter__(self): + if self.closed: + raise ValueError('I/O operation on closed file') + return self + + def __exit__(self, exc, value, tb): + self.close() + class IAPNSService(Interface): """ Interface for APNS """ - + def write(self, notification): """ Write the notification to APNS """ - + def read(self): """ Read from the feedback service """ class APNSClientContextFactory(ClientContextFactory): - def __init__(self, ssl_cert_file): - if 'BEGIN CERTIFICATE' not in ssl_cert_file: - log.msg('APNSClientContextFactory ssl_cert_file=%s' % ssl_cert_file) - else: - log.msg('APNSClientContextFactory ssl_cert_file={FROM_STRING}') - self.ctx = SSL.Context(SSL.SSLv3_METHOD) - if 'BEGIN CERTIFICATE' in ssl_cert_file: - cer = crypto.load_certificate(crypto.FILETYPE_PEM, ssl_cert_file) - pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, ssl_cert_file) - self.ctx.use_certificate(cer) - self.ctx.use_privatekey(pkey) - else: - self.ctx.use_certificate_file(ssl_cert_file) - self.ctx.use_privatekey_file(ssl_cert_file) - - def getContext(self): - return self.ctx + def __init__(self, ssl_cert_file): + if 'BEGIN CERTIFICATE' not in ssl_cert_file: + log.msg( + 'APNSClientContextFactory ssl_cert_file=%s' % ssl_cert_file) + else: + log.msg('APNSClientContextFactory ssl_cert_file={FROM_STRING}') + self.ctx = SSL.Context(SSL.SSLv3_METHOD) + if 'BEGIN CERTIFICATE' in ssl_cert_file: + cer = crypto.load_certificate(crypto.FILETYPE_PEM, ssl_cert_file) + pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, ssl_cert_file) + self.ctx.use_certificate(cer) + self.ctx.use_privatekey(pkey) + else: + self.ctx.use_certificate_file(ssl_cert_file) + self.ctx.use_privatekey_file(ssl_cert_file) + + def getContext(self): + return self.ctx class APNSProtocol(Protocol): - def connectionMade(self): - log.msg('APNSProtocol connectionMade') - self.factory.addClient(self) - - def sendMessage(self, msg): - log.msg('APNSProtocol sendMessage msg=%s' % binascii.hexlify(msg)) - return self.transport.write(msg) - - def connectionLost(self, reason): - log.msg('APNSProtocol connectionLost') - self.factory.removeClient(self) + def connectionMade(self): + log.msg('APNSProtocol connectionMade') + self.factory.addClient(self) + + def sendMessage(self, msg): + log.msg('APNSProtocol sendMessage msg=%s' % binascii.hexlify(msg)) + return self.transport.write(msg) + + def connectionLost(self, reason): + log.msg('APNSProtocol connectionLost') + self.factory.removeClient(self) class APNSFeedbackHandler(LineReceiver): - MAX_LENGTH = 1024*1024 - - def connectionMade(self): - log.msg('feedbackHandler connectionMade') + MAX_LENGTH = 1024 * 1024 + + def connectionMade(self): + log.msg('feedbackHandler connectionMade') + + def rawDataReceived(self, data): + log.msg('feedbackHandler rawDataReceived %s' % binascii.hexlify(data)) + self.io.write(data) - def rawDataReceived(self, data): - log.msg('feedbackHandler rawDataReceived %s' % binascii.hexlify(data)) - self.io.write(data) - - def lineReceived(self, data): - log.msg('feedbackHandler lineReceived %s' % binascii.hexlify(data)) - self.io.write(data) + def lineReceived(self, data): + log.msg('feedbackHandler lineReceived %s' % binascii.hexlify(data)) + self.io.write(data) - def connectionLost(self, reason): - log.msg('feedbackHandler connectionLost %s' % reason) - self.deferred.callback(self.io.getvalue()) - self.io.close() + def connectionLost(self, reason): + log.msg('feedbackHandler connectionLost %s' % reason) + self.deferred.callback(self.io.getvalue()) + self.io.close() class APNSFeedbackClientFactory(ClientFactory): - protocol = APNSFeedbackHandler - - def __init__(self): - self.deferred = defer.Deferred() - - def buildProtocol(self, addr): - p = self.protocol() - p.factory = self - p.deferred = self.deferred - p.io = StringIO() - p.setRawMode() - return p - - def startedConnecting(self, connector): - log.msg('APNSFeedbackClientFactory startedConnecting') - - def clientConnectionLost(self, connector, reason): - log.msg('APNSFeedbackClientFactory clientConnectionLost reason=%s' % reason) - ClientFactory.clientConnectionLost(self, connector, reason) - - def clientConnectionFailed(self, connector, reason): - log.msg('APNSFeedbackClientFactory clientConnectionFailed reason=%s' % reason) - ClientFactory.clientConnectionLost(self, connector, reason) + protocol = APNSFeedbackHandler + + def __init__(self): + self.deferred = defer.Deferred() + + def buildProtocol(self, addr): + p = self.protocol() + p.factory = self + p.deferred = self.deferred + p.io = StringIO() + p.setRawMode() + return p + + def startedConnecting(self, connector): + log.msg('APNSFeedbackClientFactory startedConnecting') + + def clientConnectionLost(self, connector, reason): + log.msg('APNSFeedbackClientFactory clientConnectionLost reason=%s' % + reason) + ClientFactory.clientConnectionLost(self, connector, reason) + + def clientConnectionFailed(self, connector, reason): + log.msg('APNSFeedbackClientFactory clientConnectionFailed reason=%s' % + reason) + ClientFactory.clientConnectionLost(self, connector, reason) class APNSClientFactory(ReconnectingClientFactory): - protocol = APNSProtocol - - def __init__(self): - self.clientProtocol = None - self.deferred = defer.Deferred() - self.deferred.addErrback(log_errback('APNSClientFactory __init__')) - - def addClient(self, p): - self.clientProtocol = p - self.deferred.callback(p) - - def removeClient(self, p): - self.clientProtocol = None - self.deferred = defer.Deferred() - self.deferred.addErrback(log_errback('APNSClientFactory removeClient')) - - def startedConnecting(self, connector): - log.msg('APNSClientFactory startedConnecting') - - def buildProtocol(self, addr): - self.resetDelay() - p = self.protocol() - p.factory = self - return p - - def clientConnectionLost(self, connector, reason): - log.msg('APNSClientFactory clientConnectionLost reason=%s' % reason) - ReconnectingClientFactory.clientConnectionLost(self, connector, reason) - - def clientConnectionFailed(self, connector, reason): - log.msg('APNSClientFactory clientConnectionFailed reason=%s' % reason) - ReconnectingClientFactory.clientConnectionLost(self, connector, reason) + protocol = APNSProtocol + + def __init__(self): + self.clientProtocol = None + self.deferred = defer.Deferred() + self.deferred.addErrback(log_errback('APNSClientFactory __init__')) + + def addClient(self, p): + self.clientProtocol = p + self.deferred.callback(p) + + def removeClient(self, p): + self.clientProtocol = None + self.deferred = defer.Deferred() + self.deferred.addErrback(log_errback('APNSClientFactory removeClient')) + + def startedConnecting(self, connector): + log.msg('APNSClientFactory startedConnecting') + + def buildProtocol(self, addr): + self.resetDelay() + p = self.protocol() + p.factory = self + return p + + def clientConnectionLost(self, connector, reason): + log.msg('APNSClientFactory clientConnectionLost reason=%s' % reason) + ReconnectingClientFactory.clientConnectionLost(self, connector, reason) + + def clientConnectionFailed(self, connector, reason): + log.msg('APNSClientFactory clientConnectionFailed reason=%s' % reason) + ReconnectingClientFactory.clientConnectionLost(self, connector, reason) class APNSService(service.Service): - """ A Service that sends notifications and receives - feedback from the Apple Push Notification Service - """ - - implements(IAPNSService) - clientProtocolFactory = APNSClientFactory - feedbackProtocolFactory = APNSFeedbackClientFactory - - def __init__(self, cert_path, environment, timeout=15): - log.msg('APNSService __init__') - self.factory = None - self.environment = environment - self.cert_path = cert_path - self.raw_mode = False - self.timeout = timeout - - def getContextFactory(self): - return APNSClientContextFactory(self.cert_path) - - def write(self, notifications): - "Connect to the APNS service and send notifications" - if not self.factory: - log.msg('APNSService write (connecting)') - server, port = ((APNS_SERVER_SANDBOX_HOSTNAME - if self.environment == 'sandbox' - else APNS_SERVER_HOSTNAME), APNS_SERVER_PORT) - self.factory = self.clientProtocolFactory() - context = self.getContextFactory() - reactor.connectSSL(server, port, self.factory, context) - - client = self.factory.clientProtocol - if client: - return client.sendMessage(notifications) - else: - d = self.factory.deferred - timeout = reactor.callLater(self.timeout, - lambda: d.called or d.errback( - Exception('Notification timed out after %i seconds' % self.timeout))) - def cancel_timeout(r): - try: timeout.cancel() - except: pass - return r - - d.addCallback(lambda p: p.sendMessage(notifications)) - d.addErrback(log_errback('apns-service-write')) - d.addBoth(cancel_timeout) - return d - - def read(self): - "Connect to the feedback service and read all data." - log.msg('APNSService read (connecting)') - try: - server, port = ((FEEDBACK_SERVER_SANDBOX_HOSTNAME - if self.environment == 'sandbox' - else FEEDBACK_SERVER_HOSTNAME), FEEDBACK_SERVER_PORT) - factory = self.feedbackProtocolFactory() - context = self.getContextFactory() - reactor.connectSSL(server, port, factory, context) - factory.deferred.addErrback(log_errback('apns-feedback-read')) - - timeout = reactor.callLater(self.timeout, - lambda: factory.deferred.called or factory.deferred.errback( - Exception('Feedbcak fetch timed out after %i seconds' % self.timeout))) - def cancel_timeout(r): - try: timeout.cancel() - except: pass - return r - - factory.deferred.addBoth(cancel_timeout) - except Exception, e: - log.err('APNService feedback error initializing: %s' % str(e)) - raise - return factory.deferred + """ A Service that sends notifications and receives + feedback from the Apple Push Notification Service + """ + + implements(IAPNSService) + clientProtocolFactory = APNSClientFactory + feedbackProtocolFactory = APNSFeedbackClientFactory + + def __init__(self, cert_path, environment, timeout=15): + log.msg('APNSService __init__') + self.factory = None + self.environment = environment + self.cert_path = cert_path + self.raw_mode = False + self.timeout = timeout + + def getContextFactory(self): + return APNSClientContextFactory(self.cert_path) + + def write(self, notifications): + "Connect to the APNS service and send notifications" + if not self.factory: + log.msg('APNSService write (connecting)') + server, port = ((APNS_SERVER_SANDBOX_HOSTNAME + if self.environment == 'sandbox' + else APNS_SERVER_HOSTNAME), APNS_SERVER_PORT) + self.factory = self.clientProtocolFactory() + context = self.getContextFactory() + reactor.connectSSL(server, port, self.factory, context) + + client = self.factory.clientProtocol + if client: + return client.sendMessage(notifications) + else: + d = self.factory.deferred + timeout = reactor.callLater(self.timeout, + lambda: d.called or d.errback( + Exception('Notification timed out after %i seconds' % self.timeout))) + + def cancel_timeout(r): + try: + timeout.cancel() + except: + pass + return r + + d.addCallback(lambda p: p.sendMessage(notifications)) + d.addErrback(log_errback('apns-service-write')) + d.addBoth(cancel_timeout) + return d + + def read(self): + "Connect to the feedback service and read all data." + log.msg('APNSService read (connecting)') + try: + server, port = ((FEEDBACK_SERVER_SANDBOX_HOSTNAME + if self.environment == 'sandbox' + else FEEDBACK_SERVER_HOSTNAME), FEEDBACK_SERVER_PORT) + factory = self.feedbackProtocolFactory() + context = self.getContextFactory() + reactor.connectSSL(server, port, factory, context) + factory.deferred.addErrback(log_errback('apns-feedback-read')) + + timeout = reactor.callLater(self.timeout, + lambda: factory.deferred.called or factory.deferred.errback( + Exception('Feedbcak fetch timed out after %i seconds' % self.timeout))) + + def cancel_timeout(r): + try: + timeout.cancel() + except: + pass + return r + + factory.deferred.addBoth(cancel_timeout) + except Exception, e: + log.err('APNService feedback error initializing: %s' % str(e)) + raise + return factory.deferred class APNSServer(xmlrpc.XMLRPC): - def __init__(self): - self.app_ids = app_ids - self.use_date_time = True - self.useDateTime = True - xmlrpc.XMLRPC.__init__(self, allowNone=True) - - def apns_service(self, app_id): - if app_id not in app_ids: - raise xmlrpc.Fault(404, 'The app_id specified has not been provisioned.') - return self.app_ids[app_id] - - def xmlrpc_provision(self, app_id, path_to_cert_or_cert, environment, timeout=15): - """ Starts an APNSService for the this app_id and keeps it running - - Arguments: - app_id the app_id to provision for APNS - path_to_cert_or_cert absolute path to the APNS SSL cert or a - string containing the .pem file - environment either 'sandbox' or 'production' - timeout seconds to timeout connection attempts - to the APNS server - Returns: - None - """ - - if environment not in ('sandbox', 'production'): - raise xmlrpc.Fault(401, 'Invalid environment provided `%s`. Valid ' - 'environments are `sandbox` and `production`' % ( - environment,)) - if not app_id in self.app_ids: - # log.msg('provisioning ' + app_id + ' environment ' + environment) - self.app_ids[app_id] = APNSService(path_to_cert_or_cert, environment, timeout) - - def xmlrpc_notify(self, app_id, token_or_token_list, aps_dict_or_list): - """ Sends push notifications to the Apple APNS server. Multiple - notifications can be sent by sending pairing the token/notification - arguments in lists [token1, token2], [notification1, notification2]. - - Arguments: - app_id provisioned app_id to send to - token_or_token_list token to send the notification or a list of tokens - aps_dict_or_list notification dicts or a list of notifications - Returns: - None - """ - d = self.apns_service(app_id).write( - encode_notifications( - [t.replace(' ', '') for t in token_or_token_list] - if (type(token_or_token_list) is list) - else token_or_token_list.replace(' ', ''), - aps_dict_or_list)) - if d: - def _finish_err(r): - # so far, the only error that could really become of this - # request is a timeout, since APNS simply terminates connectons - # that are made unsuccessfully, which twisted will try endlessly - # to reconnect to, we timeout and notifify the client - raise xmlrpc.Fault(500, 'Connection to the APNS server could not be made.') - return d.addCallbacks(lambda r: None, _finish_err) - - def xmlrpc_feedback(self, app_id): - """ Queries the Apple APNS feedback server for inactive app tokens. Returns - a list of tuples as (datetime_went_dark, token_str). - - Arguments: - app_id the app_id to query - Returns: - Feedback tuples like (datetime_expired, token_str) - """ - - return self.apns_service(app_id).read().addCallback( - lambda r: decode_feedback(r)) + def __init__(self): + self.app_ids = app_ids + self.use_date_time = True + self.useDateTime = True + xmlrpc.XMLRPC.__init__(self, allowNone=True) + + def apns_service(self, app_id): + if app_id not in app_ids: + raise xmlrpc.Fault( + 404, 'The app_id specified has not been provisioned.') + return self.app_ids[app_id] + + def xmlrpc_provision(self, app_id, path_to_cert_or_cert, environment, timeout=15): + """ Starts an APNSService for the this app_id and keeps it running + + Arguments: + app_id the app_id to provision for APNS + path_to_cert_or_cert absolute path to the APNS SSL cert or a + string containing the .pem file + environment either 'sandbox' or 'production' + timeout seconds to timeout connection attempts + to the APNS server + Returns: + None + """ + + if environment not in ('sandbox', 'production'): + raise xmlrpc.Fault(401, 'Invalid environment provided `%s`. Valid ' + 'environments are `sandbox` and `production`' % ( + environment,)) + if not app_id in self.app_ids: + # log.msg('provisioning ' + app_id + ' environment ' + environment) + self.app_ids[app_id] = APNSService( + path_to_cert_or_cert, environment, timeout) + + def xmlrpc_notify(self, app_id, token_or_token_list, aps_dict_or_list): + """ Sends push notifications to the Apple APNS server. Multiple + notifications can be sent by sending pairing the token/notification + arguments in lists [token1, token2], [notification1, notification2]. + + Arguments: + app_id provisioned app_id to send to + token_or_token_list token to send the notification or a list of tokens + aps_dict_or_list notification dicts or a list of notifications + Returns: + None + """ + d = self.apns_service(app_id).write( + encode_notifications( + [t.replace(' ', '') for t in token_or_token_list] + if (type(token_or_token_list) is list) + else token_or_token_list.replace(' ', ''), + aps_dict_or_list)) + if d: + def _finish_err(r): + # so far, the only error that could really become of this + # request is a timeout, since APNS simply terminates connectons + # that are made unsuccessfully, which twisted will try endlessly + # to reconnect to, we timeout and notifify the client + raise xmlrpc.Fault( + 500, 'Connection to the APNS server could not be made.') + return d.addCallbacks(lambda r: None, _finish_err) + + def xmlrpc_feedback(self, app_id): + """ Queries the Apple APNS feedback server for inactive app tokens. Returns + a list of tuples as (datetime_went_dark, token_str). + + Arguments: + app_id the app_id to query + Returns: + Feedback tuples like (datetime_expired, token_str) + """ + + return self.apns_service(app_id).read().addCallback( + lambda r: decode_feedback(r)) def encode_notifications(tokens, notifications): - """ Returns the encoded bytes of tokens and notifications - - tokens a list of tokens or a string of only one token - notifications a list of notifications or a dictionary of only one - """ - - fmt = "!BH32sH%ds" - structify = lambda t, p: struct.pack(fmt % len(p), 0, 32, t, len(p), p) - binaryify = lambda t: t.decode('hex') - if type(notifications) is dict and type(tokens) in (str, unicode): - tokens, notifications = ([tokens], [notifications]) - if type(notifications) is list and type(tokens) is list: - return ''.join(map(lambda y: structify(*y), ((binaryify(t), json.dumps(p, separators=(',',':'), ensure_ascii=False).encode('utf-8')) - for t, p in zip(tokens, notifications)))) + """ Returns the encoded bytes of tokens and notifications + + tokens a list of tokens or a string of only one token + notifications a list of notifications or a dictionary of only one + """ + + fmt = "!BH32sH%ds" + structify = lambda t, p: struct.pack(fmt % len(p), 0, 32, t, len(p), p) + binaryify = lambda t: t.decode('hex') + if type(notifications) is dict and type(tokens) in (str, unicode): + tokens, notifications = ([tokens], [notifications]) + if type(notifications) is list and type(tokens) is list: + return ''.join(map(lambda y: structify(*y), ((binaryify(t), json.dumps(p, separators=(',', ':'), ensure_ascii=False).encode('utf-8')) + for t, p in zip(tokens, notifications)))) + def decode_feedback(binary_tuples): - """ Returns a list of tuples in (datetime, token_str) format - - binary_tuples the binary-encoded feedback tuples - """ - - fmt = '!lh32s' - size = struct.calcsize(fmt) - with StringIO(binary_tuples) as f: - return [(datetime.datetime.fromtimestamp(ts), binascii.hexlify(tok)) - for ts, toklen, tok in (struct.unpack(fmt, tup) - for tup in iter(lambda: f.read(size), ''))] + """ Returns a list of tuples in (datetime, token_str) format + + binary_tuples the binary-encoded feedback tuples + """ + + fmt = '!lh32s' + size = struct.calcsize(fmt) + with StringIO(binary_tuples) as f: + return [(datetime.datetime.fromtimestamp(ts), binascii.hexlify(tok)) + for ts, toklen, tok in (struct.unpack(fmt, tup) + for tup in iter(lambda: f.read(size), ''))] + def log_errback(name): - def _log_errback(err, *args): - log.err('errback in %s : %s' % (name, str(err))) - return err - return _log_errback + def _log_errback(err, *args): + log.err('errback in %s : %s' % (name, str(err))) + return err + return _log_errback From 247bce5f1a8ab9ff42f39bd8dbc05f37dcdee724 Mon Sep 17 00:00:00 2001 From: Samuel Sutch Date: Thu, 24 Jan 2013 16:26:33 -0800 Subject: [PATCH 02/40] use json if available --- pyapns/_json.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pyapns/_json.py b/pyapns/_json.py index 0d2d713..b3e3830 100644 --- a/pyapns/_json.py +++ b/pyapns/_json.py @@ -1,5 +1,9 @@ try: - import json + try: + import usjon # try for ujson first because it rocks and is fast as hell + json = ujson + except ImportError: + import json except (ImportError, NameError): try: from django.utils import simplejson as json From f1ccd6d18423ec7c86ee44dd720931096a1749ee Mon Sep 17 00:00:00 2001 From: Samuel Sutch Date: Thu, 24 Jan 2013 23:58:00 -0800 Subject: [PATCH 03/40] feedback timestamps are in utc --- pyapns/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyapns/server.py b/pyapns/server.py index a6b0539..a1df72d 100644 --- a/pyapns/server.py +++ b/pyapns/server.py @@ -354,7 +354,7 @@ def decode_feedback(binary_tuples): fmt = '!lh32s' size = struct.calcsize(fmt) with StringIO(binary_tuples) as f: - return [(datetime.datetime.fromtimestamp(ts), binascii.hexlify(tok)) + return [(datetime.datetime.utcfromtimestamp(ts), binascii.hexlify(tok)) for ts, toklen, tok in (struct.unpack(fmt, tup) for tup in iter(lambda: f.read(size), ''))] From 63a78d96dfbb53c97add02c31a2850a2c3c24bb2 Mon Sep 17 00:00:00 2001 From: Samuel Sutch Date: Fri, 25 Jan 2013 00:11:03 -0800 Subject: [PATCH 04/40] ignore twistd run files --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 0e18061..8e521dd 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ build/** dist/** .idea atlassian-ide-plugin.xml +twistd.log +twistd.pid From 005dcc1b9a5bfc79a83ab1697e34936a79f9ffe2 Mon Sep 17 00:00:00 2001 From: Samuel Sutch Date: Fri, 25 Jan 2013 00:42:54 -0800 Subject: [PATCH 05/40] rest service, disconnection log, enhanced notifications this implements a new rest service to eventually replace the xmlrpc service. the xmlrpc service should continue working normally for now. the rest service uses the new enhanced notifications format and therefore can offer a disconnection log of recent disconnections and their reasons docs and client implementation forthcoming --- pyapns/model.py | 302 +++++++++++++++++++++++++++++++++++++++++ pyapns/rest_service.py | 188 +++++++++++++++++++++++++ pyapns/server.py | 20 ++- 3 files changed, 507 insertions(+), 3 deletions(-) create mode 100644 pyapns/model.py create mode 100644 pyapns/rest_service.py diff --git a/pyapns/model.py b/pyapns/model.py new file mode 100644 index 0000000..dfbf058 --- /dev/null +++ b/pyapns/model.py @@ -0,0 +1,302 @@ +import struct +import datetime +import calendar +from collections import defaultdict, deque +from pyapns.server import APNSService, decode_feedback +from pyapns import _json as json + + +class NoSuchAppException(Exception): + pass + + +class AppRegistry(object): + # stored as [app_name][environment] = App() + apps = defaultdict(dict) + + @classmethod + def all_apps(cls): + for envs in cls.apps.values(): + for e in envs.values(): + yield e + + @classmethod + def get(cls, name, environment): + if name not in cls.apps or environment not in cls.apps[name]: + raise NoSuchAppException() + else: + return cls.apps[name][environment] + + @classmethod + def put(cls, name, environment, cert, **attrs): + app = App(name, environment, cert, **attrs) + cls.apps[name][environment] = app + return app + +MAX_IDENT = 0xffff +START_IDENT = 0x0 + +class App(object): + @property + def connection(self): + """ + The pyapns.server.APNSService object - a kind of lazy connection object + """ + r = getattr(self, '_connection', None) + if r is None: + r = self._connection = APNSService( + self.cert, self.environment, self.timeout, + on_failure_received=self._on_apns_error + ) + return r + + + def __init__(self, name, environment, cert_file, timeout=15): + self.name = name + self.environment = environment + self.cert = cert_file + self.timeout = timeout + self.disconnections_to_keep = 5000 + self.recent_notifications_to_keep = 10000 + + self.disconnections = deque(maxlen=self.disconnections_to_keep) + + self.recent_notification_idents = deque() # recent external notification idents + self.recent_notifications = {} # maps external idents to notifications + + self.internal_idents = {} # maps internal idents to external idents + self.ident_counter = 0 + + def notify(self, notifications): + """ + Send some notifications to devices using the APN gateway. + + * `notifications` is a list of `Notification` objects. + + * Returns a deferred that will be fired when the connection is + opened if the connection was previously closed, otherwise just + returns None + + If when sending a notification and the connection to the APN gateway + is lost, we will read back the error message sent by Apple (the + "enhanced" format) and try to figure out which notification is was + the offending one. If found, it will be appended to the list of recent + disconnections accessible with `disconnections()`. + + In order to figure out which notification was offensive in the case of + a disconnect, we must keep the last N notifications sent over the + socket, in right now that number is 10000. + + We will only store the most recent 5000 disconnections. You should + probably pull the notifications at least every day, perhaps more + frequently for high-volume installations. + """ + self.remember_recent_notifications(notifications) + return self.connection.write(encode_notifications(notifications)) + + def feedback(self): + """ + Gets a list of tokens that are identified by Apple to be invalid. + This clears the backlog of invalid tokens on Apple's servers so do + your best to not loose it! + """ + # this is for testing feedback parsing w/o draining your feedbacks + # from twisted.internet.defer import Deferred + # import struct + # d = Deferred() + # d.callback(struct.pack('!lh32s', 42, 32, 'e6e9cf3d0405ee61eac9552a5a17bff62a64a131d03a2e1638d06c25e105c1e5'.decode('hex'))) + d = self.connection.read() + def decode(raw_feedback): + feedbacks = decode_feedback(raw_feedback) + print 'feedbacks', feedbacks + return [{'timestamp': ( + float(calendar.timegm(ts.timetuple())) + + float(ts.microsecond) / 1e6 + ), + 'token': tok + } for ts, tok in feedbacks] + d.addCallback(decode) + return d + + def to_simple(self): + return { + 'name': self.name, + 'environment': self.environment, + 'certificate': self.cert, + 'timeout': self.timeout, + 'type': 'app' + } + + def _on_apns_error(self, raw_error): + self.remember_disconnection( + DisconnectionEvent.from_apn_wire_format(raw_error) + ) + + def get_next_ident(self): + """ + Available range is between 0x0 and 0xffff because the 'ident' field + of the APN packet is a ushort + """ + if self.ident_counter > 0xffff: + self.ident_count = 0 + else: + self.ident_counter += 1 + return self.ident_counter + + def remember_recent_notifications(self, notifications): + for note in reversed(notifications): + # check whether we already saw this notification, ignore if so + existing_note = self.recent_notifications.get(note.identifier, None) + if existing_note is not None: + # they have the same external ident so they can share the same interna + note.internal_identifier = existing_note.internal_identifier + continue + + # make room for a notification if the remembered notifications is full + if len(self.recent_notification_idents) >= self.recent_notifications_to_keep: + removed_ident = self.recent_notification_idents.popleft() + removed_note = self.recent_notifications.pop(removed_ident) + self.internal_idents.pop(removed_note.internal_ident) + + # create a new internal identifier and map the notification to it + internal_ident = self.get_next_ident() + self.recent_notification_idents.append(note.identifier) + self.recent_notifications[note.identifier] = note + self.internal_idents[internal_ident] = note.identifier + note.internal_identifier = internal_ident + + def remember_disconnection(self, disconnection): + known_ident = self.internal_idents.get(disconnection.identifier, None) + if known_ident is not None and known_ident in self.recent_notifications: + disconnection.offending_notification = self.recent_notifications[known_ident] + self.disconnections.append(disconnection) + + +def encode_notifications(notifications): + return ''.join([n.to_apn_wire_format() for n in notifications]) + + +class Notification(object): + """ + A single notification being sent to the APN service. + + The fields are described as follows: + + * `payload` is the actual notification dict to be jsonified + * `token` is the hexlified device token you scraped from the client + * `identifier` is a unique id specific to this id. for this you + may use a UUID--but we will generate our own internal ID to track + it. The APN gateway only allows for this to be 4 bytes. + + Identifier is actually optional--we'll generate one if not + provided however then the disconnection log will be pretty much + useless. + * `expiry` is how long the notification should be retried for if + for some reason the apple servers can not contact the device + """ + + __slots__ = ('token', 'payload', 'expiry', 'identifier', 'internal_identifier') + + def __init__(self): + self.token = None + self.payload = None + self.expiry = None + self.identifier = None + self.internal_identifier = None + + @classmethod + def from_simple(cls, data, instance=None): + note = instance or cls() + note.token = data['token'] + note.payload = data['payload'] + note.expiry = int(data['expiry']) + note.identifier = int(data['identifier']) + return note + + def to_simple(self): + return { + 'type': 'notification', + 'expiry': self.expiry, + 'identifier': self.identifier, + 'payload': self.payload, + 'token': self.token + } + + def to_apn_wire_format(self): + fmt = '!BLLH32sH%ds' + structify = lambda t, i, e, p: struct.pack(fmt % len(p), 1, i, e, 32, + t, len(p), p) + binaryify = lambda t: t.decode('hex') + def binaryify(t): + try: + return t.decode('hex') + except TypeError, e: + raise ValueError( + 'token "{}" could not be decoded: {}'.format(str(t), str(e) + )) + + encoded_payload = json.dumps(self.payload, separators=(',', ':')) + return structify(binaryify(self.token), self.internal_identifier, + self.expiry, encoded_payload) + + def __repr__(self): + return u''.format( + self.token, self.internal_identifier, self.expiry, self.payload + ) + + +APNS_STATUS_CODES = { + 0: 'No errors encountered', + 1: 'Processing error', + 2: 'Missing device token', + 3: 'Missing topic', + 4: 'Missing payload', + 5: 'Invalid token size', + 6: 'Invalid topic size', + 7: 'Invalid payload size', + 8: 'Invalid token', + 255: 'None (unknown)' +} + + +class DisconnectionEvent(object): + __slots__ = ('code', 'offending_notification', 'timestamp', 'identifier') + + def __init__(self): + self.code = None + self.offending_notification = None + self.timestamp = None + self.identifier = None + + def to_simple(self): + return { + 'code': self.code, + 'internal_identifier': self.identifier, + 'offending_notification': ( + self.offending_notification.to_simple() + if self.offending_notification is not None else None + ), + 'timestamp': ( + float(calendar.timegm(self.timestamp.timetuple())) + + float(self.timestamp.microsecond) / 1e6 + ), + 'verbose_message': APNS_STATUS_CODES[self.code] + } + + @classmethod + def from_apn_wire_format(cls, packet): + fmt = '!Bbl' + cmd, code, ident = struct.unpack(fmt, packet) + + evt = cls() + evt.code = code + evt.timestamp = datetime.datetime.utcnow() + evt.identifier = ident + return evt + + def __repr__(self): + return ''.format( + self.identifier, APNS_STATUS_CODES[self.code], + self.offending_notification + ) + diff --git a/pyapns/rest_service.py b/pyapns/rest_service.py new file mode 100644 index 0000000..c4cc4f9 --- /dev/null +++ b/pyapns/rest_service.py @@ -0,0 +1,188 @@ +import calendar +from twisted.web.resource import Resource, NoResource +from twisted.web.server import NOT_DONE_YET +from twisted.python import log +from pyapns.model import AppRegistry, NoSuchAppException, Notification +from pyapns import _json as json + + +PRODUCTION = 'production' +SANDBOX = 'sandbox' +ENVIRONMENTS = (PRODUCTION, SANDBOX) + + +class ErrorResource(Resource): + isLeaf = True + + def __init__(self, code, message, **attrs): + Resource.__init__(self) + self.code = code + self.message = message + self.attrs = attrs + + def render(self, request): + request.setResponseCode(self.code) + request.setHeader('content-type', 'application/json; charset=utf-8') + return json.dumps({ + 'code': self.code, + 'message': self.message, + 'type': 'error', + 'args': self.attrs + }) + + +def json_response(data, request, status_code=200): + request.setResponseCode(status_code) + request.setHeader('content-type', 'application/json; charset=utf-8') + return json.dumps({'code': status_code, 'response': data}) + + +# to handle /apps/ +class AppRootResource(Resource): + def getChild(self, name, request): + if name == '': + return self + else: + return AppResource(name) + + def render_GET(self, request): + apps = [app.to_simple() for app in AppRegistry.all_apps()] + return json_response(apps, request) + + +# to handle /apps// +class AppResource(Resource): + def __init__(self, app_name): + Resource.__init__(self) + self.app_name = app_name + + def getChild(self, name, request): + if name in ENVIRONMENTS: + return AppEnvironmentResource(self.app_name, name) + else: + return ErrorResource( + 404, 'Environment must be either `production` or `sandbox`', + environment=name, app=self.app_name + ) + + +# to handle /apps///()? +class AppEnvironmentResource(Resource): + def __init__(self, app_name, environment): + Resource.__init__(self) + self.app_name = app_name + self.environment = environment + + def getChild(self, name, request): + if name == '': + return self + + try: + app = AppRegistry.get(self.app_name, self.environment) + except NoSuchAppException: + return ErrorResource( + 404, 'No app registered under that name and environment', + name=self.app_name, + environment=name + ) + else: + if name == 'feedback': + return FeedbackResource(app) + elif name == 'notifications': + return NotificationResource(app) + elif name == 'disconnections': + return DisconnectionLogResource(app) + else: + return ErrorResource( + 404, 'Unknown resource', app=self.app_name, + environment=self.environment + ) + + def render_GET(self, request): + try: + app = AppRegistry.get(self.app_name, self.environment) + except NoSuchAppException: + return ErrorResource( + 404, 'No app registered under that name and environment', + name=self.app_name, + environment=self.environment + ).render(request) + else: + return json_response(app.to_simple(), request) + + def render_POST(self, request): + j = json.loads(request.content.read()) + if 'certificate' not in j: + return ErrorResource( + 400, '`certificate` is a required key. It must be either a ' + 'path to a .pem file or the contents of the pem itself' + ).render(request) + + kwargs = {} + if 'timeout' in j: + kwargs['timeout'] = int(j['timeout']) + + app = AppRegistry.put(self.app_name, self.environment, + j['certificate'], **kwargs) + + return json_response(app.to_simple(), request, 201) + + +class AppEnvResourceBase(Resource): + def __init__(self, app): + self.app = app + + +class NotificationResource(AppEnvResourceBase): + isLeaf = True + + def render_POST(self, request): + notifications = json.loads(request.content.read()) + is_list = isinstance(notifications, list) + if is_list: + is_all_dicts = len(notifications) == \ + sum(1 if (isinstance(el, dict) + and 'payload' in el + and 'token' in el + and 'identifier' in el + and 'expiry' in el) + else 0 for el in notifications) + else: + is_all_dicts = False + + if not is_list or not is_all_dicts: + return ErrorResource( + 400, 'Notifications must be a list of dictionaries in the ' + 'proper format: [' + '{"payload": {...}, "token": "...", "identifier": ' + '"...", "expiry": 30}]' + ).render(request) + + # returns a deferred but we're not making the client wait + self.app.notify([Notification.from_simple(n) for n in notifications]) + + return json_response({}, request) + + +class DisconnectionLogResource(AppEnvResourceBase): + isLeaf = True + + def render_GET(self, request): + return json_response([d.to_simple() for d in self.app.disconnections], + request) + + +class FeedbackResource(AppEnvResourceBase): + isLeaf = True + + def render_GET(self, request): + def on_done(feedbacks): + request.write(json_response(feedbacks, request)) + request.finish() + d = self.app.feedback() + d.addCallback(on_done) + return NOT_DONE_YET + + +default_resource = Resource() +default_resource.putChild('apps', AppRootResource()) diff --git a/pyapns/server.py b/pyapns/server.py index a1df72d..1e41604 100644 --- a/pyapns/server.py +++ b/pyapns/server.py @@ -72,6 +72,8 @@ def getContext(self): class APNSProtocol(Protocol): + onFailureReceived = None + def connectionMade(self): log.msg('APNSProtocol connectionMade') self.factory.addClient(self) @@ -80,6 +82,11 @@ def sendMessage(self, msg): log.msg('APNSProtocol sendMessage msg=%s' % binascii.hexlify(msg)) return self.transport.write(msg) + def dataReceived(self, data): + log.msg('data received after sendMessage data=%s' % data) + if callable(self.onFailureReceived): + self.onFailureReceived(data) + def connectionLost(self, reason): log.msg('APNSProtocol connectionLost') self.factory.removeClient(self) @@ -138,10 +145,12 @@ class APNSClientFactory(ReconnectingClientFactory): def __init__(self): self.clientProtocol = None + self.onFailureReceived = None self.deferred = defer.Deferred() self.deferred.addErrback(log_errback('APNSClientFactory __init__')) def addClient(self, p): + p.onFailureReceived = self.onFailureReceived self.clientProtocol = p self.deferred.callback(p) @@ -177,13 +186,14 @@ class APNSService(service.Service): clientProtocolFactory = APNSClientFactory feedbackProtocolFactory = APNSFeedbackClientFactory - def __init__(self, cert_path, environment, timeout=15): + def __init__(self, cert_path, environment, timeout=15, on_failure_received=lambda x:x): log.msg('APNSService __init__') self.factory = None self.environment = environment self.cert_path = cert_path self.raw_mode = False self.timeout = timeout + self.on_failure_received = on_failure_received def getContextFactory(self): return APNSClientContextFactory(self.cert_path) @@ -196,6 +206,7 @@ def write(self, notifications): if self.environment == 'sandbox' else APNS_SERVER_HOSTNAME), APNS_SERVER_PORT) self.factory = self.clientProtocolFactory() + self.factory.onFailureReceived = self.on_failure_received context = self.getContextFactory() reactor.connectSSL(server, port, self.factory, context) @@ -215,7 +226,11 @@ def cancel_timeout(r): pass return r - d.addCallback(lambda p: p.sendMessage(notifications)) + def got_protocol(p): + p.onFailureReceived = self.on_failure_received + p.sendMessage(notifications) + + d.addCallback(got_protocol) d.addErrback(log_errback('apns-service-write')) d.addBoth(cancel_timeout) return d @@ -358,7 +373,6 @@ def decode_feedback(binary_tuples): for ts, toklen, tok in (struct.unpack(fmt, tup) for tup in iter(lambda: f.read(size), ''))] - def log_errback(name): def _log_errback(err, *args): log.err('errback in %s : %s' % (name, str(err))) From 2418a4989d542917431a5c5673160108d67d6d89 Mon Sep 17 00:00:00 2001 From: Samuel Sutch Date: Fri, 25 Jan 2013 00:45:32 -0800 Subject: [PATCH 06/40] forgot some types --- pyapns/model.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyapns/model.py b/pyapns/model.py index dfbf058..3b7b6a4 100644 --- a/pyapns/model.py +++ b/pyapns/model.py @@ -109,7 +109,8 @@ def feedback(self): def decode(raw_feedback): feedbacks = decode_feedback(raw_feedback) print 'feedbacks', feedbacks - return [{'timestamp': ( + return [{'type': 'feedback', + 'timestamp': ( float(calendar.timegm(ts.timetuple())) + float(ts.microsecond) / 1e6 ), @@ -270,6 +271,7 @@ def __init__(self): def to_simple(self): return { + 'type': 'disconnection', 'code': self.code, 'internal_identifier': self.identifier, 'offending_notification': ( From 0cc522be0c8a39f870270ff01555ce8d1d251b52 Mon Sep 17 00:00:00 2001 From: Samuel Sutch Date: Fri, 25 Jan 2013 01:21:23 -0800 Subject: [PATCH 07/40] remove print --- pyapns/model.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pyapns/model.py b/pyapns/model.py index 3b7b6a4..ed21d26 100644 --- a/pyapns/model.py +++ b/pyapns/model.py @@ -108,7 +108,6 @@ def feedback(self): d = self.connection.read() def decode(raw_feedback): feedbacks = decode_feedback(raw_feedback) - print 'feedbacks', feedbacks return [{'type': 'feedback', 'timestamp': ( float(calendar.timegm(ts.timetuple())) From beb7ad500a3c868d4db48aa668d4c9bcb6eb16ac Mon Sep 17 00:00:00 2001 From: Samuel Sutch Date: Fri, 25 Jan 2013 10:34:10 -0800 Subject: [PATCH 08/40] remove unneeded code --- pyapns/model.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyapns/model.py b/pyapns/model.py index ed21d26..8cb0060 100644 --- a/pyapns/model.py +++ b/pyapns/model.py @@ -33,8 +33,6 @@ def put(cls, name, environment, cert, **attrs): cls.apps[name][environment] = app return app -MAX_IDENT = 0xffff -START_IDENT = 0x0 class App(object): @property From cb933dc023b939a03d5c384fa7ef356ce42108b0 Mon Sep 17 00:00:00 2001 From: Samuel Sutch Date: Fri, 25 Jan 2013 10:34:24 -0800 Subject: [PATCH 09/40] upgrade twisted requirement (because of the new(er) web stuff) --- requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index c6bf5d0..39b8802 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -Twisted>=8.2.0 -pyOpenSSL>=0.10 +Twisted>=11.0.0 +pyOpenSSL>=0.10 \ No newline at end of file From fd057a2093cb18f2b30393afc32b667ffc0ffc6d Mon Sep 17 00:00:00 2001 From: Samuel Sutch Date: Fri, 25 Jan 2013 13:48:04 -0800 Subject: [PATCH 10/40] first brush at new REST-based Python client has no configuration built in by default --- pyapns/__init__.py | 2 +- pyapns/client.py | 155 +++++++++++++++++++++++++++++++++++++++++ pyapns/model.py | 42 ++++++----- pyapns/rest_service.py | 2 +- requirements.txt | 3 +- 5 files changed, 185 insertions(+), 19 deletions(-) diff --git a/pyapns/__init__.py b/pyapns/__init__.py index aa3eb86..50c443c 100644 --- a/pyapns/__init__.py +++ b/pyapns/__init__.py @@ -1 +1 @@ -from .client import notify, provision, feedback, configure __version__ = "0.4.0" __author__ = "Samuel Sutch" __license__ = "MIT" __copyright__ = "Copyrighit 2012 Samuel Sutch" \ No newline at end of file +from .client import notify, provision, feedback, configure __version__ = "0.5.0" __author__ = "Samuel Sutch" __license__ = "MIT" __copyright__ = "Copyrighit 2013 Samuel Sutch" \ No newline at end of file diff --git a/pyapns/client.py b/pyapns/client.py index 08632b4..a247fb2 100644 --- a/pyapns/client.py +++ b/pyapns/client.py @@ -3,6 +3,161 @@ import httplib import functools from sys import hexversion +import requests +from pyapns import _json as json +from pyapns.model import Notification, DisconnectionEvent + + +class ClientError(Exception): + def __init__(self, message, response): + super(ClientError, self).__init__(message) + self.message = message + self.response = response + + +class Client(object): + @property + def connection(self): + con = getattr(self, '_connection', None) + if con is None: + con = self._connection = requests.Session() + return con + + def __init__(self, host='http://localhost', port=8088, timeout=20): + self.host = host.strip('/') + self.port = port + self.timeout = timeout + + def provision(self, app_id, environment, certificate, timeout=15): + """ + Tells the pyapns server that we want set up an app to receive + notifications from us. + + :param app_id: An app id, you can use anything but it's + recommended to just use the bundle identifier used in your app. + :type app_id: string + + :param environment: Which environment are you using? This value + must be either "production" or "sandbox". + :type environment: string + + :param certificate: A path to a encoded, password-free .pem file + on the pyapns host. This must be a path local to the host! You + can also read an entire .pem file in and send it in this value + as well. + :type certificate: string + + :returns: Dictionary-representation of the App record + :rtype: dict + """ + status, resp = self._request( + 'POST', 'apps/{}/{}'.format(app_id, environment), + data={'certificate': certificate, 'timeout': timeout} + ) + if status != 201: + raise ClientError('Unable to provision app id', resp) + return resp['response'] + + def notify(self, app_id, environment, notifications): + """ + Sends notifications to clients via the pyapns server. The + `app_id` and `environment` must be previously provisioned + values--either by using the :py:meth:`provision` method or + having been bootstrapped on the server. + + `notifications` is a list of notification dictionaries that all + must have the following keys: + + * `payload` is the actual notification dict to be jsonified + * `token` is the hexlified device token you scraped from + the client + * `identifier` is a unique id specific to this id. for this + you may use any value--pyapns will generate its own + internal ID to track it. The APN gateway only allows for + this to be 4 bytes. + * `expiry` is how long the notification should be retried + for if for some reason the apple servers can not contact + the device + + You can also construct a :py:class:`Notification` object--the + dict and class representations are interchangable here. + + :param app_id: Which app id to use + :type app_id: string + + :param environmenet: The environment for the app_id + :type environment: string + + :param notifications: A list of notification dictionaries + (see the discussion above) + :type notifications: list + + :returns: Empty response--this method doesn't return anything + :rtype: dict + """ + notes = [] + for note in notifications: + if isinstance(note, dict): + notes.append(Notification.from_simple(note)) + elif isinstance(note, Notification): + notes.append(note) + else: + raise ValueError('Unknown notification: {}'.format(repr(note))) + data = [n.to_simple() for n in notes] + + status, resp = self._request( + 'POST', 'apps/{}/{}/notifications'.format(app_id, environment), + data=data + ) + if status != 201: + raise ClientError('Could not send notifications', resp) + return resp['response'] + + def feedback(self, app_id, environment): + """ + """ + status, feedbacks = self._request( + 'GET', 'apps/{}/{}/feedback'.format(app_id, environment) + ) + if status != 200: + raise ClientError('Could not fetch feedbacks', resp) + return feedbacks['response'] + + def disconnections(self, app_id, environment): + """ + Retrieves a list of the 5000 most recent disconnection events + recorded by pyapns. Each time apple severs the connection with + pyapns it will try to send back an error packet describing which + notification caused the error and the error that occurred. + """ + status, disconnects = self._request( + 'GET', 'apps/{}/{}/disconnections'.format(app_id, environment) + ) + if status != 200: + raise ClientError('Could not retrieve disconnections') + ret = [] + for evt in disconnects['response']: + ret.append(DisconnectionEvent.from_simple(evt)) + return ret + + def _request(self, method, path, args=None, data=None): + url = '{}:{}/{}'.format(self.host, self.port, path) + kwargs = {'timeout': self.timeout} + if args is not None: + kwargs['params'] = args + if data is not None: + kwargs['data'] = json.dumps(data) + + func = getattr(self.connection, method.lower()) + resp = func(url, **kwargs) + if resp.headers['content-type'].startswith('application/json'): + resp_data = json.loads(resp.content) + else: + resp_data = None + return resp.status_code, resp_data + + +## OLD XML-RPC INTERFACE ------------------------------------------------------ OPTIONS = {'CONFIGURED': False, 'TIMEOUT': 20} diff --git a/pyapns/model.py b/pyapns/model.py index 8cb0060..3af9980 100644 --- a/pyapns/model.py +++ b/pyapns/model.py @@ -106,12 +106,14 @@ def feedback(self): d = self.connection.read() def decode(raw_feedback): feedbacks = decode_feedback(raw_feedback) - return [{'type': 'feedback', - 'timestamp': ( + return [ + { + 'type': 'feedback', + 'timestamp': ( float(calendar.timegm(ts.timetuple())) + float(ts.microsecond) / 1e6 - ), - 'token': tok + ), + 'token': tok } for ts, tok in feedbacks] d.addCallback(decode) return d @@ -185,22 +187,19 @@ class Notification(object): * `identifier` is a unique id specific to this id. for this you may use a UUID--but we will generate our own internal ID to track it. The APN gateway only allows for this to be 4 bytes. - - Identifier is actually optional--we'll generate one if not - provided however then the disconnection log will be pretty much - useless. * `expiry` is how long the notification should be retried for if for some reason the apple servers can not contact the device """ __slots__ = ('token', 'payload', 'expiry', 'identifier', 'internal_identifier') - def __init__(self): - self.token = None - self.payload = None - self.expiry = None - self.identifier = None - self.internal_identifier = None + def __init__(self, token=None, payload=None, expiry=None, identifier=None, + internal_identifier=None): + self.token = token + self.payload = payload + self.expiry = expiry + self.identifier = identifier + self.internal_identifier = internal_identifier @classmethod def from_simple(cls, data, instance=None): @@ -208,7 +207,7 @@ def from_simple(cls, data, instance=None): note.token = data['token'] note.payload = data['payload'] note.expiry = int(data['expiry']) - note.identifier = int(data['identifier']) + note.identifier = data['identifier'] return note def to_simple(self): @@ -239,7 +238,7 @@ def binaryify(t): def __repr__(self): return u''.format( - self.token, self.internal_identifier, self.expiry, self.payload + self.token, self.identifier, self.expiry, self.payload ) @@ -282,6 +281,17 @@ def to_simple(self): 'verbose_message': APNS_STATUS_CODES[self.code] } + @classmethod + def from_simple(cls, data): + evt = cls() + evt.code = data['code'] + evt.identifier = data['internal_identifier'] + evt.timestamp = datetime.datetime.utcfromtimestamp(data['timestamp']) + if 'offending_notification' in data: + evt.offending_notification = \ + Notification.from_simple(data['offending_notification']) + return evt + @classmethod def from_apn_wire_format(cls, packet): fmt = '!Bbl' diff --git a/pyapns/rest_service.py b/pyapns/rest_service.py index c4cc4f9..232afcc 100644 --- a/pyapns/rest_service.py +++ b/pyapns/rest_service.py @@ -161,7 +161,7 @@ def render_POST(self, request): # returns a deferred but we're not making the client wait self.app.notify([Notification.from_simple(n) for n in notifications]) - return json_response({}, request) + return json_response({}, request, 201) class DisconnectionLogResource(AppEnvResourceBase): diff --git a/requirements.txt b/requirements.txt index 39b8802..7d80f13 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ Twisted>=11.0.0 -pyOpenSSL>=0.10 \ No newline at end of file +pyOpenSSL>=0.10 +requests>=1.0 \ No newline at end of file From 99cd51154a13cf20c5bd37aaa6512c924caf9416 Mon Sep 17 00:00:00 2001 From: Samuel Sutch Date: Fri, 25 Jan 2013 14:14:28 -0800 Subject: [PATCH 11/40] added some more docs --- pyapns/client.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/pyapns/client.py b/pyapns/client.py index a247fb2..187f259 100644 --- a/pyapns/client.py +++ b/pyapns/client.py @@ -115,6 +115,23 @@ def notify(self, app_id, environment, notifications): def feedback(self, app_id, environment): """ + Gets the from the APN feedback service. These are tokens that + Apple considers to be "dead" - that you should no longer attempt + to deliver to. + + Returns a list of dictionaries with the keys: + + * `timestamp` - the UTC timestamp when Apple determined the + token to be dead + * `token` - the hexlified version of the token + + :param app_id: Which app id to use + :type app_id: string + + :param environmenet: The environment for the app_id + :type environment: string + + :rtrype: list """ status, feedbacks = self._request( 'GET', 'apps/{}/{}/feedback'.format(app_id, environment) @@ -129,6 +146,14 @@ def disconnections(self, app_id, environment): recorded by pyapns. Each time apple severs the connection with pyapns it will try to send back an error packet describing which notification caused the error and the error that occurred. + + :param app_id: Which app id to use + :type app_id: string + + :param environmenet: The environment for the app_id + :type environment: string + + :rtype: list """ status, disconnects = self._request( 'GET', 'apps/{}/{}/disconnections'.format(app_id, environment) From a7bdc7a539c77fa2bd04f863493752e6d33e391b Mon Sep 17 00:00:00 2001 From: Samuel Sutch Date: Fri, 25 Jan 2013 14:17:38 -0800 Subject: [PATCH 12/40] first brush at change list for 0.5.0 --- CHANGES.txt | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/CHANGES.txt b/CHANGES.txt index 8530e82..6be4d2d 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,44 @@ +version 0.5.0 - 2013-01-25 +========================== + +There is lots of new stuff here but this release is backwards compatible. The +REST interface operates on a different default port and runs completey in +paralell with the XML-RPC one. That said, REST is the way forward and all new +features will be added to that server. + +New: + + * An all-new REST interface that simplifies access to pyapns's functionality. + + The XML-RPC server will be depreciated over the coming years. Next year it + will be "depreciated" as in it will start giving off warnings and the year + after it will likely be entirely removed. + + * REST interface uses Apple's new "enhanced" notification format which has + the ability to send back errors in the event Apple severs the connection + to pyapns (which can happen for a number of reasons). + + * A disconnection log which will pair disconnection events with the + notifications at fault. This will allow you to remove offending tokens + and notifications. + + * The new Python client uses the very fine requests library which provides + several advantages over the old XML-RPC library principally: connection + keep-alive and reuse and connection pooling. + +Fixed Bugs: + + * Fixed #31/#26 - Token blacklisting - we will not blacklist tokens as it's + not the job of this daemon to persist that kind of state. We however now + give you the tools to blacklist tokens should you wish to do so. + + * Fixed #14 - Apple Dropping Connection - this can happen for any number + of reasons, by using the new REST service you can get an idea of why. + + * Fixed #10 - reprovisioning - while this issue isn't specifically to allow + reprovisioning, the reason @mikeytrw wanted the feature was to replace + certs, which is now possible. + version 0.4.0 - 2012-02-14 ========================== From aff11069098bf4d8f9ccc9d11b7c47c0e57e9a15 Mon Sep 17 00:00:00 2001 From: Samuel Sutch Date: Fri, 25 Jan 2013 15:34:05 -0800 Subject: [PATCH 13/40] example tac is now the tac you should use; updated * updated conf * updated pyapns.tac --- example_conf.json | 35 +++----------------------- example_tac.tac | 36 --------------------------- pyapns.tac | 62 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 68 deletions(-) delete mode 100644 example_tac.tac create mode 100644 pyapns.tac diff --git a/example_conf.json b/example_conf.json index b435e6e..eb2c262 100644 --- a/example_conf.json +++ b/example_conf.json @@ -1,41 +1,12 @@ { "port": 7077, + "rest_port": 8088, "autoprovision": [ { - "app_id": "sandbox:com.ficture.ficturebeta", - "cert": "/Users/sam/dev/ficture/push_certs/development-com.ficture.ficturebeta.pem", + "app_id": "com.example.myapp", + "cert": "/path/to/cert.pem", "environment": "sandbox", "timeout": 15 }, - { - "app_id": "production:com.ficture.ficturebeta", - "cert": "/Users/sam/dev/ficture/push_certs/production-com.ficture.ficturebeta.pem", - "environment": "production", - "timeout": 15 - }, - { - "app_id": "sandbox:com.ficture.ficturebeta2", - "cert": "/Users/sam/dev/ficture/push_certs/development-com.ficture.ficturebeta2.pem", - "environment": "sandbox", - "timeout": 15 - }, - { - "app_id": "production:com.ficture.ficturebeta2", - "cert": "/Users/sam/dev/ficture/push_certs/production-com.ficture.ficturebeta2.pem", - "environment": "production", - "timeout": 15 - }, - { - "app_id": "sandbox:com.ficture.ficturebeta3", - "cert": "/Users/sam/dev/ficture/push_certs/development-com.ficture.ficturebeta3.pem", - "environment": "sandbox", - "timeout": 15 - }, - { - "app_id": "production:com.ficture.ficturebeta3", - "cert": "/Users/sam/dev/ficture/push_certs/production-com.ficture.ficturebeta3.pem", - "environment": "production", - "timeout": 15 - } ] } diff --git a/example_tac.tac b/example_tac.tac deleted file mode 100644 index 3e24e6b..0000000 --- a/example_tac.tac +++ /dev/null @@ -1,36 +0,0 @@ -# CONFIG FILE LOCATION -# relative to this file or absolute path - -config_file = 'example_conf.json' - -# you don't need to change anything below this line really - -import twisted.application, twisted.web, twisted.application.internet -import pyapns.server, pyapns._json -import os - -with open(os.path.abspath(config_file)) as f: - config = pyapns._json.loads(f.read()) - -application = twisted.application.service.Application("pyapns application") - -resource = twisted.web.resource.Resource() -service = pyapns.server.APNSServer() - -# get automatic provisioning -if 'autoprovision' in config: - for app in config['autoprovision']: - service.xmlrpc_provision(app['app_id'], app['cert'], app['environment'], - app['timeout']) - -# get port from config or 7077 -if 'port' in config: - port = config['port'] -else: - port = 7077 - -resource.putChild('', service) -site = twisted.web.server.Site(resource) - -server = twisted.application.internet.TCPServer(port, site) -server.setServiceParent(application) diff --git a/pyapns.tac b/pyapns.tac new file mode 100644 index 0000000..621ce3a --- /dev/null +++ b/pyapns.tac @@ -0,0 +1,62 @@ +# CONFIG FILE LOCATION +# relative to this file or absolute path + +config_file = '/path/to/config/pyapns_conf.json' + +# you don't need to change anything below this line really + +import twisted.application, twisted.web, twisted.application.internet +import pyapns.server, pyapns._json +import pyapns.rest_service, pyapns.model +import os + +config = {} + +if os.path.exists(os.path.abspath(config_file)): + with open(os.path.abspath(config_file)) as f: + config.update(pyapns._json.loads(f.read())) +else: + print 'No config file loaded. Alter the `config_file` variable at', \ + 'the top of this file to set one.' + +xml_service = pyapns.server.APNSServer() + +# get automatic provisioning +if 'autoprovision' in config: + for app in config['autoprovision']: + # for XML-RPC + xml_service.xmlrpc_provision(app['app_id'], app['cert'], + app['environment'], app['timeout']) + # for REST + pyapns.model.AppRegistry.put( + app['app_id'], app['environment'], app['cert'], + timeout=app['timeout'] + ) + +application = twisted.application.service.Application("pyapns application") + +# XML-RPC server support ------------------------------------------------------ + +if 'port' in config: + port = config['port'] +else: + port = 7077 + +resource = twisted.web.resource.Resource() +resource.putChild('', xml_service) + +site = twisted.web.server.Site(resource) + +server = twisted.application.internet.TCPServer(port, site) +server.setServiceParent(application) + +# rest service support -------------------------------------------------------- +if 'rest_port' in config: + rest_port = config['rest_port'] +else: + rest_port = 8088 + +site = twisted.web.server.Site(pyapns.rest_service.default_resource) + +server = twisted.application.internet.TCPServer(rest_port, site) +server.setServiceParent(application) From 855073bff9ec47efd33c74b94dd1a5bf04062fb1 Mon Sep 17 00:00:00 2001 From: Samuel Sutch Date: Fri, 25 Jan 2013 15:38:21 -0800 Subject: [PATCH 14/40] stupid import error --- pyapns/_json.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyapns/_json.py b/pyapns/_json.py index b3e3830..7e4d637 100644 --- a/pyapns/_json.py +++ b/pyapns/_json.py @@ -1,6 +1,6 @@ try: try: - import usjon # try for ujson first because it rocks and is fast as hell + import ujson # try for ujson first because it rocks and is fast as hell json = ujson except ImportError: import json From a0757f17a1061b5f99b89358789abbedb8c68870 Mon Sep 17 00:00:00 2001 From: Samuel Sutch Date: Fri, 25 Jan 2013 15:49:13 -0800 Subject: [PATCH 15/40] fixes #28 - callback returns None --- pyapns/server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyapns/server.py b/pyapns/server.py index 1e41604..02a2a4a 100644 --- a/pyapns/server.py +++ b/pyapns/server.py @@ -229,6 +229,7 @@ def cancel_timeout(r): def got_protocol(p): p.onFailureReceived = self.on_failure_received p.sendMessage(notifications) + return p d.addCallback(got_protocol) d.addErrback(log_errback('apns-service-write')) From 86860251056530b6d65abc4101567e7ca83b3c8e Mon Sep 17 00:00:00 2001 From: Samuel Sutch Date: Fri, 25 Jan 2013 15:50:16 -0800 Subject: [PATCH 16/40] note fixed #28 --- CHANGES.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES.txt b/CHANGES.txt index 6be4d2d..2b801d7 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -39,6 +39,9 @@ Fixed Bugs: reprovisioning, the reason @mikeytrw wanted the feature was to replace certs, which is now possible. + * Fixed #28 - sending multiple notifications after a disconnect fails - + simply fixed the way the deferred was handeled + version 0.4.0 - 2012-02-14 ========================== From 583e4195f3634dc1ec0e86a6ea3e18461c839403 Mon Sep 17 00:00:00 2001 From: Samuel Sutch Date: Fri, 25 Jan 2013 17:15:01 -0800 Subject: [PATCH 17/40] readme checkpoint - still plenty more to write --- README.md | 76 +++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 65 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 9daf29f..346361e 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,70 @@ pyapns A universal Apple Push Notification Service (APNS) provider. Features: -
    -
  • XML-RPC Based, works with any client in any language
  • -
  • Native Python API with Django and Pylons support
  • -
  • Native Ruby API with Rails/Rack support
  • -
  • Scalable, fast and easy to distribute behind a proxy
  • -
  • Based on Twisted
  • -
  • Multi-application and dual environment support
  • -
  • Simplified feedback interface
  • -
- -pyapns is an APNS provider that you install on your server and access through XML-RPC. To install you will need Python, [Twisted](http://pypi.python.org/pypi/Twisted) and [pyOpenSSL](http://pypi.python.org/pypi/pyOpenSSL). It's also recommended to install [python-epoll](http://pypi.python.org/pypi/python-epoll/) for best performance (if epoll is not available, like on Mac OS X, you may want to use another library, like [py-kqueue](http://pypi.python.org/pypi/py-kqueue/2.0.1)). If you like easy_install try (it should take care of the dependancies for you): + + * **REST interface** so you can start sending notifications with any language immediately + * Native Python API included + * Scalable, fast and easy to distribute behind a proxy + * Multi-application and dual environment support + * Disconnection log interface which provides reasons for recent connection issues + * Supports the full suite of APN gateway functionality + +### Quick Start + +Install and start the daemon: + + $ sudo easy_install pyapns + $ wget https://raw.github.com/samuraisam/pyapns/master/pyapns.tac + $ twistd -ny pyapns.tac + +Provision an app and send a notification: + + $ curl -d '{"certificate":"/path/to/certificate.pem"}' \ + http://localhost:8088/apps/com.example.app/sandbox + + $ curl -d '{"token": "le_token", \ + "payload": {"aps": {"alert": "Hello!"}}, \ + "identifier": "xxx", "expiry": 0}' \ + http://localhost:8088/apps/com.example.app/sandbox/ + +### About + +pyapns is a daemon that is installed on a server and designed to take the pain out of sending push notifications to Apple devices. Typically your applications you will have a thread that maintains an SSL socket to Apple. This can be error-prone, hard to maintain and plainly a burden your app servers should not have to deal with. + +Additionally, pyapns provides several features you just wouldn't get with other solutions such as the disconnection log which remembers which notifications and tokens caused disconnections with Apple - thus allowing your application layer to make decisions about whether or not th continue sending those types of notifications. This also works great as a debugging layer. + +pyapns supports sending notifications to multiple applications each with multiple environments. This is handy so you don't have to push around your APN certificates, just keep them all local to your pyapns installation. + +## The REST interface + +#### Provisioning An App + +#### Sending Notifications +###### Identifiers and Expiry + +#### Retrieving Feedback + +#### Retrieving Disconnection Events + +### The Included Python API + +### Installing in Production + +To install in production, you will want a few things that aren't covered in the quickstart above: + + 1. Automated provisioning of apps. This is supported when the pyapns server is started up. + 2. Install [python-epoll](http://pypi.python.org/pypi/python-epoll/) and [ujson](http://pypi.python.org/pypi/ujson) for dramatically improved performance + 3. (optional) start multiple instances behind a reverse proxy like HAProxy or Nginx + +#### Automated provisioning + +#### Production dependencies + +#### Example `supervisord` config + +#### Multiple instances behind a reverse proxy + + \ No newline at end of file From b3c66b514f74e307093af915be1ec334c01e9410 Mon Sep 17 00:00:00 2001 From: Samuel Sutch Date: Fri, 25 Jan 2013 17:40:40 -0800 Subject: [PATCH 18/40] another checkpoint on README - trying to nail down rest docs formatting --- README.md | 436 +++++++++++------------------------------------------- 1 file changed, 88 insertions(+), 348 deletions(-) diff --git a/README.md b/README.md index 346361e..18b9714 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,12 @@ A universal Apple Push Notification Service (APNS) provider. Features: - * **REST interface** so you can start sending notifications with any language immediately - * Native Python API included - * Scalable, fast and easy to distribute behind a proxy - * Multi-application and dual environment support - * Disconnection log interface which provides reasons for recent connection issues - * Supports the full suite of APN gateway functionality + * **REST interface** so you can start sending notifications with any language immediately + * Native Python API included + * Scalable, fast and easy to distribute behind a proxy + * Multi-application and dual environment support + * Disconnection log interface which provides reasons for recent connection issues + * Supports the full suite of APN gateway functionality ### Quick Start @@ -40,7 +40,88 @@ pyapns supports sending notifications to multiple applications each with multipl ## The REST interface -#### Provisioning An App +The rest interface is by default hosted on port `8088` and provides all of the functionality you'll need. Here are some basic things you need to know about how it works: + + * All functionality is underneath the `/apps` top level path + * Functionality specific to individual apps is available underneath the `/apps/{app id}/{environment}` path where `app id` is the provisioned app id and `environment` is the Apple environment (either "sandbox" or "production") + * You can get a list of provisioned apps: `GET /apps` + * Objects all have a `type` attribute that tells you which kind of object it is + * Successful responses will have a top level object with a `response` and `code` keys + * Unsuccessful responses will have a top level object with an `error` and `code` keys + +### Provisioning An App + +Before sending notifications to devices, you must first upload your certificate file to the server so pyapns can successfully make a connection to the APN gateway. The certificates must be a PEM encoded file. [This](http://stackoverflow.com/questions/1762555/creating-pem-file-for-apns) stackoverflow answer contains an easy way to accomplish that. + +You can upload the PEM and provision the app multiple ways: + + 1. Send the PEM file directly when provisioning the apps. Just read the whole PEM file into memory and include it as the `certificate` key: + + $ curl -d '{"certificate": "$(cat /path/to/cert.pem)"}' $HOST:$PORT/apps/com.example.myid/production + + 2. Upload the PEM file ahead of time to the same server as the pyapns daemon and provide the path to the certificate as the `certificate` key: + + $ curl -d '{"certificate": "/path/to/cert.pem"}' $HOST:$PORT/apps/com.example.myid/production + +Notice above that we are including in the URL the app id desired as well as the environment desired. They are the last and 2nd-to-last elements of the path, respectively. So for this url the app id is _com.example.myid_ and the environment is _production_. Any time you access functionality specific to these apps you'll be accessing it as a subpath of this full path. + +#### GET /apps + +Returns a list of all provisioned apps + +##### Example Response + + { + 'response': [{ + 'type': 'app', + 'certificate': '/path/to/cert.pem', + 'timeout': 15, + 'app_id': 'my.app.id', + 'environment': 'sandbox' + }], + 'code': 200 + } + +#### GET /apps/:app_id/environment + +Returns information about a provisioned app + +##### Example Response + + { + 'response': { + 'type': 'app', + 'certificate': '/path/to/cert.pem', + 'timeout': 15, + 'app_id': 'my.app.id', + 'environment': 'sandbox' + }, + 'code': 200 + } + +#### POST _/apps/:app_id/:environment_ + +Creates a newly provisioned app. You can POST multiple times to the same URL and it will merely re-provision the app, taking into account the new certificate and timeout. There may be more config values to provision in the future. + +###### Example Body: + + { + 'certificate': 'certificate or path to certificate', + 'timeout': 15 + } + +##### Example Response + + { + 'response': { + 'type': 'app', + 'certificate': '/path/to/cert.pem', + 'timeout': 15, + 'app_id': 'my.app.id', + 'environment': 'sandbox' + }, + 'code': 201 + } #### Sending Notifications ###### Identifiers and Expiry @@ -67,344 +148,3 @@ To install in production, you will want a few things that aren't covered in the #### Multiple instances behind a reverse proxy - \ No newline at end of file From 3b0004c99d23db079df126d50487134bda6fab68 Mon Sep 17 00:00:00 2001 From: Samuel Sutch Date: Fri, 25 Jan 2013 17:45:34 -0800 Subject: [PATCH 19/40] formatting test --- README.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 18b9714..f9c6153 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Provision an app and send a notification: $ curl -d '{"token": "le_token", \ "payload": {"aps": {"alert": "Hello!"}}, \ "identifier": "xxx", "expiry": 0}' \ - http://localhost:8088/apps/com.example.app/sandbox/ + http://localhost:8088/apps/com.example.app/sandbox/notifications ### About @@ -65,7 +65,7 @@ You can upload the PEM and provision the app multiple ways: Notice above that we are including in the URL the app id desired as well as the environment desired. They are the last and 2nd-to-last elements of the path, respectively. So for this url the app id is _com.example.myid_ and the environment is _production_. Any time you access functionality specific to these apps you'll be accessing it as a subpath of this full path. -#### GET /apps +#### GET _/apps_ Returns a list of all provisioned apps @@ -82,7 +82,7 @@ Returns a list of all provisioned apps 'code': 200 } -#### GET /apps/:app_id/environment +#### GET _/apps/:app_id/environment_ Returns information about a provisioned app @@ -104,14 +104,14 @@ Returns information about a provisioned app Creates a newly provisioned app. You can POST multiple times to the same URL and it will merely re-provision the app, taking into account the new certificate and timeout. There may be more config values to provision in the future. ###### Example Body: - +```json { 'certificate': 'certificate or path to certificate', 'timeout': 15 } - +``` ##### Example Response - +```json { 'response': { 'type': 'app', @@ -122,8 +122,9 @@ Creates a newly provisioned app. You can POST multiple times to the same URL and }, 'code': 201 } +``` -#### Sending Notifications +### Sending Notifications ###### Identifiers and Expiry #### Retrieving Feedback From 124a2e8c120d9e04495358de0ffa47ff546e339e Mon Sep 17 00:00:00 2001 From: Samuel Sutch Date: Fri, 25 Jan 2013 17:47:03 -0800 Subject: [PATCH 20/40] formatting test --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f9c6153..8405a6e 100644 --- a/README.md +++ b/README.md @@ -113,12 +113,12 @@ Creates a newly provisioned app. You can POST multiple times to the same URL and ##### Example Response ```json { - 'response': { - 'type': 'app', - 'certificate': '/path/to/cert.pem', - 'timeout': 15, - 'app_id': 'my.app.id', - 'environment': 'sandbox' + "response": { + "type": "app", + "certificate": "/path/to/cert.pem", + "timeout": 15, + "app_id": "my.app.id", + "environment": "sandbox" }, 'code': 201 } From 44282c76fe3cca6fb071bf628c7b7548db1b2c7b Mon Sep 17 00:00:00 2001 From: Samuel Sutch Date: Fri, 25 Jan 2013 17:49:36 -0800 Subject: [PATCH 21/40] formatting test --- README.md | 77 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 40 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 8405a6e..e35fca8 100644 --- a/README.md +++ b/README.md @@ -70,34 +70,37 @@ Notice above that we are including in the URL the app id desired as well as the Returns a list of all provisioned apps ##### Example Response - - { - 'response': [{ - 'type': 'app', - 'certificate': '/path/to/cert.pem', - 'timeout': 15, - 'app_id': 'my.app.id', - 'environment': 'sandbox' - }], - 'code': 200 - } - +```json +{ + "response": [ + { + "type": "app", + "certificate": "/path/to/cert.pem", + "timeout": 15, + "app_id": "my.app.id", + "environment": "sandbox" + }, + ] + "code": 200 +} +``` #### GET _/apps/:app_id/environment_ Returns information about a provisioned app ##### Example Response - - { - 'response': { - 'type': 'app', - 'certificate': '/path/to/cert.pem', - 'timeout': 15, - 'app_id': 'my.app.id', - 'environment': 'sandbox' - }, - 'code': 200 - } +```json +{ + "response": { + "type": "app", + "certificate": "/path/to/cert.pem", + "timeout": 15, + "app_id": "my.app.id", + "environment": "sandbox" + }, + "code": 200 +} +``` #### POST _/apps/:app_id/:environment_ @@ -105,23 +108,23 @@ Creates a newly provisioned app. You can POST multiple times to the same URL and ###### Example Body: ```json - { - 'certificate': 'certificate or path to certificate', - 'timeout': 15 - } +{ + "certificate": "certificate or path to certificate", + "timeout": 15 +} ``` ##### Example Response ```json - { - "response": { - "type": "app", - "certificate": "/path/to/cert.pem", - "timeout": 15, - "app_id": "my.app.id", - "environment": "sandbox" - }, - 'code': 201 - } +{ + "response": { + "type": "app", + "certificate": "/path/to/cert.pem", + "timeout": 15, + "app_id": "my.app.id", + "environment": "sandbox" + }, + "code": 201 +} ``` ### Sending Notifications From 55c162ff555d8ae1bda7ca626c01e23566412570 Mon Sep 17 00:00:00 2001 From: Samuel Sutch Date: Fri, 25 Jan 2013 17:50:45 -0800 Subject: [PATCH 22/40] formatting test --- README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e35fca8..45d19d6 100644 --- a/README.md +++ b/README.md @@ -56,12 +56,13 @@ Before sending notifications to devices, you must first upload your certificate You can upload the PEM and provision the app multiple ways: 1. Send the PEM file directly when provisioning the apps. Just read the whole PEM file into memory and include it as the `certificate` key: - + ```sh $ curl -d '{"certificate": "$(cat /path/to/cert.pem)"}' $HOST:$PORT/apps/com.example.myid/production - + ``` 2. Upload the PEM file ahead of time to the same server as the pyapns daemon and provide the path to the certificate as the `certificate` key: - + ```sh $ curl -d '{"certificate": "/path/to/cert.pem"}' $HOST:$PORT/apps/com.example.myid/production + ``` Notice above that we are including in the URL the app id desired as well as the environment desired. They are the last and 2nd-to-last elements of the path, respectively. So for this url the app id is _com.example.myid_ and the environment is _production_. Any time you access functionality specific to these apps you'll be accessing it as a subpath of this full path. @@ -79,8 +80,8 @@ Returns a list of all provisioned apps "timeout": 15, "app_id": "my.app.id", "environment": "sandbox" - }, - ] + } + ], "code": 200 } ``` From 59dae30ffee01dc78c0bc722848ba30e90947ef2 Mon Sep 17 00:00:00 2001 From: Samuel Sutch Date: Fri, 25 Jan 2013 17:58:51 -0800 Subject: [PATCH 23/40] try a dl --- README.md | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 45d19d6..cda4d87 100644 --- a/README.md +++ b/README.md @@ -56,21 +56,21 @@ Before sending notifications to devices, you must first upload your certificate You can upload the PEM and provision the app multiple ways: 1. Send the PEM file directly when provisioning the apps. Just read the whole PEM file into memory and include it as the `certificate` key: - ```sh - $ curl -d '{"certificate": "$(cat /path/to/cert.pem)"}' $HOST:$PORT/apps/com.example.myid/production - ``` + + $ curl -d '{"certificate": "$(cat /path/to/cert.pem)"}' $HOST:$PORT/apps/com.example.myid/production + 2. Upload the PEM file ahead of time to the same server as the pyapns daemon and provide the path to the certificate as the `certificate` key: - ```sh - $ curl -d '{"certificate": "/path/to/cert.pem"}' $HOST:$PORT/apps/com.example.myid/production - ``` -Notice above that we are including in the URL the app id desired as well as the environment desired. They are the last and 2nd-to-last elements of the path, respectively. So for this url the app id is _com.example.myid_ and the environment is _production_. Any time you access functionality specific to these apps you'll be accessing it as a subpath of this full path. + $ curl -d '{"certificate": "/path/to/cert.pem"}' $HOST:$PORT/apps/com.example.myid/production -#### GET _/apps_ +Notice above that we are including in the URL the app id desired as well as the environment desired. They are the last and 2nd-to-last elements of the path, respectively. So for this url the app id is _com.example.myid_ and the environment is _production_. Any time you access functionality specific to these apps you'll be accessing it as a subpath of this full path. +
+
GET /apps
+
Returns a list of all provisioned apps -##### Example Response +Example Response ```json { "response": [ @@ -85,6 +85,8 @@ Returns a list of all provisioned apps "code": 200 } ``` +
+
#### GET _/apps/:app_id/environment_ Returns information about a provisioned app From 456a0e094889b642030d9abbe5a7fcae92c1290e Mon Sep 17 00:00:00 2001 From: Samuel Sutch Date: Wed, 30 Jan 2013 09:07:50 -0800 Subject: [PATCH 24/40] remove trailing slash --- example_conf.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example_conf.json b/example_conf.json index eb2c262..3d41b63 100644 --- a/example_conf.json +++ b/example_conf.json @@ -7,6 +7,6 @@ "cert": "/path/to/cert.pem", "environment": "sandbox", "timeout": 15 - }, + } ] } From dd6274222e5664178fba09a05a6c887719042f67 Mon Sep 17 00:00:00 2001 From: Samuel Sutch Date: Wed, 30 Jan 2013 09:13:35 -0800 Subject: [PATCH 25/40] it's 2013! --- COPYING | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/COPYING b/COPYING index 2b32a20..1e2e4be 100644 --- a/COPYING +++ b/COPYING @@ -1,6 +1,6 @@ The MIT License -Copyright (c) 2012 Samuel Sutch +Copyright (c) 2013 Samuel Sutch Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From 6eb0b83c8755bb586c42e3ea963ebc2a902d60d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E5=98=89=E4=BF=8A?= Date: Thu, 18 Apr 2013 23:40:46 +0800 Subject: [PATCH 26/40] fix problem that ujson.dumps doesn't have separators keyword argument oson = ujson --- pyapns/_json.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/pyapns/_json.py b/pyapns/_json.py index 7e4d637..57f051f 100644 --- a/pyapns/_json.py +++ b/pyapns/_json.py @@ -1,7 +1,16 @@ try: try: import ujson # try for ujson first because it rocks and is fast as hell - json = ujson + class ujsonWrapper(object): + def dumps(self, obj, *args, **kwargs): + # ujson dumps method doesn't have separators keyword argument + if 'separators' in kwargs: + del kwargs['separators'] + return ujson.dumps(obj, *args, **kwargs) + + def loads(self, str, *args, **kwargs): + return ujson.loads(str, *args, **kwargs) + json = ujsonWrapper() except ImportError: import json except (ImportError, NameError): From da23e3e43d5eb17f737aa51c8269f6932a237010 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E5=98=89=E4=BF=8A?= Date: Fri, 19 Apr 2013 08:15:10 +0800 Subject: [PATCH 27/40] Fix typo --- pyapns/model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyapns/model.py b/pyapns/model.py index 3af9980..d7e471b 100644 --- a/pyapns/model.py +++ b/pyapns/model.py @@ -156,7 +156,7 @@ def remember_recent_notifications(self, notifications): if len(self.recent_notification_idents) >= self.recent_notifications_to_keep: removed_ident = self.recent_notification_idents.popleft() removed_note = self.recent_notifications.pop(removed_ident) - self.internal_idents.pop(removed_note.internal_ident) + self.internal_idents.pop(removed_note.internal_identifier) # create a new internal identifier and map the notification to it internal_ident = self.get_next_ident() From 548a231c20792244c6737379fd2c9bd05a6ebd28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E5=98=89=E4=BF=8A?= Date: Fri, 19 Apr 2013 08:16:03 +0800 Subject: [PATCH 28/40] Support languages other than English --- pyapns/model.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyapns/model.py b/pyapns/model.py index d7e471b..ce21f2b 100644 --- a/pyapns/model.py +++ b/pyapns/model.py @@ -232,7 +232,8 @@ def binaryify(t): 'token "{}" could not be decoded: {}'.format(str(t), str(e) )) - encoded_payload = json.dumps(self.payload, separators=(',', ':')) + encoded_payload = json.dumps(self.payload, separators=(',', ':'), + ensure_ascii=False).encode('utf-8') return structify(binaryify(self.token), self.internal_identifier, self.expiry, encoded_payload) From d34682780ce1c92e024fb8ea5db28186761a2b6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E5=98=89=E4=BF=8A?= Date: Tue, 30 Apr 2013 12:48:37 +0800 Subject: [PATCH 29/40] Clear disconnection events after getting them --- pyapns/rest_service.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyapns/rest_service.py b/pyapns/rest_service.py index 232afcc..8adb863 100644 --- a/pyapns/rest_service.py +++ b/pyapns/rest_service.py @@ -168,8 +168,10 @@ class DisconnectionLogResource(AppEnvResourceBase): isLeaf = True def render_GET(self, request): - return json_response([d.to_simple() for d in self.app.disconnections], + response = json_response([d.to_simple() for d in self.app.disconnections], request) + self.app.disconnections.clear() + return response class FeedbackResource(AppEnvResourceBase): From 7fc2611f73aa8bfc5ee287028126e5d86caa21d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E5=98=89=E4=BF=8A?= Date: Mon, 6 May 2013 09:12:53 +0800 Subject: [PATCH 30/40] Fix a bug: ClientError __init__() needs 3 arguments --- pyapns/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyapns/client.py b/pyapns/client.py index 187f259..601c737 100644 --- a/pyapns/client.py +++ b/pyapns/client.py @@ -137,7 +137,7 @@ def feedback(self, app_id, environment): 'GET', 'apps/{}/{}/feedback'.format(app_id, environment) ) if status != 200: - raise ClientError('Could not fetch feedbacks', resp) + raise ClientError('Could not fetch feedbacks', feedbacks) return feedbacks['response'] def disconnections(self, app_id, environment): @@ -159,7 +159,7 @@ def disconnections(self, app_id, environment): 'GET', 'apps/{}/{}/disconnections'.format(app_id, environment) ) if status != 200: - raise ClientError('Could not retrieve disconnections') + raise ClientError('Could not retrieve disconnections', disconnects) ret = [] for evt in disconnects['response']: ret.append(DisconnectionEvent.from_simple(evt)) From d8fc5cf65856b9a26024a4c22593b9e8466fa322 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E5=98=89=E4=BF=8A?= Date: Tue, 2 Jul 2013 13:31:10 +0800 Subject: [PATCH 31/40] Add an option: bind pyapns to a specified IP address --- example_conf.json | 1 + pyapns.tac | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/example_conf.json b/example_conf.json index 3d41b63..cbc7403 100644 --- a/example_conf.json +++ b/example_conf.json @@ -1,4 +1,5 @@ { + "host": "localhost", "port": 7077, "rest_port": 8088, "autoprovision": [ diff --git a/pyapns.tac b/pyapns.tac index 621ce3a..fd6404c 100644 --- a/pyapns.tac +++ b/pyapns.tac @@ -35,6 +35,11 @@ if 'autoprovision' in config: application = twisted.application.service.Application("pyapns application") +if 'host' in config: + host = config['host'] +else: + host = '' + # XML-RPC server support ------------------------------------------------------ if 'port' in config: @@ -47,7 +52,7 @@ resource.putChild('', xml_service) site = twisted.web.server.Site(resource) -server = twisted.application.internet.TCPServer(port, site) +server = twisted.application.internet.TCPServer(port, site, interface=host) server.setServiceParent(application) # rest service support -------------------------------------------------------- @@ -58,5 +63,5 @@ else: site = twisted.web.server.Site(pyapns.rest_service.default_resource) -server = twisted.application.internet.TCPServer(rest_port, site) +server = twisted.application.internet.TCPServer(rest_port, site, interface=host) server.setServiceParent(application) From d71abcc33c5180819f34762cd8727d34c6073c51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E5=98=89=E4=BF=8A?= Date: Thu, 18 Jul 2013 12:21:23 +0800 Subject: [PATCH 32/40] Add options to config log file --- example_conf.json | 4 ++++ pyapns.tac | 29 +++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/example_conf.json b/example_conf.json index cbc7403..ab205c4 100644 --- a/example_conf.json +++ b/example_conf.json @@ -1,5 +1,9 @@ { "host": "localhost", + "log_file_name": "pyapns.log", + "log_file_dir": ".", + "log_file_rotate_length": 1000000000, + "log_file_max_rotate": 10, "port": 7077, "rest_port": 8088, "autoprovision": [ diff --git a/pyapns.tac b/pyapns.tac index fd6404c..ff8b1bf 100644 --- a/pyapns.tac +++ b/pyapns.tac @@ -6,6 +6,8 @@ config_file = '/path/to/config/pyapns_conf.json' # you don't need to change anything below this line really import twisted.application, twisted.web, twisted.application.internet +from twisted.python.logfile import LogFile +from twisted.python.log import ILogObserver, FileLogObserver import pyapns.server, pyapns._json import pyapns.rest_service, pyapns.model import os @@ -33,7 +35,34 @@ if 'autoprovision' in config: timeout=app['timeout'] ) +if 'log_file_name' in config: + log_file_name = config['log_file_name'] +else: + log_file_name = 'twistd.log' + +if 'log_file_dir' in config: + log_file_dir = config['log_file_dir'] +else: + log_file_dir = '.' + +if 'log_file_rotate_length' in config: + log_file_rotate_length = config['log_file_rotate_length'] +else: + log_file_rotate_length = 1000000 + +if 'log_file_mode' in config: + log_file_mode = config['log_file_mode'] +else: + log_file_mode = None + +if 'log_file_max_rotate' in config: + log_file_max_rotate = config['log_file_max_rotate'] +else: + log_file_max_rotate = None + application = twisted.application.service.Application("pyapns application") +logfile = LogFile(log_file_name, log_file_dir, log_file_rotate_length, log_file_mode, log_file_max_rotate) +application.setComponent(ILogObserver, FileLogObserver(logfile).emit) if 'host' in config: host = config['host'] From bf7cecf00f595311b416a69528cd2b8b2fdeb868 Mon Sep 17 00:00:00 2001 From: John Watson Date: Thu, 5 Sep 2013 11:01:24 -0400 Subject: [PATCH 33/40] Add requests package to setup.py. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 28b187a..f57bc3c 100755 --- a/setup.py +++ b/setup.py @@ -60,5 +60,5 @@ 'Topic :: Software Development :: Libraries :: Python Modules'], packages=['pyapns'], package_data={}, - install_requires=['Twisted>=8.2.0', 'pyOpenSSL>=0.10'] + install_requires=['Twisted>=8.2.0', 'pyOpenSSL>=0.10', 'requests>=1.0'] ) From f4c20518cb906061dcb5a6a6c1100b7e08f6ccd6 Mon Sep 17 00:00:00 2001 From: John Watson Date: Thu, 5 Sep 2013 11:03:29 -0400 Subject: [PATCH 34/40] Fix crash when sending UTF-8 characters. The `ensure_ascii` kwarg in `json.dumps()` must be `True` (the default) so that the subsequent UTF-8 encoding works correctly. --- pyapns/model.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyapns/model.py b/pyapns/model.py index ce21f2b..db75621 100644 --- a/pyapns/model.py +++ b/pyapns/model.py @@ -232,8 +232,8 @@ def binaryify(t): 'token "{}" could not be decoded: {}'.format(str(t), str(e) )) - encoded_payload = json.dumps(self.payload, separators=(',', ':'), - ensure_ascii=False).encode('utf-8') + encoded_payload = json.dumps(self.payload, + separators=(',', ':')).encode('utf-8') return structify(binaryify(self.token), self.internal_identifier, self.expiry, encoded_payload) From 50ed6a3909e2967fefaf2a1f4e34cbccf5193295 Mon Sep 17 00:00:00 2001 From: Bill Davis Date: Wed, 16 Oct 2013 09:21:05 -0400 Subject: [PATCH 35/40] Attempt to fix unreliable write to APNS after connection has been established for over an hour without any activity. Reconnect to APNS if the connection has been established for an hour prior to writing. --- pyapns/server.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pyapns/server.py b/pyapns/server.py index 02a2a4a..e805065 100644 --- a/pyapns/server.py +++ b/pyapns/server.py @@ -22,6 +22,8 @@ FEEDBACK_SERVER_SANDBOX_HOSTNAME = "feedback.sandbox.push.apple.com" FEEDBACK_SERVER_HOSTNAME = "feedback.push.apple.com" FEEDBACK_SERVER_PORT = 2196 +FEEDBACK_SERVER_PORT = 2196 +MAX_CONNECTION_TIME = 3600 app_ids = {} # {'app_id': APNSService()} @@ -75,6 +77,8 @@ class APNSProtocol(Protocol): onFailureReceived = None def connectionMade(self): + self.transport.setTcpKeepAlive(True) # maintain the TCP connection + self.transport.setTcpNoDelay(False) # allow Nagle algorithm log.msg('APNSProtocol connectionMade') self.factory.addClient(self) @@ -189,6 +193,7 @@ class APNSService(service.Service): def __init__(self, cert_path, environment, timeout=15, on_failure_received=lambda x:x): log.msg('APNSService __init__') self.factory = None + self.factory_connect_time = None self.environment = environment self.cert_path = cert_path self.raw_mode = False @@ -200,6 +205,13 @@ def getContextFactory(self): def write(self, notifications): "Connect to the APNS service and send notifications" + if self.factory: + conn_time = datetime.datetime.now() - self.factory_connect_time + if (conn_time.microseconds + (conn_time.seconds + conn_time.days * 24 * 3600) * 10**6) / 10**6 > MAX_CONNECTION_TIME: + log.msg('APNSService write (disconnecting based on max connection time)') + self.factory.clientProtocol.transport.loseConnection() + self.factory = None + if not self.factory: log.msg('APNSService write (connecting)') server, port = ((APNS_SERVER_SANDBOX_HOSTNAME @@ -209,6 +221,7 @@ def write(self, notifications): self.factory.onFailureReceived = self.on_failure_received context = self.getContextFactory() reactor.connectSSL(server, port, self.factory, context) + self.factory_connect_time = datetime.datetime.now() client = self.factory.clientProtocol if client: From 4627807b6329854db2006013e115d6d73c216778 Mon Sep 17 00:00:00 2001 From: Bill Davis Date: Wed, 16 Oct 2013 10:48:52 -0400 Subject: [PATCH 36/40] Remove tcp settings that were enabled for testing --- pyapns/server.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyapns/server.py b/pyapns/server.py index e805065..eef024d 100644 --- a/pyapns/server.py +++ b/pyapns/server.py @@ -77,8 +77,6 @@ class APNSProtocol(Protocol): onFailureReceived = None def connectionMade(self): - self.transport.setTcpKeepAlive(True) # maintain the TCP connection - self.transport.setTcpNoDelay(False) # allow Nagle algorithm log.msg('APNSProtocol connectionMade') self.factory.addClient(self) From 802dcf3d589481c30f5b0ae61b435ebce49996e1 Mon Sep 17 00:00:00 2001 From: Bill Davis Date: Wed, 16 Oct 2013 12:02:11 -0400 Subject: [PATCH 37/40] Applying changes from code review notes on better datetime/timedelta handling --- pyapns/server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyapns/server.py b/pyapns/server.py index eef024d..3889a7c 100644 --- a/pyapns/server.py +++ b/pyapns/server.py @@ -23,7 +23,7 @@ FEEDBACK_SERVER_HOSTNAME = "feedback.push.apple.com" FEEDBACK_SERVER_PORT = 2196 FEEDBACK_SERVER_PORT = 2196 -MAX_CONNECTION_TIME = 3600 +MAX_CONNECTION_TIME = datetime.timedelta(minutes=60) app_ids = {} # {'app_id': APNSService()} @@ -205,7 +205,7 @@ def write(self, notifications): "Connect to the APNS service and send notifications" if self.factory: conn_time = datetime.datetime.now() - self.factory_connect_time - if (conn_time.microseconds + (conn_time.seconds + conn_time.days * 24 * 3600) * 10**6) / 10**6 > MAX_CONNECTION_TIME: + if conn_time > MAX_CONNECTION_TIME: log.msg('APNSService write (disconnecting based on max connection time)') self.factory.clientProtocol.transport.loseConnection() self.factory = None From 7f813ca75807c48ec1a509cdc1f865a6dd799882 Mon Sep 17 00:00:00 2001 From: Bill Davis Date: Mon, 11 Nov 2013 10:48:45 -0500 Subject: [PATCH 38/40] Fix for leaking file descriptor issue Add call to stopTrying on the APNSClientFactory (ReconnectingClientFactory) in the timed disconnect portion of the code. If this call is not made, the reconnecting code will reconnect to Apple and the connection is left unreferenced. This can cause an issue with 'Too many open files'. --- pyapns/server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyapns/server.py b/pyapns/server.py index 3889a7c..4348e95 100644 --- a/pyapns/server.py +++ b/pyapns/server.py @@ -208,6 +208,7 @@ def write(self, notifications): if conn_time > MAX_CONNECTION_TIME: log.msg('APNSService write (disconnecting based on max connection time)') self.factory.clientProtocol.transport.loseConnection() + self.factory.stopTrying() self.factory = None if not self.factory: From 0313d56bcb7874a908f9a99925c68f763bd2cdc3 Mon Sep 17 00:00:00 2001 From: Osvaldo Mena Date: Fri, 30 May 2014 13:18:31 -0700 Subject: [PATCH 39/40] Add Status Code 10: Shutdown As seen on https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/CommunicatingWIthAPS.html --- pyapns/model.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyapns/model.py b/pyapns/model.py index db75621..6e54d39 100644 --- a/pyapns/model.py +++ b/pyapns/model.py @@ -253,6 +253,7 @@ def __repr__(self): 6: 'Invalid topic size', 7: 'Invalid payload size', 8: 'Invalid token', + 10: 'Shutdown', 255: 'None (unknown)' } From fbf79991799fa0818d0190c4ff3e3ccd4c91e4ad Mon Sep 17 00:00:00 2001 From: Dave Noete Date: Thu, 5 Nov 2015 11:07:37 -0600 Subject: [PATCH 40/40] Use TLSv1 instead of SSLv3 --- pyapns/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyapns/server.py b/pyapns/server.py index 4348e95..d7792fb 100644 --- a/pyapns/server.py +++ b/pyapns/server.py @@ -59,7 +59,7 @@ def __init__(self, ssl_cert_file): 'APNSClientContextFactory ssl_cert_file=%s' % ssl_cert_file) else: log.msg('APNSClientContextFactory ssl_cert_file={FROM_STRING}') - self.ctx = SSL.Context(SSL.SSLv3_METHOD) + self.ctx = SSL.Context(SSL.TLSv1_METHOD) if 'BEGIN CERTIFICATE' in ssl_cert_file: cer = crypto.load_certificate(crypto.FILETYPE_PEM, ssl_cert_file) pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, ssl_cert_file)