diff --git a/README.md b/README.md
index 348cc0aa..c8b7996d 100644
--- a/README.md
+++ b/README.md
@@ -48,7 +48,7 @@ The table below identifies the services this tool supports and some example serv
| [PushBullet](https://github.com/caronc/apprise/wiki/Notify_pushbullet) | pbul:// | (TCP) 443 | pbul://accesstoken pbul://accesstoken/#channel pbul://accesstoken/A_DEVICE_ID pbul://accesstoken/email@address.com pbul://accesstoken/#channel/#channel2/email@address.net/DEVICE
| [Pushjet](https://github.com/caronc/apprise/wiki/Notify_pushjet) | pjet:// or pjets:// | (TCP) 80 or 443 | pjet://secret@hostname pjet://secret@hostname:port pjets://secret@hostname pjets://secret@hostname:port
| [Pushed](https://github.com/caronc/apprise/wiki/Notify_pushed) | pushed:// | (TCP) 443 | pushed://appkey/appsecret/ pushed://appkey/appsecret/#ChannelAlias pushed://appkey/appsecret/#ChannelAlias1/#ChannelAlias2/#ChannelAliasN pushed://appkey/appsecret/@UserPushedID pushed://appkey/appsecret/@UserPushedID1/@UserPushedID2/@UserPushedIDN
-| [Pushover](https://github.com/caronc/apprise/wiki/Notify_pushover) | pover:// | (TCP) 443 | pover://user@token pover://user@token/DEVICE pover://user@token/DEVICE1/DEVICE2/DEVICEN _Note: you must specify both your user_id and token_
+| [Pushover](https://github.com/caronc/apprise/wiki/Notify_pushover) | pover:// | (TCP) 443 | pover://user@token pover://user@token/DEVICE pover://user@token/DEVICE1/DEVICE2/DEVICEN **Note**: you must specify both your user_id and token
| [Rocket.Chat](https://github.com/caronc/apprise/wiki/Notify_rocketchat) | rocket:// or rockets:// | (TCP) 80 or 443 | rocket://user:password@hostname/RoomID/Channel rockets://user:password@hostname:443/Channel1/Channel1/RoomID rocket://user:password@hostname/Channel
| [Ryver](https://github.com/caronc/apprise/wiki/Notify_ryver) | ryver:// | (TCP) 443 | ryver://Organization/Token ryver://botname@Organization/Token
| [Slack](https://github.com/caronc/apprise/wiki/Notify_slack) | slack:// | (TCP) 443 | slack://TokenA/TokenB/TokenC/Channel slack://botname@TokenA/TokenB/TokenC/Channel slack://user@TokenA/TokenB/TokenC/Channel1/Channel2/ChannelN
@@ -92,12 +92,36 @@ cat /proc/cpuinfo | apprise -t 'cpu info' \
'mailto://myemail:mypass@gmail.com'
```
+### Configuration Files
+No one wants to put there credentials out for everyone to see on the command line. No problem *apprise* also supports configuration files. It can handle both a specific [YAML format](https://github.com/caronc/apprise/wiki/config_yaml) or a very simple [TEXT format](https://github.com/caronc/apprise/wiki/config_text). You can also pull these configuration files via an HTTP query too! More information concerning Apprise configuration can be found [here](https://github.com/caronc/apprise/wiki/config)
+```bash
+# By default now if no url or configuration is specified aprise will
+# peek for this data in:
+# ~/.apprise
+# ~/.apprise.yml
+# ~/.config/apprise
+# ~/.config/apprise.yml
+
+# If you loaded one of those files, your command line gets really easy:
+apprise -t 'my title' -b 'my notification body'
+
+# Know the location of the configuration source? No problem, just
+# specify it.
+apprise -t 'my title' -b 'my notification body' \
+ --config=/path/to/my/config.yml
+
+# Got lots of configuration locations? No problem, specify them all:
+apprise -t 'my title' -b 'my notification body' \
+ --config=/path/to/my/config.yml \
+ --config=https://localhost/my/apprise/config
+```
+
## Developers
To send a notification from within your python application, just do the following:
```python
import apprise
-# create an Apprise instance
+# Create an Apprise instance
apobj = apprise.Apprise()
# Add all of the notification services by their server url.
@@ -115,4 +139,35 @@ apobj.notify(
)
```
+### Configuration Files
+Developers need access to configuration files too. The good news is they're use just involves declaring another object that Apprise can ingest as easy as the notification urls. You can mix and match config and notification entries too!
+```python
+import apprise
+
+# Create an Apprise instance
+apobj = apprise.Apprise()
+
+# Create an Config instance
+config = apprise.AppriseCofig()
+
+# Add a configuration source:
+config.add('/path/to/my/config.yml')
+
+# Add another...
+config.add('https://myserver:8080/path/to/config')
+
+# Make sure to add our config into our apprise object
+apobj.add(config)
+
+# You can mix and match; add an entry directly if you want too
+apobj.add('mailto://myemail:mypass@gmail.com')
+
+# Then notify these services any time you desire. The below would
+# notify all of the services loaded into our Apprise object.
+apobj.notify(
+ body='what a great notification service!',
+ title='my notification title',
+)
+```
+
If you're interested in reading more about this and methods on how to customize your own notifications, please check out the wiki at https://github.com/caronc/apprise/wiki/Development_API
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..62ab147a
--- /dev/null
+++ b/apprise/config/ConfigBase.py
@@ -0,0 +1,584 @@
+# -*- 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 os
+import re
+import six
+import logging
+import yaml
+
+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.
+
+ The file syntax is:
+
+ #
+ # pound/hashtag allow for line comments
+ #
+ # One or more tags can be idenified using comma's (,) to separate
+ # them.
+ =
+
+ # Or you can use this format (no tags associated)
+
+
+ """
+ # 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)
+
+ try:
+ # split our content up to read line by line
+ content = re.split(r'\r*\n', content)
+
+ except TypeError:
+ # content was not expected string type
+ logger.error('Invalid apprise text data specified')
+ return list()
+
+ 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 results is None:
+ # 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()
+
+ try:
+ # Load our data
+ result = yaml.load(content)
+
+ except (AttributeError, yaml.error.MarkedYAMLError) as e:
+ # Invalid content
+ logger.error('Invalid apprise yaml data specified.')
+ logger.debug('YAML Exception:{}{}'.format(os.linesep, e))
+ return list()
+
+ if not isinstance(result, dict):
+ # Invalid content
+ logger.error('Invalid apprise yaml structure specified')
+ return list()
+
+ # YAML Version
+ version = result.get('version', 1)
+ if version != 1:
+ # Invalid syntax
+ logger.error(
+ 'Invalid apprise yaml version specified {}.'.format(version))
+ return list()
+
+ #
+ # global asset object
+ #
+ asset = asset if isinstance(asset, AppriseAsset) else AppriseAsset()
+ tokens = result.get('asset', None)
+ if tokens and isinstance(tokens, dict):
+ for k, v in tokens.items():
+
+ if k.startswith('_') or k.endswith('_'):
+ # Entries are considered reserved if they start or end
+ # with an underscore
+ logger.warning('Ignored asset key "{}".'.format(k))
+ continue
+
+ if not (hasattr(asset, k) and
+ isinstance(getattr(asset, k), six.string_types)):
+ # We can't set a function or non-string set value
+ logger.warning('Invalid asset key "{}".'.format(k))
+ continue
+
+ if v is None:
+ # Convert to an empty string
+ v = ''
+
+ if not isinstance(v, six.string_types):
+ # we must set strings with a string
+ logger.warning('Invalid asset value to "{}".'.format(k))
+ continue
+
+ # Set our asset object with the new value
+ setattr(asset, k, v.strip())
+
+ #
+ # global tag root directive
+ #
+ global_tags = set()
+
+ tags = result.get('tag', None)
+ if tags and isinstance(tags, (list, tuple, six.string_types)):
+ # Store any preset tags
+ global_tags = set(parse_list(tags))
+
+ #
+ # urls root directive
+ #
+ urls = result.get('urls', None)
+ if not isinstance(urls, (list, tuple)):
+ # Unsupported
+ logger.error('Missing "urls" directive in apprise yaml.')
+ return list()
+
+ # Iterate over each URL
+ for no, url in enumerate(urls):
+
+ # Our results object is what we use to instantiate our object if
+ # we can. Reset it to None on each iteration
+ results = list()
+
+ if isinstance(url, six.string_types):
+ # We're just a simple URL string
+
+ # 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)
+ if schema is None:
+ logger.warning(
+ 'Unsupported schema in urls entry #{}'.format(no))
+ continue
+
+ # 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 {} in urls entry #{}'.format(
+ schema, no))
+ 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 _results is None:
+ logger.warning(
+ 'Unparseable {} based url; entry #{}'.format(
+ schema, no))
+ continue
+
+ # add our results to our global set
+ results.append(_results)
+
+ elif isinstance(url, dict):
+ # We are a url string with additional unescaped options
+ if six.PY2:
+ _url, tokens = next(url.iteritems())
+ else: # six.PY3
+ _url, tokens = next(iter(url.items()))
+
+ # swap hash (#) tag values with their html version
+ _url = _url.replace('/#', '/%23')
+
+ # Get our schema
+ schema = GET_SCHEMA_RE.match(_url)
+ if schema is None:
+ logger.warning(
+ 'Unsupported schema in urls entry #{}'.format(no))
+ continue
+
+ # 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 {} in urls entry #{}'.format(
+ schema, no))
+ 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 _results is None:
+ # Setup dictionary
+ _results = {
+ # Minimum requirements
+ 'schema': schema,
+ }
+
+ if tokens is not None:
+ # populate and/or override any results populated by
+ # parse_url()
+ for entries in tokens:
+ # Copy ourselves a template of our parsed URL as a base
+ # to work with
+ r = _results.copy()
+
+ # We are a url string with additional unescaped options
+ if isinstance(entries, dict):
+ if six.PY2:
+ _url, tokens = next(url.iteritems())
+ else: # six.PY3
+ _url, tokens = next(iter(url.items()))
+
+ # Tags you just can't over-ride
+ if 'schema' in entries:
+ del entries['schema']
+
+ # Extend our dictionary with our new entries
+ r.update(entries)
+
+ # add our results to our global set
+ results.append(r)
+
+ else:
+ # add our results to our global set
+ results.append(_results)
+
+ else:
+ # Unsupported
+ logger.warning(
+ 'Unsupported apprise yaml entry #{}'.format(no))
+ continue
+
+ # Track our entries
+ entry = 0
+
+ while len(results):
+ # Increment our entry count
+ entry += 1
+
+ # Grab our first item
+ _results = results.pop(0)
+
+ # tag is a special keyword that is managed by apprise object.
+ # The below ensures our tags are set correctly
+ if 'tag' in _results:
+ # Tidy our list up
+ _results['tag'] = \
+ set(parse_list(_results['tag'])) | global_tags
+
+ else:
+ # Just use the global settings
+ _results['tag'] = global_tags
+
+ # Prepare our Asset Object
+ _results['asset'] = asset
+
+ 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 apprise yaml entry #{}, item #{}'
+ .format(no, entry))
+ continue
+
+ # if we reach here, we successfully loaded our data
+ response.append(plugin)
+
+ 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..543d0aa2
--- /dev/null
+++ b/apprise/config/ConfigHTTP.py
@@ -0,0 +1,283 @@
+# -*- 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)
+
+# Support TEXT formats
+# text/plain
+# text/html
+MIME_IS_TEXT = re.compile('text/(plain|html)', 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
+ content_type = r.headers.get(
+ 'Content-Type', 'application/octet-stream')
+ if self.config_format is None and content_type:
+ if MIME_IS_YAML.match(content_type) is not None:
+
+ # YAML data detected based on header content
+ self.default_config_format = ConfigFormat.YAML
+
+ elif MIME_IS_TEXT.match(content_type) is not None:
+
+ # TEXT data detected based on header content
+ self.default_config_format = ConfigFormat.TEXT
+
+ # else do nothing; fall back to whatever default is
+ # already set.
+
+ 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