Config file support added for http & file (text); refs #55

This commit is contained in:
Chris Caron 2019-02-25 01:02:29 -05:00
parent d329f8cdcd
commit 0ab86c2115
51 changed files with 3798 additions and 1165 deletions

View File

@ -24,71 +24,27 @@
# THE SOFTWARE. # THE SOFTWARE.
import re import re
import six
import logging import logging
from markdown import markdown from markdown import markdown
from itertools import chain
from .common import NotifyType from .common import NotifyType
from .common import NotifyFormat from .common import NotifyFormat
from .utils import is_exclusive_match
from .utils import parse_list from .utils import parse_list
from .utils import compat_is_basestring
from .utils import GET_SCHEMA_RE from .utils import GET_SCHEMA_RE
from .AppriseAsset import AppriseAsset 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 plugins
from . import __version__ from . import __version__
logger = logging.getLogger(__name__) 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): class Apprise(object):
""" """
@ -112,10 +68,8 @@ class Apprise(object):
# directory images can be found in. It can also identify remote # directory images can be found in. It can also identify remote
# URL paths that contain the images you want to present to the end # 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. # user. If no asset is specified, then the default one is used.
self.asset = asset self.asset = \
if asset is None: asset if isinstance(asset, AppriseAsset) else AppriseAsset()
# Load our default configuration
self.asset = AppriseAsset()
if servers: if servers:
self.add(servers) self.add(servers)
@ -128,48 +82,45 @@ class Apprise(object):
""" """
# swap hash (#) tag values with their html version # swap hash (#) tag values with their html version
# This is useful for accepting channels (as arguments to pushbullet)
_url = url.replace('/#', '/%23') _url = url.replace('/#', '/%23')
# Attempt to acquire the schema at the very least to allow our plugins # 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 # 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) schema = GET_SCHEMA_RE.match(_url)
if schema is None: if schema is None:
logger.error('%s is an unparseable server url.' % url) logger.error('Unparseable schema:// found in URL {}.'.format(url))
return None return None
# Update the schema # Ensure our schema is always in lower case
schema = schema.group('schema').lower() schema = schema.group('schema').lower()
# Some basic validation # Some basic validation
if schema not in SCHEMA_MAP: if schema not in plugins.SCHEMA_MAP:
logger.error( logger.error('Unsupported schema {}.'.format(schema))
'{0} is not a supported server type (url={1}).'.format(
schema,
_url,
)
)
return None return None
# Parse our url details # Parse our url details of the server object as dictionary containing
# the server object is a dictionary containing all of the information # all of the information parsed from our URL
# parsed from our URL results = plugins.SCHEMA_MAP[schema].parse_url(_url)
results = SCHEMA_MAP[schema].parse_url(_url)
if not results: if results is None:
# Failed to parse the server URL # Failed to parse the server URL
logger.error('Could not parse URL: %s' % url) logger.error('Unparseable URL {}.'.format(url))
return None return None
# Build a list of tags to associate with the newly added notifications # Build a list of tags to associate with the newly added notifications
results['tag'] = set(parse_list(tag)) results['tag'] = set(parse_list(tag))
# Prepare our Asset Object
results['asset'] = \
asset if isinstance(asset, AppriseAsset) else AppriseAsset()
if suppress_exceptions: if suppress_exceptions:
try: try:
# Attempt to create an instance of our plugin using the parsed # Attempt to create an instance of our plugin using the parsed
# URL information # URL information
plugin = SCHEMA_MAP[results['schema']](**results) plugin = plugins.SCHEMA_MAP[results['schema']](**results)
except Exception: except Exception:
# the arguments are invalid or can not be used. # the arguments are invalid or can not be used.
@ -179,11 +130,7 @@ class Apprise(object):
else: else:
# Attempt to create an instance of our plugin using the parsed # Attempt to create an instance of our plugin using the parsed
# URL information but don't wrap it in a try catch # URL information but don't wrap it in a try catch
plugin = SCHEMA_MAP[results['schema']](**results) plugin = plugins.SCHEMA_MAP[results['schema']](**results)
# Save our asset
if asset:
plugin.asset = asset
return plugin return plugin
@ -202,23 +149,43 @@ class Apprise(object):
# Initialize our return status # Initialize our return status
return_status = True return_status = True
if asset is None: if isinstance(asset, AppriseAsset):
# prepare default asset # prepare default asset
asset = self.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 # Go ahead and just add our plugin into our list
self.servers.append(servers) self.servers.append(servers)
return True return True
# build our server listings elif not isinstance(servers, (tuple, set, list)):
servers = parse_list(servers) logging.error(
"An invalid notification (type={}) was specified.".format(
type(servers)))
return False
for _server in servers: 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 # Instantiate ourselves an object, this function throws or
# returns None if it fails # returns None if it fails
instance = Apprise.instantiate(_server, asset=asset, tag=tag) instance = Apprise.instantiate(_server, asset=asset, tag=tag)
if not instance: if not isinstance(instance, NotifyBase):
return_status = False return_status = False
logging.error( logging.error(
"Failed to load notification url: {}".format(_server), "Failed to load notification url: {}".format(_server),
@ -254,7 +221,7 @@ class Apprise(object):
""" """
# Initialize our return result # Initialize our return result
status = len(self.servers) > 0 status = len(self) > 0
if not (title or body): if not (title or body):
return False return False
@ -273,51 +240,23 @@ class Apprise(object):
# tag=[('tagB', 'tagC')] = tagB and tagC # tag=[('tagB', 'tagC')] = tagB and tagC
# Iterate over our loaded plugins # 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)): else:
# using the tags detected; determine if we'll allow the servers = [entry, ]
# notification to be sent or not
matched = False
# Every entry here will be or'ed with the next for server in servers:
for entry in tag: # Apply our tag matching based on our defined logic
if isinstance(entry, (list, tuple, set)): if tag is not None and not is_exclusive_match(
logic=tag, data=server.tags):
# 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 continue
elif tag not in server: # If our code reaches here, we either did not define a tag (it
# one or more tags were defined and they didn't match the # was set to None), or we did define a tag and the logic above
# entry in the current service; move along...
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 # determined we need to notify the service it's associated with
if server.notify_format not in conversion_map: if server.notify_format not in conversion_map:
if body_format == NotifyFormat.MARKDOWN and \ if body_format == NotifyFormat.MARKDOWN and \
@ -335,8 +274,8 @@ class Apprise(object):
r'&': '&', r'&': '&',
# Spaces to   for formatting purposes since # Spaces to   for formatting purposes since
# multiple spaces are treated as one an this may not # multiple spaces are treated as one an this may
# be the callers intention # not be the callers intention
r' ': ' ', r' ': ' ',
# Tab support # Tab support
@ -349,15 +288,17 @@ class Apprise(object):
# Compile our map # Compile our map
re_table = re.compile( re_table = re.compile(
r'(' + '|'.join(map(re.escape, re_map.keys())) + r')', r'(' + '|'.join(
map(re.escape, re_map.keys())) + r')',
re.IGNORECASE, re.IGNORECASE,
) )
# Execute our map against our body in addition to swapping # Execute our map against our body in addition to
# out new lines and replacing them with <br/> # swapping out new lines and replacing them with <br/>
conversion_map[server.notify_format] = \ conversion_map[server.notify_format] = \
re.sub(r'\r*\n', '<br/>\r\n', re.sub(r'\r*\n', '<br/>\r\n',
re_table.sub(lambda x: re_map[x.group()], body)) re_table.sub(
lambda x: re_map[x.group()], body))
else: else:
# Store entry directly # Store entry directly
@ -412,12 +353,12 @@ class Apprise(object):
# Standard protocol(s) should be None or a tuple # Standard protocol(s) should be None or a tuple
protocols = getattr(plugin, 'protocol', None) protocols = getattr(plugin, 'protocol', None)
if compat_is_basestring(protocols): if isinstance(protocols, six.string_types):
protocols = (protocols, ) protocols = (protocols, )
# Secure protocol(s) should be None or a tuple # Secure protocol(s) should be None or a tuple
secure_protocols = getattr(plugin, 'secure_protocol', None) secure_protocols = getattr(plugin, 'secure_protocol', None)
if compat_is_basestring(secure_protocols): if isinstance(secure_protocols, six.string_types):
secure_protocols = (secure_protocols, ) secure_protocols = (secure_protocols, )
# Build our response object # Build our response object
@ -439,27 +380,87 @@ class Apprise(object):
def pop(self, index): def pop(self, index):
""" """
Removes an indexed Notification Service from the stack and Removes an indexed Notification Service from the stack and returns it.
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 # Tracking variables
return self.servers.pop(index) 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): def __getitem__(self, index):
""" """
Returns the indexed server entry of a loaded notification server 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): 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): 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])

292
apprise/AppriseConfig.py Normal file
View File

@ -0,0 +1,292 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# 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)

427
apprise/URLBase.py Normal file
View File

@ -0,0 +1,427 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# 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, {"'": "&apos;", "\"": "&quot;"})
if whitespace:
# Tidy up whitespace too
escaped = escaped\
.replace(u'\t', u'&emsp;')\
.replace(u' ', u'&nbsp;')
if convert_new_lines:
return escaped.replace(u'\n', u'&lt;br/&gt;')
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

View File

@ -27,7 +27,7 @@ __title__ = 'apprise'
__version__ = '0.7.3' __version__ = '0.7.3'
__author__ = 'Chris Caron' __author__ = 'Chris Caron'
__license__ = 'MIT' __license__ = 'MIT'
__copywrite__ = 'Copyright 2019 Chris Caron <lead2gold@gmail.com>' __copywrite__ = 'Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>'
__email__ = 'lead2gold@gmail.com' __email__ = 'lead2gold@gmail.com'
__status__ = 'Production' __status__ = 'Production'
@ -39,10 +39,16 @@ from .common import NotifyFormat
from .common import NOTIFY_FORMATS from .common import NOTIFY_FORMATS
from .common import OverflowMode from .common import OverflowMode
from .common import OVERFLOW_MODES from .common import OVERFLOW_MODES
from .common import ConfigFormat
from .common import CONFIG_FORMATS
from .URLBase import URLBase
from .plugins.NotifyBase import NotifyBase from .plugins.NotifyBase import NotifyBase
from .config.ConfigBase import ConfigBase
from .Apprise import Apprise from .Apprise import Apprise
from .AppriseAsset import AppriseAsset from .AppriseAsset import AppriseAsset
from .AppriseConfig import AppriseConfig
# Set default logging handler to avoid "No handler found" warnings. # Set default logging handler to avoid "No handler found" warnings.
import logging import logging
@ -51,9 +57,11 @@ logging.getLogger(__name__).addHandler(NullHandler())
__all__ = [ __all__ = [
# Core # Core
'Apprise', 'AppriseAsset', 'NotifyBase', 'Apprise', 'AppriseAsset', 'AppriseConfig', 'URLBase', 'NotifyBase',
'ConfigBase',
# Reference # Reference
'NotifyType', 'NotifyImageSize', 'NotifyFormat', 'OverflowMode', 'NotifyType', 'NotifyImageSize', 'NotifyFormat', 'OverflowMode',
'NOTIFY_TYPES', 'NOTIFY_IMAGE_SIZES', 'NOTIFY_FORMATS', 'OVERFLOW_MODES', 'NOTIFY_TYPES', 'NOTIFY_IMAGE_SIZES', 'NOTIFY_FORMATS', 'OVERFLOW_MODES',
'ConfigFormat', 'CONFIG_FORMATS',
] ]

View File

@ -31,6 +31,12 @@ import sys
from . import NotifyType from . import NotifyType
from . import Apprise from . import Apprise
from . import AppriseAsset from . import AppriseAsset
from . import AppriseConfig
from .utils import parse_list
from . import __title__
from . import __version__
from . import __license__
from . import __copywrite__
# Logging # Logging
logger = logging.getLogger('apprise.plugins.NotifyBase') 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 # can be specified to get the help menu to come up
CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) 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): def print_help_msg(command):
""" """
@ -49,19 +63,39 @@ def print_help_msg(command):
click.echo(command.get_help(ctx)) 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.command(context_settings=CONTEXT_SETTINGS)
@click.option('--title', '-t', default=None, type=str, @click.option('--title', '-t', default=None, type=str,
help='Specify the message title.') help='Specify the message title.')
@click.option('--body', '-b', default=None, type=str, @click.option('--body', '-b', default=None, type=str,
help='Specify the message body.') 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, @click.option('--notification-type', '-n', default=NotifyType.INFO, type=str,
metavar='TYPE', help='Specify the message type (default=info).') metavar='TYPE', help='Specify the message type (default=info).')
@click.option('--theme', '-T', default='default', type=str, @click.option('--theme', '-T', default='default', type=str,
help='Specify the default theme.') 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', '--verbose', count=True)
@click.option('-V', '--version', is_flag=True,
help='Display the apprise version and exit.')
@click.argument('urls', nargs=-1, @click.argument('urls', nargs=-1,
metavar='SERVER_URL [SERVER_URL2 [SERVER_URL3]]',) 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 Send a notification to all of the specified servers identified by their
URLs the content provided within the title, body and notification-type. 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: else:
logger.setLevel(logging.ERROR) logger.setLevel(logging.ERROR)
if version:
print_version_msg()
sys.exit(0)
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
ch.setFormatter(formatter) ch.setFormatter(formatter)
logger.addHandler(ch) 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 # Prepare our asset
asset = AppriseAsset(theme=theme) asset = AppriseAsset(theme=theme)
# Create our object # Create our object
a = Apprise(asset=asset) 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 # Load our inventory up
for url in urls: for url in urls:
a.add(url) 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 body is None:
# if no body was specified, then read from STDIN # if no body was specified, then read from STDIN
body = click.get_text_stream('stdin').read() 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 # 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(0)
sys.exit(1) sys.exit(1)

View File

@ -105,3 +105,26 @@ OVERFLOW_MODES = (
OverflowMode.TRUNCATE, OverflowMode.TRUNCATE,
OverflowMode.SPLIT, 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,
)

View File

@ -0,0 +1,337 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# 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<line>([;#]+(?P<comment>.*))|'
r'(\s*(?P<tags>[^=]+)=|=)?\s*'
r'(?P<url>[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)

View File

@ -0,0 +1,164 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# 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<path>[^?]+)(\?.*)?', url, re.I)
if not match:
return None
results['path'] = match.group('path')
return results

View File

@ -0,0 +1,269 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# 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

View File

@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# 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()

View File

@ -24,26 +24,8 @@
# THE SOFTWARE. # THE SOFTWARE.
import re import re
import logging
from time import sleep
from datetime import datetime
try: from ..URLBase import URLBase
# 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 ..common import NotifyType from ..common import NotifyType
from ..common import NOTIFY_TYPES from ..common import NOTIFY_TYPES
from ..common import NotifyFormat from ..common import NotifyFormat
@ -51,67 +33,23 @@ from ..common import NOTIFY_FORMATS
from ..common import OverflowMode from ..common import OverflowMode
from ..common import OVERFLOW_MODES 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 # HTML New Line Delimiter
NOTIFY_NEWLINE = '\r\n' 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: class NotifyBase(URLBase):
# http://www.regular-expressions.info/email.html
IS_EMAIL_RE = re.compile(
r"((?P<label>[^+]+)\+)?"
r"(?P<userid>[a-z0-9$%=_~-]+"
r"(?:\.[a-z0-9$%+=_~-]+)"
r"*)@(?P<domain>(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+"
r"[a-z0-9](?:[a-z0-9-]*"
r"[a-z0-9]))?",
re.IGNORECASE,
)
class NotifyBase(object):
""" """
This is the base class for all notification services This is the base class for all notification services
""" """
# The default descriptive name associated with the Notification
service_name = None
# The services URL # The services URL
service_url = None service_url = 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
# A URL that takes you to the setup/help of the specific protocol # A URL that takes you to the setup/help of the specific protocol
setup_url = None setup_url = None
# Most Servers do not like more then 1 request per 5 seconds, so 5.5 gives # Most Servers do not like more then 1 request per 5 seconds, so 5.5 gives
# us a safe play range. # us a safe play range. Override the one defined already in the URLBase
request_rate_per_sec = 5.5 request_rate_per_sec = 5.5
# Allows the user to specify the NotifyImageSize object # Allows the user to specify the NotifyImageSize object
@ -136,40 +74,14 @@ class NotifyBase(object):
# Default Overflow Mode # Default Overflow Mode
overflow_mode = OverflowMode.UPSTREAM overflow_mode = OverflowMode.UPSTREAM
# Maintain a set of tags to associate with this specific notification
tags = set()
# Logging
logger = logging.getLogger(__name__)
def __init__(self, **kwargs): def __init__(self, **kwargs):
""" """
Initialize some general logging and common server arguments that will Initialize some general configuration that will keep things consistent
keep things consistent when working with the notifiers that will when working with the notifiers that will inherit this class.
inherit this class.
""" """
# Prepare our Assets super(NotifyBase, self).__init__(**kwargs)
self.asset = 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 'format' in kwargs: if 'format' in kwargs:
# Store the specified format if specified # Store the specified format if specified
@ -197,53 +109,6 @@ class NotifyBase(object):
# Provide override # Provide override
self.overflow_mode = overflow self.overflow_mode = overflow
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 image_url(self, notify_type, logo=False, extension=None): def image_url(self, notify_type, logo=False, extension=None):
""" """
Returns Image URL if possible Returns Image URL if possible
@ -407,227 +272,8 @@ class NotifyBase(object):
Should preform the actual notification itself. Should preform the actual notification itself.
""" """
raise NotImplementedError("send() is implimented by the child class.") raise NotImplementedError(
"send() is not implimented by the child class.")
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
@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 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, {"'": "&apos;", "\"": "&quot;"})
if whitespace:
# Tidy up whitespace too
escaped = escaped\
.replace(u'\t', u'&emsp;')\
.replace(u' ', u'&nbsp;')
if convert_new_lines:
return escaped.replace(u'\n', u'&lt;br/&gt;')
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(
NotifyBase.unquote(path).lstrip('/'))
return PATHSPLIT_LIST_DELIM.split(path.lstrip('/'))
@staticmethod
def is_email(address):
"""Determine if the specified entry is an email address
Args:
address (str): The string you want to check.
Returns:
bool: Returns True if the address specified is an email address
and False if it isn't.
"""
return IS_EMAIL_RE.match(address) is not None
@staticmethod
def is_hostname(hostname):
"""Determine if the specified entry is a hostname
Args:
hostname (str): The string you want to check.
Returns:
bool: Returns True if the hostname specified is in fact a hostame
and False if it isn't.
"""
return is_hostname(hostname)
@staticmethod @staticmethod
def parse_url(url, verify_host=True): def parse_url(url, verify_host=True):
@ -648,29 +294,17 @@ class NotifyBase(object):
A dictionary is returned containing the URL fully parsed if A dictionary is returned containing the URL fully parsed if
successful, otherwise None is returned. successful, otherwise None is returned.
""" """
results = URLBase.parse_url(url, verify_host=verify_host)
results = parse_url(
url, default_schema='unknown', verify_host=verify_host)
if not results: if not results:
# We're done; we failed to parse our url # We're done; we failed to parse our url
return results 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))
# Allow overriding the default format # Allow overriding the default format
if 'format' in results['qsd']: if 'format' in results['qsd']:
results['format'] = results['qsd'].get('format') results['format'] = results['qsd'].get('format')
if results['format'] not in NOTIFY_FORMATS: if results['format'] not in NOTIFY_FORMATS:
NotifyBase.logger.warning( URLBase.logger.warning(
'Unsupported format specified {}'.format( 'Unsupported format specified {}'.format(
results['format'])) results['format']))
del results['format'] del results['format']
@ -679,17 +313,9 @@ class NotifyBase(object):
if 'overflow' in results['qsd']: if 'overflow' in results['qsd']:
results['overflow'] = results['qsd'].get('overflow') results['overflow'] = results['qsd'].get('overflow')
if results['overflow'] not in OVERFLOW_MODES: if results['overflow'] not in OVERFLOW_MODES:
NotifyBase.logger.warning( URLBase.logger.warning(
'Unsupported overflow specified {}'.format( 'Unsupported overflow specified {}'.format(
results['overflow'])) results['overflow']))
del results['overflow'] del results['overflow']
# 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 return results

