From 0ab86c2115ef5482ab5b15f1000aa736793cfe2e Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Mon, 25 Feb 2019 01:02:29 -0500 Subject: [PATCH 1/3] Config file support added for http & file (text); refs #55 --- apprise/Apprise.py | 369 ++++++++-------- apprise/AppriseConfig.py | 292 +++++++++++++ apprise/URLBase.py | 427 +++++++++++++++++++ apprise/__init__.py | 12 +- apprise/cli.py | 65 ++- apprise/common.py | 23 + apprise/config/ConfigBase.py | 337 +++++++++++++++ apprise/config/ConfigFile.py | 164 ++++++++ apprise/config/ConfigHTTP.py | 269 ++++++++++++ apprise/config/__init__.py | 87 ++++ apprise/plugins/NotifyBase.py | 396 +----------------- apprise/plugins/NotifyBoxcar.py | 27 +- apprise/plugins/NotifyDiscord.py | 22 +- apprise/plugins/NotifyEmail.py | 7 +- apprise/plugins/NotifyEmby.py | 93 ++--- apprise/plugins/NotifyFaast.py | 22 +- apprise/plugins/NotifyIFTTT.py | 43 +- apprise/plugins/NotifyJSON.py | 26 +- apprise/plugins/NotifyJoin.py | 57 +-- apprise/plugins/NotifyMatrix.py | 50 ++- apprise/plugins/NotifyMatterMost.py | 23 +- apprise/plugins/NotifyProwl.py | 30 +- apprise/plugins/NotifyPushBullet.py | 46 +- apprise/plugins/NotifyPushed.py | 25 +- apprise/plugins/NotifyPushover.py | 44 +- apprise/plugins/NotifyRocketChat.py | 90 ++-- apprise/plugins/NotifyRyver.py | 24 +- apprise/plugins/NotifySNS.py | 37 +- apprise/plugins/NotifySlack.py | 55 +-- apprise/plugins/NotifyTelegram.py | 111 +++-- apprise/plugins/NotifyXBMC.py | 21 +- apprise/plugins/NotifyXML.py | 26 +- apprise/plugins/__init__.py | 57 ++- apprise/utils.py | 103 ++++- test/test_api.py | 28 +- test/test_apprise_config.py | 626 ++++++++++++++++++++++++++++ test/test_cli.py | 69 ++- test/test_config_base.py | 168 ++++++++ test/test_config_file.py | 104 +++++ test/test_config_http.py | 173 ++++++++ test/test_email_plugin.py | 14 +- test/test_glib_plugin.py | 12 +- test/test_gnome_plugin.py | 8 +- test/test_growl_plugin.py | 9 +- test/test_notify_base.py | 23 +- test/test_pushjet_plugin.py | 9 +- test/test_rest_plugins.py | 119 +++--- test/test_sns_plugin.py | 17 +- test/test_twitter_plugin.py | 4 + test/test_utils.py | 92 ++++ test/test_windows_plugin.py | 8 +- 51 files changed, 3798 insertions(+), 1165 deletions(-) create mode 100644 apprise/AppriseConfig.py create mode 100644 apprise/URLBase.py create mode 100644 apprise/config/ConfigBase.py create mode 100644 apprise/config/ConfigFile.py create mode 100644 apprise/config/ConfigHTTP.py create mode 100644 apprise/config/__init__.py create mode 100644 test/test_apprise_config.py create mode 100644 test/test_config_base.py create mode 100644 test/test_config_file.py create mode 100644 test/test_config_http.py diff --git a/apprise/Apprise.py b/apprise/Apprise.py index 467826dd..aa9700f4 100644 --- a/apprise/Apprise.py +++ b/apprise/Apprise.py @@ -24,71 +24,27 @@ # THE SOFTWARE. import re +import six import logging from markdown import markdown +from itertools import chain from .common import NotifyType from .common import NotifyFormat +from .utils import is_exclusive_match from .utils import parse_list -from .utils import compat_is_basestring from .utils import GET_SCHEMA_RE from .AppriseAsset import AppriseAsset +from .AppriseConfig import AppriseConfig +from .config.ConfigBase import ConfigBase +from .plugins.NotifyBase import NotifyBase -from . import NotifyBase from . import plugins from . import __version__ logger = logging.getLogger(__name__) -# Build a list of supported plugins -SCHEMA_MAP = {} - - -# Load our Lookup Matrix -def __load_matrix(): - """ - Dynamically load our schema map; this allows us to gracefully - skip over plugins we simply don't have the dependecies for. - - """ - # to add it's mapping to our hash table - for entry in dir(plugins): - - # Get our plugin - plugin = getattr(plugins, entry) - if not hasattr(plugin, 'app_id'): # pragma: no branch - # Filter out non-notification modules - continue - - # Load protocol(s) if defined - proto = getattr(plugin, 'protocol', None) - if compat_is_basestring(proto): - if proto not in SCHEMA_MAP: - SCHEMA_MAP[proto] = plugin - - elif isinstance(proto, (set, list, tuple)): - # Support iterables list types - for p in proto: - if p not in SCHEMA_MAP: - SCHEMA_MAP[p] = plugin - - # Load secure protocol(s) if defined - protos = getattr(plugin, 'secure_protocol', None) - if compat_is_basestring(protos): - if protos not in SCHEMA_MAP: - SCHEMA_MAP[protos] = plugin - - if isinstance(protos, (set, list, tuple)): - # Support iterables list types - for p in protos: - if p not in SCHEMA_MAP: - SCHEMA_MAP[p] = plugin - - -# Dynamically build our module -__load_matrix() - class Apprise(object): """ @@ -112,10 +68,8 @@ class Apprise(object): # directory images can be found in. It can also identify remote # URL paths that contain the images you want to present to the end # user. If no asset is specified, then the default one is used. - self.asset = asset - if asset is None: - # Load our default configuration - self.asset = AppriseAsset() + self.asset = \ + asset if isinstance(asset, AppriseAsset) else AppriseAsset() if servers: self.add(servers) @@ -128,48 +82,45 @@ class Apprise(object): """ # swap hash (#) tag values with their html version - # This is useful for accepting channels (as arguments to pushbullet) _url = url.replace('/#', '/%23') # Attempt to acquire the schema at the very least to allow our plugins # to determine if they can make a better interpretation of a URL - # geared for them anyway. + # geared for them schema = GET_SCHEMA_RE.match(_url) if schema is None: - logger.error('%s is an unparseable server url.' % url) + logger.error('Unparseable schema:// found in URL {}.'.format(url)) return None - # Update the schema + # Ensure our schema is always in lower case schema = schema.group('schema').lower() # Some basic validation - if schema not in SCHEMA_MAP: - logger.error( - '{0} is not a supported server type (url={1}).'.format( - schema, - _url, - ) - ) + if schema not in plugins.SCHEMA_MAP: + logger.error('Unsupported schema {}.'.format(schema)) return None - # Parse our url details - # the server object is a dictionary containing all of the information - # parsed from our URL - results = SCHEMA_MAP[schema].parse_url(_url) + # Parse our url details of the server object as dictionary containing + # all of the information parsed from our URL + results = plugins.SCHEMA_MAP[schema].parse_url(_url) - if not results: + if results is None: # Failed to parse the server URL - logger.error('Could not parse URL: %s' % url) + logger.error('Unparseable URL {}.'.format(url)) return None # Build a list of tags to associate with the newly added notifications results['tag'] = set(parse_list(tag)) + # Prepare our Asset Object + results['asset'] = \ + asset if isinstance(asset, AppriseAsset) else AppriseAsset() + if suppress_exceptions: try: # Attempt to create an instance of our plugin using the parsed # URL information - plugin = SCHEMA_MAP[results['schema']](**results) + plugin = plugins.SCHEMA_MAP[results['schema']](**results) except Exception: # the arguments are invalid or can not be used. @@ -179,11 +130,7 @@ class Apprise(object): else: # Attempt to create an instance of our plugin using the parsed # URL information but don't wrap it in a try catch - plugin = SCHEMA_MAP[results['schema']](**results) - - # Save our asset - if asset: - plugin.asset = asset + plugin = plugins.SCHEMA_MAP[results['schema']](**results) return plugin @@ -202,23 +149,43 @@ class Apprise(object): # Initialize our return status return_status = True - if asset is None: + if isinstance(asset, AppriseAsset): # prepare default asset asset = self.asset - if isinstance(servers, NotifyBase): + if isinstance(servers, six.string_types): + # build our server list + servers = parse_list(servers) + + elif isinstance(servers, (ConfigBase, NotifyBase, AppriseConfig)): # Go ahead and just add our plugin into our list self.servers.append(servers) return True - # build our server listings - servers = parse_list(servers) + elif not isinstance(servers, (tuple, set, list)): + logging.error( + "An invalid notification (type={}) was specified.".format( + type(servers))) + return False + for _server in servers: + if isinstance(_server, (ConfigBase, NotifyBase, AppriseConfig)): + # Go ahead and just add our plugin into our list + self.servers.append(_server) + continue + + elif not isinstance(_server, six.string_types): + logging.error( + "An invalid notification (type={}) was specified.".format( + type(_server))) + return_status = False + continue + # Instantiate ourselves an object, this function throws or # returns None if it fails instance = Apprise.instantiate(_server, asset=asset, tag=tag) - if not instance: + if not isinstance(instance, NotifyBase): return_status = False logging.error( "Failed to load notification url: {}".format(_server), @@ -254,7 +221,7 @@ class Apprise(object): """ # Initialize our return result - status = len(self.servers) > 0 + status = len(self) > 0 if not (title or body): return False @@ -273,115 +240,89 @@ class Apprise(object): # tag=[('tagB', 'tagC')] = tagB and tagC # Iterate over our loaded plugins - for server in self.servers: + for entry in self.servers: - if tag is not None: + if isinstance(entry, (ConfigBase, AppriseConfig)): + # load our servers + servers = entry.servers() - if isinstance(tag, (list, tuple, set)): - # using the tags detected; determine if we'll allow the - # notification to be sent or not - matched = False + else: + servers = [entry, ] - # Every entry here will be or'ed with the next - for entry in tag: - if isinstance(entry, (list, tuple, set)): - - # treat these entries as though all elements found - # must exist in the notification service - tags = set(parse_list(entry)) - - if len(tags.intersection( - server.tags)) == len(tags): - # our set contains all of the entries found - # in our notification server object - matched = True - break - - elif entry in server: - # our entr(ies) match what was found in our server - # object. - matched = True - break - - # else: keep looking - - if not matched: - # We did not meet any of our and'ed criteria - continue - - elif tag not in server: - # one or more tags were defined and they didn't match the - # entry in the current service; move along... + for server in servers: + # Apply our tag matching based on our defined logic + if tag is not None and not is_exclusive_match( + logic=tag, data=server.tags): continue - # else: our content was found inside the server, so we're good + # If our code reaches here, we either did not define a tag (it + # was set to None), or we did define a tag and the logic above + # determined we need to notify the service it's associated with + if server.notify_format not in conversion_map: + if body_format == NotifyFormat.MARKDOWN and \ + server.notify_format == NotifyFormat.HTML: - # If our code reaches here, we either did not define a tag (it was - # set to None), or we did define a tag and the logic above - # determined we need to notify the service it's associated with - if server.notify_format not in conversion_map: - if body_format == NotifyFormat.MARKDOWN and \ - server.notify_format == NotifyFormat.HTML: + # Apply Markdown + conversion_map[server.notify_format] = markdown(body) - # Apply Markdown - conversion_map[server.notify_format] = markdown(body) + elif body_format == NotifyFormat.TEXT and \ + server.notify_format == NotifyFormat.HTML: - elif body_format == NotifyFormat.TEXT and \ - server.notify_format == NotifyFormat.HTML: + # Basic TEXT to HTML format map; supports keys only + re_map = { + # Support Ampersand + r'&': '&', - # Basic TEXT to HTML format map; supports keys only - re_map = { - # Support Ampersand - r'&': '&', + # Spaces to   for formatting purposes since + # multiple spaces are treated as one an this may + # not be the callers intention + r' ': ' ', - # Spaces to   for formatting purposes since - # multiple spaces are treated as one an this may not - # be the callers intention - r' ': ' ', + # Tab support + r'\t': '   ', - # Tab support - r'\t': '   ', + # Greater than and Less than Characters + r'>': '>', + r'<': '<', + } - # Greater than and Less than Characters - r'>': '>', - r'<': '<', - } + # Compile our map + re_table = re.compile( + r'(' + '|'.join( + map(re.escape, re_map.keys())) + r')', + re.IGNORECASE, + ) - # Compile our map - re_table = re.compile( - r'(' + '|'.join(map(re.escape, re_map.keys())) + r')', - re.IGNORECASE, - ) + # Execute our map against our body in addition to + # swapping out new lines and replacing them with
+ conversion_map[server.notify_format] = \ + re.sub(r'\r*\n', '
\r\n', + re_table.sub( + lambda x: re_map[x.group()], body)) - # Execute our map against our body in addition to swapping - # out new lines and replacing them with
- conversion_map[server.notify_format] = \ - re.sub(r'\r*\n', '
\r\n', - re_table.sub(lambda x: re_map[x.group()], body)) + else: + # Store entry directly + conversion_map[server.notify_format] = body - else: - # Store entry directly - conversion_map[server.notify_format] = body + try: + # Send notification + if not server.notify( + body=conversion_map[server.notify_format], + title=title, + 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 - # Toggle our return status flag + except TypeError: + # These our our internally thrown notifications 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 + 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 @@ -412,12 +353,12 @@ class Apprise(object): # Standard protocol(s) should be None or a tuple protocols = getattr(plugin, 'protocol', None) - if compat_is_basestring(protocols): + if isinstance(protocols, six.string_types): protocols = (protocols, ) # Secure protocol(s) should be None or a tuple secure_protocols = getattr(plugin, 'secure_protocol', None) - if compat_is_basestring(secure_protocols): + if isinstance(secure_protocols, six.string_types): secure_protocols = (secure_protocols, ) # Build our response object @@ -439,27 +380,87 @@ class Apprise(object): def pop(self, index): """ - Removes an indexed Notification Service from the stack and - returns it. + Removes an indexed Notification Service from the stack and returns it. + + The thing is we can never pop AppriseConfig() entries, only what was + loaded within them. So pop needs to carefully iterate over our list + and only track actual entries. """ - # Remove our entry - return self.servers.pop(index) + # Tracking variables + prev_offset = -1 + offset = prev_offset + + for idx, s in enumerate(self.servers): + if isinstance(s, (ConfigBase, AppriseConfig)): + servers = s.servers() + if len(servers) > 0: + # Acquire a new maximum offset to work with + offset = prev_offset + len(servers) + + if offset >= index: + # we can pop an element from our config stack + fn = s.pop if isinstance(s, ConfigBase) \ + else s.server_pop + + return fn(index if prev_offset == -1 + else (index - prev_offset - 1)) + + else: + offset = prev_offset + 1 + if offset == index: + return self.servers.pop(idx) + + # Update our old offset + prev_offset = offset + + # If we reach here, then we indexed out of range + raise IndexError('list index out of range') def __getitem__(self, index): """ Returns the indexed server entry of a loaded notification server """ - return self.servers[index] + # Tracking variables + prev_offset = -1 + offset = prev_offset + + for idx, s in enumerate(self.servers): + if isinstance(s, (ConfigBase, AppriseConfig)): + # Get our list of servers associate with our config object + servers = s.servers() + if len(servers) > 0: + # Acquire a new maximum offset to work with + offset = prev_offset + len(servers) + + if offset >= index: + return servers[index if prev_offset == -1 + else (index - prev_offset - 1)] + + else: + offset = prev_offset + 1 + if offset == index: + return self.servers[idx] + + # Update our old offset + prev_offset = offset + + # If we reach here, then we indexed out of range + raise IndexError('list index out of range') def __iter__(self): """ - Returns an iterator to our server list + Returns an iterator to each of our servers loaded. This includes those + found inside configuration. """ - return iter(self.servers) + return chain(*[[s] if not isinstance(s, (ConfigBase, AppriseConfig)) + else iter(s.servers()) for s in self.servers]) def __len__(self): """ - Returns the number of servers loaded + Returns the number of servers loaded; this includes those found within + loaded configuration. This funtion nnever actually counts the + Config entry themselves (if they exist), only what they contain. """ - return len(self.servers) + return sum([1 if not isinstance(s, (ConfigBase, AppriseConfig)) + else len(s.servers()) for s in self.servers]) diff --git a/apprise/AppriseConfig.py b/apprise/AppriseConfig.py new file mode 100644 index 00000000..fc12e20f --- /dev/null +++ b/apprise/AppriseConfig.py @@ -0,0 +1,292 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2019 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import six +import logging + +from . import config +from . import ConfigBase +from . import URLBase +from .AppriseAsset import AppriseAsset + +from .utils import GET_SCHEMA_RE +from .utils import parse_list +from .utils import is_exclusive_match + +logger = logging.getLogger(__name__) + + +class AppriseConfig(object): + """ + Our Apprise Configuration File Manager + + - Supports a list of URLs defined one after another (text format) + - Supports a destinct YAML configuration format + + """ + + def __init__(self, paths=None, asset=None, cache=True, **kwargs): + """ + Loads all of the paths specified (if any). + + The path can either be a single string identifying one explicit + location, otherwise you can pass in a series of locations to scan + via a list. + + If no path is specified then a default list is used. + + If cache is set to True, then after the data is loaded, it's cached + within this object so it isn't retrieved again later. + """ + + # Initialize a server list of URLs + self.configs = list() + + # Prepare our Asset Object + self.asset = \ + asset if isinstance(asset, AppriseAsset) else AppriseAsset() + + if paths is not None: + # Store our path(s) + self.add(paths) + + return + + def add(self, configs, asset=None, tag=None): + """ + Adds one or more config URLs into our list. + + You can override the global asset if you wish by including it with the + config(s) that you add. + + """ + + # Initialize our return status + return_status = True + + if isinstance(asset, AppriseAsset): + # prepare default asset + asset = self.asset + + if isinstance(configs, ConfigBase): + # Go ahead and just add our configuration into our list + self.configs.append(configs) + return True + + elif isinstance(configs, six.string_types): + # Save our path + configs = (configs, ) + + elif not isinstance(configs, (tuple, set, list)): + logging.error( + 'An invalid configuration path (type={}) was ' + 'specified.'.format(type(configs))) + return False + + # Iterate over our + for _config in configs: + + if isinstance(_config, ConfigBase): + # Go ahead and just add our configuration into our list + self.configs.append(_config) + continue + + elif not isinstance(_config, six.string_types): + logging.error( + "An invalid configuration (type={}) was specified.".format( + type(_config))) + return_status = False + continue + + # Instantiate ourselves an object, this function throws or + # returns None if it fails + instance = AppriseConfig.instantiate(_config, asset=asset, tag=tag) + if not isinstance(instance, ConfigBase): + return_status = False + logging.error( + "Failed to load configuration url: {}".format(_config), + ) + continue + + # Add our initialized plugin to our server listings + self.configs.append(instance) + + # Return our status + return return_status + + def servers(self, tag=None, cache=True): + """ + Returns all of our servers dynamically build based on parsed + configuration. + + If a tag is specified, it applies to the configuration sources + themselves and not the notification services inside them. + + This is for filtering the configuration files polled for + results. + + """ + # Build our tag setup + # - top level entries are treated as an 'or' + # - second level (or more) entries are treated as 'and' + # + # examples: + # tag="tagA, tagB" = tagA or tagB + # tag=['tagA', 'tagB'] = tagA or tagB + # tag=[('tagA', 'tagC'), 'tagB'] = (tagA and tagC) or tagB + # tag=[('tagB', 'tagC')] = tagB and tagC + + response = list() + + for entry in self.configs: + + # Apply our tag matching based on our defined logic + if tag is not None and not is_exclusive_match( + logic=tag, data=entry.tags): + continue + + # Build ourselves a list of services dynamically and return the + # as a list + response.extend(entry.servers(cache=cache)) + + return response + + @staticmethod + def instantiate(url, asset=None, tag=None, suppress_exceptions=True): + """ + Returns the instance of a instantiated configuration plugin based on + the provided Server URL. If the url fails to be parsed, then None + is returned. + + """ + # Attempt to acquire the schema at the very least to allow our + # configuration based urls. + schema = GET_SCHEMA_RE.match(url) + if schema is None: + # Plan B is to assume we're dealing with a file + schema = config.ConfigFile.protocol + url = '{}://{}'.format(schema, URLBase.quote(url)) + + else: + # Ensure our schema is always in lower case + schema = schema.group('schema').lower() + + # Some basic validation + if schema not in config.SCHEMA_MAP: + logger.error('Unsupported schema {}.'.format(schema)) + return None + + # Parse our url details of the server object as dictionary containing + # all of the information parsed from our URL + results = config.SCHEMA_MAP[schema].parse_url(url) + + if not results: + # Failed to parse the server URL + logger.error('Unparseable URL {}.'.format(url)) + return None + + # Build a list of tags to associate with the newly added notifications + results['tag'] = set(parse_list(tag)) + + # Prepare our Asset Object + results['asset'] = \ + asset if isinstance(asset, AppriseAsset) else AppriseAsset() + + if suppress_exceptions: + try: + # Attempt to create an instance of our plugin using the parsed + # URL information + cfg_plugin = config.SCHEMA_MAP[results['schema']](**results) + + except Exception: + # the arguments are invalid or can not be used. + logger.error('Could not load URL: %s' % url) + return None + + else: + # Attempt to create an instance of our plugin using the parsed + # URL information but don't wrap it in a try catch + cfg_plugin = config.SCHEMA_MAP[results['schema']](**results) + + return cfg_plugin + + def clear(self): + """ + Empties our configuration list + + """ + self.configs[:] = [] + + def server_pop(self, index): + """ + Removes an indexed Apprise Notification from the servers + """ + + # Tracking variables + prev_offset = -1 + offset = prev_offset + + for entry in self.configs: + servers = entry.servers(cache=True) + if len(servers) > 0: + # Acquire a new maximum offset to work with + offset = prev_offset + len(servers) + + if offset >= index: + # we can pop an notification from our config stack + return entry.pop(index if prev_offset == -1 + else (index - prev_offset - 1)) + + # Update our old offset + prev_offset = offset + + # If we reach here, then we indexed out of range + raise IndexError('list index out of range') + + def pop(self, index): + """ + Removes an indexed Apprise Configuration from the stack and + returns it. + """ + # Remove our entry + return self.configs.pop(index) + + def __getitem__(self, index): + """ + Returns the indexed config entry of a loaded apprise configuration + """ + return self.configs[index] + + def __iter__(self): + """ + Returns an iterator to our config list + """ + return iter(self.configs) + + def __len__(self): + """ + Returns the number of config entries loaded + """ + return len(self.configs) diff --git a/apprise/URLBase.py b/apprise/URLBase.py new file mode 100644 index 00000000..f185d87b --- /dev/null +++ b/apprise/URLBase.py @@ -0,0 +1,427 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2019 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import re +import logging +from time import sleep +from datetime import datetime +from xml.sax.saxutils import escape as sax_escape + +try: + # Python 2.7 + from urllib import unquote as _unquote + from urllib import quote as _quote + from urllib import urlencode as _urlencode + +except ImportError: + # Python 3.x + from urllib.parse import unquote as _unquote + from urllib.parse import quote as _quote + from urllib.parse import urlencode as _urlencode + +from .AppriseAsset import AppriseAsset +from .utils import parse_url +from .utils import parse_bool +from .utils import parse_list + +# Used to break a path list into parts +PATHSPLIT_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') + +# Define the HTML Lookup Table +HTML_LOOKUP = { + 400: 'Bad Request - Unsupported Parameters.', + 401: 'Verification Failed.', + 404: 'Page not found.', + 405: 'Method not allowed.', + 500: 'Internal server error.', + 503: 'Servers are overloaded.', +} + + +class URLBase(object): + """ + This is the base class for all URL Manipulation + """ + + # The default descriptive name associated with the URL + service_name = None + + # The default simple (insecure) protocol + # all inheriting entries must provide their protocol lookup + # protocol:// (in this example they would specify 'protocol') + protocol = None + + # The default secure protocol + # all inheriting entries must provide their protocol lookup + # protocols:// (in this example they would specify 'protocols') + # This value can be the same as the defined protocol. + secure_protocol = None + + # Throttle + request_rate_per_sec = 0 + + # Maintain a set of tags to associate with this specific notification + tags = set() + + # Logging + logger = logging.getLogger(__name__) + + def __init__(self, asset=None, **kwargs): + """ + Initialize some general logging and common server arguments that will + keep things consistent when working with the children that + inherit this class. + + """ + # Prepare our Asset Object + self.asset = \ + asset if isinstance(asset, AppriseAsset) else AppriseAsset() + + # Certificate Verification (for SSL calls); default to being enabled + self.verify_certificate = kwargs.get('verify', True) + + # Secure Mode + self.secure = kwargs.get('secure', False) + + self.host = kwargs.get('host', '') + self.port = kwargs.get('port') + if self.port: + try: + self.port = int(self.port) + + except (TypeError, ValueError): + self.port = None + + self.user = kwargs.get('user') + self.password = kwargs.get('password') + + if 'tag' in kwargs: + # We want to associate some tags with our notification service. + # the code below gets the 'tag' argument if defined, otherwise + # it just falls back to whatever was already defined globally + self.tags = set(parse_list(kwargs.get('tag', self.tags))) + + # 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 + """ + + if last_io is not None: + # Assume specified last_io + self._last_io_datetime = last_io + + # 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 url(self): + """ + Assembles the URL associated with the notification based on the + arguments provied. + + """ + raise NotImplementedError("url() is implimented by the child class.") + + def __contains__(self, tags): + """ + Returns true if the tag specified is associated with this notification. + + tag can also be a tuple, set, and/or list + + """ + if isinstance(tags, (tuple, set, list)): + return bool(set(tags) & self.tags) + + # return any match + return tags in self.tags + + @staticmethod + def escape_html(html, convert_new_lines=False, whitespace=True): + """ + 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 + + # Escape HTML + escaped = sax_escape(html, {"'": "'", "\"": """}) + + if whitespace: + # Tidy up whitespace too + escaped = escaped\ + .replace(u'\t', u' ')\ + .replace(u' ', u' ') + + if convert_new_lines: + return escaped.replace(u'\n', u'<br/>') + + return escaped + + @staticmethod + def unquote(content, encoding='utf-8', errors='replace'): + """ + 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 '' + + try: + # Python v3.x + return _unquote(content, encoding=encoding, errors=errors) + + except TypeError: + # Python v2.7 + return _unquote(content) + + @staticmethod + def quote(content, safe='/', encoding=None, errors=None): + """ 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 '' + + try: + # Python v3.x + return _quote(content, safe=safe, encoding=encoding, errors=errors) + + except TypeError: + # Python v2.7 + return _quote(content, safe=safe) + + @staticmethod + def urlencode(query, doseq=False, safe='', encoding=None, errors=None): + """Convert a mapping object or a sequence of two-element tuples + + 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} + try: + # Python v3.x + return _urlencode( + _query, doseq=doseq, safe=safe, encoding=encoding, + errors=errors) + + except TypeError: + # Python v2.7 + return _urlencode(_query) + + @staticmethod + def split_path(path, unquote=True): + """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( + URLBase.unquote(path).lstrip('/')) + return PATHSPLIT_LIST_DELIM.split(path.lstrip('/')) + + @property + def app_id(self): + return self.asset.app_id + + @property + def app_desc(self): + return self.asset.app_desc + + @property + def app_url(self): + return self.asset.app_url + + @staticmethod + def parse_url(url, verify_host=True): + """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) + + if not results: + # We're done; we failed to parse our url + return results + + # if our URL ends with an 's', then assueme our secure flag is set. + results['secure'] = (results['schema'][-1] == 's') + + # Support SSL Certificate 'verify' keyword. Default to being enabled + results['verify'] = verify_host + + if 'verify' in results['qsd']: + results['verify'] = parse_bool( + results['qsd'].get('verify', True)) + + # Password overrides + if 'pass' in results['qsd']: + results['password'] = results['qsd']['pass'] + + # User overrides + if 'user' in results['qsd']: + results['user'] = results['qsd']['user'] + + return results + + @staticmethod + def http_response_code_lookup(code, response_mask=None): + """Parses the interger response code returned by a remote call from + a web request into it's human readable string version. + + You can over-ride codes or add new ones by providing your own + response_mask that contains a dictionary of integer -> string mapped + variables + """ + if isinstance(response_mask, dict): + # Apply any/all header over-rides defined + HTML_LOOKUP.update(response_mask) + + # Look up our response + try: + response = HTML_LOOKUP[code] + + except KeyError: + response = '' + + return response diff --git a/apprise/__init__.py b/apprise/__init__.py index 674f68e7..f7ee6df0 100644 --- a/apprise/__init__.py +++ b/apprise/__init__.py @@ -27,7 +27,7 @@ __title__ = 'apprise' __version__ = '0.7.3' __author__ = 'Chris Caron' __license__ = 'MIT' -__copywrite__ = 'Copyright 2019 Chris Caron ' +__copywrite__ = 'Copyright (C) 2019 Chris Caron ' __email__ = 'lead2gold@gmail.com' __status__ = 'Production' @@ -39,10 +39,16 @@ from .common import NotifyFormat from .common import NOTIFY_FORMATS from .common import OverflowMode from .common import OVERFLOW_MODES +from .common import ConfigFormat +from .common import CONFIG_FORMATS + +from .URLBase import URLBase from .plugins.NotifyBase import NotifyBase +from .config.ConfigBase import ConfigBase from .Apprise import Apprise from .AppriseAsset import AppriseAsset +from .AppriseConfig import AppriseConfig # Set default logging handler to avoid "No handler found" warnings. import logging @@ -51,9 +57,11 @@ logging.getLogger(__name__).addHandler(NullHandler()) __all__ = [ # Core - 'Apprise', 'AppriseAsset', 'NotifyBase', + 'Apprise', 'AppriseAsset', 'AppriseConfig', 'URLBase', 'NotifyBase', + 'ConfigBase', # Reference 'NotifyType', 'NotifyImageSize', 'NotifyFormat', 'OverflowMode', 'NOTIFY_TYPES', 'NOTIFY_IMAGE_SIZES', 'NOTIFY_FORMATS', 'OVERFLOW_MODES', + 'ConfigFormat', 'CONFIG_FORMATS', ] diff --git a/apprise/cli.py b/apprise/cli.py index 974dd8bb..6604e9bf 100644 --- a/apprise/cli.py +++ b/apprise/cli.py @@ -31,6 +31,12 @@ import sys from . import NotifyType from . import Apprise from . import AppriseAsset +from . import AppriseConfig +from .utils import parse_list +from . import __title__ +from . import __version__ +from . import __license__ +from . import __copywrite__ # Logging logger = logging.getLogger('apprise.plugins.NotifyBase') @@ -39,6 +45,14 @@ logger = logging.getLogger('apprise.plugins.NotifyBase') # can be specified to get the help menu to come up CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) +# Define our default configuration we use if nothing is otherwise specified +DEFAULT_SEARCH_PATHS = ( + 'file://~/.apprise', + 'file://~/.apprise.yml', + 'file://~/.config/apprise', + 'file://~/.config/apprise.yml', +) + def print_help_msg(command): """ @@ -49,19 +63,39 @@ def print_help_msg(command): click.echo(command.get_help(ctx)) +def print_version_msg(): + """ + Prints version message when -V or --version is specified. + + """ + result = list() + result.append('{} v{}'.format(__title__, __version__)) + result.append(__copywrite__) + result.append( + 'This code is licensed under the {} License.'.format(__license__)) + click.echo('\n'.join(result)) + + @click.command(context_settings=CONTEXT_SETTINGS) @click.option('--title', '-t', default=None, type=str, help='Specify the message title.') @click.option('--body', '-b', default=None, type=str, help='Specify the message body.') +@click.option('--config', '-c', default=None, type=str, multiple=True, + help='Specify one or more configuration locations.') @click.option('--notification-type', '-n', default=NotifyType.INFO, type=str, metavar='TYPE', help='Specify the message type (default=info).') @click.option('--theme', '-T', default='default', type=str, help='Specify the default theme.') +@click.option('--tag', '-g', default=None, type=str, multiple=True, + help='Specify one or more tags to reference.') @click.option('-v', '--verbose', count=True) +@click.option('-V', '--version', is_flag=True, + help='Display the apprise version and exit.') @click.argument('urls', nargs=-1, metavar='SERVER_URL [SERVER_URL2 [SERVER_URL3]]',) -def main(title, body, urls, notification_type, theme, verbose): +def main(title, body, config, urls, notification_type, theme, tag, verbose, + version): """ Send a notification to all of the specified servers identified by their URLs the content provided within the title, body and notification-type. @@ -82,30 +116,47 @@ def main(title, body, urls, notification_type, theme, verbose): else: logger.setLevel(logging.ERROR) + if version: + print_version_msg() + sys.exit(0) + formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') ch.setFormatter(formatter) logger.addHandler(ch) - if not urls: - logger.error('You must specify at least one server URL.') - print_help_msg(main) - sys.exit(1) - # Prepare our asset asset = AppriseAsset(theme=theme) # Create our object a = Apprise(asset=asset) + # Load our configuration if no URLs or specified configuration was + # identified on the command line + a.add(AppriseConfig( + paths=DEFAULT_SEARCH_PATHS + if not (config or urls) else config), asset=asset) + # Load our inventory up for url in urls: a.add(url) + if len(a) == 0: + logger.error( + 'You must specify at least one server URL or populated ' + 'configuration file.') + print_help_msg(main) + sys.exit(1) + if body is None: # if no body was specified, then read from STDIN body = click.get_text_stream('stdin').read() + # each --tag entry comprises of a comma separated 'and' list + # we or each of of the --tag and sets specified. + tags = None if not tag else [parse_list(t) for t in tag] + # now print it out - if a.notify(title=title, body=body, notify_type=notification_type): + if a.notify( + body=body, title=title, notify_type=notification_type, tag=tags): sys.exit(0) sys.exit(1) diff --git a/apprise/common.py b/apprise/common.py index 75fcd481..8005cc19 100644 --- a/apprise/common.py +++ b/apprise/common.py @@ -105,3 +105,26 @@ OVERFLOW_MODES = ( OverflowMode.TRUNCATE, OverflowMode.SPLIT, ) + + +class ConfigFormat(object): + """ + A list of pre-defined config formats that can be passed via the + apprise library. + """ + + # A text based configuration. This consists of a list of URLs delimited by + # a new line. pound/hashtag (#) or semi-colon (;) can be used as comment + # characters. + TEXT = 'text' + + # YAML files allow a more rich of an experience when settig up your + # apprise configuration files. + YAML = 'yaml' + + +# Define our configuration formats mostly used for verification +CONFIG_FORMATS = ( + ConfigFormat.TEXT, + ConfigFormat.YAML, +) diff --git a/apprise/config/ConfigBase.py b/apprise/config/ConfigBase.py new file mode 100644 index 00000000..095f220c --- /dev/null +++ b/apprise/config/ConfigBase.py @@ -0,0 +1,337 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2019 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import re +import six +import logging + +from .. import plugins +from ..AppriseAsset import AppriseAsset +from ..URLBase import URLBase +from ..common import ConfigFormat +from ..common import CONFIG_FORMATS +from ..utils import GET_SCHEMA_RE +from ..utils import parse_list + +logger = logging.getLogger(__name__) + + +class ConfigBase(URLBase): + """ + This is the base class for all supported configuration sources + """ + + # The Default Encoding to use if not otherwise detected + encoding = 'utf-8' + + # The default expected configuration format unless otherwise + # detected by the sub-modules + default_config_format = ConfigFormat.TEXT + + # This is only set if the user overrides the config format on the URL + # this should always initialize itself as None + config_format = None + + # Don't read any more of this amount of data into memory as there is no + # reason we should be reading in more. This is more of a safe guard then + # anything else. 128KB (131072B) + max_buffer_size = 131072 + + # Logging + logger = logging.getLogger(__name__) + + def __init__(self, **kwargs): + """ + Initialize some general logging and common server arguments that will + keep things consistent when working with the configurations that + inherit this class. + + """ + + super(ConfigBase, self).__init__(**kwargs) + + # Tracks previously loaded content for speed + self._cached_servers = None + + if 'encoding' in kwargs: + # Store the encoding + self.encoding = kwargs.get('encoding') + + if 'format' in kwargs: + # Store the enforced config format + self.config_format = kwargs.get('format').lower() + + if self.config_format not in CONFIG_FORMATS: + # Simple error checking + err = 'An invalid config format ({}) was specified.'.format( + self.config_format) + self.logger.warning(err) + raise TypeError(err) + + return + + def servers(self, asset=None, cache=True, **kwargs): + """ + Performs reads loaded configuration and returns all of the services + that could be parsed and loaded. + + """ + + if cache is True and isinstance(self._cached_servers, list): + # We already have cached results to return; use them + return self._cached_servers + + # Our response object + self._cached_servers = list() + + # read() causes the child class to do whatever it takes for the + # config plugin to load the data source and return unparsed content + # None is returned if there was an error or simply no data + content = self.read(**kwargs) + if not isinstance(content, six.string_types): + # Nothing more to do + return list() + + # Our Configuration format uses a default if one wasn't one detected + # or enfored. + config_format = \ + self.default_config_format \ + if self.config_format is None else self.config_format + + # Dynamically load our parse_ function based on our config format + fn = getattr(ConfigBase, 'config_parse_{}'.format(config_format)) + + # Execute our config parse function which always returns a list + self._cached_servers.extend(fn(content=content, asset=asset)) + + return self._cached_servers + + def read(self): + """ + This object should be implimented by the child classes + + """ + return None + + @staticmethod + def parse_url(url, verify_host=True): + """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 = URLBase.parse_url(url, verify_host=verify_host) + + if not results: + # We're done; we failed to parse our url + return results + + # Allow overriding the default config format + if 'format' in results['qsd']: + results['format'] = results['qsd'].get('format') + if results['format'] not in CONFIG_FORMATS: + URLBase.logger.warning( + 'Unsupported format specified {}'.format( + results['format'])) + del results['format'] + + # Defines the encoding of the payload + if 'encoding' in results['qsd']: + results['encoding'] = results['qsd'].get('encoding') + + return results + + @staticmethod + def config_parse_text(content, asset=None): + """ + Parse the specified content as though it were a simple text file only + containing a list of URLs. Return a list of loaded notification plugins + + Optionally associate an asset with the notification. + + """ + # For logging, track the line number + line = 0 + + response = list() + + # Define what a valid line should look like + valid_line_re = re.compile( + r'^\s*(?P([;#]+(?P.*))|' + r'(\s*(?P[^=]+)=|=)?\s*' + r'(?P[a-z0-9]{2,9}://.*))?$', re.I) + + # split our content up to read line by line + content = re.split(r'\r*\n', content) + + for entry in content: + # Increment our line count + line += 1 + + result = valid_line_re.match(entry) + if not result: + # Invalid syntax + logger.error( + 'Invalid apprise text format found ' + '{} on line {}.'.format(entry, line)) + + # Assume this is a file we shouldn't be parsing. It's owner + # can read the error printed to screen and take action + # otherwise. + return list() + + if result.group('comment') or not result.group('line'): + # Comment/empty line; do nothing + continue + + # Store our url read in + url = result.group('url') + + # swap hash (#) tag values with their html version + _url = url.replace('/#', '/%23') + + # Attempt to acquire the schema at the very least to allow our + # plugins to determine if they can make a better + # interpretation of a URL geared for them + schema = GET_SCHEMA_RE.match(_url) + + # Ensure our schema is always in lower case + schema = schema.group('schema').lower() + + # Some basic validation + if schema not in plugins.SCHEMA_MAP: + logger.warning( + 'Unsupported schema {} on line {}.'.format( + schema, line)) + continue + + # Parse our url details of the server object as dictionary + # containing all of the information parsed from our URL + results = plugins.SCHEMA_MAP[schema].parse_url(_url) + + if not results: + # Failed to parse the server URL + logger.warning( + 'Unparseable URL {} on line {}.'.format(url, line)) + continue + + # Build a list of tags to associate with the newly added + # notifications if any were set + results['tag'] = set(parse_list(result.group('tags'))) + + # Prepare our Asset Object + results['asset'] = \ + asset if isinstance(asset, AppriseAsset) else AppriseAsset() + + try: + # Attempt to create an instance of our plugin using the + # parsed URL information + plugin = plugins.SCHEMA_MAP[results['schema']](**results) + + except Exception: + # the arguments are invalid or can not be used. + logger.warning( + 'Could not load URL {} on line {}.'.format( + url, line)) + continue + + # if we reach here, we successfully loaded our data + response.append(plugin) + + # Return what was loaded + return response + + @staticmethod + def config_parse_yaml(content, asset=None): + """ + Parse the specified content as though it were a yaml file + specifically formatted for apprise. Return a list of loaded + notification plugins. + + Optionally associate an asset with the notification. + + """ + response = list() + + # TODO + + return response + + def pop(self, index): + """ + Removes an indexed Notification Service from the stack and + returns it. + """ + + if not isinstance(self._cached_servers, list): + # Generate ourselves a list of content we can pull from + self.servers(cache=True) + + # Pop the element off of the stack + return self._cached_servers.pop(index) + + def __getitem__(self, index): + """ + Returns the indexed server entry associated with the loaded + notification servers + """ + if not isinstance(self._cached_servers, list): + # Generate ourselves a list of content we can pull from + self.servers(cache=True) + + return self._cached_servers[index] + + def __iter__(self): + """ + Returns an iterator to our server list + """ + if not isinstance(self._cached_servers, list): + # Generate ourselves a list of content we can pull from + self.servers(cache=True) + + return iter(self._cached_servers) + + def __len__(self): + """ + Returns the total number of servers loaded + """ + if not isinstance(self._cached_servers, list): + # Generate ourselves a list of content we can pull from + self.servers(cache=True) + + return len(self._cached_servers) diff --git a/apprise/config/ConfigFile.py b/apprise/config/ConfigFile.py new file mode 100644 index 00000000..5b086c7f --- /dev/null +++ b/apprise/config/ConfigFile.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2019 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import re +import io +import os +from .ConfigBase import ConfigBase +from ..common import ConfigFormat + + +class ConfigFile(ConfigBase): + """ + A wrapper for File based configuration sources + """ + + # The default descriptive name associated with the Notification + service_name = 'Local File' + + # The default protocol + protocol = 'file' + + def __init__(self, path, **kwargs): + """ + Initialize File 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(ConfigFile, self).__init__(**kwargs) + + # Store our file path as it was set + self.path = path + + return + + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + args = { + 'encoding': self.encoding, + } + + if self.config_format: + # A format was enforced; make sure it's passed back with the url + args['format'] = self.config_format + + return 'file://{path}?{args}'.format( + path=self.quote(self.path), + args=self.urlencode(args), + ) + + def read(self, **kwargs): + """ + Perform retrieval of the configuration based on the specified request + """ + + response = None + + path = os.path.expanduser(self.path) + try: + if self.max_buffer_size > 0 and \ + os.path.getsize(path) > self.max_buffer_size: + + # Content exceeds maximum buffer size + self.logger.error( + 'File size exceeds maximum allowable buffer length' + ' ({}KB).'.format(int(self.max_buffer_size / 1024))) + return None + + except OSError: + # getsize() can throw this acception if the file is missing + # and or simply isn't accessible + self.logger.debug( + 'File is not accessible: {}'.format(path)) + return None + + # Always call throttle before any server i/o is made + self.throttle() + + try: + # Python 3 just supports open(), however to remain compatible with + # Python 2, we use the io module + with io.open(path, "rt", encoding=self.encoding) as f: + # Store our content for parsing + response = f.read() + + except (ValueError, UnicodeDecodeError): + # A result of our strict encoding check; if we receive this + # then the file we're opening is not something we can + # understand the encoding of.. + + self.logger.error( + 'File not using expected encoding ({}) : {}'.format( + self.encoding, path)) + return None + + except (IOError, OSError): + # IOError is present for backwards compatibility with Python + # versions older then 3.3. >= 3.3 throw OSError now. + + # Could not open and/or read the file; this is not a problem since + # we scan a lot of default paths. + self.logger.debug( + 'File can not be opened for read: {}'.format(path)) + return None + + # Detect config format based on file extension if it isn't already + # enforced + if self.config_format is None and \ + re.match(r'^.*\.ya?ml\s*$', path, re.I) is not None: + + # YAML Filename Detected + self.default_config_format = ConfigFormat.YAML + + self.logger.debug('Read Config File: %s' % (path)) + + # Return our response object + return response + + @staticmethod + def parse_url(url): + """ + Parses the URL so that we can handle all different file paths + and return it as our path object + + """ + + results = ConfigBase.parse_url(url) + if not results: + # We're done early; it's not a good URL + return results + + match = re.match(r'file://(?P[^?]+)(\?.*)?', url, re.I) + if not match: + return None + + results['path'] = match.group('path') + return results diff --git a/apprise/config/ConfigHTTP.py b/apprise/config/ConfigHTTP.py new file mode 100644 index 00000000..0ccca37e --- /dev/null +++ b/apprise/config/ConfigHTTP.py @@ -0,0 +1,269 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2019 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import re +import six +import requests +from .ConfigBase import ConfigBase +from ..common import ConfigFormat + +# Support YAML formats +# text/yaml +# text/x-yaml +# application/yaml +# application/x-yaml +MIME_IS_YAML = re.compile('(text|application)/(x-)?yaml', re.I) + + +class ConfigHTTP(ConfigBase): + """ + A wrapper for HTTP based configuration sources + """ + + # The default descriptive name associated with the Notification + service_name = 'HTTP' + + # The default protocol + protocol = 'http' + + # The default secure protocol + secure_protocol = 'https' + + # The maximum number of seconds to wait for a connection to be established + # before out-right just giving up + connection_timeout_sec = 5.0 + + # If an HTTP error occurs, define the number of characters you still want + # to read back. This is useful for debugging purposes, but nothing else. + # The idea behind enforcing this kind of restriction is to prevent abuse + # from queries to services that may be untrusted. + max_error_buffer_size = 2048 + + def __init__(self, headers=None, **kwargs): + """ + Initialize HTTP 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(ConfigHTTP, self).__init__(**kwargs) + + if self.secure: + self.schema = 'https' + + else: + self.schema = 'http' + + self.fullpath = kwargs.get('fullpath') + if not isinstance(self.fullpath, six.string_types): + self.fullpath = '/' + + self.headers = {} + if headers: + # Store our extra headers + self.headers.update(headers) + + return + + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + args = { + 'encoding': self.encoding, + } + + if self.config_format: + # A format was enforced; make sure it's passed back with the url + args['format'] = self.config_format + + # 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: + 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 read(self, **kwargs): + """ + Perform retrieval of the configuration based on the specified request + """ + + # prepare XML Object + headers = { + 'User-Agent': self.app_id, + } + + # Apply any/all header over-rides defined + headers.update(self.headers) + + auth = None + if self.user: + auth = (self.user, self.password) + + url = '%s://%s' % (self.schema, self.host) + if isinstance(self.port, int): + url += ':%d' % self.port + + url += self.fullpath + + self.logger.debug('HTTP POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + + # Prepare our response object + response = None + + # Where our request object will temporarily live. + r = None + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + # Make our request + r = requests.post( + url, + headers=headers, + auth=auth, + verify=self.verify_certificate, + timeout=self.connection_timeout_sec, + stream=True, + ) + + if r.status_code != requests.codes.ok: + status_str = \ + ConfigBase.http_response_code_lookup(r.status_code) + self.logger.error( + 'Failed to get HTTP configuration: ' + '{}{} error={}.'.format( + status_str, + ',' if status_str else '', + r.status_code)) + + # Display payload for debug information only; Don't read any + # more than the first X bytes since we're potentially accessing + # content from untrusted servers. + if self.max_error_buffer_size > 0: + self.logger.debug( + 'Response Details:\r\n{}'.format( + r.content[0:self.max_error_buffer_size])) + + # Close out our connection if it exists to eliminate any + # potential inefficiencies with the Request connection pool as + # documented on their site when using the stream=True option. + r.close() + + # Return None (signifying a failure) + return None + + # Store our response + if self.max_buffer_size > 0 and \ + r.headers['Content-Length'] > self.max_buffer_size: + + # Provide warning of data truncation + self.logger.error( + 'HTTP config response exceeds maximum buffer length ' + '({}KB);'.format(int(self.max_buffer_size / 1024))) + + # Close out our connection if it exists to eliminate any + # potential inefficiencies with the Request connection pool as + # documented on their site when using the stream=True option. + r.close() + + # Return None - buffer execeeded + return None + + else: + # Store our result + response = r.content + + # Detect config format based on mime if the format isn't + # already enforced + if self.config_format is None \ + and MIME_IS_YAML.match(r.headers.get( + 'Content-Type', 'text/plain')) is not None: + + # YAML data detected based on header content + self.default_config_format = ConfigFormat.YAML + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured retrieving HTTP ' + 'configuration from %s.' % self.host) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return None (signifying a failure) + return None + + # Close out our connection if it exists to eliminate any potential + # inefficiencies with the Request connection pool as documented on + # their site when using the stream=True option. + r.close() + + # Return our response object + return response + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to substantiate this object. + + """ + results = ConfigBase.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/config/__init__.py b/apprise/config/__init__.py new file mode 100644 index 00000000..f89e5c5d --- /dev/null +++ b/apprise/config/__init__.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2019 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import sys +import six + +from .ConfigHTTP import ConfigHTTP +from .ConfigFile import ConfigFile + +# Maintains a mapping of all of the configuration services +SCHEMA_MAP = {} + + +__all__ = [ + # Configuration Services + 'ConfigFile', 'ConfigHTTP', +] + + +# Load our Lookup Matrix +def __load_matrix(): + """ + Dynamically load our schema map; this allows us to gracefully + skip over modules we simply don't have the dependencies for. + + """ + + thismodule = sys.modules[__name__] + + # to add it's mapping to our hash table + for entry in dir(thismodule): + + # Get our plugin + plugin = getattr(thismodule, entry) + if not hasattr(plugin, 'app_id'): # pragma: no branch + # Filter out non-notification modules + continue + + # Load protocol(s) if defined + proto = getattr(plugin, 'protocol', None) + if isinstance(proto, six.string_types): + if proto not in SCHEMA_MAP: + SCHEMA_MAP[proto] = plugin + + elif isinstance(proto, (set, list, tuple)): + # Support iterables list types + for p in proto: + if p not in SCHEMA_MAP: + SCHEMA_MAP[p] = plugin + + # Load secure protocol(s) if defined + protos = getattr(plugin, 'secure_protocol', None) + if isinstance(protos, six.string_types): + if protos not in SCHEMA_MAP: + SCHEMA_MAP[protos] = plugin + + if isinstance(protos, (set, list, tuple)): + # Support iterables list types + for p in protos: + if p not in SCHEMA_MAP: + SCHEMA_MAP[p] = plugin + + +# Dynamically build our module +__load_matrix() diff --git a/apprise/plugins/NotifyBase.py b/apprise/plugins/NotifyBase.py index 857e577e..09d9875a 100644 --- a/apprise/plugins/NotifyBase.py +++ b/apprise/plugins/NotifyBase.py @@ -24,26 +24,8 @@ # THE SOFTWARE. import re -import logging -from time import sleep -from datetime import datetime -try: - # Python 2.7 - from urllib import unquote as _unquote - from urllib import quote as _quote - from urllib import urlencode as _urlencode - -except ImportError: - # Python 3.x - from urllib.parse import unquote as _unquote - from urllib.parse import quote as _quote - from urllib.parse import urlencode as _urlencode - -from ..utils import parse_url -from ..utils import parse_bool -from ..utils import parse_list -from ..utils import is_hostname +from ..URLBase import URLBase from ..common import NotifyType from ..common import NOTIFY_TYPES from ..common import NotifyFormat @@ -51,67 +33,23 @@ from ..common import NOTIFY_FORMATS from ..common import OverflowMode from ..common import OVERFLOW_MODES -from ..AppriseAsset import AppriseAsset - -# use sax first because it's faster -from xml.sax.saxutils import escape as sax_escape - - -HTTP_ERROR_MAP = { - 400: 'Bad Request - Unsupported Parameters.', - 401: 'Verification Failed.', - 404: 'Page not found.', - 405: 'Method not allowed.', - 500: 'Internal server error.', - 503: 'Servers are overloaded.', -} - # HTML New Line Delimiter NOTIFY_NEWLINE = '\r\n' -# Used to break a path list into parts -PATHSPLIT_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') -# Regular expression retrieved from: -# http://www.regular-expressions.info/email.html -IS_EMAIL_RE = re.compile( - r"((?P