diff --git a/libs/apprise/Apprise.py b/libs/apprise/Apprise.py
index ee199c4b1..31bd2888e 100644
--- a/libs/apprise/Apprise.py
+++ b/libs/apprise/Apprise.py
@@ -30,14 +30,15 @@ from markdown import markdown
from itertools import chain
from .common import NotifyType
from .common import NotifyFormat
+from .common import MATCH_ALL_TAG
from .utils import is_exclusive_match
from .utils import parse_list
from .utils import split_urls
-from .utils import GET_SCHEMA_RE
from .logger import logger
from .AppriseAsset import AppriseAsset
from .AppriseConfig import AppriseConfig
+from .AppriseAttachment import AppriseAttachment
from .AppriseLocale import AppriseLocale
from .config.ConfigBase import ConfigBase
from .plugins.NotifyBase import NotifyBase
@@ -107,38 +108,8 @@ class Apprise(object):
results = None
if isinstance(url, six.string_types):
- # swap hash (#) tag values with their html version
- _url = url.replace('/#', '/%23')
-
- # Attempt to acquire the schema at the very least to allow our
- # plugins to determine if they can make a better interpretation of
- # a URL geared for them
- schema = GET_SCHEMA_RE.match(_url)
- if schema is None:
- logger.error(
- 'Unparseable schema:// found in URL {}.'.format(url))
- return None
-
- # Ensure our schema is always in lower case
- schema = schema.group('schema').lower()
-
- # Some basic validation
- if schema not in plugins.SCHEMA_MAP:
- # Give the user the benefit of the doubt that the user may be
- # using one of the URLs provided to them by their notification
- # service. Before we fail for good, just scan all the plugins
- # that support he native_url() parse function
- results = \
- next((r['plugin'].parse_native_url(_url)
- for r in plugins.MODULE_MAP.values()
- if r['plugin'].parse_native_url(_url) is not None),
- None)
-
- else:
- # 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)
-
+ # Acquire our url tokens
+ results = plugins.url_to_dict(url)
if results is None:
# Failed to parse the server URL
logger.error('Unparseable URL {}.'.format(url))
@@ -273,30 +244,12 @@ class Apprise(object):
"""
self.servers[:] = []
- def notify(self, body, title='', notify_type=NotifyType.INFO,
- body_format=None, tag=None):
+ def find(self, tag=MATCH_ALL_TAG):
"""
- Send a notification to all of the plugins previously loaded.
-
- If the body_format specified is NotifyFormat.MARKDOWN, it will
- be converted to HTML if the Notification type expects this.
-
- if the tag is specified (either a string or a set/list/tuple
- of strings), then only the notifications flagged with that
- tagged value are notified. By default all added services
- are notified (tag=None)
+ Returns an list of all servers matching against the tag specified.
"""
- # Initialize our return result
- status = len(self) > 0
-
- if not (title or body):
- return False
-
- # Tracks conversions
- conversion_map = dict()
-
# Build our tag setup
# - top level entries are treated as an 'or'
# - second level (or more) entries are treated as 'and'
@@ -319,79 +272,135 @@ class Apprise(object):
for server in servers:
# Apply our tag matching based on our defined logic
- if tag is not None and not is_exclusive_match(
- logic=tag, data=server.tags):
- continue
-
- # If our code reaches here, we either did not define a tag (it
- # was set to None), or we did define a tag and the logic above
- # determined we need to notify the service it's associated with
- if server.notify_format not in conversion_map:
- if body_format == NotifyFormat.MARKDOWN and \
- server.notify_format == NotifyFormat.HTML:
-
- # Apply Markdown
- conversion_map[server.notify_format] = markdown(body)
-
- elif body_format == NotifyFormat.TEXT and \
- server.notify_format == NotifyFormat.HTML:
-
- # Basic TEXT to HTML format map; supports keys only
- re_map = {
- # Support Ampersand
- r'&': '&',
-
- # Spaces to for formatting purposes since
- # multiple spaces are treated as one an this may
- # not be the callers intention
- r' ': ' ',
-
- # Tab support
- r'\t': ' ',
-
- # Greater than and Less than Characters
- r'>': '>',
- r'<': '<',
- }
-
- # Compile our map
- re_table = re.compile(
- r'(' + '|'.join(
- map(re.escape, re_map.keys())) + r')',
- re.IGNORECASE,
- )
-
- # Execute our map against our body in addition to
- # swapping out new lines and replacing them with
- conversion_map[server.notify_format] = \
- re.sub(r'\r*\n', ' \r\n',
- re_table.sub(
- lambda x: re_map[x.group()], body))
-
- else:
- # Store entry directly
- conversion_map[server.notify_format] = body
-
- try:
- # Send notification
- if not server.notify(
- body=conversion_map[server.notify_format],
- title=title,
- notify_type=notify_type):
-
- # Toggle our return status flag
- status = False
-
- except TypeError:
- # These our our internally thrown notifications
- status = False
+ if is_exclusive_match(
+ logic=tag, data=server.tags, match_all=MATCH_ALL_TAG):
+ yield server
+ return
+
+ def notify(self, body, title='', notify_type=NotifyType.INFO,
+ body_format=None, tag=MATCH_ALL_TAG, attach=None):
+ """
+ Send a notification to all of the plugins previously loaded.
+
+ If the body_format specified is NotifyFormat.MARKDOWN, it will
+ be converted to HTML if the Notification type expects this.
+
+ if the tag is specified (either a string or a set/list/tuple
+ of strings), then only the notifications flagged with that
+ tagged value are notified. By default all added services
+ are notified (tag=MATCH_ALL_TAG)
+
+ This function returns True if all notifications were successfully
+ sent, False if even just one of them fails, and None if no
+ notifications were sent at all as a result of tag filtering and/or
+ simply having empty configuration files that were read.
+
+ Attach can contain a list of attachment URLs. attach can also be
+ represented by a an AttachBase() (or list of) object(s). This
+ identifies the products you wish to notify
+ """
+
+ if len(self) == 0:
+ # Nothing to notify
+ return False
- except Exception:
- # A catch all so we don't have to abort early
- # just because one of our plugins has a bug in it.
- logger.exception("Notification Exception")
+ # Initialize our return result which only turns to True if we send
+ # at least one valid notification
+ status = None
+
+ if not (title or body):
+ return False
+
+ # Tracks conversions
+ conversion_map = dict()
+
+ # Prepare attachments if required
+ if attach is not None and not isinstance(attach, AppriseAttachment):
+ try:
+ attach = AppriseAttachment(attach, asset=self.asset)
+
+ except TypeError:
+ # bad attachments
+ return False
+
+ # Iterate over our loaded plugins
+ for server in self.find(tag):
+ if status is None:
+ # We have at least one server to notify; change status
+ # to be a default value of True from now (purely an
+ # initialiation at this point)
+ status = True
+
+ # If our code reaches here, we either did not define a tag (it
+ # was set to None), or we did define a tag and the logic above
+ # determined we need to notify the service it's associated with
+ if server.notify_format not in conversion_map:
+ if body_format == NotifyFormat.MARKDOWN and \
+ server.notify_format == NotifyFormat.HTML:
+
+ # Apply Markdown
+ conversion_map[server.notify_format] = markdown(body)
+
+ elif body_format == NotifyFormat.TEXT and \
+ server.notify_format == NotifyFormat.HTML:
+
+ # Basic TEXT to HTML format map; supports keys only
+ re_map = {
+ # Support Ampersand
+ r'&': '&',
+
+ # Spaces to for formatting purposes since
+ # multiple spaces are treated as one an this may
+ # not be the callers intention
+ r' ': ' ',
+
+ # Tab support
+ r'\t': ' ',
+
+ # Greater than and Less than Characters
+ r'>': '>',
+ r'<': '<',
+ }
+
+ # Compile our map
+ re_table = re.compile(
+ r'(' + '|'.join(
+ map(re.escape, re_map.keys())) + r')',
+ re.IGNORECASE,
+ )
+
+ # Execute our map against our body in addition to
+ # swapping out new lines and replacing them with
+ conversion_map[server.notify_format] = \
+ re.sub(r'\r*\n', ' \r\n',
+ re_table.sub(
+ lambda x: re_map[x.group()], body))
+
+ else:
+ # Store entry directly
+ conversion_map[server.notify_format] = body
+
+ try:
+ # Send notification
+ if not server.notify(
+ body=conversion_map[server.notify_format],
+ title=title,
+ notify_type=notify_type,
+ attach=attach):
+
+ # Toggle our return status flag
status = False
+ except TypeError:
+ # These our our internally thrown notifications
+ status = False
+
+ except Exception:
+ # A catch all so we don't have to abort early
+ # just because one of our plugins has a bug in it.
+ logger.exception("Notification Exception")
+ status = False
+
return status
def details(self, lang=None):
@@ -519,6 +528,20 @@ class Apprise(object):
# If we reach here, then we indexed out of range
raise IndexError('list index out of range')
+ def __bool__(self):
+ """
+ Allows the Apprise object to be wrapped in an Python 3.x based 'if
+ statement'. True is returned if at least one service has been loaded.
+ """
+ return len(self) > 0
+
+ def __nonzero__(self):
+ """
+ Allows the Apprise object to be wrapped in an Python 2.x based 'if
+ statement'. True is returned if at least one service has been loaded.
+ """
+ return len(self) > 0
+
def __iter__(self):
"""
Returns an iterator to each of our servers loaded. This includes those
diff --git a/libs/apprise/AppriseConfig.py b/libs/apprise/AppriseConfig.py
index a07ef4b44..95070012a 100644
--- a/libs/apprise/AppriseConfig.py
+++ b/libs/apprise/AppriseConfig.py
@@ -30,6 +30,7 @@ from . import ConfigBase
from . import URLBase
from .AppriseAsset import AppriseAsset
+from .common import MATCH_ALL_TAG
from .utils import GET_SCHEMA_RE
from .utils import parse_list
from .utils import is_exclusive_match
@@ -55,8 +56,19 @@ class AppriseConfig(object):
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.
+ By default we cache our responses so that subsiquent calls does not
+ cause the content to be retrieved again. Setting this to False does
+ mean more then one call can be made to retrieve the (same) data. This
+ method can be somewhat inefficient if disabled and you're set up to
+ make remote calls. Only disable caching if you understand the
+ consequences.
+
+ You can alternatively set the cache value to an int identifying the
+ number of seconds the previously retrieved can exist for before it
+ should be considered expired.
+
+ It's also worth nothing that the cache value is only set to elements
+ that are not already of subclass ConfigBase()
"""
# Initialize a server list of URLs
@@ -66,24 +78,43 @@ class AppriseConfig(object):
self.asset = \
asset if isinstance(asset, AppriseAsset) else AppriseAsset()
+ # Set our cache flag
+ self.cache = cache
+
if paths is not None:
# Store our path(s)
self.add(paths)
return
- def add(self, configs, asset=None, tag=None):
+ def add(self, configs, asset=None, tag=None, cache=True):
"""
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.
+ By default we cache our responses so that subsiquent calls does not
+ cause the content to be retrieved again. Setting this to False does
+ mean more then one call can be made to retrieve the (same) data. This
+ method can be somewhat inefficient if disabled and you're set up to
+ make remote calls. Only disable caching if you understand the
+ consequences.
+
+ You can alternatively set the cache value to an int identifying the
+ number of seconds the previously retrieved can exist for before it
+ should be considered expired.
+
+ It's also worth nothing that the cache value is only set to elements
+ that are not already of subclass ConfigBase()
"""
# Initialize our return status
return_status = True
+ # Initialize our default cache value
+ cache = cache if cache is not None else self.cache
+
if isinstance(asset, AppriseAsset):
# prepare default asset
asset = self.asset
@@ -103,7 +134,7 @@ class AppriseConfig(object):
'specified.'.format(type(configs)))
return False
- # Iterate over our
+ # Iterate over our configuration
for _config in configs:
if isinstance(_config, ConfigBase):
@@ -122,7 +153,8 @@ class AppriseConfig(object):
# Instantiate ourselves an object, this function throws or
# returns None if it fails
- instance = AppriseConfig.instantiate(_config, asset=asset, tag=tag)
+ instance = AppriseConfig.instantiate(
+ _config, asset=asset, tag=tag, cache=cache)
if not isinstance(instance, ConfigBase):
return_status = False
continue
@@ -133,7 +165,7 @@ class AppriseConfig(object):
# Return our status
return return_status
- def servers(self, tag=None, cache=True):
+ def servers(self, tag=MATCH_ALL_TAG, *args, **kwargs):
"""
Returns all of our servers dynamically build based on parsed
configuration.
@@ -160,21 +192,20 @@ class AppriseConfig(object):
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))
+ if is_exclusive_match(
+ logic=tag, data=entry.tags, match_all=MATCH_ALL_TAG):
+ # Build ourselves a list of services dynamically and return the
+ # as a list
+ response.extend(entry.servers())
return response
@staticmethod
- def instantiate(url, asset=None, tag=None, suppress_exceptions=True):
+ def instantiate(url, asset=None, tag=None, cache=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
+ the provided Config URL. If the url fails to be parsed, then None
is returned.
"""
@@ -211,6 +242,10 @@ class AppriseConfig(object):
results['asset'] = \
asset if isinstance(asset, AppriseAsset) else AppriseAsset()
+ if cache is not None:
+ # Force an over-ride of the cache value to what we have specified
+ results['cache'] = cache
+
if suppress_exceptions:
try:
# Attempt to create an instance of our plugin using the parsed
@@ -262,10 +297,11 @@ class AppriseConfig(object):
# If we reach here, then we indexed out of range
raise IndexError('list index out of range')
- def pop(self, index):
+ def pop(self, index=-1):
"""
- Removes an indexed Apprise Configuration from the stack and
- returns it.
+ Removes an indexed Apprise Configuration from the stack and returns it.
+
+ By default, the last element is removed from the list
"""
# Remove our entry
return self.configs.pop(index)
@@ -276,6 +312,20 @@ class AppriseConfig(object):
"""
return self.configs[index]
+ def __bool__(self):
+ """
+ Allows the Apprise object to be wrapped in an Python 3.x based 'if
+ statement'. True is returned if at least one service has been loaded.
+ """
+ return True if self.configs else False
+
+ def __nonzero__(self):
+ """
+ Allows the Apprise object to be wrapped in an Python 2.x based 'if
+ statement'. True is returned if at least one service has been loaded.
+ """
+ return True if self.configs else False
+
def __iter__(self):
"""
Returns an iterator to our config list
diff --git a/libs/apprise/URLBase.py b/libs/apprise/URLBase.py
index af5e67d5b..4d62b82cd 100644
--- a/libs/apprise/URLBase.py
+++ b/libs/apprise/URLBase.py
@@ -50,6 +50,21 @@ from .utils import parse_list
# Used to break a path list into parts
PATHSPLIT_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+')
+
+class PrivacyMode(object):
+ # Defines different privacy modes strings can be printed as
+ # Astrisk sets 4 of them: e.g. ****
+ # This is used for passwords
+ Secret = '*'
+
+ # Outer takes the first and last character displaying them with
+ # 3 dots between. Hence, 'i-am-a-token' would become 'i...n'
+ Outer = 'o'
+
+ # Displays the last four characters
+ Tail = 't'
+
+
# Define the HTML Lookup Table
HTML_LOOKUP = {
400: 'Bad Request - Unsupported Parameters.',
@@ -183,7 +198,7 @@ class URLBase(object):
self._last_io_datetime = datetime.now()
return
- def url(self):
+ def url(self, privacy=False, *args, **kwargs):
"""
Assembles the URL associated with the notification based on the
arguments provied.
@@ -204,6 +219,12 @@ class URLBase(object):
# return any match
return tags in self.tags
+ def __str__(self):
+ """
+ Returns the url path
+ """
+ return self.url(privacy=True)
+
@staticmethod
def escape_html(html, convert_new_lines=False, whitespace=True):
"""
@@ -302,6 +323,44 @@ class URLBase(object):
# Python v2.7
return _quote(content, safe=safe)
+ @staticmethod
+ def pprint(content, privacy=True, mode=PrivacyMode.Outer,
+ # privacy print; quoting is ignored when privacy is set to True
+ quote=True, safe='/', encoding=None, errors=None):
+ """
+ Privacy Print is used to mainpulate the string before passing it into
+ part of the URL. It is used to mask/hide private details such as
+ tokens, passwords, apikeys, etc from on-lookers. If the privacy=False
+ is set, then the quote variable is the next flag checked.
+
+ Quoting is never done if the privacy flag is set to true to avoid
+ skewing the expected output.
+ """
+
+ if not privacy:
+ if quote:
+ # Return quoted string if specified to do so
+ return URLBase.quote(
+ content, safe=safe, encoding=encoding, errors=errors)
+
+ # Return content 'as-is'
+ return content
+
+ if mode is PrivacyMode.Secret:
+ # Return 4 Asterisks
+ return '****'
+
+ if not isinstance(content, six.string_types) or not content:
+ # Nothing more to do
+ return ''
+
+ if mode is PrivacyMode.Tail:
+ # Return the trailing 4 characters
+ return '...{}'.format(content[-4:])
+
+ # Default mode is Outer Mode
+ return '{}...{}'.format(content[0:1], content[-1:])
+
@staticmethod
def urlencode(query, doseq=False, safe='', encoding=None, errors=None):
"""Convert a mapping object or a sequence of two-element tuples
diff --git a/libs/apprise/__init__.py b/libs/apprise/__init__.py
index 055a2369e..0b055d7fe 100644
--- a/libs/apprise/__init__.py
+++ b/libs/apprise/__init__.py
@@ -24,7 +24,7 @@
# THE SOFTWARE.
__title__ = 'apprise'
-__version__ = '0.7.9'
+__version__ = '0.8.1'
__author__ = 'Chris Caron'
__license__ = 'MIT'
__copywrite__ = 'Copyright (C) 2019 Chris Caron '
@@ -43,12 +43,15 @@ from .common import ConfigFormat
from .common import CONFIG_FORMATS
from .URLBase import URLBase
+from .URLBase import PrivacyMode
from .plugins.NotifyBase import NotifyBase
from .config.ConfigBase import ConfigBase
+from .attachment.AttachBase import AttachBase
from .Apprise import Apprise
from .AppriseAsset import AppriseAsset
from .AppriseConfig import AppriseConfig
+from .AppriseAttachment import AppriseAttachment
# Set default logging handler to avoid "No handler found" warnings.
import logging
@@ -57,11 +60,11 @@ logging.getLogger(__name__).addHandler(NullHandler())
__all__ = [
# Core
- 'Apprise', 'AppriseAsset', 'AppriseConfig', 'URLBase', 'NotifyBase',
- 'ConfigBase',
+ 'Apprise', 'AppriseAsset', 'AppriseConfig', 'AppriseAttachment', 'URLBase',
+ 'NotifyBase', 'ConfigBase', 'AttachBase',
# Reference
'NotifyType', 'NotifyImageSize', 'NotifyFormat', 'OverflowMode',
'NOTIFY_TYPES', 'NOTIFY_IMAGE_SIZES', 'NOTIFY_FORMATS', 'OVERFLOW_MODES',
- 'ConfigFormat', 'CONFIG_FORMATS',
+ 'ConfigFormat', 'CONFIG_FORMATS', 'PrivacyMode',
]
diff --git a/libs/apprise/cli.py b/libs/apprise/cli.py
index e43f88a23..57e964a72 100644
--- a/libs/apprise/cli.py
+++ b/libs/apprise/cli.py
@@ -99,6 +99,9 @@ def print_version_msg():
@click.option('--config', '-c', default=None, type=str, multiple=True,
metavar='CONFIG_URL',
help='Specify one or more configuration locations.')
+@click.option('--attach', '-a', default=None, type=str, multiple=True,
+ metavar='ATTACHMENT_URL',
+ help='Specify one or more configuration locations.')
@click.option('--notification-type', '-n', default=NotifyType.INFO, type=str,
metavar='TYPE',
help='Specify the message type (default=info). Possible values'
@@ -111,13 +114,17 @@ def print_version_msg():
'which services to notify. Use multiple --tag (-g) entries to '
'"OR" the tags together and comma separated to "AND" them. '
'If no tags are specified then all services are notified.')
-@click.option('-v', '--verbose', count=True)
-@click.option('-V', '--version', is_flag=True,
+@click.option('--dry-run', '-d', is_flag=True,
+ help='Perform a trial run but only prints the notification '
+ 'services to-be triggered to stdout. Notifications are never '
+ 'sent using this mode.')
+@click.option('--verbose', '-v', count=True)
+@click.option('--version', '-V', is_flag=True,
help='Display the apprise version and exit.')
@click.argument('urls', nargs=-1,
metavar='SERVER_URL [SERVER_URL2 [SERVER_URL3]]',)
-def main(body, title, config, urls, notification_type, theme, tag, verbose,
- version):
+def main(body, title, config, attach, urls, notification_type, theme, tag,
+ dry_run, verbose, version):
"""
Send a notification to all of the specified servers identified by their
URLs the content provided within the title, body and notification-type.
@@ -184,16 +191,52 @@ def main(body, title, config, urls, notification_type, theme, tag, verbose,
print_help_msg(main)
sys.exit(1)
- if body is None:
- # if no body was specified, then read from STDIN
- body = click.get_text_stream('stdin').read()
-
# each --tag entry comprises of a comma separated 'and' list
# we or each of of the --tag and sets specified.
tags = None if not tag else [parse_list(t) for t in tag]
- # now print it out
- if a.notify(
- body=body, title=title, notify_type=notification_type, tag=tags):
- sys.exit(0)
- sys.exit(1)
+ if not dry_run:
+ if body is None:
+ logger.trace('No --body (-b) specified; reading from stdin')
+ # if no body was specified, then read from STDIN
+ body = click.get_text_stream('stdin').read()
+
+ # now print it out
+ result = a.notify(
+ body=body, title=title, notify_type=notification_type, tag=tags,
+ attach=attach)
+ else:
+ # Number of rows to assume in the terminal. In future, maybe this can
+ # be detected and made dynamic. The actual row count is 80, but 5
+ # characters are already reserved for the counter on the left
+ rows = 75
+
+ # Initialize our URL response; This is populated within the for/loop
+ # below; but plays a factor at the end when we need to determine if
+ # we iterated at least once in the loop.
+ url = None
+
+ for idx, server in enumerate(a.find(tag=tags)):
+ url = server.url(privacy=True)
+ click.echo("{: 3d}. {}".format(
+ idx + 1,
+ url if len(url) <= rows else '{}...'.format(url[:rows - 3])))
+ if server.tags:
+ click.echo("{} - {}".format(' ' * 5, ', '.join(server.tags)))
+
+ # Initialize a default response of nothing matched, otherwise
+ # if we matched at least one entry, we can return True
+ result = None if url is None else True
+
+ if result is None:
+ # There were no notifications set. This is a result of just having
+ # empty configuration files and/or being to restrictive when filtering
+ # by specific tag(s)
+ sys.exit(2)
+
+ elif result is False:
+ # At least 1 notification service failed to send
+ sys.exit(1)
+
+ # else: We're good!
+ sys.exit(0)
diff --git a/libs/apprise/common.py b/libs/apprise/common.py
index 8005cc19d..90c65744a 100644
--- a/libs/apprise/common.py
+++ b/libs/apprise/common.py
@@ -128,3 +128,7 @@ CONFIG_FORMATS = (
ConfigFormat.TEXT,
ConfigFormat.YAML,
)
+
+# This is a reserved tag that is automatically assigned to every
+# Notification Plugin
+MATCH_ALL_TAG = 'all'
diff --git a/libs/apprise/config/ConfigBase.py b/libs/apprise/config/ConfigBase.py
index d5acc4af1..539d4c494 100644
--- a/libs/apprise/config/ConfigBase.py
+++ b/libs/apprise/config/ConfigBase.py
@@ -27,6 +27,7 @@ import os
import re
import six
import yaml
+import time
from .. import plugins
from ..AppriseAsset import AppriseAsset
@@ -35,6 +36,7 @@ from ..common import ConfigFormat
from ..common import CONFIG_FORMATS
from ..utils import GET_SCHEMA_RE
from ..utils import parse_list
+from ..utils import parse_bool
class ConfigBase(URLBase):
@@ -58,16 +60,31 @@ class ConfigBase(URLBase):
# anything else. 128KB (131072B)
max_buffer_size = 131072
- def __init__(self, **kwargs):
+ def __init__(self, cache=True, **kwargs):
"""
Initialize some general logging and common server arguments that will
keep things consistent when working with the configurations that
inherit this class.
+ By default we cache our responses so that subsiquent calls does not
+ cause the content to be retrieved again. For local file references
+ this makes no difference at all. But for remote content, this does
+ mean more then one call can be made to retrieve the (same) data. This
+ method can be somewhat inefficient if disabled. Only disable caching
+ if you understand the consequences.
+
+ You can alternatively set the cache value to an int identifying the
+ number of seconds the previously retrieved can exist for before it
+ should be considered expired.
"""
super(ConfigBase, self).__init__(**kwargs)
+ # Tracks the time the content was last retrieved on. This place a role
+ # for cases where we are not caching our response and are required to
+ # re-retrieve our settings.
+ self._cached_time = None
+
# Tracks previously loaded content for speed
self._cached_servers = None
@@ -86,20 +103,34 @@ class ConfigBase(URLBase):
self.logger.warning(err)
raise TypeError(err)
+ # Set our cache flag; it can be True or a (positive) integer
+ try:
+ self.cache = cache if isinstance(cache, bool) else int(cache)
+ if self.cache < 0:
+ err = 'A negative cache value ({}) was specified.'.format(
+ cache)
+ self.logger.warning(err)
+ raise TypeError(err)
+
+ except (ValueError, TypeError):
+ err = 'An invalid cache value ({}) was specified.'.format(cache)
+ self.logger.warning(err)
+ raise TypeError(err)
+
return
- def servers(self, asset=None, cache=True, **kwargs):
+ def servers(self, asset=None, **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):
+ if not self.expired():
# We already have cached results to return; use them
return self._cached_servers
- # Our response object
+ # Our cached response object
self._cached_servers = list()
# read() causes the child class to do whatever it takes for the
@@ -107,8 +138,11 @@ class ConfigBase(URLBase):
# 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()
+ # Set the time our content was cached at
+ self._cached_time = time.time()
+
+ # Nothing more to do; return our empty cache list
+ return self._cached_servers
# Our Configuration format uses a default if one wasn't one detected
# or enfored.
@@ -129,6 +163,9 @@ class ConfigBase(URLBase):
self.logger.warning('Failed to load configuration from {}'.format(
self.url()))
+ # Set the time our content was cached at
+ self._cached_time = time.time()
+
return self._cached_servers
def read(self):
@@ -138,13 +175,35 @@ class ConfigBase(URLBase):
"""
return None
+ def expired(self):
+ """
+ Simply returns True if the configuration should be considered
+ as expired or False if content should be retrieved.
+ """
+ if isinstance(self._cached_servers, list) and self.cache:
+ # We have enough reason to look further into our cached content
+ # and verify it has not expired.
+ if self.cache is True:
+ # we have not expired, return False
+ return False
+
+ # Verify our cache time to determine whether we will get our
+ # content again.
+ age_in_sec = time.time() - self._cached_time
+ if age_in_sec <= self.cache:
+ # We have not expired; return False
+ return False
+
+ # If we reach here our configuration should be considered
+ # missing and/or expired.
+ return True
+
@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
@@ -177,6 +236,17 @@ class ConfigBase(URLBase):
if 'encoding' in results['qsd']:
results['encoding'] = results['qsd'].get('encoding')
+ # Our cache value
+ if 'cache' in results['qsd']:
+ # First try to get it's integer value
+ try:
+ results['cache'] = int(results['qsd']['cache'])
+
+ except (ValueError, TypeError):
+ # No problem, it just isn't an integer; now treat it as a bool
+ # instead:
+ results['cache'] = parse_bool(results['qsd']['cache'])
+
return results
@staticmethod
@@ -236,35 +306,14 @@ class ConfigBase(URLBase):
# 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:
- ConfigBase.logger.warning(
- 'Unsupported schema {} on line {}.'.format(
- schema, line))
+ if not url:
+ # Comment/empty line; do nothing
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)
-
+ # Acquire our url tokens
+ results = plugins.url_to_dict(url)
if results is None:
# Failed to parse the server URL
ConfigBase.logger.warning(
@@ -316,6 +365,7 @@ class ConfigBase(URLBase):
Optionally associate an asset with the notification.
"""
+
response = list()
try:
@@ -406,72 +456,69 @@ class ConfigBase(URLBase):
results = list()
if isinstance(url, six.string_types):
- # We're just a simple URL string
-
- # swap hash (#) tag values with their html version
- _url = url.replace('/#', '/%23')
-
- # Attempt to acquire the schema at the very least to allow our
- # plugins to determine if they can make a better
- # interpretation of a URL geared for them
- schema = GET_SCHEMA_RE.match(_url)
+ # We're just a simple URL string...
+ schema = GET_SCHEMA_RE.match(url)
if schema is None:
+ # Log invalid entries so that maintainer of config
+ # config file at least has something to take action
+ # with.
ConfigBase.logger.warning(
- 'Unsupported schema in urls entry #{}'.format(no + 1))
- continue
-
- # Ensure our schema is always in lower case
- schema = schema.group('schema').lower()
-
- # Some basic validation
- if schema not in plugins.SCHEMA_MAP:
- ConfigBase.logger.warning(
- 'Unsupported schema {} in urls entry #{}'.format(
- schema, no + 1))
+ 'Invalid URL {}, entry #{}'.format(url, no + 1))
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)
+ # We found a valid schema worthy of tracking; store it's
+ # details:
+ _results = plugins.url_to_dict(url)
if _results is None:
ConfigBase.logger.warning(
- 'Unparseable {} based url; entry #{}'.format(
- schema, no + 1))
+ 'Unparseable URL {}, entry #{}'.format(
+ url, no + 1))
continue
# add our results to our global set
results.append(_results)
elif isinstance(url, dict):
- # We are a url string with additional unescaped options
+ # We are a url string with additional unescaped options. In
+ # this case we want to iterate over all of our options so we
+ # can at least tell the end user what entries were ignored
+ # due to errors
+
if six.PY2:
- _url, tokens = next(url.iteritems())
+ it = url.iteritems()
else: # six.PY3
- _url, tokens = next(iter(url.items()))
-
- # swap hash (#) tag values with their html version
- _url = _url.replace('/#', '/%23')
-
- # Get our schema
- schema = GET_SCHEMA_RE.match(_url)
- if schema is None:
- ConfigBase.logger.warning(
- 'Unsupported schema in urls entry #{}'.format(no + 1))
- continue
-
- # Ensure our schema is always in lower case
- schema = schema.group('schema').lower()
-
- # Some basic validation
- if schema not in plugins.SCHEMA_MAP:
+ it = iter(url.items())
+
+ # Track the URL to-load
+ _url = None
+
+ # Track last acquired schema
+ schema = None
+ for key, tokens in it:
+ # Test our schema
+ _schema = GET_SCHEMA_RE.match(key)
+ if _schema is None:
+ # Log invalid entries so that maintainer of config
+ # config file at least has something to take action
+ # with.
+ ConfigBase.logger.warning(
+ 'Ignored entry {} found under urls, entry #{}'
+ .format(key, no + 1))
+ continue
+
+ # Store our URL and Schema Regex
+ _url = key
+
+ # Store our schema
+ schema = _schema.group('schema').lower()
+
+ if _url is None:
+ # the loop above failed to match anything
ConfigBase.logger.warning(
- 'Unsupported schema {} in urls entry #{}'.format(
- schema, no + 1))
+ 'Unsupported schema in urls, entry #{}'.format(no + 1))
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)
+ _results = plugins.url_to_dict(_url)
if _results is None:
# Setup dictionary
_results = {
@@ -479,7 +526,7 @@ class ConfigBase(URLBase):
'schema': schema,
}
- if tokens is not None:
+ if isinstance(tokens, (list, tuple, set)):
# populate and/or override any results populated by
# parse_url()
for entries in tokens:
@@ -565,15 +612,16 @@ class ConfigBase(URLBase):
return response
- def pop(self, index):
+ def pop(self, index=-1):
"""
- Removes an indexed Notification Service from the stack and
- returns it.
+ Removes an indexed Notification Service from the stack and returns it.
+
+ By default, the last element of the list is removed.
"""
if not isinstance(self._cached_servers, list):
# Generate ourselves a list of content we can pull from
- self.servers(cache=True)
+ self.servers()
# Pop the element off of the stack
return self._cached_servers.pop(index)
@@ -585,7 +633,7 @@ class ConfigBase(URLBase):
"""
if not isinstance(self._cached_servers, list):
# Generate ourselves a list of content we can pull from
- self.servers(cache=True)
+ self.servers()
return self._cached_servers[index]
@@ -595,7 +643,7 @@ class ConfigBase(URLBase):
"""
if not isinstance(self._cached_servers, list):
# Generate ourselves a list of content we can pull from
- self.servers(cache=True)
+ self.servers()
return iter(self._cached_servers)
@@ -605,6 +653,28 @@ class ConfigBase(URLBase):
"""
if not isinstance(self._cached_servers, list):
# Generate ourselves a list of content we can pull from
- self.servers(cache=True)
+ self.servers()
return len(self._cached_servers)
+
+ def __bool__(self):
+ """
+ Allows the Apprise object to be wrapped in an Python 3.x based 'if
+ statement'. True is returned if our content was downloaded correctly.
+ """
+ if not isinstance(self._cached_servers, list):
+ # Generate ourselves a list of content we can pull from
+ self.servers()
+
+ return True if self._cached_servers else False
+
+ def __nonzero__(self):
+ """
+ Allows the Apprise object to be wrapped in an Python 2.x based 'if
+ statement'. True is returned if our content was downloaded correctly.
+ """
+ if not isinstance(self._cached_servers, list):
+ # Generate ourselves a list of content we can pull from
+ self.servers()
+
+ return True if self._cached_servers else False
diff --git a/libs/apprise/config/ConfigFile.py b/libs/apprise/config/ConfigFile.py
index e9ab93bf1..917eea081 100644
--- a/libs/apprise/config/ConfigFile.py
+++ b/libs/apprise/config/ConfigFile.py
@@ -26,9 +26,9 @@
import re
import io
import os
-from os.path import expanduser
from .ConfigBase import ConfigBase
from ..common import ConfigFormat
+from ..AppriseLocale import gettext_lazy as _
class ConfigFile(ConfigBase):
@@ -36,8 +36,8 @@ class ConfigFile(ConfigBase):
A wrapper for File based configuration sources
"""
- # The default descriptive name associated with the Notification
- service_name = 'Local File'
+ # The default descriptive name associated with the service
+ service_name = _('Local File')
# The default protocol
protocol = 'file'
@@ -53,27 +53,35 @@ class ConfigFile(ConfigBase):
super(ConfigFile, self).__init__(**kwargs)
# Store our file path as it was set
- self.path = path
+ self.path = os.path.expanduser(path)
return
- def url(self):
+ def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.
"""
+ # Prepare our cache value
+ if isinstance(self.cache, bool) or not self.cache:
+ cache = 'yes' if self.cache else 'no'
+
+ else:
+ cache = int(self.cache)
+
# Define any arguments set
args = {
'encoding': self.encoding,
+ 'cache': cache,
}
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(
+ return 'file://{path}{args}'.format(
path=self.quote(self.path),
- args=self.urlencode(args),
+ args='?{}'.format(self.urlencode(args)) if args else '',
)
def read(self, **kwargs):
@@ -159,5 +167,5 @@ class ConfigFile(ConfigBase):
if not match:
return None
- results['path'] = expanduser(ConfigFile.unquote(match.group('path')))
+ results['path'] = ConfigFile.unquote(match.group('path'))
return results
diff --git a/libs/apprise/config/ConfigHTTP.py b/libs/apprise/config/ConfigHTTP.py
index 6c3a3259c..299255d09 100644
--- a/libs/apprise/config/ConfigHTTP.py
+++ b/libs/apprise/config/ConfigHTTP.py
@@ -28,6 +28,8 @@ import six
import requests
from .ConfigBase import ConfigBase
from ..common import ConfigFormat
+from ..URLBase import PrivacyMode
+from ..AppriseLocale import gettext_lazy as _
# Support YAML formats
# text/yaml
@@ -47,8 +49,8 @@ class ConfigHTTP(ConfigBase):
A wrapper for HTTP based configuration sources
"""
- # The default descriptive name associated with the Notification
- service_name = 'HTTP'
+ # The default descriptive name associated with the service
+ service_name = _('Web Based')
# The default protocol
protocol = 'http'
@@ -89,14 +91,23 @@ class ConfigHTTP(ConfigBase):
return
- def url(self):
+ def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.
"""
+ # Prepare our cache value
+ if isinstance(self.cache, bool) or not self.cache:
+ cache = 'yes' if self.cache else 'no'
+
+ else:
+ cache = int(self.cache)
+
# Define any arguments set
args = {
+ 'verify': 'yes' if self.verify_certificate else 'no',
'encoding': self.encoding,
+ 'cache': cache,
}
if self.config_format:
@@ -111,7 +122,8 @@ class ConfigHTTP(ConfigBase):
if self.user and self.password:
auth = '{user}:{password}@'.format(
user=self.quote(self.user, safe=''),
- password=self.quote(self.password, safe=''),
+ password=self.pprint(
+ self.password, privacy, mode=PrivacyMode.Secret, safe=''),
)
elif self.user:
auth = '{user}@'.format(
@@ -120,12 +132,13 @@ class ConfigHTTP(ConfigBase):
default_port = 443 if self.secure else 80
- return '{schema}://{auth}{hostname}{port}/?{args}'.format(
+ return '{schema}://{auth}{hostname}{port}{fullpath}/?{args}'.format(
schema=self.secure_protocol if self.secure else self.protocol,
auth=auth,
- hostname=self.host,
+ hostname=self.quote(self.host, safe=''),
port='' if self.port is None or self.port == default_port
else ':{}'.format(self.port),
+ fullpath=self.quote(self.fullpath, safe='/'),
args=self.urlencode(args),
)
@@ -167,61 +180,48 @@ class ConfigHTTP(ConfigBase):
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
+ with requests.post(
+ url,
+ headers=headers,
+ auth=auth,
+ verify=self.verify_certificate,
+ timeout=self.connection_timeout_sec,
+ stream=True) as r:
+
+ # Handle Errors
+ r.raise_for_status()
+
+ # Get our file-size (if known)
+ try:
+ file_size = int(r.headers.get('Content-Length', '0'))
+ except (TypeError, ValueError):
+ # Handle edge case where Content-Length is a bad value
+ file_size = 0
+
+ # Store our response
+ if self.max_buffer_size > 0 \
+ and file_size > 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)))
+
+ # Return None - buffer execeeded
+ return None
+
+ # Store our result (but no more than our buffer length)
+ response = r.content[:self.max_buffer_size + 1]
+
+ # Verify that our content did not exceed the buffer size:
+ if len(response) > 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)))
+
+ # Return None - buffer execeeded
+ return None
# Detect config format based on mime if the format isn't
# already enforced
@@ -247,11 +247,6 @@ class ConfigHTTP(ConfigBase):
# 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
diff --git a/libs/apprise/config/__init__.py b/libs/apprise/config/__init__.py
index f91bc9e38..5c3980318 100644
--- a/libs/apprise/config/__init__.py
+++ b/libs/apprise/config/__init__.py
@@ -43,7 +43,7 @@ def __load_matrix(path=abspath(dirname(__file__)), name='apprise.config'):
skip over modules we simply don't have the dependencies for.
"""
- # Used for the detection of additional Notify Services objects
+ # Used for the detection of additional Configuration Services objects
# The .py extension is optional as we support loading directories too
module_re = re.compile(r'^(?PConfig[a-z0-9]+)(\.py)?$', re.I)
diff --git a/libs/apprise/i18n/en/LC_MESSAGES/apprise.mo b/libs/apprise/i18n/en/LC_MESSAGES/apprise.mo
deleted file mode 100644
index 0decd3509..000000000
Binary files a/libs/apprise/i18n/en/LC_MESSAGES/apprise.mo and /dev/null differ
diff --git a/libs/apprise/plugins/NotifyBase.py b/libs/apprise/plugins/NotifyBase.py
index c0183c9f5..7e963b0ce 100644
--- a/libs/apprise/plugins/NotifyBase.py
+++ b/libs/apprise/plugins/NotifyBase.py
@@ -33,6 +33,7 @@ from ..common import NOTIFY_FORMATS
from ..common import OverflowMode
from ..common import OVERFLOW_MODES
from ..AppriseLocale import gettext_lazy as _
+from ..AppriseAttachment import AppriseAttachment
class NotifyBase(URLBase):
@@ -241,12 +242,21 @@ class NotifyBase(URLBase):
)
def notify(self, body, title=None, notify_type=NotifyType.INFO,
- overflow=None, **kwargs):
+ overflow=None, attach=None, **kwargs):
"""
Performs notification
"""
+ # Prepare attachments if required
+ if attach is not None and not isinstance(attach, AppriseAttachment):
+ try:
+ attach = AppriseAttachment(attach, asset=self.asset)
+
+ except TypeError:
+ # bad attachments
+ return False
+
# Handle situations where the title is None
title = '' if not title else title
@@ -255,7 +265,7 @@ class NotifyBase(URLBase):
overflow=overflow):
# Send notification
if not self.send(body=chunk['body'], title=chunk['title'],
- notify_type=notify_type):
+ notify_type=notify_type, attach=attach):
# Toggle our return status flag
return False
diff --git a/libs/apprise/plugins/NotifyBoxcar.py b/libs/apprise/plugins/NotifyBoxcar.py
index 5c74f44d6..341c5098c 100644
--- a/libs/apprise/plugins/NotifyBoxcar.py
+++ b/libs/apprise/plugins/NotifyBoxcar.py
@@ -38,7 +38,9 @@ except ImportError:
from urllib.parse import urlparse
from .NotifyBase import NotifyBase
+from ..URLBase import PrivacyMode
from ..utils import parse_bool
+from ..utils import validate_regex
from ..common import NotifyType
from ..common import NotifyImageSize
from ..AppriseLocale import gettext_lazy as _
@@ -57,11 +59,6 @@ IS_TAG = re.compile(r'^[@](?P[A-Z0-9]{1,63})$', re.I)
# this plugin supports it.
IS_DEVICETOKEN = re.compile(r'^[A-Z0-9]{64}$', re.I)
-# Both an access key and seret key are created and assigned to each project
-# you create on the boxcar website
-VALIDATE_ACCESS = re.compile(r'[A-Z0-9_-]{64}', re.I)
-VALIDATE_SECRET = re.compile(r'[A-Z0-9_-]{64}', re.I)
-
# Used to break apart list of potential tags by their delimiter into a useable
# list.
TAGS_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+')
@@ -104,30 +101,30 @@ class NotifyBoxcar(NotifyBase):
'access_key': {
'name': _('Access Key'),
'type': 'string',
- 'regex': (r'[A-Z0-9_-]{64}', 'i'),
'private': True,
'required': True,
+ 'regex': (r'^[A-Z0-9_-]{64}$', 'i'),
'map_to': 'access',
},
'secret_key': {
'name': _('Secret Key'),
'type': 'string',
- 'regex': (r'[A-Z0-9_-]{64}', 'i'),
'private': True,
'required': True,
+ 'regex': (r'^[A-Z0-9_-]{64}$', 'i'),
'map_to': 'secret',
},
'target_tag': {
'name': _('Target Tag ID'),
'type': 'string',
'prefix': '@',
- 'regex': (r'[A-Z0-9]{1,63}', 'i'),
+ 'regex': (r'^[A-Z0-9]{1,63}$', 'i'),
'map_to': 'targets',
},
'target_device': {
'name': _('Target Device ID'),
'type': 'string',
- 'regex': (r'[A-Z0-9]{64}', 'i'),
+ 'regex': (r'^[A-Z0-9]{64}$', 'i'),
'map_to': 'targets',
},
'targets': {
@@ -162,33 +159,21 @@ class NotifyBoxcar(NotifyBase):
# Initialize device_token list
self.device_tokens = list()
- try:
- # Access Key (associated with project)
- self.access = access.strip()
-
- except AttributeError:
- msg = 'The specified access key is invalid.'
- self.logger.warning(msg)
- raise TypeError(msg)
-
- try:
- # Secret Key (associated with project)
- self.secret = secret.strip()
-
- except AttributeError:
- msg = 'The specified secret key is invalid.'
- self.logger.warning(msg)
- raise TypeError(msg)
-
- if not VALIDATE_ACCESS.match(self.access):
- msg = 'The access key specified ({}) is invalid.'\
- .format(self.access)
+ # Access Key (associated with project)
+ self.access = validate_regex(
+ access, *self.template_tokens['access_key']['regex'])
+ if not self.access:
+ msg = 'An invalid Boxcar Access Key ' \
+ '({}) was specified.'.format(access)
self.logger.warning(msg)
raise TypeError(msg)
- if not VALIDATE_SECRET.match(self.secret):
- msg = 'The secret key specified ({}) is invalid.'\
- .format(self.secret)
+ # Secret Key (associated with project)
+ self.secret = validate_regex(
+ secret, *self.template_tokens['secret_key']['regex'])
+ if not self.secret:
+ msg = 'An invalid Boxcar Secret Key ' \
+ '({}) was specified.'.format(secret)
self.logger.warning(msg)
raise TypeError(msg)
@@ -227,7 +212,6 @@ class NotifyBoxcar(NotifyBase):
"""
Perform Boxcar Notification
"""
-
headers = {
'User-Agent': self.app_id,
'Content-Type': 'application/json'
@@ -330,7 +314,7 @@ class NotifyBoxcar(NotifyBase):
return True
- def url(self):
+ def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.
"""
@@ -343,10 +327,11 @@ class NotifyBoxcar(NotifyBase):
'verify': 'yes' if self.verify_certificate else 'no',
}
- return '{schema}://{access}/{secret}/{targets}/?{args}'.format(
+ return '{schema}://{access}/{secret}/{targets}?{args}'.format(
schema=self.secure_protocol,
- access=NotifyBoxcar.quote(self.access, safe=''),
- secret=NotifyBoxcar.quote(self.secret, safe=''),
+ access=self.pprint(self.access, privacy, safe=''),
+ secret=self.pprint(
+ self.secret, privacy, mode=PrivacyMode.Secret, safe=''),
targets='/'.join([
NotifyBoxcar.quote(x, safe='') for x in chain(
self.tags, self.device_tokens) if x != DEFAULT_TAG]),
diff --git a/libs/apprise/plugins/NotifyD7Networks.py b/libs/apprise/plugins/NotifyD7Networks.py
index 1b7fcb5eb..d784f1cda 100644
--- a/libs/apprise/plugins/NotifyD7Networks.py
+++ b/libs/apprise/plugins/NotifyD7Networks.py
@@ -38,6 +38,7 @@ from json import dumps
from json import loads
from .NotifyBase import NotifyBase
+from ..URLBase import PrivacyMode
from ..common import NotifyType
from ..utils import parse_list
from ..utils import parse_bool
@@ -130,7 +131,7 @@ class NotifyD7Networks(NotifyBase):
'name': _('Target Phone No'),
'type': 'string',
'prefix': '+',
- 'regex': (r'[0-9\s)(+-]+', 'i'),
+ 'regex': (r'^[0-9\s)(+-]+$', 'i'),
'map_to': 'targets',
},
'targets': {
@@ -226,6 +227,8 @@ class NotifyD7Networks(NotifyBase):
self.logger.warning(msg)
raise TypeError(msg)
+ return
+
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
"""
Depending on whether we are set to batch mode or single mode this
@@ -315,11 +318,13 @@ class NotifyD7Networks(NotifyBase):
json_response = loads(r.content)
status_str = json_response.get('message', status_str)
- except (AttributeError, ValueError):
- # could not parse JSON response... just use the status
- # we already have.
+ except (AttributeError, TypeError, ValueError):
+ # ValueError = r.content is Unparsable
+ # TypeError = r.content is None
+ # AttributeError = r is None
- # AttributeError means r.content was None
+ # We could not parse JSON response.
+ # We will just use the status we already have.
pass
self.logger.warning(
@@ -347,9 +352,13 @@ class NotifyD7Networks(NotifyBase):
count = int(json_response.get(
'data', {}).get('messageCount', -1))
- except (AttributeError, ValueError, TypeError):
- # could not parse JSON response... just assume
- # that our delivery is okay for now
+ except (AttributeError, TypeError, ValueError):
+ # ValueError = r.content is Unparsable
+ # TypeError = r.content is None
+ # AttributeError = r is None
+
+ # We could not parse JSON response. Assume that
+ # our delivery is okay for now.
pass
if count != len(self.targets):
@@ -380,7 +389,7 @@ class NotifyD7Networks(NotifyBase):
return not has_error
- def url(self):
+ def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.
"""
@@ -402,7 +411,8 @@ class NotifyD7Networks(NotifyBase):
return '{schema}://{user}:{password}@{targets}/?{args}'.format(
schema=self.secure_protocol,
user=NotifyD7Networks.quote(self.user, safe=''),
- password=NotifyD7Networks.quote(self.password, safe=''),
+ password=self.pprint(
+ self.password, privacy, mode=PrivacyMode.Secret, safe=''),
targets='/'.join(
[NotifyD7Networks.quote(x, safe='') for x in self.targets]),
args=NotifyD7Networks.urlencode(args))
diff --git a/libs/apprise/plugins/NotifyDBus.py b/libs/apprise/plugins/NotifyDBus.py
index b1db496cc..37f2b256a 100644
--- a/libs/apprise/plugins/NotifyDBus.py
+++ b/libs/apprise/plugins/NotifyDBus.py
@@ -54,6 +54,7 @@ try:
from dbus import Interface
from dbus import Byte
from dbus import ByteArray
+ from dbus import DBusException
#
# now we try to determine which mainloop(s) we can access
@@ -88,7 +89,7 @@ try:
from gi.repository import GdkPixbuf
NOTIFY_DBUS_IMAGE_SUPPORT = True
- except (ImportError, ValueError):
+ except (ImportError, ValueError, AttributeError):
# No problem; this will get caught in outer try/catch
# A ValueError will get thrown upon calling gi.require_version() if
@@ -159,10 +160,6 @@ class NotifyDBus(NotifyBase):
# content to display
body_max_line_count = 10
- # A title can not be used for SMS Messages. Setting this to zero will
- # cause any title (if defined) to get placed into the message body.
- title_maxlen = 0
-
# This entry is a bit hacky, but it allows us to unit-test this library
# in an environment that simply doesn't have the gnome packages
# available to us. It also allows us to handle situations where the
@@ -240,6 +237,8 @@ class NotifyDBus(NotifyBase):
# or not.
self.include_image = include_image
+ return
+
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
"""
Perform DBus Notification
@@ -251,7 +250,20 @@ class NotifyDBus(NotifyBase):
return False
# Acquire our session
- session = SessionBus(mainloop=MAINLOOP_MAP[self.schema])
+ try:
+ session = SessionBus(mainloop=MAINLOOP_MAP[self.schema])
+
+ except DBusException:
+ # Handle exception
+ self.logger.warning('Failed to send DBus notification.')
+ self.logger.exception('DBus Exception')
+ return False
+
+ # If there is no title, but there is a body, swap the two to get rid
+ # of the weird whitespace
+ if not title:
+ title = body
+ body = ''
# acquire our dbus object
dbus_obj = session.get_object(
@@ -332,7 +344,7 @@ class NotifyDBus(NotifyBase):
return True
- def url(self):
+ def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.
"""
@@ -362,7 +374,7 @@ class NotifyDBus(NotifyBase):
args['y'] = str(self.y_axis)
return '{schema}://_/?{args}'.format(
- schema=self.protocol,
+ schema=self.schema,
args=NotifyDBus.urlencode(args),
)
diff --git a/libs/apprise/plugins/NotifyDiscord.py b/libs/apprise/plugins/NotifyDiscord.py
index 6db65c8dd..af6bafd49 100644
--- a/libs/apprise/plugins/NotifyDiscord.py
+++ b/libs/apprise/plugins/NotifyDiscord.py
@@ -49,6 +49,7 @@ from ..common import NotifyImageSize
from ..common import NotifyFormat
from ..common import NotifyType
from ..utils import parse_bool
+from ..utils import validate_regex
from ..AppriseLocale import gettext_lazy as _
@@ -144,20 +145,22 @@ class NotifyDiscord(NotifyBase):
"""
super(NotifyDiscord, self).__init__(**kwargs)
- if not webhook_id:
- msg = 'An invalid Client ID was specified.'
+ # Webhook ID (associated with project)
+ self.webhook_id = validate_regex(webhook_id)
+ if not self.webhook_id:
+ msg = 'An invalid Discord Webhook ID ' \
+ '({}) was specified.'.format(webhook_id)
self.logger.warning(msg)
raise TypeError(msg)
- if not webhook_token:
- msg = 'An invalid Webhook Token was specified.'
+ # Webhook Token (associated with project)
+ self.webhook_token = validate_regex(webhook_token)
+ if not self.webhook_token:
+ msg = 'An invalid Discord Webhook Token ' \
+ '({}) was specified.'.format(webhook_token)
self.logger.warning(msg)
raise TypeError(msg)
- # Store our data
- self.webhook_id = webhook_id
- self.webhook_token = webhook_token
-
# Text To Speech
self.tts = tts
@@ -175,17 +178,12 @@ class NotifyDiscord(NotifyBase):
return
- def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
+ def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
+ **kwargs):
"""
Perform Discord Notification
"""
- headers = {
- 'User-Agent': self.app_id,
- 'Content-Type': 'multipart/form-data',
- }
-
- # Prepare JSON Object
payload = {
# Text-To-Speech
'tts': self.tts,
@@ -255,6 +253,50 @@ class NotifyDiscord(NotifyBase):
# Optionally override the default username of the webhook
payload['username'] = self.user
+ if not self._send(payload):
+ # We failed to post our message
+ return False
+
+ if attach:
+ # Update our payload; the idea is to preserve it's other detected
+ # and assigned values for re-use here too
+ payload.update({
+ # Text-To-Speech
+ 'tts': False,
+ # Wait until the upload has posted itself before continuing
+ 'wait': True,
+ })
+
+ # Remove our text/title based content for attachment use
+ if 'embeds' in payload:
+ # Markdown
+ del payload['embeds']
+
+ if 'content' in payload:
+ # Markdown
+ del payload['content']
+
+ # Send our attachments
+ for attachment in attach:
+ self.logger.info(
+ 'Posting Discord Attachment {}'.format(attachment.name))
+ if not self._send(payload, attach=attachment):
+ # We failed to post our message
+ return False
+
+ # Otherwise return
+ return True
+
+ def _send(self, payload, attach=None, **kwargs):
+ """
+ Wrapper to the requests (post) object
+ """
+
+ # Our headers
+ headers = {
+ 'User-Agent': self.app_id,
+ }
+
# Construct Notify URL
notify_url = '{0}/{1}/{2}'.format(
self.notify_url,
@@ -270,11 +312,22 @@ class NotifyDiscord(NotifyBase):
# Always call throttle before any remote server i/o is made
self.throttle()
+ # Our attachment path (if specified)
+ files = None
try:
+
+ # Open our attachment path if required:
+ if attach:
+ files = {'file': (attach.name, open(attach.path, 'rb'))}
+
+ else:
+ headers['Content-Type'] = 'application/json; charset=utf-8'
+
r = requests.post(
notify_url,
- data=dumps(payload),
+ data=payload if files else dumps(payload),
headers=headers,
+ files=files,
verify=self.verify_certificate,
)
if r.status_code not in (
@@ -285,8 +338,9 @@ class NotifyDiscord(NotifyBase):
NotifyBase.http_response_code_lookup(r.status_code)
self.logger.warning(
- 'Failed to send Discord notification: '
+ 'Failed to send {}to Discord notification: '
'{}{}error={}.'.format(
+ attach.name if attach else '',
status_str,
', ' if status_str else '',
r.status_code))
@@ -297,19 +351,32 @@ class NotifyDiscord(NotifyBase):
return False
else:
- self.logger.info('Sent Discord notification.')
+ self.logger.info('Sent Discord {}.'.format(
+ 'attachment' if attach else 'notification'))
except requests.RequestException as e:
self.logger.warning(
- 'A Connection error occured sending Discord '
- 'notification.'
- )
+ 'A Connection error occured posting {}to Discord.'.format(
+ attach.name if attach else ''))
self.logger.debug('Socket Exception: %s' % str(e))
return False
+ except (OSError, IOError) as e:
+ self.logger.warning(
+ 'An I/O error occured while reading {}.'.format(
+ attach.name if attach else 'attachment'))
+ self.logger.debug('I/O Exception: %s' % str(e))
+ return False
+
+ finally:
+ # Close our file (if it's open) stored in the second element
+ # of our files tuple (index 1)
+ if files:
+ files['file'][1].close()
+
return True
- def url(self):
+ def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.
"""
@@ -328,8 +395,8 @@ class NotifyDiscord(NotifyBase):
return '{schema}://{webhook_id}/{webhook_token}/?{args}'.format(
schema=self.secure_protocol,
- webhook_id=NotifyDiscord.quote(self.webhook_id, safe=''),
- webhook_token=NotifyDiscord.quote(self.webhook_token, safe=''),
+ webhook_id=self.pprint(self.webhook_id, privacy, safe=''),
+ webhook_token=self.pprint(self.webhook_token, privacy, safe=''),
args=NotifyDiscord.urlencode(args),
)
@@ -405,7 +472,7 @@ class NotifyDiscord(NotifyBase):
r'^https?://discordapp\.com/api/webhooks/'
r'(?P[0-9]+)/'
r'(?P[A-Z0-9_-]+)/?'
- r'(?P\?[.+])?$', url, re.I)
+ r'(?P\?.+)?$', url, re.I)
if result:
return NotifyDiscord.parse_url(
@@ -427,8 +494,8 @@ class NotifyDiscord(NotifyBase):
"""
regex = re.compile(
- r'^\s*#+\s*(?P[^#\n]+)([ \r\t\v#])?'
- r'(?P([^ \r\t\v#].+?)(\n(?!\s#))|\s*$)', flags=re.S | re.M)
+ r'\s*#[# \t\v]*(?P[^\n]+)(\n|\s*$)'
+ r'\s*((?P[^#].+?)(?=\s*$|[\r\n]+\s*#))?', flags=re.S)
common = regex.finditer(markdown)
fields = list()
@@ -436,8 +503,9 @@ class NotifyDiscord(NotifyBase):
d = el.groupdict()
fields.append({
- 'name': d.get('name', '').strip(),
- 'value': '```md\n' + d.get('value', '').strip() + '\n```'
+ 'name': d.get('name', '').strip('# \r\n\t\v'),
+ 'value': '```md\n' +
+ (d.get('value').strip() if d.get('value') else '') + '\n```'
})
return fields
diff --git a/libs/apprise/plugins/NotifyEmail.py b/libs/apprise/plugins/NotifyEmail.py
index 3430d3825..d903ca554 100644
--- a/libs/apprise/plugins/NotifyEmail.py
+++ b/libs/apprise/plugins/NotifyEmail.py
@@ -27,14 +27,19 @@ import re
import six
import smtplib
from email.mime.text import MIMEText
+from email.mime.application import MIMEApplication
+from email.mime.multipart import MIMEMultipart
+
from socket import error as SocketError
from datetime import datetime
from .NotifyBase import NotifyBase
+from ..URLBase import PrivacyMode
from ..common import NotifyFormat
from ..common import NotifyType
from ..utils import is_email
from ..utils import parse_list
+from ..utils import GET_EMAIL_RE
from ..AppriseLocale import gettext_lazy as _
@@ -195,6 +200,23 @@ EMAIL_TEMPLATES = (
},
),
+ # SendGrid (Email Server)
+ # You must specify an authenticated sender address in the from= settings
+ # and a valid email in the to= to deliver your emails to
+ (
+ 'SendGrid',
+ re.compile(
+ r'^((?P