View File

@ -24,6 +24,7 @@
# THE SOFTWARE. # THE SOFTWARE.
import re import re
import six
import requests import requests
import hmac import hmac
from json import dumps from json import dumps
@ -37,10 +38,8 @@ except ImportError:
from urllib.parse import urlparse from urllib.parse import urlparse
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
from .NotifyBase import HTTP_ERROR_MAP
from ..common import NotifyType from ..common import NotifyType
from ..common import NotifyImageSize from ..common import NotifyImageSize
from ..utils import compat_is_basestring
# Default to sending to all devices if nothing is specified # Default to sending to all devices if nothing is specified
DEFAULT_TAG = '@all' DEFAULT_TAG = '@all'
@ -148,7 +147,7 @@ class NotifyBoxcar(NotifyBase):
self.tags.append(DEFAULT_TAG) self.tags.append(DEFAULT_TAG)
recipients = [] recipients = []
elif compat_is_basestring(recipients): elif isinstance(recipients, six.string_types):
recipients = [x for x in filter(bool, TAGS_LIST_DELIM.split( recipients = [x for x in filter(bool, TAGS_LIST_DELIM.split(
recipients, recipients,
))] ))]
@ -243,20 +242,18 @@ class NotifyBoxcar(NotifyBase):
# Boxcar returns 201 (Created) when successful # Boxcar returns 201 (Created) when successful
if r.status_code != requests.codes.created: if r.status_code != requests.codes.created:
try: # We had a problem
status_str = \
NotifyBase.http_response_code_lookup(r.status_code)
self.logger.warning( self.logger.warning(
'Failed to send Boxcar notification: ' 'Failed to send Boxcar notification: '
'%s (error=%s).' % ( '{}{}error={}.'.format(
HTTP_ERROR_MAP[r.status_code], status_str,
', ' if status_str else '',
r.status_code)) r.status_code))
except KeyError: self.logger.debug('Response Details:\r\n{}'.format(r.content))
self.logger.warning(
'Failed to send Boxcar notification '
'(error=%s).' % (
r.status_code))
# self.logger.debug('Response Details: %s' % r.raw.read())
# Return; we're done # Return; we're done
return False return False

View File

@ -45,7 +45,6 @@ import requests
from json import dumps from json import dumps
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
from .NotifyBase import HTTP_ERROR_MAP
from ..common import NotifyImageSize from ..common import NotifyImageSize
from ..common import NotifyFormat from ..common import NotifyFormat
from ..common import NotifyType from ..common import NotifyType
@ -215,20 +214,19 @@ class NotifyDiscord(NotifyBase):
) )
if r.status_code not in ( if r.status_code not in (
requests.codes.ok, requests.codes.no_content): requests.codes.ok, requests.codes.no_content):
# We had a problem # We had a problem
try: status_str = \
NotifyBase.http_response_code_lookup(r.status_code)
self.logger.warning( self.logger.warning(
'Failed to send Discord notification: ' 'Failed to send Discord notification: '
'%s (error=%s).' % ( '{}{}error={}.'.format(
HTTP_ERROR_MAP[r.status_code], status_str,
', ' if status_str else '',
r.status_code)) r.status_code))
except KeyError: self.logger.debug('Response Details:\r\n{}'.format(r.content))
self.logger.warning(
'Failed to send Discord notification '
'(error=%s).' % r.status_code)
self.logger.debug('Response Details: %s' % r.raw.read())
# Return; we're done # Return; we're done
return False return False

View File

@ -32,6 +32,7 @@ from datetime import datetime
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
from ..common import NotifyFormat from ..common import NotifyFormat
from ..common import NotifyType from ..common import NotifyType
from ..utils import is_email
class WebBaseLogin(object): class WebBaseLogin(object):
@ -268,11 +269,11 @@ class NotifyEmail(NotifyBase):
self.from_addr = kwargs.get('from', None) self.from_addr = kwargs.get('from', None)
self.to_addr = kwargs.get('to', self.from_addr) self.to_addr = kwargs.get('to', self.from_addr)
if not NotifyBase.is_email(self.from_addr): if not is_email(self.from_addr):
# Parse Source domain based on from_addr # Parse Source domain based on from_addr
raise TypeError('Invalid ~From~ email format: %s' % self.from_addr) raise TypeError('Invalid ~From~ email format: %s' % self.from_addr)
if not NotifyBase.is_email(self.to_addr): if not is_email(self.to_addr):
raise TypeError('Invalid ~To~ email format: %s' % self.to_addr) raise TypeError('Invalid ~To~ email format: %s' % self.to_addr)
# Now detect the SMTP Server # Now detect the SMTP Server
@ -330,7 +331,7 @@ class NotifyEmail(NotifyBase):
login_type = WEBBASE_LOOKUP_TABLE[i][2]\ login_type = WEBBASE_LOOKUP_TABLE[i][2]\
.get('login_type', []) .get('login_type', [])
if NotifyBase.is_email(self.user) and \ if is_email(self.user) and \
WebBaseLogin.EMAIL not in login_type: WebBaseLogin.EMAIL not in login_type:
# Email specified but login type # Email specified but login type
# not supported; switch it to user id # not supported; switch it to user id

View File

@ -35,7 +35,6 @@ from json import dumps
from json import loads from json import loads
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
from .NotifyBase import HTTP_ERROR_MAP
from ..utils import parse_bool from ..utils import parse_bool
from ..common import NotifyType from ..common import NotifyType
from .. import __version__ as VERSION from .. import __version__ as VERSION
@ -168,20 +167,19 @@ class NotifyEmby(NotifyBase):
) )
if r.status_code != requests.codes.ok: if r.status_code != requests.codes.ok:
try: # We had a problem
status_str = \
NotifyBase.http_response_code_lookup(r.status_code)
self.logger.warning( self.logger.warning(
'Failed to authenticate user %s details: ' 'Failed to authenticate Emby user {} details: '
'%s (error=%s).' % ( '{}{}error={}.'.format(
self.user, self.user,
HTTP_ERROR_MAP[r.status_code], status_str,
', ' if status_str else '',
r.status_code)) r.status_code))
except KeyError: self.logger.debug('Response Details:\r\n{}'.format(r.content))
self.logger.warning(
'Failed to authenticate user %s details: '
'(error=%s).' % (self.user, r.status_code))
self.logger.debug('Emby Response:\r\n%s' % r.text)
# Return; we're done # Return; we're done
return False return False
@ -329,20 +327,19 @@ class NotifyEmby(NotifyBase):
) )
if r.status_code != requests.codes.ok: if r.status_code != requests.codes.ok:
try: # We had a problem
status_str = \
NotifyBase.http_response_code_lookup(r.status_code)
self.logger.warning( self.logger.warning(
'Failed to acquire session for user %s details: ' 'Failed to acquire Emby session for user {}: '
'%s (error=%s).' % ( '{}{}error={}.'.format(
self.user, self.user,
HTTP_ERROR_MAP[r.status_code], status_str,
', ' if status_str else '',
r.status_code)) r.status_code))
except KeyError: self.logger.debug('Response Details:\r\n{}'.format(r.content))
self.logger.warning(
'Failed to acquire session for user %s details: '
'(error=%s).' % (self.user, r.status_code))
self.logger.debug('Emby Response:\r\n%s' % r.text)
# Return; we're done # Return; we're done
return sessions return sessions
@ -412,20 +409,20 @@ class NotifyEmby(NotifyBase):
# The below show up if we were 'just' logged out # The below show up if we were 'just' logged out
requests.codes.ok, requests.codes.ok,
requests.codes.no_content): requests.codes.no_content):
try:
# We had a problem
status_str = \
NotifyBase.http_response_code_lookup(r.status_code)
self.logger.warning( self.logger.warning(
'Failed to logoff user %s details: ' 'Failed to logoff Emby user {}: '
'%s (error=%s).' % ( '{}{}error={}.'.format(
self.user, self.user,
HTTP_ERROR_MAP[r.status_code], status_str,
', ' if status_str else '',
r.status_code)) r.status_code))
except KeyError: self.logger.debug('Response Details:\r\n{}'.format(r.content))
self.logger.warning(
'Failed to logoff user %s details: '
'(error=%s).' % (self.user, r.status_code))
self.logger.debug('Emby Response:\r\n%s' % r.text)
# Return; we're done # Return; we're done
return False return False
@ -509,17 +506,19 @@ class NotifyEmby(NotifyBase):
if r.status_code not in ( if r.status_code not in (
requests.codes.ok, requests.codes.ok,
requests.codes.no_content): requests.codes.no_content):
try: # We had a problem
status_str = \
NotifyBase.http_response_code_lookup(r.status_code)
self.logger.warning( self.logger.warning(
'Failed to send Emby notification: ' 'Failed to send Emby notification: '
'%s (error=%s).' % ( '{}{}error={}.'.format(
HTTP_ERROR_MAP[r.status_code], status_str,
', ' if status_str else '',
r.status_code)) r.status_code))
except KeyError: self.logger.debug(
self.logger.warning( 'Response Details:\r\n{}'.format(r.content))
'Failed to send Emby notification '
'(error=%s).' % (r.status_code))
# Mark our failure # Mark our failure
has_error = True has_error = True

View File

@ -25,7 +25,6 @@
import requests import requests
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
from .NotifyBase import HTTP_ERROR_MAP
from ..common import NotifyImageSize from ..common import NotifyImageSize
from ..common import NotifyType from ..common import NotifyType
@ -99,18 +98,17 @@ class NotifyFaast(NotifyBase):
) )
if r.status_code != requests.codes.ok: if r.status_code != requests.codes.ok:
# We had a problem # We had a problem
try: status_str = \
NotifyBase.http_response_code_lookup(r.status_code)
self.logger.warning( self.logger.warning(
'Failed to send Faast notification:' 'Failed to send Faast notification:'
'%s (error=%s).' % ( '{}{}error={}.'.format(
HTTP_ERROR_MAP[r.status_code], status_str,
', ' if status_str else '',
r.status_code)) r.status_code))
except KeyError: self.logger.debug('Response Details:\r\n{}'.format(r.content))
self.logger.warning(
'Failed to send Faast notification '
'(error=%s).' % (
r.status_code))
# Return; we're done # Return; we're done
return False return False

View File

@ -43,7 +43,6 @@ import requests
from json import dumps from json import dumps
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
from .NotifyBase import HTTP_ERROR_MAP
from ..common import NotifyType from ..common import NotifyType
from ..utils import parse_list from ..utils import parse_list
@ -163,8 +162,8 @@ class NotifyIFTTT(NotifyBase):
payload = {x.lower(): y for x, y in payload.items() payload = {x.lower(): y for x, y in payload.items()
if x not in self.del_tokens} if x not in self.del_tokens}
# Track our failures # error tracking (used for function return)
error_count = 0 has_error = False
# Create a copy of our event lit # Create a copy of our event lit
events = list(self.events) events = list(self.events)
@ -202,26 +201,27 @@ class NotifyIFTTT(NotifyBase):
if r.status_code != requests.codes.ok: if r.status_code != requests.codes.ok:
# We had a problem # We had a problem
try: status_str = \
NotifyBase.http_response_code_lookup(r.status_code)
self.logger.warning( self.logger.warning(
'Failed to send IFTTT:%s ' 'Failed to send IFTTT notification to {}: '
'notification: %s (error=%s).' % ( '{}{}error={}.'.format(
event, event,
HTTP_ERROR_MAP[r.status_code], status_str,
', ' if status_str else '',
r.status_code)) r.status_code))
except KeyError: self.logger.debug(
self.logger.warning( 'Response Details:\r\n{}'.format(r.content))
'Failed to send IFTTT:%s '
'notification (error=%s).' % (
event, r.status_code))
# self.logger.debug('Response Details: %s' % r.content) # Mark our failure
error_count += 1 has_error = True
continue
else: else:
self.logger.info( self.logger.info(
'Sent IFTTT notification to Event %s.' % event) 'Sent IFTTT notification to %s.' % event)
except requests.RequestException as e: except requests.RequestException as e:
self.logger.warning( self.logger.warning(
@ -229,9 +229,12 @@ class NotifyIFTTT(NotifyBase):
event) + 'notification.' event) + 'notification.'
) )
self.logger.debug('Socket Exception: %s' % str(e)) self.logger.debug('Socket Exception: %s' % str(e))
error_count += 1
return (error_count == 0) # Mark our failure
has_error = True
continue
return not has_error
def url(self): def url(self):
""" """

View File

@ -23,14 +23,13 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE. # THE SOFTWARE.
import six
import requests import requests
from json import dumps from json import dumps
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
from .NotifyBase import HTTP_ERROR_MAP
from ..common import NotifyImageSize from ..common import NotifyImageSize
from ..common import NotifyType from ..common import NotifyType
from ..utils import compat_is_basestring
class NotifyJSON(NotifyBase): class NotifyJSON(NotifyBase):
@ -74,7 +73,7 @@ class NotifyJSON(NotifyBase):
self.schema = 'http' self.schema = 'http'
self.fullpath = kwargs.get('fullpath') self.fullpath = kwargs.get('fullpath')
if not compat_is_basestring(self.fullpath): if not isinstance(self.fullpath, six.string_types):
self.fullpath = '/' self.fullpath = '/'
self.headers = {} self.headers = {}
@ -172,17 +171,18 @@ class NotifyJSON(NotifyBase):
verify=self.verify_certificate, verify=self.verify_certificate,
) )
if r.status_code != requests.codes.ok: if r.status_code != requests.codes.ok:
try: # We had a problem
status_str = \
NotifyBase.http_response_code_lookup(r.status_code)
self.logger.warning( self.logger.warning(
'Failed to send JSON notification: ' 'Failed to send JSON notification: '
'%s (error=%s).' % ( '{}{}error={}.'.format(
HTTP_ERROR_MAP[r.status_code], status_str,
', ' if status_str else '',
r.status_code)) r.status_code))
except KeyError: self.logger.debug('Response Details:\r\n{}'.format(r.content))
self.logger.warning(
'Failed to send JSON notification '
'(error=%s).' % (r.status_code))
# Return; we're done # Return; we're done
return False return False

View File

@ -34,22 +34,20 @@
# https://play.google.com/store/apps/details?id=com.joaomgcd.join # https://play.google.com/store/apps/details?id=com.joaomgcd.join
import re import re
import six
import requests import requests
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
from .NotifyBase import HTTP_ERROR_MAP
from ..common import NotifyImageSize from ..common import NotifyImageSize
from ..common import NotifyType from ..common import NotifyType
from ..utils import compat_is_basestring
# Token required as part of the API request # Token required as part of the API request
VALIDATE_APIKEY = re.compile(r'[A-Za-z0-9]{32}') VALIDATE_APIKEY = re.compile(r'[A-Za-z0-9]{32}')
# Extend HTTP Error Messages # Extend HTTP Error Messages
JOIN_HTTP_ERROR_MAP = HTTP_ERROR_MAP.copy() JOIN_HTTP_ERROR_MAP = {
JOIN_HTTP_ERROR_MAP.update({
401: 'Unauthorized - Invalid Token.', 401: 'Unauthorized - Invalid Token.',
}) }
# Used to break path apart into list of devices # Used to break path apart into list of devices
DEVICE_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') DEVICE_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+')
@ -98,6 +96,9 @@ class NotifyJoin(NotifyBase):
# The maximum allowable characters allowed in the body per message # The maximum allowable characters allowed in the body per message
body_maxlen = 1000 body_maxlen = 1000
# The default group to use if none is specified
default_join_group = 'group.all'
def __init__(self, apikey, devices, **kwargs): def __init__(self, apikey, devices, **kwargs):
""" """
Initialize Join Object Initialize Join Object
@ -116,7 +117,7 @@ class NotifyJoin(NotifyBase):
# The token associated with the account # The token associated with the account
self.apikey = apikey.strip() self.apikey = apikey.strip()
if compat_is_basestring(devices): if isinstance(devices, six.string_types):
self.devices = [x for x in filter(bool, DEVICE_LIST_DELIM.split( self.devices = [x for x in filter(bool, DEVICE_LIST_DELIM.split(
devices, devices,
))] ))]
@ -129,7 +130,7 @@ class NotifyJoin(NotifyBase):
if len(self.devices) == 0: if len(self.devices) == 0:
# Default to everyone # Default to everyone
self.devices.append('group.all') self.devices.append(self.default_join_group)
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
""" """
@ -142,7 +143,7 @@ class NotifyJoin(NotifyBase):
} }
# error tracking (used for function return) # error tracking (used for function return)
return_status = True has_error = False
# Create a copy of the devices list # Create a copy of the devices list
devices = list(self.devices) devices = list(self.devices)
@ -158,6 +159,8 @@ class NotifyJoin(NotifyBase):
device, device,
) )
) )
# Mark our failure
has_error = True
continue continue
url_args = { url_args = {
@ -192,26 +195,27 @@ class NotifyJoin(NotifyBase):
headers=headers, headers=headers,
verify=self.verify_certificate, verify=self.verify_certificate,
) )
if r.status_code != requests.codes.ok: if r.status_code != requests.codes.ok:
# We had a problem # We had a problem
try: status_str = \
NotifyBase.http_response_code_lookup(
r.status_code, JOIN_HTTP_ERROR_MAP)
self.logger.warning( self.logger.warning(
'Failed to send Join:%s ' 'Failed to send Join notification to {}: '
'notification: %s (error=%s).' % ( '{}{}error={}.'.format(
device, device,
JOIN_HTTP_ERROR_MAP[r.status_code], status_str,
', ' if status_str else '',
r.status_code)) r.status_code))
except KeyError: self.logger.debug(
self.logger.warning( 'Response Details:\r\n{}'.format(r.content))
'Failed to send Join:%s '
'notification (error=%s).' % (
device,
r.status_code))
# self.logger.debug('Response Details: %s' % r.raw.read()) # Mark our failure
has_error = True
return_status = False continue
else: else:
self.logger.info('Sent Join notification to %s.' % device) self.logger.info('Sent Join notification to %s.' % device)
@ -222,9 +226,12 @@ class NotifyJoin(NotifyBase):
'notification.' % device 'notification.' % device
) )
self.logger.debug('Socket Exception: %s' % str(e)) self.logger.debug('Socket Exception: %s' % str(e))
return_status = False
return return_status # Mark our failure
has_error = True
continue
return not has_error
def url(self): def url(self):
""" """

