From 0d56da9ac88d549d57cb0ae57a881de15ea6cbd2 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Wed, 13 Feb 2019 17:48:09 -0500 Subject: [PATCH 1/8] added service.url(), apprise.urls(), iter support and pop() --- README.md | 2 +- apprise/Apprise.py | 29 ++- apprise/plugins/NotifyBase.py | 16 +- apprise/plugins/NotifyBoxcar.py | 21 ++ apprise/plugins/NotifyDBus.py | 7 + apprise/plugins/NotifyDiscord.py | 25 ++- apprise/plugins/NotifyEmail.py | 46 ++++ apprise/plugins/NotifyEmby.py | 32 +++ apprise/plugins/NotifyFaast.py | 16 ++ apprise/plugins/NotifyGnome.py | 7 + apprise/plugins/NotifyGrowl/NotifyGrowl.py | 42 +++- apprise/plugins/NotifyIFTTT.py | 203 ++++++++++-------- apprise/plugins/NotifyJSON.py | 33 +++ apprise/plugins/NotifyJoin.py | 18 +- apprise/plugins/NotifyMatrix.py | 35 ++- apprise/plugins/NotifyMatterMost.py | 22 ++ apprise/plugins/NotifyProwl.py | 33 ++- apprise/plugins/NotifyPushBullet.py | 22 ++ apprise/plugins/NotifyPushed.py | 24 +++ .../plugins/NotifyPushjet/NotifyPushjet.py | 39 +++- apprise/plugins/NotifyPushover.py | 35 +++ apprise/plugins/NotifyRocketChat.py | 41 ++++ apprise/plugins/NotifyRyver.py | 26 +++ apprise/plugins/NotifySNS.py | 31 ++- apprise/plugins/NotifySlack.py | 32 ++- apprise/plugins/NotifyTelegram.py | 74 ++++--- .../plugins/NotifyTwitter/NotifyTwitter.py | 4 +- apprise/plugins/NotifyWindows.py | 7 + apprise/plugins/NotifyXBMC.py | 47 +++- apprise/plugins/NotifyXML.py | 33 +++ apprise/utils.py | 4 + test/test_api.py | 21 +- test/test_email_plugin.py | 59 +++-- test/test_glib_plugin.py | 4 + test/test_gnome_plugin.py | 4 + test/test_growl_plugin.py | 24 ++- test/test_notify_base.py | 10 + test/test_pushjet_plugin.py | 46 +++- test/test_rest_plugins.py | 189 ++++++++-------- test/test_windows_plugin.py | 4 + 40 files changed, 1087 insertions(+), 280 deletions(-) diff --git a/README.md b/README.md index 3127f114..57766fac 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ The table below identifies the services this tool supports and some example serv | [Faast](https://github.com/caronc/apprise/wiki/Notify_faast) | faast:// | (TCP) 443 | faast://authorizationtoken | [Gnome](https://github.com/caronc/apprise/wiki/Notify_gnome) | gnome:// | n/a | gnome:// | [Growl](https://github.com/caronc/apprise/wiki/Notify_growl) | growl:// | (UDP) 23053 | growl://hostname
growl://hostname:portno
growl://password@hostname
growl://password@hostname:port
**Note**: you can also use the get parameter _version_ which can allow the growl request to behave using the older v1.x protocol. An example would look like: growl://hostname?version=1 -| [IFTTT](https://github.com/caronc/apprise/wiki/Notify_ifttt) | ifttt:// | (TCP) 443 | ifttt://webhooksID/EventToTrigger
ifttt://webhooksID/EventToTrigger/Value1/Value2/Value3
ifttt://webhooksID/EventToTrigger/?Value3=NewEntry&Value2=AnotherValue +| [IFTTT](https://github.com/caronc/apprise/wiki/Notify_ifttt) | ifttt:// | (TCP) 443 | ifttt://webhooksID/EventToTrigger
ifttt://webhooksID/EventToTrigger/Event2/EventN | [Join](https://github.com/caronc/apprise/wiki/Notify_join) | join:// | (TCP) 443 | join://apikey/device
join://apikey/device1/device2/deviceN/
join://apikey/group
join://apikey/groupA/groupB/groupN
join://apikey/DeviceA/groupA/groupN/DeviceN/ | [KODI](https://github.com/caronc/apprise/wiki/Notify_kodi) | kodi:// or kodis:// | (TCP) 8080 or 443 | kodi://hostname
kodi://user@hostname
kodi://user:password@hostname:port | [Matrix](https://github.com/caronc/apprise/wiki/Notify_matrix) | matrix:// or matrixs:// | (TCP) 80 or 443 | matrix://token
matrix://user@token
matrixs://token?mode=slack
matrixs://user@token diff --git a/apprise/Apprise.py b/apprise/Apprise.py index 576f1d54..9c9b81de 100644 --- a/apprise/Apprise.py +++ b/apprise/Apprise.py @@ -171,7 +171,7 @@ class Apprise(object): # URL information plugin = SCHEMA_MAP[results['schema']](**results) - except: + except Exception: # the arguments are invalid or can not be used. logger.error('Could not load URL: %s' % url) return None @@ -432,6 +432,33 @@ class Apprise(object): return response + def urls(self): + """ + Returns all of the loaded URLs defined in this apprise object. + """ + return [x.url() for x in self.servers] + + def pop(self, index): + """ + Removes an indexed Notification Service from the stack and + returns it. + """ + + # Remove our entry + return self.servers.pop(index) + + def __getitem__(self, index): + """ + Returns the indexed server entry of a loaded notification server + """ + return self.servers[index] + + def __iter__(self): + """ + Returns an iterator to our server list + """ + return iter(self.servers) + def __len__(self): """ Returns the number of servers loaded diff --git a/apprise/plugins/NotifyBase.py b/apprise/plugins/NotifyBase.py index a8471f83..9e05a908 100644 --- a/apprise/plugins/NotifyBase.py +++ b/apprise/plugins/NotifyBase.py @@ -260,6 +260,14 @@ class NotifyBase(object): color_type=color_type, ) + def url(self): + """ + Assembles the URL associated with the notification based on the + arguments provied. + + """ + raise NotImplementedError("This is implimented by the child class.") + def __contains__(self, tags): """ Returns true if the tag specified is associated with this notification. @@ -347,16 +355,20 @@ class NotifyBase(object): """ common urlencode function + The query should always be a dictionary. + """ + # Tidy query by eliminating any records set to None + _query = {k: v for (k, v) in query.items() if v is not None} try: # Python v3.x return _urlencode( - query, doseq=doseq, safe=safe, encoding=encoding, + _query, doseq=doseq, safe=safe, encoding=encoding, errors=errors) except TypeError: # Python v2.7 - return _urlencode(query) + return _urlencode(_query) @staticmethod def split_path(path, unquote=True): diff --git a/apprise/plugins/NotifyBoxcar.py b/apprise/plugins/NotifyBoxcar.py index 58891b76..dd55a3a3 100644 --- a/apprise/plugins/NotifyBoxcar.py +++ b/apprise/plugins/NotifyBoxcar.py @@ -29,6 +29,7 @@ import re from time import time import hmac from hashlib import sha1 +from itertools import chain try: from urlparse import urlparse @@ -272,6 +273,26 @@ class NotifyBoxcar(NotifyBase): return True + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + args = { + 'format': self.notify_format + } + + return '{schema}://{access}/{secret}/{recipients}/?{args}'.format( + schema=self.secure_protocol, + access=self.quote(self.access), + secret=self.quote(self.secret), + recipients='/'.join([ + self.quote(x) for x in chain( + self.tags, self.device_tokens) if x != DEFAULT_TAG]), + args=self.urlencode(args), + ) + @staticmethod def parse_url(url): """ diff --git a/apprise/plugins/NotifyDBus.py b/apprise/plugins/NotifyDBus.py index 0517f1b6..8083a4a6 100644 --- a/apprise/plugins/NotifyDBus.py +++ b/apprise/plugins/NotifyDBus.py @@ -287,6 +287,13 @@ class NotifyDBus(NotifyBase): return True + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + return '{schema}://'.format(schema=self.schema) + @staticmethod def parse_url(url): """ diff --git a/apprise/plugins/NotifyDiscord.py b/apprise/plugins/NotifyDiscord.py index 7cdf283c..2ce78bd0 100644 --- a/apprise/plugins/NotifyDiscord.py +++ b/apprise/plugins/NotifyDiscord.py @@ -180,8 +180,8 @@ class NotifyDiscord(NotifyBase): else: # not markdown - payload['content'] = body if not title \ - else "{}\r\n{}".format(title, body) + payload['content'] = \ + body if not title else "{}\r\n{}".format(title, body) if self.avatar and image_url: payload['avatar_url'] = image_url @@ -241,6 +241,27 @@ class NotifyDiscord(NotifyBase): return True + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + args = { + 'format': self.notify_format, + 'tts': 'yes' if self.tts else 'no', + 'avatar': 'yes' if self.avatar else 'no', + 'footer': 'yes' if self.footer else 'no', + 'thumbnail': 'yes' if self.thumbnail else 'no', + } + + return '{schema}://{webhook_id}/{webhook_token}/?{args}'.format( + schema=self.secure_protocol, + webhook_id=self.quote(self.webhook_id), + webhook_token=self.quote(self.webhook_token), + args=self.urlencode(args), + ) + @staticmethod def parse_url(url): """ diff --git a/apprise/plugins/NotifyEmail.py b/apprise/plugins/NotifyEmail.py index 69d836c8..bdc29a2d 100644 --- a/apprise/plugins/NotifyEmail.py +++ b/apprise/plugins/NotifyEmail.py @@ -421,6 +421,52 @@ class NotifyEmail(NotifyBase): return True + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + args = { + 'format': self.notify_format, + 'to': self.to_addr, + 'from': self.from_addr, + 'name': self.from_name, + 'mode': self.secure_mode, + 'smtp': self.smtp_host, + 'timeout': self.timeout, + 'user': self.user, + } + + # pull email suffix from username (if present) + user = self.user.split('@')[0] + + # Determine Authentication + auth = '' + if self.user and self.password: + auth = '{user}:{password}@'.format( + user=self.quote(user, safe=''), + password=self.quote(self.password, safe=''), + ) + else: + # user url + auth = '{user}@'.format( + user=self.quote(user, safe=''), + ) + + # Default Port setup + default_port = \ + self.default_secure_port if self.secure else self.default_port + + return '{schema}://{auth}{hostname}{port}/?{args}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + auth=auth, + hostname=self.host, + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + args=self.urlencode(args), + ) + @staticmethod def parse_url(url): """ diff --git a/apprise/plugins/NotifyEmby.py b/apprise/plugins/NotifyEmby.py index 02093078..4a514ee5 100644 --- a/apprise/plugins/NotifyEmby.py +++ b/apprise/plugins/NotifyEmby.py @@ -535,6 +535,38 @@ class NotifyEmby(NotifyBase): return not has_error + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + args = { + 'format': self.notify_format, + 'modal': 'yes' if self.modal else 'no', + } + + # Determine Authentication + auth = '' + if self.user and self.password: + auth = '{user}:{password}@'.format( + user=self.quote(self.user, safe=''), + password=self.quote(self.password, safe=''), + ) + else: # self.user is set + auth = '{user}@'.format( + user=self.quote(self.user, safe=''), + ) + + return '{schema}://{auth}{hostname}{port}/?{args}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + auth=auth, + hostname=self.host, + port='' if self.port is None or self.port == self.emby_default_port + else ':{}'.format(self.port), + args=self.urlencode(args), + ) + @property def is_authenticated(self): """ diff --git a/apprise/plugins/NotifyFaast.py b/apprise/plugins/NotifyFaast.py index cabee82b..5fffd724 100644 --- a/apprise/plugins/NotifyFaast.py +++ b/apprise/plugins/NotifyFaast.py @@ -124,6 +124,22 @@ class NotifyFaast(NotifyBase): return True + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + args = { + 'format': self.notify_format, + } + + return '{schema}://{authtoken}/?{args}'.format( + schema=self.protocol, + authtoken=self.quote(self.authtoken, safe=''), + args=self.urlencode(args), + ) + @staticmethod def parse_url(url): """ diff --git a/apprise/plugins/NotifyGnome.py b/apprise/plugins/NotifyGnome.py index 7f9747e5..a0f20fcc 100644 --- a/apprise/plugins/NotifyGnome.py +++ b/apprise/plugins/NotifyGnome.py @@ -164,6 +164,13 @@ class NotifyGnome(NotifyBase): return True + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + return '{schema}://'.format(schema=self.protocol) + @staticmethod def parse_url(url): """ diff --git a/apprise/plugins/NotifyGrowl/NotifyGrowl.py b/apprise/plugins/NotifyGrowl/NotifyGrowl.py index e478998b..b88cbb4d 100644 --- a/apprise/plugins/NotifyGrowl/NotifyGrowl.py +++ b/apprise/plugins/NotifyGrowl/NotifyGrowl.py @@ -207,6 +207,43 @@ class NotifyGrowl(NotifyBase): return True + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + _map = { + GrowlPriority.LOW: 'low', + GrowlPriority.MODERATE: 'moderate', + GrowlPriority.NORMAL: 'normal', + GrowlPriority.HIGH: 'high', + GrowlPriority.EMERGENCY: 'emergency', + } + + # Define any arguments set + args = { + 'format': self.notify_format, + 'priority': + _map[GrowlPriority.NORMAL] if self.priority not in _map + else _map[self.priority], + 'version': self.version, + } + + auth = '' + if self.password: + auth = '{password}@'.format( + password=self.quote(self.user, safe=''), + ) + + return '{schema}://{auth}{hostname}{port}/?{args}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + auth=auth, + hostname=self.host, + port='' if self.port is None or self.port == self.default_port + else ':{}'.format(self.port), + args=self.urlencode(args), + ) + @staticmethod def parse_url(url): """ @@ -239,15 +276,10 @@ class NotifyGrowl(NotifyBase): if 'priority' in results['qsd'] and len(results['qsd']['priority']): _map = { 'l': GrowlPriority.LOW, - '-2': GrowlPriority.LOW, 'm': GrowlPriority.MODERATE, - '-1': GrowlPriority.MODERATE, 'n': GrowlPriority.NORMAL, - '0': GrowlPriority.NORMAL, 'h': GrowlPriority.HIGH, - '1': GrowlPriority.HIGH, 'e': GrowlPriority.EMERGENCY, - '2': GrowlPriority.EMERGENCY, } try: results['priority'] = \ diff --git a/apprise/plugins/NotifyIFTTT.py b/apprise/plugins/NotifyIFTTT.py index 45a7782b..2f0bad50 100644 --- a/apprise/plugins/NotifyIFTTT.py +++ b/apprise/plugins/NotifyIFTTT.py @@ -34,7 +34,7 @@ # URL. For example, it might look like this: # https://maker.ifttt.com/use/a3nHB7gA9TfBQSqJAHklod # -# In the above example a3nHB7gA9TfBQSqJAHklod becomes your {apikey} +# In the above example a3nHB7gA9TfBQSqJAHklod becomes your {webhook_id} # You will need this to make this notification work correctly # # For each event you create you will assign it a name (this will be known as @@ -44,6 +44,7 @@ import requests from json import dumps from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP +from ..utils import parse_list class NotifyIFTTT(NotifyBase): @@ -59,7 +60,7 @@ class NotifyIFTTT(NotifyBase): service_url = 'https://ifttt.com/' # The default protocol - protocol = 'ifttt' + secure_protocol = 'ifttt' # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_ifttt' @@ -87,35 +88,28 @@ class NotifyIFTTT(NotifyBase): ifttt_default_type_key = 'value3' # IFTTT uses the http protocol with JSON requests - notify_url = 'https://maker.ifttt.com/trigger/{event}/with/key/{apikey}' + notify_url = 'https://maker.ifttt.com/' \ + 'trigger/{event}/with/key/{webhook_id}' - def __init__(self, apikey, event, event_args=None, **kwargs): + def __init__(self, webhook_id, events, **kwargs): """ Initialize IFTTT Object """ super(NotifyIFTTT, self).__init__(**kwargs) - if not apikey: - raise TypeError('You must specify the Webhooks apikey.') + if not webhook_id: + raise TypeError('You must specify the Webhooks webhook_id.') - if not event: - raise TypeError('You must specify the Event you wish to trigger.') + # Store our Events we wish to trigger + self.events = parse_list(events) + + if not self.events: + raise TypeError( + 'You must specify at least one event you wish to trigger on.') # Store our APIKey - self.apikey = apikey - - # Store our Event we wish to trigger - self.event = event - - if isinstance(event_args, dict): - # Make a copy of the arguments so that they can't change - # outside of this plugin - self.event_args = event_args.copy() - - else: - # Force a dictionary - self.event_args = dict() + self.webhook_id = webhook_id def notify(self, title, body, notify_type, **kwargs): """ @@ -134,72 +128,101 @@ class NotifyIFTTT(NotifyBase): self.ifttt_default_type_key: notify_type, } - # Update our payload using any other event_args specified - payload.update(self.event_args) - # Eliminate empty fields; users wishing to cancel the use of the # self.ifttt_default_ entries can preset these keys to being # empty so that they get caught here and removed. payload = {x: y for x, y in payload.items() if y} - # URL to transmit content via - url = self.notify_url.format( - apikey=self.apikey, - event=self.event, + # Track our failures + error_count = 0 + + # Create a copy of our event lit + events = list(self.events) + + while len(events): + + # Retrive an entry off of our event list + event = events.pop(0) + + # URL to transmit content via + url = self.notify_url.format( + webhook_id=self.webhook_id, + event=event, + ) + + self.logger.debug('IFTTT POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('IFTTT Payload: %s' % str(payload)) + try: + r = requests.post( + url, + data=dumps(payload), + headers=headers, + verify=self.verify_certificate, + ) + self.logger.debug( + u"IFTTT HTTP response status: %r" % r.status_code) + self.logger.debug( + u"IFTTT HTTP response headers: %r" % r.headers) + self.logger.debug( + u"IFTTT HTTP response body: %r" % r.content) + + if r.status_code != requests.codes.ok: + # We had a problem + try: + self.logger.warning( + 'Failed to send IFTTT:%s ' + 'notification: %s (error=%s).' % ( + event, + HTTP_ERROR_MAP[r.status_code], + r.status_code)) + + except KeyError: + self.logger.warning( + 'Failed to send IFTTT:%s ' + 'notification (error=%s).' % ( + event, r.status_code)) + + # self.logger.debug('Response Details: %s' % r.content) + error_count += 1 + + else: + self.logger.info( + 'Sent IFTTT notification to Event %s.' % event) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured sending IFTTT:%s ' % ( + event) + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + error_count += 1 + + finally: + if len(events): + # Prevent thrashing requests + self.throttle() + + return (error_count == 0) + + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + args = { + 'format': self.notify_format, + } + + return '{schema}://{webhook_id}@{events}/?{args}'.format( + schema=self.secure_protocol, + webhook_id=self.webhook_id, + events='/'.join([self.quote(x, safe='') for x in self.events]), + args=self.urlencode(args), ) - self.logger.debug('IFTTT POST URL: %s (cert_verify=%r)' % ( - url, self.verify_certificate, - )) - self.logger.debug('IFTTT Payload: %s' % str(payload)) - try: - r = requests.post( - url, - data=dumps(payload), - headers=headers, - verify=self.verify_certificate, - ) - self.logger.debug( - u"IFTTT HTTP response status: %r" % r.status_code) - self.logger.debug( - u"IFTTT HTTP response headers: %r" % r.headers) - self.logger.debug( - u"IFTTT HTTP response body: %r" % r.content) - - if r.status_code != requests.codes.ok: - # We had a problem - try: - self.logger.warning( - 'Failed to send IFTTT:%s ' - 'notification: %s (error=%s).' % ( - self.event, - HTTP_ERROR_MAP[r.status_code], - r.status_code)) - - except KeyError: - self.logger.warning( - 'Failed to send IFTTT:%s ' - 'notification (error=%s).' % ( - self.event, - r.status_code)) - - # self.logger.debug('Response Details: %s' % r.content) - return False - - else: - self.logger.info( - 'Sent IFTTT notification to Event %s.' % self.event) - - except requests.RequestException as e: - self.logger.warning( - 'A Connection error occured sending IFTTT:%s ' % ( - self.event) + 'notification.' - ) - self.logger.debug('Socket Exception: %s' % str(e)) - return False - - return True - @staticmethod def parse_url(url): """ @@ -214,22 +237,14 @@ class NotifyIFTTT(NotifyBase): return results # Our Event - results['event'] = results['host'] + results['events'] = list() + results['events'].append(results['host']) # Our API Key - results['apikey'] = results['user'] + results['webhook_id'] = results['user'] - # Store ValueX entries based on each entry past the host - results['event_args'] = { - '{0}{1}'.format(NotifyIFTTT.ifttt_default_key_prefix, n + 1): - NotifyBase.unquote(x) - for n, x in enumerate( - NotifyBase.split_path(results['fullpath'])) if x} - - # Allow users to set key=val parameters to specify more types - # of payload options - results['event_args'].update( - {k: NotifyBase.unquote(v) - for k, v in results['qsd'].items()}) + # Now fetch the remaining tokens + results['events'].extend([x for x in filter( + bool, NotifyBase.split_path(results['fullpath']))][0:]) return results diff --git a/apprise/plugins/NotifyJSON.py b/apprise/plugins/NotifyJSON.py index ba160e92..c2903c43 100644 --- a/apprise/plugins/NotifyJSON.py +++ b/apprise/plugins/NotifyJSON.py @@ -70,6 +70,39 @@ class NotifyJSON(NotifyBase): return + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + args = { + 'format': self.notify_format, + } + + # Determine Authentication + auth = '' + if self.user and self.password: + auth = '{user}:{password}@'.format( + user=self.quote(self.user, safe=''), + password=self.quote(self.password, safe=''), + ) + elif self.user: + auth = '{user}@'.format( + user=self.quote(self.user, safe=''), + ) + + default_port = 443 if self.secure else 80 + + return '{schema}://{auth}{hostname}{port}/?{args}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + auth=auth, + hostname=self.host, + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + args=self.urlencode(args), + ) + def notify(self, title, body, notify_type, **kwargs): """ Perform JSON Notification diff --git a/apprise/plugins/NotifyJoin.py b/apprise/plugins/NotifyJoin.py index 67b75ab9..ed74c430 100644 --- a/apprise/plugins/NotifyJoin.py +++ b/apprise/plugins/NotifyJoin.py @@ -78,7 +78,7 @@ class NotifyJoin(NotifyBase): service_url = 'https://joaoapps.com/join/' # The default protocol - protocol = 'join' + secure_protocol = 'join' # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_join' @@ -233,6 +233,22 @@ class NotifyJoin(NotifyBase): return return_status + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + args = { + 'format': self.notify_format, + } + + return '{schema}://{apikey}/{devices}/?{args}'.format( + schema=self.secure_protocol, + apikey=self.quote(self.apikey, safe=''), + devices='/'.join([self.quote(x) for x in self.devices]), + args=self.urlencode(args)) + @staticmethod def parse_url(url): """ diff --git a/apprise/plugins/NotifyMatrix.py b/apprise/plugins/NotifyMatrix.py index 53c38942..27422d1b 100644 --- a/apprise/plugins/NotifyMatrix.py +++ b/apprise/plugins/NotifyMatrix.py @@ -112,7 +112,6 @@ class NotifyMatrix(NotifyBase): if not self.user: self.logger.warning( 'No user was specified; using %s.' % MATRIX_DEFAULT_USER) - self.user = MATRIX_DEFAULT_USER if mode not in MATRIX_NOTIFICATION_MODES: self.logger.warning('The mode specified (%s) is invalid.' % mode) @@ -209,7 +208,7 @@ class NotifyMatrix(NotifyBase): def __slack_mode_payload(self, title, body, notify_type): # prepare JSON Object payload = { - 'username': self.user, + 'username': self.user if self.user else MATRIX_DEFAULT_USER, # Use Markdown language 'mrkdwn': True, 'attachments': [{ @@ -230,13 +229,43 @@ class NotifyMatrix(NotifyBase): msg = '

%s

%s
' % (title, body) payload = { - 'displayName': self.user, + 'displayName': self.user if self.user else MATRIX_DEFAULT_USER, 'format': 'html', 'text': msg, } return payload + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + args = { + 'format': self.notify_format, + 'mode': self.mode, + } + + # Determine Authentication + auth = '' + if self.user: + auth = '{user}@'.format( + user=self.quote(self.user, safe=''), + ) + + default_port = 443 if self.secure else 80 + + return '{schema}://{auth}{host}/{token}{port}/?{args}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + host=self.host, + auth=auth, + token=self.token, + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + args=self.urlencode(args), + ) + @staticmethod def parse_url(url): """ diff --git a/apprise/plugins/NotifyMatterMost.py b/apprise/plugins/NotifyMatterMost.py index e92b8987..02ed020c 100644 --- a/apprise/plugins/NotifyMatterMost.py +++ b/apprise/plugins/NotifyMatterMost.py @@ -179,6 +179,28 @@ class NotifyMatterMost(NotifyBase): return True + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + args = { + 'format': self.notify_format, + } + + default_port = 443 if self.secure else self.default_port + default_schema = self.secure_protocol if self.secure else self.protocol + + return '{schema}://{hostname}{port}/{authtoken}/?{args}'.format( + schema=default_schema, + hostname=self.host, + port='' if not self.port or self.port == default_port + else ':{}'.format(self.port), + authtoken=self.quote(self.authtoken, safe=''), + args=self.urlencode(args), + ) + @staticmethod def parse_url(url): """ diff --git a/apprise/plugins/NotifyProwl.py b/apprise/plugins/NotifyProwl.py index 7ddb6f11..c501d370 100644 --- a/apprise/plugins/NotifyProwl.py +++ b/apprise/plugins/NotifyProwl.py @@ -190,6 +190,34 @@ class NotifyProwl(NotifyBase): return True + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + _map = { + ProwlPriority.LOW: 'low', + ProwlPriority.MODERATE: 'moderate', + ProwlPriority.NORMAL: 'normal', + ProwlPriority.HIGH: 'high', + ProwlPriority.EMERGENCY: 'emergency', + } + + # Define any arguments set + args = { + 'format': self.notify_format, + 'priority': 'normal' if self.priority not in _map + else _map[self.priority] + } + + return '{schema}://{apikey}/{providerkey}/?{args}'.format( + schema=self.secure_protocol, + apikey=self.quote(self.apikey, safe=''), + providerkey='' if not self.providerkey + else self.quote(self.providerkey, safe=''), + args=self.urlencode(args), + ) + @staticmethod def parse_url(url): """ @@ -216,15 +244,10 @@ class NotifyProwl(NotifyBase): if 'priority' in results['qsd'] and len(results['qsd']['priority']): _map = { 'l': ProwlPriority.LOW, - '-2': ProwlPriority.LOW, 'm': ProwlPriority.MODERATE, - '-1': ProwlPriority.MODERATE, 'n': ProwlPriority.NORMAL, - '0': ProwlPriority.NORMAL, 'h': ProwlPriority.HIGH, - '1': ProwlPriority.HIGH, 'e': ProwlPriority.EMERGENCY, - '2': ProwlPriority.EMERGENCY, } try: results['priority'] = \ diff --git a/apprise/plugins/NotifyPushBullet.py b/apprise/plugins/NotifyPushBullet.py index 5eac9a46..e772d2eb 100644 --- a/apprise/plugins/NotifyPushBullet.py +++ b/apprise/plugins/NotifyPushBullet.py @@ -182,6 +182,28 @@ class NotifyPushBullet(NotifyBase): return not has_error + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + args = { + 'format': self.notify_format, + } + + recipients = '/'.join([self.quote(x) for x in self.recipients]) + if recipients == PUSHBULLET_SEND_TO_ALL: + # keyword is reserved for internal usage only; it's safe to remove + # it from the recipients list + recipients = '' + + return '{schema}://{accesstoken}/{recipients}/?{args}'.format( + schema=self.secure_protocol, + accesstoken=self.quote(self.accesstoken, safe=''), + recipients=recipients, + args=self.urlencode(args)) + @staticmethod def parse_url(url): """ diff --git a/apprise/plugins/NotifyPushed.py b/apprise/plugins/NotifyPushed.py index b925ba63..df5a72fe 100644 --- a/apprise/plugins/NotifyPushed.py +++ b/apprise/plugins/NotifyPushed.py @@ -26,6 +26,7 @@ import re import requests from json import dumps +from itertools import chain from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP @@ -256,6 +257,29 @@ class NotifyPushed(NotifyBase): return True + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + args = { + 'format': self.notify_format, + } + + return '{schema}://{app_key}/{app_secret}/{targets}/?{args}'.format( + schema=self.secure_protocol, + app_key=self.quote(self.app_key, safe=''), + app_secret=self.quote(self.app_secret, safe=''), + targets='/'.join( + [self.quote(x) for x in chain( + # Channels are prefixed with a pound/hashtag symbol + ['#{}'.format(x) for x in self.channels], + # Users are prefixed with an @ symbol + ['@{}'.format(x) for x in self.users], + )]), + args=self.urlencode(args)) + @staticmethod def parse_url(url): """ diff --git a/apprise/plugins/NotifyPushjet/NotifyPushjet.py b/apprise/plugins/NotifyPushjet/NotifyPushjet.py index cb9ddc94..f4bf6062 100644 --- a/apprise/plugins/NotifyPushjet/NotifyPushjet.py +++ b/apprise/plugins/NotifyPushjet/NotifyPushjet.py @@ -52,12 +52,15 @@ class NotifyPushjet(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_pushjet' - def __init__(self, **kwargs): + def __init__(self, secret_key, **kwargs): """ Initialize Pushjet Object """ super(NotifyPushjet, self).__init__(**kwargs) + # store our key + self.secret_key = secret_key + def notify(self, title, body, notify_type): """ Perform Pushjet Notification @@ -72,7 +75,7 @@ class NotifyPushjet(NotifyBase): server += ":" + str(self.port) api = pushjet.Api(server) - service = api.Service(secret_key=self.user) + service = api.Service(secret_key=self.secret_key) service.send(body, title) self.logger.info('Sent Pushjet notification.') @@ -84,6 +87,27 @@ class NotifyPushjet(NotifyBase): return True + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + args = { + 'format': self.notify_format, + } + + default_port = 443 if self.secure else 80 + + return '{schema}://{secret_key}@{hostname}{port}/?{args}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + secret_key=self.quote(self.secret_key, safe=''), + hostname=self.host, + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + args=self.urlencode(args), + ) + @staticmethod def parse_url(url): """ @@ -91,10 +115,10 @@ class NotifyPushjet(NotifyBase): us to substantiate this object. Syntax: - pjet://secret@hostname - pjet://secret@hostname:port - pjets://secret@hostname - pjets://secret@hostname:port + pjet://secret_key@hostname + pjet://secret_key@hostname:port + pjets://secret_key@hostname + pjets://secret_key@hostname:port """ results = NotifyBase.parse_url(url) @@ -107,4 +131,7 @@ class NotifyPushjet(NotifyBase): # a username is required return None + # Store it as it's value + results['secret_key'] = results.get('user') + return results diff --git a/apprise/plugins/NotifyPushover.py b/apprise/plugins/NotifyPushover.py index fee093d7..72232f7b 100644 --- a/apprise/plugins/NotifyPushover.py +++ b/apprise/plugins/NotifyPushover.py @@ -237,6 +237,41 @@ class NotifyPushover(NotifyBase): return not has_error + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + _map = { + PushoverPriority.LOW: 'low', + PushoverPriority.MODERATE: 'moderate', + PushoverPriority.NORMAL: 'normal', + PushoverPriority.HIGH: 'high', + PushoverPriority.EMERGENCY: 'emergency', + } + + # Define any arguments set + args = { + 'format': self.notify_format, + 'priority': + _map[PushoverPriority.NORMAL] if self.priority not in _map + else _map[self.priority], + } + + devices = '/'.join([self.quote(x) for x in self.devices]) + if devices == PUSHOVER_SEND_TO_ALL: + # keyword is reserved for internal usage only; it's safe to remove + # it from the devices list + devices = '' + + return '{schema}://{auth}{token}/{devices}/?{args}'.format( + schema=self.secure_protocol, + auth='' if not self.user + else '{user}@'.format(user=self.quote(self.user, safe='')), + token=self.quote(self.token, safe=''), + devices=devices, + args=self.urlencode(args)) + @staticmethod def parse_url(url): """ diff --git a/apprise/plugins/NotifyRocketChat.py b/apprise/plugins/NotifyRocketChat.py index cb86b84d..899c8196 100644 --- a/apprise/plugins/NotifyRocketChat.py +++ b/apprise/plugins/NotifyRocketChat.py @@ -26,6 +26,7 @@ import re import requests from json import loads +from itertools import chain from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP @@ -106,6 +107,12 @@ class NotifyRocketChat(NotifyBase): elif not isinstance(recipients, (set, tuple, list)): recipients = [] + if not (self.user and self.password): + # Username & Password is required for Rocket Chat to work + raise TypeError( + 'No Rocket.Chat user/pass combo specified.' + ) + # Validate recipients and drop bad ones: for recipient in recipients: result = IS_CHANNEL.match(recipient) @@ -133,6 +140,40 @@ class NotifyRocketChat(NotifyBase): # Used to track token headers upon authentication (if successful) self.headers = {} + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + args = { + 'format': self.notify_format, + } + + # Determine Authentication + auth = '{user}:{password}@'.format( + user=self.quote(self.user, safe=''), + password=self.quote(self.password, safe=''), + ) + + default_port = 443 if self.secure else 80 + + return '{schema}://{auth}{hostname}{port}/{targets}/?{args}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + auth=auth, + hostname=self.host, + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + targets='/'.join( + [self.quote(x) for x in chain( + # Channels are prefixed with a pound/hashtag symbol + ['#{}'.format(x) for x in self.channels], + # Rooms are as is + self.rooms, + )]), + args=self.urlencode(args), + ) + def notify(self, title, body, notify_type, **kwargs): """ wrapper to send_notification since we can alert more then one channel diff --git a/apprise/plugins/NotifyRyver.py b/apprise/plugins/NotifyRyver.py index c0f6b81c..49b17412 100644 --- a/apprise/plugins/NotifyRyver.py +++ b/apprise/plugins/NotifyRyver.py @@ -221,6 +221,32 @@ class NotifyRyver(NotifyBase): return True + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + args = { + 'format': self.notify_format, + 'webhook': self.webhook, + } + + # Determine if there is a botname present + botname = '' + if self.user: + botname = '{botname}@'.format( + botname=self.quote(self.user, safe=''), + ) + + return '{schema}://{botname}{organization}/{token}/?{args}'.format( + schema=self.secure_protocol, + botname=botname, + organization=self.quote(self.organization, safe=''), + token=self.quote(self.token, safe=''), + args=self.urlencode(args), + ) + @staticmethod def parse_url(url): """ diff --git a/apprise/plugins/NotifySNS.py b/apprise/plugins/NotifySNS.py index abc46f42..b29327f0 100644 --- a/apprise/plugins/NotifySNS.py +++ b/apprise/plugins/NotifySNS.py @@ -31,6 +31,7 @@ from hashlib import sha256 from datetime import datetime from collections import OrderedDict from xml.etree import ElementTree +from itertools import chain from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP @@ -58,8 +59,8 @@ LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') # region as a delimiter. This is a bit hacky; but it's much easier than having # users of this product search though this Access Key Secret and escape all # of the forward slashes! -IS_REGION = re.compile(r'^\s*(?P[a-z]{2})-' - r'(?P[a-z]+)-(?P[0-9]+)\s*$', re.I) +IS_REGION = re.compile( + r'^\s*(?P[a-z]{2})-(?P[a-z]+)-(?P[0-9]+)\s*$', re.I) # Extend HTTP Error Messages AWS_HTTP_ERROR_MAP = HTTP_ERROR_MAP.copy() @@ -521,6 +522,32 @@ class NotifySNS(NotifyBase): return response + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + args = { + 'format': self.notify_format, + } + + return '{schema}://{key_id}/{key_secret}/{region}/{targets}/'\ + '?{args}'.format( + schema=self.secure_protocol, + key_id=self.quote(self.aws_access_key_id, safe=''), + key_secret=self.quote(self.aws_secret_access_key, safe=''), + region=self.quote(self.aws_region_name, safe=''), + targets='/'.join( + [self.quote(x) for x in chain( + # Phone # are prefixed with a plus symbol + ['+{}'.format(x) for x in self.phone], + # Topics are prefixed with a pound/hashtag symbol + ['#{}'.format(x) for x in self.topics], + )]), + args=self.urlencode(args), + ) + @staticmethod def parse_url(url): """ diff --git a/apprise/plugins/NotifySlack.py b/apprise/plugins/NotifySlack.py index ffa8a71a..430a33ff 100644 --- a/apprise/plugins/NotifySlack.py +++ b/apprise/plugins/NotifySlack.py @@ -141,7 +141,6 @@ class NotifySlack(NotifyBase): if not self.user: self.logger.warning( 'No user was specified; using %s.' % SLACK_DEFAULT_USER) - self.user = SLACK_DEFAULT_USER if compat_is_basestring(channels): self.channels = [x for x in filter(bool, CHANNEL_LIST_DELIM.split( @@ -231,7 +230,7 @@ class NotifySlack(NotifyBase): # prepare JSON Object payload = { 'channel': _channel, - 'username': self.user, + 'username': self.user if self.user else SLACK_DEFAULT_USER, # Use Markdown language 'mrkdwn': True, 'attachments': [{ @@ -297,6 +296,35 @@ class NotifySlack(NotifyBase): return notify_okay + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + args = { + 'format': self.notify_format, + } + + # Determine if there is a botname present + botname = '' + if self.user: + botname = '{botname}@'.format( + botname=self.quote(self.user, safe=''), + ) + + return '{schema}://{botname}{token_a}/{token_b}/{token_c}/{targets}/'\ + '?{args}'.format( + schema=self.secure_protocol, + botname=botname, + token_a=self.quote(self.token_a, safe=''), + token_b=self.quote(self.token_b, safe=''), + token_c=self.quote(self.token_c, safe=''), + targets='/'.join( + [self.quote(x, safe='') for x in self.channels]), + args=self.urlencode(args), + ) + @staticmethod def parse_url(url): """ diff --git a/apprise/plugins/NotifyTelegram.py b/apprise/plugins/NotifyTelegram.py index 4ad54cc8..094665fe 100644 --- a/apprise/plugins/NotifyTelegram.py +++ b/apprise/plugins/NotifyTelegram.py @@ -60,8 +60,8 @@ from json import dumps from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP from ..common import NotifyImageSize -from ..utils import compat_is_basestring from ..utils import parse_bool +from ..utils import parse_list from ..common import NotifyFormat TELEGRAM_IMAGE_XY = NotifyImageSize.XY_256 @@ -81,9 +81,6 @@ IS_CHAT_ID_RE = re.compile( re.IGNORECASE, ) -# Used to break path apart into list of chat identifiers -CHAT_ID_LIST_DELIM = re.compile(r'[ \t\r\n,#\\/]+') - class NotifyTelegram(NotifyBase): """ @@ -134,16 +131,8 @@ class NotifyTelegram(NotifyBase): # Store our Bot Token self.bot_token = result.group('key') - if compat_is_basestring(chat_ids): - self.chat_ids = [x for x in filter(bool, CHAT_ID_LIST_DELIM.split( - chat_ids, - ))] - - elif isinstance(chat_ids, (set, tuple, list)): - self.chat_ids = list(chat_ids) - - else: - self.chat_ids = list() + # Parse our list + self.chat_ids = parse_list(chat_ids) if self.user: # Treat this as a channel too @@ -153,7 +142,7 @@ class NotifyTelegram(NotifyBase): _id = self.detect_bot_owner() if _id: # Store our id - self.chat_ids = [str(_id)] + self.chat_ids.append(str(_id)) if len(self.chat_ids) == 0: self.logger.warning('No chat_id(s) were specified.') @@ -501,6 +490,25 @@ class NotifyTelegram(NotifyBase): return not has_error + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + args = { + 'format': self.notify_format, + } + + # No need to check the user token because the user automatically gets + # appended into the list of chat ids + return '{schema}://{bot_token}/{targets}/?{args}'.format( + schema=self.secure_protocol, + bot_token=self.quote(self.bot_token, safe=''), + targets='/'.join( + [self.quote('@{}'.format(x)) for x in self.chat_ids]), + args=self.urlencode(args)) + @staticmethod def parse_url(url): """ @@ -512,17 +520,17 @@ class NotifyTelegram(NotifyBase): # tgram:// messages since the bot_token has a colon in it. # It invalidates an normal URL. - # This hack searches for this bogus URL and corrects it - # so we can properly load it further down. The other - # alternative is to ask users to actually change the colon - # into a slash (which will work too), but it's more likely - # to cause confusion... So this is the next best thing + # This hack searches for this bogus URL and corrects it so we can + # properly load it further down. The other alternative is to ask users + # to actually change the colon into a slash (which will work too), but + # it's more likely to cause confusion... So this is the next best thing + # we also check for %3A (incase the URL is encoded) as %3A == : try: tgram = re.match( - r'(?P%s://)(bot)?(?P([a-z0-9_-]+)' - r'(:[a-z0-9_-]+)?@)?(?P[0-9]+):+' - r'(?P.*)$' % NotifyTelegram.secure_protocol, - url, re.I) + r'(?P{schema}://)(bot)?(?P([a-z0-9_-]+)' + r'(:[a-z0-9_-]+)?@)?(?P[0-9]+)(:|%3A)+' + r'(?P.*)$'.format( + schema=NotifyTelegram.secure_protocol), url, re.I) except (TypeError, AttributeError): # url is bad; force tgram to be None @@ -534,14 +542,11 @@ class NotifyTelegram(NotifyBase): if tgram.group('prefix'): # Try again - results = NotifyBase.parse_url( - '%s%s%s/%s' % ( - tgram.group('protocol'), - tgram.group('prefix'), - tgram.group('btoken_a'), - tgram.group('remaining'), - ), - ) + results = NotifyBase.parse_url('%s%s%s/%s' % ( + tgram.group('protocol'), + tgram.group('prefix'), + tgram.group('btoken_a'), + tgram.group('remaining'))) else: # Try again @@ -562,9 +567,8 @@ class NotifyTelegram(NotifyBase): bot_token = '%s:%s' % (bot_token_a, bot_token_b) - chat_ids = ','.join( - [x for x in filter( - bool, NotifyBase.split_path(results['fullpath']))][1:]) + chat_ids = [x for x in filter( + bool, NotifyBase.split_path(results['fullpath']))][1:] # Store our bot token results['bot_token'] = bot_token diff --git a/apprise/plugins/NotifyTwitter/NotifyTwitter.py b/apprise/plugins/NotifyTwitter/NotifyTwitter.py index df7dbdd8..394e5473 100644 --- a/apprise/plugins/NotifyTwitter/NotifyTwitter.py +++ b/apprise/plugins/NotifyTwitter/NotifyTwitter.py @@ -109,7 +109,9 @@ class NotifyTwitter(NotifyBase): ) return False - text = '%s\r\n%s' % (title, body) + # Only set title if it was specified + text = body if not title else '%s\r\n%s' % (title, body) + try: # Get our API api = tweepy.API(self.auth) diff --git a/apprise/plugins/NotifyWindows.py b/apprise/plugins/NotifyWindows.py index ea0f0e16..86fd6dc8 100644 --- a/apprise/plugins/NotifyWindows.py +++ b/apprise/plugins/NotifyWindows.py @@ -175,6 +175,13 @@ class NotifyWindows(NotifyBase): return True + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + return '{schema}://'.format(schema=self.protocol) + @staticmethod def parse_url(url): """ diff --git a/apprise/plugins/NotifyXBMC.py b/apprise/plugins/NotifyXBMC.py index 264e8fad..fc46645f 100644 --- a/apprise/plugins/NotifyXBMC.py +++ b/apprise/plugins/NotifyXBMC.py @@ -44,11 +44,16 @@ class NotifyXBMC(NotifyBase): # The services URL service_url = 'http://kodi.tv/' + xbmc_protocol = 'xbmc' + xbmc_secure_protocol = 'xbmcs' + kodi_protocol = 'kodi' + kodi_secure_protocol = 'kodis' + # The default protocols - protocol = ('xbmc', 'kodi') + protocol = (xbmc_protocol, kodi_protocol) # The default secure protocols - secure_protocol = ('xbmc', 'kodis') + secure_protocol = (xbmc_secure_protocol, kodi_secure_protocol) # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_kodi' @@ -224,6 +229,44 @@ class NotifyXBMC(NotifyBase): return True + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + args = { + 'format': self.notify_format, + } + + # Determine Authentication + auth = '' + if self.user and self.password: + auth = '{user}:{password}@'.format( + user=self.quote(self.user, safe=''), + password=self.quote(self.password, safe=''), + ) + elif self.user: + auth = '{user}@'.format( + user=self.quote(self.user, safe=''), + ) + + default_schema = self.xbmc_protocol if ( + self.protocol <= self.xbmc_remote_protocol) else self.kodi_protocol + default_port = 443 if self.secure else self.xbmc_default_port + if self.secure: + # Append 's' to schema + default_schema + 's' + + return '{schema}://{auth}{hostname}{port}/?{args}'.format( + schema=default_schema, + auth=auth, + hostname=self.host, + port='' if not self.port or self.port == default_port + else ':{}'.format(self.port), + args=self.urlencode(args), + ) + @staticmethod def parse_url(url): """ diff --git a/apprise/plugins/NotifyXML.py b/apprise/plugins/NotifyXML.py index 9b2f36b2..cad538db 100644 --- a/apprise/plugins/NotifyXML.py +++ b/apprise/plugins/NotifyXML.py @@ -85,6 +85,39 @@ class NotifyXML(NotifyBase): return + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + args = { + 'format': self.notify_format, + } + + # Determine Authentication + auth = '' + if self.user and self.password: + auth = '{user}:{password}@'.format( + user=self.quote(self.user, safe=''), + password=self.quote(self.password, safe=''), + ) + elif self.user: + auth = '{user}@'.format( + user=self.quote(self.user, safe=''), + ) + + default_port = 443 if self.secure else 80 + + return '{schema}://{auth}{hostname}{port}/?{args}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + auth=auth, + hostname=self.host, + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + args=self.urlencode(args), + ) + def notify(self, title, body, notify_type, **kwargs): """ Perform XML Notification diff --git a/apprise/utils.py b/apprise/utils.py index 091c7e88..420cf2b5 100644 --- a/apprise/utils.py +++ b/apprise/utils.py @@ -397,6 +397,10 @@ def parse_list(*args): elif isinstance(arg, (set, list, tuple)): result += parse_list(*arg) + elif arg is None: + # Ignore + continue + else: # Convert whatever it is to a string and work with it result += parse_list(str(arg)) diff --git a/test/test_api.py b/test/test_api.py index fa6ffada..bf499a65 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -71,15 +71,34 @@ def test_apprise(): a = Apprise(servers=servers) - # 3 servers loaded + # 2 servers loaded assert(len(a) == 2) + # We can retrieve our URLs this way: + assert(len(a.urls()) == 2) + # We can add another server assert( a.add('mmosts://mattermost.server.local/' '3ccdd113474722377935511fc85d3dd4') is True) assert(len(a) == 3) + # We can pop an object off of our stack by it's indexed value: + obj = a.pop(0) + assert(isinstance(obj, NotifyBase) is True) + assert(len(a) == 2) + + # We can retrieve elements from our list too by reference: + assert(compat_is_basestring(a[0].url()) is True) + + # We can iterate over our list too: + count = 0 + for o in a: + assert(compat_is_basestring(o.url()) is True) + count += 1 + # verify that we did indeed iterate over each element + assert(len(a) == count) + # We can empty our set a.clear() assert(len(a) == 0) diff --git a/test/test_email_plugin.py b/test/test_email_plugin.py index bbd89c8d..80d108f4 100644 --- a/test/test_email_plugin.py +++ b/test/test_email_plugin.py @@ -26,6 +26,7 @@ from apprise import plugins from apprise import NotifyType from apprise import Apprise +from apprise.utils import compat_is_basestring from apprise.plugins import NotifyEmailBase import smtplib @@ -49,7 +50,7 @@ TEST_URLS = ( # No Username ('mailtos://:pass@nuxref.com:567', { # Can't prepare a To address using this expression - 'exception': TypeError, + 'instance': TypeError, }), # Pre-Configured Email Services @@ -115,27 +116,27 @@ TEST_URLS = ( }), # Invalid From Address ('mailtos://user:pass@nuxref.com?from=@', { - 'exception': TypeError, + 'instance': TypeError, }), # Invalid From Address ('mailtos://nuxref.com?user=&pass=.', { - 'exception': TypeError, + 'instance': TypeError, }), # Invalid To Address ('mailtos://user:pass@nuxref.com?to=@', { - 'exception': TypeError, + 'instance': TypeError, }), # Valid URL, but can't structure a proper email ('mailtos://nuxref.com?user=%20!&pass=.', { - 'exception': TypeError, + 'instance': TypeError, }), # Invalid From (and To) Address ('mailtos://nuxref.com?to=test', { - 'exception': TypeError, + 'instance': TypeError, }), # Invalid Secure Mode ('mailtos://user:pass@example.com?mode=notamode', { - 'exception': TypeError, + 'instance': TypeError, }), # STARTTLS flag checking ('mailtos://user:pass@gmail.com?mode=starttls', { @@ -172,9 +173,6 @@ def test_email_plugin(mock_smtp, mock_smtpssl): # Our expected instance instance = meta.get('instance', None) - # Our expected exception - exception = meta.get('exception', None) - # Our expected server objects self = meta.get('self', None) @@ -217,19 +215,37 @@ def test_email_plugin(mock_smtp, mock_smtpssl): try: obj = Apprise.instantiate(url, suppress_exceptions=False) - assert(exception is None) - if obj is None: - # We're done + # We're done (assuming this is what we were expecting) + assert instance is None continue if instance is None: # Expected None but didn't get it - print('%s instantiated %s' % (url, str(obj))) + print('%s instantiated %s (but expected None)' % ( + url, str(obj))) assert(False) assert(isinstance(obj, instance)) + if isinstance(obj, plugins.NotifyBase.NotifyBase): + # We loaded okay; now lets make sure we can reverse this url + assert(compat_is_basestring(obj.url()) is True) + + # Instantiate the exact same object again using the URL from + # the one that was already created properly + obj_cmp = Apprise.instantiate(obj.url()) + + # Our object should be the same instance as what we had + # originally expected above. + if not isinstance(obj_cmp, plugins.NotifyBase.NotifyBase): + # Assert messages are hard to trace back with the way + # these tests work. Just printing before throwing our + # assertion failure makes things easier to debug later on + print('TEST FAIL: {} regenerated as {}'.format( + url, obj.url())) + assert(False) + if self: # Iterate over our expected entries inside of our object for key, val in self.items(): @@ -258,16 +274,17 @@ def test_email_plugin(mock_smtp, mock_smtpssl): except Exception as e: # We can't handle this exception type - print('%s / %s' % (url, str(e))) - assert False + raise except AssertionError: # Don't mess with these entries + print('%s AssertionError' % url) raise except Exception as e: # Check that we were expecting this exception to happen - assert isinstance(e, response) + if not isinstance(e, response): + raise except AssertionError: # Don't mess with these entries @@ -276,9 +293,11 @@ def test_email_plugin(mock_smtp, mock_smtpssl): except Exception as e: # Handle our exception - print('%s / %s' % (url, str(e))) - assert(exception is not None) - assert(isinstance(e, exception)) + if(instance is None): + raise + + if not isinstance(e, instance): + raise @mock.patch('smtplib.SMTP') diff --git a/test/test_glib_plugin.py b/test/test_glib_plugin.py index 98d5e6b4..c2650f1a 100644 --- a/test/test_glib_plugin.py +++ b/test/test_glib_plugin.py @@ -28,6 +28,7 @@ import mock import sys import types import apprise +from apprise.utils import compat_is_basestring try: # Python v3.4+ @@ -223,6 +224,9 @@ def test_dbus_plugin(mock_mainloop, mock_byte, mock_bytearray, assert(isinstance(obj, apprise.plugins.NotifyDBus) is True) obj.duration = 0 + # Test url() call + assert(compat_is_basestring(obj.url()) is True) + # Our notification succeeds even though the gi library was not loaded assert(obj.notify(title='title', body='body', notify_type=apprise.NotifyType.INFO) is True) diff --git a/test/test_gnome_plugin.py b/test/test_gnome_plugin.py index 150293c9..ef66e8d2 100644 --- a/test/test_gnome_plugin.py +++ b/test/test_gnome_plugin.py @@ -28,6 +28,7 @@ import sys import types import apprise +from apprise.utils import compat_is_basestring try: # Python v3.4+ @@ -113,6 +114,9 @@ def test_gnome_plugin(): # Check that it found our mocked environments assert(obj._enabled is True) + # Test url() call + assert(compat_is_basestring(obj.url()) is True) + # test notifications assert(obj.notify(title='title', body='body', notify_type=apprise.NotifyType.INFO) is True) diff --git a/test/test_growl_plugin.py b/test/test_growl_plugin.py index 289f726f..295ec5fb 100644 --- a/test/test_growl_plugin.py +++ b/test/test_growl_plugin.py @@ -26,6 +26,8 @@ from apprise import plugins from apprise import NotifyType from apprise import Apprise +from apprise.utils import compat_is_basestring + import mock import re @@ -195,7 +197,6 @@ def test_growl_plugin(mock_gntp): except Exception as e: # We can't handle this exception type - print('%s / %s' % (url, str(e))) assert False # We're done this part of the test @@ -216,10 +217,27 @@ def test_growl_plugin(mock_gntp): if instance is None: # Expected None but didn't get it - print('%s instantiated %s' % (url, str(obj))) assert(False) - assert(isinstance(obj, instance)) + assert(isinstance(obj, instance) is True) + + if isinstance(obj, plugins.NotifyBase.NotifyBase): + # We loaded okay; now lets make sure we can reverse this url + assert(compat_is_basestring(obj.url()) is True) + + # Instantiate the exact same object again using the URL from + # the one that was already created properly + obj_cmp = Apprise.instantiate(obj.url()) + + # Our object should be the same instance as what we had + # originally expected above. + if not isinstance(obj_cmp, plugins.NotifyBase.NotifyBase): + # Assert messages are hard to trace back with the way + # these tests work. Just printing before throwing our + # assertion failure makes things easier to debug later on + print('TEST FAIL: {} regenerated as {}'.format( + url, obj.url())) + assert(False) if self: # Iterate over our expected entries inside of our object diff --git a/test/test_notify_base.py b/test/test_notify_base.py index dc3ee937..1afd328c 100644 --- a/test/test_notify_base.py +++ b/test/test_notify_base.py @@ -52,6 +52,16 @@ def test_notify_base(): nb = NotifyBase(port=10) assert nb.port == 10 + try: + nb.url() + assert False + + except NotImplementedError: + # Each sub-module is that inherits this as a parent is required to + # over-ride this function. So direct calls to this throws a not + # implemented error intentionally + assert True + # Throttle overrides.. nb = NotifyBase() nb.throttle_attempt = 0.0 diff --git a/test/test_pushjet_plugin.py b/test/test_pushjet_plugin.py index 789d4844..f2d19028 100644 --- a/test/test_pushjet_plugin.py +++ b/test/test_pushjet_plugin.py @@ -26,6 +26,8 @@ from apprise import plugins from apprise import NotifyType from apprise import Apprise +from apprise.utils import compat_is_basestring + import mock TEST_URLS = ( @@ -104,13 +106,37 @@ def test_plugin(mock_refresh, mock_send): try: obj = Apprise.instantiate(url, suppress_exceptions=False) - if instance is None: - # Check that we got what we came for - assert obj is instance + if obj is None: + # We're done (assuming this is what we were expecting) + assert instance is None continue + if instance is None: + # Expected None but didn't get it + print('%s instantiated %s (but expected None)' % ( + url, str(obj))) + assert(False) + assert(isinstance(obj, instance)) + if isinstance(obj, plugins.NotifyBase.NotifyBase): + # We loaded okay; now lets make sure we can reverse this url + assert(compat_is_basestring(obj.url()) is True) + + # Instantiate the exact same object again using the URL from + # the one that was already created properly + obj_cmp = Apprise.instantiate(obj.url()) + + # Our object should be the same instance as what we had + # originally expected above. + if not isinstance(obj_cmp, plugins.NotifyBase.NotifyBase): + # Assert messages are hard to trace back with the way + # these tests work. Just printing before throwing our + # assertion failure makes things easier to debug later on + print('TEST FAIL: {} regenerated as {}'.format( + url, obj.url())) + assert(False) + if self: # Iterate over our expected entries inside of our object for key, val in self.items(): @@ -144,21 +170,27 @@ def test_plugin(mock_refresh, mock_send): except Exception as e: # We can't handle this exception type - assert False + raise except AssertionError: # Don't mess with these entries + print('%s AssertionError' % url) raise except Exception as e: # Check that we were expecting this exception to happen - assert isinstance(e, response) + if not isinstance(e, response): + raise except AssertionError: # Don't mess with these entries + print('%s AssertionError' % url) raise except Exception as e: # Handle our exception - assert(instance is not None) - assert(isinstance(e, instance)) + if(instance is None): + raise + + if not isinstance(e, instance): + raise diff --git a/test/test_rest_plugins.py b/test/test_rest_plugins.py index bee5fbea..f0f6d29a 100644 --- a/test/test_rest_plugins.py +++ b/test/test_rest_plugins.py @@ -263,42 +263,13 @@ TEST_URLS = ( ('ifttt://EventID/', { 'instance': TypeError, }), - # Value1 gets assigned Entry1 - # Title = - # Body = - ('ifttt://WebHookID@EventID/Entry1/', { - 'instance': plugins.NotifyIFTTT, - }), - # Value1, Value2, and Value2, the below assigns: - # Value1 = Entry1 - # Value2 = AnotherEntry - # Value3 = ThirdValue - # Title = - # Body = - ('ifttt://WebHookID@EventID/Entry1/AnotherEntry/ThirdValue', { - 'instance': plugins.NotifyIFTTT, - }), - # Mix and match content, the below assigns: - # Value1 = FirstValue - # AnotherKey = Hello - # Value5 = test - # Title = - # Body = - ('ifttt://WebHookID@EventID/FirstValue/?AnotherKey=Hello&Value5=test', { - 'instance': plugins.NotifyIFTTT, - }), - # This would assign: - # Value1 = FirstValue - # Title = - disable the one passed by the notify call - # Body = - disable the one passed by the notify call - # The idea here is maybe you just want to use the apprise IFTTTT hook - # to trigger something and not nessisarily pass text along to it - ('ifttt://WebHookID@EventID/FirstValue/?Title=&Body=', { - 'instance': plugins.NotifyIFTTT, - }), ('ifttt://:@/', { 'instance': None, }), + # A nicely formed ifttt url with 2 events defined: + ('ifttt://WebHookID@EventID/EventID2/', { + 'instance': plugins.NotifyIFTTT, + }), # Test website connection failures ('ifttt://WebHookID@EventID', { 'instance': plugins.NotifyIFTTT, @@ -392,6 +363,9 @@ TEST_URLS = ( ('json://user:pass@localhost', { 'instance': plugins.NotifyJSON, }), + ('json://user@localhost', { + 'instance': plugins.NotifyJSON, + }), ('json://localhost:8080', { 'instance': plugins.NotifyJSON, }), @@ -529,8 +503,8 @@ TEST_URLS = ( ('matrix://user@localhost/%s' % ('a' * 64), { 'instance': plugins.NotifyMatrix, }), - # Name, port and token (secure) - ('matrixs://user@localhost:9000/%s' % ('a' * 64), { + # port and token (secure) + ('matrixs://localhost:9000/%s' % ('a' * 64), { 'instance': plugins.NotifyMatrix, }), # Name, port, token and slack mode @@ -963,6 +937,14 @@ TEST_URLS = ( ('rocket://user:pass@localhost/#/!/@', { 'instance': TypeError, }), + # No user/pass combo + ('rocket://user@localhost/room/', { + 'instance': TypeError, + }), + # No user/pass combo + ('rocket://localhost/room/', { + 'instance': TypeError, + }), # A room and port identifier ('rocket://user:pass@localhost:8080/room/', { 'instance': plugins.NotifyRocketChat, @@ -1383,7 +1365,7 @@ TEST_URLS = ( ('xbmc://user:pass@localhost:8080', { 'instance': plugins.NotifyXBMC, }), - ('xbmc://localhost', { + ('xbmc://user@localhost', { 'instance': plugins.NotifyXBMC, # don't include an image by default 'include_image': False, @@ -1432,6 +1414,9 @@ TEST_URLS = ( ('xml://localhost', { 'instance': plugins.NotifyXML, }), + ('xml://user@localhost', { + 'instance': plugins.NotifyXML, + }), ('xml://user:pass@localhost', { 'instance': plugins.NotifyXML, }), @@ -1487,6 +1472,8 @@ def test_rest_plugins(mock_post, mock_get): API: REST Based Plugins() """ + # Disable Throttling to speed testing + plugins.NotifyBase.NotifyBase.throttle_attempt = 0 # iterate over our dictionary and test it out for (url, meta) in TEST_URLS: @@ -1567,10 +1554,31 @@ def test_rest_plugins(mock_post, mock_get): assert instance is None continue + if instance is None: + # Expected None but didn't get it + print('%s instantiated %s (but expected None)' % ( + url, str(obj))) + assert(False) + assert(isinstance(obj, instance)) - # Disable throttling to speed up unit tests - obj.throttle_attempt = 0 + if isinstance(obj, plugins.NotifyBase.NotifyBase): + # We loaded okay; now lets make sure we can reverse this url + assert(compat_is_basestring(obj.url()) is True) + + # Instantiate the exact same object again using the URL from + # the one that was already created properly + obj_cmp = Apprise.instantiate(obj.url()) + + # Our object should be the same instance as what we had + # originally expected above. + if not isinstance(obj_cmp, plugins.NotifyBase.NotifyBase): + # Assert messages are hard to trace back with the way + # these tests work. Just printing before throwing our + # assertion failure makes things easier to debug later on + print('TEST FAIL: {} regenerated as {}'.format( + url, obj.url())) + assert(False) if self: # Iterate over our expected entries inside of our object @@ -1605,8 +1613,7 @@ def test_rest_plugins(mock_post, mock_get): except Exception as e: # We can't handle this exception type - print('%s / %s' % (url, str(e))) - assert False + raise except AssertionError: # Don't mess with these entries @@ -1615,7 +1622,8 @@ def test_rest_plugins(mock_post, mock_get): except Exception as e: # Check that we were expecting this exception to happen - assert isinstance(e, response) + if not isinstance(e, response): + raise # # Stage 2: without title defined @@ -1643,8 +1651,7 @@ def test_rest_plugins(mock_post, mock_get): except Exception as e: # We can't handle this exception type - print('%s / %s' % (url, str(e))) - assert False + raise except AssertionError: # Don't mess with these entries @@ -1653,7 +1660,8 @@ def test_rest_plugins(mock_post, mock_get): except Exception as e: # Check that we were expecting this exception to happen - assert isinstance(e, response) + if not isinstance(e, response): + raise except AssertionError: # Don't mess with these entries @@ -1662,9 +1670,11 @@ def test_rest_plugins(mock_post, mock_get): except Exception as e: # Handle our exception - print('%s / %s' % (url, str(e))) - assert(instance is not None) - assert(isinstance(e, instance)) + if(instance is None): + raise + + if not isinstance(e, instance): + raise @mock.patch('requests.get') @@ -1674,6 +1684,9 @@ def test_notify_boxcar_plugin(mock_post, mock_get): API: NotifyBoxcar() Extra Checks """ + # Disable Throttling to speed testing + plugins.NotifyBase.NotifyBase.throttle_attempt = 0 + # Generate some generic message types device = 'A' * 64 tag = '@B' * 63 @@ -1724,9 +1737,6 @@ def test_notify_boxcar_plugin(mock_post, mock_get): # Test notifications without a body or a title p = plugins.NotifyBoxcar(access=access, secret=secret, recipients=None) - # Disable throttling to speed up unit tests - p.throttle_attempt = 0 - p.notify(body=None, title=None, notify_type=NotifyType.INFO) is True @@ -1737,6 +1747,8 @@ def test_notify_discord_plugin(mock_post, mock_get): API: NotifyDiscord() Extra Checks """ + # Disable Throttling to speed testing + plugins.NotifyBase.NotifyBase.throttle_attempt = 0 # Initialize some generic (but valid) tokens webhook_id = 'A' * 24 @@ -1762,9 +1774,6 @@ def test_notify_discord_plugin(mock_post, mock_get): webhook_token=webhook_token, footer=True, thumbnail=False) - # Disable throttling to speed up unit tests - obj.throttle_attempt = 0 - # This call includes an image with it's payload: assert obj.notify(title='title', body='body', notify_type=NotifyType.INFO) is True @@ -2190,10 +2199,12 @@ def test_notify_ifttt_plugin(mock_post, mock_get): API: NotifyIFTTT() Extra Checks """ + # Disable Throttling to speed testing + plugins.NotifyBase.NotifyBase.throttle_attempt = 0 # Initialize some generic (but valid) tokens - apikey = 'webhookid' - event = 'event' + webhook_id = 'webhookid' + events = ['event1', 'event2'] # Prepare Mock mock_get.return_value = requests.Request() @@ -2204,7 +2215,7 @@ def test_notify_ifttt_plugin(mock_post, mock_get): mock_post.return_value.content = '{}' try: - obj = plugins.NotifyIFTTT(apikey=apikey, event=None, event_args=None) + obj = plugins.NotifyIFTTT(webhook_id=webhook_id, events=None) # No token specified assert(False) @@ -2212,12 +2223,8 @@ def test_notify_ifttt_plugin(mock_post, mock_get): # Exception should be thrown about the fact no token was specified assert(True) - obj = plugins.NotifyIFTTT(apikey=apikey, event=event, event_args=None) + obj = plugins.NotifyIFTTT(webhook_id=webhook_id, events=events) assert(isinstance(obj, plugins.NotifyIFTTT)) - assert(len(obj.event_args) == 0) - - # Disable throttling to speed up unit tests - obj.throttle_attempt = 0 assert obj.notify(title='title', body='body', notify_type=NotifyType.INFO) is True @@ -2230,6 +2237,9 @@ def test_notify_join_plugin(mock_post, mock_get): API: NotifyJoin() Extra Checks """ + # Disable Throttling to speed testing + plugins.NotifyBase.NotifyBase.throttle_attempt = 0 + # Generate some generic message types device = 'A' * 32 group = 'group.chrome' @@ -2250,9 +2260,6 @@ def test_notify_join_plugin(mock_post, mock_get): mock_post.return_value.status_code = requests.codes.created mock_get.return_value.status_code = requests.codes.created - # Disable throttling to speed up unit tests - p.throttle_attempt = 0 - # Test notifications without a body or a title; nothing to send # so we return False p.notify(body=None, title=None, notify_type=NotifyType.INFO) is False @@ -2265,6 +2272,8 @@ def test_notify_slack_plugin(mock_post, mock_get): API: NotifySlack() Extra Checks """ + # Disable Throttling to speed testing + plugins.NotifyBase.NotifyBase.throttle_attempt = 0 # Initialize some generic (but valid) tokens token_a = 'A' * 9 @@ -2300,9 +2309,6 @@ def test_notify_slack_plugin(mock_post, mock_get): token_a=token_a, token_b=token_b, token_c=token_c, channels=channels, include_image=True) - # Disable throttling to speed up unit tests - obj.throttle_attempt = 0 - # This call includes an image with it's payload: assert obj.notify(title='title', body='body', notify_type=NotifyType.INFO) is True @@ -2358,6 +2364,9 @@ def test_notify_pushed_plugin(mock_post, mock_get): API: NotifyPushed() Extra Checks """ + # Disable Throttling to speed testing + plugins.NotifyBase.NotifyBase.throttle_attempt = 0 + # Chat ID recipients = '@ABCDEFG, @DEFGHIJ, #channel, #channel2' @@ -2436,9 +2445,6 @@ def test_notify_pushed_plugin(mock_post, mock_get): assert(len(obj.channels) == 2) assert(len(obj.users) == 2) - # Disable throttling to speed up unit tests - obj.throttle_attempt = 0 - # Support the handling of an empty and invalid URL strings assert plugins.NotifyPushed.parse_url(None) is None assert plugins.NotifyPushed.parse_url('') is None @@ -2458,6 +2464,8 @@ def test_notify_pushover_plugin(mock_post, mock_get): API: NotifyPushover() Extra Checks """ + # Disable Throttling to speed testing + plugins.NotifyBase.NotifyBase.throttle_attempt = 0 # Initialize some generic (but valid) tokens token = 'a' * 30 @@ -2487,9 +2495,6 @@ def test_notify_pushover_plugin(mock_post, mock_get): assert(isinstance(obj, plugins.NotifyPushover)) assert(len(obj.devices) == 3) - # Disable throttling to speed up unit tests - obj.throttle_attempt = 0 - # This call fails because there is 1 invalid device assert obj.notify(title='title', body='body', notify_type=NotifyType.INFO) is False @@ -2500,9 +2505,6 @@ def test_notify_pushover_plugin(mock_post, mock_get): # device defined here assert(len(obj.devices) == 1) - # Disable throttling to speed up unit tests - obj.throttle_attempt = 0 - # This call succeeds because all of the devices are valid assert obj.notify(title='title', body='body', notify_type=NotifyType.INFO) is True @@ -2526,9 +2528,16 @@ def test_notify_rocketchat_plugin(mock_post, mock_get): API: NotifyRocketChat() Extra Checks """ + # Disable Throttling to speed testing + plugins.NotifyBase.NotifyBase.throttle_attempt = 0 + # Chat ID recipients = 'l2g, lead2gold, #channel, #channel2' + # Authentication + user = 'myuser' + password = 'mypass' + # Prepare Mock mock_get.return_value = requests.Request() mock_post.return_value = requests.Request() @@ -2538,7 +2547,8 @@ def test_notify_rocketchat_plugin(mock_post, mock_get): mock_get.return_value.text = '' try: - obj = plugins.NotifyRocketChat(recipients=None) + obj = plugins.NotifyRocketChat( + user=user, password=password, recipients=None) # invalid recipients list (None) assert(False) @@ -2548,7 +2558,8 @@ def test_notify_rocketchat_plugin(mock_post, mock_get): assert(True) try: - obj = plugins.NotifyRocketChat(recipients=object()) + obj = plugins.NotifyRocketChat( + user=user, password=password, recipients=object()) # invalid recipients list (object) assert(False) @@ -2558,7 +2569,8 @@ def test_notify_rocketchat_plugin(mock_post, mock_get): assert(True) try: - obj = plugins.NotifyRocketChat(recipients=set()) + obj = plugins.NotifyRocketChat( + user=user, password=password, recipients=set()) # invalid recipient list/set (no entries) assert(False) @@ -2567,14 +2579,12 @@ def test_notify_rocketchat_plugin(mock_post, mock_get): # specified assert(True) - obj = plugins.NotifyRocketChat(recipients=recipients) + obj = plugins.NotifyRocketChat( + user=user, password=password, recipients=recipients) assert(isinstance(obj, plugins.NotifyRocketChat)) assert(len(obj.channels) == 2) assert(len(obj.rooms) == 2) - # Disable throttling to speed up unit tests - obj.throttle_attempt = 0 - # # Logout # @@ -2653,6 +2663,9 @@ def test_notify_telegram_plugin(mock_post, mock_get): API: NotifyTelegram() Extra Checks """ + # Disable Throttling to speed testing + plugins.NotifyBase.NotifyBase.throttle_attempt = 0 + # Bot Token bot_token = '123456789:abcdefg_hijklmnop' invalid_bot_token = 'abcd:123' @@ -2710,6 +2723,12 @@ def test_notify_telegram_plugin(mock_post, mock_get): assert(isinstance(obj, plugins.NotifyTelegram)) assert(len(obj.chat_ids) == 2) + # test url call + assert(compat_is_basestring(obj.url())) + # Test that we can load the string we generate back: + obj = plugins.NotifyTelegram(**plugins.NotifyTelegram.parse_url(obj.url())) + assert(isinstance(obj, plugins.NotifyTelegram)) + # Support the handling of an empty and invalid URL strings assert(plugins.NotifyTelegram.parse_url(None) is None) assert(plugins.NotifyTelegram.parse_url('') is None) @@ -2730,10 +2749,6 @@ def test_notify_telegram_plugin(mock_post, mock_get): nimg_obj = plugins.NotifyTelegram(bot_token=bot_token, chat_ids=chat_ids) nimg_obj.asset = AppriseAsset(image_path_mask=False, image_url_mask=False) - # Disable throttling to speed up unit tests - nimg_obj.throttle_attempt = 0 - obj.throttle_attempt = 0 - # Test that our default settings over-ride base settings since they are # not the same as the one specified in the base; this check merely # ensures our plugin inheritance is working properly diff --git a/test/test_windows_plugin.py b/test/test_windows_plugin.py index 1c577ede..bfe52632 100644 --- a/test/test_windows_plugin.py +++ b/test/test_windows_plugin.py @@ -29,6 +29,7 @@ import types # Rebuild our Apprise environment import apprise +from apprise.utils import compat_is_basestring try: # Python v3.4+ @@ -107,6 +108,9 @@ def test_windows_plugin(): obj = apprise.Apprise.instantiate('windows://', suppress_exceptions=False) obj.duration = 0 + # Test URL functionality + assert(compat_is_basestring(obj.url()) is True) + # Check that it found our mocked environments assert(obj._enabled is True) From cdd72086ee8fa38ef5cb49fceb86642a6b5b5973 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sat, 16 Feb 2019 00:26:33 -0500 Subject: [PATCH 2/8] Implimented overflow support (upstream, truncate, and split) --- apprise/Apprise.py | 38 +- apprise/__init__.py | 6 +- apprise/common.py | 28 ++ apprise/plugins/NotifyBase.py | 103 +++++- apprise/plugins/NotifyBoxcar.py | 3 +- apprise/plugins/NotifyDBus.py | 17 +- apprise/plugins/NotifyDiscord.py | 1 + apprise/plugins/NotifyEmail.py | 1 + apprise/plugins/NotifyEmby.py | 1 + apprise/plugins/NotifyFaast.py | 1 + apprise/plugins/NotifyGnome.py | 19 +- apprise/plugins/NotifyGrowl/NotifyGrowl.py | 1 + apprise/plugins/NotifyIFTTT.py | 1 + apprise/plugins/NotifyJSON.py | 1 + apprise/plugins/NotifyJoin.py | 16 +- apprise/plugins/NotifyMatrix.py | 1 + apprise/plugins/NotifyMatterMost.py | 1 + apprise/plugins/NotifyProwl.py | 1 + apprise/plugins/NotifyPushBullet.py | 1 + apprise/plugins/NotifyPushed.py | 1 + .../plugins/NotifyPushjet/NotifyPushjet.py | 1 + apprise/plugins/NotifyPushover.py | 1 + apprise/plugins/NotifyRocketChat.py | 1 + apprise/plugins/NotifyRyver.py | 1 + apprise/plugins/NotifySNS.py | 5 + apprise/plugins/NotifySlack.py | 1 + apprise/plugins/NotifyTelegram.py | 1 + apprise/plugins/NotifyWindows.py | 11 +- apprise/plugins/NotifyXBMC.py | 12 +- apprise/plugins/NotifyXML.py | 1 + test/test_rest_plugins.py | 341 ++++++++++++++++++ 31 files changed, 551 insertions(+), 67 deletions(-) diff --git a/apprise/Apprise.py b/apprise/Apprise.py index 9c9b81de..32367a00 100644 --- a/apprise/Apprise.py +++ b/apprise/Apprise.py @@ -363,26 +363,30 @@ class Apprise(object): # Store entry directly conversion_map[server.notify_format] = body - try: - # Send notification - if not server.notify( - title=title, - body=conversion_map[server.notify_format], - notify_type=notify_type): + # Apply our overflow (if defined) + for chunk in server._apply_overflow( + body=conversion_map[server.notify_format], title=title): + try: + # Send notification + if not server.notify( + title=chunk['title'], + body=chunk['body'], + notify_type=notify_type): - # Toggle our return status flag + # Toggle our return status flag + status = False + + except TypeError: + # These our our internally thrown notifications + # TODO: Change this to a custom one such as + # AppriseNotifyError status = False - except TypeError: - # These our our internally thrown notifications - # TODO: Change this to a custom one such as AppriseNotifyError - status = False - - except Exception: - # A catch all so we don't have to abort early - # just because one of our plugins has a bug in it. - logging.exception("Notification Exception") - status = False + except Exception: + # A catch all so we don't have to abort early + # just because one of our plugins has a bug in it. + logging.exception("Notification Exception") + status = False return status diff --git a/apprise/__init__.py b/apprise/__init__.py index cf9e034e..674f68e7 100644 --- a/apprise/__init__.py +++ b/apprise/__init__.py @@ -37,6 +37,8 @@ from .common import NotifyImageSize from .common import NOTIFY_IMAGE_SIZES from .common import NotifyFormat from .common import NOTIFY_FORMATS +from .common import OverflowMode +from .common import OVERFLOW_MODES from .plugins.NotifyBase import NotifyBase from .Apprise import Apprise @@ -52,6 +54,6 @@ __all__ = [ 'Apprise', 'AppriseAsset', 'NotifyBase', # Reference - 'NotifyType', 'NotifyImageSize', 'NotifyFormat', 'NOTIFY_TYPES', - 'NOTIFY_IMAGE_SIZES', 'NOTIFY_FORMATS', + 'NotifyType', 'NotifyImageSize', 'NotifyFormat', 'OverflowMode', + 'NOTIFY_TYPES', 'NOTIFY_IMAGE_SIZES', 'NOTIFY_FORMATS', 'OVERFLOW_MODES', ] diff --git a/apprise/common.py b/apprise/common.py index 834c1928..75fcd481 100644 --- a/apprise/common.py +++ b/apprise/common.py @@ -77,3 +77,31 @@ NOTIFY_FORMATS = ( NotifyFormat.HTML, NotifyFormat.MARKDOWN, ) + + +class OverflowMode(object): + """ + A list of pre-defined modes of how to handle the text when it exceeds the + defined maximum message size. + """ + + # Send the data as is; untouched. Let the upstream server decide how the + # content is handled. Some upstream services might gracefully handle this + # with expected intentions; others might not. + UPSTREAM = 'upstream' + + # Always truncate the text when it exceeds the maximum message size and + # send it anyway + TRUNCATE = 'truncate' + + # Split the message into multiple smaller messages that fit within the + # limits of what is expected. The smaller messages are sent + SPLIT = 'split' + + +# Define our modes so we can verify if we need to +OVERFLOW_MODES = ( + OverflowMode.UPSTREAM, + OverflowMode.TRUNCATE, + OverflowMode.SPLIT, +) diff --git a/apprise/plugins/NotifyBase.py b/apprise/plugins/NotifyBase.py index 9e05a908..adaf5cb9 100644 --- a/apprise/plugins/NotifyBase.py +++ b/apprise/plugins/NotifyBase.py @@ -45,6 +45,8 @@ from ..utils import is_hostname from ..common import NOTIFY_TYPES from ..common import NotifyFormat from ..common import NOTIFY_FORMATS +from ..common import OverflowMode +from ..common import OVERFLOW_MODES from ..AppriseAsset import AppriseAsset @@ -120,14 +122,26 @@ class NotifyBase(object): image_size = None # The maximum allowable characters allowed in the body per message - body_maxlen = 32768 + # We set it to what would virtually be an infinite value really + # 2^63 - 1 = 9223372036854775807 + body_maxlen = 9223372036854775807 - # Defines the maximum allowable characters in the title + # Defines the maximum allowable characters in the title; set this to zero + # if a title can't be used. Titles that are not used but are defined are + # automatically placed into the body title_maxlen = 250 + # Set the maximum line count; if this is set to anything larger then zero + # the message (prior to it being sent) will be truncated to this number + # of lines. Setting this to zero disables this feature. + body_max_line_count = 0 + # Default Notify Format notify_format = NotifyFormat.TEXT + # Default Overflow Mode + overflow_mode = OverflowMode.UPSTREAM + # Maintain a set of tags to associate with this specific notification tags = set() @@ -177,6 +191,19 @@ class NotifyBase(object): # Provide override self.notify_format = notify_format + if 'overflow' in kwargs: + # Store the specified format if specified + overflow_mode = kwargs.get('overflow', '') + if overflow_mode.lower() not in OVERFLOW_MODES: + self.logger.error( + 'Invalid overflow method %s' % overflow_mode, + ) + raise TypeError( + 'Invalid overflow method %s' % overflow_mode, + ) + # Provide override + self.overflow_mode = overflow_mode + if 'tag' in kwargs: # We want to associate some tags with our notification service. # the code below gets the 'tag' argument if defined, otherwise @@ -260,6 +287,78 @@ class NotifyBase(object): color_type=color_type, ) + def _apply_overflow(self, body, title=None): + """ + Takes the message body and title as input. This function then + applies any defined overflow restrictions associated with the + notification service and may alter the message if/as required. + + The function will always return a list object in the following + structure: + [ + { + title: 'the title goes here', + body: 'the message body goes here', + }, + { + title: 'the title goes here', + body: 'the message body goes here', + }, + + ] + """ + + response = list() + + if self.title_maxlen <= 0: + # Content is appended to body + body = '{}\r\n{}'.format(title, body) + title = '' + + # Enforce the line count first always + if self.body_max_line_count > 0: + # Limit results to just the first 2 line otherwise + # there is just to much content to display + body = re.split('\r*\n', body) + body = '\r\n'.join(body[0:self.body_max_line_count]) + + if self.overflow_mode == OverflowMode.UPSTREAM: + # Nothing to do + response.append({ + 'body': body, + 'title': title, + }) + return response + + elif len(title) > self.title_maxlen: + # Truncate our Title + title = title[:self.title_maxlen] + + if self.body_maxlen > 0 and len(body) <= self.body_maxlen: + response.append({ + 'body': body, + 'title': title, + }) + return response + + if self.overflow_mode == OverflowMode.TRUNCATE: + # Truncate our body and return + response.append({ + 'body': body[:self.body_maxlen], + 'title': title, + }) + # For truncate mode, we're done now + return response + + # If we reach here, then we are in SPLIT mode. + # For here, we want to split the message as many times as we have to + # in order to fit it within the designated limits. + response = [{ + 'body': body[i: i + self.body_maxlen], + 'title': title} for i in range(0, len(body), self.body_maxlen)] + + return response + def url(self): """ Assembles the URL associated with the notification based on the diff --git a/apprise/plugins/NotifyBoxcar.py b/apprise/plugins/NotifyBoxcar.py index dd55a3a3..636a571e 100644 --- a/apprise/plugins/NotifyBoxcar.py +++ b/apprise/plugins/NotifyBoxcar.py @@ -280,7 +280,8 @@ class NotifyBoxcar(NotifyBase): # Define any arguments set args = { - 'format': self.notify_format + 'format': self.notify_format, + 'overflow': self.overflow_mode, } return '{schema}://{access}/{secret}/{recipients}/?{args}'.format( diff --git a/apprise/plugins/NotifyDBus.py b/apprise/plugins/NotifyDBus.py index 8083a4a6..a466ecd0 100644 --- a/apprise/plugins/NotifyDBus.py +++ b/apprise/plugins/NotifyDBus.py @@ -148,6 +148,14 @@ class NotifyDBus(NotifyBase): # The number of seconds to keep the message present for message_timeout_ms = 13000 + # Limit results to just the first 10 line otherwise there is just to much + # content to display + body_max_line_count = 10 + + # A title can not be used for SMS Messages. Setting this to zero will + # cause any title (if defined) to get placed into the message body. + title_maxlen = 0 + # This entry is a bit hacky, but it allows us to unit-test this library # in an environment that simply doesn't have the gnome packages # available to us. It also allows us to handle situations where the @@ -249,15 +257,6 @@ class NotifyDBus(NotifyBase): "Could not load Gnome notification icon ({}): {}" .format(icon_path, e)) - # Limit results to just the first 10 line otherwise - # there is just to much content to display - body = re.split('[\r\n]+', body) - if title: - # Place title on first line if it exists - body.insert(0, title) - - body = '\r\n'.join(body[0:10]) - try: dbus_iface.Notify( # Application Identifier diff --git a/apprise/plugins/NotifyDiscord.py b/apprise/plugins/NotifyDiscord.py index 2ce78bd0..76e1ec8d 100644 --- a/apprise/plugins/NotifyDiscord.py +++ b/apprise/plugins/NotifyDiscord.py @@ -249,6 +249,7 @@ class NotifyDiscord(NotifyBase): # Define any arguments set args = { 'format': self.notify_format, + 'overflow': self.overflow_mode, 'tts': 'yes' if self.tts else 'no', 'avatar': 'yes' if self.avatar else 'no', 'footer': 'yes' if self.footer else 'no', diff --git a/apprise/plugins/NotifyEmail.py b/apprise/plugins/NotifyEmail.py index bdc29a2d..c7ba1c1d 100644 --- a/apprise/plugins/NotifyEmail.py +++ b/apprise/plugins/NotifyEmail.py @@ -429,6 +429,7 @@ class NotifyEmail(NotifyBase): # Define any arguments set args = { 'format': self.notify_format, + 'overflow': self.overflow_mode, 'to': self.to_addr, 'from': self.from_addr, 'name': self.from_name, diff --git a/apprise/plugins/NotifyEmby.py b/apprise/plugins/NotifyEmby.py index 4a514ee5..41fd6f4f 100644 --- a/apprise/plugins/NotifyEmby.py +++ b/apprise/plugins/NotifyEmby.py @@ -543,6 +543,7 @@ class NotifyEmby(NotifyBase): # Define any arguments set args = { 'format': self.notify_format, + 'overflow': self.overflow_mode, 'modal': 'yes' if self.modal else 'no', } diff --git a/apprise/plugins/NotifyFaast.py b/apprise/plugins/NotifyFaast.py index 5fffd724..2bb41818 100644 --- a/apprise/plugins/NotifyFaast.py +++ b/apprise/plugins/NotifyFaast.py @@ -132,6 +132,7 @@ class NotifyFaast(NotifyBase): # Define any arguments set args = { 'format': self.notify_format, + 'overflow': self.overflow_mode, } return '{schema}://{authtoken}/?{args}'.format( diff --git a/apprise/plugins/NotifyGnome.py b/apprise/plugins/NotifyGnome.py index a0f20fcc..a380cc42 100644 --- a/apprise/plugins/NotifyGnome.py +++ b/apprise/plugins/NotifyGnome.py @@ -26,8 +26,6 @@ from __future__ import absolute_import from __future__ import print_function -import re - from .NotifyBase import NotifyBase from ..common import NotifyImageSize @@ -86,6 +84,14 @@ class NotifyGnome(NotifyBase): # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_128 + # Limit results to just the first 10 line otherwise there is just to much + # content to display + body_max_line_count = 10 + + # A title can not be used for SMS Messages. Setting this to zero will + # cause any title (if defined) to get placed into the message body. + title_maxlen = 0 + # This entry is a bit hacky, but it allows us to unit-test this library # in an environment that simply doesn't have the gnome packages # available to us. It also allows us to handle situations where the @@ -119,15 +125,6 @@ class NotifyGnome(NotifyBase): "Gnome Notifications are not supported by this system.") return False - # Limit results to just the first 10 line otherwise - # there is just to much content to display - body = re.split('[\r\n]+', body) - if title: - # Place title on first line if it exists - body.insert(0, title) - - body = '\r\n'.join(body[0:10]) - try: # App initialization Notify.init(self.app_id) diff --git a/apprise/plugins/NotifyGrowl/NotifyGrowl.py b/apprise/plugins/NotifyGrowl/NotifyGrowl.py index b88cbb4d..09f4f672 100644 --- a/apprise/plugins/NotifyGrowl/NotifyGrowl.py +++ b/apprise/plugins/NotifyGrowl/NotifyGrowl.py @@ -223,6 +223,7 @@ class NotifyGrowl(NotifyBase): # Define any arguments set args = { 'format': self.notify_format, + 'overflow': self.overflow_mode, 'priority': _map[GrowlPriority.NORMAL] if self.priority not in _map else _map[self.priority], diff --git a/apprise/plugins/NotifyIFTTT.py b/apprise/plugins/NotifyIFTTT.py index 2f0bad50..7505ebd0 100644 --- a/apprise/plugins/NotifyIFTTT.py +++ b/apprise/plugins/NotifyIFTTT.py @@ -214,6 +214,7 @@ class NotifyIFTTT(NotifyBase): # Define any arguments set args = { 'format': self.notify_format, + 'overflow': self.overflow_mode, } return '{schema}://{webhook_id}@{events}/?{args}'.format( diff --git a/apprise/plugins/NotifyJSON.py b/apprise/plugins/NotifyJSON.py index c2903c43..982fa294 100644 --- a/apprise/plugins/NotifyJSON.py +++ b/apprise/plugins/NotifyJSON.py @@ -78,6 +78,7 @@ class NotifyJSON(NotifyBase): # Define any arguments set args = { 'format': self.notify_format, + 'overflow': self.overflow_mode, } # Determine Authentication diff --git a/apprise/plugins/NotifyJoin.py b/apprise/plugins/NotifyJoin.py index ed74c430..901e3940 100644 --- a/apprise/plugins/NotifyJoin.py +++ b/apprise/plugins/NotifyJoin.py @@ -90,6 +90,10 @@ class NotifyJoin(NotifyBase): # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_72 + # Limit results to just the first 2 line otherwise there is just to much + # content to display + body_max_line_count = 2 + # The maximum allowable characters allowed in the body per message body_maxlen = 1000 @@ -131,17 +135,6 @@ class NotifyJoin(NotifyBase): Perform Join Notification """ - try: - # Limit results to just the first 2 line otherwise - # there is just to much content to display - body = re.split('[\r\n]+', body) - body[0] = body[0].strip('#').strip() - body = '\r\n'.join(body[0:2]) - - except (AttributeError, TypeError): - # body was None or not of a type string - body = '' - headers = { 'User-Agent': self.app_id, 'Content-Type': 'application/x-www-form-urlencoded', @@ -241,6 +234,7 @@ class NotifyJoin(NotifyBase): # Define any arguments set args = { 'format': self.notify_format, + 'overflow': self.overflow_mode, } return '{schema}://{apikey}/{devices}/?{args}'.format( diff --git a/apprise/plugins/NotifyMatrix.py b/apprise/plugins/NotifyMatrix.py index 27422d1b..36673c24 100644 --- a/apprise/plugins/NotifyMatrix.py +++ b/apprise/plugins/NotifyMatrix.py @@ -244,6 +244,7 @@ class NotifyMatrix(NotifyBase): # Define any arguments set args = { 'format': self.notify_format, + 'overflow': self.overflow_mode, 'mode': self.mode, } diff --git a/apprise/plugins/NotifyMatterMost.py b/apprise/plugins/NotifyMatterMost.py index 02ed020c..0d46cb0e 100644 --- a/apprise/plugins/NotifyMatterMost.py +++ b/apprise/plugins/NotifyMatterMost.py @@ -187,6 +187,7 @@ class NotifyMatterMost(NotifyBase): # Define any arguments set args = { 'format': self.notify_format, + 'overflow': self.overflow_mode, } default_port = 443 if self.secure else self.default_port diff --git a/apprise/plugins/NotifyProwl.py b/apprise/plugins/NotifyProwl.py index c501d370..58e7e8ad 100644 --- a/apprise/plugins/NotifyProwl.py +++ b/apprise/plugins/NotifyProwl.py @@ -206,6 +206,7 @@ class NotifyProwl(NotifyBase): # Define any arguments set args = { 'format': self.notify_format, + 'overflow': self.overflow_mode, 'priority': 'normal' if self.priority not in _map else _map[self.priority] } diff --git a/apprise/plugins/NotifyPushBullet.py b/apprise/plugins/NotifyPushBullet.py index e772d2eb..fc5f4963 100644 --- a/apprise/plugins/NotifyPushBullet.py +++ b/apprise/plugins/NotifyPushBullet.py @@ -190,6 +190,7 @@ class NotifyPushBullet(NotifyBase): # Define any arguments set args = { 'format': self.notify_format, + 'overflow': self.overflow_mode, } recipients = '/'.join([self.quote(x) for x in self.recipients]) diff --git a/apprise/plugins/NotifyPushed.py b/apprise/plugins/NotifyPushed.py index df5a72fe..037a37c2 100644 --- a/apprise/plugins/NotifyPushed.py +++ b/apprise/plugins/NotifyPushed.py @@ -265,6 +265,7 @@ class NotifyPushed(NotifyBase): # Define any arguments set args = { 'format': self.notify_format, + 'overflow': self.overflow_mode, } return '{schema}://{app_key}/{app_secret}/{targets}/?{args}'.format( diff --git a/apprise/plugins/NotifyPushjet/NotifyPushjet.py b/apprise/plugins/NotifyPushjet/NotifyPushjet.py index f4bf6062..b5461eec 100644 --- a/apprise/plugins/NotifyPushjet/NotifyPushjet.py +++ b/apprise/plugins/NotifyPushjet/NotifyPushjet.py @@ -95,6 +95,7 @@ class NotifyPushjet(NotifyBase): # Define any arguments set args = { 'format': self.notify_format, + 'overflow': self.overflow_mode, } default_port = 443 if self.secure else 80 diff --git a/apprise/plugins/NotifyPushover.py b/apprise/plugins/NotifyPushover.py index 72232f7b..4f6999be 100644 --- a/apprise/plugins/NotifyPushover.py +++ b/apprise/plugins/NotifyPushover.py @@ -253,6 +253,7 @@ class NotifyPushover(NotifyBase): # Define any arguments set args = { 'format': self.notify_format, + 'overflow': self.overflow_mode, 'priority': _map[PushoverPriority.NORMAL] if self.priority not in _map else _map[self.priority], diff --git a/apprise/plugins/NotifyRocketChat.py b/apprise/plugins/NotifyRocketChat.py index 899c8196..ce95b15a 100644 --- a/apprise/plugins/NotifyRocketChat.py +++ b/apprise/plugins/NotifyRocketChat.py @@ -148,6 +148,7 @@ class NotifyRocketChat(NotifyBase): # Define any arguments set args = { 'format': self.notify_format, + 'overflow': self.overflow_mode, } # Determine Authentication diff --git a/apprise/plugins/NotifyRyver.py b/apprise/plugins/NotifyRyver.py index 49b17412..a0552df5 100644 --- a/apprise/plugins/NotifyRyver.py +++ b/apprise/plugins/NotifyRyver.py @@ -229,6 +229,7 @@ class NotifyRyver(NotifyBase): # Define any arguments set args = { 'format': self.notify_format, + 'overflow': self.overflow_mode, 'webhook': self.webhook, } diff --git a/apprise/plugins/NotifySNS.py b/apprise/plugins/NotifySNS.py index b29327f0..750d2d4a 100644 --- a/apprise/plugins/NotifySNS.py +++ b/apprise/plugins/NotifySNS.py @@ -90,6 +90,10 @@ class NotifySNS(NotifyBase): # Source: https://docs.aws.amazon.com/sns/latest/api/API_Publish.html body_maxlen = 140 + # A title can not be used for SMS Messages. Setting this to zero will + # cause any title (if defined) to get placed into the message body. + title_maxlen = 0 + def __init__(self, access_key_id, secret_access_key, region_name, recipients=None, **kwargs): """ @@ -530,6 +534,7 @@ class NotifySNS(NotifyBase): # Define any arguments set args = { 'format': self.notify_format, + 'overflow': self.overflow_mode, } return '{schema}://{key_id}/{key_secret}/{region}/{targets}/'\ diff --git a/apprise/plugins/NotifySlack.py b/apprise/plugins/NotifySlack.py index 430a33ff..aec32156 100644 --- a/apprise/plugins/NotifySlack.py +++ b/apprise/plugins/NotifySlack.py @@ -304,6 +304,7 @@ class NotifySlack(NotifyBase): # Define any arguments set args = { 'format': self.notify_format, + 'overflow': self.overflow_mode, } # Determine if there is a botname present diff --git a/apprise/plugins/NotifyTelegram.py b/apprise/plugins/NotifyTelegram.py index 094665fe..697c0040 100644 --- a/apprise/plugins/NotifyTelegram.py +++ b/apprise/plugins/NotifyTelegram.py @@ -498,6 +498,7 @@ class NotifyTelegram(NotifyBase): # Define any arguments set args = { 'format': self.notify_format, + 'overflow': self.overflow_mode, } # No need to check the user token because the user automatically gets diff --git a/apprise/plugins/NotifyWindows.py b/apprise/plugins/NotifyWindows.py index 86fd6dc8..c1251cb1 100644 --- a/apprise/plugins/NotifyWindows.py +++ b/apprise/plugins/NotifyWindows.py @@ -26,7 +26,6 @@ from __future__ import absolute_import from __future__ import print_function -import re from time import sleep from .NotifyBase import NotifyBase @@ -67,6 +66,10 @@ class NotifyWindows(NotifyBase): # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_128 + # Limit results to just the first 2 line otherwise there is just to much + # content to display + body_max_line_count = 2 + # This entry is a bit hacky, but it allows us to unit-test this library # in an environment that simply doesn't have the windows packages # available to us. It also allows us to handle situations where the @@ -110,12 +113,6 @@ class NotifyWindows(NotifyBase): "Windows Notifications are not supported by this system.") return False - # Limit results to just the first 2 line otherwise - # there is just to much content to display - body = re.split('[\r\n]+', body) - body[0] = body[0].strip('#').strip() - body = '\r\n'.join(body[0:2]) - try: # Register destruction callback message_map = {win32con.WM_DESTROY: self._on_destroy, } diff --git a/apprise/plugins/NotifyXBMC.py b/apprise/plugins/NotifyXBMC.py index fc46645f..32f9257f 100644 --- a/apprise/plugins/NotifyXBMC.py +++ b/apprise/plugins/NotifyXBMC.py @@ -23,7 +23,6 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -import re import requests from json import dumps @@ -58,6 +57,10 @@ class NotifyXBMC(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_kodi' + # Limit results to just the first 2 line otherwise there is just to much + # content to display + body_max_line_count = 2 + # XBMC uses the http protocol with JSON requests xbmc_default_port = 8080 @@ -159,12 +162,6 @@ class NotifyXBMC(NotifyBase): Perform XBMC/KODI Notification """ - # Limit results to just the first 2 line otherwise - # there is just to much content to display - body = re.split('[\r\n]+', body) - body[0] = body[0].strip('#').strip() - body = '\r\n'.join(body[0:2]) - if self.protocol == self.xbmc_remote_protocol: # XBMC v2.0 (headers, payload) = self._payload_20( @@ -237,6 +234,7 @@ class NotifyXBMC(NotifyBase): # Define any arguments set args = { 'format': self.notify_format, + 'overflow': self.overflow_mode, } # Determine Authentication diff --git a/apprise/plugins/NotifyXML.py b/apprise/plugins/NotifyXML.py index cad538db..cfbe97ac 100644 --- a/apprise/plugins/NotifyXML.py +++ b/apprise/plugins/NotifyXML.py @@ -93,6 +93,7 @@ class NotifyXML(NotifyBase): # Define any arguments set args = { 'format': self.notify_format, + 'overflow': self.overflow_mode, } # Determine Authentication diff --git a/test/test_rest_plugins.py b/test/test_rest_plugins.py index f0f6d29a..8c8a056e 100644 --- a/test/test_rest_plugins.py +++ b/test/test_rest_plugins.py @@ -25,12 +25,18 @@ from apprise import plugins from apprise import NotifyType +from apprise import NotifyBase from apprise import Apprise from apprise import AppriseAsset from apprise.utils import compat_is_basestring from apprise.common import NotifyFormat +from apprise.common import OverflowMode from json import dumps +from random import choice +from string import ascii_uppercase as str_alpha +from string import digits as str_num + import requests import mock @@ -2883,3 +2889,338 @@ def test_notify_telegram_plugin(mock_post, mock_get): except TypeError: # Exception should be thrown about the fact no token was specified assert(True) + + +def test_notify_overflow_truncate(): + """ + API: Overflow Truncate Functionality Testing + + """ + # + # A little preparation + # + + # Number of characters per line + row = 24 + + # Some variables we use to control the data we work with + body_len = 1024 + title_len = 1024 + + # Create a large body and title with random data + body = ''.join(choice(str_alpha + str_num + ' ') for _ in range(body_len)) + body = '\r\n'.join([body[i: i + row] for i in range(0, len(body), row)]) + + # the new lines add a large amount to our body; lets force the content + # back to being 1024 characters. + body = body[0:1024] + + # Create our title using random data + title = ''.join(choice(str_alpha + str_num) for _ in range(title_len)) + + # + # First Test: Truncated Title + # + class TestNotification(NotifyBase): + + # Test title max length + title_maxlen = 10 + + def __init__(self, *args, **kwargs): + super(TestNotification, self).__init__(**kwargs) + + def notify(self, *args, **kwargs): + # Pretend everything is okay + return True + + try: + # Load our object + obj = TestNotification(overflow='invalid') + + # We should have thrown an exception because our specified overflow + # is wrong. + assert False + + except TypeError: + # Expected to be here + assert True + + # Load our object + obj = TestNotification(overflow=OverflowMode.TRUNCATE) + assert obj is not None + + # Verify that we break the title to a max length of our title_max + # and that the body remains untouched + chunks = obj._apply_overflow(body=body, title=title) + assert len(chunks) == 1 + assert body == chunks[0].get('body') + assert title[0:TestNotification.title_maxlen] == chunks[0].get('title') + + # + # Next Test: Line Count Control + # + + class TestNotification(NotifyBase): + + # Test title max length + title_maxlen = 5 + + # Maximum number of lines + body_max_line_count = 5 + + def __init__(self, *args, **kwargs): + super(TestNotification, self).__init__(**kwargs) + + def notify(self, *args, **kwargs): + # Pretend everything is okay + return True + + # Load our object + obj = TestNotification(overflow=OverflowMode.TRUNCATE) + assert obj is not None + + # Verify that we break the title to a max length of our title_max + # and that the body remains untouched + chunks = obj._apply_overflow(body=body, title=title) + assert len(chunks) == 1 + assert len(chunks[0].get('body').split('\n')) == \ + TestNotification.body_max_line_count + assert title[0:TestNotification.title_maxlen] == chunks[0].get('title') + + # + # Next Test: Truncated body + # + + class TestNotification(NotifyBase): + + # Test title max length + title_maxlen = title_len + + # Enforce a body length of just 10 + body_maxlen = 10 + + def __init__(self, *args, **kwargs): + super(TestNotification, self).__init__(**kwargs) + + def notify(self, *args, **kwargs): + # Pretend everything is okay + return True + + # Load our object + obj = TestNotification(overflow=OverflowMode.TRUNCATE) + assert obj is not None + + # Verify that we break the title to a max length of our title_max + # and that the body remains untouched + chunks = obj._apply_overflow(body=body, title=title) + assert len(chunks) == 1 + assert body[0:TestNotification.body_maxlen] == chunks[0].get('body') + assert title == chunks[0].get('title') + + # + # Next Test: Append title to body + Truncated body + # + + class TestNotification(NotifyBase): + + # Enforce no title + title_maxlen = 0 + + # Enforce a body length of just 100 + body_maxlen = 100 + + def __init__(self, *args, **kwargs): + super(TestNotification, self).__init__(**kwargs) + + def notify(self, *args, **kwargs): + # Pretend everything is okay + return True + + # Load our object + obj = TestNotification(overflow=OverflowMode.TRUNCATE) + assert obj is not None + + # Verify that we break the title to a max length of our title_max + # and that the body remains untouched + chunks = obj._apply_overflow(body=body, title=title) + assert len(chunks) == 1 + + # The below line should be read carefully... We're actually testing to see + # that our title is matched against our body. Behind the scenes, the title + # was appended to the body. The body was then truncated to the maxlen. + # The thing is, since the title is so large, all of the body was lost + # and a good chunk of the title was too. The message sent will just be a + # small portion of the title + assert len(chunks[0].get('body')) == TestNotification.body_maxlen + assert title[0:TestNotification.body_maxlen] == chunks[0].get('body') + + +def test_notify_overflow_split(): + """ + API: Overflow Split Functionality Testing + + """ + + # + # A little preparation + # + + # Number of characters per line + row = 24 + + # Some variables we use to control the data we work with + body_len = 1024 + title_len = 1024 + + # Create a large body and title with random data + body = ''.join(choice(str_alpha + str_num + ' ') for _ in range(body_len)) + body = '\r\n'.join([body[i: i + row] for i in range(0, len(body), row)]) + + # the new lines add a large amount to our body; lets force the content + # back to being 1024 characters. + body = body[0:1024] + + # Create our title using random data + title = ''.join(choice(str_alpha + str_num) for _ in range(title_len)) + + # + # First Test: Truncated Title + # + class TestNotification(NotifyBase): + + # Test title max length + title_maxlen = 10 + + def __init__(self, *args, **kwargs): + super(TestNotification, self).__init__(**kwargs) + + def notify(self, *args, **kwargs): + # Pretend everything is okay + return True + + # Load our object + obj = TestNotification(overflow=OverflowMode.SPLIT) + assert obj is not None + + # Verify that we break the title to a max length of our title_max + # and that the body remains untouched + chunks = obj._apply_overflow(body=body, title=title) + assert len(chunks) == 1 + assert body == chunks[0].get('body') + assert title[0:TestNotification.title_maxlen] == chunks[0].get('title') + + # + # Next Test: Line Count Control + # + + class TestNotification(NotifyBase): + + # Test title max length + title_maxlen = 5 + + # Maximum number of lines + body_max_line_count = 5 + + def __init__(self, *args, **kwargs): + super(TestNotification, self).__init__(**kwargs) + + def notify(self, *args, **kwargs): + # Pretend everything is okay + return True + + # Load our object + obj = TestNotification(overflow=OverflowMode.SPLIT) + assert obj is not None + + # Verify that we break the title to a max length of our title_max + # and that the body remains untouched + chunks = obj._apply_overflow(body=body, title=title) + assert len(chunks) == 1 + assert len(chunks[0].get('body').split('\n')) == \ + TestNotification.body_max_line_count + assert title[0:TestNotification.title_maxlen] == chunks[0].get('title') + + # + # Next Test: Split body + # + + class TestNotification(NotifyBase): + + # Test title max length + title_maxlen = title_len + + # Enforce a body length + body_maxlen = (body_len / 4) + + def __init__(self, *args, **kwargs): + super(TestNotification, self).__init__(**kwargs) + + def notify(self, *args, **kwargs): + # Pretend everything is okay + return True + + # Load our object + obj = TestNotification(overflow=OverflowMode.SPLIT) + assert obj is not None + + # Verify that we break the title to a max length of our title_max + # and that the body remains untouched + chunks = obj._apply_overflow(body=body, title=title) + offset = 0 + assert len(chunks) == 4 + for chunk in chunks: + # Our title never changes + assert title == chunk.get('title') + + # Our body is only broken up; not lost + _body = chunk.get('body') + assert body[offset: len(_body) + offset] == _body + offset += len(_body) + + # + # Next Test: Append title to body + split body + # + + class TestNotification(NotifyBase): + + # Enforce no title + title_maxlen = 0 + + # Enforce a body length + body_maxlen = (title_len / 4) + + def __init__(self, *args, **kwargs): + super(TestNotification, self).__init__(**kwargs) + + def notify(self, *args, **kwargs): + # Pretend everything is okay + return True + + # Load our object + obj = TestNotification(overflow=OverflowMode.SPLIT) + assert obj is not None + + # Verify that we break the title to a max length of our title_max + # and that the body remains untouched + chunks = obj._apply_overflow(body=body, title=title) + + # Our final product is that our title has been appended to our body to + # create one great big body. As a result we'll get quite a few lines back + # now. + offset = 0 + + # Our body will look like this in small chunks at the end of the day + bulk = title + '\r\n' + body + + # Due to the new line added to the end + assert len(chunks) == ( + (len(bulk) / TestNotification.body_maxlen) + + (1 if len(bulk) % TestNotification.body_maxlen else 0)) + + for chunk in chunks: + # Our title is empty every time + assert chunk.get('title') == '' + + _body = chunk.get('body') + assert bulk[offset:len(_body)+offset] == _body + offset += len(_body) From b1133be85b142b35bf151d1d04475629a18947f9 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sun, 17 Feb 2019 13:45:48 -0500 Subject: [PATCH 3/8] refactored how +/- prefixed url arguments are handled --- apprise/plugins/NotifyBase.py | 3 - apprise/plugins/NotifyIFTTT.py | 42 +++++++++++-- apprise/plugins/NotifyJSON.py | 38 ++++++++++- apprise/plugins/NotifyXML.py | 38 ++++++++++- apprise/utils.py | 112 ++++++++++++++++++++++++++++----- test/test_notify_base.py | 6 -- test/test_rest_plugins.py | 66 +++++++++++++++++-- test/test_utils.py | 99 +++++++++++++++++++++++++++++ 8 files changed, 365 insertions(+), 39 deletions(-) diff --git a/apprise/plugins/NotifyBase.py b/apprise/plugins/NotifyBase.py index adaf5cb9..ddb75bbd 100644 --- a/apprise/plugins/NotifyBase.py +++ b/apprise/plugins/NotifyBase.py @@ -176,7 +176,6 @@ class NotifyBase(object): self.user = kwargs.get('user') self.password = kwargs.get('password') - self.headers = kwargs.get('headers') if 'format' in kwargs: # Store the specified format if specified @@ -536,6 +535,4 @@ class NotifyBase(object): if 'user' in results['qsd']: results['user'] = results['qsd']['user'] - results['headers'] = {k[1:]: v for k, v in results['qsd'].items() - if re.match(r'^-.', k)} return results diff --git a/apprise/plugins/NotifyIFTTT.py b/apprise/plugins/NotifyIFTTT.py index 7505ebd0..95abc8d9 100644 --- a/apprise/plugins/NotifyIFTTT.py +++ b/apprise/plugins/NotifyIFTTT.py @@ -91,10 +91,19 @@ class NotifyIFTTT(NotifyBase): notify_url = 'https://maker.ifttt.com/' \ 'trigger/{event}/with/key/{webhook_id}' - def __init__(self, webhook_id, events, **kwargs): + def __init__(self, webhook_id, events, add_tokens=None, del_tokens=None, + **kwargs): """ Initialize IFTTT Object + add_tokens can optionally be a dictionary of key/value pairs + that you want to include in the IFTTT post to the server. + + del_tokens can optionally be a list/tuple/set of tokens + that you want to eliminate from the IFTTT post. There isn't + much real functionality to this one unless you want to remove + reference to Value1, Value2, and/or Value3 + """ super(NotifyIFTTT, self).__init__(**kwargs) @@ -111,6 +120,22 @@ class NotifyIFTTT(NotifyBase): # Store our APIKey self.webhook_id = webhook_id + # Tokens to include in post + self.add_tokens = {} + if add_tokens: + self.add_tokens.update(add_tokens) + + # Tokens to remove + self.del_tokens = [] + if del_tokens is not None: + if isinstance(del_tokens, (list, tuple, set)): + self.del_tokens = del_tokens + + else: + raise TypeError( + 'del_token must be a list; {} was provided'.format( + str(type(del_tokens)))) + def notify(self, title, body, notify_type, **kwargs): """ Perform IFTTT Notification @@ -128,10 +153,13 @@ class NotifyIFTTT(NotifyBase): self.ifttt_default_type_key: notify_type, } - # Eliminate empty fields; users wishing to cancel the use of the - # self.ifttt_default_ entries can preset these keys to being - # empty so that they get caught here and removed. - payload = {x: y for x, y in payload.items() if y} + # Add any new tokens expected (this can also potentially override + # any entries defined above) + payload.update(self.add_tokens) + + # Eliminate fields flagged for removal + payload = {x: y for x, y in payload.items() + if x not in self.del_tokens} # Track our failures error_count = 0 @@ -217,6 +245,10 @@ class NotifyIFTTT(NotifyBase): 'overflow': self.overflow_mode, } + # Store any new key/value pairs added to our list + args.update({'+{}'.format(k): v for k, v in self.add_tokens}) + args.update({'-{}'.format(k): '' for k in self.del_tokens}) + return '{schema}://{webhook_id}@{events}/?{args}'.format( schema=self.secure_protocol, webhook_id=self.webhook_id, diff --git a/apprise/plugins/NotifyJSON.py b/apprise/plugins/NotifyJSON.py index 982fa294..35a3bead 100644 --- a/apprise/plugins/NotifyJSON.py +++ b/apprise/plugins/NotifyJSON.py @@ -52,9 +52,13 @@ class NotifyJSON(NotifyBase): # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_128 - def __init__(self, **kwargs): + def __init__(self, headers, **kwargs): """ Initialize JSON Object + + headers can be a dictionary of key/value pairs that you want to + additionally include as part of the server headers to post with + """ super(NotifyJSON, self).__init__(**kwargs) @@ -68,6 +72,11 @@ class NotifyJSON(NotifyBase): if not compat_is_basestring(self.fullpath): self.fullpath = '/' + self.headers = {} + if headers: + # Store our extra headers + self.headers.update(headers) + return def url(self): @@ -81,6 +90,9 @@ class NotifyJSON(NotifyBase): 'overflow': self.overflow_mode, } + # Append our headers into our args + args.update({'+{}'.format(k): v for k, v in self.headers.items()}) + # Determine Authentication auth = '' if self.user and self.password: @@ -125,8 +137,8 @@ class NotifyJSON(NotifyBase): 'Content-Type': 'application/json' } - if self.headers: - headers.update(self.headers) + # Apply any/all header over-rides defined + headers.update(self.headers) auth = None if self.user: @@ -179,3 +191,23 @@ class NotifyJSON(NotifyBase): return False return True + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to substantiate this object. + + """ + results = NotifyBase.parse_url(url) + + if not results: + # We're done early as we couldn't load the results + return results + + # Add our headers that the user can potentially over-ride if they wish + # to to our returned result set + results['headers'] = results['qsd-'] + results['headers'].update(results['qsd+']) + + return results diff --git a/apprise/plugins/NotifyXML.py b/apprise/plugins/NotifyXML.py index cfbe97ac..83f200cd 100644 --- a/apprise/plugins/NotifyXML.py +++ b/apprise/plugins/NotifyXML.py @@ -52,9 +52,13 @@ class NotifyXML(NotifyBase): # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_128 - def __init__(self, **kwargs): + def __init__(self, headers=None, **kwargs): """ Initialize XML Object + + headers can be a dictionary of key/value pairs that you want to + additionally include as part of the server headers to post with + """ super(NotifyXML, self).__init__(**kwargs) @@ -83,6 +87,11 @@ class NotifyXML(NotifyBase): if not compat_is_basestring(self.fullpath): self.fullpath = '/' + self.headers = {} + if headers: + # Store our extra headers + self.headers.update(headers) + return def url(self): @@ -96,6 +105,9 @@ class NotifyXML(NotifyBase): 'overflow': self.overflow_mode, } + # Append our headers into our args + args.update({'+{}'.format(k): v for k, v in self.headers.items()}) + # Determine Authentication auth = '' if self.user and self.password: @@ -130,8 +142,8 @@ class NotifyXML(NotifyBase): 'Content-Type': 'application/xml' } - if self.headers: - headers.update(self.headers) + # Apply any/all header over-rides defined + headers.update(self.headers) re_map = { '{MESSAGE_TYPE}': NotifyBase.quote(notify_type), @@ -197,3 +209,23 @@ class NotifyXML(NotifyBase): return False return True + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to substantiate this object. + + """ + results = NotifyBase.parse_url(url) + + if not results: + # We're done early as we couldn't load the results + return results + + # Add our headers that the user can potentially over-ride if they wish + # to to our returned result set + results['headers'] = results['qsd-'] + results['headers'].update(results['qsd+']) + + return results diff --git a/apprise/utils.py b/apprise/utils.py index 420cf2b5..010fe7d9 100644 --- a/apprise/utils.py +++ b/apprise/utils.py @@ -32,14 +32,12 @@ try: from urllib import unquote from urllib import quote from urlparse import urlparse - from urlparse import parse_qsl except ImportError: # Python 3.x from urllib.parse import unquote from urllib.parse import quote from urllib.parse import urlparse - from urllib.parse import parse_qsl import logging logger = logging.getLogger(__name__) @@ -91,6 +89,12 @@ TIDY_NUX_TRIM_RE = re.compile( ), ) +# The handling of custom arguments passed in the URL; we treat any +# argument (which would otherwise appear in the qsd area of our parse_url() +# function differently if they start with a + or - value +NOTIFY_CUSTOM_ADD_TOKENS = re.compile(r'^( |\+)(?P.*)\s*') +NOTIFY_CUSTOM_DEL_TOKENS = re.compile(r'^-(?P.*)\s*') + # Used for attempting to acquire the schema if the URL can't be parsed. GET_SCHEMA_RE = re.compile(r'\s*(?P[a-z0-9]{2,9})://.*$', re.I) @@ -143,6 +147,81 @@ def tidy_path(path): return path +def parse_qsd(qs): + """ + Query String Dictionary Builder + + A custom implimentation of the parse_qsl() function already provided + by Python. This function is slightly more light weight and gives us + more control over parsing out arguments such as the plus/+ symbol + at the head of a key/value pair. + + qs should be a query string part made up as part of the URL such as + a=1&c=2&d= + + a=1 gets interpreted as { 'a': '1' } + a= gets interpreted as { 'a': '' } + a gets interpreted as { 'a': '' } + + + This function returns a result object that fits with the apprise + expected parameters (populating the 'qsd' portion of the dictionary + """ + + # Our return result set: + result = { + # The arguments passed in (the parsed query). This is in a dictionary + # of {'key': 'val', etc }. Keys are all made lowercase before storing + # to simplify access to them. + 'qsd': {}, + + # Detected Entries that start with + or - are additionally stored in + # these values (un-touched). The +/- however are stripped from their + # name before they are stored here. + 'qsd+': {}, + 'qsd-': {}, + } + + pairs = [s2 for s1 in qs.split('&') for s2 in s1.split(';')] + for name_value in pairs: + nv = name_value.split('=', 1) + # Handle case of a control-name with no equal sign + if len(nv) != 2: + nv.append('') + + # Apprise keys can start with a + symbol; so we need to skip over + # the very first entry + key = '{}{}'.format( + '' if len(nv[0]) == 0 else nv[0][0], + '' if len(nv[0]) <= 1 else nv[0][1:].replace('+', ' '), + ) + + key = unquote(key) + key = '' if not key else key + + val = nv[1].replace('+', ' ') + val = unquote(val) + val = '' if not val else val.strip() + + # Always Query String Dictionary (qsd) for every entry we have + # content is always made lowercase for easy indexing + result['qsd'][key.lower().strip()] = val + + # Check for tokens that start with a addition/plus symbol (+) + k = NOTIFY_CUSTOM_ADD_TOKENS.match(key) + if k is not None: + # Store content 'as-is' + result['qsd+'][k.group('key')] = val + + # Check for tokens that start with a subtraction/hyphen symbol (-) + k = NOTIFY_CUSTOM_DEL_TOKENS.match(key) + if k is not None: + # Store content 'as-is' + result['qsd-'][k.group('key')] = val + + return result + + def parse_url(url, default_schema='http', verify_host=True): """A function that greatly simplifies the parsing of a url specified by the end user. @@ -190,10 +269,17 @@ def parse_url(url, default_schema='http', verify_host=True): 'schema': None, # The schema 'url': None, - # The arguments passed in (the parsed query) - # This is in a dictionary of {'key': 'val', etc } + # The arguments passed in (the parsed query). This is in a dictionary + # of {'key': 'val', etc }. Keys are all made lowercase before storing + # to simplify access to them. # qsd = Query String Dictionary - 'qsd': {} + 'qsd': {}, + + # Detected Entries that start with + or - are additionally stored in + # these values (un-touched). The +/- however are stripped from their + # name before they are stored here. + 'qsd+': {}, + 'qsd-': {}, } qsdata = '' @@ -220,6 +306,11 @@ def parse_url(url, default_schema='http', verify_host=True): # No qsdata pass + # Parse Query Arugments ?val=key&key=val + # while ensuring that all keys are lowercase + if qsdata: + result.update(parse_qsd(qsdata)) + # Now do a proper extraction of data parsed = urlparse('http://%s' % host) @@ -231,6 +322,7 @@ def parse_url(url, default_schema='http', verify_host=True): return None result['fullpath'] = quote(unquote(tidy_path(parsed[2].strip()))) + try: # Handle trailing slashes removed by tidy_path if result['fullpath'][-1] not in ('/', '\\') and \ @@ -242,16 +334,6 @@ def parse_url(url, default_schema='http', verify_host=True): # and therefore, no trailing slash pass - # Parse Query Arugments ?val=key&key=val - # while ensureing that all keys are lowercase - if qsdata: - result['qsd'] = dict([(k.lower().strip(), v.strip()) - for k, v in parse_qsl( - qsdata, - keep_blank_values=True, - strict_parsing=False, - )]) - if not result['fullpath']: # Default result['fullpath'] = None diff --git a/test/test_notify_base.py b/test/test_notify_base.py index 1afd328c..3ad62264 100644 --- a/test/test_notify_base.py +++ b/test/test_notify_base.py @@ -176,12 +176,6 @@ def test_notify_base_urls(): assert 'password' in results assert results['password'] == "newpassword" - # pass headers - results = NotifyBase.parse_url( - 'https://localhost:8080?-HeaderKey=HeaderValue') - assert 'headerkey' in results['headers'] - assert results['headers']['headerkey'] == 'HeaderValue' - # User Handling # user keyword over-rides default password diff --git a/test/test_rest_plugins.py b/test/test_rest_plugins.py index 8c8a056e..a6a13eaa 100644 --- a/test/test_rest_plugins.py +++ b/test/test_rest_plugins.py @@ -272,6 +272,14 @@ TEST_URLS = ( ('ifttt://:@/', { 'instance': None, }), + # A nicely formed ifttt url with 1 event and a new key/value store + ('ifttt://WebHookID@EventID/?+TemplateKey=TemplateVal', { + 'instance': plugins.NotifyIFTTT, + }), + # Removing certain keys: + ('ifttt://WebHookID@EventID/?-Value1=&-Value2', { + 'instance': plugins.NotifyIFTTT, + }), # A nicely formed ifttt url with 2 events defined: ('ifttt://WebHookID@EventID/EventID2/', { 'instance': plugins.NotifyIFTTT, @@ -2236,6 +2244,52 @@ def test_notify_ifttt_plugin(mock_post, mock_get): notify_type=NotifyType.INFO) is True + # Test the addition of tokens + obj = plugins.NotifyIFTTT( + webhook_id=webhook_id, events=events, + add_tokens={'Test':'ValueA', 'Test2': 'ValueB'}) + + assert(isinstance(obj, plugins.NotifyIFTTT)) + + assert obj.notify(title='title', body='body', + notify_type=NotifyType.INFO) is True + + try: + # Invalid del_tokens entry + obj = plugins.NotifyIFTTT( + webhook_id=webhook_id, events=events, + del_tokens=plugins.NotifyIFTTT.ifttt_default_title_key) + + # we shouldn't reach here + assert False + + except TypeError: + # del_tokens must be a list, so passing a string will throw + # an exception. + assert True + + assert(isinstance(obj, plugins.NotifyIFTTT)) + + assert obj.notify(title='title', body='body', + notify_type=NotifyType.INFO) is True + + # Test removal of tokens by a list + obj = plugins.NotifyIFTTT( + webhook_id=webhook_id, events=events, + add_tokens={ + 'MyKey': 'MyValue' + }, + del_tokens=( + plugins.NotifyIFTTT.ifttt_default_title_key, + plugins.NotifyIFTTT.ifttt_default_body_key, + plugins.NotifyIFTTT.ifttt_default_type_key, + )) + + assert(isinstance(obj, plugins.NotifyIFTTT)) + + assert obj.notify(title='title', body='body', + notify_type=NotifyType.INFO) is True + @mock.patch('requests.get') @mock.patch('requests.post') def test_notify_join_plugin(mock_post, mock_get): @@ -3150,7 +3204,8 @@ def test_notify_overflow_split(): title_maxlen = title_len # Enforce a body length - body_maxlen = (body_len / 4) + # Wrap in int() so Python v3 doesn't convert the response into a float + body_maxlen = int(body_len / 4) def __init__(self, *args, **kwargs): super(TestNotification, self).__init__(**kwargs) @@ -3186,8 +3241,9 @@ def test_notify_overflow_split(): # Enforce no title title_maxlen = 0 - # Enforce a body length - body_maxlen = (title_len / 4) + # Enforce a body length based on the title + # Wrap in int() so Python v3 doesn't convert the response into a float + body_maxlen = int(title_len / 4) def __init__(self, *args, **kwargs): super(TestNotification, self).__init__(**kwargs) @@ -3214,7 +3270,9 @@ def test_notify_overflow_split(): # Due to the new line added to the end assert len(chunks) == ( - (len(bulk) / TestNotification.body_maxlen) + + # wrap division in int() so Python 3 doesn't convert it to a float on + # us + int(len(bulk) / TestNotification.body_maxlen) + (1 if len(bulk) % TestNotification.body_maxlen else 0)) for chunk in chunks: diff --git a/test/test_utils.py b/test/test_utils.py index 64e5568f..1281c6e4 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -35,6 +35,26 @@ except ImportError: from apprise import utils +def test_parse_qsd(): + "utils: parse_qsd() testing """ + + result = utils.parse_qsd('a=1&b=&c&d=abcd') + assert(isinstance(result, dict) is True) + assert(len(result) == 3) + assert 'qsd' in result + assert 'qsd+' in result + assert 'qsd-' in result + + assert(len(result['qsd']) == 4) + assert 'a' in result['qsd'] + assert 'b' in result['qsd'] + assert 'c' in result['qsd'] + assert 'd' in result['qsd'] + + assert(len(result['qsd-']) == 0) + assert(len(result['qsd+']) == 0) + + def test_parse_url(): "utils: parse_url() testing """ @@ -49,6 +69,8 @@ def test_parse_url(): assert(result['query'] is None) assert(result['url'] == 'http://hostname') assert(result['qsd'] == {}) + assert(result['qsd-'] == {}) + assert(result['qsd+'] == {}) result = utils.parse_url('http://hostname/') assert(result['schema'] == 'http') @@ -61,6 +83,8 @@ def test_parse_url(): assert(result['query'] is None) assert(result['url'] == 'http://hostname/') assert(result['qsd'] == {}) + assert(result['qsd-'] == {}) + assert(result['qsd+'] == {}) result = utils.parse_url('hostname') assert(result['schema'] == 'http') @@ -73,6 +97,61 @@ def test_parse_url(): assert(result['query'] is None) assert(result['url'] == 'http://hostname') assert(result['qsd'] == {}) + assert(result['qsd-'] == {}) + assert(result['qsd+'] == {}) + + result = utils.parse_url('http://hostname/?-KeY=Value') + assert(result['schema'] == 'http') + assert(result['host'] == 'hostname') + assert(result['port'] is None) + assert(result['user'] is None) + assert(result['password'] is None) + assert(result['fullpath'] == '/') + assert(result['path'] == '/') + assert(result['query'] is None) + assert(result['url'] == 'http://hostname/') + assert('-key' in result['qsd']) + assert(unquote(result['qsd']['-key']) == 'Value') + assert('KeY' in result['qsd-']) + assert(unquote(result['qsd-']['KeY']) == 'Value') + assert(result['qsd+'] == {}) + + result = utils.parse_url('http://hostname/?+KeY=Value') + assert(result['schema'] == 'http') + assert(result['host'] == 'hostname') + assert(result['port'] is None) + assert(result['user'] is None) + assert(result['password'] is None) + assert(result['fullpath'] == '/') + assert(result['path'] == '/') + assert(result['query'] is None) + assert(result['url'] == 'http://hostname/') + assert('+key' in result['qsd']) + assert('KeY' in result['qsd+']) + assert(result['qsd+']['KeY'] == 'Value') + assert(result['qsd-'] == {}) + + result = utils.parse_url( + 'http://hostname/?+KeY=ValueA&-kEy=ValueB&KEY=Value%20+C') + assert(result['schema'] == 'http') + assert(result['host'] == 'hostname') + assert(result['port'] is None) + assert(result['user'] is None) + assert(result['password'] is None) + assert(result['fullpath'] == '/') + assert(result['path'] == '/') + assert(result['query'] is None) + assert(result['url'] == 'http://hostname/') + assert('+key' in result['qsd']) + assert('-key' in result['qsd']) + assert('key' in result['qsd']) + assert('KeY' in result['qsd+']) + assert(result['qsd+']['KeY'] == 'ValueA') + assert('kEy' in result['qsd-']) + assert(result['qsd-']['kEy'] == 'ValueB') + assert(result['qsd']['key'] == 'Value C') + assert(result['qsd']['+key'] == result['qsd+']['KeY']) + assert(result['qsd']['-key'] == result['qsd-']['kEy']) result = utils.parse_url('http://hostname////') assert(result['schema'] == 'http') @@ -85,6 +164,8 @@ def test_parse_url(): assert(result['query'] is None) assert(result['url'] == 'http://hostname/') assert(result['qsd'] == {}) + assert(result['qsd-'] == {}) + assert(result['qsd+'] == {}) result = utils.parse_url('http://hostname:40////') assert(result['schema'] == 'http') @@ -97,6 +178,8 @@ def test_parse_url(): assert(result['query'] is None) assert(result['url'] == 'http://hostname:40/') assert(result['qsd'] == {}) + assert(result['qsd-'] == {}) + assert(result['qsd+'] == {}) result = utils.parse_url('HTTP://HoStNaMe:40/test.php') assert(result['schema'] == 'http') @@ -109,6 +192,8 @@ def test_parse_url(): assert(result['query'] == 'test.php') assert(result['url'] == 'http://HoStNaMe:40/test.php') assert(result['qsd'] == {}) + assert(result['qsd-'] == {}) + assert(result['qsd+'] == {}) result = utils.parse_url('HTTPS://user@hostname/test.py') assert(result['schema'] == 'https') @@ -121,6 +206,8 @@ def test_parse_url(): assert(result['query'] == 'test.py') assert(result['url'] == 'https://user@hostname/test.py') assert(result['qsd'] == {}) + assert(result['qsd-'] == {}) + assert(result['qsd+'] == {}) result = utils.parse_url(' HTTPS://///user@@@hostname///test.py ') assert(result['schema'] == 'https') @@ -133,6 +220,8 @@ def test_parse_url(): assert(result['query'] == 'test.py') assert(result['url'] == 'https://user@hostname/test.py') assert(result['qsd'] == {}) + assert(result['qsd-'] == {}) + assert(result['qsd+'] == {}) result = utils.parse_url( 'HTTPS://user:password@otherHost/full///path/name/', @@ -147,6 +236,8 @@ def test_parse_url(): assert(result['query'] is None) assert(result['url'] == 'https://user:password@otherHost/full/path/name/') assert(result['qsd'] == {}) + assert(result['qsd-'] == {}) + assert(result['qsd+'] == {}) # Handle garbage assert(utils.parse_url(None) is None) @@ -173,6 +264,8 @@ def test_parse_url(): assert(unquote(result['qsd']['from']) == 'test@test.com') assert('format' in result['qsd']) assert(unquote(result['qsd']['format']) == 'text') + assert(result['qsd-'] == {}) + assert(result['qsd+'] == {}) # Test Passwords with question marks ?; not supported result = utils.parse_url( @@ -194,6 +287,8 @@ def test_parse_url(): assert(result['query'] is None) assert(result['url'] == 'http://nuxref.com') assert(result['qsd'] == {}) + assert(result['qsd-'] == {}) + assert(result['qsd+'] == {}) # just host and path result = utils.parse_url( @@ -209,6 +304,8 @@ def test_parse_url(): assert(result['query'] == 'host') assert(result['url'] == 'http://invalid/host') assert(result['qsd'] == {}) + assert(result['qsd-'] == {}) + assert(result['qsd+'] == {}) # just all out invalid assert(utils.parse_url('?') is None) @@ -227,6 +324,8 @@ def test_parse_url(): assert(result['query'] is None) assert(result['url'] == 'http://nuxref.com') assert(result['qsd'] == {}) + assert(result['qsd-'] == {}) + assert(result['qsd+'] == {}) def test_parse_bool(): From 00d1c9b9580e169f017cbd8a57214ef7cc4ebbe2 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sun, 17 Feb 2019 13:51:35 -0500 Subject: [PATCH 4/8] IFTTT refactoring docs updated --- README.md | 2 +- apprise/plugins/NotifyIFTTT.py | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 57766fac..76449c68 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ The table below identifies the services this tool supports and some example serv | [Faast](https://github.com/caronc/apprise/wiki/Notify_faast) | faast:// | (TCP) 443 | faast://authorizationtoken | [Gnome](https://github.com/caronc/apprise/wiki/Notify_gnome) | gnome:// | n/a | gnome:// | [Growl](https://github.com/caronc/apprise/wiki/Notify_growl) | growl:// | (UDP) 23053 | growl://hostname
growl://hostname:portno
growl://password@hostname
growl://password@hostname:port
**Note**: you can also use the get parameter _version_ which can allow the growl request to behave using the older v1.x protocol. An example would look like: growl://hostname?version=1 -| [IFTTT](https://github.com/caronc/apprise/wiki/Notify_ifttt) | ifttt:// | (TCP) 443 | ifttt://webhooksID/EventToTrigger
ifttt://webhooksID/EventToTrigger/Event2/EventN +| [IFTTT](https://github.com/caronc/apprise/wiki/Notify_ifttt) | ifttt:// | (TCP) 443 | ifttt://webhooksID/Event
ifttt://webhooksID/Event1/Event2/EventN
ifttt://webhooksID/Event1/?+Key=Value
ifttt://webhooksID/Event1/?-Key=value1 | [Join](https://github.com/caronc/apprise/wiki/Notify_join) | join:// | (TCP) 443 | join://apikey/device
join://apikey/device1/device2/deviceN/
join://apikey/group
join://apikey/groupA/groupB/groupN
join://apikey/DeviceA/groupA/groupN/DeviceN/ | [KODI](https://github.com/caronc/apprise/wiki/Notify_kodi) | kodi:// or kodis:// | (TCP) 8080 or 443 | kodi://hostname
kodi://user@hostname
kodi://user:password@hostname:port | [Matrix](https://github.com/caronc/apprise/wiki/Notify_matrix) | matrix:// or matrixs:// | (TCP) 80 or 443 | matrix://token
matrix://user@token
matrixs://token?mode=slack
matrixs://user@token diff --git a/apprise/plugins/NotifyIFTTT.py b/apprise/plugins/NotifyIFTTT.py index 95abc8d9..24ea9f60 100644 --- a/apprise/plugins/NotifyIFTTT.py +++ b/apprise/plugins/NotifyIFTTT.py @@ -189,8 +189,6 @@ class NotifyIFTTT(NotifyBase): headers=headers, verify=self.verify_certificate, ) - self.logger.debug( - u"IFTTT HTTP response status: %r" % r.status_code) self.logger.debug( u"IFTTT HTTP response headers: %r" % r.headers) self.logger.debug( From 7d9715aa5af60d016242d2d23900862b607aad2b Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sun, 17 Feb 2019 16:35:09 -0500 Subject: [PATCH 5/8] refactored and drastically improved throttling functionality --- apprise/plugins/NotifyBase.py | 46 ++++++++++++---- apprise/plugins/NotifyBoxcar.py | 3 ++ apprise/plugins/NotifyDBus.py | 6 +++ apprise/plugins/NotifyDiscord.py | 4 ++ apprise/plugins/NotifyEmail.py | 4 ++ apprise/plugins/NotifyEmby.py | 4 ++ apprise/plugins/NotifyFaast.py | 4 ++ apprise/plugins/NotifyGnome.py | 7 +++ apprise/plugins/NotifyGrowl/NotifyGrowl.py | 7 +++ apprise/plugins/NotifyIFTTT.py | 9 ++-- apprise/plugins/NotifyJSON.py | 8 +++ apprise/plugins/NotifyJoin.py | 7 ++- apprise/plugins/NotifyMatrix.py | 4 ++ apprise/plugins/NotifyMatterMost.py | 9 +++- apprise/plugins/NotifyProwl.py | 8 +++ apprise/plugins/NotifyPushBullet.py | 8 +-- apprise/plugins/NotifyPushed.py | 13 ++--- .../plugins/NotifyPushjet/NotifyPushjet.py | 21 +++++--- apprise/plugins/NotifyPushover.py | 8 +-- apprise/plugins/NotifyRocketChat.py | 23 ++++---- apprise/plugins/NotifyRyver.py | 4 ++ apprise/plugins/NotifySNS.py | 20 ++++--- apprise/plugins/NotifySlack.py | 9 ++-- apprise/plugins/NotifyTelegram.py | 17 +++--- .../plugins/NotifyTwitter/NotifyTwitter.py | 10 ++-- apprise/plugins/NotifyWindows.py | 7 +++ apprise/plugins/NotifyXBMC.py | 8 +++ apprise/plugins/NotifyXML.py | 8 +++ test/test_email_plugin.py | 6 +++ test/test_notify_base.py | 53 +++++++++++++++++-- test/test_rest_plugins.py | 51 ++++++++++++------ test/test_sns_plugin.py | 5 +- 32 files changed, 298 insertions(+), 103 deletions(-) diff --git a/apprise/plugins/NotifyBase.py b/apprise/plugins/NotifyBase.py index ddb75bbd..089c991a 100644 --- a/apprise/plugins/NotifyBase.py +++ b/apprise/plugins/NotifyBase.py @@ -26,6 +26,8 @@ import re import logging from time import sleep +from datetime import datetime + try: # Python 2.7 from urllib import unquote as _unquote @@ -115,8 +117,8 @@ class NotifyBase(object): setup_url = None # Most Servers do not like more then 1 request per 5 seconds, so 5.5 gives - # us a safe play range... - throttle_attempt = 5.5 + # us a safe play range. + request_rate_per_sec = 5.5 # Allows the user to specify the NotifyImageSize object image_size = None @@ -209,19 +211,45 @@ class NotifyBase(object): # it just falls back to whatever was already defined globally self.tags = set(parse_list(kwargs.get('tag', self.tags))) - def throttle(self, throttle_time=None): + # Tracks the time any i/o was made to the remote server. This value + # is automatically set and controlled through the throttle() call. + self._last_io_datetime = None + + def throttle(self, last_io=None): """ A common throttle control """ - self.logger.debug('Throttling...') - throttle_time = throttle_time \ - if throttle_time is not None else self.throttle_attempt + if last_io is not None: + # Assume specified last_io + self._last_io_datetime = last_io - # Perform throttle - if throttle_time > 0: - sleep(throttle_time) + # Get ourselves a reference time of 'now' + reference = datetime.now() + if self._last_io_datetime is None: + # Set time to 'now' and no need to throttle + self._last_io_datetime = reference + return + + if self.request_rate_per_sec <= 0.0: + # We're done if there is no throttle limit set + return + + # If we reach here, we need to do additional logic. + # If the difference between the reference time and 'now' is less than + # the defined request_rate_per_sec then we need to throttle for the + # remaining balance of this time. + + elapsed = (reference - self._last_io_datetime).total_seconds() + + if elapsed < self.request_rate_per_sec: + self.logger.debug('Throttling for {}s...'.format( + self.request_rate_per_sec - elapsed)) + sleep(self.request_rate_per_sec - elapsed) + + # Update our timestamp before we leave + self._last_io_datetime = reference return def image_url(self, notify_type, logo=False, extension=None): diff --git a/apprise/plugins/NotifyBoxcar.py b/apprise/plugins/NotifyBoxcar.py index 636a571e..a2e913af 100644 --- a/apprise/plugins/NotifyBoxcar.py +++ b/apprise/plugins/NotifyBoxcar.py @@ -230,6 +230,9 @@ class NotifyBoxcar(NotifyBase): )) self.logger.debug('Boxcar Payload: %s' % str(payload)) + # Always call throttle before any remote server i/o is made + self.throttle() + try: r = requests.post( notify_url, diff --git a/apprise/plugins/NotifyDBus.py b/apprise/plugins/NotifyDBus.py index a466ecd0..ce1695b9 100644 --- a/apprise/plugins/NotifyDBus.py +++ b/apprise/plugins/NotifyDBus.py @@ -142,6 +142,9 @@ class NotifyDBus(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_dbus' + # No throttling required for DBus queries + request_rate_per_sec = 0 + # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_128 @@ -258,6 +261,9 @@ class NotifyDBus(NotifyBase): .format(icon_path, e)) try: + # Always call throttle() before any remote execution is made + self.throttle() + dbus_iface.Notify( # Application Identifier self.app_id, diff --git a/apprise/plugins/NotifyDiscord.py b/apprise/plugins/NotifyDiscord.py index 76e1ec8d..98c323fc 100644 --- a/apprise/plugins/NotifyDiscord.py +++ b/apprise/plugins/NotifyDiscord.py @@ -201,6 +201,10 @@ class NotifyDiscord(NotifyBase): notify_url, self.verify_certificate, )) self.logger.debug('Discord Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: r = requests.post( notify_url, diff --git a/apprise/plugins/NotifyEmail.py b/apprise/plugins/NotifyEmail.py index c7ba1c1d..dda718cf 100644 --- a/apprise/plugins/NotifyEmail.py +++ b/apprise/plugins/NotifyEmail.py @@ -375,6 +375,10 @@ class NotifyEmail(NotifyBase): # bind the socket variable to the current namespace socket = None + + # Always call throttle before any remote server i/o is made + self.throttle() + try: self.logger.debug('Connecting to remote SMTP server...') socket_func = smtplib.SMTP diff --git a/apprise/plugins/NotifyEmby.py b/apprise/plugins/NotifyEmby.py index 41fd6f4f..d9542f46 100644 --- a/apprise/plugins/NotifyEmby.py +++ b/apprise/plugins/NotifyEmby.py @@ -494,6 +494,10 @@ class NotifyEmby(NotifyBase): session_url, self.verify_certificate, )) self.logger.debug('Emby Payload: %s' % str(payload)) + + # Always call throttle before the requests are made + self.throttle() + try: r = requests.post( session_url, diff --git a/apprise/plugins/NotifyFaast.py b/apprise/plugins/NotifyFaast.py index 2bb41818..74d86d9b 100644 --- a/apprise/plugins/NotifyFaast.py +++ b/apprise/plugins/NotifyFaast.py @@ -85,6 +85,10 @@ class NotifyFaast(NotifyBase): self.notify_url, self.verify_certificate, )) self.logger.debug('Faast Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: r = requests.post( self.notify_url, diff --git a/apprise/plugins/NotifyGnome.py b/apprise/plugins/NotifyGnome.py index a380cc42..595cde22 100644 --- a/apprise/plugins/NotifyGnome.py +++ b/apprise/plugins/NotifyGnome.py @@ -84,6 +84,10 @@ class NotifyGnome(NotifyBase): # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_128 + # Disable throttle rate for Gnome requests since they are normally + # local anyway + request_rate_per_sec = 0 + # Limit results to just the first 10 line otherwise there is just to much # content to display body_max_line_count = 10 @@ -138,6 +142,9 @@ class NotifyGnome(NotifyBase): # Assign urgency notification.set_urgency(self.urgency) + # Always call throttle before any remote server i/o is made + self.throttle() + try: # Use Pixbuf to create the proper image type image = GdkPixbuf.Pixbuf.new_from_file(icon_path) diff --git a/apprise/plugins/NotifyGrowl/NotifyGrowl.py b/apprise/plugins/NotifyGrowl/NotifyGrowl.py index 09f4f672..5c1bc97b 100644 --- a/apprise/plugins/NotifyGrowl/NotifyGrowl.py +++ b/apprise/plugins/NotifyGrowl/NotifyGrowl.py @@ -69,6 +69,10 @@ class NotifyGrowl(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_growl' + # Disable throttle rate for Growl requests since they are normally + # local anyway + request_rate_per_sec = 0 + # Default Growl Port default_port = 23053 @@ -178,6 +182,9 @@ class NotifyGrowl(NotifyBase): # print the binary contents of an image payload['icon'] = icon + # Always call throttle before any remote server i/o is made + self.throttle() + try: response = self.growl.notify(**payload) if not isinstance(response, bool): diff --git a/apprise/plugins/NotifyIFTTT.py b/apprise/plugins/NotifyIFTTT.py index 24ea9f60..6340edc9 100644 --- a/apprise/plugins/NotifyIFTTT.py +++ b/apprise/plugins/NotifyIFTTT.py @@ -182,6 +182,10 @@ class NotifyIFTTT(NotifyBase): url, self.verify_certificate, )) self.logger.debug('IFTTT Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: r = requests.post( url, @@ -225,11 +229,6 @@ class NotifyIFTTT(NotifyBase): self.logger.debug('Socket Exception: %s' % str(e)) error_count += 1 - finally: - if len(events): - # Prevent thrashing requests - self.throttle() - return (error_count == 0) def url(self): diff --git a/apprise/plugins/NotifyJSON.py b/apprise/plugins/NotifyJSON.py index 35a3bead..ef4c9763 100644 --- a/apprise/plugins/NotifyJSON.py +++ b/apprise/plugins/NotifyJSON.py @@ -52,6 +52,10 @@ class NotifyJSON(NotifyBase): # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_128 + # Disable throttle rate for JSON requests since they are normally + # local anyway + request_rate_per_sec = 0 + def __init__(self, headers, **kwargs): """ Initialize JSON Object @@ -154,6 +158,10 @@ class NotifyJSON(NotifyBase): url, self.verify_certificate, )) self.logger.debug('JSON Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: r = requests.post( url, diff --git a/apprise/plugins/NotifyJoin.py b/apprise/plugins/NotifyJoin.py index 901e3940..4f45d14c 100644 --- a/apprise/plugins/NotifyJoin.py +++ b/apprise/plugins/NotifyJoin.py @@ -181,6 +181,9 @@ class NotifyJoin(NotifyBase): )) self.logger.debug('Join Payload: %s' % str(payload)) + # Always call throttle before any remote server i/o is made + self.throttle() + try: r = requests.post( url, @@ -220,10 +223,6 @@ class NotifyJoin(NotifyBase): self.logger.debug('Socket Exception: %s' % str(e)) return_status = False - if len(devices): - # Prevent thrashing requests - self.throttle() - return return_status def url(self): diff --git a/apprise/plugins/NotifyMatrix.py b/apprise/plugins/NotifyMatrix.py index 36673c24..7f3edbe3 100644 --- a/apprise/plugins/NotifyMatrix.py +++ b/apprise/plugins/NotifyMatrix.py @@ -169,6 +169,10 @@ class NotifyMatrix(NotifyBase): url, self.verify_certificate, )) self.logger.debug('Matrix Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: r = requests.post( url, diff --git a/apprise/plugins/NotifyMatterMost.py b/apprise/plugins/NotifyMatterMost.py index 0d46cb0e..da12b1b8 100644 --- a/apprise/plugins/NotifyMatterMost.py +++ b/apprise/plugins/NotifyMatterMost.py @@ -68,6 +68,9 @@ class NotifyMatterMost(NotifyBase): # The maximum allowable characters allowed in the body per message body_maxlen = 4000 + # Mattermost does not have a title + title_maxlen = 0 + def __init__(self, authtoken, channel=None, **kwargs): """ Initialize MatterMost Object @@ -120,7 +123,7 @@ class NotifyMatterMost(NotifyBase): # prepare JSON Object payload = { - 'text': '###### %s\n%s' % (title, body), + 'text': body, 'icon_url': self.image_url(notify_type), } @@ -140,6 +143,10 @@ class NotifyMatterMost(NotifyBase): url, self.verify_certificate, )) self.logger.debug('MatterMost Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: r = requests.post( url, diff --git a/apprise/plugins/NotifyProwl.py b/apprise/plugins/NotifyProwl.py index 58e7e8ad..a966c601 100644 --- a/apprise/plugins/NotifyProwl.py +++ b/apprise/plugins/NotifyProwl.py @@ -81,6 +81,10 @@ class NotifyProwl(NotifyBase): # Prowl uses the http protocol with JSON requests notify_url = 'https://api.prowlapp.com/publicapi/add' + # Disable throttle rate for Prowl requests since they are normally + # local anyway + request_rate_per_sec = 0 + # The maximum allowable characters allowed in the body per message body_maxlen = 10000 @@ -150,6 +154,10 @@ class NotifyProwl(NotifyBase): self.notify_url, self.verify_certificate, )) self.logger.debug('Prowl Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: r = requests.post( self.notify_url, diff --git a/apprise/plugins/NotifyPushBullet.py b/apprise/plugins/NotifyPushBullet.py index fc5f4963..5de62c2d 100644 --- a/apprise/plugins/NotifyPushBullet.py +++ b/apprise/plugins/NotifyPushBullet.py @@ -135,6 +135,10 @@ class NotifyPushBullet(NotifyBase): self.notify_url, self.verify_certificate, )) self.logger.debug('PushBullet Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: r = requests.post( self.notify_url, @@ -176,10 +180,6 @@ class NotifyPushBullet(NotifyBase): self.logger.debug('Socket Exception: %s' % str(e)) has_error = True - if len(recipients): - # Prevent thrashing requests - self.throttle() - return not has_error def url(self): diff --git a/apprise/plugins/NotifyPushed.py b/apprise/plugins/NotifyPushed.py index 037a37c2..0295c137 100644 --- a/apprise/plugins/NotifyPushed.py +++ b/apprise/plugins/NotifyPushed.py @@ -177,10 +177,6 @@ class NotifyPushed(NotifyBase): # toggle flag has_error = True - if len(channels) + len(users) > 0: - # Prevent thrashing requests - self.throttle() - # Copy our payload _payload = dict(payload) _payload['target_type'] = 'pushed_id' @@ -189,16 +185,13 @@ class NotifyPushed(NotifyBase): while len(users): # Get User's Pushed ID _payload['pushed_id'] = users.pop(0) + if not self.send_notification( payload=_payload, notify_type=notify_type, **kwargs): # toggle flag has_error = True - if len(users) > 0: - # Prevent thrashing requests - self.throttle() - return not has_error def send_notification(self, payload, notify_type, **kwargs): @@ -217,6 +210,10 @@ class NotifyPushed(NotifyBase): self.notify_url, self.verify_certificate, )) self.logger.debug('Pushed Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: r = requests.post( self.notify_url, diff --git a/apprise/plugins/NotifyPushjet/NotifyPushjet.py b/apprise/plugins/NotifyPushjet/NotifyPushjet.py index b5461eec..8b45a9ef 100644 --- a/apprise/plugins/NotifyPushjet/NotifyPushjet.py +++ b/apprise/plugins/NotifyPushjet/NotifyPushjet.py @@ -52,6 +52,10 @@ class NotifyPushjet(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_pushjet' + # Disable throttle rate for Pushjet requests since they are normally + # local anyway (the remote/online service is no more) + request_rate_per_sec = 0 + def __init__(self, secret_key, **kwargs): """ Initialize Pushjet Object @@ -65,15 +69,16 @@ class NotifyPushjet(NotifyBase): """ Perform Pushjet Notification """ + # Always call throttle before any remote server i/o is made + self.throttle() + + server = "https://" if self.secure else "http://" + + server += self.host + if self.port: + server += ":" + str(self.port) + try: - server = "http://" - if self.secure: - server = "https://" - - server += self.host - if self.port: - server += ":" + str(self.port) - api = pushjet.Api(server) service = api.Service(secret_key=self.secret_key) diff --git a/apprise/plugins/NotifyPushover.py b/apprise/plugins/NotifyPushover.py index 4f6999be..35ee6828 100644 --- a/apprise/plugins/NotifyPushover.py +++ b/apprise/plugins/NotifyPushover.py @@ -189,6 +189,10 @@ class NotifyPushover(NotifyBase): self.notify_url, self.verify_certificate, )) self.logger.debug('Pushover Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: r = requests.post( self.notify_url, @@ -231,10 +235,6 @@ class NotifyPushover(NotifyBase): self.logger.debug('Socket Exception: %s' % str(e)) has_error = True - if len(devices): - # Prevent thrashing requests - self.throttle() - return not has_error def url(self): diff --git a/apprise/plugins/NotifyRocketChat.py b/apprise/plugins/NotifyRocketChat.py index ce95b15a..ca33164b 100644 --- a/apprise/plugins/NotifyRocketChat.py +++ b/apprise/plugins/NotifyRocketChat.py @@ -67,8 +67,11 @@ class NotifyRocketChat(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_rocketchat' - # Defines the maximum allowable characters in the title - title_maxlen = 200 + # The title is not used + title_maxlen = 0 + + # The maximum size of the message + body_maxlen = 200 def __init__(self, recipients=None, **kwargs): """ @@ -185,8 +188,8 @@ class NotifyRocketChat(NotifyBase): if not self.login(): return False - # Prepare our message - text = '*%s*\r\n%s' % (title.replace('*', '\\*'), body) + # Prepare our message using the body only + text = body # Initiaize our error tracking has_error = False @@ -208,10 +211,6 @@ class NotifyRocketChat(NotifyBase): # toggle flag has_error = True - if len(channels) + len(rooms) > 0: - # Prevent thrashing requests - self.throttle() - # Send all our defined room id's while len(rooms): # Get Room @@ -226,10 +225,6 @@ class NotifyRocketChat(NotifyBase): # toggle flag has_error = True - if len(rooms) > 0: - # Prevent thrashing requests - self.throttle() - # logout self.logout() @@ -244,6 +239,10 @@ class NotifyRocketChat(NotifyBase): self.api_url + 'chat.postMessage', self.verify_certificate, )) self.logger.debug('Rocket.Chat Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: r = requests.post( self.api_url + 'chat.postMessage', diff --git a/apprise/plugins/NotifyRyver.py b/apprise/plugins/NotifyRyver.py index a0552df5..532a6de0 100644 --- a/apprise/plugins/NotifyRyver.py +++ b/apprise/plugins/NotifyRyver.py @@ -178,6 +178,10 @@ class NotifyRyver(NotifyBase): url, self.verify_certificate, )) self.logger.debug('Ryver Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: r = requests.post( url, diff --git a/apprise/plugins/NotifySNS.py b/apprise/plugins/NotifySNS.py index 750d2d4a..02de1546 100644 --- a/apprise/plugins/NotifySNS.py +++ b/apprise/plugins/NotifySNS.py @@ -86,6 +86,10 @@ class NotifySNS(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_sns' + # AWS is pretty good for handling data load so request limits + # can occur in much shorter bursts + request_rate_per_sec = 2.5 + # The maximum length of the body # Source: https://docs.aws.amazon.com/sns/latest/api/API_Publish.html body_maxlen = 140 @@ -219,10 +223,6 @@ class NotifySNS(NotifyBase): if not result: error_count += 1 - if len(phone) > 0: - # Prevent thrashing requests - self.throttle() - # Send all our defined topic id's while len(topics): @@ -261,10 +261,6 @@ class NotifySNS(NotifyBase): if not result: error_count += 1 - if len(topics) > 0: - # Prevent thrashing requests - self.throttle() - return error_count == 0 def _post(self, payload, to): @@ -276,6 +272,13 @@ class NotifySNS(NotifyBase): if it wasn't. """ + # Always call throttle before any remote server i/o is made; for AWS + # time plays a huge factor in the headers being sent with the payload. + # So for AWS (SNS) requests we must throttle before they're generated + # and not directly before the i/o call like other notification + # services do. + self.throttle() + # Convert our payload from a dict() into a urlencoded string payload = self.urlencode(payload) @@ -287,6 +290,7 @@ class NotifySNS(NotifyBase): self.notify_url, self.verify_certificate, )) self.logger.debug('AWS Payload: %s' % str(payload)) + try: r = requests.post( self.notify_url, diff --git a/apprise/plugins/NotifySlack.py b/apprise/plugins/NotifySlack.py index aec32156..c8575231 100644 --- a/apprise/plugins/NotifySlack.py +++ b/apprise/plugins/NotifySlack.py @@ -250,6 +250,9 @@ class NotifySlack(NotifyBase): url, self.verify_certificate, )) self.logger.debug('Slack Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() try: r = requests.post( url, @@ -274,7 +277,7 @@ class NotifySlack(NotifyBase): channel, r.status_code)) - # self.logger.debug('Response Details: %s' % r.raw.read()) + # self.logger.debug('Response Details: %s' % r.content) # Return; we're done notify_okay = False @@ -290,10 +293,6 @@ class NotifySlack(NotifyBase): self.logger.debug('Socket Exception: %s' % str(e)) notify_okay = False - if len(channels): - # Prevent thrashing requests - self.throttle() - return notify_okay def url(self): diff --git a/apprise/plugins/NotifyTelegram.py b/apprise/plugins/NotifyTelegram.py index 697c0040..1050a156 100644 --- a/apprise/plugins/NotifyTelegram.py +++ b/apprise/plugins/NotifyTelegram.py @@ -413,14 +413,14 @@ class NotifyTelegram(NotifyBase): # ID payload['chat_id'] = int(chat_id.group('idno')) + # Always call throttle before any remote server i/o is made; + # Telegram throttles to occur before sending the image so that + # content can arrive together. + self.throttle() + if self.include_image is True: # Send an image - if self.send_image( - payload['chat_id'], notify_type) is not None: - # We sent a post (whether we were successful or not) - # we still hit the remote server... just throttle - # before our next hit server query - self.throttle() + self.send_image(payload['chat_id'], notify_type) self.logger.debug('Telegram POST URL: %s (cert_verify=%r)' % ( url, self.verify_certificate, @@ -483,11 +483,6 @@ class NotifyTelegram(NotifyBase): self.logger.debug('Socket Exception: %s' % str(e)) has_error = True - finally: - if len(chat_ids): - # Prevent thrashing requests - self.throttle() - return not has_error def url(self): diff --git a/apprise/plugins/NotifyTwitter/NotifyTwitter.py b/apprise/plugins/NotifyTwitter/NotifyTwitter.py index 394e5473..488f0c51 100644 --- a/apprise/plugins/NotifyTwitter/NotifyTwitter.py +++ b/apprise/plugins/NotifyTwitter/NotifyTwitter.py @@ -50,6 +50,9 @@ class NotifyTwitter(NotifyBase): # which are limited to 240 characters) body_maxlen = 4096 + # Twitter does have titles when creating a message + title_maxlen = 0 + def __init__(self, ckey, csecret, akey, asecret, **kwargs): """ Initialize Twitter Object @@ -109,15 +112,16 @@ class NotifyTwitter(NotifyBase): ) return False - # Only set title if it was specified - text = body if not title else '%s\r\n%s' % (title, body) + # Always call throttle before any remote server i/o is made to avoid + # thrashing the remote server and risk being blocked. + self.throttle() try: # Get our API api = tweepy.API(self.auth) # Send our Direct Message - api.send_direct_message(self.user, text=text) + api.send_direct_message(self.user, text=body) self.logger.info('Sent Twitter DM notification.') except Exception as e: diff --git a/apprise/plugins/NotifyWindows.py b/apprise/plugins/NotifyWindows.py index c1251cb1..aa474248 100644 --- a/apprise/plugins/NotifyWindows.py +++ b/apprise/plugins/NotifyWindows.py @@ -63,6 +63,10 @@ class NotifyWindows(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_windows' + # Disable throttle rate for Windows requests since they are normally + # local anyway + request_rate_per_sec = 0 + # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_128 @@ -113,6 +117,9 @@ class NotifyWindows(NotifyBase): "Windows Notifications are not supported by this system.") return False + # Always call throttle before any remote server i/o is made + self.throttle() + try: # Register destruction callback message_map = {win32con.WM_DESTROY: self._on_destroy, } diff --git a/apprise/plugins/NotifyXBMC.py b/apprise/plugins/NotifyXBMC.py index 32f9257f..f02da157 100644 --- a/apprise/plugins/NotifyXBMC.py +++ b/apprise/plugins/NotifyXBMC.py @@ -57,6 +57,10 @@ class NotifyXBMC(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_kodi' + # Disable throttle rate for XBMC/KODI requests since they are normally + # local anyway + request_rate_per_sec = 0 + # Limit results to just the first 2 line otherwise there is just to much # content to display body_max_line_count = 2 @@ -186,6 +190,10 @@ class NotifyXBMC(NotifyBase): url, self.verify_certificate, )) self.logger.debug('XBMC/KODI Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: r = requests.post( url, diff --git a/apprise/plugins/NotifyXML.py b/apprise/plugins/NotifyXML.py index 83f200cd..af3ca2d5 100644 --- a/apprise/plugins/NotifyXML.py +++ b/apprise/plugins/NotifyXML.py @@ -52,6 +52,10 @@ class NotifyXML(NotifyBase): # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_128 + # Disable throttle rate for JSON requests since they are normally + # local anyway + request_rate_per_sec = 0 + def __init__(self, headers=None, **kwargs): """ Initialize XML Object @@ -172,6 +176,10 @@ class NotifyXML(NotifyBase): url, self.verify_certificate, )) self.logger.debug('XML Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: r = requests.post( url, diff --git a/test/test_email_plugin.py b/test/test_email_plugin.py index 80d108f4..d4eec5f2 100644 --- a/test/test_email_plugin.py +++ b/test/test_email_plugin.py @@ -166,6 +166,8 @@ def test_email_plugin(mock_smtp, mock_smtpssl): API: NotifyEmail Plugin() """ + # Disable Throttling to speed testing + plugins.NotifyBase.NotifyBase.request_rate_per_sec = 0 # iterate over our dictionary and test it out for (url, meta) in TEST_URLS: @@ -342,6 +344,8 @@ def test_smtplib_init_fail(mock_smtplib): API: Test exception handling when calling smtplib.SMTP() """ + # Disable Throttling to speed testing + plugins.NotifyBase.NotifyBase.request_rate_per_sec = 0 obj = Apprise.instantiate( 'mailto://user:pass@gmail.com', suppress_exceptions=False) @@ -378,6 +382,8 @@ def test_smtplib_send_okay(mock_smtplib): API: Test a successfully sent email """ + # Disable Throttling to speed testing + plugins.NotifyBase.NotifyBase.request_rate_per_sec = 0 # Defaults to HTML obj = Apprise.instantiate( diff --git a/test/test_notify_base.py b/test/test_notify_base.py index 3ad62264..e72f560d 100644 --- a/test/test_notify_base.py +++ b/test/test_notify_base.py @@ -23,6 +23,9 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. +from datetime import datetime +from datetime import timedelta + from apprise.plugins.NotifyBase import NotifyBase from apprise import NotifyType from apprise import NotifyImageSize @@ -64,7 +67,7 @@ def test_notify_base(): # Throttle overrides.. nb = NotifyBase() - nb.throttle_attempt = 0.0 + nb.request_rate_per_sec = 0.0 start_time = default_timer() nb.throttle() elapsed = default_timer() - start_time @@ -73,13 +76,57 @@ def test_notify_base(): # then other assert elapsed < 0.5 + # Concurrent calls should achieve the same response start_time = default_timer() - nb.throttle(1.0) + nb.throttle() + elapsed = default_timer() - start_time + assert elapsed < 0.5 + + nb = NotifyBase() + nb.request_rate_per_sec = 1.0 + + # Set our time to now + start_time = default_timer() + nb.throttle() + elapsed = default_timer() - start_time + # A first call to throttle (Without telling it a time previously ran) does + # not block for any length of time; it just merely sets us up for + # concurrent calls to block + assert elapsed < 0.5 + + # Concurrent calls could take up to the rate_per_sec though... + start_time = default_timer() + nb.throttle(last_io=datetime.now()) + elapsed = default_timer() - start_time + assert elapsed > 0.5 and elapsed < 1.5 + + nb = NotifyBase() + nb.request_rate_per_sec = 1.0 + + # Set our time to now + start_time = default_timer() + nb.throttle(last_io=datetime.now()) + elapsed = default_timer() - start_time + # because we told it that we had already done a previous action (now) + # the throttle holds out until the right time has passed + assert elapsed > 0.5 and elapsed < 1.5 + + # Concurrent calls could take up to the rate_per_sec though... + start_time = default_timer() + nb.throttle(last_io=datetime.now()) + elapsed = default_timer() - start_time + assert elapsed > 0.5 and elapsed < 1.5 + + nb = NotifyBase() + start_time = default_timer() + nb.request_rate_per_sec = 1.0 + # Force a time in the past + nb.throttle(last_io=(datetime.now() - timedelta(seconds=20))) elapsed = default_timer() - start_time # Should be a very fast response time since we set it to zero but we'll # check for less then 500 to be fair as some testing systems may be slower # then other - assert elapsed < 1.5 + assert elapsed < 0.5 # our NotifyBase wasn't initialized with an ImageSize so this will fail assert nb.image_url(notify_type=NotifyType.INFO) is None diff --git a/test/test_rest_plugins.py b/test/test_rest_plugins.py index a6a13eaa..e23c923a 100644 --- a/test/test_rest_plugins.py +++ b/test/test_rest_plugins.py @@ -1487,7 +1487,7 @@ def test_rest_plugins(mock_post, mock_get): """ # Disable Throttling to speed testing - plugins.NotifyBase.NotifyBase.throttle_attempt = 0 + plugins.NotifyBase.NotifyBase.request_rate_per_sec = 0 # iterate over our dictionary and test it out for (url, meta) in TEST_URLS: @@ -1606,12 +1606,18 @@ def test_rest_plugins(mock_post, mock_get): # try: if test_requests_exceptions is False: + # Disable throttling + obj.request_rate_per_sec = 0 + # check that we're as expected assert obj.notify( title='test', body='body', notify_type=notify_type) == response else: + # Disable throttling + obj.request_rate_per_sec = 0 + for _exception in REQUEST_EXCEPTIONS: mock_post.side_effect = _exception mock_get.side_effect = _exception @@ -1699,7 +1705,7 @@ def test_notify_boxcar_plugin(mock_post, mock_get): """ # Disable Throttling to speed testing - plugins.NotifyBase.NotifyBase.throttle_attempt = 0 + plugins.NotifyBase.NotifyBase.request_rate_per_sec = 0 # Generate some generic message types device = 'A' * 64 @@ -1762,7 +1768,7 @@ def test_notify_discord_plugin(mock_post, mock_get): """ # Disable Throttling to speed testing - plugins.NotifyBase.NotifyBase.throttle_attempt = 0 + plugins.NotifyBase.NotifyBase.request_rate_per_sec = 0 # Initialize some generic (but valid) tokens webhook_id = 'A' * 24 @@ -1844,6 +1850,8 @@ def test_notify_emby_plugin_login(mock_post, mock_get): API: NotifyEmby.login() """ + # Disable Throttling to speed testing + plugins.NotifyBase.NotifyBase.request_rate_per_sec = 0 # Prepare Mock mock_get.return_value = requests.Request() @@ -1969,6 +1977,8 @@ def test_notify_emby_plugin_sessions(mock_post, mock_get, mock_logout, API: NotifyEmby.sessions() """ + # Disable Throttling to speed testing + plugins.NotifyBase.NotifyBase.request_rate_per_sec = 0 # Prepare Mock mock_get.return_value = requests.Request() @@ -2070,6 +2080,8 @@ def test_notify_emby_plugin_logout(mock_post, mock_get, mock_login): API: NotifyEmby.sessions() """ + # Disable Throttling to speed testing + plugins.NotifyBase.NotifyBase.request_rate_per_sec = 0 # Prepare Mock mock_get.return_value = requests.Request() @@ -2138,6 +2150,8 @@ def test_notify_emby_plugin_notify(mock_post, mock_get, mock_logout, API: NotifyEmby.notify() """ + # Disable Throttling to speed testing + plugins.NotifyBase.NotifyBase.request_rate_per_sec = 0 # Prepare Mock mock_get.return_value = requests.Request() @@ -2214,7 +2228,7 @@ def test_notify_ifttt_plugin(mock_post, mock_get): """ # Disable Throttling to speed testing - plugins.NotifyBase.NotifyBase.throttle_attempt = 0 + plugins.NotifyBase.NotifyBase.request_rate_per_sec = 0 # Initialize some generic (but valid) tokens webhook_id = 'webhookid' @@ -2243,11 +2257,10 @@ def test_notify_ifttt_plugin(mock_post, mock_get): assert obj.notify(title='title', body='body', notify_type=NotifyType.INFO) is True - # Test the addition of tokens obj = plugins.NotifyIFTTT( webhook_id=webhook_id, events=events, - add_tokens={'Test':'ValueA', 'Test2': 'ValueB'}) + add_tokens={'Test': 'ValueA', 'Test2': 'ValueB'}) assert(isinstance(obj, plugins.NotifyIFTTT)) @@ -2282,14 +2295,14 @@ def test_notify_ifttt_plugin(mock_post, mock_get): del_tokens=( plugins.NotifyIFTTT.ifttt_default_title_key, plugins.NotifyIFTTT.ifttt_default_body_key, - plugins.NotifyIFTTT.ifttt_default_type_key, - )) + plugins.NotifyIFTTT.ifttt_default_type_key)) assert(isinstance(obj, plugins.NotifyIFTTT)) assert obj.notify(title='title', body='body', notify_type=NotifyType.INFO) is True + @mock.patch('requests.get') @mock.patch('requests.post') def test_notify_join_plugin(mock_post, mock_get): @@ -2298,7 +2311,7 @@ def test_notify_join_plugin(mock_post, mock_get): """ # Disable Throttling to speed testing - plugins.NotifyBase.NotifyBase.throttle_attempt = 0 + plugins.NotifyBase.NotifyBase.request_rate_per_sec = 0 # Generate some generic message types device = 'A' * 32 @@ -2333,7 +2346,7 @@ def test_notify_slack_plugin(mock_post, mock_get): """ # Disable Throttling to speed testing - plugins.NotifyBase.NotifyBase.throttle_attempt = 0 + plugins.NotifyBase.NotifyBase.request_rate_per_sec = 0 # Initialize some generic (but valid) tokens token_a = 'A' * 9 @@ -2381,6 +2394,8 @@ def test_notify_pushbullet_plugin(mock_post, mock_get): API: NotifyPushBullet() Extra Checks """ + # Disable Throttling to speed testing + plugins.NotifyBase.NotifyBase.request_rate_per_sec = 0 # Initialize some generic (but valid) tokens accesstoken = 'a' * 32 @@ -2425,7 +2440,7 @@ def test_notify_pushed_plugin(mock_post, mock_get): """ # Disable Throttling to speed testing - plugins.NotifyBase.NotifyBase.throttle_attempt = 0 + plugins.NotifyBase.NotifyBase.request_rate_per_sec = 0 # Chat ID recipients = '@ABCDEFG, @DEFGHIJ, #channel, #channel2' @@ -2525,7 +2540,7 @@ def test_notify_pushover_plugin(mock_post, mock_get): """ # Disable Throttling to speed testing - plugins.NotifyBase.NotifyBase.throttle_attempt = 0 + plugins.NotifyBase.NotifyBase.request_rate_per_sec = 0 # Initialize some generic (but valid) tokens token = 'a' * 30 @@ -2589,7 +2604,7 @@ def test_notify_rocketchat_plugin(mock_post, mock_get): """ # Disable Throttling to speed testing - plugins.NotifyBase.NotifyBase.throttle_attempt = 0 + plugins.NotifyBase.NotifyBase.request_rate_per_sec = 0 # Chat ID recipients = 'l2g, lead2gold, #channel, #channel2' @@ -2724,7 +2739,7 @@ def test_notify_telegram_plugin(mock_post, mock_get): """ # Disable Throttling to speed testing - plugins.NotifyBase.NotifyBase.throttle_attempt = 0 + plugins.NotifyBase.NotifyBase.request_rate_per_sec = 0 # Bot Token bot_token = '123456789:abcdefg_hijklmnop' @@ -2954,6 +2969,9 @@ def test_notify_overflow_truncate(): # A little preparation # + # Disable Throttling to speed testing + plugins.NotifyBase.NotifyBase.request_rate_per_sec = 0 + # Number of characters per line row = 24 @@ -3119,6 +3137,9 @@ def test_notify_overflow_split(): # A little preparation # + # Disable Throttling to speed testing + plugins.NotifyBase.NotifyBase.request_rate_per_sec = 0 + # Number of characters per line row = 24 @@ -3280,5 +3301,5 @@ def test_notify_overflow_split(): assert chunk.get('title') == '' _body = chunk.get('body') - assert bulk[offset:len(_body)+offset] == _body + assert bulk[offset: len(_body) + offset] == _body offset += len(_body) diff --git a/test/test_sns_plugin.py b/test/test_sns_plugin.py index 27b0f23e..7e88bf1c 100644 --- a/test/test_sns_plugin.py +++ b/test/test_sns_plugin.py @@ -303,6 +303,8 @@ def test_aws_topic_handling(mock_post): API: NotifySNS Plugin() AWS Topic Handling """ + # Disable Throttling to speed testing + plugins.NotifySNS.request_rate_per_sec = 0 arn_response = \ """ @@ -336,9 +338,6 @@ def test_aws_topic_handling(mock_post): # Assign ourselves a new function mock_post.side_effect = post - # Disable Throttling to speed testing - plugins.NotifyBase.NotifyBase.throttle_attempt = 0 - # Create our object a = Apprise() From eb4c83f3f8d48f6c33acc0c95c00fe3277a8279e Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sun, 17 Feb 2019 22:21:59 -0500 Subject: [PATCH 6/8] NotifyBase() home of notify(); calls send() in children --- README.md | 2 +- apprise/Apprise.py | 39 +++--- apprise/plugins/NotifyBase.py | 77 +++++++++--- apprise/plugins/NotifyBoxcar.py | 10 +- apprise/plugins/NotifyDBus.py | 5 +- apprise/plugins/NotifyDiscord.py | 3 +- apprise/plugins/NotifyEmail.py | 9 +- apprise/plugins/NotifyEmby.py | 3 +- apprise/plugins/NotifyFaast.py | 3 +- apprise/plugins/NotifyGnome.py | 3 +- apprise/plugins/NotifyGrowl/NotifyGrowl.py | 25 ++-- apprise/plugins/NotifyIFTTT.py | 5 +- apprise/plugins/NotifyJSON.py | 3 +- apprise/plugins/NotifyJoin.py | 3 +- apprise/plugins/NotifyMatrix.py | 3 +- apprise/plugins/NotifyMatterMost.py | 3 +- apprise/plugins/NotifyProwl.py | 3 +- apprise/plugins/NotifyPushBullet.py | 4 +- apprise/plugins/NotifyPushed.py | 13 +- .../plugins/NotifyPushjet/NotifyPushjet.py | 3 +- apprise/plugins/NotifyPushover.py | 5 +- apprise/plugins/NotifyRocketChat.py | 11 +- apprise/plugins/NotifyRyver.py | 3 +- apprise/plugins/NotifySNS.py | 6 +- apprise/plugins/NotifySlack.py | 3 +- apprise/plugins/NotifyTelegram.py | 5 +- .../plugins/NotifyTwitter/NotifyTwitter.py | 3 +- apprise/plugins/NotifyWindows.py | 3 +- apprise/plugins/NotifyXBMC.py | 2 +- apprise/plugins/NotifyXML.py | 3 +- test/test_email_plugin.py | 27 ++--- test/test_notify_base.py | 44 +++++++ test/test_rest_plugins.py | 113 +++++++++++------- 33 files changed, 281 insertions(+), 166 deletions(-) diff --git a/README.md b/README.md index 76449c68..348cc0aa 100644 --- a/README.md +++ b/README.md @@ -110,8 +110,8 @@ apobj.add('pbul://o.gn5kj6nfhv736I7jC3cj3QLRiyhgl98b') # Then notify these services any time you desire. The below would # notify all of the services loaded into our Apprise object. apobj.notify( - title='my notification title', body='what a great notification service!', + title='my notification title', ) ``` diff --git a/apprise/Apprise.py b/apprise/Apprise.py index 32367a00..467826dd 100644 --- a/apprise/Apprise.py +++ b/apprise/Apprise.py @@ -238,7 +238,7 @@ class Apprise(object): """ self.servers[:] = [] - def notify(self, title, body, notify_type=NotifyType.INFO, + def notify(self, body, title='', notify_type=NotifyType.INFO, body_format=None, tag=None): """ Send a notification to all of the plugins previously loaded. @@ -363,30 +363,25 @@ class Apprise(object): # Store entry directly conversion_map[server.notify_format] = body - # Apply our overflow (if defined) - for chunk in server._apply_overflow( - body=conversion_map[server.notify_format], title=title): - try: - # Send notification - if not server.notify( - title=chunk['title'], - body=chunk['body'], - notify_type=notify_type): + try: + # Send notification + if not server.notify( + body=conversion_map[server.notify_format], + title=title, + notify_type=notify_type): - # Toggle our return status flag - status = False - - except TypeError: - # These our our internally thrown notifications - # TODO: Change this to a custom one such as - # AppriseNotifyError + # Toggle our return status flag status = False - except Exception: - # A catch all so we don't have to abort early - # just because one of our plugins has a bug in it. - logging.exception("Notification Exception") - status = False + except TypeError: + # These our our internally thrown notifications + status = False + + except Exception: + # A catch all so we don't have to abort early + # just because one of our plugins has a bug in it. + logging.exception("Notification Exception") + status = False return status diff --git a/apprise/plugins/NotifyBase.py b/apprise/plugins/NotifyBase.py index 089c991a..94f73ffa 100644 --- a/apprise/plugins/NotifyBase.py +++ b/apprise/plugins/NotifyBase.py @@ -44,6 +44,7 @@ from ..utils import parse_url from ..utils import parse_bool from ..utils import parse_list from ..utils import is_hostname +from ..common import NotifyType from ..common import NOTIFY_TYPES from ..common import NotifyFormat from ..common import NOTIFY_FORMATS @@ -194,16 +195,16 @@ class NotifyBase(object): if 'overflow' in kwargs: # Store the specified format if specified - overflow_mode = kwargs.get('overflow', '') - if overflow_mode.lower() not in OVERFLOW_MODES: + overflow = kwargs.get('overflow', '') + if overflow.lower() not in OVERFLOW_MODES: self.logger.error( - 'Invalid overflow method %s' % overflow_mode, + 'Invalid overflow method %s' % overflow, ) raise TypeError( - 'Invalid overflow method %s' % overflow_mode, + 'Invalid overflow method %s' % overflow, ) # Provide override - self.overflow_mode = overflow_mode + self.overflow_mode = overflow if 'tag' in kwargs: # We want to associate some tags with our notification service. @@ -314,7 +315,29 @@ class NotifyBase(object): color_type=color_type, ) - def _apply_overflow(self, body, title=None): + def notify(self, body, title=None, notify_type=NotifyType.INFO, + overflow=None, **kwargs): + """ + Performs notification + + """ + + # Handle situations where the title is None + title = '' if not title else title + + # Apply our overflow (if defined) + for chunk in self._apply_overflow(body=body, title=title, + overflow=overflow): + # Send notification + if not self.send(body=chunk['body'], title=chunk['title'], + notify_type=notify_type): + + # Toggle our return status flag + return False + + return True + + def _apply_overflow(self, body, title=None, overflow=None): """ Takes the message body and title as input. This function then applies any defined overflow restrictions associated with the @@ -337,6 +360,14 @@ class NotifyBase(object): response = list() + # safety + title = '' if not title else title.strip() + body = '' if not body else body.rstrip() + + if overflow is None: + # default + overflow = self.overflow_mode + if self.title_maxlen <= 0: # Content is appended to body body = '{}\r\n{}'.format(title, body) @@ -349,12 +380,9 @@ class NotifyBase(object): body = re.split('\r*\n', body) body = '\r\n'.join(body[0:self.body_max_line_count]) - if self.overflow_mode == OverflowMode.UPSTREAM: - # Nothing to do - response.append({ - 'body': body, - 'title': title, - }) + if overflow == OverflowMode.UPSTREAM: + # Nothing more to do + response.append({'body': body, 'title': title}) return response elif len(title) > self.title_maxlen: @@ -362,13 +390,10 @@ class NotifyBase(object): title = title[:self.title_maxlen] if self.body_maxlen > 0 and len(body) <= self.body_maxlen: - response.append({ - 'body': body, - 'title': title, - }) + response.append({'body': body, 'title': title}) return response - if self.overflow_mode == OverflowMode.TRUNCATE: + if overflow == OverflowMode.TRUNCATE: # Truncate our body and return response.append({ 'body': body[:self.body_maxlen], @@ -386,13 +411,20 @@ class NotifyBase(object): return response + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Should preform the actual notification itself. + + """ + raise NotImplementedError("send() is implimented by the child class.") + def url(self): """ Assembles the URL associated with the notification based on the arguments provied. """ - raise NotImplementedError("This is implimented by the child class.") + raise NotImplementedError("url() is implimented by the child class.") def __contains__(self, tags): """ @@ -555,6 +587,15 @@ class NotifyBase(object): results['format'])) del results['format'] + # Allow overriding the default overflow + if 'overflow' in results['qsd']: + results['overflow'] = results['qsd'].get('overflow') + if results['overflow'] not in OVERFLOW_MODES: + NotifyBase.logger.warning( + 'Unsupported overflow specified {}'.format( + results['overflow'])) + del results['overflow'] + # Password overrides if 'pass' in results['qsd']: results['password'] = results['qsd']['pass'] diff --git a/apprise/plugins/NotifyBoxcar.py b/apprise/plugins/NotifyBoxcar.py index a2e913af..cfdb968a 100644 --- a/apprise/plugins/NotifyBoxcar.py +++ b/apprise/plugins/NotifyBoxcar.py @@ -23,11 +23,11 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -from json import dumps -import requests import re -from time import time +import requests import hmac +from json import dumps +from time import time from hashlib import sha1 from itertools import chain try: @@ -38,7 +38,7 @@ except ImportError: from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP - +from ..common import NotifyType from ..common import NotifyImageSize from ..utils import compat_is_basestring @@ -169,7 +169,7 @@ class NotifyBoxcar(NotifyBase): '(%s) specified.' % recipient, ) - def notify(self, title, body, notify_type, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform Boxcar Notification """ diff --git a/apprise/plugins/NotifyDBus.py b/apprise/plugins/NotifyDBus.py index ce1695b9..5eb79cb2 100644 --- a/apprise/plugins/NotifyDBus.py +++ b/apprise/plugins/NotifyDBus.py @@ -26,10 +26,9 @@ from __future__ import absolute_import from __future__ import print_function -import re - from .NotifyBase import NotifyBase from ..common import NotifyImageSize +from ..common import NotifyType from ..utils import GET_SCHEMA_RE # Default our global support flag @@ -201,7 +200,7 @@ class NotifyDBus(NotifyBase): self.x_axis = x_axis self.y_axis = y_axis - def notify(self, title, body, notify_type, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform DBus Notification """ diff --git a/apprise/plugins/NotifyDiscord.py b/apprise/plugins/NotifyDiscord.py index 98c323fc..c2384105 100644 --- a/apprise/plugins/NotifyDiscord.py +++ b/apprise/plugins/NotifyDiscord.py @@ -48,6 +48,7 @@ from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP from ..common import NotifyImageSize from ..common import NotifyFormat +from ..common import NotifyType from ..utils import parse_bool @@ -113,7 +114,7 @@ class NotifyDiscord(NotifyBase): return - def notify(self, title, body, notify_type, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform Discord Notification """ diff --git a/apprise/plugins/NotifyEmail.py b/apprise/plugins/NotifyEmail.py index dda718cf..b3ff37ff 100644 --- a/apprise/plugins/NotifyEmail.py +++ b/apprise/plugins/NotifyEmail.py @@ -24,15 +24,14 @@ # THE SOFTWARE. import re - -from datetime import datetime import smtplib -from socket import error as SocketError - from email.mime.text import MIMEText +from socket import error as SocketError +from datetime import datetime from .NotifyBase import NotifyBase from ..common import NotifyFormat +from ..common import NotifyType class WebBaseLogin(object): @@ -344,7 +343,7 @@ class NotifyEmail(NotifyBase): break - def notify(self, title, body, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform Email Notification """ diff --git a/apprise/plugins/NotifyEmby.py b/apprise/plugins/NotifyEmby.py index d9542f46..fff5417c 100644 --- a/apprise/plugins/NotifyEmby.py +++ b/apprise/plugins/NotifyEmby.py @@ -37,6 +37,7 @@ from json import loads from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP from ..utils import parse_bool +from ..common import NotifyType from .. import __version__ as VERSION @@ -445,7 +446,7 @@ class NotifyEmby(NotifyBase): self.user_id = None return True - def notify(self, title, body, notify_type, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform Emby Notification """ diff --git a/apprise/plugins/NotifyFaast.py b/apprise/plugins/NotifyFaast.py index 74d86d9b..5fd2d968 100644 --- a/apprise/plugins/NotifyFaast.py +++ b/apprise/plugins/NotifyFaast.py @@ -27,6 +27,7 @@ import requests from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP from ..common import NotifyImageSize +from ..common import NotifyType class NotifyFaast(NotifyBase): @@ -60,7 +61,7 @@ class NotifyFaast(NotifyBase): self.authtoken = authtoken - def notify(self, title, body, notify_type, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform Faast Notification """ diff --git a/apprise/plugins/NotifyGnome.py b/apprise/plugins/NotifyGnome.py index 595cde22..bfb9ed92 100644 --- a/apprise/plugins/NotifyGnome.py +++ b/apprise/plugins/NotifyGnome.py @@ -28,6 +28,7 @@ from __future__ import print_function from .NotifyBase import NotifyBase from ..common import NotifyImageSize +from ..common import NotifyType # Default our global support flag NOTIFY_GNOME_SUPPORT_ENABLED = False @@ -119,7 +120,7 @@ class NotifyGnome(NotifyBase): else: self.urgency = urgency - def notify(self, title, body, notify_type, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform Gnome Notification """ diff --git a/apprise/plugins/NotifyGrowl/NotifyGrowl.py b/apprise/plugins/NotifyGrowl/NotifyGrowl.py index 5c1bc97b..86adee1b 100644 --- a/apprise/plugins/NotifyGrowl/NotifyGrowl.py +++ b/apprise/plugins/NotifyGrowl/NotifyGrowl.py @@ -23,12 +23,11 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -import re - from .gntp import notifier from .gntp import errors from ..NotifyBase import NotifyBase from ...common import NotifyImageSize +from ...common import NotifyType # Priorities @@ -69,16 +68,24 @@ class NotifyGrowl(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_growl' + # Allows the user to specify the NotifyImageSize object + image_size = NotifyImageSize.XY_72 + # Disable throttle rate for Growl requests since they are normally # local anyway request_rate_per_sec = 0 + # A title can not be used for Growl Messages. Setting this to zero will + # cause any title (if defined) to get placed into the message body. + title_maxlen = 0 + + # Limit results to just the first 10 line otherwise there is just to much + # content to display + body_max_line_count = 2 + # Default Growl Port default_port = 23053 - # Allows the user to specify the NotifyImageSize object - image_size = NotifyImageSize.XY_72 - def __init__(self, priority=None, version=2, **kwargs): """ Initialize Growl Object @@ -147,17 +154,11 @@ class NotifyGrowl(NotifyBase): return - def notify(self, title, body, notify_type, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform Growl Notification """ - # Limit results to just the first 2 line otherwise there is just to - # much content to display - body = re.split('[\r\n]+', body) - body[0] = body[0].strip('#').strip() - body = '\r\n'.join(body[0:2]) - icon = None if self.version >= 2: # URL Based diff --git a/apprise/plugins/NotifyIFTTT.py b/apprise/plugins/NotifyIFTTT.py index 6340edc9..b0b006fa 100644 --- a/apprise/plugins/NotifyIFTTT.py +++ b/apprise/plugins/NotifyIFTTT.py @@ -40,10 +40,11 @@ # For each event you create you will assign it a name (this will be known as # the {event} when building your URL. import requests - from json import dumps + from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP +from ..common import NotifyType from ..utils import parse_list @@ -136,7 +137,7 @@ class NotifyIFTTT(NotifyBase): 'del_token must be a list; {} was provided'.format( str(type(del_tokens)))) - def notify(self, title, body, notify_type, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform IFTTT Notification """ diff --git a/apprise/plugins/NotifyJSON.py b/apprise/plugins/NotifyJSON.py index ef4c9763..466a6fa1 100644 --- a/apprise/plugins/NotifyJSON.py +++ b/apprise/plugins/NotifyJSON.py @@ -29,6 +29,7 @@ from json import dumps from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP from ..common import NotifyImageSize +from ..common import NotifyType from ..utils import compat_is_basestring @@ -120,7 +121,7 @@ class NotifyJSON(NotifyBase): args=self.urlencode(args), ) - def notify(self, title, body, notify_type, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform JSON Notification """ diff --git a/apprise/plugins/NotifyJoin.py b/apprise/plugins/NotifyJoin.py index 4f45d14c..73824d99 100644 --- a/apprise/plugins/NotifyJoin.py +++ b/apprise/plugins/NotifyJoin.py @@ -39,6 +39,7 @@ import requests from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP from ..common import NotifyImageSize +from ..common import NotifyType from ..utils import compat_is_basestring # Token required as part of the API request @@ -130,7 +131,7 @@ class NotifyJoin(NotifyBase): # Default to everyone self.devices.append('group.all') - def notify(self, title, body, notify_type, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform Join Notification """ diff --git a/apprise/plugins/NotifyMatrix.py b/apprise/plugins/NotifyMatrix.py index 7f3edbe3..f530ebe1 100644 --- a/apprise/plugins/NotifyMatrix.py +++ b/apprise/plugins/NotifyMatrix.py @@ -30,6 +30,7 @@ from time import time from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP +from ..common import NotifyType # Token required as part of the API request VALIDATE_TOKEN = re.compile(r'[A-Za-z0-9]{64}') @@ -134,7 +135,7 @@ class NotifyMatrix(NotifyBase): re.IGNORECASE, ) - def notify(self, title, body, notify_type, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform Matrix Notification """ diff --git a/apprise/plugins/NotifyMatterMost.py b/apprise/plugins/NotifyMatterMost.py index da12b1b8..1d79bb8b 100644 --- a/apprise/plugins/NotifyMatterMost.py +++ b/apprise/plugins/NotifyMatterMost.py @@ -30,6 +30,7 @@ from json import dumps from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP from ..common import NotifyImageSize +from ..common import NotifyType # Some Reference Locations: # - https://docs.mattermost.com/developer/webhooks-incoming.html @@ -111,7 +112,7 @@ class NotifyMatterMost(NotifyBase): return - def notify(self, title, body, notify_type, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform MatterMost Notification """ diff --git a/apprise/plugins/NotifyProwl.py b/apprise/plugins/NotifyProwl.py index a966c601..2bd8235e 100644 --- a/apprise/plugins/NotifyProwl.py +++ b/apprise/plugins/NotifyProwl.py @@ -28,6 +28,7 @@ import requests from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP +from ..common import NotifyType # Used to validate API Key VALIDATE_APIKEY = re.compile(r'[A-Za-z0-9]{40}') @@ -128,7 +129,7 @@ class NotifyProwl(NotifyBase): # Store the Provider Key self.providerkey = providerkey - def notify(self, title, body, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform Prowl Notification """ diff --git a/apprise/plugins/NotifyPushBullet.py b/apprise/plugins/NotifyPushBullet.py index 5de62c2d..e4317afd 100644 --- a/apprise/plugins/NotifyPushBullet.py +++ b/apprise/plugins/NotifyPushBullet.py @@ -30,7 +30,7 @@ from json import dumps from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP from .NotifyBase import IS_EMAIL_RE - +from ..common import NotifyType from ..utils import compat_is_basestring # Flag used as a placeholder to sending to all devices @@ -87,7 +87,7 @@ class NotifyPushBullet(NotifyBase): if len(self.recipients) == 0: self.recipients = (PUSHBULLET_SEND_TO_ALL, ) - def notify(self, title, body, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform PushBullet Notification """ diff --git a/apprise/plugins/NotifyPushed.py b/apprise/plugins/NotifyPushed.py index 0295c137..7d7f21b4 100644 --- a/apprise/plugins/NotifyPushed.py +++ b/apprise/plugins/NotifyPushed.py @@ -30,6 +30,7 @@ from itertools import chain from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP +from ..common import NotifyType from ..utils import compat_is_basestring # Used to detect and parse channels @@ -128,7 +129,7 @@ class NotifyPushed(NotifyBase): return - def notify(self, title, body, notify_type, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform Pushed Notification """ @@ -153,7 +154,7 @@ class NotifyPushed(NotifyBase): if len(self.channels) + len(self.users) == 0: # Just notify the app - return self.send_notification( + return self._send( payload=payload, notify_type=notify_type, **kwargs) # If our code reaches here, we want to target channels and users (by @@ -171,7 +172,7 @@ class NotifyPushed(NotifyBase): # Get Channel _payload['target_alias'] = channels.pop(0) - if not self.send_notification( + if not self._send( payload=_payload, notify_type=notify_type, **kwargs): # toggle flag @@ -186,7 +187,7 @@ class NotifyPushed(NotifyBase): # Get User's Pushed ID _payload['pushed_id'] = users.pop(0) - if not self.send_notification( + if not self._send( payload=_payload, notify_type=notify_type, **kwargs): # toggle flag @@ -194,11 +195,11 @@ class NotifyPushed(NotifyBase): return not has_error - def send_notification(self, payload, notify_type, **kwargs): + def _send(self, payload, notify_type, **kwargs): """ A lower level call that directly pushes a payload to the Pushed Notification servers. This should never be called directly; it is - referenced automatically through the notify() function. + referenced automatically through the send() function. """ headers = { diff --git a/apprise/plugins/NotifyPushjet/NotifyPushjet.py b/apprise/plugins/NotifyPushjet/NotifyPushjet.py index 8b45a9ef..dafd8214 100644 --- a/apprise/plugins/NotifyPushjet/NotifyPushjet.py +++ b/apprise/plugins/NotifyPushjet/NotifyPushjet.py @@ -28,6 +28,7 @@ from .pushjet import errors from .pushjet import pushjet from ..NotifyBase import NotifyBase +from ...common import NotifyType PUBLIC_KEY_RE = re.compile( r'^[a-z0-9]{4}-[a-z0-9]{6}-[a-z0-9]{12}-[a-z0-9]{5}-[a-z0-9]{9}$', re.I) @@ -65,7 +66,7 @@ class NotifyPushjet(NotifyBase): # store our key self.secret_key = secret_key - def notify(self, title, body, notify_type): + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform Pushjet Notification """ diff --git a/apprise/plugins/NotifyPushover.py b/apprise/plugins/NotifyPushover.py index 35ee6828..99a78dd3 100644 --- a/apprise/plugins/NotifyPushover.py +++ b/apprise/plugins/NotifyPushover.py @@ -26,9 +26,10 @@ import re import requests -from ..utils import compat_is_basestring from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP +from ..common import NotifyType +from ..utils import compat_is_basestring # Flag used as a placeholder to sending to all devices PUSHOVER_SEND_TO_ALL = 'ALL_DEVICES' @@ -149,7 +150,7 @@ class NotifyPushover(NotifyBase): 'The user/group specified (%s) is invalid.' % self.user, ) - def notify(self, title, body, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform Pushover Notification """ diff --git a/apprise/plugins/NotifyRocketChat.py b/apprise/plugins/NotifyRocketChat.py index ca33164b..2ede6ad2 100644 --- a/apprise/plugins/NotifyRocketChat.py +++ b/apprise/plugins/NotifyRocketChat.py @@ -30,6 +30,7 @@ from itertools import chain from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP +from ..common import NotifyType from ..utils import compat_is_basestring IS_CHANNEL = re.compile(r'^#(?P[A-Za-z0-9]+)$') @@ -178,9 +179,9 @@ class NotifyRocketChat(NotifyBase): args=self.urlencode(args), ) - def notify(self, title, body, notify_type, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ - wrapper to send_notification since we can alert more then one channel + wrapper to _send since we can alert more then one channel """ # Track whether we authenticated okay @@ -202,7 +203,7 @@ class NotifyRocketChat(NotifyBase): # Get Channel channel = channels.pop(0) - if not self.send_notification( + if not self._send( { 'text': text, 'channel': channel, @@ -216,7 +217,7 @@ class NotifyRocketChat(NotifyBase): # Get Room room = rooms.pop(0) - if not self.send_notification( + if not self._send( { 'text': text, 'roomId': room, @@ -230,7 +231,7 @@ class NotifyRocketChat(NotifyBase): return not has_error - def send_notification(self, payload, notify_type, **kwargs): + def _send(self, payload, notify_type, **kwargs): """ Perform Notify Rocket.Chat Notification """ diff --git a/apprise/plugins/NotifyRyver.py b/apprise/plugins/NotifyRyver.py index 532a6de0..5139e7b8 100644 --- a/apprise/plugins/NotifyRyver.py +++ b/apprise/plugins/NotifyRyver.py @@ -38,6 +38,7 @@ from json import dumps from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP from ..common import NotifyImageSize +from ..common import NotifyType # Token required as part of the API request VALIDATE_TOKEN = re.compile(r'[A-Za-z0-9]{15}') @@ -141,7 +142,7 @@ class NotifyRyver(NotifyBase): re.IGNORECASE, ) - def notify(self, title, body, notify_type, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform Ryver Notification """ diff --git a/apprise/plugins/NotifySNS.py b/apprise/plugins/NotifySNS.py index 02de1546..b04be920 100644 --- a/apprise/plugins/NotifySNS.py +++ b/apprise/plugins/NotifySNS.py @@ -24,7 +24,6 @@ # THE SOFTWARE. import re - import hmac import requests from hashlib import sha256 @@ -35,6 +34,7 @@ from itertools import chain from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP +from ..common import NotifyType from ..utils import compat_is_basestring # Some Phone Number Detection @@ -194,7 +194,7 @@ class NotifySNS(NotifyBase): self.logger.warning( 'There are no valid recipient identified to notify.') - def notify(self, title, body, notify_type, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ wrapper to send_notification since we can alert more then one channel """ @@ -266,7 +266,7 @@ class NotifySNS(NotifyBase): def _post(self, payload, to): """ Wrapper to request.post() to manage it's response better and make - the notify() function cleaner and easier to maintain. + the send() function cleaner and easier to maintain. This function returns True if the _post was successful and False if it wasn't. diff --git a/apprise/plugins/NotifySlack.py b/apprise/plugins/NotifySlack.py index c8575231..7980d908 100644 --- a/apprise/plugins/NotifySlack.py +++ b/apprise/plugins/NotifySlack.py @@ -43,6 +43,7 @@ from time import time from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP from ..common import NotifyImageSize +from ..common import NotifyType from ..utils import compat_is_basestring # Token required as part of the API request @@ -174,7 +175,7 @@ class NotifySlack(NotifyBase): re.IGNORECASE, ) - def notify(self, title, body, notify_type, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform Slack Notification """ diff --git a/apprise/plugins/NotifyTelegram.py b/apprise/plugins/NotifyTelegram.py index 1050a156..7598cced 100644 --- a/apprise/plugins/NotifyTelegram.py +++ b/apprise/plugins/NotifyTelegram.py @@ -59,10 +59,11 @@ from json import dumps from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP +from ..common import NotifyType from ..common import NotifyImageSize +from ..common import NotifyFormat from ..utils import parse_bool from ..utils import parse_list -from ..common import NotifyFormat TELEGRAM_IMAGE_XY = NotifyImageSize.XY_256 @@ -325,7 +326,7 @@ class NotifyTelegram(NotifyBase): return 0 - def notify(self, title, body, notify_type, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform Telegram Notification """ diff --git a/apprise/plugins/NotifyTwitter/NotifyTwitter.py b/apprise/plugins/NotifyTwitter/NotifyTwitter.py index 488f0c51..50275d4e 100644 --- a/apprise/plugins/NotifyTwitter/NotifyTwitter.py +++ b/apprise/plugins/NotifyTwitter/NotifyTwitter.py @@ -25,6 +25,7 @@ from . import tweepy from ..NotifyBase import NotifyBase +from ...common import NotifyType class NotifyTwitter(NotifyBase): @@ -93,7 +94,7 @@ class NotifyTwitter(NotifyBase): return - def notify(self, title, body, notify_type, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform Twitter Notification """ diff --git a/apprise/plugins/NotifyWindows.py b/apprise/plugins/NotifyWindows.py index aa474248..df76a3b7 100644 --- a/apprise/plugins/NotifyWindows.py +++ b/apprise/plugins/NotifyWindows.py @@ -30,6 +30,7 @@ from time import sleep from .NotifyBase import NotifyBase from ..common import NotifyImageSize +from ..common import NotifyType # Default our global support flag NOTIFY_WINDOWS_SUPPORT_ENABLED = False @@ -107,7 +108,7 @@ class NotifyWindows(NotifyBase): return None - def notify(self, title, body, notify_type, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform Windows Notification """ diff --git a/apprise/plugins/NotifyXBMC.py b/apprise/plugins/NotifyXBMC.py index f02da157..f4970453 100644 --- a/apprise/plugins/NotifyXBMC.py +++ b/apprise/plugins/NotifyXBMC.py @@ -161,7 +161,7 @@ class NotifyXBMC(NotifyBase): return (self.headers, dumps(payload)) - def notify(self, title, body, notify_type, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform XBMC/KODI Notification """ diff --git a/apprise/plugins/NotifyXML.py b/apprise/plugins/NotifyXML.py index af3ca2d5..2aaba506 100644 --- a/apprise/plugins/NotifyXML.py +++ b/apprise/plugins/NotifyXML.py @@ -29,6 +29,7 @@ import requests from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP from ..common import NotifyImageSize +from ..common import NotifyType from ..utils import compat_is_basestring @@ -135,7 +136,7 @@ class NotifyXML(NotifyBase): args=self.urlencode(args), ) - def notify(self, title, body, notify_type, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform XML Notification """ diff --git a/test/test_email_plugin.py b/test/test_email_plugin.py index d4eec5f2..fa0a9266 100644 --- a/test/test_email_plugin.py +++ b/test/test_email_plugin.py @@ -352,28 +352,15 @@ def test_smtplib_init_fail(mock_smtplib): assert(isinstance(obj, plugins.NotifyEmail)) # Support Exception handling of smtplib.SMTP - mock_smtplib.side_effect = TypeError('Test') + mock_smtplib.side_effect = RuntimeError('Test') - try: - obj.notify( - title='test', body='body', - notify_type=NotifyType.INFO) - - # We should have thrown an exception - assert False - - except TypeError: - # Exception thrown as expected - assert True - - except Exception: - # Un-Expected - assert False + assert obj.notify( + body='body', title='test', notify_type=NotifyType.INFO) is False # A handled and expected exception mock_smtplib.side_effect = smtplib.SMTPException('Test') - assert obj.notify(title='test', body='body', - notify_type=NotifyType.INFO) is False + assert obj.notify( + body='body', title='test', notify_type=NotifyType.INFO) is False @mock.patch('smtplib.SMTP') @@ -397,7 +384,7 @@ def test_smtplib_send_okay(mock_smtplib): mock_smtplib.quit.return_value = True assert(obj.notify( - title='test', body='body', notify_type=NotifyType.INFO) is True) + body='body', title='test', notify_type=NotifyType.INFO) is True) # Set Text obj = Apprise.instantiate( @@ -405,4 +392,4 @@ def test_smtplib_send_okay(mock_smtplib): assert(isinstance(obj, plugins.NotifyEmail)) assert(obj.notify( - title='test', body='body', notify_type=NotifyType.INFO) is True) + body='body', title='test', notify_type=NotifyType.INFO) is True) diff --git a/test/test_notify_base.py b/test/test_notify_base.py index e72f560d..4ee8de0d 100644 --- a/test/test_notify_base.py +++ b/test/test_notify_base.py @@ -48,6 +48,15 @@ def test_notify_base(): except TypeError: assert(True) + # invalid types throw exceptions + try: + nb = NotifyBase(**{'overflow': 'invalid'}) + # We should never reach here as an exception should be thrown + assert(False) + + except TypeError: + assert(True) + # Bad port information nb = NotifyBase(port='invalid') assert nb.port is None @@ -65,6 +74,16 @@ def test_notify_base(): # implemented error intentionally assert True + try: + nb.send('test message') + assert False + + except NotImplementedError: + # Each sub-module is that inherits this as a parent is required to + # over-ride this function. So direct calls to this throws a not + # implemented error intentionally + assert True + # Throttle overrides.. nb = NotifyBase() nb.request_rate_per_sec = 0.0 @@ -223,6 +242,31 @@ def test_notify_base_urls(): assert 'password' in results assert results['password'] == "newpassword" + # Options + results = NotifyBase.parse_url('https://localhost?format=invalid') + assert 'format' not in results + results = NotifyBase.parse_url('https://localhost?format=text') + assert 'format' in results + assert results['format'] == 'text' + results = NotifyBase.parse_url('https://localhost?format=markdown') + assert 'format' in results + assert results['format'] == 'markdown' + results = NotifyBase.parse_url('https://localhost?format=html') + assert 'format' in results + assert results['format'] == 'html' + + results = NotifyBase.parse_url('https://localhost?overflow=invalid') + assert 'overflow' not in results + results = NotifyBase.parse_url('https://localhost?overflow=upstream') + assert 'overflow' in results + assert results['overflow'] == 'upstream' + results = NotifyBase.parse_url('https://localhost?overflow=split') + assert 'overflow' in results + assert results['overflow'] == 'split' + results = NotifyBase.parse_url('https://localhost?overflow=truncate') + assert 'overflow' in results + assert results['overflow'] == 'truncate' + # User Handling # user keyword over-rides default password diff --git a/test/test_rest_plugins.py b/test/test_rest_plugins.py index e23c923a..728b87cd 100644 --- a/test/test_rest_plugins.py +++ b/test/test_rest_plugins.py @@ -1489,6 +1489,20 @@ def test_rest_plugins(mock_post, mock_get): # Disable Throttling to speed testing plugins.NotifyBase.NotifyBase.request_rate_per_sec = 0 + # Define how many characters exist per line + row = 80 + + # Some variables we use to control the data we work with + body_len = 1024 + title_len = 1024 + + # Create a large body and title with random data + body = ''.join(choice(str_alpha + str_num + ' ') for _ in range(body_len)) + body = '\r\n'.join([body[i: i + row] for i in range(0, len(body), row)]) + + # Create our title using random data + title = ''.join(choice(str_alpha + str_num) for _ in range(title_len)) + # iterate over our dictionary and test it out for (url, meta) in TEST_URLS: # Our expected instance @@ -1611,9 +1625,24 @@ def test_rest_plugins(mock_post, mock_get): # check that we're as expected assert obj.notify( - title='test', body='body', + body=body, title=title, notify_type=notify_type) == response + # check that this doesn't change using different overflow + # methods + assert obj.notify( + body=body, title=title, + notify_type=notify_type, + overflow=OverflowMode.UPSTREAM) == response + assert obj.notify( + body=body, title=title, + notify_type=notify_type, + overflow=OverflowMode.TRUNCATE) == response + assert obj.notify( + body=body, title=title, + notify_type=notify_type, + overflow=OverflowMode.SPLIT) == response + else: # Disable throttling obj.request_rate_per_sec = 0 @@ -1624,7 +1653,7 @@ def test_rest_plugins(mock_post, mock_get): try: assert obj.notify( - title='test', body='body', + body=body, title=title, notify_type=NotifyType.INFO) is False except AssertionError: @@ -1652,8 +1681,7 @@ def test_rest_plugins(mock_post, mock_get): if test_requests_exceptions is False: # check that we're as expected assert obj.notify( - title='', body='body', - notify_type=notify_type) == response + body='body', notify_type=notify_type) == response else: for _exception in REQUEST_EXCEPTIONS: @@ -1662,7 +1690,7 @@ def test_rest_plugins(mock_post, mock_get): try: assert obj.notify( - title='', body='body', + body=body, notify_type=NotifyType.INFO) is False except AssertionError: @@ -1795,8 +1823,8 @@ def test_notify_discord_plugin(mock_post, mock_get): footer=True, thumbnail=False) # This call includes an image with it's payload: - assert obj.notify(title='title', body='body', - notify_type=NotifyType.INFO) is True + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO) is True # Test our header parsing test_markdown = "## Heading one\nbody body\n\n" + \ @@ -1814,8 +1842,8 @@ def test_notify_discord_plugin(mock_post, mock_get): assert(len(results) == 5) # Use our test markdown string during a notification - assert obj.notify(title='title', body=test_markdown, - notify_type=NotifyType.INFO) is True + assert obj.notify( + body=test_markdown, title='title', notify_type=NotifyType.INFO) is True # Create an apprise instance a = Apprise() @@ -1829,18 +1857,18 @@ def test_notify_discord_plugin(mock_post, mock_get): webhook_token=webhook_token)) is True # This call includes an image with it's payload: - assert a.notify(title='title', body=test_markdown, + assert a.notify(body=test_markdown, title='title', notify_type=NotifyType.INFO, body_format=NotifyFormat.TEXT) is True - assert a.notify(title='title', body=test_markdown, + assert a.notify(body=test_markdown, title='title', notify_type=NotifyType.INFO, body_format=NotifyFormat.MARKDOWN) is True # Toggle our logo availability a.asset.image_url_logo = None - assert a.notify(title='title', body='body', - notify_type=NotifyType.INFO) is True + assert a.notify( + body='body', title='title', notify_type=NotifyType.INFO) is True @mock.patch('requests.get') @@ -2254,8 +2282,8 @@ def test_notify_ifttt_plugin(mock_post, mock_get): obj = plugins.NotifyIFTTT(webhook_id=webhook_id, events=events) assert(isinstance(obj, plugins.NotifyIFTTT)) - assert obj.notify(title='title', body='body', - notify_type=NotifyType.INFO) is True + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO) is True # Test the addition of tokens obj = plugins.NotifyIFTTT( @@ -2264,8 +2292,8 @@ def test_notify_ifttt_plugin(mock_post, mock_get): assert(isinstance(obj, plugins.NotifyIFTTT)) - assert obj.notify(title='title', body='body', - notify_type=NotifyType.INFO) is True + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO) is True try: # Invalid del_tokens entry @@ -2283,8 +2311,8 @@ def test_notify_ifttt_plugin(mock_post, mock_get): assert(isinstance(obj, plugins.NotifyIFTTT)) - assert obj.notify(title='title', body='body', - notify_type=NotifyType.INFO) is True + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO) is True # Test removal of tokens by a list obj = plugins.NotifyIFTTT( @@ -2299,8 +2327,8 @@ def test_notify_ifttt_plugin(mock_post, mock_get): assert(isinstance(obj, plugins.NotifyIFTTT)) - assert obj.notify(title='title', body='body', - notify_type=NotifyType.INFO) is True + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO) is True @mock.patch('requests.get') @@ -2383,8 +2411,8 @@ def test_notify_slack_plugin(mock_post, mock_get): include_image=True) # This call includes an image with it's payload: - assert obj.notify(title='title', body='body', - notify_type=NotifyType.INFO) is True + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO) is True @mock.patch('requests.get') @@ -2571,8 +2599,8 @@ def test_notify_pushover_plugin(mock_post, mock_get): assert(len(obj.devices) == 3) # This call fails because there is 1 invalid device - assert obj.notify(title='title', body='body', - notify_type=NotifyType.INFO) is False + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO) is False obj = plugins.NotifyPushover(user=user, token=token) assert(isinstance(obj, plugins.NotifyPushover)) @@ -2581,8 +2609,8 @@ def test_notify_pushover_plugin(mock_post, mock_get): assert(len(obj.devices) == 1) # This call succeeds because all of the devices are valid - assert obj.notify(title='title', body='body', - notify_type=NotifyType.INFO) is True + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO) is True obj = plugins.NotifyPushover(user=user, token=token, devices=set()) assert(isinstance(obj, plugins.NotifyPushover)) @@ -2680,9 +2708,8 @@ def test_notify_rocketchat_plugin(mock_post, mock_get): # Send Notification # assert obj.notify( - title='title', body='body', notify_type=NotifyType.INFO) is False - assert obj.send_notification( - payload='test', notify_type=NotifyType.INFO) is False + body='body', title='title', notify_type=NotifyType.INFO) is False + assert obj._send(payload='test', notify_type=NotifyType.INFO) is False # # Logout @@ -2697,9 +2724,8 @@ def test_notify_rocketchat_plugin(mock_post, mock_get): # Send Notification # assert obj.notify( - title='title', body='body', notify_type=NotifyType.INFO) is False - assert obj.send_notification( - payload='test', notify_type=NotifyType.INFO) is False + body='body', title='title', notify_type=NotifyType.INFO) is False + assert obj._send(payload='test', notify_type=NotifyType.INFO) is False # # Logout @@ -2717,14 +2743,13 @@ def test_notify_rocketchat_plugin(mock_post, mock_get): # # Send Notification # - assert obj.send_notification( - payload='test', notify_type=NotifyType.INFO) is False + assert obj._send(payload='test', notify_type=NotifyType.INFO) is False # Attempt the check again but fake a successful login obj.login = mock.Mock() obj.login.return_value = True assert obj.notify( - title='title', body='body', notify_type=NotifyType.INFO) is False + body='body', title='title', notify_type=NotifyType.INFO) is False # # Logout # @@ -2835,9 +2860,11 @@ def test_notify_telegram_plugin(mock_post, mock_get): # This tests erroneous messages involving multiple chat ids assert obj.notify( - title='title', body='body', notify_type=NotifyType.INFO) is False + body='body', title='title', notify_type=NotifyType.INFO) is False + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO) is False assert nimg_obj.notify( - title='title', body='body', notify_type=NotifyType.INFO) is False + body='body', title='title', notify_type=NotifyType.INFO) is False # This tests erroneous messages involving a single chat id obj = plugins.NotifyTelegram(bot_token=bot_token, chat_ids='l2g') @@ -2845,9 +2872,9 @@ def test_notify_telegram_plugin(mock_post, mock_get): nimg_obj.asset = AppriseAsset(image_path_mask=False, image_url_mask=False) assert obj.notify( - title='title', body='body', notify_type=NotifyType.INFO) is False + body='body', title='title', notify_type=NotifyType.INFO) is False assert nimg_obj.notify( - title='title', body='body', notify_type=NotifyType.INFO) is False + body='body', title='title', notify_type=NotifyType.INFO) is False # Bot Token Detection # Just to make it clear to people reading this code and trying to learn @@ -2945,7 +2972,7 @@ def test_notify_telegram_plugin(mock_post, mock_get): # notification without a bot detection by providing at least 1 chat id obj = plugins.NotifyTelegram(bot_token=bot_token, chat_ids=['@abcd']) assert nimg_obj.notify( - title='title', body='body', notify_type=NotifyType.INFO) is False + body='body', title='title', notify_type=NotifyType.INFO) is False # iterate over our exceptions and test them for _exception in REQUEST_EXCEPTIONS: @@ -3023,7 +3050,9 @@ def test_notify_overflow_truncate(): # Verify that we break the title to a max length of our title_max # and that the body remains untouched - chunks = obj._apply_overflow(body=body, title=title) + chunks = obj._apply_overflow(body=body, title=title, overflow=None) + chunks = obj._apply_overflow( + body=body, title=title, overflow=OverflowMode.SPLIT) assert len(chunks) == 1 assert body == chunks[0].get('body') assert title[0:TestNotification.title_maxlen] == chunks[0].get('title') From 75bde9d2a76202ceb5e855e30a85c31e5972ed01 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sun, 17 Feb 2019 23:28:37 -0500 Subject: [PATCH 7/8] travis-ci set to run pep8 tests as part of CI --- .travis.yml | 2 +- apprise/plugins/NotifyDBus.py | 2 +- apprise/plugins/NotifyGnome.py | 2 +- apprise/plugins/NotifyWindows.py | 2 +- setup.cfg | 6 ++++++ test/test_email_plugin.py | 2 +- test/test_growl_plugin.py | 3 +-- test/test_pushjet_plugin.py | 2 +- test/test_rest_plugins.py | 4 ++-- tox.ini | 32 ++++++++++++++++++++++---------- 10 files changed, 37 insertions(+), 20 deletions(-) diff --git a/.travis.yml b/.travis.yml index 46f1bcc6..c7d4cc6c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -26,7 +26,7 @@ matrix: install: - pip install . - - pip install codecov tox + - pip install codecov - pip install -r dev-requirements.txt - pip install -r requirements.txt - if [[ $TRAVIS_PYTHON_VERSION != 'pypy'* ]]; then travis_retry pip install dbus-python; fi diff --git a/apprise/plugins/NotifyDBus.py b/apprise/plugins/NotifyDBus.py index 5eb79cb2..a9740d24 100644 --- a/apprise/plugins/NotifyDBus.py +++ b/apprise/plugins/NotifyDBus.py @@ -284,7 +284,7 @@ class NotifyDBus(NotifyBase): self.logger.info('Sent DBus notification.') - except Exception as e: + except Exception: self.logger.warning('Failed to send DBus notification.') self.logger.exception('DBus Exception') return False diff --git a/apprise/plugins/NotifyGnome.py b/apprise/plugins/NotifyGnome.py index bfb9ed92..c1f1cd5b 100644 --- a/apprise/plugins/NotifyGnome.py +++ b/apprise/plugins/NotifyGnome.py @@ -162,7 +162,7 @@ class NotifyGnome(NotifyBase): notification.show() self.logger.info('Sent Gnome notification.') - except Exception as e: + except Exception: self.logger.warning('Failed to send Gnome notification.') self.logger.exception('Gnome Exception') return False diff --git a/apprise/plugins/NotifyWindows.py b/apprise/plugins/NotifyWindows.py index df76a3b7..f39a5285 100644 --- a/apprise/plugins/NotifyWindows.py +++ b/apprise/plugins/NotifyWindows.py @@ -173,7 +173,7 @@ class NotifyWindows(NotifyBase): self.logger.info('Sent Windows notification.') - except Exception as e: + except Exception: self.logger.warning('Failed to send Windows notification.') self.logger.exception('Windows Exception') return False diff --git a/setup.cfg b/setup.cfg index f1e329a3..5ae99e9a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -11,6 +11,12 @@ exclude = .eggs,.tox,gntp,tweepy,pushjet ignore = E722,W503,W504 statistics = true +[flake8] +# We exclude packages we don't maintain +exclude = .eggs,.tox,gntp,tweepy,pushjet +ignore = E722,W503,W504 +statistics = true + [aliases] test=pytest diff --git a/test/test_email_plugin.py b/test/test_email_plugin.py index fa0a9266..a067b65c 100644 --- a/test/test_email_plugin.py +++ b/test/test_email_plugin.py @@ -274,7 +274,7 @@ def test_email_plugin(mock_smtp, mock_smtpssl): # Don't mess with these entries raise - except Exception as e: + except Exception: # We can't handle this exception type raise diff --git a/test/test_growl_plugin.py b/test/test_growl_plugin.py index 295ec5fb..e92742f2 100644 --- a/test/test_growl_plugin.py +++ b/test/test_growl_plugin.py @@ -29,7 +29,6 @@ from apprise import Apprise from apprise.utils import compat_is_basestring import mock -import re TEST_URLS = ( @@ -195,7 +194,7 @@ def test_growl_plugin(mock_gntp): # This is the response we expect assert True - except Exception as e: + except Exception: # We can't handle this exception type assert False diff --git a/test/test_pushjet_plugin.py b/test/test_pushjet_plugin.py index f2d19028..29642223 100644 --- a/test/test_pushjet_plugin.py +++ b/test/test_pushjet_plugin.py @@ -168,7 +168,7 @@ def test_plugin(mock_refresh, mock_send): # Don't mess with these entries raise - except Exception as e: + except Exception: # We can't handle this exception type raise diff --git a/test/test_rest_plugins.py b/test/test_rest_plugins.py index 728b87cd..ded27f11 100644 --- a/test/test_rest_plugins.py +++ b/test/test_rest_plugins.py @@ -1660,7 +1660,7 @@ def test_rest_plugins(mock_post, mock_get): # Don't mess with these entries raise - except Exception as e: + except Exception: # We can't handle this exception type raise @@ -1697,7 +1697,7 @@ def test_rest_plugins(mock_post, mock_get): # Don't mess with these entries raise - except Exception as e: + except Exception: # We can't handle this exception type raise diff --git a/tox.ini b/tox.ini index 3ca38ef7..3a5af053 100644 --- a/tox.ini +++ b/tox.ini @@ -10,36 +10,45 @@ setenv = deps= -r{toxinidir}/requirements.txt -r{toxinidir}/dev-requirements.txt -commands = python -m pytest {posargs} - +commands = + coverage run --parallel -m pytest {posargs} + flake8 . --count --show-source --statistics [testenv:py27] deps= dbus-python -r{toxinidir}/requirements.txt -r{toxinidir}/dev-requirements.txt -commands = coverage run --parallel -m pytest {posargs} +commands = + coverage run --parallel -m pytest {posargs} + flake8 . --count --show-source --statistics [testenv:py34] deps= dbus-python -r{toxinidir}/requirements.txt -r{toxinidir}/dev-requirements.txt -commands = coverage run --parallel -m pytest {posargs} +commands = + coverage run --parallel -m pytest {posargs} + flake8 . --count --show-source --statistics [testenv:py35] deps= dbus-python -r{toxinidir}/requirements.txt -r{toxinidir}/dev-requirements.txt -commands = coverage run --parallel -m pytest {posargs} +commands = + coverage run --parallel -m pytest {posargs} + flake8 . --count --show-source --statistics [testenv:py36] deps= dbus-python -r{toxinidir}/requirements.txt -r{toxinidir}/dev-requirements.txt -commands = coverage run --parallel -m pytest {posargs} +commands = + coverage run --parallel -m pytest {posargs} + flake8 . --count --show-source --statistics [testenv:py37] deps= @@ -47,21 +56,24 @@ deps= -r{toxinidir}/requirements.txt -r{toxinidir}/dev-requirements.txt commands = - flake8 . --count --select=E901,E999,F821,F822,F823 --show-source --statistics coverage run --parallel -m pytest {posargs} + flake8 . --count --show-source --statistics [testenv:pypy] deps= -r{toxinidir}/requirements.txt -r{toxinidir}/dev-requirements.txt -commands = coverage run --parallel -m pytest {posargs} +commands = + coverage run --parallel -m pytest {posargs} + flake8 . --count --show-source --statistics [testenv:pypy3] deps= -r{toxinidir}/requirements.txt -r{toxinidir}/dev-requirements.txt -commands = coverage run --parallel -m pytest {posargs} - +commands = + coverage run --parallel -m pytest {posargs} + flake8 . --count --show-source --statistics [testenv:coverage-report] deps = coverage From 9bd0144112a9478b8de4b09bf824cce164bad72c Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Mon, 18 Feb 2019 21:24:30 -0500 Subject: [PATCH 8/8] experimented with sphinx function inline doc --- apprise/plugins/NotifyBase.py | 138 ++++++++++++++++++++++++++++------ test/test_rest_plugins.py | 2 +- 2 files changed, 115 insertions(+), 25 deletions(-) diff --git a/apprise/plugins/NotifyBase.py b/apprise/plugins/NotifyBase.py index 94f73ffa..ac6df799 100644 --- a/apprise/plugins/NotifyBase.py +++ b/apprise/plugins/NotifyBase.py @@ -57,13 +57,6 @@ from ..AppriseAsset import AppriseAsset from xml.sax.saxutils import escape as sax_escape -def _escape(text): - """ - saxutil escape tool - """ - return sax_escape(text, {"'": "'", "\"": """}) - - HTTP_ERROR_MAP = { 400: 'Bad Request - Unsupported Parameters.', 401: 'Verification Failed.', @@ -360,7 +353,7 @@ class NotifyBase(object): response = list() - # safety + # tidy title = '' if not title else title.strip() body = '' if not body else body.rstrip() @@ -377,7 +370,7 @@ class NotifyBase(object): if self.body_max_line_count > 0: # Limit results to just the first 2 line otherwise # there is just to much content to display - body = re.split('\r*\n', body) + body = re.split(r'\r*\n', body) body = '\r\n'.join(body[0:self.body_max_line_count]) if overflow == OverflowMode.UPSTREAM: @@ -456,12 +449,21 @@ class NotifyBase(object): """ Takes html text as input and escapes it so that it won't conflict with any xml/html wrapping characters. + + Args: + html (str): The HTML code to escape + convert_new_lines (:obj:`bool`, optional): escape new lines (\n) + whitespace (:obj:`bool`, optional): escape whitespace + + Returns: + str: The escaped html """ if not html: # nothing more to do; return object as is return html - escaped = _escape(html) + # Escape HTML + escaped = sax_escape(html, {"'": "'", "\"": """}) if whitespace: # Tidy up whitespace too @@ -477,8 +479,25 @@ class NotifyBase(object): @staticmethod def unquote(content, encoding='utf-8', errors='replace'): """ - common unquote function + Replace %xx escapes by their single-character equivalent. The optional + encoding and errors parameters specify how to decode percent-encoded + sequences. + Wrapper to Python's unquote while remaining compatible with both + Python 2 & 3 since the reference to this function changed between + versions. + + Note: errors set to 'replace' means that invalid sequences are + replaced by a placeholder character. + + Args: + content (str): The quoted URI string you wish to unquote + encoding (:obj:`str`, optional): encoding type + errors (:obj:`str`, errors): how to handle invalid character found + in encoded string (defined by encoding) + + Returns: + str: The unquoted URI string """ if not content: return '' @@ -493,9 +512,25 @@ class NotifyBase(object): @staticmethod def quote(content, safe='/', encoding=None, errors=None): - """ - common quote function + """ Replaces single character non-ascii characters and URI specific + ones by their %xx code. + Wrapper to Python's unquote while remaining compatible with both + Python 2 & 3 since the reference to this function changed between + versions. + + Args: + content (str): The URI string you wish to quote + safe (str): non-ascii characters and URI specific ones that you + do not wish to escape (if detected). Setting this + string to an empty one causes everything to be + escaped. + encoding (:obj:`str`, optional): encoding type + errors (:obj:`str`, errors): how to handle invalid character found + in encoded string (defined by encoding) + + Returns: + str: The quoted URI string """ if not content: return '' @@ -510,11 +545,32 @@ class NotifyBase(object): @staticmethod def urlencode(query, doseq=False, safe='', encoding=None, errors=None): - """ - common urlencode function + """Convert a mapping object or a sequence of two-element tuples - The query should always be a dictionary. + Wrapper to Python's unquote while remaining compatible with both + Python 2 & 3 since the reference to this function changed between + versions. + The resulting string is a series of key=value pairs separated by '&' + characters, where both key and value are quoted using the quote() + function. + + Note: If the dictionary entry contains an entry that is set to None + it is not included in the final result set. If you want to + pass in an empty variable, set it to an empty string. + + Args: + query (str): The dictionary to encode + doseq (:obj:`bool`, optional): Handle sequences + safe (:obj:`str`): non-ascii characters and URI specific ones that + you do not wish to escape (if detected). Setting this string + to an empty one causes everything to be escaped. + encoding (:obj:`str`, optional): encoding type + errors (:obj:`str`, errors): how to handle invalid character found + in encoded string (defined by encoding) + + Returns: + str: The escaped parameters returned as a string """ # Tidy query by eliminating any records set to None _query = {k: v for (k, v) in query.items() if v is not None} @@ -530,10 +586,19 @@ class NotifyBase(object): @staticmethod def split_path(path, unquote=True): - """ - Splits a URL up into a list object. + """Splits a URL up into a list object. + Parses a specified URL and breaks it into a list. + + Args: + path (str): The path to split up into a list. + unquote (:obj:`bool`, optional): call unquote on each element + added to the returned list. + + Returns: + list: A list containing all of the elements in the path """ + if unquote: return PATHSPLIT_LIST_DELIM.split( NotifyBase.unquote(path).lstrip('/')) @@ -541,26 +606,51 @@ class NotifyBase(object): @staticmethod def is_email(address): - """ - Returns True if specified entry is an email address + """Determine if the specified entry is an email address + Args: + address (str): The string you want to check. + + Returns: + bool: Returns True if the address specified is an email address + and False if it isn't. """ + return IS_EMAIL_RE.match(address) is not None @staticmethod def is_hostname(hostname): - """ - Returns True if specified entry is a hostname + """Determine if the specified entry is a hostname + Args: + hostname (str): The string you want to check. + + Returns: + bool: Returns True if the hostname specified is in fact a hostame + and False if it isn't. """ return is_hostname(hostname) @staticmethod def parse_url(url, verify_host=True): - """ - Parses the URL and returns it broken apart into a dictionary. + """Parses the URL and returns it broken apart into a dictionary. + This is very specific and customized for Apprise. + + + Args: + url (str): The URL you want to fully parse. + verify_host (:obj:`bool`, optional): a flag kept with the parsed + URL which some child classes will later use to verify SSL + keys (if SSL transactions take place). Unless under very + specific circumstances, it is strongly recomended that + you leave this default value set to True. + + Returns: + A dictionary is returned containing the URL fully parsed if + successful, otherwise None is returned. """ + results = parse_url( url, default_schema='unknown', verify_host=verify_host) diff --git a/test/test_rest_plugins.py b/test/test_rest_plugins.py index ded27f11..8128674b 100644 --- a/test/test_rest_plugins.py +++ b/test/test_rest_plugins.py @@ -3177,7 +3177,7 @@ def test_notify_overflow_split(): title_len = 1024 # Create a large body and title with random data - body = ''.join(choice(str_alpha + str_num + ' ') for _ in range(body_len)) + body = ''.join(choice(str_alpha + str_num) for _ in range(body_len)) body = '\r\n'.join([body[i: i + row] for i in range(0, len(body), row)]) # the new lines add a large amount to our body; lets force the content