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