View File

@ -29,20 +29,15 @@ from json import dumps
from time import time from time import time
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
from .NotifyBase import HTTP_ERROR_MAP
from ..common import NotifyType from ..common import NotifyType
# Token required as part of the API request # Token required as part of the API request
VALIDATE_TOKEN = re.compile(r'[A-Za-z0-9]{64}') VALIDATE_TOKEN = re.compile(r'[A-Za-z0-9]{64}')
# Default User
MATRIX_DEFAULT_USER = 'apprise'
# Extend HTTP Error Messages # Extend HTTP Error Messages
MATRIX_HTTP_ERROR_MAP = HTTP_ERROR_MAP.copy() MATRIX_HTTP_ERROR_MAP = {
MATRIX_HTTP_ERROR_MAP.update({
403: 'Unauthorized - Invalid Token.', 403: 'Unauthorized - Invalid Token.',
}) }
class MatrixNotificationMode(object): class MatrixNotificationMode(object):
@ -79,6 +74,9 @@ class NotifyMatrix(NotifyBase):
# The maximum allowable characters allowed in the body per message # The maximum allowable characters allowed in the body per message
body_maxlen = 1000 body_maxlen = 1000
# Default User
matrix_default_user = 'apprise'
def __init__(self, token, mode=MatrixNotificationMode.MATRIX, **kwargs): def __init__(self, token, mode=MatrixNotificationMode.MATRIX, **kwargs):
""" """
Initialize Matrix Object Initialize Matrix Object
@ -112,7 +110,7 @@ class NotifyMatrix(NotifyBase):
if not self.user: if not self.user:
self.logger.warning( self.logger.warning(
'No user was specified; using %s.' % MATRIX_DEFAULT_USER) 'No user was specified; using %s.' % self.matrix_default_user)
if mode not in MATRIX_NOTIFICATION_MODES: if mode not in MATRIX_NOTIFICATION_MODES:
self.logger.warning('The mode specified (%s) is invalid.' % mode) self.logger.warning('The mode specified (%s) is invalid.' % mode)
@ -145,9 +143,6 @@ class NotifyMatrix(NotifyBase):
'Content-Type': 'application/json', 'Content-Type': 'application/json',
} }
# error tracking (used for function return)
notify_okay = True
# Perform Formatting # Perform Formatting
title = self._re_formatting_rules.sub( # pragma: no branch title = self._re_formatting_rules.sub( # pragma: no branch
lambda x: self._re_formatting_map[x.group()], title, lambda x: self._re_formatting_map[x.group()], title,
@ -183,20 +178,21 @@ class NotifyMatrix(NotifyBase):
) )
if r.status_code != requests.codes.ok: if r.status_code != requests.codes.ok:
# We had a problem # We had a problem
try: status_str = \
NotifyBase.http_response_code_lookup(
r.status_code, MATRIX_HTTP_ERROR_MAP)
self.logger.warning( self.logger.warning(
'Failed to send Matrix ' 'Failed to send Matrix notification: '
'notification: %s (error=%s).' % ( '{}{}error={}.'.format(
MATRIX_HTTP_ERROR_MAP[r.status_code], status_str,
', ' if status_str else '',
r.status_code)) r.status_code))
except KeyError: self.logger.debug('Response Details:\r\n{}'.format(r.content))
self.logger.warning(
'Failed to send Matrix '
'notification (error=%s).' % r.status_code)
# Return; we're done # Return; we're done
notify_okay = False return False
else: else:
self.logger.info('Sent Matrix notification.') self.logger.info('Sent Matrix notification.')
@ -206,14 +202,15 @@ class NotifyMatrix(NotifyBase):
'A Connection error occured sending Matrix notification.' 'A Connection error occured sending Matrix notification.'
) )
self.logger.debug('Socket Exception: %s' % str(e)) self.logger.debug('Socket Exception: %s' % str(e))
notify_okay = False # Return; we're done
return False
return notify_okay return True
def __slack_mode_payload(self, title, body, notify_type): def __slack_mode_payload(self, title, body, notify_type):
# prepare JSON Object # prepare JSON Object
payload = { payload = {
'username': self.user if self.user else MATRIX_DEFAULT_USER, 'username': self.user if self.user else self.matrix_default_user,
# Use Markdown language # Use Markdown language
'mrkdwn': True, 'mrkdwn': True,
'attachments': [{ 'attachments': [{
@ -234,7 +231,8 @@ class NotifyMatrix(NotifyBase):
msg = '<h4>%s</h4>%s<br/>' % (title, body) msg = '<h4>%s</h4>%s<br/>' % (title, body)
payload = { payload = {
'displayName': self.user if self.user else MATRIX_DEFAULT_USER, 'displayName':
self.user if self.user else self.matrix_default_user,
'format': 'html', 'format': 'html',
'text': msg, 'text': msg,
} }

View File

@ -28,7 +28,6 @@ import requests
from json import dumps from json import dumps
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
from .NotifyBase import HTTP_ERROR_MAP
from ..common import NotifyImageSize from ..common import NotifyImageSize
from ..common import NotifyType from ..common import NotifyType
@ -157,21 +156,21 @@ class NotifyMatterMost(NotifyBase):
) )
if r.status_code != requests.codes.ok: if r.status_code != requests.codes.ok:
# We had a problem # We had a problem
try: status_str = \
NotifyBase.http_response_code_lookup(r.status_code)
self.logger.warning( self.logger.warning(
'Failed to send MatterMost notification: ' 'Failed to send MatterMost notification: '
'%s (error=%s).' % ( '{}{}error={}.'.format(
HTTP_ERROR_MAP[r.status_code], status_str,
', ' if status_str else '',
r.status_code)) r.status_code))
except KeyError: self.logger.debug('Response Details:\r\n{}'.format(r.content))
self.logger.warning(
'Failed to send MatterMost notification '
'(error=%s).' % (
r.status_code))
# Return; we're done # Return; we're done
return False return False
else: else:
self.logger.info('Sent MatterMost notification.') self.logger.info('Sent MatterMost notification.')

View File

@ -27,7 +27,6 @@ import re
import requests import requests
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
from .NotifyBase import HTTP_ERROR_MAP
from ..common import NotifyType from ..common import NotifyType
# Used to validate API Key # Used to validate API Key
@ -54,12 +53,11 @@ PROWL_PRIORITIES = (
ProwlPriority.EMERGENCY, ProwlPriority.EMERGENCY,
) )
# Extend HTTP Error Messages # Provide some known codes Prowl uses and what they translate to:
PROWL_HTTP_ERROR_MAP = HTTP_ERROR_MAP.copy() PROWL_HTTP_ERROR_MAP = {
HTTP_ERROR_MAP.update({
406: 'IP address has exceeded API limit', 406: 'IP address has exceeded API limit',
409: 'Request not aproved.', 409: 'Request not aproved.',
}) }
class NotifyProwl(NotifyBase): class NotifyProwl(NotifyBase):
@ -168,20 +166,18 @@ class NotifyProwl(NotifyBase):
) )
if r.status_code != requests.codes.ok: if r.status_code != requests.codes.ok:
# We had a problem # We had a problem
try: status_str = \
NotifyBase.http_response_code_lookup(
r.status_code, PROWL_HTTP_ERROR_MAP)
self.logger.warning( self.logger.warning(
'Failed to send Prowl notification:' 'Failed to send Prowl notification:'
'%s (error=%s).' % ( '{}{}error={}.'.format(
PROWL_HTTP_ERROR_MAP[r.status_code], status_str,
', ' if status_str else '',
r.status_code)) r.status_code))
except KeyError: self.logger.debug('Response Details:\r\n{}'.format(r.content))
self.logger.warning(
'Failed to send Prowl notification '
'(error=%s).' % (
r.status_code))
self.logger.debug('Response Details: %s' % r.raw.read())
# Return; we're done # Return; we're done
return False return False

View File

@ -24,14 +24,13 @@
# THE SOFTWARE. # THE SOFTWARE.
import re import re
import six
import requests import requests
from json import dumps from json import dumps
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
from .NotifyBase import HTTP_ERROR_MAP from ..utils import GET_EMAIL_RE
from .NotifyBase import IS_EMAIL_RE
from ..common import NotifyType from ..common import NotifyType
from ..utils import compat_is_basestring
# Flag used as a placeholder to sending to all devices # Flag used as a placeholder to sending to all devices
PUSHBULLET_SEND_TO_ALL = 'ALL_DEVICES' PUSHBULLET_SEND_TO_ALL = 'ALL_DEVICES'
@ -40,11 +39,10 @@ PUSHBULLET_SEND_TO_ALL = 'ALL_DEVICES'
# into a usable list. # into a usable list.
RECIPIENTS_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') RECIPIENTS_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+')
# Extend HTTP Error Messages # Provide some known codes Pushbullet uses and what they translate to:
PUSHBULLET_HTTP_ERROR_MAP = HTTP_ERROR_MAP.copy() PUSHBULLET_HTTP_ERROR_MAP = {
PUSHBULLET_HTTP_ERROR_MAP.update({
401: 'Unauthorized - Invalid Token.', 401: 'Unauthorized - Invalid Token.',
}) }
class NotifyPushBullet(NotifyBase): class NotifyPushBullet(NotifyBase):
@ -74,7 +72,7 @@ class NotifyPushBullet(NotifyBase):
super(NotifyPushBullet, self).__init__(**kwargs) super(NotifyPushBullet, self).__init__(**kwargs)
self.accesstoken = accesstoken self.accesstoken = accesstoken
if compat_is_basestring(recipients): if isinstance(recipients, six.string_types):
self.recipients = [x for x in filter( self.recipients = [x for x in filter(
bool, RECIPIENTS_LIST_DELIM.split(recipients))] bool, RECIPIENTS_LIST_DELIM.split(recipients))]
@ -117,7 +115,7 @@ class NotifyPushBullet(NotifyBase):
# Send to all # Send to all
pass pass
elif IS_EMAIL_RE.match(recipient): elif GET_EMAIL_RE.match(recipient):
payload['email'] = recipient payload['email'] = recipient
self.logger.debug( self.logger.debug(
"Recipient '%s' is an email address" % recipient) "Recipient '%s' is an email address" % recipient)
@ -150,23 +148,24 @@ class NotifyPushBullet(NotifyBase):
if r.status_code != requests.codes.ok: if r.status_code != requests.codes.ok:
# We had a problem # We had a problem
try: status_str = \
NotifyBase.http_response_code_lookup(
r.status_code, PUSHBULLET_HTTP_ERROR_MAP)
self.logger.warning( self.logger.warning(
'Failed to send PushBullet notification to ' 'Failed to send PushBullet notification to {}:'
'"%s": %s (error=%s).' % ( '{}{}error={}.'.format(
recipient, recipient,
PUSHBULLET_HTTP_ERROR_MAP[r.status_code], status_str,
', ' if status_str else '',
r.status_code)) r.status_code))
except KeyError: self.logger.debug(
self.logger.warning( 'Response Details:\r\n{}'.format(r.content))
'Failed to send PushBullet notification to '
'"%s" (error=%s).' % (recipient, r.status_code))
# self.logger.debug('Response Details: %s' % r.raw.read()) # Mark our failure
# Return; we're done
has_error = True has_error = True
continue
else: else:
self.logger.info( self.logger.info(
@ -178,7 +177,10 @@ class NotifyPushBullet(NotifyBase):
'notification to "%s".' % (recipient), 'notification to "%s".' % (recipient),
) )
self.logger.debug('Socket Exception: %s' % str(e)) self.logger.debug('Socket Exception: %s' % str(e))
# Mark our failure
has_error = True has_error = True
continue
return not has_error return not has_error

View File

@ -24,14 +24,13 @@
# THE SOFTWARE. # THE SOFTWARE.
import re import re
import six
import requests import requests
from json import dumps from json import dumps
from itertools import chain from itertools import chain
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
from .NotifyBase import HTTP_ERROR_MAP
from ..common import NotifyType from ..common import NotifyType
from ..utils import compat_is_basestring
# Used to detect and parse channels # Used to detect and parse channels
IS_CHANNEL = re.compile(r'^#(?P<name>[A-Za-z0-9]+)$') IS_CHANNEL = re.compile(r'^#(?P<name>[A-Za-z0-9]+)$')
@ -94,7 +93,7 @@ class NotifyPushed(NotifyBase):
if recipients is None: if recipients is None:
recipients = [] recipients = []
elif compat_is_basestring(recipients): elif isinstance(recipients, six.string_types):
recipients = [x for x in filter(bool, LIST_DELIM.split( recipients = [x for x in filter(bool, LIST_DELIM.split(
recipients, recipients,
))] ))]
@ -225,19 +224,17 @@ class NotifyPushed(NotifyBase):
if r.status_code != requests.codes.ok: if r.status_code != requests.codes.ok:
# We had a problem # We had a problem
try: status_str = \
NotifyBase.http_response_code_lookup(r.status_code)
self.logger.warning( self.logger.warning(
'Failed to send Pushed notification:' 'Failed to send Pushed notification:'
'%s (error=%s).' % ( '{}{}error={}.'.format(
HTTP_ERROR_MAP[r.status_code], status_str,
', ' if status_str else '',
r.status_code)) r.status_code))
except KeyError: self.logger.debug('Response Details:\r\n{}'.format(r.content))
self.logger.warning(
'Failed to send Pushed notification '
'(error=%s).' % r.status_code)
self.logger.debug('Response Details: %s' % r.raw.read())
# Return; we're done # Return; we're done
return False return False

View File

@ -24,12 +24,11 @@
# THE SOFTWARE. # THE SOFTWARE.
import re import re
import six
import requests import requests
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
from .NotifyBase import HTTP_ERROR_MAP
from ..common import NotifyType from ..common import NotifyType
from ..utils import compat_is_basestring
# Flag used as a placeholder to sending to all devices # Flag used as a placeholder to sending to all devices
PUSHOVER_SEND_TO_ALL = 'ALL_DEVICES' PUSHOVER_SEND_TO_ALL = 'ALL_DEVICES'
@ -65,10 +64,9 @@ PUSHOVER_PRIORITIES = (
DEVICE_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') DEVICE_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+')
# Extend HTTP Error Messages # Extend HTTP Error Messages
PUSHOVER_HTTP_ERROR_MAP = HTTP_ERROR_MAP.copy() PUSHOVER_HTTP_ERROR_MAP = {
PUSHOVER_HTTP_ERROR_MAP.update({
401: 'Unauthorized - Invalid Token.', 401: 'Unauthorized - Invalid Token.',
}) }
class NotifyPushover(NotifyBase): class NotifyPushover(NotifyBase):
@ -117,7 +115,7 @@ class NotifyPushover(NotifyBase):
'The API Token specified (%s) is invalid.' % token, 'The API Token specified (%s) is invalid.' % token,
) )
if compat_is_basestring(devices): if isinstance(devices, six.string_types):
self.devices = [x for x in filter(bool, DEVICE_LIST_DELIM.split( self.devices = [x for x in filter(bool, DEVICE_LIST_DELIM.split(
devices, devices,
))] ))]
@ -173,6 +171,8 @@ class NotifyPushover(NotifyBase):
self.logger.warning( self.logger.warning(
'The device specified (%s) is invalid.' % device, 'The device specified (%s) is invalid.' % device,
) )
# Mark our failure
has_error = True has_error = True
continue continue
@ -204,25 +204,24 @@ class NotifyPushover(NotifyBase):
) )
if r.status_code != requests.codes.ok: if r.status_code != requests.codes.ok:
# We had a problem # We had a problem
try: status_str = \
NotifyBase.http_response_code_lookup(
r.status_code, PUSHOVER_HTTP_ERROR_MAP)
self.logger.warning( self.logger.warning(
'Failed to send Pushover:%s ' 'Failed to send Pushover notification to {}: '
'notification: %s (error=%s).' % ( '{}{}error={}.'.format(
device, device,
PUSHOVER_HTTP_ERROR_MAP[r.status_code], status_str,
', ' if status_str else '',
r.status_code)) r.status_code))
except KeyError: self.logger.debug(
self.logger.warning( 'Response Details:\r\n{}'.format(r.content))
'Failed to send Pushover:%s '
'notification (error=%s).' % (
device,
r.status_code))
# self.logger.debug('Response Details: %s' % r.raw.read()) # Mark our failure
# Return; we're done
has_error = True has_error = True
continue
else: else:
self.logger.info( self.logger.info(
@ -234,7 +233,10 @@ class NotifyPushover(NotifyBase):
device) + 'notification.' device) + 'notification.'
) )
self.logger.debug('Socket Exception: %s' % str(e)) self.logger.debug('Socket Exception: %s' % str(e))
# Mark our failure
has_error = True has_error = True
continue
return not has_error return not has_error

View File

