diff --git a/libs/apprise/Apprise.py b/libs/apprise/Apprise.py index 30c936532..39a1ff0aa 100644 --- a/libs/apprise/Apprise.py +++ b/libs/apprise/Apprise.py @@ -24,18 +24,14 @@ # THE SOFTWARE. import os -import six from itertools import chain -from .common import NotifyType -from .common import MATCH_ALL_TAG -from .common import MATCH_ALWAYS_TAG +from . import common from .conversion import convert_between from .utils import is_exclusive_match from .utils import parse_list from .utils import parse_urls from .utils import cwe312_url from .logger import logger - from .AppriseAsset import AppriseAsset from .AppriseConfig import AppriseConfig from .AppriseAttachment import AppriseAttachment @@ -47,13 +43,13 @@ from .plugins.NotifyBase import NotifyBase from . import plugins from . import __version__ -# Python v3+ support code made importable so it can remain backwards +# Python v3+ support code made importable, so it can remain backwards # compatible with Python v2 +# TODO: Review after dropping support for Python 2. from . import py3compat -ASYNCIO_SUPPORT = not six.PY2 -class Apprise(object): +class Apprise: """ Our Notification Manager @@ -127,7 +123,7 @@ class Apprise(object): # Prepare our Asset Object asset = asset if isinstance(asset, AppriseAsset) else AppriseAsset() - if isinstance(url, six.string_types): + if isinstance(url, str): # Acquire our url tokens results = plugins.url_to_dict( url, secure_logging=asset.secure_logging) @@ -141,7 +137,7 @@ class Apprise(object): # We already have our result set results = url - if results.get('schema') not in plugins.SCHEMA_MAP: + if results.get('schema') not in common.NOTIFY_SCHEMA_MAP: # schema is a mandatory dictionary item as it is the only way # we can index into our loaded plugins logger.error('Dictionary does not include a "schema" entry.') @@ -164,7 +160,7 @@ class Apprise(object): type(url)) return None - if not plugins.SCHEMA_MAP[results['schema']].enabled: + if not common.NOTIFY_SCHEMA_MAP[results['schema']].enabled: # # First Plugin Enable Check (Pre Initialization) # @@ -184,12 +180,13 @@ class Apprise(object): try: # Attempt to create an instance of our plugin using the parsed # URL information - plugin = plugins.SCHEMA_MAP[results['schema']](**results) + plugin = common.NOTIFY_SCHEMA_MAP[results['schema']](**results) # Create log entry of loaded URL logger.debug( 'Loaded {} URL: {}'.format( - plugins.SCHEMA_MAP[results['schema']].service_name, + common. + NOTIFY_SCHEMA_MAP[results['schema']].service_name, plugin.url(privacy=asset.secure_logging))) except Exception: @@ -200,14 +197,15 @@ class Apprise(object): # the arguments are invalid or can not be used. logger.error( 'Could not load {} URL: {}'.format( - plugins.SCHEMA_MAP[results['schema']].service_name, + common. + NOTIFY_SCHEMA_MAP[results['schema']].service_name, loggable_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 - plugin = plugins.SCHEMA_MAP[results['schema']](**results) + plugin = common.NOTIFY_SCHEMA_MAP[results['schema']](**results) if not plugin.enabled: # @@ -248,7 +246,7 @@ class Apprise(object): # prepare default asset asset = self.asset - if isinstance(servers, six.string_types): + if isinstance(servers, str): # build our server list servers = parse_urls(servers) if len(servers) == 0: @@ -276,7 +274,7 @@ class Apprise(object): self.servers.append(_server) continue - elif not isinstance(_server, (six.string_types, dict)): + elif not isinstance(_server, (str, dict)): logger.error( "An invalid notification (type={}) was specified.".format( type(_server))) @@ -305,9 +303,9 @@ class Apprise(object): """ self.servers[:] = [] - def find(self, tag=MATCH_ALL_TAG, match_always=True): + def find(self, tag=common.MATCH_ALL_TAG, match_always=True): """ - Returns an list of all servers matching against the tag specified. + Returns a list of all servers matching against the tag specified. """ @@ -323,7 +321,7 @@ class Apprise(object): # A match_always flag allows us to pick up on our 'any' keyword # and notify these services under all circumstances - match_always = MATCH_ALWAYS_TAG if match_always else None + match_always = common.MATCH_ALWAYS_TAG if match_always else None # Iterate over our loaded plugins for entry in self.servers: @@ -338,23 +336,24 @@ class Apprise(object): for server in servers: # Apply our tag matching based on our defined logic if is_exclusive_match( - logic=tag, data=server.tags, match_all=MATCH_ALL_TAG, + logic=tag, data=server.tags, + match_all=common.MATCH_ALL_TAG, match_always=match_always): yield server return - def notify(self, body, title='', notify_type=NotifyType.INFO, - body_format=None, tag=MATCH_ALL_TAG, match_always=True, + def notify(self, body, title='', notify_type=common.NotifyType.INFO, + body_format=None, tag=common.MATCH_ALL_TAG, match_always=True, attach=None, interpret_escapes=None): """ - Send a notification to all of the plugins previously loaded. + Send a notification to all 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 + tagged value are notified. By default, all added services are notified (tag=MATCH_ALL_TAG) This function returns True if all notifications were successfully @@ -363,60 +362,33 @@ class Apprise(object): 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 + represented by an AttachBase() (or list of) object(s). This identifies the products you wish to notify Set interpret_escapes to True if you want to pre-escape a string such as turning a \n into an actual new line, etc. """ - if ASYNCIO_SUPPORT: - return py3compat.asyncio.tosync( - self.async_notify( - body, title, - notify_type=notify_type, body_format=body_format, - tag=tag, match_always=match_always, attach=attach, - interpret_escapes=interpret_escapes, - ), - debug=self.debug - ) - - else: - try: - results = list( - self._notifyall( - Apprise._notifyhandler, - body, title, - notify_type=notify_type, body_format=body_format, - tag=tag, attach=attach, - interpret_escapes=interpret_escapes, - ) - ) - - except TypeError: - # No notifications sent, and there was an internal error. - return False - - else: - if len(results) > 0: - # All notifications sent, return False if any failed. - return all(results) - - else: - # No notifications sent. - return None + return py3compat.asyncio.tosync( + self.async_notify( + body, title, + notify_type=notify_type, body_format=body_format, + tag=tag, match_always=match_always, attach=attach, + interpret_escapes=interpret_escapes, + ), + debug=self.debug + ) def async_notify(self, *args, **kwargs): """ - Send a notification to all of the plugins previously loaded, for + Send a notification to all the plugins previously loaded, for asynchronous callers. This method is an async method that should be awaited on, even if it is missing the async keyword in its signature. (This is omitted to preserve syntax compatibility with Python 2.) - The arguments are identical to those of Apprise.notify(). This method - is not available in Python 2. - """ + The arguments are identical to those of Apprise.notify(). + """ try: coroutines = list( self._notifyall( @@ -424,7 +396,7 @@ class Apprise(object): except TypeError: # No notifications sent, and there was an internal error. - return py3compat.asyncio.toasyncwrap(False) + return py3compat.asyncio.toasyncwrapvalue(False) else: if len(coroutines) > 0: @@ -433,7 +405,7 @@ class Apprise(object): else: # No notifications sent. - return py3compat.asyncio.toasyncwrap(None) + return py3compat.asyncio.toasyncwrapvalue(None) @staticmethod def _notifyhandler(server, **kwargs): @@ -470,13 +442,14 @@ class Apprise(object): # Send the notification immediately, and wrap the result in a # coroutine. status = Apprise._notifyhandler(server, **kwargs) - return py3compat.asyncio.toasyncwrap(status) + return py3compat.asyncio.toasyncwrapvalue(status) - def _notifyall(self, handler, body, title='', notify_type=NotifyType.INFO, - body_format=None, tag=MATCH_ALL_TAG, match_always=True, - attach=None, interpret_escapes=None): + def _notifyall(self, handler, body, title='', + notify_type=common.NotifyType.INFO, body_format=None, + tag=common.MATCH_ALL_TAG, match_always=True, attach=None, + interpret_escapes=None): """ - Creates notifications for all of the plugins loaded. + Creates notifications for all the plugins loaded. Returns a generator that calls handler for each notification. The first and only argument supplied to handler is the server, and the keyword @@ -485,7 +458,7 @@ class Apprise(object): if len(self) == 0: # Nothing to notify - msg = "There are service(s) to notify" + msg = "There are no service(s) to notify" logger.error(msg) raise TypeError(msg) @@ -495,23 +468,11 @@ class Apprise(object): raise TypeError(msg) try: - if six.PY2: - # Python 2.7 encoding support isn't the greatest, so we try - # to ensure that we're ALWAYS dealing with unicode characters - # prior to entrying the next part. This is especially required - # for Markdown support - if title and isinstance(title, str): # noqa: F821 - title = title.decode(self.asset.encoding) + if title and isinstance(title, bytes): + title = title.decode(self.asset.encoding) - if body and isinstance(body, str): # noqa: F821 - body = body.decode(self.asset.encoding) - - else: # Python 3+ - if title and isinstance(title, bytes): # noqa: F821 - title = title.decode(self.asset.encoding) - - if body and isinstance(body, bytes): # noqa: F821 - body = body.decode(self.asset.encoding) + if body and isinstance(body, bytes): + body = body.decode(self.asset.encoding) except UnicodeDecodeError: msg = 'The content passed into Apprise was not of encoding ' \ @@ -579,43 +540,12 @@ class Apprise(object): .encode('ascii', 'backslashreplace')\ .decode('unicode-escape') - except UnicodeDecodeError: # pragma: no cover - # This occurs using a very old verion of Python 2.7 - # such as the one that ships with CentOS/RedHat 7.x - # (v2.7.5). - conversion_body_map[server.notify_format] = \ - conversion_body_map[server.notify_format] \ - .decode('string_escape') - - conversion_title_map[server.notify_format] = \ - conversion_title_map[server.notify_format] \ - .decode('string_escape') - except AttributeError: # Must be of string type msg = 'Failed to escape message body' logger.error(msg) raise TypeError(msg) - if six.PY2: - # Python 2.7 strings must be encoded as utf-8 for - # consistency across all platforms - if conversion_body_map[server.notify_format] and \ - isinstance( - conversion_body_map[server.notify_format], - unicode): # noqa: F821 - conversion_body_map[server.notify_format] = \ - conversion_body_map[server.notify_format]\ - .encode('utf-8') - - if conversion_title_map[server.notify_format] and \ - isinstance( - conversion_title_map[server.notify_format], - unicode): # noqa: F821 - conversion_title_map[server.notify_format] = \ - conversion_title_map[server.notify_format]\ - .encode('utf-8') - yield handler( server, body=conversion_body_map[server.notify_format], @@ -641,7 +571,7 @@ class Apprise(object): 'asset': self.asset.details(), } - for plugin in set(plugins.SCHEMA_MAP.values()): + for plugin in set(common.NOTIFY_SCHEMA_MAP.values()): # Iterate over our hashed plugins and dynamically build details on # their status: @@ -650,7 +580,10 @@ class Apprise(object): 'service_url': getattr(plugin, 'service_url', None), 'setup_url': getattr(plugin, 'setup_url', None), # Placeholder - populated below - 'details': None + 'details': None, + # Differentiat between what is a custom loaded plugin and + # which is native. + 'category': getattr(plugin, 'category', None) } # Standard protocol(s) should be None or a tuple @@ -665,12 +598,12 @@ class Apprise(object): # Standard protocol(s) should be None or a tuple protocols = getattr(plugin, 'protocol', None) - if isinstance(protocols, six.string_types): + if isinstance(protocols, str): protocols = (protocols, ) # Secure protocol(s) should be None or a tuple secure_protocols = getattr(plugin, 'secure_protocol', None) - if isinstance(secure_protocols, six.string_types): + if isinstance(secure_protocols, str): secure_protocols = (secure_protocols, ) # Add our protocol details to our content @@ -775,15 +708,8 @@ class Apprise(object): 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. + Allows the Apprise object to be wrapped in an 'if statement'. + True is returned if at least one service has been loaded. """ return len(self) > 0 @@ -803,7 +729,3 @@ class Apprise(object): """ return sum([1 if not isinstance(s, (ConfigBase, AppriseConfig)) else len(s.servers()) for s in self.servers]) - - -if six.PY2: - del Apprise.async_notify diff --git a/libs/apprise/Apprise.pyi b/libs/apprise/Apprise.pyi index 919d370db..5a34c9c65 100644 --- a/libs/apprise/Apprise.pyi +++ b/libs/apprise/Apprise.pyi @@ -58,6 +58,5 @@ class Apprise: def pop(self, index: int) -> ConfigBase: ... def __getitem__(self, index: int) -> ConfigBase: ... def __bool__(self) -> bool: ... - def __nonzero__(self) -> bool: ... def __iter__(self) -> Iterator[ConfigBase]: ... def __len__(self) -> int: ... \ No newline at end of file diff --git a/libs/apprise/AppriseAsset.py b/libs/apprise/AppriseAsset.py index cd7324837..80bd0656c 100644 --- a/libs/apprise/AppriseAsset.py +++ b/libs/apprise/AppriseAsset.py @@ -30,15 +30,20 @@ from os.path import dirname from os.path import isfile from os.path import abspath from .common import NotifyType +from .utils import module_detection -class AppriseAsset(object): +class AppriseAsset: """ Provides a supplimentary class that can be used to provide extra information and details that can be used by Apprise such as providing an alternate location to where images/icons can be found and the URL masks. + Any variable that starts with an underscore (_) can only be initialized + by this class manually and will/can not be parsed from a configuration + file. + """ # Application Identifier app_id = 'Apprise' @@ -102,8 +107,8 @@ class AppriseAsset(object): # - NotifyFormat.HTML # - None # - # If no format is specified (hence None), then no special pre-formating - # actions will take place during a notificaton. This has been and always + # If no format is specified (hence None), then no special pre-formatting + # actions will take place during a notification. This has been and always # will be the default. body_format = None @@ -132,6 +137,10 @@ class AppriseAsset(object): # that you leave this option as is otherwise. secure_logging = True + # Optionally specify one or more path to attempt to scan for Python modules + # By default, no paths are scanned. + __plugin_paths = [] + # All internal/system flags are prefixed with an underscore (_) # These can only be initialized using Python libraries and are not picked # up from (yaml) configuration files (if set) @@ -146,7 +155,7 @@ class AppriseAsset(object): # A unique identifer we can use to associate our calling source _uid = str(uuid4()) - def __init__(self, **kwargs): + def __init__(self, plugin_paths=None, **kwargs): """ Asset Initialization @@ -160,6 +169,10 @@ class AppriseAsset(object): setattr(self, key, value) + if plugin_paths: + # Load any decorated modules if defined + module_detection(plugin_paths) + def color(self, notify_type, color_type=None): """ Returns an HTML mapped color based on passed in notify type diff --git a/libs/apprise/AppriseAttachment.py b/libs/apprise/AppriseAttachment.py index 37d2c0901..b808cfaee 100644 --- a/libs/apprise/AppriseAttachment.py +++ b/libs/apprise/AppriseAttachment.py @@ -23,18 +23,17 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -import six - from . import attachment from . import URLBase from .AppriseAsset import AppriseAsset from .logger import logger from .common import ContentLocation from .common import CONTENT_LOCATIONS +from .common import ATTACHMENT_SCHEMA_MAP from .utils import GET_SCHEMA_RE -class AppriseAttachment(object): +class AppriseAttachment: """ Our Apprise Attachment File Manager @@ -142,7 +141,7 @@ class AppriseAttachment(object): self.attachments.append(attachments) return True - elif isinstance(attachments, six.string_types): + elif isinstance(attachments, str): # Save our path attachments = (attachments, ) @@ -161,7 +160,7 @@ class AppriseAttachment(object): return_status = False continue - if isinstance(_attachment, six.string_types): + if isinstance(_attachment, str): logger.debug("Loading attachment: {}".format(_attachment)) # Instantiate ourselves an object, this function throws or # returns None if it fails @@ -225,13 +224,13 @@ class AppriseAttachment(object): schema = schema.group('schema').lower() # Some basic validation - if schema not in attachment.SCHEMA_MAP: + if schema not in ATTACHMENT_SCHEMA_MAP: logger.warning('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 = attachment.SCHEMA_MAP[schema].parse_url(url) + results = ATTACHMENT_SCHEMA_MAP[schema].parse_url(url) if not results: # Failed to parse the server URL @@ -251,7 +250,7 @@ class AppriseAttachment(object): # Attempt to create an instance of our plugin using the parsed # URL information attach_plugin = \ - attachment.SCHEMA_MAP[results['schema']](**results) + ATTACHMENT_SCHEMA_MAP[results['schema']](**results) except Exception: # the arguments are invalid or can not be used. @@ -261,7 +260,7 @@ class AppriseAttachment(object): else: # Attempt to create an instance of our plugin using the parsed # URL information but don't wrap it in a try catch - attach_plugin = attachment.SCHEMA_MAP[results['schema']](**results) + attach_plugin = ATTACHMENT_SCHEMA_MAP[results['schema']](**results) return attach_plugin @@ -295,15 +294,8 @@ class AppriseAttachment(object): 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.attachments 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. + Allows the Apprise object to be wrapped in an 'if statement'. + True is returned if at least one service has been loaded. """ return True if self.attachments else False diff --git a/libs/apprise/AppriseAttachment.pyi b/libs/apprise/AppriseAttachment.pyi index d68eccc13..a28acb144 100644 --- a/libs/apprise/AppriseAttachment.pyi +++ b/libs/apprise/AppriseAttachment.pyi @@ -33,6 +33,5 @@ class AppriseAttachment: def pop(self, index: int = ...) -> AttachBase: ... def __getitem__(self, index: int) -> AttachBase: ... def __bool__(self) -> bool: ... - def __nonzero__(self) -> bool: ... def __iter__(self) -> Iterator[AttachBase]: ... def __len__(self) -> int: ... \ No newline at end of file diff --git a/libs/apprise/AppriseConfig.py b/libs/apprise/AppriseConfig.py index 64d01738c..f92d31d18 100644 --- a/libs/apprise/AppriseConfig.py +++ b/libs/apprise/AppriseConfig.py @@ -23,23 +23,19 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -import six - from . import config from . import ConfigBase from . import CONFIG_FORMATS from . import URLBase from .AppriseAsset import AppriseAsset - -from .common import MATCH_ALL_TAG -from .common import MATCH_ALWAYS_TAG +from . import common from .utils import GET_SCHEMA_RE from .utils import parse_list from .utils import is_exclusive_match from .logger import logger -class AppriseConfig(object): +class AppriseConfig: """ Our Apprise Configuration File Manager @@ -171,7 +167,7 @@ class AppriseConfig(object): self.configs.append(configs) return True - elif isinstance(configs, six.string_types): + elif isinstance(configs, str): # Save our path configs = (configs, ) @@ -189,7 +185,7 @@ class AppriseConfig(object): self.configs.append(_config) continue - elif not isinstance(_config, six.string_types): + elif not isinstance(_config, str): logger.warning( "An invalid configuration (type={}) was specified.".format( type(_config))) @@ -243,7 +239,7 @@ class AppriseConfig(object): # prepare default asset asset = self.asset - if not isinstance(content, six.string_types): + if not isinstance(content, str): logger.warning( "An invalid configuration (type={}) was specified.".format( type(content))) @@ -267,7 +263,8 @@ class AppriseConfig(object): # Return our status return True - def servers(self, tag=MATCH_ALL_TAG, match_always=True, *args, **kwargs): + def servers(self, tag=common.MATCH_ALL_TAG, match_always=True, *args, + **kwargs): """ Returns all of our servers dynamically build based on parsed configuration. @@ -285,7 +282,7 @@ class AppriseConfig(object): # A match_always flag allows us to pick up on our 'any' keyword # and notify these services under all circumstances - match_always = MATCH_ALWAYS_TAG if match_always else None + match_always = common.MATCH_ALWAYS_TAG if match_always else None # Build our tag setup # - top level entries are treated as an 'or' @@ -303,7 +300,7 @@ class AppriseConfig(object): # Apply our tag matching based on our defined logic if is_exclusive_match( - logic=tag, data=entry.tags, match_all=MATCH_ALL_TAG, + logic=tag, data=entry.tags, match_all=common.MATCH_ALL_TAG, match_always=match_always): # Build ourselves a list of services dynamically and return the # as a list @@ -334,13 +331,13 @@ class AppriseConfig(object): schema = schema.group('schema').lower() # Some basic validation - if schema not in config.SCHEMA_MAP: + if schema not in common.CONFIG_SCHEMA_MAP: logger.warning('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) + results = common.CONFIG_SCHEMA_MAP[schema].parse_url(url) if not results: # Failed to parse the server URL @@ -368,7 +365,8 @@ class AppriseConfig(object): try: # Attempt to create an instance of our plugin using the parsed # URL information - cfg_plugin = config.SCHEMA_MAP[results['schema']](**results) + cfg_plugin = \ + common.CONFIG_SCHEMA_MAP[results['schema']](**results) except Exception: # the arguments are invalid or can not be used. @@ -378,7 +376,7 @@ class AppriseConfig(object): 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) + cfg_plugin = common.CONFIG_SCHEMA_MAP[results['schema']](**results) return cfg_plugin @@ -432,15 +430,8 @@ class AppriseConfig(object): 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. + Allows the Apprise object to be wrapped in an 'if statement'. + True is returned if at least one service has been loaded. """ return True if self.configs else False diff --git a/libs/apprise/AppriseConfig.pyi b/libs/apprise/AppriseConfig.pyi index 36fa9c065..9ea819ac3 100644 --- a/libs/apprise/AppriseConfig.pyi +++ b/libs/apprise/AppriseConfig.pyi @@ -44,6 +44,5 @@ class AppriseConfig: def pop(self, index: int = ...) -> ConfigBase: ... def __getitem__(self, index: int) -> ConfigBase: ... def __bool__(self) -> bool: ... - def __nonzero__(self) -> bool: ... def __iter__(self) -> Iterator[ConfigBase]: ... def __len__(self) -> int: ... \ No newline at end of file diff --git a/libs/apprise/AppriseLocale.py b/libs/apprise/AppriseLocale.py index 714e11804..9da8467b7 100644 --- a/libs/apprise/AppriseLocale.py +++ b/libs/apprise/AppriseLocale.py @@ -23,7 +23,6 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -import six import ctypes import locale import contextlib @@ -52,18 +51,11 @@ try: except ImportError: # gettext isn't available; no problem, just fall back to using # the library features without multi-language support. - try: - # Python v2.7 - import __builtin__ - __builtin__.__dict__['_'] = lambda x: x # pragma: no branch - - except ImportError: - # Python v3.4+ - import builtins - builtins.__dict__['_'] = lambda x: x # pragma: no branch + import builtins + builtins.__dict__['_'] = lambda x: x # pragma: no branch -class LazyTranslation(object): +class LazyTranslation: """ Doesn't translate anything until str() or unicode() references are made. @@ -89,7 +81,7 @@ def gettext_lazy(text): return LazyTranslation(text=text) -class AppriseLocale(object): +class AppriseLocale: """ A wrapper class to gettext so that we can manipulate multiple lanaguages on the fly if required. @@ -186,7 +178,7 @@ class AppriseLocale(object): """ # We want to only use the 2 character version of this language # hence en_CA becomes en, en_US becomes en. - if not isinstance(lang, six.string_types): + if not isinstance(lang, str): if detect_fallback is False: # no detection enabled; we're done return None diff --git a/libs/apprise/URLBase.py b/libs/apprise/URLBase.py index f5428dbb1..eb4a379e4 100644 --- a/libs/apprise/URLBase.py +++ b/libs/apprise/URLBase.py @@ -24,26 +24,17 @@ # THE SOFTWARE. import re -import six from .logger import logger 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 urllib.parse import unquote as _unquote +from urllib.parse import quote as _quote from .AppriseLocale import gettext_lazy as _ from .AppriseAsset import AppriseAsset +from .utils import urlencode from .utils import parse_url from .utils import parse_bool from .utils import parse_list @@ -53,7 +44,7 @@ from .utils import parse_phone_no PATHSPLIT_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') -class PrivacyMode(object): +class PrivacyMode: # Defines different privacy modes strings can be printed as # Astrisk sets 4 of them: e.g. **** # This is used for passwords @@ -78,7 +69,7 @@ HTML_LOOKUP = { } -class URLBase(object): +class URLBase: """ This is the base class for all URL Manipulation """ @@ -346,7 +337,7 @@ class URLBase(object): Returns: str: The escaped html """ - if not isinstance(html, six.string_types) or not html: + if not isinstance(html, str) or not html: return '' # Escape HTML @@ -359,7 +350,7 @@ class URLBase(object): .replace(u' ', u' ') if convert_new_lines: - return escaped.replace(u'\n', u'<br/>') + return escaped.replace(u'\n', u'
') return escaped @@ -370,7 +361,7 @@ class URLBase(object): encoding and errors parameters specify how to decode percent-encoded sequences. - Wrapper to Python's unquote while remaining compatible with both + Wrapper to Python's `unquote` while remaining compatible with both Python 2 & 3 since the reference to this function changed between versions. @@ -389,20 +380,14 @@ class URLBase(object): if not content: return '' - try: - # Python v3.x - return _unquote(content, encoding=encoding, errors=errors) - - except TypeError: - # Python v2.7 - return _unquote(content) + return _unquote(content, encoding=encoding, errors=errors) @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 + Wrapper to Python's `quote` while remaining compatible with both Python 2 & 3 since the reference to this function changed between versions. @@ -422,13 +407,7 @@ class URLBase(object): 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) + return _quote(content, safe=safe, encoding=encoding, errors=errors) @staticmethod def pprint(content, privacy=True, mode=PrivacyMode.Outer, @@ -457,7 +436,7 @@ class URLBase(object): # Return 4 Asterisks return '****' - if not isinstance(content, six.string_types) or not content: + if not isinstance(content, str) or not content: # Nothing more to do return '' @@ -472,7 +451,7 @@ class URLBase(object): 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 + Wrapper to Python's `urlencode` while remaining compatible with both Python 2 & 3 since the reference to this function changed between versions. @@ -497,17 +476,8 @@ class URLBase(object): 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) + return urlencode( + query, doseq=doseq, safe=safe, encoding=encoding, errors=errors) @staticmethod def split_path(path, unquote=True): @@ -585,11 +555,6 @@ class URLBase(object): # Nothing further to do return [] - except AttributeError: - # This exception ONLY gets thrown under Python v2.7 if an - # object() is passed in place of the content - return [] - content = parse_phone_no(content) return content @@ -687,6 +652,9 @@ class URLBase(object): if 'cto' in results['qsd']: results['socket_connect_timeout'] = results['qsd']['cto'] + if 'port' in results['qsd']: + results['port'] = results['qsd']['port'] + return results @staticmethod @@ -721,13 +689,13 @@ class URLBase(object): for key in ('protocol', 'secure_protocol'): schema = getattr(self, key, None) - if isinstance(schema, six.string_types): + if isinstance(schema, str): schemas.add(schema) elif isinstance(schema, (set, list, tuple)): # Support iterables list types for s in schema: - if isinstance(s, six.string_types): + if isinstance(s, str): schemas.add(s) return schemas diff --git a/libs/apprise/__init__.py b/libs/apprise/__init__.py index ee031d93f..04ae0982d 100644 --- a/libs/apprise/__init__.py +++ b/libs/apprise/__init__.py @@ -24,7 +24,7 @@ # THE SOFTWARE. __title__ = 'Apprise' -__version__ = '0.9.8.3' +__version__ = '1.1.0' __author__ = 'Chris Caron' __license__ = 'MIT' __copywrite__ = 'Copyright (C) 2022 Chris Caron ' @@ -57,6 +57,8 @@ from .AppriseAsset import AppriseAsset from .AppriseConfig import AppriseConfig from .AppriseAttachment import AppriseAttachment +from . import decorators + # Inherit our logging with our additional entries added to it from .logger import logging from .logger import logger @@ -78,6 +80,9 @@ __all__ = [ 'ContentLocation', 'CONTENT_LOCATIONS', 'PrivacyMode', + # Decorator + 'decorators', + # Logging 'logging', 'logger', 'LogCapture', ] diff --git a/libs/apprise/attachment/AttachBase.py b/libs/apprise/attachment/AttachBase.py index aa7174fcf..16f6c6429 100644 --- a/libs/apprise/attachment/AttachBase.py +++ b/libs/apprise/attachment/AttachBase.py @@ -367,14 +367,7 @@ class AttachBase(URLBase): 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. - """ - return True if self.path 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. + Allows the Apprise object to be wrapped in an based 'if statement'. + True is returned if our content was downloaded correctly. """ return True if self.path else False diff --git a/libs/apprise/attachment/AttachBase.pyi b/libs/apprise/attachment/AttachBase.pyi index 9b8eb02a5..66b7179d3 100644 --- a/libs/apprise/attachment/AttachBase.pyi +++ b/libs/apprise/attachment/AttachBase.pyi @@ -34,4 +34,3 @@ class AttachBase: ) -> Dict[str, Any]: ... def __len__(self) -> int: ... def __bool__(self) -> bool: ... - def __nonzero__(self) -> bool: ... \ No newline at end of file diff --git a/libs/apprise/attachment/AttachHTTP.py b/libs/apprise/attachment/AttachHTTP.py index 1d915ad3c..da1d698e8 100644 --- a/libs/apprise/attachment/AttachHTTP.py +++ b/libs/apprise/attachment/AttachHTTP.py @@ -25,7 +25,6 @@ import re import os -import six import requests from tempfile import NamedTemporaryFile from .AttachBase import AttachBase @@ -67,7 +66,7 @@ class AttachHTTP(AttachBase): self.schema = 'https' if self.secure else 'http' self.fullpath = kwargs.get('fullpath') - if not isinstance(self.fullpath, six.string_types): + if not isinstance(self.fullpath, str): self.fullpath = '/' self.headers = {} diff --git a/libs/apprise/attachment/__init__.py b/libs/apprise/attachment/__init__.py index da6dbbf1e..7f83769a8 100644 --- a/libs/apprise/attachment/__init__.py +++ b/libs/apprise/attachment/__init__.py @@ -23,15 +23,12 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -import six import re from os import listdir from os.path import dirname from os.path import abspath - -# Maintains a mapping of all of the attachment services -SCHEMA_MAP = {} +from ..common import ATTACHMENT_SCHEMA_MAP __all__ = [] @@ -90,29 +87,29 @@ def __load_matrix(path=abspath(dirname(__file__)), name='apprise.attachment'): # 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 + if isinstance(proto, str): + if proto not in ATTACHMENT_SCHEMA_MAP: + ATTACHMENT_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 + if p not in ATTACHMENT_SCHEMA_MAP: + ATTACHMENT_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, str): + if protos not in ATTACHMENT_SCHEMA_MAP: + ATTACHMENT_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 + if p not in ATTACHMENT_SCHEMA_MAP: + ATTACHMENT_SCHEMA_MAP[p] = plugin - return SCHEMA_MAP + return ATTACHMENT_SCHEMA_MAP # Dynamically build our schema base diff --git a/libs/apprise/cli.py b/libs/apprise/cli.py index 70458c92d..0e60e5cd2 100644 --- a/libs/apprise/cli.py +++ b/libs/apprise/cli.py @@ -26,12 +26,12 @@ import click import logging import platform -import six import sys import os import re from os.path import isfile +from os.path import exists from os.path import expanduser from os.path import expandvars @@ -40,6 +40,7 @@ from . import NotifyFormat from . import Apprise from . import AppriseAsset from . import AppriseConfig + from .utils import parse_list from .common import NOTIFY_TYPES from .common import NOTIFY_FORMATS @@ -60,23 +61,42 @@ DEFAULT_RECURSION_DEPTH = 1 CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) # Define our default configuration we use if nothing is otherwise specified -DEFAULT_SEARCH_PATHS = ( +DEFAULT_CONFIG_PATHS = ( + # Legacy Path Support '~/.apprise', '~/.apprise.yml', '~/.config/apprise', '~/.config/apprise.yml', + + # Plugin Support Extended Directory Search Paths + '~/.apprise/apprise', + '~/.apprise/apprise.yml', + '~/.config/apprise/apprise', + '~/.config/apprise/apprise.yml', +) + +# Define our paths to search for plugins +DEFAULT_PLUGIN_PATHS = ( + '~/.apprise/plugins', + '~/.config/apprise/plugins', ) # Detect Windows if platform.system() == 'Windows': - # Default Search Path for Windows Users - DEFAULT_SEARCH_PATHS = ( + # Default Config Search Path for Windows Users + DEFAULT_CONFIG_PATHS = ( expandvars('%APPDATA%/Apprise/apprise'), expandvars('%APPDATA%/Apprise/apprise.yml'), expandvars('%LOCALAPPDATA%/Apprise/apprise'), expandvars('%LOCALAPPDATA%/Apprise/apprise.yml'), ) + # Default Plugin Search Path for Windows Users + DEFAULT_PLUGIN_PATHS = ( + expandvars('%APPDATA%/Apprise/plugins'), + expandvars('%LOCALAPPDATA%/Apprise/plugins'), + ) + def print_help_msg(command): """ @@ -107,6 +127,9 @@ def print_version_msg(): @click.option('--title', '-t', default=None, type=str, help='Specify the message title. This field is complete ' 'optional.') +@click.option('--plugin-path', '-P', default=None, type=str, multiple=True, + metavar='PLUGIN_PATH', + help='Specify one or more plugin paths to scan.') @click.option('--config', '-c', default=None, type=str, multiple=True, metavar='CONFIG_URL', help='Specify one or more configuration locations.') @@ -158,7 +181,7 @@ def print_version_msg(): metavar='SERVER_URL [SERVER_URL2 [SERVER_URL3]]',) def main(body, title, config, attach, urls, notification_type, theme, tag, input_format, dry_run, recursion_depth, verbose, disable_async, - details, interpret_escapes, debug, version): + details, interpret_escapes, plugin_path, debug, version): """ Send a notification to all of the specified servers identified by their URLs the content provided within the title, body and notification-type. @@ -232,6 +255,12 @@ def main(body, title, config, attach, urls, notification_type, theme, tag, # issue. For consistency, we also return a 2 sys.exit(2) + if not plugin_path: + # Prepare a default set of plugin path + plugin_path = \ + next((path for path in DEFAULT_PLUGIN_PATHS + if exists(expanduser(path))), None) + # Prepare our asset asset = AppriseAsset( # Our body format @@ -243,11 +272,14 @@ def main(body, title, config, attach, urls, notification_type, theme, tag, # Set the theme theme=theme, - # Async mode is only used for Python v3+ and allows a user to send - # all of their notifications asyncronously. This was made an option - # incase there are problems in the future where it's better that - # everything run sequentially/syncronously instead. + # Async mode allows a user to send all of their notifications + # asynchronously. This was made an option incase there are problems + # in the future where it is better that everything runs sequentially/ + # synchronously instead. async_mode=disable_async is not True, + + # Load our plugins + plugin_paths=plugin_path, ) # Create our Apprise object @@ -263,11 +295,11 @@ def main(body, title, config, attach, urls, notification_type, theme, tag, for entry in plugins: protocols = [] if not entry['protocols'] else \ [p for p in entry['protocols'] - if isinstance(p, six.string_types)] + if isinstance(p, str)] protocols.extend( [] if not entry['secure_protocols'] else [p for p in entry['secure_protocols'] - if isinstance(p, six.string_types)]) + if isinstance(p, str)]) if len(protocols) == 1: # Simplify view by swapping {schema} with the single @@ -284,11 +316,18 @@ def main(body, title, config, attach, urls, notification_type, theme, tag, '{}://'.format(protocols[0]), entry['details']['templates'][x]) + fg = "green" if entry['enabled'] else "red" + if entry['category'] == 'custom': + # Identify these differently + fg = "cyan" + # Flip the enable switch so it forces the requirements + # to be displayed + entry['enabled'] = False + click.echo(click.style( '{} {:<30} '.format( '+' if entry['enabled'] else '-', - str(entry['service_name'])), - fg="green" if entry['enabled'] else "red", bold=True), + str(entry['service_name'])), fg=fg, bold=True), nl=(not entry['enabled'] or len(protocols) == 1)) if not entry['enabled']: @@ -307,8 +346,9 @@ def main(body, title, config, attach, urls, notification_type, theme, tag, click.echo(' - ' + req) # new line padding between entries - click.echo() - continue + if entry['category'] == 'native': + click.echo() + continue if len(protocols) > 1: click.echo('| Schema(s): {}'.format( @@ -324,6 +364,7 @@ def main(body, title, config, attach, urls, notification_type, theme, tag, click.echo() sys.exit(0) + # end if details() # The priorities of what is accepted are parsed in order below: # 1. URLs by command line @@ -372,13 +413,14 @@ def main(body, title, config, attach, urls, notification_type, theme, tag, a.add(AppriseConfig( paths=os.environ['APPRISE_CONFIG'].strip(), asset=asset, recursion=recursion_depth)) + else: # Load default configuration a.add(AppriseConfig( - paths=[f for f in DEFAULT_SEARCH_PATHS if isfile(expanduser(f))], + paths=[f for f in DEFAULT_CONFIG_PATHS if isfile(expanduser(f))], asset=asset, recursion=recursion_depth)) - if len(a) == 0: + if len(a) == 0 and not urls: logger.error( 'You must specify at least one server URL or populated ' 'configuration file.') diff --git a/libs/apprise/common.py b/libs/apprise/common.py index d1f43ada0..958bd22ee 100644 --- a/libs/apprise/common.py +++ b/libs/apprise/common.py @@ -24,7 +24,52 @@ # THE SOFTWARE. -class NotifyType(object): +# we mirror our base purely for the ability to reset everything; this +# is generally only used in testing and should not be used by developers +# It is also used as a means of preventing a module from being reloaded +# in the event it already exists +NOTIFY_MODULE_MAP = {} + +# Maintains a mapping of all of the Notification services +NOTIFY_SCHEMA_MAP = {} + +# This contains a mapping of all plugins dynamicaly loaded at runtime from +# external modules such as the @notify decorator +# +# The elements here will be additionally added to the NOTIFY_SCHEMA_MAP if +# there is no conflict otherwise. +# The structure looks like the following: +# Module path, e.g. /usr/share/apprise/plugins/my_notify_hook.py +# { +# 'path': path, +# +# 'notify': { +# 'schema': { +# 'name': 'Custom schema name', +# 'fn_name': 'name_of_function_decorator_was_found_on', +# 'url': 'schema://any/additional/info/found/on/url' +# 'plugin': +# }, +# 'schema2': { +# 'name': 'Custom schema name', +# 'fn_name': 'name_of_function_decorator_was_found_on', +# 'url': 'schema://any/additional/info/found/on/url' +# 'plugin': +# } +# } +# +# Note: that the inherits from +# NotifyBase +NOTIFY_CUSTOM_MODULE_MAP = {} + +# Maintains a mapping of all configuration schema's supported +CONFIG_SCHEMA_MAP = {} + +# Maintains a mapping of all attachment schema's supported +ATTACHMENT_SCHEMA_MAP = {} + + +class NotifyType: """ A simple mapping of notification types most commonly used with all types of logging and notification services. @@ -43,7 +88,7 @@ NOTIFY_TYPES = ( ) -class NotifyImageSize(object): +class NotifyImageSize: """ A list of pre-defined image sizes to make it easier to work with defined plugins. @@ -62,7 +107,7 @@ NOTIFY_IMAGE_SIZES = ( ) -class NotifyFormat(object): +class NotifyFormat: """ A list of pre-defined text message formats that can be passed via the apprise library. @@ -79,7 +124,7 @@ NOTIFY_FORMATS = ( ) -class OverflowMode(object): +class OverflowMode: """ A list of pre-defined modes of how to handle the text when it exceeds the defined maximum message size. @@ -107,7 +152,7 @@ OVERFLOW_MODES = ( ) -class ConfigFormat(object): +class ConfigFormat: """ A list of pre-defined config formats that can be passed via the apprise library. @@ -130,7 +175,7 @@ CONFIG_FORMATS = ( ) -class ContentIncludeMode(object): +class ContentIncludeMode: """ The different Content inclusion modes. All content based plugins will have one of these associated with it. @@ -155,7 +200,7 @@ CONTENT_INCLUDE_MODES = ( ) -class ContentLocation(object): +class ContentLocation: """ This is primarily used for handling file attachments. The idea is to track the source of the attachment itself. We don't want diff --git a/libs/apprise/config/ConfigBase.py b/libs/apprise/config/ConfigBase.py index f2b958ed8..d504a98dd 100644 --- a/libs/apprise/config/ConfigBase.py +++ b/libs/apprise/config/ConfigBase.py @@ -25,22 +25,18 @@ import os import re -import six import yaml import time from .. import plugins +from .. import common from ..AppriseAsset import AppriseAsset from ..URLBase import URLBase -from ..common import ConfigFormat -from ..common import CONFIG_FORMATS -from ..common import ContentIncludeMode from ..utils import GET_SCHEMA_RE from ..utils import parse_list from ..utils import parse_bool from ..utils import parse_urls from ..utils import cwe312_url -from . import SCHEMA_MAP # Test whether token is valid or not VALID_TOKEN = re.compile( @@ -57,7 +53,7 @@ class ConfigBase(URLBase): # The default expected configuration format unless otherwise # detected by the sub-modules - default_config_format = ConfigFormat.TEXT + default_config_format = common.ConfigFormat.TEXT # This is only set if the user overrides the config format on the URL # this should always initialize itself as None @@ -70,7 +66,7 @@ class ConfigBase(URLBase): # By default all configuration is not includable using the 'include' # line found in configuration files. - allow_cross_includes = ContentIncludeMode.NEVER + allow_cross_includes = common.ContentIncludeMode.NEVER # the config path manages the handling of relative include config_path = os.getcwd() @@ -138,11 +134,11 @@ class ConfigBase(URLBase): self.encoding = kwargs.get('encoding') if 'format' in kwargs \ - and isinstance(kwargs['format'], six.string_types): + and isinstance(kwargs['format'], str): # Store the enforced config format self.config_format = kwargs.get('format').lower() - if self.config_format not in CONFIG_FORMATS: + if self.config_format not in common.CONFIG_FORMATS: # Simple error checking err = 'An invalid config format ({}) was specified.'.format( self.config_format) @@ -183,7 +179,7 @@ class ConfigBase(URLBase): # 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): + if not isinstance(content, str): # Set the time our content was cached at self._cached_time = time.time() @@ -230,7 +226,7 @@ class ConfigBase(URLBase): schema = schema.group('schema').lower() # Some basic validation - if schema not in SCHEMA_MAP: + if schema not in common.CONFIG_SCHEMA_MAP: ConfigBase.logger.warning( 'Unsupported include schema {}.'.format(schema)) continue @@ -241,7 +237,7 @@ class ConfigBase(URLBase): # Parse our url details of the server object as dictionary # containing all of the information parsed from our URL - results = SCHEMA_MAP[schema].parse_url(url) + results = common.CONFIG_SCHEMA_MAP[schema].parse_url(url) if not results: # Failed to parse the server URL self.logger.warning( @@ -249,12 +245,13 @@ class ConfigBase(URLBase): continue # Handle cross inclusion based on allow_cross_includes rules - if (SCHEMA_MAP[schema].allow_cross_includes == - ContentIncludeMode.STRICT + if (common.CONFIG_SCHEMA_MAP[schema].allow_cross_includes == + common.ContentIncludeMode.STRICT and schema not in self.schemas() and not self.insecure_includes) or \ - SCHEMA_MAP[schema].allow_cross_includes == \ - ContentIncludeMode.NEVER: + common.CONFIG_SCHEMA_MAP[schema] \ + .allow_cross_includes == \ + common.ContentIncludeMode.NEVER: # Prevent the loading if insecure base protocols ConfigBase.logger.warning( @@ -280,7 +277,8 @@ class ConfigBase(URLBase): try: # Attempt to create an instance of our plugin using the # parsed URL information - cfg_plugin = SCHEMA_MAP[results['schema']](**results) + cfg_plugin = \ + common.CONFIG_SCHEMA_MAP[results['schema']](**results) except Exception as e: # the arguments are invalid or can not be used. @@ -379,7 +377,7 @@ class ConfigBase(URLBase): # Allow overriding the default config format if 'format' in results['qsd']: results['format'] = results['qsd'].get('format') - if results['format'] not in CONFIG_FORMATS: + if results['format'] not in common.CONFIG_FORMATS: URLBase.logger.warning( 'Unsupported format specified {}'.format( results['format'])) @@ -457,14 +455,14 @@ class ConfigBase(URLBase): # Attempt to detect configuration if result.group('yaml'): - config_format = ConfigFormat.YAML + config_format = common.ConfigFormat.YAML ConfigBase.logger.debug( 'Detected YAML configuration ' 'based on line {}.'.format(line)) break elif result.group('text'): - config_format = ConfigFormat.TEXT + config_format = common.ConfigFormat.TEXT ConfigBase.logger.debug( 'Detected TEXT configuration ' 'based on line {}.'.format(line)) @@ -472,7 +470,7 @@ class ConfigBase(URLBase): # If we reach here, we have a comment entry # Adjust default format to TEXT - config_format = ConfigFormat.TEXT + config_format = common.ConfigFormat.TEXT return config_format @@ -493,7 +491,7 @@ class ConfigBase(URLBase): ConfigBase.logger.error('Could not detect configuration') return (list(), list()) - if config_format not in CONFIG_FORMATS: + if config_format not in common.CONFIG_FORMATS: # Invalid configuration type specified ConfigBase.logger.error( 'An invalid configuration format ({}) was specified'.format( @@ -618,7 +616,7 @@ class ConfigBase(URLBase): try: # Attempt to create an instance of our plugin using the # parsed URL information - plugin = plugins.SCHEMA_MAP[results['schema']](**results) + plugin = common.NOTIFY_SCHEMA_MAP[results['schema']](**results) # Create log entry of loaded URL ConfigBase.logger.debug( @@ -705,7 +703,7 @@ class ConfigBase(URLBase): if not (hasattr(asset, k) and isinstance(getattr(asset, k), - (bool, six.string_types))): + (bool, str))): # We can't set a function or non-string set value ConfigBase.logger.warning( @@ -716,7 +714,7 @@ class ConfigBase(URLBase): # Convert to an empty string v = '' - if (isinstance(v, (bool, six.string_types)) + if (isinstance(v, (bool, str)) and isinstance(getattr(asset, k), bool)): # If the object in the Asset is a boolean, then @@ -724,7 +722,7 @@ class ConfigBase(URLBase): # match that. setattr(asset, k, parse_bool(v)) - elif isinstance(v, six.string_types): + elif isinstance(v, str): # Set our asset object with the new value setattr(asset, k, v.strip()) @@ -739,7 +737,7 @@ class ConfigBase(URLBase): global_tags = set() tags = result.get('tag', None) - if tags and isinstance(tags, (list, tuple, six.string_types)): + if tags and isinstance(tags, (list, tuple, str)): # Store any preset tags global_tags = set(parse_list(tags)) @@ -747,7 +745,7 @@ class ConfigBase(URLBase): # include root directive # includes = result.get('include', None) - if isinstance(includes, six.string_types): + if isinstance(includes, str): # Support a single inline string or multiple ones separated by a # comma and/or space includes = parse_urls(includes) @@ -759,7 +757,7 @@ class ConfigBase(URLBase): # Iterate over each config URL for no, url in enumerate(includes): - if isinstance(url, six.string_types): + if isinstance(url, str): # Support a single inline string or multiple ones separated by # a comma and/or space configs.extend(parse_urls(url)) @@ -787,7 +785,7 @@ class ConfigBase(URLBase): loggable_url = url if not asset.secure_logging \ else cwe312_url(url) - if isinstance(url, six.string_types): + if isinstance(url, str): # We're just a simple URL string... schema = GET_SCHEMA_RE.match(url) if schema is None: @@ -818,10 +816,7 @@ class ConfigBase(URLBase): # can at least tell the end user what entries were ignored # due to errors - if six.PY2: - it = url.iteritems() - else: # six.PY3 - it = iter(url.items()) + it = iter(url.items()) # Track the URL to-load _url = None @@ -871,17 +866,14 @@ class ConfigBase(URLBase): # We are a url string with additional unescaped options if isinstance(entries, dict): - if six.PY2: - _url, tokens = next(url.iteritems()) - else: # six.PY3 - _url, tokens = next(iter(url.items())) + _url, tokens = next(iter(url.items())) # Tags you just can't over-ride if 'schema' in entries: del entries['schema'] # support our special tokens (if they're present) - if schema in plugins.SCHEMA_MAP: + if schema in common.NOTIFY_SCHEMA_MAP: entries = ConfigBase._special_token_handler( schema, entries) @@ -893,7 +885,7 @@ class ConfigBase(URLBase): elif isinstance(tokens, dict): # support our special tokens (if they're present) - if schema in plugins.SCHEMA_MAP: + if schema in common.NOTIFY_SCHEMA_MAP: tokens = ConfigBase._special_token_handler( schema, tokens) @@ -927,6 +919,14 @@ class ConfigBase(URLBase): # Grab our first item _results = results.pop(0) + if _results['schema'] not in common.NOTIFY_SCHEMA_MAP: + # the arguments are invalid or can not be used. + ConfigBase.logger.warning( + 'An invalid Apprise schema ({}) in YAML configuration ' + 'entry #{}, item #{}' + .format(_results['schema'], no + 1, entry)) + continue + # tag is a special keyword that is managed by Apprise object. # The below ensures our tags are set correctly if 'tag' in _results: @@ -958,10 +958,12 @@ class ConfigBase(URLBase): # Prepare our Asset Object _results['asset'] = asset + # Now we generate our plugin try: # Attempt to create an instance of our plugin using the # parsed URL information - plugin = plugins.SCHEMA_MAP[_results['schema']](**_results) + plugin = common.\ + NOTIFY_SCHEMA_MAP[_results['schema']](**_results) # Create log entry of loaded URL ConfigBase.logger.debug( @@ -1014,7 +1016,7 @@ class ConfigBase(URLBase): # Create a copy of our dictionary tokens = tokens.copy() - for kw, meta in plugins.SCHEMA_MAP[schema]\ + for kw, meta in common.NOTIFY_SCHEMA_MAP[schema]\ .template_kwargs.items(): # Determine our prefix: @@ -1059,7 +1061,7 @@ class ConfigBase(URLBase): # This function here allows these mappings to take place within the # YAML file as independant arguments. class_templates = \ - plugins.details(plugins.SCHEMA_MAP[schema]) + plugins.details(common.NOTIFY_SCHEMA_MAP[schema]) for key in list(tokens.keys()): @@ -1088,7 +1090,7 @@ class ConfigBase(URLBase): # Detect if we're dealign with a list or not is_list = re.search( - r'^(list|choice):.*', + r'^list:.*', meta.get('type'), re.IGNORECASE) @@ -1105,7 +1107,7 @@ class ConfigBase(URLBase): r'^(choice:)?string', meta.get('type'), re.IGNORECASE) \ - and not isinstance(value, six.string_types): + and not isinstance(value, str): # Ensure our format is as expected value = str(value) @@ -1158,19 +1160,8 @@ class ConfigBase(URLBase): 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. + Allows the Apprise object to be wrapped in an '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 diff --git a/libs/apprise/config/ConfigFile.py b/libs/apprise/config/ConfigFile.py index 6fd1ecb23..10f0a463c 100644 --- a/libs/apprise/config/ConfigFile.py +++ b/libs/apprise/config/ConfigFile.py @@ -24,7 +24,6 @@ # THE SOFTWARE. import re -import io import os from .ConfigBase import ConfigBase from ..common import ConfigFormat @@ -119,9 +118,7 @@ class ConfigFile(ConfigBase): self.throttle() try: - # Python 3 just supports open(), however to remain compatible with - # Python 2, we use the io module - with io.open(self.path, "rt", encoding=self.encoding) as f: + with open(self.path, "rt", encoding=self.encoding) as f: # Store our content for parsing response = f.read() diff --git a/libs/apprise/config/ConfigHTTP.py b/libs/apprise/config/ConfigHTTP.py index 88352733c..795c6fac8 100644 --- a/libs/apprise/config/ConfigHTTP.py +++ b/libs/apprise/config/ConfigHTTP.py @@ -24,7 +24,6 @@ # THE SOFTWARE. import re -import six import requests from .ConfigBase import ConfigBase from ..common import ConfigFormat @@ -81,7 +80,7 @@ class ConfigHTTP(ConfigBase): self.schema = 'https' if self.secure else 'http' self.fullpath = kwargs.get('fullpath') - if not isinstance(self.fullpath, six.string_types): + if not isinstance(self.fullpath, str): self.fullpath = '/' self.headers = {} diff --git a/libs/apprise/config/__init__.py b/libs/apprise/config/__init__.py index 353123e9a..783118903 100644 --- a/libs/apprise/config/__init__.py +++ b/libs/apprise/config/__init__.py @@ -24,14 +24,11 @@ # THE SOFTWARE. import re -import six from os import listdir from os.path import dirname from os.path import abspath from ..logger import logger - -# Maintains a mapping of all of the configuration services -SCHEMA_MAP = {} +from ..common import CONFIG_SCHEMA_MAP __all__ = [] @@ -89,40 +86,20 @@ def __load_matrix(path=abspath(dirname(__file__)), name='apprise.config'): globals()[plugin_name] = plugin fn = getattr(plugin, 'schemas', None) - try: - schemas = set([]) if not callable(fn) else fn(plugin) - - except TypeError: - # Python v2.x support where functions associated with classes - # were considered bound to them and could not be called prior - # to the classes initialization. This code can be dropped - # once Python v2.x support is dropped. The below code introduces - # replication as it already exists and is tested in - # URLBase.schemas() - schemas = set([]) - for key in ('protocol', 'secure_protocol'): - schema = getattr(plugin, key, None) - if isinstance(schema, six.string_types): - schemas.add(schema) - - elif isinstance(schema, (set, list, tuple)): - # Support iterables list types - for s in schema: - if isinstance(s, six.string_types): - schemas.add(s) + schemas = set([]) if not callable(fn) else fn(plugin) # map our schema to our plugin for schema in schemas: - if schema in SCHEMA_MAP: + if schema in CONFIG_SCHEMA_MAP: logger.error( "Config schema ({}) mismatch detected - {} to {}" - .format(schema, SCHEMA_MAP[schema], plugin)) + .format(schema, CONFIG_SCHEMA_MAP[schema], plugin)) continue # Assign plugin - SCHEMA_MAP[schema] = plugin + CONFIG_SCHEMA_MAP[schema] = plugin - return SCHEMA_MAP + return CONFIG_SCHEMA_MAP # Dynamically build our schema base diff --git a/libs/apprise/conversion.py b/libs/apprise/conversion.py index bfd9a644d..3b692aa60 100644 --- a/libs/apprise/conversion.py +++ b/libs/apprise/conversion.py @@ -23,18 +23,12 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. - import re -import six from markdown import markdown from .common import NotifyFormat from .URLBase import URLBase -if six.PY2: - from HTMLParser import HTMLParser - -else: - from html.parser import HTMLParser +from html.parser import HTMLParser def convert_between(from_format, to_format, content): @@ -70,7 +64,8 @@ def text_to_html(content): Converts specified content from plain text to HTML. """ - return URLBase.escape_html(content) + # First eliminate any carriage returns + return URLBase.escape_html(content, convert_new_lines=True) def html_to_text(content): @@ -79,10 +74,6 @@ def html_to_text(content): """ parser = HTMLConverter() - if six.PY2: - # Python 2.7 requires an additional parsing to un-escape characters - content = parser.unescape(content) - parser.feed(content) parser.close() return parser.converted @@ -96,7 +87,9 @@ class HTMLConverter(HTMLParser, object): 'div', 'td', 'th', 'code', 'pre', 'label', 'li',) # the folowing tags ignore any internal text - IGNORE_TAGS = ('style', 'link', 'meta', 'title', 'html', 'head', 'script') + IGNORE_TAGS = ( + 'form', 'input', 'textarea', 'select', 'ul', 'ol', 'style', 'link', + 'meta', 'title', 'html', 'head', 'script') # Condense Whitespace WS_TRIM = re.compile(r'[\s]+', re.DOTALL | re.MULTILINE) @@ -122,14 +115,6 @@ class HTMLConverter(HTMLParser, object): string = ''.join(self._finalize(self._result)) self.converted = string.strip() - if six.PY2: - # See https://stackoverflow.com/questions/10993612/\ - # how-to-remove-xa0-from-string-in-python - # - # This is required since the unescape() nbsp; with \xa0 when - # using Python 2.7 - self.converted = self.converted.replace(u'\xa0', u' ') - def _finalize(self, result): """ Combines and strips consecutive strings, then converts consecutive diff --git a/libs/apprise/decorators/CustomNotifyPlugin.py b/libs/apprise/decorators/CustomNotifyPlugin.py new file mode 100644 index 000000000..39fb51a9e --- /dev/null +++ b/libs/apprise/decorators/CustomNotifyPlugin.py @@ -0,0 +1,226 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2022 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +from ..plugins.NotifyBase import NotifyBase +from ..utils import URL_DETAILS_RE +from ..utils import parse_url +from ..utils import url_assembly +from ..utils import dict_full_update +from .. import common +from ..logger import logger +import inspect + + +class CustomNotifyPlugin(NotifyBase): + """ + Apprise Custom Plugin Hook + + This gets initialized based on @notify decorator definitions + + """ + # Our Custom notification + service_url = 'https://github.com/caronc/apprise/wiki/Custom_Notification' + + # Over-ride our category since this inheritance of the NotifyBase class + # should be treated differently. + category = 'custom' + + # Define object templates + templates = ( + '{schema}://', + ) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns arguments retrieved + + """ + return parse_url(url, verify_host=False, simple=True) + + def url(self, privacy=False, *args, **kwargs): + """ + General URL assembly + """ + return '{schema}://'.format(schema=self.secure_protocol) + + @staticmethod + def instantiate_plugin(url, send_func, name=None): + """ + The function used to add a new notification plugin based on the schema + parsed from the provided URL into our supported matrix structure. + """ + + if not isinstance(url, str): + msg = 'An invalid custom notify url/schema ({}) provided in ' \ + 'function {}.'.format(url, send_func.__name__) + logger.warning(msg) + return None + + # Validate that our schema is okay + re_match = URL_DETAILS_RE.match(url) + if not re_match: + msg = 'An invalid custom notify url/schema ({}) provided in ' \ + 'function {}.'.format(url, send_func.__name__) + logger.warning(msg) + return None + + # Acquire our plugin name + plugin_name = re_match.group('schema').lower() + + if not re_match.group('base'): + url = '{}://'.format(plugin_name) + + # Keep a default set of arguments to apply to all called references + base_args = parse_url( + url, default_schema=plugin_name, verify_host=False, simple=True) + + if plugin_name in common.NOTIFY_SCHEMA_MAP: + # we're already handling this object + msg = 'The schema ({}) is already defined and could not be ' \ + 'loaded from custom notify function {}.' \ + .format(url, send_func.__name__) + logger.warning(msg) + return None + + # We define our own custom wrapper class so that we can initialize + # some key default configuration values allowing calls to our + # `Apprise.details()` to correctly differentiate one custom plugin + # that was loaded from another + class CustomNotifyPluginWrapper(CustomNotifyPlugin): + + # Our Service Name + service_name = name if isinstance(name, str) \ + and name else 'Custom - {}'.format(plugin_name) + + # Store our matched schema + secure_protocol = plugin_name + + requirements = { + # Define our required packaging in order to work + 'details': "Source: {}".format(inspect.getfile(send_func)) + } + + # Assign our send() function + __send = staticmethod(send_func) + + # Update our default arguments + _base_args = base_args + + def __init__(self, **kwargs): + """ + Our initialization + + """ + # init parent + super(CustomNotifyPluginWrapper, self).__init__(**kwargs) + + self._default_args = {} + + # Apply our updates based on what was parsed + dict_full_update(self._default_args, self._base_args) + dict_full_update(self._default_args, kwargs) + + # Update our arguments (applying them to what we originally) + # initialized as + self._default_args['url'] = url_assembly(**self._default_args) + + def send(self, body, title='', notify_type=common.NotifyType.INFO, + *args, **kwargs): + """ + Our send() call which triggers our hook + """ + + response = False + try: + # Enforce a boolean response + result = self.__send( + body, title, notify_type, *args, + meta=self._default_args, **kwargs) + + if result is None: + # The wrapper did not define a return (or returned + # None) + # this is treated as a successful return as it is + # assumed the developer did not care about the result + # of the call. + response = True + + else: + # Perform boolean check (allowing obects to also be + # returned and check against the __bool__ call + response = True if result else False + + except Exception as e: + # Unhandled Exception + self.logger.warning( + 'An exception occured sending a %s notification.', + common. + NOTIFY_SCHEMA_MAP[self.secure_protocol].service_name) + self.logger.debug( + '%s Exception: %s', + common.NOTIFY_SCHEMA_MAP[self.secure_protocol], str(e)) + return False + + if response: + self.logger.info( + 'Sent %s notification.', + common. + NOTIFY_SCHEMA_MAP[self.secure_protocol].service_name) + else: + self.logger.warning( + 'Failed to send %s notification.', + common. + NOTIFY_SCHEMA_MAP[self.secure_protocol].service_name) + return response + + # Store our plugin into our core map file + common.NOTIFY_SCHEMA_MAP[plugin_name] = CustomNotifyPluginWrapper + + # Update our custom plugin map + module_pyname = str(send_func.__module__) + if module_pyname not in common.NOTIFY_CUSTOM_MODULE_MAP: + # Support non-dynamic includes as well... + common.NOTIFY_CUSTOM_MODULE_MAP[module_pyname] = { + 'path': inspect.getfile(send_func), + + # Initialize our template + 'notify': {}, + } + + common.\ + NOTIFY_CUSTOM_MODULE_MAP[module_pyname]['notify'][plugin_name] = { + # Our Serivice Description (for API and CLI --details view) + 'name': CustomNotifyPluginWrapper.service_name, + # The name of the send function the @notify decorator wrapped + 'fn_name': send_func.__name__, + # The URL that was provided in the @notify decorator call + # associated with the 'on=' + 'url': url, + # The Initialized Plugin that was generated based on the above + # parameters + 'plugin': CustomNotifyPluginWrapper} + + # return our plugin + return common.NOTIFY_SCHEMA_MAP[plugin_name] diff --git a/libs/apprise/decorators/__init__.py b/libs/apprise/decorators/__init__.py new file mode 100644 index 000000000..a6ef9662a --- /dev/null +++ b/libs/apprise/decorators/__init__.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2022 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +from .notify import notify + + +__all__ = [ + 'notify' +] diff --git a/libs/apprise/decorators/notify.py b/libs/apprise/decorators/notify.py new file mode 100644 index 000000000..3705e8708 --- /dev/null +++ b/libs/apprise/decorators/notify.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2022 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +from .CustomNotifyPlugin import CustomNotifyPlugin + + +def notify(on, name=None): + """ + @notify decorator allows you to map functions you've defined to be loaded + as a regular notify by Apprise. You must identify a protocol that + users will trigger your call by. + + @notify(on="foobar") + def your_declaration(body, title, notify_type, meta, *args, **kwargs): + ... + + You can optionally provide the name to associate with the plugin which + is what calling functions via the API will receive. + + @notify(on="foobar", name="My Foobar Process") + def your_action(body, title, notify_type, meta, *args, **kwargs): + ... + + The meta variable is actually the processed URL contents found in + configuration files that landed you in this function you wrote in + the first place. It's very easily tokenized already for you so + that you can bend the notification logic to your hearts content. + + @notify(on="foobar", name="My Foobar Process") + def your_action(body, title, notify_type, body_format, meta, attach, + *args, **kwargs): + ... + + Arguments break down as follows: + body: The message body associated with the notification + title: The message title associated with the notification + notify_type: The message type (info, success, warning, and failure) + body_format: The format of the incoming notification body. This is + either text, html, or markdown. + meta: Combines the URL arguments specified on the `on` call + with the ones loaded from a users configuration. This + is a dictionary that presents itself like this: + { + 'schema': 'http', + 'url': 'http://hostname', + 'host': 'hostname', + + 'user': 'john', + 'password': 'doe', + 'port': 80, + 'path': '/', + 'fullpath': '/test.php', + 'query': 'test.php', + + 'qsd': {'key': 'value', 'key2': 'value2'}, + + 'asset': , + 'tag': set(), + } + + Meta entries are ONLY present if found. A simple URL + such as foobar:// would only produce the following: + { + 'schema': 'foobar', + 'url': 'foobar://', + + 'asset': , + 'tag': set(), + } + + attach: An array AppriseAttachment objects (if any were provided) + + body_format: Defaults to the expected format output; By default this + will be TEXT unless over-ridden in the Apprise URL + + + If you don't intend on using all of the parameters, your @notify() call + # can be greatly simplified to just: + + @notify(on="foobar", name="My Foobar Process") + def your_action(body, title, *args, **kwargs) + + Always end your wrappers declaration with *args and **kwargs to be future + proof with newer versions of Apprise. + + Your wrapper should return True if processed the send() function as you + expected and return False if not. If nothing is returned, then this is + treated as as success (True). + + """ + def wrapper(func): + """ + Instantiate our custom (notification) plugin + """ + + # Generate + CustomNotifyPlugin.instantiate_plugin( + url=on, send_func=func, name=name) + + return func + + return wrapper diff --git a/libs/apprise/logger.py b/libs/apprise/logger.py index 082178129..4510e1b60 100644 --- a/libs/apprise/logger.py +++ b/libs/apprise/logger.py @@ -66,7 +66,7 @@ logging.Logger.deprecate = deprecate logger = logging.getLogger(LOGGER_NAME) -class LogCapture(object): +class LogCapture: """ A class used to allow one to instantiate loggers that write to memory for temporary purposes. e.g.: diff --git a/libs/apprise/plugins/NotifyAppriseAPI.py b/libs/apprise/plugins/NotifyAppriseAPI.py index b981f97a2..10d52d5ba 100644 --- a/libs/apprise/plugins/NotifyAppriseAPI.py +++ b/libs/apprise/plugins/NotifyAppriseAPI.py @@ -24,7 +24,6 @@ # THE SOFTWARE. import re -import six import requests from json import dumps @@ -137,7 +136,7 @@ class NotifyAppriseAPI(NotifyBase): super(NotifyAppriseAPI, self).__init__(**kwargs) self.fullpath = kwargs.get('fullpath') - if not isinstance(self.fullpath, six.string_types): + if not isinstance(self.fullpath, str): self.fullpath = '/' self.token = validate_regex( @@ -339,18 +338,10 @@ class NotifyAppriseAPI(NotifyBase): 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+'] - if results['qsd-']: - results['headers'].update(results['qsd-']) - NotifyBase.logger.deprecate( - "minus (-) based Apprise API header tokens are being " - " removed; use the plus (+) symbol instead.") - - # Tidy our header entries by unquoting them + # to to our returned result set and tidy entries by unquoting them results['headers'] = \ {NotifyAppriseAPI.unquote(x): NotifyAppriseAPI.unquote(y) - for x, y in results['headers'].items()} + for x, y in results['qsd+'].items()} # Support the passing of tags in the URL if 'tags' in results['qsd'] and len(results['qsd']['tags']): diff --git a/libs/apprise/plugins/NotifyBark.py b/libs/apprise/plugins/NotifyBark.py new file mode 100644 index 000000000..d6283c022 --- /dev/null +++ b/libs/apprise/plugins/NotifyBark.py @@ -0,0 +1,506 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2022 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# API: https://github.com/Finb/bark-server/blob/master/docs/API_V2.md#python +# +import requests +import json + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyImageSize +from ..common import NotifyType +from ..utils import parse_list +from ..utils import parse_bool +from ..AppriseLocale import gettext_lazy as _ + + +# Sounds generated off of: https://github.com/Finb/Bark/tree/master/Sounds +BARK_SOUNDS = ( + "alarm.caf", + "anticipate.caf", + "bell.caf", + "birdsong.caf", + "bloom.caf", + "calypso.caf", + "chime.caf", + "choo.caf", + "descent.caf", + "electronic.caf", + "fanfare.caf", + "glass.caf", + "gotosleep.caf", + "healthnotification.caf", + "horn.caf", + "ladder.caf", + "mailsent.caf", + "minuet.caf", + "multiwayinvitation.caf", + "newmail.caf", + "newsflash.caf", + "noir.caf", + "paymentsuccess.caf", + "shake.caf", + "sherwoodforest.caf", + "silence.caf", + "spell.caf", + "suspense.caf", + "telegraph.caf", + "tiptoes.caf", + "typewriters.caf", + "update.caf", +) + + +# Supported Level Entries +class NotifyBarkLevel: + """ + Defines the Bark Level options + """ + ACTIVE = 'active' + + TIME_SENSITIVE = 'timeSensitive' + + PASSIVE = 'passive' + + +BARK_LEVELS = ( + NotifyBarkLevel.ACTIVE, + NotifyBarkLevel.TIME_SENSITIVE, + NotifyBarkLevel.PASSIVE, +) + + +class NotifyBark(NotifyBase): + """ + A wrapper for Notify Bark Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Bark' + + # The services URL + service_url = 'https://github.com/Finb/Bark' + + # The default protocol + protocol = 'bark' + + # The default secure protocol + secure_protocol = 'barks' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_bark' + + # Allows the user to specify the NotifyImageSize object; this is supported + # through the webhook + image_size = NotifyImageSize.XY_128 + + # Define object templates + templates = ( + '{schema}://{host}:{port}/{targets}', + '{schema}://{user}:{password}@{host}/{targets}', + '{schema}://{user}:{password}@{host}:{port}/{targets}', + '{schema}://{user}:{password}@{host}/{targets}', + ) + + # Define our template arguments + template_tokens = dict(NotifyBase.template_tokens, **{ + 'host': { + 'name': _('Hostname'), + 'type': 'string', + 'required': True, + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + }, + 'user': { + 'name': _('Username'), + 'type': 'string', + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + }, + 'target_device': { + 'name': _('Target Device'), + 'type': 'string', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'sound': { + 'name': _('Sound'), + 'type': 'choice:string', + 'values': BARK_SOUNDS, + }, + 'level': { + 'name': _('Level'), + 'type': 'choice:string', + 'values': BARK_LEVELS, + }, + 'click': { + 'name': _('Click'), + 'type': 'string', + }, + 'badge': { + 'name': _('Badge'), + 'type': 'int', + 'min': 0, + }, + 'category': { + 'name': _('Category'), + 'type': 'string', + }, + 'group': { + 'name': _('Group'), + 'type': 'string', + }, + 'image': { + 'name': _('Include Image'), + 'type': 'bool', + 'default': True, + 'map_to': 'include_image', + }, + }) + + def __init__(self, targets=None, include_image=True, sound=None, + category=None, group=None, level=None, click=None, + badge=None, **kwargs): + """ + Initialize Notify Bark Object + """ + super(NotifyBark, self).__init__(**kwargs) + + # Prepare our URL + self.notify_url = '%s://%s%s/push' % ( + 'https' if self.secure else 'http', + self.host, + ':{}'.format(self.port) + if (self.port and isinstance(self.port, int)) else '', + ) + + # Assign our category + self.category = \ + category if isinstance(category, str) else None + + # Assign our group + self.group = group if isinstance(group, str) else None + + # Initialize device list + self.targets = parse_list(targets) + + # Place an image inline with the message body + self.include_image = include_image + + # A clickthrough option for notifications + self.click = click + + # Badge + try: + # Acquire our badge count if we can: + # - We accept both the integer form as well as a string + # representation + self.badge = int(badge) + if self.badge < 0: + raise ValueError() + + except TypeError: + # NoneType means use Default; this is an okay exception + self.badge = None + + except ValueError: + self.badge = None + self.logger.warning( + 'The specified Bark badge ({}) is not valid ', badge) + + # Sound (easy-lookup) + self.sound = None if not sound else next( + (f for f in BARK_SOUNDS if f.startswith(sound.lower())), None) + if sound and not self.sound: + self.logger.warning( + 'The specified Bark sound ({}) was not found ', sound) + + # Level + self.level = None if not level else next( + (f for f in BARK_LEVELS if f[0] == level[0]), None) + if level and not self.level: + self.logger.warning( + 'The specified Bark level ({}) is not valid ', level) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Bark Notification + """ + + # error tracking (used for function return) + has_error = False + + if not len(self.targets): + # We have nothing to notify; we're done + self.logger.warning('There are no Bark devices to notify') + return False + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json; charset=utf-8', + } + + # Prepare our payload (sample below) + # { + # "body": "Test Bark Server", + # "device_key": "nysrshcqielvoxsa", + # "title": "bleem", + # "category": "category", + # "sound": "minuet.caf", + # "badge": 1, + # "icon": "https://day.app/assets/images/avatar.jpg", + # "group": "test", + # "url": "https://mritd.com" + # } + payload = { + 'title': title if title else self.app_desc, + 'body': body, + } + + # Acquire our image url if configured to do so + image_url = None if not self.include_image else \ + self.image_url(notify_type) + + if image_url: + payload['icon'] = image_url + + if self.sound: + payload['sound'] = self.sound + + if self.click: + payload['url'] = self.click + + if self.badge: + payload['badge'] = self.badge + + if self.level: + payload['level'] = self.level + + if self.category: + payload['category'] = self.category + + if self.group: + payload['group'] = self.group + + auth = None + if self.user: + auth = (self.user, self.password) + + # Create a copy of the targets + targets = list(self.targets) + + while len(targets) > 0: + # Retrieve our device key + target = targets.pop() + + payload['device_key'] = target + self.logger.debug('Bark POST URL: %s (cert_verify=%r)' % ( + self.notify_url, self.verify_certificate, + )) + self.logger.debug('Bark Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + self.notify_url, + data=json.dumps(payload), + headers=headers, + auth=auth, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyBark.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send Bark notification to {}: ' + '{}{}error={}.'.format( + target, + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + continue + + else: + self.logger.info( + 'Sent Bark notification to {}.'.format(target)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Bark ' + 'notification to {}.'.format(target)) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + return not has_error + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'image': 'yes' if self.include_image else 'no', + } + + if self.sound: + params['sound'] = self.sound + + if self.click: + params['click'] = self.click + + if self.badge: + params['badge'] = str(self.badge) + + if self.level: + params['level'] = self.level + + if self.category: + params['category'] = self.category + + if self.group: + params['group'] = self.group + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # Determine Authentication + auth = '' + if self.user and self.password: + auth = '{user}:{password}@'.format( + user=NotifyBark.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + ) + elif self.user: + auth = '{user}@'.format( + user=NotifyBark.quote(self.user, safe=''), + ) + + default_port = 443 if self.secure else 80 + + return '{schema}://{auth}{hostname}{port}/{targets}?{params}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + auth=auth, + # never encode hostname since we're expecting it to be a valid one + hostname=self.host, + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + targets='/'.join( + [NotifyBark.quote('{}'.format(x)) for x in self.targets]), + params=NotifyBark.urlencode(params), + ) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + + results = NotifyBase.parse_url(url) + if not results: + # We're done early as we couldn't load the results + return results + + # Apply our targets + results['targets'] = NotifyBark.split_path(results['fullpath']) + + # Category + if 'category' in results['qsd'] and results['qsd']['category']: + results['category'] = NotifyBark.unquote( + results['qsd']['category'].strip()) + + # Group + if 'group' in results['qsd'] and results['qsd']['group']: + results['group'] = NotifyBark.unquote( + results['qsd']['group'].strip()) + + # Badge + if 'badge' in results['qsd'] and results['qsd']['badge']: + results['badge'] = NotifyBark.unquote( + results['qsd']['badge'].strip()) + + # Level + if 'level' in results['qsd'] and results['qsd']['level']: + results['level'] = NotifyBark.unquote( + results['qsd']['level'].strip()) + + # Click (URL) + if 'click' in results['qsd'] and results['qsd']['click']: + results['click'] = NotifyBark.unquote( + results['qsd']['click'].strip()) + + # Sound + if 'sound' in results['qsd'] and results['qsd']['sound']: + results['sound'] = NotifyBark.unquote( + results['qsd']['sound'].strip()) + + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyBark.parse_list(results['qsd']['to']) + + # use image= for consistency with the other plugins + results['include_image'] = \ + parse_bool(results['qsd'].get('image', True)) + + return results diff --git a/libs/apprise/plugins/NotifyBase.py b/libs/apprise/plugins/NotifyBase.py index 54e897906..4bb937795 100644 --- a/libs/apprise/plugins/NotifyBase.py +++ b/libs/apprise/plugins/NotifyBase.py @@ -24,7 +24,6 @@ # THE SOFTWARE. import re -import six from ..URLBase import URLBase from ..common import NotifyType @@ -37,14 +36,9 @@ from ..AppriseLocale import gettext_lazy as _ from ..AppriseAttachment import AppriseAttachment -if six.PY3: - # Wrap our base with the asyncio wrapper - from ..py3compat.asyncio import AsyncNotifyBase - BASE_OBJECT = AsyncNotifyBase - -else: - # Python v2.7 (backwards compatibility) - BASE_OBJECT = URLBase +# Wrap our base with the asyncio wrapper +from ..py3compat.asyncio import AsyncNotifyBase +BASE_OBJECT = AsyncNotifyBase class NotifyBase(BASE_OBJECT): @@ -59,6 +53,15 @@ class NotifyBase(BASE_OBJECT): # enabled. enabled = True + # The category allows for parent inheritance of this object to alter + # this when it's function/use is intended to behave differently. The + # following category types exist: + # + # native: Is a native plugin written/stored in `apprise/plugins/Notify*` + # custom: Is a custom plugin written/stored in a users plugin directory + # that they loaded at execution time. + category = 'native' + # Some plugins may require additional packages above what is provided # already by Apprise. # diff --git a/libs/apprise/plugins/NotifyBoxcar.py b/libs/apprise/plugins/NotifyBoxcar.py index 04b43b194..b40b71cd9 100644 --- a/libs/apprise/plugins/NotifyBoxcar.py +++ b/libs/apprise/plugins/NotifyBoxcar.py @@ -24,7 +24,6 @@ # THE SOFTWARE. import re -import six import requests import hmac from json import dumps @@ -181,7 +180,7 @@ class NotifyBoxcar(NotifyBase): self.tags.append(DEFAULT_TAG) targets = [] - elif isinstance(targets, six.string_types): + elif isinstance(targets, str): targets = [x for x in filter(bool, TAGS_LIST_DELIM.split( targets, ))] @@ -357,13 +356,8 @@ class NotifyBoxcar(NotifyBase): # by default entries = NotifyBoxcar.split_path(results['fullpath']) - try: - # Now fetch the remaining tokens - results['secret'] = entries.pop(0) - - except IndexError: - # secret wasn't specified - results['secret'] = None + # Now fetch the remaining tokens + results['secret'] = entries.pop(0) if entries else None # Our recipients make up the remaining entries of our array results['targets'] = entries diff --git a/libs/apprise/plugins/NotifyBulkSMS.py b/libs/apprise/plugins/NotifyBulkSMS.py new file mode 100644 index 000000000..8fa546421 --- /dev/null +++ b/libs/apprise/plugins/NotifyBulkSMS.py @@ -0,0 +1,457 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2022 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +# To use this service you will need a BulkSMS account +# You will need credits (new accounts start with a few) +# https://www.bulksms.com/account/ +# +# API is documented here: +# - https://www.bulksms.com/developer/json/v1/#tag/Message +import re +import six +import requests +import json +from itertools import chain +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyType +from ..utils import is_phone_no +from ..utils import parse_phone_no +from ..utils import parse_bool +from ..AppriseLocale import gettext_lazy as _ + + +IS_GROUP_RE = re.compile( + r'^(@?(?P[A-Z0-9_-]+))$', + re.IGNORECASE, +) + + +class BulkSMSRoutingGroup(object): + """ + The different categories of routing + """ + ECONOMY = "ECONOMY" + STANDARD = "STANDARD" + PREMIUM = "PREMIUM" + + +# Used for verification purposes +BULKSMS_ROUTING_GROUPS = ( + BulkSMSRoutingGroup.ECONOMY, + BulkSMSRoutingGroup.STANDARD, + BulkSMSRoutingGroup.PREMIUM, +) + + +class BulkSMSEncoding(object): + """ + The different categories of routing + """ + TEXT = "TEXT" + UNICODE = "UNICODE" + BINARY = "BINARY" + + +class NotifyBulkSMS(NotifyBase): + """ + A wrapper for BulkSMS Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'BulkSMS' + + # The services URL + service_url = 'https://bulksms.com/' + + # All notification requests are secure + secure_protocol = 'bulksms' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_bulksms' + + # BulkSMS uses the http protocol with JSON requests + notify_url = 'https://api.bulksms.com/v1/messages' + + # The maximum length of the body + body_maxlen = 160 + + # The maximum amount of texts that can go out in one batch + default_batch_size = 4000 + + # 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 + + # Define object templates + templates = ( + '{schema}://{user}:{password}@{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'user': { + 'name': _('User Name'), + 'type': 'string', + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + }, + 'target_phone': { + 'name': _('Target Phone No'), + 'type': 'string', + 'prefix': '+', + 'regex': (r'^[0-9\s)(+-]+$', 'i'), + 'map_to': 'targets', + }, + 'target_group': { + 'name': _('Target Group'), + 'type': 'string', + 'prefix': '+', + 'regex': (r'^[A-Z0-9 _-]+$', 'i'), + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'from': { + 'name': _('From Phone No'), + 'type': 'string', + 'regex': (r'^\+?[0-9\s)(+-]+$', 'i'), + 'map_to': 'source', + }, + 'route': { + 'name': _('Route Group'), + 'type': 'choice:string', + 'values': BULKSMS_ROUTING_GROUPS, + 'default': BulkSMSRoutingGroup.STANDARD, + }, + 'unicode': { + # Unicode characters + 'name': _('Unicode Characters'), + 'type': 'bool', + 'default': True, + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + }) + + def __init__(self, source=None, targets=None, unicode=None, batch=None, + route=None, **kwargs): + """ + Initialize BulkSMS Object + """ + super(NotifyBulkSMS, self).__init__(**kwargs) + + self.source = None + if source: + result = is_phone_no(source) + if not result: + msg = 'The Account (From) Phone # specified ' \ + '({}) is invalid.'.format(source) + self.logger.warning(msg) + raise TypeError(msg) + + # Tidy source + self.source = '+{}'.format(result['full']) + + # Setup our route + self.route = self.template_args['route']['default'] \ + if not isinstance(route, six.string_types) else route.upper() + if self.route not in BULKSMS_ROUTING_GROUPS: + msg = 'The route specified ({}) is invalid.'.format(route) + self.logger.warning(msg) + raise TypeError(msg) + + # Define whether or not we should set the unicode flag + self.unicode = self.template_args['unicode']['default'] \ + if unicode is None else bool(unicode) + + # Define whether or not we should operate in a batch mode + self.batch = self.template_args['batch']['default'] \ + if batch is None else bool(batch) + + # Parse our targets + self.targets = list() + self.groups = list() + + for target in parse_phone_no(targets): + # Parse each phone number we found + result = is_phone_no(target) + if result: + self.targets.append('+{}'.format(result['full'])) + continue + + group_re = IS_GROUP_RE.match(target) + if group_re and not target.isdigit(): + # If the target specified is all digits, it MUST have a @ + # in front of it to eliminate any ambiguity + self.groups.append(group_re.group('group')) + continue + + self.logger.warning( + 'Dropped invalid phone # and/or Group ' + '({}) specified.'.format(target), + ) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform BulkSMS Notification + """ + + if not (self.password and self.user): + self.logger.warning( + 'There were no valid login credentials provided') + return False + + if not (self.targets or self.groups): + # We have nothing to notify + self.logger.warning('There are no Twist targets to notify') + return False + + # Send in batches if identified to do so + batch_size = 1 if not self.batch else self.default_batch_size + + # error tracking (used for function return) + has_error = False + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + } + + # Prepare our payload + payload = { + # The To gets populated in the loop below + 'to': None, + 'body': body, + 'routingGroup': self.route, + 'encoding': BulkSMSEncoding.UNICODE \ + if self.unicode else BulkSMSEncoding.TEXT, + # Options are NONE, ALL and ERRORS + 'deliveryReports': "ERRORS" + } + + if self.source: + payload.update({ + 'from': self.source, + }) + + # Authentication + auth = (self.user, self.password) + + # Prepare our targets + targets = list(self.targets) if batch_size == 1 else \ + [self.targets[index:index + batch_size] + for index in range(0, len(self.targets), batch_size)] + targets += [{"type": "GROUP", "name": g} for g in self.groups] + + while len(targets): + # Get our target to notify + target = targets.pop(0) + + # Prepare our user + payload['to'] = target + + # Printable reference + if isinstance(target, dict): + p_target = target['name'] + + elif isinstance(target, list): + p_target = '{} targets'.format(len(target)) + + else: + p_target = target + + # Some Debug Logging + self.logger.debug('BulkSMS POST URL: {} (cert_verify={})'.format( + self.notify_url, self.verify_certificate)) + self.logger.debug('BulkSMS Payload: {}' .format(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + self.notify_url, + data=json.dumps(payload), + headers=headers, + auth=auth, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + # The responsne might look like: + # [ + # { + # "id": "string", + # "type": "SENT", + # "from": "string", + # "to": "string", + # "body": null, + # "encoding": "TEXT", + # "protocolId": 0, + # "messageClass": 0, + # "numberOfParts": 0, + # "creditCost": 0, + # "submission": {...}, + # "status": {...}, + # "relatedSentMessageId": "string", + # "userSuppliedId": "string" + # } + # ] + + if r.status_code not in ( + requests.codes.created, requests.codes.ok): + # We had a problem + status_str = \ + NotifyBase.http_response_code_lookup(r.status_code) + + # set up our status code to use + status_code = r.status_code + + self.logger.warning( + 'Failed to send BulkSMS notification to {}: ' + '{}{}error={}.'.format( + p_target, + status_str, + ', ' if status_str else '', + status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + continue + + else: + self.logger.info( + 'Sent BulkSMS notification to {}.'.format(p_target)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending BulkSMS: to %s ', + p_target) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + return not has_error + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'unicode': 'yes' if self.unicode else 'no', + 'batch': 'yes' if self.batch else 'no', + 'route': self.route, + } + + if self.source: + params['from'] = self.source + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{user}:{password}@{targets}/?{params}'.format( + schema=self.secure_protocol, + user=self.pprint(self.user, privacy, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + targets='/'.join(chain( + [NotifyBulkSMS.quote('{}'.format(x), safe='+') + for x in self.targets], + [NotifyBulkSMS.quote('@{}'.format(x), safe='@') + for x in self.groups])), + params=NotifyBulkSMS.urlencode(params)) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + results = NotifyBase.parse_url(url, verify_host=False) + if not results: + # We're done early as we couldn't load the results + return results + + # Get our entries; split_path() looks after unquoting content for us + # by default + results['targets'] = [ + NotifyBulkSMS.unquote(results['host']), + *NotifyBulkSMS.split_path(results['fullpath'])] + + # Support the 'from' and 'source' variable so that we can support + # targets this way too. + # The 'from' makes it easier to use yaml configuration + if 'from' in results['qsd'] and len(results['qsd']['from']): + results['source'] = \ + NotifyBulkSMS.unquote(results['qsd']['from']) + + # Support the 'to' variable so that we can support targets this way too + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyBulkSMS.parse_phone_no(results['qsd']['to']) + + # Unicode Characters + results['unicode'] = \ + parse_bool(results['qsd'].get( + 'unicode', NotifyBulkSMS.template_args['unicode']['default'])) + + # Get Batch Mode Flag + results['batch'] = \ + parse_bool(results['qsd'].get( + 'batch', NotifyBulkSMS.template_args['batch']['default'])) + + # Allow one to define a route group + if 'route' in results['qsd'] and len(results['qsd']['route']): + results['route'] = \ + NotifyBulkSMS.unquote(results['qsd']['route']) + + return results diff --git a/libs/apprise/plugins/NotifyD7Networks.py b/libs/apprise/plugins/NotifyD7Networks.py index 728f119ab..b7575d92e 100644 --- a/libs/apprise/plugins/NotifyD7Networks.py +++ b/libs/apprise/plugins/NotifyD7Networks.py @@ -30,7 +30,6 @@ # (both user and password) from the API Details section from within your # account profile area: https://d7networks.com/accounts/profile/ -import six import requests import base64 from json import dumps @@ -54,7 +53,7 @@ D7NETWORKS_HTTP_ERROR_MAP = { # Priorities -class D7SMSPriority(object): +class D7SMSPriority: """ D7 Networks SMS Message Priority """ @@ -192,7 +191,7 @@ class NotifyD7Networks(NotifyBase): # Setup our source address (if defined) self.source = None \ - if not isinstance(source, six.string_types) else source.strip() + if not isinstance(source, str) else source.strip() if not (self.user and self.password): msg = 'A D7 Networks user/pass was not provided.' @@ -232,10 +231,10 @@ class NotifyD7Networks(NotifyBase): auth = '{user}:{password}'.format( user=self.user, password=self.password) - if six.PY3: - # Python 3's versio of b64encode() expects a byte array and not - # a string. To accomodate this, we encode the content here - auth = auth.encode('utf-8') + + # Python 3's versio of b64encode() expects a byte array and not + # a string. To accommodate this, we encode the content here + auth = auth.encode('utf-8') # Prepare our headers headers = { diff --git a/libs/apprise/plugins/NotifyDBus.py b/libs/apprise/plugins/NotifyDBus.py index 145e1c05c..b568dfe73 100644 --- a/libs/apprise/plugins/NotifyDBus.py +++ b/libs/apprise/plugins/NotifyDBus.py @@ -60,7 +60,7 @@ try: from dbus.mainloop.glib import DBusGMainLoop LOOP_GLIB = DBusGMainLoop() - except ImportError: + except ImportError: # pragma: no cover # No problem pass @@ -109,18 +109,36 @@ MAINLOOP_MAP = { # Urgencies -class DBusUrgency(object): +class DBusUrgency: LOW = 0 NORMAL = 1 HIGH = 2 -# Define our urgency levels -DBUS_URGENCIES = ( - DBusUrgency.LOW, - DBusUrgency.NORMAL, - DBusUrgency.HIGH, -) +DBUS_URGENCIES = { + # Note: This also acts as a reverse lookup mapping + DBusUrgency.LOW: 'low', + DBusUrgency.NORMAL: 'normal', + DBusUrgency.HIGH: 'high', +} + +DBUS_URGENCY_MAP = { + # Maps against string 'low' + 'l': DBusUrgency.LOW, + # Maps against string 'moderate' + 'm': DBusUrgency.LOW, + # Maps against string 'normal' + 'n': DBusUrgency.NORMAL, + # Maps against string 'high' + 'h': DBusUrgency.HIGH, + # Maps against string 'emergency' + 'e': DBusUrgency.HIGH, + + # Entries to additionally support (so more like DBus's API) + '0': DBusUrgency.LOW, + '1': DBusUrgency.NORMAL, + '2': DBusUrgency.HIGH, +} class NotifyDBus(NotifyBase): @@ -143,10 +161,11 @@ class NotifyDBus(NotifyBase): service_url = 'http://www.freedesktop.org/Software/dbus/' # The default protocols - # Python 3 keys() does not return a list object, it's it's own dict_keys() + # Python 3 keys() does not return a list object, it is its own dict_keys() # object if we were to reference, we wouldn't be backwards compatible with # Python v2. So converting the result set back into a list makes us # compatible + # TODO: Review after dropping support for Python 2. protocol = list(MAINLOOP_MAP.keys()) # A URL that takes you to the setup/help of the specific protocol @@ -182,6 +201,12 @@ class NotifyDBus(NotifyBase): 'values': DBUS_URGENCIES, 'default': DBusUrgency.NORMAL, }, + 'priority': { + # Apprise uses 'priority' everywhere; it's just a nice consistent + # feel to be able to use it here as well. Just map the + # value back to 'priority' + 'alias_of': 'urgency', + }, 'x': { 'name': _('X-Axis'), 'type': 'int', @@ -223,15 +248,29 @@ class NotifyDBus(NotifyBase): raise TypeError(msg) # The urgency of the message - if urgency not in DBUS_URGENCIES: - self.urgency = DBusUrgency.NORMAL - - else: - self.urgency = urgency + self.urgency = int( + NotifyDBus.template_args['urgency']['default'] + if urgency is None else + next(( + v for k, v in DBUS_URGENCY_MAP.items() + if str(urgency).lower().startswith(k)), + NotifyDBus.template_args['urgency']['default'])) # Our x/y axis settings - self.x_axis = x_axis if isinstance(x_axis, int) else None - self.y_axis = y_axis if isinstance(y_axis, int) else None + if x_axis or y_axis: + try: + self.x_axis = int(x_axis) + self.y_axis = int(y_axis) + + except (TypeError, ValueError): + # Invalid x/y values specified + msg = 'The x,y coordinates specified ({},{}) are invalid.'\ + .format(x_axis, y_axis) + self.logger.warning(msg) + raise TypeError(msg) + else: + self.x_axis = None + self.y_axis = None # Track whether or not we want to send an image with our notification # or not. @@ -343,17 +382,13 @@ class NotifyDBus(NotifyBase): Returns the URL built dynamically based on specified arguments. """ - _map = { - DBusUrgency.LOW: 'low', - DBusUrgency.NORMAL: 'normal', - DBusUrgency.HIGH: 'high', - } - # Define any URL parameters params = { 'image': 'yes' if self.include_image else 'no', - 'urgency': 'normal' if self.urgency not in _map - else _map[self.urgency], + 'urgency': + DBUS_URGENCIES[self.template_args['urgency']['default']] + if self.urgency not in DBUS_URGENCIES + else DBUS_URGENCIES[self.urgency], } # Extend our parameters @@ -389,38 +424,20 @@ class NotifyDBus(NotifyBase): # DBus supports urgency, but we we also support the keyword priority # so that it is consistent with some of the other plugins - urgency = results['qsd'].get('urgency', results['qsd'].get('priority')) - if urgency and len(urgency): - _map = { - '0': DBusUrgency.LOW, - 'l': DBusUrgency.LOW, - 'n': DBusUrgency.NORMAL, - '1': DBusUrgency.NORMAL, - 'h': DBusUrgency.HIGH, - '2': DBusUrgency.HIGH, - } + if 'priority' in results['qsd'] and len(results['qsd']['priority']): + # We intentionally store the priority in the urgency section + results['urgency'] = \ + NotifyDBus.unquote(results['qsd']['priority']) - try: - # Attempt to index/retrieve our urgency - results['urgency'] = _map[urgency[0].lower()] - - except KeyError: - # No priority was set - pass + if 'urgency' in results['qsd'] and len(results['qsd']['urgency']): + results['urgency'] = \ + NotifyDBus.unquote(results['qsd']['urgency']) # handle x,y coordinates - try: - results['x_axis'] = int(results['qsd'].get('x')) + if 'x' in results['qsd'] and len(results['qsd']['x']): + results['x_axis'] = NotifyDBus.unquote(results['qsd'].get('x')) - except (TypeError, ValueError): - # No x was set - pass - - try: - results['y_axis'] = int(results['qsd'].get('y')) - - except (TypeError, ValueError): - # No y was set - pass + if 'y' in results['qsd'] and len(results['qsd']['y']): + results['y_axis'] = NotifyDBus.unquote(results['qsd'].get('y')) return results diff --git a/libs/apprise/plugins/NotifyDapnet.py b/libs/apprise/plugins/NotifyDapnet.py index 2e0389dbc..27ea65cd3 100644 --- a/libs/apprise/plugins/NotifyDapnet.py +++ b/libs/apprise/plugins/NotifyDapnet.py @@ -58,15 +58,27 @@ from ..utils import parse_list from ..utils import parse_bool -class DapnetPriority(object): +class DapnetPriority: NORMAL = 0 EMERGENCY = 1 -DAPNET_PRIORITIES = ( - DapnetPriority.NORMAL, - DapnetPriority.EMERGENCY, -) +DAPNET_PRIORITIES = { + DapnetPriority.NORMAL: 'normal', + DapnetPriority.EMERGENCY: 'emergency', +} + + +DAPNET_PRIORITY_MAP = { + # Maps against string 'normal' + 'n': DapnetPriority.NORMAL, + # Maps against string 'emergency' + 'e': DapnetPriority.EMERGENCY, + + # Entries to additionally support (so more like Dapnet's API) + '0': DapnetPriority.NORMAL, + '1': DapnetPriority.EMERGENCY, +} class NotifyDapnet(NotifyBase): @@ -172,11 +184,14 @@ class NotifyDapnet(NotifyBase): # Parse our targets self.targets = list() - # get the emergency prio setting - if priority not in DAPNET_PRIORITIES: - self.priority = self.template_args['priority']['default'] - else: - self.priority = priority + # The Priority of the message + self.priority = int( + NotifyDapnet.template_args['priority']['default'] + if priority is None else + next(( + v for k, v in DAPNET_PRIORITY_MAP.items() + if str(priority).lower().startswith(k)), + NotifyDapnet.template_args['priority']['default'])) if not (self.user and self.password): msg = 'A Dapnet user/pass was not provided.' @@ -201,8 +216,7 @@ class NotifyDapnet(NotifyBase): ) continue - # Store callsign without SSID and - # ignore duplicates + # Store callsign without SSID and ignore duplicates if result['callsign'] not in self.targets: self.targets.append(result['callsign']) @@ -230,10 +244,6 @@ class NotifyDapnet(NotifyBase): # error tracking (used for function return) has_error = False - # prepare the emergency mode - emergency_mode = True \ - if self.priority == DapnetPriority.EMERGENCY else False - # Create a copy of the targets list targets = list(self.targets) @@ -244,7 +254,7 @@ class NotifyDapnet(NotifyBase): 'text': body, 'callSignNames': targets[index:index + batch_size], 'transmitterGroupNames': self.txgroups, - 'emergency': emergency_mode, + 'emergency': (self.priority == DapnetPriority.EMERGENCY), } self.logger.debug('DAPNET POST URL: %s' % self.notify_url) @@ -304,16 +314,12 @@ class NotifyDapnet(NotifyBase): Returns the URL built dynamically based on specified arguments. """ - # Define any URL parameters - _map = { - DapnetPriority.NORMAL: 'normal', - DapnetPriority.EMERGENCY: 'emergency', - } - # Define any URL parameters params = { - 'priority': 'normal' if self.priority not in _map - else _map[self.priority], + 'priority': + DAPNET_PRIORITIES[self.template_args['priority']['default']] + if self.priority not in DAPNET_PRIORITIES + else DAPNET_PRIORITIES[self.priority], 'batch': 'yes' if self.batch else 'no', 'txgroups': ','.join(self.txgroups), } @@ -361,25 +367,10 @@ class NotifyDapnet(NotifyBase): results['targets'] += \ NotifyDapnet.parse_list(results['qsd']['to']) - # Check for priority + # Set our priority if 'priority' in results['qsd'] and len(results['qsd']['priority']): - _map = { - # Letter Assignments - 'n': DapnetPriority.NORMAL, - 'e': DapnetPriority.EMERGENCY, - 'no': DapnetPriority.NORMAL, - 'em': DapnetPriority.EMERGENCY, - # Numeric assignments - '0': DapnetPriority.NORMAL, - '1': DapnetPriority.EMERGENCY, - } - try: - results['priority'] = \ - _map[results['qsd']['priority'][0:2].lower()] - - except KeyError: - # No priority was set - pass + results['priority'] = \ + NotifyDapnet.unquote(results['qsd']['priority']) # Check for one or multiple transmitter groups (comma separated) # and split them up, when necessary diff --git a/libs/apprise/plugins/NotifyDiscord.py b/libs/apprise/plugins/NotifyDiscord.py index a4e7df6d4..48e2c2836 100644 --- a/libs/apprise/plugins/NotifyDiscord.py +++ b/libs/apprise/plugins/NotifyDiscord.py @@ -128,6 +128,12 @@ class NotifyDiscord(NotifyBase): 'name': _('Avatar URL'), 'type': 'string', }, + # Send a message to the specified thread within a webhook's channel. + # The thread will automatically be unarchived. + 'thread': { + 'name': _('Thread ID'), + 'type': 'string', + }, 'footer': { 'name': _('Display Footer'), 'type': 'bool', @@ -153,7 +159,7 @@ class NotifyDiscord(NotifyBase): def __init__(self, webhook_id, webhook_token, tts=False, avatar=True, footer=False, footer_logo=True, include_image=False, - fields=True, avatar_url=None, **kwargs): + fields=True, avatar_url=None, thread=None, **kwargs): """ Initialize Discord Object @@ -194,6 +200,9 @@ class NotifyDiscord(NotifyBase): # Use Fields self.fields = fields + # Specified Thread ID + self.thread_id = thread + # Avatar URL # This allows a user to provide an over-ride to the otherwise # dynamically generated avatar url images @@ -274,6 +283,9 @@ class NotifyDiscord(NotifyBase): payload['content'] = \ body if not title else "{}\r\n{}".format(title, body) + if self.thread_id: + payload['thread_id'] = self.thread_id + if self.avatar and (image_url or self.avatar_url): payload['avatar_url'] = \ self.avatar_url if self.avatar_url else image_url @@ -447,6 +459,9 @@ class NotifyDiscord(NotifyBase): if self.avatar_url: params['avatar_url'] = self.avatar_url + if self.thread_id: + params['thread'] = self.thread_id + # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) @@ -515,6 +530,11 @@ class NotifyDiscord(NotifyBase): results['avatar_url'] = \ NotifyDiscord.unquote(results['qsd']['avatar_url']) + # Extract thread id if it was specified + if 'thread' in results['qsd']: + results['thread'] = \ + NotifyDiscord.unquote(results['qsd']['thread']) + return results @staticmethod diff --git a/libs/apprise/plugins/NotifyEmail.py b/libs/apprise/plugins/NotifyEmail.py index 14937b9a3..5858f0906 100644 --- a/libs/apprise/plugins/NotifyEmail.py +++ b/libs/apprise/plugins/NotifyEmail.py @@ -24,12 +24,11 @@ # THE SOFTWARE. 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 email.utils import formataddr +from email.utils import formataddr, make_msgid from email.header import Header from email import charset @@ -38,17 +37,16 @@ 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_emails +from ..common import NotifyFormat, NotifyType +from ..conversion import convert_between +from ..utils import is_email, parse_emails from ..AppriseLocale import gettext_lazy as _ # Globally Default encoding mode set to Quoted Printable. charset.add_charset('utf-8', charset.QP, charset.QP, 'utf-8') -class WebBaseLogin(object): +class WebBaseLogin: """ This class is just used in conjunction of the default emailers to best formulate a login to it using the data detected @@ -61,7 +59,7 @@ class WebBaseLogin(object): # Secure Email Modes -class SecureMailMode(object): +class SecureMailMode: SSL = "ssl" STARTTLS = "starttls" @@ -91,21 +89,6 @@ EMAIL_TEMPLATES = ( }, ), - # Pronto Mail - ( - 'Pronto Mail', - re.compile( - r'^((?P