@ -24,24 +24,22 @@
# THE SOFTWARE. # THE SOFTWARE.
import re import re
import six
import requests import requests
from json import loads from json import loads
from itertools import chain from itertools import chain
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
from .NotifyBase import HTTP_ERROR_MAP
from ..common import NotifyType from ..common import NotifyType
from ..utils import compat_is_basestring
IS_CHANNEL = re.compile(r'^#(?P<name>[A-Za-z0-9]+)$') IS_CHANNEL = re.compile(r'^#(?P<name>[A-Za-z0-9]+)$')
IS_ROOM_ID = re.compile(r'^(?P<name>[A-Za-z0-9]+)$') IS_ROOM_ID = re.compile(r'^(?P<name>[A-Za-z0-9]+)$')
# Extend HTTP Error Messages # Extend HTTP Error Messages
RC_HTTP_ERROR_MAP = HTTP_ERROR_MAP.copy() RC_HTTP_ERROR_MAP = {
RC_HTTP_ERROR_MAP.update({
400: 'Channel/RoomId is wrong format, or missing from server.', 400: 'Channel/RoomId is wrong format, or missing from server.',
401: 'Authentication tokens provided is invalid or missing.', 401: 'Authentication tokens provided is invalid or missing.',
}) }
# Used to break apart list of potential tags by their delimiter # Used to break apart list of potential tags by their delimiter
# into a usable list. # into a usable list.
@ -103,7 +101,7 @@ class NotifyRocketChat(NotifyBase):
if recipients is None: if recipients is None:
recipients = [] recipients = []
elif compat_is_basestring(recipients): elif isinstance(recipients, six.string_types):
recipients = [x for x in filter(bool, LIST_DELIM.split( recipients = [x for x in filter(bool, LIST_DELIM.split(
recipients, recipients,
))] ))]
@ -253,24 +251,23 @@ class NotifyRocketChat(NotifyBase):
) )
if r.status_code != requests.codes.ok: if r.status_code != requests.codes.ok:
# We had a problem # We had a problem
try: status_str = \
NotifyBase.http_response_code_lookup(
r.status_code, RC_HTTP_ERROR_MAP)
self.logger.warning( self.logger.warning(
'Failed to send Rocket.Chat notification: ' 'Failed to send Rocket.Chat notification: '
'%s (error=%s).' % ( '{}{}error={}.'.format(
RC_HTTP_ERROR_MAP[r.status_code], status_str,
', ' if status_str else '',
r.status_code)) r.status_code))
except KeyError: self.logger.debug('Response Details:\r\n{}'.format(r.content))
self.logger.warning(
'Failed to send Rocket.Chat notification '
'(error=%s).' % (
r.status_code))
# Return; we're done # Return; we're done
return False return False
else: else:
self.logger.debug('Rocket.Chat Server Response: %s.' % r.text)
self.logger.info('Sent Rocket.Chat notification.') self.logger.info('Sent Rocket.Chat notification.')
except requests.RequestException as e: except requests.RequestException as e:
@ -302,28 +299,30 @@ class NotifyRocketChat(NotifyBase):
) )
if r.status_code != requests.codes.ok: if r.status_code != requests.codes.ok:
# We had a problem # We had a problem
try: status_str = \
NotifyBase.http_response_code_lookup(
r.status_code, RC_HTTP_ERROR_MAP)
self.logger.warning( self.logger.warning(
'Failed to authenticate with Rocket.Chat server: ' 'Failed to authenticate {} with Rocket.Chat: '
'%s (error=%s).' % ( '{}{}error={}.'.format(
RC_HTTP_ERROR_MAP[r.status_code], self.user,
status_str,
', ' if status_str else '',
r.status_code)) r.status_code))
except KeyError: self.logger.debug('Response Details:\r\n{}'.format(r.content))
self.logger.warning(
'Failed to authenticate with Rocket.Chat server '
'(error=%s).' % (
r.status_code))
# Return; we're done # Return; we're done
return False return False
else: else:
self.logger.debug('Rocket.Chat authentication successful') self.logger.debug('Rocket.Chat authentication successful')
response = loads(r.text) response = loads(r.content)
if response.get('status') != "success": if response.get('status') != "success":
self.logger.warning( self.logger.warning(
'Could not authenticate with Rocket.Chat server.') 'Could not authenticate {} with Rocket.Chat.'.format(
self.user))
return False return False
# Set our headers for further communication # Set our headers for further communication
@ -334,8 +333,8 @@ class NotifyRocketChat(NotifyBase):
except requests.RequestException as e: except requests.RequestException as e:
self.logger.warning( self.logger.warning(
'A Connection error occured authenticating to the ' 'A Connection error occured authenticating {} on '
'Rocket.Chat server.') 'Rocket.Chat.'.format(self.user))
self.logger.debug('Socket Exception: %s' % str(e)) self.logger.debug('Socket Exception: %s' % str(e))
return False return False
@ -353,18 +352,19 @@ class NotifyRocketChat(NotifyBase):
) )
if r.status_code != requests.codes.ok: if r.status_code != requests.codes.ok:
# We had a problem # We had a problem
try: status_str = \
NotifyBase.http_response_code_lookup(
r.status_code, RC_HTTP_ERROR_MAP)
self.logger.warning( self.logger.warning(
'Failed to log off Rocket.Chat server: ' 'Failed to logoff {} from Rocket.Chat: '
'%s (error=%s).' % ( '{}{}error={}.'.format(
RC_HTTP_ERROR_MAP[r.status_code], self.user,
status_str,
', ' if status_str else '',
r.status_code)) r.status_code))
except KeyError: self.logger.debug('Response Details:\r\n{}'.format(r.content))
self.logger.warning(
'Failed to log off Rocket.Chat server '
'(error=%s).' % (
r.status_code))
# Return; we're done # Return; we're done
return False return False
@ -372,7 +372,7 @@ class NotifyRocketChat(NotifyBase):
else: else:
self.logger.debug( self.logger.debug(
'Rocket.Chat log off successful; response %s.' % ( 'Rocket.Chat log off successful; response %s.' % (
r.text)) r.content))
except requests.RequestException as e: except requests.RequestException as e:
self.logger.warning( self.logger.warning(

View File

@ -36,7 +36,6 @@ import requests
from json import dumps from json import dumps
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
from .NotifyBase import HTTP_ERROR_MAP
from ..common import NotifyImageSize from ..common import NotifyImageSize
from ..common import NotifyType from ..common import NotifyType
@ -193,22 +192,17 @@ class NotifyRyver(NotifyBase):
if r.status_code != requests.codes.ok: if r.status_code != requests.codes.ok:
# We had a problem # We had a problem
try: status_str = \
NotifyBase.http_response_code_lookup(r.status_code)
self.logger.warning( self.logger.warning(
'Failed to send Ryver:%s ' 'Failed to send Ryver notification: '
'notification: %s (error=%s).' % ( '{}{}error={}.'.format(
self.organization, status_str,
HTTP_ERROR_MAP[r.status_code], ', ' if status_str else '',
r.status_code)) r.status_code))
except KeyError: self.logger.debug('Response Details:\r\n{}'.format(r.content))
self.logger.warning(
'Failed to send Ryver:%s '
'notification (error=%s).' % (
self.organization,
r.status_code))
# self.logger.debug('Response Details: %s' % r.raw.read())
# Return; we're done # Return; we're done
return False return False

View File

@ -24,6 +24,7 @@
# THE SOFTWARE. # THE SOFTWARE.
import re import re
import six
import hmac import hmac
import requests import requests
from hashlib import sha256 from hashlib import sha256
@ -33,9 +34,7 @@ from xml.etree import ElementTree
from itertools import chain from itertools import chain
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
from .NotifyBase import HTTP_ERROR_MAP
from ..common import NotifyType from ..common import NotifyType
from ..utils import compat_is_basestring
# Some Phone Number Detection # Some Phone Number Detection
IS_PHONE_NO = re.compile(r'^\+?(?P<phone>[0-9\s)(+-]+)\s*$') IS_PHONE_NO = re.compile(r'^\+?(?P<phone>[0-9\s)(+-]+)\s*$')
@ -63,10 +62,9 @@ IS_REGION = re.compile(
r'^\s*(?P<country>[a-z]{2})-(?P<area>[a-z]+)-(?P<no>[0-9]+)\s*$', re.I) r'^\s*(?P<country>[a-z]{2})-(?P<area>[a-z]+)-(?P<no>[0-9]+)\s*$', re.I)
# Extend HTTP Error Messages # Extend HTTP Error Messages
AWS_HTTP_ERROR_MAP = HTTP_ERROR_MAP.copy() AWS_HTTP_ERROR_MAP = {
AWS_HTTP_ERROR_MAP.update({
403: 'Unauthorized - Invalid Access/Secret Key Combination.', 403: 'Unauthorized - Invalid Access/Secret Key Combination.',
}) }
class NotifySNS(NotifyBase): class NotifySNS(NotifyBase):
@ -152,7 +150,7 @@ class NotifySNS(NotifyBase):
if recipients is None: if recipients is None:
recipients = [] recipients = []
elif compat_is_basestring(recipients): elif isinstance(recipients, six.string_types):
recipients = [x for x in filter(bool, LIST_DELIM.split( recipients = [x for x in filter(bool, LIST_DELIM.split(
recipients, recipients,
))] ))]
@ -301,22 +299,21 @@ class NotifySNS(NotifyBase):
if r.status_code != requests.codes.ok: if r.status_code != requests.codes.ok:
# We had a problem # We had a problem
try: status_str = \
NotifyBase.http_response_code_lookup(
r.status_code, AWS_HTTP_ERROR_MAP)
self.logger.warning( self.logger.warning(
'Failed to send AWS notification to ' 'Failed to send AWS notification to {}: '
'"%s": %s (error=%s).' % ( '{}{}error={}.'.format(
to, to,
AWS_HTTP_ERROR_MAP[r.status_code], status_str,
', ' if status_str else '',
r.status_code)) r.status_code))
except KeyError: self.logger.debug('Response Details:\r\n{}'.format(r.content))
self.logger.warning(
'Failed to send AWS notification to '
'"%s" (error=%s).' % (to, r.status_code))
self.logger.debug('Response Details: %s' % r.text) return (False, NotifySNS.aws_response_to_dict(r.content))
return (False, NotifySNS.aws_response_to_dict(r.text))
else: else:
self.logger.info( self.logger.info(
@ -330,7 +327,7 @@ class NotifySNS(NotifyBase):
self.logger.debug('Socket Exception: %s' % str(e)) self.logger.debug('Socket Exception: %s' % str(e))
return (False, NotifySNS.aws_response_to_dict(None)) return (False, NotifySNS.aws_response_to_dict(None))
return (True, NotifySNS.aws_response_to_dict(r.text)) return (True, NotifySNS.aws_response_to_dict(r.content))
def aws_prepare_request(self, payload, reference=None): def aws_prepare_request(self, payload, reference=None):
""" """

View File

@ -36,15 +36,14 @@
# #
# #
import re import re
import six
import requests import requests
from json import dumps from json import dumps
from time import time from time import time
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
from .NotifyBase import HTTP_ERROR_MAP
from ..common import NotifyImageSize from ..common import NotifyImageSize
from ..common import NotifyType from ..common import NotifyType
from ..utils import compat_is_basestring
# Token required as part of the API request # Token required as part of the API request
# /AAAAAAAAA/........./........................ # /AAAAAAAAA/........./........................
@ -62,10 +61,9 @@ VALIDATE_TOKEN_C = re.compile(r'[A-Za-z0-9]{24}')
SLACK_DEFAULT_USER = 'apprise' SLACK_DEFAULT_USER = 'apprise'
# Extend HTTP Error Messages # Extend HTTP Error Messages
SLACK_HTTP_ERROR_MAP = HTTP_ERROR_MAP.copy() SLACK_HTTP_ERROR_MAP = {
SLACK_HTTP_ERROR_MAP.update({
401: 'Unauthorized - Invalid Token.', 401: 'Unauthorized - Invalid Token.',
}) }
# Used to break path apart into list of channels # Used to break path apart into list of channels
CHANNEL_LIST_DELIM = re.compile(r'[ \t\r\n,#\\/]+') CHANNEL_LIST_DELIM = re.compile(r'[ \t\r\n,#\\/]+')
@ -143,7 +141,7 @@ class NotifySlack(NotifyBase):
self.logger.warning( self.logger.warning(
'No user was specified; using %s.' % SLACK_DEFAULT_USER) 'No user was specified; using %s.' % SLACK_DEFAULT_USER)
if compat_is_basestring(channels): if isinstance(channels, six.string_types):
self.channels = [x for x in filter(bool, CHANNEL_LIST_DELIM.split( self.channels = [x for x in filter(bool, CHANNEL_LIST_DELIM.split(
channels, channels,
))] ))]
@ -186,7 +184,7 @@ class NotifySlack(NotifyBase):
} }
# error tracking (used for function return) # error tracking (used for function return)
notify_okay = True has_error = False
# Perform Formatting # Perform Formatting
title = self._re_formatting_rules.sub( # pragma: no branch title = self._re_formatting_rules.sub( # pragma: no branch
@ -214,6 +212,8 @@ class NotifySlack(NotifyBase):
channel, channel,
) )
) )
# Mark our failure
has_error = True
continue continue
if len(channel) > 1 and channel[0] == '+': if len(channel) > 1 and channel[0] == '+':
@ -263,28 +263,28 @@ class NotifySlack(NotifyBase):
) )
if r.status_code != requests.codes.ok: if r.status_code != requests.codes.ok:
# We had a problem # We had a problem
try: status_str = \
NotifyBase.http_response_code_lookup(
r.status_code, SLACK_HTTP_ERROR_MAP)
self.logger.warning( self.logger.warning(
'Failed to send Slack:%s ' 'Failed to send Slack notification to {}: '
'notification: %s (error=%s).' % ( '{}{}error={}.'.format(
channel, channel,
SLACK_HTTP_ERROR_MAP[r.status_code], status_str,
', ' if status_str else '',
r.status_code)) r.status_code))
except KeyError: self.logger.debug(
self.logger.warning( 'Response Details:\r\n{}'.format(r.content))
'Failed to send Slack:%s '
'notification (error=%s).' % (
channel,
r.status_code))
# self.logger.debug('Response Details: %s' % r.content) # Mark our failure
has_error = True
# Return; we're done continue
notify_okay = False
else: else:
self.logger.info('Sent Slack notification.') self.logger.info(
'Sent Slack notification to {}.'.format(channel))
except requests.RequestException as e: except requests.RequestException as e:
self.logger.warning( self.logger.warning(
@ -292,9 +292,12 @@ class NotifySlack(NotifyBase):
channel) + 'notification.' channel) + 'notification.'
) )
self.logger.debug('Socket Exception: %s' % str(e)) self.logger.debug('Socket Exception: %s' % str(e))
notify_okay = False
return notify_okay # Mark our failure
has_error = True
continue
return not has_error
def url(self): def url(self):
""" """

View File

@ -58,7 +58,6 @@ from json import loads
from json import dumps from json import dumps
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
from .NotifyBase import HTTP_ERROR_MAP
from ..common import NotifyType from ..common import NotifyType
from ..common import NotifyImageSize from ..common import NotifyImageSize
from ..common import NotifyFormat from ..common import NotifyFormat
@ -120,14 +119,15 @@ class NotifyTelegram(NotifyBase):
except AttributeError: except AttributeError:
# Token was None # Token was None
self.logger.warning('No Bot Token was specified.') err = 'No Bot Token was specified.'
raise TypeError('No Bot Token was specified.') self.logger.warning(err)
raise TypeError(err)
result = VALIDATE_BOT_TOKEN.match(self.bot_token) result = VALIDATE_BOT_TOKEN.match(self.bot_token)
if not result: if not result:
raise TypeError( err = 'The Bot Token specified (%s) is invalid.' % bot_token
'The Bot Token specified (%s) is invalid.' % bot_token, self.logger.warning(err)
) raise TypeError(err)
# Store our Bot Token # Store our Bot Token
self.bot_token = result.group('key') self.bot_token = result.group('key')
@ -146,8 +146,9 @@ class NotifyTelegram(NotifyBase):
self.chat_ids.append(str(_id)) self.chat_ids.append(str(_id))
if len(self.chat_ids) == 0: if len(self.chat_ids) == 0:
self.logger.warning('No chat_id(s) were specified.') err = 'No chat_id(s) were specified.'
raise TypeError('No chat_id(s) were specified.') self.logger.warning(err)
raise TypeError(err)
# Track whether or not we want to send an image with our notification # Track whether or not we want to send an image with our notification
# or not. # or not.
@ -171,8 +172,7 @@ class NotifyTelegram(NotifyBase):
if not path: if not path:
# No image to send # No image to send
self.logger.debug( self.logger.debug(
'Telegram Image does not exist for %s' % ( 'Telegram Image does not exist for %s' % (notify_type))
notify_type))
return None return None
files = {'photo': (basename(path), open(path), 'rb')} files = {'photo': (basename(path), open(path), 'rb')}
@ -195,19 +195,18 @@ class NotifyTelegram(NotifyBase):
if r.status_code != requests.codes.ok: if r.status_code != requests.codes.ok:
# We had a problem # We had a problem
try: status_str = \
NotifyBase.http_response_code_lookup(r.status_code)
self.logger.warning( self.logger.warning(
'Failed to post Telegram Image: ' 'Failed to send Telegram Image: '
'%s (error=%s).' % ( '{}{}error={}.'.format(
HTTP_ERROR_MAP[r.status_code], status_str,
', ' if status_str else '',
r.status_code)) r.status_code))
except KeyError: self.logger.debug('Response Details:\r\n{}'.format(r.content))
self.logger.warning(
'Failed to detect Telegram Image. (error=%s).' % (
r.status_code))
# self.logger.debug('Response Details: %s' % r.raw.read())
return False return False
except requests.RequestException as e: except requests.RequestException as e:
@ -248,6 +247,8 @@ class NotifyTelegram(NotifyBase):
if r.status_code != requests.codes.ok: if r.status_code != requests.codes.ok:
# We had a problem # We had a problem
status_str = \
NotifyBase.http_response_code_lookup(r.status_code)
try: try:
# Try to get the error message if we can: # Try to get the error message if we can:
@ -256,30 +257,26 @@ class NotifyTelegram(NotifyBase):
except Exception: except Exception:
error_msg = None error_msg = None
try:
if error_msg: if error_msg:
self.logger.warning( self.logger.warning(
'Failed to detect Telegram user: (%s) %s.' % ( 'Failed to detect the Telegram user: (%s) %s.' % (
r.status_code, error_msg)) r.status_code, error_msg))
else: else:
self.logger.warning( self.logger.warning(
'Failed to detect Telegram user: ' 'Failed to detect the Telegram user: '
'%s (error=%s).' % ( '{}{}error={}.'.format(
HTTP_ERROR_MAP[r.status_code], status_str,
', ' if status_str else '',
r.status_code)) r.status_code))
except KeyError: self.logger.debug('Response Details:\r\n{}'.format(r.content))
self.logger.warning(
'Failed to detect Telegram user. (error=%s).' % (
r.status_code))
# self.logger.debug('Response Details: %s' % r.raw.read())
return 0 return 0
except requests.RequestException as e: except requests.RequestException as e:
self.logger.warning( self.logger.warning(
'A connection error occured detecting Telegram User.') 'A connection error occured detecting the Telegram User.')
self.logger.debug('Socket Exception: %s' % str(e)) self.logger.debug('Socket Exception: %s' % str(e))
return 0 return 0
@ -403,6 +400,8 @@ class NotifyTelegram(NotifyBase):
chat_id, chat_id,
) )
) )
# Flag our error
has_error = True has_error = True
continue continue
@ -438,6 +437,8 @@ class NotifyTelegram(NotifyBase):
if r.status_code != requests.codes.ok: if r.status_code != requests.codes.ok:
# We had a problem # We had a problem
status_str = \
NotifyBase.http_response_code_lookup(r.status_code)
try: try:
# Try to get the error message if we can: # Try to get the error message if we can:
@ -446,32 +447,19 @@ class NotifyTelegram(NotifyBase):
except Exception: except Exception:
error_msg = None error_msg = None
try:
if error_msg:
self.logger.warning( self.logger.warning(
'Failed to send Telegram:%s ' 'Failed to send Telegram notification to {}: '
'notification: (%s) %s.' % ( '{}, error={}.'.format(
payload['chat_id'], payload['chat_id'],
r.status_code, error_msg)) error_msg if error_msg else status_str,
else:
self.logger.warning(
'Failed to send Telegram:%s '
'notification: %s (error=%s).' % (
payload['chat_id'],
HTTP_ERROR_MAP[r.status_code],
r.status_code)) r.status_code))
except KeyError: self.logger.debug(
self.logger.warning( 'Response Details:\r\n{}'.format(r.content))
'Failed to send Telegram:%s '
'notification (error=%s).' % (
payload['chat_id'], r.status_code))
# self.logger.debug('Response Details: %s' % r.raw.read())
# Flag our error # Flag our error
has_error = True has_error = True
continue
else: else:
self.logger.info('Sent Telegram notification.') self.logger.info('Sent Telegram notification.')
@ -482,7 +470,10 @@ class NotifyTelegram(NotifyBase):
payload['chat_id']) + 'notification.' payload['chat_id']) + 'notification.'
) )
self.logger.debug('Socket Exception: %s' % str(e)) self.logger.debug('Socket Exception: %s' % str(e))
# Flag our error
has_error = True has_error = True
continue
return not has_error return not has_error

View File

@ -27,7 +27,6 @@ import requests
from json import dumps from json import dumps
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
from .NotifyBase import HTTP_ERROR_MAP
from ..common import NotifyType from ..common import NotifyType
from ..common import NotifyImageSize from ..common import NotifyImageSize
@ -204,17 +203,17 @@ class NotifyXBMC(NotifyBase):
) )
if r.status_code != requests.codes.ok: if r.status_code != requests.codes.ok:
# We had a problem # We had a problem
try: status_str = \
NotifyBase.http_response_code_lookup(r.status_code)
self.logger.warning( self.logger.warning(
'Failed to send XBMC/KODI notification: ' 'Failed to send XBMC/KODI notification: '
'%s (error=%s).' % ( '{}{}error={}.'.format(
HTTP_ERROR_MAP[r.status_code], status_str,
', ' if status_str else '',
r.status_code)) r.status_code))
except KeyError: self.logger.debug('Response Details:\r\n{}'.format(r.content))
self.logger.warning(
'Failed to send XBMC/KODI notification '
'(error=%s).' % r.status_code)
# Return; we're done # Return; we're done
return False return False

View File

@ -24,13 +24,12 @@
# THE SOFTWARE. # THE SOFTWARE.
import re import re
import six
import requests import requests
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
from .NotifyBase import HTTP_ERROR_MAP
from ..common import NotifyImageSize from ..common import NotifyImageSize
from ..common import NotifyType from ..common import NotifyType
from ..utils import compat_is_basestring
class NotifyXML(NotifyBase): class NotifyXML(NotifyBase):
@ -89,7 +88,7 @@ class NotifyXML(NotifyBase):
self.schema = 'http' self.schema = 'http'
self.fullpath = kwargs.get('fullpath') self.fullpath = kwargs.get('fullpath')
if not compat_is_basestring(self.fullpath): if not isinstance(self.fullpath, six.string_types):
self.fullpath = '/' self.fullpath = '/'
self.headers = {} self.headers = {}
@ -190,17 +189,18 @@ class NotifyXML(NotifyBase):
verify=self.verify_certificate, verify=self.verify_certificate,
) )
if r.status_code != requests.codes.ok: if r.status_code != requests.codes.ok:
try: # We had a problem
status_str = \
NotifyBase.http_response_code_lookup(r.status_code)
self.logger.warning( self.logger.warning(
'Failed to send XML notification: ' 'Failed to send XML notification: '
'%s (error=%s).' % ( '{}{}error={}.'.format(
HTTP_ERROR_MAP[r.status_code], status_str,
', ' if status_str else '',
r.status_code)) r.status_code))
except KeyError: self.logger.debug('Response Details:\r\n{}'.format(r.content))
self.logger.warning(
'Failed to send XML notification '
'(error=%s).' % r.status_code)
# Return; we're done # Return; we're done
return False return False

View File

@ -23,8 +23,9 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE. # THE SOFTWARE.
# Used for Testing; specifically test_email_plugin.py needs access import sys
# to the modules WEBBASE_LOOKUP_TABLE and WebBaseLogin objects import six
from . import NotifyEmail as NotifyEmailBase from . import NotifyEmail as NotifyEmailBase
from .NotifyBoxcar import NotifyBoxcar from .NotifyBoxcar import NotifyBoxcar
@ -64,6 +65,10 @@ from ..common import NOTIFY_IMAGE_SIZES
from ..common import NotifyType from ..common import NotifyType
from ..common import NOTIFY_TYPES from ..common import NOTIFY_TYPES
# Maintains a mapping of all of the Notification services
SCHEMA_MAP = {}
__all__ = [ __all__ = [
# Notification Services # Notification Services
'NotifyBoxcar', 'NotifyDBus', 'NotifyEmail', 'NotifyEmby', 'NotifyDiscord', 'NotifyBoxcar', 'NotifyDBus', 'NotifyEmail', 'NotifyEmby', 'NotifyDiscord',
@ -89,3 +94,51 @@ __all__ = [
# tweepy (used for NotifyTwitter Testing) # tweepy (used for NotifyTwitter Testing)
'tweepy', 'tweepy',
] ]
# 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()

View File

@ -24,7 +24,7 @@
# THE SOFTWARE. # THE SOFTWARE.
import re import re
import six
from os.path import expanduser from os.path import expanduser
try: try:
@ -98,6 +98,18 @@ NOTIFY_CUSTOM_DEL_TOKENS = re.compile(r'^-(?P<key>.*)\s*')
# Used for attempting to acquire the schema if the URL can't be parsed. # Used for attempting to acquire the schema if the URL can't be parsed.
GET_SCHEMA_RE = re.compile(r'\s*(?P<schema>[a-z0-9]{2,9})://.*$', re.I) GET_SCHEMA_RE = re.compile(r'\s*(?P<schema>[a-z0-9]{2,9})://.*$', re.I)
# Regular expression based and expanded from:
# http://www.regular-expressions.info/email.html
GET_EMAIL_RE = re.compile(
r"((?P<label>[^+]+)\+)?"
r"(?P<userid>[a-z0-9$%=_~-]+"
r"(?:\.[a-z0-9$%+=_~-]+)"
r"*)@(?P<domain>(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+"
r"[a-z0-9](?:[a-z0-9-]*"
r"[a-z0-9]))?",
re.IGNORECASE,
)
def is_hostname(hostname): def is_hostname(hostname):
""" """
@ -113,18 +125,18 @@ def is_hostname(hostname):
return all(allowed.match(x) for x in hostname.split(".")) return all(allowed.match(x) for x in hostname.split("."))
def compat_is_basestring(content): def is_email(address):
""" """Determine if the specified entry is an email address
Python 3 support for checking if content is unicode and/or
of a string type
"""
try:
# Python v2.x
return isinstance(content, basestring)
except NameError: Args:
# Python v3.x address (str): The string you want to check.
return isinstance(content, str)
Returns:
bool: Returns True if the address specified is an email address
and False if it isn't.
"""
return GET_EMAIL_RE.match(address) is not None
def tidy_path(path): def tidy_path(path):
@ -245,7 +257,7 @@ def parse_url(url, default_schema='http', verify_host=True):
content could not be extracted. content could not be extracted.
""" """
if not compat_is_basestring(url): if not isinstance(url, six.string_types):
# Simple error checking # Simple error checking
return None return None
@ -391,10 +403,10 @@ def parse_url(url, default_schema='http', verify_host=True):
# Re-assemble cleaned up version of the url # Re-assemble cleaned up version of the url
result['url'] = '%s://' % result['schema'] result['url'] = '%s://' % result['schema']
if compat_is_basestring(result['user']): if isinstance(result['user'], six.string_types):
result['url'] += result['user'] result['url'] += result['user']
if compat_is_basestring(result['password']): if isinstance(result['password'], six.string_types):
result['url'] += ':%s@' % result['password'] result['url'] += ':%s@' % result['password']
else: else:
@ -420,7 +432,7 @@ def parse_bool(arg, default=False):
If the content could not be parsed, then the default is returned. If the content could not be parsed, then the default is returned.
""" """
if compat_is_basestring(arg): if isinstance(arg, six.string_types):
# no = no - False # no = no - False
# of = short for off - False # of = short for off - False
# 0 = int for False # 0 = int for False
@ -473,7 +485,7 @@ def parse_list(*args):
result = [] result = []
for arg in args: for arg in args:
if compat_is_basestring(arg): if isinstance(arg, six.string_types):
result += re.split(STRING_DELIMITERS, arg) result += re.split(STRING_DELIMITERS, arg)
elif isinstance(arg, (set, list, tuple)): elif isinstance(arg, (set, list, tuple)):
@ -494,3 +506,60 @@ def parse_list(*args):
# a list, we need to change it into a list object to remain compatible with # a list, we need to change it into a list object to remain compatible with
# both distribution types. # both distribution types.
return sorted([x for x in filter(bool, list(set(result)))]) return sorted([x for x in filter(bool, list(set(result)))])
def is_exclusive_match(logic, data):
"""
The data variable should always be a set of strings that the logic can be
compared against. It should be a set. If it isn't already, then it will
be converted as such. These identify the tags themselves.
Our logic should be a list as well:
- top level entries are treated as an 'or'
- second level (or more) entries are treated as 'and'
examples:
logic="tagA, tagB" = tagA or tagB
logic=['tagA', 'tagB'] = tagA or tagB
logic=[('tagA', 'tagC'), 'tagB'] = (tagA and tagC) or tagB
logic=[('tagB', 'tagC')] = tagB and tagC
"""
if logic is None:
# If there is no logic to apply then we're done early
return True
elif isinstance(logic, six.string_types):
# Update our logic to support our delimiters
logic = set(parse_list(logic))
if not isinstance(logic, (list, tuple, set)):
# garbage input
return False
# using the data detected; determine if we'll allow the
# notification to be sent or not
matched = (len(logic) == 0)
# Every entry here will be or'ed with the next
for entry in logic:
if not isinstance(entry, (six.string_types, list, tuple, set)):
# Garbage entry in our logic found
return False
# treat these entries as though all elements found
# must exist in the notification service
entries = set(parse_list(entry))
if len(entries.intersection(data)) == len(entries):
# our set contains all of the entries found
# in our notification data set
matched = True
break
# else: keep looking
# Return True if we matched against our logic (or simply none was
# specified).
return matched

View File

@ -24,22 +24,28 @@
# THE SOFTWARE. # THE SOFTWARE.
from __future__ import print_function from __future__ import print_function
import six
import pytest
import requests
import mock
from os import chmod from os import chmod
from os import getuid from os import getuid
from os.path import dirname from os.path import dirname
from apprise import Apprise from apprise import Apprise
from apprise import AppriseAsset from apprise import AppriseAsset
from apprise.utils import compat_is_basestring
from apprise.Apprise import SCHEMA_MAP
from apprise import NotifyBase from apprise import NotifyBase
from apprise import NotifyType from apprise import NotifyType
from apprise import NotifyFormat from apprise import NotifyFormat
from apprise import NotifyImageSize from apprise import NotifyImageSize
from apprise import __version__ from apprise import __version__
from apprise.Apprise import __load_matrix
import pytest from apprise.plugins import SCHEMA_MAP
import requests from apprise.plugins import __load_matrix
import mock
# Disable logging for a cleaner testing output
import logging
logging.disable(logging.CRITICAL)
def test_apprise(): def test_apprise():
@ -89,12 +95,12 @@ def test_apprise():
assert(len(a) == 2) assert(len(a) == 2)
# We can retrieve elements from our list too by reference: # We can retrieve elements from our list too by reference:
assert(compat_is_basestring(a[0].url()) is True) assert(isinstance(a[0].url(), six.string_types) is True)
# We can iterate over our list too: # We can iterate over our list too:
count = 0 count = 0
for o in a: for o in a:
assert(compat_is_basestring(o.url()) is True) assert(isinstance(o.url(), six.string_types) is True)
count += 1 count += 1
# verify that we did indeed iterate over each element # verify that we did indeed iterate over each element
assert(len(a) == count) assert(len(a) == count)
@ -242,6 +248,10 @@ def test_apprise():
a.add(plugin) a.add(plugin)
assert(len(a) == 1) assert(len(a) == 1)
# We can add entries as a list too (to add more then one)
a.add([plugin, plugin, plugin])
assert(len(a) == 4)
# Reset our object again # Reset our object again
a.clear() a.clear()
try: try:
@ -659,4 +669,4 @@ def test_apprise_details():
# All plugins must have a name defined; the below generates # All plugins must have a name defined; the below generates
# a list of entrys that do not have a string defined. # a list of entrys that do not have a string defined.
assert(not len([x['service_name'] for x in details['schemas'] assert(not len([x['service_name'] for x in details['schemas']
if not compat_is_basestring(x['service_name'])])) if not isinstance(x['service_name'], six.string_types)]))

626
test/test_apprise_config.py Normal file
View File

@ -0,0 +1,626 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# 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 io
import mock
from apprise import NotifyFormat
from apprise.Apprise import Apprise
from apprise.AppriseConfig import AppriseConfig
from apprise.AppriseAsset import AppriseAsset
from apprise.config.ConfigBase import ConfigBase
from apprise.plugins.NotifyBase import NotifyBase
from apprise.config import SCHEMA_MAP as CONFIG_SCHEMA_MAP
from apprise.plugins import SCHEMA_MAP as NOTIFY_SCHEMA_MAP
from apprise.config import __load_matrix
from apprise.config.ConfigFile import ConfigFile
# Disable logging for a cleaner testing output
import logging
logging.disable(logging.CRITICAL)
def test_apprise_config(tmpdir):
"""
API: AppriseConfig basic testing
"""
# Create ourselves a config object
ac = AppriseConfig()
# There are no servers loaded
assert len(ac) == 0
# lets try anyway
assert len(ac.servers()) == 0
t = tmpdir.mkdir("simple-formatting").join("apprise")
t.write("""
# A comment line over top of a URL
mailto://usera:pass@gmail.com
# A line with mulitiple tag assignments to it
taga,tagb=gnome://
# Event if there is accidental leading spaces, this configuation
# is accepting of htat and will not exclude them
tagc=kde://
# A very poorly structured url
sns://:@/
# Just 1 token provided causes exception
sns://T1JJ3T3L2/
""")
# Create ourselves a config object
ac = AppriseConfig(paths=str(t))
# One configuration file should have been found
assert len(ac) == 1
# We should be able to read our 3 servers from that
assert len(ac.servers()) == 3
# Get our URL back
assert isinstance(ac[0].url(), six.string_types)
# Test cases where our URL is invalid
t = tmpdir.mkdir("strange-lines").join("apprise")
t.write("""
# basicly this consists of defined tags and no url
tag=
""")
# Create ourselves a config object
ac = AppriseConfig(paths=str(t), asset=AppriseAsset())
# One configuration file should have been found
assert len(ac) == 1
# No urls were set
assert len(ac.servers()) == 0
# Create a ConfigBase object
cb = ConfigBase()
# Test adding of all entries
assert ac.add(configs=cb, asset=AppriseAsset(), tag='test') is True
# Test adding of all entries
assert ac.add(
configs=['file://?', ], asset=AppriseAsset(), tag='test') is False
# Test the adding of garbage
assert ac.add(configs=object()) is False
# Try again but enforce our format
ac = AppriseConfig(paths='file://{}?format=text'.format(str(t)))
# One configuration file should have been found
assert len(ac) == 1
# No urls were set
assert len(ac.servers()) == 0
#
# Test Internatialization and the handling of unicode characters
#
istr = """
# Iñtërnâtiônàlization Testing
windows://"""
if six.PY2:
# decode string into unicode
istr = istr.decode('utf-8')
# Write our content to our file
t = tmpdir.mkdir("internationalization").join("apprise")
with io.open(str(t), 'wb') as f:
f.write(istr.encode('latin-1'))
# Create ourselves a config object
ac = AppriseConfig(paths=str(t))
# One configuration file should have been found
assert len(ac) == 1
# This will fail because our default encoding is utf-8; however the file
# we opened was not; it was latin-1 and could not be parsed.
assert len(ac.servers()) == 0
# Test iterator
count = 0
for entry in ac:
count += 1
assert len(ac) == count
# We can fix this though; set our encoding to latin-1
ac = AppriseConfig(paths='file://{}?encoding=latin-1'.format(str(t)))
# One configuration file should have been found
assert len(ac) == 1
# Our URL should be found
assert len(ac.servers()) == 1
# Get our URL back
assert isinstance(ac[0].url(), six.string_types)
# pop an entry from our list
assert isinstance(ac.pop(0), ConfigBase) is True
# Determine we have no more configuration entries loaded
assert len(ac) == 0
#
# Test buffer handling (and overflow)
t = tmpdir.mkdir("buffer-handling").join("apprise")
buf = "gnome://"
t.write(buf)
# Reset our config object
ac.clear()
# Create ourselves a config object
ac = AppriseConfig(paths=str(t))
# update our length to be the size of our actual file
ac[0].max_buffer_size = len(buf)
# One configuration file should have been found
assert len(ac) == 1
assert len(ac.servers()) == 1
# update our buffer size to be slightly smaller then what we allow
ac[0].max_buffer_size = len(buf) - 1
# Content is automatically cached; so even though we adjusted the buffer
# above, our results have been cached so we get a 1 response.
assert len(ac.servers()) == 1
# Now do the same check but force a flushed cache
assert len(ac.servers(cache=False)) == 0
def test_apprise_multi_config_entries(tmpdir):
"""
API: AppriseConfig basic multi-adding functionality
"""
# temporary file to work with
t = tmpdir.mkdir("apprise-multi-add").join("apprise")
buf = """
good://hostname
"""
t.write(buf)
# temporary empty file to work with
te = tmpdir.join("apprise-multi-add", "apprise-empty")
te.write("")
# Define our good:// url
class GoodNotification(NotifyBase):
def __init__(self, **kwargs):
super(GoodNotification, self).__init__(
notify_format=NotifyFormat.HTML, **kwargs)
def notify(self, **kwargs):
# Pretend everything is okay
return True
# Store our good notification in our schema map
NOTIFY_SCHEMA_MAP['good'] = GoodNotification
# Create ourselves a config object
ac = AppriseConfig()
# There are no servers loaded
assert len(ac) == 0
# Support adding of muilt strings and objects:
assert ac.add(configs=(str(t), str(t))) is True
assert ac.add(configs=(
ConfigFile(path=str(te)), ConfigFile(path=str(t)))) is True
# don't support the adding of invalid content
assert ac.add(configs=(object(), object())) is False
assert ac.add(configs=object()) is False
# Try to pop an element out of range
try:
ac.server_pop(len(ac.servers()))
# We should have thrown an exception here
assert False
except IndexError:
# We expect to be here
assert True
# Pop our elements
while len(ac.servers()) > 0:
assert isinstance(
ac.server_pop(len(ac.servers()) - 1), NotifyBase) is True
def test_apprise_config_tagging(tmpdir):
"""
API: AppriseConfig tagging
"""
# temporary file to work with
t = tmpdir.mkdir("tagging").join("apprise")
buf = "gnome://"
t.write(buf)
# Create ourselves a config object
ac = AppriseConfig()
# Add an item associated with tag a
assert ac.add(configs=str(t), asset=AppriseAsset(), tag='a') is True
# Add an item associated with tag b
assert ac.add(configs=str(t), asset=AppriseAsset(), tag='b') is True
# Add an item associated with tag a or b
assert ac.add(configs=str(t), asset=AppriseAsset(), tag='a,b') is True
# Now filter: a:
assert len(ac.servers(tag='a')) == 2
# Now filter: a or b:
assert len(ac.servers(tag='a,b')) == 3
# Now filter: a and b
assert len(ac.servers(tag=[('a', 'b')])) == 1
def test_apprise_instantiate():
"""
API: AppriseConfig.instantiate()
"""
assert AppriseConfig.instantiate(
'file://?', suppress_exceptions=True) is None
assert AppriseConfig.instantiate(
'invalid://?', suppress_exceptions=True) is None
class BadConfig(ConfigBase):
def __init__(self, **kwargs):
super(BadConfig, self).__init__(**kwargs)
# We fail whenever we're initialized
raise TypeError()
# Store our bad configuration in our schema map
CONFIG_SCHEMA_MAP['bad'] = BadConfig
try:
AppriseConfig.instantiate(
'bad://path', suppress_exceptions=False)
# We should never make it to this line
assert False
except TypeError:
# Exception caught as expected
assert True
# Same call but exceptions suppressed
assert AppriseConfig.instantiate(
'bad://path', suppress_exceptions=True) is None
def test_apprise_config_with_apprise_obj(tmpdir):
"""
API: ConfigBase.parse_inaccessible_text_file
"""
# temporary file to work with
t = tmpdir.mkdir("apprise-obj").join("apprise")
buf = """
good://hostname
localhost=good://localhost
"""
t.write(buf)
# Define our good:// url
class GoodNotification(NotifyBase):
def __init__(self, **kwargs):
super(GoodNotification, self).__init__(
notify_format=NotifyFormat.HTML, **kwargs)
def notify(self, **kwargs):
# Pretend everything is okay
return True
# Store our good notification in our schema map
NOTIFY_SCHEMA_MAP['good'] = GoodNotification
# Create ourselves a config object
ac = AppriseConfig(cache=False)
# Nothing loaded yet
assert len(ac) == 0
# Add an item associated with tag a
assert ac.add(configs=str(t), asset=AppriseAsset(), tag='a') is True
# One configuration file
assert len(ac) == 1
# 2 services found in it
assert len(ac.servers()) == 2
# Pop one of them (at index 0)
ac.server_pop(0)
# Verify that it no longer listed
assert len(ac.servers()) == 1
# Test our ability to add Config objects to our apprise object
a = Apprise()
# Add our configuration object
assert a.add(servers=ac) is True
# Detect our 1 entry (originally there were 2 but we deleted one)
assert len(a) == 1
# Notify our service
assert a.notify(body='apprise configuration power!') is True
# Add our configuration object
assert a.add(
servers=[AppriseConfig(str(t)), AppriseConfig(str(t))]) is True
# Detect our 5 loaded entries now; 1 from first config, and another
# 2x2 based on adding our list above
assert len(a) == 5
# We can't add garbage
assert a.add(servers=object()) is False
assert a.add(servers=[object(), object()]) is False
# Our length is unchanged
assert len(a) == 5
# reference index 0 of our list
ref = a[0]
assert isinstance(ref, NotifyBase) is True
# Our length is unchanged
assert len(a) == 5
# pop the index
ref_popped = a.pop(0)
# Verify our response
assert isinstance(ref_popped, NotifyBase) is True
# Our length drops by 1
assert len(a) == 4
# Content popped is the same as one referenced by index
# earlier
assert ref == ref_popped
# pop an index out of range
try:
a.pop(len(a))
# We'll thrown an IndexError and not make it this far
assert False
except IndexError:
# As expected
assert True
# Our length remains unchanged
assert len(a) == 4
# Reference content out of range
try:
a[len(a)]
# We'll thrown an IndexError and not make it this far
assert False
except IndexError:
# As expected
assert True
# reference index at the end of our list
ref = a[len(a) - 1]
# Verify our response
assert isinstance(ref, NotifyBase) is True
# Our length stays the same
assert len(a) == 4
# We can pop from the back of the list without a problem too
ref_popped = a.pop(len(a) - 1)
# Verify our response
assert isinstance(ref_popped, NotifyBase) is True
# Content popped is the same as one referenced by index
# earlier
assert ref == ref_popped
# Our length drops by 1
assert len(a) == 3
# Now we'll test adding another element to the list so that it mixes up
# our response object.
# Below we add 3 different types, a ConfigBase, NotifyBase, and URL
assert a.add(
servers=[
ConfigFile(path=(str(t))),
'good://another.host',
GoodNotification(**{'host': 'nuxref.com'})]) is True
# Our length increases by 4 (2 entries in the config file, + 2 others)
assert len(a) == 7
# reference index at the end of our list
ref = a[len(a) - 1]
# Verify our response
assert isinstance(ref, NotifyBase) is True
# We can pop from the back of the list without a problem too
ref_popped = a.pop(len(a) - 1)
# Verify our response
assert isinstance(ref_popped, NotifyBase) is True
# Content popped is the same as one referenced by index
# earlier
assert ref == ref_popped
# Our length drops by 1
assert len(a) == 6
# pop our list
while len(a) > 0:
assert isinstance(a.pop(len(a) - 1), NotifyBase) is True
def test_apprise_config_matrix_load():
"""
API: AppriseConfig() matrix initialization
"""
import apprise
class ConfigDummy(ConfigBase):
"""
A dummy wrapper for testing the different options in the load_matrix
function
"""
# The default descriptive name associated with the Notification
service_name = 'dummy'
# protocol as tuple
protocol = ('uh', 'oh')
# secure protocol as tuple
secure_protocol = ('no', 'yes')
class ConfigDummy2(ConfigBase):
"""
A dummy wrapper for testing the different options in the load_matrix
function
"""
# The default descriptive name associated with the Notification
service_name = 'dummy2'
# secure protocol as tuple
secure_protocol = ('true', 'false')
class ConfigDummy3(ConfigBase):
"""
A dummy wrapper for testing the different options in the load_matrix
function
"""
# The default descriptive name associated with the Notification
service_name = 'dummy3'
# secure protocol as string
secure_protocol = 'true'
class ConfigDummy4(ConfigBase):
"""
A dummy wrapper for testing the different options in the load_matrix
function
"""
# The default descriptive name associated with the Notification
service_name = 'dummy4'
# protocol as string
protocol = 'true'
# Generate ourselfs a fake entry
apprise.config.ConfigDummy = ConfigDummy
apprise.config.ConfigDummy2 = ConfigDummy2
apprise.config.ConfigDummy3 = ConfigDummy3
apprise.config.ConfigDummy4 = ConfigDummy4
__load_matrix()
# Call it again so we detect our entries already loaded
__load_matrix()
@mock.patch('os.path.getsize')
def test_config_base_parse_inaccessible_text_file(mock_getsize, tmpdir):
"""
API: ConfigBase.parse_inaccessible_text_file
"""
# temporary file to work with
t = tmpdir.mkdir("inaccessible").join("apprise")
buf = "gnome://"
t.write(buf)
# Set getsize return value
mock_getsize.return_value = None
mock_getsize.side_effect = OSError
# Create ourselves a config object
ac = AppriseConfig(paths=str(t))
# The following internally throws an exception but still counts
# as a loaded configuration file
assert len(ac) == 1
# Thus no notifications are loaded
assert len(ac.servers()) == 0
def test_config_base_parse_yaml_file(tmpdir):
"""
API: ConfigBase.parse_yaml_file
"""
t = tmpdir.mkdir("empty-file").join("apprise.yml")
t.write("")
# Create ourselves a config object
ac = AppriseConfig(paths=str(t))
# The number of configuration files that exist
assert len(ac) == 1
# no notifications are loaded
assert len(ac.servers()) == 0

View File

@ -27,26 +27,30 @@ from __future__ import print_function
from apprise import cli from apprise import cli
from apprise import NotifyBase from apprise import NotifyBase
from click.testing import CliRunner from click.testing import CliRunner
from apprise.Apprise import SCHEMA_MAP from apprise.plugins import SCHEMA_MAP
# Disable logging for a cleaner testing output
import logging
logging.disable(logging.CRITICAL)
def test_apprise_cli(): def test_apprise_cli(tmpdir):
""" """
API: Apprise() CLI API: Apprise() CLI
""" """
class GoodNotification(NotifyBase): class GoodNotification(NotifyBase):
def __init__(self, **kwargs): def __init__(self, *args, **kwargs):
super(GoodNotification, self).__init__() super(GoodNotification, self).__init__(*args, **kwargs)
def notify(self, **kwargs): def notify(self, **kwargs):
# Pretend everything is okay # Pretend everything is okay
return True return True
class BadNotification(NotifyBase): class BadNotification(NotifyBase):
def __init__(self, **kwargs): def __init__(self, *args, **kwargs):
super(BadNotification, self).__init__() super(BadNotification, self).__init__(*args, **kwargs)
def notify(self, **kwargs): def notify(self, **kwargs):
# Pretend everything is okay # Pretend everything is okay
@ -70,6 +74,10 @@ def test_apprise_cli():
result = runner.invoke(cli.main, ['-vvv']) result = runner.invoke(cli.main, ['-vvv'])
assert result.exit_code == 1 assert result.exit_code == 1
# Display version information and exit
result = runner.invoke(cli.main, ['-V'])
assert result.exit_code == 0
result = runner.invoke(cli.main, [ result = runner.invoke(cli.main, [
'-t', 'test title', '-t', 'test title',
'-b', 'test body', '-b', 'test body',
@ -89,3 +97,52 @@ def test_apprise_cli():
'bad://localhost', 'bad://localhost',
]) ])
assert result.exit_code == 1 assert result.exit_code == 1
# Write a simple text based configuration file
t = tmpdir.mkdir("apprise-obj").join("apprise")
buf = """
taga,tagb=good://localhost
tagc=good://nuxref.com
"""
t.write(buf)
# This will read our configuration and send 2 notices to
# each of the above defined good:// entries
result = runner.invoke(cli.main, [
'-b', 'test config',
'--config', str(t),
])
assert result.exit_code == 0
# This will send out 1 notification because our tag matches
# one of the entries above
# translation: has taga
result = runner.invoke(cli.main, [
'-b', 'has taga',
'--config', str(t),
'--tag', 'taga',
])
assert result.exit_code == 0
# This will send out 0 notification because our tag requests that we meet
# at least 2 tags associated with the same notification service (which
# isn't the case above)
# translation: has taga AND tagd
result = runner.invoke(cli.main, [
'-b', 'has taga AND tagd',
'--config', str(t),
'--tag', 'taga,tagd',
])
assert result.exit_code == 0
# This will send out 2 notifications because by specifying 2 tag
# entries, we 'or' them together:
# translation: has taga or tagb or tagd
result = runner.invoke(cli.main, [
'-b', 'has taga OR tagc OR tagd',
'--config', str(t),
'--tag', 'taga',
'--tag', 'tagc',
'--tag', 'tagd',
])
assert result.exit_code == 0

168
test/test_config_base.py Normal file
View File

@ -0,0 +1,168 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# 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.
from apprise.AppriseAsset import AppriseAsset
from apprise.config.ConfigBase import ConfigBase
# Disable logging for a cleaner testing output
import logging
logging.disable(logging.CRITICAL)
def test_config_base():
"""
API: ConfigBase() object
"""
# invalid types throw exceptions
try:
ConfigBase(**{'format': 'invalid'})
# We should never reach here as an exception should be thrown
assert(False)
except TypeError:
assert(True)
# Notify format types are not the same as ConfigBase ones
try:
ConfigBase(**{'format': 'markdown'})
# We should never reach here as an exception should be thrown
assert(False)
except TypeError:
assert(True)
cb = ConfigBase(**{'format': 'yaml'})
assert isinstance(cb, ConfigBase)
cb = ConfigBase(**{'format': 'text'})
assert isinstance(cb, ConfigBase)
# Set encoding
cb = ConfigBase(encoding='utf-8', format='text')
assert isinstance(cb, ConfigBase)
# read is not supported in the base object; only the children
assert cb.read() is None
# There are no servers loaded on a freshly created object
assert len(cb.servers()) == 0
# Unsupported URLs are not parsed
assert ConfigBase.parse_url(url='invalid://') is None
# Valid URL & Valid Format
results = ConfigBase.parse_url(
url='file://relative/path?format=yaml&encoding=latin-1')
assert isinstance(results, dict)
# These are moved into the root
assert results.get('format') == 'yaml'
assert results.get('encoding') == 'latin-1'
# But they also exist in the qsd location
assert isinstance(results.get('qsd'), dict)
assert results['qsd'].get('encoding') == 'latin-1'
assert results['qsd'].get('format') == 'yaml'
# Valid URL & Invalid Format
results = ConfigBase.parse_url(
url='file://relative/path?format=invalid&encoding=latin-1')
assert isinstance(results, dict)
# Only encoding is moved into the root
assert 'format' not in results
assert results.get('encoding') == 'latin-1'
# But they will always exist in the qsd location
assert isinstance(results.get('qsd'), dict)
assert results['qsd'].get('encoding') == 'latin-1'
assert results['qsd'].get('format') == 'invalid'
def test_config_base_config_parse_text():
"""
API: ConfigBase.config_parse_text object
"""
result = ConfigBase.config_parse_text("""
# A comment line over top of a URL
mailto://userb:pass@gmail.com
# A line with mulitiple tag assignments to it
taga,tagb=kde://
""", asset=AppriseAsset())
# We expect to parse 2 entries from the above
assert isinstance(result, list)
assert len(result) == 2
assert len(result[0].tags) == 0
# Our second element will have tags associated with it
assert len(result[1].tags) == 2
assert 'taga' in result[1].tags
assert 'tagb' in result[1].tags
# Here is a similar result set however this one has an invalid line
# in it which invalidates the entire file
result = ConfigBase.config_parse_text("""
# A comment line over top of a URL
mailto://userc:pass@gmail.com
# A line with mulitiple tag assignments to it
taga,tagb=windows://
I am an invalid line that does not follow any of the Apprise file rules!
""")
# We expect to parse 0 entries from the above
assert isinstance(result, list)
assert len(result) == 0
# More invalid data
result = ConfigBase.config_parse_text("""
# An invalid URL
invalid://user:pass@gmail.com
# A tag without a url
taga=
# A very poorly structured url
sns://:@/
# Just 1 token provided
sns://T1JJ3T3L2/
""")
# We expect to parse 0 entries from the above
assert isinstance(result, list)
assert len(result) == 0
# Here is an empty file
result = ConfigBase.config_parse_text('')
# We expect to parse 0 entries from the above
assert isinstance(result, list)
assert len(result) == 0

104
test/test_config_file.py Normal file
View File

@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# 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 mock
from apprise.config.ConfigFile import ConfigFile
from apprise.plugins.NotifyBase import NotifyBase
# Disable logging for a cleaner testing output
import logging
logging.disable(logging.CRITICAL)
def test_config_file(tmpdir):
"""
API: ConfigFile() object
"""
assert ConfigFile.parse_url('garbage://') is None
# Test cases where our URL is invalid
t = tmpdir.mkdir("testing").join("apprise")
t.write("gnome://")
assert ConfigFile.parse_url('file://?'.format(str(t))) is None
# Initialize our object
cf = ConfigFile(path=str(t), format='text')
# one entry added
assert len(cf) == 1
assert isinstance(cf.url(), six.string_types) is True
# Testing of pop
cf = ConfigFile(path=str(t), format='text')
ref = cf[0]
assert isinstance(ref, NotifyBase) is True
ref_popped = cf.pop(0)
assert isinstance(ref_popped, NotifyBase) is True
assert ref == ref_popped
assert len(cf) == 0
# reference to calls on initial reference
cf = ConfigFile(path=str(t), format='text')
assert isinstance(cf.pop(0), NotifyBase) is True
cf = ConfigFile(path=str(t), format='text')
assert isinstance(cf[0], NotifyBase) is True
# Second reference actually uses cache
assert isinstance(cf[0], NotifyBase) is True
cf = ConfigFile(path=str(t), format='text')
# Itereator creation (nothing needed to assert here)
iter(cf)
# Second reference actually uses cache
iter(cf)
@mock.patch('io.open')
def test_config_file_exceptions(mock_open, tmpdir):
"""
API: ConfigFile() i/o exception handling
"""
# Test cases where our URL is invalid
t = tmpdir.mkdir("testing").join("apprise")
t.write("gnome://")
mock_open.side_effect = OSError
# Initialize our object
cf = ConfigFile(path=str(t), format='text')
# Internal Exception would have been thrown and this would fail
assert cf.read() is None

173
test/test_config_http.py Normal file
View File

@ -0,0 +1,173 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# 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 mock
import requests
from apprise.common import ConfigFormat
from apprise.config.ConfigHTTP import ConfigHTTP
from apprise.plugins.NotifyBase import NotifyBase
from apprise.plugins import SCHEMA_MAP
# Disable logging for a cleaner testing output
import logging
logging.disable(logging.CRITICAL)
# Some exception handling we'll use
REQUEST_EXCEPTIONS = (
requests.ConnectionError(
0, 'requests.ConnectionError() not handled'),
requests.RequestException(
0, 'requests.RequestException() not handled'),
requests.HTTPError(
0, 'requests.HTTPError() not handled'),
requests.ReadTimeout(
0, 'requests.ReadTimeout() not handled'),
requests.TooManyRedirects(
0, 'requests.TooManyRedirects() not handled'),
)
@mock.patch('requests.get')
@mock.patch('requests.post')
def test_config_http(mock_post, mock_get):
"""
API: ConfigHTTP() object
"""
# Define our good:// url
class GoodNotification(NotifyBase):
def __init__(self, *args, **kwargs):
super(GoodNotification, self).__init__(*args, **kwargs)
def notify(self, *args, **kwargs):
# Pretend everything is okay
return True
# Store our good notification in our schema map
SCHEMA_MAP['good'] = GoodNotification
# Prepare Mock
dummy_request = mock.Mock()
dummy_request.close.return_value = True
dummy_request.status_code = requests.codes.ok
dummy_request.content = """
taga,tagb=good://server01
"""
dummy_request.headers = {
'Content-Length': len(dummy_request.content),
'Content-Type': 'text/plain',
}
mock_post.return_value = dummy_request
mock_get.return_value = dummy_request
assert ConfigHTTP.parse_url('garbage://') is None
results = ConfigHTTP.parse_url('http://user:pass@localhost?+key=value')
assert isinstance(results, dict)
ch = ConfigHTTP(**results)
assert isinstance(ch.url(), six.string_types) is True
assert isinstance(ch.read(), six.string_types) is True
results = ConfigHTTP.parse_url('http://localhost:8080/path/')
assert isinstance(results, dict)
ch = ConfigHTTP(**results)
assert isinstance(ch.url(), six.string_types) is True
assert isinstance(ch.read(), six.string_types) is True
results = ConfigHTTP.parse_url('http://user@localhost?format=text')
assert isinstance(results, dict)
ch = ConfigHTTP(**results)
assert isinstance(ch.url(), six.string_types) is True
assert isinstance(ch.read(), six.string_types) is True
results = ConfigHTTP.parse_url('https://localhost')
assert isinstance(results, dict)
ch = ConfigHTTP(**results)
assert isinstance(ch.url(), six.string_types) is True
assert isinstance(ch.read(), six.string_types) is True
# one entry added
assert len(ch) == 1
# Testing of pop
ch = ConfigHTTP(**results)
ref = ch[0]
assert isinstance(ref, NotifyBase) is True
ref_popped = ch.pop(0)
assert isinstance(ref_popped, NotifyBase) is True
assert ref == ref_popped
assert len(ch) == 0
# reference to calls on initial reference
ch = ConfigHTTP(**results)
assert isinstance(ch.pop(0), NotifyBase) is True
ch = ConfigHTTP(**results)
assert isinstance(ch[0], NotifyBase) is True
# Second reference actually uses cache
assert isinstance(ch[0], NotifyBase) is True
ch = ConfigHTTP(**results)
# Itereator creation (nothing needed to assert here)
iter(ch)
# Second reference actually uses cache
iter(ch)
# Test a buffer size limit reach
ch.max_buffer_size = len(dummy_request.content)
assert isinstance(ch.read(), six.string_types) is True
# Test YAML detection
yaml_supported_types = (
'text/yaml', 'text/x-yaml', 'application/yaml', 'application/x-yaml')
for st in yaml_supported_types:
dummy_request.headers['Content-Type'] = st
ch.default_config_format = ConfigFormat.TEXT
assert isinstance(ch.read(), six.string_types) is True
assert ch.default_config_format == ConfigFormat.YAML
ch.max_buffer_size = len(dummy_request.content) - 1
assert ch.read() is None
# Test an invalid return code
dummy_request.status_code = 400
assert ch.read() is None
ch.max_error_buffer_size = 0
assert ch.read() is None
# Exception handling
for _exception in REQUEST_EXCEPTIONS:
mock_post.side_effect = _exception
mock_get.side_effect = _exception
assert ch.read() is None

View File

@ -23,15 +23,19 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE. # THE SOFTWARE.
import re
import six
import mock
import smtplib
from apprise import plugins from apprise import plugins
from apprise import NotifyType from apprise import NotifyType
from apprise import Apprise from apprise import Apprise
from apprise.utils import compat_is_basestring
from apprise.plugins import NotifyEmailBase from apprise.plugins import NotifyEmailBase
import smtplib # Disable logging for a cleaner testing output
import mock import logging
import re logging.disable(logging.CRITICAL)
TEST_URLS = ( TEST_URLS = (
@ -232,7 +236,7 @@ def test_email_plugin(mock_smtp, mock_smtpssl):
if isinstance(obj, plugins.NotifyBase.NotifyBase): if isinstance(obj, plugins.NotifyBase.NotifyBase):
# We loaded okay; now lets make sure we can reverse this url # We loaded okay; now lets make sure we can reverse this url
assert(compat_is_basestring(obj.url()) is True) assert(isinstance(obj.url(), six.string_types) is True)
# Instantiate the exact same object again using the URL from # Instantiate the exact same object again using the URL from
# the one that was already created properly # the one that was already created properly

View File

@ -23,12 +23,12 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE. # THE SOFTWARE.
import six
import pytest import pytest
import mock import mock
import sys import sys
import types import types
import apprise import apprise
from apprise.utils import compat_is_basestring
try: try:
# Python v3.4+ # Python v3.4+
@ -41,6 +41,10 @@ except ImportError:
# Python v2.7 # Python v2.7
pass pass
# Disable logging for a cleaner testing output
import logging
logging.disable(logging.CRITICAL)
if 'dbus' not in sys.modules: if 'dbus' not in sys.modules:
# Environment doesn't allow for dbus # Environment doesn't allow for dbus
pytest.skip("Skipping dbus-python based tests", allow_module_level=True) pytest.skip("Skipping dbus-python based tests", allow_module_level=True)
@ -225,7 +229,7 @@ def test_dbus_plugin(mock_mainloop, mock_byte, mock_bytearray,
obj.duration = 0 obj.duration = 0
# Test url() call # Test url() call
assert(compat_is_basestring(obj.url()) is True) assert(isinstance(obj.url(), six.string_types) is True)
# Our notification succeeds even though the gi library was not loaded # Our notification succeeds even though the gi library was not loaded
assert(obj.notify(title='title', body='body', assert(obj.notify(title='title', body='body',
@ -255,7 +259,7 @@ def test_dbus_plugin(mock_mainloop, mock_byte, mock_bytearray,
obj.duration = 0 obj.duration = 0
# Test url() call # Test url() call
assert(compat_is_basestring(obj.url()) is True) assert(isinstance(obj.url(), six.string_types) is True)
# Our notification succeeds even though the gi library was not loaded # Our notification succeeds even though the gi library was not loaded
assert(obj.notify(title='title', body='body', assert(obj.notify(title='title', body='body',
@ -277,7 +281,7 @@ def test_dbus_plugin(mock_mainloop, mock_byte, mock_bytearray,
obj.duration = 0 obj.duration = 0
# Test url() call # Test url() call
assert(compat_is_basestring(obj.url()) is True) assert(isinstance(obj.url(), six.string_types) is True)
# Our notification fail because the dbus library wasn't present # Our notification fail because the dbus library wasn't present
assert(obj.notify(title='title', body='body', assert(obj.notify(title='title', body='body',

View File

@ -23,12 +23,12 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE. # THE SOFTWARE.
import six
import mock import mock
import sys import sys
import types import types
import apprise import apprise
from apprise.utils import compat_is_basestring
try: try:
# Python v3.4+ # Python v3.4+
@ -41,6 +41,10 @@ except ImportError:
# Python v2.7 # Python v2.7
pass pass
# Disable logging for a cleaner testing output
import logging
logging.disable(logging.CRITICAL)
def test_gnome_plugin(): def test_gnome_plugin():
""" """
@ -115,7 +119,7 @@ def test_gnome_plugin():
assert(obj._enabled is True) assert(obj._enabled is True)
# Test url() call # Test url() call
assert(compat_is_basestring(obj.url()) is True) assert(isinstance(obj.url(), six.string_types) is True)
# test notifications # test notifications
assert(obj.notify(title='title', body='body', assert(obj.notify(title='title', body='body',

View File

@ -23,12 +23,15 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE. # THE SOFTWARE.
import mock
import six
from apprise import plugins from apprise import plugins
from apprise import NotifyType from apprise import NotifyType
from apprise import Apprise from apprise import Apprise
from apprise.utils import compat_is_basestring
import mock # Disable logging for a cleaner testing output
import logging
logging.disable(logging.CRITICAL)
TEST_URLS = ( TEST_URLS = (
@ -222,7 +225,7 @@ def test_growl_plugin(mock_gntp):
if isinstance(obj, plugins.NotifyBase.NotifyBase): if isinstance(obj, plugins.NotifyBase.NotifyBase):
# We loaded okay; now lets make sure we can reverse this url # We loaded okay; now lets make sure we can reverse this url
assert(compat_is_basestring(obj.url()) is True) assert(isinstance(obj.url(), six.string_types) is True)
# Instantiate the exact same object again using the URL from # Instantiate the exact same object again using the URL from
# the one that was already created properly # the one that was already created properly

View File

@ -22,7 +22,7 @@
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # 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 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE. # THE SOFTWARE.
import six
from datetime import datetime from datetime import datetime
from datetime import timedelta from datetime import timedelta
@ -30,7 +30,10 @@ from apprise.plugins.NotifyBase import NotifyBase
from apprise import NotifyType from apprise import NotifyType
from apprise import NotifyImageSize from apprise import NotifyImageSize
from timeit import default_timer from timeit import default_timer
from apprise.utils import compat_is_basestring
# Disable logging for a cleaner testing output
import logging
logging.disable(logging.CRITICAL)
def test_notify_base(): def test_notify_base():
@ -41,7 +44,7 @@ def test_notify_base():
# invalid types throw exceptions # invalid types throw exceptions
try: try:
nb = NotifyBase(**{'format': 'invalid'}) NotifyBase(**{'format': 'invalid'})
# We should never reach here as an exception should be thrown # We should never reach here as an exception should be thrown
assert(False) assert(False)
@ -50,7 +53,7 @@ def test_notify_base():
# invalid types throw exceptions # invalid types throw exceptions
try: try:
nb = NotifyBase(**{'overflow': 'invalid'}) NotifyBase(**{'overflow': 'invalid'})
# We should never reach here as an exception should be thrown # We should never reach here as an exception should be thrown
assert(False) assert(False)
@ -154,8 +157,9 @@ def test_notify_base():
# Color handling # Color handling
assert nb.color(notify_type='invalid') is None assert nb.color(notify_type='invalid') is None
assert compat_is_basestring( assert isinstance(
nb.color(notify_type=NotifyType.INFO, color_type=None)) nb.color(notify_type=NotifyType.INFO, color_type=None),
six.string_types)
assert isinstance( assert isinstance(
nb.color(notify_type=NotifyType.INFO, color_type=int), int) nb.color(notify_type=NotifyType.INFO, color_type=int), int)
assert isinstance( assert isinstance(
@ -192,13 +196,6 @@ def test_notify_base():
'/path/?name=Dr%20Disrespect', unquote=True) == \ '/path/?name=Dr%20Disrespect', unquote=True) == \
['path', '?name=Dr', 'Disrespect'] ['path', '?name=Dr', 'Disrespect']
# Test is_email
assert NotifyBase.is_email('test@gmail.com') is True
assert NotifyBase.is_email('invalid.com') is False
# Test is_hostname
assert NotifyBase.is_hostname('example.com') is True
# Test quote # Test quote
assert NotifyBase.unquote('%20') == ' ' assert NotifyBase.unquote('%20') == ' '
assert NotifyBase.quote(' ') == '%20' assert NotifyBase.quote(' ') == '%20'

View File

@ -23,13 +23,18 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE. # THE SOFTWARE.
import six
from apprise import plugins from apprise import plugins
from apprise import NotifyType from apprise import NotifyType
from apprise import Apprise from apprise import Apprise
from apprise.utils import compat_is_basestring
import mock import mock
# Disable logging for a cleaner testing output
import logging
logging.disable(logging.CRITICAL)
TEST_URLS = ( TEST_URLS = (
################################## ##################################
# NotifyPushjet # NotifyPushjet
@ -121,7 +126,7 @@ def test_plugin(mock_refresh, mock_send):
if isinstance(obj, plugins.NotifyBase.NotifyBase): if isinstance(obj, plugins.NotifyBase.NotifyBase):
# We loaded okay; now lets make sure we can reverse this url # We loaded okay; now lets make sure we can reverse this url
assert(compat_is_basestring(obj.url()) is True) assert(isinstance(obj.url(), six.string_types) is True)
# Instantiate the exact same object again using the URL from # Instantiate the exact same object again using the URL from
# the one that was already created properly # the one that was already created properly

View File

@ -23,22 +23,25 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE. # THE SOFTWARE.
from apprise import plugins import six
from apprise import NotifyType import requests
from apprise import NotifyBase import mock
from apprise import Apprise
from apprise import AppriseAsset
from apprise.utils import compat_is_basestring
from apprise.common import NotifyFormat
from apprise.common import OverflowMode
from json import dumps from json import dumps
from random import choice from random import choice
from string import ascii_uppercase as str_alpha from string import ascii_uppercase as str_alpha
from string import digits as str_num from string import digits as str_num
import requests from apprise import plugins
import mock from apprise import NotifyType
from apprise import NotifyBase
from apprise import Apprise
from apprise import AppriseAsset
from apprise.common import NotifyFormat
from apprise.common import OverflowMode
# Disable logging for a cleaner testing output
import logging
logging.disable(logging.CRITICAL)
# Some exception handling we'll use # Some exception handling we'll use
REQUEST_EXCEPTIONS = ( REQUEST_EXCEPTIONS = (
@ -142,6 +145,13 @@ TEST_URLS = (
'instance': plugins.NotifyDiscord, 'instance': plugins.NotifyDiscord,
'requests_response_code': requests.codes.no_content, 'requests_response_code': requests.codes.no_content,
}), }),
('discord://%s/%s?format=markdown&footer=Yes&thumbnail=Yes' % (
'i' * 24, 't' * 64), {
'instance': plugins.NotifyDiscord,
'requests_response_code': requests.codes.no_content,
# don't include an image by default
'include_image': False,
}),
('discord://%s/%s?format=markdown&avatar=No&footer=No' % ( ('discord://%s/%s?format=markdown&avatar=No&footer=No' % (
'i' * 24, 't' * 64), { 'i' * 24, 't' * 64), {
'instance': plugins.NotifyDiscord, 'instance': plugins.NotifyDiscord,
@ -338,6 +348,7 @@ TEST_URLS = (
# APIKey + bad device # APIKey + bad device
('join://%s/%s' % ('a' * 32, 'd' * 10), { ('join://%s/%s' % ('a' * 32, 'd' * 10), {
'instance': plugins.NotifyJoin, 'instance': plugins.NotifyJoin,
'response': False,
}), }),
# APIKey + bad url # APIKey + bad url
('join://:@/', { ('join://:@/', {
@ -1129,6 +1140,9 @@ TEST_URLS = (
# No username specified; this is still okay as we sub in # No username specified; this is still okay as we sub in
# default; The one invalid channel is skipped when sending a message # default; The one invalid channel is skipped when sending a message
'instance': plugins.NotifySlack, 'instance': plugins.NotifySlack,
# There is an invalid channel that we will fail to deliver to
# as a result the response type will be false
'response': False,
}), }),
('slack://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#channel', { ('slack://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#channel', {
# No username specified; this is still okay as we sub in # No username specified; this is still okay as we sub in
@ -1524,7 +1538,7 @@ def test_rest_plugins(mock_post, mock_get):
# Allow us to force the server response text to be something other then # Allow us to force the server response text to be something other then
# the defaults # the defaults
requests_response_text = meta.get('requests_response_text') requests_response_text = meta.get('requests_response_text')
if not compat_is_basestring(requests_response_text): if not isinstance(requests_response_text, six.string_types):
# Convert to string # Convert to string
requests_response_text = dumps(requests_response_text) requests_response_text = dumps(requests_response_text)
@ -1541,17 +1555,14 @@ def test_rest_plugins(mock_post, mock_get):
else: else:
# Disable images # Disable images
asset = AppriseAsset(image_path_mask=False, image_url_mask=False) asset = AppriseAsset(image_path_mask=False, image_url_mask=False)
asset.image_url_logo = None
test_requests_exceptions = meta.get( test_requests_exceptions = meta.get(
'test_requests_exceptions', False) 'test_requests_exceptions', False)
# A request # A request
robj = mock.Mock() robj = mock.Mock()
setattr(robj, 'raw', mock.Mock()) robj.content = u''
# Allow raw.read() calls
robj.raw.read.return_value = ''
robj.text = ''
robj.content = ''
mock_get.return_value = robj mock_get.return_value = robj
mock_post.return_value = robj mock_post.return_value = robj
@ -1561,8 +1572,8 @@ def test_rest_plugins(mock_post, mock_get):
mock_get.return_value.status_code = requests_response_code mock_get.return_value.status_code = requests_response_code
# Handle our default text response # Handle our default text response
mock_get.return_value.text = requests_response_text mock_get.return_value.content = requests_response_text
mock_post.return_value.text = requests_response_text mock_post.return_value.content = requests_response_text
# Ensure there is no side effect set # Ensure there is no side effect set
mock_post.side_effect = None mock_post.side_effect = None
@ -1592,7 +1603,7 @@ def test_rest_plugins(mock_post, mock_get):
if isinstance(obj, plugins.NotifyBase.NotifyBase): if isinstance(obj, plugins.NotifyBase.NotifyBase):
# We loaded okay; now lets make sure we can reverse this url # We loaded okay; now lets make sure we can reverse this url
assert(compat_is_basestring(obj.url()) is True) assert(isinstance(obj.url(), six.string_types) is True)
# Instantiate the exact same object again using the URL from # Instantiate the exact same object again using the URL from
# the one that was already created properly # the one that was already created properly
@ -1666,14 +1677,18 @@ def test_rest_plugins(mock_post, mock_get):
except AssertionError: except AssertionError:
# Don't mess with these entries # Don't mess with these entries
print('%s AssertionError' % url)
raise raise
except Exception as e: except Exception as e:
# Check that we were expecting this exception to happen # Check that we were expecting this exception to happen
try:
if not isinstance(e, response): if not isinstance(e, response):
raise raise
except TypeError:
print('%s Unhandled response %s' % (url, type(e)))
raise
# #
# Stage 2: without title defined # Stage 2: without title defined
# #
@ -1901,9 +1916,7 @@ def test_notify_emby_plugin_login(mock_post, mock_get):
# Our login flat out fails if we don't have proper parseable content # Our login flat out fails if we don't have proper parseable content
mock_post.return_value.content = u'' mock_post.return_value.content = u''
mock_post.return_value.text = ''
mock_get.return_value.content = mock_post.return_value.content mock_get.return_value.content = mock_post.return_value.content
mock_get.return_value.text = mock_post.return_value.text
# KeyError handling # KeyError handling
mock_post.return_value.status_code = 999 mock_post.return_value.status_code = 999
@ -1944,9 +1957,7 @@ def test_notify_emby_plugin_login(mock_post, mock_get):
mock_post.return_value.content = dumps({ mock_post.return_value.content = dumps({
u'AccessToken': u'0000-0000-0000-0000', u'AccessToken': u'0000-0000-0000-0000',
}) })
mock_post.return_value.text = str(mock_post.return_value.content)
mock_get.return_value.content = mock_post.return_value.content mock_get.return_value.content = mock_post.return_value.content
mock_get.return_value.text = mock_post.return_value.text
obj = Apprise.instantiate('emby://l2g:l2gpass@localhost') obj = Apprise.instantiate('emby://l2g:l2gpass@localhost')
assert isinstance(obj, plugins.NotifyEmby) assert isinstance(obj, plugins.NotifyEmby)
@ -1964,9 +1975,7 @@ def test_notify_emby_plugin_login(mock_post, mock_get):
u'Id': u'123abc', u'Id': u'123abc',
u'AccessToken': u'0000-0000-0000-0000', u'AccessToken': u'0000-0000-0000-0000',
}) })
mock_post.return_value.text = str(mock_post.return_value.content)
mock_get.return_value.content = mock_post.return_value.content mock_get.return_value.content = mock_post.return_value.content
mock_get.return_value.text = mock_post.return_value.text
obj = Apprise.instantiate('emby://l2g:l2gpass@localhost') obj = Apprise.instantiate('emby://l2g:l2gpass@localhost')
assert isinstance(obj, plugins.NotifyEmby) assert isinstance(obj, plugins.NotifyEmby)
@ -1985,9 +1994,7 @@ def test_notify_emby_plugin_login(mock_post, mock_get):
}, },
u'AccessToken': u'0000-0000-0000-0000', u'AccessToken': u'0000-0000-0000-0000',
}) })
mock_post.return_value.text = str(mock_post.return_value.content)
mock_get.return_value.content = mock_post.return_value.content mock_get.return_value.content = mock_post.return_value.content
mock_get.return_value.text = mock_post.return_value.text
# Login # Login
assert obj.login() is True assert obj.login() is True
@ -2036,9 +2043,7 @@ def test_notify_emby_plugin_sessions(mock_post, mock_get, mock_logout,
# Our login flat out fails if we don't have proper parseable content # Our login flat out fails if we don't have proper parseable content
mock_post.return_value.content = u'' mock_post.return_value.content = u''
mock_post.return_value.text = ''
mock_get.return_value.content = mock_post.return_value.content mock_get.return_value.content = mock_post.return_value.content
mock_get.return_value.text = mock_post.return_value.text
# KeyError handling # KeyError handling
mock_post.return_value.status_code = 999 mock_post.return_value.status_code = 999
@ -2056,9 +2061,7 @@ def test_notify_emby_plugin_sessions(mock_post, mock_get, mock_logout,
mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.status_code = requests.codes.ok
mock_get.return_value.status_code = requests.codes.ok mock_get.return_value.status_code = requests.codes.ok
mock_post.return_value.text = str(mock_post.return_value.content)
mock_get.return_value.content = mock_post.return_value.content mock_get.return_value.content = mock_post.return_value.content
mock_get.return_value.text = mock_post.return_value.text
# Disable the port completely # Disable the port completely
obj.port = None obj.port = None
@ -2079,9 +2082,7 @@ def test_notify_emby_plugin_sessions(mock_post, mock_get, mock_logout,
u'InvalidEntry': None, u'InvalidEntry': None,
}, },
]) ])
mock_post.return_value.text = str(mock_post.return_value.content)
mock_get.return_value.content = mock_post.return_value.content mock_get.return_value.content = mock_post.return_value.content
mock_get.return_value.text = mock_post.return_value.text
sessions = obj.sessions(user_controlled=True) sessions = obj.sessions(user_controlled=True)
assert isinstance(sessions, dict) is True assert isinstance(sessions, dict) is True
@ -2138,9 +2139,7 @@ def test_notify_emby_plugin_logout(mock_post, mock_get, mock_login):
# Our login flat out fails if we don't have proper parseable content # Our login flat out fails if we don't have proper parseable content
mock_post.return_value.content = u'' mock_post.return_value.content = u''
mock_post.return_value.text = ''
mock_get.return_value.content = mock_post.return_value.content mock_get.return_value.content = mock_post.return_value.content
mock_get.return_value.text = mock_post.return_value.text
# KeyError handling # KeyError handling
mock_post.return_value.status_code = 999 mock_post.return_value.status_code = 999
@ -2158,9 +2157,7 @@ def test_notify_emby_plugin_logout(mock_post, mock_get, mock_login):
mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.status_code = requests.codes.ok
mock_get.return_value.status_code = requests.codes.ok mock_get.return_value.status_code = requests.codes.ok
mock_post.return_value.text = str(mock_post.return_value.content)
mock_get.return_value.content = mock_post.return_value.content mock_get.return_value.content = mock_post.return_value.content
mock_get.return_value.text = mock_post.return_value.text
# Disable the port completely # Disable the port completely
obj.port = None obj.port = None
@ -2181,11 +2178,11 @@ def test_notify_emby_plugin_notify(mock_post, mock_get, mock_logout,
# Disable Throttling to speed testing # Disable Throttling to speed testing
plugins.NotifyBase.NotifyBase.request_rate_per_sec = 0 plugins.NotifyBase.NotifyBase.request_rate_per_sec = 0
# Prepare Mock req = requests.Request()
mock_get.return_value = requests.Request() req.status_code = requests.codes.ok
mock_post.return_value = requests.Request() req.content = ''
mock_post.return_value.status_code = requests.codes.ok mock_get.return_value = req
mock_get.return_value.status_code = requests.codes.ok mock_post.return_value = req
# This is done so we don't obstruct our access_token and user_id values # This is done so we don't obstruct our access_token and user_id values
mock_login.return_value = True mock_login.return_value = True
@ -2218,9 +2215,7 @@ def test_notify_emby_plugin_notify(mock_post, mock_get, mock_logout,
# Our login flat out fails if we don't have proper parseable content # Our login flat out fails if we don't have proper parseable content
mock_post.return_value.content = u'' mock_post.return_value.content = u''
mock_post.return_value.text = ''
mock_get.return_value.content = mock_post.return_value.content mock_get.return_value.content = mock_post.return_value.content
mock_get.return_value.text = mock_post.return_value.text
# KeyError handling # KeyError handling
mock_post.return_value.status_code = 999 mock_post.return_value.status_code = 999
@ -2234,9 +2229,7 @@ def test_notify_emby_plugin_notify(mock_post, mock_get, mock_logout,
mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.status_code = requests.codes.ok
mock_get.return_value.status_code = requests.codes.ok mock_get.return_value.status_code = requests.codes.ok
mock_post.return_value.text = str(mock_post.return_value.content)
mock_get.return_value.content = mock_post.return_value.content mock_get.return_value.content = mock_post.return_value.content
mock_get.return_value.text = mock_post.return_value.text
# Disable the port completely # Disable the port completely
obj.port = None obj.port = None
@ -2356,10 +2349,11 @@ def test_notify_join_plugin(mock_post, mock_get):
p = plugins.NotifyJoin(apikey=apikey, devices=[group, device]) p = plugins.NotifyJoin(apikey=apikey, devices=[group, device])
# Prepare our mock responses # Prepare our mock responses
mock_get.return_value = requests.Request() req = requests.Request()
mock_post.return_value = requests.Request() req.status_code = requests.codes.created
mock_post.return_value.status_code = requests.codes.created req.content = ''
mock_get.return_value.status_code = requests.codes.created mock_get.return_value = req
mock_post.return_value = req
# Test notifications without a body or a title; nothing to send # Test notifications without a body or a title; nothing to send
# so we return False # so we return False
@ -2482,8 +2476,6 @@ def test_notify_pushed_plugin(mock_post, mock_get):
mock_post.return_value = requests.Request() mock_post.return_value = requests.Request()
mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.status_code = requests.codes.ok
mock_get.return_value.status_code = requests.codes.ok mock_get.return_value.status_code = requests.codes.ok
mock_post.return_value.text = ''
mock_get.return_value.text = ''
try: try:
obj = plugins.NotifyPushed( obj = plugins.NotifyPushed(
@ -2556,8 +2548,6 @@ def test_notify_pushed_plugin(mock_post, mock_get):
# Prepare Mock to fail # Prepare Mock to fail
mock_post.return_value.status_code = requests.codes.internal_server_error mock_post.return_value.status_code = requests.codes.internal_server_error
mock_get.return_value.status_code = requests.codes.internal_server_error mock_get.return_value.status_code = requests.codes.internal_server_error
mock_post.return_value.text = ''
mock_get.return_value.text = ''
@mock.patch('requests.get') @mock.patch('requests.get')
@ -2646,8 +2636,8 @@ def test_notify_rocketchat_plugin(mock_post, mock_get):
mock_post.return_value = requests.Request() mock_post.return_value = requests.Request()
mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.status_code = requests.codes.ok
mock_get.return_value.status_code = requests.codes.ok mock_get.return_value.status_code = requests.codes.ok
mock_post.return_value.text = '' mock_post.return_value.content = ''
mock_get.return_value.text = '' mock_get.return_value.content = ''
try: try:
obj = plugins.NotifyRocketChat( obj = plugins.NotifyRocketChat(
@ -2701,8 +2691,6 @@ def test_notify_rocketchat_plugin(mock_post, mock_get):
# Prepare Mock to fail # Prepare Mock to fail
mock_post.return_value.status_code = requests.codes.internal_server_error mock_post.return_value.status_code = requests.codes.internal_server_error
mock_get.return_value.status_code = requests.codes.internal_server_error mock_get.return_value.status_code = requests.codes.internal_server_error
mock_post.return_value.text = ''
mock_get.return_value.text = ''
# #
# Send Notification # Send Notification
@ -2732,13 +2720,10 @@ def test_notify_rocketchat_plugin(mock_post, mock_get):
# #
assert obj.logout() is False assert obj.logout() is False
mock_post.return_value.text = ''
# Generate exceptions # Generate exceptions
mock_get.side_effect = requests.ConnectionError( mock_get.side_effect = requests.ConnectionError(
0, 'requests.ConnectionError() not handled') 0, 'requests.ConnectionError() not handled')
mock_post.side_effect = mock_get.side_effect mock_post.side_effect = mock_get.side_effect
mock_get.return_value.text = ''
mock_post.return_value.text = ''
# #
# Send Notification # Send Notification
@ -2824,7 +2809,7 @@ def test_notify_telegram_plugin(mock_post, mock_get):
assert(len(obj.chat_ids) == 2) assert(len(obj.chat_ids) == 2)
# test url call # test url call
assert(compat_is_basestring(obj.url())) assert(isinstance(obj.url(), six.string_types))
# Test that we can load the string we generate back: # Test that we can load the string we generate back:
obj = plugins.NotifyTelegram(**plugins.NotifyTelegram.parse_url(obj.url())) obj = plugins.NotifyTelegram(**plugins.NotifyTelegram.parse_url(obj.url()))
assert(isinstance(obj, plugins.NotifyTelegram)) assert(isinstance(obj, plugins.NotifyTelegram))
@ -2839,7 +2824,7 @@ def test_notify_telegram_plugin(mock_post, mock_get):
response.status_code = requests.codes.internal_server_error response.status_code = requests.codes.internal_server_error
# a error response # a error response
response.text = dumps({ response.content = dumps({
'description': 'test', 'description': 'test',
}) })
mock_get.return_value = response mock_get.return_value = response
@ -3054,7 +3039,7 @@ def test_notify_overflow_truncate():
chunks = obj._apply_overflow( chunks = obj._apply_overflow(
body=body, title=title, overflow=OverflowMode.SPLIT) body=body, title=title, overflow=OverflowMode.SPLIT)
assert len(chunks) == 1 assert len(chunks) == 1
assert body == chunks[0].get('body') assert body.rstrip() == chunks[0].get('body')
assert title[0:TestNotification.title_maxlen] == chunks[0].get('title') assert title[0:TestNotification.title_maxlen] == chunks[0].get('title')
# #

View File

@ -28,6 +28,11 @@ import requests
from apprise import plugins from apprise import plugins
from apprise import Apprise from apprise import Apprise
# Disable logging for a cleaner testing output
import logging
logging.disable(logging.CRITICAL)
TEST_ACCESS_KEY_ID = 'AHIAJGNT76XIMXDBIJYA' TEST_ACCESS_KEY_ID = 'AHIAJGNT76XIMXDBIJYA'
TEST_ACCESS_KEY_SECRET = 'bu1dHSdO22pfaaVy/wmNsdljF4C07D3bndi9PQJ9' TEST_ACCESS_KEY_SECRET = 'bu1dHSdO22pfaaVy/wmNsdljF4C07D3bndi9PQJ9'
TEST_REGION = 'us-east-2' TEST_REGION = 'us-east-2'
@ -326,7 +331,7 @@ def test_aws_topic_handling(mock_post):
# A request # A request
robj = mock.Mock() robj = mock.Mock()
robj.text = '' robj.content = ''
robj.status_code = requests.codes.ok robj.status_code = requests.codes.ok
if data.find('=CreateTopic') >= 0: if data.find('=CreateTopic') >= 0:
@ -361,11 +366,11 @@ def test_aws_topic_handling(mock_post):
# A request # A request
robj = mock.Mock() robj = mock.Mock()
robj.text = '' robj.content = ''
robj.status_code = requests.codes.ok robj.status_code = requests.codes.ok
if data.find('=CreateTopic') >= 0: if data.find('=CreateTopic') >= 0:
robj.text = arn_response robj.content = arn_response
# Manipulate Topic Publishing only (not phone) # Manipulate Topic Publishing only (not phone)
elif data.find('=Publish') >= 0 and data.find('TopicArn=') >= 0: elif data.find('=Publish') >= 0 and data.find('TopicArn=') >= 0:
@ -385,7 +390,7 @@ def test_aws_topic_handling(mock_post):
# Handle case where TopicArn is missing: # Handle case where TopicArn is missing:
robj = mock.Mock() robj = mock.Mock()
robj.text = "<CreateTopicResponse></CreateTopicResponse>" robj.content = "<CreateTopicResponse></CreateTopicResponse>"
robj.status_code = requests.codes.ok robj.status_code = requests.codes.ok
# Assign ourselves a new function # Assign ourselves a new function
@ -394,14 +399,14 @@ def test_aws_topic_handling(mock_post):
# Handle case where we fails get a bad response # Handle case where we fails get a bad response
robj = mock.Mock() robj = mock.Mock()
robj.text = '' robj.content = ''
robj.status_code = requests.codes.bad_request robj.status_code = requests.codes.bad_request
mock_post.return_value = robj mock_post.return_value = robj
assert(a.notify(title='', body='test') is False) assert(a.notify(title='', body='test') is False)
# Handle case where we get a valid response and TopicARN # Handle case where we get a valid response and TopicARN
robj = mock.Mock() robj = mock.Mock()
robj.text = arn_response robj.content = arn_response
robj.status_code = requests.codes.ok robj.status_code = requests.codes.ok
mock_post.return_value = robj mock_post.return_value = robj
# We would have failed to make Post # We would have failed to make Post

View File

@ -28,6 +28,10 @@ from apprise import NotifyType
from apprise import Apprise from apprise import Apprise
import mock import mock
# Disable logging for a cleaner testing output
import logging
logging.disable(logging.CRITICAL)
TEST_URLS = ( TEST_URLS = (
################################## ##################################

View File

@ -34,6 +34,10 @@ except ImportError:
from apprise import utils from apprise import utils
# Disable logging for a cleaner testing output
import logging
logging.disable(logging.CRITICAL)
def test_parse_qsd(): def test_parse_qsd():
"utils: parse_qsd() testing """ "utils: parse_qsd() testing """
@ -387,6 +391,18 @@ def test_is_hostname():
assert utils.is_hostname('') is False assert utils.is_hostname('') is False
def test_is_email():
"""
API: is_email() function
"""
# Valid Emails
assert utils.is_email('test@gmail.com') is True
# Invalid Emails
assert utils.is_email('invalid.com') is False
def test_parse_list(): def test_parse_list():
"utils: parse_list() testing """ "utils: parse_list() testing """
@ -424,3 +440,79 @@ def test_parse_list():
'.divx', '.wmv', '.iso', '.mkv', '.mov', '.mpg', '.avi', '.vob', '.divx', '.wmv', '.iso', '.mkv', '.mov', '.mpg', '.avi', '.vob',
'.xvid', '.mpeg', '.mp4', '.xvid', '.mpeg', '.mp4',
])) ]))
def test_exclusive_match():
"""utils: is_exclusive_match() testing
"""
# No Logic always returns True
assert utils.is_exclusive_match(data=None, logic=None) is True
assert utils.is_exclusive_match(data=None, logic=set()) is True
assert utils.is_exclusive_match(data='', logic=set()) is True
assert utils.is_exclusive_match(data=u'', logic=set()) is True
assert utils.is_exclusive_match(data=u'check', logic=set()) is True
assert utils.is_exclusive_match(
data=['check', 'checkb'], logic=set()) is True
# String delimters are stripped out so that a list can be formed
# the below is just an empty token list
assert utils.is_exclusive_match(data=set(), logic=',; ,') is True
# garbage logic is never an exclusive match
assert utils.is_exclusive_match(data=set(), logic=object()) is False
assert utils.is_exclusive_match(data=set(), logic=[object(), ]) is False
#
# Test with logic:
#
data = set(['abc'])
# def in data
assert utils.is_exclusive_match(
logic='def', data=data) is False
# def in data
assert utils.is_exclusive_match(
logic=['def', ], data=data) is False
# def in data
assert utils.is_exclusive_match(
logic=('def', ), data=data) is False
# def in data
assert utils.is_exclusive_match(
logic=set(['def', ]), data=data) is False
# abc in data
assert utils.is_exclusive_match(
logic=['abc', ], data=data) is True
# abc in data
assert utils.is_exclusive_match(
logic=('abc', ), data=data) is True
# abc in data
assert utils.is_exclusive_match(
logic=set(['abc', ]), data=data) is True
# abc or def in data
assert utils.is_exclusive_match(
logic='abc, def', data=data) is True
#
# Update our data set so we can do more advance checks
#
data = set(['abc', 'def', 'efg', 'xyz'])
# def and abc in data
assert utils.is_exclusive_match(
logic=[('abc', 'def')], data=data) is True
# cba and abc in data
assert utils.is_exclusive_match(
logic=[('cba', 'abc')], data=data) is False
# www or zzz or abc and xyz
assert utils.is_exclusive_match(
logic=['www', 'zzz', ('abc', 'xyz')], data=data) is True
# www or zzz or abc and xyz (strings are valid too)
assert utils.is_exclusive_match(
logic=['www', 'zzz', ('abc, xyz')], data=data) is True
# www or zzz or abc and jjj
assert utils.is_exclusive_match(
logic=['www', 'zzz', ('abc', 'jjj')], data=data) is False

View File

@ -25,11 +25,11 @@
import mock import mock
import sys import sys
import six
import types import types
# Rebuild our Apprise environment # Rebuild our Apprise environment
import apprise import apprise
from apprise.utils import compat_is_basestring
try: try:
# Python v3.4+ # Python v3.4+
@ -42,6 +42,10 @@ except ImportError:
# Python v2.7 # Python v2.7
pass pass
# Disable logging for a cleaner testing output
import logging
logging.disable(logging.CRITICAL)
def test_windows_plugin(): def test_windows_plugin():
""" """
@ -109,7 +113,7 @@ def test_windows_plugin():
obj.duration = 0 obj.duration = 0
# Test URL functionality # Test URL functionality
assert(compat_is_basestring(obj.url()) is True) assert(isinstance(obj.url(), six.string_types) is True)
# Check that it found our mocked environments # Check that it found our mocked environments
assert(obj._enabled is True) assert(obj._enabled is True)